Compare commits

...
7 Commits
Author SHA1 Message Date
mrhid6 78f1bf853c fix: More fixes to vuln matching
Chart Release / chart (push) Successful in 31s
Server Deploy / deploy (push) Successful in 1m42s
2026-08-07 13:29:15 +01:00
mrhid6 e28238191d feat: Added vuln filter
Chart Release / chart (push) Successful in 25s
Server Deploy / deploy (push) Successful in 2m29s
2026-08-07 11:58:42 +01:00
mrhid6 82bcc5776f fix: Fixed vuln scanning
Chart Release / chart (push) Successful in 15s
Server Deploy / deploy (push) Successful in 4m7s
2026-08-07 11:13:58 +01:00
mrhid6 1993802c38 feat: Updated rescan button text 2026-08-07 10:52:16 +01:00
mrhid6 5db49b6b0e feat: Vuln debug logs
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Successful in 1m47s
2026-08-07 10:50:08 +01:00
mrhid6 0c15b25ecd fix: Fixed agent package version
Server Deploy / deploy (push) Successful in 14s
Chart Release / chart (push) Successful in 26s
Agent Release / build (push) Successful in 11m32s
Agent Release / msi (push) Successful in 1m9s
2026-08-07 10:21:00 +01:00
mrhid6 0c21765da3 feat: Added pagination
Chart Release / chart (push) Successful in 13s
Server Deploy / deploy (push) Successful in 1m39s
2026-08-07 09:59:50 +01:00
14 changed files with 477 additions and 66 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ func Collect() (OSRelease, []Package, error) {
switch {
case have("dpkg-query"):
out, err := run(ctx, "dpkg-query", "-W", "-f",
`${Package}\t${Version}\t${Architecture}\t${source:Package}\n`)
`${Package}\t${Version}\t${Architecture}\t${source:Package}\t${db:Status-Status}\n`)
if err != nil {
return osrel, nil, err
}
+14 -1
View File
@@ -20,12 +20,20 @@ type Package struct {
}
// ParseDpkg reads tab-separated output of
// dpkg-query -W -f '${Package}\t${Version}\t${Architecture}\t${source:Package}\n'
// dpkg-query -W -f '${Package}\t${Version}\t${Architecture}\t${source:Package}\t${db:Status-Status}\n'
//
// SourceName is why the fourth column is requested at all: Debian and Ubuntu
// advisories are keyed on the SOURCE package, so one CVE against "openssl"
// covers the binaries libssl3, openssl and libssl-dev. Matching on binary name
// alone finds one of the three.
//
// The fifth column is why "rc" packages do not appear. dpkg-query -W lists
// every package dpkg knows about, including ones removed with their config
// files left behind — a host that has upgraded its kernel a dozen times reports
// a dozen old linux-modules versions that are not on disk, and the oldest of
// them sorts first and reads as the installed version. Only "installed" is
// installed. An empty status means dpkg did not understand the field, in which
// case the line is kept rather than the whole inventory silently vanishing.
func ParseDpkg(out string) []Package {
var pkgs []Package
for _, line := range strings.Split(out, "\n") {
@@ -36,6 +44,11 @@ func ParseDpkg(out string) []Package {
if len(f) < 3 {
continue
}
if len(f) > 4 {
if s := strings.TrimSpace(f[4]); s != "" && s != "installed" {
continue
}
}
p := Package{Name: f[0], Version: f[1], Arch: f[2]}
if len(f) > 3 && f[3] != "" {
p.SourceName = f[3]
+17
View File
@@ -32,6 +32,7 @@ func listVulnerabilities(c *gin.Context) {
State: c.DefaultQuery("state", models.FindingOpen),
ServerID: c.Query("server"),
Tags: tagsFromQuery(c),
HasFix: hasFixFromQuery(c),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -82,6 +83,22 @@ func groupByCVE(findings []models.VulnFinding) []vulnGroup {
return out
}
// hasFixFromQuery reads ?has_fix=true|false. Anything else, including an empty
// or malformed value, is no filter — a filter nobody asked for must never hide
// findings, and the wrong direction here hides the unfixable ones.
func hasFixFromQuery(c *gin.Context) *bool {
switch c.Query("has_fix") {
case "true":
v := true
return &v
case "false":
v := false
return &v
default:
return nil
}
}
// tagsFromQuery reads repeated tag=key:value parameters.
func tagsFromQuery(c *gin.Context) map[string]string {
out := map[string]string{}
+50 -9
View File
@@ -112,6 +112,10 @@ type FindingFilter struct {
State string
ServerID string
Tags map[string]string
// HasFix nil is no filter. true is "a vendor fix exists, this is
// patchable"; false is the unfixable set — remove the package, disable the
// service, or accept it, but do not wait for an update.
HasFix *bool
}
// ListInstanceFindings returns findings across the whole fleet.
@@ -130,6 +134,17 @@ func ListInstanceFindings(instanceID string, f FindingFilter) ([]models.VulnFind
filter["server_id"] = f.ServerID
}
// fixed_in is omitempty, so a finding with no vendor fix carries no such
// field at all rather than an empty string. Both forms must be matched, or
// the unfixable set reads as empty on any document written before this.
if f.HasFix != nil {
if *f.HasFix {
filter["fixed_in"] = bson.M{"$nin": bson.A{"", nil}}
} else {
filter["fixed_in"] = bson.M{"$in": bson.A{"", nil}}
}
}
// The tag selector resolves through ResolveTargets, the single answer to
// which servers a selector touches. A second matcher here could disagree
// with what a workflow means by env:prod.
@@ -207,8 +222,11 @@ func MarkInstanceForRescan(instanceID string) (int64, error) {
bson.M{"$set": bson.M{"scan_pending": true}},
)
if err != nil {
log.Printf("vulnsched: mark rescan for instance %s: %v", instanceID, err)
return 0, err
}
log.Printf("vulnsched: rescan requested for instance %s, %d of %d server(s) flagged",
instanceID, res.ModifiedCount, res.MatchedCount)
return res.ModifiedCount, nil
}
@@ -300,15 +318,33 @@ func ListFindings(ctx context.Context, instanceID, serverID string) ([]models.Vu
func ApplyFindingDiff(ctx context.Context, instanceID, serverID string, d FindingDiff, now time.Time) error {
col := db.Col("vuln_findings")
// One BulkWrite per batch, not one UpdateOne per finding. A freshly scanned
// Ubuntu host opens tens of thousands of findings, and at one round trip each
// that is minutes of sequential latency during which the tick holds the
// leader and every other server waits its turn. Unordered, because the
// upserts are independent and one duplicate-key race must not abandon the
// rest of the batch.
const bulkBatch = 1000
ops := make([]mongo.WriteModel, 0, bulkBatch)
flush := func() error {
if len(ops) == 0 {
return nil
}
_, err := col.BulkWrite(ctx, ops, options.BulkWrite().SetOrdered(false))
ops = ops[:0]
return err
}
for _, f := range d.Upserts {
_, err := col.UpdateOne(ctx,
bson.M{
ops = append(ops, mongo.NewUpdateOneModel().
SetFilter(bson.M{
"instance_id": instanceID,
"server_id": serverID,
"cve_id": f.CVEID,
"package_name": f.PackageName,
},
bson.M{
}).
SetUpdate(bson.M{
"$set": bson.M{
"installed_version": f.Installed,
"fixed_in": f.FixedIn,
@@ -326,13 +362,18 @@ func ApplyFindingDiff(ctx context.Context, instanceID, serverID string, d Findin
"first_seen": f.FirstSeen,
},
"$unset": bson.M{"fixed_at": "", "accepted": ""},
},
options.UpdateOne().SetUpsert(true),
)
if err != nil {
return err
}).
SetUpsert(true))
if len(ops) >= bulkBatch {
if err := flush(); err != nil {
return err
}
}
}
if err := flush(); err != nil {
return err
}
if len(d.FixedIDs) > 0 {
if _, err := col.UpdateMany(ctx,
+9
View File
@@ -1,6 +1,7 @@
package vulndb
import (
"log"
"strings"
trivydb "github.com/aquasecurity/trivy-db/pkg/db"
@@ -14,6 +15,11 @@ type Advisory struct {
// state, not an absence of data, and callers must treat it as vulnerable.
FixedVersion string
Severity string
// Status is filled by trivy-db ONLY when FixedVersion is empty — when there
// is a fix, "fixed" is the obvious state and the field is left zero. It is
// what separates "the vendor confirms this package is affected and has not
// fixed it" from "nobody has looked yet".
Status string
}
// VulnInfo is the CVE's own metadata, shared across every server it affects.
@@ -33,8 +39,10 @@ type Store struct {
// it appends "trivy.db" itself.
func Open(dir string) (*Store, error) {
if err := trivydb.Init(dir); err != nil {
log.Printf("vulndb: open %s: %v", dir, err)
return nil, err
}
log.Printf("vulndb: opened database in %s", dir)
return &Store{cfg: trivydb.Config{}}, nil
}
@@ -55,6 +63,7 @@ func (s *Store) Advisories(bucket, srcName string) ([]Advisory, error) {
// Vulnerability.Severity which is a string. They are genuinely
// different types in trivy-db, not an inconsistency here.
Severity: severityFromLevel(a.Severity),
Status: a.Status.String(),
})
}
return out, nil
+119 -6
View File
@@ -32,11 +32,22 @@ type Result struct {
func Match(src AdvisorySource, os models.OSRelease, pkgs []models.InstalledPackage) ([]Result, error) {
bucket, err := Bucket(os.Family, os.VersionID)
if err != nil {
log.Printf("vulndb: no bucket for family=%q version=%q: %v", os.Family, os.VersionID, err)
return nil, err
}
log.Printf("vulndb: matching %d packages against bucket %q", len(pkgs), bucket)
var advisoryCount, skipped, unactionable, noFix int
// Advisories are keyed on the SOURCE package, and several hundred binary
// packages on a host resolve to the same few hundred sources — linux-modules,
// linux-image and linux-headers all ask about "linux", whose advisory list is
// thousands long. Without this the same bolt read is repeated once per binary
// package, which is most of what made a single Ubuntu host take minutes.
cache := make(map[string][]Advisory, len(pkgs))
var out []Result
for _, p := range pkgs {
for _, p := range newestPerSource(os.Family, pkgs) {
// Debian and Ubuntu advisories are keyed on the source package: one
// advisory against "openssl" covers libssl3, openssl and libssl-dev.
srcName := p.SourceName
@@ -44,15 +55,30 @@ func Match(src AdvisorySource, os models.OSRelease, pkgs []models.InstalledPacka
srcName = p.Name
}
advs, err := src.Advisories(bucket, srcName)
if err != nil {
return nil, fmt.Errorf("advisories for %s: %w", srcName, err)
advs, cached := cache[srcName]
if !cached {
var err error
advs, err = src.Advisories(bucket, srcName)
if err != nil {
return nil, fmt.Errorf("advisories for %s: %w", srcName, err)
}
cache[srcName] = advs
}
advisoryCount += len(advs)
if len(advs) > 0 {
Debugf("%s (src %s, installed %s): %d advisories", p.Name, srcName, p.Version, len(advs))
}
for _, a := range advs {
// No published fix. Vulnerable, and the finding most in need of
// acceptance, since there is nothing to patch.
// No published fix. Whether that is a finding depends entirely on the
// status the vendor attached to it — see actionable().
if a.FixedVersion == "" {
if !actionable(a.Status) {
unactionable++
Debugf("%s (src %s): %s skipped, status %q", p.Name, srcName, a.CVEID, a.Status)
continue
}
noFix++
out = append(out, Result{
CVEID: a.CVEID, PackageName: p.Name,
Installed: p.Version, Severity: a.Severity,
@@ -67,8 +93,10 @@ func Match(src AdvisorySource, os models.OSRelease, pkgs []models.InstalledPacka
// 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)
skipped++
continue
}
Debugf("%s %s vs fixed %s (%s): vulnerable=%t", p.Name, p.Version, a.FixedVersion, a.CVEID, older)
if older {
out = append(out, Result{
CVEID: a.CVEID, PackageName: p.Name,
@@ -77,5 +105,90 @@ func Match(src AdvisorySource, os models.OSRelease, pkgs []models.InstalledPacka
}
}
}
log.Printf("vulndb: bucket %q done: %d packages, %d sources, %d advisories considered, "+
"%d results (%d with no vendor fix), %d skipped as not-yet-triaged, %d unparseable comparisons",
bucket, len(pkgs), len(cache), advisoryCount, len(out), noFix, unactionable, skipped)
return out, nil
}
// newestPerSource collapses the installed set to one binary package per source
// package: the one carrying the highest version.
//
// Advisories are keyed on the source, so every binary package of a source asks
// the same question. Normally they all carry the same version and the answer is
// the same, so collapsing is free. The kernel is the exception that makes it
// necessary: Ubuntu encodes the ABI in the binary name, so an upgrade INSTALLS
// linux-headers-6.8.0-137 beside linux-headers-6.8.0-124 rather than replacing
// it, and the old one lingers until an autoremove. Matched per binary package,
// a fully patched host reports every superseded ABI package as vulnerable —
// which is the noise this exists to stop — and reports it twice over, once for
// linux-headers-6.8.0-124 and again for its -generic sibling.
//
// The version, not the name, decides. There is no kernel special case here: a
// source's newest installed version is what the fix landed as, whatever the
// source is.
//
// A comparison that cannot be made keeps the incumbent rather than guessing;
// the loser is dropped either way, and dropping the parseable one would be the
// false-negative direction.
func newestPerSource(family string, pkgs []models.InstalledPackage) []models.InstalledPackage {
best := make(map[string]models.InstalledPackage, len(pkgs))
order := make([]string, 0, len(pkgs))
for _, p := range pkgs {
src := p.SourceName
if src == "" {
src = p.Name
}
cur, seen := best[src]
if !seen {
best[src] = p
order = append(order, src)
continue
}
older, err := LessThan(family, cur.Version, p.Version)
if err != nil {
log.Printf("vulndb: newest for source %s: compare %s vs %s: %v",
src, cur.Version, p.Version, err)
continue
}
if older {
Debugf("source %s: %s %s supersedes %s %s",
src, p.Name, p.Version, cur.Name, cur.Version)
best[src] = p
}
}
out := make([]models.InstalledPackage, 0, len(order))
for _, src := range order {
out = append(out, best[src])
}
return out
}
// actionable decides whether an advisory with no fixed version is a finding.
//
// trivy-db fills Status only when FixedVersion is empty, and Ubuntu publishes a
// status for every CVE against every source package it ships — the vast
// majority being "under_investigation" (the tracker's needs-triage), meaning
// nobody has yet established that the package is affected at all. Reporting
// those produced ~24,000 findings for a single 797-package host, which is not a
// security report, it is a wall. A "not_affected" is the vendor stating the
// opposite of a finding, so it is never one.
//
// What survives is what the vendor has confirmed: affected, will_not_fix,
// fix_deferred, end_of_life. Those are exactly the findings the CLAUDE.md rule
// is about — an empty fixed_in that means "no fix exists", the one most in need
// of acceptance rather than patching.
// Only the two statuses that positively say "this is not a finding" are
// dropped. "unknown" is kept: a feed that sets no status at all must not become
// a silent false negative, and it is not what generates the noise — Ubuntu
// states under_investigation explicitly.
func actionable(status string) bool {
switch status {
case "not_affected", "under_investigation":
return false
default:
return true
}
}
+29 -1
View File
@@ -7,9 +7,11 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"time"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"oras.land/oras-go/v2"
@@ -49,6 +51,24 @@ func Disabled() bool {
return strings.EqualFold(os.Getenv("VANTAGE_VULNDB_DISABLED"), "true")
}
// DebugEnabled turns on per-package and per-advisory tracing.
//
// It is a switch rather than always-on because a single scan asks the store one
// question per installed package — ~2000 lines per server, per tick — which
// would bury every other subsystem's logs on a fleet of any size. The lifecycle
// logs (pull, tick, per-server totals) are unconditional; only the inner loop
// is gated.
func DebugEnabled() bool {
return strings.EqualFold(os.Getenv("VANTAGE_VULN_DEBUG"), "true")
}
// Debugf logs only when VANTAGE_VULN_DEBUG=true.
func Debugf(format string, args ...any) {
if DebugEnabled() {
log.Printf("vulndb[debug]: "+format, args...)
}
}
// dbMetadata is the subset of trivy-db's metadata.json we read.
type dbMetadata struct {
Version int `json:"Version"`
@@ -62,6 +82,8 @@ type dbMetadata struct {
// one that Open would happily accept and scan against.
func Pull(ctx context.Context, dir string) (int, error) {
ref := Ref()
started := time.Now()
log.Printf("vulndb: pull starting ref=%s dir=%s", ref, dir)
parsed, err := registry.ParseReference(ref)
if err != nil {
@@ -92,6 +114,8 @@ func Pull(ctx context.Context, dir string) (int, error) {
if len(man.Layers) == 0 {
return 0, fmt.Errorf("artifact %s has no layers", ref)
}
log.Printf("vulndb: manifest resolved layers=%d digest=%s size=%dB",
len(man.Layers), man.Layers[0].Digest, man.Layers[0].Size)
// Streamed rather than buffered: the layer is ~50MB and there is no reason
// to hold it in memory on the way to disk.
@@ -110,6 +134,7 @@ func Pull(ctx context.Context, dir string) (int, error) {
if err := extractTarGz(rc, staging); err != nil {
return 0, fmt.Errorf("extract layer: %w", err)
}
log.Printf("vulndb: layer extracted into %s after %s", staging, time.Since(started).Round(time.Millisecond))
metaBytes, err := os.ReadFile(filepath.Join(staging, metaFileName))
if err != nil {
@@ -123,9 +148,11 @@ func Pull(ctx context.Context, dir string) (int, error) {
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 {
fi, err := os.Stat(filepath.Join(staging, dbFileName))
if err != nil {
return 0, fmt.Errorf("artifact has no %s: %w", dbFileName, err)
}
log.Printf("vulndb: %s is %dB, schema %d accepted", dbFileName, fi.Size(), meta.Version)
// Both files present and the schema accepted, so it is safe to replace.
for _, name := range []string{dbFileName, metaFileName} {
@@ -139,6 +166,7 @@ func Pull(ctx context.Context, dir string) (int, error) {
}
}
log.Printf("vulndb: pull complete ref=%s schema=%d in %s", ref, meta.Version, time.Since(started).Round(time.Millisecond))
return meta.Version, nil
}
+33 -1
View File
@@ -51,11 +51,17 @@ func Start(ctx context.Context, deps Deps) {
dir, err := os.MkdirTemp("", "vantage-vulndb-")
if err != nil {
log.Printf("vulnsched: temp dir: %v", err)
// The classic form of this is "stat /tmp: no such file or directory" on
// the scratch runtime image. It is logged once at boot while everything
// else runs normally, so the only other symptom is a fleet that never
// reports a finding.
log.Printf("vulnsched: temp dir: %v (scan loop NOT started)", err)
return
}
s := &scheduler{deps: deps, dir: dir}
log.Printf("vulnsched: started, ref=%s tick=%s dir=%s debug=%t",
vulndb.Ref(), tickInterval, dir, vulndb.DebugEnabled())
go func() {
defer os.RemoveAll(dir)
@@ -66,6 +72,7 @@ func Start(ctx context.Context, deps Deps) {
for {
select {
case <-ctx.Done():
log.Println("vulnsched: leadership lost or shutting down, scan loop stopping")
return
case <-ticker.C:
s.tick(ctx)
@@ -75,16 +82,22 @@ func Start(ctx context.Context, deps Deps) {
}
func (s *scheduler) tick(ctx context.Context) {
started := time.Now()
vulndb.Debugf("vulnsched tick starting (store loaded=%t, db version=%d, pulled %s ago)",
s.store != nil, s.version, time.Since(s.pulled).Round(time.Second))
if err := s.ensureDB(ctx); err != nil {
// Keep the last good database and carry on scanning against it. A
// network blip must never clear findings or read as "all fixed".
log.Printf("vulnsched: database unavailable: %v", err)
s.recordDBError(ctx, err)
if s.store == nil {
log.Println("vulnsched: no database loaded at all, nothing can be scanned this tick")
return
}
}
s.scanPending(ctx)
vulndb.Debugf("vulnsched tick finished in %s", time.Since(started).Round(time.Millisecond))
}
// ensureDB pulls a fresh database when the local copy is stale, and marks the
@@ -93,8 +106,11 @@ func (s *scheduler) tick(ctx context.Context) {
// next agent report.
func (s *scheduler) ensureDB(ctx context.Context) error {
if s.store != nil && time.Since(s.pulled) < dbMaxAge {
vulndb.Debugf("database is %s old, under the %s limit; not pulling",
time.Since(s.pulled).Round(time.Second), dbMaxAge)
return nil
}
log.Printf("vulnsched: pulling database (age %s, max %s)", time.Since(s.pulled).Round(time.Second), dbMaxAge)
version, err := vulndb.Pull(ctx, s.dir)
if err != nil {
@@ -110,6 +126,7 @@ func (s *scheduler) ensureDB(ctx context.Context) error {
s.pulled = time.Now()
changed := version != s.version
log.Printf("vulnsched: database ready, schema %d (previous %d, changed=%t)", version, s.version, changed)
s.version = version
_, _ = db.Col("vulndb_meta").UpdateOne(ctx, bson.M{},
@@ -152,6 +169,12 @@ func (s *scheduler) scanPending(ctx context.Context) {
return
}
if len(pending) == 0 {
vulndb.Debugf("no servers pending scan")
return
}
log.Printf("vulnsched: %d server(s) pending scan", len(pending))
// Newly opened findings are collected across the whole tick and sent as one
// digest per instance. A database refresh can open several hundred findings
// at once; one message per finding would rate-limit the webhook or get the
@@ -182,6 +205,8 @@ func (s *scheduler) scanPending(ctx context.Context) {
func (s *scheduler) scanOne(ctx context.Context, sp models.ServerPackages) []models.VulnFinding {
now := time.Now()
log.Printf("vulnsched: scanning server %s (instance %s, os %s %s, %d packages)",
sp.ServerID, sp.InstanceID, sp.OS.Family, sp.OS.VersionID, len(sp.Packages))
results, err := vulndb.Match(s.store, sp.OS, sp.Packages)
if err != nil {
@@ -193,6 +218,9 @@ func (s *scheduler) scanOne(ctx context.Context, sp models.ServerPackages) []mod
if !errors.Is(err, vulndb.ErrUnsupportedFamily) {
log.Printf("vulnsched: scan %s: %v", sp.ServerID, err)
status = sp.Status
} else {
log.Printf("vulnsched: server %s marked unsupported: no feed for %s %s",
sp.ServerID, sp.OS.Family, sp.OS.VersionID)
}
s.clearPending(ctx, sp.ID, status, now)
return nil
@@ -210,6 +238,10 @@ func (s *scheduler) scanOne(ctx context.Context, sp models.ServerPackages) []mod
return nil
}
log.Printf("vulnsched: server %s scanned: %d matches, %d existing, %d upserts, %d newly opened, %d reopened, %d fixed",
sp.ServerID, len(results), len(existing), len(diff.Upserts),
len(diff.NewlyOpened), len(diff.ReopenIDs), len(diff.FixedIDs))
s.clearPending(ctx, sp.ID, models.ScanStatusOK, now)
for i := range diff.NewlyOpened {
+68 -42
View File
@@ -4,7 +4,7 @@ import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, vulnerabilities, type FindingState, type Severity, type VulnFinding } from "@/lib/api";
import { useAuth } from "@/components/AuthProvider";
import { Button, Card } from "@/components/ui";
import { Button, Card, Pagination, usePagination } from "@/components/ui";
import { AcceptDialog } from "@/components/vulnerabilities/AcceptDialog";
import { DBFreshness } from "@/components/vulnerabilities/DBFreshness";
import { PackageRow } from "@/components/vulnerabilities/PackageRow";
@@ -26,17 +26,33 @@ import { groupByPackage } from "@/lib/vulnPackages";
const STATES: FindingState[] = ["open", "accepted", "fixed"];
/*
* The fix filter. "Unfixable" is not a synonym for "ignorable": those findings
* are the ones whose action is to remove the package, disable the service or
* move off an end-of-life release, and they are invisible in a list sorted for
* patching. Splitting them is what lets the patchable list be worked top to
* bottom without them quietly disappearing.
*/
const FIX_FILTERS: { key: string; label: string; hasFix: boolean | undefined }[] = [
{ key: "all", label: "All", hasFix: undefined },
{ key: "fixable", label: "Fix available", hasFix: true },
{ key: "nofix", label: "No fix", hasFix: false },
];
export default function VulnerabilitiesPage() {
const { isAdmin } = useAuth();
const qc = useQueryClient();
const [state, setState] = useState<FindingState>("open");
const [severity, setSeverity] = useState<Severity | "">("");
const [fixFilter, setFixFilter] = useState("all");
const [accepting, setAccepting] = useState<VulnFinding | null>(null);
const hasFix = FIX_FILTERS.find((f) => f.key === fixFilter)?.hasFix;
const groups = useQuery({
queryKey: ["vulnerabilities", state, severity],
queryFn: () => vulnerabilities.list({ state, severity: severity || undefined }),
queryKey: ["vulnerabilities", state, severity, fixFilter],
queryFn: () => vulnerabilities.list({ state, severity: severity || undefined, hasFix }),
});
const summary = useQuery({
@@ -48,6 +64,10 @@ export default function VulnerabilitiesPage() {
const packages = useMemo(() => groupByPackage(groups.data ?? []), [groups.data]);
// Each package row carries its own findings and servers underneath it, so
// the cost of a full fleet's board is well past the row count alone.
const paged = usePagination(packages, 25);
const serverName = useMemo(() => {
const byId = new Map((servers.data ?? []).map((s) => [s.server_id, s.hostname]));
// Falls back to the raw id rather than an empty cell: an unnamed row is
@@ -95,7 +115,7 @@ export default function VulnerabilitiesPage() {
</div>
{isAdmin && (
<Button variant="secondary" loading={rescan.isPending} onClick={() => rescan.mutate()}>
Rescan fleet
Rescan
</Button>
)}
</div>
@@ -111,11 +131,12 @@ export default function VulnerabilitiesPage() {
{SEVERITY_ORDER.map((s) => (
<button
key={s}
onClick={() => setSeverity(severity === s ? "" : s)}
onClick={() => {
setSeverity(severity === s ? "" : s);
paged.reset();
}}
aria-pressed={severity === s}
className={`flex items-center gap-2 rounded-lg border px-2.5 py-1.5 text-left transition-colors ${
severity === s ? "border-accent bg-surface-2" : "border-transparent hover:bg-surface-2"
}`}
className={`flex items-center gap-2 rounded-lg border px-2.5 py-1.5 text-left transition-colors ${severity === s ? "border-accent bg-surface-2" : "border-transparent hover:bg-surface-2"}`}
>
<SeverityBadge severity={s} />
<span className="font-mono text-lg font-semibold tabular-nums text-text-primary">{counts[s] ?? 0}</span>
@@ -123,26 +144,39 @@ export default function VulnerabilitiesPage() {
))}
</div>
<div className="mb-4 flex gap-2">
<div className="mb-4 flex flex-wrap items-center gap-2">
{STATES.map((s) => (
<button
key={s}
onClick={() => setState(s)}
onClick={() => {
setState(s);
paged.reset();
}}
aria-pressed={state === s}
className={`rounded-lg border px-3 py-1.5 text-sm capitalize transition-colors ${
state === s ? "border-accent text-accent" : "border-border text-text-secondary hover:text-text-primary"
}`}
className={`rounded-lg border px-3 py-1.5 text-sm capitalize transition-colors ${state === s ? "border-accent text-accent" : "border-border text-text-secondary hover:text-text-primary"}`}
>
{s}
</button>
))}
<span aria-hidden className="mx-1 h-5 w-px bg-border" />
{FIX_FILTERS.map((f) => (
<button
key={f.key}
onClick={() => {
setFixFilter(f.key);
paged.reset();
}}
aria-pressed={fixFilter === f.key}
className={`rounded-lg border px-3 py-1.5 text-sm transition-colors ${fixFilter === f.key ? "border-accent text-accent" : "border-border text-text-secondary hover:text-text-primary"}`}
>
{f.label}
</button>
))}
</div>
{groups.error && (
<div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">
{(groups.error as Error).message}
</div>
)}
{groups.error && <div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{(groups.error as Error).message}</div>}
<Card padding={false}>
{groups.isLoading ? (
@@ -150,40 +184,32 @@ export default function VulnerabilitiesPage() {
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
) : packages.length > 0 ? (
packages.map((g) => (
<PackageRow
key={g.package_name}
group={g}
serverName={serverName}
canAct={isAdmin}
onAccept={setAccepting}
onUnaccept={(f) => unaccept.mutate(f.id)}
onApplyUpdates={(serverId) => applyUpdates.mutate(serverId)}
applying={applyUpdates.isPending ? (applyUpdates.variables as string) : undefined}
/>
))
<>
{paged.slice.map((g) => (
<PackageRow key={g.package_name} group={g} serverName={serverName} canAct={isAdmin} onAccept={setAccepting} onUnaccept={(f) => unaccept.mutate(f.id)} onApplyUpdates={(serverId) => applyUpdates.mutate(serverId)} applying={applyUpdates.isPending ? (applyUpdates.variables as string) : undefined} />
))}
<Pagination page={paged.page} pageCount={paged.pageCount} size={paged.size} total={paged.total} onPage={paged.setPage} onSize={paged.setSize} unit="packages" />
</>
) : (
<div className="px-6 py-14 text-center">
<p className="text-[15px] font-semibold text-text-primary">
No {state} findings{severity ? ` at ${severity} severity` : ""}.
No {state} findings{severity ? ` at ${severity} severity` : ""}
{hasFix === true ? " with a fix available" : hasFix === false ? " without a vendor fix" : ""}.
</p>
{/* Named explicitly, because "no findings" under a filter
the reader has forgotten setting reads as a clean
fleet — the one claim this page must never make by
accident. */}
<p className="mx-auto mt-2 max-w-[52ch] text-sm text-text-secondary">
Servers report their packages hourly. A server whose distribution has no advisory feed is reported as unsupported
on its own page rather than counted as clean here.
{hasFix !== undefined
? "This is a filtered view. Switch to All to see every finding in this state."
: "Servers report their packages hourly. A server whose distribution has no advisory feed is reported as unsupported on its own page rather than counted as clean here."}
</p>
</div>
)}
</Card>
{accepting && (
<AcceptDialog
finding={accepting}
serverName={serverName(accepting.server_id)}
pending={accept.isPending}
onClose={() => setAccepting(null)}
onAccept={(reason, until) => accept.mutate({ id: accepting.id, reason, until })}
/>
)}
{accepting && <AcceptDialog finding={accepting} serverName={serverName(accepting.server_id)} pending={accept.isPending} onClose={() => setAccepting(null)} onAccept={(reason, until) => accept.mutate({ id: accepting.id, reason, until })} />}
</div>
);
}
+20 -3
View File
@@ -3,7 +3,7 @@
import { useMemo, useState } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { Badge, Button, Card, Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
import { Badge, Button, Card, Pagination, Table, Thead, Tbody, Tr, Th, Td, usePagination } from "@/components/ui";
import { api, workloads } from "@/lib/api";
/*
@@ -29,6 +29,11 @@ export default function WorkloadsPage() {
return m;
}, [servers.data]);
// A fleet of a few hundred servers reports tens of thousands of workloads;
// the whole set in one table is what freezes the tab.
const rows = useMemo(() => hits.data ?? [], [hits.data]);
const paged = usePagination(rows, 50);
const inputClass =
"w-full rounded border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent focus:outline-none";
@@ -45,6 +50,7 @@ export default function WorkloadsPage() {
onSubmit={(e) => {
e.preventDefault();
setApplied({ image: image.trim(), stack: stack.trim(), state: state.trim() });
paged.reset();
}}
>
<input className={inputClass} placeholder="image (exact)" value={image} onChange={(e) => setImage(e.target.value)} />
@@ -59,9 +65,10 @@ export default function WorkloadsPage() {
<Card padding={false}>
{hits.isLoading ? (
<p className="px-6 py-5 text-sm text-text-secondary">Loading</p>
) : (hits.data ?? []).length === 0 ? (
) : rows.length === 0 ? (
<p className="px-6 py-5 text-sm text-text-secondary">No workloads match.</p>
) : (
<>
<Table>
<Thead>
<Tr>
@@ -74,7 +81,7 @@ export default function WorkloadsPage() {
</Tr>
</Thead>
<Tbody>
{(hits.data ?? []).map((h) => (
{paged.slice.map((h) => (
<Tr key={`${h.server_id}:${h.workload.kind}:${h.workload.id}`}>
<Td>
<Link href={`/servers/${h.server_id}`} className="text-accent hover:underline">
@@ -92,6 +99,16 @@ export default function WorkloadsPage() {
))}
</Tbody>
</Table>
<Pagination
page={paged.page}
pageCount={paged.pageCount}
size={paged.size}
total={paged.total}
onPage={paged.setPage}
onSize={paged.setSize}
unit="workloads"
/>
</>
)}
</Card>
</div>
+111
View File
@@ -0,0 +1,111 @@
"use client";
import { useEffect, useMemo, useState } from "react";
/*
* Client-side pagination.
*
* The fleet endpoints answer with the whole result set, and a few thousand rows
* rendered at once is what locks the tab up. Slicing in the browser is enough:
* the payload was never the problem, the DOM node count was. If a result set
* ever outgrows the response itself, this is the seam a server-side cursor
* would replace.
*/
export const PAGE_SIZES = [25, 50, 100, 200];
export function usePagination<T>(items: T[], initialSize = 50) {
const [page, setPage] = useState(1);
const [size, setSize] = useState(initialSize);
const pageCount = Math.max(1, Math.ceil(items.length / size));
// A filter change shortens the list under a page that no longer exists;
// clamping here rather than in every caller keeps the empty state honest.
useEffect(() => {
if (page > pageCount) setPage(1);
}, [page, pageCount]);
const slice = useMemo(() => {
const start = (page - 1) * size;
return items.slice(start, start + size);
}, [items, page, size]);
return {
slice,
page,
size,
pageCount,
total: items.length,
setPage,
setSize: (n: number) => {
setSize(n);
setPage(1);
},
reset: () => setPage(1),
};
}
export function Pagination({
page,
pageCount,
size,
total,
onPage,
onSize,
unit = "rows",
}: {
page: number;
pageCount: number;
size: number;
total: number;
onPage: (n: number) => void;
onSize: (n: number) => void;
unit?: string;
}) {
if (total === 0) return null;
const first = (page - 1) * size + 1;
const last = Math.min(page * size, total);
return (
<div className="flex flex-col gap-3 border-t border-border px-4 py-3 text-sm text-text-secondary sm:flex-row sm:items-center sm:justify-between sm:px-6">
<span className="tabular-nums">
{first}{last} of {total} {unit}
</span>
<div className="flex items-center gap-2">
<select
aria-label="Rows per page"
value={size}
onChange={(e) => onSize(Number(e.target.value))}
className="rounded border border-border bg-surface-2 px-2 py-1 text-sm text-text-primary focus:border-accent focus:outline-none"
>
{PAGE_SIZES.map((n) => (
<option key={n} value={n}>
{n} / page
</option>
))}
</select>
<button
onClick={() => onPage(page - 1)}
disabled={page <= 1}
className="rounded border border-border px-2.5 py-1 text-text-secondary transition-colors hover:text-text-primary disabled:opacity-40 disabled:hover:text-text-secondary"
>
Previous
</button>
<span className="tabular-nums">
{page} / {pageCount}
</span>
<button
onClick={() => onPage(page + 1)}
disabled={page >= pageCount}
className="rounded border border-border px-2.5 py-1 text-text-secondary transition-colors hover:text-text-primary disabled:opacity-40 disabled:hover:text-text-secondary"
>
Next
</button>
</div>
</div>
);
}
+1
View File
@@ -3,3 +3,4 @@ export { Badge } from "./Badge";
export { Card, CardHeader, CardTitle } from "./Card";
export { Table, Thead, Tbody, Tr, Th, Td } from "./Table";
export { Modal } from "./Modal";
export { Pagination, usePagination, PAGE_SIZES } from "./Pagination";
+4 -1
View File
@@ -996,11 +996,14 @@ export interface VulnAlertRuleInput {
}
export const vulnerabilities = {
list(params?: { severity?: string; state?: string; server?: string; tags?: Record<string, string> }): Promise<VulnGroup[]> {
list(params?: { severity?: string; state?: string; server?: string; hasFix?: boolean; tags?: Record<string, string> }): Promise<VulnGroup[]> {
const q = new URLSearchParams();
if (params?.severity) q.set("severity", params.severity);
if (params?.state) q.set("state", params.state);
if (params?.server) q.set("server", params.server);
// Explicitly undefined-checked: `false` is a real selection here (the
// unfixable set), so a truthiness test would silently drop it.
if (params?.hasFix !== undefined) q.set("has_fix", String(params.hasFix));
for (const [k, v] of Object.entries(params?.tags ?? {})) q.append("tag", `${k}:${v}`);
const qs = q.toString();
return request<VulnGroup[]>(`/vulnerabilities${qs ? `?${qs}` : ""}`);
File diff suppressed because one or more lines are too long