From f60c509b476fdcbee8a110a367d2a67faa54a452 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Thu, 6 Aug 2026 14:44:11 +0100 Subject: [PATCH] feat: vuln_scanning entitlement and documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds license.FeatureVulnScanning as the one name for the feature and a catalogue row per deployment/tier, following console and oidc: features are opt-in per customer, so no plan bundles it. Documents the subsystem in CLAUDE.md, including that ScopedCollections is the canonical registry instance deletion derives from — there is no separate deletion list, which the plan had wrong. --- CLAUDE.md | 53 +++++++++++++- admin/internal/models/catalogue.go | 1 + docsite/docs/vantage/vulnerabilities.md | 94 +++++++++++++++++++++++++ docsite/sidebars.ts | 1 + server/internal/services/packages.go | 13 ++-- shared/license/license.go | 4 ++ 6 files changed, 158 insertions(+), 8 deletions(-) create mode 100644 docsite/docs/vantage/vulnerabilities.md diff --git a/CLAUDE.md b/CLAUDE.md index d46f823..c9db44e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -315,6 +315,45 @@ a Service cannot address the one pod holding a console listener. Agents report CPU/memory/swap/partitions/kernel — metrics every 30s, full static snapshot every 15 min. They also check for pending OS package updates hourly and can apply them on command (`ApplyUpdatesCmd`). +### Package inventory and CVE findings + +Agents report their installed packages hourly; the control plane matches them +against distribution security feeds and raises findings that link to the +existing `ApplyUpdatesCmd` patching path. Gated by the `vuln_scanning` licence +feature, **checked at collection rather than display** — an ungated instance +stores no inventory, and storage is the expensive half. + +**Matching uses distribution feeds, never NVD version ranges.** Distributions +backport security fixes without changing the upstream version: Ubuntu's +`openssl 3.0.2-0ubuntu1.15` is patched against CVE-2023-0286 while NVD still +calls 3.0.2 vulnerable. Matching on NVD would report a fully patched fleet as +critical, and once the first report is mostly wrong nobody reads the second. +`trivy-db` is those feeds pre-merged; `server/internal/vulndb` pulls it as an +OCI artifact to an ephemeral directory. Version comparison is bought from +`go-deb-version`/`go-rpm-version`/`go-apk-version` because dpkg epochs, `~` +sorting before the empty string, and `rpmvercmp` are each a silent false +negative waiting to happen. + +**Only the leader matches.** `ReportPackages` upserts the list and sets +`scan_pending`; it does not scan. `vulnsched` runs inside the existing +`bus.RunAsLeader("housekeeping", …)` and does the matching, because otherwise +every replica needs the ~50MB database resident and a database refresh has N +replicas rescanning the same fleet and sending N digests. The tick is also the +digest's batch boundary, which is what makes "one message, not five hundred" +structural rather than a debounce someone maintains. + +Findings are **never deleted when a package is patched** — the state moves to +`fixed`, so "what did we remediate last quarter" stays answerable. Acceptance +requires a reason and an expiry, and reopens automatically: permanent dismissal +is where risk goes to be forgotten. An unsupported distribution reports +`status: unsupported`, never "0 findings"; claiming clean when the truth is +unknown is the same lie as a silently stale database, which is why +`vulndb_meta.pulled_at` is on screen rather than only in a log. + +Two environment variables: `VANTAGE_TRIVY_DB_REF` mirrors the artifact for +air-gapped installs, and `VANTAGE_VULNDB_DISABLED` switches the puller and +scheduler off entirely. + ### Agent self-update `UpdateAgentCmd` carries a target version and Gitea base URL; the agent downloads and replaces itself. @@ -527,6 +566,12 @@ channels GET,POST /channels · PUT,DELETE /channels/:id · POST /channels/:i secrets GET,POST /secrets · GET,PUT,DELETE /secrets/:group POST /secrets/:group/reveal · DELETE /secrets/:group/:key console POST /console/connect · GET /console/tunnel (websocket) +vulns GET /vulnerabilities · GET /vulnerabilities/summary + POST /vulnerabilities/rescan (owner|admin) + POST,DELETE /vulnerabilities/:id/accept (owner|admin) + GET /servers/:id/vulnerabilities · GET /servers/:id/packages + GET /packages/search?name= + GET,POST /vuln-rules · PUT,DELETE /vuln-rules/:id (owner|admin) audit GET /audit agent GET /agent/latest-version settings GET,PUT /settings · POST /settings/secrets-token (owner|admin) @@ -607,7 +652,7 @@ Paddle is merchant of record; `admin/internal/paddle` is a thin REST client (no ## MongoDB Collections -`servers` · `keys` · `assignments` · `orgs` · `users` · `auth_providers` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `workflow_log_lines` · `workflow_log_seq` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `migrations` +`servers` · `keys` · `assignments` · `orgs` · `users` · `auth_providers` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `workflow_log_lines` · `workflow_log_seq` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `server_packages` · `vuln_findings` · `vuln_alert_rules` · `vulndb_meta` · `migrations` Every document except `migrations` carries `org_id`. Struct definitions are the source of truth — see `server/internal/models/`. @@ -622,6 +667,10 @@ Notes that are not obvious from the structs: - `auth_providers.provider_id` is a short random identifier, not the Mongo `_id`: it appears in the callback URL a customer pastes into their IdP, and an `_id` there would publish a database key. `callback_notice` marks a provider migrated from the old single-provider shape, whose redirect URI therefore changed. - `workflow_log_lines` is keyed `(run_id, server_id, seq)` — the index is not an optimisation, every read is a range scan over it. `workflow_log_seq` holds one counter document per `run_id/server_id`, which is what lets two pods interleave into one ordered log. Neither carries `instance_id`: they are reached only through a run, and a run is already scoped. - `users.auth_source` is `local`, `oidc` or `hq`. An `hq` user was projected from a Vantage HQ account and carries `hq_user_id`; HQ owns its role, password and existence. +- `server_packages` holds a server's whole package set in **one** document, not one per package. The hash already established that something changed, so a report is a single atomic upsert with no delta logic to get wrong; ~2000 packages is ~150KB, well inside the 16MB limit. `scan_pending` lives on the document rather than in memory so a leader handover cannot lose it. +- `vuln_findings` is unique on `(instance_id, server_id, cve_id, package_name)`. That key is what makes a rescan an idempotent upsert rather than a duplicate factory, and what lets `first_seen` survive one. An empty `fixed_in` means no vendor fix exists — a real state, never "not vulnerable". +- `vulndb_meta` is a singleton and deliberately carries **no** `instance_id`: the vulnerability database is a property of the deployment, not a tenant. Same reasoning as `migrations`, and the reason it is absent from `services.ScopedCollections`. +- **`services.ScopedCollections` is the canonical registry of tenant-scoped collections**, and `scopedCollectionsForPurge` derives instance deletion from it rather than keeping a second list. A new collection carrying `instance_id` must be added there or its rows outlive the instance. Admin's own database is separate and holds `accounts` · `admin_instances` · `licenses` · `subscriptions` · `plans` · `catalogue` · `entitlements` · `paddle_events` · `staff_users` · `customer_users` · `instance_members` · `admin_audit`. `paddle_events` is the webhook idempotency log, unique on `event_id`: an event is claimed there before processing, and a duplicate of a handled event is a 200 no-op. `instance_members` is unique on `(instance_id, customer_user_id)` — one person holds at most one user in one instance, which makes a grant idempotent-by-refusal rather than silently doubling a projection. It is an _index_ of the control-plane rows, not the authority (see "Grants project, they do not federate"). Admin has no migrations collection; `models.Backfill` runs on every boot and is idempotent by filtering on the absence of what it writes. @@ -714,6 +763,8 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a | `POD_IP` | no | this pod's own address, set by the Helm chart from the downward API. **Takes precedence over `PROXY_ADVERTISE_HOST`** — a console relay listener belongs to one replica, and a Service address names all of them | | `VANTAGE_MIGRATE_ONLY` | no | run schema setup (migrations, index builders, default-step seeding) and exit without serving. `GRPC_HOST` is not required in this mode. Set by the Helm chart's pre-upgrade Job | | `VANTAGE_SKIP_MIGRATIONS` | no | serve without running schema setup, on the assumption a Job already did. Set by the chart's Deployment whenever `server.migrationJob.enabled`. Unset under Compose, where one process still migrates and then serves | +| `VANTAGE_TRIVY_DB_REF` | no | default `ghcr.io/aquasecurity/trivy-db:2`. Point at a mirror for an air-gapped install, or to avoid the anonymous ghcr rate limit | +| `VANTAGE_VULNDB_DISABLED` | no | `true` disables the vulnerability database puller and scan loop entirely. Findings already written are still served, and still shown as stale | | `FREE_INSTANCE_REAP_AFTER` | no | duration past a Free licence's expiry before the instance and all its data are deleted. **Empty disables the reaper, and empty is the default.** Set to `336h` in `docker-compose.site.yml` only — a self-hosted deployment must never reap. Must match admin's value, which only names the date in warning emails | **sitesvc** (`deploy/docker-compose.site.yml` only): diff --git a/admin/internal/models/catalogue.go b/admin/internal/models/catalogue.go index a5b89fc..56c6a45 100644 --- a/admin/internal/models/catalogue.go +++ b/admin/internal/models/catalogue.go @@ -84,6 +84,7 @@ func SeedCatalogue(ctx context.Context) error { {Kind: KindLimit, Deployment: deployment, Tier: tier, LimitKey: LimitKeyServers}, {Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureConsole}, {Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureOIDC}, + {Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureVulnScanning}, } for _, r := range rows { filter := bson.M{ diff --git a/docsite/docs/vantage/vulnerabilities.md b/docsite/docs/vantage/vulnerabilities.md new file mode 100644 index 0000000..b3eb103 --- /dev/null +++ b/docsite/docs/vantage/vulnerabilities.md @@ -0,0 +1,94 @@ +--- +id: vulnerabilities +title: Vulnerabilities +sidebar_label: Vulnerabilities +--- + +Each Linux server reports the packages it has installed. Vantage matches them +against the security advisories published by that server's own distribution and +raises a finding for anything not yet patched. + +Requires the **vulnerability scanning** feature on your licence. Without it, +agents collect nothing at all — there is no inventory stored and no findings +page to read. + +## What gets scanned + +Linux servers running `apt`, `dnf`/`yum`, `apk`, `zypper` or `pacman`. Agents +report their package list hourly, and only when it has changed since the last +report. + +Windows servers are not scanned. + +Some distributions publish no machine-readable advisory feed. Those servers +show **unsupported** on their own page rather than appearing as having no +vulnerabilities — the two are very different answers, and only one of them is +good news. + +## Why versions look "wrong" + +A finding names the version your distribution ships, not the upstream release. +Ubuntu's `openssl 3.0.2-0ubuntu1.15` carries security fixes backported into +what still calls itself 3.0.2, so public CVE databases listing "3.0.2" as +vulnerable are describing upstream, not your machine. + +Vantage matches against your distribution's own advisories, which is why a +server can be running a version some scanners flag while Vantage correctly +reports it as patched. + +For the same reason a severity here may be lower than the one you find on a CVE +website. Debian and Red Hat routinely downgrade a rating when the vulnerable +code path is not reachable in the way they build the package. Their rating is +the accurate one for the package you are actually running. + +## The board + +`/vulnerabilities` groups findings by CVE. One row per CVE with the number of +affected servers, expandable to the individual servers — the same CVE across +forty machines is one decision, not forty. + +Severity counts at the top filter the list when clicked. The state tabs switch +between **open**, **accepted** and **fixed**. + +The vulnerability database's age is shown above the board. If a pull has failed +for long enough for the data to be stale, that becomes a warning: a low count +against three-week-old data is not the same as a low count. + +## Fixing something + +A finding with a known fixed version gets an **Apply updates** button, which +runs the same OS update the server page offers. There is no separate patching +mechanism. + +Vantage never patches automatically. An unattended upgrade triggered by a third +party's data feed is a fleet-wide change nobody chose. + +## Accepting a finding + +Some findings cannot be fixed today: a kernel CVE waiting on a reboot window, +or one with no vendor fix published at all. + +**Accept** hides a finding from counts and alerts until a date you choose, with +a reason that is recorded in the audit log along with your name. On that date it +reopens by itself. + +The expiry is required. A dismissal with no end date is how a finding gets +forgotten, and it is exactly what an auditor will ask to see. + +## Alerts + +Alert rules live with your notification channels, under +**Settings → Notification Channels**. A rule has a minimum severity, an optional +server tag filter, and one or more channels. + +A rule sends **one digest per scan** summarising what newly opened — never one +message per finding. A database refresh can open several hundred findings at +once, and a message each would flood the channel. + +Findings that were already open do not re-alert. + +## Fleet-wide package search + +`GET /api/packages/search?name=openssl` answers which servers run a given +package and at what version, across the whole fleet. Useful during an incident +before a finding exists for it. diff --git a/docsite/sidebars.ts b/docsite/sidebars.ts index 75ff65c..716c1a4 100644 --- a/docsite/sidebars.ts +++ b/docsite/sidebars.ts @@ -26,6 +26,7 @@ const sidebars: SidebarsConfig = { "vantage/ssh-keys", "vantage/workflows", "vantage/monitors", + "vantage/vulnerabilities", "vantage/notification-channels", "vantage/secrets", "vantage/browser-console", diff --git a/server/internal/services/packages.go b/server/internal/services/packages.go index c42a292..ff099f6 100644 --- a/server/internal/services/packages.go +++ b/server/internal/services/packages.go @@ -6,22 +6,21 @@ import ( "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db" "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" + "gitea.hostxtra.co.uk/mrhid6/vantage/shared/license" "go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo/options" ) -// FeatureVulnScanning is the licence feature name gating package collection. -const FeatureVulnScanning = "vuln_scanning" - // VulnScanningEnabled reports whether this instance may collect packages. // -// It reads the feature by name and never switches on tier, so changing what a -// tier includes needs no server release. A lapsed licence collects nothing: -// there is no point accumulating inventory an instance cannot act on. +// It reads the feature by name from shared/license and never switches on tier, +// so changing what a tier includes needs no server release. A lapsed licence +// collects nothing: there is no point accumulating inventory an instance cannot +// act on. func VulnScanningEnabled(instanceID string) bool { st := GetLicenseState(instanceID) - return st.Active() && st.Feature(FeatureVulnScanning) + return st.Active() && st.Feature(license.FeatureVulnScanning) } // HasPackageHash reports whether we already hold this exact package set, which diff --git a/shared/license/license.go b/shared/license/license.go index 6ba27ce..bffb278 100644 --- a/shared/license/license.go +++ b/shared/license/license.go @@ -31,6 +31,10 @@ const ( FeatureConsole = "console" // browser SSH/RDP/VNC FeatureOIDC = "oidc" // per-instance single sign-on + // FeatureVulnScanning gates package inventory collection as well as the + // findings themselves. The gate is at collection, not display: an ungated + // instance stores no inventory, and storage is the expensive half. + FeatureVulnScanning = "vuln_scanning" ) // Support levels. Carried for display and enforced by nothing — there is no code