Skip to content

About

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

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

10 Commits

Folders and files

Repository files navigation

PackageScanner

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.

Why

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_dispatch inputs, literals passed to a composite action

The scanner models those distinctions so output is ranked by exploitability.

What it analyses

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_run listeners 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 on push, github.event.inputs.* only on workflow_dispatch).
  • Expression controllability — free_text, maintainer, constrained or unknown. inputs.* and matrix.* are resolved through the caller's with: values and strategy.matrix.
  • Gates — job/step if: evaluated over its ||/&& structure, gates inherited through needs:, environment:, and permission-check steps or reusable workflows.
  • Impact, per job — write-scoped or undeclared (repo default) token, id-token: write, secrets other than GITHUB_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.

Output tiers

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").

Feeding it data: resolving packages to GitHub repos

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

  • uv (installs Python 3.13, zizmor, rich, pyyaml)
  • A GitHub token with public-repo read access in GITHUB_TOKEN or GH_TOKEN

zizmor is a pinned project dependency so results are reproducible; pass --zizmor PATH to use another binary.

Usage

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

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

Input format

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.

Flags

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.

Cache layout

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

Output

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

Quick triage with jq

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

Development

uv run pytest
uv run ruff check src tests && uv run ruff format --check src tests
uv run ty check

Known limitations

  • Gate idioms are pattern-matched, not evaluated. A permission-check step is assumed to fail the job when the check fails.
  • steps.*.outputs, needs.*.outputs and env.* are unknown; 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 critical findings against the live workflow before writing an advisory; repository settings (fork PR approval, default token permissions, environment protection rules) are not visible here.

About

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

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages