On September 8, 2025, the maintainer of chalk and debug was phished, and malicious versions of those and other widely used npm packages were published from that account. The maintainer disclosed it within hours, and clean versions were out the same afternoon. chalk had shipped 5.6.0 three weeks earlier. By any maintenance measure, it was a maintained package.
uzomuzo, the dependency health checker I maintain, judges dependencies by exactly that kind of measure. Its job is to find dependencies with maintenance or lifecycle concerns. Until v0.6.0, uzomuzo scan pkg:npm/chalk@5.6.1 returned ✅ ok … Active. A hijacked release of a maintained package is the case that kind of check can't see.
This post covers the two things we added to narrow that gap, the numbers behind each decision, and what still gets through:
- Malicious versions: read OSV's malicious-package records and stop the versions they name.
- Before a record exists: for 7 days, mark a release that comes after a year of silence.
1. Stopping versions that OSV says are malicious
The OpenSSF malicious-packages project publishes records with IDs starting with MAL-, served through OSV. For a hijack, a record usually names the specific versions that were malicious; for a package that was malicious from the start, it covers every version.
Here's what uzomuzo v0.10.0 shows (output excerpt, October 26, 2026):
$ uzomuzo scan pkg:npm/chalk@5.6.1 pkg:npm/chalk@5.6.2 pkg:cargo/rustdecimal@1.23.1
STATUS PURL LIFECYCLE BUILD
🔴 replace pkg:npm/chalk@5.6.1 Active ☠ MAL-2025-46969 —
✅ ok pkg:npm/chalk@5.6.2 Active Moderate 4.4
🔴 replace pkg:cargo/rustdecimal@1.23.1 Review Needed ☠ MAL-2022-1 —
Three design choices are visible here.
The lifecycle label doesn't change. chalk is still Active, because it is. Whether a project is maintained and whether one of its versions is malicious are separate facts, so the malicious record sits next to the label (☠ MAL-…) instead of replacing it. Other tools that read the label keep working.
The match is by version. Chalk's record names 5.6.1, so 5.6.2, the clean release that followed, isn't affected by it. A flag on the whole package would push people away from the fix. (rustdecimal's record covers every version, because that package was malicious from the start.)
Any CI gate catches it. If --fail-on names anything at all, a matched malicious version fails the build. A pipeline that only ever wrote --fail-on eol-confirmed will stop on chalk@5.6.1 too:
$ uzomuzo scan pkg:npm/chalk@5.6.1 --fail-on eol-confirmed > /dev/null; echo $?
1
Two conditions apply. Without --fail-on, findings alone don't make uzomuzo exit with an error (command errors still do). And if the OSV lookup fails (no network, for example), uzomuzo warns that the check is incomplete; it doesn't fail the build on that alone.
No waiting period
uzomuzo already waits 14 days before acting on a RustSec "unmaintained" advisory, because those advisories are sometimes withdrawn and filed again. For malicious-package records there is no waiting period. Chalk's record was published about four hours after the malicious version (13:13 → 17:11 UTC, the same day). Waiting a few days before trusting such a record would let the version through during exactly the days it matters.
That has a cost, and we accepted it. In June 2024, MAL-2024-2929 named react@1.0.0 and react@35.0.0 as malicious. Those versions don't exist on npm, and the record was withdrawn about six days later as "False positive caused by problematic ingestion". Nobody's build could have broken on that one. But if a record like it named a version people actually use, our rule would fail every gate on that version until the record was withdrawn. The flag clears on the next run after a withdrawal; it doesn't undo a build that already failed.
What it misses
uzomuzo only acts on records that say, in a field a machine can read, that the code is malicious: a MAL- ID, or a GitHub advisory tagged CWE-506 ("embedded malicious code"). ctx 0.2.6 on PyPI was hijacked in 2022. Its advisories list the affected versions and describe the takeover in prose, but none carries that classification. We considered reading the prose and decided against it: in a CI gate, misreading free text is expensive.
2. Before a record exists
A record needs time to appear. We looked at npm records in the OpenSSF data (as of October 8, 2026) that name only some versions of a package, kept packages that had existed for at least a year with at least three normal releases before the malicious one, and treated those as takeovers. That left 1,556 packages.
For the 1,515 of them with a usable timestamp, the median time from the first malicious publish to the earliest timestamp in its record was about 6 hours. 89% were within a day, and 98% within 7 days. That timestamp approximates when the malware was found, not when OSV started serving the record.
Those figures are flattered by a few huge events. Most of the 1,556 packages belong to bulk account compromises, where one stolen account published to every package it owned and records came quickly (1,418 packages, 99% within 7 days). Outside those waves the tail is longer: of 97 packages, 86% had a record within 7 days, and 8% took more than 30 days.
So for hours, sometimes for weeks, a check that only reads records says nothing. Can anything be said in that window?
What these packages looked like before the attack
In 737 of the 1,556 packages (47%), the malicious version was the first release in more than a year.
That number needs three qualifications.
- It's mostly a few events. 724 of the 737 come from 13 bulk account compromises — the Shai-Hulud waves, the September 2025 chalk/debug phishing, and others. Outside those waves, only 13 of 103 packages had been quiet for a year.
- It doesn't show that attackers prefer quiet packages. On the dates of those waves, between about a quarter and a half (27–53%) of the same packages that hadn't been hit yet had also been quiet for a year. That fits bulk attacks publishing from accounts that already held many quiet packages; it says nothing about which packages attackers prefer across npm.
- It compares different units. 47% is per hijacked package. Among the normal releases of the same packages, about 0.5% came after a year of silence (counting every release), or 6.8% if you look only at the last normal release before the attack.
What the numbers support is narrow: a release after a year of silence is uncommon among normal releases and common among these malicious ones. That makes it a reason to look at a release, not evidence of compromise.
The marker
So for npm packages, uzomuzo now marks a stable release that comes after at least a year without releases. Here's a benign release that triggered it. This is an excerpt from a v0.8.0 scan captured on October 15, 2026; the marker expired on October 18, so running the command today won't show it.
$ uzomuzo scan pkg:npm/dateformat@5.0.4
STATUS PURL LIFECYCLE BUILD
✅ ok pkg:npm/dateformat@5.0.4 Active ⚠ 1695d silence Moderate 5.0
The detailed view explains it:
⚠ Release after silence: This version came out 2026-10-11,
after 1695 days with no release.
Takeovers of npm packages that
had gone quiet looked like this,
and so does a maintainer who
ships again after a break. No OSV
malicious-package record names it
yet. Before using it, check who
published it (npm view
<package>@<version> _npmUser).
This marker clears on 2026-10-18.
The verdict stays ok. We did what the message says: npm view dateformat@5.0.4 _npmUser shows the same account that published 5.0.3 four years earlier, and 5.0.5 followed on October 24. That's what a project coming back after a break looks like.
The publisher check is a starting point, not proof. In a takeover the account name stays the same, and a legitimate handover changes it. Look at the release itself and at what the maintainers have said, too.
The marker covers the first release after the silence, plus anything published within a day of it, since a follow-up the next day comes from the same silence. dateformat@5.0.3, the release before the silence, has no marker.
Why 7 days
The marker is a bridge until a record can take over. Once an OSV record names the version, the first mechanism stops it, whatever its age.

Two packages from the same attack, with today's rules applied to the published timestamps. The marker only helps the one that had been quiet. So the question is: for takeovers that started with a release after a year of silence, how long did the record take?
The percentages above count packages, so a bulk wave of hundreds of packages counts hundreds of times. For choosing a marker period the right unit is the incident, so we counted again: incidents whose first malicious version came after a year of silence, a bulk wave counted once, timed from the first malicious publish to when the record entered the OpenSSF data. 27 incidents qualified: 20 that touched one release line (such as 5.x), and 7 that touched two or more at once. An incident qualified if at least one of its packages matched, and the earliest publish and the earliest record can belong to different packages. So these counts describe timing per incident, not a record for every affected version.
| Marker kept for | One-line incidents with a record by then (of 20) | Multi-line incidents with a record by then (of 7) |
|---|---|---|
| 1 day | 11 | 6 |
| 3 days | 13 | 7 |
| 7 days | 16 | 7 |
| 14 days | 17 | 7 |
| 365 days | 19 | 7 |
One one-line incident has no import time; it stays in the count but never counts as recorded. The other three that took longer than 7 days took about 12.6, 57 and 310 days.
Going from 7 to 14 days would have added one incident. A year would have added three, and kept every ordinary return from a break flagged for a year. So 7 days is a policy whose window held a record for 16 of the 20 one-line incidents and all 7 multi-line ones we've seen so far. It isn't proof that 7 days will be enough next time, and when the marker expires, that says nothing about whether the release is safe.
This incident count is a separate selection from the package counts above. It was made outside the uzomuzo repository: the design record publishes the results and limitations, but not the full selection.
Why it doesn't fail CI by default
On the lockfiles of VS Code and the npm CLI, 186 of about 6,100 and 146 of about 2,600 stable releases in the year to October 2026 would have carried the marker. A team that adopts and scans every release within its first week could have run into all of them. None of them is known to be malicious.
So by default the marker only shows up in the output. To fail CI on it:
uzomuzo scan --sbom bom.json --show-transitive --fail-on dormant-release
One exception: when a quiet package returns with releases on two or more lines at once (as node-ipc did in May 2026, with three malicious versions in 55 seconds), an Active or Legacy-Safe label also becomes Review Needed for those 7 days, so an existing --fail-on review-needed gate fails too.
What it can't catch
-
Attacks on recently active packages.
chalkitself had released three weeks earlier, so there was no silence to mark. A release cooldown can delay adopting such a version, and a matching record identifies it once one exists; neither guarantees it's caught in time. (Several other packages published from the same account that day had been quiet for years, and would have carried the marker.) - Patient attackers. An attacker who ends the silence with a harmless release, then publishes the malicious one more than a day later, walks past the marker.
- Takeovers that never get a record. Some older incidents have only a GitHub advisory tagged as malicious code, which uzomuzo also reads. Some have nothing.
Turn on a release cooldown too
The marker can't help with the attack this post opened with. What would have helped is simply not taking a brand-new release. chalk@5.6.1 was gone from npm within about 2 hours 40 minutes, so a team that waited even one day before adopting new releases would never have installed it.
So, separately from uzomuzo, we recommend a release cooldown in whatever updates your dependencies. It doesn't detect anything; it just gives the ecosystem time to notice.
Dependabot (.github/dependabot.yml):
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 3
Renovate (renovate.json):
{
"minimumReleaseAge": "3 days"
}
pnpm (pnpm-workspace.yaml, in minutes; pnpm 11 waits one day by default):
minimumReleaseAge: 4320
Two caveats. Dependabot's cooldown applies to version updates, not security updates, so a fix for a known vulnerability still arrives right away; that's usually what you want. And a cooldown is a delay, not a guarantee: event-stream's malicious version went unnoticed for about 72 days.
The two work together. A cooldown holds back every new release for a few days. The marker picks out the small share of releases that end a long silence and asks you to look at them. The MAL record stops the version once someone has identified it.
In v0.10.0 we also added a quieter notice for any selected version that is 7 days old or less, in any ecosystem, when uzomuzo can find its publish time. It's always in the JSON output, and appears in the detailed view unless the release-after-silence marker or a malicious record already covers the version. It doesn't change the verdict or fail CI. It tells you that you're early, before records have had time to arrive.
Try it
go install github.com/future-architect/uzomuzo-oss/cmd/uzomuzo@v0.10.0 # or download a release binary
export GITHUB_TOKEN=... # optional; adds commit history and repository signals
# One package
uzomuzo scan pkg:npm/chalk@5.6.1
# A whole project, via a CycloneDX SBOM
trivy fs . --format cyclonedx | uzomuzo scan --sbom - --show-transitive --fail-on malicious
Include --show-transitive. Without it, uzomuzo skips the dependencies your SBOM marks as transitive, and chalk and debug usually arrive as transitive ones. What gets checked also depends on what your SBOM contains.
The malicious-version check needs v0.6.0 or later, and the release-after-silence marker v0.7.0 or later. The design records behind this post are ADR-0027 and ADR-0028.
Earlier posts on uzomuzo: "Your dependencies are 48% unmaintained — and SCA tools can't see it" and "Your CI runs other people's code. Is anyone still maintaining it?"
Kota Kanbe — Creator of vuls, an open-source vulnerability scanner, and maintainer of uzomuzo.
Top comments (2)
把“维护状态”和“某个版本是否恶意”分开处理这个设计很关键:只拦 5.6.1、不给整个 chalk 打标签,才不会逼大家一刀切降级。最扎心的是文末那句——chalk 三周前刚发过版,“沉寂后复出”标记根本拦不住它,最后还是得靠发布冷却期,哪怕只等一天,5.6.1 那 2 小时 40 分钟的窗口就错过去了。
Thanks! That split was the main design decision: a malicious release doesn't make the project unmaintained, and flagging the whole package would push people away from the fix. And yes, for chalk itself the cooldown is what would have helped, not the marker.