Skip to content
Closed
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
150 changes: 150 additions & 0 deletions docs/specs/010-cve-tracking.md
Original file line number Diff line number Diff line change
@@ -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 `<no-dsa>`, `<ignored>` and
`<postponed>` 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)

`<no-dsa>` and `<ignored>` 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.
Loading