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
@@ -0,0 +1,13 @@
{{define "title"}}New vulnerabilities detected{{end}}
{{define "pill"}}{{template "chip" (dict "label" (upper .TopSeverity) "tone" "down")}}{{end}}
{{define "body"}}
{{template "lead" .Summary}}
{{template "rows" (list
(dict "k" "Instance" "v" .InstanceName)
(dict "k" "New findings" "v" .Count))}}
{{range .Rows}}
{{if .FixedIn}}{{template "well" (printf "%s (%s) — %s on %s, fixed in %s" .CVEID .Severity .PackageName .ServerName .FixedIn)}}{{else}}{{template "well" (printf "%s (%s) — %s on %s, no fix published" .CVEID .Severity .PackageName .ServerName)}}{{end}}
{{end}}
{{if .More}}{{template "p" (printf "…and %d more." .More)}}{{end}}
{{template "note" (printf "Scanned against a vulnerability database pulled %s ago." .DBAge)}}
{{end}}
@@ -0,0 +1,12 @@
{{define "subject"}}{{.Count}} new {{if eq .Count 1}}vulnerability{{else}}vulnerabilities{{end}} on {{.InstanceName}}{{end}}
{{define "title"}}New vulnerabilities detected{{end}}
{{define "pill"}}{{.TopSeverity}}{{end}}
{{define "body"}}
{{template "lead" .Summary}}
{{range .Rows}}- {{.CVEID}} ({{.Severity}}) — {{.PackageName}} on {{.ServerName}}{{if .FixedIn}}, fixed in {{.FixedIn}}{{else}}, no fix published{{end}}
{{end}}
{{if .More}}...and {{.More}} more.{{end}}
Scanned against vulnerability database pulled {{.DBAge}} ago.
{{end}}
+41
View File
@@ -0,0 +1,41 @@
package mail
// VulnDigestRow is one newly opened finding as the digest shows it.
//
// It lives here rather than in server/ so the templates and the caller agree on
// the fields without server's model package leaking into shared.
type VulnDigestRow struct {
CVEID string
Severity string
PackageName string
ServerName string
// FixedIn empty means no vendor fix has been published, which the template
// says explicitly rather than leaving blank — it is a real state, not
// missing data.
FixedIn string
}
// VulnDigest is one batch of newly opened findings.
//
// One message per rule per scan, never one per finding: a database refresh can
// open several hundred at once, and one message each would rate-limit the
// webhook or get the channel muted.
type VulnDigest struct {
InstanceName string
// Count is every newly opened finding in the batch, which may exceed
// len(Rows) — Rows is capped and More carries the remainder.
Count int
TopSeverity string
Summary string
Rows []VulnDigestRow
More int
// DBAge is pre-formatted by the caller. A digest scanned against a
// three-week-old database must say so rather than quietly imply freshness.
DBAge string
}
// SendVulnDigest delivers one digest to an SMTP notification channel's
// recipients, which may be a comma-separated list.
func (s Sender) SendVulnDigest(to string, d VulnDigest) error {
return s.sendTemplate(to, "", "vuln_digest", d)
}
+5
View File
@@ -34,6 +34,11 @@ type Settings struct {
// absent as disabled — turning off password login for the entire fleet at
// upgrade. Nil means enabled.
LocalLoginEnabled *bool `bson:"local_login_enabled,omitempty" json:"local_login_enabled,omitempty"`
// VulnFindingRetentionDays is a pointer for the same reason
// WorkflowLogRetentionDays is: absent must mean the default, not zero.
// Nil is 90 days, 0 is forever. Only "fixed" findings are ever swept.
VulnFindingRetentionDays *int `bson:"vuln_finding_retention_days,omitempty" json:"vuln_finding_retention_days,omitempty"`
}
// LocalLoginEnabled reads the setting with its absent-means-on default. Every