mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 14:33:19 +00:00
083442d5ea
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.
249 lines
8.6 KiB
TypeScript
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';
|
|
}
|