Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<name>"` 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.
Expand Down
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file> --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
Expand Down
8 changes: 6 additions & 2 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<name>"` 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

Expand Down
5 changes: 3 additions & 2 deletions docs/nis2-compliance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
68 changes: 59 additions & 9 deletions src/patchradar/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 <file> --accept \"<name>\", 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 = {
Expand Down
149 changes: 149 additions & 0 deletions tests/test_import_accept.py
Original file line number Diff line number Diff line change
@@ -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 "<name>"`, 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"]