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.
This commit is contained in:
2026-08-06 11:59:00 +01:00
parent c277ecff44
commit 3a6d24fe0e
5 changed files with 230 additions and 0 deletions
+4
View File
@@ -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 {
+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"`
}
@@ -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
+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
}