Bulk-audit GitHub repositories for exploitable GitHub Actions workflow
vulnerabilities. Wraps zizmor with a
structural analysis layer that re-ranks its findings by real external-attacker
exploitability, and adds first-class detection for the pwn-request pattern
(fork-code checkout + execution under a privileged trigger), which zizmor
does not flag as a single audit.
zizmor is precise about what is interpolated where, but it can't tell you
whether a given finding is reachable by an external, unprivileged attacker. In
practice, most high/high findings across a large corpus turn out to be:
- fork PRs running in their own read-only context (
pull_request, notpull_request_target) — no secrets, no write token - gated behind a sound identity check (
github.actor,author_association) - neutralized by a real
env:indirection or a quoted heredoc - injected values that are GitHub-generated and charset-constrained (SHAs, IDs, run numbers) and simply can't carry a shell payload
This scanner encodes those distinctions structurally, so the output is ranked by exploitability instead of raw severity, and the noise floor is far lower.
- Template/script injection (
template-injection,github-env,artipacked), re-scored using:- trigger risk (does an external fork attacker actually reach this trigger with secrets/write token in scope, or does GitHub run it read-only?)
- expression controllability (free-text like
.title/.body/branch refs vs. constrained/generated values like SHAs, run IDs, login names) - neutralization (quoted heredoc, or a genuine
env:-key indirection — verified against actualenv:mappings in the file, not just "text shaped like$VAR") - gating soundness (a job/step
if:that looks like access control but checks a spoofable field, e.g.pull_request.user.login, doesn't count as a real gate)
- Pwn requests — a synthetic, first-class finding independent of any
zizmoraudit: a job that checks out fork PR head code (SHA or branch ref) and then executes project-controlled code (npm/pnpm/yarn installlifecycle scripts,<pm> run <script>, build tooling, local./actions) under a trigger that runs in the base repo's privileged context. - gh-aw (GitHub Agentic Workflows) generated lock files — detected and discounted, since their attacker-influenced expressions land inside JSON data strings consumed by a vetted safe-outputs sanitizer, not raw shell sinks.
| Tier | Score | Meaning |
|---|---|---|
critical |
≥ 6 | Strong external-attacker exploitation path |
review |
2–5 | Plausible, needs a human look |
probably-fp |
< 2 | Low-signal, neutralized, or maintainer-only |
Every finding carries a score_reasons list documenting exactly which
signals drove its score, so a triage pass can audit why something landed
where it did rather than trusting a black-box number.
zizmor_scan.py consumes a JSONL file of {"package", "github_repository", ...}
entries — it doesn't care which package ecosystem they came from. Three
companion scripts resolve package names from PyPI, npm, and crates.io into
that same schema, so you can point the scanner at an entire ecosystem rather
than hand-curating repo lists.
All three write the same output record shape:
{
"package": "requests", "version": "2.31.0",
"github_repository": "https://github.com/psf/requests",
"repository_source": "declared|attested|both", "trusted_publishing": true
}repository_source— whether the GitHub URL came from metadata the maintainer declared (project homepage/repo URL), from a provenance attestation tying the release to the CI workflow that actually built it, or both (and they agree).trusted_publishing— whether the release was published via Trusted Publishing / provenance (OIDC from a CI workflow) rather than a long-lived API token. Relevant context when triaging findings: a package published via Trusted Publishing has a concrete workflow file to point a pwn-request or injection finding at; a token-published package's release process may not even live in the repo the scanner is looking at.
Feed any of their output files straight into zizmor_scan.py:
python pypi_check.py -i top-pypi-packages.txt -o pypi_repos.jsonl --resume
python zizmor_scan.py pypi_repos.jsonl --out pypi_findings.jsonlGiven package names, queries the PyPI JSON API per package (and per-version, if given) and reports:
- Whether the release was published via Trusted Publishing or with a manually-managed API token.
- If Trusted Publishing was used, the publishing repository and workflow.
- The repository/homepage URLs the maintainer declared in package
metadata (
project_urls/home_page). - Whether the declared repository matches the attested one.
Usage:
python pypi_check.py <package> [version]
python pypi_check.py <pkg1> <pkg2> <pkg3> ...
python pypi_check.py -i packages.txt -o repos.jsonl --resume
cat requirements.txt | python pypi_check.py -i - -o repos.jsonlKey flags:
| Flag | Default | Description |
|---|---|---|
-i/--input |
— | File of package names, one per line (- for stdin). Version specifiers and [extras] are stripped, so requirements.txt/pip freeze output works as-is |
-o/--output |
— | Write a package -> github_repository summary (jsonl or csv, inferred from extension) |
--delay |
1.0 |
Seconds between PyPI requests |
--retries |
5 |
Retries for 429/5xx with exponential backoff |
--resume |
off | Skip packages already recorded in a state file (defaults to <output>.state.jsonl); crash-safe, since each result is flushed as computed |
--all-files |
off | Fetch a provenance attestation for every distribution file instead of just the first (all wheels in a release share one CI run, so the default is much faster and just as accurate) |
Requires no auth token — PyPI's JSON API and integrity/provenance endpoints are public.
Same purpose and output schema, against the npm registry. Given package names, reports:
- Whether the release has a provenance attestation (npm's Trusted Publishing equivalent) or was published with an npm token.
- If provenance exists, the publishing repository and workflow file.
- The repository/homepage URLs declared in
package.json(repository.url,homepage,bugs.url). - Whether the declared repository matches the attested one.
Usage is identical to pypi_check.py, including scoped package names
(@scope/name) and stripping of trailing @version specifiers so
npm ls --json output or hand-edited dependency lists work directly:
python npm_check.py <package> [version]
python npm_check.py -i packages.txt -o repos.jsonl --resumeSame flag set as pypi_check.py (-i, -o, --format, --delay,
--retries, --resume, --state), minus --all-files — npm's registry
exposes a dist.attestations field directly on each version, so there's no
"first file vs. every file" tradeoff to make. Default --delay is 0.5
rather than 1.0, since the npm registry is generally more permissive than
PyPI's.
Different approach, because crates.io explicitly asks bulk consumers to use their database dump rather than the public API: one ~1+ GB compressed tarball, refreshed every 24h, with every public crate and no rate limits or pagination to fight. This script streams and parses that dump rather than hitting an API per crate.
python crates_dump_fetch.py -o crates_repos.jsonl
python crates_dump_fetch.py -o crates_repos.jsonl --keep crates-dump.tar.gz
python crates_dump_fetch.py --dump crates-dump.tar.gz -o crates_repos.jsonl
python crates_dump_fetch.py --probe # inspect dump metadata + first rows, no output written| Flag | Default | Description |
|---|---|---|
-o/--output |
— | Output path. Required unless --probe |
--url |
https://static.crates.io/db-dump.tar.gz |
Dump source |
--dump PATH |
— | Use an already-downloaded tarball instead of streaming from --url (fastest for repeated processing) |
--keep PATH |
— | Stream from the network and also save a local copy for future --dump runs |
--include-no-repo |
off | Also emit crates with no resolvable GitHub URL (skipped by default) |
--resume |
off | Skip crates already present in --output |
--probe |
off | Print the dump's metadata.json and the first rows of crates.csv; writes nothing |
--dump and --keep are mutually exclusive.
Because the crates.io dump's crates table doesn't carry per-release
download counts or version numbers (those live in the much larger
versions.csv, which isn't worth parsing just to feed a workflow scanner),
version and downloads are always null in this script's output —
everything else matches the shared schema. trusted_publishing maps to the
dump's trustpub_only column.
- Python 3.10+
zizmorinstalled and onPATH(or point--zizmorat the binary)pip install rich pyyaml- A GitHub token with at least public-repo read access
export GITHUB_TOKEN=ghp_xxx # or GH_TOKEN
python zizmor_scan.py packages.jsonlA JSONL file, one entry per line:
{"package": "some-pkg", "version": "1.2.3", "github_repository": "https://github.com/owner/repo"}Only github_repository is required to resolve a target; package/version
are carried through into the output for traceability back to the originating
package ecosystem.
| Flag | Default | Description |
|---|---|---|
input (positional) |
— | JSONL file with package/github_repository entries |
--out |
findings.jsonl |
Output JSONL file |
--state |
scan_state.jsonl |
Resume-state file — tracks done/failed/pending repos |
--workers |
4 |
Parallel workers. Keep low to respect GitHub API rate limits |
--zizmor |
(from PATH) |
Path to the zizmor binary |
--reset |
off | Ignore existing state file and start over |
The scan is resumable: re-running the same command picks up where it left off using the state file. Ctrl-C triggers a graceful stop (in-flight repos are left unrecorded and retried next run); a second Ctrl-C forces an immediate stop.
Two files are written:
<out>— one JSON record per finding, e.g.:{ "package": "...", "repo": "owner/repo", "audit": "template-injection", "expression": "github.event.issue.title", "expr_controllability": "free_text", "trigger_risk": "high_risk", "pwn_request": false, "exploitability": 8, "tier": "critical", "score_reasons": ["+4 privileged trigger reaches fork attacker", "..."] }<out>.triage.jsonl— the same records, sorted by exploitability (critical first), for opening straight into a triage workflow.
A live terminal dashboard (via rich) shows running totals by repo status,
audit type, trigger risk, and exploitability tier, plus a scrolling feed of
critical findings as they land.
# All critical-tier findings
jq 'select(.tier=="critical")' findings.jsonl
# The tightest real-RCE cluster: privileged trigger + fork checkout + write token
jq 'select(.trigger_risk=="high_risk" and .fork_sha_checkout and .has_write_token)' findings.jsonlenv:-indirection neutralization is verified against realenv:keys collected across the whole file, not bound to the exact step's scope (Python YAML parsing doesn't preserve line-accurate step boundaries). A same-namedenv:key elsewhere in the file could in principle cause an over-neutralize on a different step — rare, but worth a manual glance on anything the scanner drops toprobably-fpviaenv_var_indirected.- Analysis is best-effort against parsed YAML; a workflow that fails to parse
yields an "unknown" analysis rather than being silently dropped, so it still
surfaces at whatever severity
zizmorgave it — just without the exploitability re-ranking. - This is a triage aid, not a source of truth.
critical-tier findings should still be verified against the live workflow file before being written up as an advisory — deployment-specific configuration (branch protection, required reviewers, environment approval) can neutralize a structurally-real finding in ways the static analysis can't see.