Files
sencho/backend/src/services/securityPosture.ts
T
Anso 083442d5ea fix: differentiate security action links and add suppression editing (#1500)
Security page UX fixes:

- Stop the CVSS x EPSS scatter chart from painting a full-plot "white
  rectangle" cursor on click (cursor disabled), and prevent click-drag
  selection on charts.
- Differentiate the overview action links: "fixable" links (masthead primary
  action, review-queue blocker, and the Fixable signal tile) now open the
  Images tab pre-filtered to fixable findings; the Stale and Failed signal
  tiles link to the History tab where those scans are listed; Secrets and
  Misconfigs tiles link to their tabs. The Images tab accepts an initialFilter
  and exposes a Fixable option in the severity dropdown.
- Fix the "Secrets / misconfigs" option wrapping and misaligning in the
  severity dropdown (single-line option labels, wider trigger).
- Add Edit for CVE suppressions and misconfig acknowledgements (reason, scope
  pattern, expiry), reusing the existing dialog and the existing PUT endpoints;
  the CVE/rule identity stays fixed.
2026-06-28 06:17:47 -04:00

249 lines
8.6 KiB
TypeScript

/**
* 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 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).
*
* Posture is deliberately NOT raw severity: a page is never "Secure" merely
* because counts are zero-weighted, and never "Action needed" merely because a
* 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;
/** The reason kind that produced this action, so the UI can target the
* affected items precisely (e.g. filter Images to fixable findings). */
kind: PostureReasonKind;
}
export interface SecurityPostureFacts {
/** The scanner is installed and usable on this node. */
scannerAvailable: boolean;
/** At least one scan has completed (a freshly installed node has none). */
hasCompletedScan: boolean;
/** Critical/High findings with a fix available, net of suppressions. */
fixableCriticalHigh: number;
/** Detected secrets (not suppressible in the current model). */
secrets: number;
/** High-severity Compose misconfigurations, net of acknowledgements. */
dangerousCompose: number;
/** Known-exploited (CISA KEV) findings among non-suppressed Critical/High. */
knownExploited: number;
/** 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: r.targetTab, kind: r.kind };
}
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: r.targetTab, kind: r.kind };
}
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: r.targetTab, kind: r.kind };
}
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: r.targetTab, kind: r.kind };
}
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: r.targetTab, kind: r.kind };
}
// 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';
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';
}