Dependency Cooldowns: Catching Supply-Chain Attacks Before a CVE Exists

The defenders aren't the only ones getting AI leverage. In mid-2026, GLM-5.2 — a freely-downloadable, MIT-licensed open-weight model anyone can run — scored 39% F1 finding IDOR and authorization vulnerabilities at roughly $0.17 per vulnerability found, beating flagship closed coding-agent scores. That benchmark measured one narrow bug class, not supply-chain compromise — but the economics it exposes generalize: the same cheap, headcount-free automation that lets a defender triage findings at scale is available to an adversary who wants to phish a maintainer, reverse-engineer a patch, or credential-harvest at scale, none of which require a large team anymore either. Supply-chain attacks were already faster than the defenses built to catch them. Now the people running them can scale their own capability without hiring anyone.

You can watch how that asymmetry plays out in a modern npm or PyPI compromise. A maintainer account gets phished. A malicious version ships. It harvests npm and GitHub tokens, AWS keys, SSH keys — and in the self-propagating cases, uses those stolen credentials to publish itself into the next set of packages. The whole campaign burns hot and fast, and the ecosystem's defenders — competing scanners, the registries themselves, the maintainers — typically catch and yank the bad version within hours to a few days. By the time a CVE is filed, if one ever is, the window that mattered has already closed. The attack lived and died in the gap before any vulnerability database knew about it. We've watched this exact worm-speed pattern land in live sales conversations, because it's the risk AppSec leads feel in their gut and can't answer with a CVE scanner.

A cooldown is our answer to that gap, and it's built the way we think this whole fight has to be fought: decouple the security outcome from headcount by automating the detection end-to-end, running it efficiently enough that it costs nothing to run everywhere, and keeping it controlled and measurable so a security team owns the policy rather than trusting a black box. Concretely, that means a recency-only install filter — a dependency version whose registry publish timestamp is younger than your configured window is treated as risky until the ecosystem has had time to detect and pull a malicious release — that runs with zero LLM cost on a deterministic pipeline and exposes every knob through a REST API and CLI. It never consults CVE or vulnerability data. That's not an oversight — it's the entire point. We are catching the window before a CVE would even exist, which is exactly when real supply-chain worms do their damage. The idea itself didn't even come from us: it came from a prospect's AppSec lead, who described a policy his team wanted but couldn't easily enforce — don't let developers pull a brand-new package version for about a week after it's published — and the more we sat with it, the more we liked it, because it attacks a class of risk every CVE-based scanner on the market is structurally blind to.

Not the same job as CVE scanning

It's worth being precise about the boundary, because it's easy to conflate two things that look adjacent but answer opposite questions. Our SCA product answers "is this dependency known-bad?" — it's CVE-based, and when it finds a vulnerable dependency it can reason about whether your code even reaches the flaw. That's per-CVE reachability, and it's the right tool once a vulnerability is known and cataloged. Cooldowns answer the inverse: "is this dependency too new to know yet?" A version can be perfectly clean of any known vulnerability and still trip a cooldown purely because it was published yesterday. There is no known-bad list involved, because the threat model is precisely the versions that no known-bad list has caught up to.

We verified this separation isn't just conceptual. There is no shared code between the cooldown rules and SCA's CVE machinery — grep all of SCA for "cooldown" and you get nothing. Two clean detection paths, two different questions. Cooldowns don't replace CVE scanning; they cover the blind spot that sits temporally in front of it.

The recency rule, and the anti-noise decisions inside it

The core detector is deliberately dull, in the way good deterministic checks are dull. When a pull request adds or bumps a direct or development dependency, the system looks up that package version's first-published date. If the time since publish is less than the configured window for that ecosystem, it raises a finding. Once the version ages past the window, the check passes automatically on the next scan — no action needed, no manual clearing.

The design work that matters here is all in the guardrails, because a recency check that fires indiscriminately is worse than no check at all:

  • Direct and dev dependencies only. Transitive, lockfile-resolved dependencies are explicitly out of scope. A cooldown is about what a human or CI pipeline directly chose to pull in — the deliberate act of adopting a fresh version. An earlier iteration fired on transitive deps too; we walked that back in a follow-up specifically scoped to direct dependencies, because the transitive firing was noise no one could act on.
  • Static reason string, structured context beside it. The finding's human-readable reason is deliberately fixed: "Direct or development dependency in use was published within the configured cooldown window." It does not embed the package's live age. That's not laziness — the finding ID is hashed from its reason, and a reason that changed every day (age 2, age 3, age 4...) would churn the finding's identity on every scan, resurrecting a ticket a human already triaged. So the volatile, useful context — ecosystem, publish date, exact age, the configured window, dev-vs-direct scope — lives in a separate structured context map. A security team triages the finding once; the age ticking upward underneath it doesn't invalidate that decision.
  • Severity that reflects real exposure. Base severity is Medium. It escalates to High when the package was published today (age zero — the freshest and most dangerous case), or when the package's ecosystem has no native package-manager-level cooldown support at all, because in that case Nullify's check is the only line of defense there is.
  • Seven false-positive guards, and they fail open. Unknown publish time (a registry outage, an unsupported ecosystem) is treated as unknown and never blocks a scan. A master kill switch. An org allowlist bypass for pre-cleared packages. Skipping CI/CD pseudo-registries. Skipping floating or unresolved specs like *, latest, or version ranges, because a cooldown needs a concrete published version to age. And a negative age — a future publish timestamp from registry clock skew — is treated as unknown rather than misread as a "published today" High. That last guard wasn't in the first cut: it was added during the PR #7645 review-hardening pass (commit 2d5b2c8244, finding M1), after review caught that a future/clock-skewed publish time was producing a false High.

That fail-open posture is the recurring theme of the whole feature. Every failure mode degrades toward silently under-protecting — never toward blocking or crashing your pipeline. A cooldown check that took down a build over a registry hiccup would get disabled within a week, and then it protects nothing.

Deterministic, zero-token, and built to not care how big your repo is

This is the part we're most proud of, and it's the part that runs against the grain of how "AI-native" is usually marketed. The entire cooldown detection path involves zero LLM calls. Not "cheap" LLM calls — zero. There is no model reasoning about whether a package is too new; that's a timestamp comparison, and timestamp comparisons are the last thing on earth you want to spend inference tokens on. Not everything needs an LLM, and a security program that reaches for one on every dependency in every repo is lighting money on fire for answers arithmetic already has.

But "just compare timestamps" hides a real scaling problem: where do the timestamps come from, and what happens when a monorepo has ten thousand dependencies and half the registries are slow? The answer is a data pipeline built to make that question irrelevant:

  • Pre-warmed per-scan lookups with fail-open on a miss. The production provider resolves publish dates for every distinct dependency in the whole scan up front, in batched bulk calls (on the order of 500 per request), against our own pre-warmed vulnerability-database index. Every individual rule evaluation then answers from an in-memory map in O(1), with zero live network round-trips on the hot path. And when a lookup misses — a version that hasn't been ingested yet, an unsupported ecosystem — the production path does not stall the scan to go hit a live registry. It fails open: the publish time is treated as unknown, which (per the false-positive guards above) never blocks. The scan always completes within the lambda budget regardless of how many dependencies the repo has or how unhealthy the registries are that day, because the primary path never depends on a registry being reachable at scan time.
  • A shared, cross-tenant cache. Underneath the index is a dedicated ingestor service with an LRU cache. Positive results — a known publish date — are immutable and never expire, because a package version's first-published date is a historical fact that doesn't change. Negative "not found" results expire after six hours, so a version that simply hadn't propagated to the registry index yet gets re-checked later instead of being permanently blacklisted. The upstream fetch happens asynchronously, off the request path, on a bounded worker pool. The practical consequence: the n-th customer to scan a popular package — react, requests, express — hits a warm cache, not a live registry, and the cost of cataloging the ecosystem amortizes across every tenant.
  • A live-registry provider as an alternate deployment mode. There is a second, standalone provider that resolves publish dates by querying registries directly. It is mutually exclusive with the pre-warmed path — it's the provider you run when the primary vulnerability-database path is turned off, not a fallback layered behind it. Its publish-date lookup bounds latency through the scan context's own deadline rather than a dedicated per-lookup or cumulative-budget guard, so a slow registry there is contained by the surrounding scan timeout rather than by a cooldown-specific cutoff. In the default production configuration the pre-warmed index is authoritative and a miss simply degrades to "unknown"; the live provider exists for deployments that opt to trade that fail-open behavior for an on-demand registry hit.

Put those together and you get a detection path that is deterministic, costs no tokens, and holds its latency flat whether you point it at a five-file service or a ten-thousand-dependency monorepo. That's the least human work and the least AI spend for a real security outcome — which is the bar we hold every feature to.

Where the tooling ecosystem can't help you, and we can

A genuinely interesting wrinkle showed up once we started implementing: some package managers can now enforce a cooldown natively, at install time, without Nullify in the loop at all.

  • npm (11.10.0+), pip / uv (0.9.17+), Bundler (4.0.13+), and Hex for Elixir all support a native minimum-release-age gate.
  • Go modules, Maven, Gradle, NuGet, Cargo, Composer, and Swift have no package-manager-level cooldown primitive whatsoever.

That second list is why the recent-publish rule escalates to High severity for those ecosystems. If you're pulling a fresh Go module or a fresh NuGet package, there is no mechanical backstop the tooling can offer — Nullify's static detection is doing work the ecosystem itself cannot yet do for that language. For the ecosystems that do support it natively, our finding maps onto a control the package manager could also enforce, which sets up the more durable move: getting that control turned on so the enforcement outlives any single scan.

Turning a catch into an enforced policy: updater-bot conformance

Catching a fresh install after the fact is good. Making sure it can never quietly happen again is better — and it's where this crosses from a one-time finding into a program-management control. Most teams already run an automated dependency-update bot: Renovate or Dependabot. Those bots can enforce a release-age gate themselves, going forward, on every future bump. So we audit whether they actually do:

  • "Missing" conformance: a Renovate or Dependabot config exists but sets no effective minimum-release-age / cooldown at all.
  • "Below threshold" conformance: the bot has a release-age gate, but it's shorter than your required window.

Dependabot's cooldown is configured independently per ecosystem block in .github/dependabot.yml, so each ecosystem gets its own compliance check rather than a single pass/fail for the repo. And the Renovate parser catches a real-world gotcha a naive "does the key exist?" check would sail right past: internalChecksFilter set to "none" or "flexible" silently disables an otherwise-configured minimumReleaseAge. The config looks compliant; it isn't.

Underneath sits one tested conversion table, because every tool measures release age in its own units. Renovate, Dependabot, Poetry, and Bundler use days; pnpm and Yarn Berry use minutes; Bun uses seconds; Hex uses a custom Nd/Nw/Nmo suffix grammar; uv's exclude-newer accepts either a bare ISO date or a duration string. We reverse-engineered each of those from the tools' actual docs rather than guessing, so the conformance check reads every config in its native dialect. The result is that a cooldown stops being something Nullify catches for you and becomes something your own bot enforces on every future update — the finding pays for itself once and then keeps paying.

The lever is yours, on every dimension

None of this is a black box you have to accept as-is. The full policy surface is exposed through both a REST API and a first-class CLI, so a security team can tune every dimension without an engineer:

  • Master enable/disable (CooldownEnabled) — the kill switch.
  • A global window (CooldownDays) — the default cooldown length.
  • Per-ecosystem overrides (CooldownOverrides) — e.g. npm at 10 days, PyPI at 14, if you judge one ecosystem riskier than another.
  • An approved-package allowlist — pre-clear a specific trusted package, optionally pinned to a version, so a legitimately fast-releasing internal or vendor dependency never trips the check.

From the CLI that's as direct as scpm settings set --cooldown-days 7 --ecosystem npm=10 --ecosystem pypi=14. The platform default is cooldowns enabled with a 7-day window — enough time for the ecosystem to detect and pull a malicious release — and you can configure the window anywhere up to 365 days. Policy loading is itself fail-safe: a tenant with no settings row, or a transient database error, degrades to the platform default with a warning log rather than failing the scan. The feature's worst case is always "quietly fall back to the safe default," never "break the pipeline."

What's next, honestly

We'd rather tell you where the edges are than imply a finished taxonomy. Today, the recent-publish rule and the updater-bot conformance checks are what's shipped. The code carries a numbering scheme with slots for further recency rules we haven't built yet, and we're not going to pretend those exist. There's no maintainer-change or ownership-transfer detector in the product today. And there's no cooldown-specific autofix path yet — remediation is handled by our general-purpose autofix agent reasoning over the finding's structured context (ecosystem, publish date, age, window, scope), not a dedicated deterministic fixer that auto-pins you to a safe version. The obvious next steps — pin to the most recent version already outside the window, or open a PR adding a minimumReleaseAge gate to your Renovate/Dependabot config — are implied by the data model, and they're where we're headed. Wiring cooldown findings into configurable block/warn levels alongside our other supply-chain categories is on that same path.

The shape of the thing is what we want you to take away. A supply-chain worm doesn't wait for a CVE, so neither do we. The check that catches it is deterministic, costs no tokens, holds its latency flat at any repo size, degrades safely when the registries misbehave, and hands your security team a lever on every knob. It came from a customer's offhand suggestion, and it turned out to be one of the cleanest examples we have of a real security outcome bought with the least possible human effort and the least possible AI spend. Sometimes the right answer to a hard problem isn't a smarter model. It's a timestamp and the discipline to wait a week.

Book a demo