diff --git a/docs/specs/010-cve-tracking.md b/docs/specs/010-cve-tracking.md new file mode 100644 index 0000000..671070b --- /dev/null +++ b/docs/specs/010-cve-tracking.md @@ -0,0 +1,150 @@ +# S-010: Track open CVEs from the fleet's own package evidence + +status: proposed +serves: G-2 + +## Problem + +The baseline installs `unattended-upgrades` (`engine/ansible/roles/cloudfall_unattended_upgrades`), +so most Debian security fixes land on their own. Nothing tells the team whether they did: + +- An upgrade that was held back, failed, or needs a package from outside `-security` leaves the + CVE open, and nothing reports it +- A fixed library on disk does not help a service still running the old one, and a fixed kernel + does not help until the host reboots. "Package upgraded" is not "CVE closed" +- A host read from a brownfield inventory (G-4) may have no `unattended-upgrades` at all +- A managed service provider is asked by its customers "were we exposed to CVE X, and since + when". Today the answer is a manual `apt list --upgradable` on each host, with no record + +Generic CVE feeds do not answer this: they list about a hundred new CVEs a day, most for +products the fleet does not run. The fleet's own package evidence does. + +`cloudfall_inspect` already reads the package database with `dpkg-query` and writes one +snapshot per host (`config/schemas/v1/observed-server.schema.json`) with `os` (distribution, +version, release, running kernel) and `packages` (`name`, `version`, `arch`). Its +`packages[].source` is the literal string `"dpkg-query"` (the collector), not the Debian +source package. Debian tracks security status per source package and source version, so the +snapshot cannot be matched as it is. + +## Behaviour + +**Collect what matching needs** + +`cloudfall_inspect` extends its `dpkg-query` format with `${source:Package}` and +`${source:Version}`, and the snapshot schema gains two required fields per package: + +- `sourcePackage`: the Debian source package name +- `sourceVersion`: the source version, which differs from `version` for binNMUs and some + multi-binary sources + +The existing `source` field keeps its meaning (the collector). A snapshot without the new +fields is reported as not matchable, never matched by binary name. + +The role also records, read-only: + +- `rebootRequired`: whether `/run/reboot-required` exists, and the packages listed in + `/run/reboot-required.pkgs` +- `servicesNeedingRestart`: the services `needrestart -b` reports, when `needrestart` is + installed; `null`, not `[]`, when it is not, so "unknown" never reads as "none" + +**Match on the management host** + +A new read command, `cloudfall security observe` (name open), runs on the management host and +never on the nodes: + +1. It fetches the Debian Security Tracker JSON (`https://security-tracker.debian.org/tracker/data/json`) + and CISA's Known Exploited Vulnerabilities catalog, and keeps each with its fetch time and + sha256, so every finding names the exact data it came from +2. For each host snapshot and each `(sourcePackage, sourceVersion)`, it looks up the host's + release (`os.release`, e.g. `trixie`) and classifies the CVE: + - `open`: unfixed in that release, or fixed in a version newer than the installed one + - `fixed_not_active`: the installed version is fixed, but the host needs a reboot for that + package or a service using it needs a restart + - `not_affected`: the installed version is at or past the fixed version +3. It compares versions by Debian's rules (`apt_pkg.version_compare` or `python-debian`), + never as strings +4. It keeps the tracker's own triage: `urgency` and the ``, `` and + `` marks travel with the finding, so a team can tell "Debian will not ship a fix" + from "a fix exists and we have not installed it" + +Each finding carries the host, source package, installed version, fixed version (or `null`), +CVE id, state, Debian urgency, whether CISA lists it as exploited and since when, and the first +time Cloudfall saw it open on that host. That first-seen time is what answers "since when" +later, so findings are kept as history, not overwritten. + +Output follows the existing read commands: a JSON envelope for agents and a short table for +people, ordered exploited first, then by urgency, then by age. + +**Alert on what matters** + +A declared policy, shaped like `AlertRule`, turns findings into alerts: + +- an exploited CVE open on a host longer than a declared grace (default 1 day) +- a `high` urgency CVE open longer than a declared grace (default 7 days) +- a `fixed_not_active` finding older than a declared grace (default 3 days) + +`` and `` findings never alert by default; the team can opt in. + +**Close it as an operation with a verify step** + +A catalog operation, `apply-security-updates`, runs on one host: + +- Precondition: the host has open findings that a package in its configured repositories fixes +- Action: an upgrade limited to the fixing packages, not a full `dist-upgrade`, then a restart + of the services `needrestart` names. A reboot is a separate operation that always asks +- Verify: re-inspect the host and re-run the match; the operation passes only when every + finding it targeted is `not_affected`. A finding left `fixed_not_active` fails verify and + names the reboot or restart that is missing +- Risk: write. The audit entry holds the findings before, the package diff and the findings + after + +With a clear verify, it is a candidate for unattended runs once its receipt history earns it, +under the usual policy. A reboot never is. + +## Acceptance criteria + +- S-010-1: A host with a package below its fixed version shows that CVE as `open`, with the + installed and fixed versions and the tracker's urgency +- S-010-2: A host with a fixed library and a service still running the old one shows + `fixed_not_active` and names the service; without `needrestart` it says the restart state is + unknown +- S-010-3: A binNMU package (binary version `1.2-3+b1`, source version `1.2-3`) matches by + source version and is classified correctly +- S-010-4: A CVE in CISA's KEV catalog is flagged exploited, with its `dateAdded` +- S-010-5: A snapshot without `sourcePackage` is reported as not matchable and produces no + findings +- S-010-6: A CVE first seen open in one run keeps its first-seen time in later runs +- S-010-7: `apply-security-updates` fails verify, and says why, when a reboot is still needed +- S-010-8: Every finding names the tracker and KEV data it was matched against (fetch time, + sha256) + +## Out of scope + +- Packages outside dpkg: pip, npm, gem, Go modules vendored into applications, container images +- Hosts other than Debian; the inspect role already refuses them +- Firmware, network devices and the management host's own tooling +- A general CVE feed; this reports only what is installed on the fleet +- EPSS or other exploit-likelihood scores, which could rank findings later + +## Open questions + +- Command name: `cloudfall security observe`, `cloudfall cves`, or part of `cloudfall inspect` +- The tracker JSON is tens of MB: cache it on the management host with conditional requests, + and decide how stale it may be before findings say so +- Whether the baseline installs `needrestart`, so restart state is known on every host + Cloudfall builds, and how brownfield hosts without it are reported +- Whether findings live in their own history file per host or in the decision record +- The tracker's JSON fields and triage marks above are to be checked against a live copy + before this spec is approved + +## Decisions relied on + +- none + +## Issues + +- none yet + +## Verification + +Not built.