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:
parent
3134afecd2
commit
c7c1601f59
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 '[]'
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user