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
+207 -13
View File
@@ -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';
}