ADR 020: Zizmor & actionlint (GitHub Actions Security)

This legacy URL now resolves to the canonical ADR.

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 codeGitHub Actions workflows
DeterministicCodeQL, Trivy, SonarQube, Biomegap
Non-deterministicAI 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-codes keeps 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 CI workflow 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?

ToolRoleProsConsVerdict
zizmor (accepted)GHA SASTBroadest 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 linterLargest 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 SASTConservative, low-noise, supply-chain focused.Narrower than zizmor; overlaps it.Rejected: zizmor's breadth fits better as the single scanner.
octoscan (Synacktiv)GHA SASTStrong expression-injection detection.Narrower scope; less momentum than zizmor.Rejected: subset of zizmor's coverage.
StepSecurity Harden-RunnerRuntime egressNetwork egress control + auto-pinning.A platform/runtime agent, not static analysis.Rejected: reintroduces platform sprawl (Less Is More).
CodeQL Actions analysisGHA SASTAlready 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/aNo 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:actions before 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.