Patch Management on Autopilot
Triaging CVEs every morning by deterministic rules and a Claude Code routine, with every suppression written down, PR-reviewed, and synced back to Inspector.
This past March, axios was compromised.
An attacker took over the lead maintainer’s npm account and published two backdoored
releases, 1.14.1 and 0.30.4, that pulled in a malicious dependency, which dropped a cross-platform remote access trojan on any machine
that installed them. The bad versions were live for under three hours. But axios does
roughly 100 million downloads a week and sits transitively under thousands of Node
packages, including some of the libraries Mirage relies on.
Around that time, I updated a package in our stack that transitively depended on
axios. Fortunately, it was a few hours before the malicious publish, and our axios
version never moved off 1.14.0. Some of this was by design, we pin explicit versions
everywhere we can, and we were relying on package-lock.json to fully pin transitive
dependencies. Some of this was complete luck though.
I didn’t want the next one to come down to luck. We’ve hardened our dependency management configuration, use automation to aggregate all dependency vulnerabilities into a single place, and have Claude Code triage and patch them. Given the increased pace of CVEs in the wild, we wanted to share what we’ve built and learned through these changes from running this process over the last several months.
Secure defaults for Node dependencies
Just like how you can have tests and linters operate as invariants to the coding model, you can configure your package manager to provide safety guardrails as well.
We’ve switched to pnpm from npm which as of v11 auto-enables a number
of security features. We’ve gone a step further and tuned it a bit more.
Here’s an excerpt from our pnpm-workspace.yaml that not only turns on specific features, but also explains to both engineers and coding agents why they’re on in the first place so they do not disable them.
# =============================================================================
# Supply chain hardening
#
# We sell enterprise security software.
# Our dependency management should reflect that.
# These settings are load-bearing for SOC2 and customer trust:
# don't weaken them without a team discussion.
# =============================================================================
# Pin exact versions so installs are deterministic and lockfile diffs
# are reviewable. No surprise semver drift.
saveExact: true
# Deny all lifecycle scripts by default. If a package genuinely needs
# a postinstall (e.g. native compilation), add it to allowBuilds
# explicitly. This is the primary defense against install-time RCE.
ignoreScripts: true
# Fail on peer dep mismatches rather than silently resolving the wrong
# version. Catches real bugs before they hit production.
strictPeerDependencies: true
# https://pnpm.io/settings#minimumreleaseage
# Quarantine period: 7 days (10080 minutes). Most malicious packages
# are detected and pulled within 24-48 hours — 7 days gives us margin.
# If you need to bypass this for a specific package, add it to
# minimumReleaseAgeExclude temporarily with a comment explaining why.
minimumReleaseAge: 10080
# https://pnpm.io/settings#trustpolicy
# Block installs when a package's trust level drops (e.g. previously
# published via CI with provenance, now published from a local machine).
# Catches credential theft attacks that minimumReleaseAge alone won't.
trustPolicy: no-downgrade
# Block transitive dependencies from resolving via git repos, tarballs,
# or other non-registry sources. If a direct dep legitimately needs
# this, it still works — only transitive deps are restricted.
# https://pnpm.io/settings#blockexoticsubdeps
blockExoticSubdeps: true
# If/when we move from ignoreScripts to granular allowBuilds, this
# ensures any package not explicitly listed is a hard error, not a
# warning. Belt-and-suspenders with ignoreScripts for now.
# https://pnpm.io/settings#strictdepbuilds
strictDepBuilds: true
In addition to this configuration, we also run Socket Security on every PR to raise awareness of possible risks introduced through dependency changes.
Figuring out what packages are at risk
We want to make sure we understand our inventory of CVEs, what is exploitable, and stayting within our committed SLAs around patch management. To do this, we have a multi-step pipeline and set of skills that we run daily.
Our raw CVE data comes from a few places: AWS Inspector for containers and Dependabot for our Node ecosystem. We run a GHA Workflow every day that pulls from these registries, auto-triages via deterministic rules, suppresses what can’t be updated, and updates what can.
Our deterministic rules are based on severity, exploitability, and fixability. If we can’t fix it because the package isn’t available, we have to suppress it until it’s ready. At a high-level, the rules evaluate:
- CVEs that impact the latest image of each service.
- The SLA clock starts when Inspector first sees the finding, not when we notice: 24 hours for critical, 7 days for high.
- A KEV listing or an EPSS score above 0.2 promotes a high to the 24 hour clock.
- No fix available, or a vendor image we don’t control, isn’t actionable. It gets suppressed with a written reason and re-checked every day until upstream ships something.
- Suppressions are scoped to specific services and can carry an expiration date, so they lapse on their own instead of quietly living forever.
The output of the job looks like this:
Fetching CISA KEV catalog...
Loaded CISA KEV catalog: 1627 CVEs (released 2026-06-23T17:00:37.2944Z)
Querying Inspector for ACTIVE critical/high ECR findings...
Found 1114 total findings across all ECR images.
Scanning latest image per repo: 40 findings kept, 1074 dropped (prior digests aging out).
Checking 135 suppressed CVE(s) for fix availability...
🟡 13 actionable CVE(s) found (0 SLA breached):
...
============================================================
Summary: 13 actionable (0 breached) | 0 suppressed | 17 no-fix | 0 vendor
🔔 72 suppressed CVE(s) now have fixes available:
- CVE-2025-15467 (openssl/openssl -> 3.6.1) — suppressed 2026-02-10
...
⏸️ Suppressed CVEs (135):
- CVE-2025-55130: Node.js Permissions model (--allow-fs-read/--allow-fs-write) is not used in any of our services
...
✅ 13 actionable CVE(s) require attention, but none have breached SLA.
Claude Code’s Routine
After that daily scan, a Claude Code Routine ingests that output and figures out what can be safely suppressed or updated based on the codebase. The prompt for the routine looks like this:
Make sure this branch is up to date with origin/main.
Run the repo skill `vuln-triage` (`.claude/skills/vuln-triage/SKILL.md`) and
follow it end to end. It owns the full routine: read the latest
vuln-tools-scan run, check open PRs for duplicate work, patch what we can,
suppress what we can't (Brocard reasoning, material-risk gate), ship split
PRs (ungated triage PR + human-gated patch PR with expiring TEMPORARY
suppressions), run the suppression audit and cleanup loop.
And a simplified version of the mentioned skill is this:
# Vuln Triage
The suppression file is version-controlled. Every entry records who,
when, why, and optionally an expiration date.
1. **Read the latest scan.** Extract actionable findings: CVE, package,
current and fixed versions, SLA state. Skip findings already covered
by an open PR.
2. **Classify each finding.** If we can fix it (dep bump, lockfile
override, base-image bump), open a patch PR. If we can't (no fix
published, fix pinned behind an upstream release, dep bundled in a
vendor image), suppress it. Verify "can't fix" against upstream
release notes; never assert it from memory.
3. **Write suppressions as claims with evidence**, for a future auditor.
Name the justification: code path never invoked in our usage, fix not
adoptable independently, finding is from a stale image, or fix
riskier than the bug. Be specific. "Transitive dependency" alone is
not a reason.
4. **Never suppress plausible risk**: network-reachable, data exposure,
KEV-listed, or EPSS above your bar. When in doubt, leave it
unsuppressed and page a human.
5. **Suppressions for in-flight patches expire** (~2 weeks). If the
patch stalls, the finding re-flags on its own. Temporary must never
silently become permanent.
6. **Clean up every run**: drop entries that merged or expired, and
re-check suppressed findings whose fix has since shipped.
For each CVE, we let Claude triage using a checked-in TRIAGE.md which is based on these brocards (thanks William Woodruff for putting this up).
This pipeline edits our checked-in suppressions.json that include what CVE we’re suppressing for which service and the rationale for it. This is then considered during PR
review by both our AI reviewer and human reviewers.
With all of this context, the agent is able to come up with a clear multi-phased plan, running our tests along the way, so it can know whether the app actually works after a huge breaking change. When it opens the PR, it also includes the evidence from the end-to-end tests for engineers to inspect for accuracy and we auto-ship PR preview environments so we can try changes live.
Results
Patch management used to be toil. It was a recurring tax that pulled us off roadmap work to chase version bumps, read advisories, and hope our tests caught whatever we missed. Now it’s nearly automatic.
Over the last six months this loop has triaged 1,194 CVEs across our container images. 75% of the findings that closed did so within a day of Inspector first seeing them, with a median of about 16 hours.
Most of that work was never ours to do. 81% of those CVEs had no fix available on any of our images — nothing to patch, only something to document and re-check every morning until upstream shipped. Only 9% had a known exploit, and exactly five of the 1,194 cleared the EPSS bar we use to promote a high to the 24 hour clock.
That’s patch management. The volume is real and the danger mostly isn’t, and you don’t get to know which is which until someone looks. Having that someone be an agent, every morning, with the reasoning written down for the auditor, is the whole win.
And almost none of which required an engineer to stop what they were doing and context-switch into dependency work.