feat(updater): surface Trivy CVE scanner state (installed / DB updating / ready)
Trivy runs as a server whose vulnerability DB downloads on first boot; until it
lands no scan can produce results. Previously the updater generator wrote an
empty-but-valid cves.json the moment the file was missing, so installing Trivy
painted a green "no known vulnerabilities" all-clear that was actually a lie —
the DB hadn't even downloaded, and the Updates/Security view gave no signal.
Add an honest scanner state the WebUI branches on:
- containers/trivy/scripts/trivy_scan.sh — trivyScannerState (absent |
db_updating | ready) via `trivy version -f json`, trivyDbUpdatedAt, and
trivyScanImageCves (per-image scan normalized to {id,severity,package,
installed,fixed_in,url}, deduped). All degrade safely on error.
- webui_updater_scan.sh — stamp cves.json with scanner.state; only run real
per-image scans once the DB is ready. Always rewritten so state tracks live.
- updater-page.js — Security tab shows a loading box while the DB updates, an
install nudge when absent, and the genuine 🎉 only when ready+empty; Overview
CVE card sub + hint reflect the state.
- overview-manager.js — fleet Security row surfaces the "building CVE database"
pending state instead of silently omitting.
- function_manifest.sh — regenerated for the new trivy_scan.sh functions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ee64880293
commit
abd8e0b68b
@ -407,8 +407,17 @@ class OverviewManager {
|
||||
});
|
||||
}
|
||||
|
||||
// Security (needs scan data — without it the updates row already says so)
|
||||
if (scanned) {
|
||||
// Security. The CVE scanner (Trivy) may be installed but still downloading
|
||||
// its vulnerability DB — surface that pending state instead of silently
|
||||
// omitting the row (which would read as "no CVEs").
|
||||
const scannerState = up && up.scannerState ? up.scannerState() : null;
|
||||
if (scannerState === 'db_updating') {
|
||||
rows.push({
|
||||
hue: 'verify', icon: '🛡️', kind: 'none',
|
||||
text: 'Security — building CVE database',
|
||||
sub: 'Trivy is downloading its vulnerability database; results appear here shortly.',
|
||||
});
|
||||
} else if (scanned) {
|
||||
const sev = u.cveTotals || {};
|
||||
if (u.totalCves) {
|
||||
const hit = up.apps.filter((a) => (a.cves || []).length).length;
|
||||
|
||||
@ -183,6 +183,16 @@ class UpdaterPage {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The CVE scanner's live state, stamped on cves.json by the updater generator:
|
||||
// 'ready' — Trivy's vulnerability DB is present; results are real
|
||||
// 'db_updating' — Trivy is installed but still downloading its DB (no
|
||||
// results yet — do NOT paint a green all-clear)
|
||||
// 'absent' — Trivy isn't installed/running (CVE scanning unavailable)
|
||||
// null before the first scan file exists.
|
||||
scannerState() {
|
||||
return (this.cves && this.cves.scanner && this.cves.scanner.state) || null;
|
||||
}
|
||||
|
||||
// ---- derived counts ------------------------------------------------------
|
||||
|
||||
counts() {
|
||||
@ -288,6 +298,17 @@ class UpdaterPage {
|
||||
const c = this.counts();
|
||||
const sev = c.cveTotals;
|
||||
const checked = c.lastChecked ? this.fmtRel(c.lastChecked) : 'never';
|
||||
const st = this.scannerState();
|
||||
// The CVE card's sub-line reflects the scanner state so an empty count reads
|
||||
// honestly (updating vs not-installed vs genuinely clean).
|
||||
const cveSub = st === 'db_updating' ? 'database updating…'
|
||||
: st === 'absent' ? 'scanner not installed'
|
||||
: (sev.critical || sev.high ? `${sev.critical || 0} critical · ${sev.high || 0} high` : 'no high-severity issues');
|
||||
const scanHint = st === 'db_updating'
|
||||
? `<div class="updater-hint">🛡️ Trivy is installed and downloading its vulnerability database — CVE results will fill in here automatically shortly.</div>`
|
||||
: (st === 'absent'
|
||||
? `<div class="updater-hint">🛡️ CVE scanning is off — add <strong>Trivy</strong> from the App Center to see vulnerabilities here.</div>`
|
||||
: '');
|
||||
const card = (hue, big, label, sub, action) => `
|
||||
<div class="updater-stat" style="--page: var(--page-${hue}); --page-rgb: var(--page-${hue}-rgb);">
|
||||
<div class="updater-stat-big">${big}</div>
|
||||
@ -299,8 +320,7 @@ class UpdaterPage {
|
||||
<div class="updater-stat-grid">
|
||||
${card('updates', c.updatesAvailable, 'Updates available', c.updatesAvailable ? 'across your apps' : "you're current",
|
||||
`<button class="updater-btn updater-btn-primary" data-updater-action="goto" data-tab="updates">Review</button>`)}
|
||||
${card('verify', c.totalCves, 'Known CVEs',
|
||||
sev.critical || sev.high ? `${sev.critical || 0} critical · ${sev.high || 0} high` : 'no high-severity issues',
|
||||
${card('verify', c.totalCves, 'Known CVEs', cveSub,
|
||||
`<button class="updater-btn" data-updater-action="goto" data-tab="security">View</button>`)}
|
||||
${card('setup', c.improvements, 'Improvements', c.improvements ? 'signed hotfixes to apply' : 'nothing pending',
|
||||
`<button class="updater-btn" data-updater-action="goto" data-tab="improvements">View</button>`)}
|
||||
@ -309,7 +329,8 @@ class UpdaterPage {
|
||||
${card('system', c.apps, 'Apps tracked', `last scan: ${checked}`,
|
||||
`<button class="updater-btn" data-updater-action="check">Check now</button>`)}
|
||||
</div>
|
||||
${this.updates ? '' : `<div class="updater-hint">No scan data yet — showing your installed apps. The first automatic scan runs within a couple of minutes, or hit <strong>Check now</strong>.</div>`}`;
|
||||
${this.updates ? '' : `<div class="updater-hint">No scan data yet — showing your installed apps. The first automatic scan runs within a couple of minutes, or hit <strong>Check now</strong>.</div>`}
|
||||
${scanHint}`;
|
||||
}
|
||||
|
||||
renderUpdates() {
|
||||
@ -379,6 +400,18 @@ class UpdaterPage {
|
||||
|
||||
renderSecurity() {
|
||||
const withCves = this.apps.filter(a => (a.cves || []).length);
|
||||
// Scanner-state gates come first: an empty CVE list means very different
|
||||
// things depending on whether the scanner has even run yet. Painting a green
|
||||
// "no vulnerabilities" all-clear while Trivy's DB is still downloading would
|
||||
// be a false all-clear, so that state gets its own loading UI.
|
||||
const st = this.scannerState();
|
||||
if (st === 'db_updating') {
|
||||
const msg = 'Trivy is installed and downloading its vulnerability database. CVE results appear here automatically once it finishes — usually a minute or two.';
|
||||
return (window.lpLoadingBox && window.lpLoadingBox(msg)) || this.empty(msg);
|
||||
}
|
||||
if (st === 'absent') {
|
||||
return this.empty('CVE scanning isn’t set up yet. Add Trivy (the vulnerability scanner) from the App Center to check your app images for known CVEs — it scans entirely on your box.');
|
||||
}
|
||||
// No inline Check button: the host auto-scan runs the vulnerability scan on
|
||||
// its own within a couple of minutes (and the embedding header carries a
|
||||
// manual Check), so the message alone is the right button-free empty UI.
|
||||
|
||||
74
containers/trivy/scripts/trivy_scan.sh
Normal file
74
containers/trivy/scripts/trivy_scan.sh
Normal file
@ -0,0 +1,74 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Trivy CVE scanner integration — probed by the WebUI updater generator.
|
||||
# ---------------------------------------------------------------------------
|
||||
# Trivy (containers/trivy) runs as a long-lived server whose vulnerability DB
|
||||
# is cached in ./trivy-cache. On first start — and periodically after — that DB
|
||||
# is downloaded (tens of MB). Until it lands NO scan can produce results, so the
|
||||
# WebUI must not paint a green "no known vulnerabilities" all-clear yet.
|
||||
#
|
||||
# These helpers expose the scanner's state so the updater generator can write an
|
||||
# honest cves.json:
|
||||
# absent — Trivy isn't installed/running (CVE scanning unavailable)
|
||||
# db_updating — installed, but the vulnerability DB is still downloading
|
||||
# ready — DB present; real per-image scans can run
|
||||
# Every helper degrades to the safe answer (absent / [] ) on any error so the
|
||||
# generator never breaks.
|
||||
|
||||
# Echoes: absent | db_updating | ready
|
||||
trivyScannerState() {
|
||||
# Not in `docker ps` (never installed, stopped, or still starting) -> absent.
|
||||
dockerCommandRun "docker ps --format '{{.Names}}'" 2>/dev/null \
|
||||
| grep -qx trivy-service || { echo absent; return; }
|
||||
|
||||
# Running: the DB is ready once `trivy version` reports a VulnerabilityDB
|
||||
# block (null/absent while it's still being fetched on first boot).
|
||||
local ver; ver="$(dockerCommandRun "docker exec trivy-service trivy version -f json" 2>/dev/null)"
|
||||
[ -n "$ver" ] || { echo db_updating; return; }
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
[ -n "$(printf '%s' "$ver" | jq -r '.VulnerabilityDB // empty' 2>/dev/null)" ] \
|
||||
&& echo ready || echo db_updating
|
||||
else
|
||||
# No jq: DownloadedAt only appears once the DB is present (and, unlike a
|
||||
# nullable VulnerabilityDB key, avoids a false positive when it's null).
|
||||
printf '%s' "$ver" | grep -q '"DownloadedAt"' \
|
||||
&& echo ready || echo db_updating
|
||||
fi
|
||||
}
|
||||
|
||||
# Echoes the DB's UpdatedAt timestamp (ISO8601) if available, else nothing.
|
||||
trivyDbUpdatedAt() {
|
||||
command -v jq >/dev/null 2>&1 || return 0
|
||||
local ver; ver="$(dockerCommandRun "docker exec trivy-service trivy version -f json" 2>/dev/null)"
|
||||
[ -n "$ver" ] || return 0
|
||||
printf '%s' "$ver" | jq -r '.VulnerabilityDB.UpdatedAt // empty' 2>/dev/null
|
||||
}
|
||||
|
||||
# trivyScanImageCves <image> — scan one image against the cached DB and echo a
|
||||
# JSON array of normalized CVE objects for the WebUI:
|
||||
# [ { id, severity, package, installed, fixed_in, url }, ... ]
|
||||
# Echoes [] on any failure so a single bad image never aborts the whole scan.
|
||||
# Requires jq (the generator already guards on it).
|
||||
trivyScanImageCves() {
|
||||
local image="$1"
|
||||
[ -n "$image" ] || { echo '[]'; return; }
|
||||
command -v jq >/dev/null 2>&1 || { echo '[]'; return; }
|
||||
|
||||
# Scan runs inside the server container, reusing its cached DB. --quiet keeps
|
||||
# the progress spinner out of stdout; we only want CRITICAL..LOW findings.
|
||||
local raw
|
||||
raw="$(dockerCommandRun "docker exec trivy-service trivy image --quiet --scanners vuln --format json --severity CRITICAL,HIGH,MEDIUM,LOW '$image'" 2>/dev/null)"
|
||||
[ -n "$raw" ] || { echo '[]'; return; }
|
||||
|
||||
printf '%s' "$raw" | jq -c '
|
||||
[ (.Results // [])[] | (.Vulnerabilities // [])[] | {
|
||||
id: .VulnerabilityID,
|
||||
severity: ((.Severity // "UNKNOWN") | ascii_downcase),
|
||||
package: .PkgName,
|
||||
installed: (.InstalledVersion // ""),
|
||||
fixed_in: (.FixedVersion // ""),
|
||||
url: (.PrimaryURL // "")
|
||||
} ]
|
||||
| unique_by(.id + "|" + (.package // ""))
|
||||
' 2>/dev/null || echo '[]'
|
||||
}
|
||||
@ -891,6 +891,9 @@ declare -gA LP_FN_MAP=(
|
||||
[traefikSetupLoginCredentials]="network/traefik/traefik_login_credentials.sh"
|
||||
[traefikUpdateWhitelist]="network/traefik/traefik_whitelist.sh"
|
||||
[trilium_install_post_start]="trilium/scripts/trilium_install_hooks.sh"
|
||||
[trivyDbUpdatedAt]="trivy/scripts/trivy_scan.sh"
|
||||
[trivyScanImageCves]="trivy/scripts/trivy_scan.sh"
|
||||
[trivyScannerState]="trivy/scripts/trivy_scan.sh"
|
||||
[unbound_install_post_compose]="unbound/scripts/unbound_install_hooks.sh"
|
||||
[uninstallCrowdsec]="crowdsec/crowdsec.sh"
|
||||
[uninstallDockerRootless]="docker/install/rootless/rootless_uninstall.sh"
|
||||
@ -1873,6 +1876,9 @@ declare -gA LP_FN_ROOT=(
|
||||
[traefikSetupLoginCredentials]="scripts"
|
||||
[traefikUpdateWhitelist]="scripts"
|
||||
[trilium_install_post_start]="containers"
|
||||
[trivyDbUpdatedAt]="containers"
|
||||
[trivyScanImageCves]="containers"
|
||||
[trivyScannerState]="containers"
|
||||
[unbound_install_post_compose]="containers"
|
||||
[uninstallCrowdsec]="containers"
|
||||
[uninstallDockerRootless]="scripts"
|
||||
@ -2888,6 +2894,9 @@ traefikSetupLabelsMiddlewares() { unset -f traefikSetupLabelsMiddlewares; __lpAu
|
||||
traefikSetupLoginCredentials() { unset -f traefikSetupLoginCredentials; __lpAutoload "${install_scripts_dir}network/traefik/traefik_login_credentials.sh"; traefikSetupLoginCredentials "$@"; }
|
||||
traefikUpdateWhitelist() { unset -f traefikUpdateWhitelist; __lpAutoload "${install_scripts_dir}network/traefik/traefik_whitelist.sh"; traefikUpdateWhitelist "$@"; }
|
||||
trilium_install_post_start() { unset -f trilium_install_post_start; __lpAutoload "${install_containers_dir}trilium/scripts/trilium_install_hooks.sh"; trilium_install_post_start "$@"; }
|
||||
trivyDbUpdatedAt() { unset -f trivyDbUpdatedAt; __lpAutoload "${install_containers_dir}trivy/scripts/trivy_scan.sh"; trivyDbUpdatedAt "$@"; }
|
||||
trivyScanImageCves() { unset -f trivyScanImageCves; __lpAutoload "${install_containers_dir}trivy/scripts/trivy_scan.sh"; trivyScanImageCves "$@"; }
|
||||
trivyScannerState() { unset -f trivyScannerState; __lpAutoload "${install_containers_dir}trivy/scripts/trivy_scan.sh"; trivyScannerState "$@"; }
|
||||
unbound_install_post_compose() { unset -f unbound_install_post_compose; __lpAutoload "${install_containers_dir}unbound/scripts/unbound_install_hooks.sh"; unbound_install_post_compose "$@"; }
|
||||
uninstallCrowdsec() { unset -f uninstallCrowdsec; __lpAutoload "${install_containers_dir}crowdsec/crowdsec.sh"; uninstallCrowdsec "$@"; }
|
||||
uninstallDockerRootless() { unset -f uninstallDockerRootless; __lpAutoload "${install_scripts_dir}docker/install/rootless/rootless_uninstall.sh"; uninstallDockerRootless "$@"; }
|
||||
|
||||
@ -79,14 +79,49 @@ EOF
|
||||
runFileWrite "$out_dir/updates.json" < "$tmp"
|
||||
rm -f "$tmp"
|
||||
|
||||
# CVE data — pluggable. Wire trivy/grype per image here and emit per-app
|
||||
# cves[]. Honest empty-but-valid default until a scanner is configured.
|
||||
if [ ! -f "$out_dir/cves.json" ]; then
|
||||
local ctmp; ctmp="$(mktemp)"
|
||||
printf '{ "generated_at": "%s", "apps": [], "totals": { "critical": 0, "high": 0, "medium": 0, "low": 0 } }\n' "$now" > "$ctmp"
|
||||
runFileWrite "$out_dir/cves.json" < "$ctmp"
|
||||
rm -f "$ctmp"
|
||||
# CVE data via Trivy (containers/trivy/scripts/trivy_scan.sh). Trivy runs as
|
||||
# a server whose vulnerability DB downloads on first boot; until it lands we
|
||||
# must NOT emit a green empty all-clear. We stamp cves.json with the scanner
|
||||
# STATE the WebUI branches on (absent | db_updating | ready) and only run the
|
||||
# real per-image scans once the DB is ready. Always rewritten so the state
|
||||
# tracks the live scanner. Honest empty-but-valid on any gap.
|
||||
local scanner_state="absent" db_updated=""
|
||||
if declare -f trivyScannerState >/dev/null 2>&1; then
|
||||
scanner_state="$(trivyScannerState 2>/dev/null || echo absent)"
|
||||
[ "$scanner_state" = "ready" ] && db_updated="$(trivyDbUpdatedAt 2>/dev/null)"
|
||||
fi
|
||||
|
||||
local cve_apps="" cfirst=1 tc=0 th=0 tm=0 tl=0
|
||||
if [ "$scanner_state" = "ready" ] && command -v jq >/dev/null 2>&1 \
|
||||
&& declare -f trivyScanImageCves >/dev/null 2>&1; then
|
||||
for app in "${apps[@]}"; do
|
||||
local cimg="" ccompose="$containers_dir/$app/docker-compose.yml"
|
||||
[ -f "$ccompose" ] && cimg="$(grep -m1 -E '^\s*image:' "$ccompose" 2>/dev/null | sed -E 's/^\s*image:\s*//; s/["'"'"']//g')"
|
||||
[ -n "$cimg" ] || continue
|
||||
local cvej; cvej="$(trivyScanImageCves "$cimg")"
|
||||
[ -z "$cvej" ] || [ "$cvej" = "[]" ] && continue
|
||||
tc=$((tc + $(printf '%s' "$cvej" | jq '[.[]|select(.severity=="critical")]|length' 2>/dev/null || echo 0)))
|
||||
th=$((th + $(printf '%s' "$cvej" | jq '[.[]|select(.severity=="high")]|length' 2>/dev/null || echo 0)))
|
||||
tm=$((tm + $(printf '%s' "$cvej" | jq '[.[]|select(.severity=="medium")]|length' 2>/dev/null || echo 0)))
|
||||
tl=$((tl + $(printf '%s' "$cvej" | jq '[.[]|select(.severity=="low")]|length' 2>/dev/null || echo 0)))
|
||||
[ $cfirst -eq 0 ] && cve_apps+=","
|
||||
cfirst=0
|
||||
cve_apps+=$(printf '\n { "name": "%s", "displayName": "%s", "cves": %s }' \
|
||||
"$(printf '%s' "$app" | sed 's/"/\\"/g')" \
|
||||
"$(printf '%s' "$app" | sed 's/"/\\"/g')" "$cvej")
|
||||
done
|
||||
fi
|
||||
|
||||
local ctmp; ctmp="$(mktemp)"
|
||||
cat > "$ctmp" <<EOF
|
||||
{ "generated_at": "$now",
|
||||
"scanner": { "name": "trivy", "state": "$scanner_state", "db_updated_at": "$db_updated" },
|
||||
"apps": [${cve_apps}
|
||||
],
|
||||
"totals": { "critical": $tc, "high": $th, "medium": $tm, "low": $tl } }
|
||||
EOF
|
||||
runFileWrite "$out_dir/cves.json" < "$ctmp"
|
||||
rm -f "$ctmp"
|
||||
# Ensure a valid (possibly empty) history file exists for the WebUI.
|
||||
if [ ! -f "$out_dir/history.json" ]; then
|
||||
local htmp; htmp="$(mktemp)"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user