From 3a6d24fe0e251e826763961ac2c3b27cd49d50b5 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Thu, 6 Aug 2026 11:59:00 +0100 Subject: [PATCH] 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. --- server/cmd/main.go | 4 + server/internal/models/packages.go | 52 +++++++++ server/internal/models/vuln.go | 109 +++++++++++++++++++ server/internal/services/migrate_instance.go | 3 + server/internal/services/vulnindexes.go | 62 +++++++++++ 5 files changed, 230 insertions(+) create mode 100644 server/internal/models/packages.go create mode 100644 server/internal/models/vuln.go create mode 100644 server/internal/services/vulnindexes.go diff --git a/server/cmd/main.go b/server/cmd/main.go index 6ae3df9..d5d81be 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -131,6 +131,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 { diff --git a/server/internal/models/packages.go b/server/internal/models/packages.go new file mode 100644 index 0000000..6e384e9 --- /dev/null +++ b/server/internal/models/packages.go @@ -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"` +} diff --git a/server/internal/models/vuln.go b/server/internal/models/vuln.go new file mode 100644 index 0000000..9326a40 --- /dev/null +++ b/server/internal/models/vuln.go @@ -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"` +} diff --git a/server/internal/services/migrate_instance.go b/server/internal/services/migrate_instance.go index defe65f..a6b7d5e 100644 --- a/server/internal/services/migrate_instance.go +++ b/server/internal/services/migrate_instance.go @@ -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 diff --git a/server/internal/services/vulnindexes.go b/server/internal/services/vulnindexes.go new file mode 100644 index 0000000..ffdf950 --- /dev/null +++ b/server/internal/services/vulnindexes.go @@ -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 +}