Skip to content

ci: correct three wrong action-pin version comments and enforce the form (#3313) - #3407

Merged
Xore merged 6 commits into
mainfrom
oc/3313-checkout-credential-hardening
Sep 27, 2026
Merged

Xore merged 6 commits into
mainfrom
oc/3313-checkout-credential-hardening

Conversation

@Xore

@Xore Xore commented Sep 27, 2026

Copy link
Copy Markdown
Owner

Summary

Two commits' worth of audit, one small change. The headline finding is that most of #3313 was already merged as #3388 (e747d91f, an ancestor of main), and the issue is still open because it was never closed. The issue table in the task brief is pre-#3388 and no longer describes main.

I re-derived every claim in the issue from scratch against current main rather than trusting the table, then fixed what was genuinely still wrong.

Already complete on main (verified, nothing to do)

  • 39 of 39 actions/checkout steps carry persist-credentials: false. Zero gaps.
  • Zero tag-pinned uses: — every third-party reference is a full 40-char SHA.
  • Every workflow-level permissions: is {} or read-only. Both items the issue called out by name (dependabot-auto-merge.yml, the five *-watch.yml) are already {} at workflow level with the write scope moved to the one job that spends it.

The grep -rc count the brief asks for is misleading and worth flagging: it reports diagnostics.yml:2 checkouts and quality.yml:23, but those extra hits are the string actions/checkout inside comments — diagnostics.yml states twice that it deliberately has no checkout. Counting actions/checkout@ (a real uses:) gives 39.

I also independently re-verified every grant rather than assuming the merged commit's summary: frontend-testing-pilot.yml's issues: write is spent by github.rest.issues.create; main-health-watch.yml's actions: write is spent by the workflow_dispatch at scripts/main-health-watch.py:174; dependabot-auto-merge.yml uses gh pr review/merge/comment. All justified — nothing to narrow. No pull_request_target and no head.sha checkout anywhere.

Actually still broken — fixed here

#3388's own message claims it fixed "quality.yml's two setup-node pins [that] said # v7". Two # v7 comments survived it, and a third pin was labelled with a major it is not in at all:

location was is ground truth (git ls-remote)
quality.yml:453 # v7 # v7.0.0 820762786 is the commit behind both v7 and v7.0.0
quality.yml:648 # v7 # v7.0.0 same
weekly-schemathesis.yml:276 # v6 # v4.6.2 ea165f8d is the commit behind both v4 and v4.6.2; v6 is b7c566a, a different commit

ea165f8d is the SHA all seven other upload-artifact pins in the tree use, so I corrected the comment to match the code rather than bumping the major to match the comment — that would be a build-logic change, not a pin fix.

Enforcement, because that is how both survived

zizmor's unpinned-uses audit reads the SHA and ignores the comment entirely, so a pin can be perfectly well-formed and still misreport what it runs. persist-credentials: false is enforced (zizmor folds it into artipacked, medium, blocking — confirmed by probing a deliberately non-compliant checkout). The version comment was enforced by nothing.

The new check lives in the existing #3314 lint gate: offline, deterministic, asserts a full # vX.Y.Z on every SHA pin. Proven both directions against the pre-fix tree — it fails on all three lines above and on a pin with no comment at all, and passes on the fixed tree.

Issues

Refs #3313 — deliberately Refs, not Closes. The bulk of the work landed via #3388; this finishes the remaining comments and the missing guard. #3313 should be closed by a human who is satisfied #3388 covered the rest, not silently by this PR. Not merged — left open as instructed.

Security impact

  • No real credentials, private addresses, payloads, PCAPs, keys, or .env files were added.
  • Sandbox/network-isolation implications were reviewed. (This is the shared self-hosted honeypot-ci runner, so the credential surface is the point: no checkout leaves a token in .git/config, and the comment now correctly says which action version a compromised job would load.)
  • Publicly exposed ports and routes are unchanged.

No check was weakened, no test edited, and no new finding was allowlisted. The ADVISORY set is untouched.

Validation

$ zizmor --offline --min-severity medium .github/workflows
error[dangerous-triggers]  --> main-health-watch.yml:7
78 findings (10 ignored, 67 suppressed): 0 informational, 0 low, 0 medium, 1 high

Identical to the pre-change baseline — same counts, same single advisory. Under the repo's own ADVISORY classification: advisory 1 dangerous-triggers, blocking=0 → gate PASSES.

$ grep -rc persist-credentials .github/workflows/*.yml   # 39 checkouts, 39 flagged, 0 gaps
$ SHELLCHECK_OPTS="-S warning" actionlint -color          # exit 0, clean
$ new pin-comment gate                                    # GATE PASSES

I verified all 83 distinct pins resolve to the tag their comment claims, via git ls-remote (annotated tags peeled at ^{}), not the release page. That is how the # v6 defect was caught. One earlier apparent failure was a bug in my own checker — I had passed an action subpath as the repository — so github/codeql-action@1c5b675 is correct, not a finding.

Not validated: the gate asserts the form # vX.Y.Z, not that each comment is tag-accurate. A full-semver-but-wrong comment (the # v6 case) would still pass. I verified truthfulness by hand for all 83 pins and the result is clean, but I did not add a network-dependent ls-remote check to CI: it would make a lint gate depend on 15 upstream repos staying put, and a force-moved or deleted tag would fail a PR for reasons unrelated to it. Worth adding later as a scheduled audit rather than a per-PR blocking check. I also did not re-run the full Quality suite locally — the self-hosted lanes need the runner. rustfmt for the 1.98.0 toolchain is unavailable here (pre-existing).

Rollout

None. No deploy step. #3313 can be closed once a reviewer confirms #3388 covered the rest.

@github-actions

Copy link
Copy Markdown

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

@Xore
Xore force-pushed the oc/3313-checkout-credential-hardening branch 2 times, most recently from ddaaecb to d33c9c4 Compare September 27, 2026 16:36
…orm (#3313)

#3388 landed the #3313 work and the tree already satisfies it: 39 of 39
checkouts carry persist-credentials: false, no `uses:` is tag-pinned, and
every workflow-level `permissions:` is {} or read-only. The issue table it
was written against is pre-#3388 and no longer describes main.

What #3388 did not finish is the version comment on a pin. It claims
"quality.yml's two setup-node pins said `# v7` where the tag is v7.0.0" --
two `# v7` comments survived that commit, and a third pin was labelled with
a major it is not in:

  quality.yml:453,648          setup-node@820762786  # v7      -> # v7.0.0
  weekly-schemathesis.yml:276  upload-artifact@ea165  # v6      -> # v4.6.2

All three verified with `git ls-remote`, not the release page: 820762786 is
the commit behind both `v7` and `v7.0.0`, and ea165f8d is the commit behind
both `v4` and `v4.6.2` -- `v6` is b7c566a, a different commit. The
upload-artifact SHA is what all seven other upload-artifact pins in the
tree use, so the comment is corrected to match the code; bumping the major
to match the comment would be a build-logic change, not a pin fix.

Enforced, because zizmor's unpinned-uses audit reads the SHA and ignores the
comment -- that gap is exactly how a well-formed pin shipped claiming a
version it does not run. The new check is in the existing #3314 lint gate,
offline and deterministic, and asserts a full `# vX.Y.Z` on every SHA pin.
It was proven against the pre-fix tree: it fails on all three lines above
and on a pin with no comment at all, and passes on the fixed tree.

zizmor --min-severity medium: 1 advisory (dangerous-triggers), 0 blocking,
unchanged from the pre-change baseline. actionlint 1.7.7: clean.
@Xore
Xore force-pushed the oc/3313-checkout-credential-hardening branch from d33c9c4 to db3e552 Compare September 27, 2026 18:22
…pened

build-push-action emits steps.build.outputs.digest even with push: false,
where it describes the image built locally and names nothing in the
registry. The boot-smoke step tested IMAGE_DIGEST for non-empty to mean "we
pushed this", so every pull_request run took the docker-pull path against a
tag that was never pushed: three "manifest unknown" retries, then a hard
exit. That is what turned every PR run of this workflow red, including the
docs PRs, which changed no container at all.

The comment above the input already stated the intended contract ("Empty
exactly when nothing was pushed"); only the expression disagreed. Gate it on
the event so a pull_request reaches the local-label branch that exists for
exactly this case, and a push with no digest now fails with that branch's own
message instead of a phantom registry lookup.

The tag that was being pulled, sha-51ced49, is metadata-action's type=sha
applied to the synthetic refs/pull/3398/merge commit -- which is why it names
no SHA in the repository and why ghcr has no such tag.
…ter never matches

The image this step is looking for is the one it just built. On a
pull_request the build uses the docker exporter, which drops the tag, so
the image arrives dangling. `docker image ls --filter label=...` applies
the label filter to tagged images only, so the query returned nothing and
the step failed on a build that had loaded correctly -- the error even said
"no local image carries label ...", which is false.

Reproduced against a real buildx --load: the label is on the image
(`docker image inspect` shows it) and `--filter dangling=true` finds it,
but `--filter label=...` finds nothing until --all is passed. With --all
the same query returns the image id.

The comment three lines above the query already said "The docker exporter
drops the tag" -- the code just did not account for what that implies for
this particular filter.
…ys literal

The boot-smoke step finds its image with
`docker image ls --all --filter "label=apiary.ci.build-row=$BUILD_ROW"`,
but apiary.ci.build-row was never applied as its own key. The labels
input appended it with format('\napiary.ci.build-row=...'), and GitHub's
format() does not interpret \n -- it emits a backslash and an 'n'. The
text landed at the end of the previous label's value, which the run log
shows as:

  "label:org.opencontainers.image.version": "sha-047a7a3\\napiary.ci.build-row=dashboard-next-36338870116-1"

so the filter had no key to match and every pull_request run failed the
"Resolve the image ID to boot-smoke" step. Only the push path looks for
the label, which is why it went unnoticed.

Build the list in a shell step instead, where printf can emit a real line
break, and feed that to build-push-action. The list goes out through the
`labels<<EOF` heredoc form rather than a bare printf of k=v lines: a bare
key=value line becomes its own step output, so the list would arrive as
outputs named org.opencontainers.* and the `labels` key the input reads
would not exist at all.

Gated on matrix.boot_smoke, so the other sixteen rows skip the step, get
an empty output, and fall back to the metadata-action list byte for byte.
They must not gain a CI run id in their manifest.

Proven locally: an untagged buildx --load image carrying the label is
found by the lookup above, and `image inspect` shows apiary.ci.build-row
as its own key with org.opencontainers.image.version left intact. The
pre-fix label shape was built too as a control, and that one is not found
-- so the check discriminates rather than passing trivially.
…d fleet

The self-hosted fleet is seven CI runners and saturates on two concurrent
runs, leaving the second queued with no signal. Waiting for each PR's CI
after each sibling merge is O(N^2) runs on top of that.

scripts/merge-train.sh merges every train member into a throwaway worktree
cut from the base tip, runs the local gate suite ONCE on the result, and
prints the evidence that authorizes the merges. It reads origin only: never
pushes, never merges, never touches another worktree, never stashes.

A PR that conflicts is ejected and the train continues. A named gate that is
missing at the base is a failure, not a skip, so a renamed gate cannot
quietly turn the train green.

Adapted from diegosouzapw/OmniRoute scripts/release/merge-train.sh. Their
--fast reduced-coverage mode is deliberately not ported: the doc gates are
the point of what this repo is merging, so parity means the full suite.
The boot-smoke parity test asserted `matrix.boot_smoke` appears in the
build step's `labels:` expression. The newline fix moved that gating into
the `if:` of the label-append step, so the assertion no longer described
where the gating lives and the test failed on a correct workflow.

Assert the invariant instead: the labels input still falls back to the
base labels, still prefers the appended build-row list, and the append
step itself is gated on a boot-smoke row. Verified the test fails when the
shell-step form is reverted to the inline expression, so it still bites.
@Xore
Xore merged commit 5fa85a6 into main Sep 27, 2026
117 of 118 checks passed
@Xore
Xore deleted the oc/3313-checkout-credential-hardening branch September 27, 2026 19:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant