mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-07 01:14:14 +00:00
2a4955f56d
* feat: add dedicated Security page and policy-pack foundation Bring vulnerability scanning, scan history, suppressions, Compose risks, secrets, policy packs, and scanner setup into one node-scoped Security command center instead of scattering them across Resources and Settings. - New top-level Security view with Overview, Images, Compose risks, Secrets, Policies, Suppressions, History, and Scanner setup tabs (status masthead + signal rail; controlled tabs with deep-link support). - Backend: GET /security/overview rollup and GET /security/policy-packs static catalog (auth-only, Community). DatabaseService gains an uncapped scan-status count and a node-eligible block-policy count, and getImageScanSummaries now projects secret and misconfig counts. - Reuse existing surfaces: the scan-history sheet, the control-governed suppression and acknowledgement panels, and the scan-detail sheet (now with an initial-tab prop so it opens on the matching finding type). - Extract a shared SeverityBadge (from Resources) and a TrivyManager (from Settings) so both surfaces render identical controls. - Resources "Scan history" now links into the Security page History tab. - Docs for the new Security surface and tests for the new endpoints, helpers, nav wiring, and tabs. * refactor: consolidate scanner and policy management onto the Security page Remove the Settings "Vulnerability Scanning" section now that the Security page covers the same ground, with every option preserved: - Scanner install / update / uninstall / auto-update live on the Scanner setup tab (TrivyManager). - Scan policies, the honor-suppressions toggle, and the replica managed-by-control / demote controls move into a new ScanPolicyManager on the Policies tab (paid; Community sees only the policy-pack catalog). - CVE suppressions and acknowledgements remain on the Suppressions tab. Wiring removed: the registry section and the now-empty Security settings group, the SectionId, the SettingsSectionContent case and the isPaid prop it was the sole consumer of, and SecuritySection itself. The dashboard configuration-status "Vulnerability scanning" row now navigates to the Security page Policies tab. Docs that pointed at "Settings -> Security -> Vulnerability Scanning" are swept to the relevant Security page tabs. * fix: harden Security page scanner refresh, policy-load errors, and secret-only badges Address independent-review findings on the Security page: - Scanner setup now refreshes Trivy state when the active node changes, so the displayed scanner status matches the node TrivyManager's actions target (both follow x-node-id). Previously, switching nodes on the tab left stale state. - ScanPolicyManager surfaces an explicit error state on a failed policy fetch instead of falling through to a false "No scan policies configured". - The shared SeverityBadge and the Images findings column no longer label a scan "clean" when it has secrets or misconfigurations but no CVE severity (highest_severity is derived from vulnerabilities only); they show a "Findings" state and the secret/misconfig counts instead. - The Overview enforcement note points to the Policies tab, not the removed Settings section. - The History tab auto-opens the scan-history sheet only on a deep-link (mount with the History tab active), not on every manual tab selection. Adds tests for the badge secret/misconfig state and the policy-load error state.
1185 lines
50 KiB
TypeScript
1185 lines
50 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { SystemSheet, SheetSection } from '@/components/ui/system-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 {
|
|
ShieldOff,
|
|
ShieldCheck,
|
|
ExternalLink,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
RefreshCw,
|
|
Download,
|
|
Loader2,
|
|
Check,
|
|
GitCompare,
|
|
} from 'lucide-react';
|
|
import { Combobox } from '@/components/ui/combobox';
|
|
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 { formatTimeAgo } from '@/lib/relativeTime';
|
|
import type {
|
|
VulnerabilityScan,
|
|
VulnerabilityDetail,
|
|
VulnSeverity,
|
|
SecretFinding,
|
|
MisconfigFinding,
|
|
ScanDetailTab,
|
|
} from '@/types/security';
|
|
|
|
interface VulnerabilityScanSheetProps {
|
|
scanId: number | null;
|
|
onClose: () => void;
|
|
onRescan?: (imageRef: string) => void;
|
|
canGenerateSbom?: boolean;
|
|
canExportSarif?: boolean;
|
|
canCompare?: boolean;
|
|
canManageSuppressions?: boolean;
|
|
/**
|
|
* Tab to open on first load. Defaults to 'vulns' (with the existing
|
|
* auto-switch to a populated tab when the scan has no vulnerabilities).
|
|
* Callers that open the sheet from a secret/misconfig context pass the
|
|
* matching tab so it lands there even when the scan also has CVEs.
|
|
*/
|
|
initialTab?: FindingTab;
|
|
}
|
|
|
|
interface SuppressDialogState {
|
|
cveId: string;
|
|
pkgName: string;
|
|
imagePattern: string;
|
|
reason: string;
|
|
expiresInDays: string;
|
|
}
|
|
|
|
interface AckDialogState {
|
|
ruleId: string;
|
|
stackPattern: string;
|
|
reason: string;
|
|
expiresInDays: string;
|
|
}
|
|
|
|
type SeverityFilter = 'ALL' | VulnSeverity;
|
|
// Single source of truth lives in types/security as ScanDetailTab; alias here so
|
|
// the initialTab prop is provably the same type its callers (SecurityView) hold.
|
|
type FindingTab = ScanDetailTab;
|
|
|
|
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-warning border-warning/40 bg-warning/10',
|
|
LOW: 'text-stat-subtitle border-border bg-muted/30',
|
|
UNKNOWN: 'text-stat-subtitle 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,
|
|
canExportSarif = false,
|
|
canCompare = false,
|
|
canManageSuppressions: canManageSuppressionsProp = false,
|
|
initialTab,
|
|
}: VulnerabilityScanSheetProps) {
|
|
const [isReplica, setIsReplica] = useState(false);
|
|
useEffect(() => {
|
|
// Reset on every probe so a stale `true` from a previous replica view
|
|
// does not survive switching to a control instance with the sheet kept
|
|
// mounted by its parent. Defense in depth: if the probe never resolves
|
|
// the UI stays permissive and the backend blockIfReplica guard runs.
|
|
setIsReplica(false);
|
|
if (!canManageSuppressionsProp || scanId == null) return;
|
|
let cancelled = false;
|
|
(async () => {
|
|
try {
|
|
const res = await apiFetch('/fleet/role', { localOnly: true });
|
|
if (cancelled || !res.ok) return;
|
|
const data = await res.json();
|
|
if (!cancelled) setIsReplica(data?.role === 'replica');
|
|
} catch (err) {
|
|
console.warn('Failed to probe fleet role for replica gate:', err);
|
|
}
|
|
})();
|
|
return () => { cancelled = true; };
|
|
}, [canManageSuppressionsProp, scanId]);
|
|
const canManageSuppressions = canManageSuppressionsProp && !isReplica;
|
|
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 [ackForm, setAckForm] = useState<AckDialogState | null>(null);
|
|
const [savingAck, setSavingAck] = 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 (initialTab) {
|
|
// Caller asked to land on a specific tab (e.g. opened from the
|
|
// Secrets or Compose-risks list), which wins over the default.
|
|
setTab(initialTab);
|
|
} else 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, initialTab]);
|
|
|
|
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 openAckDialog = useCallback((m: MisconfigFinding) => {
|
|
// Prefill stack_pattern with the exact stack name when this scan is a
|
|
// stack-scoped config scan. Operators can broaden in the dialog.
|
|
const stackPattern = scan?.stack_context ?? '';
|
|
setAckForm({
|
|
ruleId: m.rule_id,
|
|
stackPattern,
|
|
reason: '',
|
|
expiresInDays: '',
|
|
});
|
|
}, [scan]);
|
|
|
|
const submitAcknowledgement = useCallback(async () => {
|
|
if (!ackForm) return;
|
|
const reason = ackForm.reason.trim();
|
|
if (!reason) {
|
|
toast.error('A reason is required.');
|
|
return;
|
|
}
|
|
const days = ackForm.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;
|
|
}
|
|
setSavingAck(true);
|
|
try {
|
|
const res = await apiFetch('/security/misconfig-acks', {
|
|
method: 'POST',
|
|
localOnly: true,
|
|
body: JSON.stringify({
|
|
rule_id: ackForm.ruleId,
|
|
stack_pattern: ackForm.stackPattern.trim() || null,
|
|
reason,
|
|
expires_at: expiresAt,
|
|
}),
|
|
});
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({}));
|
|
throw new Error(body?.error || 'Failed to create acknowledgement');
|
|
}
|
|
toast.success('Acknowledgement created');
|
|
setAckForm(null);
|
|
await load();
|
|
} catch (err) {
|
|
toast.error((err as Error)?.message || 'Failed to create acknowledgement');
|
|
} finally {
|
|
setSavingAck(false);
|
|
}
|
|
}, [ackForm, 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]);
|
|
|
|
const meta = scan
|
|
? `${scan.total_vulnerabilities} vulns · ${scan.fixable_count} fixable · ${scan.triggered_by}`
|
|
: (loading ? 'Loading…' : 'No scan');
|
|
|
|
const footerContext = scan
|
|
? `Scanned ${formatTimeAgo(new Date(scan.scanned_at).getTime())}`
|
|
: undefined;
|
|
|
|
const secondaryActions = scan ? [
|
|
...(canCompare ? [{
|
|
label: 'Compare',
|
|
icon: compareLoading ? Loader2 : GitCompare,
|
|
onClick: openCompareMenu,
|
|
disabled: compareLoading,
|
|
}] : []),
|
|
...(details.length > 0 ? [{
|
|
label: 'CSV',
|
|
icon: Download,
|
|
onClick: exportCsv,
|
|
}] : []),
|
|
...(canExportSarif && scan.status === 'completed' ? [{
|
|
label: 'SARIF',
|
|
icon: Download,
|
|
onClick: () => { void exportSarif(); },
|
|
}] : []),
|
|
] : undefined;
|
|
|
|
return (
|
|
<>
|
|
<SystemSheet
|
|
open={scanId != null}
|
|
onOpenChange={(open) => !open && onClose()}
|
|
crumb={['Security', 'Scans', scan?.image_ref ?? '…']}
|
|
name={scan?.image_ref ?? 'Loading…'}
|
|
meta={meta}
|
|
primaryAction={onRescan && scan ? {
|
|
label: 'Re-scan',
|
|
icon: RefreshCw,
|
|
onClick: () => onRescan(scan.image_ref),
|
|
disabled: scan.status === 'in_progress',
|
|
} : undefined}
|
|
secondaryActions={secondaryActions}
|
|
tabs={scan ? [
|
|
{ id: 'vulns', label: 'Vulnerabilities', count: totalDetails },
|
|
{ id: 'secrets', label: 'Secrets', count: scan.secret_count ?? secrets.length },
|
|
{ id: 'misconfigs', label: 'Misconfigs', count: scan.misconfig_count ?? misconfigs.length },
|
|
] : undefined}
|
|
activeTab={tab}
|
|
onTabChange={(id) => setTab(id as FindingTab)}
|
|
footerContext={footerContext}
|
|
size="lg"
|
|
>
|
|
{loading && !scan && (
|
|
<div className="flex items-center justify-center py-12">
|
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" strokeWidth={1.5} />
|
|
</div>
|
|
)}
|
|
|
|
{scan && (
|
|
<>
|
|
<SheetSection title="Summary">
|
|
{scan.policy_evaluation?.violated && (
|
|
<div
|
|
role="alert"
|
|
className="relative rounded border border-destructive/40 bg-destructive/10 px-3 py-2 pl-4 mb-3 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 mb-3">
|
|
{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>
|
|
|
|
{canGenerateSbom && (
|
|
<div className="flex flex-wrap items-center gap-2 mt-3">
|
|
<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>
|
|
</div>
|
|
)}
|
|
|
|
{compareOpen && canCompare && (
|
|
<div className="pt-3 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>
|
|
)}
|
|
</SheetSection>
|
|
|
|
{tab === 'vulns' && (
|
|
<SheetSection title={`Vulnerabilities · ${totalDetails}`}>
|
|
<div className="flex items-center gap-1 flex-wrap mb-3">
|
|
{(['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="text-xs text-stat-subtitle font-mono mb-2">
|
|
Showing first {details.length} of {totalDetails}. Export CSV for the complete list.
|
|
</div>
|
|
)}
|
|
|
|
<ScrollArea block className="max-h-[60vh]">
|
|
{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>
|
|
)}
|
|
</ScrollArea>
|
|
</SheetSection>
|
|
)}
|
|
|
|
{tab === 'secrets' && (
|
|
<SheetSection title={`Secrets · ${scan.secret_count ?? secrets.length}`}>
|
|
{secretsNeedsPagination && (
|
|
<div className="flex items-center gap-1 mb-3">
|
|
<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 block className="max-h-[60vh]">
|
|
{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>
|
|
)}
|
|
</ScrollArea>
|
|
</SheetSection>
|
|
)}
|
|
|
|
{tab === 'misconfigs' && (
|
|
<SheetSection title={`Misconfigs · ${scan.misconfig_count ?? misconfigs.length}`}>
|
|
{misconfigsNeedsPagination && (
|
|
<div className="flex items-center gap-1 mb-3">
|
|
<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 block className="max-h-[60vh]">
|
|
{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>
|
|
{canManageSuppressions && <TableHead className="w-[40px]" />}
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{misconfigsPageItems.map((m) => (
|
|
<TableRow
|
|
key={m.id}
|
|
className={cn(
|
|
SEVERITY_ROW_TINT[m.severity],
|
|
m.acknowledged && 'opacity-60',
|
|
)}
|
|
title={
|
|
m.acknowledged
|
|
? `Acknowledged: ${m.acknowledgement_reason ?? '(no reason)'}`
|
|
: undefined
|
|
}
|
|
>
|
|
<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={cn('text-xs', m.acknowledged && 'line-through')}>
|
|
<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>
|
|
{canManageSuppressions && (
|
|
<TableCell>
|
|
{!m.acknowledged && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
onClick={() => openAckDialog(m)}
|
|
title="Acknowledge this misconfiguration"
|
|
>
|
|
<ShieldCheck className="w-3.5 h-3.5" strokeWidth={1.5} />
|
|
</Button>
|
|
)}
|
|
</TableCell>
|
|
)}
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</ScrollArea>
|
|
</SheetSection>
|
|
)}
|
|
</>
|
|
)}
|
|
</SystemSheet>
|
|
|
|
<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>
|
|
|
|
<Dialog
|
|
open={ackForm !== null}
|
|
onOpenChange={(open) => !open && setAckForm(null)}
|
|
>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>Acknowledge misconfiguration</DialogTitle>
|
|
<DialogDescription className="sr-only">
|
|
Accept this misconfiguration as known-benign so it stops triggering alerts across the fleet.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
{ackForm && (
|
|
<div className="space-y-4 py-2">
|
|
<div className="space-y-1">
|
|
<Label className="text-xs uppercase tracking-wide text-stat-subtitle">Rule</Label>
|
|
<div className="font-mono text-sm">{ackForm.ruleId}</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="ack-pattern">Stack pattern (optional)</Label>
|
|
<Input
|
|
id="ack-pattern"
|
|
placeholder="e.g. traefik or web-* (leave blank for all stacks)"
|
|
value={ackForm.stackPattern}
|
|
onChange={(e) =>
|
|
setAckForm((f) => (f ? { ...f, stackPattern: e.target.value } : f))
|
|
}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Glob pattern matched against the stack name. Prefilled with the current stack so this ack stays narrowest by default.
|
|
</p>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="ack-reason">Reason</Label>
|
|
<textarea
|
|
id="ack-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 misconfiguration safe to accept?"
|
|
value={ackForm.reason}
|
|
onChange={(e) =>
|
|
setAckForm((f) => (f ? { ...f, reason: e.target.value } : f))
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="ack-expiry">Expires in (days, optional)</Label>
|
|
<Input
|
|
id="ack-expiry"
|
|
type="number"
|
|
min="1"
|
|
placeholder="Leave blank for no expiry"
|
|
value={ackForm.expiresInDays}
|
|
onChange={(e) =>
|
|
setAckForm((f) => (f ? { ...f, expiresInDays: e.target.value } : f))
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setAckForm(null)} disabled={savingAck}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={submitAcknowledgement} disabled={savingAck}>
|
|
{savingAck ? 'Saving...' : 'Acknowledge'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export { SeverityChip };
|
|
export type { VulnerabilityScanSheetProps };
|