DEV Community

takahiro hashito
takahiro hashito

Posted on

A threshold that filters CVEs is only half a design until zero has two meanings

Introduction

I run a site that publishes vulnerability write-ups. Every day a script pulls freshly published vulnerabilities from two upstream sources and does a first-pass triage before I write anything.

Quick glossary, since these acronyms carry the whole article:

  • CVE (Common Vulnerabilities and Exposures) is the globally unique identifier assigned to a single vulnerability, in the form CVE-2026-50148.
  • CVSS (Common Vulnerability Scoring System) expresses severity as a number from 0.0 to 10.0. 7.0 and above is High; 9.0 and above is Critical.
  • NVD (National Vulnerability Database) is the vulnerability database run by NIST. It holds the details and CVSS score for each CVE.
  • KEV (Known Exploited Vulnerabilities) is a catalog published by CISA. Only vulnerabilities with confirmed in-the-wild exploitation are listed.

NVD publishes dozens to hundreds of CVEs per day. Nobody reads all of them. So I set a threshold: keep only CVSS 7.0 and above.

This post is about the two things that threshold got wrong before it got right.

The shape of the collector

Two independent sources:

  1. KEV — everything in it, regardless of score
  2. NVD — only CVSS >= 7.0, restricted to the last N days

They are separate on purpose, and not for redundancy. They answer different questions. KEV selects on a fact: this is being exploited right now. A CVSS score is a prediction: this would be severe if exploited. Merge them into one list and sort by number, and the distinction disappears.

Concretely: local privilege-escalation bugs usually land around 7.0, because on their own they cannot get an attacker in. Plenty of them are in KEV anyway, because they are the second half of a real attack chain. Sort by score and they sink.

Implementation

The threshold itself is one line.

const cvss = m && m.cvssData ? m.cvssData.baseScore : null;
if (cvss != null && cvss < 7.0) continue;
Enter fullscreen mode Exit fullscreen mode

The important half is cvss != null, not 7.0.

Drop it and write if (cvss < 7.0) continue; and you silently discard every CVE that has not been scored yet, because JavaScript coerces null to 0 in numeric comparison, and 0 < 7.0 is true.

Freshly published CVEs are frequently unscored. So the naive version quietly deletes exactly the newest entries — the ones you most wanted to see.

Writing a threshold is rarely about picking the boundary. It is about deciding what happens to the values that live outside the number line: null, empty string, not-yet-evaluated, not-applicable. Those four default to being dropped unless you say otherwise.

There was a second trap, in the NVD API itself. Without date parameters it returns results in registration order, which means the oldest records first. My first run happily collected CVEs from 2002. pubStartDate and pubEndDate must be supplied together, and the span is capped at 120 days.

function nvdUrl(days) {
  const span = Math.min(Math.max(Number(days) || 30, 1), 120);
  const end = new Date();
  const start = new Date(end.getTime() - span * 24 * 60 * 60 * 1000);
  const iso = (d) => d.toISOString().replace(/\.\d{3}Z$/, ".000");
  const q = new URLSearchParams({
    cvssV3Severity: "CRITICAL",
    resultsPerPage: "40",
    pubStartDate: iso(start),
    pubEndDate: iso(end),
  });
  return `https://services.nvd.nist.gov/rest/json/cves/2.0?${q}`;
}
Enter fullscreen mode Exit fullscreen mode

Note the .000 suffix instead of a trailing Z. NVD is picky about the timestamp format.

The part that actually bit

After filtering, some days return zero candidates. That is correct: nothing matched.

The problem is that a failed fetch also returns zero candidates. CISA not responding, NVD rate-limiting, the network dropping — all of them produce the same output. And the output reads as "nothing to worry about today," which is the single most reassuring answer the system can give you.

Worse, if the failure persists, that reassuring answer repeats every day, and nothing ever surfaces it.

The fix is a few lines: collect errors instead of swallowing them, and let their presence change the exit code.

try {
  result.candidates.push(...(await fromKEV(known, args.max)));
} catch (e) {
  result.errors.push(`KEV: ${e.message}`);
}
if (!args.kevOnly) {
  try {
    const seen = new Set(result.candidates.map((c) => c.cveId));
    const nvd = await fromNVD(known, args.max, args.days);
    result.candidates.push(...nvd.filter((c) => !seen.has(c.cveId)));
  } catch (e) {
    result.errors.push(`NVD: ${e.message}`);
  }
}
result.newCount = result.candidates.length;
if (result.errors.length > 0) process.exitCode = 1;
Enter fullscreen mode Exit fullscreen mode

Now the two zeros are distinguishable:

  • Fetched fine, nothing matched → {"newCount": 0, "errors": []}, exit code 0
  • KEV returned HTTP 503 → {"newCount": 0, "errors": ["KEV: HTTP 503"]}, exit code 1

The caller only has to read the exit code to tell a quiet day from a blind one.

What I would tell my earlier self

An empty catch block looks, at the moment you write it, like you made the script resilient. What you actually did was delete the evidence. The difference only shows up on the day something breaks, which is the one day you needed it.

Lowering the threshold is also the wrong lever. Going from 7.0 to 6.0 more than doubles the candidate count without increasing how much anyone can read. What helped was keeping KEV as a separate track with its own remediation deadline, rather than folding it into the score ranking.

The site

One example of the output in production: https://cve.autoarticles.net

Conclusion

If you are building anything that filters a feed, decide two things, not one.

  1. What gets dropped — the boundary value, and how you treat everything outside the number line
  2. Whether you can tell when the filter did not run — "nothing matched" and "nothing fetched" must not share an exit code

Ship only the first and you get a system that is correct while healthy and quietly wrong while broken. Count how many distinct meanings your zero has. If the answer is more than one, your output needs more than one shape.


This article is about my own side project. It was written with AI assistance.

Top comments (0)