Repository navigation
feat(scan): judge each CVE against the installed version - #58
Merged
Merged
Conversation
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 = ?", |
Merging this PR will not alter performance
|
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



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.pyalready 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 whataffected.py,inventory.pyandtest_priority_wiring.pyalready 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>:affected.pycannot order;notepad++is refused with NVD'snotepad\+\+shown, because NVD would match nothing and the scan would read as clean.cpe.py).affected.judge: affected, not affected or not evaluable, from the record's own NVDconfigurations, whichnvd._parse_itemnow keeps (an empty list while NVD has not analysed the CVE). "Cannot tell" never becomes "not affected". This covers:Range.comparableseparates "outside the range" from "cannot be ordered", andclosed_byis factored out ofsummarise.scan:--alladds not affected;listshows version and CPE.Not in this PR, per DESIGN §5.6:
import(PR-01c).Tests (strict TDD)
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 withsummarise: 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.comparable,--allwith nothing hidden, caveat on an empty answer, 5 of 5, set nothing) each kill their mutant: 26/26 mutants killed.test_cli_escaping.py:addcalled as a function now passes its new options. Thelisttest no longer stubsget_watchlist: the hostile row is written straight into the test database, the way an older version left it.🤖 Generated with Claude Code