MRPH·LAB

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.

the install and gate steps, everywhere automated
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:

package.json, forcing a transitive version
{
  "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:

build-scan-push.yml, hardened
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:

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.