656 Findings, Zero Secrets
On 16 September 2025, a piece of malware called Shai-Hulud began spreading through npm. It did not need a zero-day. It needed a maintainer account. Within days it had infected more than 500 packages — including one downloaded more than two million times a week — by publishing new, infected releases to every package the stolen accounts could still write to. Installing a dependency was enough: the payload ran at install time, harvested credentials, and used them to infect the next package. [1][2]That is the shape of the problem in 2026. Not a hole in your code. A hole in your supply chain — in the list of things you did not write and cannot read, but execute anyway.

This piece is about what actually changed. Machine-assisted discovery is now producing findings at a rate the institutions cannot classify (the causation is argued, not proven — more on that below). The raw knowledge has become downloadable infrastructure. And the scarce resource — the one that decides whether any of it matters — is still a human making a judgement call with incomplete information.
Your Dependency List Is an Attack Surface
A modern Python or Node project is mostly other people’s code. A mid-sized application pulls in hundreds of transitive packages, each maintained by someone you will never meet, each able to run arbitrary code the moment it is installed. The 2025 npm worm exploited exactly this: not a bug in a library, but the trust model of the registry itself. Once an account was taken over, the worm republished the victim’s packages, and downstream projects picked up the new versions as routine updates.
The older, quieter version of the same attack is social. In March 2024 the world learned that the xz/liblzma compression library had been backdoored upstream in versions 5.6.0 and 5.6.1. The backdoor was carried in the release tarballs, not only in the git history, which is why it survived casual review. What made it dangerous was reach: on several distributions, distribution patches pulled liblzma into the OpenSSH chain indirectly through systemd’s liblzma dependency — a path nobody would have drawn on a whiteboard. It was discovered by accident: Andres Freund, a PostgreSQL developer at Microsoft, noticed that SSH logins on a Debian sid machine were burning unusual amounts of CPU and that valgrind was reporting errors. He published the finding on the oss-security mailing list on 29 March 2024; the advisory is CVE-2024-3094. [3][4]
Two attacks, two lessons. The worm says: your lockfile is executable surface. xz says: the people in the supply chain are surface too — and that surface is built over years, not minutes.
One correction to this article’s own opening examples, because it matters for honesty: a pure CVE matcher would not have caught either of them before disclosure. xz had no CVE until the day it was found, and Shai-Hulud’s infected versions have MAL- records, not CVEs. Vulnerability matching is necessary and it is not sufficient.
It tells you what is known to be broken; it does not tell you what has been deliberately poisoned.
We see the shallow end of this daily. In our own web server logs, the same two paths —
/wp-includes/wlwmanifest.xml and /xmlrpc.php?rsd — arrive from different addresses in
the same /24, week after week, on a site that does not run WordPress. Nobody is targeting us personally. A scanner found a domain, tried the cheapest known probes, and moved on. This is the background radiation of an internet-connected machine, and it is the reason “we are too small to be interesting” stopped being a security argument roughly twenty years ago.
The Vulnerability Database Became Public Infrastructure
Here is the part that most operators still do not use: the knowledge is not scarce. It is a download.
The CVE programme has numbered publicly disclosed vulnerabilities since 1999 [10] — a quarter of a century of institutional habit. On top of it, OSV aggregates advisories across ecosystems (PyPI, npm, Go, Rust, and others) in a machine-readable form, with affected version ranges and fix versions, free to fetch. [9]
We measured this on 26 October 2026, on one ordinary workstation, with the complete published dumps: 37 MB for PyPI, 219 MB for npm. Building a local index took 7.5 and 40 seconds. Matching a real inventory of 632 installed packages — three Python environments plus a global npm tree — takes seconds, entirely offline.
And then the number that changes how you read every headline about “vulnerability growth”. The npm dump holds
230,073 unique advisories. 222,508 of them are not vulnerabilities at all — they are reports of maliciouspackages, the MAL- records that OSV carries in the same format as GHSA- and CVE- entries. Only 7,565 are actual vulnerabilities. On PyPI the raw split is 25,987 advisories: 11,932 malicious packages against 14,055 vulnerability records — but that 14,055 is inflated, because 7,159 of the PYSEC- ids are listed as aliases of a GHSA- record for the same issue. De-duplicated, PyPI holds roughly 6,900 distinct vulnerabilities against 11,932 malicious-package records. [9]
Read that again, and note what it does and does not say. The majority of what the npm feed publishes is not a bug in the code — it is a package that should never have been installed. The count says nothing about how widely any of those packages were actually used: many are typosquats, spam or empty packages that never reached a real dependency tree, and the records exist precisely so that a matcher can catch them if they do. It is a statement about the feed, and it is still the most surprising number in this article. The Shai-Hulud worm is what those records look like when they get a name, reach and a body count.
The practical consequence is simple and it is why the opening examples belong here: a matcher that only looks at CVE- and GHSA- records covers a fraction of the npm feed. Ours matches the malicious-package records too, because they live in the same index and the same version ranges. Checking for vulnerabilities alone would have been a comfortable half-measure.
AI Finds the Bugs. The Patch Decision Is Still Human.
Nobody Audits the Auditor.
The discovery side is genuinely accelerating. In November 2024 Google reported that its LLM-based agent “Big Sleep” had found a real, previously unknown memory-safety bug in SQLite — a stack buffer underflow — in code that had been fuzzed for years. In July 2025 the company reported a case in which the same line of work caught an exploitable SQLite flaw before it was used in the wild. [7][8]
Read that carefully, because it is the honest version of “AI versus AI”: a machine found the flaw, and humans decided what to do about it. The finding was the cheap part.
That is where PHP2Shell belongs, because it complicates the story in a useful way. In 2024, during PHP’s first external security audit in years — funded by the Sovereign Tech Agency, organised through OSTIF, carried out by Quarkslab — a PHP core developer found the first signs of an out-of-memory defect in the engine, in the code that decides what happens when a request dies badly. That path is reachable from every PHP-backed website. The fix shipped on 21 October 2026, in emergency releases 8.2.35, 8.3.36, 8.4.27 and 8.5.12, closing CVE-2026-109618 and CVE-2026-109619. On Apache with mod_php an unauthenticated request could reach the flaw before any application code ran; full remote code execution was demonstrated with default settings. Wiz describes the severity as reaching CVSS 9.2. [11][12]
So a human found this one, in a human audit, two years early. Why did it take until October 2026? The Foundation says it plainly: several competing fixes, each with a real cost — backward compatibility breaks, new denial-of-service potential, changes across a large third-party ecosystem that seemed impossible to coordinate; a security team that decides by consensus; a small core team. And one factor that sounded decisive at the time:
no known path to remote code execution. Its own verdict is not softened — “the delay was a structural failure,caused by both the security team’s decision process and its lack of resources.” [11]
Then read the timeline from the other side, because it does not start with the machines. The PHP Foundation had put the project into OpenAI’s “Patch the Planet” programme; the security firm Calif began an audit of PHP on 30 July 2026. On 11 August a Calif researcher reported an unconditional remote code execution with exactly the root cause the PHP team had found in 2024 — and OpenAI independently reported the same root cause. Wiz Research found it again on 19 August, with AI assistance, using its own tooling alongside Gemini 4 Argon and GPT-5.6 Sol. [11][12] On 21 October the emergency releases existed: ten weeks after that first report, two years after the human audit.
Put those three facts next to each other and the honest version of “AI finds the bugs” appears. The machine did not find something the humans had missed — a human had the signs in 2024. What arrived in August was proof that the risk was real, which is exactly the objection that had stalled the fix for two years. The finding side got faster, and the decision became unavoidable. Both are true, and the second one is the reason anything shipped.
The Foundation says the quiet part out loud, and it is the strongest evidence in this article for its own title: “the growth of AI-assisted research also means the PHP security team receives far more reports than before” — 15 CVEs published since 1 July, and more than 100 reports worked through that turned out to be invalid, duplicates, or not reproducible. Its own summary of who decides: “AI helps the team validate reports and group reports sharing the same root cause, but a person makes every call and works on every patch.” [11]
That is also the uncomfortable half of this article’s title. “The patch decision is still human” is not a reassurance — in this case the human decision was the bottleneck, and it failed for roughly two years, by the Foundation’s own account. The lesson is not that people are a safe backstop. It is that a decision nobody is resourced or empowered to make is indistinguishable, from the outside, from a decision nobody made.
The same post shows both halves moving at once: PHP now runs AI-assisted scanning (a Claude-based scan with Alpha-Omega), alongside sanitizer builds and reproduction environments; and the deciding side is being staffed — a new ecosystem security team, more triage people, a changed decision process. Only one of those two is a model.
An honest caveat about the title of this piece. A rising submission curve, one case where AI found a real flaw in SQLite, and one case where AI proved an old human finding was exploitable do not prove that AI is the cause of the curve. NIST’s announcement names no cause at all; it reports volume. The link is an argument, not a measurement — the kind of argument that sounds obviously true and can still be wrong. What the record does show is narrower: on the finding side, machines are now producing results that change decisions, and the classifying side has nothing comparable.
Patch Tuesday Never Ended.
“Patch Tuesday” is the industry’s old rhythm: once a month, vendors ship fixes, administrators install them, and the job is considered done. The backlog says the queue only gets longer — the monthly ritual never covered it.
The expensive part has a number, and NIST published it. In April 2026 the agency announced it can no longer enrich every CVE. Submissions grew 263% between 2020 and 2025; the first quarter of 2026 ran nearly a third above the same period a year earlier. NIST enriched almost 42,000 CVEs in 2025 — 45% more than in any previous year — and it was still not enough. From 15 April 2026, only prioritized CVEs (those in CISA’s Known Exploited Vulnerabilities catalog, in federal software, or in “critical software” under Executive Order 14028) are enriched promptly; everything else is labelled “lowest priority”, and the backlog of CVEs published before 1 March 2026 was moved to “Not Scheduled”. [5] Nobody audits the auditor — the body that scores everyone else’s vulnerabilities has declared that, for most of them, it will not score at all, and the queue behind that decision is not a queue anyone is assigned to clear.
A vulnerability database that has officially stopped scoring most vulnerabilities is not a scandal. It is a signal: the pipeline from found to understood is the bottleneck, and it is staffed by people.
So the real work looks different from the headlines. It is triage. It is deciding, for each finding, one of four things: patch now, pin and wait, accept the risk with a written reason, or replace the component. And it is knowing that patching is not free.
We have the receipts for that last point. In one system of this size — a few hundred packages, a handful of services — a single automated audit produced 656 findings, then 278 after two rounds of upgrades. Most of what remains is not fixable today, and the reasons are concrete rather than lazy:
- a component with 214 open advisories is a locally-run tool that never touches the internet; its fix line requires a configuration migration we are not doing casually;
- one image library cannot be upgraded because every usable release of a framework we depend on pins it below the fixed version;
- another package has no fixed release at all;
- and one build tool is pinned from above by a machine-learning framework that declares an upper bound.
We also learned the other half the hard way: upgrading is an operation, not a click. A single-version bump of one telemetry component — one package, in a set of four that must move together — took down a service overnight, and the fix was to revert it and move the whole set at once. An audit tells you what is vulnerable.
It cannot tell you what breaks.

The Inventory Is the Secret
Which brings us to the part almost nobody writes about, because it is not a product.
The common way to check whether your dependencies are vulnerable is to ask someone. pip-audit and npm audit take your package names and versions and send them — to PyPI, to npm — and get advisories back. That is convenient, and it means the exact list of software you run, in exact versions, leaves your house on a schedule.
The list of what you run is also the list of what could be attacked, and the gap between your versions and the fixed ones is precisely the map of where you are exposed.
To be fair to the tools: offline operation is not exotic. OSV-Scanner, Trivy and Grype can all run against a local database, and any vendor that makes you upload a lockfile is making a design choice, not solving a technical necessity. Nor is “the registry already knows you downloaded the package” a secret — PyPI and npm see the fetch. What they do not see is which versions you kept, on which host, in which combination — and they never see internal or private packages at all. That mapping is the thing worth protecting, especially for software that ships to customers or runs inside a company.
“Zero Secrets” in the heading above is not a slogan, it is the constraint we set ourselves: the numbers inthis article come from our own audit, and not one package name left the machine to produce them.
The European Union has now made that observation legally relevant. The Cyber Resilience Act entered into force on 10 December 2024, with reporting obligations applying from 11 September 2026 and the main obligations from 11 December 2027; the Commission published practical guidance on 27 July 2026. [6] Manufacturers must handle vulnerabilities across a product’s lifecycle and report actively exploited ones. In practice, a company that must report exploitation must first know what it contains. The inventory becomes a compliance artefact — and therefore a document worth protecting.
There is a way to have both: keep the database, not the query. Download the advisory dumps, build a local index, match locally, log what leaves. We do it this way in our own system, and the constraint is enforced in code rather than in policy — every fetch goes through one function:
teile = urllib.parse.urlsplit(url)
if teile.scheme != "https" or teile.netloc != ERLAUBTER_HOST:
raise DatenSperre("Host nicht erlaubt: %r" % url)
if teile.query: # ein Parameter in der URL koennte Daten tragen
raise DatenSperre("Parameter wuerden Daten tragen - verweigert")
anfrage = urllib.request.Request(url, method="GET") # kein Body, kein Paketname
No other host, no query string (a parameter is exactly where a package name would travel), no request body.
The identifiers are German because the tool is; the logic is four lines.
Two limits of that guard, stated plainly because this article is about exactly that kind of honesty: it checks the URL it is asked to fetch, not where a redirect might lead, and it does not pin the path — the dumps come from paths we construct ourselves, not from anything we parse. The sibling tool for the other feed allows exactly three read-only parameters (view, limit, since) because that API is addressed by parameter; everything else is refused. Every outgoing request is logged with timestamp, status and byte count, so “what left this machine” is answerable afterwards.
It works entirely offline once the dumps are on disk. Nobody has to know what we run in order for us to know whether we are exposed.
That is not a heroic stance, and we are not selling it — the tool is a few hundred lines and will stay internal.
It is the observation that in a market where vulnerability data is public infrastructure, the private asset is the inventory — and it should be treated like one.
What a small operator can actually do
Before the list, one limit that matters. Malicious-package records are reactive: they exist after someone noticed the package and reported it. They do not protect you from the freshly compromised version that your next npm install would pull in before anyone has written a record. For that window the defence is not a database but a habit, and it is the first item below.
- Close the fresh-release window. Install from the lockfile instead of resolving from the registry (npm ci, or pip install against a hash-checked requirements file), so a new publish cannot silently replace what you already tested; switch install-time scripts off where you can (npm ci with scripts disabled), because that is exactly where the worm ran; and let new releases age for a day or two. A cooldown costs almost nothing and closes most of the window in which a poisoned release is still fresh.
- Get the data locally. Fetch the OSV dumps for the ecosystems you use — including the malicious-package records, not just the CVE-shaped ones. It is minutes of bandwidth and a few hundred megabytes.
- Keep the matching offline. Your inventory compares against the index on your own disk. If a tool cannot run without uploading your package list, that is a decision, not a technical necessity.
- Triage in four buckets, in writing. Patch, pin, accept with a reason, replace. The deciding evidence is public: is the CVE in CISA’s Known Exploited Vulnerabilities catalog, what is its EPSS score, is the vulnerable function actually reachable from your code paths, and do you even run the affected feature. A finding without a decision is not handled, it is carried.
- Treat upgrades as operations. One component at a time, with a before/after measurement. Version sets that declare mutual bounds move together or not at all.
- Re-run on a schedule. Weekly for the feeds, monthly for the bigger ecosystems, and never trust a report that is older than the code it describes.
- Know what knowing does not tell you. A published advisory is not a confirmation that your system is exploitable, and the absence of one is not a clean bill of health. It is an input to a decision, not the decision.
The tools that find bugs keep getting better, and they will keep finding more than any team can fix. That is not a reason to stop looking. It is the reason the decision — which of these matter, and which we will carry knowingly — has to be made deliberately, by someone with the resources and the standing to make it. For now that someone is still a person, and the PHP case shows how badly that can go when nobody is empowered to decide.
Automating the finding does not fix that. It only makes the queue arrive faster.
Sources
- Kaspersky Securelist — “Massive npm infection: the Shai-Hulud worm and patient zero” (16 September 2025): https://securelist.com/shai-hulud-worm-infects-500-npm-packages-in-a-supply-chain-attack/117547/
- ReversingLabs — “Shai-Hulud npm supply chain attack: What you need to know”: https://www.reversinglabs.com/blog/shai-hulud-worm-npm
- oss-security mailing list, Andres Freund — “backdoor in upstream xz/liblzma leading to ssh server compromise” (29 March 2024): https://www.openwall.com/lists/oss-security/2024/03/29/4
- XZ Utils backdoor, CVE-2024-3094 — project page and timeline: https://tukaani.org/xz-backdoor/ · https://en.wikipedia.org/wiki/XZ_Utils_backdoor
- NIST — “NIST Updates NVD Operations to Address Record CVE Growth” (April 2026): https://www.nist.gov/news-events/news/2026/04/nist-updates-nvd-operations-address-record-cve-growth
- European Commission — Cyber Resilience Act (in force 10 December 2024; reporting obligations from 11 September 2026; main obligations from 11 December 2027; guidance 27 July 2026): https://digital-strategy.ec.europa.eu/en/policies/cyber-resilience-act
- The Hacker News — “Google AI ‘Big Sleep’ Stops Exploitation of Critical SQLite Vulnerability Before Hackers Act” (July 2025): https://thehackernews.com/2025/07/google-ai-big-sleep-stops-exploitation.html
- The Hacker News — “Google’s AI Tool Big Sleep Finds Zero-Day Vulnerability in SQLite” (November 2024): https://thehackernews.com/2024/11/googles-ai-tool-big-sleep-finds-zero.html
- OSV — Open Source Vulnerabilities, aggregated advisory database: https://osv.dev
- CVE Program (MITRE), overview and history: https://www.cve.org/About/Overview
- The PHP Foundation — “Today’s PHP Security Release, and How We’re Strengthening PHP Security” (21 October 2026; releases 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12; CVE-2026-109618, CVE-2026-109619; “the delay was a structural failure, caused by both the security team’s decision process and its lack of resources”): https://thephp.foundation/blog/2026/10/21/todays-php-security-release-and-how-were-strengthening-php-security/
- Wiz Research — “PHP2Shell: PHP RCE” (disclosure timeline: 19 August 2026 discovery with Wiz Atlas, Gemini 4 Argon and GPT-5.6 Sol; severity reaching CVSS 9.2; credit to Calif and OpenAI for discovery): https://www.wiz.io/blog/php2shell-php-rce
index times come from a single internal system measured on 26–27 October 2026. They describe one installation of a few hundred dependencies. They are not market data and should not be read as such.
Counting note (public data, our count): the advisory figures were counted on our own local OSV dumps on27 October 2026, by unique id (not by package or version), split on the prefix of that id. npm: 230,073 unique records — 222,508 MAL-, 7,565 other (GHSA-, one EEF-). PyPI: 25,987 unique records — 11,932 MAL-, 14,055 other (GHSA-, PYSEC-, OSV-).
Two distortions worth naming, and what we found when we checked: withdrawn records are zero in both dumps (the field is not set anywhere), so they do not inflate anything. Aliases do matter on PyPI: of the 7,582 PYSEC- ids, 7,159 are also listed as aliases of a GHSA- record in the same index — the same advisory counted twice under two schemes. De-duplicated, PyPI holds roughly 6,900 distinct vulnerabilities against 11,932 malicious-package records. The npm figures need no such correction, because that feed has no second identifier scheme. The counting is reproducible with any JSON-capable tool; the dumps come from OSV’s published archives.
links above carry the dates.
