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.
This commit is contained in:
2026-08-06 14:33:46 +01:00
parent db64320bd8
commit 5dda3b5c4a
18 changed files with 1962 additions and 1 deletions
+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)
}
}
}
+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
}