fix(updater): honest CVE framing — key actionability on a real update

The Security triage implied 'fix available · update the app to clear these'
even when the app was already on the newest image — where updating does
nothing and the patch only lands when the maintainer rebuilds. That read as a
false to-do. Reframe honestly:

- Status line keyed on update_available, not Trivy's fixed_in: up to date =>
  'nothing to apply; clears when the maintainer ships a rebuilt image'; update
  available => 'updating may pull in patched packages'.
- Groups relabeled to describe reality: 'Patch released upstream' (hint: lands
  on rebuild / may be cleared by updating) and 'No patch yet'. Counts go
  neutral so a big number doesn't read as either alarm or all-clear.
- Row 'no fix yet' -> 'no patch'; use getAppDisplayName so it's 'Trivy' not
  'trivy'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
librelad 2026-07-18 23:34:20 +01:00
parent 59098667a1
commit d72f84a9cf
2 changed files with 37 additions and 24 deletions

View File

@ -108,29 +108,33 @@
border: 1px solid rgba(var(--text-rgb), 0.10);
}
/* 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); }
/* Explainer + status above the CVE box: name what these CVEs actually are
(image dependencies, not the app version) and tell the user honestly whether
there's anything they can do keyed on a real app update, not a package
patch that only lands when the maintainer rebuilds. */
.updater-cve-explain { margin: 0 0 5px; font-size: 0.8rem; color: rgba(var(--text-rgb), 0.6); }
.updater-cve-status { margin: 0 0 10px; font-size: 0.8rem; 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. */
/* Triage groups inside the box: "Patch released upstream" leads, the dimmed
"No patch yet" pile follows. The count sits at the row's right edge. */
.updater-cve-group + .updater-cve-group { margin-top: 2px; }
.updater-cve-group-head {
display: flex; align-items: center; gap: 8px;
display: flex; align-items: center; gap: 6px;
padding: 10px 0 6px;
}
.updater-cve-group-title {
font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em;
color: rgba(var(--text-rgb), 0.72);
}
.updater-cve-group-hint { font-size: 0.72rem; font-weight: 500; color: rgba(var(--text-rgb), 0.45); }
.updater-cve-group-hint::before { content: '·'; margin-right: 6px; opacity: 0.55; }
.updater-cve-group-n {
margin-left: auto;
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));
background: rgba(var(--text-rgb), 0.12); color: rgba(var(--text-rgb), 0.7);
}
.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-group-n { background: rgba(var(--text-rgb), 0.08); color: rgba(var(--text-rgb), 0.55); }
.updater-cve-group.is-nofix .updater-cve { opacity: 0.72; }
.updater-cve-group .updater-cve:first-of-type { border-top: 0; }

View File

@ -226,7 +226,7 @@ class UpdaterPage {
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>`;
: `<span class="updater-cve-ver updater-cve-ver-none">no patch</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>
@ -236,16 +236,20 @@ class UpdaterPage {
</div>`;
}
renderCveList(cves) {
renderCveList(cves, opts = {}) {
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));
// The "patched upstream" pile is only user-actionable when an app update
// exists — otherwise the patch just waits for the maintainer's rebuild.
const fixHint = opts.updateAvailable ? 'may be cleared by updating' : 'lands when the image is rebuilt';
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>`
? `<div class="updater-cve-group${mod}"><div class="updater-cve-group-head"><span class="updater-cve-group-title">${title}</span>${
hint ? `<span class="updater-cve-group-hint">${hint}</span>` : ''}<span class="updater-cve-group-n">${rows.length}</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);
const body = group('Patch released upstream', fixHint, '', fixable)
+ group('No patch yet', 'waiting on upstream', ' is-nofix', noFix);
return `<div class="updater-cve-scroll${list.length > 6 ? ' is-scrollable' : ''}">${body}</div>`;
}
@ -484,7 +488,7 @@ class UpdaterPage {
if (!this.cves) return this.empty('No vulnerability scan yet — one runs automatically within a couple of minutes.');
if (!withCves.length) return this.empty('No known vulnerabilities in your installed apps. 🎉');
const blocks = withCves.map(a =>
`<div class="updater-cve-app"><div class="updater-cve-app-name">${this.escape(a.displayName)} <span class="updater-badge sev-${a.worstSeverity}">${(a.cves || []).length}</span></div>${this.renderCveList(a.cves)}</div>`
`<div class="updater-cve-app"><div class="updater-cve-app-name">${this.escape(a.displayName)} <span class="updater-badge sev-${a.worstSeverity}">${(a.cves || []).length}</span></div>${this.renderCveList(a.cves, { updateAvailable: a.update_available })}</div>`
).join('');
return `<div class="updater-list">${blocks}</div>`;
}
@ -539,17 +543,22 @@ 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 appLabel = this.escape((window.getAppDisplayName ? window.getAppDisplayName(a.name) : null) || a.displayName || a.name || 'the app');
const verLabel = a.current_version ? ` ${this.escape(a.current_version)}` : '';
// Frame actionability by whether an app update actually EXISTS — not by
// Trivy's fixed_in. An upstream package patch does the user no good while
// they're already on the newest image; it only lands when the maintainer
// rebuilds. Telling them to "update to clear these" in that state is a lie.
const statusLine = a.update_available
? 'An update is available — installing it (a snapshot is taken first) may pull in patched packages.'
: `You're on the latest published version, so there's nothing to apply. These clear when ${appLabel}'s maintainer ships a rebuilt image.`;
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>`
<p class="updater-cve-status">${statusLine}</p>`
: '';
const security = `<div class="updater-detail-section"><h4>Security${
cves.length ? ` <span class="updater-cve-count">${cves.length}</span>` : ''}</h4>${
cves.length ? `${secIntro}<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, { updateAvailable: a.update_available })}</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