From 0384c47d1eacdba875283330c983606db3dc2260 Mon Sep 17 00:00:00 2001 From: Anso Date: Thu, 25 Jun 2026 21:56:25 -0400 Subject: [PATCH] feat: add posture reasons and review queue to Security overview (#1462) Add structured posture reasons derived alongside the posture verb in securityPosture.ts so the masthead and Overview tab can answer why the page is red, what to do first, and what clears it. Backend: - derivePostureReasons() returns blocker, review, and info reasons from the same SecurityPostureFacts used by deriveSecurityPosture() - deriveSecurityPosture() depends on derivePostureReasons() internally - Exposure split: public exposure with KEV, fixable, or EPSS >= 0.1 is a blocker; exposure without any of those is a review item - Fully dismissed exposed images produce no posture reason - postureReasons and primaryAction returned by the overview endpoint Frontend: - ReviewQueueCard on the Overview tab with per-row CTAs for blockers - Action summary in masthead subtitle and desktop primary CTA button - Card gated on posture not being Unknown - Backward compatible with older remote nodes --- backend/src/__tests__/securityPosture.test.ts | 127 +++++++++- backend/src/routes/security.ts | 42 +++- backend/src/services/securityPosture.ts | 220 ++++++++++++++++-- docs/features/security.mdx | 10 +- frontend/src/components/SecurityView.tsx | 19 +- .../src/components/security/OverviewTab.tsx | 78 ++++++- frontend/src/types/security.ts | 34 +++ 7 files changed, 504 insertions(+), 26 deletions(-) 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. */