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