Files
sencho/frontend/src/components/VulnerabilityScanSheet.tsx
T
Anso c9cd6990d2 feat(images): Trivy-powered vulnerability scanning (#635)
* feat(images): Trivy-powered vulnerability scanning

Scan container images for known CVEs via Trivy. On-demand scanning and
severity badges are available on every tier; scheduled scans, scan
policies, SBOM generation, and scan history are gated to Skipper+.

- New TrivyService (binary detection, per-image scan, SBOM, digest cache)
- Three new tables: vulnerability_scans, vulnerability_details, scan_policies
- 12 routes under /api/security (scan, results, summaries, SBOM, policies, compare)
- Post-deploy async scans wired into all five deploy paths, with a
  per-deploy opt-out toggle in the App Store deploy sheet
- "scan" action type added to SchedulerService for fleet-wide recurring scans
- Frontend: severity badges in Resources Hub with animated cursor detail,
  scan results drawer with vulnerability table and filters, and a new
  Security section in Settings for scan policy CRUD
- Policy threshold violations dispatch a warning or critical alert based on
  the policy's block_on_deploy flag; deploys themselves are never blocked

* fix(security): compute scan age in useEffect to satisfy react-hooks/purity
2026-04-16 15:03:36 -04:00

409 lines
16 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from 'react';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
ShieldCheck,
ExternalLink,
ChevronLeft,
ChevronRight,
RefreshCw,
Download,
Loader2,
Check,
} from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { cn } from '@/lib/utils';
import type {
VulnerabilityScan,
VulnerabilityDetail,
VulnSeverity,
} from '@/types/security';
interface VulnerabilityScanSheetProps {
scanId: number | null;
onClose: () => void;
onRescan?: (imageRef: string) => void;
canGenerateSbom?: boolean;
}
type SeverityFilter = 'ALL' | VulnSeverity;
const PAGE_SIZE = 25;
const SEVERITY_CLASSES: Record<VulnSeverity, string> = {
CRITICAL: 'text-destructive border-destructive/40 bg-destructive/10',
HIGH: 'text-warning border-warning/40 bg-warning/10',
MEDIUM: 'text-info border-info/40 bg-info/10',
LOW: 'text-muted-foreground border-border bg-muted/30',
UNKNOWN: 'text-muted-foreground border-border bg-muted/20',
};
function SeverityChip({ severity }: { severity: VulnSeverity }) {
return (
<span
className={cn(
'inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] font-mono tabular-nums uppercase tracking-wide',
SEVERITY_CLASSES[severity],
)}
>
{severity}
</span>
);
}
export function VulnerabilityScanSheet({
scanId,
onClose,
onRescan,
canGenerateSbom = false,
}: VulnerabilityScanSheetProps) {
const [scan, setScan] = useState<VulnerabilityScan | null>(null);
const [details, setDetails] = useState<VulnerabilityDetail[]>([]);
const [loading, setLoading] = useState(false);
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>('ALL');
const [page, setPage] = useState(0);
const [downloadingSbom, setDownloadingSbom] = useState(false);
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`),
]);
if (!scanRes.ok) throw new Error('Failed to fetch scan');
if (!detailsRes.ok) throw new Error('Failed to fetch vulnerabilities');
const scanData = await scanRes.json();
const detailsData = await detailsRes.json();
setScan(scanData);
setDetails(Array.isArray(detailsData.items) ? detailsData.items : []);
setPage(0);
} catch (err) {
toast.error((err as Error)?.message || 'Failed to load scan');
} finally {
setLoading(false);
}
}, [scanId]);
useEffect(() => {
if (scanId != null) load();
else {
setScan(null);
setDetails([]);
setSeverityFilter('ALL');
setPage(0);
}
}, [scanId, load]);
const filtered = useMemo(() => {
if (severityFilter === 'ALL') return details;
return details.filter((d) => d.severity === severityFilter);
}, [details, severityFilter]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages - 1);
const pageItems = filtered.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE);
const needsPagination = filtered.length > PAGE_SIZE;
const downloadSbom = useCallback(
async (format: 'spdx-json' | 'cyclonedx') => {
if (!scan) return;
setDownloadingSbom(true);
try {
const res = await apiFetch('/security/sbom', {
method: 'POST',
body: JSON.stringify({ imageRef: scan.image_ref, format }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body?.error || 'Failed to generate SBOM');
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${scan.image_ref.replace(/[^a-z0-9]+/gi, '_')}-sbom-${format}.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
toast.success('SBOM downloaded');
} catch (err) {
toast.error((err as Error)?.message || 'SBOM generation failed');
} finally {
setDownloadingSbom(false);
}
},
[scan],
);
const exportCsv = useCallback(() => {
if (!scan || details.length === 0) return;
const header = 'CVE,Package,Severity,Installed,Fixed,URL\n';
const escape = (v: string) => `"${v.replace(/"/g, '""')}"`;
const rows = details
.map((d) =>
[
escape(d.vulnerability_id),
escape(d.pkg_name),
escape(d.severity),
escape(d.installed_version),
escape(d.fixed_version ?? ''),
escape(d.primary_url ?? ''),
].join(','),
)
.join('\n');
const blob = new Blob([header + rows], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${scan.image_ref.replace(/[^a-z0-9]+/gi, '_')}-vulnerabilities.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}, [scan, details]);
return (
<Sheet open={scanId != null} onOpenChange={(open) => !open && onClose()}>
<SheetContent className="sm:max-w-2xl flex flex-col p-0">
<SheetHeader className="p-6 pb-4 border-b">
<SheetTitle className="flex items-center gap-2 pr-6">
<ShieldCheck className="w-4 h-4 text-muted-foreground" strokeWidth={1.5} />
<span className="font-mono text-sm truncate">
{scan?.image_ref ?? 'Loading...'}
</span>
</SheetTitle>
</SheetHeader>
{loading && !scan && (
<div className="flex items-center justify-center flex-1">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" strokeWidth={1.5} />
</div>
)}
{scan && (
<div className="flex flex-col flex-1 min-h-0">
{/* Summary stats */}
<div className="px-6 py-4 border-b space-y-3">
<div className="flex flex-wrap gap-2">
{scan.critical_count > 0 && (
<span className={cn('rounded border px-2 py-1 text-xs font-mono tabular-nums', SEVERITY_CLASSES.CRITICAL)}>
{scan.critical_count} CRITICAL
</span>
)}
{scan.high_count > 0 && (
<span className={cn('rounded border px-2 py-1 text-xs font-mono tabular-nums', SEVERITY_CLASSES.HIGH)}>
{scan.high_count} HIGH
</span>
)}
{scan.medium_count > 0 && (
<span className={cn('rounded border px-2 py-1 text-xs font-mono tabular-nums', SEVERITY_CLASSES.MEDIUM)}>
{scan.medium_count} MEDIUM
</span>
)}
{scan.low_count > 0 && (
<span className={cn('rounded border px-2 py-1 text-xs font-mono tabular-nums', SEVERITY_CLASSES.LOW)}>
{scan.low_count} LOW
</span>
)}
{scan.total_vulnerabilities === 0 && (
<span className="rounded border border-success/40 bg-success/10 text-success px-2 py-1 text-xs font-mono tabular-nums uppercase">
No vulnerabilities
</span>
)}
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
<div>
<div className="text-stat-subtitle uppercase tracking-wide">Total</div>
<div className="font-mono tabular-nums text-stat-value">{scan.total_vulnerabilities}</div>
</div>
<div>
<div className="text-stat-subtitle uppercase tracking-wide">Fixable</div>
<div className="font-mono tabular-nums text-success">{scan.fixable_count}</div>
</div>
<div>
<div className="text-stat-subtitle uppercase tracking-wide">Triggered</div>
<div className="font-mono text-stat-value">{scan.triggered_by}</div>
</div>
<div>
<div className="text-stat-subtitle uppercase tracking-wide">Scanned</div>
<div className="font-mono text-stat-value">
{new Date(scan.scanned_at).toLocaleString()}
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 pt-2">
{onRescan && (
<Button
variant="outline"
size="sm"
onClick={() => onRescan(scan.image_ref)}
disabled={scan.status === 'in_progress'}
>
<RefreshCw className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
Re-scan
</Button>
)}
{canGenerateSbom && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={downloadingSbom}>
{downloadingSbom ? (
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />
) : (
<Download className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
)}
SBOM
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => downloadSbom('spdx-json')}>
SPDX JSON
</DropdownMenuItem>
<DropdownMenuItem onClick={() => downloadSbom('cyclonedx')}>
CycloneDX
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
<Button variant="outline" size="sm" onClick={exportCsv} disabled={details.length === 0}>
<Download className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
CSV
</Button>
</div>
</div>
{/* Severity filter tabs */}
<div className="px-6 pt-3 flex items-center gap-1 flex-wrap">
{(['ALL', 'CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as SeverityFilter[]).map((s) => (
<Button
key={s}
variant={severityFilter === s ? 'default' : 'ghost'}
size="sm"
className="h-7 text-xs px-2.5"
onClick={() => {
setSeverityFilter(s);
setPage(0);
}}
>
{s}
</Button>
))}
{needsPagination && (
<div className="flex items-center gap-1 ml-auto">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setPage(Math.max(0, safePage - 1))}
disabled={safePage === 0}
>
<ChevronLeft className="w-4 h-4" strokeWidth={1.5} />
</Button>
<span className="text-xs font-mono tabular-nums text-stat-subtitle min-w-[3rem] text-center">
{safePage + 1} / {totalPages}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setPage(Math.min(totalPages - 1, safePage + 1))}
disabled={safePage >= totalPages - 1}
>
<ChevronRight className="w-4 h-4" strokeWidth={1.5} />
</Button>
</div>
)}
</div>
<ScrollArea className="flex-1 min-h-0">
<div className="px-6 py-3">
{pageItems.length === 0 ? (
<div className="text-center text-sm text-muted-foreground py-12">
{details.length === 0
? 'No vulnerabilities found.'
: 'No vulnerabilities match the selected filter.'}
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[180px]">CVE</TableHead>
<TableHead>Package</TableHead>
<TableHead className="w-[100px]">Severity</TableHead>
<TableHead className="w-[110px]">Installed</TableHead>
<TableHead className="w-[110px]">Fixed</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pageItems.map((d) => (
<TableRow key={d.id}>
<TableCell className="font-mono text-xs">
{d.primary_url ? (
<a
href={d.primary_url}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-1 hover:underline"
>
{d.vulnerability_id}
<ExternalLink className="w-3 h-3" strokeWidth={1.5} />
</a>
) : (
d.vulnerability_id
)}
</TableCell>
<TableCell className="font-mono text-xs truncate max-w-[180px]" title={d.pkg_name}>
{d.pkg_name}
</TableCell>
<TableCell>
<SeverityChip severity={d.severity} />
</TableCell>
<TableCell className="font-mono text-xs">{d.installed_version}</TableCell>
<TableCell className="font-mono text-xs">
{d.fixed_version ? (
<span className="inline-flex items-center gap-1 text-success">
<Check className="w-3 h-3" strokeWidth={1.5} />
{d.fixed_version}
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</ScrollArea>
</div>
)}
</SheetContent>
</Sheet>
);
}
export { SeverityChip };
export type { VulnerabilityScanSheetProps };