Compare commits

...
16 Commits
Author SHA1 Message Date
mrhid6 f60c509b47 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.
2026-08-06 14:44:11 +01:00
mrhid6 84dfcfeac7 feat: vulnerability findings UI
Fleet board grouped by CVE, a per-server section on server detail, and
alert rules beside the channels they consume.

The server detail page has no tab pattern despite the plan saying to
follow one, so this adds a section in the existing vertical stack.

Three states are kept visually distinct because they are identical if
handled carelessly and only one is good news: never reported, no advisory
feed for the distribution, and scanned-and-clean. Database freshness sits
with the findings rather than in settings for the same reason.
2026-08-06 14:40:37 +01:00
mrhid6 5dda3b5c4a feat: vulnerability scanning pipeline, matcher, scheduler and API
Completes tasks 10-15 and fixes what was outstanding:

- vulndb.Pull implemented with oras-go, streaming the ~50MB layer and
  staging both files before replacing either, so a failed pull leaves the
  previous database intact rather than a half-written one.
- db.go: Vulnerability.Severity is a string, not trivy Severity, so the
  int conversion did not compile. Severity now resolves vendor (highest
  when vendors disagree) then NVD then unknown, and CVSS is read too.
- findings.go: added sweepFixedFindings plus the fleet query, severity
  counts, rescan flag and accept/unaccept the API needs.
- vulnrules.go: added rule CRUD and the digest builder. ResolveTargets
  returns []models.Server, not []string, so filterByServers was wrong.
- api/vulnerabilities.go was an empty file while handlers.go registered
  twelve routes against it; written, grouped by CVE.
- shared/mail: added the missing sender. The templates were orphaned and
  the HTML one was a copy of the text one, defining "subject" (which
  html/template would escape) and emitting no markup. render.go parses
  every template in init(), so a bad one panics server, admin and sitesvc
  at boot — go build never runs init(), which is why nothing complained.
- notify: digests dispatch through their own path so SMTP gets the digest
  template rather than arriving dressed as a monitor alert.
2026-08-06 14:33:46 +01:00
mrhid6 db64320bd8 feat: agent reports installed packages on the hourly loop
SyncKeys now returns the whole response so the poll can carry
CollectPackages; a separate RPC for one boolean would be a message every
30 seconds for a value that changes when a licence does.

The flag is an atomic: the 30s poll writes it, the hourly package loop
reads it, and they are different goroutines.
2026-08-06 13:21:13 +01:00
mrhid6 583f60771c feat: store agent package reports and serve the collect flag
VulnScanningEnabled reads GetLicenseState(...).Feature("vuln_scanning")
and requires an active licence, never switching on tier. ReportPackages
re-checks it server-side: the agent flag is the optimisation, this is
the boundary.
2026-08-06 13:19:39 +01:00
mrhid6 a92c3190c2 feat: ReportPackages wire types with hash short-circuit
The pb packages are hand-written, not protoc-generated, and the wire
codec is JSON (encoding.RegisterCodec(JSONCodec{})). Field numbers in
the .proto are documentation; JSON field names are the contract. Both pb
packages edited by hand to match.

SyncResponse.collect_packages is omitempty and absent decodes as false,
so an older server leaves agents collecting nothing rather than
collecting without a licence.
2026-08-06 13:17:44 +01:00
mrhid6 3a6d24fe0e feat: models and indexes for package inventory and CVE findings
Adds server_packages, vuln_findings and vuln_alert_rules to
ScopedCollections rather than to a separate deletion list. purgeInstance
derives its collection list from that registry, so instance deletion
follows automatically and there is no second copy to drift.
2026-08-06 11:59:00 +01:00
mrhid6 c277ecff44 feat: agent collects installed packages per package manager 2026-08-06 11:56:41 +01:00
mrhid6 bd690c94c3 feat: agent parses /etc/os-release for distro identification 2026-08-06 11:55:45 +01:00
mrhid6 a22fdf197e feat: map OS family and version to trivy-db advisory buckets 2026-08-06 11:55:01 +01:00
mrhid6 bd24b03cac feat: version comparators for distro package ordering 2026-08-06 11:54:13 +01:00
mrhid6 3afc4ab012 docs: workload registry plan; remove tests from both plans
Both plans now verify by build, vet and manual checks written into the
tasks. Spec verification sections updated to match so they no longer
describe tests that will not be written.
2026-08-06 11:27:37 +01:00
mrhid6 d1ac3e98ce docs: design for the workload registry
Agents enumerate Docker containers, compose stacks and systemd services;
start/stop/restart and bounded log snapshots from the UI.

Sub-project B, Linux only. Live log following stays in the console.
2026-08-06 11:11:26 +01:00
mrhid6 5bba54f3e5 fix: Fixed style layout on workflow run page 2026-08-06 10:50:31 +01:00
mrhid6 fe7bc300e2 docs: implementation plan for package inventory and CVE findings
17 tasks, TDD where the logic is pure. Corrects two spec claims:
the server reads features via License.HasFeature rather than admin's
entitlement directly, and shared/mail/render_test.go does not exist.
2026-08-06 10:49:10 +01:00
mrhid6 00c03c365d docs: design for package inventory and CVE findings
Agents report installed packages; the control plane matches them against
trivy-db and raises findings that link to the existing ApplyUpdatesCmd
patching path.

Scoped to sub-project A, Linux only. Container registry, image scanning
and compliance baselines are separate specs.
2026-08-06 10:33:54 +01:00
56 changed files with 9260 additions and 16 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{
+20 -2
View File
@@ -80,7 +80,11 @@ func (c *Client) Register(serverID, preRegToken, hostname, ipAddress, osInfo str
return resp.AgentToken, nil
}
func (c *Client) SyncKeys(serverID, agentToken, version string) ([]string, error) {
// SyncKeys returns the whole response rather than just the keys: the poll now
// also carries CollectPackages, and a second RPC purely to learn one boolean
// would be a message every 30 seconds for a value that changes at most when a
// licence does.
func (c *Client) SyncKeys(serverID, agentToken, version string) (*pb.SyncResponse, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@@ -92,7 +96,21 @@ func (c *Client) SyncKeys(serverID, agentToken, version string) ([]string, error
if err != nil {
return nil, err
}
return resp.PublicKeys, nil
return resp, nil
}
// ReportPackages sends a package report and returns whether the server wants
// the full list. Given a longer deadline than the other unary calls because the
// full body is ~150KB on a slow link.
func (c *Client) ReportPackages(req *pb.ReportPackagesRequest) (bool, error) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
resp, err := c.client.ReportPackages(ctx, req)
if err != nil {
return false, err
}
return resp.NeedFull, nil
}
func (c *Client) UploadGeneratedKey(serverID, agentToken, publicKey, privateKey, label string) (string, error) {
+48
View File
@@ -30,6 +30,45 @@ type SyncRequest struct {
type SyncResponse struct {
PublicKeys []string `json:"public_keys"`
// CollectPackages tells the agent whether this instance's licence grants
// vulnerability scanning. Absent decodes as false, which is the safe
// direction: an older server leaves agents collecting nothing.
CollectPackages bool `json:"collect_packages,omitempty"`
}
type OSRelease struct {
Family string `json:"family"`
// VersionId is not optional: Ubuntu 22.04 and 24.04 publish different fixed
// versions for the same CVE, so a scan without it is guesswork.
VersionId string `json:"version_id"`
Arch string `json:"arch,omitempty"`
}
type InstalledPackage struct {
Name string `json:"name"`
Version string `json:"version"`
Epoch int32 `json:"epoch,omitempty"`
Arch string `json:"arch,omitempty"`
// SourceName is what the Debian and Ubuntu feeds are keyed on: one advisory
// against "openssl" covers libssl3, openssl and libssl-dev.
SourceName string `json:"source_name,omitempty"`
}
// ReportPackagesRequest carries a server's installed package set.
//
// The agent calls twice at most: first with Packages empty, offering only the
// hash. If the server already holds it, NeedFull is false and the ~150KB body
// is never sent.
type ReportPackagesRequest struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
Hash string `json:"hash"`
Os OSRelease `json:"os"`
Packages []InstalledPackage `json:"packages,omitempty"`
}
type ReportPackagesResponse struct {
NeedFull bool `json:"need_full"`
}
type UploadKeyRequest struct {
@@ -337,6 +376,7 @@ type VantageClient interface {
SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error)
UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error)
ReportUpdates(ctx context.Context, in *ReportUpdatesRequest, opts ...grpc.CallOption) (*ReportUpdatesResponse, error)
ReportPackages(ctx context.Context, in *ReportPackagesRequest, opts ...grpc.CallOption) (*ReportPackagesResponse, error)
ReportInventory(ctx context.Context, in *InventoryReport, opts ...grpc.CallOption) (*InventoryReportResponse, error)
SyncMonitors(ctx context.Context, in *SyncMonitorsRequest, opts ...grpc.CallOption) (*SyncMonitorsResponse, error)
ReportChecks(ctx context.Context, in *ReportChecksRequest, opts ...grpc.CallOption) (*ReportChecksResponse, error)
@@ -396,6 +436,14 @@ func (c *keyManagerClient) ReportUpdates(ctx context.Context, in *ReportUpdatesR
return out, nil
}
func (c *keyManagerClient) ReportPackages(ctx context.Context, in *ReportPackagesRequest, opts ...grpc.CallOption) (*ReportPackagesResponse, error) {
out := new(ReportPackagesResponse)
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportPackages", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
func (c *keyManagerClient) ReportInventory(ctx context.Context, in *InventoryReport, opts ...grpc.CallOption) (*InventoryReportResponse, error) {
out := new(InventoryReportResponse)
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportInventory", in, out, opts...); err != nil {
+63
View File
@@ -0,0 +1,63 @@
package packages
import (
"bufio"
"errors"
"io"
"os"
"runtime"
"strings"
)
// OSRelease identifies the distribution well enough to select an advisory
// feed. VersionID is not optional: Ubuntu 22.04 and 24.04 publish different
// fixed versions for the same CVE.
type OSRelease struct {
Family string
VersionID string
Arch string
}
// ParseOSRelease reads the os-release format: KEY=value, one per line, with
// values optionally quoted, and # comments.
//
// The quote stripping handles both ID=ubuntu and ID="rocky", which real
// distributions both emit.
func ParseOSRelease(r io.Reader) (OSRelease, error) {
out := OSRelease{Arch: runtime.GOARCH}
sc := bufio.NewScanner(r)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, val, ok := strings.Cut(line, "=")
if !ok {
continue
}
val = strings.Trim(strings.TrimSpace(val), `"'`)
switch strings.TrimSpace(key) {
case "ID":
out.Family = strings.ToLower(val)
case "VERSION_ID":
out.VersionID = val
}
}
if err := sc.Err(); err != nil {
return OSRelease{}, err
}
if out.Family == "" {
return OSRelease{}, errors.New("os-release has no ID")
}
return out, nil
}
// DetectOS reads /etc/os-release.
func DetectOS() (OSRelease, error) {
f, err := os.Open("/etc/os-release")
if err != nil {
return OSRelease{}, err
}
defer f.Close()
return ParseOSRelease(f)
}
+73
View File
@@ -0,0 +1,73 @@
package packages
import (
"context"
"fmt"
"os/exec"
"runtime"
"time"
)
const collectTimeout = 2 * time.Minute
// Collect enumerates installed packages. Linux only: Windows agents are
// second-class by design, and vulnerability scanning there needs a different
// source, a different collector and a different matcher, all out of scope.
//
// The format strings below are raw string literals on purpose. The "\t" and
// "\n" reach dpkg-query and rpm as two characters each, and those tools do the
// interpreting themselves — Go must not consume the escapes first.
func Collect() (OSRelease, []Package, error) {
if runtime.GOOS != "linux" {
return OSRelease{}, nil, fmt.Errorf("package collection is linux-only, got %s", runtime.GOOS)
}
osrel, err := DetectOS()
if err != nil {
return OSRelease{}, nil, fmt.Errorf("detect os: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), collectTimeout)
defer cancel()
switch {
case have("dpkg-query"):
out, err := run(ctx, "dpkg-query", "-W", "-f",
`${Package}\t${Version}\t${Architecture}\t${source:Package}\n`)
if err != nil {
return osrel, nil, err
}
return osrel, ParseDpkg(out), nil
case have("rpm"):
out, err := run(ctx, "rpm", "-qa", "--qf",
`%{NAME}\t%{EPOCH}\t%{VERSION}-%{RELEASE}\t%{ARCH}\t%{SOURCERPM}\n`)
if err != nil {
return osrel, nil, err
}
return osrel, ParseRPM(out), nil
case have("apk"):
out, err := run(ctx, "apk", "info", "-v")
if err != nil {
return osrel, nil, err
}
return osrel, ParseAPK(out), nil
default:
return osrel, nil, fmt.Errorf("no supported package manager found")
}
}
func have(bin string) bool {
_, err := exec.LookPath(bin)
return err == nil
}
func run(ctx context.Context, name string, args ...string) (string, error) {
out, err := exec.CommandContext(ctx, name, args...).Output()
if err != nil {
return "", fmt.Errorf("%s: %w", name, err)
}
return string(out), nil
}
+146
View File
@@ -0,0 +1,146 @@
package packages
import (
"crypto/sha256"
"encoding/hex"
"sort"
"strconv"
"strings"
)
// Package is one installed package as the distribution reports it. Version is
// the distribution's own version string, verbatim — never normalised, because
// the advisory feeds are keyed on exactly this form.
type Package struct {
Name string
Version string
Epoch int
Arch string
SourceName string
}
// ParseDpkg reads tab-separated output of
// dpkg-query -W -f '${Package}\t${Version}\t${Architecture}\t${source:Package}\n'
//
// SourceName is why the fourth column is requested at all: Debian and Ubuntu
// advisories are keyed on the SOURCE package, so one CVE against "openssl"
// covers the binaries libssl3, openssl and libssl-dev. Matching on binary name
// alone finds one of the three.
func ParseDpkg(out string) []Package {
var pkgs []Package
for _, line := range strings.Split(out, "\n") {
if strings.TrimSpace(line) == "" {
continue
}
f := strings.Split(line, "\t")
if len(f) < 3 {
continue
}
p := Package{Name: f[0], Version: f[1], Arch: f[2]}
if len(f) > 3 && f[3] != "" {
p.SourceName = f[3]
} else {
p.SourceName = p.Name
}
pkgs = append(pkgs, p)
}
return pkgs
}
// ParseRPM reads tab-separated output of
// rpm -qa --qf '%{NAME}\t%{EPOCH}\t%{VERSION}-%{RELEASE}\t%{ARCH}\t%{SOURCERPM}\n'
func ParseRPM(out string) []Package {
var pkgs []Package
for _, line := range strings.Split(out, "\n") {
if strings.TrimSpace(line) == "" {
continue
}
f := strings.Split(line, "\t")
if len(f) < 4 {
continue
}
epoch := 0
// rpm prints "(none)" rather than omitting the field when a package has
// no epoch. That must become 0, not fail the line.
if f[1] != "" && f[1] != "(none)" {
if n, err := strconv.Atoi(f[1]); err == nil {
epoch = n
}
}
p := Package{Name: f[0], Epoch: epoch, Version: f[2], Arch: f[3]}
if len(f) > 4 {
p.SourceName = srcRPMName(f[4])
}
if p.SourceName == "" {
p.SourceName = p.Name
}
pkgs = append(pkgs, p)
}
return pkgs
}
// srcRPMName reduces "openssl-3.0.7-24.el9.src.rpm" to "openssl" by dropping
// the trailing ".src.rpm" and then the version and release segments, which are
// the last two hyphen-separated fields.
func srcRPMName(s string) string {
s = strings.TrimSuffix(s, ".src.rpm")
parts := strings.Split(s, "-")
if len(parts) <= 2 {
return s
}
return strings.Join(parts[:len(parts)-2], "-")
}
// ParseAPK reads "apk info -v" output: one "name-version-rREV" per line.
// Alpine has no separate source package, so SourceName mirrors Name.
func ParseAPK(out string) []Package {
var pkgs []Package
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
name, version := splitAPK(line)
if name == "" {
continue
}
pkgs = append(pkgs, Package{Name: name, Version: version, SourceName: name})
}
return pkgs
}
// splitAPK finds the version boundary from the RIGHT. The version is always the
// last two hyphen-separated fields ("<version>-r<rev>"), which is reliable
// where scanning from the left is not: package names legitimately contain
// digits and underscores, so "musl" in "musl-1.2.4_git20230717-r4" cannot be
// found by looking for the first digit.
func splitAPK(s string) (name, version string) {
last := strings.LastIndex(s, "-")
if last <= 0 {
return "", ""
}
prev := strings.LastIndex(s[:last], "-")
if prev <= 0 {
return "", ""
}
return s[:prev], s[prev+1:]
}
// Hash fingerprints a package set so an unchanged set never has to be sent.
//
// It sorts first: the ordering of dpkg or rpm output is not guaranteed stable,
// and an ordering-sensitive hash would resend the full ~150KB list every hour
// for no reason — a cost visible only as traffic.
func Hash(pkgs []Package) string {
lines := make([]string, 0, len(pkgs))
for _, p := range pkgs {
lines = append(lines, p.Name+"\x00"+strconv.Itoa(p.Epoch)+"\x00"+p.Version+"\x00"+p.Arch)
}
sort.Strings(lines)
h := sha256.New()
for _, l := range lines {
h.Write([]byte(l))
h.Write([]byte("\n"))
}
return hex.EncodeToString(h.Sum(nil))
}
+90
View File
@@ -0,0 +1,90 @@
package agentsync
import (
"log"
"runtime"
"sync/atomic"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/config"
grpcclient "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/packages"
)
// collectPackagesFlag is written by the 30s key poll and read by the hourly
// package loop — two different goroutines, hence the atomic.
//
// It defaults to false, so an agent that has not yet completed a poll, or is
// talking to a server too old to send the field, collects nothing. Off is the
// safe default: collecting without a licence costs the customer storage they
// are not paying for.
var collectPackagesFlag atomic.Bool
func collectPackagesEnabled() bool { return collectPackagesFlag.Load() }
// reportPackages offers a hash of the installed package set and sends the full
// list only if the server does not already hold it.
//
// It runs on the same hourly cadence as the update check because a package set
// changes on roughly the same schedule, and reusing that loop means one timer
// rather than two.
func reportPackages(client *grpcclient.Client, cfg *config.Config) {
if runtime.GOOS != "linux" {
return
}
if !collectPackagesEnabled() {
return
}
osrel, pkgs, err := packages.Collect()
if err != nil {
log.Printf("package collection error: %v", err)
return
}
pbOS := pb.OSRelease{
Family: osrel.Family,
VersionId: osrel.VersionID,
Arch: osrel.Arch,
}
hash := packages.Hash(pkgs)
// The offer: hash only, no body. On an unchanged host this is the whole
// exchange, which is the point of the handshake.
needFull, err := client.ReportPackages(&pb.ReportPackagesRequest{
ServerId: cfg.ServerID,
AgentToken: cfg.AgentToken,
Hash: hash,
Os: pbOS,
})
if err != nil {
log.Printf("ReportPackages offer error: %v", err)
return
}
if !needFull {
return
}
pbPkgs := make([]pb.InstalledPackage, len(pkgs))
for i, p := range pkgs {
pbPkgs[i] = pb.InstalledPackage{
Name: p.Name,
Version: p.Version,
Epoch: int32(p.Epoch),
Arch: p.Arch,
SourceName: p.SourceName,
}
}
if _, err := client.ReportPackages(&pb.ReportPackagesRequest{
ServerId: cfg.ServerID,
AgentToken: cfg.AgentToken,
Hash: hash,
Os: pbOS,
Packages: pbPkgs,
}); err != nil {
log.Printf("ReportPackages full error: %v", err)
return
}
log.Printf("reported %d installed packages", len(pkgs))
}
+13 -1
View File
@@ -92,11 +92,18 @@ func Run(ctx context.Context, cfg *config.Config, version string) error {
}
func poll(client *grpcclient.Client, cfg *config.Config, version string) error {
desired, err := client.SyncKeys(cfg.ServerID, cfg.AgentToken, version)
resp, err := client.SyncKeys(cfg.ServerID, cfg.AgentToken, version)
if err != nil {
return fmt.Errorf("SyncKeys: %w", err)
}
// Stored atomically: the hourly package loop reads this from another
// goroutine. Absent on the wire decodes as false, so an older server leaves
// collection off rather than on.
collectPackagesFlag.Store(resp.CollectPackages)
desired := resp.PublicKeys
if runtime.GOOS != "linux" {
return nil
}
@@ -395,6 +402,11 @@ func runUpdateCheck(ctx context.Context, cfg *config.Config) {
return
}
log.Printf("reported %d available OS updates", len(pkgs))
// Same hourly cadence, same connection. A package set changes on
// roughly the schedule available updates do, so this needs no timer of
// its own.
reportPackages(client, cfg)
}
doCheck()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,538 @@
# Package inventory and CVE findings
Date: 2026-08-06
Agents report the packages installed on each server. The control plane matches
them against distro security feeds and raises findings that link straight to
the patching path that already exists. A finding nobody can fix today can be
accepted with a reason and an expiry date rather than sitting red forever.
This is one of four sub-projects sketched together and deliberately separated:
| # | Sub-project | Depends on |
| - | ----------- | ---------- |
| A | **Package inventory + CVE findings** — this spec | nothing |
| B | Container/service registry | nothing |
| C | Container image scanning | A and B |
| D | Compliance profiles (baseline assertions) | shares A's findings UI only |
A and B are independent of one another. C is the joiner and must not be
designed before both exist. D shares a page with A and nothing else — a
different collector, a different evaluation model and a different remediation
story — so folding it in here would double the size for no shared machinery.
Scope of this spec is **A, Linux only.** Windows needs a separate source
(MSRC CVRF), a separate collector (`Get-HotFix` plus registry) and a KB
supersedence matcher that shares no code with the Linux path. That matches the
existing position that Windows agents are second-class by design, and the six
package managers `updates.go` already detects cover the whole Linux surface.
---
## The trap this design is built around
Distributions **backport** security fixes without changing the upstream
version. Ubuntu ships `openssl 3.0.2-0ubuntu1.15` patched against
CVE-2023-0286; NVD says version 3.0.2 is vulnerable. Matching installed
versions against NVD or CPE ranges therefore reports a fleet full of criticals
that are all already fixed.
That is not merely noisy. It is fatal to the feature: once the first report is
mostly wrong, nobody reads the second one, and a genuine finding is lost in the
noise it created. Everything below follows from refusing to make that mistake.
The correct source is the **distribution's own security feed**, keyed on the
distribution's own version string — Debian and Ubuntu OVAL/USN, Red Hat OVAL
v2, Alpine secdb. `trivy-db` is those feeds pre-merged into one BoltDB
artifact, rebuilt every six hours and published as an OCI artifact.
---
## Where the vulnerability data comes from
`trivy-db`, pulled server-side from `ghcr.io/aquasecurity/trivy-db:2`.
The alternative considered was querying OSV.dev per scan, which needs no
storage and no puller. It was rejected on two counts: it requires outbound
internet on every scan, which breaks air-gapped installs; and it sends the
package list of a customer's entire fleet to a third party. The audience most
likely to buy vulnerability scanning is the audience least willing to do that.
The blob is roughly 50MB, read-only, reproducible, and identified by a version
number. **It is not stored in Mongo and not written to `/data`**
`server.persistence` defaults to off and nothing writes to `/data` any more.
It does not need durable storage: whichever pod needs it pulls it to its own
ephemeral temp directory. Nothing shared, nothing to back up, nothing to
migrate.
`VANTAGE_TRIVY_DB_REF` overrides the default reference so a customer can mirror
the artifact into their own registry. It also covers the anonymous ghcr rate
limit, which the six-hourly pull cadence already makes unlikely to bite.
---
## Only the leader matches
This is the crux, and it falls out of the replica model already in the
codebase.
Two things trigger matching, and they happen on different pods:
1. a fleet-wide rescan when `trivy-db` updates — naturally the leader's job
2. a server's package list changing — handled by whichever pod holds *that
agent's* command stream
If (2) matched inline, **every replica would need the 50MB database resident**,
and a database refresh would have N pods racing to rescan the same fleet and N
digests reaching the customer. That is the exact failure `RunAsLeader` exists
to prevent, and it is the same argument that put `monitorsched` behind the
lock.
So `ReportPackages` does not match. It upserts the package list and sets
`scan_pending: true`. That is all it does.
`server/internal/vulnsched` then runs inside the **existing**
`bus.RunAsLeader("housekeeping", …)` alongside `monitorsched`,
`workflowsched` and the sweepers — one role, one lock. Every 60 seconds it:
1. pulls `trivy-db` if the local copy is older than six hours
2. if the pulled version differs from `vulndb_meta.db_version`, marks **every**
server `scan_pending`
3. matches all `scan_pending` servers, clears the flag, diffs against existing
findings
4. emits **one** digest per tick covering everything newly opened
Step 4 is why batching is structural rather than bolted on. A `trivy-db`
refresh can open several hundred findings across a fleet at once; one message
per finding would rate-limit the webhook or get the channel muted, and either
way the customer stops receiving the alerts they are paying for. The tick is
already the natural batch boundary, so **the failure cannot occur by
construction** rather than by a debounce someone has to maintain.
`scan_pending` lives on the document rather than in memory, for the same reason
`next_run_at` and `workflow_log_seq` do: a leader handover between marking and
scanning would otherwise lose it. A handover costs the new leader one re-pull
of the database.
The cost of this indirection is up to 60 seconds between an agent reporting a
changed package set and its findings updating. For vulnerability data that is
nothing, and it buys a single matching path instead of two.
---
## Components
```
agent/internal/packages/ collect installed packages + /etc/os-release
proto/ ReportPackages RPC
server/internal/vulndb/ puller, BoltDB access, matcher
server/internal/vulnsched/ leader-owned tick: pull, scan, digest
server/internal/services/ findings, acceptance, alert rules
web/app/(app)/vulnerabilities/ fleet board; plus two server-detail tabs
```
`vulnsched` takes the dependencies it needs — `LogEvent` and the notification
dispatch — as a `vulnsched.Deps` injected from `main.go`, following
`workflowsched`. The manual rescan endpoint does not call into `vulnsched` at
all: it sets `scan_pending` on every server and lets the next tick find them,
so there is no path by which `services` imports the scheduler and no cycle to
avoid later.
---
## The wire path
A new `ReportPackages` RPC on the agent's existing hourly loop — the same
`runUpdateCheck` cadence, reusing `updates.go`'s `detectPM()`.
```protobuf
rpc ReportPackages(ReportPackagesRequest) returns (ReportPackagesResponse);
message ReportPackagesRequest {
string server_id = 1;
string agent_token = 2;
string hash = 3; // sha256 of the sorted list
OSRelease os = 4;
repeated InstalledPackage packages = 5; // omitted when only offering a hash
}
message ReportPackagesResponse {
bool need_full = 1; // hash differs; resend with packages populated
}
```
The agent calls once with `packages` empty. `need_full` true means the hash
differs from what the server holds, and the agent immediately calls again with
the list populated.
The agent sends a SHA-256 of its sorted package list first. If it matches what
the server already holds, the server answers `unchanged` and the ~150KB body is
never sent. A machine's package set changes rarely, so almost every hour costs
one small message, and the rare changed hour costs one extra round trip.
Folding the list into the existing 15-minute `InventoryReport` static snapshot
was rejected: it would re-send ~150KB per server every 15 minutes regardless of
change, roughly 40MB/hour of gRPC traffic on a 100-server fleet to transmit
data that is almost always identical.
---
## Data model
Four new collections. Every one carries `instance_id` except `vulndb_meta`,
which is explained below.
### `server_packages` — one document per server, not per package
```go
type ServerPackages struct {
ID primitive.ObjectID `bson:"_id"`
InstanceID primitive.ObjectID `bson:"instance_id"`
ServerID string `bson:"server_id"`
OS OSRelease `bson:"os"` // family, version_id, arch
Hash string `bson:"hash"` // sha256 of the sorted list
Packages []InstalledPackage `bson:"packages"`
CollectedAt time.Time `bson:"collected_at"`
ScanPending bool `bson:"scan_pending"`
ScannedAt time.Time `bson:"scanned_at"`
Status string `bson:"status"` // ok | unsupported
DBVersion int `bson:"db_version"` // last matched against
}
type InstalledPackage struct {
Name string `bson:"name"`
Version string `bson:"version"` // distro version string, verbatim
Epoch int `bson:"epoch,omitempty"`
Arch string `bson:"arch"`
SourceName string `bson:"source_name,omitempty"`
}
```
One document rather than two thousand is what makes a report a **single atomic
upsert with no delta logic** — the hash already established that something
changed, so there is nothing to reconcile field by field. A typical Linux host
lands near 150KB, comfortably inside the 16MB document limit.
Indexes: `{instance_id, server_id}` unique, and a multikey
`{instance_id, "packages.name"}` for fleet-wide package search.
`SourceName` is not decoration. **Debian and Ubuntu advisories are keyed on the
source package**: a CVE against `openssl` covers the binaries `libssl3`,
`openssl` and `libssl-dev`, so matching on binary name alone misses two of the
three.
`OS.VersionID` selects the feed. Ubuntu 22.04 and 24.04 publish different fixed
versions for the same CVE, so a scan without it is guesswork.
### `vuln_findings` — one document per (server, CVE, package)
```go
type VulnFinding struct {
ID primitive.ObjectID `bson:"_id"`
InstanceID primitive.ObjectID `bson:"instance_id"`
ServerID string `bson:"server_id"`
CVEID string `bson:"cve_id"`
PackageName string `bson:"package_name"`
Installed string `bson:"installed_version"`
FixedIn string `bson:"fixed_in,omitempty"`
Severity string `bson:"severity"`
CVSSScore float64 `bson:"cvss_score,omitempty"`
Title string `bson:"title,omitempty"`
References []string `bson:"references,omitempty"`
State string `bson:"state"` // open | fixed | accepted
FirstSeen time.Time `bson:"first_seen"`
LastSeen time.Time `bson:"last_seen"`
FixedAt *time.Time `bson:"fixed_at,omitempty"`
Accepted *Acceptance `bson:"accepted,omitempty"`
}
type Acceptance struct {
By primitive.ObjectID `bson:"by"`
Reason string `bson:"reason"`
Until time.Time `bson:"until"`
At time.Time `bson:"at"`
}
```
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 it is
what lets `first_seen` survive across scans. Query index
`{instance_id, state, severity}`.
**An empty `FixedIn` is a real and common state** and must never be conflated
with "not vulnerable". A CVE with no vendor fix published yet is exactly the
finding people most need to see, and also the one that most needs acceptance,
because there is nothing to patch.
Findings are **not deleted when a package is patched**. State moves to `fixed`
with `fixed_at` set, so "what did we remediate last quarter" remains
answerable — which is the question an auditor asks.
### `vulndb_meta` — singleton, deliberately unscoped
`db_version`, `pulled_at`, `last_full_scan_at`, `last_error`. It carries no
`instance_id` because the vulnerability database is a property of the
deployment, not of a tenant. Same reasoning as `migrations`.
### `vuln_alert_rules`
`instance_id`, `name`, `enabled`, `min_severity`, `tags map[string]string`,
`channel_ids []`, timestamps.
The tag filter resolves through **`services.ResolveTargets`**, not a second
matcher. That function is already the single answer to which servers a
selector touches, and an alert rule that disagreed with a workflow about what
`env:prod` means would be worse than having no filter at all.
---
## The matching engine
```
server/internal/vulndb/
pull.go OCI fetch → temp dir, version compare against vulndb_meta
db.go BoltDB open, advisory lookup by (ecosystem, source, version)
match.go per-family matching, severity resolution
version.go dispatch to deb/rpm/apk comparator by OS family
```
Dependencies: `github.com/aquasecurity/trivy-db` for the BoltDB schema, plus
`go-deb-version`, `go-rpm-version` and `go-apk-version` — each a small
standalone module doing one job. The roughly 200 lines of per-distro advisory
lookup are ours.
Importing `trivy` itself was rejected: it would pull a very large transitive
dependency tree into the server binary for one feature, and its Go API carries
no stability guarantee across minor versions. Shelling out to the `trivy`
binary against a generated SBOM was rejected for shipping a second binary in
the image and turning a library call into subprocess lifecycle, timeouts and
output-format drift.
### Why the comparators are bought rather than written
Version ordering is where this feature lives or dies, and its failure mode is
silent. `dpkg` ordering has epochs, and `~` sorts *before* the empty string, so
`3.0.2-0ubuntu1.15~rc1` precedes `3.0.2-0ubuntu1.15`. `rpmvercmp` has its own
segment rules and treats `~` and `^` differently again. A `strings.Compare` or
a semver parse orders `1.9` above `1.10` and reports a vulnerable fleet as
clean — a false negative, which nobody notices until it matters.
### Scanning one server
1. Load `server_packages`; resolve OS family and version to a `trivy-db`
ecosystem.
2. **Unsupported ecosystem → record `status: unsupported`, clear the flag,
write no findings.**
3. For each package: resolve source name, look up advisories, compare versions.
4. Upsert vulnerable results as `open`, preserving `first_seen`.
5. Any currently-`open` finding absent from this result set → `fixed`, stamp
`fixed_at`.
6. Any `accepted` finding past its `until` → back to `open`.
7. Clear `scan_pending`, stamp `scanned_at` and `db_version`.
Steps 5 and 6 must run in that order, so a finding that is both absent and
expired settles as `fixed` rather than reopening on a package that no longer
carries it.
Step 2 matters as much as any of the matching. Arch has no feed in `trivy-db`,
so an Arch host must report **unsupported**, never "0 findings". Reporting
clean when the truth is unknown is the same class of lie as a silently stale
database, and it is the reason `vulndb_meta.pulled_at` appears on screen rather
than only in a log.
### Severity
Resolved **vendor → NVD → unknown**, in that order, never invented.
This will surface as "why is this critical CVE marked low", and the answer is
that Debian and Red Hat routinely downgrade an NVD score because the vulnerable
code path is not reachable in their build. Their rating is the accurate one for
that package, and showing NVD's above it would manufacture work that does not
need doing.
---
## Findings lifecycle
`open | fixed | accepted`.
An accepted finding is suppressed from counts and alerts until its `until`
date, then reopens automatically. A reason is required.
Acceptance with a mandatory expiry, rather than permanent dismissal, is what
keeps the feature usable in both directions. Without any acceptance mechanism,
a kernel CVE awaiting a reboot window sits red indefinitely and trains people
to ignore the page. With permanent dismissal, accepted findings accumulate
silently and nobody revisits them — the dismissal list becomes where risk goes
to be forgotten, which is precisely what an auditor asks to see.
Retention: `settings.vuln_finding_retention_days`, a `*int` on the same pattern
as `workflow_log_retention_days` — nil means 90 days, 0 means forever. Only
`fixed` findings are swept, by a `StartVulnSweeper` inside the same
`RunAsLeader("housekeeping", …)` as the existing sweepers. `open` and
`accepted` findings are never swept at any setting.
---
## Alerting
Per-org rules over the existing `notification_channels`: severity threshold,
optional tag filter, target channels.
A rescan emits one message summarising what newly opened — "12 new critical
across 4 servers" — never one message per finding. See the leader section for
why the tick boundary makes this structural.
Modelling findings as a monitor type was rejected. It would reuse monitors'
state machine and channel wiring for free, but monitors are up/down for one
endpoint with retries and hourly rollups, none of which means anything for a
CVE; most fields would be disabled in the UI and the uptime graphs would be
polluted with a signal that is not uptime.
This adds one `notify` payload type and a `vuln_digest.html.tmpl` /
`vuln_digest.txt.tmpl` pair in `shared/mail`. Note that `shared/mail` templates
are parsed in `init()`, so a mistyped field is a boot-time panic — CLAUDE.md
describes a `render_test.go` guarding against exactly this, but **that file does
not exist**; the repository has no Go tests at all, and by instruction this
feature adds none. The template pair must therefore be verified by starting the
binary and sending one digest through a real channel.
---
## Entitlement
The feature name is `vuln_scanning`, and it crosses the two services the way
every other feature does:
- **admin** carries it as a per-instance entitlement toggle, so it can later be
priced as a catalogue `feature` component without a second migration;
- **the licence** snapshots it into `License.Features []string` at issue time;
- **the server** asks `lic.HasFeature("vuln_scanning")` and never switches on
tier, so changing what a tier includes needs no server release.
Off on Free.
**The gate is checked at `ReportPackages`, not at display.** Gating only the UI
would still pay every write cost, and storage is the expensive half.
The agent learns of it through the existing 30-second `SyncKeys` poll:
`SyncResponse` gains a `collect_packages` bool, and the hourly loop skips
collection entirely when it is false. So an ungated instance produces no
collection, no gRPC body, no document and no storage. `ReportPackages` still
re-checks the entitlement server-side and refuses — the agent flag is an
optimisation, the server check is the boundary.
Turning the feature off does not delete existing findings; they stop being
served and stop updating. Deletion is the instance-deletion path's job.
---
## REST API
```
GET /api/vulnerabilities # filter: severity, state, server, tags
GET /api/vulnerabilities/summary # severity counts + database freshness
POST /api/vulnerabilities/rescan # marks all scan_pending (owner|admin)
POST /api/vulnerabilities/:id/accept # reason + until (owner|admin)
DELETE /api/vulnerabilities/:id/accept # (owner|admin)
GET /api/servers/:id/vulnerabilities
GET /api/servers/:id/packages
GET /api/packages/search?name= # fleet-wide
GET,POST /api/vuln-rules · PUT,DELETE /api/vuln-rules/:id
```
Every mutating path writes an audit event, as all of them do. Acceptance is the
one decision people will be asked to justify, so `by`, `reason`, `until` and
`at` land in the audit record and not only on the document.
---
## UI
`/vulnerabilities` is a fleet board **grouped by CVE** — one row per CVE with
an affected-server count, expandable to the individual servers. The same CVE
across 40 servers is one decision, and a flat list of findings makes it look
like forty.
Server detail gains **Vulnerabilities** and **Packages** tabs. Alert rules go
on `/settings/notifications`, beside the channels they consume.
Remediation introduces no new mechanism: a finding carrying `fixed_in` renders
an **Apply updates** action calling the existing
`POST /api/servers/:id/apply-updates`, which is already `ApplyUpdatesCmd`. See
it, patch it, one place — and no second patching path to keep consistent with
the first.
Database freshness is shown wherever findings are, not tucked into settings. A
fleet scanning against a three-week-old database must say so rather than
quietly report all-clear.
---
## Verification
**No automated tests.** The repository has none today, and by explicit
instruction this feature adds none — no `*_test.go`, no frontend test files.
That is a deliberate decision by the repository owner, recorded here so the
absence reads as a choice rather than an omission.
It does change the risk profile, and the places it changes it are worth naming,
because each fails by producing a **wrong answer rather than a crash**:
- **Version comparison.** The backport case — installed `1:3.0.2-0ubuntu1.15`
against advisory fixed-in `1:3.0.2-0ubuntu1.15` resolving to *not
vulnerable* — plus tilde ordering (`1.0~rc1` < `1.0`), epoch dominance
(`1:1.0` > `2.0`) and `1.9` < `1.10`. Wrong here means a vulnerable fleet
reported clean.
- **Source-package fan-out.** One advisory against `openssl` must flag
`libssl3`, `openssl` and `libssl-dev`. Matching on binary name alone silently
finds one of three.
- **`first_seen` preservation.** An upsert that overwrites it makes every
finding look discovered today, and nothing surfaces that until someone reads
a report.
- **Fixed-before-reopen ordering.** A finding both absent from a scan and past
its acceptance expiry must settle `fixed`, not reopen.
The implementation plan carries a manual verification table for each, to be
walked before the relevant task is committed. They are the substitute for the
tests, not a formality.
---
## Failure modes
| Failure | Behaviour |
| ------- | --------- |
| Database pull fails | Keep the last good copy and serve stale. Record `last_error`, surface `pulled_at` age. **Never clear findings** — a network blip must not read as "all fixed" |
| Unsupported distribution | `status: unsupported`, not zero findings |
| Agent stops reporting | Findings persist and `collected_at` age is shown. No auto-expiry: a silent agent is not a patched server |
| `trivy-db` schema version bumps | The puller refuses an unknown schema rather than mis-parsing it |
| ghcr anonymous rate limit | Backoff; `VANTAGE_TRIVY_DB_REF` mirrors to a private registry |
| Leadership lost mid-scan | The context is cancelled and the scan returns; `scan_pending` is still set, so the next leader picks it up |
| Instance deleted | **`server_packages` and `vuln_findings` must be added to the control plane's instance-deletion collection list.** Easy to miss, and missing it orphans a tenant's package data indefinitely |
---
## Environment variables
| Name | Required | Notes |
| ---- | -------- | ----- |
| `VANTAGE_TRIVY_DB_REF` | no | default `ghcr.io/aquasecurity/trivy-db:2`. Point at a mirror for air-gapped installs or to avoid the anonymous ghcr rate limit |
| `VANTAGE_VULNDB_DISABLED` | no | disables the puller and the scheduler entirely. Findings already written are still served and still marked stale |
---
## Deliberately out of scope
- **Windows.** Separate source, collector and matcher; its own spec.
- **Container image scanning.** Sub-project C; needs the container registry.
- **Compliance baseline assertions.** Sub-project D; shares this findings UI
and nothing else.
- **Language-level dependency scanning** (npm, pip, Go modules). `trivy-db`
covers these ecosystems, but finding the manifests on a host is a different
collection problem from asking the package manager what is installed.
- **Automatic patching on a finding.** Remediation is one click, not zero. An
unattended upgrade triggered by a CVE feed is a fleet-wide change driven by a
third party's data, which is not a decision to take away from an operator.
@@ -0,0 +1,441 @@
# Workload registry
Date: 2026-08-06
Agents enumerate what each server actually runs — Docker containers, the
compose stacks grouping them, and systemd services — and report it to the
control plane. Containers and units can be started, stopped and restarted from
the UI, and a bounded snapshot of their logs can be read without opening a
console.
This is **sub-project B** of the four sketched in
`2026-08-06-package-inventory-and-cve-findings-design.md`:
| # | Sub-project | Depends on |
| - | ----------- | ---------- |
| A | Package inventory + CVE findings — its own spec | nothing |
| B | **Workload registry** — this spec | nothing |
| C | Container image scanning | A and B |
| D | Compliance profiles | shares A's findings UI only |
A and B are independent. C is the joiner and must not be designed before both
exist: it needs B's image list and A's findings model.
**Workload** is the domain word throughout: one container or one systemd unit.
It gives the collection, the commands and the page a single honest name rather
than saying "container or service" in every identifier.
Scope is **Linux only**, matching sub-project A and the existing position that
Windows agents are second-class by design. Docker runs on Windows; systemd does
not, and half a feature per platform is worse than a clear line.
---
## What this is for
The control plane can manage a fleet's keys, run workflows across it and watch
its endpoints, but it has no idea what any of those servers actually *runs*.
"Restart nginx on that box" means opening a console. "Which of these 80 servers
is still on the old image" is unanswerable.
---
## Reporting and refresh are one path
The agent reports on its own 60-second ticker through a `ReportWorkloads` RPC,
using the same hash short-circuit as the package report: it offers a SHA-256 of
the sorted workload list, and sends the body only when the server does not
already hold that hash. An unchanged list costs one small message, which on a
60-second cadence is the common case by a wide margin.
The on-demand refresh **does not return data**. `RefreshWorkloadsCmd` carries
no payload back; it makes the agent report immediately through the normal RPC,
and the UI refetches the stored document.
That is deliberate. A refresh that returned workloads inline would be a second
writer for the same collection, arriving by a different route, with its own
serialisation and its own opportunity to disagree with the periodic one. One
writer, one shape; the refresh is a nudge, not a channel.
Opening a server's Workloads tab dispatches a refresh, so what is on screen is
live rather than up to a minute stale. That matters because the page has a
Restart button on it: a stale list is not merely a wrong impression, it is a
wrong action aimed at a container that already died.
## What does answer back
Two operations genuinely return something:
| Command | Answers with |
| ------- | ------------ |
| `ControlWorkloadCmd{kind, id, action}` | the existing `CommandResult` — ok or error |
| `WorkloadLogsCmd{kind, id, tail}` | a new `WorkloadLogsResult{command_id, text, truncated}` |
Both ride the proven path: `commandDispatcher.send()` for request and ack, and
a `WorkloadResults` registry mirroring `StepResults.Await`/`Deliver` over the
bus. **`Await` must subscribe before the command is dispatched** — the pod
driving the request is usually not the pod holding the agent's stream, and a
fast agent otherwise answers into a channel nobody has joined. This is not a
new hazard; it is the one `stepresults.go` already documents.
```protobuf
rpc ReportWorkloads(ReportWorkloadsRequest) returns (ReportWorkloadsResponse);
message ReportWorkloadsRequest {
string server_id = 1;
string agent_token = 2;
string hash = 3;
bool docker_ok = 4;
string docker_error = 5;
bool systemd_ok = 6;
string systemd_error = 7;
repeated Workload workloads = 8; // empty on the offer call
}
message ReportWorkloadsResponse {
bool need_full = 1;
}
// ServerCommand gains three variants.
message RefreshWorkloadsCmd {}
message ControlWorkloadCmd {
string kind = 1; // "container" | "unit"
string id = 2;
string action = 3; // "start" | "stop" | "restart"
}
message WorkloadLogsCmd {
string kind = 1;
string id = 2;
int32 tail = 3;
}
// AgentMessage gains one variant.
message WorkloadLogsResult {
string command_id = 1;
string text = 2;
bool truncated = 3;
string error = 4;
}
```
The offer-then-send handshake is the package report's, unchanged: the agent
calls once with `workloads` empty, and resends with the body only if the
response sets `need_full`.
An agent whose stream no pod holds gets a 503 from the dispatcher, as
everything else does. Commands are not queued: a command whose owner died must
fail loudly rather than be delivered to nobody while the operator is told it
worked.
---
## Not gated by licence
Unlike CVE scanning, this reads as core fleet management rather than a premium
add-on, so v1 ships to every instance with no entitlement check.
If that changes it is a one-line `HasFeature` check at `ReportWorkloads`,
gating collection rather than display — the same placement and the same
reasoning as sub-project A, where gating the UI alone would still pay every
write cost.
---
## Data model
One new collection, `server_workloads`, one document per server, mirroring
`server_packages`.
```go
type ServerWorkloads struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
InstanceID string `bson:"instance_id" json:"-"`
ServerID string `bson:"server_id" json:"server_id"`
Hash string `bson:"hash" json:"hash"`
Workloads []Workload `bson:"workloads" json:"workloads"`
CollectedAt time.Time `bson:"collected_at" json:"collected_at"`
DockerOK bool `bson:"docker_ok" json:"docker_ok"`
DockerError string `bson:"docker_error,omitempty" json:"docker_error,omitempty"`
SystemdOK bool `bson:"systemd_ok" json:"systemd_ok"`
SystemdError string `bson:"systemd_error,omitempty" json:"systemd_error,omitempty"`
}
type Workload struct {
Kind string `bson:"kind" json:"kind"` // "container" | "unit"
ID string `bson:"id" json:"id"` // container id, or unit name
Name string `bson:"name" json:"name"`
State string `bson:"state" json:"state"`
Health string `bson:"health,omitempty" json:"health,omitempty"`
Image string `bson:"image,omitempty" json:"image,omitempty"`
Stack string `bson:"stack,omitempty" json:"stack,omitempty"`
Ports []string `bson:"ports,omitempty" json:"ports,omitempty"`
Restarts int `bson:"restarts,omitempty" json:"restarts,omitempty"`
StartedAt time.Time `bson:"started_at,omitempty" json:"started_at,omitempty"`
Protected bool `bson:"protected" json:"protected"`
}
```
`State` is normalised across the two kinds: containers report `running`,
`exited`, `paused`, `restarting`, `created`; units report `active`, `inactive`,
`failed`, `activating`. They are deliberately **not** collapsed into a shared
vocabulary — a failed unit and an exited container mean different things, and
flattening them would lose the distinction the operator needs.
Indexes: `{instance_id, server_id}` unique, plus multikey
`{instance_id, "workloads.image"}` for the fleet-wide "which servers run image
X" query.
### Why the OK/Error pairs exist
A host with no Docker installed and a host where Docker is installed and
running nothing both produce an empty list. One should read "not in use here",
the other "nothing running", and only the second deserves any alarm.
The error strings separate a third case the booleans alone cannot: Docker
installed with the daemon down. "Not installed" and "installed but not
responding" are different problems with different fixes, and collapsing them
into one false boolean throws away the only thing that tells them apart.
### Why `Protected` is reported rather than derived
The agent already knows which unit and container it is. Sending that up lets
the UI render the action disabled with a reason instead of offering a button
whose refusal is already known.
The field is the courtesy; the agent's own check is the boundary. See the
control section.
### No history
A workload list is state, not a record. Nobody asks what containers ran last
Tuesday, and keeping it would grow a collection per server per minute in
exchange for a question nobody has.
---
## Collectors
### Docker: two commands, no English parsing
```
docker ps -aq
docker inspect --format '{{json .}}' <ids…>
```
Not `docker ps --format '{{json .}}'` alone. That reports health and uptime
inside a human `Status` string — `"Up 2 hours (healthy)"` — and anything built
on it is parsing English that is localised, reworded between releases, and
silently different for a paused or restarting container. `inspect` returns
`State.Health.Status`, `State.StartedAt` and `RestartCount` as typed fields.
Two execs instead of one, and no parser to be wrong.
`RestartCount` justifies the second call by itself: a container cycling is the
single thing this page most needs to show, and it is invisible in a list that
only ever says "Up".
Compose stacks come from the `com.docker.compose.project` label. **No YAML is
read from disk** — the label is what Docker itself treats as authoritative, and
a compose file on disk may not be what is actually running.
Docker absent, or a socket that cannot be reached, sets `DockerOK: false`. It
is not an error and produces no log line: most servers in a fleet built around
SSH key management will not have Docker, and treating the normal case as a
fault makes the feature look broken on the majority of the estate.
### systemd: filtered on purpose
```
systemctl list-units --type=service --state=running,failed --no-legend --plain --no-pager
systemctl list-unit-files --type=service --state=enabled --no-legend --plain --no-pager
```
Two calls because "running or failed" and "enabled but stopped" are different
questions, and an enabled unit that is not running is exactly the one worth
seeing.
Excluded by prefix: `systemd-`, `user@`, `session-`, `init.scope`. A typical
host carries 300+ units, the platform's own accounting for most of them.
Listing all of them buries the ten anyone cares about — the same failure mode
as an unfiltered vulnerability report, and the same fix.
Column output rather than `--output=json`: the JSON flag requires systemd 246+,
and this fleet includes older stable distributions. The column format has been
stable considerably longer than the JSON one has existed.
---
## Control actions
```
container: docker {start|stop|restart} <id>
unit: systemctl {start|stop|restart} <unit>
```
Owner or admin only. Every action writes an audit event naming the actor, the
server and the target.
### The protected set
Computed agent-side: `vantage-agent.service`, plus the container ID read from
`/proc/self/cgroup` should the agent ever be run inside a container.
The agent refuses those before doing anything. As with the console relay
hardcoding `127.0.0.1` agent-side, **the control plane may name a target, but
the agent decides what it will do to itself**. A server-side denylist alone
would be bypassed by the next dispatch path someone adds, and the failure is
unrecoverable from the UI: a server that stops its own agent goes offline, and
the way back is SSH or physical access — precisely what this feature exists to
avoid needing.
### Timeouts
`docker stop` waits on a container that may ignore SIGTERM. `systemctl stop`
on a unit with a long `TimeoutStopSec` blocks for exactly as long as that says.
Both run under a 90-second context, and a timeout returns a real error rather
than an ack implying success.
---
## Logs
```
container: docker logs --tail 500 --timestamps <id>
unit: journalctl -u <unit> -n 500 --no-pager --output=short-iso
```
Capped at **500 lines and 256KB, whichever binds first**, with `truncated` set
so the UI can say so. Two caps because 500 lines of a container emitting 4KB
JSON blobs is 2MB, and a line count alone does not stop it — the same reasoning
that gave workflow logs both a per-line and a per-run cap.
Live following is deliberately absent. The browser console already offers a
real terminal on the same server, where `docker logs -f` works properly with
its own scrollback and cancellation. Building a second streaming path — a
relay listener, proxy bus keys, a WebSocket upgrade and a cancellation story
for a follow nobody closed — to duplicate that would be a large amount of
machinery aimed at a capability already shipped. A bounded snapshot answers
"why did this restart", which is the question that sends people to the console
in the first place.
### Log reads are owner or admin only, and audited
Unlike workflow logs, these cannot be masked. A workflow's logs can be masked
because the run injected the secrets and therefore knows their values. A
container's stdout is arbitrary and may contain credentials nobody declared —
a connection string in a startup banner, a token in a stack trace.
So log reads sit behind the same role check as control actions and are audited.
A member who can see the fleet cannot read its logs. This is a deliberate
access decision, not an oversight, and it is why log reading is not simply
folded in with the read-only snapshot endpoints.
---
## REST API
```
GET /api/servers/:id/workloads # stored snapshot
POST /api/servers/:id/workloads/refresh # dispatch, then refetch
POST /api/servers/:id/workloads/:wid/action # {"action":"start|stop|restart"} (owner|admin)
GET /api/servers/:id/workloads/:wid/logs?tail= # (owner|admin)
GET /api/workloads?image=&stack=&state= # fleet-wide
```
`:wid` is a container ID or a unit name, URL-encoded. Unit names carry dots and
`@`, which are legal in a path segment but not worth relying on unencoded.
`tail` is clamped to the 500-line cap server-side; a client asking for more
gets 500, not an error.
---
## UI
Server detail gains a **Workloads** tab, ordered compose stacks first — grouped
under the stack name — then loose containers, then units.
That ordering is not cosmetic. A stack is one thing to an operator even when it
is six containers, and a flat list turns one decision into six rows. It is the
same argument that groups the vulnerabilities board by CVE rather than by
finding.
A `/workloads` fleet view answers "which servers run image X", which is the
reason the snapshot is stored at all rather than fetched on demand and
discarded.
Three rules that follow directly from the model:
- **Protected rows render their actions disabled, with the reason**, rather
than offering a button whose refusal is already known.
- **`DockerOK: false` reads "Docker not in use on this server"**, never an
empty list, and `DockerError` when present is shown as a distinct problem.
- State never reads by colour alone: every pill carries a distinct shape and a
text label, matching the existing monitor and severity pills.
---
## Verification
**No automated tests.** The repository has none today, and by explicit
instruction this feature adds none — no `*_test.go`, no frontend test files.
A deliberate decision by the repository owner, recorded so the absence reads as
a choice rather than an omission.
The behaviours that would otherwise have been tested are the ones that fail
quietly, and the implementation plan carries a manual check for each:
- **Parser output against real command output.** `docker inspect` must yield
`RestartCount`, health and the compose label as `Stack`; the `systemctl`
exclusion filter must drop `systemd-*` and `user@*` while keeping
`nginx.service`. Both are verified against a live host rather than a fixture.
- **Protected-set computation.** `vantage-agent.service` marked,
`nginx.service` not. Getting this wrong in the permissive direction lets a
server stop its own agent, which is unrecoverable from the UI.
- **Hash order-independence.** An ordering-sensitive hash resends the full list
every 60 seconds, which is invisible except as traffic.
- **Log capping in both directions.** 600 lines in → 500 out with `truncated`;
a 300KB blob of fewer than 500 lines → capped, `truncated`. The second is the
case a line-count-only implementation silently fails, and it fails by sending
megabytes rather than by erroring.
---
## Failure modes
| Failure | Behaviour |
| ------- | --------- |
| Docker not installed | `DockerOK: false`, no error, UI reads "not in use" |
| Docker installed, daemon down | `DockerOK: false` **plus** `DockerError` — different message, different fix |
| Agent offline | 503 from the existing dispatcher. No queueing: a command whose owner died must fail loudly |
| Action on a protected workload | Agent refuses; API answers 409 naming the reason |
| `stop` exceeds its timeout | Real error surfaced, never a hopeful ack. Snapshot refreshed afterwards |
| Container removed between snapshot and action | Docker's "No such container" surfaced and a refresh dispatched — this is what on-demand refresh is for |
| Log exceeds either cap | Truncated, flagged, and stated in the UI |
| Instance deleted | **`server_workloads` must be added to the control plane's instance-deletion collection list**, alongside sub-project A's two collections |
---
## Deliberately out of scope
- **Live log following.** The console already does it. See the logs section.
- **Creating, deleting or updating containers and units.** This is a control
and visibility surface, not a deployment tool — workflows already exist for
changing what a server runs, with snapshots, audit and rollback.
- **`docker exec` into a container.** The console reaches the host; exec from
the control plane is a second remote-execution path with its own audit and
authorisation story, and it belongs in its own spec if anywhere.
- **Kubernetes and containerd.** The Docker collector shells to the `docker`
CLI, so a node whose runtime is containerd or CRI-O reports nothing from it —
`DockerOK: false`, correctly, since Docker genuinely is not in use. Covering
those runtimes means a `crictl`/`nerdctl` collector, and talking to a
Kubernetes API server is a different subsystem again. Neither is v1.
- **Podman as a supported runtime.** Its `docker`-compatible CLI means an
aliased install will largely work, and that is a happy accident rather than a
claim: nothing here is tested against Podman and its `RestartCount` and
compose-label behaviour are not verified.
- **Windows.** No systemd, and a different container story.
- **Image vulnerability scanning.** Sub-project C, which needs this spec's
image list and sub-project A's findings model.
+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",
+50
View File
@@ -1,22 +1,69 @@
cloud.google.com/go/compute v1.25.1/go.mod h1:oopOIR53ly6viBYxaDhBfJwzUAxf1zE//uf3IB011ls=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
github.com/Intevation/gval v1.3.0/go.mod h1:xmGyGpP5be12EL0P12h+dqiYG8qn2j3PJxIgkoOHO5o=
github.com/Intevation/jsonpath v0.2.1/go.mod h1:WnZ8weMmwAx/fAO3SutjYFU+v7DFreNYnibV7CiaYIw=
github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
github.com/aquasecurity/bolt-fixtures v0.0.0-20200903104109-d34e7f983986/go.mod h1:NT+jyeCzXk6vXR5MTkdn4z64TgGfE5HMLC8qfj5unl8=
github.com/aquasecurity/go-gem-version v0.0.0-20201115065557-8eed6fe000ce/go.mod h1:HXgVzOPvXhVGLJs4ZKO817idqr/xhwsTcj17CLYY74s=
github.com/aquasecurity/go-npm-version v0.0.1/go.mod h1:hxbJZtKlO4P8sZ9nztizR6XLoE33O+BkPmuYQ4ACyz0=
github.com/aquasecurity/go-pep440-version v0.0.1/go.mod h1:3naPe+Bp6wi3n4l5iBFCZgS0JG8vY6FT0H4NGhFJ+i4=
github.com/aquasecurity/go-version v0.0.1/go.mod h1:s1UU6/v2hctXcOa3OLwfj5d9yoXHa3ahf+ipSwEvGT0=
github.com/briandowns/spinner v1.23.0/go.mod h1:rPG4gmXeN3wQV/TsAY4w8lPdIM6RX3yqeBQJSrbXjuE=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw=
github.com/cheggaaa/pb/v3 v3.1.7/go.mod h1:/Ji89zfVPeC/u5j8ukD0MBPHt2bzTYp74lQ7KlgFWTQ=
github.com/cncf/xds/go v0.0.0-20240318125728-8a4994d93e50/go.mod h1:5e1+Vvlzido69INQaVO6d87Qn543Xr6nooe9Kz7oBFM=
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/envoyproxy/go-control-plane v0.12.0/go.mod h1:ZBTaoJ23lqITozF0M6G4/IragXCQKCnYbmlmtHvwRG0=
github.com/envoyproxy/protoc-gen-validate v1.0.4/go.mod h1:qys6tmnRsYrQqIhm2bvKZH4Blx/1gTIZ2UKVY1M+Yew=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gocsaf/csaf/v3 v3.1.1/go.mod h1:EpUCrQg69i+Y66MphmQvVbcj333GFLjXOYHg1zoXVso=
github.com/golang/glog v1.2.0/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/josephburnett/jd/v2 v2.3.0/go.mod h1:0I5+gbo7y8diuajJjm79AF44eqTheSJy1K7DSbIUFAQ=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/masahiro331/go-mvn-version v0.0.0-20250131095131-f4974fa13b8a/go.mod h1:jZ3F25l7DbD7l7DcA8aj7eo1EZ84nbzcQHBB4lCSrI8=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/package-url/packageurl-go v0.1.3/go.mod h1:nKAWB8E6uk1MHqiS/lQb9pYBGH2+mdJ2PJc2s50dQY0=
github.com/pandatix/go-cvss v0.6.2/go.mod h1:jDXYlQBZrc8nvrMUVVvTG8PhmuShOnKrxP53nOFkt8Q=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/samber/lo v1.50.0 h1:XrG0xOeHs+4FQ8gJR97zDz5uOFMW7OwFWiFVzqopKgY=
github.com/samber/lo v1.50.0/go.mod h1:RjZyNk6WSnUFRKK6EyOhsRJMqft3G+pg7dCWHQCWvsc=
github.com/samber/oops v1.18.1 h1:qjhZbqbdyhWBKntkY8sxrDNKA8b4c5VHlmI1rli7X7M=
github.com/samber/oops v1.18.1/go.mod h1:xYqvimigkKV70HyLXiBZJFpIWi2CGcc6Xx7eV+2HycI=
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/urfave/cli v1.22.16/go.mod h1:EeJR6BKodywf4zciqrdw6hpCPk68JO9z5LazXZMn5Po=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
@@ -32,10 +79,13 @@ golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/genproto/googleapis/api v0.0.0-20240318140521-94a12d6c2237/go.mod h1:Z5Iiy3jtmioajWHDGFk7CeugTyHtPvMHA4UTmUkyalE=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
k8s.io/utils v0.0.0-20201110183641-67b214c5f920/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA=
+46
View File
@@ -9,6 +9,7 @@ service Vantage {
rpc SyncKeys(SyncRequest) returns (SyncResponse);
rpc UploadGeneratedKey(UploadKeyRequest) returns (UploadKeyResponse);
rpc ReportUpdates(ReportUpdatesRequest) returns (ReportUpdatesResponse);
rpc ReportPackages(ReportPackagesRequest) returns (ReportPackagesResponse);
rpc ReportInventory(InventoryReport) returns (InventoryReportResponse);
rpc SyncMonitors(SyncMonitorsRequest) returns (SyncMonitorsResponse);
rpc ReportChecks(ReportChecksRequest) returns (ReportChecksResponse);
@@ -37,6 +38,51 @@ message SyncRequest {
message SyncResponse {
repeated string public_keys = 1;
// collect_packages tells the agent whether this instance's licence grants
// vulnerability scanning. False means do not collect at all: no gRPC body,
// no document, no storage. The server re-checks on ReportPackages — this
// flag is the optimisation, the server check is the boundary.
//
// Absent reads as false, which is the safe direction: an old server that
// does not send it leaves agents collecting nothing.
bool collect_packages = 2;
}
// ReportPackages carries a server's installed package set.
//
// The agent calls twice at most. The first call sends only the hash; if the
// server already holds that hash it answers need_full = false and the ~150KB
// body is never sent. A machine's package set changes rarely, so almost every
// hour costs one small message.
message ReportPackagesRequest {
string server_id = 1;
string agent_token = 2;
string hash = 3;
OSRelease os = 4;
repeated InstalledPackage packages = 5; // empty on the offer call
}
message ReportPackagesResponse {
bool need_full = 1;
}
message OSRelease {
string family = 1;
// version_id is not optional: Ubuntu 22.04 and 24.04 publish different fixed
// versions for the same CVE, so a scan without it is guesswork.
string version_id = 2;
string arch = 3;
}
message InstalledPackage {
string name = 1;
string version = 2;
int32 epoch = 3;
string arch = 4;
// source_name is what the Debian and Ubuntu feeds are keyed on: one advisory
// against "openssl" covers libssl3, openssl and libssl-dev.
string source_name = 5;
}
message UploadKeyRequest {
+11
View File
@@ -24,6 +24,7 @@ import (
grpcserver "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/monitorsched"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/vulnsched"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/workflowsched"
"github.com/gin-gonic/gin"
)
@@ -131,6 +132,10 @@ func runSchemaSetup() {
log.Printf("warning: failed to ensure workflow indexes: %v", err)
}
if err := services.EnsureVulnIndexes(); err != nil {
log.Printf("warning: failed to ensure vuln indexes: %v", err)
}
if instanceIDs, err := services.ListInstanceIDs(); err != nil {
log.Printf("warning: failed to list instances for default step seeding: %v", err)
} else {
@@ -191,6 +196,12 @@ func serve() {
LogEvent: services.LogEvent,
})
vulnsched.Start(jobCtx, vulnsched.Deps{
LogEvent: services.LogEvent,
SendDigest: services.SendVulnDigest,
})
services.StartVulnSweeper(jobCtx)
ticker := time.NewTicker(2 * time.Minute)
defer ticker.Stop()
for {
+7
View File
@@ -15,8 +15,15 @@ require (
)
require (
github.com/aquasecurity/trivy-db v0.0.0-20260713131703-4be526083c54 // indirect
github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 // indirect
github.com/knqyf263/go-apk-version v0.0.0-20200609155635-041fdbb8563f // indirect
github.com/knqyf263/go-deb-version v0.0.0-20241115132648-6f4aee6ccd23 // indirect
github.com/knqyf263/go-rpm-version v0.0.0-20240918084003-2afd7dc6a38f // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
oras.land/oras-go/v2 v2.6.2 // indirect
)
require (
+16
View File
@@ -1,3 +1,5 @@
github.com/aquasecurity/trivy-db v0.0.0-20260713131703-4be526083c54 h1:4CZNoDkNfcuACevZeDraACGmP1+L0nKkRY52+jV8k1M=
github.com/aquasecurity/trivy-db v0.0.0-20260713131703-4be526083c54/go.mod h1:iIEV2oGuZScvfyX2SMIn78iVMNnepgo0QuJJh/srgVI=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
@@ -52,6 +54,12 @@ github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6K
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knqyf263/go-apk-version v0.0.0-20200609155635-041fdbb8563f h1:GvCU5GXhHq+7LeOzx/haG7HSIZokl3/0GkoUFzsRJjg=
github.com/knqyf263/go-apk-version v0.0.0-20200609155635-041fdbb8563f/go.mod h1:q59u9px8b7UTj0nIjEjvmTWekazka6xIt6Uogz5Dm+8=
github.com/knqyf263/go-deb-version v0.0.0-20241115132648-6f4aee6ccd23 h1:dWzdsqjh1p2gNtRKqNwuBvKqMNwnLOPLzVZT1n6DK7s=
github.com/knqyf263/go-deb-version v0.0.0-20241115132648-6f4aee6ccd23/go.mod h1:lUaIXCWzf7BRKTY5iEcrYy1TfgbYLYVIS/B2vPkJzOc=
github.com/knqyf263/go-rpm-version v0.0.0-20240918084003-2afd7dc6a38f h1:xt29M2T6STgldg+WEP51gGePQCsQvklmP2eIhPIBK3g=
github.com/knqyf263/go-rpm-version v0.0.0-20240918084003-2afd7dc6a38f/go.mod h1:i4sF0l1fFnY1aiw08QQSwVAFxHEm311Me3WsU/X7nL0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
@@ -64,6 +72,10 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -81,6 +93,7 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
@@ -158,8 +171,11 @@ google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6h
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
oras.land/oras-go/v2 v2.6.2 h1:N04RXngAp1LJKTG6ifz3xHPipasEkWr+hFmInja5YKo=
oras.land/oras-go/v2 v2.6.2/go.mod h1:PlTtg4JTDJkDe8yVHpM2wz7/YDc00GVas+i4jAW2TZ4=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+13
View File
@@ -119,6 +119,19 @@ func RegisterRoutes(r *gin.Engine) {
providers.POST("/:id/ack-notice", ackAuthProviderNotice)
}
apiGroup.GET("/auth/presets", auth.RequireRole("owner", "admin"), listAuthPresets)
apiGroup.GET("/vulnerabilities", listVulnerabilities)
apiGroup.GET("/vulnerabilities/summary", vulnerabilitySummary)
apiGroup.POST("/vulnerabilities/rescan", auth.RequireRole("owner", "admin"), rescanVulnerabilities)
apiGroup.POST("/vulnerabilities/:id/accept", auth.RequireRole("owner", "admin"), acceptFinding)
apiGroup.DELETE("/vulnerabilities/:id/accept", auth.RequireRole("owner", "admin"), unacceptFinding)
apiGroup.GET("/servers/:id/vulnerabilities", listServerVulnerabilities)
apiGroup.GET("/servers/:id/packages", getServerPackages)
apiGroup.GET("/packages/search", searchPackages)
apiGroup.GET("/vuln-rules", listVulnRules)
apiGroup.POST("/vuln-rules", auth.RequireRole("owner", "admin"), createVulnRule)
apiGroup.PUT("/vuln-rules/:id", auth.RequireRole("owner", "admin"), updateVulnRule)
apiGroup.DELETE("/vuln-rules/:id", auth.RequireRole("owner", "admin"), deleteVulnRule)
}
}
+304
View File
@@ -0,0 +1,304 @@
package api
import (
"errors"
"net/http"
"sort"
"strconv"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/gin-gonic/gin"
)
// vulnGroup is one CVE across every server it affects.
//
// The board groups by CVE rather than listing findings flat: the same CVE on
// forty servers is one decision, and a flat list makes it look like forty.
type vulnGroup struct {
CVEID string `json:"cve_id"`
Severity string `json:"severity"`
Title string `json:"title,omitempty"`
ServerCount int `json:"server_count"`
Findings []models.VulnFinding `json:"findings"`
}
func listVulnerabilities(c *gin.Context) {
findings, err := services.ListInstanceFindings(auth.InstanceID(c), services.FindingFilter{
Severity: c.Query("severity"),
State: c.DefaultQuery("state", models.FindingOpen),
ServerID: c.Query("server"),
Tags: tagsFromQuery(c),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, groupByCVE(findings))
}
// groupByCVE collapses findings into one row per CVE, most severe first.
func groupByCVE(findings []models.VulnFinding) []vulnGroup {
index := map[string]*vulnGroup{}
order := []string{}
for _, f := range findings {
g, ok := index[f.CVEID]
if !ok {
g = &vulnGroup{CVEID: f.CVEID, Severity: f.Severity, Title: f.Title}
index[f.CVEID] = g
order = append(order, f.CVEID)
}
// Several servers can disagree on severity when their distributions
// rate the same CVE differently. The highest is shown, because that is
// the one deciding whether anyone acts.
if models.SeverityRank(f.Severity) > models.SeverityRank(g.Severity) {
g.Severity = f.Severity
}
g.Findings = append(g.Findings, f)
}
out := make([]vulnGroup, 0, len(order))
for _, id := range order {
g := index[id]
servers := map[string]bool{}
for _, f := range g.Findings {
servers[f.ServerID] = true
}
g.ServerCount = len(servers)
out = append(out, *g)
}
sort.SliceStable(out, func(i, j int) bool {
ri, rj := models.SeverityRank(out[i].Severity), models.SeverityRank(out[j].Severity)
if ri != rj {
return ri > rj
}
return out[i].ServerCount > out[j].ServerCount
})
return out
}
// tagsFromQuery reads repeated tag=key:value parameters.
func tagsFromQuery(c *gin.Context) map[string]string {
out := map[string]string{}
for _, raw := range c.QueryArray("tag") {
k, v, ok := strings.Cut(raw, ":")
if !ok || k == "" {
continue
}
out[k] = v
}
if len(out) == 0 {
return nil
}
return out
}
func vulnerabilitySummary(c *gin.Context) {
counts, err := services.CountOpenFindingsBySeverity(auth.InstanceID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
resp := gin.H{"counts": counts}
// Database freshness travels with the counts rather than living in
// settings: a fleet scanned against a three-week-old database must say so
// wherever its findings are read, not somewhere the reader has to go and
// look for it.
if meta, err := services.GetVulnDBMeta(); err == nil && meta != nil {
resp["db_version"] = meta.DBVersion
resp["pulled_at"] = meta.PulledAt
resp["last_full_scan_at"] = meta.LastFullScanAt
if meta.LastError != "" {
resp["last_error"] = meta.LastError
}
}
c.JSON(http.StatusOK, resp)
}
func rescanVulnerabilities(c *gin.Context) {
instanceID := auth.InstanceID(c)
n, err := services.MarkInstanceForRescan(instanceID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(instanceID, "vuln.rescan", actorFromCtx(c), "", "",
"queued "+strconv.FormatInt(n, 10)+" server(s) for rescan")
c.JSON(http.StatusOK, gin.H{"queued": n})
}
type acceptFindingRequest struct {
Reason string `json:"reason"`
Until time.Time `json:"until"`
}
func acceptFinding(c *gin.Context) {
var req acceptFindingRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
// Both rejected deliberately. An acceptance with no reason is a dismissal
// nobody can audit, and one already expired is a permanent dismissal
// wearing an expiry — the graveyard the expiry exists to prevent.
if strings.TrimSpace(req.Reason) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "a reason is required"})
return
}
if req.Until.IsZero() || !req.Until.After(time.Now()) {
c.JSON(http.StatusBadRequest, gin.H{"error": "until must be a future date"})
return
}
instanceID := auth.InstanceID(c)
actor := actorFromCtx(c)
f, err := services.AcceptFinding(instanceID, c.Param("id"), actor, req.Reason, req.Until)
if err != nil {
writeFindingError(c, err)
return
}
services.LogEvent(instanceID, "vuln.accepted", actor, f.ServerID, "",
f.CVEID+" on "+f.PackageName+" accepted until "+req.Until.Format(time.RFC3339)+": "+req.Reason)
c.JSON(http.StatusOK, f)
}
func unacceptFinding(c *gin.Context) {
instanceID := auth.InstanceID(c)
actor := actorFromCtx(c)
f, err := services.UnacceptFinding(instanceID, c.Param("id"))
if err != nil {
writeFindingError(c, err)
return
}
services.LogEvent(instanceID, "vuln.unaccepted", actor, f.ServerID, "",
f.CVEID+" on "+f.PackageName+" returned to open")
c.JSON(http.StatusOK, f)
}
func writeFindingError(c *gin.Context, err error) {
if errors.Is(err, services.ErrFindingNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "finding not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
func listServerVulnerabilities(c *gin.Context) {
findings, err := services.ListFindings(c.Request.Context(), auth.InstanceID(c), c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if findings == nil {
findings = []models.VulnFinding{}
}
c.JSON(http.StatusOK, findings)
}
func getServerPackages(c *gin.Context) {
sp, err := services.ListPackages(auth.InstanceID(c), c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if sp == nil {
// Not a 404: an agent that has not reported yet is the normal state for
// the first hour after install, and is a different thing from a bad
// server id.
c.JSON(http.StatusOK, gin.H{"reported": false})
return
}
c.JSON(http.StatusOK, sp)
}
func searchPackages(c *gin.Context) {
name := c.Query("name")
if name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
hits, err := services.SearchPackages(auth.InstanceID(c), name)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, hits)
}
func listVulnRules(c *gin.Context) {
rules, err := services.ListVulnRules(auth.InstanceID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, rules)
}
func createVulnRule(c *gin.Context) {
var r models.VulnAlertRule
if err := c.ShouldBindJSON(&r); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
instanceID := auth.InstanceID(c)
created, err := services.CreateVulnRule(instanceID, &r)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
services.LogEvent(instanceID, "vuln.rule_created", actorFromCtx(c), "", "", "rule "+created.Name)
c.JSON(http.StatusCreated, created)
}
func updateVulnRule(c *gin.Context) {
var r models.VulnAlertRule
if err := c.ShouldBindJSON(&r); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
instanceID := auth.InstanceID(c)
if err := services.UpdateVulnRule(instanceID, c.Param("id"), &r); err != nil {
if errors.Is(err, services.ErrVulnRuleNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "rule not found"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
services.LogEvent(instanceID, "vuln.rule_updated", actorFromCtx(c), "", "", "rule "+r.Name)
c.JSON(http.StatusOK, gin.H{"status": "updated"})
}
func deleteVulnRule(c *gin.Context) {
instanceID := auth.InstanceID(c)
if err := services.DeleteVulnRule(instanceID, c.Param("id")); err != nil {
if errors.Is(err, services.ErrVulnRuleNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "rule not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(instanceID, "vuln.rule_deleted", actorFromCtx(c), "", "", "rule "+c.Param("id"))
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
}
+69
View File
@@ -28,6 +28,45 @@ type SyncRequest struct {
type SyncResponse struct {
PublicKeys []string `json:"public_keys"`
// CollectPackages tells the agent whether this instance's licence grants
// vulnerability scanning. Absent decodes as false, which is the safe
// direction: an older server leaves agents collecting nothing.
CollectPackages bool `json:"collect_packages,omitempty"`
}
type OSRelease struct {
Family string `json:"family"`
// VersionId is not optional: Ubuntu 22.04 and 24.04 publish different fixed
// versions for the same CVE, so a scan without it is guesswork.
VersionId string `json:"version_id"`
Arch string `json:"arch,omitempty"`
}
type InstalledPackage struct {
Name string `json:"name"`
Version string `json:"version"`
Epoch int32 `json:"epoch,omitempty"`
Arch string `json:"arch,omitempty"`
// SourceName is what the Debian and Ubuntu feeds are keyed on: one advisory
// against "openssl" covers libssl3, openssl and libssl-dev.
SourceName string `json:"source_name,omitempty"`
}
// ReportPackagesRequest carries a server's installed package set.
//
// The agent calls twice at most: first with Packages empty, offering only the
// hash. If the server already holds it, NeedFull is false and the ~150KB body
// is never sent.
type ReportPackagesRequest struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
Hash string `json:"hash"`
Os OSRelease `json:"os"`
Packages []InstalledPackage `json:"packages,omitempty"`
}
type ReportPackagesResponse struct {
NeedFull bool `json:"need_full"`
}
type UploadKeyRequest struct {
@@ -326,6 +365,7 @@ type VantageServer interface {
SyncKeys(context.Context, *SyncRequest) (*SyncResponse, error)
UploadGeneratedKey(context.Context, *UploadKeyRequest) (*UploadKeyResponse, error)
ReportUpdates(context.Context, *ReportUpdatesRequest) (*ReportUpdatesResponse, error)
ReportPackages(context.Context, *ReportPackagesRequest) (*ReportPackagesResponse, error)
ReportInventory(context.Context, *InventoryReport) (*InventoryReportResponse, error)
SyncMonitors(context.Context, *SyncMonitorsRequest) (*SyncMonitorsResponse, error)
ReportChecks(context.Context, *ReportChecksRequest) (*ReportChecksResponse, error)
@@ -351,6 +391,10 @@ func (UnimplementedVantageServer) ReportUpdates(context.Context, *ReportUpdatesR
return nil, status.Errorf(codes.Unimplemented, "method ReportUpdates not implemented")
}
func (UnimplementedVantageServer) ReportPackages(context.Context, *ReportPackagesRequest) (*ReportPackagesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ReportPackages not implemented")
}
func (UnimplementedVantageServer) ReportInventory(context.Context, *InventoryReport) (*InventoryReportResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ReportInventory not implemented")
}
@@ -376,6 +420,7 @@ type VantageClient interface {
SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error)
UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error)
ReportUpdates(ctx context.Context, in *ReportUpdatesRequest, opts ...grpc.CallOption) (*ReportUpdatesResponse, error)
ReportPackages(ctx context.Context, in *ReportPackagesRequest, opts ...grpc.CallOption) (*ReportPackagesResponse, error)
ReportInventory(ctx context.Context, in *InventoryReport, opts ...grpc.CallOption) (*InventoryReportResponse, error)
SyncMonitors(ctx context.Context, in *SyncMonitorsRequest, opts ...grpc.CallOption) (*SyncMonitorsResponse, error)
ReportChecks(ctx context.Context, in *ReportChecksRequest, opts ...grpc.CallOption) (*ReportChecksResponse, error)
@@ -423,6 +468,14 @@ func (c *keyManagerClient) ReportUpdates(ctx context.Context, in *ReportUpdatesR
return out, nil
}
func (c *keyManagerClient) ReportPackages(ctx context.Context, in *ReportPackagesRequest, opts ...grpc.CallOption) (*ReportPackagesResponse, error) {
out := new(ReportPackagesResponse)
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportPackages", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
func (c *keyManagerClient) ReportInventory(ctx context.Context, in *InventoryReport, opts ...grpc.CallOption) (*InventoryReportResponse, error) {
out := new(InventoryReportResponse)
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportInventory", in, out, opts...); err != nil {
@@ -475,6 +528,7 @@ var Vantage_ServiceDesc = grpc.ServiceDesc{
{MethodName: "SyncKeys", Handler: _Vantage_SyncKeys_Handler},
{MethodName: "UploadGeneratedKey", Handler: _Vantage_UploadGeneratedKey_Handler},
{MethodName: "ReportUpdates", Handler: _Vantage_ReportUpdates_Handler},
{MethodName: "ReportPackages", Handler: _Vantage_ReportPackages_Handler},
{MethodName: "ReportInventory", Handler: _Vantage_ReportInventory_Handler},
{MethodName: "SyncMonitors", Handler: _Vantage_SyncMonitors_Handler},
{MethodName: "ReportChecks", Handler: _Vantage_ReportChecks_Handler},
@@ -556,6 +610,21 @@ func _Vantage_ReportUpdates_Handler(srv interface{}, ctx context.Context, dec fu
return interceptor(ctx, in, info, handler)
}
func _Vantage_ReportPackages_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ReportPackagesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(VantageServer).ReportPackages(ctx, in)
}
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/vantage.v1.Vantage/ReportPackages"}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(VantageServer).ReportPackages(ctx, req.(*ReportPackagesRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Vantage_ReportInventory_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(InventoryReport)
if err := dec(in); err != nil {
+60 -1
View File
@@ -63,7 +63,13 @@ func (s *vantageServer) SyncKeys(ctx context.Context, req *pb.SyncRequest) (*pb.
return nil, status.Errorf(codes.Internal, "failed to build authorized keys: %v", err)
}
return &pb.SyncResponse{PublicKeys: keys}, nil
// Carried on the 30s key poll rather than its own RPC: the agent needs it
// before its hourly package report, and this is the only message it already
// receives that often.
return &pb.SyncResponse{
PublicKeys: keys,
CollectPackages: services.VulnScanningEnabled(srv.InstanceID),
}, nil
}
func (s *vantageServer) UploadGeneratedKey(ctx context.Context, req *pb.UploadKeyRequest) (*pb.UploadKeyResponse, error) {
@@ -104,6 +110,59 @@ func (s *vantageServer) ReportUpdates(ctx context.Context, req *pb.ReportUpdates
return &pb.ReportUpdatesResponse{}, nil
}
// ReportPackages stores a server's installed package set.
//
// It does NOT match against the vulnerability database. Matching happens in
// vulnsched, on the leader: every replica would otherwise need the ~50MB
// database resident, and a database refresh would have N replicas racing to
// rescan the same fleet and sending N digests to the customer.
func (s *vantageServer) ReportPackages(ctx context.Context, req *pb.ReportPackagesRequest) (*pb.ReportPackagesResponse, error) {
srv, err := services.ValidateAgentToken(req.ServerId, req.AgentToken)
if err != nil {
return nil, status.Errorf(codes.Unauthenticated, "invalid agent token")
}
// The agent's collect_packages flag is an optimisation; this is the
// boundary. An agent that ignores the flag still stores nothing.
if !services.VulnScanningEnabled(srv.InstanceID) {
return &pb.ReportPackagesResponse{NeedFull: false}, nil
}
// The offer call: a hash and no packages. Answering NeedFull=false here is
// what saves the ~150KB body on the overwhelming majority of reports.
if len(req.Packages) == 0 {
known, err := services.HasPackageHash(srv.InstanceID, srv.ServerID, req.Hash)
if err != nil {
log.Printf("package hash lookup for %s: %v", srv.ServerID, err)
return nil, status.Errorf(codes.Internal, "package hash lookup failed")
}
return &pb.ReportPackagesResponse{NeedFull: !known}, nil
}
pkgs := make([]models.InstalledPackage, len(req.Packages))
for i, p := range req.Packages {
pkgs[i] = models.InstalledPackage{
Name: p.Name,
Version: p.Version,
Epoch: int(p.Epoch),
Arch: p.Arch,
SourceName: p.SourceName,
}
}
os := models.OSRelease{
Family: req.Os.Family,
VersionID: req.Os.VersionId,
Arch: req.Os.Arch,
}
if err := services.StorePackages(srv.InstanceID, srv.ServerID, os, req.Hash, pkgs); err != nil {
log.Printf("store packages for %s: %v", srv.ServerID, err)
return nil, status.Errorf(codes.Internal, "failed to store packages")
}
return &pb.ReportPackagesResponse{NeedFull: false}, nil
}
func (s *vantageServer) ReportInventory(ctx context.Context, req *pb.InventoryReport) (*pb.InventoryReportResponse, error) {
srv, err := services.ValidateAgentToken(req.ServerId, req.AgentToken)
if err != nil {
+52
View File
@@ -0,0 +1,52 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
// Scan status values for ServerPackages.
const (
ScanStatusOK = "ok"
ScanStatusUnsupported = "unsupported"
)
type OSRelease struct {
Family string `bson:"family" json:"family"`
VersionID string `bson:"version_id" json:"version_id"`
Arch string `bson:"arch" json:"arch"`
}
type InstalledPackage struct {
Name string `bson:"name" json:"name"`
Version string `bson:"version" json:"version"`
Epoch int `bson:"epoch,omitempty" json:"epoch,omitempty"`
Arch string `bson:"arch" json:"arch"`
// SourceName is what the Debian and Ubuntu feeds are keyed on. One advisory
// against "openssl" covers the binaries libssl3, openssl and libssl-dev;
// matching on binary name alone finds one of the three.
SourceName string `bson:"source_name,omitempty" json:"source_name,omitempty"`
}
// ServerPackages holds one server's whole package set in ONE document rather
// than one document per package. The hash has already established that
// something changed, so a report is a single atomic upsert with no delta logic
// to get wrong. A typical Linux host is ~2000 packages and ~150KB, comfortably
// inside the 16MB document limit.
type ServerPackages struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
InstanceID string `bson:"instance_id" json:"-"`
ServerID string `bson:"server_id" json:"server_id"`
OS OSRelease `bson:"os" json:"os"`
Hash string `bson:"hash" json:"hash"`
Packages []InstalledPackage `bson:"packages" json:"packages"`
CollectedAt time.Time `bson:"collected_at" json:"collected_at"`
ScanPending bool `bson:"scan_pending" json:"scan_pending"`
ScannedAt time.Time `bson:"scanned_at,omitempty" json:"scanned_at,omitempty"`
// Status distinguishes a scanned host from one whose distribution we hold
// no feed for. Reporting zero findings for an unsupported distribution is
// indistinguishable from reporting a clean host, and one of those is a lie.
Status string `bson:"status" json:"status"`
DBVersion int `bson:"db_version" json:"db_version"`
}
+109
View File
@@ -0,0 +1,109 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
// Finding states.
const (
FindingOpen = "open"
FindingFixed = "fixed"
FindingAccepted = "accepted"
)
// Severities. Lowercase and fixed; SeverityRank orders them.
const (
SeverityUnknown = "unknown"
SeverityLow = "low"
SeverityMedium = "medium"
SeverityHigh = "high"
SeverityCritical = "critical"
)
// SeverityRank orders severities for threshold comparisons. An unrecognised
// value ranks lowest rather than panicking: severity comes from a third-party
// feed, and an unexpected string must not stop a scan.
func SeverityRank(s string) int {
switch s {
case SeverityCritical:
return 4
case SeverityHigh:
return 3
case SeverityMedium:
return 2
case SeverityLow:
return 1
default:
return 0
}
}
// Acceptance records a decision someone will be asked to justify, so who, why
// and until when all live on the document as well as in the audit log.
type Acceptance struct {
By string `bson:"by" json:"by"`
Reason string `bson:"reason" json:"reason"`
Until time.Time `bson:"until" json:"until"`
At time.Time `bson:"at" json:"at"`
}
// VulnFinding is one vulnerable package on one server.
//
// Findings are never deleted when a package is patched: the state moves to
// "fixed" with FixedAt stamped, which is what keeps "what did we remediate last
// quarter" answerable.
type VulnFinding struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"id"`
InstanceID string `bson:"instance_id" json:"-"`
ServerID string `bson:"server_id" json:"server_id"`
CVEID string `bson:"cve_id" json:"cve_id"`
PackageName string `bson:"package_name" json:"package_name"`
Installed string `bson:"installed_version" json:"installed_version"`
// FixedIn empty means no vendor fix has been published. That is a real and
// common state and must never be conflated with "not vulnerable" — it is
// the finding most in need of acceptance, since there is nothing to patch.
FixedIn string `bson:"fixed_in,omitempty" json:"fixed_in,omitempty"`
Severity string `bson:"severity" json:"severity"`
CVSSScore float64 `bson:"cvss_score,omitempty" json:"cvss_score,omitempty"`
Title string `bson:"title,omitempty" json:"title,omitempty"`
References []string `bson:"references,omitempty" json:"references,omitempty"`
State string `bson:"state" json:"state"`
FirstSeen time.Time `bson:"first_seen" json:"first_seen"`
LastSeen time.Time `bson:"last_seen" json:"last_seen"`
FixedAt *time.Time `bson:"fixed_at,omitempty" json:"fixed_at,omitempty"`
Accepted *Acceptance `bson:"accepted,omitempty" json:"accepted,omitempty"`
}
// VulnDBMeta is a singleton and deliberately carries no instance_id: the
// vulnerability database is a property of the deployment, not of a tenant.
// Same reasoning as the migrations collection, and the reason vulndb_meta is
// absent from ScopedCollections.
type VulnDBMeta struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
DBVersion int `bson:"db_version" json:"db_version"`
PulledAt time.Time `bson:"pulled_at" json:"pulled_at"`
LastFullScanAt time.Time `bson:"last_full_scan_at,omitempty" json:"last_full_scan_at,omitempty"`
LastError string `bson:"last_error,omitempty" json:"last_error,omitempty"`
}
// VulnAlertRule routes newly opened findings to notification channels.
//
// Tags resolve through services.ResolveTargets rather than a second matcher:
// that function is already the single answer to which servers a selector
// touches, and a rule disagreeing with a workflow about what env:prod means
// would be worse than having no filter.
type VulnAlertRule struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"id"`
InstanceID string `bson:"instance_id" json:"-"`
Name string `bson:"name" json:"name"`
Enabled bool `bson:"enabled" json:"enabled"`
MinSeverity string `bson:"min_severity" json:"min_severity"`
Tags map[string]string `bson:"tags,omitempty" json:"tags,omitempty"`
ChannelIDs []string `bson:"channel_ids" json:"channel_ids"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
+5 -1
View File
@@ -26,7 +26,11 @@ func (e Event) title() string {
verb = "is DOWN"
}
var s string
if e.Type == TypeServer {
if e.Type == TypeVuln {
// A digest is not a transition. MonitorName already carries the whole
// headline ("12 new critical across 4 servers"), so no verb applies.
s = fmt.Sprintf("[Vantage] %s", e.MonitorName)
} else if e.Type == TypeServer {
if e.NewStatus == models.StatusDown {
verb = "went offline"
} else {
+83
View File
@@ -0,0 +1,83 @@
package notify
import (
"fmt"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/mail"
)
// TypeVuln marks an event whose subject is a batch of new vulnerability
// findings rather than a state transition. It exists so title() does not
// describe a digest as something going "DOWN".
const TypeVuln = "vulnerability"
// VulnDigest is one batch of newly opened findings, ready to send.
//
// One per rule per scan, never one per finding: a database refresh can open
// several hundred at once, and a message each would rate-limit the webhook or
// get the channel muted — either way the alerts stop being read.
type VulnDigest struct {
InstanceName string
RuleName string
Summary string
TopSeverity string
Count int
Rows []mail.VulnDigestRow
More int
DBAge string
}
// DispatchVulnDigest delivers a digest over one channel.
//
// SMTP gets its own template so a vulnerability digest does not arrive dressed
// as a monitor alert. The other four transports carry short text, so they reuse
// the existing Event path rather than growing a second payload shape per
// transport.
func DispatchVulnDigest(ch models.NotificationChannel, d VulnDigest) error {
if ch.Type == models.ChannelSMTP {
to := ch.Config["to"]
sender := mail.Sender{
Host: ch.Config["host"],
Port: ch.Config["port"],
From: ch.Config["from"],
Username: ch.Config["username"],
Password: ch.Config["password"],
}
if !sender.Enabled() || sender.Port == "" || to == "" {
return fmt.Errorf("smtp: missing host/port/from/to")
}
return sender.SendVulnDigest(to, mail.VulnDigest{
InstanceName: d.InstanceName,
Count: d.Count,
TopSeverity: d.TopSeverity,
Summary: d.Summary,
Rows: d.Rows,
More: d.More,
DBAge: d.DBAge,
})
}
return Dispatch(ch, Event{
MonitorName: d.Summary,
Type: TypeVuln,
Message: vulnLines(d),
})
}
// vulnLines renders the finding list for the text-only transports, capped by
// whatever the caller already put in Rows.
func vulnLines(d VulnDigest) string {
var s string
for _, r := range d.Rows {
fix := "no fix published"
if r.FixedIn != "" {
fix = "fixed in " + r.FixedIn
}
s += fmt.Sprintf("\n• %s (%s) — %s on %s, %s", r.CVEID, r.Severity, r.PackageName, r.ServerName, fix)
}
if d.More > 0 {
s += fmt.Sprintf("\n…and %d more.", d.More)
}
return s
}
+420
View File
@@ -0,0 +1,420 @@
package services
import (
"context"
"errors"
"log"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/vulndb"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// FindingDiff is what one server's scan changes.
type FindingDiff struct {
Upserts []models.VulnFinding
FixedIDs []bson.ObjectID
ReopenIDs []bson.ObjectID
// NewlyOpened is what the digest reports: findings that were not open
// before this scan. A finding that was already open must not re-alert every
// tick, or the digest becomes noise and stops being read.
NewlyOpened []models.VulnFinding
}
func findingKey(cveID, pkg string) string { return cveID + "\x00" + pkg }
// DiffFindings computes the state changes for one server's scan.
//
// Pure by design: no database, no clock of its own. The ordering below is
// load-bearing — see the comment above the second loop.
func DiffFindings(existing []models.VulnFinding, results []vulndb.Result, now time.Time) FindingDiff {
var d FindingDiff
byKey := make(map[string]models.VulnFinding, len(existing))
for _, f := range existing {
byKey[findingKey(f.CVEID, f.PackageName)] = f
}
seen := make(map[string]bool, len(results))
for _, r := range results {
key := findingKey(r.CVEID, r.PackageName)
seen[key] = true
prev, had := byKey[key]
f := models.VulnFinding{
CVEID: r.CVEID,
PackageName: r.PackageName,
Installed: r.Installed,
FixedIn: r.FixedIn,
Severity: r.Severity,
State: models.FindingOpen,
FirstSeen: now,
LastSeen: now,
}
if had {
f.ID = prev.ID
// Preserved, never overwritten: an upsert that moves first_seen
// forward makes every finding look discovered today.
f.FirstSeen = prev.FirstSeen
// A live acceptance survives the scan untouched: it is suppressed
// from counts and alerts until its expiry, then reopens on its own.
if prev.State == models.FindingAccepted && prev.Accepted != nil {
if now.Before(prev.Accepted.Until) {
continue
}
d.ReopenIDs = append(d.ReopenIDs, prev.ID)
continue
}
if prev.State != models.FindingOpen {
d.NewlyOpened = append(d.NewlyOpened, f)
}
} else {
d.NewlyOpened = append(d.NewlyOpened, f)
}
d.Upserts = append(d.Upserts, f)
}
// Anything we hold that this scan did not produce is fixed. This runs AFTER
// the loop above, and the ordering matters: a finding that is both absent
// and past its acceptance expiry must settle as fixed rather than reopening
// on a package that no longer carries it.
for _, f := range existing {
if seen[findingKey(f.CVEID, f.PackageName)] {
continue
}
if f.State == models.FindingFixed {
continue
}
d.FixedIDs = append(d.FixedIDs, f.ID)
}
return d
}
// ErrFindingNotFound is returned for a finding that does not exist in this
// instance. Callers turn it into a 404 — never a 403, which would confirm the
// finding exists in someone else's instance.
var ErrFindingNotFound = errors.New("finding not found")
// FindingFilter narrows a fleet-wide finding query. An empty field is no
// filter.
type FindingFilter struct {
Severity string
State string
ServerID string
Tags map[string]string
}
// ListInstanceFindings returns findings across the whole fleet.
func ListInstanceFindings(instanceID string, f FindingFilter) ([]models.VulnFinding, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
filter := bson.M{"instance_id": instanceID}
if f.State != "" {
filter["state"] = f.State
}
if f.Severity != "" {
filter["severity"] = f.Severity
}
if f.ServerID != "" {
filter["server_id"] = f.ServerID
}
// The tag selector resolves through ResolveTargets, the single answer to
// which servers a selector touches. A second matcher here could disagree
// with what a workflow means by env:prod.
if len(f.Tags) > 0 {
servers, err := ResolveTargets(instanceID, nil, f.Tags)
if err != nil {
return nil, err
}
ids := make([]string, 0, len(servers))
for _, s := range servers {
ids = append(ids, s.ServerID)
}
if len(ids) == 0 {
return []models.VulnFinding{}, nil
}
filter["server_id"] = bson.M{"$in": ids}
}
cur, err := db.Col("vuln_findings").Find(ctx, filter)
if err != nil {
return nil, err
}
defer cur.Close(ctx)
out := []models.VulnFinding{}
if err := cur.All(ctx, &out); err != nil {
return nil, err
}
return out, nil
}
// CountOpenFindingsBySeverity powers the summary tiles. Accepted findings are
// excluded: they are suppressed from counts until their expiry, which is the
// whole point of accepting one.
func CountOpenFindingsBySeverity(instanceID string) (map[string]int, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
cur, err := db.Col("vuln_findings").Aggregate(ctx, []bson.M{
{"$match": bson.M{"instance_id": instanceID, "state": models.FindingOpen}},
{"$group": bson.M{"_id": "$severity", "n": bson.M{"$sum": 1}}},
})
if err != nil {
return nil, err
}
defer cur.Close(ctx)
var rows []struct {
Severity string `bson:"_id"`
N int `bson:"n"`
}
if err := cur.All(ctx, &rows); err != nil {
return nil, err
}
counts := map[string]int{}
for _, r := range rows {
counts[r.Severity] = r.N
}
return counts, nil
}
// MarkInstanceForRescan flags every server in an instance for rescanning and
// returns how many were flagged.
//
// It does not scan. vulnsched picks the flags up on its next tick, which keeps
// matching on the leader and means this endpoint cannot become a second
// scanning path.
func MarkInstanceForRescan(instanceID string) (int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
res, err := db.Col("server_packages").UpdateMany(ctx,
bson.M{"instance_id": instanceID},
bson.M{"$set": bson.M{"scan_pending": true}},
)
if err != nil {
return 0, err
}
return res.ModifiedCount, nil
}
// AcceptFinding suppresses a finding until a date, with a reason.
//
// The expiry is mandatory at the API layer. A finding reopens on its own when
// it passes, which is what stops the accepted list becoming where risk goes to
// be forgotten.
func AcceptFinding(instanceID, findingID, actor, reason string, until time.Time) (*models.VulnFinding, error) {
id, err := bson.ObjectIDFromHex(findingID)
if err != nil {
return nil, ErrFindingNotFound
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var f models.VulnFinding
err = db.Col("vuln_findings").FindOneAndUpdate(ctx,
bson.M{"_id": id, "instance_id": instanceID},
bson.M{"$set": bson.M{
"state": models.FindingAccepted,
"accepted": models.Acceptance{
By: actor,
Reason: reason,
Until: until,
At: time.Now(),
},
}},
options.FindOneAndUpdate().SetReturnDocument(options.After),
).Decode(&f)
if err == mongo.ErrNoDocuments {
return nil, ErrFindingNotFound
}
if err != nil {
return nil, err
}
return &f, nil
}
// UnacceptFinding returns an accepted finding to open before its expiry.
func UnacceptFinding(instanceID, findingID string) (*models.VulnFinding, error) {
id, err := bson.ObjectIDFromHex(findingID)
if err != nil {
return nil, ErrFindingNotFound
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var f models.VulnFinding
err = db.Col("vuln_findings").FindOneAndUpdate(ctx,
bson.M{"_id": id, "instance_id": instanceID},
bson.M{
"$set": bson.M{"state": models.FindingOpen},
"$unset": bson.M{"accepted": ""},
},
options.FindOneAndUpdate().SetReturnDocument(options.After),
).Decode(&f)
if err == mongo.ErrNoDocuments {
return nil, ErrFindingNotFound
}
if err != nil {
return nil, err
}
return &f, nil
}
// ListFindings returns every finding held for one server.
func ListFindings(ctx context.Context, instanceID, serverID string) ([]models.VulnFinding, error) {
cur, err := db.Col("vuln_findings").Find(ctx, bson.M{
"instance_id": instanceID,
"server_id": serverID,
})
if err != nil {
return nil, err
}
defer cur.Close(ctx)
var out []models.VulnFinding
if err := cur.All(ctx, &out); err != nil {
return nil, err
}
return out, nil
}
// ApplyFindingDiff writes a diff. Thin on purpose — the logic worth reading
// twice is all in DiffFindings.
func ApplyFindingDiff(ctx context.Context, instanceID, serverID string, d FindingDiff, now time.Time) error {
col := db.Col("vuln_findings")
for _, f := range d.Upserts {
_, err := col.UpdateOne(ctx,
bson.M{
"instance_id": instanceID,
"server_id": serverID,
"cve_id": f.CVEID,
"package_name": f.PackageName,
},
bson.M{
"$set": bson.M{
"installed_version": f.Installed,
"fixed_in": f.FixedIn,
"severity": f.Severity,
"state": models.FindingOpen,
"last_seen": now,
},
// first_seen is written only on insert, so a rescan cannot move
// it forward.
"$setOnInsert": bson.M{
"instance_id": instanceID,
"server_id": serverID,
"cve_id": f.CVEID,
"package_name": f.PackageName,
"first_seen": f.FirstSeen,
},
"$unset": bson.M{"fixed_at": "", "accepted": ""},
},
options.UpdateOne().SetUpsert(true),
)
if err != nil {
return err
}
}
if len(d.FixedIDs) > 0 {
if _, err := col.UpdateMany(ctx,
bson.M{"_id": bson.M{"$in": d.FixedIDs}},
bson.M{"$set": bson.M{"state": models.FindingFixed, "fixed_at": now}},
); err != nil {
return err
}
}
if len(d.ReopenIDs) > 0 {
if _, err := col.UpdateMany(ctx,
bson.M{"_id": bson.M{"$in": d.ReopenIDs}},
bson.M{"$set": bson.M{"state": models.FindingOpen, "last_seen": now}, "$unset": bson.M{"accepted": ""}},
); err != nil {
return err
}
}
return nil
}
// StartVulnSweeper deletes old FIXED findings. Open and accepted findings are
// never swept at any setting: retention is about history, and an unresolved
// vulnerability is not history.
func StartVulnSweeper(ctx context.Context) {
go func() {
ticker := time.NewTicker(6 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
sweepFixedFindings(ctx)
}
}
}()
}
// defaultVulnRetentionDays is what an unset setting means. A pointer field and
// this constant together give absent-means-90 and 0-means-forever, the same
// shape as workflow log retention.
const defaultVulnRetentionDays = 90
// sweepFixedFindings deletes fixed findings past each instance's retention.
//
// Only "fixed" is ever swept. An open or accepted finding is not history, it is
// an outstanding decision, and deleting one on a timer would quietly shrink the
// fleet's risk picture.
func sweepFixedFindings(ctx context.Context) {
instanceIDs, err := ListInstanceIDs()
if err != nil {
log.Printf("vuln sweeper: list instances: %v", err)
return
}
for _, instanceID := range instanceIDs {
if ctx.Err() != nil {
return
}
days := defaultVulnRetentionDays
if s, err := GetSettings(instanceID); err == nil && s != nil && s.VulnFindingRetentionDays != nil {
days = *s.VulnFindingRetentionDays
}
if days <= 0 {
continue // 0 means keep forever
}
cutoff := time.Now().AddDate(0, 0, -days)
res, err := db.Col("vuln_findings").DeleteMany(ctx, bson.M{
"instance_id": instanceID,
"state": models.FindingFixed,
"fixed_at": bson.M{"$lt": cutoff},
})
if err != nil {
log.Printf("vuln sweeper: delete for %s: %v", instanceID, err)
continue
}
if res.DeletedCount > 0 {
log.Printf("vuln sweeper: removed %d fixed findings for %s", res.DeletedCount, instanceID)
}
}
}
@@ -40,6 +40,9 @@ var ScopedCollections = []string{
"console_sessions",
"audit_logs",
"auth_providers",
"server_packages",
"vuln_findings",
"vuln_alert_rules",
}
// collectionRenames maps the two collections whose names change. Ordered so the
+121
View File
@@ -0,0 +1,121 @@
package services
import (
"context"
"time"
"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"
)
// VulnScanningEnabled reports whether this instance may collect packages.
//
// 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(license.FeatureVulnScanning)
}
// HasPackageHash reports whether we already hold this exact package set, which
// is what lets the agent skip sending ~150KB it has already sent.
func HasPackageHash(instanceID, serverID, hash string) (bool, error) {
err := db.Col("server_packages").FindOne(context.Background(), bson.M{
"instance_id": instanceID,
"server_id": serverID,
"hash": hash,
}, options.FindOne().SetProjection(bson.M{"_id": 1})).Err()
if err == mongo.ErrNoDocuments {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
// StorePackages replaces a server's package set and marks it for scanning.
//
// It deliberately does NOT match against the vulnerability database. Matching
// happens in vulnsched, on the leader, for two reasons: every replica would
// otherwise need the ~50MB database resident, and a database refresh would have
// N replicas racing to rescan the same fleet and sending N digests.
func StorePackages(instanceID, serverID string, os models.OSRelease, hash string, pkgs []models.InstalledPackage) error {
now := time.Now()
_, err := db.Col("server_packages").UpdateOne(context.Background(),
bson.M{"instance_id": instanceID, "server_id": serverID},
bson.M{"$set": bson.M{
"os": os,
"hash": hash,
"packages": pkgs,
"collected_at": now,
"scan_pending": true,
}},
options.UpdateOne().SetUpsert(true),
)
return err
}
// ListPackages returns a server's stored package set, or nil when the agent has
// not reported yet. A missing document is not an error: an agent that has never
// reported is the normal state for the first hour after install.
func ListPackages(instanceID, serverID string) (*models.ServerPackages, error) {
var sp models.ServerPackages
err := db.Col("server_packages").FindOne(context.Background(), bson.M{
"instance_id": instanceID,
"server_id": serverID,
}).Decode(&sp)
if err == mongo.ErrNoDocuments {
return nil, nil
}
if err != nil {
return nil, err
}
return &sp, nil
}
// PackageHit is one server running one package.
type PackageHit struct {
ServerID string `json:"server_id"`
Name string `json:"name"`
Version string `json:"version"`
}
// SearchPackages answers "which servers run package X" across the fleet — the
// question people actually ask during an incident.
//
// The Mongo filter narrows to documents containing the name; the second pass is
// needed because a multikey match returns the whole document, not the matching
// array element.
func SearchPackages(instanceID, name string) ([]PackageHit, error) {
ctx := context.Background()
cur, err := db.Col("server_packages").Find(ctx, bson.M{
"instance_id": instanceID,
"packages.name": name,
}, options.Find().SetProjection(bson.M{"server_id": 1, "packages": 1}))
if err != nil {
return nil, err
}
defer cur.Close(ctx)
var docs []models.ServerPackages
if err := cur.All(ctx, &docs); err != nil {
return nil, err
}
hits := []PackageHit{}
for _, d := range docs {
for _, p := range d.Packages {
if p.Name == name {
hits = append(hits, PackageHit{ServerID: d.ServerID, Name: p.Name, Version: p.Version})
}
}
}
return hits, nil
}
+62
View File
@@ -0,0 +1,62 @@
package services
import (
"context"
"log"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// EnsureVulnIndexes declares the indexes for package inventory and findings.
//
// It warns rather than being fatal, matching EnsureSecretIndexes and
// EnsureWorkflowIndexes: a missing index degrades these queries to a collection
// scan, which is no reason to refuse to serve the fleet.
func EnsureVulnIndexes() error {
ctx := context.Background()
pkgIdx := []mongo.IndexModel{
{
Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "server_id", Value: 1}},
Options: options.Index().SetUnique(true),
},
// Multikey, for fleet-wide package search: "who runs openssl 3.0.2?"
{Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "packages.name", Value: 1}}},
// The scheduler's only query. Deliberately unscoped: it sweeps the whole
// deployment on the leader, not one tenant.
{Keys: bson.D{{Key: "scan_pending", Value: 1}}},
}
if _, err := db.Col("server_packages").Indexes().CreateMany(ctx, pkgIdx); err != nil {
log.Printf("warning: server_packages indexes: %v", err)
}
findingIdx := []mongo.IndexModel{
{
// This key is what makes a rescan an idempotent upsert rather than a
// duplicate factory, and what lets first_seen survive a rescan.
Keys: bson.D{
{Key: "instance_id", Value: 1},
{Key: "server_id", Value: 1},
{Key: "cve_id", Value: 1},
{Key: "package_name", Value: 1},
},
Options: options.Index().SetUnique(true),
},
{Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "state", Value: 1}, {Key: "severity", Value: 1}}},
{Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "cve_id", Value: 1}}},
}
if _, err := db.Col("vuln_findings").Indexes().CreateMany(ctx, findingIdx); err != nil {
log.Printf("warning: vuln_findings indexes: %v", err)
}
if _, err := db.Col("vuln_alert_rules").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "instance_id", Value: 1}},
}); err != nil {
log.Printf("warning: vuln_alert_rules indexes: %v", err)
}
return nil
}
+363
View File
@@ -0,0 +1,363 @@
package services
import (
"context"
"fmt"
"log"
"sort"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/notify"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/mail"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
// digestRowLimit caps how many findings a single digest lists by name. The
// remainder is summarised as a count: a webhook payload holding six hundred
// rows is not a notification, it is a report nobody reads in a chat client.
const digestRowLimit = 20
// ErrVulnRuleNotFound is returned for a rule that does not exist in this
// instance. Callers turn it into a 404.
var ErrVulnRuleNotFound = fmt.Errorf("vulnerability alert rule not found")
func ListVulnRules(instanceID string) ([]models.VulnAlertRule, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cur, err := db.Col("vuln_alert_rules").Find(ctx, bson.M{"instance_id": instanceID})
if err != nil {
return nil, err
}
defer cur.Close(ctx)
rules := []models.VulnAlertRule{}
if err := cur.All(ctx, &rules); err != nil {
return nil, err
}
return rules, nil
}
func CreateVulnRule(instanceID string, r *models.VulnAlertRule) (*models.VulnAlertRule, error) {
if err := validateVulnRule(instanceID, r); err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
r.ID = bson.NewObjectID()
r.InstanceID = instanceID
r.CreatedAt = time.Now()
r.UpdatedAt = r.CreatedAt
if _, err := db.Col("vuln_alert_rules").InsertOne(ctx, r); err != nil {
return nil, err
}
return r, nil
}
func UpdateVulnRule(instanceID, ruleID string, r *models.VulnAlertRule) error {
if err := validateVulnRule(instanceID, r); err != nil {
return err
}
id, err := bson.ObjectIDFromHex(ruleID)
if err != nil {
return ErrVulnRuleNotFound
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
res, err := db.Col("vuln_alert_rules").UpdateOne(ctx,
bson.M{"_id": id, "instance_id": instanceID},
bson.M{"$set": bson.M{
"name": r.Name,
"enabled": r.Enabled,
"min_severity": r.MinSeverity,
"tags": r.Tags,
"channel_ids": r.ChannelIDs,
"updated_at": time.Now(),
}},
)
if err != nil {
return err
}
if res.MatchedCount == 0 {
return ErrVulnRuleNotFound
}
return nil
}
func DeleteVulnRule(instanceID, ruleID string) error {
id, err := bson.ObjectIDFromHex(ruleID)
if err != nil {
return ErrVulnRuleNotFound
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
res, err := db.Col("vuln_alert_rules").DeleteOne(ctx, bson.M{"_id": id, "instance_id": instanceID})
if err != nil {
return err
}
if res.DeletedCount == 0 {
return ErrVulnRuleNotFound
}
return nil
}
// validateVulnRule rejects a rule that could never fire, and one naming a
// channel from another instance. The channel check reuses validateChannelIDs so
// there is one answer to "is this channel mine".
func validateVulnRule(instanceID string, r *models.VulnAlertRule) error {
if r.Name == "" {
return fmt.Errorf("name is required")
}
switch r.MinSeverity {
case models.SeverityUnknown, models.SeverityLow, models.SeverityMedium,
models.SeverityHigh, models.SeverityCritical:
default:
return fmt.Errorf("min_severity %q is not a severity", r.MinSeverity)
}
if len(r.ChannelIDs) == 0 {
return fmt.Errorf("at least one channel is required")
}
return validateChannelIDs(instanceID, r.ChannelIDs)
}
// SendVulnDigest delivers one message per rule per tick — never one per
// finding. See vulnsched for why the tick is the batch boundary.
func SendVulnDigest(instanceID string, newly []models.VulnFinding) {
rules, err := ListVulnRules(instanceID)
if err != nil {
log.Printf("vuln digest: list rules: %v", err)
return
}
for _, rule := range rules {
if !rule.Enabled {
continue
}
matched := filterBySeverity(newly, rule.MinSeverity)
if len(matched) == 0 {
continue
}
if len(rule.Tags) > 0 {
// ResolveTargets is already the single answer to which servers a
// selector touches. A rule that disagreed with a workflow about
// what env:prod means would be worse than no filter at all.
allowed, err := ResolveTargets(instanceID, nil, rule.Tags)
if err != nil {
log.Printf("vuln digest: resolve targets: %v", err)
continue
}
matched = filterByServers(matched, allowed)
if len(matched) == 0 {
continue
}
}
dispatchVulnDigest(instanceID, rule, matched)
}
}
func filterBySeverity(findings []models.VulnFinding, min string) []models.VulnFinding {
floor := models.SeverityRank(min)
out := make([]models.VulnFinding, 0, len(findings))
for _, f := range findings {
if models.SeverityRank(f.Severity) >= floor {
out = append(out, f)
}
}
return out
}
// filterByServers keeps findings on servers the rule's tag selector matched.
// ResolveTargets answers in whole server documents, so the IDs are lifted here.
func filterByServers(findings []models.VulnFinding, allowed []models.Server) []models.VulnFinding {
set := make(map[string]bool, len(allowed))
for _, s := range allowed {
set[s.ServerID] = true
}
out := make([]models.VulnFinding, 0, len(findings))
for _, f := range findings {
if set[f.ServerID] {
out = append(out, f)
}
}
return out
}
// dispatchVulnDigest builds one digest and sends it over each of the rule's
// channels.
func dispatchVulnDigest(instanceID string, rule models.VulnAlertRule, findings []models.VulnFinding) {
channels, err := GetChannels(instanceID, rule.ChannelIDs)
if err != nil {
log.Printf("vuln digest: load channels for rule %s: %v", rule.Name, err)
return
}
digest := buildVulnDigest(instanceID, rule, findings)
for _, ch := range channels {
if !ch.Enabled {
continue
}
go func(c models.NotificationChannel) {
if err := notify.DispatchVulnDigest(c, digest); err != nil {
log.Printf("vuln digest: dispatch to %s (%s): %v", c.Name, c.Type, err)
}
}(ch)
}
}
// buildVulnDigest turns a batch of findings into one message.
//
// Findings are ordered most severe first so the capped list shows the ones that
// matter rather than whichever the scan happened to produce first.
func buildVulnDigest(instanceID string, rule models.VulnAlertRule, findings []models.VulnFinding) notify.VulnDigest {
sorted := make([]models.VulnFinding, len(findings))
copy(sorted, findings)
sort.SliceStable(sorted, func(i, j int) bool {
return models.SeverityRank(sorted[i].Severity) > models.SeverityRank(sorted[j].Severity)
})
counts := map[string]int{}
servers := map[string]bool{}
for _, f := range sorted {
counts[f.Severity]++
servers[f.ServerID] = true
}
names := serverNames(instanceID)
shown := sorted
more := 0
if len(shown) > digestRowLimit {
more = len(shown) - digestRowLimit
shown = shown[:digestRowLimit]
}
rows := make([]mail.VulnDigestRow, 0, len(shown))
for _, f := range shown {
name := names[f.ServerID]
if name == "" {
name = f.ServerID
}
rows = append(rows, mail.VulnDigestRow{
CVEID: f.CVEID,
Severity: f.Severity,
PackageName: f.PackageName,
ServerName: name,
FixedIn: f.FixedIn,
})
}
top := models.SeverityUnknown
if len(sorted) > 0 {
top = sorted[0].Severity
}
instanceName := instanceID
if inst, err := GetInstance(instanceID); err == nil && inst != nil && inst.Name != "" {
instanceName = inst.Name
}
return notify.VulnDigest{
InstanceName: instanceName,
RuleName: rule.Name,
Summary: summariseCounts(counts, len(servers)),
TopSeverity: top,
Count: len(sorted),
Rows: rows,
More: more,
DBAge: vulnDBAge(),
}
}
// summariseCounts renders "12 new critical, 4 new high across 6 servers".
func summariseCounts(counts map[string]int, serverCount int) string {
order := []string{
models.SeverityCritical, models.SeverityHigh,
models.SeverityMedium, models.SeverityLow, models.SeverityUnknown,
}
parts := ""
for _, sev := range order {
if counts[sev] == 0 {
continue
}
if parts != "" {
parts += ", "
}
parts += fmt.Sprintf("%d new %s", counts[sev], sev)
}
if parts == "" {
parts = "new findings"
}
plural := "servers"
if serverCount == 1 {
plural = "server"
}
return fmt.Sprintf("%s across %d %s", parts, serverCount, plural)
}
// serverNames maps server IDs to display names for one instance. A digest that
// named raw UUIDs would be unreadable in a chat client.
func serverNames(instanceID string) map[string]string {
out := map[string]string{}
servers, err := ListServers(instanceID)
if err != nil {
log.Printf("vuln digest: list servers: %v", err)
return out
}
for _, s := range servers {
out[s.ServerID] = s.Hostname
}
return out
}
// vulnDBAge renders how long ago the vulnerability database was pulled.
//
// It is on every digest deliberately: a fleet scanned against a three-week-old
// database must say so rather than let the reader assume freshness.
func vulnDBAge() string {
meta, err := GetVulnDBMeta()
if err != nil || meta == nil || meta.PulledAt.IsZero() {
return "an unknown time"
}
d := time.Since(meta.PulledAt)
switch {
case d < time.Hour:
return fmt.Sprintf("%d minutes", int(d.Minutes()))
case d < 48*time.Hour:
return fmt.Sprintf("%d hours", int(d.Hours()))
default:
return fmt.Sprintf("%d days", int(d.Hours()/24))
}
}
// GetVulnDBMeta reads the deployment-wide vulnerability database metadata.
// It carries no instance_id: the database is a property of the deployment, not
// of a tenant.
func GetVulnDBMeta() (*models.VulnDBMeta, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var meta models.VulnDBMeta
err := db.Col("vulndb_meta").FindOne(ctx, bson.M{}).Decode(&meta)
if err == mongo.ErrNoDocuments {
return nil, nil
}
if err != nil {
return nil, err
}
return &meta, nil
}
+129
View File
@@ -0,0 +1,129 @@
package vulndb
import (
"strings"
trivydb "github.com/aquasecurity/trivy-db/pkg/db"
trivytypes "github.com/aquasecurity/trivy-db/pkg/types"
)
// Advisory is one fixed-version statement for one source package.
type Advisory struct {
CVEID string
// FixedVersion empty means no vendor fix has been published. It is a real
// state, not an absence of data, and callers must treat it as vulnerable.
FixedVersion string
Severity string
}
// VulnInfo is the CVE's own metadata, shared across every server it affects.
type VulnInfo struct {
Title string
Severity string
CVSSScore float64
References []string
}
// Store reads a pulled trivy-db.
type Store struct {
cfg trivydb.Config
}
// Open opens the database in dir. trivy-db expects the directory, not the file:
// it appends "trivy.db" itself.
func Open(dir string) (*Store, error) {
if err := trivydb.Init(dir); err != nil {
return nil, err
}
return &Store{cfg: trivydb.Config{}}, nil
}
func (s *Store) Close() error { return trivydb.Close() }
// Advisories returns every advisory for a source package in a bucket.
func (s *Store) Advisories(bucket, srcName string) ([]Advisory, error) {
raw, err := s.cfg.GetAdvisories(bucket, srcName)
if err != nil {
return nil, err
}
out := make([]Advisory, 0, len(raw))
for _, a := range raw {
out = append(out, Advisory{
CVEID: a.VulnerabilityID,
FixedVersion: a.FixedVersion,
// Advisory.Severity is trivy's numeric Severity type, unlike
// Vulnerability.Severity which is a string. They are genuinely
// different types in trivy-db, not an inconsistency here.
Severity: severityFromLevel(a.Severity),
})
}
return out, nil
}
// Vulnerability returns a CVE's shared metadata.
func (s *Store) Vulnerability(cveID string) (VulnInfo, error) {
v, err := s.cfg.GetVulnerability(cveID)
if err != nil {
return VulnInfo{}, err
}
return VulnInfo{
Title: v.Title,
Severity: resolveSeverity(v),
CVSSScore: topCVSS(v),
References: v.References,
}, nil
}
// resolveSeverity picks a CVE's severity: vendor, then NVD, then unknown.
//
// Never invented. This will surface as "why is this critical CVE marked low":
// Debian and Red Hat routinely downgrade an NVD score because the vulnerable
// path is not reachable in their build, and their rating is the accurate one
// for that package. Where several vendors disagree the highest wins, because
// under-reporting a vulnerability is the worse mistake.
func resolveSeverity(v trivytypes.Vulnerability) string {
best := 0
for _, sev := range v.VendorSeverity {
if int(sev) > best {
best = int(sev)
}
}
if best > 0 {
return severityFromLevel(trivytypes.Severity(best))
}
// Vulnerability.Severity is the deprecated NVD-derived string. Used only as
// the fallback, which is exactly what it is good for.
if s := strings.ToLower(strings.TrimSpace(v.Severity)); s != "" && s != "unknown" {
return s
}
return "unknown"
}
// topCVSS returns the highest V3 score any source published, or 0.
func topCVSS(v trivytypes.Vulnerability) float64 {
var top float64
for _, c := range v.CVSS {
if c.V3Score > top {
top = c.V3Score
}
}
return top
}
// severityFromLevel maps trivy-db's numeric severity onto our lowercase
// strings. The names are fixed by models.Severity* and must stay in step.
func severityFromLevel(n trivytypes.Severity) string {
switch int(n) {
case 4:
return "critical"
case 3:
return "high"
case 2:
return "medium"
case 1:
return "low"
default:
return "unknown"
}
}
+61
View File
@@ -0,0 +1,61 @@
package vulndb
import (
"fmt"
"strings"
)
// rhelRebuilds share Red Hat's advisory feed rather than publishing their own.
var rhelRebuilds = map[string]bool{
"redhat": true, "centos": true, "rocky": true, "alma": true, "oracle": true,
}
// Bucket maps an OS family and version onto the trivy-db bucket that holds its
// advisories.
//
// It returns ErrUnsupportedFamily rather than a best guess when we have no
// feed. A scan that cannot be performed must say so; reporting zero findings
// for a distribution we do not cover is indistinguishable from reporting a
// clean host, and one of those is a lie.
func Bucket(family, versionID string) (string, error) {
family = strings.ToLower(strings.TrimSpace(family))
versionID = strings.TrimSpace(versionID)
switch {
case family == "debian" || family == "ubuntu":
if versionID == "" {
return "", fmt.Errorf("%s requires a version id", family)
}
return family + " " + versionID, nil
case family == "alpine":
if versionID == "" {
return "", fmt.Errorf("alpine requires a version id")
}
return "alpine " + majorMinor(versionID), nil
case rhelRebuilds[family]:
if versionID == "" {
return "", fmt.Errorf("%s requires a version id", family)
}
return "redhat " + major(versionID), nil
default:
return "", fmt.Errorf("%w: %s", ErrUnsupportedFamily, family)
}
}
func major(v string) string {
if i := strings.Index(v, "."); i != -1 {
return v[:i]
}
return v
}
func majorMinor(v string) string {
parts := strings.Split(v, ".")
if len(parts) >= 2 {
return parts[0] + "." + parts[1]
}
return v
}
+81
View File
@@ -0,0 +1,81 @@
package vulndb
import (
"fmt"
"log"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
)
// AdvisorySource is the advisory lookup the matcher needs. *Store satisfies it.
// The seam keeps the matching logic independent of how the database is opened.
type AdvisorySource interface {
Advisories(bucket, srcName string) ([]Advisory, error)
}
// Result is one vulnerable package on one server, before it becomes a finding.
type Result struct {
CVEID string
PackageName string // the BINARY package, which is what is installed
Installed string
FixedIn string
Severity string
}
// Match returns every advisory that the installed packages do not satisfy.
//
// Vulnerable means: no fix has been published, or the installed version sorts
// strictly before the fixed version under the distribution's own ordering.
// Equal is NOT vulnerable — that is the backported-fix case, where a
// distribution patches in place without changing the upstream version, and
// treating it as vulnerable reports a patched fleet as exposed.
func Match(src AdvisorySource, os models.OSRelease, pkgs []models.InstalledPackage) ([]Result, error) {
bucket, err := Bucket(os.Family, os.VersionID)
if err != nil {
return nil, err
}
var out []Result
for _, p := range pkgs {
// Debian and Ubuntu advisories are keyed on the source package: one
// advisory against "openssl" covers libssl3, openssl and libssl-dev.
srcName := p.SourceName
if srcName == "" {
srcName = p.Name
}
advs, err := src.Advisories(bucket, srcName)
if err != nil {
return nil, fmt.Errorf("advisories for %s: %w", srcName, err)
}
for _, a := range advs {
// No published fix. Vulnerable, and the finding most in need of
// acceptance, since there is nothing to patch.
if a.FixedVersion == "" {
out = append(out, Result{
CVEID: a.CVEID, PackageName: p.Name,
Installed: p.Version, Severity: a.Severity,
})
continue
}
older, err := LessThan(os.Family, p.Version, a.FixedVersion)
if err != nil {
// Skip this one advisory rather than failing the whole server:
// one unparseable version must not blind us to every other CVE
// on the host. Log it — a silent skip is a silent false
// negative, which is the direction that hurts.
log.Printf("vulndb: compare %s %s vs %s: %v", p.Name, p.Version, a.FixedVersion, err)
continue
}
if older {
out = append(out, Result{
CVEID: a.CVEID, PackageName: p.Name,
Installed: p.Version, FixedIn: a.FixedVersion, Severity: a.Severity,
})
}
}
}
return out, nil
}
+186
View File
@@ -0,0 +1,186 @@
package vulndb
import (
"archive/tar"
"compress/gzip"
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"oras.land/oras-go/v2"
"oras.land/oras-go/v2/registry"
"oras.land/oras-go/v2/registry/remote"
)
// DefaultRef is the published trivy-db OCI artifact, rebuilt every six hours.
const DefaultRef = "ghcr.io/aquasecurity/trivy-db:2"
// SupportedSchema is the trivy-db schema version this code understands.
//
// A different version is refused rather than parsed on the assumption it is
// close enough. Mis-reading the schema would not fail loudly — it would return
// no advisories, which is indistinguishable from a clean fleet.
const SupportedSchema = 2
// dbFileName and metaFileName are the two files inside the artifact layer.
const (
dbFileName = "trivy.db"
metaFileName = "metadata.json"
)
// Ref returns the artifact reference, honouring VANTAGE_TRIVY_DB_REF so an
// air-gapped deployment can mirror the artifact into its own registry, and so
// a busy deployment can avoid the anonymous ghcr rate limit.
func Ref() string {
if v := os.Getenv("VANTAGE_TRIVY_DB_REF"); v != "" {
return v
}
return DefaultRef
}
// Disabled reports whether the puller and scheduler are switched off entirely.
// Findings already written are still served, and still marked stale.
func Disabled() bool {
return strings.EqualFold(os.Getenv("VANTAGE_VULNDB_DISABLED"), "true")
}
// dbMetadata is the subset of trivy-db's metadata.json we read.
type dbMetadata struct {
Version int `json:"Version"`
}
// Pull fetches the trivy-db artifact into dir and returns its schema version.
//
// It extracts into a staging directory and only moves the files into place once
// both are present and the schema has been accepted. A pull that fails partway
// therefore leaves the previous database untouched rather than a half-written
// one that Open would happily accept and scan against.
func Pull(ctx context.Context, dir string) (int, error) {
ref := Ref()
parsed, err := registry.ParseReference(ref)
if err != nil {
return 0, fmt.Errorf("parse reference %q: %w", ref, err)
}
repo, err := remote.NewRepository(ref)
if err != nil {
return 0, fmt.Errorf("open repository %q: %w", ref, err)
}
// The tag or digest half of the reference; the repository already knows the
// registry and path.
target := parsed.Reference
if target == "" {
target = "latest"
}
_, manifestBytes, err := oras.FetchBytes(ctx, repo, target, oras.DefaultFetchBytesOptions)
if err != nil {
return 0, fmt.Errorf("fetch manifest %s: %w", ref, err)
}
var man ocispec.Manifest
if err := json.Unmarshal(manifestBytes, &man); err != nil {
return 0, fmt.Errorf("decode manifest %s: %w", ref, err)
}
if len(man.Layers) == 0 {
return 0, fmt.Errorf("artifact %s has no layers", ref)
}
// Streamed rather than buffered: the layer is ~50MB and there is no reason
// to hold it in memory on the way to disk.
rc, err := repo.Blobs().Fetch(ctx, man.Layers[0])
if err != nil {
return 0, fmt.Errorf("fetch layer: %w", err)
}
defer rc.Close()
staging, err := os.MkdirTemp(dir, ".staging-")
if err != nil {
return 0, fmt.Errorf("staging dir: %w", err)
}
defer os.RemoveAll(staging)
if err := extractTarGz(rc, staging); err != nil {
return 0, fmt.Errorf("extract layer: %w", err)
}
metaBytes, err := os.ReadFile(filepath.Join(staging, metaFileName))
if err != nil {
return 0, fmt.Errorf("read %s: %w", metaFileName, err)
}
var meta dbMetadata
if err := json.Unmarshal(metaBytes, &meta); err != nil {
return 0, fmt.Errorf("decode %s: %w", metaFileName, err)
}
if meta.Version != SupportedSchema {
return 0, fmt.Errorf("trivy-db schema %d is not supported (want %d)", meta.Version, SupportedSchema)
}
if _, err := os.Stat(filepath.Join(staging, dbFileName)); err != nil {
return 0, fmt.Errorf("artifact has no %s: %w", dbFileName, err)
}
// Both files present and the schema accepted, so it is safe to replace.
for _, name := range []string{dbFileName, metaFileName} {
src := filepath.Join(staging, name)
dst := filepath.Join(dir, name)
if err := os.Remove(dst); err != nil && !os.IsNotExist(err) {
return 0, fmt.Errorf("remove old %s: %w", name, err)
}
if err := os.Rename(src, dst); err != nil {
return 0, fmt.Errorf("install %s: %w", name, err)
}
}
return meta.Version, nil
}
// extractTarGz writes the artifact layer into dir. Paths are flattened and
// checked so a crafted archive cannot write outside dir.
func extractTarGz(r io.Reader, dir string) error {
gz, err := gzip.NewReader(r)
if err != nil {
return err
}
defer gz.Close()
tr := tar.NewReader(gz)
for {
hdr, err := tr.Next()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
if hdr.Typeflag != tar.TypeReg {
continue
}
name := filepath.Base(hdr.Name) // flatten; the archive is two files
if name == "." || name == ".." || name == "" {
continue
}
dst := filepath.Join(dir, name)
if !strings.HasPrefix(dst, filepath.Clean(dir)+string(os.PathSeparator)) {
return fmt.Errorf("archive entry escapes destination: %q", hdr.Name)
}
f, err := os.Create(dst)
if err != nil {
return err
}
if _, err := io.Copy(f, tr); err != nil {
f.Close()
return err
}
if err := f.Close(); err != nil {
return err
}
}
}
+73
View File
@@ -0,0 +1,73 @@
// Package vulndb matches installed packages against distribution security
// advisories.
//
// Version comparison is bought rather than written. Distribution version
// ordering is subtle in ways that are invisible until they are wrong: dpkg has
// epochs and sorts "~" before the empty string, rpmvercmp has its own segment
// rules and treats "~" and "^" differently again, and any ordering that falls
// back on string comparison puts 1.10 before 1.9. Every one of those mistakes
// produces a false negative — a vulnerable host reported clean — which is the
// failure nobody notices.
package vulndb
import (
"errors"
"fmt"
apk "github.com/knqyf263/go-apk-version"
deb "github.com/knqyf263/go-deb-version"
rpm "github.com/knqyf263/go-rpm-version"
)
// ErrUnsupportedFamily means we hold no comparator for this distribution, and
// therefore cannot answer whether it is vulnerable. Callers must surface this
// as "unsupported" and must never treat it as "not vulnerable".
var ErrUnsupportedFamily = errors.New("unsupported OS family")
// LessThan reports whether version a sorts before version b under the ordering
// rules of the given OS family.
//
// An unparseable or empty version is an error, never a quiet false. False here
// means "not vulnerable", which is the dangerous direction to guess in.
func LessThan(family, a, b string) (bool, error) {
switch family {
case "debian", "ubuntu":
if a == "" || b == "" {
return false, fmt.Errorf("empty deb version (a=%q b=%q)", a, b)
}
va, err := deb.NewVersion(a)
if err != nil {
return false, fmt.Errorf("parse deb version %q: %w", a, err)
}
vb, err := deb.NewVersion(b)
if err != nil {
return false, fmt.Errorf("parse deb version %q: %w", b, err)
}
return va.LessThan(vb), nil
case "redhat", "centos", "rocky", "alma", "amazon", "oracle", "suse", "opensuse", "sles":
// go-rpm-version does not error; rpmvercmp is defined over arbitrary
// strings. Guard empties so a missing version cannot read as equal.
if a == "" || b == "" {
return false, fmt.Errorf("empty rpm version (a=%q b=%q)", a, b)
}
return rpm.NewVersion(a).LessThan(rpm.NewVersion(b)), nil
case "alpine":
if a == "" || b == "" {
return false, fmt.Errorf("empty apk version (a=%q b=%q)", a, b)
}
va, err := apk.NewVersion(a)
if err != nil {
return false, fmt.Errorf("parse apk version %q: %w", a, err)
}
vb, err := apk.NewVersion(b)
if err != nil {
return false, fmt.Errorf("parse apk version %q: %w", b, err)
}
return va.LessThan(vb), nil
default:
return false, fmt.Errorf("%w: %s", ErrUnsupportedFamily, family)
}
}
+238
View File
@@ -0,0 +1,238 @@
// Package vulnsched owns the vulnerability scan loop.
//
// It runs inside bus.RunAsLeader("housekeeping", …) alongside monitorsched,
// workflowsched and the sweepers: one role, one lock. N replicas each running
// this loop would mean N copies of the ~50MB database resident, N rescans of
// the same fleet on every database refresh, and N digests reaching the
// customer for one set of findings.
package vulnsched
import (
"context"
"errors"
"log"
"os"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/vulndb"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
const (
tickInterval = 60 * time.Second
// trivy-db is rebuilt every six hours; pulling more often buys nothing.
dbMaxAge = 6 * time.Hour
)
// Deps are injected from main.go rather than imported, following
// workflowsched. It keeps this package's reach explicit and reviewable.
type Deps struct {
LogEvent func(instanceID, eventType, actor, serverID, keyID, details string)
SendDigest func(instanceID string, newly []models.VulnFinding)
}
type scheduler struct {
deps Deps
dir string
store *vulndb.Store
version int
pulled time.Time
}
func Start(ctx context.Context, deps Deps) {
if vulndb.Disabled() {
log.Println("vulnsched: disabled by VANTAGE_VULNDB_DISABLED")
return
}
dir, err := os.MkdirTemp("", "vantage-vulndb-")
if err != nil {
log.Printf("vulnsched: temp dir: %v", err)
return
}
s := &scheduler{deps: deps, dir: dir}
go func() {
defer os.RemoveAll(dir)
defer s.closeStore()
ticker := time.NewTicker(tickInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.tick(ctx)
}
}
}()
}
func (s *scheduler) tick(ctx context.Context) {
if err := s.ensureDB(ctx); err != nil {
// Keep the last good database and carry on scanning against it. A
// network blip must never clear findings or read as "all fixed".
log.Printf("vulnsched: database unavailable: %v", err)
s.recordDBError(ctx, err)
if s.store == nil {
return
}
}
s.scanPending(ctx)
}
// ensureDB pulls a fresh database when the local copy is stale, and marks the
// whole fleet for rescanning when the version changes — which is what makes a
// newly published CVE flag existing servers within a minute rather than at the
// next agent report.
func (s *scheduler) ensureDB(ctx context.Context) error {
if s.store != nil && time.Since(s.pulled) < dbMaxAge {
return nil
}
version, err := vulndb.Pull(ctx, s.dir)
if err != nil {
return err
}
s.closeStore()
store, err := vulndb.Open(s.dir)
if err != nil {
return err
}
s.store = store
s.pulled = time.Now()
changed := version != s.version
s.version = version
_, _ = db.Col("vulndb_meta").UpdateOne(ctx, bson.M{},
bson.M{"$set": bson.M{"db_version": version, "pulled_at": s.pulled}, "$unset": bson.M{"last_error": ""}},
options.UpdateOne().SetUpsert(true),
)
if changed {
res, err := db.Col("server_packages").UpdateMany(ctx,
bson.M{"status": bson.M{"$ne": models.ScanStatusUnsupported}},
bson.M{"$set": bson.M{"scan_pending": true}},
)
if err != nil {
log.Printf("vulnsched: mark fleet pending: %v", err)
} else {
log.Printf("vulnsched: database version %d, %d servers marked for rescan", version, res.ModifiedCount)
}
}
return nil
}
func (s *scheduler) recordDBError(ctx context.Context, err error) {
_, _ = db.Col("vulndb_meta").UpdateOne(ctx, bson.M{},
bson.M{"$set": bson.M{"last_error": err.Error()}},
options.UpdateOne().SetUpsert(true),
)
}
func (s *scheduler) scanPending(ctx context.Context) {
cur, err := db.Col("server_packages").Find(ctx, bson.M{"scan_pending": true})
if err != nil {
log.Printf("vulnsched: find pending: %v", err)
return
}
defer cur.Close(ctx)
var pending []models.ServerPackages
if err := cur.All(ctx, &pending); err != nil {
log.Printf("vulnsched: decode pending: %v", err)
return
}
// Newly opened findings are collected across the whole tick and sent as one
// digest per instance. A database refresh can open several hundred findings
// at once; one message per finding would rate-limit the webhook or get the
// channel muted, and either way the alerts stop being read.
newly := map[string][]models.VulnFinding{}
for _, sp := range pending {
if ctx.Err() != nil {
// Leadership lost. scan_pending is still set, so the next leader
// picks these up — which is why it lives on the document.
return
}
opened := s.scanOne(ctx, sp)
newly[sp.InstanceID] = append(newly[sp.InstanceID], opened...)
}
for instanceID, findings := range newly {
if len(findings) > 0 && s.deps.SendDigest != nil {
s.deps.SendDigest(instanceID, findings)
}
}
_, _ = db.Col("vulndb_meta").UpdateOne(ctx, bson.M{},
bson.M{"$set": bson.M{"last_full_scan_at": time.Now()}},
options.UpdateOne().SetUpsert(true),
)
}
func (s *scheduler) scanOne(ctx context.Context, sp models.ServerPackages) []models.VulnFinding {
now := time.Now()
results, err := vulndb.Match(s.store, sp.OS, sp.Packages)
if err != nil {
// We hold no feed for this distribution, so we cannot answer whether it
// is vulnerable. Say "unsupported" — reporting zero findings here would
// be indistinguishable from reporting a clean host, and one of those is
// a lie.
status := models.ScanStatusUnsupported
if !errors.Is(err, vulndb.ErrUnsupportedFamily) {
log.Printf("vulnsched: scan %s: %v", sp.ServerID, err)
status = sp.Status
}
s.clearPending(ctx, sp.ID, status, now)
return nil
}
existing, err := services.ListFindings(ctx, sp.InstanceID, sp.ServerID)
if err != nil {
log.Printf("vulnsched: list findings %s: %v", sp.ServerID, err)
return nil
}
diff := services.DiffFindings(existing, results, now)
if err := services.ApplyFindingDiff(ctx, sp.InstanceID, sp.ServerID, diff, now); err != nil {
log.Printf("vulnsched: apply diff %s: %v", sp.ServerID, err)
return nil
}
s.clearPending(ctx, sp.ID, models.ScanStatusOK, now)
for i := range diff.NewlyOpened {
diff.NewlyOpened[i].ServerID = sp.ServerID
}
return diff.NewlyOpened
}
func (s *scheduler) clearPending(ctx context.Context, id bson.ObjectID, status string, now time.Time) {
_, _ = db.Col("server_packages").UpdateOne(ctx,
bson.M{"_id": id},
bson.M{"$set": bson.M{
"scan_pending": false,
"status": status,
"scanned_at": now,
"db_version": s.version,
}},
)
}
func (s *scheduler) closeStore() {
if s.store != nil {
_ = s.store.Close()
s.store = nil
}
}
+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
@@ -0,0 +1,13 @@
{{define "title"}}New vulnerabilities detected{{end}}
{{define "pill"}}{{template "chip" (dict "label" (upper .TopSeverity) "tone" "down")}}{{end}}
{{define "body"}}
{{template "lead" .Summary}}
{{template "rows" (list
(dict "k" "Instance" "v" .InstanceName)
(dict "k" "New findings" "v" .Count))}}
{{range .Rows}}
{{if .FixedIn}}{{template "well" (printf "%s (%s) — %s on %s, fixed in %s" .CVEID .Severity .PackageName .ServerName .FixedIn)}}{{else}}{{template "well" (printf "%s (%s) — %s on %s, no fix published" .CVEID .Severity .PackageName .ServerName)}}{{end}}
{{end}}
{{if .More}}{{template "p" (printf "…and %d more." .More)}}{{end}}
{{template "note" (printf "Scanned against a vulnerability database pulled %s ago." .DBAge)}}
{{end}}
@@ -0,0 +1,12 @@
{{define "subject"}}{{.Count}} new {{if eq .Count 1}}vulnerability{{else}}vulnerabilities{{end}} on {{.InstanceName}}{{end}}
{{define "title"}}New vulnerabilities detected{{end}}
{{define "pill"}}{{.TopSeverity}}{{end}}
{{define "body"}}
{{template "lead" .Summary}}
{{range .Rows}}- {{.CVEID}} ({{.Severity}}) — {{.PackageName}} on {{.ServerName}}{{if .FixedIn}}, fixed in {{.FixedIn}}{{else}}, no fix published{{end}}
{{end}}
{{if .More}}...and {{.More}} more.{{end}}
Scanned against vulnerability database pulled {{.DBAge}} ago.
{{end}}
+41
View File
@@ -0,0 +1,41 @@
package mail
// VulnDigestRow is one newly opened finding as the digest shows it.
//
// It lives here rather than in server/ so the templates and the caller agree on
// the fields without server's model package leaking into shared.
type VulnDigestRow struct {
CVEID string
Severity string
PackageName string
ServerName string
// FixedIn empty means no vendor fix has been published, which the template
// says explicitly rather than leaving blank — it is a real state, not
// missing data.
FixedIn string
}
// VulnDigest is one batch of newly opened findings.
//
// One message per rule per scan, never one per finding: a database refresh can
// open several hundred at once, and one message each would rate-limit the
// webhook or get the channel muted.
type VulnDigest struct {
InstanceName string
// Count is every newly opened finding in the batch, which may exceed
// len(Rows) — Rows is capped and More carries the remainder.
Count int
TopSeverity string
Summary string
Rows []VulnDigestRow
More int
// DBAge is pre-formatted by the caller. A digest scanned against a
// three-week-old database must say so rather than quietly imply freshness.
DBAge string
}
// SendVulnDigest delivers one digest to an SMTP notification channel's
// recipients, which may be a comma-separated list.
func (s Sender) SendVulnDigest(to string, d VulnDigest) error {
return s.sendTemplate(to, "", "vuln_digest", d)
}
+5
View File
@@ -34,6 +34,11 @@ type Settings struct {
// absent as disabled — turning off password login for the entire fleet at
// upgrade. Nil means enabled.
LocalLoginEnabled *bool `bson:"local_login_enabled,omitempty" json:"local_login_enabled,omitempty"`
// VulnFindingRetentionDays is a pointer for the same reason
// WorkflowLogRetentionDays is: absent must mean the default, not zero.
// Nil is 90 days, 0 is forever. Only "fixed" findings are ever swept.
VulnFindingRetentionDays *int `bson:"vuln_finding_retention_days,omitempty" json:"vuln_finding_retention_days,omitempty"`
}
// LocalLoginEnabled reads the setting with its absent-means-on default. Every
+5
View File
@@ -9,6 +9,7 @@ import { Badge, Button, Card, CardHeader, CardTitle } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
import { useLicense } from "@/lib/useLicense";
import { TagChips } from "@/components/servers/TagChips";
import { ServerVulnerabilities } from "@/components/vulnerabilities/ServerVulnerabilities";
function statusVariant(status: ServerStatus) {
switch (status) {
@@ -549,6 +550,10 @@ export default function ServerDetailPage() {
</dl>
</Card>
<div className="lg:col-span-2">
<ServerVulnerabilities serverId={server.server_id} />
</div>
<div className="lg:col-span-2">
<Card padding={false}>
<div className="flex items-center justify-between border-b border-border px-6 py-4">
@@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { api, ChannelInput, ChannelType, NotificationChannel } from "@/lib/api";
import { Badge, Button, Card } from "@/components/ui";
import { VulnAlertRulesCard } from "@/components/vulnerabilities/VulnAlertRulesCard";
const inputClass =
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30";
@@ -182,6 +183,12 @@ export default function NotificationSettingsPage() {
channels.map((ch) => <ChannelRow key={ch.channel_id} ch={ch} />)
)}
</Card>
{/* Beside the channels it consumes rather than on its own page: a rule is
a routing decision about destinations configured directly above it. */}
<div className="mt-6">
<VulnAlertRulesCard />
</div>
</div>
);
}
+169
View File
@@ -0,0 +1,169 @@
"use client";
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, vulnerabilities, type FindingState, type Severity, type VulnFinding } from "@/lib/api";
import { useAuth } from "@/components/AuthProvider";
import { Button } from "@/components/ui";
import { AcceptDialog } from "@/components/vulnerabilities/AcceptDialog";
import { DBFreshness } from "@/components/vulnerabilities/DBFreshness";
import { FindingRow } from "@/components/vulnerabilities/FindingRow";
import { SEVERITY_ORDER, SeverityBadge } from "@/components/vulnerabilities/SeverityVisuals";
/*
* The fleet vulnerability board.
*
* Grouped by CVE, defaulting to open findings, with database freshness always
* on screen. The three things this page must never do: imply freshness it does
* not have, present an unsupported distribution as clean, or make one CVE on
* forty servers look like forty problems.
*/
const STATES: FindingState[] = ["open", "accepted", "fixed"];
export default function VulnerabilitiesPage() {
const { isAdmin } = useAuth();
const qc = useQueryClient();
const [state, setState] = useState<FindingState>("open");
const [severity, setSeverity] = useState<Severity | "">("");
const [accepting, setAccepting] = useState<VulnFinding | null>(null);
const groups = useQuery({
queryKey: ["vulnerabilities", state, severity],
queryFn: () => vulnerabilities.list({ state, severity: severity || undefined }),
});
const summary = useQuery({
queryKey: ["vulnerabilities", "summary"],
queryFn: () => vulnerabilities.summary(),
});
const servers = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() });
const serverName = useMemo(() => {
const byId = new Map((servers.data ?? []).map((s) => [s.server_id, s.hostname]));
// Falls back to the raw id rather than an empty cell: an unnamed row is
// worse than an ugly one.
return (id: string) => byId.get(id) ?? id;
}, [servers.data]);
const invalidate = () => {
qc.invalidateQueries({ queryKey: ["vulnerabilities"] });
};
const rescan = useMutation({
mutationFn: () => vulnerabilities.rescan(),
onSuccess: invalidate,
});
const accept = useMutation({
mutationFn: ({ id, reason, until }: { id: string; reason: string; until: string }) => vulnerabilities.accept(id, reason, until),
onSuccess: () => {
setAccepting(null);
invalidate();
},
});
const unaccept = useMutation({
mutationFn: (id: string) => vulnerabilities.unaccept(id),
onSuccess: invalidate,
});
const applyUpdates = useMutation({
mutationFn: (serverId: string) => api.applyUpdates(serverId),
});
const counts = summary.data?.counts ?? {};
const total = SEVERITY_ORDER.reduce((n, s) => n + (counts[s] ?? 0), 0);
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-xl font-bold text-text-primary">Vulnerabilities</h1>
<p className="mt-1 text-sm text-text-secondary">
Installed packages matched against distribution security advisories.
</p>
</div>
{isAdmin && (
<Button variant="secondary" loading={rescan.isPending} onClick={() => rescan.mutate()}>
Rescan fleet
</Button>
)}
</div>
<DBFreshness summary={summary.data} />
<div className="flex flex-wrap gap-4 rounded-lg border border-border bg-surface px-5 py-4">
{SEVERITY_ORDER.map((s) => (
<button
key={s}
onClick={() => setSeverity(severity === s ? "" : s)}
className={`flex items-center gap-2 rounded px-2 py-1 text-left transition-colors ${
severity === s ? "bg-surface-2" : "hover:bg-surface-2"
}`}
>
<SeverityBadge severity={s} />
<span className="font-mono text-lg font-semibold tabular-nums text-text-primary">{counts[s] ?? 0}</span>
</button>
))}
<span className="ml-auto self-center font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">
{total} open
</span>
</div>
<div className="flex gap-2">
{STATES.map((s) => (
<button
key={s}
onClick={() => setState(s)}
className={`rounded border px-3 py-1.5 text-sm capitalize transition-colors ${
state === s ? "border-accent text-accent" : "border-border text-text-secondary hover:text-text-primary"
}`}
>
{s}
</button>
))}
</div>
{groups.isLoading && <p className="text-sm text-text-secondary">Loading</p>}
{groups.error && <p className="text-sm text-danger">{(groups.error as Error).message}</p>}
{groups.data && groups.data.length === 0 && (
<div className="rounded-lg border border-border bg-surface px-5 py-8 text-center">
<p className="text-sm text-text-secondary">No {state} findings.</p>
<p className="mt-1 text-xs text-text-tertiary">
Servers report packages hourly. A server whose distribution has no advisory feed is reported as unsupported on its own
page rather than counted here.
</p>
</div>
)}
<div className="space-y-2">
{groups.data?.map((g) => (
<FindingRow
key={g.cve_id}
group={g}
serverName={serverName}
canAct={isAdmin}
onAccept={setAccepting}
onUnaccept={(f) => unaccept.mutate(f.id)}
onApplyUpdates={(serverId) => applyUpdates.mutate(serverId)}
applying={applyUpdates.isPending ? (applyUpdates.variables as string) : undefined}
/>
))}
</div>
{accepting && (
<AcceptDialog
finding={accepting}
serverName={serverName(accepting.server_id)}
pending={accept.isPending}
onClose={() => setAccepting(null)}
onAccept={(reason, until) => accept.mutate({ id: accepting.id, reason, until })}
/>
)}
</div>
);
}
@@ -69,11 +69,7 @@ const pillLed: Record<PillKind, string> = {
function StatusPill({ status, small }: { status: string; small?: boolean }) {
const kind = pillKind(status);
return (
<span
className={`inline-flex items-center gap-2 rounded-full border font-mono font-semibold uppercase tracking-wide ${
small ? "px-2 py-0.5 text-[10px]" : "px-2.5 py-1 text-xs"
} ${pillClass[kind]}`}
>
<span className={`inline-flex items-center gap-2 rounded-full border font-mono font-semibold uppercase tracking-wide ${small ? "px-2 py-0.5 text-[10px]" : "px-2.5 py-1 text-xs"} ${pillClass[kind]}`}>
<span className={`h-1.5 w-1.5 rounded-full ${pillLed[kind]}`} />
{status}
</span>
@@ -192,10 +188,7 @@ function StepList({ server, now }: { server: ServerRun; now: number }) {
{server.steps.map((st) => {
const kind = cellKind(st.status);
return (
<div
key={st.order}
className={`grid grid-cols-[20px_1fr_auto] items-center gap-2.5 rounded-lg px-3 py-2.5 text-[13px] hover:bg-surface-2 ${st.status === "running" ? "bg-accent/[0.06]" : ""}`}
>
<div key={st.order} className={`grid grid-cols-[20px_1fr_auto] items-center gap-2.5 rounded-lg px-3 py-2.5 text-[13px] hover:bg-surface-2 ${st.status === "running" ? "bg-accent/[0.06]" : ""}`}>
<span className="text-right font-mono text-[11px] text-text-secondary">{String(st.order + 1).padStart(2, "0")}</span>
<span className="flex items-center gap-2 font-medium text-text-primary">
<span className={`font-mono ${cellClass[kind].replace(/bg-\S+/, "")}`}>{cellGlyph[kind]}</span>
@@ -340,7 +333,7 @@ export default function RunDetail() {
const ago = fmtDuration(now - startMs);
return (
<div className="mx-auto max-w-[1180px] p-4 pb-16 sm:p-6 lg:p-8">
<div className="p-4 sm:p-6 lg:p-8">
{/* identity bar */}
<div className="flex flex-wrap items-start justify-between gap-6">
<div>
+13
View File
@@ -121,9 +121,22 @@ function StepsIcon() {
);
}
function ShieldIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M11.998 2.25a.75.75 0 01.298.062l7.5 3.214a.75.75 0 01.454.69v5.034c0 4.63-2.94 8.75-7.5 10.25a.75.75 0 01-.5 0c-4.56-1.5-7.5-5.62-7.5-10.25V6.216a.75.75 0 01.454-.69l7.5-3.214a.75.75 0 01.294-.062zM12 8.25v3.75m0 3h.008v.008H12v-.008z"
/>
</svg>
);
}
const navItems: NavItem[] = [
{ href: "/servers", label: "Servers", icon: <ServerIcon /> },
{ href: "/monitors", label: "Monitors", icon: <MonitorIcon /> },
{ href: "/vulnerabilities", label: "Vulnerabilities", icon: <ShieldIcon /> },
{ href: "/keys", label: "SSH Keys", icon: <KeyIcon /> },
{ href: "/secrets", label: "Secrets", icon: <SecretIcon /> },
{ href: "/workflows", label: "Workflows", icon: <WorkflowIcon /> },
@@ -0,0 +1,86 @@
"use client";
import { useState } from "react";
import { Button, Modal } from "@/components/ui";
import type { VulnFinding } from "@/lib/api";
/*
* Accepting a finding needs a reason and an expiry, and the API refuses without
* both. The expiry is the point: a permanent dismissal is where risk goes to be
* forgotten, and it is exactly what an auditor asks to see. This dialog says so
* in as many words, because someone clicking it a year later needs to know the
* finding will come back on its own.
*/
const DEFAULT_DAYS = 30;
function defaultUntil(): string {
const d = new Date();
d.setDate(d.getDate() + DEFAULT_DAYS);
return d.toISOString().slice(0, 10);
}
interface Props {
finding: VulnFinding;
serverName: string;
onClose: () => void;
onAccept: (reason: string, untilISO: string) => void;
pending?: boolean;
}
export function AcceptDialog({ finding, serverName, onClose, onAccept, pending }: Props) {
const [reason, setReason] = useState("");
const [until, setUntil] = useState(defaultUntil());
const untilDate = new Date(`${until}T23:59:59`);
const validUntil = !Number.isNaN(untilDate.getTime()) && untilDate.getTime() > Date.now();
const canSubmit = reason.trim().length > 0 && validUntil && !pending;
return (
<Modal open onClose={onClose} title="Accept finding">
<div className="space-y-4">
<div className="rounded border border-border bg-well px-3 py-2 font-mono text-xs text-text-secondary">
{finding.cve_id} · {finding.package_name} on {serverName}
<br />
{finding.fixed_in ? `fixed in ${finding.fixed_in}` : "no fix published"}
</div>
<label className="block">
<span className="mb-1 block text-xs font-medium text-text-secondary">Reason</span>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={3}
placeholder="Why this cannot be fixed now"
className="w-full rounded border border-border bg-surface px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent focus:outline-none"
/>
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-text-secondary">Reopens on</span>
<input
type="date"
value={until}
onChange={(e) => setUntil(e.target.value)}
className="w-full rounded border border-border bg-surface px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
/>
{!validUntil && <span className="mt-1 block text-xs text-danger">Pick a future date.</span>}
</label>
<p className="text-xs text-text-tertiary">
The finding is hidden from counts and alerts until this date, then reopens automatically. Your name and reason are recorded in
the audit log.
</p>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button disabled={!canSubmit} onClick={() => onAccept(reason.trim(), untilDate.toISOString())}>
{pending ? "Accepting…" : "Accept"}
</Button>
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,44 @@
import type { VulnSummary } from "@/lib/api";
import { ageHours, relativeTime } from "./SeverityVisuals";
/*
* Database freshness sits with the findings, not in settings.
*
* A fleet scanned against a three-week-old database must say so where its
* findings are read. Quietly reporting "0 open" against stale data is the same
* class of lie as reporting zero findings for a distribution we hold no feed
* for — it looks exactly like good news.
*/
// Past this the banner stops being informational and starts being a warning.
// trivy-db rebuilds every six hours, so a day without a successful pull already
// means something is wrong.
const STALE_AFTER_HOURS = 24;
export function DBFreshness({ summary }: { summary?: VulnSummary }) {
if (!summary) return null;
const age = ageHours(summary.pulled_at);
const stale = age === null || age > STALE_AFTER_HOURS;
if (!stale && !summary.last_error) {
return (
<p className="font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">
Vulnerability database v{summary.db_version ?? "?"} · pulled {relativeTime(summary.pulled_at)}
{summary.last_full_scan_at && <> · last scan {relativeTime(summary.last_full_scan_at)}</>}
</p>
);
}
return (
<div className="rounded-lg border border-warning/50 bg-warning/10 px-4 py-3">
<p className="text-sm font-medium text-warning">
{age === null ? "No vulnerability database has been pulled yet" : `Vulnerability database is ${relativeTime(summary.pulled_at)}`}
</p>
<p className="mt-1 text-xs text-text-secondary">
Findings below are matched against that data. Counts may be incomplete until a fresh pull succeeds.
</p>
{summary.last_error && <p className="mt-2 break-words font-mono text-xs text-text-tertiary">{summary.last_error}</p>}
</div>
);
}
@@ -0,0 +1,104 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Button } from "@/components/ui";
import type { VulnFinding, VulnGroup } from "@/lib/api";
import { SeverityBadge, StateBadge, relativeTime } from "./SeverityVisuals";
/*
* One row per CVE, expandable to the servers it affects.
*
* The grouping is the point. The same CVE across forty servers is one decision
* — patch it, or accept it and say why — and a flat list of forty findings
* makes it look like forty decisions, which is how a board stops being read.
*/
interface Props {
group: VulnGroup;
serverName: (serverId: string) => string;
canAct: boolean;
onAccept: (f: VulnFinding) => void;
onUnaccept: (f: VulnFinding) => void;
onApplyUpdates: (serverId: string) => void;
applying?: string;
}
export function FindingRow({ group, serverName, canAct, onAccept, onUnaccept, onApplyUpdates, applying }: Props) {
const [open, setOpen] = useState(false);
// A CVE with no fix anywhere cannot be patched, only accepted. Saying so on
// the collapsed row saves opening it to find there is nothing to do.
const anyFix = group.findings.some((f) => f.fixed_in);
return (
<div className="rounded-lg border border-border bg-surface">
<button
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-surface-2"
aria-expanded={open}
>
<span className="font-mono text-xs text-text-tertiary">{open ? "▾" : "▸"}</span>
<SeverityBadge severity={group.severity} />
<span className="font-mono text-sm font-medium text-text-primary">{group.cve_id}</span>
{group.title && <span className="hidden truncate text-sm text-text-secondary sm:block">{group.title}</span>}
<span className="ml-auto whitespace-nowrap text-xs text-text-secondary">
{group.server_count} {group.server_count === 1 ? "server" : "servers"}
</span>
{!anyFix && <span className="whitespace-nowrap text-xs text-text-tertiary">no fix published</span>}
</button>
{open && (
<div className="border-t border-border">
{group.findings.map((f) => (
<div key={f.id} className="flex flex-wrap items-center gap-x-4 gap-y-2 border-b border-border-soft px-4 py-3 last:border-b-0">
<Link href={`/servers/${f.server_id}`} className="text-sm text-accent hover:underline">
{serverName(f.server_id)}
</Link>
<span className="font-mono text-xs text-text-secondary">
{f.package_name} {f.installed_version}
</span>
<span className="font-mono text-xs text-text-tertiary">
{f.fixed_in ? `${f.fixed_in}` : "no fix published"}
</span>
<StateBadge state={f.state} />
{f.state === "accepted" && f.accepted && (
<span className="text-xs text-text-tertiary">
{f.accepted.reason} · reopens {new Date(f.accepted.until).toLocaleDateString()}
</span>
)}
{f.state !== "accepted" && <span className="text-xs text-text-tertiary">first seen {relativeTime(f.first_seen)}</span>}
{canAct && (
<div className="ml-auto flex gap-2">
{/* Remediation is the existing endpoint, not a new
mechanism: see it, patch it, one place. */}
{f.fixed_in && f.state !== "fixed" && (
<Button size="sm" variant="secondary" loading={applying === f.server_id} onClick={() => onApplyUpdates(f.server_id)}>
Apply updates
</Button>
)}
{f.state === "accepted" ? (
<Button size="sm" variant="ghost" onClick={() => onUnaccept(f)}>
Reopen
</Button>
) : (
f.state === "open" && (
<Button size="sm" variant="ghost" onClick={() => onAccept(f)}>
Accept
</Button>
)
)}
</div>
)}
</div>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,113 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { Button, Card } from "@/components/ui";
import { vulnerabilities, type ServerPackages } from "@/lib/api";
import { SeverityBadge, SEVERITY_ORDER, StateBadge, relativeTime } from "./SeverityVisuals";
/*
* One server's findings and package inventory.
*
* The three states this must keep apart, because they look identical if you are
* careless and only one of them is good news:
*
* - the agent has never reported → "no inventory yet"
* - the distribution has no advisory feed → "unsupported"
* - scanned, nothing found → "no known vulnerabilities"
*/
function hasReported(p: ServerPackages | { reported: false } | undefined): p is ServerPackages {
return !!p && !("reported" in p);
}
export function ServerVulnerabilities({ serverId }: { serverId: string }) {
const findings = useQuery({
queryKey: ["vulnerabilities", "server", serverId],
queryFn: () => vulnerabilities.forServer(serverId),
});
const packages = useQuery({
queryKey: ["packages", serverId],
queryFn: () => vulnerabilities.packagesForServer(serverId),
});
const pkg = hasReported(packages.data) ? packages.data : undefined;
const open = (findings.data ?? []).filter((f) => f.state === "open");
const counts = SEVERITY_ORDER.map((s) => ({ severity: s, n: open.filter((f) => f.severity === s).length })).filter((c) => c.n > 0);
return (
<Card padding={false}>
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-6 py-4">
<h2 className="text-lg font-semibold text-text-primary">Vulnerabilities</h2>
<Link href="/vulnerabilities">
<Button variant="ghost" size="sm">
Fleet board
</Button>
</Link>
</div>
<div className="px-6 py-5">
{packages.isLoading || findings.isLoading ? (
<p className="text-sm text-text-secondary">Loading</p>
) : !pkg ? (
<p className="text-sm text-text-secondary">
No package inventory yet. Agents report hourly, and only when vulnerability scanning is included in this instance&apos;s
licence.
</p>
) : pkg.status === "unsupported" ? (
<>
<p className="text-sm text-warning">
{pkg.os.family} {pkg.os.version_id} has no advisory feed, so this server cannot be scanned.
</p>
<p className="mt-1 text-xs text-text-tertiary">
This is not the same as having no vulnerabilities it means we cannot answer the question for this distribution.
</p>
</>
) : (
<>
<div className="flex flex-wrap items-center gap-4">
{counts.length === 0 ? (
<p className="text-sm text-success">No known vulnerabilities.</p>
) : (
counts.map((c) => (
<span key={c.severity} className="flex items-center gap-2">
<SeverityBadge severity={c.severity} />
<span className="font-mono text-lg font-semibold tabular-nums text-text-primary">{c.n}</span>
</span>
))
)}
</div>
<p className="mt-4 font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">
{pkg.packages.length} packages · {pkg.os.family} {pkg.os.version_id} · collected {relativeTime(pkg.collected_at)}
{pkg.scan_pending && " · rescan queued"}
</p>
{open.length > 0 && (
<ul className="mt-4 space-y-2">
{open.slice(0, 10).map((f) => (
<li key={f.id} className="flex flex-wrap items-center gap-3 border-b border-border-soft pb-2 last:border-b-0">
<SeverityBadge severity={f.severity} />
<span className="font-mono text-sm text-text-primary">{f.cve_id}</span>
<span className="font-mono text-xs text-text-secondary">
{f.package_name} {f.installed_version}
</span>
<span className="font-mono text-xs text-text-tertiary">
{f.fixed_in ? `${f.fixed_in}` : "no fix published"}
</span>
<StateBadge state={f.state} />
</li>
))}
{open.length > 10 && (
<li className="pt-1 text-xs text-text-tertiary">and {open.length - 10} more on the fleet board.</li>
)}
</ul>
)}
</>
)}
</div>
</Card>
);
}
@@ -0,0 +1,67 @@
import { Badge } from "@/components/ui";
import type { Severity, FindingState } from "@/lib/api";
/*
* One place that knows how a severity looks, because the board, the server tab
* and the digest counts all draw the same five words and must not drift.
*
* Colour is never the whole message: every pill carries its word, and the three
* state variants add a dot, so the distinction survives a monochrome screen.
*/
export const SEVERITY_ORDER: Severity[] = ["critical", "high", "medium", "low", "unknown"];
const severityVariant: Record<Severity, "danger" | "warning" | "accent" | "neutral"> = {
critical: "danger",
high: "danger",
medium: "warning",
low: "accent",
unknown: "neutral",
};
export function severityRank(s: Severity): number {
switch (s) {
case "critical":
return 4;
case "high":
return 3;
case "medium":
return 2;
case "low":
return 1;
default:
return 0;
}
}
export function SeverityBadge({ severity }: { severity: Severity }) {
return <Badge variant={severityVariant[severity]}>{severity}</Badge>;
}
export function StateBadge({ state }: { state: FindingState }) {
if (state === "fixed") return <Badge variant="success">fixed</Badge>;
if (state === "accepted") return <Badge variant="warning">accepted</Badge>;
return <Badge variant="danger">open</Badge>;
}
/** relativeTime renders an age in the coarsest honest unit. */
export function relativeTime(iso?: string): string {
if (!iso) return "never";
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return "unknown";
const mins = Math.floor((Date.now() - then) / 60000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 48) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}
/** ageHours is how the freshness banner decides whether to raise its voice. */
export function ageHours(iso?: string): number | null {
if (!iso) return null;
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return null;
return (Date.now() - then) / 3600000;
}
@@ -0,0 +1,203 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Badge, Button, Card } from "@/components/ui";
import { api, vulnerabilities, type Severity, type VulnAlertRule } from "@/lib/api";
import { SEVERITY_ORDER, SeverityBadge } from "./SeverityVisuals";
/*
* Alert rules live beside the channels they consume.
*
* A rule fires once per scan, not once per finding: the scheduler batches a
* whole tick into one digest, which is what stops a database refresh opening
* five hundred findings and sending five hundred messages.
*/
const inputClass =
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30";
const labelClass = "mb-1.5 block text-sm font-medium text-text-secondary";
export function VulnAlertRulesCard() {
const qc = useQueryClient();
const [adding, setAdding] = useState(false);
const rules = useQuery({ queryKey: ["vuln-rules"], queryFn: () => vulnerabilities.listRules() });
const channels = useQuery({ queryKey: ["channels"], queryFn: () => api.listChannels() });
const invalidate = () => qc.invalidateQueries({ queryKey: ["vuln-rules"] });
const remove = useMutation({
mutationFn: (id: string) => vulnerabilities.deleteRule(id),
onSuccess: invalidate,
});
const toggle = useMutation({
mutationFn: (r: VulnAlertRule) =>
vulnerabilities.updateRule(r.id, {
name: r.name,
enabled: !r.enabled,
min_severity: r.min_severity,
tags: r.tags,
channel_ids: r.channel_ids,
}),
onSuccess: invalidate,
});
const channelName = (id: string) => channels.data?.find((c) => c.channel_id === id)?.name ?? id;
return (
<Card>
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<h2 className="text-base font-bold tracking-[-0.02em] text-text-primary">Vulnerability alerts</h2>
<p className="mt-0.5 text-sm text-text-secondary">
One digest per rule per scan, summarising what newly opened never one message per finding.
</p>
</div>
{!adding && (
<Button size="sm" variant="secondary" onClick={() => setAdding(true)}>
New rule
</Button>
)}
</div>
{adding && <RuleForm onDone={() => setAdding(false)} />}
{rules.isLoading ? (
<p className="text-sm text-text-secondary">Loading</p>
) : !rules.data || rules.data.length === 0 ? (
<p className="text-sm text-text-secondary">
No rules yet. Without one, findings appear on the board but nobody is told about them.
</p>
) : (
<ul className="divide-y divide-border-soft">
{rules.data.map((r) => (
<li key={r.id} className="flex flex-wrap items-center gap-3 py-3">
<span className="text-sm font-medium text-text-primary">{r.name}</span>
<Badge variant={r.enabled ? "success" : "neutral"}>{r.enabled ? "enabled" : "disabled"}</Badge>
<span className="flex items-center gap-1.5 text-xs text-text-secondary">
at or above <SeverityBadge severity={r.min_severity} />
</span>
{r.tags && Object.keys(r.tags).length > 0 && (
<span className="font-mono text-xs text-text-tertiary">
{Object.entries(r.tags)
.map(([k, v]) => `${k}:${v}`)
.join(" ")}
</span>
)}
<span className="text-xs text-text-tertiary"> {r.channel_ids.map(channelName).join(", ")}</span>
<div className="ml-auto flex gap-2">
<Button size="sm" variant="ghost" onClick={() => toggle.mutate(r)}>
{r.enabled ? "Disable" : "Enable"}
</Button>
<Button size="sm" variant="ghost" onClick={() => remove.mutate(r.id)}>
Delete
</Button>
</div>
</li>
))}
</ul>
)}
</Card>
);
}
function RuleForm({ onDone }: { onDone: () => void }) {
const qc = useQueryClient();
const channels = useQuery({ queryKey: ["channels"], queryFn: () => api.listChannels() });
const [name, setName] = useState("");
const [minSeverity, setMinSeverity] = useState<Severity>("high");
const [selected, setSelected] = useState<string[]>([]);
const [tags, setTags] = useState("");
const create = useMutation({
mutationFn: () =>
vulnerabilities.createRule({
name: name.trim(),
enabled: true,
min_severity: minSeverity,
tags: parseTags(tags),
channel_ids: selected,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["vuln-rules"] });
onDone();
},
});
const canSave = name.trim().length > 0 && selected.length > 0 && !create.isPending;
return (
<div className="mb-5 space-y-3 rounded-lg border border-border bg-surface-2 p-4">
<div>
<label className={labelClass}>Name</label>
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} placeholder="Production criticals" />
</div>
<div>
<label className={labelClass}>Minimum severity</label>
<select className={inputClass} value={minSeverity} onChange={(e) => setMinSeverity(e.target.value as Severity)}>
{SEVERITY_ORDER.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
<div>
<label className={labelClass}>Server tags (optional)</label>
<input className={inputClass} value={tags} onChange={(e) => setTags(e.target.value)} placeholder="env:prod role:web" />
<p className="mt-1 text-xs text-text-tertiary">
Space-separated key:value pairs. Resolved the same way a workflow resolves its targets.
</p>
</div>
<div>
<label className={labelClass}>Channels</label>
<div className="space-y-1">
{channels.data?.map((c) => (
<label key={c.channel_id} className="flex items-center gap-2 text-sm text-text-secondary">
<input
type="checkbox"
checked={selected.includes(c.channel_id)}
onChange={(e) =>
setSelected((prev) => (e.target.checked ? [...prev, c.channel_id] : prev.filter((id) => id !== c.channel_id)))
}
/>
{c.name} <span className="text-text-tertiary">({c.type})</span>
</label>
))}
{channels.data && channels.data.length === 0 && (
<p className="text-xs text-text-tertiary">No channels configured yet. A rule needs at least one.</p>
)}
</div>
</div>
{create.error && <p className="text-sm text-danger">{(create.error as Error).message}</p>}
<div className="flex justify-end gap-2">
<Button size="sm" variant="secondary" onClick={onDone}>
Cancel
</Button>
<Button size="sm" disabled={!canSave} loading={create.isPending} onClick={() => create.mutate()}>
Create
</Button>
</div>
</div>
);
}
/** parseTags reads "env:prod role:web" into a map. A pair without a colon is
* dropped rather than guessed at. */
function parseTags(raw: string): Record<string, string> | undefined {
const out: Record<string, string> = {};
for (const part of raw.split(/\s+/)) {
const idx = part.indexOf(":");
if (idx <= 0) continue;
out[part.slice(0, idx)] = part.slice(idx + 1);
}
return Object.keys(out).length > 0 ? out : undefined;
}
+152
View File
@@ -902,6 +902,158 @@ export interface LicenseInfo {
deployment: string;
}
export type Severity = "critical" | "high" | "medium" | "low" | "unknown";
export type FindingState = "open" | "fixed" | "accepted";
export interface Acceptance {
by: string;
reason: string;
until: string;
at: string;
}
export interface VulnFinding {
id: string;
server_id: string;
cve_id: string;
package_name: string;
installed_version: string;
/** Absent means no vendor fix is published — a real state, not missing data. */
fixed_in?: string;
severity: Severity;
cvss_score?: number;
title?: string;
references?: string[];
state: FindingState;
first_seen: string;
last_seen: string;
fixed_at?: string;
accepted?: Acceptance;
}
/** One CVE across every server it affects. The board groups by CVE because the
* same CVE on forty servers is one decision, not forty rows. */
export interface VulnGroup {
cve_id: string;
severity: Severity;
title?: string;
server_count: number;
findings: VulnFinding[];
}
export interface VulnSummary {
counts: Partial<Record<Severity, number>>;
db_version?: number;
pulled_at?: string;
last_full_scan_at?: string;
last_error?: string;
}
export interface InstalledPackage {
name: string;
version: string;
epoch?: number;
arch: string;
source_name?: string;
}
export interface ServerPackages {
server_id: string;
os: { family: string; version_id: string; arch: string };
hash: string;
packages: InstalledPackage[];
collected_at: string;
scan_pending: boolean;
scanned_at?: string;
/** "ok" | "unsupported". Unsupported must never read as "clean". */
status: string;
db_version: number;
}
export interface PackageHit {
server_id: string;
name: string;
version: string;
}
export interface VulnAlertRule {
id: string;
name: string;
enabled: boolean;
min_severity: Severity;
tags?: Record<string, string>;
channel_ids: string[];
created_at: string;
updated_at: string;
}
export interface VulnAlertRuleInput {
name: string;
enabled: boolean;
min_severity: Severity;
tags?: Record<string, string>;
channel_ids: string[];
}
export const vulnerabilities = {
list(params?: { severity?: string; state?: string; server?: string; tags?: Record<string, string> }): Promise<VulnGroup[]> {
const q = new URLSearchParams();
if (params?.severity) q.set("severity", params.severity);
if (params?.state) q.set("state", params.state);
if (params?.server) q.set("server", params.server);
for (const [k, v] of Object.entries(params?.tags ?? {})) q.append("tag", `${k}:${v}`);
const qs = q.toString();
return request<VulnGroup[]>(`/vulnerabilities${qs ? `?${qs}` : ""}`);
},
summary(): Promise<VulnSummary> {
return request<VulnSummary>("/vulnerabilities/summary");
},
rescan(): Promise<{ queued: number }> {
return request<{ queued: number }>("/vulnerabilities/rescan", { method: "POST" });
},
accept(id: string, reason: string, until: string): Promise<VulnFinding> {
return request<VulnFinding>(`/vulnerabilities/${id}/accept`, {
method: "POST",
body: JSON.stringify({ reason, until }),
});
},
unaccept(id: string): Promise<VulnFinding> {
return request<VulnFinding>(`/vulnerabilities/${id}/accept`, { method: "DELETE" });
},
forServer(serverId: string): Promise<VulnFinding[]> {
return request<VulnFinding[]>(`/servers/${serverId}/vulnerabilities`);
},
packagesForServer(serverId: string): Promise<ServerPackages | { reported: false }> {
return request<ServerPackages | { reported: false }>(`/servers/${serverId}/packages`);
},
searchPackages(name: string): Promise<PackageHit[]> {
return request<PackageHit[]>(`/packages/search?name=${encodeURIComponent(name)}`);
},
listRules(): Promise<VulnAlertRule[]> {
return request<VulnAlertRule[]>("/vuln-rules");
},
createRule(input: VulnAlertRuleInput): Promise<VulnAlertRule> {
return request<VulnAlertRule>("/vuln-rules", { method: "POST", body: JSON.stringify(input) });
},
updateRule(id: string, input: VulnAlertRuleInput): Promise<{ status: string }> {
return request<{ status: string }>(`/vuln-rules/${id}`, { method: "PUT", body: JSON.stringify(input) });
},
deleteRule(id: string): Promise<{ status: string }> {
return request<{ status: string }>(`/vuln-rules/${id}`, { method: "DELETE" });
},
};
// `request` already prefixes /api, so these paths do not repeat it.
export const licence = {
get(): Promise<LicenseInfo> {