Bulk-audit the GitHub repositories behind PyPI, npm and crates.io packages for
exploitable GitHub Actions workflows. It wraps
zizmor with a structural analysis
layer that re-ranks findings by external-attacker exploitability, and adds
first-class detection of pwn requests (fork code checked out and executed
under a privileged trigger), which zizmor does not flag as a single audit.
zizmor is precise about what is interpolated where, but it cannot tell
whether an unprivileged outsider can reach a finding. Across a large corpus,
most high-severity findings turn out to be:
- reachable only through sandboxed fork triggers (
pull_request: read-only token, no secrets), or only by maintainers (push,workflow_dispatch) - gated: same-repo-only checks, labels, trusted identities, permission-check steps or jobs, deployment environments
- values an attacker cannot shape: SHAs, IDs, logins, URLs, maintainer-set
labels,
workflow_dispatchinputs, literals passed to a composite action
The scanner models those distinctions so output is ranked by exploitability.
For every repository it fetches .github/workflows/*.yml and every
action.yml/action.yaml, runs zizmor, and scores each finding with:
- Trigger reach, per trigger — privileged (
pull_request_target,issue_comment,issues,discussion*,workflow_run, …), privileged but maintainer-only activity types (types: [labeled]), sandboxed fork triggers, or maintainer-only triggers.workflow_runlisteners are resolved to the upstream workflow they name: fork-reachable only if that upstream runs on a fork-reachable trigger.branches:filters are ignored because fork branch names are attacker-chosen. - Event relevance — an expression only counts under triggers whose event
populates it (
github.event.head_commit.*only exists onpush,github.event.inputs.*only onworkflow_dispatch). - Expression controllability —
free_text,maintainer,constrainedorunknown.inputs.*andmatrix.*are resolved through the caller'swith:values andstrategy.matrix. - Gates — job/step
if:evaluated over its||/&&structure, gates inherited throughneeds:,environment:, and permission-check steps or reusable workflows. - Impact, per job — write-scoped or undeclared (repo default) token,
id-token: write, secrets other thanGITHUB_TOKEN. - Call graph — reusable workflows and composite actions are scored once per local call site, using that caller's triggers, gate and passed value.
- gh-aw lock files — compiled GitHub Agentic Workflows are detected and their non-free-text expressions discounted.
Supply-chain hygiene audits (unpinned-uses, excessive-permissions,
unpinned-images, github-app) are dropped: they are not external exploit
paths and made up 97% of earlier output. dangerous-triggers is dropped too: it
flags every pull_request_target/workflow_run use, and the exploitable cases
are covered by pwn-request detection and injection scoring.
| Tier | Score | Meaning |
|---|---|---|
critical |
≥ 6 | Attacker-reachable privileged trigger, controllable input, real impact |
review |
2–5 | Plausible, needs a human look |
probably-fp |
< 2 | Gated, sandboxed, maintainer-only or constrained |
Every record has score_reasons, one signed line per contribution, plus any
tier cap (for example "capped at review: needs a maintainer to pass the gate").
package-scan 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 package-scan:
python pypi_check.py -i top-pypi-packages.txt -o pypi_repos.jsonl --resume
uv run package-scan scan 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.
- uv (installs Python 3.13,
zizmor,rich,pyyaml) - A GitHub token with public-repo read access in
GITHUB_TOKENorGH_TOKEN
zizmor is a pinned project dependency so results are reproducible; pass
--zizmor PATH to use another binary.
Run from the repository root:
export GITHUB_TOKEN=ghp_xxx
uv run package-scan scan crates_repos.jsonl --out data/results/crates.jsonl
uv run package-scan rescore --out data/results/crates.rescored.jsonlscan fetches, caches, runs zizmor and scores. rescore re-scores every cached
snapshot offline without touching GitHub; use it after changing heuristics.
Cached zizmor output is reused while the zizmor version matches.
A JSONL file, one entry per line (the resolvers above produce it):
{"package": "some-pkg", "version": "1.2.3", "github_repository": "https://github.com/owner/repo"}Only github_repository is required. package, version,
trusted_publishing and repository_source are carried into every finding.
| Flag | Command | Default | Description |
|---|---|---|---|
input |
scan | — | JSONL with github_repository entries |
--out |
both | findings.jsonl (scan), findings.rescored.jsonl (rescore) |
Findings JSONL; rescore overwrites its output |
--cache-dir |
both | data/cache |
Snapshot cache |
--workers |
both | 4 |
Parallel workers |
--zizmor |
both | from PATH |
zizmor binary |
--state |
scan | scan_state.jsonl |
Resume state |
--reset |
scan | off | Delete the state and output files first |
scan is resumable: ok, no_workflows, not_found and blocked repos are
skipped on the next run; error and rate_limited are retried. The GitHub
client uses one tree API call per repository, downloads files from
raw.githubusercontent.com, and pauses every worker until the rate-limit reset
when the budget is exhausted. Ctrl-C stops gracefully; a second Ctrl-C forces it.
data/cache/<owner>/<repo>/meta.json package metadata, status, file list
data/cache/<owner>/<repo>/files/... fetched workflow and action files
data/cache/<owner>/<repo>/zizmor.json raw zizmor output + zizmor version
<out> holds one record per finding; <stem>.triage.jsonl holds the same
records sorted by exploitability. The same expression repeated in one step is
merged into a single record with every line in lines.
{
"package": "pkg", "repo": "owner/repo", "path": ".github/workflows/ci.yml",
"audit": "template-injection", "line": 42, "lines": [42, 57], "job": "build", "step": 3,
"expression": "inputs.title", "controllability": "free_text",
"resolved_expression": "inputs.title <- github.event.pull_request.title",
"via": ".github/workflows/caller.yml (job build)",
"triggers": ["pull_request_target"], "exposure": {"pull_request_target": "privileged"},
"gate": {"kind": "open", "reason": ""},
"impact": {"token": "write", "id_token_write": false, "secrets": true, "write_capable": true},
"exploitability": 7, "tier": "critical", "score_reasons": ["+3 attacker-reachable ..."]
}pwn-request records also carry checkout (kind: sha, mutable or
branch_without_repository, the evidence, step and path) and exec_reason.
jq 'select(.tier=="critical")' findings.triage.jsonl
jq 'select(.audit=="pwn-request" and .gate.kind=="open" and .impact.secrets)' findings.jsonl
jq 'select(.exposure.workflow_run=="privileged")' findings.jsonluv run pytest
uv run ruff check src tests && uv run ruff format --check src tests
uv run ty check- Gate idioms are pattern-matched, not evaluated. A permission-check step is assumed to fail the job when the check fails.
steps.*.outputs,needs.*.outputsandenv.*areunknown; the scanner does not trace data through step outputs.- Callers outside the repository are invisible, so a published action with no local caller is scored with unknown trigger context.
- Base-repo branch names (
github.ref_name,github.base_ref,pull_request.base.ref) are treated as maintainer-controlled: creating them needs write access. A workflow that creates branches from attacker input would break that assumption. - This is a triage aid. Verify
criticalfindings against the live workflow before writing an advisory; repository settings (fork PR approval, default token permissions, environment protection rules) are not visible here.