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:
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user