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'
+ ? `
${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" <