# ADR 049: Zizmor & actionlint (GitHub Actions Security)

- HTML version: https://robbiepalmer.me/projects/personal-site/adrs/049-zizmor
- Project: Personal Site (https://robbiepalmer.me/projects/personal-site.md)
- Status: Accepted
- Date: 2026-06-27

# 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)](/projects/personal-site/adrs/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](/projects/personal-site/adrs/032-codeql)),
Trivy covers IaC/deps/containers ([ADR 047](/projects/personal-site/adrs/047-trivy)),
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. So 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](/projects/personal-site/adrs/024-renovate)).

# Decision

Adopt two deterministic, GitHub-native tools for the workflow layer, both
managed through [mise](/projects/personal-site/adrs/004-mise) so what runs in CI
runs identically on a laptop ([ADR 016](/projects/personal-site/adrs/016-github-actions)):

* **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](/projects/personal-site/adrs/047-trivy)), 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 are complementary, not alternatives: actionlint catches
correctness (a typo'd expression, an invalid `runs-on`) and 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](/projects/personal-site/adrs/047-trivy):
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](/projects?tab=philosophy#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](/projects?tab=philosophy#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](/projects?tab=philosophy#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](/projects?tab=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](/projects?tab=philosophy#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](/projects?tab=philosophy#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](/projects?tab=philosophy#build-flywheels). Workflow-security
  rules learned once carry to every future workflow in the monorepo.
* [Respect Goodhart's Law](/projects?tab=philosophy#respect-goodharts-and-conways-laws).
  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.

---

Markdown index of this site: https://robbiepalmer.me/llms.txt
