mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 03:36:59 +00:00
c9cd6990d2
* 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
409 lines
16 KiB
TypeScript
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 };
|