diff --git a/backend/src/__tests__/securityPosture.test.ts b/backend/src/__tests__/securityPosture.test.ts index 14523a3f..ef41e633 100644 --- a/backend/src/__tests__/securityPosture.test.ts +++ b/backend/src/__tests__/securityPosture.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; -import { deriveSecurityPosture, type SecurityPostureFacts } from '../services/securityPosture'; +import { deriveSecurityPosture, derivePostureReasons, type SecurityPostureFacts } from '../services/securityPosture'; -function facts(o: Partial): SecurityPostureFacts { +function facts(o: Partial = {}): SecurityPostureFacts { return { scannerAvailable: true, hasCompletedScan: true, @@ -10,8 +10,13 @@ function facts(o: Partial): SecurityPostureFacts { dangerousCompose: 0, knownExploited: 0, publiclyExposed: 0, + exposedBlocker: 0, + exposedReview: 0, rawCritical: 0, rawHigh: 0, + staleScans: 0, + failedScans: 0, + needsReview: 0, ...o, }; } @@ -38,19 +43,129 @@ describe('deriveSecurityPosture', () => { }); it('is Action needed when a finding is known-exploited even if unfixable', () => { - // KEV escalates: no fix available, but exploited in the wild. expect(deriveSecurityPosture(facts({ knownExploited: 1, fixableCriticalHigh: 0, rawCritical: 1 }))).toBe('Action needed'); }); - it('is Action needed when an affected service is publicly exposed', () => { - expect(deriveSecurityPosture(facts({ publiclyExposed: 1 }))).toBe('Action needed'); + it('is Action needed when exposedBlocker > 0 (KEV, fixable, or elevated EPSS on a public interface)', () => { + expect(deriveSecurityPosture(facts({ exposedBlocker: 1 }))).toBe('Action needed'); + }); + + it('is Monitoring when publiclyExposed > 0 but exposedBlocker is 0 (review-only exposure)', () => { + expect(deriveSecurityPosture(facts({ publiclyExposed: 3, exposedReview: 3, rawCritical: 2 }))).toBe('Monitoring'); }); it('is Monitoring when Critical/High exist but nothing is actionable', () => { expect(deriveSecurityPosture(facts({ rawCritical: 3, rawHigh: 7 }))).toBe('Monitoring'); }); + it('is Monitoring when only review/info reasons exist', () => { + expect(deriveSecurityPosture(facts({ exposedReview: 1, needsReview: 2, staleScans: 1 }))).toBe('Monitoring'); + }); + it('is Secure when a scan completed and nothing is actionable or severe', () => { - expect(deriveSecurityPosture(facts({}))).toBe('Secure'); + expect(deriveSecurityPosture(facts())).toBe('Secure'); + }); +}); + +describe('derivePostureReasons', () => { + it('returns an empty reason list and null primary action for a clean node', () => { + const { reasons, primaryAction } = derivePostureReasons(facts()); + expect(reasons).toEqual([]); + expect(primaryAction).toBeNull(); + }); + + it('returns a blocker reason for fixable findings', () => { + const { reasons } = derivePostureReasons(facts({ fixableCriticalHigh: 4 })); + expect(reasons).toContainEqual(expect.objectContaining({ kind: 'fixable_cve', count: 4, severity: 'blocker' })); + }); + + it('returns a blocker reason for known-exploited findings', () => { + const { reasons } = derivePostureReasons(facts({ knownExploited: 2 })); + expect(reasons).toContainEqual(expect.objectContaining({ kind: 'known_exploited', count: 2, severity: 'blocker' })); + }); + + it('returns a blocker reason for secrets', () => { + const { reasons } = derivePostureReasons(facts({ secrets: 3 })); + expect(reasons).toContainEqual(expect.objectContaining({ kind: 'secret', count: 3, severity: 'blocker' })); + }); + + it('returns a blocker reason for dangerous Compose misconfigs', () => { + const { reasons } = derivePostureReasons(facts({ dangerousCompose: 5 })); + expect(reasons).toContainEqual(expect.objectContaining({ kind: 'dangerous_compose', count: 5, severity: 'blocker' })); + }); + + it('returns a blocker reason for exposedBlocker', () => { + const { reasons } = derivePostureReasons(facts({ exposedBlocker: 1 })); + expect(reasons).toContainEqual(expect.objectContaining({ kind: 'public_exposure', count: 1, severity: 'blocker' })); + }); + + it('returns a review reason for exposedReview', () => { + const { reasons } = derivePostureReasons(facts({ exposedReview: 2 })); + expect(reasons).toContainEqual(expect.objectContaining({ kind: 'public_exposure', count: 2, severity: 'review' })); + }); + + it('returns a review reason for needsReview', () => { + const { reasons } = derivePostureReasons(facts({ needsReview: 7 })); + expect(reasons).toContainEqual(expect.objectContaining({ kind: 'needs_review', count: 7, severity: 'review' })); + }); + + it('returns info reasons for stale and failed scans', () => { + const { reasons } = derivePostureReasons(facts({ staleScans: 3, failedScans: 1 })); + expect(reasons).toContainEqual(expect.objectContaining({ kind: 'stale_scan', count: 3, severity: 'info' })); + expect(reasons).toContainEqual(expect.objectContaining({ kind: 'failed_scan', count: 1, severity: 'info' })); + }); + + it('returns ALL reasons regardless of posture state', () => { + // Even with no scanner (Unknown posture), the facts produce reasons. + const { reasons } = derivePostureReasons(facts({ + scannerAvailable: false, + fixableCriticalHigh: 4, + staleScans: 1, + })); + expect(reasons).toHaveLength(2); + expect(reasons[0].kind).toBe('fixable_cve'); + expect(reasons[1].kind).toBe('stale_scan'); + }); + + it('sets primaryAction to the first blocker (fixable_cve priority)', () => { + const { primaryAction } = derivePostureReasons(facts({ + fixableCriticalHigh: 3, + knownExploited: 1, + secrets: 2, + })); + expect(primaryAction).toEqual({ label: 'Update affected images', targetTab: 'images' }); + }); + + it('falls through to the next blocker when the first is absent', () => { + const { primaryAction } = derivePostureReasons(facts({ secrets: 1 })); + expect(primaryAction).toEqual({ label: 'Review detected secrets', targetTab: 'secrets' }); + }); + + it('returns null primaryAction when no blockers exist', () => { + const { primaryAction } = derivePostureReasons(facts({ + exposedReview: 1, needsReview: 2, staleScans: 1, failedScans: 0, + })); + expect(primaryAction).toBeNull(); + }); + + it('each blocker reason has a targetTab matching a valid Security tab', () => { + const validTabs = ['images', 'secrets', 'compose', 'history', 'suppressions', 'scanner']; + const { reasons } = derivePostureReasons(facts({ + fixableCriticalHigh: 1, secrets: 1, dangerousCompose: 1, + knownExploited: 1, exposedBlocker: 1, + })); + for (const r of reasons) { + expect(validTabs).toContain(r.targetTab); + } + }); + + // Invariant: Action needed posture always has at least one blocker reason. + it('Action needed posture always has at least one blocker reason', () => { + const f = facts({ fixableCriticalHigh: 1 }); + const posture = deriveSecurityPosture(f); + const { reasons } = derivePostureReasons(f); + if (posture === 'Action needed') { + expect(reasons.some((r) => r.severity === 'blocker')).toBe(true); + } }); }); diff --git a/backend/src/routes/security.ts b/backend/src/routes/security.ts index 7d6c6d39..6b05aae8 100644 --- a/backend/src/routes/security.ts +++ b/backend/src/routes/security.ts @@ -14,7 +14,7 @@ import { applySuppressions, isTriageStatus, isTriageJustification } from '../uti import { applyMisconfigAcknowledgements } from '../utils/misconfig-ack-filter'; import { generateSarif } from '../services/SarifExporter'; import { generateOpenVex } from '../services/OpenVexExporter'; -import { deriveSecurityPosture, type SecurityPostureFacts, type SecurityPostureState } from '../services/securityPosture'; +import { deriveSecurityPosture, derivePostureReasons, HIGH_EPSS_THRESHOLD, type SecurityPostureFacts, type SecurityPostureState, type PostureReason, type PostureAction } from '../services/securityPosture'; import { buildExposedImageMap } from '../services/preflight/exposure'; import { sanitizeForLog } from '../utils/safeLog'; import { getErrorMessage } from '../utils/errors'; @@ -160,6 +160,10 @@ interface SecurityOverviewResponse { posture: SecurityPostureState; /** True when the bounded posture pass hit its row cap on this node. */ posturePartial: boolean; + /** Structured reasons explaining the posture (blockers, review, info). */ + postureReasons: PostureReason[]; + /** Highest-priority action for the masthead CTA, or null when no blockers. */ + primaryAction: PostureAction | null; } export const securityRouter = Router(); @@ -773,10 +777,32 @@ securityRouter.get('/overview', authMiddleware, (req: Request, res: Response): v }).filter(Boolean), ); let publiclyExposed = 0; - for (const [imageRef] of critHighByImage) { - if (exposedMap.get(imageRef) === true) publiclyExposed += 1; + let exposedBlocker = 0; + let exposedReview = 0; + for (const [imageRef, group] of critHighByImage) { + if (exposedMap.get(imageRef) !== true) continue; + publiclyExposed += 1; + let hasUnsuppressedFinding = false; + let hasKevOrFixOrHighEpss = false; + for (const e of applySuppressions(group, imageRef, cveSuppressions)) { + if (e.suppressed) continue; + hasUnsuppressedFinding = true; + if ( + e.fixed_version + || intel.get(e.vulnerability_id)?.kev + || (intel.get(e.vulnerability_id)?.epssScore ?? 0) >= HIGH_EPSS_THRESHOLD + ) { + hasKevOrFixOrHighEpss = true; + break; + } + } + if (!hasUnsuppressedFinding) continue; // fully dismissed + if (hasKevOrFixOrHighEpss) exposedBlocker += 1; + else exposedReview += 1; } + const failedScans = db.countScansByStatus(req.nodeId, 'failed'); + const postureFacts: SecurityPostureFacts = { scannerAvailable: svc.isTrivyAvailable(), hasCompletedScan: lastSuccessfulScanAt !== null, @@ -785,10 +811,16 @@ securityRouter.get('/overview', authMiddleware, (req: Request, res: Response): v dangerousCompose, knownExploited, publiclyExposed, + exposedBlocker, + exposedReview, rawCritical: critical, rawHigh: high, + staleScans, + failedScans, + needsReview, }; const posture = deriveSecurityPosture(postureFacts); + const { reasons: postureReasons, primaryAction } = derivePostureReasons(postureFacts); const actionable = fixableCriticalHigh + secrets + dangerousCompose + knownExploited + publiclyExposed; const overview: SecurityOverviewResponse = { @@ -799,7 +831,7 @@ securityRouter.get('/overview', authMiddleware, (req: Request, res: Response): v secrets, misconfigs, staleScans, - failedScans: db.countScansByStatus(req.nodeId, 'failed'), + failedScans, lastSuccessfulScanAt, scanner: { available: svc.isTrivyAvailable(), @@ -827,6 +859,8 @@ securityRouter.get('/overview', authMiddleware, (req: Request, res: Response): v actionable, posture, posturePartial: critHigh.truncated || highMisconfigs.truncated, + postureReasons, + primaryAction, }; res.json(overview); } catch (error) { diff --git a/backend/src/services/securityPosture.ts b/backend/src/services/securityPosture.ts index 5abe4b97..1eff6708 100644 --- a/backend/src/services/securityPosture.ts +++ b/backend/src/services/securityPosture.ts @@ -1,8 +1,12 @@ /** - * Single source of truth for the Security page's action posture. + * Single source of truth for the Security page's action posture and the + * "why" breakdown that explains it. * * The overview route gathers the facts (suppression-, acknowledgement-, and - * intel-aware) and this function buckets them into one of four product verbs. + * intel-aware) and this module buckets them into a product verb plus a list of + * structured posture reasons so the masthead and Overview tab can answer "what + * should I do first?" rather than merely stating a state word. + * * Keeping the bucketing here, separate from storage, means copy or threshold * changes never require a schema migration, and the same verdict can be reused * by other surfaces (action queue, per-stack blast radius). @@ -12,8 +16,52 @@ * Critical exists with nothing to do about it. "Secure" means nothing is * actionable right now, not a claim that no vulnerabilities exist. */ + +/** EPSS score at or above this is treated as an elevated exploitation + * likelihood, matching the frontend threshold in SecurityCharts.tsx. */ +export const HIGH_EPSS_THRESHOLD = 0.1; + export type SecurityPostureState = 'Action needed' | 'Monitoring' | 'Secure' | 'Unknown'; +/** Valid Security tab targets for a posture reason CTA. Mirrors the frontend + * SecurityTab union (frontend/src/lib/events.ts). */ +export type SecurityPostureTargetTab = + | 'images' + | 'secrets' + | 'compose' + | 'history' + | 'suppressions' + | 'scanner'; + +export type PostureReasonKind = + | 'fixable_cve' + | 'known_exploited' + | 'secret' + | 'dangerous_compose' + | 'public_exposure' + | 'stale_scan' + | 'failed_scan' + | 'needs_review'; + +export type PostureReasonSeverity = 'blocker' | 'review' | 'info'; + +export interface PostureReason { + kind: PostureReasonKind; + count: number; + severity: PostureReasonSeverity; + /** Short label for the reason row (e.g. "Fixable findings"). */ + label: string; + /** One-sentence explanation visible under the label. */ + description: string; + /** Which Security tab the CTA navigates to. */ + targetTab: SecurityPostureTargetTab; +} + +export interface PostureAction { + label: string; + targetTab: SecurityPostureTargetTab; +} + export interface SecurityPostureFacts { /** The scanner is installed and usable on this node. */ scannerAvailable: boolean; @@ -27,25 +75,171 @@ export interface SecurityPostureFacts { dangerousCompose: number; /** Known-exploited (CISA KEV) findings among non-suppressed Critical/High. */ knownExploited: number; - /** Affected services published to a non-loopback address. */ + /** Total affected services published to a non-loopback address (legacy; + * exposedBlocker + exposedReview is the authoritative split). */ publiclyExposed: number; + /** Exposed images with KEV, fixable, or elevated-EPSS findings (blocker). */ + exposedBlocker: number; + /** Exposed images without KEV, fix, or elevated EPSS (review only). */ + exposedReview: number; /** Raw Critical scanner detections (for the Monitoring fallback). */ rawCritical: number; /** Raw High scanner detections (for the Monitoring fallback). */ rawHigh: number; + /** Images whose latest scan is older than the stale threshold. */ + staleScans: number; + /** Scans that terminated with an error. */ + failedScans: number; + /** Findings with triage_status = 'needs_review' (not dismissed, not accepted). */ + needsReview: number; } +/** + * Derive the structured reasons behind the current security posture. + * + * Called by `deriveSecurityPosture` so the posture word and its explanation + * can never drift: the same blocker input that turns the masthead red is the + * blocker that appears in the reason list. + * + * All reasons (blocker, review, info) are returned regardless of posture + * state. The caller decides which subset to surface. + */ +export function derivePostureReasons(f: SecurityPostureFacts): { + reasons: PostureReason[]; + primaryAction: PostureAction | null; +} { + const reasons: PostureReason[] = []; + let primaryAction: PostureAction | null = null; + + // Blockers. Each of these can keep the masthead red. + + if (f.fixableCriticalHigh > 0) { + const r: PostureReason = { + kind: 'fixable_cve', + count: f.fixableCriticalHigh, + severity: 'blocker', + label: 'Fixable findings', + description: 'Critical or High findings with an available fix.', + targetTab: 'images', + }; + reasons.push(r); + if (!primaryAction) primaryAction = { label: 'Update affected images', targetTab: 'images' }; + } + + if (f.knownExploited > 0) { + const r: PostureReason = { + kind: 'known_exploited', + count: f.knownExploited, + severity: 'blocker', + label: 'Known-exploited findings', + description: 'Findings in the CISA Known Exploited Vulnerabilities catalog.', + targetTab: 'images', + }; + reasons.push(r); + if (!primaryAction) primaryAction = { label: 'Review exploited findings', targetTab: 'images' }; + } + + if (f.secrets > 0) { + const r: PostureReason = { + kind: 'secret', + count: f.secrets, + severity: 'blocker', + label: 'Detected secrets', + description: 'Images with exposed credentials or keys. Review on the Secrets tab.', + targetTab: 'secrets', + }; + reasons.push(r); + if (!primaryAction) primaryAction = { label: 'Review detected secrets', targetTab: 'secrets' }; + } + + if (f.dangerousCompose > 0) { + const r: PostureReason = { + kind: 'dangerous_compose', + count: f.dangerousCompose, + severity: 'blocker', + label: 'Unacknowledged Compose risks', + description: 'High-severity misconfigurations that have not been acknowledged.', + targetTab: 'compose', + }; + reasons.push(r); + if (!primaryAction) primaryAction = { label: 'Review Compose risks', targetTab: 'compose' }; + } + + if (f.exposedBlocker > 0) { + const r: PostureReason = { + kind: 'public_exposure', + count: f.exposedBlocker, + severity: 'blocker', + label: 'Publicly exposed affected images', + description: 'Images with fixable, known-exploited, or elevated-EPSS findings published on a public interface.', + targetTab: 'images', + }; + reasons.push(r); + if (!primaryAction) primaryAction = { label: 'Review public exposure', targetTab: 'images' }; + } + + // Review items. These appear in-page but do not force a red masthead. + + if (f.exposedReview > 0) { + reasons.push({ + kind: 'public_exposure', + count: f.exposedReview, + severity: 'review', + label: 'Exposed images (monitoring)', + description: 'Images published on a public interface with no fix, no KEV, and no elevated EPSS.', + targetTab: 'images', + }); + } + + if (f.needsReview > 0) { + reasons.push({ + kind: 'needs_review', + count: f.needsReview, + severity: 'review', + label: 'Findings needing review', + description: 'Findings awaiting a triage decision on the Suppressions tab.', + targetTab: 'suppressions', + }); + } + + // Info items. Context only, never red. + + if (f.staleScans > 0) { + reasons.push({ + kind: 'stale_scan', + count: f.staleScans, + severity: 'info', + label: 'Stale scans', + description: 'Images whose latest scan is older than 7 days.', + targetTab: 'history', + }); + } + + if (f.failedScans > 0) { + reasons.push({ + kind: 'failed_scan', + count: f.failedScans, + severity: 'info', + label: 'Failed scans', + description: 'Scans that terminated with an error. Inspect on the History tab.', + targetTab: 'history', + }); + } + + return { reasons, primaryAction }; +} + +/** + * Bucket the security facts into one of four product verbs. + * + * Calls `derivePostureReasons` internally so the posture word and its + * explanation are derived from the same inputs: if the masthead is red, + * there is always at least one blocker reason in the reason list. + */ export function deriveSecurityPosture(f: SecurityPostureFacts): SecurityPostureState { if (!f.scannerAvailable || !f.hasCompletedScan) return 'Unknown'; - if ( - f.fixableCriticalHigh > 0 - || f.secrets > 0 - || f.dangerousCompose > 0 - || f.knownExploited > 0 - || f.publiclyExposed > 0 - ) { - return 'Action needed'; - } - if (f.rawCritical > 0 || f.rawHigh > 0) return 'Monitoring'; + const { reasons } = derivePostureReasons(f); + if (reasons.some((r) => r.severity === 'blocker')) return 'Action needed'; + if (f.rawCritical > 0 || f.rawHigh > 0 || reasons.length > 0) return 'Monitoring'; return 'Secure'; } diff --git a/docs/features/security.mdx b/docs/features/security.mdx index be0c6dec..0c3eb788 100644 --- a/docs/features/security.mdx +++ b/docs/features/security.mdx @@ -27,7 +27,15 @@ posture itself: a vulnerable component being present is not the same as a reacha The masthead carries a standing note to that effect, and posture weighs fix availability, exploit intelligence, and triage decisions rather than raw severity alone. -Below it, the charts lead with prioritization rather than raw severity: a **risk trend** for context, +Below the masthead, a **Review queue** card leads the overview when actions or review items exist. +When the posture is Action needed the card is titled **Why Action needed** and lists each concrete +action with a count and a tab-shortcut button: fixable findings, known-exploited CVEs, detected +secrets, unacknowledged Compose risks, and publicly exposed affected images. When only monitoring +items remain (exposed images with no fix or known exploit, findings awaiting triage, stale or failed +scans) the card is titled **Review queue** and lists them without the red masthead, so the operator +can see what to keep an eye on without a permanent alarm. + +The charts then lead with prioritization rather than raw severity: a **risk trend** for context, an **action posture** breakdown (fixable, known-exploited, needs-review, accepted, not-affected), a **top exploit-risk** list ranking actionable findings by known-exploited status then EPSS, and a **severity-by-exploitability** quadrant that separates high-severity-but-unlikely findings from the diff --git a/frontend/src/components/SecurityView.tsx b/frontend/src/components/SecurityView.tsx index d36a2d59..b86ea493 100644 --- a/frontend/src/components/SecurityView.tsx +++ b/frontend/src/components/SecurityView.tsx @@ -214,8 +214,15 @@ 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. + 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 subtitle = overview ? ( + {actionSummary ? {actionSummary} : null} {overview.scannedImages} {overview.scannedImages === 1 ? 'image' : 'images'} scanned · scanner {overview.scanner.available ? 'ready' : 'not installed'} @@ -363,7 +370,17 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security { label: 'HIGH', value: String(overview.high), tone: overview.high > 0 ? 'warn' : 'value' }, { label: 'LAST SCAN', value: overview.lastSuccessfulScanAt ? formatTimeAgo(overview.lastSuccessfulScanAt) : 'never', tone: 'subtitle' }, ] : undefined} - /> + > + {overview?.posture === 'Action needed' && overview.primaryAction ? ( + + ) : null} + onTabChange(v as SecurityTab)}> diff --git a/frontend/src/components/security/OverviewTab.tsx b/frontend/src/components/security/OverviewTab.tsx index 1cd238ba..e310d5d4 100644 --- a/frontend/src/components/security/OverviewTab.tsx +++ b/frontend/src/components/security/OverviewTab.tsx @@ -5,7 +5,7 @@ import { cn } from '@/lib/utils'; import { formatTimeAgo } from '@/lib/relativeTime'; import { useIsMobile } from '@/hooks/use-is-mobile'; import { SecuritySevStrip, SecurityTotalsGrid, SecurityFooterBand } from './SecurityMobile'; -import type { SecurityOverview, SecurityRiskTrendPoint, ExploitIntelFinding } from '@/types/security'; +import type { SecurityOverview, SecurityRiskTrendPoint, ExploitIntelFinding, PostureReason } from '@/types/security'; import type { SecurityTab } from '@/lib/events'; import { RiskTrendChart, @@ -57,6 +57,73 @@ function ChartCard({ title, className, children }: { title: string; className?: ); } +const SEVERITY_DOT: Record = { + blocker: 'bg-destructive', + review: 'bg-warning', + info: 'bg-stat-subtitle', +}; + +const SEVERITY_LABEL: Record = { + blocker: 'text-destructive', + review: 'text-warning', + info: 'text-stat-subtitle', +}; + +function ReviewQueueCard({ + reasons, + onNavigate, +}: { + reasons: PostureReason[]; + onNavigate: (tab: SecurityTab) => void; +}) { + 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'; + + return ( +
+

{title}

+
+ {blockers.map((r, i) => ( +
+ +
+
+ {r.label} + {r.count} + +
+

{r.description}

+
+
+ ))} + {nonBlockers.length > 0 && hasBlockers && ( +
+ )} + {nonBlockers.map((r, i) => ( +
+ +
+
+ {r.label} + {r.count} +
+

{r.description}

+
+
+ ))} +
+
+ ); +} + export function OverviewTab({ overview, loadError, trend, exploitIntel, onNavigate, onInspect, canScan, onScanComplete, isPaid }: OverviewTabProps) { const isMobile = useIsMobile(); @@ -127,6 +194,15 @@ export function OverviewTab({ overview, loadError, trend, exploitIntel, onNaviga scanner-detections note lives in the masthead's info affordance. */} {isMobile && } + {/* Review queue: surfaces the "why" behind the posture -- blocker reasons + with CTAs, plus review/info items even when the masthead is not red. */} + {overview.posture && overview.posture !== 'Unknown' && overview.postureReasons && overview.postureReasons.length > 0 && ( + + )} + {/* Charts lead the dashboard: the trend gives severity context, the rest answer "what should I act on first?" from posture + exploit intel. */}
diff --git a/frontend/src/types/security.ts b/frontend/src/types/security.ts index cde9f49a..5f93f70d 100644 --- a/frontend/src/types/security.ts +++ b/frontend/src/types/security.ts @@ -1,3 +1,5 @@ +import type { SecurityTab } from '@/lib/events'; + export type VulnSeverity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN'; export type VulnScanStatus = 'in_progress' | 'completed' | 'failed'; export type VulnScanTrigger = 'manual' | 'scheduled' | 'deploy' | 'deploy-preflight'; @@ -230,6 +232,33 @@ export interface ScanCompareResult { * backend `SecurityPostureState`. */ export type SecurityPostureState = 'Action needed' | 'Monitoring' | 'Secure' | 'Unknown'; +/** Kinds of posture reason the backend can report. */ +export type PostureReasonKind = + | 'fixable_cve' + | 'known_exploited' + | 'secret' + | 'dangerous_compose' + | 'public_exposure' + | 'stale_scan' + | 'failed_scan' + | 'needs_review'; + +/** One structured reason explaining why the security posture is what it is. */ +export interface PostureReason { + kind: PostureReasonKind; + count: number; + severity: 'blocker' | 'review' | 'info'; + label: string; + description: string; + targetTab: SecurityTab; +} + +/** Highest-priority action for the masthead CTA. */ +export interface PostureAction { + label: string; + targetTab: SecurityTab; +} + /** Node-scoped security posture rollup for the Security page Overview. */ export interface SecurityOverview { scannedImages: number; @@ -269,6 +298,11 @@ export interface SecurityOverview { posture?: SecurityPostureState; /** True when the bounded posture pass hit its row cap on this node. */ posturePartial?: boolean; + /** Structured reasons for the posture (blockers, review, info). Optional for + * older remote nodes that do not report them. */ + postureReasons?: PostureReason[]; + /** Highest-priority action for the masthead CTA, or null when no blockers. */ + primaryAction?: PostureAction | null; } /** Which detail tab the scan sheet opens on. Matches VulnerabilityScanSheet's tabs. */