TX·12 · SUPPLY CHAIN / CI · LOGGED 2026·08 · 14 MIN
Supply chain security: every hash matched, the malware installed anyway
TX·11 ended by naming the gap
every drill kept finding: npm install runs strangers'
code on my machine, and the CI pipeline runs it holding
credentials. This article is the audit of that trust, and it opens
with the fact that reframes the whole problem. In the worst npm
incidents of the past year, every integrity check passed. The
registry hashed the malicious tarball faithfully, the lockfile
verified it perfectly, and the malware installed exactly as
designed. The hash proves the bytes are the bytes. Nothing about
the system ever promised the bytes were safe.
The adversary publishes packages now
The scale first, because it changed while most tutorials weren't
looking: registry-malware trackers count cumulative malicious open
source packages in the high six figures, and the past year brought
the ecosystem's first genuinely wormable attacks, self-propagating
campaigns that compromised hundreds of packages and tens of
thousands of downstream secrets within hours. Five attack classes
cover nearly all of it, each with a named incident. Typosquats,
lookalike names one keystroke off, bite at authoring time, never at
npm ci time. Maintainer account takeover is the
dominant vector: in September 2025 a phishing domain three days old
hijacked one maintainer and poisoned eighteen packages with a
combined weekly download count in the billions, reverted within
about two hours, which was still long enough to reach a measurable
fraction of the world's build systems. Install-script malware
executes during the install itself, the mechanism behind the
wormable campaigns and the March 2026 compromise of a major HTTP
library that injected a fake crypto dependency, attributed by two
separate vendors to a state actor. Dependency confusion tricks
resolvers that mix private and public registries, largely out of
scope for an all-public dependency tree until the day an internal
package appears. And protestware is the reminder that a legitimate,
uncompromised maintainer having a very bad day is also in the
threat model: the color-library sabotage of 2022 broke around
nineteen thousand dependents on purpose.
Read against this stack, the risk concentrates into two moments.
The lockfile-committed, npm ci-driven steady state is
genuinely hard to hit; the exposure is the moment a dependency gets
added or upgraded, and the runtime behavior of code that's already
trusted. Everything below defends one of those two moments.
What the lockfile actually promises
npm ci installs strictly from the lockfile, fails if
the manifest disagrees, and starts from a clean tree, and the
lockfile records an SHA-512 integrity hash for every package,
direct and transitive. That buys two real things: reproducibility,
the same tree everywhere, and tamper evidence in transit, a mirror
serving altered bytes fails the install. Now the honest limit,
which the March 2026 incident demonstrated end to end: when a
compromised maintainer publishes a malicious version, the registry
computes a perfectly valid hash for the malicious tarball, and
every downstream verification confirms that the poison is exactly
the poison the lockfile recorded. The integrity system worked
exactly as designed. It was never designed to know good from evil.
Three disciplines follow. Automated installs use
npm ci everywhere, CI, Dockerfile, deploy scripts,
never bare npm install. The lockfile regenerates only
deliberately, on a branch, scanned before merge, because
regenerating during a compromise window is precisely how the
malicious hash becomes your trusted value. And lockfile diffs get
reviewed with tooling rather than eyeballs, since no human reads
three thousand generated lines: the red flag to automate is a
massive lockfile change riding a tiny code change.
Kill the install scripts
The single most cost-effective control in this article is one flag.
npm packages can run lifecycle scripts during installation, and
that's the execution vehicle for the entire worm class, including
the November 2025 variant that moved to pre-install and quietly
pulled in an alternate runtime to evade Node-process monitoring.
ignore-scripts=true in .npmrc, or
npm ci --ignore-scripts, refuses the whole mechanism:
install writes bytes to disk and executes nothing.
npm ci --ignore-scripts
npm audit signatures # registry signatures + any provenance
npm audit --omit=dev --audit-level=high # production deps, high and critical only
Scope honesty: this stops automatic execution at install time and
nothing else. A payload that lives in the package's actual code
and runs when the app require()s it is untouched,
which is what the runtime detection section is for.
The dangerous moment is adoption
Since the steady state is defensible, attackers get their best
odds at the moment of choice, so the choice gets a checklist. Before
a package joins the tree: is it maintained, is there a real
repository and README, how old is this exact version, how many
maintainers, does it carry install scripts, does it publish with
provenance. Free tooling automates most of that: a pre-install
gate like npq interrogates a package before npm
touches it, warning on brand-new versions, dormant or single
maintainers, expired maintainer email domains (the account-takeover
tell), newly introduced binaries, typosquat similarity to popular
names, and lifecycle scripts; the OpenSSF Scorecard grades a
repository's practices; and deps.dev aggregates advisories and
health signals in one place. None of it is proof, all of it is
signal, and five minutes of signal at adoption time is the
cheapest security spend in this whole series.
For the tree you already have, transitive pins are occasionally
necessary, and npm's overrides field does it natively:
{
"overrides": {
"lodash": "4.17.21",
"some-parent": { "vulnerable-dep": "1.0.1" }
}
}
Treat an override as a patch with an expiry, removed when upstream ships the fix, not a permanent state, and remember it controls which version installs, not whether that version is safe. And the cargo cult to decline politely: zero-dependency absolutism trades supply-chain risk for write-it-yourself bug risk; vendoring everything freezes you at a point in time and makes CVE patching harder while changing nothing about trust; and pinning without a scheduled review-and-update loop is stale-pin rot, an accumulation of known vulnerabilities wearing a security costume. Pin, and run the bot, and review its pull requests instead of auto-merging them, because the protestware incidents were shipped by legitimate maintainers whose packages auto-merged their way into production.
npm audit without the fatigue
npm audit is useful and mistuned by default: it
audits dev dependencies that never ship, inflates transitive
advisories you can't reach, and surfaces unfixables until the
alarm means nothing, the exact failure mode
TX·06 spent an article
avoiding. The gate that stays meaningful is the one in the code
block above: production dependencies only, high and critical
only, and unfixable-but-accepted advisories handled as documented
exceptions with an expiry date rather than by lowering the global
threshold. The one thing never wired into CI is
npm audit fix unattended, which can bump major
versions on its own recognizance. Alongside it, two free
supplements earn their place: an OSV-based scanner as a second
advisory source with lockfile and container awareness, and a
behavioral layer, a package firewall that refuses to fetch
packages already confirmed malicious, which addresses the window
advisory databases can't, the hours between a malicious publish
and anyone's advisory. Scanners see the past; the behavioral
tools are the only ones even pointed at the present.
Provenance: a signal, not a gate
The ecosystem's integrity story genuinely improved: packages
published from supported CI can carry Sigstore-backed provenance
attestations tying the artifact to a source repo, commit, and
build workflow, recorded in a public transparency log, and
npm audit signatures verifies registry signatures
plus whatever attestations exist for your installed tree, one
cheap CI step. Two honesty notes calibrate the enthusiasm. First,
adoption: audits this year put provenance coverage among top
packages at roughly one in eight, so a hard gate requiring it
would fail nearly everything; missing provenance is a signal
about a package, not a verdict. Second, semantics, and npm's own
documentation says it flatly: provenance "does not guarantee the
package has no malicious code." It ties an artifact to a build
identity, and a compromised account produces cleanly attested
malware. Applying TX·07's
test, does anyone verify it and does it reduce a threat in my
model: verifying signatures on what I consume, yes, free;
publishing with OIDC trusted publishing if I ever ship a package,
yes, since npm killed classic tokens at the end of 2025 anyway
and short-lived credentials beat stored ones on every axis; and
chasing the highest build-hardening tiers, no, that's enterprise
ceremony at one-person scale. The plain-language version of the
SLSA ladder: level one is producing a build record, level two is
a hosted platform signing it so a compromised laptop can't forge
it, which GitHub's attestations give this pipeline for free, and
level three is isolation engineering priced for organizations.
The pipeline is an attack surface
CI is code that runs with credentials, triggered by events
partially controlled by strangers, and the two canonical
incidents define the two failure modes. In March 2025, a popular
GitHub Action was compromised by repointing its version tags at
a malicious commit, twenty-three thousand repositories ran
attacker code that dumped CI secrets into logs, and every
workflow that pinned a tag was exposed while every workflow that
pinned a full commit SHA was immune, because tags are mutable
and hashes are not. Four years earlier, one injected line in a
coverage uploader exfiltrated the entire environment of every CI
run that used it, proving that a single env dump is
the whole jackpot. The defenses are unglamorous and near-free:
every third-party action pinned to its forty-character SHA with
the tag as a comment, an explicit least-privilege
permissions: block on every workflow so omitted
scopes are none, untrusted event text (PR titles, branch names)
routed through environment variables instead of interpolated
into shell, the privileged pull_request_target
trigger avoided outright, and cloud auth minted per-job via OIDC
instead of stored as long-lived secrets. The bots keep the pins
fresh, bumping SHAs by pull request, so immutability doesn't
decay into stale-pin rot:
name: build-scan-push
on:
push:
branches: [main]
permissions:
contents: read # workflow default: nothing else
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # push the image
id-token: write # OIDC + attestations
attestations: write
steps:
- uses: actions/checkout@PINNED_SHA # v4, full 40-char pin
- uses: actions/setup-node@PINNED_SHA # v4
with: { node-version: "24", cache: "npm" }
- run: npm ci --ignore-scripts
- run: npm audit signatures
- run: npm audit --omit=dev --audit-level=high
- uses: google/osv-scanner-action@PINNED_SHA
with:
scan-args: --lockfile=package-lock.json
- run: docker build -t "registry.example/app:$GITHUB_SHA" .
- uses: aquasecurity/trivy-action@PINNED_SHA
with:
image-ref: registry.example/app:${{ github.sha }}
exit-code: "1"
severity: HIGH,CRITICAL
ignore-unfixed: true
- uses: actions/attest-build-provenance@PINNED_SHA
with:
subject-name: registry.example/app
subject-digest: ${{ steps.build.outputs.digest }}
- run: docker push "registry.example/app:$GITHUB_SHA"
What the runner can read
The blast-radius question from TX·07, asked of the pipeline: assume a step goes hostile, a compromised action or a malicious package, and inventory what it reaches. The answer is everything in that step's environment, every secret exposed to it, every file earlier steps wrote, and the workflow token at whatever scope the permissions block allows. Log masking is cosmetic against a real attacker: it edits what's displayed, does nothing about what leaves the runner, and fails against trivially encoded output, which is exactly how the 2025 incident published secrets in public logs. So the secrets that exist at all are scoped to be boring: a registry token that can push one package and nothing else, a VPS deploy key locked to a restricted user and forced command so that a stolen one can deploy and do nothing more, and OIDC everywhere a target supports it, since a credential that expires in minutes barely qualifies as loot. The remainder is bookkeeping that pays off exactly once: a one-page inventory of every pipeline credential and its one-command revocation, which slots straight into TX·11's secret-leak playbook as the rotation list.
The runtime tail
Everything above narrows the funnel; none of it catches a
payload that lives in trusted code and fires at runtime, so the
last line is the detection stack that already exists. On this
stack a compromised dependency has two loud tells. Unexpected
egress from the app network, a dependency phoning home on a
timer, is precisely the beaconing pattern TX·06's analysis leg
hunts. And any shell spawning inside the distroless app
container is anomalous by definition, TX·07's construction
guaranteeing there is no legitimate shell to spawn, so the
existing rule needs no tuning at all. Build-time gets two cheap
additions: with install scripts disabled, an npm ci
that makes any network call beyond the registry is suspicious,
and the lockfile-diff alarm from earlier feeds the same review
queue. Then the honest limits, because this section always has
one: no lockfile catches the malicious version it faithfully
recorded, no scanner catches a zero-day package with no
advisory, no egress rule catches exfiltration smuggled over an
allowed channel to an allowed host, and nothing here catches a
logic bomb that sleeps until a trigger. The most capable
supply-chain payloads look legitimate until they don't, which
is why the response article exists and why the rotation list is
a script.
The checklist
The supply chain, compressed into what I now enforce:
- Lockfile committed, SHA-512 format, and every automated install is
npm ci --ignore-scripts; native-module builds run explicitly for named, trusted packages - Lockfile regenerated only deliberately, on a branch, scanned before merge; big lockfile diffs on small code changes flagged automatically
- New dependencies vetted at adoption: age, maintainers, repo, install scripts, provenance; a pre-install gate doing the interrogation
overridesused as expiring patches for transitive pins, removed when upstream fixes- Audit gate at
--omit=dev --audit-level=high; exceptions documented with expiry;npm audit fixnever unattended - OSV scanning and a behavioral package firewall alongside the registry audit, covering the pre-advisory window
npm audit signaturesafter every CI install; missing provenance treated as a signal, never a hard gate- Every third-party action pinned to a full commit SHA with the tag as a comment; a bot maintains the pins by reviewable PR
- Explicit least-privilege
permissions:on every workflow; untrusted event text only ever via environment variables; nopull_request_target - Cloud and registry auth via OIDC where supported; remaining secrets scoped to single actions: push one package, deploy one service
- Pipeline credential inventory with one-command revocation, wired into the TX·11 secret-leak playbook
- Runtime tail covered by existing rules: egress anomalies from the app network, any shell in the distroless container, network calls during install
- Update loop running: scheduled dependency and pin bumps, reviewed, never auto-merged
The vocabulary this article runs on, provenance, attestation, SLSA levels, trusted publishing, scorecards, has quietly become the shared language of DevSecOps and platform postings, and the controls above are the practical fluency those postings are asking for. Twelve articles in, the series has hardened the stack, watched it, backed it up, rehearsed its worst day, and now audited the strangers whose code it runs. One article remains in this arc: the capstone. TX·13 is the threat model, written down formally, every control from twelve articles placed against the adversary it answers, and, just as important, the honest list of what's accepted rather than covered. The map of the whole territory, drawn last, the way maps always are.