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
@@ -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<PostureReason['severity'], string> = {
blocker: 'bg-destructive',
review: 'bg-warning',
info: 'bg-stat-subtitle',
};
const SEVERITY_LABEL: Record<PostureReason['severity'], string> = {
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 (
<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)}
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>
))}
{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>
))}
</div>
</div>
);
}
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 && <SecuritySevStrip overview={overview} />}
{/* 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 && (
<ReviewQueueCard
reasons={overview.postureReasons}
onNavigate={onNavigate}
/>
)}
{/* Charts lead the dashboard: the trend gives severity context, the rest
answer "what should I act on first?" from posture + exploit intel. */}
<div className="grid gap-4 lg:grid-cols-3">