Navigation
Engineering / Study

Replaying supply-chain attacks against postmortem

Eleven public npm, PyPI and crates.io incidents replayed against postmortem, then five large real projects read by hand: which signals fire before an advisory exists, why a 48-hour cooldown is the cheapest control, and the six bugs the test found in our own tool.

mlab engineering 15 min read supply-chainnpmpostmortemci

TL;DR

Incidents replayed
11
8 raise a signal, 3 do not
Earliest warning
82 days
event-stream, before npm was notified
npx n8n, full code scan
11.3 s
1,975 packages, 2.9 GB on disk
Bugs this test found
6
all fixed in 2.7.2, same evening
  • Supply-chain attacks leave metadata before they leave CVEs. A new publisher, a release after two years of silence, a dropped provenance attestation, a name one edit away from a popular one. Replaying eleven public incidents, seven of them trip at least one of these signals, and an eighth shows up in its CI configuration, in postmortem.
  • The cheapest control is a 48-hour cooldown. chalk/debug, axios and ua-parser-js were each on npm for two to four hours. A release-age gate would have kept all three out of a build.
  • Scale is where a tool gets honest. Pointed at n8n, Strapi, Zed, Airflow and Superset, version 2.7.1 failed to read three of them and produced 23 CI alerts, 20 of them false. The fixes shipped the same evening; this note runs on 2.7.2 and says where it still misses.

Where the payload actually lives

A vulnerability scanner asks one question: does a version I depend on have a published advisory? That question has an answer only after somebody has found the problem, written it up and filed it. A supply-chain attack is over long before that. The compromised chalk and debug releases of September 2025 were pulled from npm about two hours after they went up1. The advisory came after the damage window had closed.

What an attack does leave behind, while it is happening, is a trail in the places a dependency passes through on its way to production: the account that published it, the tarball on the registry, the lockfile that pins it, the hook that runs at install, the code that ships. postmortem puts a probe on each of those stages.

Figure 1
From a maintainer account to production, and what postmortem reads at each hop
Animated
A dependency travels from the maintainer account to the registry tarball, the lockfile, the install step and the running application. postmortem reads each hop: timeline and maintainer graph for the account, ghost for the tarball, tree online and hunt for the lockfile, scripts for install, scan and blast radius for the code. Account who can publish Registry tarball what you download Lockfile what you pinned Install hooks run here Production timeline tree --human ghost tree --online hunt scripts scan why --blast the payload is here, the git repo is clean and it runs here, before any test a CVE scanner only looks at the last box, and only after disclosure
The amber hops are where the attacks in this note put their code: in a release artifact that differs from the reviewed source, executed by an install hook. Packets show the path a dependency takes; the labels underneath are the postmortem command that reads each hop.

The question changes from “is this version known bad?” to “does this release look like the previous ones?”. Most of what follows is that one change, measured.

Methodology

Two experiments. The first replays public incidents: for each one we rebuild a lockfile pinned to the malicious version, as it would have looked in a victim's repository, and ask postmortem what it sees today. The second points postmortem at large, real projects and reads everything it reports, by hand, to separate findings from noise.

postmortem
2.7.2
Run date
10 October 2026
Incidents
11, 2017 to 2026
IOC feed for hunt
3,151 package versions
Projects
n8n, Strapi, Zed, Airflow, Superset
Deep install
npm i [email protected] --ignore-scripts
Network
anonymous GitHub API, TLS-inspecting proxy
Hardware
2 vCPU, 8 GB
What a replay can and cannot see. npm removes a malicious version after the fact: the tarball and the version manifest go, but the publish timestamp stays in the package document. A replay therefore sees who published before, how long the package had been quiet and whether earlier releases carried provenance. It cannot see the install script or the payload of a version that no longer exists. Where a signal would have depended on those, the table below says expected, not replayed, and we do not count it.

Each replay is two files and one command. The lockfile carries no integrity hash, which postmortem does not need for metadata checks:

# a victim's lockfile on 31 March 2026, as far as postmortem cares
mkdir axios-2026 && cd axios-2026
cat > package-lock.json <<'EOF'
{ "name": "victim", "lockfileVersion": 3, "packages": {
    "": { "dependencies": { "axios": "1.14.1", "plain-crypto-js": "4.2.1" } },
    "node_modules/axios":           { "version": "1.14.1" },
    "node_modules/plain-crypto-js": { "version": "4.2.1" } } }
EOF
postmortem tree . --online          # registry history, provenance, reputation
postmortem timeline axios           # who published what, and what disappeared

Eleven incidents, replayed

The table reads left to right as the attack did: who published, what the release looked like, and what the code did. Replayed means postmortem 2.7.2 reports it today on the rebuilt lockfile. Expected means the published analysis of the incident describes exactly what the signal checks for, but the evidence is gone from the registry.

IncidentDateReplayed todayExpected at the timeVerdict
event-stream → flatmap-streamSep 2018new-publisher, dormant-release (779 d), 3.3.6 unpublishedobfuscated payload in flatmap-streamcaught
Typosquats: crossenv, babelcli, electorn, loadsh, mongose, colourama, jeIlyfish, python3-dateutil, rustdecimal2017 to 2022typosquat on 8 of 9 names (babelcli missed), offline, npm / PyPI / crates.ioinstall hookscaught
ua-parser-js 0.7.29, 0.8.0, 1.0.0Oct 20213 versions unpublishedinstall-script-added, fresh-releaseexpected only
colors 1.4.44-liberty-2, faker 6.6.6Jan 2022dormant-release (838 d), dangling-reponone: same maintainer, payload in codeweak
node-ipc 10.1.1Mar 2022nothingobfuscated geolocation check in codemissed
xz-utils 5.6.0 / 5.6.1Mar 2024out of scope: ghost covers npm onlysystem --vulns, after disclosuremissed
tj-actions/changed-filesMar 2025third-party action pinned to a tag, not a SHA-low only
nx (s1ngularity)Aug 2025provenance-removedfresh-release, install scriptcaught
chalk, debug, ansi-styles and 16 moreSep 2025ansi-styles: dormant-release (1,061 d); chalk 5.6.1 unpublishedfresh-release on all 19partial
Shai-Hulud, patient zero @ctrl/tinycolorSep 2025dormant-release (520 d), 4.1.1 and 4.1.2 unpublishedinstall-script-added (node bundle.js)caught
axios 1.14.1 + plain-crypto-jsMar 2026provenance-removed, 1.14.1 and 0.30.4 unpublishednewborn-package, install-script, fresh-release on plain-crypto-jscaught

Two caveats keep this table honest. The provenance-removed verdicts on nx and axios are right for the real attack (both malicious releases were pushed from an account or a token, not from the trusted-publishing pipeline the previous releases used), but today postmortem also reaches that verdict because the manifest is gone. And “unpublished” is a forensic signal, not a preventive one: it tells you afterwards that a version was pulled, which is exactly what hunt needs.

event-stream: the 82-day warning

event-stream is the case every supply-chain talk opens with, and the clearest result here. The attacker asked the original maintainer for publish rights, got them, and shipped a harmless 3.3.5 to establish themselves before 3.3.6 pulled in flatmap-stream. That first harmless release is the one that gives the game away.

Figure 2
event-stream, September to November 2018
Animated
Timeline. 5 September 2018: event-stream 3.3.5 published by a new account after 779 days of silence, postmortem flags it. 9 September: 3.3.6 adds flatmap-stream. 26 November: npm is notified and removes the packages. The flagged window is 82 days. 82 days in which postmortem already shows the release as suspicious 5 Sep · 3.3.5 new publisher, 779 d quiet 9 Sep · 3.3.6 adds flatmap-stream payload release targets the Copay wallet build 26 Nov npm notified, removed
Dates from npm's incident report2. The amber band starts at the first release postmortem marks and ends when the registry acted. The payload release date is not in npm's report, so it is placed without a date.

The terminal view, as it prints today. Note the third line: the version that carried the attack is gone from the registry, and postmortem shows it anyway, from the timestamp npm keeps.

The cheapest control: wait 48 hours

The three most recent npm takeovers in the table share a shape: a trusted maintainer account publishes, the community notices within hours, the registry pulls the version. Nothing about the package looks wrong except that it is brand new. postmortem flags any release younger than 48 hours as fresh-release; we checked it live on the TypeScript nightly published eight hours before the run, and a gate set to --max-risk 10 failed the build on it.

Figure 3
Hours each malicious release stayed on npm, against a 48-hour cooldown
Exposure windows from Wiz, Datadog Security Labs and CERT-EU1. event-stream (78 days) and Shai-Hulud (a worm that kept publishing) do not fit this chart and are not stopped by a cooldown alone.
The cost of waiting. A cooldown also delays legitimate fixes by two days. For a security patch you need today, allow the exact version with an expiry ([[gate.allow]], shown in Wiring it in) rather than lowering the gate.

Five real projects

Replays prove a detector can fire. They say nothing about what it is like to live with. For that we took five well-known open-source projects across three ecosystems and read every High finding by hand.

Leads, not verdicts. Everything this section reports is a possible problem that needs a human to validate it. The patterns below are dangerous by default, which is why postmortem raises them, but each one can be a reasonable choice in its context. None of them is a known vulnerability in the projects named, and the call belongs to someone who knows the project.
ProjectLockfilePackagesShips to prodCI findings High, 2.7.1CI findings High, 2.7.2Exploitable
n8npnpm-lock.yaml (pnpm 12)3,7672,5801200
Strapiyarn.lock (Berry)2,8941,272100
ZedCargo.lock1,5301,530620
Airflowuv.lock840816---
Supersetrequirements/base.txt146146410

The three CI findings that survived the manual read are real pattern matches, and none of them is a vulnerability. All three fetch a script over the network and pipe it into a shell inside a workflow, the pattern behind the 2021 Codecov compromise. What that is worth depends on everything around it, so we read both workflows and downloaded the installers they fetch.

Zed, docs_suggestions.yml (2 findings)
  • Installs Factory's Droid CLI with curl -fsSL https://app.factory.ai/cli | sh
  • The installer pins a version (0.238.0) and checks a SHA-256, but the checksum comes from the same host: it catches a corrupted file, not a compromised vendor
  • Both jobs run only for pull requests from the repository itself, never from a fork
  • Residual risk: a compromise of Factory's distribution would run with contents: write, the checkout token and FACTORY_API_KEY, which the install step probably does not need
Superset, pre-commit.yml (1 finding)
  • Installs prek from a pinned GitHub release (v0.4.11)
  • The installer does not verify a checksum for the Linux archive
  • The job has contents: read and actions: read, no secrets, persist-credentials: false, and is marked as a CI trial
  • Worst case: whoever controls the prek release fakes a lint result
Checked by hand, looks fine. We verified both cases ourselves on 10 October 2026: neither can be triggered or exploited by an outside contributor. Zed relies on a vendor it chose knowingly, and Superset's job has nothing worth stealing. These are hardening points, not vulnerabilities.

This is also a limit of postmortem. It rates both findings High, at the same level, although the permissions and secrets that decide the impact sit in the same workflow file. Weighting CI severity by token permissions, secrets in scope and fork exposure is in the 2.7.2 bug report as a proposal for the next release.

What installing n8n actually runs

To scan dependency code, not just metadata, we installed n8n the way its users do, from npm, with install scripts disabled: 1,975 distinct packages, 2.9 GB on disk. postmortem read all of it in 11.3 seconds. Fifteen of those packages declare an install-time hook. Most build native addons (sqlite3, ssh2, isolated-vm, oracledb). Two deserve a closer look.

@scarf/scarf, via swagger-ui-dist
  • postinstall: node ./report.js
  • Sends install analytics to scarf.sh, package names hashed
  • On by default (defaultOptIn: true); SCARF_ANALYTICS=false turns it off
agent-browser, via @n8n/mcp-browser
  • postinstall: node scripts/postinstall.js
  • Downloads a native binary from GitHub Releases and marks it executable
  • No checksum or signature check in the script

Neither is malicious, and both are documented choices by their authors. We list them as possible problems for a human to validate: an install hook that reports home, or one that fetches a binary without checking it, is dangerous by default, and acceptable here because the behaviour is documented and the download comes from the project's own release page. What makes them worth a look is that neither is visible from n8n's own package.json, and the second means that installing n8n fetches and runs a binary that never went through the npm registry at all. That binary is exactly the kind of artifact a takeover would swap.

If one of them fell

The incidents above are not hypothetical for n8n: every package touched by the September 2025 chalk/debug takeover and by the March 2026 axios takeover is in its production tree today, at clean versions. why --blast measures how much of the tree a compromise of each would reach.

Figure 4
Packages in npx n8n that depend on a once-compromised package
Transitive dependents, production scope only. debug alone reaches 8.6% of the tree; all six ship and run in the n8n process.

Is the tarball the source?

ghost downloads each published tarball, checks out the commit it claims to come from and compares the two. It is the check that would have caught flatmap-stream, whose payload never existed in any repository. We ran it on the 100 most depended-on packages in npx n8n plus the 15 with install scripts. The tree often holds several versions of the same package and ghost compares each tarball on its own, so the table below counts 234 package versions, not 115 packages.

VerdictPackage versionsMeaning
identical49every published file is byte-for-byte in the tagged commit
rebuilt159files differ, but a declared build step explains every difference
unverifiable25no gitHead and no matching tag, or no repository on a known host
ghost1code in the tarball that the source does not explain

The one ghost is oracledb 6.10.0, pulled in twice by n8n's LangChain nodes. Its npm tarball ships five prebuilt native addons, one per platform, about 3.1 MB of machine code with no counterpart in oracle/node-oracledb at v6.10.0, and an install hook that selects one. We flag it as a possible problem that needs human validation, and here the validation is quick: this is Oracle's documented way of distributing the driver. Prebuilt machine code is dangerous by default, since nobody can review it from source, and acceptable when it comes from the vendor through its usual release process, as it does here. It is also precisely the shape of the flatmap-stream and xz payloads: an artifact nobody can review from source, delivered by a trusted name.

The unverifiable list is the quieter finding. 18 of the 25 are n8n's own packages (6) or the Azure (8) and AWS (4) SDKs, published from monorepos without recording the commit they were built from; five more are DefinitelyTyped typings. Nothing is wrong with them; there is simply no way, from the outside, to tie the code you install to a commit you could read.

Were they exposed?

The last question in an incident is not whether a package is bad but whether you ever had it. hunt replays every lockfile in a repository's git history against a list of known-bad versions. We fed it 3,151 versions from public IOC lists (Shai-Hulud 2.0, keyv, chalk/debug, axios, nx, tinycolor, event-stream, ua-parser-js, node-ipc) and replayed fourteen months of history.

n8n history
10,757 commits, 731 touch a lockfile
Strapi history
1,735 commits, 404 touch a lockfile
Window
August 2025 to October 2026
Replay time
13 min (n8n), 2.8 s (Strapi)
Exposed
0 of 2 projects

Neither project ever pinned a compromised version, although the chalk/debug, Shai-Hulud and axios campaigns all hit packages that sit in n8n's production tree today. That is what exact lockfiles and frozen installs buy: a malicious release has to be resolved into a lockfile to reach a build, and both projects resolve rarely enough that two-hour windows passed them by.

A clean hunt is only as good as the lockfiles it could read. On 2.7.1 the same Strapi replay reported a 430-day exposure to the chalk/debug takeover that never happened, and every n8n revision written by pnpm 12 would have been read as empty without a warning. 2.7.2 fixes both parsers; it still does not list unparseable revisions in unreadable, which is the next fix.

Where it failed, and what we fixed

The first run of this note was on postmortem 2.7.1, and the large projects broke it in ways the fixtures never had. We publish the list because a tool that claims “0 findings is never mistaken for clean” has to show what happens when it is wrong.

Problem on 2.7.1Effect2.7.2
pnpm 11+ writes two YAML documents in pnpm-lock.yamln8n read as 0 packages (diagnostic raised, not silent)3,767 packages
Untrusted ${{ … }} matched anywhere in a workflow with a run:20 of 20 High alerts false across four projects0 false, real cases kept
yarn Berry dependency line took the version of the next blockStrapi “exposed to chalk/debug for 430 days”, falsenever pinned
uv.lock and requirements/*.txt not readAirflow and Superset unreadable840 and 146 packages
Every URL and base58-looking string reported57,054 findings on npx n8n, 38 fake wallet addresses9,400, one real address
Unpublished versions skipped by timelinethe malicious release invisible in the historyshown as unpublished

The CI false positives are worth one picture, because the mistake is instructive. Writing an untrusted value into env: and reading it back as a quoted shell variable is the textbook fix for expression injection. 2.7.1 flagged the fix.

Figure 5
Same pull request title, two paths into a shell
Animated
Top path: the pull request title is pasted into the run script by the template engine before the shell starts, so a title containing shell syntax executes. Bottom path: the title goes into an environment variable and the script reads quoted "$TITLE", so it stays data. PR title attacker-controlled run: echo "${{ title }}" pasted before the shell starts env: T + run: echo "$T" value stays a string shell runs the title shell prints the title 2.7.1 flagged both. 2.7.2 flags only the top one.
The amber path is the vulnerability. The grey path is the fix GitHub recommends, and the one n8n, Strapi, Zed and Superset already use everywhere 2.7.1 raised an alert.
Figure 6
Findings on npx n8n by severity, before and after the fix
Same 1,975 packages, same 11 to 12 seconds. The drop is almost entirely embedded URLs, now reported once per package and host.
Still noisy: 381 Critical and High findings on a clean install. The remaining Critical ones are obfuscation scores on minified vendor bundles (pdfjs-dist, playwright-core, alasql, xlsx), and most High ones are SDKs doing what SDKs do: reading cloud metadata endpoints (169.254.169.254) or registry tokens. Nobody reviews 381 alerts. For a real app, run scan in diff mode against a baseline and let the metadata signals gate the build.
Not covered. node-ipc put its payload in ordinary code from its own maintainer: no metadata signal exists for that. xz-utils hid its backdoor in a release tarball of a C project, which ghost (npm only) does not compare.

Wiring it in

The incidents above argue for three checks in CI, in this order of value: metadata on every lockfile change, a release-age cooldown, and a known-bad hunt the day an attack is announced. The gate below fails a pull request that introduces a High-risk dependency, a High CVE, or anything younger than 48 hours.

[gate]
max_high     = 0        # typosquat, install-script-added, provenance-removed…
max_risk     = 10       # fresh-release (< 48 h) scores 15: this is the cooldown
fail_on_vuln = "high"

[[gate.allow]]
package = "[email protected]"   # the patched release you need today
reason  = "CVE fix, reviewed by hand"
expires = "2026-10-17"
# an attack is announced: were we ever exposed, where, and since when?
postmortem hunt --feed compromised.txt --in ~/code --json -o hunt.json

# a pull request bumps the lockfile: what does it pull in?
postmortem diff https://github.com/org/repo/pull/123 --online

# before merging a new dependency: is the tarball the source?
postmortem ghost . --package new-dep

postmortem ci github prints a ready-to-commit workflow with the same gate, uploading SARIF to code scanning.

Takeaways

  1. Watch releases, not just versions. Seven of eleven incidents changed something observable about how a package is published before anyone filed an advisory: who published, after how long, with or without provenance, under which name. An eighth, tj-actions, showed up as a workflow pinned to a tag rather than a commit.
  2. Make new releases wait. The three fastest takeovers in the set lived two to four hours. A 48-hour cooldown is the single control that would have stopped all three, and it costs nothing but patience.
  3. Test on the biggest thing you can find. Fixtures passed on 2.7.1. Five real projects found six bugs in an afternoon, including two that made whole repositories invisible. The next runs of this note will add more ecosystems and the open misses listed above.
Reproduce this note
postmortem, lockfiles and raw outputs

postmortem is open source. Every command in this note runs offline except --online, timeline and ghost. github.com/mlab-sh/postmortem

replays/*/package-lock.json11 incidents
ioc-feed.txt3,151 versions
results/*.jsonpostmortem 2.7.2
  1. Exposure windows: chalk/debug about two hours on 8 September 2025 (Wiz Research); axios 1.14.1 from 00:21 to about 03:40 UTC on 31 March 2026 (Datadog Security Labs); ua-parser-js about four hours on 22 October 2021 (CERT-EU advisory 2021-057). ↩
  2. npm, “Details about the event-stream incident”, 27 November 2018: 3.3.6 added flatmap-stream on 9 September; npm was notified on 26 November. The 5 September date of 3.3.5 and its publisher come from the npm registry document for event-stream. ↩
Written by
mlab engineering

Questions, corrections or a counter-benchmark? We read every message and update notes when we get something wrong.