Skip to content
Merged
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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,22 @@ what has already shipped, and for nothing else. See `RELEASING.md`.

### Added

- **The scan judges CVEs against the installed version** (docs/DESIGN.md §5). A scan found the
CVEs that name a product and could not say which affect what is installed: for Notepad++ the
recorded NVD answer holds 21, of which 11 concern other versions or another product, and all
21 were listed and ranked. `patchradar add <name> --version <v> --cpe <cpe>` records the
installed version and the product's CPE — typed by a person, never guessed, checked for form
(`notepad++` is refused with NVD's `notepad\+\+`) — in five new watchlist columns, migrated
into existing databases. Each CVE NVD returned is then *affected*, *not affected* or *not
evaluable*, from its own `configurations` (`affected.judge`); "cannot tell" never becomes
"not affected". The table lists the first and the last, `--all` the rest; one line gives the
split, one what an update closes, and the NVD caveat is always printed. Entries without a
version are scanned by name, and the scan says which. `list` shows version and CPE. The
verdict is not stored yet (§5.5): `status`, the API and the web UI are the next step.
- **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.

- **VulDB as a scan source** (`patchradar.collectors.vuldb`), queried only when
`VULDB_API_KEY` is set; the key travels in the `X-VulDB-ApiKey` header and the software
name as the search term. Pushed straight to main on 2026-10-12 without an entry here.
Expand Down
37 changes: 36 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

> Know when your software is vulnerable — before attackers do.

PatchRadar checks the CVE feeds for the software on your watchlist and tells you which vulnerabilities affect it, most urgent first. No more manually checking NVD, MSRC, or Snyk — just add your software and run a scan.
PatchRadar checks the CVE feeds for the software on your watchlist, most urgent first. By name it finds the CVEs that **name** the software; give it the installed version and the product's CPE and it says which of them **affect** that version, which do not, and which nobody can tell yet.

Scans run when you start one — `patchradar scan`, or **Scan** in the web UI. There is no scheduler and no notification inside PatchRadar: to scan on a schedule, run `patchradar scan` from cron or the Windows Task Scheduler.

Expand All @@ -26,6 +26,7 @@ Scans run when you start one — `patchradar scan`, or **Scan** in the web UI. T
- 🔍 **On-demand CVE scans** — `patchradar scan` queries NVD, MSRC and CISA KEV; a scan from the web UI (`POST /api/scan`) also queries the Debian Security Tracker
- 🎯 **Ranked by what is actually a threat** — CISA KEV for observed exploitation, FIRST EPSS for the 30-day forecast, CVSS only to break the tie
- 📋 **Personal watchlist** — add any software you want to monitor
- 🎯 **Judged against what is installed** — with `--version` and `--cpe`, each CVE is *affected*, *not affected* or *not evaluable*, from NVD's own list of affected versions, and the scan says which update closes how many
- 🎨 **Beautiful web UI** — dark theme dashboard with charts and filters
- 💻 **CLI first** — full command line interface for automation
- 📊 **CVSS scoring** — color-coded severity (Critical / High / Medium / Low)
Expand Down Expand Up @@ -59,13 +60,47 @@ patchradar list
# Scan for CVEs (last 30 days)
patchradar scan --days 30

# Say what is installed, so the scan can judge each CVE against it
patchradar add notepad --version 8.5.0 --cpe "cpe:2.3:a:notepad-plus-plus:notepad\+\+"
patchradar scan notepad --days 365 # what affects 8.5.0, and what is unknown
patchradar scan notepad --days 365 --all # also the CVEs that do not affect it

# Show latest CVEs in terminal
patchradar status

# Remove software
patchradar remove proxmox
```

### Which CVEs affect the installed version

A scan by name finds the CVEs that mention the software. For Notepad++ the NVD
answer recorded for the tests holds 21, and 11 of them concern other versions or
another product: by name alone they are all in the table, ranked, as if each
were a job.

`patchradar add <name> --version <v> --cpe <cpe>` records what is installed.
The CPE is typed by you, as NVD writes it — PatchRadar never guesses one from a
name — and it is checked for form: `notepad++` is refused with the escaped
`notepad\+\+` shown, because NVD would match nothing and the scan would read
as clean. The scan then judges every CVE NVD returned with NVD's own list of
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;
- **not affected** — counted, and listed only with `--all`.

One line gives the split, one says which update closes how many of the
affected ones — the rest stay open because NVD does not state where they were
fixed, not because the update falls short — and a count from NVD is always
followed by the reminder that it counts identifiers, not flaws. Entries without
a version are scanned by name as before, and the scan names them. The verdict
is not stored yet, so `status`, the API and the web UI still show CVEs by name
([docs/DESIGN.md](docs/DESIGN.md) §5.5).

### Asking Debian what it is actually doing

A container scanner prints one line for anything without a published fix — *no
Expand Down
125 changes: 125 additions & 0 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# PatchRadar — design

Written on 2026-10-17. `affected.py`, `inventory.py` and the tests around them
referred to "the versionradar design, §5" for the watchlist columns a
version-aware scan needs; that text was never committed and could not be found.
This document is the design the code refers to, reconstructed from what those
modules already commit to — the column names in `inventory.py`, the rules in
`affected.py`, the boundary in `tests/test_priority_wiring.py` — so that every
reference to §5 now points at something that exists. Sections 1 to 4 describe
what PatchRadar already does; §5 is what the version-aware scan adds.

## 1. The question

"Which published vulnerabilities concern the software I run, and which first?"
A scan answers it for each entry of the watchlist, on demand: there is no
scheduler and no notification inside PatchRadar.

## 2. Sources

NVD, MSRC and CISA KEV answer every scan; VulDB only with `VULDB_API_KEY`; the
Debian Security Tracker on a scan started from the web UI. FIRST EPSS finds
nothing on its own: it scores the CVEs the sources returned. A source that
fails makes the scan *incomplete*, and only a scan every queried source answered
may say "no CVEs found".

## 3. Matching by name

Each source is asked by keyword, and a record is kept when the keyword names
the software as a word (`names.keyword_pattern`); MSRC also through the products
an entry lists as Known Affected. This finds the CVEs that **name** the software.
It cannot say whether they affect the version installed: a CVE fixed in 8.9.6.4
and a CVE that only ever affected 7.3.3 look the same. §5 is the answer to that.

## 4. Ranking

CISA KEV (exploited now) before FIRST EPSS (likely in 30 days, threshold 10%)
before CVSS, which only breaks ties. `priority.py` holds the order and the
reason printed beside each rank.

## 5. The installed version

### 5.1 Watchlist columns

Five columns on `watchlist`, every one nullable and without a default — a
missing version is an absence, and a default would be a claim:

| column | holds | set by |
|---|---|---|
| `cpe` | the CPE 2.3 name of the product, as NVD writes it: `cpe:2.3:a:notepad-plus-plus:notepad\+\+` | `add --cpe` |
| `vendor` | the CPE vendor field, `notepad-plus-plus` | derived from `cpe` |
| `product_id` | the CPE product field, escaped as NVD escapes it, `notepad\+\+` | derived from `cpe` |
| `installed_version` | the version installed, `8.5.0` | `add --version` |
| `channel` | the release channel a vendor source answers for (stable, ESR, LTS) | reserved for the inventory import (§5.6) |

An existing database gets them on the next start, as `cves` got its EPSS columns.

### 5.2 Nothing is guessed

The CPE is typed by a person, never derived from the name. Measured on a real
inventory, a name-to-CPE guess mapped `MX5` to a Juniper router and
`Visual C++ 2012` to `visual_c++:2008`, and nothing in the output said so
(`inventory.py`). `add` checks the form only: `cpe:2.3:`, a part, a vendor and a
product, neither `*` nor `-`. A product written with a CPE special character
unescaped — `notepad++` instead of `notepad\+\+` — is refused with the escaped
form shown, because NVD would match nothing and the scan would read as clean.

The version must be one `affected.py` can order. One it cannot read is refused
at `add`, not discovered as "not evaluable" on every CVE of every scan.

### 5.3 Three verdicts per CVE

For an entry with both `installed_version` and `cpe`, each CVE the scan finds by
name is judged against the installed version with NVD's own `configurations`:

- **affected** — a vulnerable `cpeMatch` for the vendor and product contains the
version. "Fixed in X" only where NVD states `versionEndExcluding: X`.
- **not affected** — NVD lists the affected products and versions, and this
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`).

"Not evaluable" never becomes "not affected": for a security tool an answer it
cannot give is not an absolution.

### 5.4 What the scan prints

- The table lists the CVEs that are **affected** or **not evaluable**, each with
its verdict. The **not affected** ones are counted, not listed; `scan --all`
lists them too, with their verdict.
- One line per entry says the split: found by name, how many affect the
installed version, how many could not be evaluated, how many do not affect it.
- When NVD states fixes: which version closes how many of the affected ones, and
that the rest stay open because NVD does not say where they were fixed — not
because the update falls short (`affected.summarise`).
- Always, for a versioned entry: the caveat that a count from NVD is a count of
identifiers requested, not of flaws (`affected.CAVEAT`).
- An entry without a version is scanned by name as before, and the scan says
once which entries were matched by name only.

The total line keeps its meaning: CVEs found by name.

### 5.5 What is stored

PR-01a judges in memory, during the scan. Storing the verdict — so that
`status`, the API and the web UI can show it — needs columns in `cves` and is the
migration `tests/test_priority_wiring.py` waits for, together with the KEV fields.

### 5.6 Order of work

1. **PR-01a** — the columns, `add --version --cpe`, the verdict for NVD records
in the command-line scan, `--all`, the README.
2. **PR-01b** — KEV and MSRC entries judged through NVD's record of the same
CVE; the verdict and the KEV fields stored; the API.
3. **PR-01c** — the web UI; `patchradar import` writing the mappings a person
confirmed, `channel` included.

## 6. What PatchRadar does not do

It does not read the machine it runs on: the inventory collector is a separate
script whose output is read elsewhere. It does not install, update or patch
anything, and a CVE count from any source is not a statement that the software
is safe.
131 changes: 109 additions & 22 deletions src/patchradar/affected.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,11 @@
ever assigned an identifier. `Summary.caveat` carries that sentence so a caller
cannot print "no known vulnerabilities" without it.

Not wired into the scan yet, and deliberately: "fixed in X" is a fact about a
product, and a keyword scan has neither a CPE nor an installed version to ask
about. That needs the watchlist columns of the versionradar design (§5) —
`cpe`, `installed_version`. This module is the half of the feature that can be
written and tested before the migration, and `tests/test_priority_wiring.py`
holds the boundary.
**Wired into the command-line scan** for a watchlist entry that has both an
installed version and a CPE (docs/DESIGN.md §5): `judge` gives each CVE found by
name one of three verdicts — affected, not affected, not evaluable — and
`closed_by` says what one update closes. Storing the verdict, and the API, are
the next step (§5.5); `tests/test_priority_wiring.py` holds that boundary.
"""
from __future__ import annotations

Expand Down Expand Up @@ -91,6 +90,11 @@ def _parts(text: object) -> _Version | None:
return tuple(zip(numbers, letters, strict=True))


def orderable(version: str) -> bool:
"""Whether `version` is one the ranges can be compared with at all."""
return _parts(version) is not None


def _comparable(left: _Version, right: _Version) -> tuple[_Version, _Version] | None:
if abs(len(left) - len(right)) > _SCHEME_TOLERANCE and left[0][0] != right[0][0]:
return None
Expand Down Expand Up @@ -148,6 +152,28 @@ def contains(self, version: str) -> bool:
return False
return True

def comparable(self, version: str) -> bool:
"""Whether `contains` can give a real answer about `version`.

`contains` says False both for a version outside the range and for one
it cannot order against the range — another scheme, or no version at
all — and only the first is "not affected". This is what tells them
apart.
"""
have = _parts(version)
if have is None:
return False
if self.unbounded:
return True
bounds = [self.exact] if self.exact is not None else [self.start, self.end]
for bound in bounds:
if bound is None:
continue
limit = _parts(bound)
if limit is None or _comparable(have, limit) is None:
return False
return True

def describe(self) -> str:
if self.exact is not None:
return f"only {self.exact}"
Expand Down Expand Up @@ -321,15 +347,42 @@ def affects(payload: dict, *, version: str, vendor: str,
return sorted(hits, key=lambda hit: (hit.score is not None, hit.score or 0.0), reverse=True)


def _highest_fix(fixed_versions) -> str | None:
candidates = [(parts, fixed) for fixed in fixed_versions
if fixed and (parts := _parts(fixed)) is not None]
if not candidates:
return None
return max(candidates, key=lambda pair: pair[0])[1]


def target_version(hits: list[Hit]) -> str | None:
"""The highest fix any of them declares: where to go to close what can be
closed with one update. None when nobody declares one — and then no number
is invented."""
candidates = [(parts, hit.fixed_version) for hit in hits
if hit.fixed_version and (parts := _parts(hit.fixed_version)) is not None]
if not candidates:
return None
return max(candidates, key=lambda pair: pair[0])[1]
return _highest_fix(hit.fixed_version for hit in hits)


def closed_by(fixed_versions) -> tuple[str | None, int]:
"""(the update to take, how many of these it closes), from the fixed
versions of the CVEs that affect a version — None where NVD states none.

A CVE without a stated fix is never counted as closed: NVD naming its last
affected release says nothing about which one is sound.
"""
fixes = list(fixed_versions)
target = _highest_fix(fixes)
goal = _parts(target) if target is not None else None
if goal is None:
return target, 0
closed = 0
for fixed in fixes:
fix = _parts(fixed) if fixed else None
if fix is None:
continue
pair = _comparable(fix, goal)
if pair is not None and pair[0] <= pair[1]:
closed += 1
return target, closed


def summarise(payload: dict, *, version: str, vendor: str,
Expand All @@ -342,17 +395,7 @@ def summarise(payload: dict, *, version: str, vendor: str,
"3 of 10" without that distinction invites the opposite reading.
"""
hits = affects(payload, version=version, vendor=vendor, cpe_product=cpe_product)
target = target_version(hits)
closed = 0
if target is not None:
goal = _parts(target)
for hit in hits:
fix = _parts(hit.fixed_version) if hit.fixed_version else None
if fix is None or goal is None:
continue
pair = _comparable(fix, goal)
if pair is not None and pair[0] <= pair[1]:
closed += 1
target, closed = closed_by(hit.fixed_version for hit in hits)
scores = [hit.score for hit in hits if hit.score is not None]
return Summary(
version=version,
Expand All @@ -364,3 +407,47 @@ def summarise(payload: dict, *, version: str, vendor: str,
caveat=CAVEAT,
hits=tuple(hits),
)


# ─── One CVE found by name, judged against the installed version ─────────────

AFFECTED = "affected"
NOT_AFFECTED = "not affected"
NOT_EVALUABLE = "not evaluable"


@dataclass(frozen=True)
class Verdict:
status: str
reason: str
fixed_version: str | None = None


def judge(record: dict, *, version: str, vendor: str, cpe_product: str) -> Verdict:
"""The verdict on one scan record for the installed `version` (DESIGN §5.3).

The record carries NVD's `configurations` when NVD returned it — an empty
list when NVD has not analysed the CVE yet — and no such key when it came
from another source alone. Only a statement NVD made about this product
decides: anything short of that is "not evaluable", never "not affected".
"""
configurations = record.get("configurations")
if configurations is None:
return Verdict(NOT_EVALUABLE, "NVD's record of it was not part of this scan")
if not isinstance(configurations, list) or not configurations:
return Verdict(NOT_EVALUABLE, "NVD has not yet stated which products it affects")

spans = ranges_in({"configurations": configurations}, vendor=vendor, cpe_product=cpe_product)
if not spans:
return Verdict(NOT_AFFECTED, f"NVD lists the products it affects, and "
f"{vendor}:{cpe_product} is not among them")
for span in spans:
if span.contains(version):
return Verdict(AFFECTED, _note_for(span), span.fixed_version)

stated = "; ".join(span.describe() for span in spans)
if not all(span.comparable(version) for span in spans):
return Verdict(NOT_EVALUABLE, f"NVD's affected versions ({stated}) cannot be "
f"compared with {version}")
return Verdict(NOT_AFFECTED, f"NVD states the affected versions ({stated}); "
f"{version} is not among them")
Loading
Loading