From 45ef35c8f5f189acd64949dcc1039441825f3f96 Mon Sep 17 00:00:00 2001 From: maksimtech <268127343+maksimtech@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:47:30 +0200 Subject: [PATCH] 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 ""`, 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) --- CHANGELOG.md | 6 ++ README.md | 9 ++- docs/DESIGN.md | 8 +- docs/nis2-compliance.md | 5 +- src/patchradar/cli.py | 68 +++++++++++++--- tests/test_import_accept.py | 149 ++++++++++++++++++++++++++++++++++++ 6 files changed, 230 insertions(+), 15 deletions(-) create mode 100644 tests/test_import_accept.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f9e35bd..d81dcc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,12 @@ what has already shipped, and for nothing else. See `RELEASING.md`. listing faster than the date order was (1.0 ms against 1.45 ms on the benchmark's 500 CVEs). `/api/cves` rows carry `priority`, `priority_reason` and the KEV fields; `status` has a Priority column. +- **`patchradar import --accept ""` writes what a person confirmed.** `import` proposes and a + person confirms, and until the watchlist had the columns the confirmation had nowhere to go. + Named once per product, a product the survey identified completely — Chrome, Edge — is watched + with the registry's version and its vendor source in `vendor` and `product_id`. One it did not + identify (a source without its product, a component, a program nothing answers for) is refused + with the survey's reason, and the command exits 1; without `--accept` nothing is written. - **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 b11afd5..019a250 100644 --- a/README.md +++ b/README.md @@ -147,8 +147,13 @@ package, because the machine worth surveying is the one without a checkout: run reconfiguration of every installed product, and it changes nothing else either. `patchradar import` then reports, per product, whether a vendor source can answer -by version. It writes nothing and guesses nothing: a name cannot be turned into a -vendor's product id by deduction, so it proposes and a person confirms. +by version. It guesses nothing: a name cannot be turned into a vendor's product id +by deduction, so it proposes and a person confirms. Without `--accept` it writes +nothing; `patchradar import --accept "Google Chrome"`, once per product, +puts a product listed as watchable by version on the watchlist with its version +and source. One the survey could not identify completely — a source without the +product it needs, a component, a program nothing answers for — is refused with +the survey's reason, however it is asked. The file boundary in the middle is the point. The analysis is Python, tested against recorded snapshots, and runs on a machine that never saw the one being diff --git a/docs/DESIGN.md b/docs/DESIGN.md index ec2e100..82e3250 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -140,8 +140,12 @@ expression keeps a listing of 50 a read of 50 rows. 2. **PR-01b** — in three parts: the verdict stored and the API (01b-1); KEV and MSRC entries judged through NVD's record of the same CVE (01b-2); the KEV fields stored, with the database-backed listings ranked by priority (01b-3). -3. **PR-01c** — the web UI; `patchradar import` writing the mappings a person - confirmed, `channel` included. +3. **PR-01c** — `patchradar import --accept ""` writing the products a + person confirmed, only those the survey identified completely (source and + product), into `vendor`, `product_id` and `installed_version` (01c-1); + the web UI (01c-2). `channel` stays empty until a source the survey + identifies needs one: the two certain today, Chrome and Edge, are matched + without it. ## 6. What PatchRadar does not do diff --git a/docs/nis2-compliance.md b/docs/nis2-compliance.md index ba66570..5582d89 100644 --- a/docs/nis2-compliance.md +++ b/docs/nis2-compliance.md @@ -145,8 +145,9 @@ Su disco PatchRadar scrive: Lo script `inventory.ps1` è il raccoglitore dell'inventario di una macchina Windows: va eseguito a mano, possibilmente come amministratore, e scrive un solo file JSON, nella cartella corrente o nel percorso indicato con `-OutFile`. Non contiene comandi di rete e non invia il file a nessuno; il file -resta dove viene scritto. `patchradar import` lo legge e non scrive nulla né contatta -nessuno. Per verificare le firme dei driver lo script chiede a Windows +resta dove viene scritto. `patchradar import` lo legge e non contatta nessuno; scrive +nella watchlist locale solo i prodotti che una persona conferma con `--accept`, e niente +senza. Per verificare le firme dei driver lo script chiede a Windows `Get-AuthenticodeSignature`, e per farlo Windows può contattare i server delle CA indicate nei certificati: quei contatti li decide la configurazione di Windows, non PatchRadar. diff --git a/src/patchradar/cli.py b/src/patchradar/cli.py index e9010af..d38782f 100644 --- a/src/patchradar/cli.py +++ b/src/patchradar/cli.py @@ -38,6 +38,7 @@ remove_from_watchlist, save_cve, set_installed, + set_source, ) from patchradar.epss import enrich as epss_enrich from patchradar.names import SOFTWARE_NAME_MAX_LENGTH, normalise_software_name @@ -741,15 +742,18 @@ def import_inventory( path: str = typer.Argument(..., help="Inventory snapshot written by `patchradar collector`"), show: int = typer.Option(12, "--show", "-n", help="How many entries to list per group; 0 for all"), + accept: list[str] = typer.Option(None, "--accept", + help="Watch this product, once per product: only one listed " + "as watchable by version"), ): """Read a machine's inventory and report what could be watched by version. - Writes nothing, asks nothing of the network, decides nothing. The mapping - from what the registry writes to what a vendor API wants cannot be deduced - from a name — measured on a real machine, 30% mapped by themselves and a - guess produced `MX5` → a Juniper router — so this proposes and a person - confirms. Storing a confirmed mapping needs the watchlist columns that do - not exist yet. + Asks nothing of the network and decides nothing. The mapping from what the + registry writes to what a vendor API wants cannot be deduced from a name — + measured on a real machine, 30% mapped by themselves and a guess produced + `MX5` → a Juniper router — so this proposes and a person confirms: without + `--accept` it writes nothing, and with it only the products named, and only + those the survey identified completely (docs/DESIGN.md §5.6). """ from patchradar.inventory import CERTAIN, PROPOSED, read_snapshot, survey @@ -818,9 +822,55 @@ def import_inventory( if limit is not None and len(rows) > limit: console.print(f" [dim]…and {len(rows) - limit} more[/dim]") - console.print("\n[dim]Nothing was written. A confirmed mapping needs the watchlist " - "columns (vendor, product_id, installed_version, channel, cpe), " - "which this release does not have.[/dim]") + if not accept: + console.print("\n[dim]Nothing was written. To watch a product listed as watchable by " + "version, confirm it: patchradar import --accept \"\", once per " + "product.[/dim]") + return + written, refused = run(_write_accepted(found, accept)) + console.print(f"\nWritten: {written}. Everything else was only reported.") + if refused: + raise typer.Exit(1) + + +async def _write_accepted(found, names: list[str]) -> tuple[int, int]: + """Write the products a person confirmed; (written, refused). + + Only a product the survey identified completely — the vendor source and the + product it needs — is written. A source without its product, a component, a + program nothing answers for: refused with the survey's own reason, because + writing it would be the guess `import` exists to avoid. + """ + from patchradar.inventory import CERTAIN, Family + + by_name: dict[str, Family] = {} + for family in found.families: + by_name.setdefault(family.name.lower(), family) + for candidate in family.candidates: + by_name.setdefault(candidate.name.lower(), family) + written = refused = 0 + for asked in names: + family = by_name.get(asked.strip().lower()) + if family is None: + console.print(Text(f"❌ {asked} is not in this inventory", style="red")) + refused += 1 + continue + best = family.best + if family.confidence is not CERTAIN or not best.source or not best.product_id: + source = f"the source is {best.source}; " if best.source else "" + console.print(Text(f"❌ {family.name}: not written — {source}{best.reason}", style="red")) + refused += 1 + continue + # A certain product is matched by an exact name ("Google Chrome", + # "Microsoft Edge"), which the watchlist always takes. + name = family.name.strip().lower() + await add_to_watchlist(name) + await set_installed(name, version=best.installed_version, cpe=None) + await set_source(name, vendor=best.source, product_id=best.product_id, channel=None) + version = best.installed_version or "(no version in the registry)" + console.print(Text(f"✅ Watching {name} {version} — {best.source}/{best.product_id}")) + written += 1 + return written, refused POSITION_STYLES = { diff --git a/tests/test_import_accept.py b/tests/test_import_accept.py new file mode 100644 index 0000000..53b0fe3 --- /dev/null +++ b/tests/test_import_accept.py @@ -0,0 +1,149 @@ +""" +PatchRadar — `import --accept` writes what a person confirmed, and only that (DESIGN §5.6) + +`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. Now +`--accept ""`, once per product, writes a product the survey identified +completely — the vendor source and the product it needs — with the version the +registry holds, into the watchlist and its `vendor`, `product_id` and +`installed_version` columns. + +What the survey did not identify is not written however it is asked: a source +without its product, a component, a program no source answers for. Writing +those would be the silent guess the command exists to avoid. +""" +from __future__ import annotations + +import asyncio +import json + +from typer.testing import CliRunner + +from patchradar.cli import app +from patchradar.db import database + +runner = CliRunner() + + +def snapshot_file(tmp_path, *programs) -> str: + path = tmp_path / "cavia.json" + path.write_text(json.dumps({ + "schema": "radar.inventory/1", + "takenAt": "2026-09-27T21:15:42.0000000+02:00", + "installedSoftware": list(programs), + }), encoding="utf-8") + return str(path) + + +def entry(name: str, version: str | None = "1.0", publisher: str = "") -> dict: + return {"name": name, "version": version, "publisher": publisher, "hive": "HKLM"} + + +CHROME = entry("Google Chrome", "154.0.8037.58", "Google LLC") +EDGE = entry("Microsoft Edge", "154.0.3912.60", "Microsoft Corporation") +OFFICE = entry("Microsoft Office LTSC Professional Plus 2021", "16.0.14332.21037", "Microsoft Corporation") +WAVEPAD = entry("WavePad Sound Editor", "5.99", "NCH Software") + + +def _import(tmp_path, *accepted, programs=(CHROME, EDGE, OFFICE, WAVEPAD)): + args = ["import", snapshot_file(tmp_path, *programs)] + for name in accepted: + args += ["--accept", name] + result = runner.invoke(app, args) + return result, " ".join(result.output.split()) + + +def _entries(): + return {e.name: e for e in asyncio.run(database.get_watch_entries())} + + +def test_an_accepted_product_is_watched_with_its_version_and_source(tmp_path): + result, out = _import(tmp_path, "Google Chrome") + + assert result.exit_code == 0, result.output + chrome = _entries()["google chrome"] + assert (chrome.installed_version, chrome.vendor, chrome.product_id) == ("154.0.8037.58", "chrome", "chrome") + assert "google chrome" in out and "154.0.8037.58" in out + + +def test_the_name_is_matched_however_it_is_capitalised(tmp_path): + _import(tmp_path, "microsoft edge") + + edge = _entries()["microsoft edge"] + assert (edge.vendor, edge.product_id) == ("edge", "Edge") + + +def test_only_what_was_accepted_is_written(tmp_path): + _import(tmp_path, "Google Chrome") + + assert list(_entries()) == ["google chrome"] + + +def test_without_accept_nothing_is_written_and_the_way_to_accept_is_said(tmp_path): + result, out = _import(tmp_path) + + assert result.exit_code == 0 + assert _entries() == {} + assert "Nothing was written" in out + assert "--accept" in out + assert "which this release does not have" not in out + + +def test_a_source_without_its_product_is_not_written(tmp_path): + """MSRC answers for Office, and lists 13 products for one advisory: which + one this install is has not been stated, so there is nothing to confirm.""" + result, out = _import(tmp_path, "Microsoft Office LTSC Professional Plus 2021") + + assert result.exit_code == 1 + assert _entries() == {} + assert "not written" in out + assert "msrc" in out + + +def test_a_program_no_source_answers_for_is_not_written(tmp_path): + result, out = _import(tmp_path, "WavePad Sound Editor") + + assert result.exit_code == 1 + assert _entries() == {} + assert "no source that answers by version" in out + + +def test_a_name_not_in_the_inventory_is_said_to_be_missing(tmp_path): + result, out = _import(tmp_path, "Firefox") + + assert result.exit_code == 1 + assert "Firefox" in out and "not in this inventory" in out + + +def test_one_refusal_does_not_cancel_what_was_confirmed(tmp_path): + result, out = _import(tmp_path, "Google Chrome", "WavePad Sound Editor") + + assert result.exit_code == 1 + assert list(_entries()) == ["google chrome"] + assert "Written: 1" in out + + +def test_a_product_without_a_version_is_watched_and_said_to_have_none(tmp_path): + result, out = _import(tmp_path, "Google Chrome", programs=(entry("Google Chrome", None, "Google LLC"),)) + + assert result.exit_code == 0, result.output + assert _entries()["google chrome"].installed_version is None + assert "no version" in out + + +def test_accepting_again_updates_the_version(tmp_path): + _import(tmp_path, "Google Chrome") + _import(tmp_path, "Google Chrome", programs=(entry("Google Chrome", "155.0.8100.12", "Google LLC"),)) + + assert _entries()["google chrome"].installed_version == "155.0.8100.12" + + +def test_a_product_can_be_named_by_any_of_its_registry_entries(tmp_path): + """Two rows, one product: the survey groups "Google Chrome (x86)" with + "Google Chrome", and whichever row the person names, the product is Chrome.""" + result, _ = _import(tmp_path, "Google Chrome (x86)", + programs=(CHROME, entry("Google Chrome (x86)", "154.0.8037.58", "Google LLC"))) + + assert result.exit_code == 0, result.output + assert list(_entries()) == ["google chrome"]