diff --git a/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js b/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js index e20e46b..6690776 100644 --- a/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js +++ b/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js @@ -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; diff --git a/containers/libreportal/frontend/components/updater/js/updater-page.js b/containers/libreportal/frontend/components/updater/js/updater-page.js index 960a6f7..1bc8de4 100644 --- a/containers/libreportal/frontend/components/updater/js/updater-page.js +++ b/containers/libreportal/frontend/components/updater/js/updater-page.js @@ -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' + ? `
πŸ›‘οΈ Trivy is installed and downloading its vulnerability database β€” CVE results will fill in here automatically shortly.
` + : (st === 'absent' + ? `
πŸ›‘οΈ CVE scanning is off β€” add Trivy from the App Center to see vulnerabilities here.
` + : ''); const card = (hue, big, label, sub, action) => `
${big}
@@ -299,8 +320,7 @@ class UpdaterPage {
${card('updates', c.updatesAvailable, 'Updates available', c.updatesAvailable ? 'across your apps' : "you're current", ``)} - ${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, ``)} ${card('setup', c.improvements, 'Improvements', c.improvements ? 'signed hotfixes to apply' : 'nothing pending', ``)} @@ -309,7 +329,8 @@ class UpdaterPage { ${card('system', c.apps, 'Apps tracked', `last scan: ${checked}`, ``)}
- ${this.updates ? '' : `
No scan data yet β€” showing your installed apps. The first automatic scan runs within a couple of minutes, or hit Check now.
`}`; + ${this.updates ? '' : `
No scan data yet β€” showing your installed apps. The first automatic scan runs within a couple of minutes, or hit Check now.
`} + ${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. diff --git a/containers/trivy/scripts/trivy_scan.sh b/containers/trivy/scripts/trivy_scan.sh new file mode 100644 index 0000000..e7ee58c --- /dev/null +++ b/containers/trivy/scripts/trivy_scan.sh @@ -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 β€” 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 '[]' +} diff --git a/scripts/source/files/arrays/function_manifest.sh b/scripts/source/files/arrays/function_manifest.sh index 5b9fc16..81cfb4d 100644 --- a/scripts/source/files/arrays/function_manifest.sh +++ b/scripts/source/files/arrays/function_manifest.sh @@ -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 "$@"; } diff --git a/scripts/webui/data/generators/updater/webui_updater_scan.sh b/scripts/webui/data/generators/updater/webui_updater_scan.sh index 4d46633..015a295 100644 --- a/scripts/webui/data/generators/updater/webui_updater_scan.sh +++ b/scripts/webui/data/generators/updater/webui_updater_scan.sh @@ -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" <