feat: vuln_scanning entitlement and documentation
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Failing after 1m11s
Agent Release / build (push) Successful in 1m0s
Agent Release / msi (push) Successful in 2m18s

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.
This commit is contained in:
2026-08-06 14:44:11 +01:00
parent 84dfcfeac7
commit f60c509b47
6 changed files with 158 additions and 8 deletions
+52 -1
View File
@@ -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):
+1
View File
@@ -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{
+94
View File
@@ -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.
+1
View File
@@ -26,6 +26,7 @@ const sidebars: SidebarsConfig = {
"vantage/ssh-keys",
"vantage/workflows",
"vantage/monitors",
"vantage/vulnerabilities",
"vantage/notification-channels",
"vantage/secrets",
"vantage/browser-console",
+6 -7
View File
@@ -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
+4
View File
@@ -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