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
+129
View File
@@ -0,0 +1,129 @@
package vulndb
import (
"strings"
trivydb "github.com/aquasecurity/trivy-db/pkg/db"
trivytypes "github.com/aquasecurity/trivy-db/pkg/types"
)
// Advisory is one fixed-version statement for one source package.
type Advisory struct {
CVEID string
// FixedVersion empty means no vendor fix has been published. It is a real
// state, not an absence of data, and callers must treat it as vulnerable.
FixedVersion string
Severity string
}
// VulnInfo is the CVE's own metadata, shared across every server it affects.
type VulnInfo struct {
Title string
Severity string
CVSSScore float64
References []string
}
// Store reads a pulled trivy-db.
type Store struct {
cfg trivydb.Config
}
// Open opens the database in dir. trivy-db expects the directory, not the file:
// it appends "trivy.db" itself.
func Open(dir string) (*Store, error) {
if err := trivydb.Init(dir); err != nil {
return nil, err
}
return &Store{cfg: trivydb.Config{}}, nil
}
func (s *Store) Close() error { return trivydb.Close() }
// Advisories returns every advisory for a source package in a bucket.
func (s *Store) Advisories(bucket, srcName string) ([]Advisory, error) {
raw, err := s.cfg.GetAdvisories(bucket, srcName)
if err != nil {
return nil, err
}
out := make([]Advisory, 0, len(raw))
for _, a := range raw {
out = append(out, Advisory{
CVEID: a.VulnerabilityID,
FixedVersion: a.FixedVersion,
// Advisory.Severity is trivy's numeric Severity type, unlike
// Vulnerability.Severity which is a string. They are genuinely
// different types in trivy-db, not an inconsistency here.
Severity: severityFromLevel(a.Severity),
})
}
return out, nil
}
// Vulnerability returns a CVE's shared metadata.
func (s *Store) Vulnerability(cveID string) (VulnInfo, error) {
v, err := s.cfg.GetVulnerability(cveID)
if err != nil {
return VulnInfo{}, err
}
return VulnInfo{
Title: v.Title,
Severity: resolveSeverity(v),
CVSSScore: topCVSS(v),
References: v.References,
}, nil
}
// resolveSeverity picks a CVE's severity: vendor, then NVD, then unknown.
//
// Never invented. This will surface as "why is this critical CVE marked low":
// Debian and Red Hat routinely downgrade an NVD score because the vulnerable
// path is not reachable in their build, and their rating is the accurate one
// for that package. Where several vendors disagree the highest wins, because
// under-reporting a vulnerability is the worse mistake.
func resolveSeverity(v trivytypes.Vulnerability) string {
best := 0
for _, sev := range v.VendorSeverity {
if int(sev) > best {
best = int(sev)
}
}
if best > 0 {
return severityFromLevel(trivytypes.Severity(best))
}
// Vulnerability.Severity is the deprecated NVD-derived string. Used only as
// the fallback, which is exactly what it is good for.
if s := strings.ToLower(strings.TrimSpace(v.Severity)); s != "" && s != "unknown" {
return s
}
return "unknown"
}
// topCVSS returns the highest V3 score any source published, or 0.
func topCVSS(v trivytypes.Vulnerability) float64 {
var top float64
for _, c := range v.CVSS {
if c.V3Score > top {
top = c.V3Score
}
}
return top
}
// severityFromLevel maps trivy-db's numeric severity onto our lowercase
// strings. The names are fixed by models.Severity* and must stay in step.
func severityFromLevel(n trivytypes.Severity) string {
switch int(n) {
case 4:
return "critical"
case 3:
return "high"
case 2:
return "medium"
case 1:
return "low"
default:
return "unknown"
}
}
+81
View File
@@ -0,0 +1,81 @@
package vulndb
import (
"fmt"
"log"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
)
// AdvisorySource is the advisory lookup the matcher needs. *Store satisfies it.
// The seam keeps the matching logic independent of how the database is opened.
type AdvisorySource interface {
Advisories(bucket, srcName string) ([]Advisory, error)
}
// Result is one vulnerable package on one server, before it becomes a finding.
type Result struct {
CVEID string
PackageName string // the BINARY package, which is what is installed
Installed string
FixedIn string
Severity string
}
// Match returns every advisory that the installed packages do not satisfy.
//
// Vulnerable means: no fix has been published, or the installed version sorts
// strictly before the fixed version under the distribution's own ordering.
// Equal is NOT vulnerable — that is the backported-fix case, where a
// distribution patches in place without changing the upstream version, and
// treating it as vulnerable reports a patched fleet as exposed.
func Match(src AdvisorySource, os models.OSRelease, pkgs []models.InstalledPackage) ([]Result, error) {
bucket, err := Bucket(os.Family, os.VersionID)
if err != nil {
return nil, err
}
var out []Result
for _, p := range pkgs {
// Debian and Ubuntu advisories are keyed on the source package: one
// advisory against "openssl" covers libssl3, openssl and libssl-dev.
srcName := p.SourceName
if srcName == "" {
srcName = p.Name
}
advs, err := src.Advisories(bucket, srcName)
if err != nil {
return nil, fmt.Errorf("advisories for %s: %w", srcName, err)
}
for _, a := range advs {
// No published fix. Vulnerable, and the finding most in need of
// acceptance, since there is nothing to patch.
if a.FixedVersion == "" {
out = append(out, Result{
CVEID: a.CVEID, PackageName: p.Name,
Installed: p.Version, Severity: a.Severity,
})
continue
}
older, err := LessThan(os.Family, p.Version, a.FixedVersion)
if err != nil {
// Skip this one advisory rather than failing the whole server:
// one unparseable version must not blind us to every other CVE
// on the host. Log it — a silent skip is a silent false
// negative, which is the direction that hurts.
log.Printf("vulndb: compare %s %s vs %s: %v", p.Name, p.Version, a.FixedVersion, err)
continue
}
if older {
out = append(out, Result{
CVEID: a.CVEID, PackageName: p.Name,
Installed: p.Version, FixedIn: a.FixedVersion, Severity: a.Severity,
})
}
}
}
return out, nil
}
+186
View File
@@ -0,0 +1,186 @@
package vulndb
import (
"archive/tar"
"compress/gzip"
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"oras.land/oras-go/v2"
"oras.land/oras-go/v2/registry"
"oras.land/oras-go/v2/registry/remote"
)
// DefaultRef is the published trivy-db OCI artifact, rebuilt every six hours.
const DefaultRef = "ghcr.io/aquasecurity/trivy-db:2"
// SupportedSchema is the trivy-db schema version this code understands.
//
// A different version is refused rather than parsed on the assumption it is
// close enough. Mis-reading the schema would not fail loudly — it would return
// no advisories, which is indistinguishable from a clean fleet.
const SupportedSchema = 2
// dbFileName and metaFileName are the two files inside the artifact layer.
const (
dbFileName = "trivy.db"
metaFileName = "metadata.json"
)
// Ref returns the artifact reference, honouring VANTAGE_TRIVY_DB_REF so an
// air-gapped deployment can mirror the artifact into its own registry, and so
// a busy deployment can avoid the anonymous ghcr rate limit.
func Ref() string {
if v := os.Getenv("VANTAGE_TRIVY_DB_REF"); v != "" {
return v
}
return DefaultRef
}
// Disabled reports whether the puller and scheduler are switched off entirely.
// Findings already written are still served, and still marked stale.
func Disabled() bool {
return strings.EqualFold(os.Getenv("VANTAGE_VULNDB_DISABLED"), "true")
}
// dbMetadata is the subset of trivy-db's metadata.json we read.
type dbMetadata struct {
Version int `json:"Version"`
}
// Pull fetches the trivy-db artifact into dir and returns its schema version.
//
// It extracts into a staging directory and only moves the files into place once
// both are present and the schema has been accepted. A pull that fails partway
// therefore leaves the previous database untouched rather than a half-written
// one that Open would happily accept and scan against.
func Pull(ctx context.Context, dir string) (int, error) {
ref := Ref()
parsed, err := registry.ParseReference(ref)
if err != nil {
return 0, fmt.Errorf("parse reference %q: %w", ref, err)
}
repo, err := remote.NewRepository(ref)
if err != nil {
return 0, fmt.Errorf("open repository %q: %w", ref, err)
}
// The tag or digest half of the reference; the repository already knows the
// registry and path.
target := parsed.Reference
if target == "" {
target = "latest"
}
_, manifestBytes, err := oras.FetchBytes(ctx, repo, target, oras.DefaultFetchBytesOptions)
if err != nil {
return 0, fmt.Errorf("fetch manifest %s: %w", ref, err)
}
var man ocispec.Manifest
if err := json.Unmarshal(manifestBytes, &man); err != nil {
return 0, fmt.Errorf("decode manifest %s: %w", ref, err)
}
if len(man.Layers) == 0 {
return 0, fmt.Errorf("artifact %s has no layers", ref)
}
// Streamed rather than buffered: the layer is ~50MB and there is no reason
// to hold it in memory on the way to disk.
rc, err := repo.Blobs().Fetch(ctx, man.Layers[0])
if err != nil {
return 0, fmt.Errorf("fetch layer: %w", err)
}
defer rc.Close()
staging, err := os.MkdirTemp(dir, ".staging-")
if err != nil {
return 0, fmt.Errorf("staging dir: %w", err)
}
defer os.RemoveAll(staging)
if err := extractTarGz(rc, staging); err != nil {
return 0, fmt.Errorf("extract layer: %w", err)
}
metaBytes, err := os.ReadFile(filepath.Join(staging, metaFileName))
if err != nil {
return 0, fmt.Errorf("read %s: %w", metaFileName, err)
}
var meta dbMetadata
if err := json.Unmarshal(metaBytes, &meta); err != nil {
return 0, fmt.Errorf("decode %s: %w", metaFileName, err)
}
if meta.Version != SupportedSchema {
return 0, fmt.Errorf("trivy-db schema %d is not supported (want %d)", meta.Version, SupportedSchema)
}
if _, err := os.Stat(filepath.Join(staging, dbFileName)); err != nil {
return 0, fmt.Errorf("artifact has no %s: %w", dbFileName, err)
}
// Both files present and the schema accepted, so it is safe to replace.
for _, name := range []string{dbFileName, metaFileName} {
src := filepath.Join(staging, name)
dst := filepath.Join(dir, name)
if err := os.Remove(dst); err != nil && !os.IsNotExist(err) {
return 0, fmt.Errorf("remove old %s: %w", name, err)
}
if err := os.Rename(src, dst); err != nil {
return 0, fmt.Errorf("install %s: %w", name, err)
}
}
return meta.Version, nil
}
// extractTarGz writes the artifact layer into dir. Paths are flattened and
// checked so a crafted archive cannot write outside dir.
func extractTarGz(r io.Reader, dir string) error {
gz, err := gzip.NewReader(r)
if err != nil {
return err
}
defer gz.Close()
tr := tar.NewReader(gz)
for {
hdr, err := tr.Next()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
if hdr.Typeflag != tar.TypeReg {
continue
}
name := filepath.Base(hdr.Name) // flatten; the archive is two files
if name == "." || name == ".." || name == "" {
continue
}
dst := filepath.Join(dir, name)
if !strings.HasPrefix(dst, filepath.Clean(dir)+string(os.PathSeparator)) {
return fmt.Errorf("archive entry escapes destination: %q", hdr.Name)
}
f, err := os.Create(dst)
if err != nil {
return err
}
if _, err := io.Copy(f, tr); err != nil {
f.Close()
return err
}
if err := f.Close(); err != nil {
return err
}
}
}