mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 06:46:23 +00:00
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.
This commit is contained in:
@@ -18,12 +18,17 @@ import { Masthead, type Tone } from './mobile/mobile-ui';
|
||||
import { SecurityMobileTabs, type SecurityMobileTab } from './security/SecurityMobile';
|
||||
import type { SecurityTab } from '@/lib/events';
|
||||
import type { ImageFilterValue } from '@/lib/severityStyles';
|
||||
import type { SecurityOverview, ScanSummary, ScanDetailTab, SecurityRiskTrendPoint, ExploitIntelFinding, FleetRole } from '@/types/security';
|
||||
import type { SecurityOverview, ScanSummary, ScanDetailTab, SecurityRiskTrendPoint, ExploitIntelFinding, FleetRole, PostureReasonKind } from '@/types/security';
|
||||
import { VulnerabilityScanSheet } from './VulnerabilityScanSheet';
|
||||
import { SuppressionsPanel } from './settings/SuppressionsPanel';
|
||||
import { MisconfigAckPanel } from './settings/MisconfigAckPanel';
|
||||
import { OverviewTab } from './security/OverviewTab';
|
||||
import { reasonImageFilter } from './security/postureNavigation';
|
||||
import {
|
||||
targetingFromTargets,
|
||||
type ImagesTargetingInput,
|
||||
type ImagesTargetingState,
|
||||
} from './security/imagesTargeting';
|
||||
import { ImagesTab } from './security/ImagesTab';
|
||||
import { FindingsTab } from './security/FindingsTab';
|
||||
import { ScanPolicyManager } from './security/ScanPolicyManager';
|
||||
@@ -86,19 +91,67 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
|
||||
|
||||
const [inspectScanId, setInspectScanId] = useState<number | null>(null);
|
||||
const [inspectInitialTab, setInspectInitialTab] = useState<ScanDetailTab | undefined>(undefined);
|
||||
// Filter to preselect on the Images tab when arriving from an overview link
|
||||
// (e.g. "fixable findings"). Null leaves the Images tab on its own default.
|
||||
const [inspectDriverVulnerabilityIds, setInspectDriverVulnerabilityIds] = useState<string[] | undefined>(undefined);
|
||||
// Filter / targeting for the Images tab when arriving from an overview link.
|
||||
// SecurityView owns both; ImagesTab never clears targeting locally (R1).
|
||||
const [imagesFilter, setImagesFilter] = useState<ImageFilterValue | null>(null);
|
||||
const [imagesFilterToken, setImagesFilterToken] = useState(0);
|
||||
const [imagesTargeting, setImagesTargeting] = useState<ImagesTargetingState | null>(null);
|
||||
|
||||
// Navigate between security tabs, optionally preselecting an Images filter so
|
||||
// an overview action link lands on exactly the affected images.
|
||||
const handleNavigate = useCallback((tab: SecurityTab, filter?: ImageFilterValue) => {
|
||||
if (tab === 'images' && filter) setImagesFilter(filter);
|
||||
// Navigate between security tabs, optionally preselecting an Images filter
|
||||
// and/or posture targeting so an overview action lands on the affected images.
|
||||
const handleNavigate = useCallback((
|
||||
tab: SecurityTab,
|
||||
filter?: ImageFilterValue,
|
||||
targeting?: ImagesTargetingInput,
|
||||
) => {
|
||||
if (tab === 'images') {
|
||||
if (targeting && targeting.imageRefs.length > 0) {
|
||||
setImagesTargeting((prev) => ({
|
||||
kind: targeting.kind,
|
||||
label: targeting.label,
|
||||
imageRefs: targeting.imageRefs,
|
||||
targets: targeting.targets,
|
||||
...(targeting.drivers ? { drivers: targeting.drivers } : {}),
|
||||
...(targeting.driverCount !== undefined ? { driverCount: targeting.driverCount } : {}),
|
||||
...(targeting.driversTruncated !== undefined
|
||||
? { driversTruncated: targeting.driversTruncated }
|
||||
: {}),
|
||||
token: (prev?.token ?? 0) + 1,
|
||||
}));
|
||||
// R2: targeting navigation resets severity unless an explicit filter is supplied.
|
||||
setImagesFilter(filter ?? null);
|
||||
setImagesFilterToken((t) => t + 1);
|
||||
} else {
|
||||
setImagesTargeting(null);
|
||||
if (filter) {
|
||||
setImagesFilter(filter);
|
||||
setImagesFilterToken((t) => t + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
onTabChange(tab);
|
||||
}, [onTabChange]);
|
||||
|
||||
const onInspect = useCallback((scanId: number, initialTab?: ScanDetailTab) => {
|
||||
const clearImagesTargeting = useCallback(() => {
|
||||
setImagesTargeting(null);
|
||||
}, []);
|
||||
|
||||
// Drop Images drill-down state when the active node changes so refs from
|
||||
// node A never filter node B's summaries.
|
||||
useEffect(() => {
|
||||
setImagesTargeting(null);
|
||||
setImagesFilter(null);
|
||||
setImagesFilterToken(0);
|
||||
}, [activeNode?.id]);
|
||||
|
||||
const onInspect = useCallback((
|
||||
scanId: number,
|
||||
initialTab?: ScanDetailTab,
|
||||
driverVulnerabilityIds?: string[],
|
||||
) => {
|
||||
setInspectInitialTab(initialTab);
|
||||
setInspectDriverVulnerabilityIds(driverVulnerabilityIds);
|
||||
setInspectScanId(scanId);
|
||||
}, []);
|
||||
|
||||
@@ -229,12 +282,23 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
|
||||
|
||||
// The scanner-detections disclaimer rides as an info affordance next to the
|
||||
// scanned-images count rather than a standing caption below the masthead.
|
||||
// When posture is Action needed, the subtitle leads with the action count and
|
||||
// top blocker labels so the operator sees "why red" without opening the page.
|
||||
// When posture is Action needed or Monitoring, the subtitle leads with the
|
||||
// residual Crit/High count and top reason labels so the operator sees why
|
||||
// without opening the review queue.
|
||||
const blockers = overview?.postureReasons?.filter((r) => r.severity === 'blocker') ?? [];
|
||||
const actionSummary = overview?.posture === 'Action needed' && blockers.length > 0
|
||||
? `${blockers.length} action${blockers.length === 1 ? '' : 's'}: ${blockers.slice(0, 2).map((r) => r.label.toLowerCase()).join(', ')} · `
|
||||
: null;
|
||||
const reviewReasons = overview?.postureReasons?.filter((r) => r.severity === 'review') ?? [];
|
||||
const residualCritHigh = (overview?.rawCritical ?? overview?.critical ?? 0)
|
||||
+ (overview?.rawHigh ?? overview?.high ?? 0);
|
||||
let actionSummary: string | null = null;
|
||||
if (overview?.posture === 'Action needed' && blockers.length > 0) {
|
||||
actionSummary = `${blockers.length} action${blockers.length === 1 ? '' : 's'}: ${blockers.slice(0, 2).map((r) => r.label.toLowerCase()).join(', ')} · `;
|
||||
} else if (overview?.posture === 'Monitoring' && residualCritHigh > 0) {
|
||||
const labels = (reviewReasons.length > 0 ? reviewReasons : overview.postureReasons ?? [])
|
||||
.slice(0, 2)
|
||||
.map((r) => r.label.toLowerCase());
|
||||
const labelPart = labels.length > 0 ? `: ${labels.join(', ')}` : '';
|
||||
actionSummary = `${residualCritHigh} residual Crit/High${labelPart} · `;
|
||||
}
|
||||
const subtitle = overview ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{actionSummary ? <span>{actionSummary}</span> : null}
|
||||
@@ -268,6 +332,7 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
|
||||
onNavigate={handleNavigate}
|
||||
onInspect={onInspect}
|
||||
canScan={canScanNode}
|
||||
canManageNode={!!activeNode?.id && can('node:manage', 'node', String(activeNode.id))}
|
||||
onScanComplete={() => setReloadToken((t) => t + 1)}
|
||||
/>
|
||||
</TabsContent>
|
||||
@@ -283,6 +348,11 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
|
||||
scanningRef={scanningRef}
|
||||
onScan={scanImage}
|
||||
initialFilter={imagesFilter ?? undefined}
|
||||
filterToken={imagesFilterToken}
|
||||
targeting={imagesTargeting}
|
||||
onClearTargeting={clearImagesTargeting}
|
||||
posturePartial={overview?.posturePartial === true}
|
||||
nodeId={activeNode?.id}
|
||||
/>
|
||||
</CapabilityGate>
|
||||
</TabsContent>
|
||||
@@ -338,7 +408,18 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
|
||||
<VulnerabilityScanSheet
|
||||
scanId={inspectScanId}
|
||||
initialTab={inspectInitialTab}
|
||||
onClose={() => setInspectScanId(null)}
|
||||
driverVulnerabilityIds={inspectDriverVulnerabilityIds}
|
||||
driverFilterMode={
|
||||
imagesTargeting?.kind === 'waiting_upstream' || imagesTargeting?.kind === 'update_check_uncertain'
|
||||
? 'monitoring'
|
||||
: 'action'
|
||||
}
|
||||
driverCount={imagesTargeting?.driverCount}
|
||||
driversTruncated={imagesTargeting?.driversTruncated}
|
||||
onClose={() => {
|
||||
setInspectScanId(null);
|
||||
setInspectDriverVulnerabilityIds(undefined);
|
||||
}}
|
||||
canGenerateSbom={canReadSecurityExports}
|
||||
canExportSarif={canReadSecurityExports}
|
||||
canCompare
|
||||
@@ -391,10 +472,21 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
|
||||
{overview?.posture === 'Action needed' && overview.primaryAction ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleNavigate(
|
||||
overview.primaryAction!.targetTab,
|
||||
reasonImageFilter(overview.primaryAction!.kind),
|
||||
)}
|
||||
onClick={() => {
|
||||
const action = overview.primaryAction!;
|
||||
const blockerLabel = overview.postureReasons?.find(
|
||||
(r) => r.kind === action.kind && r.severity === 'blocker',
|
||||
)?.label ?? action.label;
|
||||
const targeting = targetingFromTargets(
|
||||
action.kind as PostureReasonKind,
|
||||
blockerLabel,
|
||||
action.targets,
|
||||
action.drivers,
|
||||
{ driverCount: action.driverCount, driversTruncated: action.driversTruncated },
|
||||
);
|
||||
const filter = targeting ? undefined : reasonImageFilter(action.kind);
|
||||
handleNavigate(action.targetTab, filter, targeting);
|
||||
}}
|
||||
className="text-xs font-medium text-brand hover:underline whitespace-nowrap"
|
||||
>
|
||||
{overview.primaryAction.label} →
|
||||
|
||||
@@ -58,7 +58,7 @@ import type {
|
||||
ScanDetailTab,
|
||||
TriageStatus,
|
||||
} from '@/types/security';
|
||||
import { TRIAGE_STATUS_OPTIONS, TRIAGE_JUSTIFICATION_OPTIONS, TRIAGE_STATUS_HINT, openVexRequiresJustification, justificationForStatus } from '@/lib/triage';
|
||||
import { TRIAGE_STATUS_OPTIONS, TRIAGE_JUSTIFICATION_OPTIONS, TRIAGE_STATUS_HINT, openVexRequiresJustification, justificationForStatus, triageStatusLabel } from '@/lib/triage';
|
||||
|
||||
interface VulnerabilityScanSheetProps {
|
||||
scanId: number | null;
|
||||
@@ -75,6 +75,19 @@ interface VulnerabilityScanSheetProps {
|
||||
* 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 {
|
||||
@@ -159,10 +172,21 @@ function EvidenceTags({ d }: { d: VulnerabilityDetail }) {
|
||||
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,
|
||||
@@ -172,6 +196,10 @@ export function VulnerabilityScanSheet({
|
||||
canCompare = false,
|
||||
canManageSuppressions: canManageSuppressionsProp = false,
|
||||
initialTab,
|
||||
driverVulnerabilityIds,
|
||||
driverFilterMode = 'action',
|
||||
driverCount,
|
||||
driversTruncated = false,
|
||||
}: VulnerabilityScanSheetProps) {
|
||||
const [isReplica, setIsReplica] = useState(false);
|
||||
useEffect(() => {
|
||||
@@ -283,9 +311,16 @@ export function VulnerabilityScanSheet({
|
||||
}, [scanId, load]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (severityFilter === 'ALL') return details;
|
||||
return details.filter((d) => d.severity === severityFilter);
|
||||
}, [details, severityFilter]);
|
||||
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);
|
||||
@@ -366,13 +401,13 @@ export function VulnerabilityScanSheet({
|
||||
setSuppressForm({
|
||||
cveId: d.vulnerability_id,
|
||||
pkgName: d.pkg_name,
|
||||
imagePattern: '',
|
||||
imagePattern: scan?.image_ref ?? '',
|
||||
reason: '',
|
||||
expiresInDays: '',
|
||||
status: 'accepted',
|
||||
justification: '',
|
||||
});
|
||||
}, []);
|
||||
}, [scan?.image_ref]);
|
||||
|
||||
const submitSuppression = useCallback(async () => {
|
||||
if (!suppressForm) return;
|
||||
@@ -550,7 +585,7 @@ export function VulnerabilityScanSheet({
|
||||
<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">Published service</EvidenceTag>
|
||||
<EvidenceTag tone="warn">Network exposed</EvidenceTag>
|
||||
)}
|
||||
</span>
|
||||
) : (loading ? 'Loading…' : 'No scan');
|
||||
@@ -742,7 +777,33 @@ export function VulnerabilityScanSheet({
|
||||
</SheetSection>
|
||||
|
||||
{tab === 'vulns' && (
|
||||
<SheetSection title={`Vulnerabilities · ${totalDetails}`} className="flex min-h-0 flex-1 flex-col">
|
||||
<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
|
||||
@@ -807,10 +868,11 @@ export function VulnerabilityScanSheet({
|
||||
<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], d.suppressed && 'opacity-60')}
|
||||
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">
|
||||
@@ -866,7 +928,8 @@ export function VulnerabilityScanSheet({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground/70 hover:text-foreground"
|
||||
title="Suppress this CVE"
|
||||
title="Triage finding"
|
||||
aria-label="Triage finding"
|
||||
onClick={() => openSuppressDialog(d)}
|
||||
>
|
||||
<ShieldOff className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
@@ -1115,9 +1178,9 @@ export function VulnerabilityScanSheet({
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Suppress CVE</DialogTitle>
|
||||
<DialogTitle>Triage finding</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Accept this CVE as known-benign so it stops triggering alerts across the fleet.
|
||||
Record a triage decision for this finding so it stops or continues driving security posture.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{suppressForm && (
|
||||
@@ -1145,7 +1208,7 @@ export function VulnerabilityScanSheet({
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Glob pattern matched against the image reference. Leave blank to suppress this CVE on any image.
|
||||
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">
|
||||
@@ -1224,7 +1287,7 @@ export function VulnerabilityScanSheet({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submitSuppression} disabled={savingSuppression}>
|
||||
{savingSuppression ? 'Saving...' : 'Suppress'}
|
||||
{savingSuppression ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -49,7 +49,7 @@ beforeEach(() => {
|
||||
async function openSuppressDialog() {
|
||||
render(<VulnerabilityScanSheet scanId={1} onClose={() => {}} canManageSuppressions />);
|
||||
await waitFor(() => expect(screen.getByText('CVE-2026-1000')).toBeInTheDocument());
|
||||
await userEvent.click(screen.getByTitle('Suppress this CVE'));
|
||||
await userEvent.click(screen.getByTitle('Triage finding'));
|
||||
}
|
||||
|
||||
async function pickSelect(label: string, optionName: string) {
|
||||
@@ -58,6 +58,12 @@ async function pickSelect(label: string, optionName: string) {
|
||||
}
|
||||
|
||||
describe('VulnerabilityScanSheet suppress dialog', () => {
|
||||
it('titles the dialog Triage finding and prefills the exact image pattern', async () => {
|
||||
await openSuppressDialog();
|
||||
expect(screen.getByRole('heading', { name: 'Triage finding' })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Image pattern (optional)')).toHaveValue('img:1');
|
||||
});
|
||||
|
||||
it('requires an OpenVEX justification for a not-affected decision and clears it when switching away', async () => {
|
||||
await openSuppressDialog();
|
||||
|
||||
@@ -65,7 +71,7 @@ describe('VulnerabilityScanSheet suppress dialog', () => {
|
||||
expect(screen.getByRole('combobox', { name: 'OpenVEX justification' })).toBeInTheDocument();
|
||||
|
||||
await userEvent.type(screen.getByLabelText('Reason'), 'Vendor confirmed unreachable code path.');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Suppress' }));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
expect(toast.error).toHaveBeenCalledWith('An OpenVEX justification is required for this triage decision.');
|
||||
expect(postSuppressionCall()).toBeUndefined();
|
||||
|
||||
@@ -79,11 +85,12 @@ describe('VulnerabilityScanSheet suppress dialog', () => {
|
||||
await userEvent.type(screen.getByLabelText('Reason'), 'False positive confirmed by vendor.');
|
||||
await pickSelect('Triage decision', 'False positive');
|
||||
await pickSelect('OpenVEX justification', 'Inline mitigations already exist');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Suppress' }));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => expect(postSuppressionCall()).toBeTruthy());
|
||||
const body = JSON.parse((postSuppressionCall()![1] as { body: string }).body);
|
||||
expect(body.status).toBe('false_positive');
|
||||
expect(body.justification).toBe('inline_mitigations_already_exist');
|
||||
expect(body.image_pattern).toBe('img:1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SENCHO_OPEN_STACK_EVENT, type SenchoOpenStackDetail } from '@/lib/events';
|
||||
import type { ImageExposureContext } from '@/types/security';
|
||||
|
||||
function openNetworking(nodeId: number | undefined, stackName: string) {
|
||||
if (nodeId === undefined) return;
|
||||
window.dispatchEvent(new CustomEvent<SenchoOpenStackDetail>(SENCHO_OPEN_STACK_EVENT, {
|
||||
detail: { nodeId, stackName, destination: 'anatomy-networking' },
|
||||
}));
|
||||
}
|
||||
|
||||
function networkingActionLabel(ctx: ImageExposureContext): string {
|
||||
return ctx.intentConflict ? 'Review networking' : 'View networking';
|
||||
}
|
||||
|
||||
function NetworkingContextList({
|
||||
contexts,
|
||||
nodeId,
|
||||
showConflictHint = false,
|
||||
}: {
|
||||
contexts: ImageExposureContext[];
|
||||
nodeId: number;
|
||||
showConflictHint?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ul className="space-y-1">
|
||||
{contexts.map((ctx) => (
|
||||
<li key={`${ctx.stackName}\0${ctx.serviceName}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-start justify-between gap-2 rounded-md px-2 py-1.5 text-left hover:bg-muted/40"
|
||||
onClick={() => openNetworking(nodeId, ctx.stackName)}
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-mono text-[11px] text-stat-value">
|
||||
{ctx.stackName}/{ctx.serviceName}
|
||||
</span>
|
||||
{showConflictHint && ctx.intentConflict ? (
|
||||
<span className="font-mono text-[10px] text-warning">Intent mismatch</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-[10px] text-brand whitespace-nowrap">
|
||||
{networkingActionLabel(ctx)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/** Network exposed badge + optional multi-context Networking popover. */
|
||||
export function NetworkExposedControl({
|
||||
contexts,
|
||||
nodeId,
|
||||
className,
|
||||
}: {
|
||||
contexts: ImageExposureContext[];
|
||||
nodeId?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const badge = (
|
||||
<span className={cn('font-mono text-[10px] uppercase tracking-[0.14em] text-warning whitespace-nowrap', className)}>
|
||||
Network exposed
|
||||
</span>
|
||||
);
|
||||
|
||||
if (contexts.length === 0 || nodeId === undefined) {
|
||||
return badge;
|
||||
}
|
||||
|
||||
if (contexts.length === 1) {
|
||||
const only = contexts[0]!;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="hover:underline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openNetworking(nodeId, only.stackName);
|
||||
}}
|
||||
aria-label={`${networkingActionLabel(only)} for ${only.stackName}/${only.serviceName}`}
|
||||
>
|
||||
{badge}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label="View networking contexts"
|
||||
>
|
||||
{badge}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-72 p-2" onClick={(e) => e.stopPropagation()}>
|
||||
<NetworkingContextList contexts={contexts} nodeId={nodeId} showConflictHint />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
/** Banner-only View networking control (single dispatch or multi popover). */
|
||||
export function ViewNetworkingAction({
|
||||
contexts,
|
||||
nodeId,
|
||||
}: {
|
||||
contexts: ImageExposureContext[];
|
||||
nodeId?: number;
|
||||
}) {
|
||||
if (contexts.length === 0 || nodeId === undefined) return null;
|
||||
|
||||
if (contexts.length === 1) {
|
||||
const only = contexts[0]!;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs font-medium text-brand hover:underline whitespace-nowrap shrink-0"
|
||||
onClick={() => openNetworking(nodeId, only.stackName)}
|
||||
>
|
||||
View networking
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button type="button" className="text-xs font-medium text-brand hover:underline whitespace-nowrap shrink-0">
|
||||
View networking
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-72 p-2">
|
||||
<NetworkingContextList contexts={contexts} nodeId={nodeId} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
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';
|
||||
@@ -14,8 +14,18 @@ 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 type { ScanSummary, ScanDetailTab, ScannerKind } from '@/types/security';
|
||||
|
||||
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[] = [
|
||||
@@ -66,24 +76,140 @@ const FILTER_OPTIONS: Array<{ value: ImageFilterValue; label: string }> = [
|
||||
|
||||
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) => void;
|
||||
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. Applied whenever the value changes. */
|
||||
* 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 }: ImagesTabProps) {
|
||||
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');
|
||||
@@ -95,23 +221,57 @@ export function ImagesTab({ summaries, loading, error, onInspect, canScan, scann
|
||||
|
||||
useEffect(() => { if (searchExpanded) searchInputRef.current?.focus(); }, [searchExpanded]);
|
||||
|
||||
// Apply an externally-driven filter (e.g. an overview "fixable" deep link).
|
||||
// Keyed on the incoming value so re-navigating to the same filter re-applies.
|
||||
// Apply externally-driven filter / targeting. Keyed on tokens so repeating the
|
||||
// same navigation re-applies after Clear (R1) and resets severity (R2).
|
||||
useEffect(() => {
|
||||
if (initialFilter) { setSeverity(initialFilter); setPage(0); }
|
||||
}, [initialFilter]);
|
||||
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();
|
||||
return Object.values(summaries)
|
||||
.filter((s) => !s.image_ref.startsWith('stack:'))
|
||||
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;
|
||||
});
|
||||
}, [summaries, search, severity]);
|
||||
}, [imageSummaries, search, severity, targetingActive, matchedTargetMeta.refs]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const dir = sortDir === 'asc' ? 1 : -1;
|
||||
@@ -155,19 +315,128 @@ export function ImagesTab({ summaries, loading, error, onInspect, canScan, scann
|
||||
);
|
||||
}
|
||||
|
||||
const noImagesAtAll = Object.values(summaries).every((s) => s.image_ref.startsWith('stack:'));
|
||||
const noImagesAtAll = imageSummaries.length === 0;
|
||||
if (noImagesAtAll) {
|
||||
return (
|
||||
<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 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 ? (
|
||||
@@ -202,7 +471,15 @@ export function ImagesTab({ summaries, loading, error, onInspect, canScan, scann
|
||||
<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} />
|
||||
<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 && (
|
||||
@@ -263,14 +540,23 @@ export function ImagesTab({ summaries, loading, error, onInspect, canScan, scann
|
||||
{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]">
|
||||
<button type="button" className="hover:text-brand truncate block w-full text-left" onClick={() => onInspect(s.scan_id, 'vulns')}>
|
||||
{s.image_ref}
|
||||
</button>
|
||||
<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')}
|
||||
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>}
|
||||
@@ -285,7 +571,7 @@ export function ImagesTab({ summaries, loading, error, onInspect, canScan, scann
|
||||
{formatTimeAgo(s.scanned_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SeverityBadge summary={s} tooltip={false} onClick={() => onInspect(s.scan_id, 'vulns')} />
|
||||
<SeverityBadge summary={s} tooltip={false} onClick={() => onInspect(s.scan_id, 'vulns', inspectDriversFor(s.image_ref))} />
|
||||
</TableCell>
|
||||
{canScan && (
|
||||
<TableCell className="text-right">
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { useState } from 'react';
|
||||
import { ShieldOff } from 'lucide-react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { SignalRail, type SignalTile } from '@/components/ui/SignalRail';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatTimeAgo } from '@/lib/relativeTime';
|
||||
import { useIsMobile } from '@/hooks/use-is-mobile';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { SecuritySevStrip, SecurityTotalsGrid, SecurityFooterBand } from './SecurityMobile';
|
||||
import type { SecurityOverview, SecurityRiskTrendPoint, ExploitIntelFinding, PostureReason } from '@/types/security';
|
||||
import type { SecurityTab } from '@/lib/events';
|
||||
import type { ImageFilterValue } from '@/lib/severityStyles';
|
||||
import { reasonImageFilter } from './postureNavigation';
|
||||
import { reasonImageFilter, defaultReasonActionLabel } from './postureNavigation';
|
||||
import { triggerNodeImageUpdateCheck } from './imageUpdateRecheck';
|
||||
import { targetingFromTargets, type ImagesTargetingInput } from './imagesTargeting';
|
||||
import {
|
||||
RiskTrendChart,
|
||||
ActionPostureChart,
|
||||
@@ -17,8 +21,13 @@ import {
|
||||
} from './SecurityCharts';
|
||||
import { ScanNodeLauncher } from './ScanNodeLauncher';
|
||||
|
||||
/** Navigate to a security tab, optionally preselecting an Images filter. */
|
||||
type NavigateFn = (tab: SecurityTab, filter?: ImageFilterValue) => void;
|
||||
/** Navigate to a security tab, optionally with an Images severity filter and/or
|
||||
* posture targeting (image refs). */
|
||||
type NavigateFn = (
|
||||
tab: SecurityTab,
|
||||
filter?: ImageFilterValue,
|
||||
targeting?: ImagesTargetingInput,
|
||||
) => void;
|
||||
|
||||
interface OverviewTabProps {
|
||||
overview: SecurityOverview | null;
|
||||
@@ -35,6 +44,8 @@ interface OverviewTabProps {
|
||||
canScan: boolean;
|
||||
/** Refresh the overview after a node-wide scan completes. */
|
||||
onScanComplete: () => void;
|
||||
/** Whether the operator may trigger node-scoped image-update refresh. */
|
||||
canManageNode?: boolean;
|
||||
}
|
||||
|
||||
const STATUS_ROW_TONE: Record<'value' | 'warn' | 'subtitle', string> = {
|
||||
@@ -74,62 +85,144 @@ const SEVERITY_LABEL: Record<PostureReason['severity'], string> = {
|
||||
info: 'text-stat-subtitle',
|
||||
};
|
||||
|
||||
function reasonNavLabel(r: PostureReason): string {
|
||||
return `${r.actionLabel ?? defaultReasonActionLabel(r.targetTab)} →`;
|
||||
}
|
||||
|
||||
function navigateReason(onNavigate: NavigateFn, reason: PostureReason): void {
|
||||
const targeting = targetingFromTargets(
|
||||
reason.kind,
|
||||
reason.label,
|
||||
reason.targets,
|
||||
reason.drivers,
|
||||
{ driverCount: reason.driverCount, driversTruncated: reason.driversTruncated },
|
||||
);
|
||||
// Prefer precise targets; severity filter is only the older-node fallback.
|
||||
const filter = targeting ? undefined : reasonImageFilter(reason.kind);
|
||||
onNavigate(reason.targetTab, filter, targeting);
|
||||
}
|
||||
|
||||
function ReasonRow({
|
||||
reason,
|
||||
onNavigate,
|
||||
showCheckAgain = false,
|
||||
checkAgainBusy = false,
|
||||
onCheckAgain,
|
||||
}: {
|
||||
reason: PostureReason;
|
||||
onNavigate: NavigateFn;
|
||||
showCheckAgain?: boolean;
|
||||
checkAgainBusy?: boolean;
|
||||
onCheckAgain?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={cn('mt-1.5 h-2 w-2 shrink-0 rounded-full', SEVERITY_DOT[reason.severity])} aria-hidden />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={cn('font-mono text-sm', SEVERITY_LABEL[reason.severity])}>{reason.label}</span>
|
||||
<span className="font-mono tabular-nums text-xs text-stat-subtitle">{reason.count}</span>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{showCheckAgain && onCheckAgain ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={checkAgainBusy}
|
||||
onClick={onCheckAgain}
|
||||
className="text-xs font-medium text-brand hover:underline whitespace-nowrap disabled:opacity-50"
|
||||
>
|
||||
{checkAgainBusy ? 'Starting…' : 'Check again'}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigateReason(onNavigate, reason)}
|
||||
className="text-xs font-medium text-brand hover:underline whitespace-nowrap"
|
||||
>
|
||||
{reasonNavLabel(reason)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-stat-subtitle mt-0.5">{reason.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewQueueCard({
|
||||
reasons,
|
||||
onNavigate,
|
||||
canManageNode,
|
||||
updateChecksDisabled,
|
||||
posture,
|
||||
}: {
|
||||
reasons: PostureReason[];
|
||||
onNavigate: NavigateFn;
|
||||
canManageNode: boolean;
|
||||
updateChecksDisabled: boolean;
|
||||
posture?: SecurityOverview['posture'];
|
||||
}) {
|
||||
const [checkAgainBusy, setCheckAgainBusy] = useState(false);
|
||||
const blockers = reasons.filter((r) => r.severity === 'blocker');
|
||||
const nonBlockers = reasons.filter((r) => r.severity !== 'blocker');
|
||||
const hasBlockers = blockers.length > 0;
|
||||
const title = hasBlockers ? 'Why Action needed' : 'Review queue';
|
||||
const title = hasBlockers
|
||||
? 'Why Action needed'
|
||||
: posture === 'Monitoring'
|
||||
? 'Why Monitoring'
|
||||
: 'Review queue';
|
||||
|
||||
const handleCheckAgain = async () => {
|
||||
if (checkAgainBusy) return;
|
||||
setCheckAgainBusy(true);
|
||||
try {
|
||||
await triggerNodeImageUpdateCheck();
|
||||
} catch (err) {
|
||||
toast.error((err as Error)?.message || 'Failed to start image update check');
|
||||
} finally {
|
||||
setCheckAgainBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showCheckAgainFor = (r: PostureReason): boolean =>
|
||||
r.kind === 'update_check_uncertain' && canManageNode && !updateChecksDisabled;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4">
|
||||
<h3 className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle mb-3">{title}</h3>
|
||||
<div className="space-y-3">
|
||||
{blockers.map((r, i) => (
|
||||
<div key={`${r.kind}-${i}`} className="flex items-start gap-3">
|
||||
<span className={cn('mt-1.5 h-2 w-2 shrink-0 rounded-full', SEVERITY_DOT[r.severity])} aria-hidden />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={cn('font-mono text-sm', SEVERITY_LABEL[r.severity])}>{r.label}</span>
|
||||
<span className="font-mono tabular-nums text-xs text-stat-subtitle">{r.count}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigate(r.targetTab, reasonImageFilter(r.kind))}
|
||||
className="text-xs font-medium text-brand hover:underline whitespace-nowrap ml-auto"
|
||||
>
|
||||
Open {r.targetTab === 'compose' ? 'Compose risks' : r.targetTab === 'suppressions' ? 'Suppressions' : r.targetTab === 'secrets' ? 'Secrets' : r.targetTab === 'history' ? 'History' : r.targetTab === 'scanner' ? 'Scanner setup' : 'Images'} →
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-stat-subtitle mt-0.5">{r.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ReasonRow key={`${r.kind}-${i}`} reason={r} onNavigate={onNavigate} />
|
||||
))}
|
||||
{nonBlockers.length > 0 && hasBlockers && (
|
||||
<div className="border-t border-hairline pt-3 mt-1" />
|
||||
)}
|
||||
{nonBlockers.map((r, i) => (
|
||||
<div key={`${r.kind}-${i}`} className="flex items-start gap-3">
|
||||
<span className={cn('mt-1.5 h-2 w-2 shrink-0 rounded-full', SEVERITY_DOT[r.severity])} aria-hidden />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn('font-mono text-sm', SEVERITY_LABEL[r.severity])}>{r.label}</span>
|
||||
<span className="font-mono tabular-nums text-xs text-stat-subtitle">{r.count}</span>
|
||||
</div>
|
||||
<p className="text-xs text-stat-subtitle mt-0.5">{r.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ReasonRow
|
||||
key={`${r.kind}-${i}`}
|
||||
reason={r}
|
||||
onNavigate={onNavigate}
|
||||
showCheckAgain={showCheckAgainFor(r)}
|
||||
checkAgainBusy={checkAgainBusy}
|
||||
onCheckAgain={handleCheckAgain}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OverviewTab({ overview, loadError, trend, exploitIntel, exploitTruncated, onNavigate, onInspect, canScan, onScanComplete }: OverviewTabProps) {
|
||||
export function OverviewTab({
|
||||
overview,
|
||||
loadError,
|
||||
trend,
|
||||
exploitIntel,
|
||||
exploitTruncated,
|
||||
onNavigate,
|
||||
onInspect,
|
||||
canScan,
|
||||
onScanComplete,
|
||||
canManageNode = false,
|
||||
}: OverviewTabProps) {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
if (loadError === 'unsupported') {
|
||||
@@ -220,6 +313,9 @@ export function OverviewTab({ overview, loadError, trend, exploitIntel, exploitT
|
||||
<ReviewQueueCard
|
||||
reasons={overview.postureReasons}
|
||||
onNavigate={onNavigate}
|
||||
canManageNode={canManageNode}
|
||||
updateChecksDisabled={overview.updateChecksDisabled === true}
|
||||
posture={overview.posture}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -288,7 +384,7 @@ export function OverviewTab({ overview, loadError, trend, exploitIntel, exploitT
|
||||
tone="subtitle"
|
||||
/>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Manage enforcement policies on the Policies tab. This is a read-only posture for the active node.
|
||||
Security posture describes current operational actionability. Explicit deploy policies may enforce stricter admission rules (package fix available ≠ confirmed image update). Manage enforcement policies on the Policies tab. This is a read-only posture for the active node.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -374,7 +374,7 @@ export function ScanPolicyManager() {
|
||||
)}
|
||||
{policy.block_on_fixable === 1 && (
|
||||
<Badge variant="outline" className="text-[10px] shrink-0">
|
||||
Fixable
|
||||
Package fix
|
||||
</Badge>
|
||||
)}
|
||||
{policy.block_on_deploy === 1 && (
|
||||
@@ -501,11 +501,11 @@ export function ScanPolicyManager() {
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-glass-border px-3 py-2.5">
|
||||
<div>
|
||||
<Label className="text-sm">Fixable Critical/High</Label>
|
||||
<p className="text-xs text-muted-foreground">Flag an image with a Critical or High finding that has a fix available.</p>
|
||||
<Label className="text-sm">Package fix available (Critical/High)</Label>
|
||||
<p className="text-xs text-muted-foreground">Uses the scanner fixed_version for Critical or High findings. This is not a confirmed container-image update.</p>
|
||||
</div>
|
||||
<TogglePill
|
||||
aria-label="Fixable Critical/High"
|
||||
aria-label="Package fix available (Critical/High)"
|
||||
checked={form.block_on_fixable}
|
||||
onChange={(c) => setForm({ ...form, block_on_fixable: c })}
|
||||
/>
|
||||
|
||||
@@ -10,7 +10,8 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { getSeverityKey, SEVERITY_DOT_CLASSES, type ImageFilterValue } from '@/lib/severityStyles';
|
||||
import { formatTimeAgo } from '@/lib/relativeTime';
|
||||
import type { SecurityTab } from '@/lib/events';
|
||||
import type { ScanSummary, SecurityOverview, ScanDetailTab, VulnerabilityScan } from '@/types/security';
|
||||
import type { ImageExposureContext, ScanSummary, SecurityOverview, ScanDetailTab, VulnerabilityScan } from '@/types/security';
|
||||
import { NetworkExposedControl } from './ExposureNetworking';
|
||||
|
||||
export interface SecurityMobileTab {
|
||||
value: SecurityTab;
|
||||
@@ -122,9 +123,23 @@ function CountTag({ tone, children }: { tone: keyof typeof COUNT_TAG_TONE; child
|
||||
|
||||
/** One image row in the mobile Images list: severity dot, truncated mono ref over
|
||||
* a freshness meta line, trailing C/H count tags (or CLEAN), and a chevron. */
|
||||
export function ImageScanRow({ summary, onInspect }: {
|
||||
export function ImageScanRow({
|
||||
summary,
|
||||
onInspect,
|
||||
driverVulnerabilityIds,
|
||||
intentEvidence = null,
|
||||
exposureContexts = [],
|
||||
nodeId,
|
||||
}: {
|
||||
summary: ScanSummary;
|
||||
onInspect: (scanId: number, initialTab?: ScanDetailTab) => void;
|
||||
onInspect: (scanId: number, initialTab?: ScanDetailTab, driverVulnerabilityIds?: string[]) => void;
|
||||
/** Per-image driving finding ids from posture targeting. */
|
||||
driverVulnerabilityIds?: string[];
|
||||
/** Compact exposure-intent line (standing or while posture-targeting). */
|
||||
intentEvidence?: string | null;
|
||||
/** Stack/service contexts for Networking navigation from Network exposed. */
|
||||
exposureContexts?: ImageExposureContext[];
|
||||
nodeId?: number;
|
||||
}) {
|
||||
// Use the shared classifier so the count tags agree with the leading dot: a
|
||||
// medium/low-only image is not "clean", it is its highest severity.
|
||||
@@ -133,13 +148,19 @@ export function ImageScanRow({ summary, onInspect }: {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onInspect(summary.scan_id, 'vulns')}
|
||||
onClick={() => onInspect(summary.scan_id, 'vulns', driverVulnerabilityIds)}
|
||||
className="flex min-h-11 w-full items-center gap-[11px] border-b border-hairline py-[11px] text-left last:border-b-0"
|
||||
>
|
||||
<span className={cn('h-[7px] w-[7px] shrink-0 rounded-full', SEVERITY_DOT_CLASSES[severityKey])} aria-hidden />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-mono text-[13px] text-stat-value">{summary.image_ref}</span>
|
||||
<span className="mt-px block font-mono text-[10px] text-stat-icon">scanned {formatTimeAgo(summary.scanned_at)}</span>
|
||||
<span className="mt-px flex flex-wrap items-center gap-x-2 gap-y-0.5 font-mono text-[10px] text-stat-icon">
|
||||
<span>scanned {formatTimeAgo(summary.scanned_at)}</span>
|
||||
{summary.publicly_exposed === true ? (
|
||||
<NetworkExposedControl contexts={exposureContexts} nodeId={nodeId} />
|
||||
) : null}
|
||||
{intentEvidence ? <span className="normal-case tracking-normal">{intentEvidence}</span> : null}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
{summary.critical > 0 && <CountTag tone="destructive">{summary.critical}C</CountTag>}
|
||||
|
||||
@@ -66,7 +66,7 @@ it('opens the scan sheet on the vulns tab from the image name', async () => {
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByText('nginx:1'));
|
||||
expect(onInspect).toHaveBeenCalledWith(7, 'vulns');
|
||||
expect(onInspect).toHaveBeenCalledWith(7, 'vulns', undefined);
|
||||
});
|
||||
|
||||
it('opens the scan sheet on the vulns tab from the Findings cell', async () => {
|
||||
@@ -79,7 +79,7 @@ it('opens the scan sheet on the vulns tab from the Findings cell', async () => {
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByText('clean'));
|
||||
expect(onInspect).toHaveBeenCalledWith(9, 'vulns');
|
||||
expect(onInspect).toHaveBeenCalledWith(9, 'vulns', undefined);
|
||||
});
|
||||
|
||||
it('narrows the list with the search box', async () => {
|
||||
@@ -136,3 +136,367 @@ it('shows the scan action only when scanning is allowed', () => {
|
||||
rerender(<ImagesTab {...base} canScan={true} summaries={data} />);
|
||||
expect(screen.getByLabelText('Scan nginx:1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters to posture targets and shows a clearable banner', async () => {
|
||||
const onClear = vi.fn();
|
||||
render(
|
||||
<ImagesTab
|
||||
{...base}
|
||||
onClearTargeting={onClear}
|
||||
targeting={{
|
||||
kind: 'public_exposure',
|
||||
label: 'Network-exposed images not yet classified',
|
||||
imageRefs: ['exp:1'],
|
||||
targets: [{
|
||||
imageRef: 'exp:1',
|
||||
intentStatus: 'unset',
|
||||
}],
|
||||
token: 1,
|
||||
}}
|
||||
summaries={asMap(
|
||||
summary({ image_ref: 'exp:1', scan_id: 1, publicly_exposed: true }),
|
||||
summary({ image_ref: 'other:1', scan_id: 2 }),
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/Network-exposed images not yet classified · 1 affected image/)).toBeInTheDocument();
|
||||
expect(screen.getByText('exp:1')).toBeInTheDocument();
|
||||
expect(screen.queryByText('other:1')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Network exposed')).toBeInTheDocument();
|
||||
expect(screen.getByText('Intent: not classified')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Clear' }));
|
||||
expect(onClear).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows standing intent evidence without targeting', () => {
|
||||
render(
|
||||
<ImagesTab
|
||||
{...base}
|
||||
nodeId={7}
|
||||
summaries={asMap(summary({
|
||||
image_ref: 'exp:1',
|
||||
scan_id: 1,
|
||||
publicly_exposed: true,
|
||||
exposure_contexts: [{
|
||||
stackName: 'web',
|
||||
serviceName: 'api',
|
||||
exposureReason: 'published-port',
|
||||
intentStatus: 'set',
|
||||
exposureIntent: 'public',
|
||||
}],
|
||||
exposure_context_count: 1,
|
||||
exposure_context_summary: {
|
||||
hasConflict: false,
|
||||
hasUnclassified: false,
|
||||
hasUnavailable: false,
|
||||
allKnownIntentional: true,
|
||||
},
|
||||
}))}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Network exposed')).toBeInTheDocument();
|
||||
expect(screen.getByText('Intent: public')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Network exposed only for mixed-version payloads without contexts', () => {
|
||||
render(
|
||||
<ImagesTab
|
||||
{...base}
|
||||
summaries={asMap(summary({
|
||||
image_ref: 'exp:1',
|
||||
scan_id: 1,
|
||||
publicly_exposed: true,
|
||||
}))}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Network exposed')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Intent:/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows absolute intentional targeting banner with View networking only', () => {
|
||||
render(
|
||||
<ImagesTab
|
||||
{...base}
|
||||
nodeId={3}
|
||||
onClearTargeting={vi.fn()}
|
||||
targeting={{
|
||||
kind: 'public_exposure',
|
||||
label: 'Network-exposed images not yet classified',
|
||||
imageRefs: ['exp:1'],
|
||||
targets: [{
|
||||
imageRef: 'exp:1',
|
||||
stackName: 'web',
|
||||
serviceName: 'api',
|
||||
intentStatus: 'set',
|
||||
exposureIntent: 'public',
|
||||
}],
|
||||
token: 1,
|
||||
}}
|
||||
summaries={asMap(summary({ image_ref: 'exp:1', scan_id: 1, publicly_exposed: true }))}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Exposure is intentional')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'View networking' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Review findings/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Network-exposed images not yet classified ·/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows partial intentional targeting banner copy', () => {
|
||||
render(
|
||||
<ImagesTab
|
||||
{...base}
|
||||
nodeId={3}
|
||||
onClearTargeting={vi.fn()}
|
||||
targeting={{
|
||||
kind: 'public_exposure',
|
||||
label: 'Network-exposed images not yet classified',
|
||||
imageRefs: ['exp:1'],
|
||||
targets: [
|
||||
{
|
||||
imageRef: 'exp:1',
|
||||
stackName: 'web',
|
||||
serviceName: 'api',
|
||||
intentStatus: 'set',
|
||||
exposureIntent: 'lan',
|
||||
},
|
||||
{
|
||||
imageRef: 'exp:1',
|
||||
stackName: 'web',
|
||||
serviceName: 'worker',
|
||||
intentStatus: 'unavailable',
|
||||
},
|
||||
],
|
||||
token: 1,
|
||||
}}
|
||||
summaries={asMap(summary({ image_ref: 'exp:1', scan_id: 1, publicly_exposed: true }))}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Known exposure is intentional')).toBeInTheDocument();
|
||||
expect(screen.getByText(/could not be verified for 1 service/)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'View networking' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows driver-focused banner for elevated_exploit_risk targeting', () => {
|
||||
render(
|
||||
<ImagesTab
|
||||
{...base}
|
||||
onClearTargeting={vi.fn()}
|
||||
targeting={{
|
||||
kind: 'elevated_exploit_risk',
|
||||
label: 'Elevated exploit risk on network-exposed workload',
|
||||
imageRefs: ['exp:1'],
|
||||
targets: [{
|
||||
imageRef: 'exp:1',
|
||||
intentStatus: 'set',
|
||||
exposureIntent: 'public',
|
||||
}],
|
||||
drivers: [
|
||||
{ vulnerabilityId: 'CVE-2024-1', imageRef: 'exp:1' },
|
||||
{ vulnerabilityId: 'CVE-2024-2', imageRef: 'exp:1' },
|
||||
],
|
||||
token: 1,
|
||||
}}
|
||||
summaries={asMap(summary({ image_ref: 'exp:1', scan_id: 1, publicly_exposed: true }))}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/Driving current Security action · 2 findings/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/exact findings driving this Security action/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Elevated exploit risk on network-exposed workload ·/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Monitoring findings banner with truncation when waiting_upstream drivers are capped', () => {
|
||||
render(
|
||||
<ImagesTab
|
||||
{...base}
|
||||
onClearTargeting={vi.fn()}
|
||||
targeting={{
|
||||
kind: 'waiting_upstream',
|
||||
label: 'Waiting for upstream image',
|
||||
imageRefs: ['app:1'],
|
||||
targets: [{ imageRef: 'app:1' }],
|
||||
drivers: [
|
||||
{ vulnerabilityId: 'CVE-2024-1', imageRef: 'app:1' },
|
||||
{ vulnerabilityId: 'CVE-2024-2', imageRef: 'app:1' },
|
||||
],
|
||||
driverCount: 9,
|
||||
driversTruncated: true,
|
||||
token: 1,
|
||||
}}
|
||||
summaries={asMap(summary({ image_ref: 'app:1', scan_id: 1 }))}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/Findings under Monitoring · showing 2 of 9/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
it('shows conflict banner for public_exposure intent mismatch', () => {
|
||||
render(
|
||||
<ImagesTab
|
||||
{...base}
|
||||
nodeId={3}
|
||||
onClearTargeting={vi.fn()}
|
||||
targeting={{
|
||||
kind: 'public_exposure',
|
||||
label: 'Exposure conflicts with declared intent',
|
||||
imageRefs: ['exp:1'],
|
||||
targets: [{
|
||||
imageRef: 'exp:1',
|
||||
stackName: 'web',
|
||||
serviceName: 'api',
|
||||
intentStatus: 'set',
|
||||
exposureIntent: 'internal',
|
||||
intentConflict: true,
|
||||
}],
|
||||
token: 1,
|
||||
}}
|
||||
summaries={asMap(summary({ image_ref: 'exp:1', scan_id: 1, publicly_exposed: true }))}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Exposure conflicts with declared intent')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Review networking to align configuration with intent/)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'View networking' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows matched of total when a target has no summary', () => {
|
||||
render(
|
||||
<ImagesTab
|
||||
{...base}
|
||||
onClearTargeting={vi.fn()}
|
||||
targeting={{
|
||||
kind: 'public_exposure',
|
||||
label: 'Network-exposed images not yet classified',
|
||||
imageRefs: ['a:1', 'b:1', 'missing:1'],
|
||||
targets: ['a:1', 'b:1', 'missing:1'].map((imageRef) => ({ imageRef })),
|
||||
token: 1,
|
||||
}}
|
||||
summaries={asMap(
|
||||
summary({ image_ref: 'a:1', scan_id: 1 }),
|
||||
summary({ image_ref: 'b:1', scan_id: 2 }),
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/2 of 3 affected images/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/no scan summary on this node/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not change the banner count when searching within the targeted set', async () => {
|
||||
render(
|
||||
<ImagesTab
|
||||
{...base}
|
||||
onClearTargeting={vi.fn()}
|
||||
targeting={{
|
||||
kind: 'public_exposure',
|
||||
label: 'Network-exposed images not yet classified',
|
||||
imageRefs: ['a:1', 'b:1'],
|
||||
targets: ['a:1', 'b:1'].map((imageRef) => ({ imageRef })),
|
||||
token: 1,
|
||||
}}
|
||||
summaries={asMap(
|
||||
summary({ image_ref: 'a:1', scan_id: 1 }),
|
||||
summary({ image_ref: 'b:1', scan_id: 2 }),
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/· 2 affected images/)).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByLabelText('Search images'));
|
||||
await userEvent.type(screen.getByPlaceholderText('Search images...'), 'a:');
|
||||
expect(screen.getByText('a:1')).toBeInTheDocument();
|
||||
expect(screen.queryByText('b:1')).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/· 2 affected images/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('re-applies targeting when the token increments after Clear', () => {
|
||||
const data = asMap(
|
||||
summary({ image_ref: 'exp:1', scan_id: 1 }),
|
||||
summary({ image_ref: 'other:1', scan_id: 2 }),
|
||||
);
|
||||
const { rerender } = render(
|
||||
<ImagesTab
|
||||
{...base}
|
||||
targeting={{ kind: 'public_exposure', label: 'Public exposure', imageRefs: ['exp:1'], targets: [{ imageRef: 'exp:1' }], token: 1 }}
|
||||
onClearTargeting={vi.fn()}
|
||||
summaries={data}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText('other:1')).not.toBeInTheDocument();
|
||||
rerender(
|
||||
<ImagesTab
|
||||
{...base}
|
||||
targeting={null}
|
||||
onClearTargeting={vi.fn()}
|
||||
summaries={data}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('other:1')).toBeInTheDocument();
|
||||
rerender(
|
||||
<ImagesTab
|
||||
{...base}
|
||||
targeting={{ kind: 'public_exposure', label: 'Public exposure', imageRefs: ['exp:1'], targets: [{ imageRef: 'exp:1' }], token: 2 }}
|
||||
onClearTargeting={vi.fn()}
|
||||
summaries={data}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText('other:1')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('exp:1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('resets a stale FIXABLE filter when targeting arrives without a filter', () => {
|
||||
const data = asMap(
|
||||
summary({ image_ref: 'exp:1', scan_id: 1, fixable: 0, highest_severity: 'HIGH', high: 1, total: 1 }),
|
||||
summary({ image_ref: 'fix:1', scan_id: 2, fixable: 2, highest_severity: 'HIGH', high: 2, total: 2 }),
|
||||
);
|
||||
const { rerender } = render(
|
||||
<ImagesTab {...base} initialFilter="FIXABLE" filterToken={1} summaries={data} />,
|
||||
);
|
||||
expect(screen.getByText('fix:1')).toBeInTheDocument();
|
||||
expect(screen.queryByText('exp:1')).not.toBeInTheDocument();
|
||||
rerender(
|
||||
<ImagesTab
|
||||
{...base}
|
||||
initialFilter={undefined}
|
||||
filterToken={2}
|
||||
targeting={{ kind: 'public_exposure', label: 'Public exposure', imageRefs: ['exp:1'], targets: [{ imageRef: 'exp:1' }], token: 1 }}
|
||||
onClearTargeting={vi.fn()}
|
||||
summaries={data}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('exp:1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back without a banner when targets are missing', () => {
|
||||
render(
|
||||
<ImagesTab
|
||||
{...base}
|
||||
summaries={asMap(
|
||||
summary({ image_ref: 'a:1', scan_id: 1 }),
|
||||
summary({ image_ref: 'b:1', scan_id: 2 }),
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText(/affected image/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText('a:1')).toBeInTheDocument();
|
||||
expect(screen.getByText('b:1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a clearable zero-match note and keeps the full list', () => {
|
||||
render(
|
||||
<ImagesTab
|
||||
{...base}
|
||||
onClearTargeting={vi.fn()}
|
||||
targeting={{
|
||||
kind: 'public_exposure',
|
||||
label: 'Network-exposed images not yet classified',
|
||||
imageRefs: ['missing:1'],
|
||||
targets: ['missing:1'].map((imageRef) => ({ imageRef })),
|
||||
token: 1,
|
||||
}}
|
||||
summaries={asMap(
|
||||
summary({ image_ref: 'a:1', scan_id: 1 }),
|
||||
summary({ image_ref: 'b:1', scan_id: 2 }),
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/scan summary on this node/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('a:1')).toBeInTheDocument();
|
||||
expect(screen.getByText('b:1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* OverviewTab review-queue affordances for remediation-aware posture:
|
||||
* non-blocker View findings, Check again gating, and node-scoped refresh.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { SecurityOverview, PostureReason } from '@/types/security';
|
||||
|
||||
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
|
||||
}));
|
||||
vi.mock('@/hooks/use-is-mobile', () => ({ useIsMobile: () => false }));
|
||||
vi.mock('../ScanNodeLauncher', () => ({ ScanNodeLauncher: () => null }));
|
||||
vi.mock('../SecurityCharts', () => ({
|
||||
RiskTrendChart: () => null,
|
||||
ActionPostureChart: () => null,
|
||||
TopExploitRiskList: () => null,
|
||||
CvssEpssQuadrantChart: () => null,
|
||||
}));
|
||||
vi.mock('../SecurityMobile', () => ({
|
||||
SecuritySevStrip: () => null,
|
||||
SecurityTotalsGrid: () => null,
|
||||
SecurityFooterBand: () => null,
|
||||
}));
|
||||
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { OverviewTab } from '../OverviewTab';
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||
type OverviewNavigate = ComponentProps<typeof OverviewTab>['onNavigate'];
|
||||
|
||||
function reason(partial: Partial<PostureReason> & Pick<PostureReason, 'kind' | 'label'>): PostureReason {
|
||||
return {
|
||||
count: 2,
|
||||
severity: 'info',
|
||||
description: 'test description',
|
||||
targetTab: 'images',
|
||||
actionLabel: 'View findings',
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
function overview(reasons: PostureReason[], extra: Partial<SecurityOverview> = {}): SecurityOverview {
|
||||
return {
|
||||
scannedImages: 1,
|
||||
critical: 1,
|
||||
high: 0,
|
||||
fixable: 1,
|
||||
secrets: 0,
|
||||
misconfigs: 0,
|
||||
staleScans: 0,
|
||||
failedScans: 0,
|
||||
lastSuccessfulScanAt: Date.now(),
|
||||
scanner: { available: true, version: '0.50.0', source: 'managed', autoUpdate: true },
|
||||
deployEnforcement: { honorSuppressionsOnDeploy: true, eligibleBlockPolicies: 0 },
|
||||
posture: 'Monitoring',
|
||||
postureReasons: reasons,
|
||||
primaryAction: null,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function renderOverview(
|
||||
reasons: PostureReason[],
|
||||
opts: {
|
||||
canManageNode?: boolean;
|
||||
updateChecksDisabled?: boolean;
|
||||
onNavigate?: OverviewNavigate;
|
||||
} = {},
|
||||
) {
|
||||
const onNavigate: OverviewNavigate = opts.onNavigate ?? vi.fn();
|
||||
render(
|
||||
<OverviewTab
|
||||
overview={overview(reasons, { updateChecksDisabled: opts.updateChecksDisabled })}
|
||||
loadError={null}
|
||||
trend={[]}
|
||||
exploitIntel={[]}
|
||||
exploitTruncated={false}
|
||||
onNavigate={onNavigate}
|
||||
onInspect={vi.fn()}
|
||||
canScan={false}
|
||||
onScanComplete={vi.fn()}
|
||||
canManageNode={opts.canManageNode ?? false}
|
||||
/>,
|
||||
);
|
||||
return { onNavigate };
|
||||
}
|
||||
|
||||
describe('OverviewTab remediation affordances', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('titles the review queue Why Monitoring when posture is Monitoring without blockers', () => {
|
||||
renderOverview([
|
||||
reason({ kind: 'waiting_upstream', label: 'Waiting for upstream image', severity: 'review' }),
|
||||
]);
|
||||
expect(screen.getByRole('heading', { name: /why monitoring/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes waiting_upstream driver meta into Images targeting', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onNavigate } = renderOverview([
|
||||
reason({
|
||||
kind: 'waiting_upstream',
|
||||
label: 'Waiting for upstream image',
|
||||
severity: 'review',
|
||||
targets: [{ imageRef: 'app:1' }],
|
||||
drivers: [{ vulnerabilityId: 'CVE-1', imageRef: 'app:1' }],
|
||||
driverCount: 5,
|
||||
driversTruncated: true,
|
||||
}),
|
||||
]);
|
||||
await user.click(screen.getByRole('button', { name: /view findings/i }));
|
||||
expect(onNavigate).toHaveBeenCalledWith('images', undefined, {
|
||||
kind: 'waiting_upstream',
|
||||
label: 'Waiting for upstream image',
|
||||
imageRefs: ['app:1'],
|
||||
targets: [{ imageRef: 'app:1' }],
|
||||
drivers: [{ vulnerabilityId: 'CVE-1', imageRef: 'app:1' }],
|
||||
driverCount: 5,
|
||||
driversTruncated: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders View findings on a waiting_upstream non-blocker row', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onNavigate } = renderOverview([
|
||||
reason({ kind: 'waiting_upstream', label: 'Waiting for upstream image' }),
|
||||
]);
|
||||
const btn = screen.getByRole('button', { name: /view findings/i });
|
||||
await user.click(btn);
|
||||
expect(onNavigate).toHaveBeenCalledWith('images', undefined, undefined);
|
||||
});
|
||||
|
||||
it('passes public_exposure targets from the review queue', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onNavigate } = renderOverview([
|
||||
reason({
|
||||
kind: 'public_exposure',
|
||||
label: 'Network-exposed images not yet classified',
|
||||
severity: 'review',
|
||||
actionLabel: 'Review networking',
|
||||
targets: [{ imageRef: 'exp:1' }, { imageRef: 'exp:2' }],
|
||||
}),
|
||||
]);
|
||||
await user.click(screen.getByRole('button', { name: /review networking/i }));
|
||||
expect(onNavigate).toHaveBeenCalledWith('images', undefined, {
|
||||
kind: 'public_exposure',
|
||||
label: 'Network-exposed images not yet classified',
|
||||
imageRefs: ['exp:1', 'exp:2'],
|
||||
targets: [{ imageRef: 'exp:1' }, { imageRef: 'exp:2' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('passes elevated_exploit_risk drivers into Images targeting', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onNavigate } = renderOverview([
|
||||
reason({
|
||||
kind: 'elevated_exploit_risk',
|
||||
label: 'Elevated exploit risk on network-exposed workload',
|
||||
severity: 'blocker',
|
||||
actionLabel: 'Review driving findings',
|
||||
targets: [{ imageRef: 'web:1', intentStatus: 'set', exposureIntent: 'public' }],
|
||||
drivers: [
|
||||
{ vulnerabilityId: 'CVE-2024-1', imageRef: 'web:1' },
|
||||
{ vulnerabilityId: 'CVE-2024-2', imageRef: 'web:1' },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
await user.click(screen.getByRole('button', { name: /review driving findings/i }));
|
||||
expect(onNavigate).toHaveBeenCalledWith('images', undefined, {
|
||||
kind: 'elevated_exploit_risk',
|
||||
label: 'Elevated exploit risk on network-exposed workload',
|
||||
imageRefs: ['web:1'],
|
||||
targets: [{ imageRef: 'web:1', intentStatus: 'set', exposureIntent: 'public' }],
|
||||
drivers: [
|
||||
{ vulnerabilityId: 'CVE-2024-1', imageRef: 'web:1' },
|
||||
{ vulnerabilityId: 'CVE-2024-2', imageRef: 'web:1' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('shows Check again for update_check_uncertain when canManageNode and checks enabled', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockedFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ success: true, message: 'Image update check started in background.' }),
|
||||
});
|
||||
renderOverview(
|
||||
[reason({ kind: 'update_check_uncertain', label: 'Update availability unknown' })],
|
||||
{ canManageNode: true, updateChecksDisabled: false },
|
||||
);
|
||||
const checkAgain = screen.getByRole('button', { name: /check again/i });
|
||||
await user.click(checkAgain);
|
||||
await waitFor(() => {
|
||||
expect(mockedFetch).toHaveBeenCalledWith('/image-updates/refresh', { method: 'POST' });
|
||||
});
|
||||
expect(mockedFetch.mock.calls[0][1]).not.toMatchObject({ localOnly: true });
|
||||
expect(toast.success).toHaveBeenCalledWith('Image update check started in background.');
|
||||
});
|
||||
|
||||
it('hides Check again without node:manage', () => {
|
||||
renderOverview(
|
||||
[reason({ kind: 'update_check_uncertain', label: 'Update availability unknown' })],
|
||||
{ canManageNode: false },
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: /check again/i })).toBeNull();
|
||||
expect(screen.getByRole('button', { name: /view findings/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('hides Check again when update checks are disabled', () => {
|
||||
renderOverview(
|
||||
[reason({ kind: 'update_check_uncertain', label: 'Update availability unknown' })],
|
||||
{ canManageNode: true, updateChecksDisabled: true },
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: /check again/i })).toBeNull();
|
||||
});
|
||||
|
||||
it('surfaces 429 cooldown via toast.warning', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockedFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 429,
|
||||
json: async () => ({ error: 'Rate limited. Please wait at least 5 minutes between manual refreshes.' }),
|
||||
});
|
||||
renderOverview(
|
||||
[reason({ kind: 'update_check_uncertain', label: 'Update availability unknown' })],
|
||||
{ canManageNode: true },
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: /check again/i }));
|
||||
await waitFor(() => {
|
||||
expect(toast.warning).toHaveBeenCalledWith(expect.stringMatching(/rate limited/i));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -69,7 +69,7 @@ const riskPolicy = {
|
||||
replicated_from_control: 0, created_at: 1, updated_at: 1,
|
||||
};
|
||||
|
||||
it('renders a per-input badge for each active input (KEV/Fixable, no severity)', async () => {
|
||||
it('renders a per-input badge for each active input (KEV/Package fix, no severity)', async () => {
|
||||
setup();
|
||||
mockedFetch.mockImplementation((url: string) =>
|
||||
Promise.resolve(url.startsWith('/fleet/role') ? jsonResponse(200, { role: 'control' }) : jsonResponse(200, [riskPolicy])),
|
||||
@@ -77,7 +77,7 @@ it('renders a per-input badge for each active input (KEV/Fixable, no severity)',
|
||||
render(<ScanPolicyManager />);
|
||||
await waitFor(() => expect(screen.getByText('risk-gate')).toBeInTheDocument());
|
||||
expect(screen.getByText('KEV')).toBeInTheDocument();
|
||||
expect(screen.getByText('Fixable')).toBeInTheDocument();
|
||||
expect(screen.getByText('Package fix')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/^max:/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -106,7 +106,7 @@ it('blocks a save that turns on block-on-deploy with no active input', async ()
|
||||
|
||||
const dialog = screen.getByRole('dialog');
|
||||
fireEvent.click(within(dialog).getByRole('switch', { name: 'Known-exploited (KEV)' })); // KEV off
|
||||
fireEvent.click(within(dialog).getByRole('switch', { name: 'Fixable Critical/High' })); // fixable off
|
||||
fireEvent.click(within(dialog).getByRole('switch', { name: 'Package fix available (Critical/High)' })); // fixable off
|
||||
fireEvent.click(within(dialog).getByRole('switch', { name: 'Block on deploy' })); // block-on-deploy on
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ describe('ImageScanRow', () => {
|
||||
const onInspect = vi.fn();
|
||||
render(<ImageScanRow summary={summary({ image_ref: 'redis:7', scan_id: 9 })} onInspect={onInspect} />);
|
||||
await userEvent.click(screen.getByText('redis:7'));
|
||||
expect(onInspect).toHaveBeenCalledWith(9, 'vulns');
|
||||
expect(onInspect).toHaveBeenCalledWith(9, 'vulns', undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -186,4 +186,85 @@ describe('ImagesTab (mobile)', () => {
|
||||
expect(screen.getByText('fix:1')).toBeInTheDocument();
|
||||
expect(screen.queryByText('nofix:1')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the targeting banner and Clear on the phone layout', async () => {
|
||||
installMatchMedia(true);
|
||||
const onClear = vi.fn();
|
||||
render(
|
||||
<ImagesTab
|
||||
{...base}
|
||||
onClearTargeting={onClear}
|
||||
targeting={{
|
||||
kind: 'public_exposure',
|
||||
label: 'Network-exposed images not yet classified',
|
||||
imageRefs: ['exp:1'],
|
||||
targets: [{
|
||||
imageRef: 'exp:1',
|
||||
intentStatus: 'unset',
|
||||
}],
|
||||
token: 1,
|
||||
}}
|
||||
summaries={asMap(
|
||||
summary({ image_ref: 'exp:1', scan_id: 1, publicly_exposed: true, critical: 2 }),
|
||||
summary({ image_ref: 'other:1', scan_id: 2 }),
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/Network-exposed images not yet classified · 1 affected image/)).toBeInTheDocument();
|
||||
expect(screen.getByText('exp:1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Network exposed')).toBeInTheDocument();
|
||||
expect(screen.getByText('Intent: not classified')).toBeInTheDocument();
|
||||
expect(screen.queryByText('other:1')).not.toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Clear' }));
|
||||
expect(onClear).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows standing intent on the phone layout without targeting', () => {
|
||||
installMatchMedia(true);
|
||||
render(
|
||||
<ImagesTab
|
||||
{...base}
|
||||
nodeId={2}
|
||||
summaries={asMap(summary({
|
||||
image_ref: 'exp:1',
|
||||
scan_id: 1,
|
||||
publicly_exposed: true,
|
||||
critical: 1,
|
||||
exposure_contexts: [{
|
||||
stackName: 'web',
|
||||
serviceName: 'api',
|
||||
exposureReason: 'published-port',
|
||||
intentStatus: 'set',
|
||||
exposureIntent: 'lan',
|
||||
}],
|
||||
exposure_context_count: 1,
|
||||
exposure_context_summary: {
|
||||
hasConflict: false,
|
||||
hasUnclassified: false,
|
||||
hasUnavailable: false,
|
||||
allKnownIntentional: true,
|
||||
},
|
||||
}))}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Network exposed')).toBeInTheDocument();
|
||||
expect(screen.getByText('Intent: LAN')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps mixed-version Network exposed without inventing intent on phone', () => {
|
||||
installMatchMedia(true);
|
||||
render(
|
||||
<ImagesTab
|
||||
{...base}
|
||||
summaries={asMap(summary({
|
||||
image_ref: 'exp:1',
|
||||
scan_id: 1,
|
||||
publicly_exposed: true,
|
||||
critical: 1,
|
||||
}))}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Network exposed')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Intent:/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,7 +64,7 @@ it('reads Monitoring/warn when criticals/highs exist but nothing is actionable',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads Secure/live when a scan completed and nothing is actionable or severe', () => {
|
||||
it('reads Secure/live when a scan completed and nothing is residual or actionable', () => {
|
||||
expect(deriveMasthead(overview({}), false)).toEqual({ state: 'Secure', tone: 'live' });
|
||||
});
|
||||
|
||||
@@ -75,6 +75,11 @@ it('prefers the backend posture over the local bootstrap when present', () => {
|
||||
state: 'Monitoring',
|
||||
tone: 'warn',
|
||||
});
|
||||
// Bootstrap would read Monitoring from raw Crit/High; cleared-residual Secure wins.
|
||||
expect(deriveMasthead(overview({ critical: 5, high: 2, posture: 'Secure' }), false)).toEqual({
|
||||
state: 'Secure',
|
||||
tone: 'live',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the local bootstrap when the node reports no posture', () => {
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
targetingFromTargets,
|
||||
primaryExposureIntentEvidence,
|
||||
standingIntentEvidence,
|
||||
intentionalBannerKind,
|
||||
} from '../imagesTargeting';
|
||||
import type { ImageExposureContext, PostureTarget, ScanSummary } from '@/types/security';
|
||||
|
||||
function summary(o: Partial<ScanSummary> & { image_ref?: string } = {}): ScanSummary {
|
||||
return {
|
||||
image_ref: 'nginx:1',
|
||||
highest_severity: null,
|
||||
scanned_at: 1,
|
||||
scan_id: 1,
|
||||
total: 0,
|
||||
critical: 0,
|
||||
high: 0,
|
||||
medium: 0,
|
||||
low: 0,
|
||||
unknown: 0,
|
||||
fixable: 0,
|
||||
secret_count: 0,
|
||||
misconfig_count: 0,
|
||||
...o,
|
||||
};
|
||||
}
|
||||
|
||||
function ctx(partial: Partial<ImageExposureContext> & Pick<ImageExposureContext, 'stackName' | 'serviceName' | 'intentStatus'>): ImageExposureContext {
|
||||
return {
|
||||
exposureReason: 'published-port',
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe('targetingFromTargets', () => {
|
||||
it('derives unique imageRefs while keeping full targets', () => {
|
||||
const targets: PostureTarget[] = [
|
||||
{ imageRef: 'a:1', stackName: 's1', serviceName: 'api', intentStatus: 'set', exposureIntent: 'public' },
|
||||
{ imageRef: 'a:1', stackName: 's2', serviceName: 'api', intentStatus: 'unset' },
|
||||
{ imageRef: 'b:1', intentStatus: 'set', exposureIntent: 'lan' },
|
||||
];
|
||||
const input = targetingFromTargets(
|
||||
'public_exposure',
|
||||
'Network-exposed images not yet classified',
|
||||
targets,
|
||||
);
|
||||
expect(input?.imageRefs).toEqual(['a:1', 'b:1']);
|
||||
expect(input?.targets).toHaveLength(3);
|
||||
expect(input?.drivers).toBeUndefined();
|
||||
});
|
||||
|
||||
it('copies drivers when present', () => {
|
||||
const drivers = [
|
||||
{ vulnerabilityId: 'CVE-2024-1', imageRef: 'web:1' },
|
||||
{ vulnerabilityId: 'CVE-2024-1', imageRef: 'web:1' },
|
||||
{ vulnerabilityId: 'CVE-2024-2', imageRef: 'web:1' },
|
||||
];
|
||||
const input = targetingFromTargets(
|
||||
'elevated_exploit_risk',
|
||||
'Elevated exploit risk on network-exposed workload',
|
||||
[{ imageRef: 'web:1', intentStatus: 'set', exposureIntent: 'public' }],
|
||||
drivers,
|
||||
);
|
||||
expect(input?.drivers).toEqual(drivers);
|
||||
});
|
||||
|
||||
it('copies driverCount and driversTruncated when provided', () => {
|
||||
const input = targetingFromTargets(
|
||||
'waiting_upstream',
|
||||
'Waiting for upstream image',
|
||||
[{ imageRef: 'app:1' }],
|
||||
[{ vulnerabilityId: 'CVE-1', imageRef: 'app:1' }],
|
||||
{ driverCount: 12, driversTruncated: true },
|
||||
);
|
||||
expect(input?.driverCount).toBe(12);
|
||||
expect(input?.driversTruncated).toBe(true);
|
||||
});
|
||||
|
||||
it('omits drivers when empty or absent', () => {
|
||||
const noDrivers = targetingFromTargets(
|
||||
'public_exposure',
|
||||
'Exposure conflicts with declared intent',
|
||||
[{ imageRef: 'a:1', intentConflict: true, intentStatus: 'set', exposureIntent: 'internal' }],
|
||||
);
|
||||
expect(noDrivers?.drivers).toBeUndefined();
|
||||
expect(
|
||||
targetingFromTargets('elevated_exploit_risk', 'x', [{ imageRef: 'a:1' }], [])
|
||||
?.drivers,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for empty targets', () => {
|
||||
expect(targetingFromTargets('public_exposure', 'x', [])).toBeUndefined();
|
||||
expect(targetingFromTargets('public_exposure', 'x', undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('primaryExposureIntentEvidence', () => {
|
||||
it('formats public intent', () => {
|
||||
expect(primaryExposureIntentEvidence(
|
||||
[{ imageRef: 'a:1', intentStatus: 'set', exposureIntent: 'public' }],
|
||||
'a:1',
|
||||
)).toBe('Intent: public');
|
||||
});
|
||||
|
||||
it('prefers mismatch over intentional', () => {
|
||||
expect(primaryExposureIntentEvidence(
|
||||
[
|
||||
{ imageRef: 'a:1', intentStatus: 'set', exposureIntent: 'public' },
|
||||
{ imageRef: 'a:1', intentStatus: 'set', exposureIntent: 'internal', intentConflict: true },
|
||||
],
|
||||
'a:1',
|
||||
)).toBe('Intent mismatch: internal (+1)');
|
||||
});
|
||||
|
||||
it('formats unset classification', () => {
|
||||
expect(primaryExposureIntentEvidence(
|
||||
[{ imageRef: 'a:1', intentStatus: 'unset' }],
|
||||
'a:1',
|
||||
)).toBe('Intent: not classified');
|
||||
});
|
||||
|
||||
it('omits intent line when unavailable only', () => {
|
||||
expect(primaryExposureIntentEvidence(
|
||||
[{ imageRef: 'a:1', intentStatus: 'unavailable' }],
|
||||
'a:1',
|
||||
)).toBeNull();
|
||||
});
|
||||
|
||||
it('handles legacy imageRef-only targets safely', () => {
|
||||
expect(primaryExposureIntentEvidence([{ imageRef: 'a:1' }], 'a:1')).toBeNull();
|
||||
expect(targetingFromTargets('fixable_cve', 'Newer image available', [{ imageRef: 'a:1' }])?.imageRefs)
|
||||
.toEqual(['a:1']);
|
||||
});
|
||||
|
||||
it('does not flatten multi-intent same image to a single false intent', () => {
|
||||
const line = primaryExposureIntentEvidence(
|
||||
[
|
||||
{ imageRef: 'a:1', intentStatus: 'set', exposureIntent: 'public' },
|
||||
{ imageRef: 'a:1', intentStatus: 'unset' },
|
||||
],
|
||||
'a:1',
|
||||
);
|
||||
expect(line).toBe('Intent: not classified (+1)');
|
||||
expect(line).not.toBe('Intent: public');
|
||||
});
|
||||
|
||||
it('accepts standing ImageExposureContext rows without imageRef', () => {
|
||||
expect(primaryExposureIntentEvidence([
|
||||
ctx({ stackName: 'web', serviceName: 'api', intentStatus: 'set', exposureIntent: 'lan' }),
|
||||
])).toBe('Intent: LAN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('standingIntentEvidence', () => {
|
||||
it('returns null for mixed-version publicly_exposed without contexts', () => {
|
||||
expect(standingIntentEvidence(summary({ publicly_exposed: true }))).toBeNull();
|
||||
});
|
||||
|
||||
it('formats standing intent from contexts and summary flags', () => {
|
||||
expect(standingIntentEvidence(summary({
|
||||
publicly_exposed: true,
|
||||
exposure_contexts: [
|
||||
ctx({ stackName: 'web', serviceName: 'api', intentStatus: 'set', exposureIntent: 'public' }),
|
||||
],
|
||||
exposure_context_count: 1,
|
||||
exposure_context_summary: {
|
||||
hasConflict: false,
|
||||
hasUnclassified: false,
|
||||
hasUnavailable: false,
|
||||
allKnownIntentional: true,
|
||||
},
|
||||
}))).toBe('Intent: public');
|
||||
});
|
||||
|
||||
it('prefers summary conflict over intentional display context', () => {
|
||||
expect(standingIntentEvidence(summary({
|
||||
publicly_exposed: true,
|
||||
exposure_contexts: [
|
||||
ctx({ stackName: 'web', serviceName: 'api', intentStatus: 'set', exposureIntent: 'public' }),
|
||||
],
|
||||
exposure_context_count: 2,
|
||||
exposure_contexts_truncated: true,
|
||||
exposure_context_summary: {
|
||||
hasConflict: true,
|
||||
hasUnclassified: false,
|
||||
hasUnavailable: false,
|
||||
allKnownIntentional: false,
|
||||
},
|
||||
}))).toBe('Intent mismatch: internal (+1)');
|
||||
});
|
||||
|
||||
it('includes truncated remainder in +N', () => {
|
||||
expect(standingIntentEvidence(summary({
|
||||
publicly_exposed: true,
|
||||
exposure_contexts: [
|
||||
ctx({ stackName: 'web', serviceName: 'api', intentStatus: 'unset' }),
|
||||
],
|
||||
exposure_context_count: 5,
|
||||
exposure_contexts_truncated: true,
|
||||
exposure_context_summary: {
|
||||
hasConflict: false,
|
||||
hasUnclassified: true,
|
||||
hasUnavailable: false,
|
||||
allKnownIntentional: false,
|
||||
},
|
||||
}))).toBe('Intent: not classified (+4)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('intentionalBannerKind', () => {
|
||||
it('marks absolute intentional targets', () => {
|
||||
expect(intentionalBannerKind([
|
||||
{ imageRef: 'a:1', intentStatus: 'set', exposureIntent: 'public' },
|
||||
{ imageRef: 'a:1', intentStatus: 'set', exposureIntent: 'lan' },
|
||||
])).toEqual({ kind: 'absolute', unavailableCount: 0 });
|
||||
});
|
||||
|
||||
it('marks partial when unavailable remains among intentional contexts', () => {
|
||||
expect(intentionalBannerKind([
|
||||
{ imageRef: 'a:1', intentStatus: 'set', exposureIntent: 'public' },
|
||||
{ imageRef: 'a:1', intentStatus: 'unavailable' },
|
||||
])).toEqual({ kind: 'partial', unavailableCount: 1 });
|
||||
});
|
||||
|
||||
it('returns none for mixed-version summary without contexts', () => {
|
||||
expect(intentionalBannerKind(summary({ publicly_exposed: true }))).toEqual({
|
||||
kind: 'none',
|
||||
unavailableCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks absolute when truncated even if display contexts look intentional', () => {
|
||||
expect(intentionalBannerKind(summary({
|
||||
publicly_exposed: true,
|
||||
exposure_contexts: [
|
||||
ctx({ stackName: 'web', serviceName: 'api', intentStatus: 'set', exposureIntent: 'public' }),
|
||||
],
|
||||
exposure_contexts_truncated: true,
|
||||
exposure_context_summary: {
|
||||
hasConflict: false,
|
||||
hasUnclassified: false,
|
||||
hasUnavailable: false,
|
||||
allKnownIntentional: true,
|
||||
},
|
||||
}))).toEqual({ kind: 'none', unavailableCount: 0 });
|
||||
});
|
||||
|
||||
it('blocks absolute when posture targeting attach was truncated', () => {
|
||||
expect(intentionalBannerKind(
|
||||
[{ imageRef: 'a:1', intentStatus: 'set', exposureIntent: 'public' }],
|
||||
{ truncated: true },
|
||||
)).toEqual({ kind: 'none', unavailableCount: 0 });
|
||||
});
|
||||
|
||||
it('uses summary flags for partial standing intentional', () => {
|
||||
expect(intentionalBannerKind(summary({
|
||||
publicly_exposed: true,
|
||||
exposure_contexts: [
|
||||
ctx({ stackName: 'web', serviceName: 'api', intentStatus: 'set', exposureIntent: 'public' }),
|
||||
ctx({ stackName: 'web', serviceName: 'worker', intentStatus: 'unavailable' }),
|
||||
],
|
||||
exposure_context_summary: {
|
||||
hasConflict: false,
|
||||
hasUnclassified: false,
|
||||
hasUnavailable: true,
|
||||
allKnownIntentional: true,
|
||||
},
|
||||
}))).toEqual({ kind: 'partial', unavailableCount: 1 });
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,18 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { reasonImageFilter } from '../postureNavigation';
|
||||
import { reasonImageFilter, defaultReasonActionLabel } from '../postureNavigation';
|
||||
import type { PostureReasonKind } from '@/types/security';
|
||||
|
||||
describe('reasonImageFilter', () => {
|
||||
it('maps fixable findings to the FIXABLE image filter', () => {
|
||||
it('maps confirmed image-update findings to the FIXABLE image filter', () => {
|
||||
expect(reasonImageFilter('fixable_cve')).toBe('FIXABLE');
|
||||
});
|
||||
|
||||
it('returns undefined for kinds with no per-image flag (opens Images unfiltered)', () => {
|
||||
const others: PostureReasonKind[] = [
|
||||
'waiting_upstream',
|
||||
'update_check_uncertain',
|
||||
'known_exploited',
|
||||
'elevated_exploit_risk',
|
||||
'secret',
|
||||
'dangerous_compose',
|
||||
'public_exposure',
|
||||
@@ -22,3 +25,11 @@ describe('reasonImageFilter', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultReasonActionLabel', () => {
|
||||
it('labels common security tabs', () => {
|
||||
expect(defaultReasonActionLabel('images')).toBe('Open Images');
|
||||
expect(defaultReasonActionLabel('compose')).toBe('Open Compose risks');
|
||||
expect(defaultReasonActionLabel('secrets')).toBe('Open Secrets');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
|
||||
/** Node-scoped image-update refresh. Confirm via toast; do not refetch overview
|
||||
* immediately (the check runs in the background). */
|
||||
export async function triggerNodeImageUpdateCheck(): Promise<void> {
|
||||
const res = await apiFetch('/image-updates/refresh', { method: 'POST' });
|
||||
const body = await res.json().catch(() => ({})) as { error?: string; message?: string };
|
||||
if (res.status === 429) {
|
||||
toast.warning(body.error || 'Rate limited. Please wait before checking again.');
|
||||
return;
|
||||
}
|
||||
if (res.status === 409) {
|
||||
toast.warning(body.error || 'Image update detection is disabled for this node.');
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(body.error || 'Failed to start image update check');
|
||||
}
|
||||
toast.success(body.message || 'Image update check started in background.');
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import type {
|
||||
ImageExposureContext,
|
||||
ImageExposureContextSummary,
|
||||
PostureDriverFinding,
|
||||
PostureReasonKind,
|
||||
PostureTarget,
|
||||
ScanSummary,
|
||||
} from '@/types/security';
|
||||
|
||||
/** Parent-owned Images drill-down from a posture reason/action. */
|
||||
export interface ImagesTargetingState {
|
||||
kind: PostureReasonKind;
|
||||
label: string;
|
||||
/** Unique image refs used for list filtering. */
|
||||
imageRefs: string[];
|
||||
/** Full target rows (may repeat imageRef across stack/service). */
|
||||
targets: PostureTarget[];
|
||||
/**
|
||||
* Exact contributing findings when the reason carries drivers.
|
||||
* Scoped per image when opening the scan sheet.
|
||||
*/
|
||||
drivers?: PostureDriverFinding[];
|
||||
/** Full contributing driver count before cap; omit when drivers omitted. */
|
||||
driverCount?: number;
|
||||
/** True when driverCount exceeds the attached drivers array length. */
|
||||
driversTruncated?: boolean;
|
||||
/** Monotonic token so re-navigating the same reason re-applies after Clear. */
|
||||
token: number;
|
||||
}
|
||||
|
||||
/** Payload passed into navigate before SecurityView assigns a token. */
|
||||
export type ImagesTargetingInput = Omit<ImagesTargetingState, 'token'>;
|
||||
|
||||
/** Intent fields shared by posture targets and standing exposure contexts. */
|
||||
export type ExposureIntentSource =
|
||||
| ImageExposureContext
|
||||
| Pick<PostureTarget, 'exposureIntent' | 'intentStatus' | 'intentConflict' | 'imageRef'>;
|
||||
|
||||
/** Intents that mean the operator deliberately classified exposure. */
|
||||
const INTENTIONAL = new Set(['public', 'lan', 'reverse-proxy', 'temporary']);
|
||||
|
||||
function uniqueImageRefs(targets: PostureTarget[]): string[] {
|
||||
return [...new Set(targets.map((t) => t.imageRef))];
|
||||
}
|
||||
|
||||
/** Driver CVE/GHSA ids for one image (omit when none match so the sheet stays unfiltered). */
|
||||
export function driverIdsForImage(
|
||||
drivers: PostureDriverFinding[] | undefined,
|
||||
imageRef: string,
|
||||
): string[] | undefined {
|
||||
if (!drivers || drivers.length === 0) return undefined;
|
||||
const ids = [...new Set(
|
||||
drivers.filter((d) => d.imageRef === imageRef).map((d) => d.vulnerabilityId),
|
||||
)];
|
||||
return ids.length > 0 ? ids : undefined;
|
||||
}
|
||||
|
||||
/** Build Images targeting from a posture reason/action target list. */
|
||||
export function targetingFromTargets(
|
||||
kind: PostureReasonKind,
|
||||
label: string,
|
||||
targets: PostureTarget[] | undefined,
|
||||
drivers?: PostureDriverFinding[],
|
||||
driverMeta?: { driverCount?: number; driversTruncated?: boolean },
|
||||
): ImagesTargetingInput | undefined {
|
||||
if (!targets || targets.length === 0) return undefined;
|
||||
return {
|
||||
kind,
|
||||
label,
|
||||
imageRefs: uniqueImageRefs(targets),
|
||||
targets,
|
||||
...(drivers && drivers.length > 0 ? { drivers } : {}),
|
||||
...(driverMeta?.driverCount !== undefined ? { driverCount: driverMeta.driverCount } : {}),
|
||||
...(driverMeta?.driversTruncated !== undefined
|
||||
? { driversTruncated: driverMeta.driversTruncated }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function intentDisplayLabel(intent: NonNullable<PostureTarget['exposureIntent']>): string {
|
||||
switch (intent) {
|
||||
case 'lan': return 'LAN';
|
||||
case 'reverse-proxy': return 'reverse proxy';
|
||||
case 'same-node': return 'same-node';
|
||||
case 'internal': return 'internal';
|
||||
case 'public': return 'public';
|
||||
case 'temporary': return 'temporary';
|
||||
case 'unknown': return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/** Rank for multi-context pick: conflict, then unset, then set (non-conflict), then unavailable. */
|
||||
function evidenceRank(t: ExposureIntentSource): number {
|
||||
if (t.intentConflict) return 0;
|
||||
if (t.intentStatus === 'unset') return 1;
|
||||
if (t.intentStatus === 'set') return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
function isIntentionalSet(c: ExposureIntentSource): boolean {
|
||||
return (
|
||||
c.intentStatus === 'set'
|
||||
&& !c.intentConflict
|
||||
&& !!c.exposureIntent
|
||||
&& INTENTIONAL.has(c.exposureIntent)
|
||||
);
|
||||
}
|
||||
|
||||
function formatIntentEvidence(t: ExposureIntentSource): string | null {
|
||||
if (t.intentStatus === 'unavailable') return null;
|
||||
if (t.intentConflict) {
|
||||
const label = t.exposureIntent ? intentDisplayLabel(t.exposureIntent) : 'internal';
|
||||
return `Intent mismatch: ${label}`;
|
||||
}
|
||||
if (t.intentStatus === 'unset') return 'Intent: not classified';
|
||||
if (t.intentStatus === 'set' && t.exposureIntent) {
|
||||
return `Intent: ${intentDisplayLabel(t.exposureIntent)}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer aggregate summary flags for the primary conclusion when present,
|
||||
* then fall back to the best ranked context line.
|
||||
*/
|
||||
function primaryLineFromContexts(
|
||||
contexts: ExposureIntentSource[],
|
||||
summary?: ImageExposureContextSummary,
|
||||
): string | null {
|
||||
if (contexts.length === 0) return null;
|
||||
|
||||
if (summary?.hasConflict) {
|
||||
const conflict = contexts.find((c) => c.intentConflict);
|
||||
const label = conflict?.exposureIntent
|
||||
? intentDisplayLabel(conflict.exposureIntent)
|
||||
: 'internal';
|
||||
return `Intent mismatch: ${label}`;
|
||||
}
|
||||
if (summary?.hasUnclassified) {
|
||||
return 'Intent: not classified';
|
||||
}
|
||||
|
||||
return [...contexts]
|
||||
.sort((a, b) => evidenceRank(a) - evidenceRank(b))
|
||||
.map(formatIntentEvidence)
|
||||
.find((line): line is string => line !== null) ?? null;
|
||||
}
|
||||
|
||||
function withExtras(primaryLine: string, totalContexts: number): string {
|
||||
const extras = totalContexts - 1;
|
||||
return extras > 0 ? `${primaryLine} (+${extras})` : primaryLine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact per-row exposure intent line while targeting.
|
||||
* Accepts posture targets or standing ImageExposureContext rows.
|
||||
* Prefers conflict, then unset, then set; appends +N when more contexts exist.
|
||||
* Returns null when there is nothing to show (legacy imageRef-only targets, or unavailable-only).
|
||||
*/
|
||||
export function primaryExposureIntentEvidence(
|
||||
sources: ExposureIntentSource[] | undefined,
|
||||
imageRef?: string,
|
||||
): string | null {
|
||||
if (!sources || sources.length === 0) return null;
|
||||
|
||||
let list = sources;
|
||||
if (imageRef !== undefined) {
|
||||
const withRef = sources.filter((t): t is PostureTarget & ExposureIntentSource => 'imageRef' in t);
|
||||
list = withRef.length > 0
|
||||
? withRef.filter((t) => t.imageRef === imageRef)
|
||||
: sources;
|
||||
}
|
||||
if (list.length === 0) return null;
|
||||
|
||||
const primaryLine = primaryLineFromContexts(list);
|
||||
if (!primaryLine) return null;
|
||||
return withExtras(primaryLine, list.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Standing Images evidence from a scan summary (no targeting required).
|
||||
* Mixed-version: publicly_exposed without contexts yields null (badge only).
|
||||
*/
|
||||
export function standingIntentEvidence(summary: ScanSummary): string | null {
|
||||
if (summary.publicly_exposed !== true) return null;
|
||||
const contexts = summary.exposure_contexts;
|
||||
if (!contexts || contexts.length === 0) return null;
|
||||
|
||||
const primaryLine = primaryLineFromContexts(contexts, summary.exposure_context_summary);
|
||||
if (!primaryLine) return null;
|
||||
|
||||
const total = summary.exposure_context_count ?? contexts.length;
|
||||
// When truncated, +N includes hidden contexts (count - displayed) plus other
|
||||
// displayed rows beyond the primary: total - 1.
|
||||
return withExtras(primaryLine, total);
|
||||
}
|
||||
|
||||
export type IntentionalBannerKind = 'absolute' | 'partial' | 'none';
|
||||
|
||||
export interface IntentionalBannerResult {
|
||||
kind: IntentionalBannerKind;
|
||||
unavailableCount: number;
|
||||
}
|
||||
|
||||
function intentionalKindFromContexts(
|
||||
contexts: ExposureIntentSource[] | undefined,
|
||||
opts: {
|
||||
truncated?: boolean;
|
||||
summary?: ImageExposureContextSummary;
|
||||
} = {},
|
||||
): IntentionalBannerResult {
|
||||
const { truncated = false, summary } = opts;
|
||||
if (truncated || !contexts || contexts.length === 0) {
|
||||
return { kind: 'none', unavailableCount: 0 };
|
||||
}
|
||||
|
||||
// Prefer pre-cap aggregates when present (standing summaries).
|
||||
if (summary) {
|
||||
if (summary.hasConflict || summary.hasUnclassified) {
|
||||
return { kind: 'none', unavailableCount: 0 };
|
||||
}
|
||||
if (summary.hasUnavailable) {
|
||||
const unavailableCount = contexts.filter((c) => c.intentStatus === 'unavailable').length;
|
||||
if (summary.allKnownIntentional && unavailableCount > 0) {
|
||||
return { kind: 'partial', unavailableCount };
|
||||
}
|
||||
return { kind: 'none', unavailableCount: 0 };
|
||||
}
|
||||
if (summary.allKnownIntentional && contexts.every(isIntentionalSet)) {
|
||||
return { kind: 'absolute', unavailableCount: 0 };
|
||||
}
|
||||
return { kind: 'none', unavailableCount: 0 };
|
||||
}
|
||||
|
||||
let unavailableCount = 0;
|
||||
let sawAvailable = false;
|
||||
for (const c of contexts) {
|
||||
if (c.intentStatus === 'unavailable') {
|
||||
unavailableCount += 1;
|
||||
continue;
|
||||
}
|
||||
sawAvailable = true;
|
||||
if (!isIntentionalSet(c)) {
|
||||
return { kind: 'none', unavailableCount };
|
||||
}
|
||||
}
|
||||
|
||||
if (unavailableCount > 0 && sawAvailable) {
|
||||
return { kind: 'partial', unavailableCount };
|
||||
}
|
||||
if (unavailableCount === 0 && contexts.every(isIntentionalSet)) {
|
||||
return { kind: 'absolute', unavailableCount: 0 };
|
||||
}
|
||||
return { kind: 'none', unavailableCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute / partial intentional classification for targeting targets or a standing summary.
|
||||
* When truncated is true (overview attach capped or standing contexts truncated), never claim absolute/partial.
|
||||
*/
|
||||
export function intentionalBannerKind(
|
||||
input: PostureTarget[] | ScanSummary | undefined,
|
||||
opts?: { truncated?: boolean },
|
||||
): IntentionalBannerResult {
|
||||
if (!input) return { kind: 'none', unavailableCount: 0 };
|
||||
|
||||
if (Array.isArray(input)) {
|
||||
return intentionalKindFromContexts(input, { truncated: opts?.truncated === true });
|
||||
}
|
||||
|
||||
if (input.publicly_exposed !== true) {
|
||||
return { kind: 'none', unavailableCount: 0 };
|
||||
}
|
||||
return intentionalKindFromContexts(input.exposure_contexts, {
|
||||
truncated: opts?.truncated === true || input.exposure_contexts_truncated === true,
|
||||
summary: input.exposure_context_summary,
|
||||
});
|
||||
}
|
||||
|
||||
/** Collect exposure contexts for networking navigation from a standing summary. */
|
||||
export function standingExposureContexts(summary: ScanSummary): ImageExposureContext[] {
|
||||
if (summary.publicly_exposed !== true) return [];
|
||||
return summary.exposure_contexts ?? [];
|
||||
}
|
||||
|
||||
/** Collect unique stack/service contexts from posture targets (banner networking nav). */
|
||||
export function allTargetingExposureContexts(
|
||||
targets: PostureTarget[],
|
||||
): ImageExposureContext[] {
|
||||
const seen = new Set<string>();
|
||||
const out: ImageExposureContext[] = [];
|
||||
for (const t of targets) {
|
||||
if (!t.stackName || !t.serviceName) continue;
|
||||
const key = `${t.stackName}\0${t.serviceName}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push({
|
||||
stackName: t.stackName,
|
||||
serviceName: t.serviceName,
|
||||
exposureReason: t.exposureReason ?? null,
|
||||
exposureIntent: t.exposureIntent,
|
||||
intentStatus: t.intentStatus ?? 'unavailable',
|
||||
intentConflict: t.intentConflict,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Collect stack/service contexts from posture targets for an image (networking nav). */
|
||||
export function targetingExposureContexts(
|
||||
targets: PostureTarget[] | undefined,
|
||||
imageRef: string,
|
||||
): ImageExposureContext[] {
|
||||
if (!targets) return [];
|
||||
return allTargetingExposureContexts(
|
||||
targets.filter((t) => t.imageRef === imageRef),
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,21 @@
|
||||
import type { PostureReasonKind } from '@/types/security';
|
||||
import type { ImageFilterValue } from '@/lib/severityStyles';
|
||||
|
||||
/** The Images filter that best isolates the affected images for a posture reason.
|
||||
* Only fixable findings map to a data-backed filter; known-exploited and
|
||||
* public-exposure have no per-image flag in the summaries, so they open Images
|
||||
* unfiltered rather than mis-hiding the affected images. */
|
||||
/** The Images severity filter that best isolates the affected images when a
|
||||
* posture reason has no targets (older remote node). With targets present,
|
||||
* Images uses reason targeting instead. waiting_upstream /
|
||||
* update_check_uncertain open Images unfiltered via View findings when
|
||||
* targets are absent rather than inventing a severity dimension. */
|
||||
export function reasonImageFilter(kind: PostureReasonKind): ImageFilterValue | undefined {
|
||||
return kind === 'fixable_cve' ? 'FIXABLE' : undefined;
|
||||
}
|
||||
|
||||
/** Default Open-button label for a reason when actionLabel is omitted. */
|
||||
export function defaultReasonActionLabel(targetTab: string): string {
|
||||
if (targetTab === 'compose') return 'Open Compose risks';
|
||||
if (targetTab === 'suppressions') return 'Open Suppressions';
|
||||
if (targetTab === 'secrets') return 'Open Secrets';
|
||||
if (targetTab === 'history') return 'Open History';
|
||||
if (targetTab === 'scanner') return 'Open Scanner setup';
|
||||
return 'Open Images';
|
||||
}
|
||||
|
||||
@@ -18,15 +18,13 @@ export const SCANNER_DETECTIONS_NOTE =
|
||||
/**
|
||||
* Derives the Security masthead from action posture, not raw severity. Raw
|
||||
* Critical/High counts are scanner detections shown separately; they no longer
|
||||
* decide the headline. "Secure" means nothing is actionable right now, never a
|
||||
* claim that no vulnerabilities exist.
|
||||
* decide the headline alone.
|
||||
*
|
||||
* The backend computes the authoritative `posture` (one bucketing function), so
|
||||
* this prefers `overview.posture` when present. The local bootstrap below is the
|
||||
* fallback for an older remote node reached through the proxy that does not
|
||||
* report posture: "actionable" is approximated from the overview facts that
|
||||
* already exist (fixable findings, secrets, misconfigs); Unknown covers a
|
||||
* missing scanner or a node that has never completed a scan.
|
||||
* Prefer backend `overview.posture` (Secure requires cleared residual Crit/High
|
||||
* and review conditions; accepting residual risk stays Monitoring). The local
|
||||
* bootstrap below is only for older remotes that omit posture: actionable is
|
||||
* approximated from fixable/secrets/misconfigs; Unknown covers a missing
|
||||
* scanner or never-scanned node.
|
||||
*/
|
||||
export function deriveMasthead(
|
||||
overview: SecurityOverview | null,
|
||||
|
||||
@@ -124,6 +124,17 @@ export interface MisconfigAcknowledgement {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/** Triage decision states (mirrors the backend TriageStatus). */
|
||||
export type TriageStatus =
|
||||
| 'needs_review' | 'affected' | 'not_affected' | 'accepted' | 'fixed' | 'false_positive' | 'ignored';
|
||||
|
||||
/** OpenVEX-aligned justification codes (mirrors the backend TriageJustification). */
|
||||
export type TriageJustification =
|
||||
| 'vulnerable_code_not_in_execute_path'
|
||||
| 'vulnerable_code_not_present'
|
||||
| 'component_not_present'
|
||||
| 'inline_mitigations_already_exist';
|
||||
|
||||
export interface VulnerabilityDetail {
|
||||
id: number;
|
||||
scan_id: number;
|
||||
@@ -153,19 +164,11 @@ export interface VulnerabilityDetail {
|
||||
suppressed?: boolean;
|
||||
suppression_id?: number;
|
||||
suppression_reason?: string;
|
||||
/** Finding-scoped triage decision when joined from suppressions. */
|
||||
triage_status?: TriageStatus;
|
||||
triage_justification?: TriageJustification | null;
|
||||
}
|
||||
|
||||
/** Triage decision states (mirrors the backend TriageStatus). */
|
||||
export type TriageStatus =
|
||||
| 'needs_review' | 'affected' | 'not_affected' | 'accepted' | 'fixed' | 'false_positive' | 'ignored';
|
||||
|
||||
/** OpenVEX-aligned justification codes (mirrors the backend TriageJustification). */
|
||||
export type TriageJustification =
|
||||
| 'vulnerable_code_not_in_execute_path'
|
||||
| 'vulnerable_code_not_present'
|
||||
| 'component_not_present'
|
||||
| 'inline_mitigations_already_exist';
|
||||
|
||||
export interface CveSuppression {
|
||||
id: number;
|
||||
cve_id: string;
|
||||
@@ -181,6 +184,24 @@ export interface CveSuppression {
|
||||
justification?: TriageJustification | null;
|
||||
}
|
||||
|
||||
/** Per stack/service exposure + Networking intent for a standing image summary. */
|
||||
export interface ImageExposureContext {
|
||||
stackName: string;
|
||||
serviceName: string;
|
||||
exposureReason: 'published-port' | 'host-network' | null;
|
||||
exposureIntent?: 'internal' | 'same-node' | 'lan' | 'reverse-proxy' | 'public' | 'temporary' | 'unknown';
|
||||
intentStatus: 'set' | 'unset' | 'unavailable';
|
||||
intentConflict?: boolean;
|
||||
}
|
||||
|
||||
/** Pre-cap aggregates for standing exposure conclusions (authoritative vs display slice). */
|
||||
export interface ImageExposureContextSummary {
|
||||
hasConflict: boolean;
|
||||
hasUnclassified: boolean;
|
||||
hasUnavailable: boolean;
|
||||
allKnownIntentional: boolean;
|
||||
}
|
||||
|
||||
export interface ScanSummary {
|
||||
image_ref: string;
|
||||
highest_severity: VulnSeverity | null;
|
||||
@@ -195,6 +216,15 @@ export interface ScanSummary {
|
||||
fixable: number;
|
||||
secret_count: number;
|
||||
misconfig_count: number;
|
||||
/** Tri-state Compose exposure from cached stack descriptors (route enrichment). */
|
||||
publicly_exposed?: boolean | null;
|
||||
/** Capped display list of stack/service exposure contexts (when publicly exposed). */
|
||||
exposure_contexts?: ImageExposureContext[];
|
||||
/** Total contexts after dedupe, before display cap. */
|
||||
exposure_context_count?: number;
|
||||
exposure_contexts_truncated?: boolean;
|
||||
/** Aggregates over the full list before cap; prefer for banner/row conclusions. */
|
||||
exposure_context_summary?: ImageExposureContextSummary;
|
||||
}
|
||||
|
||||
export interface ScanPolicy {
|
||||
@@ -257,7 +287,10 @@ export type SecurityPostureState = 'Action needed' | 'Monitoring' | 'Secure' | '
|
||||
/** Kinds of posture reason the backend can report. */
|
||||
export type PostureReasonKind =
|
||||
| 'fixable_cve'
|
||||
| 'waiting_upstream'
|
||||
| 'update_check_uncertain'
|
||||
| 'known_exploited'
|
||||
| 'elevated_exploit_risk'
|
||||
| 'secret'
|
||||
| 'dangerous_compose'
|
||||
| 'public_exposure'
|
||||
@@ -265,6 +298,27 @@ export type PostureReasonKind =
|
||||
| 'failed_scan'
|
||||
| 'needs_review';
|
||||
|
||||
/** Bounded finding identities that drive a vulnerability-derived posture reason. */
|
||||
export interface PostureDriverFinding {
|
||||
vulnerabilityId: string;
|
||||
imageRef: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Image identity behind a posture reason (raw scan image_ref).
|
||||
* Exposure reasons may enrich with stack, service, and Networking intent;
|
||||
* other reasons are typically imageRef-only.
|
||||
*/
|
||||
export interface PostureTarget {
|
||||
imageRef: string;
|
||||
stackName?: string;
|
||||
serviceName?: string;
|
||||
exposureReason?: 'published-port' | 'host-network' | null;
|
||||
exposureIntent?: 'internal' | 'same-node' | 'lan' | 'reverse-proxy' | 'public' | 'temporary' | 'unknown';
|
||||
intentStatus?: 'set' | 'unset' | 'unavailable';
|
||||
intentConflict?: boolean;
|
||||
}
|
||||
|
||||
/** One structured reason explaining why the security posture is what it is. */
|
||||
export interface PostureReason {
|
||||
kind: PostureReasonKind;
|
||||
@@ -273,6 +327,22 @@ export interface PostureReason {
|
||||
label: string;
|
||||
description: string;
|
||||
targetTab: SecurityTab;
|
||||
/** Optional Open-button label; when omitted the UI derives from targetTab. */
|
||||
actionLabel?: string;
|
||||
/**
|
||||
* Target rows for this reason (image-only, or per stack/service for exposure).
|
||||
* May repeat imageRef. Omitted when empty or unknown.
|
||||
*/
|
||||
targets?: PostureTarget[];
|
||||
/**
|
||||
* Exact contributing findings for vulnerability-derived reasons (capped).
|
||||
* Older remotes omit this field.
|
||||
*/
|
||||
drivers?: PostureDriverFinding[];
|
||||
/** Full contributing driver count before cap; omit when drivers omitted. */
|
||||
driverCount?: number;
|
||||
/** True when driverCount exceeds the attached drivers array length. */
|
||||
driversTruncated?: boolean;
|
||||
}
|
||||
|
||||
/** Highest-priority action for the masthead CTA. */
|
||||
@@ -282,6 +352,12 @@ export interface PostureAction {
|
||||
/** The reason kind behind this action, so the UI can target the affected
|
||||
* items precisely (e.g. filter Images to fixable findings). */
|
||||
kind: PostureReasonKind;
|
||||
/** Same targets as the reason that produced this action, when available. */
|
||||
targets?: PostureTarget[];
|
||||
/** Same drivers as the reason that produced this action, when available. */
|
||||
drivers?: PostureDriverFinding[];
|
||||
driverCount?: number;
|
||||
driversTruncated?: boolean;
|
||||
}
|
||||
|
||||
/** Node-scoped security posture rollup for the Security page Overview. */
|
||||
@@ -318,7 +394,7 @@ export interface SecurityOverview {
|
||||
needsReview?: number;
|
||||
accepted?: number;
|
||||
notAffected?: number;
|
||||
/** Total actionable items, for the "N actions" affordance. */
|
||||
/** Legacy mixed-unit sum of blocker counts. Prefer posture / reasons. */
|
||||
actionable?: number;
|
||||
posture?: SecurityPostureState;
|
||||
/** True when the bounded posture pass hit its row cap on this node. */
|
||||
@@ -328,6 +404,8 @@ export interface SecurityOverview {
|
||||
postureReasons?: PostureReason[];
|
||||
/** Highest-priority action for the masthead CTA, or null when no blockers. */
|
||||
primaryAction?: PostureAction | null;
|
||||
/** True when image-update checks are disabled (gates Check again on uncertain rows). */
|
||||
updateChecksDisabled?: boolean;
|
||||
}
|
||||
|
||||
/** Which detail tab the scan sheet opens on. Matches VulnerabilityScanSheet's tabs. */
|
||||
|
||||
Reference in New Issue
Block a user