Files
sencho/frontend/src/components/VulnerabilityScanSheet.tsx
T
Anso fcd44f5693 fix(security): tie fixable CVE posture to image-update evidence (#1815)
* fix(security): tie fixable CVE posture to image-update evidence

Stop treating Trivy fixed_version alone as an Update affected images CTA. Reuse persisted ImageUpdateService status so Security only offers Review update when an applicable image update is confirmed, and otherwise surfaces waiting or uncertain remediation with truthful affordances.

* fix(security): move image-update recheck helper out of OverviewTab

Satisfy react-refresh/only-export-components so Frontend lint passes.

* fix(security): preserve posture reason image targets in Images drill-down

Carry affected image refs on overview reasons so public exposure and related CTAs open a clearable targeted Images list instead of an unfiltered hunt.

* fix(security): attach Networking exposure intent to posture targets

Preserve stack/service context and intentional classification on network-exposed Security reasons without suppressing risk or claiming Internet reachability.

* fix(security): persist Images exposure intent and triage scope

Standing image summaries carry Networking intent context with cap-safe aggregates, Anatomy Networking links, and scan-sheet triage that defaults to the current image.

* fix(security): clear CI lint errors for exposure helpers

* fix(security): stop intentional exposure from forcing Action needed

Separate exposure fact, intent correctness, and vulnerability drivers so
package fixed_version cannot recreate a permanent public_exposure blocker.

* fix(security): define Monitoring residual-risk narrative

* fix(security): define Secure via residual Crit/High triage

Replace triage-blind raw Crit/High Secure gating with residual material
risk so accepted and ignored stay Monitoring, while not_affected, false
positive, and fixed can clear residual without claiming no detections.

* fix(security): exclude rollback-hold images from Security scans

Hold-only sencho-rb tags are recovery state; keep them out of Trivy node scans, Security inventory, and Overview posture while dual-tagged images remain under their registry tag.

* fix(security): keep authoritative no-update rows after preview

Opening a stack page must not delete ok+false stack_update_status evidence; Security treats a missing row as uncertain and would flip waiting-upstream to unknown.
2026-08-12 15:02:14 -04:00

1370 lines
59 KiB
TypeScript

import { type ReactNode, 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { ScanComparisonSheet } from './ScanComparisonSheet';
import { fetchAllScanVulnerabilities } from './VulnerabilityScanSheet.export';
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 { formatPolicyReasons } from '@/lib/policyReasons';
import type {
VulnerabilityScan,
VulnerabilityDetail,
VulnSeverity,
SecretFinding,
MisconfigFinding,
ScanDetailTab,
TriageStatus,
} from '@/types/security';
import { TRIAGE_STATUS_OPTIONS, TRIAGE_JUSTIFICATION_OPTIONS, TRIAGE_STATUS_HINT, openVexRequiresJustification, justificationForStatus, triageStatusLabel } from '@/lib/triage';
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;
/**
* When set (from a posture reason with drivers), the vuln list is filtered
* to these CVE/GHSA ids and a driving / monitoring findings banner shows.
*/
driverVulnerabilityIds?: string[];
/**
* action = Action-needed driver set; monitoring = waiting/uncertain review set.
* Defaults to action when omitted.
*/
driverFilterMode?: 'action' | 'monitoring';
/** Full driver count before cap (for truncated title). */
driverCount?: number;
driversTruncated?: boolean;
}
interface SuppressDialogState {
cveId: string;
pkgName: string;
imagePattern: string;
reason: string;
expiresInDays: string;
status: TriageStatus;
justification: 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>
);
}
const EVIDENCE_TAG_CLASSES = {
danger: 'text-destructive border-destructive/40 bg-destructive/10',
warn: 'text-warning border-warning/40 bg-warning/10',
muted: 'text-stat-subtitle border-border bg-muted/30',
neutral: 'text-stat-value border-border bg-muted/20',
} as const;
function EvidenceTag({ tone, children }: { tone: keyof typeof EVIDENCE_TAG_CLASSES; children: ReactNode }) {
return (
<span className={cn('inline-flex items-center rounded border px-1.5 py-px text-[9px] font-mono uppercase tracking-[0.1em]', EVIDENCE_TAG_CLASSES[tone])}>
{children}
</span>
);
}
/**
* Small, independently-verifiable evidence atoms per finding. Severity is one
* signal among several, not the only one: these surface exploit intel (KEV,
* EPSS), vendor status, and the CVSS score so an operator can tell scary from
* exploitable without an invented composite priority number.
*/
function EvidenceTags({ d }: { d: VulnerabilityDetail }) {
const tags: ReactNode[] = [];
if (d.kev) tags.push(<EvidenceTag key="kev" tone="danger">KEV</EvidenceTag>);
if (typeof d.epss_score === 'number') {
tags.push(
<EvidenceTag key="epss" tone={d.epss_score >= 0.1 ? 'warn' : 'muted'}>
EPSS {Math.round(d.epss_score * 100)}%
</EvidenceTag>,
);
}
if (d.status === 'will_not_fix' || d.status === 'end_of_life') {
tags.push(<EvidenceTag key="wontfix" tone="muted">{"Won't fix"}</EvidenceTag>);
}
if (typeof d.cvss_score === 'number') {
tags.push(<EvidenceTag key="cvss" tone="neutral">CVSS {d.cvss_score}</EvidenceTag>);
}
if (d.triage_status) {
tags.push(
<EvidenceTag key="triage" tone={d.triage_status === 'affected' || d.triage_status === 'needs_review' ? 'warn' : 'muted'}>
{triageStatusLabel(d.triage_status)}
</EvidenceTag>,
);
}
if (tags.length === 0) return null;
return <span className="mt-1 flex flex-wrap items-center gap-1">{tags}</span>;
}
function isActiveTriageStatus(status: TriageStatus | undefined): boolean {
return status === 'affected' || status === 'needs_review';
}
export function VulnerabilityScanSheet({
scanId,
onClose,
onRescan,
canGenerateSbom = false,
canExportSarif = false,
canCompare = false,
canManageSuppressions: canManageSuppressionsProp = false,
initialTab,
driverVulnerabilityIds,
driverFilterMode = 'action',
driverCount,
driversTruncated = false,
}: 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 [exportingCsv, setExportingCsv] = useState(false);
const DETAIL_FETCH_LIMIT = 500;
const load = useCallback(async () => {
if (scanId == null) return;
setLoading(true);
try {
// The vulnerability list is fetched in full (paging past the per-request
// cap), not a single capped page, so severity filtering, inspection, and
// suppression reach every finding rather than only the first page.
const [scanRes, allVulns, secretsRes, misconfigsRes] = await Promise.all([
apiFetch(`/security/scans/${scanId}`),
fetchAllScanVulnerabilities(scanId),
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');
const scanData = (await scanRes.json()) as VulnerabilityScan;
const secretsData = secretsRes.ok ? await secretsRes.json() : { items: [] };
const misconfigsData = misconfigsRes.ok ? await misconfigsRes.json() : { items: [] };
setScan(scanData);
setDetails(allVulns);
setTotalDetails(allVulns.length);
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(() => {
let rows = details;
if (driverVulnerabilityIds && driverVulnerabilityIds.length > 0) {
const allow = new Set(driverVulnerabilityIds);
rows = rows.filter((d) => allow.has(d.vulnerability_id));
}
if (severityFilter === 'ALL') return rows;
return rows.filter((d) => d.severity === severityFilter);
}, [details, severityFilter, driverVulnerabilityIds]);
const drivingFindings = (driverVulnerabilityIds?.length ?? 0) > 0;
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: scan?.image_ref ?? '',
reason: '',
expiresInDays: '',
status: 'accepted',
justification: '',
});
}, [scan?.image_ref]);
const submitSuppression = useCallback(async () => {
if (!suppressForm) return;
const reason = suppressForm.reason.trim();
if (!reason) {
toast.error('A reason is required.');
return;
}
const justification = suppressForm.justification.trim();
if (openVexRequiresJustification(suppressForm.status) && !justification) {
toast.error('An OpenVEX justification is required for this triage decision.');
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,
status: suppressForm.status,
justification: openVexRequiresJustification(suppressForm.status) ? (justification || null) : null,
}),
});
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(async () => {
if (!scan || details.length === 0) return;
setExportingCsv(true);
try {
// The table renders a capped page; the CSV is the complete-list recovery
// path the in-sheet notice promises, so fetch every row when the loaded
// set is short of the total. Otherwise reuse what is already in memory.
const rows =
details.length < totalDetails
? await fetchAllScanVulnerabilities(scan.id)
: details;
const escape = (v: string) => `"${v.replace(/"/g, '""')}"`;
const csv =
'CVE,Package,Severity,Installed,Fixed,URL\n' +
rows
.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 url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
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);
toast.success(`Exported ${rows.length} ${rows.length === 1 ? 'vulnerability' : 'vulnerabilities'}`);
} catch (err) {
toast.error((err as Error)?.message || 'CSV export failed');
} finally {
setExportingCsv(false);
}
}, [scan, details, totalDetails]);
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 ? (
<span className="inline-flex flex-wrap items-center gap-2">
{scan.total_vulnerabilities} vulns · {scan.fixable_count} fixable · {scan.triggered_by}
{scan.publicly_exposed === true && (
<EvidenceTag tone="warn">Network exposed</EvidenceTag>
)}
</span>
) : (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: exportingCsv ? Loader2 : Download,
onClick: () => { void exportCsv(); },
disabled: exportingCsv,
}] : []),
...(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"
noScroll
>
{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 && (
// noScroll skips SystemSheet's default px-6 py-5 wrapper, so supply it
// here; SheetSection's -mx-6 bleed depends on this px-6. The column lets
// the active finding section flex to fill the sheet (single scroll box).
<div className="flex min-h-0 flex-1 flex-col px-6 py-5">
<SheetSection title="Summary" className="shrink-0">
{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">
This scan violates{' '}
<span className="font-mono">{scan.policy_evaluation.policyName}</span>
{scan.policy_evaluation.reasons.length > 0 ? (
<>: matched {formatPolicyReasons(scan.policy_evaluation.reasons, scan.policy_evaluation.maxSeverity)}.</>
) : (
<>.</>
)}
</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={
drivingFindings
? (() => {
const n = filtered.length;
const total = driverCount ?? n;
const truncated = driversTruncated && total > n;
if (driverFilterMode === 'monitoring') {
return truncated
? `Findings under Monitoring · showing ${n} of ${total}`
: `Findings under Monitoring · ${n} finding${n === 1 ? '' : 's'}`;
}
return truncated
? `Driving current Security action · showing ${n} of ${total}`
: `Driving current Security action · ${n} finding${n === 1 ? '' : 's'}`;
})()
: `Vulnerabilities · ${totalDetails}`
}
className="flex min-h-0 flex-1 flex-col"
>
{drivingFindings ? (
<p className="text-xs text-stat-subtitle mb-3">
{driverFilterMode === 'monitoring'
? 'Showing findings under Monitoring for this image. Remediating or triaging these findings can change Security posture.'
: 'Showing the findings that currently drive Action needed for this image. Remediating or triaging these findings updates Security posture.'}
</p>
) : null}
<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>
<ScrollArea block className="flex-1 min-h-0">
{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 className="max-md:min-w-[720px]">
<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);
const dimmed = Boolean(d.suppressed) && !isActiveTriageStatus(d.triage_status);
return (
<TableRow
key={d.id}
className={cn(SEVERITY_ROW_TINT[d.severity], dimmed && 'opacity-60')}
>
<TableCell className="font-mono text-xs tabular-nums align-top">
<span className="flex flex-col">
<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>
<EvidenceTags d={d} />
</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="Triage finding"
aria-label="Triage finding"
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}`} className="flex min-h-0 flex-1 flex-col">
{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="flex-1 min-h-0">
{secrets.length === 0 ? (
<div className="text-center text-sm text-muted-foreground py-12">
No secrets detected.
</div>
) : (
<Table className="max-md:min-w-[720px]">
<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}`} className="flex min-h-0 flex-1 flex-col">
{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="flex-1 min-h-0">
{misconfigs.length === 0 ? (
<div className="text-center text-sm text-muted-foreground py-12">
No misconfigurations detected.
</div>
) : (
<Table className="max-md:min-w-[720px]">
<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>
)}
</div>
)}
</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>Triage finding</DialogTitle>
<DialogDescription className="sr-only">
Record a triage decision for this finding so it stops or continues driving security posture.
</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">
Defaults to this image. Clear or broaden the pattern to apply more widely; leave blank for every image.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="suppress-status">Triage decision</Label>
<Select
value={suppressForm.status}
onValueChange={(v) => {
const status = v as TriageStatus;
setSuppressForm((f) =>
f ? { ...f, status, justification: justificationForStatus(status, f.justification) } : f,
);
}}
>
<SelectTrigger id="suppress-status" aria-label="Triage decision" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TRIAGE_STATUS_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{TRIAGE_STATUS_HINT}
</p>
</div>
{openVexRequiresJustification(suppressForm.status) && (
<div className="space-y-2">
<Label htmlFor="suppress-justification">OpenVEX justification</Label>
<Select
value={suppressForm.justification}
onValueChange={(v) =>
setSuppressForm((f) => (f ? { ...f, justification: v } : f))
}
>
<SelectTrigger id="suppress-justification" aria-label="OpenVEX justification" className="w-full">
<SelectValue placeholder="Select a justification" />
</SelectTrigger>
<SelectContent>
{TRIAGE_JUSTIFICATION_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</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...' : 'Save'}
</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 };