Skip to content

ci: audit the workflows with zizmor on every pull request - #273

Merged
emrecdr merged 5 commits into
mainfrom
ci/adopt-zizmor
Aug 13, 2026
Merged

ci: audit the workflows with zizmor on every pull request#273
emrecdr merged 5 commits into
mainfrom
ci/adopt-zizmor

Conversation

@emrecdr

@emrecdr emrecdr commented Aug 13, 2026

Copy link
Copy Markdown
Owner

Researched decision on the one backlog item I'd previously deferred as "your call". It turned out to have a researchable answer for most of it, and a genuine judgement call for one part — which the repository had already made, explicitly.

Why adopt

Not because it's popular — because it found something on first contact: three live template-injection sites (F304, fixed in #270) that nothing in this repo would have caught.

Configured to agree, not to silence

Exactly one audit is configured. unpinned-uses gets the policy this repository already enforces in workflow_action_pin_test: full SHAs for third-party actions, tags permitted for actions/* and for dtolnay/rust-toolchain — whose tag names the Rust toolchain, not a release of the action, and which rust_version_pins_test reads as a version.

Two gates disagreeing about pinning is worse than either alone: a contributor gets told to pin by one and told it's fine by the other.

findings high
unconfigured 111 44
aligned with existing policy 74 7

Nothing is suppressed — the drop is entirely unpinned-uses agreeing with the rule already in force.

On the actions/* divergence

OpenSSF Scorecard asks for first-party actions to be SHA-pinned too, and tj-actions CVE-2025-30066 and reviewdog are why. I kept the exemption: a compromise inside GitHub's own namespace is a compromise of the platform running the job, which a pinned SHA doesn't survive either — and the repo made this call explicitly, with that reasoning recorded in is_exempt. Overriding a documented, guarded policy on a general principle, when the stricter half is already enforced, isn't an improvement.

Advisory, with an exit condition

Same pattern dogfood already uses in this file. The seven remaining high findings are adjudicated in F306, not hidden:

  • 2 dangerous-triggers + 3 excessive-permissions — Dependabot auto-merge. Inherent to that pattern, mitigated the documented way: neither stage checks out or executes PR code.
  • 1 excessive-permissionsrelease.yml's workflow-level contents: write. Genuinely reducible (build already overrides to read), but it touches the release path, so not in the commit that adds the tool.
  • 1 cache-poisoning — zizmor rates its own confidence Low; poisoning that cache needs push access to the branch that wrote it.

The flag comes off once each is resolved or carries a recorded exception. Silencing them to make the gate green on day one would defeat the point of adding it.

Also

Corrects the cycle-12 report's "six-site" toolchain-pin claim to five, which is what rust_version_pins_test's own doc names.

All six workflow guards pass; ci.yml itself reports 0 high. clippy and fmt verified by exit status.

Adopting it because it found something on first contact, not because it is
popular: three live template-injection sites nothing here would have
caught (F304).

Exactly one audit is configured, and it is configured to AGREE with a
policy this repository already enforces in workflow_action_pin_test — full
SHAs for third-party actions, tags permitted for actions/* and for
dtolnay/rust-toolchain, whose tag names the Rust toolchain rather than a
release of the action. Two gates disagreeing about pinning is worse than
either alone: a contributor gets told to pin by one and told it is fine by
the other. Alignment drops the report from 111 findings / 44 high to 74 / 7
while suppressing none of them.

OpenSSF Scorecard does ask for actions/* to be SHA-pinned, and tj-actions
and reviewdog are why. The exemption stands: a compromise inside GitHub's
own namespace is a compromise of the platform running the job, which a
pinned SHA does not survive either — and the repository made this call
explicitly, with that reasoning recorded in the guard. Overriding a
documented, guarded policy on a general principle, when the stricter half
is already enforced, is not an improvement.

Advisory during bake-in, the pattern dogfood already uses in this file, and
with an exit condition so advisory does not become permanent. The seven
remaining high findings are adjudicated in F306 rather than hidden: two
triggers and three write scopes on the Dependabot auto-merge workflow that
are inherent to that pattern and mitigated by never checking out PR code,
one workflow-level write scope on release.yml that looks genuinely
reducible and touches the release path, and one low-confidence cache
finding. Silencing them to make the gate green on day one would defeat the
point of adding it.

Also corrects the cycle-12 report's "six-site" toolchain-pin claim to five,
which is what rust_version_pins_test's own doc names.
The advisory version of this job was written first and discarded on seeing
it run: with findings outstanding the check is red on every pull request,
and a permanently red check teaches people to ignore red checks. That is
worse than not running the tool. So the seven were resolved.

Fixed: release.yml's workflow-level `contents: write`. It was inherited by
plan, crates-publish and homebrew-publish, none of which write to this
repository — plan reads the ref, crates-publish authenticates to crates.io
with a token, and homebrew-publish checks out the tap with its own deploy
key and pushes there. The default is now `contents: read`; `release`, the
job that creates the GitHub Release, already declared its own write.

Written exceptions, on the lines that raise them: the Dependabot
auto-merge workflow's two triggers and three write scopes — the triggers
are what that pattern is, mitigated the documented way by never checking
out PR code, and the scopes are the capability itself — plus one
cache-poisoning finding whose own confidence is Low and which requires
push access to exploit.

The exceptions are inline `# zizmor: ignore[rule]` comments rather than
line numbers in a config file, so they move with the code instead of
drifting the moment a line is inserted above them.

Gated at `high`. The 16 remaining `low` findings are artipacked —
checkout persisting credentials — and deserve their own pass rather than a
blocking gate adopted in the same commit as the tool.
Cleanup pass over the adoption commit. No change to what is enforced.

The job installed zizmor with `pipx`, introducing a second tool-install
mechanism next to the `taiki-e/install-action` this workflow already uses
for cargo-nextest. That action supports zizmor, fetches a prebuilt binary
from the tool's own GitHub Releases rather than resolving and installing a
Python package, and is already SHA-pinned here — so reusing it removes a
mechanism and adds no pin to maintain.

The config's `"*": hash-pin` entry was commented as though it were
load-bearing. It is not: tested both ways, an unpinned third-party action is
flagged at `high` with or without it, because hash-pin is already zizmor's
default. Kept, because writing it out lets the policy be read here in full
and stops an upstream change of default from quietly relaxing it — but the
comment now says that rather than implying the rule depends on it.

`release.yml` had two adjacent comment blocks explaining one one-line
value, one above the key and one inside it. Merged: the two absences —
signing and write — hold for the same reason, that a job added later
inherits the default, so they read as one argument rather than two notes.
Adding a CI job without a matching recipe made `just ci` narrower than CI,
which this project treats as a defect in its own right: the guidance is
that the two must match exactly, because narrower local runs have shipped
work that CI then rejected.

The recipe runs the identical command, asserted rather than assumed —
byte-for-byte against the step in ci.yml.

Documented as requiring `cargo install zizmor@1.29.0`, following the
`coverage` recipe's precedent for a tool that is not a cargo subcommand.
The version is named because CI pins it and a different local version can
disagree, which is the reason to have the recipe rather than leave the
audit to CI alone.
Three findings from the review pass, two of which measurement contradicted.

The audit scanned only .github/workflows/, while workflow_action_pin_test
scans the root action.yml as well — so the repository's public entry point,
a 13 KB composite action with four shell steps and user-supplied inputs,
was the one file neither the external tool nor a human had audited. A
reviewer reasoned from reading it that broadening would find nothing.
Running it found one high: a GITHUB_PATH write. Adjudicated rather than
assumed — DEST is $RUNNER_TEMP/codelore, a runner directory with a constant
suffix and nothing a caller can redirect, and putting an installed tool on
PATH is what the action is for — so it carries a written exception, on the
line the finding is attributed to. Placing it took three attempts: a shell
comment inside a run: block is not a YAML comment, and a comment above the
key just moves the key.

The job comment claimed "Blocking". It is not: protect-main requires nine
contexts and zizmor is not among them, so today it reddens a pull request
without refusing the merge. Corrected to say that, and to name adding it to
the ruleset as the one-line change that cashes in the separately-named job.

`.github/zizmor.yml` and `is_exempt` now state one pinning policy twice.
The config argues in prose that it is kept in step with that function; this
adds the check for that claim, in the shape rust_version_pins_test already
uses for the toolchain pinned in five places — name a source of truth, read
the other statements of it textually, fail listing the disagreements. No
YAML crate: the workspace has none, and the sibling guard hand-scans TOML
for the same reason. Proven both ways — permitting something is_exempt does
not, and dropping something it does, each fails naming the disagreement.

`just ci` stays byte-identical to the CI step, asserted rather than assumed.
@emrecdr
emrecdr merged commit 67548c2 into main Aug 13, 2026
14 checks passed
@emrecdr
emrecdr deleted the ci/adopt-zizmor branch August 13, 2026 14:18
emrecdr added a commit that referenced this pull request Aug 14, 2026
* docs(reports): hardening cycle 13 — a recommendation wrong for four cycles, and its resolution

Anchored to 67548c2; baseline 267426a. Delta 4 commits (#270-#273).

#271 decided the standing backlog and rejected a recommendation this
audit had carried since cycle 6: trusted publishing needs id-token on
the job running cargo publish, which builds the crate and so executes
build.rs — and repository code holding an OIDC token can request any
audience including sigstore, forging the provenance the pipeline exists
to make unforgeable. The rejection is correct. I recommended OIDC in
cycles 6, 8, 9, 11 and 12 while praising, in the same reports, the SLSA
L3 isolation whose premise is that no job holding signing credentials
executes repository code. Two individually-correct recommendations in
direct tension, unnoticed for four cycles, because each was checked
against best practice and neither against the other.

Contribution (tested, with a control): the incompatibility is contingent,
not inherent. cargo publish packages, then verifies by building; only the
verification runs build.rs, and it is switchable.
  cargo package --no-verify -> build.rs did NOT run
  cargo package            -> build.rs DID run
So: run verifying `cargo package` in the already-isolated build job, and
`cargo publish --no-verify` in the token-holding job. The long-lived
CRATES_IO_TOKEN goes away and Build L3 holds. Caveats stated: the
packaging check must actually be wired into the build job, and the
publish job must run no repository code before publish — a property
worth asserting in workflow_signing_isolation_test, the guard F303
showed gets tested.

F (LOW, new) — the normalised bot-filter matcher is line-based. #272
closed the spelling axis completely; the layout axis remains, because
the guard normalises each line independently, so a call split across
lines is never assembled. Tested with a control: single-line qualified
CAUGHT, multi-line BOOL_OR(\n is_bot \n) MISSED. Not theoretical — it is
the house style in the directory the guard scans (ownership.rs:53-55
wraps SUM( / arg / ) exactly so). Fix: normalise the file once and
recover line numbers by byte offset, the technique spa_escaping_test
already uses.

Verified: #272 fixes the finding, and its diagnosis of why the §12
census missed that guard is sharper than the finding was — the census
searched for &[&str] consts and so found one syntactic shape of instance
list rather than the class. zizmor adoption paid on contact (three live
template-injection sites, F304) and is configured to AGREE with
workflow_action_pin_test rather than to fight it. #270 separately caught
a container publish reachable from a branch dispatch. Cycle-6 M27 closed:
18/18 <th> carry scope, keyboard activation in four widgets (my first
count was wrong — '<th' matches '<thead' — corrected before reporting).

Open: gitlink fixture (oldest, only item with no decision recorded),
outputSchema 1/11, M8, zizmor not yet a required context. Currency live
2026-08-13: rmcp 3.1.2 and zizmor 1.29.0 both latest; Rust 1.97.1
deferred by decision, not drift. Note: docs/hardening-cycle-12 is still
unmerged.

* docs(reports): correct the cycle-13 housekeeping claim before landing

Section 6 said `docs/hardening-cycle-12` was unmerged and that cycle 12's
report had not landed on main. It had: f678d2c added it, swept in by a
`git add docs/reports/` in #271 rather than by its own PR, which is why it
looked unmerged from the audit side. No such branch exists on the remote.

That sweep was recorded as a process error when it happened — a 13 KB file
committed and merged unread. This is the second thing it caused: an
auditor, reading the branch list rather than the file tree, concluded work
was outstanding that was already on main.

Everything else in the report was verified against source before landing.
Its two substantive claims both hold: the line-based bot-filter matcher
misses wrapped SQL, reproduced by planting it, and `cargo publish`'s
verification step is what executes build.rs, reproduced on cargo 1.97.1
with a control.

---------

Co-authored-by: Emre <emre@valocom.nl>
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.

2 participants