From c7c1601f59035a4198ad106b53da7790197c02b8 Mon Sep 17 00:00:00 2001 From: librelad Date: Sat, 18 Jul 2026 23:16:35 +0100 Subject: [PATCH] feat(updater): triage the Security CVE list into actionable groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../components/updater/css/updater.css | 38 +++++++++- .../components/updater/js/updater-page.js | 69 ++++++++++++++++--- containers/trivy/scripts/trivy_scan.sh | 28 +++++--- 3 files changed, 117 insertions(+), 18 deletions(-) diff --git a/containers/libreportal/frontend/components/updater/css/updater.css b/containers/libreportal/frontend/components/updater/css/updater.css index c1ac38f..f353072 100644 --- a/containers/libreportal/frontend/components/updater/css/updater.css +++ b/containers/libreportal/frontend/components/updater/css/updater.css @@ -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 { diff --git a/containers/libreportal/frontend/components/updater/js/updater-page.js b/containers/libreportal/frontend/components/updater/js/updater-page.js index 0f03fcc..f34c8b6 100644 --- a/containers/libreportal/frontend/components/updater/js/updater-page.js +++ b/containers/libreportal/frontend/components/updater/js/updater-page.js @@ -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 => ` -
+ // 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) + ? `${this.escape(c.installed || '?')} ${this.escape(c.fixed_in)}` + : `no fix yet`; + return `
${this.escape((c.severity || '').toUpperCase())} ${this.escape(c.id || 'CVE')} ${this.escape(c.package || '')} - ${c.fixed_in ? `fixed in ${this.escape(c.fixed_in)}` : ''} -
`).join(''); - return `
${items}
`; + ${origin ? `${this.escape(origin)}` : ''} + ${ver} +
`; + } + + 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 + ? `
${title}${rows.length}${ + hint ? `${hint}` : ''}
${rows.map((c) => this.cveRow(c)).join('')}
` + : ''; + 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 `
${body}
`; } // The CVE scanner's live state, stamped on cves.json by the updater generator: @@ -494,9 +539,17 @@ class UpdaterPage {
${badge} ${cur}${avail ? ` ${avail}` : ''}
`; } 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 + ? `

Vulnerabilities in the packages bundled inside this app's image — not ${appLabel}${verLabel} itself.

+

${fixN} with a fix available · ${noFixN} no fix yet

` + : ''; const security = `

Security${ cves.length ? ` ${cves.length}` : ''}

${ - cves.length ? `
${this.renderCveList(cves)}
` : '

No known CVEs. 🎉

'}
`; + cves.length ? `${secIntro}
${this.renderCveList(cves)}
` : '

No known CVEs. 🎉

'}`; // 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 diff --git a/containers/trivy/scripts/trivy_scan.sh b/containers/trivy/scripts/trivy_scan.sh index ad1a205..a09c242 100644 --- a/containers/trivy/scripts/trivy_scan.sh +++ b/containers/trivy/scripts/trivy_scan.sh @@ -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 '[]' }