Skip to content

feat(scan): judge each CVE against the installed version - #58

Merged
maksimtech merged 1 commit into
mainfrom
feat/a-scan-that-knows-the-installed-version
Oct 17, 2026
Merged

maksimtech merged 1 commit into
mainfrom
feat/a-scan-that-knows-the-installed-version

Conversation

@maksimtech

Copy link
Copy Markdown
Owner

Audit finding PR-01 (critical) of 2026-10-17, first step: the scan judges CVEs against the installed version.

What was wrong

A scan found the CVEs that name a product and could not say which affect what is installed. The README said "tells you which vulnerabilities affect it". For Notepad++ the recorded NVD answer holds 21 CVEs; for 8.5.0, 11 of them concern other versions or another product, and all 21 were listed and ranked as work to do.

affected.py already knew how to answer (since 2026-09-28) and was deliberately not wired in, waiting for the watchlist columns of "the versionradar design, §5". That text was never committed and could not be found anywhere (E:\Dev, WSL, git history, notes).

What changes

  • docs/DESIGN.md: the design the code referred to, rebuilt from what affected.py, inventory.py and test_priority_wiring.py already committed to. §5 covers the columns, the three verdicts, what the scan prints, what is stored and the order of work (01a, 01b, 01c). The three references in the code now point to it.

  • Watchlist: five nullable columns with no default (cpe, vendor, product_id, installed_version, channel), migrated into existing databases.

  • patchradar add <name> --version <v> --cpe <cpe>:

    • updates an existing entry;
    • refuses a version affected.py cannot order;
    • refuses a malformed CPE. notepad++ is refused with NVD's notepad\+\+ shown, because NVD would match nothing and the scan would read as clean.
    • The CPE is never guessed from the name (cpe.py).
  • affected.judge: affected, not affected or not evaluable, from the record's own NVD configurations, which nvd._parse_item now keeps (an empty list while NVD has not analysed the CVE). "Cannot tell" never becomes "not affected". This covers:

    • no configurations;
    • a CVE from MSRC or KEV alone;
    • a version from another scheme.

    Range.comparable separates "outside the range" from "cannot be ordered", and closed_by is factored out of summarise.

  • scan:

    • the table lists affected and not evaluable CVEs, with a Verdict column; --all adds not affected;
    • one line gives the split, one says what an update closes;
    • the NVD caveat is always printed;
    • entries without a version are scanned by name as before, and named at the end;
    • the total keeps its meaning (CVEs found by name).
  • list shows version and CPE.

  • Not in this PR, per DESIGN §5.6:

    • storing the verdict, judging KEV entries via NVD, and the API (PR-01b);
    • the web UI and import (PR-01c).

Tests (strict TDD)

  • New files:
    • test_cpe.py;
    • test_watchlist_versions.py, with a hand-written pre-migration database;
    • test_affected_verdict.py, on the recorded Notepad++ NVD payload. It agrees with summarise: 10 affected and 11 not affected at 8.5.0, five fixes at 8.9.5;
    • test_scan_versioned.py, a full CLI scan against the same recording replayed with respx: MSRC 404, the KEV conftest catalogue. Derived variants (configurations removed, payload reduced to the affected ten) are declared in each test.
  • All seen failing before the code. Guards added after the first green run (unbounded comparable, --all with nothing hidden, caveat on an empty answer, 5 of 5, set nothing) each kill their mutant: 26/26 mutants killed.
  • test_cli_escaping.py: add called as a function now passes its new options. The list test no longer stubs get_watchlist: the hostile row is written straight into the test database, the way an older version left it.
  • Full suite on Windows: 2169 passed. ruff and mypy clean.

🤖 Generated with Claude Code

A scan found the CVEs that name a product and could not say which affect
what is installed: for Notepad++ 8.5.0 the recorded NVD answer holds 21,
11 of them about other versions or another product, and all 21 were
listed and ranked. affected.py could answer and waited for the watchlist
columns of "the versionradar design, section 5", a text never committed.

- docs/DESIGN.md rebuilds that design from what the code committed to.
- Five nullable watchlist columns (cpe, vendor, product_id,
  installed_version, channel), migrated into existing databases.
- `add --version --cpe`: the CPE typed by a person and checked for form
  (notepad++ is refused with NVD's notepad\+\+), a version refused when
  it cannot be ordered.
- affected.judge: affected, not affected or not evaluable, from the NVD
  record's own configurations, which nvd._parse_item now keeps. Cannot
  tell never becomes not affected.
- scan lists affected and not evaluable, --all the rest; it gives the
  split, what an update closes, and always the NVD caveat; entries
  without a version are scanned by name and named.

Written test first and seen failing; the guards added after the first
green run each kill their mutant, 26 of 26.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
if fields:
# Column names from the literal keys above, never from input.
assignments = ", ".join(f"{column} = ?" for column in fields)
await db.execute(f"UPDATE watchlist SET {assignments} WHERE name = ?",
@codspeed

codspeed Bot commented Oct 17, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

⚠️ 11 benchmarks spent significant time in system calls

System calls cannot be consistently instrumented, so they are not included in the measure, which understates the real cost. Please switch to the Walltime instrument to accurately measure system calls.

Measurement and system calls

✅ 17 untouched benchmarks


Comparing feat/a-scan-that-knows-the-installed-version (df6f195) with main (a66861a)

Open in CodSpeed

@sonarqubecloud

Copy link
Copy Markdown

@codecov

codecov Bot commented Oct 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@maksimtech
maksimtech merged commit 2e2fdc8 into main Oct 17, 2026
25 checks passed
@maksimtech
maksimtech deleted the feat/a-scan-that-knows-the-installed-version branch October 17, 2026 15:00
maksimtech added a commit that referenced this pull request Oct 17, 2026
Bandit's B608 flagged three: the watchlist UPDATE whose column list was
joined at run time (#58, open on main as alert 61) and the two listings
of get_cves whose select list and ORDER BY were interpolated (#64,
alerts 64 and 65). Every interpolated piece was a module constant, so no
input reached them - but the project takes no `# nosec`, and a statement
written out leaves nothing to vouch for.

- set_installed / set_source run fixed UPDATE statements, one per field.
- get_cves' two listings and the priority index are written out; their
  ORDER BY is priority.sql_order() verbatim, and the new test holds the
  text to it, so the one-source rule stands without building from it.
  EXPLAIN still shows the listing scanning the priority index.

Test first and seen failing: no SELECT/INSERT/UPDATE/DELETE passed to
execute() may be an f-string or a concatenation. test_priority_wiring
now reads the written-out ORDER BY. Bandit B608: no issues.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
maksimtech added a commit that referenced this pull request Oct 17, 2026
…01) (#64)

* feat(verdict): keep the verdict per product, and judge over HTTP too

PR-01a judged each CVE against the installed version in memory; the next
`status`, /api/cves and the web UI went back to CVEs by name, and a scan
over HTTP did not judge at all, so the two scans disagreed.

- cve_software gains verdict, verdict_reason, verdict_fixed_version and
  judged_version, migrated into existing databases: per product, since
  one CVE can affect one watched product and not another. The latest
  scan decides it.
- get_cves shows a verdict only while the installed version is still the
  one it was given for: a stale "affected" sends someone to patch what
  is patched, a stale "not affected" is worse.
- affected.mark is the one place both scans judge.
- The API takes version and cpe on POST /api/watchlist/{name} with the
  CLI's checks, lists entries on GET /api/watchlist, and returns the
  split per product from POST /api/scan. `status` gets a Verdict column.

Tests first, seen failing; 15 of 15 mutants killed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* perf(verdict): read the current verdict in the join, one query for the watchlist

CodSpeed on #59: /api/watchlist 24% slower, get_cves and the /api/cves
listings 14-17%.

- /api/watchlist read the names and the entries in two queries; the names
  now come from the entries.
- get_cves picked the current verdict with a CASE per column; the version
  check is now part of the join, so a stale link simply does not match.
  Measured locally on the benchmark's 500 seeded CVEs: 1.68 ms -> 1.61 ms
  (main 1.45 ms). The rest is the join and four more fields per row, which
  is what reading the verdict costs: the same query with NULL columns was
  already 1.57 ms.

The full listing's stale-verdict case had no test of its own: added, and
it kills the mutant that drops the version check from that join.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(scan): a CVE from KEV or MSRC alone is judged through NVD's record

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" - and
KEV's are the ones being exploited.

- nvd.records_for asks NVD by id (cveId=), paced like every request to
  NVD through the pacer fetch_cves now shares.
- with_records_for_the_rest merges NVD's record into each such row,
  configurations and score with it; the CLI and the API call it for an
  entry they can judge, and only then.
- When NVD cannot be asked, the CVE stays not evaluable and the scan
  says so; the API lists it in `errors`.
- The NIS2 document's NVD row says the CVE ids that are sent.

Tests first, against the recorded cveId answer for CVE-2026-48092 and a
KEV entry derived in KEV's shape; seen failing; 9 of 9 mutants killed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(priority): store what decides a rank, and rank the stored listings

CISA's exploitation facts and the CVSS v4.0 exploit maturity travelled
in the scan's records and were not columns: the next `status` listed by
publication date, and an exploited CVE from 2023 sat under every new
5.3. test_priority_wiring held that boundary to say "now rank the
database paths too" the day the columns arrived.

- cves gains known_exploited, kev_ransomware, kev_date_added,
  kev_due_date and cvss_exploit_maturity, migrated in place and
  refreshed by every scan that brings them: CISA lists a CVE long after
  NVD publishes it. CISA's "Unknown" is stored as no.
- get_cves orders by priority.sql_order, built from the constants
  priority() decides with; an index on that order makes the full
  listing 1.0 ms on the benchmark's 500 CVEs, against 1.45 ms by date.
- /api/cves rows carry priority, priority_reason and the KEV fields;
  status has a Priority column and lists most urgent first.
- test_priority_wiring now holds the other side of the boundary.

Tests first, seen failing; the SQL order is checked against
sort_by_priority on rows covering every rank and edge. 15 of 16 mutants
killed; the survivor drops the "scored" branch, which COALESCE on the
score already orders the same way (equivalent).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(watchlist): vendor and product_id are the vendor source's, as inventory.py meant

inventory.py named the watchlist columns before they existed: vendor,
product_id, installed_version, channel, cpe. There a vendor source is a
feed that answers by version (Chrome's, Edge's, MSRC, Mozilla's) and
product_id is the product it needs; NVD by CPE is the other route. #58
filled vendor and product_id with the CPE's fields instead - a misreading
of the design it rebuilt - which would have had the inventory import
write one over the other. No release carried it.

- The CPE's vendor and product are read from `cpe` (WatchEntry.cpe_vendor,
  cpe_product, installed()), not stored.
- set_source records the vendor source, product and channel a person
  confirmed, for the import to call; it never adds a name.
- DESIGN.md section 5.1 says which column is which, and why.

Tests first, seen failing; the mutants that swap source and product or
drop the version from installed() are killed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(import): --accept writes the products a person confirmed

`import` proposes and a person confirms, and what gets remembered is the
confirmation (inventory.py). Until the watchlist had the columns the
confirmation had nowhere to go and the command wrote nothing.

- `--accept "<name>"`, once per product, any of its registry entries:
  a product the survey identified completely (Chrome, Edge) is watched
  with the registry's version and its vendor source in vendor and
  product_id (set_source).
- A source without its product, a component, a program nothing answers
  for: refused with the survey's own reason, exit 1; what was confirmed
  beside it is still written.
- Without --accept nothing is written, and the closing line says how to
  accept instead of naming columns this release no longer lacks.
- README, the NIS2 document and DESIGN 5.6 say what import writes.

Tests first, seen failing; the mutants for each refusal, the version,
the exit code and the lookup by any registry entry are killed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(ui): the dashboard shows what is installed and the verdict

The CLI and the API learnt the installed version, the verdict on each
CVE and the stored priority; the dashboard still listed every CVE found
by name, ranked as if each were a job, and could not say what is
installed.

- The watchlist shows the version beside each name; the add form takes
  a version and a CPE, sent to POST /api/watchlist/{name} with the CLI's
  checks, and a refusal shows the server's detail (NVD's form of a CPE).
- The table gains Priority (its reason on hover) and Verdict columns;
  a product matched by name reads "not checked" rather than blank.
- The CVEs that do not affect the installed version are counted and
  hidden behind one button, as `scan` does without --all; with none
  hidden there is no button.
- A scan's message gives the split per judged product.

Tests first, the page's real script run in Node by the existing
harness; seen failing (8 of 9); 10 of 10 mutants killed. One test's
routes were reordered after the first run, as test_ui_errors.py orders
them: the harness takes the first matching prefix.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(db): no statement is assembled from a formatted string

Bandit's B608 flagged three: the watchlist UPDATE whose column list was
joined at run time (#58, open on main as alert 61) and the two listings
of get_cves whose select list and ORDER BY were interpolated (#64,
alerts 64 and 65). Every interpolated piece was a module constant, so no
input reached them - but the project takes no `# nosec`, and a statement
written out leaves nothing to vouch for.

- set_installed / set_source run fixed UPDATE statements, one per field.
- get_cves' two listings and the priority index are written out; their
  ORDER BY is priority.sql_order() verbatim, and the new test holds the
  text to it, so the one-source rule stands without building from it.
  EXPLAIN still shows the listing scanning the priority index.

Test first and seen failing: no SELECT/INSERT/UPDATE/DELETE passed to
execute() may be an f-string or a concatenation. test_priority_wiring
now reads the written-out ORDER BY. Bandit B608: no issues.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants