Dependency Provenance
Most of what runs in production was written by people you will never meet, pulled
by a resolver you did not watch, at a moment you did not choose. That is a
reasonable trade — and it is only reasonable if you can answer three questions at
any time:
- What do we actually run? Not what the manifest requests. What is in the
artifact.
- Where did it come from? Which registry, which publisher, which commit, and
what proves the link.
- What changed, and did anyone look?
A dependency policy that cannot answer these is a preference, not a control.
Composes with: deterministic-core (a build that isn't reproducible can't be
attested), attack-surface-triage (the build system is an entry point with
production credentials), claim-provenance-discipline (a scanner's severity is
its claim, not your finding), versioned-schema-evolution (upgrades change
serialized shapes), irreversible-action-gate (publishing is R3).
Part 1 — Four different answers to "what version do we use"
These diverge constantly, and people quote whichever one is at hand:
| Layer |
What it says |
How it lies |
Manifest (package.json, pyproject.toml) |
What you requested — usually a range |
Says ^2.1.0; you run 2.9.4 |
| Lockfile |
What the resolver chose, at resolution time |
Stale, or absent for transitive tooling; may not cover the build platform |
| Installed tree |
What is on disk in this environment |
Differs between dev, CI, and prod; may include hoisted or vendored copies |
| Shipped artifact |
What is in the container/binary/bundle that actually runs |
The only one that matters, and the one nobody inspects |
Ground your answers in the shipped artifact. When someone asks "are we affected
by CVE-X in library Y", the defensible answer comes from an inventory of the
image or bundle — not from grep in a manifest. Treat the manifest as a request,
the lockfile as a record of a past decision, and the artifact as fact.
Transitive is the majority. You chose maybe 30 packages; you run 900. The
ones you chose got the most scrutiny and carry the least risk. Any policy that
reviews direct dependencies only is inspecting a rounding error.
Part 2 — Adding a dependency grants execution authority
The question is not "is this library good code". It is "what does adding this
authorize, and where does that code run":
- Install-time scripts (
postinstall, setup.py, build hooks) execute
arbitrary code, as your user, on developer laptops and in CI — and CI is where
the deploy keys are. Install-time execution is the highest-privilege moment in
a dependency's life and the least observed.
- Build plugins, bundler plugins, CI actions, test fixtures: same authority,
same credentials, and usually exempt from whatever review the runtime deps get.
A test-only dependency that runs in CI is not "low risk"; it is a
production-credential-adjacent dependency with less scrutiny.
- Runtime: what the library can reach — network, filesystem, secrets in the
process environment. Most language runtimes give a dependency exactly the same
authority as your own code. There is no inner boundary unless you built one.
Practical stance: disable install scripts by default where the ecosystem allows
it and allowlist the few that genuinely need them; pin CI actions and build
plugins by commit digest, not by tag; and treat "dev dependency" as a description
of when it runs, never of how much it can do.
Part 3 — Identity before trust
Before evaluating whether a package is trustworthy, establish that it is the
package you think it is. The attacks here are boring, cheap, and effective:
- Typosquat / homoglyph — one character off, or a Unicode lookalike.
- Scope and namespace confusion —
@org/thing vs org-thing; a name that is
official in one ecosystem and unclaimed in another.
- Dependency confusion — an internal package name that also exists on the
public registry, where the resolver prefers the higher version. The fix is
configuration (scoped registries, explicit index priority, reserved
namespaces), not vigilance.
- Repo-link laundering — a package's metadata can claim any repository URL.
A GitHub link in a registry page is a claim by the publisher, not a
verification. Only a signed provenance attestation, or a build you performed
yourself from that source, links artifact to source.
- Maintainer transfer / account takeover — the package that was trustworthy
for eight years and changed hands last month. This is why "it's popular" is a
weak signal: popularity is what makes a takeover worth doing.
Signals worth checking at intake, in rough order of value: does a published
provenance attestation exist and verify; how many maintainers and how recently
did that set change; does the version being installed exist in the source repo as
a tag; how large is its own transitive closure; does it run install scripts; is
the release cadence consistent with the changelog.
Part 4 — Pin, lock, and prefer digests
A version range is a standing promise that every future release from that
publisher will be safe. Nobody can make that promise.
- Commit the lockfile. For every ecosystem, including the ones for tools
("we only use it in CI" — see above).
- Install in locked mode in CI (
npm ci, --frozen-lockfile,
pip-sync/hashes, --locked), so a build fails rather than silently resolving
something new. A CI job that can quietly acquire a different dependency than the
lockfile specifies has no lockfile.
- Pin by digest where the ecosystem supports it — container base images by
sha256, CI actions by commit SHA, package hashes in the lockfile. Tags move;
digests do not. FROM node:20 is a moving target, and the day it moves is
chosen by someone else.
- Separate the two upgrade decisions: when to take a new version (a
scheduled, deliberate act) from what the range would have allowed (nothing,
until you decide). Automation should open a proposal, never resolve at deploy
time.
- Vendoring is a fork. It removes upstream surprise and takes on upstream's
security work. Record the source commit and the reason, or your vendored copy
becomes a fossil nobody dares to touch (
software-archaeology).
Part 5 — The upgrade diff nobody reads
A patch bump can contain anything; semantic versioning describes intent, not
content. Scale the review to what the dependency can reach:
- Read the actual diff, not the changelog, for anything with install-time
execution, credential access, network egress, or crypto responsibility. The
changelog is written by the same party whose compromise you are worried about.
- Look for the shape of a compromise, not for clever code: a new install
script, a new network call, added obfuscation or minified blobs in a source
package, a sudden dependency on something that touches env vars, a release with
no corresponding source-repo commit.
- Batch the boring, isolate the interesting. Forty automated bumps merged as
one is a review of nothing. Merge low-reach updates in a batch; give
high-reach ones their own PR and their own diff read.
- Know why you are upgrading. "Fixes a CVE we are exposed to" and "a bot
opened a PR" deserve different urgency and different testing. Upgrading for its
own sake, at speed, is itself a supply-chain risk: it shortens the window
between a malicious publish and your adoption of it. A brief cooldown before
adopting brand-new versions costs almost nothing and removes the most common
attack window.
Part 6 — Vulnerability reports are candidates, not findings
A scanner result says: a package in your tree matches a version range in an
advisory. That is a match, not an exposure. Applying claim-provenance-discipline
to the whole class:
- Ingest at level CANDIDATE, with origin (
scanner, version, advisory ID).
Do not inherit CVSS into your own report as if you derived it — the base score
describes a generic deployment, not yours.
- Ask for reachability: is the vulnerable function called, on a path reachable
by untrusted input, in a configuration that enables the flaw? A vulnerable
parser you never invoke and a vulnerable parser on your public endpoint are
the same row in a scanner and completely different problems.
- Say which it is.
REACHABLE — exploitable path exists /
PRESENT, NOT REACHED — no call path found (evidence: …) /
UNKNOWN — reachability not analyzed. UNKNOWN is honest and common; it is
not the same as safe, and it is not the same as critical.
- Not-reached is not "won't fix" forever. It is a claim with a falsifier: a
future change that introduces the call path. Record the falsifier so the
decision reopens by itself.
- The unfixable case: no patched version exists. The options are remove,
replace, vendor-and-patch, or compensate (block the input at the boundary).
Choose one and record it (
decision-record-discipline) — an open ticket is not
a mitigation.
Part 7 — Inventory you can query during an incident
The test of all of this is a single question, asked at 2am about a package name
you have never heard of: do we ship it, in what versions, in which services?
If answering takes a day of grepping repos, you do not have an inventory. Produce
one per artifact, at build time, from the artifact itself — an SBOM is fine, but
its value is being queryable and current, not being a document that satisfies a
form. Include the transitive closure, versions, digests, and the build that
produced it, and keep it addressable by the artifact's own digest.
Two things worth alerting on: a dependency appearing that no manifest requested
(a resolver surprise or a hijack), and a package whose maintainer set changed
between the version you run and the version you are about to take.
Deliverable checklist
## Dependency provenance review
Inventory source: manifest | lockfile | installed tree | SHIPPED ARTIFACT ← use this
Direct: <n> Transitive: <n> Ecosystems: <...>
Intake checks (new dep): identity (typosquat/scope/confusion) · maintainers & recent changes ·
install scripts · transitive closure size · provenance attestation verified?
Execution authority: install-time / build & CI (credentialed) / runtime — stated per dep
Pinning: lockfile committed · CI installs locked · base images & CI actions by digest
Upgrade: reason (CVE / scheduled / bot) · diff read? · cooldown applied? · batch vs isolated
Advisories: <id> → REACHABLE | PRESENT-NOT-REACHED (evidence) | UNKNOWN → action + falsifier
Unfixable: remove | replace | vendor-and-patch | compensate — decision recorded where
Incident readiness: "do we ship X@Y?" answerable in <time> from <source>
How to respond when this skill is active
- Answer "which version do we run" from the shipped artifact, and say which layer you looked at.
- Before evaluating a new dependency's quality, check its identity — name confusion is cheaper for an attacker than writing malicious code.
- Name the execution authority being granted, especially install-time and CI, and treat "dev dependency" as a statement about timing, not privilege.
- Push for digests over tags and locked installs in CI; flag any pipeline that can resolve something the lockfile does not name.
- Read the diff, not the changelog, for anything with reach; batch the rest rather than pretending forty bumps were reviewed.
- Downgrade scanner output to CANDIDATE and ask for reachability before anyone writes "critical" in a report.
- When no patch exists, force a choice among remove / replace / vendor-and-patch / compensate, and record it. An open ticket is not a mitigation.
1---2name: dependency-provenance3description: Dependency Provenance4---56# Dependency Provenance78Most of what runs in production was written by people you will never meet, pulled9by a resolver you did not watch, at a moment you did not choose. That is a10reasonable trade — and it is only reasonable if you can answer three questions at11any time:12131. **What do we actually run?** Not what the manifest requests. What is in the14 artifact.152. **Where did it come from?** Which registry, which publisher, which commit, and16 what proves the link.173. **What changed, and did anyone look?**1819A dependency policy that cannot answer these is a preference, not a control.2021Composes with: `deterministic-core` (a build that isn't reproducible can't be22attested), `attack-surface-triage` (the build system is an entry point with23production credentials), `claim-provenance-discipline` (a scanner's severity is24its claim, not your finding), `versioned-schema-evolution` (upgrades change25serialized shapes), `irreversible-action-gate` (publishing is R3).2627---2829## Part 1 — Four different answers to "what version do we use"3031These diverge constantly, and people quote whichever one is at hand:3233| Layer | What it says | How it lies |34|---|---|---|35| **Manifest** (`package.json`, `pyproject.toml`) | What you *requested* — usually a range | Says `^2.1.0`; you run 2.9.4 |36| **Lockfile** | What the resolver *chose*, at resolution time | Stale, or absent for transitive tooling; may not cover the build platform |37| **Installed tree** | What is on disk in this environment | Differs between dev, CI, and prod; may include hoisted or vendored copies |38| **Shipped artifact** | What is in the container/binary/bundle that actually runs | The only one that matters, and the one nobody inspects |3940Ground your answers in the shipped artifact. When someone asks "are we affected41by CVE-X in library Y", the defensible answer comes from an inventory of the42image or bundle — not from `grep` in a manifest. Treat the manifest as a request,43the lockfile as a record of a past decision, and the artifact as fact.4445**Transitive is the majority.** You chose maybe 30 packages; you run 900. The46ones you chose got the most scrutiny and carry the least risk. Any policy that47reviews direct dependencies only is inspecting a rounding error.4849---5051## Part 2 — Adding a dependency grants execution authority5253The question is not "is this library good code". It is **"what does adding this54authorize, and where does that code run"**:5556- **Install-time scripts** (`postinstall`, `setup.py`, build hooks) execute57 arbitrary code, as your user, on developer laptops and in CI — and CI is where58 the deploy keys are. Install-time execution is the highest-privilege moment in59 a dependency's life and the least observed.60- **Build plugins, bundler plugins, CI actions, test fixtures**: same authority,61 same credentials, and usually exempt from whatever review the runtime deps get.62 A test-only dependency that runs in CI is not "low risk"; it is a63 production-credential-adjacent dependency with less scrutiny.64- **Runtime**: what the library can reach — network, filesystem, secrets in the65 process environment. Most language runtimes give a dependency exactly the same66 authority as your own code. There is no inner boundary unless you built one.6768Practical stance: disable install scripts by default where the ecosystem allows69it and allowlist the few that genuinely need them; pin CI actions and build70plugins by commit digest, not by tag; and treat "dev dependency" as a description71of *when* it runs, never of *how much it can do*.7273---7475## Part 3 — Identity before trust7677Before evaluating whether a package is trustworthy, establish that it is the78package you think it is. The attacks here are boring, cheap, and effective:7980- **Typosquat / homoglyph** — one character off, or a Unicode lookalike.81- **Scope and namespace confusion** — `@org/thing` vs `org-thing`; a name that is82 official in one ecosystem and unclaimed in another.83- **Dependency confusion** — an internal package name that also exists on the84 public registry, where the resolver prefers the higher version. The fix is85 configuration (scoped registries, explicit index priority, reserved86 namespaces), not vigilance.87- **Repo-link laundering** — a package's metadata can claim *any* repository URL.88 A GitHub link in a registry page is a claim by the publisher, not a89 verification. Only a signed provenance attestation, or a build you performed90 yourself from that source, links artifact to source.91- **Maintainer transfer / account takeover** — the package that was trustworthy92 for eight years and changed hands last month. This is why "it's popular" is a93 weak signal: popularity is what makes a takeover worth doing.9495Signals worth checking at intake, in rough order of value: does a published96provenance attestation exist and verify; how many maintainers and how recently97did that set change; does the version being installed exist in the source repo as98a tag; how large is its own transitive closure; does it run install scripts; is99the release cadence consistent with the changelog.100101---102103## Part 4 — Pin, lock, and prefer digests104105A version range is a standing promise that every future release from that106publisher will be safe. Nobody can make that promise.107108- **Commit the lockfile.** For every ecosystem, including the ones for tools109 ("we only use it in CI" — see above).110- **Install in locked mode in CI** (`npm ci`, `--frozen-lockfile`,111 `pip-sync`/hashes, `--locked`), so a build fails rather than silently resolving112 something new. A CI job that can quietly acquire a different dependency than the113 lockfile specifies has no lockfile.114- **Pin by digest** where the ecosystem supports it — container base images by115 `sha256`, CI actions by commit SHA, package hashes in the lockfile. Tags move;116 digests do not. `FROM node:20` is a moving target, and the day it moves is117 chosen by someone else.118- **Separate the two upgrade decisions**: *when* to take a new version (a119 scheduled, deliberate act) from *what* the range would have allowed (nothing,120 until you decide). Automation should open a proposal, never resolve at deploy121 time.122- **Vendoring is a fork.** It removes upstream surprise and takes on upstream's123 security work. Record the source commit and the reason, or your vendored copy124 becomes a fossil nobody dares to touch (`software-archaeology`).125126---127128## Part 5 — The upgrade diff nobody reads129130A patch bump can contain anything; semantic versioning describes intent, not131content. Scale the review to what the dependency can reach:132133- **Read the actual diff, not the changelog**, for anything with install-time134 execution, credential access, network egress, or crypto responsibility. The135 changelog is written by the same party whose compromise you are worried about.136- **Look for the shape of a compromise**, not for clever code: a new install137 script, a new network call, added obfuscation or minified blobs in a source138 package, a sudden dependency on something that touches env vars, a release with139 no corresponding source-repo commit.140- **Batch the boring, isolate the interesting.** Forty automated bumps merged as141 one is a review of nothing. Merge low-reach updates in a batch; give142 high-reach ones their own PR and their own diff read.143- **Know why you are upgrading.** "Fixes a CVE we are exposed to" and "a bot144 opened a PR" deserve different urgency and different testing. Upgrading for its145 own sake, at speed, is itself a supply-chain risk: it shortens the window146 between a malicious publish and your adoption of it. A brief cooldown before147 adopting brand-new versions costs almost nothing and removes the most common148 attack window.149150---151152## Part 6 — Vulnerability reports are candidates, not findings153154A scanner result says: a package in your tree matches a version range in an155advisory. That is a match, not an exposure. Applying `claim-provenance-discipline`156to the whole class:157158- **Ingest at level CANDIDATE**, with origin (`scanner`, version, advisory ID).159 Do not inherit CVSS into your own report as if you derived it — the base score160 describes a generic deployment, not yours.161- **Ask for reachability**: is the vulnerable function called, on a path reachable162 by untrusted input, in a configuration that enables the flaw? A vulnerable163 parser you never invoke and a vulnerable parser on your public endpoint are164 the same row in a scanner and completely different problems.165- **Say which it is.** `REACHABLE — exploitable path exists` /166 `PRESENT, NOT REACHED — no call path found (evidence: …)` /167 `UNKNOWN — reachability not analyzed`. `UNKNOWN` is honest and common; it is168 not the same as safe, and it is not the same as critical.169- **Not-reached is not "won't fix" forever.** It is a claim with a falsifier: a170 future change that introduces the call path. Record the falsifier so the171 decision reopens by itself.172- **The unfixable case**: no patched version exists. The options are remove,173 replace, vendor-and-patch, or compensate (block the input at the boundary).174 Choose one and record it (`decision-record-discipline`) — an open ticket is not175 a mitigation.176177---178179## Part 7 — Inventory you can query during an incident180181The test of all of this is a single question, asked at 2am about a package name182you have never heard of: **do we ship it, in what versions, in which services?**183184If answering takes a day of grepping repos, you do not have an inventory. Produce185one per artifact, at build time, from the artifact itself — an SBOM is fine, but186its value is being *queryable and current*, not being a document that satisfies a187form. Include the transitive closure, versions, digests, and the build that188produced it, and keep it addressable by the artifact's own digest.189190Two things worth alerting on: a dependency appearing that no manifest requested191(a resolver surprise or a hijack), and a package whose maintainer set changed192between the version you run and the version you are about to take.193194---195196## Deliverable checklist197198```markdown199## Dependency provenance review200201Inventory source: manifest | lockfile | installed tree | SHIPPED ARTIFACT ← use this202Direct: <n> Transitive: <n> Ecosystems: <...>203Intake checks (new dep): identity (typosquat/scope/confusion) · maintainers & recent changes ·204 install scripts · transitive closure size · provenance attestation verified?205Execution authority: install-time / build & CI (credentialed) / runtime — stated per dep206Pinning: lockfile committed · CI installs locked · base images & CI actions by digest207Upgrade: reason (CVE / scheduled / bot) · diff read? · cooldown applied? · batch vs isolated208Advisories: <id> → REACHABLE | PRESENT-NOT-REACHED (evidence) | UNKNOWN → action + falsifier209Unfixable: remove | replace | vendor-and-patch | compensate — decision recorded where210Incident readiness: "do we ship X@Y?" answerable in <time> from <source>211```212213---214215## How to respond when this skill is active216217- Answer "which version do we run" from the shipped artifact, and say which layer you looked at.218- Before evaluating a new dependency's quality, check its identity — name confusion is cheaper for an attacker than writing malicious code.219- Name the execution authority being granted, especially install-time and CI, and treat "dev dependency" as a statement about timing, not privilege.220- Push for digests over tags and locked installs in CI; flag any pipeline that can resolve something the lockfile does not name.221- Read the diff, not the changelog, for anything with reach; batch the rest rather than pretending forty bumps were reviewed.222- Downgrade scanner output to CANDIDATE and ask for reachability before anyone writes "critical" in a report.223- When no patch exists, force a choice among remove / replace / vendor-and-patch / compensate, and record it. An open ticket is not a mitigation.