fix(security): harden Trivy scan lifecycle, logging, and docs (#639)

* fix(security): harden Trivy scan lifecycle, logging, and docs

- Call TrivyService.initialize() at startup so capability state is
  accurate before first request; add periodic re-detect to the scheduler
  so newly installed Trivy binaries light up without a restart.
- Add markStaleScansAsFailed sweep (+ idx_vuln_scans_status index) to
  recover any scan row left in_progress after a crash or timeout; sweep
  runs before the paid-tier gate so every tier self-heals.
- Split scanImage persistence into beginScan/finishScan so the manual
  scan route owns a single code path and can return a scanId synchronously
  while work continues asynchronously.
- Validate image refs on /api/security/scan and /sbom via new utility;
  defense-in-depth against shell-metacharacter payloads.
- Dispatch a warning-level alert when a post-deploy scan fails so the
  operator has a user-visible path to the failure instead of a silent log.
- Share DIGEST_CACHE_TTL_MS and severity ordering across service and
  route layers; remove dead invalidateDetection().
- Add [Trivy:diag] logging gated behind developer_mode for support
  diagnostics; production logs unchanged.
- Frontend: defensive toast fallback chain, sr-only SheetDescription,
  and a truncation badge when the 500-item detail fetch is capped.
- Tests: extend trivy-service and vulnerability-db suites; add
  image-ref and severity unit tests.
- Docs: expand vulnerability-scanning troubleshooting with recovery,
  re-detect, and diagnostic-log guidance; link Dockerfile comment to
  trivy-setup.

* fix(security): drop unnecessary escape in image-ref forbidden-char regex
This commit is contained in:
Anso
2026-04-16 20:32:38 -04:00
committed by GitHub
parent f8eb1b4e88
commit dc8370f5a4
16 changed files with 563 additions and 140 deletions
+2 -2
View File
@@ -623,8 +623,8 @@ export default function ResourcesView() {
}
throw new Error('Scan timed out');
} catch (error) {
const err = error as { message?: string };
toast.error(err?.message || 'Scan failed');
const err = error as { message?: string; error?: string; data?: { error?: string } };
toast.error(err?.message || err?.error || err?.data?.error || 'Scan failed');
} finally {
toast.dismiss(loadingId);
setScanningImageRef(null);
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Button } from '@/components/ui/button';
import {
@@ -75,18 +75,21 @@ export function VulnerabilityScanSheet({
}: VulnerabilityScanSheetProps) {
const [scan, setScan] = useState<VulnerabilityScan | null>(null);
const [details, setDetails] = useState<VulnerabilityDetail[]>([]);
const [totalDetails, setTotalDetails] = useState(0);
const [loading, setLoading] = useState(false);
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>('ALL');
const [page, setPage] = useState(0);
const [downloadingSbom, setDownloadingSbom] = useState(false);
const DETAIL_FETCH_LIMIT = 500;
const load = useCallback(async () => {
if (scanId == null) return;
setLoading(true);
try {
const [scanRes, detailsRes] = await Promise.all([
apiFetch(`/security/scans/${scanId}`),
apiFetch(`/security/scans/${scanId}/vulnerabilities?limit=500`),
apiFetch(`/security/scans/${scanId}/vulnerabilities?limit=${DETAIL_FETCH_LIMIT}`),
]);
if (!scanRes.ok) throw new Error('Failed to fetch scan');
if (!detailsRes.ok) throw new Error('Failed to fetch vulnerabilities');
@@ -94,6 +97,7 @@ export function VulnerabilityScanSheet({
const detailsData = await detailsRes.json();
setScan(scanData);
setDetails(Array.isArray(detailsData.items) ? detailsData.items : []);
setTotalDetails(typeof detailsData.total === 'number' ? detailsData.total : 0);
setPage(0);
} catch (err) {
toast.error((err as Error)?.message || 'Failed to load scan');
@@ -107,6 +111,7 @@ export function VulnerabilityScanSheet({
else {
setScan(null);
setDetails([]);
setTotalDetails(0);
setSeverityFilter('ALL');
setPage(0);
}
@@ -191,6 +196,11 @@ export function VulnerabilityScanSheet({
{scan?.image_ref ?? 'Loading...'}
</span>
</SheetTitle>
<SheetDescription className="sr-only">
{scan
? `Vulnerability scan results for ${scan.image_ref}: ${scan.total_vulnerabilities} total findings.`
: 'Vulnerability scan details.'}
</SheetDescription>
</SheetHeader>
{loading && !scan && (
@@ -336,6 +346,12 @@ export function VulnerabilityScanSheet({
)}
</div>
{totalDetails > details.length && (
<div className="px-6 pt-2 text-xs text-stat-subtitle font-mono">
Showing first {details.length} of {totalDetails}. Export CSV for the complete list.
</div>
)}
<ScrollArea className="flex-1 min-h-0">
<div className="px-6 py-3">
{pageItems.length === 0 ? (