Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

5 Commits
 
 
 
 
 
 

Repository files navigation

PackageScanner

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.

Why

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, not pull_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.

What it detects

  • 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 actual env: 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 zizmor audit: a job that checks out fork PR head code (SHA or branch ref) and then executes project-controlled code (npm/pnpm/yarn install lifecycle 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.

Output tiers

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.

Feeding it data: resolving packages to GitHub repos

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.jsonl

pypi_check.py — PyPI

Given package names, queries the PyPI JSON API per package (and per-version, if given) and reports:

  1. Whether the release was published via Trusted Publishing or with a manually-managed API token.
  2. If Trusted Publishing was used, the publishing repository and workflow.
  3. The repository/homepage URLs the maintainer declared in package metadata (project_urls / home_page).
  4. 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.jsonl

Key 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.

npm_check.py — npm

Same purpose and output schema, against the npm registry. Given package names, reports:

  1. Whether the release has a provenance attestation (npm's Trusted Publishing equivalent) or was published with an npm token.
  2. If provenance exists, the publishing repository and workflow file.
  3. The repository/homepage URLs declared in package.json (repository.url, homepage, bugs.url).
  4. 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 --resume

Same 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.

crates_dump_fetch.py — crates.io

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.

Running the scanner

Requirements

  • Python 3.10+
  • zizmor installed and on PATH (or point --zizmor at the binary)
  • pip install rich pyyaml
  • A GitHub token with at least public-repo read access

Usage

export GITHUB_TOKEN=ghp_xxx   # or GH_TOKEN
python zizmor_scan.py packages.jsonl

Input format

A 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.

Flags

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.

Output

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.

Quick triage with jq

# 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.jsonl

Known limitations

  • env:-indirection neutralization is verified against real env: 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-named env: 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 to probably-fp via env_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 zizmor gave 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.

About

This repo contains the code used to perform an analysis of GitHub actions workflows at scale.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages