Files
Eric Quintero e6066e7db1 Rebuild supply-chain-risk-auditor around a deterministic collector (#227)
* Rebuild supply-chain-risk-auditor around a deterministic collector

Replace the gh-only audit method with two stdlib-only Python scripts
bundled with the skill: collect.py queries OSV, the npm and PyPI
registries, the Go module proxy, deps.dev, OpenSSF Scorecard, and
GitHub, and emits a JSON artifact; render.py turns it into a Markdown
report of facts only. The model's job is the judgment layer on top —
remediation, replacement candidates, narrative — written in report
register and labeled as judgment. The old method could not deliver its
own criteria: repository contributors are not registry publish rights,
and gh sees no download counts or ecosystem-keyed advisories.

What it measures, for npm, PyPI, and Go:

- Version-matched advisories for direct dependencies, resolved from the
  lockfile, manifest pins, or labeled fallbacks — and for the full
  lockfile-resolved transitive tree (package-lock.json, uv.lock,
  go 1.17+ go.mod), advisories only.
- Abandoned or archived upstreams, deprecated and yanked releases, npm
  publisher concentration, install-time script execution, and the two
  OpenSSF Scorecard checks that name a concrete mechanism
  (Dangerous-Workflow, Binary-Artifacts). Download volume, publish
  provenance, and security policy are reported as context, never
  flagged.

The structure enforces its honesty rules rather than documenting them:

- Every criterion resolves to assessed-clean, assessed-flagged, or
  unassessable-with-a-reason. Unavailable data is never evidence of
  risk, and every claim is bounded by a coverage table.
- An empty advisory answer counts as clean only for a package proven to
  exist: a registry document for npm and PyPI, a module-proxy answer
  for Go, and for transitive lockfile entries a registry integrity hash
  or registry source. Everything else — private registries, git
  dependencies, vendored directories — is named as unverifiable with
  its reason, never counted clean.
- Coverage must reconcile, a run that measures nothing exits non-zero
  instead of reporting that nothing is wrong, the renderer refuses an
  artifact whose flags and coverage disagree, and third-party text is
  escaped before it reaches a Markdown table.

85 offline tests exercise the invariants through the collector's own
cache format, and the suite is mutation-checked. evals/ ships three
fixtures with graded expectations; against a no-skill baseline the
skill passed ~92% of skill-agnostic assertions vs ~60%, at half the
wall clock, with its edge in reproducibility — the report regenerates
byte-for-byte from the artifact — and self-consistency.

Version 1.0.1 -> 2.0.0: method replacement. CODEOWNERS moves to @e-q.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Establish registry identity at the parse boundary; reconcile the sweep
against the raw lockfile

Review findings on the PR clustered at one boundary: a non-registry or
malformed identity reaching the registry-keyed pipeline. Close the
class, not the instances:

- Dependency gains non_registry_reason, set by the parsers (npm file:/
  workspace:/git/shorthand specs; PyPI direct deps whose uv.lock source
  is git/directory/path). One choke point in collect() marks every
  criterion unassessable with that reason and excludes such deps from
  all lookups — a same-named public package's advisories, publishers,
  and deprecation belong to code the project never installs. The deps
  stay in the report and its coverage.
- Versions extracted from requirements text pass a PEP 440-shaped gate;
  pip-compile continuation/hash debris ("2.19.0 \") becomes unresolved
  instead of a version-matched claim. Measured: OSV compares garbage
  versions lexically, so the debris did not fail — it matched the wrong
  advisory ranges.
- The transitive sweep excludes direct dependencies by (ecosystem,
  name, version), never by name: a nested copy of a direct dependency
  pinned at another version is this sweep's responsibility, and the
  name-keyed exclusion silently dropped it (measured on axios: two
  recovered entries, 620 -> 622). The artifact now carries a ledger
  counted from the raw lockfile before any exclusion — checked +
  unverifiable + excluded_direct must equal it, so a dropped triple
  fails validation instead of vanishing while the counts balance. The
  checked==0 guard hole is closed: zero reconciles like any number
  unless a reason is stated.
- pip-audit runs with --no-deps --disable-pip. Measured: --no-deps
  alone still audited a pip-resolved transitive set, so pip was still
  fetching and potentially building untrusted distributions, against
  the tool's no-execution promise; with both flags it audits exactly
  the listed pins from registry metadata. Names on both sides of the
  cross-check are PEP 503-normalised.
- Scorecard check thresholds move to model.SCORECARD_CHECKS as the
  single source of truth; the renderer derives its never-flags set from
  threshold-is-None, ending the clean-run contradiction that described
  the two flagging checks as "not flagged — poor precision".
- Duplicate requirements prefer the runtime declaration (the dev file
  sorts first, so first-seen-wins reclassified production pins as
  build-time at the dev version); _git_commit confines refs to .git and
  degrades to None on undecodable content instead of crashing the run;
  recognised-but-unread lockfiles (yarn.lock, pnpm-lock.yaml,
  poetry.lock) produce a note and the docs name exactly which lockfiles
  are read; third-party text cannot inject links; the runtime estimate
  is honest.

88 tests; the four new guards (triple-keyed exclusion, non-registry
choke, lockfile ledger, zero-checked reconciliation) are each
mutation-checked against the exact reviewed bug.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Mark PEP 508 direct references as non-registry at the parse boundary

`flask @ git+https://...` in pyproject.toml or requirements.txt was
stripped to a bare name — `@` is a name terminator in _REQ_SPLIT, so
the URL was silently discarded — and the dependency was looked up on
PyPI, attributing the public package's advisories and metadata to a
fork that may exist precisely to fix them. The npm path guards this at
spec parsing and the PyPI path guarded it only via uv.lock's source
table, so any pip-managed project walked past the choke point.

Direct references are now detected in the requirement text itself and
carry non_registry_reason with the URL; the existing choke point does
the rest. Assert folded into the requirements parsing test and
mutation-checked against the reproduction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Escape third-party text everywhere in the report, not only in tables

The Method-and-caveats notes and the report header interpolated
untrusted strings unescaped. Reproduced end to end: a package.json
dependency key containing newlines (JSON permits them) with a file:
spec produced a note that wrote a `## Summary` heading and a forged
"No known advisory affects any of the 12 direct dependencies" bullet
into report.md — a deliverable meant to survive being pasted into a
client report. The 12-byte commit field read from the target's
.git/HEAD had the same reach.

Whitespace collapsing is the half that matters: it confines hostile
text to the line it was interpolated into, where the worst available is
inline emphasis rather than forged block structure. The mechanism was
already right, so this is the missing calls plus a rename — _cell is
now _safe_text, since a table-shaped name is what invited skipping it
off-table.

The existing table test asserted on the row it expected, which is why
it never noticed these paths; the new test asserts structurally that
every heading and bullet in the report came from the renderer. Both
escaping calls are mutation-checked. Normal reports are byte-identical:
the note templates carry no pipes or brackets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Accept v-prefixed and epoch versions; stop escaping inside code spans

A regression review comparing the branch tip against its first commit
found that two of the earlier review fixes had costs worth paying back.

The PEP 440 version gate required a leading digit, so the legal pin
`django==v3.2.0` became an unresolved version: advisories were matched
against the latest release and 62 real ones for that version read as
assessed_clean. PEP 440 permits the prefix, pip accepts it, and OSV
matches it. The gate now accepts and strips it, so the reported version
is canonical. Probing that also surfaced a longer-standing defect in the
same path: extraction split on `!`, which is there for `!=` and
truncated a PEP 440 epoch, so `1!2.0` was reported as the pin `1`.
Extraction now ends the version at whitespace, a comma, or a semicolon,
which additionally recovers the real pin from pip-compile hash lines
that previously fell back to unresolved.

Markdown does not process backslash escapes inside a code span, so
escaping there wrote the backslashes out literally: the report title and
the `Scanned:` path came out as `/tmp/pkg \[v2] \| beta`, which is not a
path a reader can copy. Values inside backticks now go through
_safe_code, which collapses whitespace and neutralises the one character
that matters there — a backtick, which would close the span early — and
leaves the rest alone. Prose and table cells keep _safe_text, so link
forgery and cell escapes are unchanged. One first-party note lost its
literal brackets rather than being escaped around them.

Non-registry dependencies now share one unassessable reason, with the
specific source in the signal value and the Method note. Embedding the
source in the reason gave each dependency a unique string, which
defeated the report's grouping: a 7-workspace-package fixture produced
91 near-identical bullets across 13 criteria, and the Not-assessable
section went from 56 lines to 132. It is back to 56.

Also: a collector-level fixture now proves the transitive ledger is
sourced independently of the buckets it checks — deriving it from them
made the equation true by construction and left a dropped package
undetected while all tests passed. The docstring no longer claims the
ledger is counted from the raw lockfile, which overstated its reach, and
_locked_beyond_direct's return annotation matches its five values again.

91 tests; all four fixes mutation-checked. Real reports are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Keep the pipe escape for code spans inside table cells

Splitting the escaping into prose and code-span variants dropped the
pipe escape from both, but a GFM table row is split on pipes before
inline spans are parsed, so a pipe inside a code span still ends the
cell. A dependency named `evil|forged` put six boundaries in a
five-column row: the name truncated to a bare backtick and `evil`, and
every later value shifted one column right, so "none known" rendered
under Other findings.

Verified against a CommonMark+GFM parser that this is genuinely a third
context rather than a reason to revert: inside a table cell `\|` renders
as a literal pipe, while outside one the backslash survives into the
output — which is the corrupted `Scanned:` path the split fixed. The
three table paths now use _safe_code_cell; the bullet and header paths
stay on _safe_code.

The new test asserts column parity across every table in the document
rather than one row in one table, counting the pipes GFM actually splits
on so an escaped pipe reads as content. Per-path assertions are what let
this reach three call sites at once, and what missed the notes path two
commits earlier. Each of the three sites is mutation-checked
independently.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Check the assembled document instead of trusting every escape site

Escaping was applied per interpolation site, and three successive
commits each fixed one set of sites correctly while leaving siblings
unguarded: the notes and header fields, then three table paths, and now
the download-volume line and the informational sample. Coverage depended
on whoever wrote or reviewed the diff noticing every interpolation,
which is the wrong thing to rest a property on when the failure mode is
a client deliverable carrying a forged all-clear.

render() assembles a list of lines and joins them, and every legitimate
line is appended as its own element, so two invariants are precise and
cannot be violated by legitimate content: no assembled line contains a
newline, and every table row carries its header's unescaped-pipe count.
check_no_forged_lines enforces both immediately before the join. Block
forgery needs a newline to open a new block, so the first invariant
catches any site that leaks, including sites not yet written; verified
by reverting each of the two newly-escaped sites, which now fails seven
existing tests rather than none.

The two open sites are escaped as well rather than left to the
invariant: with escaping a hostile name renders harmlessly and the audit
completes, while the invariant alone would let any audited repository
deny its own audit by naming a dependency with a newline in it.

The shared test fixture now carries a newline, a backtick, and a pipe in
its default name, so every render test drives adversarial input through
every path it touches. Its informational criteria carry real booleans
too: they were ints, and informational_section sorts on `is True` /
`is False`, so the path that interpolates names into a Without: sample
had never executed in any test. That combination is why the misses kept
recurring.

93 tests; both invariant loops mutation-checked. Real reports are
unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: kz-tob <kara.zaffarano@trailofbits.com>
2026-08-10 16:27:14 -04:00

3.3 KiB

Supply Chain Risk Auditor

Generate a supply-chain risk report for a project's direct dependencies across npm, PyPI, and Go, plus a known-advisory sweep of everything the lockfile resolves. A deterministic collector queries registries and advisory databases; a renderer turns the artifact into a Markdown report; the model adds narrative and remediation judgment on top, clearly separated from what was measured.

Author: Spencer Michaels (original), Eric Quintero (current design)

What it measures

Criterion Depth Where it is measurable
Known advisories, version-matched (OSV) direct + full lockfile tree all three ecosystems
Deprecated / yanked releases direct npm, PyPI
Archived or abandoned upstream repository direct any GitHub-hosted repo
Publisher concentration (registry publish ACL) direct npm only — PyPI and Go publish no ACL
Install-time script execution direct npm (Go has none by design)
Dangerous CI workflows, checked-in binaries (OpenSSF Scorecard individual checks) direct repos Scorecard covers
Download volume, publish provenance, security policy direct, informational — measured, never flagged varies

Every criterion resolves to assessed-clean, assessed-flagged, or unassessable-with-a-reason, and the report carries a coverage table stating what could and could not be measured. Two rules are enforced by the code rather than by convention: unavailable data is never treated as evidence of risk, and an absent measurement is never a clean verdict — a run that measures nothing refuses to produce a report.

Usage

Ask Claude to "audit this project's dependencies" or "assess the supply-chain risk of this repo", or run the scripts directly:

cd skills/supply-chain-risk-auditor/scripts
uv run collect.py <project-dir> --json findings.json
uv run render.py findings.json --out report.md

Requirements: uv (the scripts are stdlib-only Python). Authenticated gh is strongly recommended — unauthenticated GitHub allows 60 requests/hour against 5,000, and the collector makes several per dependency. HTTP responses are cached (six-hour freshness bound) under the system temp directory; --offline reruns from cache alone.

Scope and audience

Written for the reader who owns or is engaged on the audited project. Direct dependencies carry the full criteria set; the transitive tree is checked for advisories only, and only where a lockfile (package-lock.json/npm-shrinkwrap.json, uv.lock, or a go 1.17+ go.mod) resolves it. yarn.lock, pnpm-lock.yaml, and poetry.lock are not read; the report notes their presence and versions fall back to manifest pins or the latest release. Dependencies that resolve from outside their public registry (workspace, git, vendored) are reported as unassessable rather than looked up by name. The report states what was not examined rather than leaving it to be inferred.

The audit is designed to be useful given nothing more than a list of dependencies: it reads manifests and lockfiles, and never installs, builds, or executes the project or its packages. Whether the pinned set actually installs or imports cleanly is out of scope, as are scanning the target's own source, reading dependency source, and license compliance.

Installation

/plugin install trailofbits/skills/plugins/supply-chain-risk-auditor