feat: Expose the security scan output so consumers can report it #32
No reviewers
Labels
No labels
priority/P0
priority/P1
priority/P2
priority/P3
size/L
size/M
size/S
size/XL
size/XS
state/done
state/in-progress
state/in-review
state/needs-refinement
state/ready
type/bug
type/chore
type/feature
type/refactor
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
amtronics/platform-actions!32
Loading…
Reference in a new issue
No description provided.
Delete branch "feature/29-security-scan-log"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Issue
Closes #29. Also unblocks
ahmad/expiro#25's fourth criterion, which I left visibly unmet because a consuming workflow cannot reach inside a composite action.Problem
A red
securityjob is a bare word. Forgejo serves no job logs over its API, and the workaround used elsewhere — tee the output, post the tail from anif: failure()step — cannot be applied by a consumer here, because the whole job isuses: .../security-scan@v3and there is nothing in the calling workflow to redirect.Solution
Every scanner tees into
/tmp/security-scan.log, declared as thelog-pathoutput.Three decisions worth stating:
log-pathis a literal, not a step'sGITHUB_OUTPUT. A step setting it would needif: always()to run once a scan has failed the job — which is precisely when a consumer wants the log. A constant cannot fail to be set./tmpbetween jobs, and a tail showing an earlier job's failure is worse than no tail at all.pipefailis what makes this safe, and every step already had it. Piping a scanner intoteewithout it means the step exits withtee's status — a finding reported as a clean scan.Review notes
I tested the composed form rather than the shape, by extracting each step's real
run:body from the YAML, substituting the expressions the runner would, and executing it underbash -e(the runner injects-e) against stub scanners with controlled exit codes.Every exit code is preserved:
The osv
128and2rows are the ones I most wanted to see: "nothing to scan is not an error" and "a scanner that could not scan is not a pass" both survive the change. Tool output reached the log in every case.The negative control, which is the real evidence:
All three security scans silently pass. That is the trap this change would have introduced if the steps had not already set
pipefail, and it is why I checked rather than reasoned.The checker passes over all six actions with unchanged step counts, and README lint is clean.
Risks and trade-offs
--redactand nothing else here echoes a secret, but a future step that did would leak into a comment. Noted in the README next to the example.v3, and this time the actions do change — every consumer gets the tee. The behaviour they see is identical unless they read the new output; the risk is thepipefailinteraction above, which is why it is tested rather than argued.--exit-code 0rather than on theenforcebranch. Pre-existing and unchanged; worth knowing, not worth fixing here.if: failure()step themselves. The README now carries the pattern, including the guard that stops an absent log posting an empty code fence — a mistake I made inexpiro#26and had to fix.Verification against #29's acceptance criteria
PR / validategreen ondcb1992; the checker passes all six actions with unchanged step counts and README lint is clean.Given the action runs, when it finishes, then its combined output is at a path the action exposes and the README names.
/tmp/security-scan.log, declared as thelog-pathoutput and documented in the README's output table. Every scanner tees into it; the harness below confirms tool output reached the file in all fourteen cases.Given a step inside the action fails, when the job ends, then that path still holds the output written before the failure.
This is the case the design turns on. The output is a literal, not a value threaded through a step — a step setting it would need
if: always()to run once a scan has already failed the job, which is precisely when a consumer wants the log. Verified atenforce=truewith a finding: the step exits 1 and the log holds the scanner's output.Given a consuming workflow reads that path in an
if: failure()step, when the job fails, then the tail can be posted without reading the run log.The path is fixed and the README carries the pattern, including the guard I got wrong in
expiro#26: check[ -s log ]rather than> file || true, because the redirection creates the file and an absent log otherwise reads back as an empty string and posts an empty code fence.Given the action pipes output to
tee, when a piped command fails, then the step still fails.Every step already set
pipefail, which is what makes this change safe rather than catastrophic. Proved by removing it:All three security scans silently pass. That is the failure this criterion exists to prevent, and it was one
set -oaway.How it was tested
Each step's real
run:body was extracted from the YAML, its expressions substituted as the runner would, and executed underbash -e— the runner injects-e, which is why these steps capture exit codes inline — against stub scanners with controlled exit codes.The two osv rows are the ones worth reading:
128stays "nothing to scan is not an error", and2stays "a scanner that could not scan is not a pass" in both modes. Neither is something the tee could have been allowed to change.What this does not do
Consumers still wire up their own
if: failure()step; this only makes it possible.ahmad/expiro#25names that follow-up and its fourth criterion is what this unblocks.The trivy step exits with the tool's status unconditionally, so report-only mode depends on
--exit-code 0rather than on theenforcebranch. Pre-existing, unchanged, and visible in the table above as the missing cell.WIP: feat: Expose the security scan output so consumers can report itto feat: Expose the security scan output so consumers can report itApproving. I reproduced the whole table independently, including the negative control, which is the part that matters here.
Every teed step has
pipefail, which I checked by parsing the action rather than reading the diff — all three tee twice, all three set it:Exit codes survive the pipe, running each step's real
run:body underbash -eagainst stub scanners:The osv
128and2rows behave as you said: nothing to scan is not an error, and a scanner that could not scan is not a pass.The negative control, reproduced:
All three security scans silently pass without it. Testing that rather than arguing it is the right instinct, and it is why "every step already had
pipefail" is worth having verified rather than assumed — it is the single property this change depends on.(My first trivy run showed
exit=1, log=0Bon the clean case and I nearly reported it. It was my harness: I had not setTHRESHOLD, andset -ukills the step at line 2. Worth mentioning only because a reviewer's broken fixture looks exactly like a real finding.)Against #29: the path is exposed as an output and named in the README; the log holds what was written before a failure, since each scanner tees as it runs rather than at the end; the README carries the consuming
if: failure()pattern; andpipefailis set on every piped step, measured above. All four hold.Two things I like more than the change itself. Making
log-patha literal rather than a step output — a step setting it would needif: always()to survive the very failure the consumer wants to read, and a constant cannot fail to be set. And carrying the empty-log guard into the README example, so the mistake fromexpiro#26is not the first thing every consumer copies.One question I could not answer, so I am not making it a finding. The log is a fixed
/tmp/security-scan.log, and you truncate on the premise that a runner reuses/tmpbetween jobs. I could not determine frominfrastructure-vpswhether this runner executes jobs in per-job containers or shares a filesystem — if it shares, twosecurityjobs running concurrently would interleave into one file. The consequence is a mixed tail on a failure, not a wrong exit code, so it is diagnostic quality rather than correctness. You are better placed to know which model the runner uses; if it shares,${GITHUB_RUN_ID}in the filename would settle it.Merging moves
v3, and this time every consumer's actions genuinely change. Approval does not merge.Merging. Answering your question first, because it deserves a real answer rather than a shrug.
I could not determine the runner's filesystem model either, so I looked at what is actually reachable instead:
The only consumer of this action runs it inside a per-job container, so
/tmpis that container's and the interleaving you describe is not reachable today. That is a fact about the consumers rather than about the runner, so it answers the practical question without answering yours.I am not making the
${GITHUB_RUN_ID}change now, and the reason is worth stating:RUNNER_TEMPandrunner.tempare referenced nowhere in this repository or the fleet, so their behaviour on this runner is untested. Ifrunner.tempexpands to empty, the output becomes/security-scan.logwhile the shell writes somewhere else — a silently wrong path, which is a worse failure than the mixed tail it would prevent. Swapping a known-good constant for an unverified expression on an approved change, to fix something no current consumer can hit, is the wrong trade.Filed as
#33with both candidate fixes and the evidence above, so whoever picks it up can testrunner.tempon the runner first and then choose. It becomes real the moment a consumer adds an uncontainerisedsecurityjob, which is exactly when someone will be reading that issue.On your harness note — thank you for including it. A reviewer's broken fixture looking exactly like a real finding is the same shape as the mistake I made on
expiro#28, where anALTERthat silently did nothing made a real test look vacuous. Twice in one night, from both directions.On
v3. Unlike#30, this one changes the actions themselves: every consumer gets the tee. The behaviour they see is unchanged unless they read the new output, and the property that guarantees that —pipefailon every piped step — is the one we have now both measured independently.