mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 11:16:55 +00:00
117f590332
* fix(security): gate admin-only scan affordances on isAdmin Backend already required admin for SBOM, SARIF, scan policies, Trivy install/update/uninstall, the auto-update toggle, CVE suppressions, and misconfig acknowledgements. The matching frontend surfaces were gated only on isPaid (or only on isReplica), so non-admin users at the same tier saw buttons that returned 403 on click. Threads isAdmin from useAuth() into SecuritySection, SuppressionsPanel, and MisconfigAckPanel. Updates the scan-result sheet caller in ResourcesView so SBOM and SARIF render only when paid AND admin; passes canManageSuppressions to the stack-misconfig sheet so admins can ack misconfigs from that surface too. Read paths remain visible to non-admins (policy list, suppression list, ack list, scan history) since the GET routes are auth-only on both sides. * fix(security): close 3 remaining scan-sheet parity gaps from review Code review surfaced three sites missed in the first pass: 1. SecurityHistoryView opened the scan sheet with canGenerateSbom set only on isPaid, so Skipper non-admins saw SBOM and SARIF buttons even though the backend requires admin+paid. Now ANDed with isAdmin. 2. ResourcesView passed onRescan unconditionally, and the sheet renders a Re-scan primary action whenever onRescan is defined. Non-admins reaching the sheet via the severity-badge shortcut saw the button; clicking it called POST /security/scan, which the backend requires admin for. onRescan is now undefined for non-admins. 3. ShellOverlays and ResourcesView passed canManageSuppressions=isAdmin without considering the replica gate, so a replica admin saw suppress and ack columns whose backend writes blockIfReplica. The sheet now probes /fleet/role internally and ANDs !isReplica into the effective canManageSuppressions, so the column hides on a replica regardless of how the caller wired the prop. * fix(security): clear isReplica state on every scan-sheet probe The previous probe only flipped the state to true on a replica response and never wrote false on a control, non-OK, or skipped probe. With the sheet kept mounted by ResourcesView, SecurityHistoryView, and ShellOverlays, an admin who first viewed a scan on a replica would keep suppress/ack controls hidden even after switching to a control instance, because the stale true value persisted across re-opens. The effect now resets isReplica to false at the start of every probe and assigns the result of /fleet/role directly. Probe failures and skips leave the state at false, so the UI is permissive and the backend blockIfReplica guard remains the source of truth.
324 lines
12 KiB
TypeScript
324 lines
12 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import { Input } from '@/components/ui/input';
|
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
import { SystemSheet, SheetSection } from '@/components/ui/system-sheet';
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table';
|
|
import {
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
GitCompare,
|
|
RefreshCw,
|
|
Search,
|
|
ShieldCheck,
|
|
} from 'lucide-react';
|
|
import { apiFetch } from '@/lib/api';
|
|
import { toast } from '@/components/ui/toast-store';
|
|
import { cn } from '@/lib/utils';
|
|
import { ScanComparisonSheet } from './ScanComparisonSheet';
|
|
import { SeverityChip } from './VulnerabilityScanSheet';
|
|
import { VulnerabilityScanSheet } from './VulnerabilityScanSheet';
|
|
import { useLicense } from '@/context/LicenseContext';
|
|
import { useAuth } from '@/context/AuthContext';
|
|
import { useNodes } from '@/context/NodeContext';
|
|
import type { VulnerabilityScan } from '@/types/security';
|
|
|
|
const PAGE_SIZE = 100;
|
|
|
|
interface GroupedScans {
|
|
image_ref: string;
|
|
scans: VulnerabilityScan[];
|
|
}
|
|
|
|
function groupByImage(scans: VulnerabilityScan[]): GroupedScans[] {
|
|
const map = new Map<string, VulnerabilityScan[]>();
|
|
for (const s of scans) {
|
|
const list = map.get(s.image_ref) ?? [];
|
|
list.push(s);
|
|
map.set(s.image_ref, list);
|
|
}
|
|
const groups: GroupedScans[] = [];
|
|
for (const [image_ref, list] of map.entries()) {
|
|
list.sort((a, b) => b.scanned_at - a.scanned_at);
|
|
groups.push({ image_ref, scans: list });
|
|
}
|
|
groups.sort((a, b) => (b.scans[0]?.scanned_at ?? 0) - (a.scans[0]?.scanned_at ?? 0));
|
|
return groups;
|
|
}
|
|
|
|
interface SecurityHistoryViewProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function SecurityHistoryView({ open, onClose }: SecurityHistoryViewProps) {
|
|
const { isPaid } = useLicense();
|
|
const { isAdmin } = useAuth();
|
|
const { activeNode } = useNodes();
|
|
const [scans, setScans] = useState<VulnerabilityScan[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [loading, setLoading] = useState(false);
|
|
const [searchDraft, setSearchDraft] = useState('');
|
|
const [search, setSearch] = useState('');
|
|
const [selected, setSelected] = useState<number[]>([]);
|
|
const [compareIds, setCompareIds] = useState<[number, number] | null>(null);
|
|
const [inspectScanId, setInspectScanId] = useState<number | null>(null);
|
|
const [page, setPage] = useState(0);
|
|
|
|
const load = useCallback(async (pageToLoad: number, searchTerm: string) => {
|
|
setLoading(true);
|
|
try {
|
|
const params = new URLSearchParams({
|
|
status: 'completed',
|
|
limit: String(PAGE_SIZE),
|
|
offset: String(pageToLoad * PAGE_SIZE),
|
|
});
|
|
if (searchTerm.trim()) params.set('imageRefLike', searchTerm.trim());
|
|
const res = await apiFetch(`/security/scans?${params.toString()}`);
|
|
if (!res.ok) throw new Error('Failed to load scans');
|
|
const body = await res.json();
|
|
const items: VulnerabilityScan[] = Array.isArray(body?.items) ? body.items : [];
|
|
setScans(items);
|
|
setTotal(typeof body?.total === 'number' ? body.total : items.length);
|
|
} catch (err) {
|
|
toast.error((err as Error)?.message || 'Could not load scan history');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
const lastNodeIdRef = useRef<number | null>(activeNode?.id ?? null);
|
|
const [reloadToken, setReloadToken] = useState(0);
|
|
|
|
useEffect(() => {
|
|
const id = activeNode?.id ?? null;
|
|
if (lastNodeIdRef.current === id) return;
|
|
lastNodeIdRef.current = id;
|
|
setSelected([]);
|
|
setPage(0);
|
|
setReloadToken((t) => t + 1);
|
|
}, [activeNode?.id]);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
load(page, search);
|
|
// reloadToken bumps when the active node changes even if page/search
|
|
// happen to match the previous values, so the fetch re-runs exactly once.
|
|
}, [open, load, page, search, reloadToken]);
|
|
|
|
useEffect(() => {
|
|
const t = setTimeout(() => {
|
|
setSearch(searchDraft);
|
|
setPage(0);
|
|
}, 300);
|
|
return () => clearTimeout(t);
|
|
}, [searchDraft]);
|
|
|
|
const groups = useMemo(() => groupByImage(scans), [scans]);
|
|
|
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
|
const safePage = Math.min(page, totalPages - 1);
|
|
const needsPagination = total > PAGE_SIZE;
|
|
|
|
const toggleSelect = (scanId: number) => {
|
|
setSelected((prev) => {
|
|
if (prev.includes(scanId)) return prev.filter((x) => x !== scanId);
|
|
if (prev.length >= 2) return [prev[1], scanId];
|
|
return [...prev, scanId];
|
|
});
|
|
};
|
|
|
|
const compareSelected = () => {
|
|
if (selected.length !== 2) return;
|
|
const [aId, bId] = selected;
|
|
const a = scans.find((s) => s.id === aId);
|
|
const b = scans.find((s) => s.id === bId);
|
|
if (!a || !b) return;
|
|
const [older, newer] = a.scanned_at <= b.scanned_at ? [a, b] : [b, a];
|
|
setCompareIds([older.id, newer.id]);
|
|
};
|
|
|
|
const compareDisabled = selected.length !== 2;
|
|
const meta = `${total} scan${total === 1 ? '' : 's'} · ${groups.length} image${groups.length === 1 ? '' : 's'}`;
|
|
const footerContext = `Node ${activeNode?.name ?? '—'}`;
|
|
|
|
return (
|
|
<SystemSheet
|
|
open={open}
|
|
onOpenChange={(next) => { if (!next) onClose(); }}
|
|
crumb={['Security', 'Scan history']}
|
|
name="Scan history"
|
|
meta={meta}
|
|
primaryAction={{
|
|
label: `Compare (${selected.length}/2)`,
|
|
icon: GitCompare,
|
|
onClick: compareSelected,
|
|
disabled: compareDisabled,
|
|
}}
|
|
secondaryActions={[{
|
|
label: 'Refresh',
|
|
icon: RefreshCw,
|
|
onClick: () => load(safePage, search),
|
|
disabled: loading,
|
|
}]}
|
|
footerContext={footerContext}
|
|
size="xl"
|
|
>
|
|
<SheetSection title="Scans" hideHeader>
|
|
<div className="flex items-center gap-3 mb-3 flex-wrap">
|
|
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
|
|
<Input
|
|
placeholder="Search by image..."
|
|
value={searchDraft}
|
|
onChange={(e) => setSearchDraft(e.target.value)}
|
|
className="pl-8"
|
|
/>
|
|
</div>
|
|
{needsPagination && (
|
|
<div className="flex items-center gap-1 ml-auto" aria-live="polite">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6"
|
|
onClick={() => setPage(Math.max(0, safePage - 1))}
|
|
disabled={safePage === 0}
|
|
aria-label="Previous page"
|
|
>
|
|
<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"
|
|
aria-label={`Page ${safePage + 1} of ${totalPages}`}
|
|
>
|
|
{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}
|
|
aria-label="Next page"
|
|
>
|
|
<ChevronRight className="w-4 h-4" strokeWidth={1.5} />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{groups.length === 0 && !loading ? (
|
|
<div className="flex flex-col items-center justify-center text-center py-16 gap-2">
|
|
<ShieldCheck className="w-8 h-8 text-muted-foreground" strokeWidth={1.5} />
|
|
<div className="text-sm text-muted-foreground">
|
|
{search
|
|
? 'No completed scans match your search.'
|
|
: 'No scans have completed on this node yet.'}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<ScrollArea block className="max-h-[60vh]">
|
|
<div className="space-y-5">
|
|
{groups.map((group) => (
|
|
<div key={group.image_ref}>
|
|
<div className="flex items-center gap-2 mb-1.5">
|
|
<span className="font-mono text-sm truncate" title={group.image_ref}>
|
|
{group.image_ref}
|
|
</span>
|
|
<span className="text-xs text-stat-subtitle">
|
|
{group.scans.length} scan{group.scans.length === 1 ? '' : 's'}
|
|
</span>
|
|
</div>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-[40px]" />
|
|
<TableHead className="w-[180px]">Scanned</TableHead>
|
|
<TableHead className="w-[120px]">Trigger</TableHead>
|
|
<TableHead className="w-[120px]">Highest</TableHead>
|
|
<TableHead className="w-[90px] text-right">Total</TableHead>
|
|
<TableHead className="w-[90px] text-right">Fixable</TableHead>
|
|
<TableHead />
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{group.scans.map((scan) => {
|
|
const isSelected = selected.includes(scan.id);
|
|
return (
|
|
<TableRow
|
|
key={scan.id}
|
|
className={cn(isSelected && 'bg-accent/30')}
|
|
>
|
|
<TableCell>
|
|
<Checkbox
|
|
checked={isSelected}
|
|
onCheckedChange={() => toggleSelect(scan.id)}
|
|
aria-label={`Select scan ${scan.id}`}
|
|
/>
|
|
</TableCell>
|
|
<TableCell className="font-mono text-xs">
|
|
{new Date(scan.scanned_at).toLocaleString()}
|
|
</TableCell>
|
|
<TableCell className="font-mono text-xs capitalize">
|
|
{scan.triggered_by}
|
|
</TableCell>
|
|
<TableCell>
|
|
{scan.highest_severity ? (
|
|
<SeverityChip severity={scan.highest_severity} />
|
|
) : (
|
|
<span className="text-xs text-success font-mono">none</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="text-right font-mono text-xs tabular-nums">
|
|
{scan.total_vulnerabilities}
|
|
</TableCell>
|
|
<TableCell className="text-right font-mono text-xs tabular-nums text-success">
|
|
{scan.fixable_count}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 text-xs"
|
|
onClick={() => setInspectScanId(scan.id)}
|
|
>
|
|
Open
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</ScrollArea>
|
|
)}
|
|
</SheetSection>
|
|
|
|
<ScanComparisonSheet
|
|
baselineScanId={compareIds?.[0] ?? null}
|
|
currentScanId={compareIds?.[1] ?? null}
|
|
onClose={() => setCompareIds(null)}
|
|
/>
|
|
|
|
<VulnerabilityScanSheet
|
|
scanId={inspectScanId}
|
|
onClose={() => setInspectScanId(null)}
|
|
canGenerateSbom={isPaid && isAdmin}
|
|
canCompare={false}
|
|
canManageSuppressions={isAdmin}
|
|
/>
|
|
</SystemSheet>
|
|
);
|
|
}
|