mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 02:12:59 +00:00
12bbf86dc4
Adds a new SarifExporter service that builds a SARIF 2.1.0 document from the stored scan findings (vulnerabilities, secrets, misconfigs). Rule IDs are namespaced to avoid collisions in a flat result list. Suppressions carry through as SARIF suppressions[] entries so GitHub code scanning and Defender for Cloud see the same accepted status shown in the UI. Exposed via GET /api/security/scans/:id/sarif, admin + paid-tier gated to match the SBOM export precedent. A SARIF button appears in the scan sheet next to SBOM and CSV for paid tiers.
1003 lines
42 KiB
TypeScript
1003 lines
42 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 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-wide',
|
|
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="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>
|
|
<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">
|
|
<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>
|
|
{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="text-xs text-stat-subtitle uppercase tracking-wide">
|
|
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]">CVE</TableHead>
|
|
<TableHead>Package</TableHead>
|
|
<TableHead className="w-[100px]">Severity</TableHead>
|
|
<TableHead className="w-[110px]">Installed</TableHead>
|
|
<TableHead className="w-[110px]">Fixed</TableHead>
|
|
{canManageSuppressions && <TableHead className="w-[40px]" />}
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{pageItems.map((d) => (
|
|
<TableRow key={d.id} className={d.suppressed ? 'opacity-60' : undefined}>
|
|
<TableCell className="font-mono text-xs">
|
|
<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"
|
|
/>
|
|
)}
|
|
{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
|
|
)}
|
|
</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]">Severity</TableHead>
|
|
<TableHead className="w-[160px]">Rule</TableHead>
|
|
<TableHead>Title</TableHead>
|
|
<TableHead className="w-[260px]">Target</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{secretsPageItems.map((s) => (
|
|
<TableRow key={s.id}>
|
|
<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]">Severity</TableHead>
|
|
<TableHead className="w-[140px]">Check</TableHead>
|
|
<TableHead>Title</TableHead>
|
|
<TableHead className="w-[200px]">Target</TableHead>
|
|
<TableHead className="w-[220px]">Fix</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{misconfigsPageItems.map((m) => (
|
|
<TableRow key={m.id}>
|
|
<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 };
|