Catching Supply-Chain Attacks Before a CVE Exists: Pins, Cooldowns, and Threat Investigations

Most npm and PyPI supply-chain attacks now follow the same script. A maintainer account gets phished and a malicious version ships. It steals npm and GitHub tokens, AWS keys and SSH keys, and in the self-propagating cases (Shai-Hulud and its copycats) it uses those credentials to publish itself into more packages. The ecosystem usually catches and yanks the bad version within hours or days. A CVE, if one is ever filed, comes after the damage is done.

CVE scanners can't help here, ours included. SCA answers "is this dependency known-bad?", the right question once a vulnerability is cataloged. A worm is never cataloged in time. Defending against one means asking different questions: How does a malicious version get into your build in the first place? How do you buy the ecosystem time to catch it before you install it? And when a campaign lands anyway, how fast can you answer, "Are we affected, and did it actually run?" We built five things around those questions.

Part 1: Unpinned dependencies

A floating version spec like *, latest or ^ means you run whatever the registry serves at install time. When a maintainer account gets popped, that's your next CI run installing the malicious version. A pin leaves the malicious version sitting in the registry; the only way a new version reaches your build is through a diff someone reviews.

So the first check finds direct dependencies that aren't pinned to an exact version, across npm, PyPI, Go, RubyGems, Maven and Gradle, Cargo, NuGet, Composer and Conda. Severity tracks how wide the float is: * or latest is High, ^ or >= is Medium, ~ is Low. Our autofix agent then opens the PR that pins it. That part uses an LLM on purpose: choosing a safe concrete version means reasoning about the lockfile and how the code uses the package.

Part 2: Cooldowns

Pinning makes version changes reviewable, but a pinned version can still be brand new when you take it. A cooldown sets how old a version must be first. The idea came from a prospect's AppSec lead: don't let developers pull a brand-new package version for a week after publish, because a week comfortably covers how long the ecosystem takes to catch and yank a malicious release. Package managers are now shipping native release-age gates too (see cooldowns.dev); more on that in Part 4.

Our detector is a timestamp comparison. If a direct or development dependency was published more recently than your window (7 days by default), it raises a finding. It never consults CVE data, because the whole point is to catch versions no CVE database knows about yet. Once the version ages past the window, we close the finding automatically.

Most of the design work went into not being annoying:

  • Transitive dependencies are excluded. An early version flagged them; nobody could act on those findings, so we dropped them.
  • The finding's identity stays stable as the package ages, so a ticket someone already triaged doesn't reopen as a new finding the next day.
  • Severity is Medium, rising to High if the package was published today or its ecosystem has no native cooldown support.
  • Unknown data never blocks. Registry outages, ecosystems we can't resolve publish dates for, and clock-skewed publish timestamps all resolve to "unknown", which does nothing; if this check failed builds over registry hiccups, teams would turn it off within a week.

Part 3: The PR gate

The best moment to catch a too-fresh dependency is the pull request that adds it. The same policy gates PRs: add or bump a dependency to a version younger than your window and the finding counts against the merge gate immediately, no triage step.

We've seen security gates cause developer mutinies, so this one shows its work: the package name, its publish date and age, and your window, right on the PR. Warn vs block is your call; most teams run warn-only for a few weeks first. A blocked developer can pick the newest version that's already old enough, wait for the version to age out (the finding closes automatically), or get the package allowlisted.

Part 4: Enforcement in your own tooling

Some package managers can now enforce a cooldown themselves at install time. npm (11.10.0+), uv (0.9.17+), Bundler (4.0.13+) and Hex support a native minimum-release-age gate. Go, Maven, Gradle, NuGet, Cargo, Composer, Swift and the rest have nothing.

Why not just turn on npm's flag and call it done? It lives in per-repo config, half your stack has no equivalent, and nobody checks it's still set next quarter across eight hundred repositories. That's also why our rule escalates to High for the ecosystems with no native gate: nothing else enforces the window for them.

Better still, have your own update bot enforce the window. Renovate and Dependabot can both gate on release age, so we audit whether they do. A config with no minimum release age at all, or one shorter than your window, raises a finding. Dependabot is checked per ecosystem block. The Renovate parser also catches a nasty one: internalChecksFilter: "none" silently disables an otherwise-configured minimumReleaseAge, so the config passes a visual check while enforcing nothing.

Renovate, Dependabot and the native package-manager gates all measure release age in their own units (days, minutes, seconds, Hex's 7d grammar), so one tested conversion table normalizes them against your policy. These findings feed the same autofix system as Part 1: it opens a PR against your Renovate or Dependabot config that adds a minimum release age, or raises it to meet your window. We've written about how these fix PRs are generated and verified in Fixes You Can Prove.

Almost no model calls

Every check so far is deterministic: an unpinned spec is a pattern match, and whether a package is too new is a date comparison. Across Parts 1 through 4, the only model calls are the two autofixes (pinning, and updater config), where there's actual reasoning to do.

The hard part at scale is fetching the data: ten thousand publish dates, slow registries. We resolve every publish date up front, in bulk, against a pre-warmed index. After that, each rule evaluation is an in-memory lookup: a miss means "unknown", and no evaluation touches the network, however many dependencies the repo has. A cross-tenant cache keeps known publish dates forever (they're historical facts) and retries not-found results after six hours.

Part 5: Threat investigations

When a campaign hits the news, every security lead gets the same two questions within the hour: are we affected, and did it run? Answering by hand means grepping lockfiles across hundreds of repos while the list of compromised packages keeps growing. We built the investigation into the product instead.

A recent example: the Miasma worm, a Shai-Hulud-style campaign that compromised the Leo Platform / RStreams npm packages. When it surfaced, we kicked off a threat investigation. A research agent reads the public record (advisories, researcher write-ups, registry data) and produces the affected-package list. Package names are extracted verbatim from sources, and anything the agent can't literally quote from a fetched page is dropped, because naming a package that was never compromised does more damage in an incident report than missing one.

The impact sweep is then plain code. It checks every repository's dependency trees, direct and transitive, and it checks history, not just today's lockfile: the version you ran for three weeks last month matters too. Each package gets a verdict of actively exposed, historically exposed, not exposed, or unknown. It also checks whether CI built the commits carrying a compromised version, which is where an install-time payload would have actually run. A report agent writes it up so you can forward it to leadership.

One limit: we see your repositories and CI, not your developers' laptops. The report says which packages were implicated and what anyone who installed them should do (rotate npm, GitHub and AWS tokens plus SSH keys, then check browser credential stores, shell configs and git hooks).

For Miasma we ran this across every customer, from a 33-repo team to a 2,403-repo enterprise, inside a day, and posted the evidence in their Slack channels. No customer was exposed. When an investigation does find exposure, it feeds the remediation workflow that opens fix PRs.

Configuration

This runs alongside whatever CVE scanning you keep, and it leaves your Renovate and Dependabot configs in place. For existing customers it's a settings change; nothing new has to be deployed. Everything is configurable over the API and CLI: kill switch, global window (default 7 days, up to 365), per-ecosystem overrides, allowlist. If a setting is missing or the database errors, we fall back to the platform default rather than failing a review.

Start with a seven-day cooldown: your package manager may already support it natively, and we cover the ecosystems that can't.