mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 19:57:37 +00:00
fcd44f5693
* 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.
638 lines
27 KiB
TypeScript
638 lines
27 KiB
TypeScript
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
|
import { Boxes, AlertTriangle, Search, ArrowUp, ArrowDown, ChevronLeft, ChevronRight, ShieldCheck, Loader2 } from 'lucide-react';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Combobox } from '@/components/ui/combobox';
|
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
|
import { SeverityBadge } from '@/components/ui/SeverityBadge';
|
|
import { getSeverityKey, type SeverityKey, type ImageFilterValue } from '@/lib/severityStyles';
|
|
import { formatTimeAgo } from '@/lib/relativeTime';
|
|
import { cn } from '@/lib/utils';
|
|
import { useIsMobile } from '@/hooks/use-is-mobile';
|
|
import { ImageScanRow, ImageFilterChips, type ImageFilterChip } from './SecurityMobile';
|
|
import { NetworkExposedControl, ViewNetworkingAction } from './ExposureNetworking';
|
|
import type { ImagesTargetingState } from './imagesTargeting';
|
|
import {
|
|
intentionalBannerKind,
|
|
standingExposureContexts,
|
|
standingIntentEvidence,
|
|
targetingExposureContexts,
|
|
allTargetingExposureContexts,
|
|
primaryExposureIntentEvidence,
|
|
driverIdsForImage,
|
|
} from './imagesTargeting';
|
|
import type { ImageExposureContext, ScanSummary, ScanDetailTab, ScannerKind } from '@/types/security';
|
|
// Mobile severity chips. 'FIXABLE' is a phone-only pseudo-filter (the desktop
|
|
// Combobox never emits it), so the shared filter logic treats it specially.
|
|
const MOBILE_FILTER_CHIPS: ImageFilterChip[] = [
|
|
{ value: 'all', label: 'All' },
|
|
{ value: 'CRITICAL', label: 'Critical' },
|
|
{ value: 'HIGH', label: 'High' },
|
|
{ value: 'FIXABLE', label: 'Fixable' },
|
|
{ value: 'CLEAN', label: 'Clean' },
|
|
];
|
|
|
|
const PAGE_SIZE = 12;
|
|
|
|
type SortKey = 'image_ref' | 'scanned_at' | 'severity' | 'findings';
|
|
|
|
const SEVERITY_RANK: Record<SeverityKey, number> = {
|
|
CRITICAL: 6, HIGH: 5, MEDIUM: 4, LOW: 3, UNKNOWN: 2, FINDINGS: 1, CLEAN: 0,
|
|
};
|
|
|
|
/** Sortable column header. Module-scoped so it is a stable component. */
|
|
function SortHead({ label, k, sortKey, sortDir, onSort, className }: {
|
|
label: string;
|
|
k: SortKey;
|
|
sortKey: SortKey;
|
|
sortDir: 'asc' | 'desc';
|
|
onSort: (k: SortKey) => void;
|
|
className?: string;
|
|
}) {
|
|
return (
|
|
<TableHead className={cn('text-[10px] uppercase tracking-[0.18em] cursor-pointer select-none', className)}>
|
|
<button type="button" onClick={() => onSort(k)} className="inline-flex items-center gap-1 hover:text-stat-value">
|
|
{label}
|
|
{sortKey === k && (sortDir === 'asc' ? <ArrowUp className="w-3 h-3" /> : <ArrowDown className="w-3 h-3" />)}
|
|
</button>
|
|
</TableHead>
|
|
);
|
|
}
|
|
|
|
const FILTER_OPTIONS: Array<{ value: ImageFilterValue; label: string }> = [
|
|
{ value: 'all', label: 'All severities' },
|
|
{ value: 'FIXABLE', label: 'Fixable' },
|
|
{ value: 'CRITICAL', label: 'Critical' },
|
|
{ value: 'HIGH', label: 'High' },
|
|
{ value: 'MEDIUM', label: 'Medium' },
|
|
{ value: 'LOW', label: 'Low' },
|
|
{ value: 'FINDINGS', label: 'Secrets / misconfigs' },
|
|
{ value: 'CLEAN', label: 'Clean' },
|
|
];
|
|
|
|
const findingsCount = (s: ScanSummary) => s.total + (s.secret_count ?? 0) + (s.misconfig_count ?? 0);
|
|
|
|
/** Intent evidence for standing summary, or targeting when active for this image. */
|
|
function intentEvidenceFor(
|
|
summary: ScanSummary,
|
|
targeting: ImagesTargetingState | null | undefined,
|
|
): string | null {
|
|
if (targeting?.imageRefs.includes(summary.image_ref)) {
|
|
const fromTargets = primaryExposureIntentEvidence(targeting.targets, summary.image_ref);
|
|
if (fromTargets) return fromTargets;
|
|
}
|
|
return standingIntentEvidence(summary);
|
|
}
|
|
|
|
function contextsForImage(
|
|
summary: ScanSummary,
|
|
targeting: ImagesTargetingState | null | undefined,
|
|
): ImageExposureContext[] {
|
|
if (targeting?.imageRefs.includes(summary.image_ref)) {
|
|
const fromTargets = targetingExposureContexts(targeting.targets, summary.image_ref);
|
|
if (fromTargets.length > 0) return fromTargets;
|
|
}
|
|
return standingExposureContexts(summary);
|
|
}
|
|
|
|
function IntentEvidenceLine({ line }: { line: string | null }) {
|
|
if (!line) return null;
|
|
return (
|
|
<div className="mt-0.5 font-mono text-[10px] text-stat-icon truncate">{line}</div>
|
|
);
|
|
}
|
|
|
|
const CLEAR_BTN_CLASS =
|
|
'text-xs font-medium text-brand hover:underline whitespace-nowrap shrink-0';
|
|
|
|
function TargetingClearButton({ onClear }: { onClear?: () => void }) {
|
|
if (!onClear) return null;
|
|
return (
|
|
<button type="button" onClick={onClear} className={CLEAR_BTN_CLASS}>
|
|
Clear
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function TargetingBannerFrame({ children }: { children: ReactNode }) {
|
|
return (
|
|
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card/60 px-3 py-2.5 max-md:px-3">
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function formatTargetingTitle(label: string, matched: number, total: number): string {
|
|
if (matched < total) {
|
|
return `${label} · ${matched} of ${total} affected images`;
|
|
}
|
|
return `${label} · ${matched} affected image${matched === 1 ? '' : 's'}`;
|
|
}
|
|
|
|
function IntentionalExposureBanner({
|
|
kind,
|
|
unavailableCount,
|
|
contexts,
|
|
nodeId,
|
|
onClear,
|
|
}: {
|
|
kind: 'absolute' | 'partial';
|
|
unavailableCount: number;
|
|
contexts: ImageExposureContext[];
|
|
nodeId?: number;
|
|
onClear?: () => void;
|
|
}) {
|
|
const title = kind === 'absolute'
|
|
? 'Exposure is intentional'
|
|
: 'Known exposure is intentional';
|
|
const body = kind === 'absolute'
|
|
? 'This workload is classified in Networking. Exposure still increases the security relevance of these findings. Open an affected image below to remediate or triage its findings.'
|
|
: `Known exposure contexts are intentionally classified. Intent could not be verified for ${unavailableCount} service${unavailableCount === 1 ? '' : 's'}. Open an affected image below to remediate or triage its findings.`;
|
|
|
|
return (
|
|
<TargetingBannerFrame>
|
|
<div className="flex items-start gap-3 justify-between">
|
|
<div className="min-w-0">
|
|
<p className="font-mono text-xs text-stat-value">{title}</p>
|
|
<p className="text-xs text-stat-subtitle mt-0.5">{body}</p>
|
|
</div>
|
|
<div className="flex items-center gap-3 shrink-0">
|
|
<ViewNetworkingAction contexts={contexts} nodeId={nodeId} />
|
|
<TargetingClearButton onClear={onClear} />
|
|
</div>
|
|
</div>
|
|
</TargetingBannerFrame>
|
|
);
|
|
}
|
|
|
|
interface ImagesTabProps {
|
|
summaries: Record<string, ScanSummary>;
|
|
loading: boolean;
|
|
/** True when the summaries fetch failed; render an error state, never a false "clean". */
|
|
error?: boolean;
|
|
onInspect: (scanId: number, initialTab?: ScanDetailTab, driverVulnerabilityIds?: string[]) => void;
|
|
/** Admin on a node with a ready scanner; gates the scan Actions column. */
|
|
canScan: boolean;
|
|
/** image_ref of the scan currently in flight, for the per-row spinner. */
|
|
scanningRef: string | null;
|
|
onScan: (imageRef: string, scanners: ScannerKind[]) => void;
|
|
/** Preselects the severity/fixable filter, e.g. when arriving from an
|
|
* overview "fixable findings" link. */
|
|
initialFilter?: ImageFilterValue;
|
|
/** Bumped by SecurityView on each filter/targeting navigation so re-apply works. */
|
|
filterToken?: number;
|
|
/** Parent-owned posture targeting (R1). */
|
|
targeting?: ImagesTargetingState | null;
|
|
onClearTargeting?: () => void;
|
|
/** When true, targeting banner discloses the overview pass may be incomplete. */
|
|
posturePartial?: boolean;
|
|
/** Active node id for SENCHO_OPEN_STACK Networking navigation. */
|
|
nodeId?: number;
|
|
}
|
|
|
|
/** Latest-scan index for real images (stack/config scans live in Compose risks). */
|
|
export function ImagesTab({
|
|
summaries,
|
|
loading,
|
|
error,
|
|
onInspect,
|
|
canScan,
|
|
scanningRef,
|
|
onScan,
|
|
initialFilter,
|
|
filterToken = 0,
|
|
targeting = null,
|
|
onClearTargeting,
|
|
posturePartial = false,
|
|
nodeId,
|
|
}: ImagesTabProps) {
|
|
const isMobile = useIsMobile();
|
|
const [search, setSearch] = useState('');
|
|
const [severity, setSeverity] = useState<ImageFilterValue>(initialFilter ?? 'all');
|
|
const [sortKey, setSortKey] = useState<SortKey>('scanned_at');
|
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
|
|
const [page, setPage] = useState(0);
|
|
const [searchExpanded, setSearchExpanded] = useState(false);
|
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
useEffect(() => { if (searchExpanded) searchInputRef.current?.focus(); }, [searchExpanded]);
|
|
|
|
// Apply externally-driven filter / targeting. Keyed on tokens so repeating the
|
|
// same navigation re-applies after Clear (R1) and resets severity (R2).
|
|
useEffect(() => {
|
|
if (targeting) {
|
|
setSeverity(initialFilter ?? 'all');
|
|
setPage(0);
|
|
return;
|
|
}
|
|
if (initialFilter) {
|
|
setSeverity(initialFilter);
|
|
setPage(0);
|
|
}
|
|
}, [targeting?.token, filterToken, targeting, initialFilter]);
|
|
|
|
const imageSummaries = useMemo(
|
|
() => Object.values(summaries).filter((s) => !s.image_ref.startsWith('stack:')),
|
|
[summaries],
|
|
);
|
|
|
|
const matchedTargetMeta = useMemo(() => {
|
|
if (!targeting || targeting.imageRefs.length === 0) {
|
|
return { active: false as const, matched: 0, total: 0, refs: null as Set<string> | null };
|
|
}
|
|
const wanted = new Set(targeting.imageRefs);
|
|
const refs = new Set(
|
|
imageSummaries.filter((s) => wanted.has(s.image_ref)).map((s) => s.image_ref),
|
|
);
|
|
return {
|
|
active: true as const,
|
|
matched: refs.size,
|
|
total: targeting.imageRefs.length,
|
|
refs,
|
|
label: targeting.label,
|
|
};
|
|
}, [targeting, imageSummaries]);
|
|
|
|
// R3: never filter the list at zero matches; fall back to the full list.
|
|
const targetingActive = matchedTargetMeta.active && matchedTargetMeta.matched > 0;
|
|
|
|
const filtered = useMemo(() => {
|
|
const term = search.trim().toLowerCase();
|
|
const targetRefs = targetingActive ? matchedTargetMeta.refs : null;
|
|
return imageSummaries
|
|
.filter((s) => (targetRefs ? targetRefs.has(s.image_ref) : true))
|
|
.filter((s) => (term ? s.image_ref.toLowerCase().includes(term) : true))
|
|
.filter((s) => {
|
|
if (severity === 'all') return true;
|
|
if (severity === 'FIXABLE') return s.fixable > 0;
|
|
return getSeverityKey(s) === severity;
|
|
});
|
|
}, [imageSummaries, search, severity, targetingActive, matchedTargetMeta.refs]);
|
|
|
|
const sorted = useMemo(() => {
|
|
const dir = sortDir === 'asc' ? 1 : -1;
|
|
return [...filtered].sort((a, b) => {
|
|
switch (sortKey) {
|
|
case 'image_ref': return a.image_ref.localeCompare(b.image_ref) * dir;
|
|
case 'severity': return (SEVERITY_RANK[getSeverityKey(a)] - SEVERITY_RANK[getSeverityKey(b)]) * dir;
|
|
case 'findings': return (findingsCount(a) - findingsCount(b)) * dir;
|
|
default: return (a.scanned_at - b.scanned_at) * dir;
|
|
}
|
|
});
|
|
}, [filtered, sortKey, sortDir]);
|
|
|
|
const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE));
|
|
const safePage = Math.min(page, totalPages - 1);
|
|
const pageItems = sorted.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE);
|
|
|
|
const toggleSort = (key: SortKey) => {
|
|
if (sortKey === key) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
|
else { setSortKey(key); setSortDir(key === 'image_ref' ? 'asc' : 'desc'); }
|
|
setPage(0);
|
|
};
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center py-20 text-center">
|
|
<AlertTriangle className="w-12 h-12 text-warning/60 mb-4" strokeWidth={1.5} />
|
|
<h3 className="text-lg font-medium mb-1">Couldn't load scan results</h3>
|
|
<p className="text-sm text-muted-foreground">Scan results failed to load for this node. Try again shortly.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="space-y-2" aria-busy="true">
|
|
<Skeleton className="h-12 w-full rounded-lg" />
|
|
<Skeleton className="h-12 w-full rounded-lg" />
|
|
<Skeleton className="h-12 w-full rounded-lg" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const noImagesAtAll = imageSummaries.length === 0;
|
|
if (noImagesAtAll) {
|
|
return (
|
|
<div className="space-y-4">
|
|
{targeting && onClearTargeting ? (
|
|
<TargetingBannerFrame>
|
|
<div className="flex items-start gap-3 justify-between">
|
|
<p className="text-xs text-stat-subtitle">
|
|
None of the images for this Security action have a scan summary on this node.
|
|
</p>
|
|
<TargetingClearButton onClear={onClearTargeting} />
|
|
</div>
|
|
</TargetingBannerFrame>
|
|
) : null}
|
|
<div className="flex flex-col items-center justify-center py-20 text-center">
|
|
<Boxes className="w-12 h-12 text-muted-foreground/50 mb-4" strokeWidth={1.5} />
|
|
<h3 className="text-lg font-medium mb-1">No scanned images</h3>
|
|
<p className="text-sm text-muted-foreground">Scan an image from Resources to see its findings here.</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
let targetingBanner: ReactNode = null;
|
|
if (matchedTargetMeta.active && matchedTargetMeta.matched === 0) {
|
|
targetingBanner = (
|
|
<TargetingBannerFrame>
|
|
<div className="flex items-start gap-3 justify-between">
|
|
<p className="text-xs text-stat-subtitle">
|
|
None of the images for this Security action have a scan summary on this node. Showing all images.
|
|
</p>
|
|
<TargetingClearButton onClear={onClearTargeting} />
|
|
</div>
|
|
</TargetingBannerFrame>
|
|
);
|
|
} else if (matchedTargetMeta.active && matchedTargetMeta.matched > 0 && targeting) {
|
|
const isExposure = targeting.kind === 'public_exposure';
|
|
const hasConflict = isExposure && targeting.targets.some((t) => t.intentConflict);
|
|
const driverCount = targeting.drivers?.length ?? 0;
|
|
const intentional = isExposure && !hasConflict
|
|
? intentionalBannerKind(targeting.targets, { truncated: posturePartial })
|
|
: { kind: 'none' as const, unavailableCount: 0 };
|
|
|
|
if (hasConflict) {
|
|
targetingBanner = (
|
|
<TargetingBannerFrame>
|
|
<div className="flex items-start gap-3 justify-between">
|
|
<div className="min-w-0">
|
|
<p className="font-mono text-xs text-stat-value">Exposure conflicts with declared intent</p>
|
|
<p className="text-xs text-stat-subtitle mt-0.5">
|
|
Compose publishes beyond loopback while Networking intent is internal or same-node. Review networking to align configuration with intent.
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-3 shrink-0">
|
|
<ViewNetworkingAction contexts={allTargetingExposureContexts(targeting.targets)} nodeId={nodeId} />
|
|
<TargetingClearButton onClear={onClearTargeting} />
|
|
</div>
|
|
</div>
|
|
</TargetingBannerFrame>
|
|
);
|
|
} else if (intentional.kind === 'absolute' || intentional.kind === 'partial') {
|
|
targetingBanner = (
|
|
<IntentionalExposureBanner
|
|
kind={intentional.kind}
|
|
unavailableCount={intentional.unavailableCount}
|
|
contexts={allTargetingExposureContexts(targeting.targets)}
|
|
nodeId={nodeId}
|
|
onClear={onClearTargeting}
|
|
/>
|
|
);
|
|
} else {
|
|
const partialMatch = matchedTargetMeta.matched < matchedTargetMeta.total;
|
|
const fullDriverCount = targeting.driverCount ?? driverCount;
|
|
const drivingTitle = driverCount > 0;
|
|
const monitoringKinds = new Set(['waiting_upstream', 'update_check_uncertain']);
|
|
const monitoringMode = monitoringKinds.has(targeting.kind);
|
|
const truncated = targeting.driversTruncated === true
|
|
&& fullDriverCount > driverCount
|
|
&& driverCount > 0;
|
|
const driverTitle = monitoringMode
|
|
? (truncated
|
|
? `Findings under Monitoring · showing ${driverCount} of ${fullDriverCount}`
|
|
: `Findings under Monitoring · ${fullDriverCount} finding${fullDriverCount === 1 ? '' : 's'}`)
|
|
: (truncated
|
|
? `Driving current Security action · showing ${driverCount} of ${fullDriverCount}`
|
|
: `Driving current Security action · ${fullDriverCount} finding${fullDriverCount === 1 ? '' : 's'}`);
|
|
targetingBanner = (
|
|
<TargetingBannerFrame>
|
|
<div className="flex items-start gap-3 justify-between">
|
|
<div className="min-w-0">
|
|
<p className="font-mono text-xs text-stat-value">
|
|
{drivingTitle
|
|
? driverTitle
|
|
: formatTargetingTitle(
|
|
matchedTargetMeta.label,
|
|
matchedTargetMeta.matched,
|
|
matchedTargetMeta.total,
|
|
)}
|
|
</p>
|
|
<p className="text-xs text-stat-subtitle mt-0.5">
|
|
{drivingTitle
|
|
? (monitoringMode
|
|
? 'Open an image to review findings under Monitoring for this reason.'
|
|
: 'Open an image to review the exact findings driving this Security action.')
|
|
: 'Showing images responsible for the current Security action.'}
|
|
{partialMatch ? ' An affected image has no scan summary on this node.' : ''}
|
|
{posturePartial ? ' The overview pass may be incomplete.' : ''}
|
|
</p>
|
|
</div>
|
|
<TargetingClearButton onClear={onClearTargeting} />
|
|
</div>
|
|
</TargetingBannerFrame>
|
|
);
|
|
}
|
|
}
|
|
|
|
const inspectDriversFor = (imageRef: string) => driverIdsForImage(targeting?.drivers, imageRef);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{targetingBanner}
|
|
|
|
{isMobile ? (
|
|
<>
|
|
{search !== '' || searchExpanded ? (
|
|
<div className="relative">
|
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" strokeWidth={1.5} />
|
|
<Input
|
|
ref={searchInputRef}
|
|
placeholder="Filter images…"
|
|
value={search}
|
|
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
|
onBlur={() => { if (search === '') setSearchExpanded(false); }}
|
|
className="pl-8"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button variant="outline" size="sm" className="h-9 w-9 p-0 shrink-0" onClick={() => setSearchExpanded(true)} aria-label="Search images">
|
|
<Search className="w-4 h-4" strokeWidth={1.5} />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Search images</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
)}
|
|
<ImageFilterChips
|
|
chips={MOBILE_FILTER_CHIPS}
|
|
active={severity}
|
|
onSelect={(v) => { setSeverity(v); setPage(0); }}
|
|
/>
|
|
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
|
|
<div className="px-4">
|
|
{pageItems.map((s) => (
|
|
<ImageScanRow
|
|
key={s.image_ref}
|
|
summary={s}
|
|
onInspect={onInspect}
|
|
driverVulnerabilityIds={inspectDriversFor(s.image_ref)}
|
|
intentEvidence={intentEvidenceFor(s, targeting)}
|
|
exposureContexts={contextsForImage(s, targeting)}
|
|
nodeId={nodeId}
|
|
/>
|
|
))}
|
|
</div>
|
|
{pageItems.length === 0 && (
|
|
<div className="py-12 text-center text-sm text-muted-foreground">
|
|
No images match your search or filter.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
{search !== '' || searchExpanded ? (
|
|
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" strokeWidth={1.5} />
|
|
<Input
|
|
ref={searchInputRef}
|
|
placeholder="Search images..."
|
|
value={search}
|
|
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
|
onBlur={() => { if (search === '') setSearchExpanded(false); }}
|
|
className="pl-8"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button variant="outline" size="sm" className="h-9 w-9 p-0 shrink-0" onClick={() => setSearchExpanded(true)} aria-label="Search images">
|
|
<Search className="w-4 h-4" strokeWidth={1.5} />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Search images</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
)}
|
|
<Combobox
|
|
options={FILTER_OPTIONS}
|
|
value={severity}
|
|
onValueChange={(v) => { setSeverity((v || 'all') as ImageFilterValue); setPage(0); }}
|
|
className="w-[200px] [&>button]:!bg-background"
|
|
/>
|
|
</div>
|
|
|
|
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
|
|
<ScrollArea className="h-[62vh]">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="hover:bg-transparent">
|
|
<SortHead label="Image" k="image_ref" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} />
|
|
<SortHead label="Findings" k="findings" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} className="max-md:hidden" />
|
|
<SortHead label="Last scan" k="scanned_at" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} className="max-md:hidden" />
|
|
<SortHead label="Severity" k="severity" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} />
|
|
{canScan && <TableHead className="text-right text-[10px] uppercase tracking-[0.18em]">Actions</TableHead>}
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{pageItems.map((s) => (
|
|
<TableRow key={s.image_ref} className="hover:bg-muted/30 transition-colors">
|
|
<TableCell className="font-mono text-xs truncate max-w-[280px]">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<button type="button" className="hover:text-brand truncate block text-left min-w-0" onClick={() => onInspect(s.scan_id, 'vulns', inspectDriversFor(s.image_ref))}>
|
|
{s.image_ref}
|
|
</button>
|
|
{s.publicly_exposed === true ? (
|
|
<NetworkExposedControl
|
|
contexts={contextsForImage(s, targeting)}
|
|
nodeId={nodeId}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
<IntentEvidenceLine line={intentEvidenceFor(s, targeting)} />
|
|
</TableCell>
|
|
<TableCell className="max-md:hidden">
|
|
<button
|
|
type="button"
|
|
onClick={() => onInspect(s.scan_id, 'vulns', inspectDriversFor(s.image_ref))}
|
|
className="font-mono tabular-nums text-xs text-stat-subtitle text-left hover:text-stat-value transition-colors"
|
|
>
|
|
{s.critical > 0 && <span className="text-destructive mr-2">{s.critical}C</span>}
|
|
{s.high > 0 && <span className="text-warning mr-2">{s.high}H</span>}
|
|
{s.secret_count > 0 && <span className="text-warning mr-2">{s.secret_count} secret</span>}
|
|
{s.misconfig_count > 0 && <span className="text-warning mr-2">{s.misconfig_count} misconfig</span>}
|
|
{s.fixable > 0 && <span className="text-stat-subtitle">{s.fixable} fixable</span>}
|
|
{findingsCount(s) === 0 && <span className="text-success">clean</span>}
|
|
</button>
|
|
</TableCell>
|
|
<TableCell className="font-mono text-xs text-stat-subtitle whitespace-nowrap max-md:hidden">
|
|
{formatTimeAgo(s.scanned_at)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<SeverityBadge summary={s} tooltip={false} onClick={() => onInspect(s.scan_id, 'vulns', inspectDriversFor(s.image_ref))} />
|
|
</TableCell>
|
|
{canScan && (
|
|
<TableCell className="text-right">
|
|
<DropdownMenu>
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 text-muted-foreground hover:text-foreground transition-colors"
|
|
disabled={scanningRef === s.image_ref}
|
|
aria-label={`Scan ${s.image_ref}`}
|
|
>
|
|
{scanningRef === s.image_ref
|
|
? <Loader2 className="w-3.5 h-3.5 animate-spin" strokeWidth={1.5} />
|
|
: <ShieldCheck className="w-3.5 h-3.5" strokeWidth={1.5} />}
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Scan image</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem onClick={() => onScan(s.image_ref, ['vuln'])}>
|
|
Scan (vulnerabilities)
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => onScan(s.image_ref, ['vuln', 'secret'])}>
|
|
Full scan (vulnerabilities + secrets)
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</TableCell>
|
|
)}
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
{pageItems.length === 0 && (
|
|
<div className="py-12 text-center text-sm text-muted-foreground">
|
|
No images match your search or filter.
|
|
</div>
|
|
)}
|
|
</ScrollArea>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{sorted.length > PAGE_SIZE && (
|
|
<div className="flex items-center justify-end gap-1">
|
|
<Button variant="ghost" size="icon" className="h-7 w-7" 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 text-stat-subtitle tabular-nums px-1">{safePage + 1} / {totalPages}</span>
|
|
<Button variant="ghost" size="icon" className="h-7 w-7" 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>
|
|
);
|
|
}
|