Dev.to Security 🔐 Cybersecurity 👁 0 📖 10 min read

Discovery Got Cheap, Remediation Didn’t: A Pipeline View of Vulnerabilities in 2026

For years, the hard part of software security was finding the bug. You needed a skilled researcher, good tooling and a lot of time. That is no longer true, and I think many of us have not adjusted our mental model yet.

Discovery Got Cheap, Remediation Didn’t: A Pipeline View of Vulnerabilities in 2026

For years, the hard part of software security was finding the bug. You needed a skilled researcher, good tooling and a lot of time. That is no longer true, and I think many of us have not adjusted our mental model yet.

In this post I want to look at the problem the way an engineer would, as a pipeline with stages and a throughput at each stage. Then we will build a small script to measure where your own team sits.

The numbers that started this

What Figure Source
Microsoft October 2026 Patch Tuesday 1,400+ CVEs (tallies range 1,411 to 1,417), a record Krebs on Security
Same month, one year earlier about 175 CVEs Computer Weekly
November 2026 Patch Tuesday 1,094 CVEs, second largest ever Splashtop
Exploitation as initial access (DBIR 2026) 31%, up from 20% Tenable summary
KEV vulnerabilities fully remediated 26%, down from 38% same
Median time to fully patch 43 days, up from 32 same

Microsoft CVEs fixed per Patch Tuesday

Krebs reports that Microsoft credits large language models for the record volume. So discovery has clearly sped up. The interesting question is what happens after discovery.

The pipeline view

The Cloud Security Alliance published a whitepaper on November 6 that models vulnerability handling as a pipeline. I find the framing useful, so here is my condensed version with 2026 evidence at each stage.

Stage What limits it 2026 evidence
Discovery Compute and model access Anthropic reports 129,000+ verified findings from Glasswing partners (self-reported, subset of partners). The AgentFlow paper found ten Chrome zero-days with an open-weight model.
Validation and triage Skilled human reviewers Anthropic calls independent human triage its limiting step. Claude rated about 91.5% of ledger findings critical or high, while maintainers rated fewer that way.
Intake Maintainer security capacity Torvalds described the kernel security list as unmanageable because of duplicate reports. Google paused product vulnerability intake in its open-source bounty on October 1.
Fix development Maintainer engineering time Anthropic's October 2 dashboard: 6,157 disclosed to maintainers, 516 known patched.
Publication and enrichment Database analysts NIST moved NVD to prioritized enrichment on April 15, after a 263% rise in CVE submissions from 2020 to 2025.
Downstream packaging Distro and vendor release cycles A fix released upstream does not mean it is installed anywhere.
Enterprise deployment Testing, change management, asset visibility Median 43 days to patch. Sonatype found about 13% of 2025 Log4j downloads still had Log4Shell.

Source for the model and most stage evidence: CSA, The Disclosure Bottleneck. It is an industry whitepaper, not a peer-reviewed paper, and several of its inputs are self-reported by the organizations running the discovery programs.

Where a vulnerability waits, 2026 pipeline

The key point is simple. A pipeline can only run as fast as its slowest stage. Discovery used to be the slow stage, so the volume of serious bugs entering the pipeline was small. Now discovery is fast, and every stage after it is facing inflow it was never sized for.

Interactive version: Click through the pipeline and metrics in your browser

Be careful with the measurements

A few numbers get quoted a lot, and each needs a qualifier.

  • Time-to-exploit of negative seven days (Mandiant M-Trends 2026) is a mean across vulnerabilities that were observed being exploited. It does not describe all vulnerabilities.
  • Exploited on or before CVE publication: VulnCheck measured about 29% for vulnerabilities first exploited in 2025, so a large minority, not the majority.
  • AI-found bugs and exploitation: VulnCheck's first-half 2026 data shows 14 of 1,061 AI-attributed discoveries (about 1.3%) confirmed exploited. Summary here. So the data does not support the claim that AI-found bugs are being exploited at unprecedented rates.
  • Ledger accounting: VulnCheck's November review counts 37,966 Glasswing ledger findings with 217 marked fixed, and says the numbers do not fully reconcile. See VulnCheck's post. Anthropic's own figures are self-reported.
  • Severity: AI-assigned severity ran well above maintainers' ratings, so sorting by AI severity alone will mislead your prioritization. My reading: the evidence strongly supports a capacity gap after discovery. It does not support a wave of weaponized AI-found bugs.

Patch consumption capacity

Most teams track mean time to remediate. That is an outcome metric, and it quietly assumes inflow is stable. The CSA paper suggests tracking how much inflow you can absorb, which it calls patch consumption capacity. Four indicators are enough to start:

  1. Inflow to closed ratio over a rolling window, split by risk tier. Persistently above 1 means the backlog is growing.
  2. Age of open vulnerabilities on internet-facing and high-impact systems.
  3. Share of vulnerabilities with missing metadata, meaning you cannot tell if you are affected. This matters more since NVD narrowed enrichment.
  4. Time to consume upstream fixes, from the upstream release to your deployment. Let us build the first two, plus a KEV check, in about 60 lines.

A small script

Input is a CSV export from whatever tracker you use. Dates are YYYY-MM-DD, and an empty closed_at means the item is still open.

cve_id,first_seen,closed_at
CVE-2021-44228,2026-10-05,
CVE-2026-0001,2026-10-25,2026-11-09
CVE-2026-0002,2026-10-30,

The script pulls CISA's Known Exploited Vulnerabilities catalog, then reports the inflow to closed ratio, the median age of open items, and how many open items are in KEV and older than your own target. Set KEV_FILE to a saved copy of the catalog if you want to run it offline or in CI.

TypeScript (Node 18 or newer)

// vuln-capacity.ts
// Run: npx tsx vuln-capacity.ts vulns.csv
import { readFileSync } from "node:fs";

type Row = { cveId: string; firstSeen: Date; closedAt: Date | null };
type Kev = { vulnerabilities: { cveID: string }[] };

const KEV_URL =
  "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json";
const WINDOW_DAYS = 30;
const KEV_SLA_DAYS = 7; // your own target, tune it
const DAY = 86_400_000;

function parseCsv(path: string): Row[] {
  const [, ...lines] = readFileSync(path, "utf8").trim().split("\n");
  return lines.map((line) => {
    const [cveId, firstSeen, closedAt] = line.split(",").map((s) => s.trim());
    return {
      cveId,
      firstSeen: new Date(firstSeen),
      closedAt: closedAt ? new Date(closedAt) : null,
    };
  });
}

async function loadKev(): Promise<Set<string>> {
  const raw = process.env.KEV_FILE
    ? readFileSync(process.env.KEV_FILE, "utf8")
    : await fetch(KEV_URL).then((r) => {
        if (!r.ok) throw new Error(`KEV fetch failed: ${r.status}`);
        return r.text();
      });
  const kev = JSON.parse(raw) as Kev;
  return new Set(kev.vulnerabilities.map((v) => v.cveID));
}

function median(xs: number[]): number {
  if (xs.length === 0) return 0;
  const s = [...xs].sort((a, b) => a - b);
  const mid = Math.floor(s.length / 2);
  return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
}

async function main() {
  const rows = parseCsv(process.argv[2] ?? "vulns.csv");
  const kev = await loadKev();
  const now = Date.now();
  const since = now - WINDOW_DAYS * DAY;
  const ageDays = (r: Row) => (now - r.firstSeen.getTime()) / DAY;

  const inflow = rows.filter((r) => r.firstSeen.getTime() >= since).length;
  const closed = rows.filter((r) => r.closedAt && r.closedAt.getTime() >= since).length;
  const open = rows.filter((r) => !r.closedAt);
  const openKev = open.filter((r) => kev.has(r.cveId));
  const overdueKev = openKev.filter((r) => ageDays(r) > KEV_SLA_DAYS);

  console.log(`Last ${WINDOW_DAYS} days`);
  console.log(`  New applicable vulns : ${inflow}`);
  console.log(`  Closed               : ${closed}`);
  console.log(`  Inflow / closed      : ${closed ? (inflow / closed).toFixed(2) : "inf"}`);
  console.log(`Open backlog`);
  console.log(`  Total open           : ${open.length}`);
  console.log(`  Median age (days)    : ${median(open.map(ageDays)).toFixed(1)}`);
  console.log(`  Open and in KEV      : ${openKev.length}`);
  console.log(`  KEV older than ${KEV_SLA_DAYS}d   : ${overdueKev.length}`);
  if (closed && inflow / closed > 1) {
    console.log("\nRatio above 1: backlog is growing faster than you can close it.");
  }
}

main().catch((e) => {
  console.error(e);
  process.exit(1);
});

Python 3.9 or newer

# vuln_capacity.py
# Run: python vuln_capacity.py vulns.csv
import csv
import json
import os
import statistics
import sys
import urllib.request
from datetime import datetime, timedelta, timezone

KEV_URL = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json"
WINDOW_DAYS = 30
KEV_SLA_DAYS = 7  # your own target, tune it


def parse_day(value):
    if not value:
        return None
    return datetime.fromisoformat(value).replace(tzinfo=timezone.utc)


def load_rows(path):
    with open(path, newline="") as f:
        return [
            {
                "cve": r["cve_id"].strip(),
                "first_seen": parse_day(r["first_seen"].strip()),
                "closed_at": parse_day(r["closed_at"].strip()),
            }
            for r in csv.DictReader(f)
        ]


def load_kev():
    if os.environ.get("KEV_FILE"):
        with open(os.environ["KEV_FILE"]) as f:
            data = json.load(f)
    else:
        with urllib.request.urlopen(KEV_URL, timeout=30) as resp:
            data = json.load(resp)
    return {v["cveID"] for v in data["vulnerabilities"]}


def main():
    rows = load_rows(sys.argv[1] if len(sys.argv) > 1 else "vulns.csv")
    kev = load_kev()
    now = datetime.now(timezone.utc)
    since = now - timedelta(days=WINDOW_DAYS)

    def age_days(r):
        return (now - r["first_seen"]).total_seconds() / 86400

    inflow = sum(1 for r in rows if r["first_seen"] >= since)
    closed = sum(1 for r in rows if r["closed_at"] and r["closed_at"] >= since)
    open_rows = [r for r in rows if not r["closed_at"]]
    open_kev = [r for r in open_rows if r["cve"] in kev]
    overdue = [r for r in open_kev if age_days(r) > KEV_SLA_DAYS]

    print(f"Last {WINDOW_DAYS} days")
    print(f"  New applicable vulns : {inflow}")
    print(f"  Closed               : {closed}")
    print(f"  Inflow / closed      : {inflow / closed:.2f}" if closed else "  Inflow / closed      : inf")
    print("Open backlog")
    print(f"  Total open           : {len(open_rows)}")
    median_age = statistics.median(age_days(r) for r in open_rows) if open_rows else 0
    print(f"  Median age (days)    : {median_age:.1f}")
    print(f"  Open and in KEV      : {len(open_kev)}")
    print(f"  KEV older than {KEV_SLA_DAYS}d   : {len(overdue)}")
    if closed and inflow / closed > 1:
        print("\nRatio above 1: backlog is growing faster than you can close it.")


if __name__ == "__main__":
    main()

Sample output on made-up data:

Last 30 days
  New applicable vulns : 7
  Closed               : 2
  Inflow / closed      : 3.50
Open backlog
  Total open           : 6
  Median age (days)    : 6.8
  Open and in KEV      : 2
  KEV older than 7d   : 2

Ratio above 1: backlog is growing faster than you can close it.

What this script does not do

  • It ignores reachability, so a vulnerable library you never call counts the same as one on a hot path.
  • KEV only lists vulnerabilities with confirmed exploitation. An exploit-prediction score such as EPSS would add a forward-looking signal.
  • It does not deduplicate. If your scanner reports the same CVE on 40 services, you will see 40 rows, which may or may not be what you want.
  • The 7-day target is arbitrary. CISA's BOD 26-04 uses three days for its highest-risk combination, but that directive applies to US federal agencies and uses a richer risk model than a simple KEV match. Treat it as a starting point. Run it weekly from a scheduled CI job and watch the ratio over time. The trend matters more than any single number.

Practices that raise capacity

You cannot make upstream maintainers faster, but you can raise how much your own pipeline absorbs.

  • Know your dependencies. An SBOM per service lets you match an upstream fix to your systems in minutes.
  • Automate routine updates. Dependabot or Renovate, plus tests you trust, make patching a merge instead of a project.
  • Make rollback cheap. Canary or progressive deploys let you ship fixes faster because a bad one is easy to undo.
  • Use more than one data source. Since NVD narrowed enrichment, combine vendor advisories, the GitHub Advisory Database, KEV and exploit-prediction data.
  • Contain what you cannot patch in time. Segmentation, least privilege, secret rotation, runtime protection and good detection reduce the blast radius of a late fix. Field note from a real team. Last month a critical PHP vulnerability surfaced that had sat in our codebase for over ten years, and a similar issue appeared in a Node.js dependency shortly after. Legacy systems were affected, and the fix pushed our regular release out by one sprint. The bottleneck was not writing the patch. It was the security review and testing around it, which is the same capacity gap this post measures.

Reading list

Papers:

If you try the script, I would like to know what your inflow to closed ratio looks like. And if you track patch capacity in a different way, please share it in the comments. I am always looking for better indicators.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.