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
This commit is contained in:
Anso
2026-06-25 21:56:25 -04:00
committed by GitHub
parent e9c262ae6a
commit 0384c47d1e
7 changed files with 504 additions and 26 deletions
+18 -1
View File
@@ -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 ? (
<span className="inline-flex items-center gap-1.5">
{actionSummary ? <span>{actionSummary}</span> : null}
<span>
{overview.scannedImages} {overview.scannedImages === 1 ? 'image' : 'images'} scanned · scanner {overview.scanner.available ? 'ready' : 'not installed'}
</span>
@@ -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 ? (
<button
type="button"
onClick={() => onTabChange(overview.primaryAction!.targetTab)}
className="text-xs font-medium text-brand hover:underline whitespace-nowrap"
>
{overview.primaryAction.label}
</button>
) : null}
</PageMasthead>
<Tabs value={activeTab} onValueChange={(v) => onTabChange(v as SecurityTab)}>
<TabsList className="mb-4">