Compare commits

...
2 Commits
Author SHA1 Message Date
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
4 changed files with 101 additions and 61 deletions
+32 -9
View File
@@ -303,15 +303,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,
@@ -329,13 +347,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,
+6
View File
@@ -15,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.
@@ -58,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
+55 -8
View File
@@ -37,7 +37,15 @@ func Match(src AdvisorySource, os models.OSRelease, pkgs []models.InstalledPacka
}
log.Printf("vulndb: matching %d packages against bucket %q", len(pkgs), bucket)
var advisoryCount, skipped int
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 {
// Debian and Ubuntu advisories are keyed on the source package: one
@@ -47,9 +55,14 @@ 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 {
@@ -57,9 +70,15 @@ func Match(src AdvisorySource, os models.OSRelease, pkgs []models.InstalledPacka
}
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,
@@ -86,7 +105,35 @@ func Match(src AdvisorySource, os models.OSRelease, pkgs []models.InstalledPacka
}
}
}
log.Printf("vulndb: bucket %q done: %d packages, %d advisories considered, %d results, %d unparseable comparisons",
bucket, len(pkgs), advisoryCount, len(out), skipped)
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
}
// 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
}
}
+8 -44
View File
@@ -99,7 +99,7 @@ export default function VulnerabilitiesPage() {
</div>
{isAdmin && (
<Button variant="secondary" loading={rescan.isPending} onClick={() => rescan.mutate()}>
Rescan fleet
Rescan
</Button>
)}
</div>
@@ -120,9 +120,7 @@ export default function VulnerabilitiesPage() {
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>
@@ -139,20 +137,14 @@ export default function VulnerabilitiesPage() {
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>
))}
</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 ? (
@@ -162,49 +154,21 @@ export default function VulnerabilitiesPage() {
) : packages.length > 0 ? (
<>
{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}
/>
<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"
/>
<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` : ""}.
</p>
<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.
</p>
<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.</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>
);
}