A clean CVE dashboard says nothing about malicious packages. They live in a different database, under a different kind of ID.
Every time a poisoned npm or PyPI release makes the news, someone on a security team opens their vulnerability dashboard, sees no new criticals, and relaxes a little. I understand the instinct. I also think it's measuring the wrong thing, and it's worth being precise about why.
A CVE describes a flaw in software someone meant to ship. A malicious package is software that's working exactly as its author intended. Those get tracked differently. The OpenSSF Malicious Packages project, one of the main open datasets for this, says in its own scope section that vulnerability reports are out of scope. It covers typosquats, account takeovers, dependency and manifest confusion, and malicious prebuilt binaries pulled down at install time. Its entries are published in OSV format under their own "MAL" prefix. In the OSV schema's list of ID prefixes, MAL sits right alongside the distro and ecosystem vulnerability databases, but it's a different feed.
CISA's Known Exploited Vulnerabilities catalog is built the same way. It describes itself as the authoritative source of vulnerabilities exploited in the wild, and tells organizations to use it as an input to vulnerability management prioritization. That's a very good list, and it's a list of vulnerabilities. A package that was hijacked for six hours last Tuesday isn't one.
So "zero critical CVEs" and "none of our machines pulled the bad version" are separate claims. They need separate evidence.
Disclosure: I work on Swif, and we see this split in our own product. I'd rather say so plainly than blur it. Swif's Vulnerability Detection (the first tab on the Security page, which replaced the old Device CVE Report) does exactly what it says. It reports known CVEs, vulnerable OS versions, and the most-affected packages across Macs, Windows, and Linux, using a security agent installed per platform through a Device CVE Report Policy. It's useful, and it answers the CVE question. It doesn't answer the malicious-package question, and the docs are honest about the limits. Devices only appear after their platform's policy is configured and the agent is connected. Linux coverage is x86_64 on Ubuntu, Debian, Fedora, and Arch, so no ARM. And after you patch, the data only updates on the agent's next scan.
What actually helps when a MAL-style advisory drops is a different workflow:
(1) Treat the advisory as an indicator, not a severity score. The question isn't "how bad is this CVE." It's "did package X, versions A through B, land on any of our machines, and when?"
(2) Know where your inventory stops. Most endpoint vulnerability tooling sees installed applications and OS packages. A dependency sitting in a project's node_modules or a virtualenv under someone's home directory often isn't an "installed package" in that sense. Write down which of those you can actually see, per OS, before you need to.
(3) Look for the install-time behavior, not just the package name. Malicious packages usually do their damage during install or first run. File Integrity Monitoring (changes to critical system files and, on Windows, registry keys, with hashes and permission changes) and Threat Detection (auth failures, policy changes, mapped to MITRE ATT&CK) are the signals that can show something happened even when no CVE ever gets filed. Those live on the same Swif Security page, but they're separate tabs for a reason.
(4) Assume credential exposure first and work backwards. In the incidents that have hurt people most, the payload went after tokens, SSH keys, and cloud credentials on developer laptops. If a machine might have run the bad version, rotate what it could reach. Don't wait for forensics to prove it.
(5) Keep the two reports apart when you present them. If a leadership update folds "vulnerabilities" and "malicious packages" into one number, the clean one will hide the scary one.
None of this is a knock on CVE scanning. Patching known vulnerabilities is still the boring work that prevents most bad days. It's just a different question from "did we run something hostile," and the second one has its own feeds, its own IDs, and needs its own evidence.
Curious how others handle this. Do you ingest MAL/OSV malicious-package data anywhere near your endpoint inventory, or does it only show up in your CI dependency scanning?
Sources:
- Swif Help Center, Vulnerability Detection (Device CVE Report) overview: help.swif.ai
- Swif Help Center, Introducing the New Swif Security Page: help.swif.ai
- OpenSSF Malicious Packages repository (scope and definitions): ossf/malicious-packages
- OSV Schema (ID prefixes, including MAL): ossf.github.io
- CISA Known Exploited Vulnerabilities Catalog: cisa.gov
1 point
A maintained package can still ship malware. What a health check can and can't see.
---
title: "A maintained package can still ship malware. What a health check can and can't see."
published: true
description: "A hijacked package still looks maintained, because it is. How uzomuzo now flags malicious versions from OSV, and why it marks a release after a year of silence for 7 days."
tags: security, opensource, npm, supplychain
---
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:
1. Malicious versions: read OSV's malicious-package records and stop the versions they name.
2. 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.
!Timeline of September 8, 2025: chalk@5.6.1 and ansi-styles@6.2.2 were published maliciously within a minute. chalk had released 22 days earlier, so nothing flagged it until its MAL record about 4 hours later. ansi-styles had been silent for 1,061 days, so the release-after-silence marker would have covered it from the first minute until the record took over.
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. chalk itself 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.
2 comments
Typosquatting and Slopsquatting: Defending the Package Install Path
Typosquatting and Slopsquatting: Defending the Package Install Path
Most build failures start with a name. A developer types a package name, a resolver picks the closest match, and a lifecycle script runs with the privileges of the build agent. The attack does not need a vulnerability in your code because it executes during installation, before your code runs at all.
The technique is older than the tooling around it
Publication attacks on open registries are catalogued as supply-chain compromise, and publishing a malicious dependency is a specific sub-technique rather than a generic one. The variants that matter in practice are:
- Typosquatting — a name that is a plausible misspelling or a near neighbour of a popular package.
- Brand substitution — a legitimate-looking name that borrows a vendor prefix or a scope.
- Account takeover — a maintained package receives a malicious version because a maintainer credential was stolen.
- Slopsquatting — a name invented by a code-generation model, registered in advance by someone who guessed the model would suggest it.
The first two are cheap to run and cheap to detect. The third and fourth defeat similarity heuristics, which is why registry-side scanning alone has not closed the problem.
Why installation is the dangerous moment
Both major ecosystems execute code at install time. npm runs preinstall, install and postinstall scripts; Python's packaging machinery runs build backends and .pth initialisation. A package that only needs to run once at install time does not require persistence, an exploit, or a second stage that survives a reboot. It needs the install to succeed.
That has a direct consequence for defenders: the network egress of a build agent is part of your attack surface. If a CI runner can reach the public internet on arbitrary ports, then a compromised dependency can reach it too.
What registry and platform signals now exist
Response capability has improved. OpenSSF maintains a curated malicious-packages dataset, and GitHub announced that Dependabot malware alerts draw on it, extending coverage across ecosystems including npm and PyPI. Registry-side scanning on publish adds a second filter. Neither is a guarantee: they detect what has been reported or recognised, and the useful window between a malicious publish and a public report is measured in hours.
Advisory and registry data is therefore a signal to act on, not a substitute for controlling the install path.
Practical controls, ordered by effort
Pin and verify. Commit lockfiles and enforce them in CI. A lockfile with integrity hashes turns a name collision into a resolution failure instead of an install.
Disable install scripts where the project allows it. npm supports ignoring lifecycle scripts (--ignore-scripts) and many dependency trees do not require them. Where they are required, restrict which packages may use them.
Use a proxy registry. A caching proxy with an allowlist gives you a single place to see which names your builds resolve, and a single place to block a name after an incident. It also removes direct registry reachability from build agents.
Separate credentials by scope. A publish token that can write to every package in an organisation converts one stolen CI secret into an organisation-wide compromise. Scope tokens per package and rotate them.
Treat new dependencies as changes. A pull request that adds a dependency deserves the same review as code. Check the repository, the maintainer history, the publish date of the version being pinned, and whether the package's actual contents match its description.
Watch the egress. Alert on build agents making outbound connections to hosts that are not your registries, artefact stores or package mirrors. This catches the class of attack that name-based controls miss.
Responding when a malicious package is confirmed
Speed matters less than order. First, stop the bleeding: remove or block the version at the proxy and revoke any credential the install could have read. Second, determine blast radius from lockfiles and build logs rather than from memory — which agents installed it, on what dates, with which environment variables present. Third, rotate everything those agents could reach, and assume any secret in the build environment was taken. Fourth, look for what the package did after install, not only what it did during install.
What not to conclude
A clean scan is not evidence that a dependency tree is trusted. Registry scanning flags known-bad artefacts, and the interesting cases are the ones nobody has reported yet. Likewise, a package with a long history is not automatically safe; account takeover is precisely the scenario in which reputation is the attacker's asset.
Treat the install path as production infrastructure. It executes code you did not write, with credentials you did provide.
References
- MITRE ATT&CK T1195.002, Compromise Software Supply Chain: attack.mitre.org
- OpenSSF malicious-packages dataset: ossf/malicious-packages
- Trend Micro, Slopsquatting: When AI Agents Hallucinate Malicious Packages: trendmicro.com
- Veracode, Application Security in the Cloud (malicious packages and typosquatting): veracode.com
- GBHackers, Typosquatted npm Packages Steal Cloud and CI/CD Secrets: gbhackers.com
Mentions
A clean CVE dashboard says nothing about malicious packages. They live in a different database, under a different kind of ID. Every time a poisoned npm or PyPI release makes the news, someone on a security team opens their vulnerability dashboard, sees no new criticals, and relaxes a little. I understand the instinct. I also think it's measuring the wrong thing, and it's worth being precise about why. A CVE describes a flaw in software someone meant to ship. A malicious package is software that's working exactly as its author intended. Those get tracked differently. The OpenSSF Malicious Packages project, one of the main open datasets for this, says in its own scope section that vulnerability reports are out of scope. It covers typosquats, account takeovers, dependency and manifest confusion, and malicious prebuilt binaries pulled down at install time. Its entries are published in OSV format under their own "MAL" prefix. In the OSV schema's list of ID prefixes, MAL sits right alongside the distro and ecosystem vulnerability databases, but it's a different feed. CISA's Known Exploited Vulnerabilities catalog is built the same way. It describes itself as the authoritative source of vulnerabilities exploited in the wild, and tells organizations to use it as an input to vulnerability management prioritization. That's a very good list, and it's a list of vulnerabilities. A package that was hijacked for six hours last Tuesday isn't one. So "zero critical CVEs" and "none of our machines pulled the bad version" are separate claims. They need separate evidence. Disclosure: I work on Swif, and we see this split in our own product. I'd rather say so plainly than blur it. Swif's Vulnerability Detection (the first tab on the Security page, which replaced the old Device CVE Report) does exactly what it says. It reports known CVEs, vulnerable OS versions, and the most-affected packages across Macs, Windows, and Linux, using a security agent installed per platform through a Device CVE Report Policy. It's useful, and it answers the CVE question. It doesn't answer the malicious-package question, and the docs are honest about the limits. Devices only appear after their platform's policy is configured and the agent is connected. Linux coverage is x86_64 on Ubuntu, Debian, Fedora, and Arch, so no ARM. And after you patch, the data only updates on the agent's next scan. What actually helps when a MAL-style advisory drops is a different workflow: (1) Treat the advisory as an indicator, not a severity score. The question isn't "how bad is this CVE." It's "did package X, versions A through B, land on any of our machines, and when?" (2) Know where your inventory stops. Most endpoint vulnerability tooling sees installed applications and OS packages. A dependency sitting in a project's node_modules or a virtualenv under someone's home directory often isn't an "installed package" in that sense. Write down which of those you can actually see, per OS, before you need to. (3) Look for the install-time behavior, not just the package name. Malicious packages usually do their damage during install or first run. File Integrity Monitoring (changes to critical system files and, on Windows, registry keys, with hashes and permission changes) and Threat Detection (auth failures, policy changes, mapped to MITRE ATT&CK) are the signals that can show something happened even when no CVE ever gets filed. Those live on the same Swif Security page, but they're separate tabs for a reason. (4) Assume credential exposure first and work backwards. In the incidents that have hurt people most, the payload went after tokens, SSH keys, and cloud credentials on developer laptops. If a machine might have run the bad version, rotate what it could reach. Don't wait for forensics to prove it. (5) Keep the two reports apart when you present them. If a leadership update folds "vulnerabilities" and "malicious packages" into one number, the clean one will hide the scary one. None of this is a knock on CVE scanning. Patching known vulnerabilities is still the boring work that prevents most bad days. It's just a different question from "did we run something hostile," and the second one has its own feeds, its own IDs, and needs its own evidence. Curious how others handle this. Do you ingest MAL/OSV malicious-package data anywhere near your endpoint inventory, or does it only show up in your CI dependency scanning? Sources: - Swif Help Center, Vulnerability Detection (Device CVE Report) overview: help.swif.ai - Swif Help Center, Introducing the New Swif Security Page: help.swif.ai - OpenSSF Malicious Packages repository (scope and definitions): ossf/malicious-packages - OSV Schema (ID prefixes, including MAL): ossf.github.io - CISA Known Exploited Vulnerabilities Catalog: cisa.gov
A maintained package can still ship malware. What a health check can and can't see. --- title: "A maintained package can still ship malware. What a health check can and can't see." published: true description: "A hijacked package still looks maintained, because it is. How uzomuzo now flags malicious versions from OSV, and why it marks a release after a year of silence for 7 days." tags: security, opensource, npm, supplychain --- 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: 1. Malicious versions: read OSV's malicious-package records and stop the versions they name. 2. 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. !Timeline of September 8, 2025: chalk@5.6.1 and ansi-styles@6.2.2 were published maliciously within a minute. chalk had released 22 days earlier, so nothing flagged it until its MAL record about 4 hours later. ansi-styles had been silent for 1,061 days, so the release-after-silence marker would have covered it from the first minute until the record took over. 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. chalk itself 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.
Typosquatting and Slopsquatting: Defending the Package Install Path Typosquatting and Slopsquatting: Defending the Package Install Path Most build failures start with a name. A developer types a package name, a resolver picks the closest match, and a lifecycle script runs with the privileges of the build agent. The attack does not need a vulnerability in your code because it executes during installation, before your code runs at all. The technique is older than the tooling around it Publication attacks on open registries are catalogued as supply-chain compromise, and publishing a malicious dependency is a specific sub-technique rather than a generic one. The variants that matter in practice are: - Typosquatting — a name that is a plausible misspelling or a near neighbour of a popular package. - Brand substitution — a legitimate-looking name that borrows a vendor prefix or a scope. - Account takeover — a maintained package receives a malicious version because a maintainer credential was stolen. - Slopsquatting — a name invented by a code-generation model, registered in advance by someone who guessed the model would suggest it. The first two are cheap to run and cheap to detect. The third and fourth defeat similarity heuristics, which is why registry-side scanning alone has not closed the problem. Why installation is the dangerous moment Both major ecosystems execute code at install time. npm runs preinstall, install and postinstall scripts; Python's packaging machinery runs build backends and .pth initialisation. A package that only needs to run once at install time does not require persistence, an exploit, or a second stage that survives a reboot. It needs the install to succeed. That has a direct consequence for defenders: the network egress of a build agent is part of your attack surface. If a CI runner can reach the public internet on arbitrary ports, then a compromised dependency can reach it too. What registry and platform signals now exist Response capability has improved. OpenSSF maintains a curated malicious-packages dataset, and GitHub announced that Dependabot malware alerts draw on it, extending coverage across ecosystems including npm and PyPI. Registry-side scanning on publish adds a second filter. Neither is a guarantee: they detect what has been reported or recognised, and the useful window between a malicious publish and a public report is measured in hours. Advisory and registry data is therefore a signal to act on, not a substitute for controlling the install path. Practical controls, ordered by effort Pin and verify. Commit lockfiles and enforce them in CI. A lockfile with integrity hashes turns a name collision into a resolution failure instead of an install. Disable install scripts where the project allows it. npm supports ignoring lifecycle scripts (--ignore-scripts) and many dependency trees do not require them. Where they are required, restrict which packages may use them. Use a proxy registry. A caching proxy with an allowlist gives you a single place to see which names your builds resolve, and a single place to block a name after an incident. It also removes direct registry reachability from build agents. Separate credentials by scope. A publish token that can write to every package in an organisation converts one stolen CI secret into an organisation-wide compromise. Scope tokens per package and rotate them. Treat new dependencies as changes. A pull request that adds a dependency deserves the same review as code. Check the repository, the maintainer history, the publish date of the version being pinned, and whether the package's actual contents match its description. Watch the egress. Alert on build agents making outbound connections to hosts that are not your registries, artefact stores or package mirrors. This catches the class of attack that name-based controls miss. Responding when a malicious package is confirmed Speed matters less than order. First, stop the bleeding: remove or block the version at the proxy and revoke any credential the install could have read. Second, determine blast radius from lockfiles and build logs rather than from memory — which agents installed it, on what dates, with which environment variables present. Third, rotate everything those agents could reach, and assume any secret in the build environment was taken. Fourth, look for what the package did after install, not only what it did during install. What not to conclude A clean scan is not evidence that a dependency tree is trusted. Registry scanning flags known-bad artefacts, and the interesting cases are the ones nobody has reported yet. Likewise, a package with a long history is not automatically safe; account takeover is precisely the scenario in which reputation is the attacker's asset. Treat the install path as production infrastructure. It executes code you did not write, with credentials you did provide. References - MITRE ATT&CK T1195.002, Compromise Software Supply Chain: attack.mitre.org - OpenSSF malicious-packages dataset: ossf/malicious-packages - Trend Micro, Slopsquatting: When AI Agents Hallucinate Malicious Packages: trendmicro.com - Veracode, Application Security in the Cloud (malicious packages and typosquatting): veracode.com - GBHackers, Typosquatted npm Packages Steal Cloud and CI/CD Secrets: gbhackers.com