TL;DR
- 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.
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.
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.
| Incident | Date | Replayed today | Expected at the time | Verdict |
|---|---|---|---|---|
| event-stream → flatmap-stream | Sep 2018 | new-publisher, dormant-release (779 d), 3.3.6 unpublished | obfuscated payload in flatmap-stream | caught |
| Typosquats: crossenv, babelcli, electorn, loadsh, mongose, colourama, jeIlyfish, python3-dateutil, rustdecimal | 2017 to 2022 | typosquat on 8 of 9 names (babelcli missed), offline, npm / PyPI / crates.io | install hooks | caught |
| ua-parser-js 0.7.29, 0.8.0, 1.0.0 | Oct 2021 | 3 versions unpublished | install-script-added, fresh-release | expected only |
| colors 1.4.44-liberty-2, faker 6.6.6 | Jan 2022 | dormant-release (838 d), dangling-repo | none: same maintainer, payload in code | weak |
| node-ipc 10.1.1 | Mar 2022 | nothing | obfuscated geolocation check in code | missed |
| xz-utils 5.6.0 / 5.6.1 | Mar 2024 | out of scope: ghost covers npm only | system --vulns, after disclosure | missed |
| tj-actions/changed-files | Mar 2025 | third-party action pinned to a tag, not a SHA | - | low only |
| nx (s1ngularity) | Aug 2025 | provenance-removed | fresh-release, install script | caught |
| chalk, debug, ansi-styles and 16 more | Sep 2025 | ansi-styles: dormant-release (1,061 d); chalk 5.6.1 unpublished | fresh-release on all 19 | partial |
| Shai-Hulud, patient zero @ctrl/tinycolor | Sep 2025 | dormant-release (520 d), 4.1.1 and 4.1.2 unpublished | install-script-added (node bundle.js) | caught |
| axios 1.14.1 + plain-crypto-js | Mar 2026 | provenance-removed, 1.14.1 and 0.30.4 unpublished | newborn-package, install-script, fresh-release on plain-crypto-js | caught |
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.
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.
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.
| Project | Lockfile | Packages | Ships to prod | CI findings High, 2.7.1 | CI findings High, 2.7.2 | Exploitable |
|---|---|---|---|---|---|---|
| n8n | pnpm-lock.yaml (pnpm 12) | 3,767 | 2,580 | 12 | 0 | 0 |
| Strapi | yarn.lock (Berry) | 2,894 | 1,272 | 1 | 0 | 0 |
| Zed | Cargo.lock | 1,530 | 1,530 | 6 | 2 | 0 |
| Airflow | uv.lock | 840 | 816 | - | - | - |
| Superset | requirements/base.txt | 146 | 146 | 4 | 1 | 0 |
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.
- 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
- 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
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.
- postinstall: node ./report.js
- Sends install analytics to scarf.sh, package names hashed
- On by default (defaultOptIn: true); SCARF_ANALYTICS=false turns it off
- 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.
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.
| Verdict | Package versions | Meaning |
|---|---|---|
| identical | 49 | every published file is byte-for-byte in the tagged commit |
| rebuilt | 159 | files differ, but a declared build step explains every difference |
| unverifiable | 25 | no gitHead and no matching tag, or no repository on a known host |
| ghost | 1 | code 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.
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.
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.1 | Effect | 2.7.2 |
|---|---|---|
| pnpm 11+ writes two YAML documents in pnpm-lock.yaml | n8n 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 projects | 0 false, real cases kept |
| yarn Berry dependency line took the version of the next block | Strapi “exposed to chalk/debug for 430 days”, false | never pinned |
| uv.lock and requirements/*.txt not read | Airflow and Superset unreadable | 840 and 146 packages |
| Every URL and base58-looking string reported | 57,054 findings on npx n8n, 38 fake wallet addresses | 9,400, one real address |
| Unpublished versions skipped by timeline | the malicious release invisible in the history | shown 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.
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
- 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.
- 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.
- 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.
postmortem is open source. Every command in this note runs offline except --online, timeline and ghost. github.com/mlab-sh/postmortem
- 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). ↩
- 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. ↩