The Mastra npm compromise, read as a compliance post-mortem
Sapphire Sleet needed nineteen minutes to poison 140+ @mastra packages, and the execution path the payload used is a control almost nobody writes down: here is the incident mapped to the supply-chain plans NIST finalized on June 30, and why an SBOM would not have caught it.
++++
field notesNº15
fig — The Mastra npm compromise, read as a compliance post-mortem
Sapphire Sleet needed nineteen minutes to poison 140+ @mastra packages, and the execution path the payload used is a control almost nobody writes down: here is the incident mapped to the supply-chain plans NIST finalized on June 30, and why an SBOM would not have caught it.
Nineteen minutes to 140 packages
On June 16 at 07:05 UTC, a package called [email protected] was published to npm. It was clean. That was the point: it was bait, a plausible utility sitting quietly in the registry. Eighteen hours later, at 01:01 UTC on June 17, version 1.11.22 shipped with a postinstall hook that executed a dropper called setup.cjs. At 01:20 UTC, [email protected] and 140+ other @mastra/* packages were published with easy-day-js as a dependency.
shell
2026-06-16 07:05 UTC [email protected] published (clean bait, no payload)2026-06-17 01:01 UTC [email protected] published (adds postinstall → setup.cjs)2026-06-17 01:20 UTC [email protected] + 140+ @mastra/* packages published with easy-day-js as a dependency
Nineteen minutes from weaponized publish to a fully poisoned scope. The timeline comes from Microsoft's post-mortem, which attributes the campaign with high confidence to Sapphire Sleet, "a North Korean state actor that has been active since at least March 2020" that "focuses primarily on the finance sector, including cryptocurrency, venture capital, and blockchain organizations."
The access vector was one account. Microsoft again: "The threat actor gained control of the ehindero npm account, a listed maintainer with publish rights across the entire @mastra scope." Every prior mastra release through v1.13.0 went out through GitHub Actions OIDC, the project's normal CI/CD pipeline. Version 1.13.1 was published manually, from a Tutamail address. That anomaly is how the npm supply chain attack was spotted.
What actually ran: setup.cjs sets NODE_TLS_REJECT_UNAUTHORIZED to '0', disabling certificate validation for every HTTPS request in the Node.js process. It issues a GET to hxxps://23.254.164[.]92:8000/update/49890878, pulls a second-stage Node.js implant, and runs it as a detached hidden process. Persistence is per-OS: an HKCU Run key on Windows, a RunAtLoad LaunchAgent on macOS, a systemd user unit on Linux. It matches installed browser extensions against a hardcoded list of 166 wallet extension IDs, copies browser History SQLite databases, and retrieves a PowerShell backdoor.
The sentence that carries the rest of this post:
"Because the payload executes during installation, any developer workstation or continuous integration and continuous delivery (CI/CD) pipeline that ran npm install or npm update after the compromised versions were published was potentially exposed, regardless of whether the package was imported in application code."
Exposure was defined by who ran npm install, not by what shipped to production. The payload never needed your application to import anything.
The SBOM stayed accurate and useless
Here is the uncomfortable part. Through the whole incident, the SBOM of an affected project stayed correct.
[email protected] was a validly published package with a valid name and a valid version. The moment it entered a dependency tree, any competent SBOM generator would record it, accurately:
That fragment is illustrative (the package name and version are from the Microsoft post; the JSON is a standard CycloneDX component entry). Every field in it is true. And no field in CycloneDX or SPDX records what a package's lifecycle scripts execute at install time. An SBOM is an inventory format. It answers "what is in the tree," not "what ran on the runner when the tree was materialized."
The timing kills the fallback argument too. Suppose a team reviewed SBOM diffs before promoting a build. The window between weaponized publish and scope-wide poisoning was nineteen minutes, and the payload fired at install, before any batch review could sit down with a diff. The inventory was accurate. The inventory was also beside the point.
This matters because inventory is where the money is going. ENISA's SBOM adoption report, published June 9, 2026, finds that "the CRA acts as an accelerator for SBOM adoption as organisations broadly invest in SBOM generation and automation." 78% of respondents have started adopting SBOMs; 44% are in pilot or limited adoption. On usage, ENISA reports a gap between generating SBOMs and using them: 44% report a "moderate gap," 23% a "significant gap," and "only 7 % have closed the gap entirely, which indicates that SBOMs are not actively used to their full extent for security but instead mainly for compliance purposes."
None of that is an argument against SBOMs. It is a scope statement. CRA-driven investment is flowing into the component-inventory layer, and this incident happened one layer down, on an execution path that no inventory format describes.
The control nobody writes down
Thirteen days after the compromise, on June 30, NIST finalized SP 800-18 Rev.2, "Developing Security, Privacy, and Cybersecurity Supply Chain Risk Management Plans for Systems." It supersedes Rev.1 from February 2006. Twenty years between revisions, and the headline change is that the cybersecurity supply chain risk management plan is now a first-class system plan, alongside the security plan and the privacy plan.
The abstract defines what all three plans are for:
"They describe the purpose of the system, the operational status of the controls selected and allocated for meeting risk management requirements, and the responsibilities and expected behavior of all individuals who manage, support, and access the system."
Section 2.3 describes the C-SCRM plan's content: it "describes the system's approach to managing supply chain risks that are associated with the research, development, design, manufacturing, acquisition, delivery, integration, operations, maintenance, and disposal of its components or services" and "includes supplier and component inventories." (For the controls themselves, §2.3 points to SP 800-161 Appendix A's control overlay; that reference lives inside 800-18 Rev.2 itself.)
Now map the incident onto that structure. The poisoned package lands in one plan element: §3.2, System Component Inventory, which names the SBOM as the source of the software fields, "as indicated in the Software Bill of Materials (SBOM)." Accurate again, and again not where the incident happened.
The install-time execution path belongs somewhere else entirely: in Control Implementation Details ("Provide implementation details for all controls that are allocated to the system") with a value in Control Implementation Status. Rev.2's status vocabulary is blunt:
"Planned: Control is selected but implementation has not started. • Partially Implemented: Planned control implementation has started but has not been completed... • Fully Implemented: The 'as-implemented' state of the control has been fully realized in the operational system."
Notice what is missing from that list: there is no status for a control nobody selected. A control that was never selected has no row at all. That is the quiet value of the exercise Rev.2 describes. Writing down the operational status of your selected controls is how a team notices that "package lifecycle scripts execute arbitrary code at install time" was never selected in the first place.
And it is not an obscure path. Anyone who has watched node-gyp compile something during npm install knows lifecycle scripts run. The path is well known. The written-down control is rare. That mismatch is the whole story of this incident.
What writing it down looks like
Microsoft's own mitigation list contains the control, one bullet among nine: "Run npm install with --ignore-scripts to prevent automatic execution of postinstall hooks during dependency installation."
Written down, it is boring:
shell
# .npmrc — repo root and CI base imageignore-scripts=true
Or per invocation in CI:
- run: npm ci --ignore-scripts
The honest cost: some packages genuinely need their install scripts, typically to compile native code or fetch a platform binary. With scripts disabled they install but never finish setting up, and the failure moves from install time to runtime. Teams that run this control in practice keep a short allow-list and execute the needed steps explicitly, for example npm rebuild <pkg> for the handful of packages that earn it. That is observed practice, not a compliance requirement from anyone.
But look at what that trade-off sentence is. "Scripts are disabled globally; these N packages are rebuilt explicitly; these break if you forget" is precisely an operational-status statement, the thing a C-SCRM plan's Control Implementation Details and Control Implementation Status sections exist to hold. The control is one line of config. The plan entry is what makes the exception list reviewable instead of tribal.
Version pinning belongs in the same entry. Microsoft's guidance names the known-good baselines: mastra 1.13.0 and earlier unaffected, @mastra/core 1.42.0 and earlier unaffected. A pin is only a control if someone wrote down why that version and who revisits it.
A supply-chain plan, whatever template it follows, comes down to a list of execution paths you have consciously closed or consciously accepted. "npm install runs arbitrary code" belongs on that list, and the last window was nineteen minutes.
devguard keeps the controls you have actually documented mapped against the frameworks that expect them: devguard.ch.
// authored by
AD
Angelo Dini
Co-founder · devguard
Angelo runs strategy and the relationships behind devguard. He spends his time with the teams who live with compliance day to day, and keeps what we build tied to what they actually need.