mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 09:54:26 +00:00
12c2b37510
* feat(security): polish scan sheets, fix CVE links, surface policy violations Adds cveUrl helper that rewrites Trivy's 404-ing avd.aquasec.com links to cve.org for CVE-prefixed IDs (GHSA and misconfig URLs pass through unchanged). Redesigns both scan sheets with shadow-card-bevel chips, tracked-mono kickers, severity row tinting with a left accent rail, and tabular-nums timestamps. Surfaces a destructive policy-violation banner on scans whose policy_evaluation row flags a block, and fixes the compare sheet's delta ribbon so CRITICAL net-positive deltas render in destructive (not warning) tone. Backend parses the JSON policy_evaluation column at the API boundary so the UI receives a structured object. * chore(security): suppress CVE-2026-32281 and CVE-2026-32283 in Trivy scan Both CVEs affect Go stdlib crypto/x509 and TLS in Docker CLI 29.4.0 (Go 1.26.1) and Compose v5.1.2 (Go 1.25.8). No upstream static binary has been released with the patched Go 1.26.2 or 1.25.9 runtimes yet. Exposure analysis: the Docker CLI and compose plugin connect to the local Docker socket (Unix socket, no TLS) and to public registries with well-known CAs. Neither CVE is exploitable in this configuration. Added alongside sibling entries already in .trivyignore for the same binary versions. Revisit on next Docker CLI and Compose upstream release.
1040 lines
46 KiB
TypeScript
1040 lines
46 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { Sheet, SheetContent, SheetDescription, 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,
|
|
ShieldOff,
|
|
ExternalLink,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
RefreshCw,
|
|
Download,
|
|
Loader2,
|
|
Check,
|
|
GitCompare,
|
|
KeyRound,
|
|
FileWarning,
|
|
} from 'lucide-react';
|
|
import { Combobox } from '@/components/ui/combobox';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { ScanComparisonSheet } from './ScanComparisonSheet';
|
|
import { apiFetch } from '@/lib/api';
|
|
import { toast } from '@/components/ui/toast-store';
|
|
import { cn } from '@/lib/utils';
|
|
import { cveUrl } from '@/lib/cveUrl';
|
|
import { SEVERITY_ROW_TINT } from '@/lib/severityStyles';
|
|
import type {
|
|
VulnerabilityScan,
|
|
VulnerabilityDetail,
|
|
VulnSeverity,
|
|
SecretFinding,
|
|
MisconfigFinding,
|
|
} from '@/types/security';
|
|
|
|
interface VulnerabilityScanSheetProps {
|
|
scanId: number | null;
|
|
onClose: () => void;
|
|
onRescan?: (imageRef: string) => void;
|
|
canGenerateSbom?: boolean;
|
|
canCompare?: boolean;
|
|
canManageSuppressions?: boolean;
|
|
}
|
|
|
|
interface SuppressDialogState {
|
|
cveId: string;
|
|
pkgName: string;
|
|
imagePattern: string;
|
|
reason: string;
|
|
expiresInDays: string;
|
|
}
|
|
|
|
type SeverityFilter = 'ALL' | VulnSeverity;
|
|
type FindingTab = 'vulns' | 'secrets' | 'misconfigs';
|
|
|
|
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-[0.18em] shadow-card-bevel',
|
|
SEVERITY_CLASSES[severity],
|
|
)}
|
|
>
|
|
{severity}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export function VulnerabilityScanSheet({
|
|
scanId,
|
|
onClose,
|
|
onRescan,
|
|
canGenerateSbom = false,
|
|
canCompare = false,
|
|
canManageSuppressions = false,
|
|
}: VulnerabilityScanSheetProps) {
|
|
const [scan, setScan] = useState<VulnerabilityScan | null>(null);
|
|
const [details, setDetails] = useState<VulnerabilityDetail[]>([]);
|
|
const [totalDetails, setTotalDetails] = useState(0);
|
|
const [secrets, setSecrets] = useState<SecretFinding[]>([]);
|
|
const [misconfigs, setMisconfigs] = useState<MisconfigFinding[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>('ALL');
|
|
const [page, setPage] = useState(0);
|
|
const [secretsPage, setSecretsPage] = useState(0);
|
|
const [misconfigsPage, setMisconfigsPage] = useState(0);
|
|
const [tab, setTab] = useState<FindingTab>('vulns');
|
|
const [downloadingSbom, setDownloadingSbom] = useState(false);
|
|
const [compareOpen, setCompareOpen] = useState(false);
|
|
const [compareOptions, setCompareOptions] = useState<VulnerabilityScan[]>([]);
|
|
const [compareLoading, setCompareLoading] = useState(false);
|
|
const [compareBaselineId, setCompareBaselineId] = useState<number | null>(null);
|
|
const [suppressForm, setSuppressForm] = useState<SuppressDialogState | null>(null);
|
|
const [savingSuppression, setSavingSuppression] = useState(false);
|
|
|
|
const DETAIL_FETCH_LIMIT = 500;
|
|
|
|
const load = useCallback(async () => {
|
|
if (scanId == null) return;
|
|
setLoading(true);
|
|
try {
|
|
const [scanRes, detailsRes, secretsRes, misconfigsRes] = await Promise.all([
|
|
apiFetch(`/security/scans/${scanId}`),
|
|
apiFetch(`/security/scans/${scanId}/vulnerabilities?limit=${DETAIL_FETCH_LIMIT}`),
|
|
apiFetch(`/security/scans/${scanId}/secrets?limit=${DETAIL_FETCH_LIMIT}`),
|
|
apiFetch(`/security/scans/${scanId}/misconfigs?limit=${DETAIL_FETCH_LIMIT}`),
|
|
]);
|
|
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()) as VulnerabilityScan;
|
|
const detailsData = await detailsRes.json();
|
|
const secretsData = secretsRes.ok ? await secretsRes.json() : { items: [] };
|
|
const misconfigsData = misconfigsRes.ok ? await misconfigsRes.json() : { items: [] };
|
|
setScan(scanData);
|
|
setDetails(Array.isArray(detailsData.items) ? detailsData.items : []);
|
|
setTotalDetails(typeof detailsData.total === 'number' ? detailsData.total : 0);
|
|
setSecrets(Array.isArray(secretsData.items) ? secretsData.items : []);
|
|
setMisconfigs(Array.isArray(misconfigsData.items) ? misconfigsData.items : []);
|
|
setPage(0);
|
|
setSecretsPage(0);
|
|
setMisconfigsPage(0);
|
|
if ((scanData.total_vulnerabilities ?? 0) === 0) {
|
|
if ((scanData.misconfig_count ?? 0) > 0) setTab('misconfigs');
|
|
else if ((scanData.secret_count ?? 0) > 0) setTab('secrets');
|
|
else setTab('vulns');
|
|
} else {
|
|
setTab('vulns');
|
|
}
|
|
} catch (err) {
|
|
toast.error((err as Error)?.message || 'Failed to load scan');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [scanId]);
|
|
|
|
useEffect(() => {
|
|
setCompareOpen(false);
|
|
setCompareOptions([]);
|
|
setCompareBaselineId(null);
|
|
if (scanId != null) {
|
|
load();
|
|
} else {
|
|
setScan(null);
|
|
setDetails([]);
|
|
setTotalDetails(0);
|
|
setSecrets([]);
|
|
setMisconfigs([]);
|
|
setSeverityFilter('ALL');
|
|
setPage(0);
|
|
setSecretsPage(0);
|
|
setMisconfigsPage(0);
|
|
setTab('vulns');
|
|
}
|
|
}, [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 secretsTotalPages = Math.max(1, Math.ceil(secrets.length / PAGE_SIZE));
|
|
const secretsSafePage = Math.min(secretsPage, secretsTotalPages - 1);
|
|
const secretsPageItems = secrets.slice(
|
|
secretsSafePage * PAGE_SIZE,
|
|
(secretsSafePage + 1) * PAGE_SIZE,
|
|
);
|
|
const secretsNeedsPagination = secrets.length > PAGE_SIZE;
|
|
|
|
const misconfigsTotalPages = Math.max(1, Math.ceil(misconfigs.length / PAGE_SIZE));
|
|
const misconfigsSafePage = Math.min(misconfigsPage, misconfigsTotalPages - 1);
|
|
const misconfigsPageItems = misconfigs.slice(
|
|
misconfigsSafePage * PAGE_SIZE,
|
|
(misconfigsSafePage + 1) * PAGE_SIZE,
|
|
);
|
|
const misconfigsNeedsPagination = misconfigs.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 openCompareMenu = useCallback(async () => {
|
|
if (!scan) return;
|
|
setCompareOpen((o) => !o);
|
|
if (compareOptions.length > 0 || compareLoading) return;
|
|
setCompareLoading(true);
|
|
try {
|
|
const res = await apiFetch(
|
|
`/security/scans?imageRef=${encodeURIComponent(scan.image_ref)}&limit=25`,
|
|
);
|
|
if (!res.ok) throw new Error('Failed to load scan history');
|
|
const body = await res.json();
|
|
const items: VulnerabilityScan[] = Array.isArray(body?.items) ? body.items : [];
|
|
setCompareOptions(
|
|
items.filter((s) => s.id !== scan.id && s.status === 'completed'),
|
|
);
|
|
} catch (err) {
|
|
toast.error((err as Error)?.message || 'Could not load scan history');
|
|
} finally {
|
|
setCompareLoading(false);
|
|
}
|
|
}, [scan, compareOptions.length, compareLoading]);
|
|
|
|
const openSuppressDialog = useCallback((d: VulnerabilityDetail) => {
|
|
setSuppressForm({
|
|
cveId: d.vulnerability_id,
|
|
pkgName: d.pkg_name,
|
|
imagePattern: '',
|
|
reason: '',
|
|
expiresInDays: '',
|
|
});
|
|
}, []);
|
|
|
|
const submitSuppression = useCallback(async () => {
|
|
if (!suppressForm) return;
|
|
const reason = suppressForm.reason.trim();
|
|
if (!reason) {
|
|
toast.error('A reason is required.');
|
|
return;
|
|
}
|
|
const days = suppressForm.expiresInDays.trim();
|
|
let expiresAt: number | null = null;
|
|
if (days) {
|
|
const n = Number(days);
|
|
if (!Number.isFinite(n) || n <= 0) {
|
|
toast.error('Expiry must be a positive number of days or blank.');
|
|
return;
|
|
}
|
|
expiresAt = Date.now() + n * 24 * 60 * 60 * 1000;
|
|
}
|
|
setSavingSuppression(true);
|
|
try {
|
|
const res = await apiFetch('/security/suppressions', {
|
|
method: 'POST',
|
|
localOnly: true,
|
|
body: JSON.stringify({
|
|
cve_id: suppressForm.cveId,
|
|
pkg_name: suppressForm.pkgName || null,
|
|
image_pattern: suppressForm.imagePattern.trim() || null,
|
|
reason,
|
|
expires_at: expiresAt,
|
|
}),
|
|
});
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({}));
|
|
throw new Error(body?.error || 'Failed to create suppression');
|
|
}
|
|
toast.success('Suppression created');
|
|
setSuppressForm(null);
|
|
await load();
|
|
} catch (err) {
|
|
toast.error((err as Error)?.message || 'Failed to create suppression');
|
|
} finally {
|
|
setSavingSuppression(false);
|
|
}
|
|
}, [suppressForm, load]);
|
|
|
|
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]);
|
|
|
|
const exportSarif = useCallback(async () => {
|
|
if (!scan) return;
|
|
try {
|
|
const res = await apiFetch(`/security/scans/${scan.id}/sarif`);
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({}));
|
|
throw new Error(body?.error || 'Failed to generate SARIF');
|
|
}
|
|
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, '_')}.sarif.json`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
URL.revokeObjectURL(url);
|
|
toast.success('SARIF downloaded');
|
|
} catch (err) {
|
|
const error = err as { message?: string; error?: string; data?: { error?: string } };
|
|
toast.error(error?.message || error?.error || error?.data?.error || 'SARIF export failed');
|
|
}
|
|
}, [scan]);
|
|
|
|
return (
|
|
<Sheet open={scanId != null} onOpenChange={(open) => !open && onClose()}>
|
|
<SheetContent className="sm:max-w-2xl flex flex-col p-0">
|
|
<SheetHeader className="px-6 pt-6 pb-4 pr-14 border-b border-border space-y-2">
|
|
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
|
Vulnerability scan · {scan?.triggered_by ?? '-'}
|
|
</div>
|
|
<SheetTitle className="flex items-center gap-2 font-display italic text-2xl">
|
|
<ShieldCheck className="w-5 h-5 text-muted-foreground" strokeWidth={1.5} />
|
|
<span className="font-mono text-base truncate not-italic">
|
|
{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 && (
|
|
<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">
|
|
{scan.policy_evaluation?.violated && (
|
|
<div
|
|
role="alert"
|
|
className="relative rounded border border-destructive/40 bg-destructive/10 px-3 py-2 pl-4 shadow-card-bevel"
|
|
>
|
|
<span
|
|
aria-hidden="true"
|
|
className="absolute left-0 top-0 bottom-0 w-[3px] bg-destructive/80 rounded-l"
|
|
/>
|
|
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-destructive">
|
|
Policy violation
|
|
</div>
|
|
<div className="text-sm text-stat-value mt-0.5">
|
|
<span className="font-mono">{scan.policy_evaluation.policyName}</span> blocks
|
|
severities at or above{' '}
|
|
<span className="font-mono tabular-nums">
|
|
{scan.policy_evaluation.maxSeverity}
|
|
</span>
|
|
. This scan's highest severity is{' '}
|
|
<span className="font-mono tabular-nums">
|
|
{scan.highest_severity ?? 'UNKNOWN'}
|
|
</span>
|
|
.
|
|
</div>
|
|
</div>
|
|
)}
|
|
<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 uppercase tracking-[0.18em] shadow-card-bevel', 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 uppercase tracking-[0.18em] shadow-card-bevel', 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 uppercase tracking-[0.18em] shadow-card-bevel', 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 uppercase tracking-[0.18em] shadow-card-bevel', 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 tracking-[0.18em] shadow-card-bevel">
|
|
No vulnerabilities
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
|
|
<div>
|
|
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Total</div>
|
|
<div className="font-mono tabular-nums text-stat-value">{scan.total_vulnerabilities}</div>
|
|
</div>
|
|
<div>
|
|
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Fixable</div>
|
|
<div className="font-mono tabular-nums text-success">{scan.fixable_count}</div>
|
|
</div>
|
|
<div>
|
|
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Triggered</div>
|
|
<div className="font-mono tabular-nums text-stat-value">{scan.triggered_by}</div>
|
|
</div>
|
|
<div>
|
|
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Scanned</div>
|
|
<div className="font-mono tabular-nums 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>
|
|
{canGenerateSbom && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={exportSarif}
|
|
disabled={scan.status !== 'completed'}
|
|
title="Export findings as SARIF 2.1.0 for GitHub code scanning"
|
|
>
|
|
<Download className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
|
|
SARIF
|
|
</Button>
|
|
)}
|
|
{canCompare && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={openCompareMenu}
|
|
disabled={compareLoading}
|
|
title="Compare this scan to a previous one for the same image"
|
|
>
|
|
{compareLoading ? (
|
|
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />
|
|
) : (
|
|
<GitCompare className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
|
|
)}
|
|
Compare
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{compareOpen && canCompare && (
|
|
<div className="pt-2 space-y-2">
|
|
{compareOptions.length === 0 && !compareLoading ? (
|
|
<div className="rounded border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
|
No other completed scans for this image yet. Run a second scan to enable comparison.
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
|
Compare against
|
|
</div>
|
|
<Combobox
|
|
options={compareOptions.map((s) => ({
|
|
value: String(s.id),
|
|
label: `${new Date(s.scanned_at).toLocaleString()} - ${s.total_vulnerabilities} findings (${s.triggered_by})`,
|
|
}))}
|
|
value={compareBaselineId != null ? String(compareBaselineId) : ''}
|
|
onValueChange={(v) => setCompareBaselineId(v ? Number(v) : null)}
|
|
placeholder="Choose a baseline scan..."
|
|
searchPlaceholder="Search by date..."
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<Tabs
|
|
value={tab}
|
|
onValueChange={(v) => setTab(v as FindingTab)}
|
|
className="flex flex-col flex-1 min-h-0"
|
|
>
|
|
<div className="px-6 pt-3">
|
|
<TabsList>
|
|
<TabsTrigger value="vulns" className="gap-1.5">
|
|
<ShieldCheck className="w-3.5 h-3.5" strokeWidth={1.5} />
|
|
Vulnerabilities
|
|
<span className="font-mono tabular-nums text-stat-subtitle">
|
|
({totalDetails})
|
|
</span>
|
|
</TabsTrigger>
|
|
<TabsTrigger value="secrets" className="gap-1.5">
|
|
<KeyRound className="w-3.5 h-3.5" strokeWidth={1.5} />
|
|
Secrets
|
|
<span className="font-mono tabular-nums text-stat-subtitle">
|
|
({scan.secret_count ?? secrets.length})
|
|
</span>
|
|
</TabsTrigger>
|
|
<TabsTrigger value="misconfigs" className="gap-1.5">
|
|
<FileWarning className="w-3.5 h-3.5" strokeWidth={1.5} />
|
|
Misconfigs
|
|
<span className="font-mono tabular-nums text-stat-subtitle">
|
|
({scan.misconfig_count ?? misconfigs.length})
|
|
</span>
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
</div>
|
|
|
|
<TabsContent
|
|
value="vulns"
|
|
className="flex flex-col flex-1 min-h-0 mt-0 data-[state=inactive]:hidden"
|
|
>
|
|
<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>
|
|
|
|
{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 ? (
|
|
<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] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">CVE</TableHead>
|
|
<TableHead className="text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Package</TableHead>
|
|
<TableHead className="w-[100px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Severity</TableHead>
|
|
<TableHead className="w-[110px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Installed</TableHead>
|
|
<TableHead className="w-[110px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Fixed</TableHead>
|
|
{canManageSuppressions && <TableHead className="w-[40px]" />}
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{pageItems.map((d) => {
|
|
const href = cveUrl(d.vulnerability_id, d.primary_url);
|
|
return (
|
|
<TableRow
|
|
key={d.id}
|
|
className={cn(SEVERITY_ROW_TINT[d.severity], d.suppressed && 'opacity-60')}
|
|
>
|
|
<TableCell className="font-mono text-xs tabular-nums">
|
|
<span className="inline-flex items-center gap-1.5">
|
|
{d.suppressed && (
|
|
<ShieldOff
|
|
className="w-3 h-3 text-muted-foreground"
|
|
strokeWidth={1.5}
|
|
aria-label="Suppressed"
|
|
/>
|
|
)}
|
|
{href ? (
|
|
<a
|
|
href={href}
|
|
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
|
|
)}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell
|
|
className="font-mono text-xs truncate max-w-[180px]"
|
|
title={d.suppression_reason || 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>
|
|
{canManageSuppressions && (
|
|
<TableCell>
|
|
{!d.suppressed && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 text-muted-foreground/70 hover:text-foreground"
|
|
title="Suppress this CVE"
|
|
onClick={() => openSuppressDialog(d)}
|
|
>
|
|
<ShieldOff className="w-3.5 h-3.5" strokeWidth={1.5} />
|
|
</Button>
|
|
)}
|
|
</TableCell>
|
|
)}
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
</TabsContent>
|
|
|
|
<TabsContent
|
|
value="secrets"
|
|
className="flex flex-col flex-1 min-h-0 mt-0 data-[state=inactive]:hidden"
|
|
>
|
|
{secretsNeedsPagination && (
|
|
<div className="px-6 pt-3 flex items-center gap-1">
|
|
<div className="flex items-center gap-1 ml-auto">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6"
|
|
onClick={() => setSecretsPage(Math.max(0, secretsSafePage - 1))}
|
|
disabled={secretsSafePage === 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">
|
|
{secretsSafePage + 1} / {secretsTotalPages}
|
|
</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6"
|
|
onClick={() =>
|
|
setSecretsPage(Math.min(secretsTotalPages - 1, secretsSafePage + 1))
|
|
}
|
|
disabled={secretsSafePage >= secretsTotalPages - 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">
|
|
{secrets.length === 0 ? (
|
|
<div className="text-center text-sm text-muted-foreground py-12">
|
|
No secrets detected.
|
|
</div>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-[100px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Severity</TableHead>
|
|
<TableHead className="w-[160px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Rule</TableHead>
|
|
<TableHead className="text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Title</TableHead>
|
|
<TableHead className="w-[260px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Target</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{secretsPageItems.map((s) => (
|
|
<TableRow key={s.id} className={SEVERITY_ROW_TINT[s.severity]}>
|
|
<TableCell>
|
|
<SeverityChip severity={s.severity} />
|
|
</TableCell>
|
|
<TableCell className="font-mono text-xs truncate max-w-[160px]" title={s.rule_id}>
|
|
{s.rule_id}
|
|
</TableCell>
|
|
<TableCell className="text-xs">
|
|
<div className="truncate max-w-[320px]" title={s.title ?? undefined}>
|
|
{s.title || <span className="text-muted-foreground">-</span>}
|
|
</div>
|
|
{s.match_excerpt && (
|
|
<div
|
|
className="font-mono text-[11px] text-muted-foreground truncate max-w-[320px]"
|
|
title={s.match_excerpt}
|
|
>
|
|
{s.match_excerpt}
|
|
</div>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="font-mono text-xs truncate max-w-[260px]" title={s.target}>
|
|
{s.target}
|
|
{s.start_line != null && (
|
|
<span className="text-muted-foreground">
|
|
:{s.start_line}
|
|
{s.end_line != null && s.end_line !== s.start_line
|
|
? `-${s.end_line}`
|
|
: ''}
|
|
</span>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
</TabsContent>
|
|
|
|
<TabsContent
|
|
value="misconfigs"
|
|
className="flex flex-col flex-1 min-h-0 mt-0 data-[state=inactive]:hidden"
|
|
>
|
|
{misconfigsNeedsPagination && (
|
|
<div className="px-6 pt-3 flex items-center gap-1">
|
|
<div className="flex items-center gap-1 ml-auto">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6"
|
|
onClick={() => setMisconfigsPage(Math.max(0, misconfigsSafePage - 1))}
|
|
disabled={misconfigsSafePage === 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">
|
|
{misconfigsSafePage + 1} / {misconfigsTotalPages}
|
|
</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6"
|
|
onClick={() =>
|
|
setMisconfigsPage(
|
|
Math.min(misconfigsTotalPages - 1, misconfigsSafePage + 1),
|
|
)
|
|
}
|
|
disabled={misconfigsSafePage >= misconfigsTotalPages - 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">
|
|
{misconfigs.length === 0 ? (
|
|
<div className="text-center text-sm text-muted-foreground py-12">
|
|
No misconfigurations detected.
|
|
</div>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-[100px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Severity</TableHead>
|
|
<TableHead className="w-[140px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Check</TableHead>
|
|
<TableHead className="text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Title</TableHead>
|
|
<TableHead className="w-[200px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Target</TableHead>
|
|
<TableHead className="w-[220px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Fix</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{misconfigsPageItems.map((m) => (
|
|
<TableRow key={m.id} className={SEVERITY_ROW_TINT[m.severity]}>
|
|
<TableCell>
|
|
<SeverityChip severity={m.severity} />
|
|
</TableCell>
|
|
<TableCell
|
|
className="font-mono text-xs truncate max-w-[140px]"
|
|
title={m.check_id ?? m.rule_id}
|
|
>
|
|
{m.check_id || m.rule_id}
|
|
</TableCell>
|
|
<TableCell className="text-xs">
|
|
<div className="truncate max-w-[320px]" title={m.title ?? undefined}>
|
|
{m.primary_url ? (
|
|
<a
|
|
href={m.primary_url}
|
|
target="_blank"
|
|
rel="noreferrer noopener"
|
|
className="inline-flex items-center gap-1 hover:underline"
|
|
>
|
|
{m.title || m.rule_id}
|
|
<ExternalLink className="w-3 h-3" strokeWidth={1.5} />
|
|
</a>
|
|
) : (
|
|
m.title || m.rule_id
|
|
)}
|
|
</div>
|
|
{m.message && (
|
|
<div
|
|
className="text-[11px] text-muted-foreground truncate max-w-[320px]"
|
|
title={m.message}
|
|
>
|
|
{m.message}
|
|
</div>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="font-mono text-xs truncate max-w-[200px]" title={m.target}>
|
|
{m.target}
|
|
</TableCell>
|
|
<TableCell className="text-xs truncate max-w-[220px]" title={m.resolution ?? undefined}>
|
|
{m.resolution || <span className="text-muted-foreground">-</span>}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
)}
|
|
</SheetContent>
|
|
<ScanComparisonSheet
|
|
baselineScanId={compareBaselineId}
|
|
currentScanId={compareBaselineId != null ? scanId : null}
|
|
onClose={() => setCompareBaselineId(null)}
|
|
/>
|
|
|
|
<Dialog
|
|
open={suppressForm !== null}
|
|
onOpenChange={(open) => !open && setSuppressForm(null)}
|
|
>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>Suppress CVE</DialogTitle>
|
|
<DialogDescription className="sr-only">
|
|
Accept this CVE as known-benign so it stops triggering alerts across the fleet.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
{suppressForm && (
|
|
<div className="space-y-4 py-2">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1">
|
|
<Label className="text-xs uppercase tracking-wide text-stat-subtitle">CVE</Label>
|
|
<div className="font-mono text-sm">{suppressForm.cveId}</div>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs uppercase tracking-wide text-stat-subtitle">Package</Label>
|
|
<div className="font-mono text-sm truncate" title={suppressForm.pkgName}>
|
|
{suppressForm.pkgName || '-'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="suppress-pattern">Image pattern (optional)</Label>
|
|
<Input
|
|
id="suppress-pattern"
|
|
placeholder="e.g. registry.internal/* (leave blank for all images)"
|
|
value={suppressForm.imagePattern}
|
|
onChange={(e) =>
|
|
setSuppressForm((f) => (f ? { ...f, imagePattern: e.target.value } : f))
|
|
}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Glob pattern matched against the image reference. Leave blank to suppress this CVE on any image.
|
|
</p>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="suppress-reason">Reason</Label>
|
|
<textarea
|
|
id="suppress-reason"
|
|
className="flex min-h-[72px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
|
placeholder="Why is this CVE safe to accept?"
|
|
value={suppressForm.reason}
|
|
onChange={(e) =>
|
|
setSuppressForm((f) => (f ? { ...f, reason: e.target.value } : f))
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="suppress-expiry">Expires in (days, optional)</Label>
|
|
<Input
|
|
id="suppress-expiry"
|
|
type="number"
|
|
min="1"
|
|
placeholder="Leave blank for no expiry"
|
|
value={suppressForm.expiresInDays}
|
|
onChange={(e) =>
|
|
setSuppressForm((f) => (f ? { ...f, expiresInDays: e.target.value } : f))
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setSuppressForm(null)} disabled={savingSuppression}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={submitSuppression} disabled={savingSuppression}>
|
|
{savingSuppression ? 'Saving...' : 'Suppress'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</Sheet>
|
|
);
|
|
}
|
|
|
|
export { SeverityChip };
|
|
export type { VulnerabilityScanSheetProps };
|