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
+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
}