diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f7fc72..e9f543c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,12 @@ what has already shipped, and for nothing else. See `RELEASING.md`. `POST /api/watchlist/{name}` takes `version` and `cpe` with the CLI's checks, `GET /api/watchlist` adds `entries`, and `POST /api/scan` judges with the CLI's function (`affected.mark`) and returns the split per product in `verdicts`. +- **A CVE from KEV or MSRC alone is judged through NVD's record of it.** They carry no + `cpeMatch`, so for an entry with an installed version such a CVE could only be "not + evaluable" — and KEV's are the ones being exploited. For an entry that can be judged, the + scan, CLI and API alike, now asks NVD about each by id (`nvd.records_for`, `cveId=`), paced + like every request to NVD, and merges the record in, score included. When NVD cannot be + asked, the CVE stays not evaluable and the scan says so (the API in `errors`). - **docs/DESIGN.md.** The code referred to "the versionradar design, §5" for these columns; the text was never committed and could not be found. It is reconstructed from what `affected.py`, `inventory.py` and `tests/test_priority_wiring.py` already committed to. diff --git a/README.md b/README.md index 146a7a7..7bb1797 100644 --- a/README.md +++ b/README.md @@ -88,9 +88,10 @@ affected versions: - **affected** — listed in the table, with "fixed in X" only where NVD states it; - **not evaluable** — NVD has not yet said which products the CVE affects (the - usual state for weeks after publication), the CVE came from MSRC or KEV alone, - or NVD's versions belong to another scheme: listed, because "cannot tell" is - not an all-clear; + usual state for weeks after publication), NVD could not be asked about a CVE + that came from KEV or MSRC alone — those are looked up in NVD by id — or NVD's + versions belong to another scheme: listed, because "cannot tell" is not an + all-clear; - **not affected** — counted, and listed only with `--all`. One line gives the split, one says which update closes how many of the diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 479e72a..794d694 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -78,9 +78,14 @@ name is judged against the installed version with NVD's own `configurations`: version of this product is not among them. - **not evaluable** — anything that cannot be decided: NVD has not yet stated which products the CVE affects (no `configurations`, the usual state of a CVE - for weeks after publication), the CVE came from a source other than NVD and - NVD's record was not part of the scan, or NVD's versions belong to another - scheme than the installed one (`2.07` against `16.0.14334`). + for weeks after publication), NVD could not be asked about a CVE that came + from KEV or MSRC alone, or NVD's versions belong to another scheme than the + installed one (`2.07` against `16.0.14334`). + +A CVE that reached the scan only through KEV or MSRC, which carry no +`cpeMatch`, is looked up in NVD by id (`cveId=`) for an entry that can be judged, +and NVD's record is merged in — configurations and score with it. Paced like +every request to NVD; an entry matched by name costs no lookup. "Not evaluable" never becomes "not affected": for a security tool an answer it cannot give is not an absolution. diff --git a/docs/nis2-compliance.md b/docs/nis2-compliance.md index 7e524c2..ba66570 100644 --- a/docs/nis2-compliance.md +++ b/docs/nis2-compliance.md @@ -107,7 +107,7 @@ cosa ricevono: | Host | Quando | Cosa riceve | | --- | --- | --- | -| `services.nvd.nist.gov` | `scan`, da riga di comando o dall'interfaccia web | il nome di ogni software della watchlist (o quello passato a `scan`) come parola chiave di ricerca, con l'intervallo di date; la chiave `NVD_API_KEY`, se impostata, in un'intestazione | +| `services.nvd.nist.gov` | `scan`, da riga di comando o dall'interfaccia web | il nome di ogni software della watchlist (o quello passato a `scan`) come parola chiave di ricerca, con l'intervallo di date; per un software con versione installata e CPE, l'identificativo di ogni CVE trovata solo da CISA KEV o MSRC (`cveId`); la chiave `NVD_API_KEY`, se impostata, in un'intestazione | | `api.msrc.microsoft.com` | `scan` | il mese dei bollettini richiesti; nessun nome di software, il filtro avviene sulla macchina | | `www.cisa.gov` | `scan` | nulla: scarica il catalogo KEV intero e lo filtra sulla macchina | | `security-tracker.debian.org` | `scan` dall'interfaccia web; `patchradar debian` senza `--file` | nulla: scarica il tracker intero e lo filtra sulla macchina | diff --git a/src/patchradar/api/main.py b/src/patchradar/api/main.py index 54458a6..b2beabd 100644 --- a/src/patchradar/api/main.py +++ b/src/patchradar/api/main.py @@ -21,6 +21,7 @@ from patchradar.collectors.kev import fetch_cves as kev_fetch from patchradar.collectors.msrc import fetch_cves as msrc_fetch from patchradar.collectors.nvd import fetch_cves as nvd_fetch +from patchradar.collectors.nvd import with_records_for_the_rest from patchradar.collectors.vuldb import api_key as vuldb_api_key from patchradar.collectors.vuldb import fetch_cves as vuldb_fetch from patchradar.cpe import CpeError, parse_cpe @@ -346,6 +347,15 @@ async def _scan_one(sw: str, days: int, errors: list[dict], entry: WatchEntry | # and was counted twice — so the two scans gave different totals for the # same machine, which is what keeping them in step was meant to prevent. collected = merge_by_cve(collected) + # As in the CLI: what KEV or MSRC alone found is looked up in NVD by id, + # for an entry that can be judged. + installed = entry.installed() if entry is not None else None + if installed is not None: + collected, _, failure = await with_records_for_the_rest(collected, sw) + if failure is not None: + logger.warning("scan of %r: NVD could not be asked by id: %s", sw, failure) + errors.append({"software": sw, "source": failure.source, + "reason": failure.reason, "status": failure.status}) # FIRST is asked once per package about everything the sources returned, # before any of it is stored, so the EPSS fields land in the same row as the # CVE rather than in a second pass over the database. It cannot change the @@ -358,7 +368,6 @@ async def _scan_one(sw: str, days: int, errors: list[dict], entry: WatchEntry | "reason": exc.reason, "status": exc.status}) # Judged by the function the CLI judges with, before anything is stored, so # the verdict lands with the link and the two scans give one answer. - installed = entry.installed() if entry is not None else None mark(collected, installed) if installed is not None and verdicts is not None: found = [cve["verdict"] for cve in collected] diff --git a/src/patchradar/cli.py b/src/patchradar/cli.py index af0bc89..ba43835 100644 --- a/src/patchradar/cli.py +++ b/src/patchradar/cli.py @@ -23,7 +23,7 @@ from patchradar.collectors.msrc import MAX_DAYS_BACK as msrc_max_days from patchradar.collectors.msrc import fetch_cves as msrc_fetch from patchradar.collectors.nvd import api_key as nvd_api_key -from patchradar.collectors.nvd import fetch_cves +from patchradar.collectors.nvd import fetch_cves, with_records_for_the_rest from patchradar.collectors.vuldb import SOURCE as vuldb_source from patchradar.collectors.vuldb import api_key as vuldb_api_key from patchradar.collectors.vuldb import fetch_cves as vuldb_fetch @@ -399,6 +399,14 @@ async def _scan_target( # `severity: "UNKNOWN"`. The count therefore also changes meaning, from # records returned to CVEs found — which is what the line claims to say. all_cves = merge_by_cve(all_cves) + # For an entry the scan can judge, the CVEs that came from KEV or MSRC alone + # are looked up in NVD by id: without NVD's configurations they could only + # be "not evaluable", and KEV's are the ones being exploited. + installed = entry.installed() if entry is not None else None + unasked, lookup_failure = 0, None + if installed is not None: + with _status(f"[cyan]Asking NVD about what other sources found for {safe_target}...[/cyan]"): + all_cves, unasked, lookup_failure = await with_records_for_the_rest(all_cves, target) # FIRST is asked about the CVEs the scan found, in one request, after the # merge and before the sort: `priority` ranks on the EPSS fields, so # enriching after the sort would order the report on the facts it had a @@ -417,7 +425,6 @@ async def _scan_target( # each CVE found by name is judged against the version with NVD's own # configurations, and the ones that do not affect it are counted, not # listed, unless --all asks for them. - installed = entry.installed() if entry is not None else None judged = installed is not None mark(all_cves, installed) for cve in all_cves: @@ -436,6 +443,11 @@ async def _scan_target( f"⚠️ [yellow]{escape(str(failure))}[/yellow] — " f"results for [bold]{safe_target}[/bold] are incomplete" ) + if lookup_failure is not None: + console.print(Text( + f"⚠️ NVD could not be asked about {unasked} CVE{'s' if unasked != 1 else ''} " + f"from other sources ({lookup_failure}) — not evaluable for {target}", style="yellow" + )) if epss_failure is not None: console.print( f"⚠️ [yellow]{escape(str(epss_failure))}[/yellow] — " diff --git a/src/patchradar/collectors/nvd.py b/src/patchradar/collectors/nvd.py index 7adc500..f2b95b1 100644 --- a/src/patchradar/collectors/nvd.py +++ b/src/patchradar/collectors/nvd.py @@ -9,6 +9,7 @@ from patchradar.collectors.errors import BAD_PAYLOAD, CollectorError, from_http_error from patchradar.cvss import exploit_maturity +from patchradar.priority import merge_by_cve logger = logging.getLogger(__name__) @@ -336,6 +337,31 @@ async def _fetch_window( detail=f"empty page at {start_index} of {total}") +def _pacer(sleep: Callable[[float], Awaitable[None]], delay: float) -> Callable[[], Awaitable[None]]: + """What one call awaits before each of its requests to NVD.""" + sent = 0 + + async def pace() -> None: + # Inside one call the previous request has only just been answered, so + # the whole interval is waited, as before. Before the first request the + # clock is read: a call that starts inside the interval left by the last + # request this process sent — whichever keyword sent it — waits for + # what is left of it, and one that starts later waits for nothing. A + # request that failed counts too: a 403 is the limit speaking. + global _last_request_at + nonlocal sent + if sent: + await sleep(delay) + elif _last_request_at is not None: + remaining = delay - (_monotonic() - _last_request_at) + if remaining > 0: + await sleep(remaining) + sent += 1 + _last_request_at = _monotonic() + + return pace + + async def fetch_cves( keyword: str, days_back: int = 7, *, wait: Callable[[float], Awaitable[None]] | None = None, @@ -359,30 +385,9 @@ async def fetch_cves( caller hands over its own: a test that is about the pacing passes one that writes down what it was asked to wait, rather than replacing `_pace`. """ - sleep = wait or _pace key = api_key() headers = {"apiKey": key} if key else {} - delay = KEYED_DELAY_SECONDS if key else KEYLESS_DELAY_SECONDS - - sent = 0 - - async def pace() -> None: - # Inside one call the previous request has only just been answered, so - # the whole interval is waited, as before. Before the first request the - # clock is read: a call that starts inside the interval left by the last - # request this process sent — whichever keyword sent it — waits for - # what is left of it, and one that starts later waits for nothing. A - # request that failed counts too: a 403 is the limit speaking. - global _last_request_at - nonlocal sent - if sent: - await sleep(delay) - elif _last_request_at is not None: - remaining = delay - (_monotonic() - _last_request_at) - if remaining > 0: - await sleep(remaining) - sent += 1 - _last_request_at = _monotonic() + pace = _pacer(wait or _pace, KEYED_DELAY_SECONDS if key else KEYLESS_DELAY_SECONDS) results: list[dict] = [] async with httpx.AsyncClient(timeout=30.0, headers=headers) as client: @@ -394,3 +399,56 @@ async def pace() -> None: start += timedelta(days=1) await _fetch_window(client, keyword, start, end, pace, results) return results + + + +async def records_for( + cve_ids: list[str], keyword: str, *, + wait: Callable[[float], Awaitable[None]] | None = None, +) -> list[dict]: + """NVD's records of these CVEs, asked for one id at a time (`cveId=`). + + For the CVEs a scan found through a source that carries no `cpeMatch` — + CISA KEV, MSRC — so that an installed version can be judged against them + (docs/DESIGN.md §5.3). Paced like every other request to NVD, and an id NVD + does not know simply gives nothing. A failure raises with the records + already fetched as `partial`. + """ + key = api_key() + headers = {"apiKey": key} if key else {} + pace = _pacer(wait or _pace, KEYED_DELAY_SECONDS if key else KEYLESS_DELAY_SECONDS) + results: list[dict] = [] + async with httpx.AsyncClient(timeout=30.0, headers=headers) as client: + for cve_id in cve_ids: + await pace() + try: + response = await client.get(NVD_API, params={"cveId": cve_id}) + response.raise_for_status() + except httpx.HTTPError as exc: + raise from_http_error(SOURCE, exc, partial=results) from exc + try: + data = response.json() + except ValueError as exc: + raise CollectorError(SOURCE, BAD_PAYLOAD, status=response.status_code, partial=results) from exc + vulnerabilities = data.get("vulnerabilities") if isinstance(data, dict) else None + if isinstance(vulnerabilities, list): + results.extend(_parse_page(vulnerabilities, keyword)) + return results + + +async def with_records_for_the_rest(records: list[dict], keyword: str) -> tuple[list[dict], int, CollectorError | None]: + """`records`, merged with NVD's record of each one no NVD record came with. + + Returns the merged records, how many CVEs NVD could not be asked about, and + the failure that stopped it, if any. The ones left without a record stay as + they were, and the verdict on them says so. + """ + missing = [str(r["id"]) for r in records if "configurations" not in r and r.get("id")] + if not missing: + return records, 0, None + try: + found = await records_for(missing, keyword) + failure = None + except CollectorError as exc: + found, failure = exc.partial, exc + return merge_by_cve(records + found), (len(missing) - len(found) if failure else 0), failure diff --git a/tests/test_judge_through_nvd.py b/tests/test_judge_through_nvd.py new file mode 100644 index 0000000..bf09d24 --- /dev/null +++ b/tests/test_judge_through_nvd.py @@ -0,0 +1,196 @@ +""" +PatchRadar — a CVE from KEV or MSRC is judged through NVD's record of it (DESIGN §5.3) + +CISA KEV and MSRC carry no `cpeMatch`, so for an entry with an installed version +a CVE that reached the scan only through them was "not evaluable": NVD's record +was not part of the scan. Those are the CVEs that matter most — KEV lists what +is being exploited — and the scan had the means to ask. For an entry it can +judge, it now asks NVD about each such CVE by id (`cveId=`) and merges the record +in, which brings NVD's configurations and its score with it. A CVE NVD cannot be +asked about stays not evaluable, and the scan says why. + +NVD answers from recordings: `nvd_cve_2026_48092.json` (cveId=CVE-2026-48092, +7-Zip, 2026-10-07; affected 9.34 <= v < 26.01) and `nvd_no_results.json` for the +keyword search. The KEV catalogue is **derived**: CVE-2026-48092 is not in KEV, +so one entry is written in KEV's shape (the field names of +`tests/fixtures/kev_sample.json`) to stand for an exploited CVE that NVD's keyword +search did not return. +""" +from __future__ import annotations + +import json +import pathlib +import time + +import httpx +import pytest +import respx +from typer.testing import CliRunner + +from patchradar import cli +from patchradar.api import main as api_main +from patchradar.collectors import kev, msrc, nvd +from patchradar.collectors.errors import CollectorError +from patchradar.db import database + +FIXTURES = pathlib.Path(__file__).parent / "fixtures" +CVE_48092 = json.loads((FIXTURES / "nvd_cve_2026_48092.json").read_text(encoding="utf-8")) +NVD_EMPTY = json.loads((FIXTURES / "nvd_no_results.json").read_text(encoding="utf-8")) +SEVEN_ZIP = "cpe:2.3:a:7-zip:7-zip" + +KEV_DERIVED = { + "catalogVersion": "derived", "count": 1, + "vulnerabilities": [{ + "cveID": "CVE-2026-48092", + "vendorProject": "7-Zip", + "product": "7-Zip", + "vulnerabilityName": "7-Zip Derived Entry For Tests", + "dateAdded": "2026-06-10", + "shortDescription": "Stands for an exploited 7-Zip CVE; not a real KEV entry.", + "requiredAction": "Apply updates per vendor instructions.", + "dueDate": "2026-07-01", + "knownRansomwareCampaignUse": "Unknown", + }], +} + + +@pytest.fixture +def kev_lists_it(): + kev._snapshot = KEV_DERIVED + kev._snapshot_at = time.monotonic() + + +@pytest.fixture +def nvd_router(): + with respx.mock(assert_all_called=False) as router: + router.get(url__startswith=nvd.NVD_API, params__contains={"keywordSearch": "7-zip"}).mock( + return_value=httpx.Response(200, json=NVD_EMPTY)) + by_id = router.get(url__startswith=nvd.NVD_API, params__contains={"cveId": "CVE-2026-48092"}).mock( + return_value=httpx.Response(200, json=CVE_48092)) + router.get(url__startswith=msrc.MSRC_API).mock(return_value=httpx.Response(404)) + router.route().mock(return_value=httpx.Response(503)) # Debian, for the API + yield by_id + + +# ─── nvd.records_for ───────────────────────────────────────────────────────── + +async def test_nvd_is_asked_by_id_and_answers_with_the_configurations(nvd_router): + [record] = await nvd.records_for(["CVE-2026-48092"], "7-zip") + + assert record["id"] == "CVE-2026-48092" + assert record["software"] == "7-zip" + assert record["configurations"] + assert nvd_router.call_count == 1 + + +async def test_an_id_nvd_does_not_know_gives_nothing(): + with respx.mock() as router: + router.get(url__startswith=nvd.NVD_API).mock(return_value=httpx.Response(200, json=NVD_EMPTY)) + assert await nvd.records_for(["CVE-2026-00001"], "7-zip") == [] + + +async def test_nvd_refusing_is_a_collector_error_with_what_it_had(): + with respx.mock() as router: + router.get(url__startswith=nvd.NVD_API).mock(return_value=httpx.Response(503)) + with pytest.raises(CollectorError) as raised: + await nvd.records_for(["CVE-2026-48092"], "7-zip") + + assert raised.value.partial == [] + + +# ─── the CLI scan ──────────────────────────────────────────────────────────── + +def _scan(version="24.09", cpe=SEVEN_ZIP): + runner = CliRunner() + added = ["add", "7-zip"] + (["--version", version] if version else []) + (["--cpe", cpe] if cpe else []) + assert runner.invoke(cli.app, added).exit_code == 0 + result = runner.invoke(cli.app, ["scan", "7-zip", "--days", "7", "--all"], env={"COLUMNS": "200"}) + assert result.exit_code == 0, result.output + return " ".join(result.output.split()) + + +def test_an_exploited_cve_from_kev_alone_is_judged_affected(kev_lists_it, nvd_router): + out = _scan() + + assert "CVE-2026-48092" in out + assert "affecting 24.09: 1; not evaluable: 0" in out + assert "NVD states the fix is in 26.01" in out + + +def test_nvd_s_record_brings_its_score_to_the_kev_entry(kev_lists_it, nvd_router): + """KEV states no severity; NVD's record, merged in, does.""" + out = _scan() + + assert "UNKNOWN" not in out + + +def test_the_fixed_version_is_not_affected(kev_lists_it, nvd_router): + assert "affecting 26.01: 0; not evaluable: 0; not affecting it: 1" in _scan(version="26.01") + + +def test_an_entry_matched_by_name_does_not_cost_a_request(kev_lists_it, nvd_router): + _scan(version=None, cpe=None) + + assert nvd_router.call_count == 0 + + +def test_when_nvd_cannot_be_asked_the_cve_stays_not_evaluable_and_the_scan_says_so(kev_lists_it): + with respx.mock(assert_all_called=False) as router: + router.get(url__startswith=nvd.NVD_API, params__contains={"keywordSearch": "7-zip"}).mock( + return_value=httpx.Response(200, json=NVD_EMPTY)) + router.get(url__startswith=nvd.NVD_API, params__contains={"cveId": "CVE-2026-48092"}).mock( + return_value=httpx.Response(503)) + router.get(url__startswith=msrc.MSRC_API).mock(return_value=httpx.Response(404)) + out = _scan() + + assert "affecting 24.09: 0; not evaluable: 1" in out + assert "NVD could not be asked about 1 CVE" in out + + +# ─── the API scan ──────────────────────────────────────────────────────────── + +async def test_the_api_judges_the_kev_entry_through_nvd_too(kev_lists_it, nvd_router): + from patchradar.cpe import parse_cpe + + await database.add_to_watchlist("7-zip") + await database.set_installed("7-zip", version="24.09", cpe=parse_cpe(SEVEN_ZIP)) + [entry] = await database.get_watch_entries() + verdicts: dict = {} + + await api_main._scan_one("7-zip", 7, [], entry, verdicts) + + assert verdicts == {"7-zip": {"affected": 1, "not evaluable": 0, "not affected": 0}} + + +def test_a_cve_nvd_already_returned_is_not_asked_for_again(kev_lists_it): + """NVD's keyword search found it too, configurations and all: the merged + row has them, and a second request would cost a rate-limit slot for nothing.""" + with respx.mock(assert_all_called=False) as router: + router.get(url__startswith=nvd.NVD_API, params__contains={"keywordSearch": "7-zip"}).mock( + return_value=httpx.Response(200, json=CVE_48092)) + by_id = router.get(url__startswith=nvd.NVD_API, params__contains={"cveId": "CVE-2026-48092"}).mock( + return_value=httpx.Response(200, json=CVE_48092)) + router.get(url__startswith=msrc.MSRC_API).mock(return_value=httpx.Response(404)) + out = _scan() + + assert by_id.call_count == 0 + assert "affecting 24.09: 1" in out + + +async def test_the_api_lists_nvd_refusing_the_lookup_among_its_errors(kev_lists_it): + from patchradar.cpe import parse_cpe + + await database.add_to_watchlist("7-zip") + await database.set_installed("7-zip", version="24.09", cpe=parse_cpe(SEVEN_ZIP)) + [entry] = await database.get_watch_entries() + errors: list[dict] = [] + with respx.mock(assert_all_called=False) as router: + router.get(url__startswith=nvd.NVD_API, params__contains={"keywordSearch": "7-zip"}).mock( + return_value=httpx.Response(200, json=NVD_EMPTY)) + router.get(url__startswith=nvd.NVD_API, params__contains={"cveId": "CVE-2026-48092"}).mock( + return_value=httpx.Response(503)) + router.get(url__startswith=msrc.MSRC_API).mock(return_value=httpx.Response(404)) + router.route().mock(return_value=httpx.Response(503)) + await api_main._scan_one("7-zip", 7, errors, entry, {}) + + assert {"software": "7-zip", "source": "NVD", "reason": "server_error", "status": 503} in errors