feat(updater): triage the Security CVE list into actionable groups

A user rightly noted the Security section read as a wall of unrelated
dependency CVEs against an 'Up to date' app — no cue for what, if anything,
to do. Make it answer 'is this my problem, and will updating fix it?':

- Scanner (trivy_scan.sh): stop discarding Trivy's Class/Type/Status at the
  jq flatten — bind them onto each vuln so the UI can tell an OS package from
  the app's own bundled dependency, and a real fix from a won't-fix.
- Security section (updater-page.js): explain these are vulnerabilities in the
  packages bundled in the image (not the app version), tally 'N with a fix ·
  M no fix yet', then split the list into a 'Fix available' group (worst-first,
  each row tagged OS/dependency and showing installed -> fixed) and a dimmed
  'No fix yet' group. No fabricated 'this update fixes N' claim — fixed_in vs
  the image tag isn't a reliable join, so we only state fix availability.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
librelad 2026-07-18 23:16:35 +01:00
parent 3134afecd2
commit c7c1601f59
3 changed files with 117 additions and 18 deletions

View File

@ -86,7 +86,16 @@
.updater-cve-id { font-family: var(--font-mono); color: rgb(var(--page-rgb, var(--accent-rgb))); text-decoration: none; }
.updater-cve-id:hover { text-decoration: underline; }
.updater-cve-pkg { color: rgba(var(--text-rgb), 0.6); }
.updater-cve-fix { margin-left: auto; color: rgba(var(--page-verify-rgb), 0.9); font-size: 0.76rem; }
.updater-cve-origin {
font-size: 0.64rem; font-weight: 600; letter-spacing: .02em; white-space: nowrap;
padding: 1px 7px; border-radius: 999px;
color: rgba(var(--text-rgb), 0.7);
background: rgba(var(--text-rgb), 0.07);
border: 1px solid rgba(var(--text-rgb), 0.10);
}
.updater-cve-ver { margin-left: auto; color: rgba(var(--page-verify-rgb), 0.95); font-size: 0.76rem; white-space: nowrap; }
.updater-cve-ver .updater-arrow { color: rgba(var(--text-rgb), 0.4); }
.updater-cve-ver-none { color: rgba(var(--text-rgb), 0.4); font-style: italic; }
/* Security CVEs live in their own inset dark panel so the list reads as a
contained block with breathing room on both sides matching the app rows
@ -98,7 +107,32 @@
background: rgba(10, 16, 32, 0.45);
border: 1px solid rgba(var(--text-rgb), 0.10);
}
.updater-cve-box .updater-cve:first-child { border-top: 0; }
/* Explainer + tally above the CVE box: name what these CVEs actually are
(image dependencies, not the app version) and triage them at a glance. */
.updater-cve-explain { margin: 0 0 6px; font-size: 0.8rem; color: rgba(var(--text-rgb), 0.6); }
.updater-cve-tally { margin: 0 0 10px; font-size: 0.8rem; color: rgba(var(--text-rgb), 0.5); }
.updater-cve-tally-fix { color: rgba(var(--page-verify-rgb), 0.95); font-weight: 600; }
.updater-cve-tally-none { color: rgba(var(--text-rgb), 0.55); }
/* Triage groups inside the box: "Fix available" leads, the dimmed "No fix yet"
pile follows so the actionable CVEs are what the eye lands on first. */
.updater-cve-group + .updater-cve-group { margin-top: 2px; }
.updater-cve-group-head {
display: flex; align-items: center; gap: 8px;
padding: 10px 0 6px;
font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em;
color: rgba(var(--text-rgb), 0.72);
}
.updater-cve-group-n {
font-variant-numeric: tabular-nums; font-weight: 700; font-size: 0.64rem;
padding: 1px 7px; border-radius: 999px;
background: rgba(var(--page-verify-rgb), 0.18); color: rgb(var(--page-verify-rgb));
}
.updater-cve-group-hint { text-transform: none; letter-spacing: 0; font-weight: 500; font-size: 0.72rem; color: rgba(var(--text-rgb), 0.45); }
.updater-cve-group.is-nofix .updater-cve-group-n { background: rgba(var(--text-rgb), 0.10); color: rgba(var(--text-rgb), 0.6); }
.updater-cve-group.is-nofix .updater-cve { opacity: 0.72; }
.updater-cve-group .updater-cve:first-of-type { border-top: 0; }
/* Count pill next to the "Security" detail heading (e.g. "Security 28"). */
.updater-cve-count {

View File

@ -192,16 +192,61 @@ class UpdaterPage {
// Sorted worst-first (so the most severe are visible before any scroll) and
// wrapped in a height-capped scroll box once the list is long, so an app with
// dozens of CVEs (e.g. 28) stays compact instead of pushing the page down.
renderCveList(cves) {
const list = [...(cves || [])].sort((x, y) => this.sevRank(x.severity) - this.sevRank(y.severity));
const items = list.map(c => `
<div class="updater-cve sev-${(c.severity || 'low').toLowerCase()}">
// A CVE is actionable when upstream ships a fixed version AND hasn't flagged
// it won't-fix / EOL — those are the ones an image rebuild (i.e. updating the
// app) can actually clear. The rest are noise the user can only wait on.
cveHasFix(c) {
const st = ((c && c.status) || '').toLowerCase();
return !!(c && c.fixed_in) && st !== 'will_not_fix' && st !== 'end_of_life';
}
// Human label for where a CVE lives: an OS package baked into the base image
// vs one of the app's own bundled dependencies (Go module, npm, …). Sourced
// from Trivy's Class/Type; empty when the scan predates capturing them (so we
// show no tag rather than a wrong one).
cveOrigin(c) {
const cls = ((c && c.class) || '').toLowerCase();
const ty = ((c && c.type) || '').toLowerCase();
if (cls === 'os-pkgs') return 'OS package';
if (cls === 'lang-pkgs') {
if (/go/.test(ty)) return 'Go dependency';
if (/(node|npm|yarn|pnpm)/.test(ty)) return 'npm dependency';
if (/(python|pip|poetry|conda)/.test(ty)) return 'Python dependency';
if (/(gem|bundler|ruby)/.test(ty)) return 'Ruby dependency';
if (/(cargo|rust)/.test(ty)) return 'Rust dependency';
if (/(jar|pom|gradle|java)/.test(ty)) return 'Java dependency';
if (/(composer|php)/.test(ty)) return 'PHP dependency';
return 'App dependency';
}
return '';
}
cveRow(c) {
const sev = (c.severity || 'low').toLowerCase();
const origin = this.cveOrigin(c);
const ver = this.cveHasFix(c)
? `<span class="updater-cve-ver">${this.escape(c.installed || '?')} <span class="updater-arrow">→</span> <strong>${this.escape(c.fixed_in)}</strong></span>`
: `<span class="updater-cve-ver updater-cve-ver-none">no fix yet</span>`;
return `<div class="updater-cve sev-${sev}">
<span class="updater-cve-sev">${this.escape((c.severity || '').toUpperCase())}</span>
<a class="updater-cve-id" href="${this.escape(c.url || ('https://nvd.nist.gov/vuln/detail/' + (c.id || '')))}" target="_blank" rel="noopener">${this.escape(c.id || 'CVE')}</a>
<span class="updater-cve-pkg">${this.escape(c.package || '')}</span>
${c.fixed_in ? `<span class="updater-cve-fix">fixed in ${this.escape(c.fixed_in)}</span>` : ''}
</div>`).join('');
return `<div class="updater-cve-scroll${list.length > 6 ? ' is-scrollable' : ''}">${items}</div>`;
${origin ? `<span class="updater-cve-origin">${this.escape(origin)}</span>` : ''}
${ver}
</div>`;
}
renderCveList(cves) {
const list = [...(cves || [])].sort((x, y) => this.sevRank(x.severity) - this.sevRank(y.severity));
const fixable = list.filter((c) => this.cveHasFix(c));
const noFix = list.filter((c) => !this.cveHasFix(c));
const group = (title, hint, mod, rows) => rows.length
? `<div class="updater-cve-group${mod}"><div class="updater-cve-group-head">${title}<span class="updater-cve-group-n">${rows.length}</span>${
hint ? `<span class="updater-cve-group-hint">${hint}</span>` : ''}</div>${rows.map((c) => this.cveRow(c)).join('')}</div>`
: '';
const body = group('Fix available', 'update the app to clear these', '', fixable)
+ group('No fix yet', 'waiting on an upstream patch', ' is-nofix', noFix);
return `<div class="updater-cve-scroll${list.length > 6 ? ' is-scrollable' : ''}">${body}</div>`;
}
// The CVE scanner's live state, stamped on cves.json by the updater generator:
@ -494,9 +539,17 @@ class UpdaterPage {
<div class="updater-detail-row">${badge} <span class="updater-row-ver">${cur}${avail ? ` <span class="updater-arrow">→</span> <strong>${avail}</strong>` : ''}</span></div></div>`;
}
const cves = a.cves || [];
const fixN = cves.filter((c) => this.cveHasFix(c)).length;
const noFixN = cves.length - fixN;
const appLabel = this.escape(a.displayName || a.name || 'the app');
const verLabel = a.current_version ? ` ${this.escape(a.current_version)}` : '';
const secIntro = cves.length
? `<p class="updater-cve-explain">Vulnerabilities in the packages bundled inside this app's image — not ${appLabel}${verLabel} itself.</p>
<p class="updater-cve-tally"><span class="updater-cve-tally-fix">${fixN} with a fix available</span> · <span class="updater-cve-tally-none">${noFixN} no fix yet</span></p>`
: '';
const security = `<div class="updater-detail-section"><h4>Security${
cves.length ? ` <span class="updater-cve-count">${cves.length}</span>` : ''}</h4>${
cves.length ? `<div class="updater-cve-box">${this.renderCveList(cves)}</div>` : '<p class="updater-detail-empty">No known CVEs. 🎉</p>'}</div>`;
cves.length ? `${secIntro}<div class="updater-cve-box">${this.renderCveList(cves)}</div>` : '<p class="updater-detail-empty">No known CVEs. 🎉</p>'}</div>`;
// A rollback target exists if a snapshot field is present (future-proofing)
// OR — the data the generator actually emits today — this app has a prior

View File

@ -73,15 +73,27 @@ trivyScanImageCves() {
raw="$(dockerCommandRun "docker exec $envs trivy-service trivy image --server http://localhost:4954 --image-src docker --quiet --scanners vuln --format json --severity CRITICAL,HIGH,MEDIUM,LOW '$image'" 2>/dev/null)"
[ -n "$raw" ] || { echo '[]'; return; }
# Carry the enclosing Result's Class/Type down onto each vuln: Class
# (os-pkgs|lang-pkgs) is what separates an OS-package CVE from an app's own
# bundled dependency (Go module, etc.), and Status (fixed|will_not_fix|…)
# separates "a fix exists" from "upstream won't fix" — both drive the
# triaged Security view. Without binding them before the Vulnerabilities[]
# flatten they'd be lost.
printf '%s' "$raw" | jq -c '
[ (.Results // [])[] | (.Vulnerabilities // [])[] | {
id: .VulnerabilityID,
severity: ((.Severity // "UNKNOWN") | ascii_downcase),
package: .PkgName,
installed: (.InstalledVersion // ""),
fixed_in: (.FixedVersion // ""),
url: (.PrimaryURL // "")
} ]
[ (.Results // [])[]
| (.Class // "") as $class
| (.Type // "") as $type
| (.Vulnerabilities // [])[] | {
id: .VulnerabilityID,
severity: ((.Severity // "UNKNOWN") | ascii_downcase),
package: .PkgName,
installed: (.InstalledVersion // ""),
fixed_in: (.FixedVersion // ""),
status: (.Status // ""),
class: $class,
type: $type,
url: (.PrimaryURL // "")
} ]
| unique_by(.id + "|" + (.package // ""))
' 2>/dev/null || echo '[]'
}