From 18c2982accdf5efbe5510cf95561b7eb323e92ea Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Thu, 6 Aug 2026 14:33:46 +0100 Subject: [PATCH] feat: vulnerability scanning pipeline, matcher, scheduler and API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- mail/templates/vuln_digest.html.tmpl | 13 +++++++++ mail/templates/vuln_digest.txt.tmpl | 12 ++++++++ mail/vuln.go | 41 ++++++++++++++++++++++++++++ models/settings.go | 5 ++++ 4 files changed, 71 insertions(+) create mode 100644 mail/templates/vuln_digest.html.tmpl create mode 100644 mail/templates/vuln_digest.txt.tmpl create mode 100644 mail/vuln.go diff --git a/mail/templates/vuln_digest.html.tmpl b/mail/templates/vuln_digest.html.tmpl new file mode 100644 index 0000000..9d2679e --- /dev/null +++ b/mail/templates/vuln_digest.html.tmpl @@ -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}} diff --git a/mail/templates/vuln_digest.txt.tmpl b/mail/templates/vuln_digest.txt.tmpl new file mode 100644 index 0000000..086c803 --- /dev/null +++ b/mail/templates/vuln_digest.txt.tmpl @@ -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}} \ No newline at end of file diff --git a/mail/vuln.go b/mail/vuln.go new file mode 100644 index 0000000..a82215d --- /dev/null +++ b/mail/vuln.go @@ -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) +} diff --git a/models/settings.go b/models/settings.go index 9ea06b2..4b2dc75 100644 --- a/models/settings.go +++ b/models/settings.go @@ -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