Context
The CI/CD pipeline is itself an attack surface. The workflows in .github/workflows
run with access to deployment credentials (Cloudflare, Neon, Terraform Cloud,
OpenRouter). Privileged triggers, broad GITHUB_TOKEN permissions, template
interpolation into run: blocks, and unpinned actions are each a short step from
there to leaked secrets. In March 2026 aquasecurity/trivy-action, an action
this repo uses, was exploited through a pull_request_target misconfiguration to
exfiltrate organization secrets.
ADR 048 (SonarQube) framed the quality/security stack along a determinism axis. Mapping the workflow-security question onto it exposes a gap:
| Application source code | GitHub Actions workflows | |
|---|---|---|
| Deterministic | CodeQL, Trivy, SonarQube, Biome | gap |
| Non-deterministic | AI reviewers (ADRs 041–046) | actionlint (only inside CodeRabbit) |
The existing deterministic scanners are generalists pointed at application code:
CodeQL does dataflow SAST on TS/JS (ADR 032),
Trivy covers IaC/deps/containers (ADR 047),
SonarQube scores code health. None of them understands GitHub Actions semantics
, template injection through ${{ }} interpolation into run:, dangerous
triggers, credential persistence, or over-broad GITHUB_TOKEN permissions.
SonarQube's only workflow rule is "pin actions to a digest". That was the finding
that prompted this work, but it covers a small fraction of what a dedicated
Actions analyzer checks.
actionlint does already run, but only as a tool inside CodeRabbit's AI
review, which sits in the non-deterministic column. A workflow regression it
flags on one PR may not be flagged on the next. In practice there is no
deterministic check watching the workflows at all.
A prerequisite was addressed first: every external action across all workflows
was repinned from floating tags (@v6) to full commit SHAs, so a moved or
compromised tag cannot silently change what runs in CI. Renovate keeps the
pinned digests updated (helpers:pinGitHubActionDigests), so pinning costs no
freshness (ADR 024).
Decision
Adopt two deterministic, GitHub-native tools for the workflow layer, both managed through mise so what runs in CI runs identically on a laptop (ADR 016):
- zizmor, a dedicated static analyzer (SAST) for GitHub Actions. It runs as
its own workflow and uploads SARIF to the GitHub Security tab, where, like
Trivy (ADR 047), GitHub Code Scanning
fails a pull request that introduces a new alert.
zizmor --no-exit-codeskeeps the scan job green on findings, Code Scanning does the gating, but fails the job on a genuine tool or upload failure, so the scan cannot silently skip its job. - actionlint, a fast deterministic linter for workflow syntax, expression
validity, and (when present) shell via shellcheck. Unlike zizmor it runs as a
blocking check in the existing
Lint CIworkflow alongside the Markdown and YAML linters, because the workflows pass it cleanly today, so it has a green baseline to protect. This also moves actionlint out of the non-deterministic CodeRabbit-only column into a reproducible gate.
Why It Adds Something New
The distinctive property is determinism applied to GitHub Actions, which
nothing in the current stack covers. zizmor is the specialist; CodeQL, Trivy and
SonarQube are generalists that each happen to carry one or two workflow rules.
The overlap with SonarQube is a single rule (digest pinning), fixed once,
redundant rather than harmful, the same reasoning ADR 048 applied to its CodeQL
overlap. Everything else zizmor audits (template injection, dangerous triggers,
github-env injection, artifact credential persistence, excessive permissions)
is net-new coverage on the most privileged, least-watched part of the repo.
actionlint and zizmor cover different concerns. actionlint catches correctness
problems such as a mistyped expression or invalid runs-on, while zizmor catches
security. The research consensus on workflow scanners is explicitly to layer a
fast linter with a broad security scanner rather than pick one.
Alternatives Considered
Weighted toward the same test as ADR 047: does it surface in GitHub with no separate platform, and is it deterministic?
| Tool | Role | Pros | Cons | Verdict |
|---|---|---|---|---|
| zizmor (accepted) | GHA SAST | Broadest workflow security coverage; SARIF → Security tab; mise/pre-commit/CI; active (Trail of Bits). | Newer (2024), so a thinner LLM corpus; noisier than poutine. | Accepted as the security scanner. |
| actionlint (accepted) | GHA linter | Largest rule set for correctness; shellcheck integration; near-zero runtime. | Not security-focused. | Accepted as the deterministic linter (was only in CodeRabbit). |
| poutine (BoostSecurity) | GHA/CI SAST | Conservative, low-noise, supply-chain focused. | Narrower than zizmor; overlaps it. | Rejected: zizmor's breadth fits better as the single scanner. |
| octoscan (Synacktiv) | GHA SAST | Strong expression-injection detection. | Narrower scope; less momentum than zizmor. | Rejected: subset of zizmor's coverage. |
| StepSecurity Harden-Runner | Runtime egress | Network egress control + auto-pinning. | A platform/runtime agent, not static analysis. | Rejected: reintroduces platform sprawl (Less Is More). |
| CodeQL Actions analysis | GHA SAST | Already run for source SAST. | Workflow coverage is shallow vs a dedicated tool; not its strength. | Rejected for this gap; stays the source-code SAST owner. |
| Status quo (SonarQube/CodeRabbit only) | n/a | No new check. | Leaves the deterministic-GHA quadrant empty; actionlint stays non-deterministic. | Rejected: closing that gap is the point. |
Where It Sits On The Adoption Curve
actionlint is late majority, a long-standing default in serious Actions setups, with the maturity (stable CLI, deep docs, large corpus) the philosophy values (LLM-Optimized). zizmor is early majority, first released in 2024, rising fast, now the de-facto open-source workflow auditor, which puts it in the Goldilocks zone we target: past the bleeding edge, before ubiquity. The trade-off is a smaller training corpus than a decade-old tool, accepted because the security gap is concrete.
Relation To Building Philosophy
- Less Is More. Both delivered as a PR check and a Security-tab entry, not a new console; both managed in mise, so the residual cost is one lint step and one scheduled scan.
- LLM-Optimized. When agents author most workflow YAML, the constraint is trustworthy verification. A deterministic floor means the same workflow regression fails the same check every time, unlike the CodeRabbit-only actionlint it replaces.
- Short Feedback Loops.
actionlint fails fast on the PR; zizmor annotates the Security tab. Both run
locally via
mise run //:lint:actions///:security:actionsbefore pushing. - Build Flywheels. Workflow-security rules learned once carry to every future workflow in the monorepo.
- Respect Goodhart's Law. zizmor gates only new alerts rather than the whole backlog, so the pressure is to fix a regression rather than to switch the check off.
Gaps & Risks
- The gate only catches new alerts. Code Scanning fails a PR that introduces a finding, but anything that predates the scan or lives in a file it does not cover only stays addressed if the Security tab is read, the same watch-it-or-lose-it risk Trivy carries.
- Vendor/free-tier risk. zizmor's online audits use the GitHub API; the tool itself is open source and pinned via mise, so the dependency is light.
Consequences
Positive
- Fills the deterministic GitHub-Actions-security quadrant with a Security-tab check and a blocking linter, no new platform.
- Repins every external action to an immutable commit SHA, closing the moved-tag / compromised-action vector that motivated the work.
- Moves actionlint from a non-deterministic AI-review tool to a reproducible gate.
- Free, GitHub-native, and mise-managed for local/CI parity.
Negative
- Adds one blocking lint step and one scheduled scan workflow.
- zizmor overlaps SonarQube on the single digest-pinning rule and CodeQL nowhere meaningful; accepted as harmless redundancy.
- Newer tool (zizmor) means a thinner LLM corpus than the decade-old linters.