DEV Community

takahiro hashito
takahiro hashito

Posted on

Your CVSS >= 7.0 filter is silently dropping the CVEs you most want

The problem: too much input

A CVE (Common Vulnerabilities and Exposures) is the globally unique ID assigned to a single vulnerability, in the form CVE-2026-85706. The NVD (National Vulnerability Database, run by the US government) collects them, and it registers anywhere from a few dozen to well over a hundred entries per day. Reading all of that by hand is not an option for a side project.

So I wrote a collector that keeps only the serious ones. CVSS (Common Vulnerability Scoring System) rates severity from 0.0 to 10.0, where 7.0 and above is High and 9.0 and above is Critical. It is a number, so a machine can cut on it.

The first version of that cut threw away exactly the entries I cared about most. This post is about where the threshold belonged.

The shape of the collector

Two sources feed one merge step:

                     ┌──────────────────────────┐
  CISA KEV ─────────▶│ diff against known IDs   │
  (exploited)        │ (data/vulns.json)        │──▶ new candidates
  NVD ──────────────▶│                          │
  (last N days)      └──────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

CISA KEV (short for Known Exploited Vulnerabilities Catalog) is a list of entries that CISA has confirmed are being exploited in the wild. It ships as a single JSON file. NVD is the full database, queryable by date range and severity.

The rule I settled on:

("severity is High/Critical and CVSS >= 7.0") OR ("listed in CISA KEV")

The OR is the whole point.

What went wrong

The obvious implementation merges first and filters once:

// first version (wrong)
const candidates = [...fromKEV(), ...fromNVD()];
const filtered = candidates.filter((c) => c.cvssScore >= 7.0);
Enter fullscreen mode Exit fullscreen mode

Run that and zero KEV entries survive. The KEV feed has no CVSS field at all. Here is a real record:

{
  "cveID": "CVE-2026-85706",
  "vendorProject": "GitLab",
  "product": "Community Edition and Enterprise Edition",
  "vulnerabilityName": "GitLab Community Edition and Enterprise Edition Path Traversal Vulnerability",
  "dateAdded": "2026-09-11",
  "dueDate": "2026-09-14",
  "knownRansomwareCampaignUse": "Unknown"
}
Enter fullscreen mode Exit fullscreen mode

No cvssScore. In JavaScript, undefined >= 7.0 is false, so every KEV candidate is dropped. No exception. Nothing in the logs. The count just goes to zero.

And what was being dropped was the highest priority half of the input. KEV membership means the vulnerability is being exploited right now, not that a scoring rubric says it could be bad. A 6.5 that attackers are using outranks a 9.8 that nobody has touched. A filter meant to keep the serious items was deleting the ones under active attack.

The fix: push the filter into the source

The filter moves out of the merge step and into each fetcher. The KEV fetcher below reads the catalog JSON, sorts by dateAdded descending, and skips only IDs already present in known (the set of CVE IDs the site has published). There is no score comparison anywhere in it.

// KEV source: no CVSS cut. Confirmed exploitation is the evidence.
async function fromKEV(known, max) {
  const data = await getJSON(KEV_URL);
  const vulns = (data && data.vulnerabilities) || [];
  vulns.sort((a, b) =>
    String(b.dateAdded || "").localeCompare(String(a.dateAdded || "")),
  );
  const out = [];
  for (const v of vulns) {
    const id = String(v.cveID || "").toUpperCase();
    if (!id || known.has(id)) continue;
    out.push({ cveId: id, source: "CISA KEV", /* ...trimmed... */ });
    if (out.length >= max) break;
  }
  return out;
}

// NVD source: not in KEV, so the score is the only evidence. Enforce 7.0 here.
for (const it of items) {
  const m =
    (metrics.cvssMetricV31 && metrics.cvssMetricV31[0]) ||
    (metrics.cvssMetricV30 && metrics.cvssMetricV30[0]);
  const cvss = m && m.cvssData ? m.cvssData.baseScore : null;
  if (cvss != null && cvss < 7.0) continue;
  out.push({ cveId: id, source: "NVD", cvssScore: cvss, /* ...trimmed... */ });
}
Enter fullscreen mode Exit fullscreen mode

The NVD loop is the other half of the same file. It walks the API results, pulls the base score out of whichever CVSS metric block is present (v3.1 first, v3.0 as fallback), and applies the 7.0 cut there and only there.

The cvss != null guard matters. NVD occasionally serves an entry with no score yet (still being analysed). Those are kept rather than dropped: "cannot be judged by score" must not collapse into "fails the score check", or you have built the same silent-deletion path a second time.

Output after the fix:

$ node automation/scripts/cve/fetch-critical.js --max 3 --days 7
{
  "generatedFrom": ["CISA KEV", "NVD(last 7 days)"],
  "knownCount": 317,
  "candidates": [
    { "cveId": "CVE-2026-85706", "source": "CISA KEV", "vendorProject": "GitLab",  ... },
    { "cveId": "CVE-2026-86060", "source": "CISA KEV", "vendorProject": "MikroTik", ... },
    { "cveId": "CVE-2026-67277", "source": "CISA KEV", "vendorProject": "MikroTik", ... }
  ],
  "errors": []
}
Enter fullscreen mode Exit fullscreen mode

Every candidate carries source, so the evidence behind each pick is auditable later. Without it there is no way to check whether the rule was right.

Two things that bit me

1. The NVD API returns ancient CVEs unless you pass dates.

Query with cvssV3Severity=CRITICAL alone and you get the head of the registration order, which is CVEs from around 2002. I was collecting "new critical vulnerabilities" and receiving twenty-year-old ones.

pubStartDate and pubEndDate must be sent as a pair, and the span is capped at 120 days. Ordering is not guaranteed either, so sort by publication date yourself after fetching.

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

2. "Could not fetch" and "found nothing" must not look alike.

Same failure shape as the threshold bug. A dropped connection or a rate limit both produce "0 candidates" if you write it naively, and the caller cannot tell that apart from a genuinely quiet day.

So errors are collected instead of swallowed, and a non-empty error list sets the exit code:

result.newCount = result.candidates.length;
if (result.errors.length > 0) process.exitCode = 1;
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
Enter fullscreen mode Exit fullscreen mode

A successful fetch that returned nothing exits 0 with errors: []. The caller can now treat the two cases differently. Never let a missing measurement pass as a green result.

Demo

One of the sites running this collector: https://cve.autoarticles.net

Takeaway

Cutting noise with a threshold was the right idea. The mistake was where the cut lived.

Generalised: write filters per data source, not over the merged set. Merge first and apply one shared condition, and every source lacking that field disappears completely and silently. Here that was the one source I could least afford to lose.

Before you make a field part of a condition, confirm it always exists on that path. If it might not, spell out the != null case so "unknown" does not collapse into "rejected". And always report a fetch failure through a different exit than a measured zero.


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

Top comments (1)

Collapse
 
raknaos profile image
Raknaos

undefined >= 7.0 is false and the whole KEV half disappears — the failure mode that makes it nasty is that it fails toward silence. You only caught it because you counted; on a dashboard without an invariant, a zero count just looks like a quiet day. I hit the same shape in a log retention filter where the timestamp arrived as a string, and a string comparison kept the oldest window instead of dropping it.

On the rule itself, the OR is right, but I'd tag KEV membership at ingest rather than only in the merge result: NVD CVSS answers "how bad could this be" and KEV answers "someone is doing it now", and collapsing both into one boolean at filter time means you can't later rank by exploited without re-fetching the feed.

What happens to an entry that has no CVSS yet and isn't in KEV — dropped at ingest, or parked in a second bucket? NVD scores some CVEs late and others never (awaiting analysis), so a first-pass drop makes that miss permanent.