mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-21 23:56:39 +00:00
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:
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { deriveSecurityPosture, type SecurityPostureFacts } from '../services/securityPosture';
|
||||
import { deriveSecurityPosture, derivePostureReasons, type SecurityPostureFacts } from '../services/securityPosture';
|
||||
|
||||
function facts(o: Partial<SecurityPostureFacts>): SecurityPostureFacts {
|
||||
function facts(o: Partial<SecurityPostureFacts> = {}): SecurityPostureFacts {
|
||||
return {
|
||||
scannerAvailable: true,
|
||||
hasCompletedScan: true,
|
||||
@@ -10,8 +10,13 @@ function facts(o: Partial<SecurityPostureFacts>): SecurityPostureFacts {
|
||||
dangerousCompose: 0,
|
||||
knownExploited: 0,
|
||||
publiclyExposed: 0,
|
||||
exposedBlocker: 0,
|
||||
exposedReview: 0,
|
||||
rawCritical: 0,
|
||||
rawHigh: 0,
|
||||
staleScans: 0,
|
||||
failedScans: 0,
|
||||
needsReview: 0,
|
||||
...o,
|
||||
};
|
||||
}
|
||||
@@ -38,19 +43,129 @@ describe('deriveSecurityPosture', () => {
|
||||
});
|
||||
|
||||
it('is Action needed when a finding is known-exploited even if unfixable', () => {
|
||||
// KEV escalates: no fix available, but exploited in the wild.
|
||||
expect(deriveSecurityPosture(facts({ knownExploited: 1, fixableCriticalHigh: 0, rawCritical: 1 }))).toBe('Action needed');
|
||||
});
|
||||
|
||||
it('is Action needed when an affected service is publicly exposed', () => {
|
||||
expect(deriveSecurityPosture(facts({ publiclyExposed: 1 }))).toBe('Action needed');
|
||||
it('is Action needed when exposedBlocker > 0 (KEV, fixable, or elevated EPSS on a public interface)', () => {
|
||||
expect(deriveSecurityPosture(facts({ exposedBlocker: 1 }))).toBe('Action needed');
|
||||
});
|
||||
|
||||
it('is Monitoring when publiclyExposed > 0 but exposedBlocker is 0 (review-only exposure)', () => {
|
||||
expect(deriveSecurityPosture(facts({ publiclyExposed: 3, exposedReview: 3, rawCritical: 2 }))).toBe('Monitoring');
|
||||
});
|
||||
|
||||
it('is Monitoring when Critical/High exist but nothing is actionable', () => {
|
||||
expect(deriveSecurityPosture(facts({ rawCritical: 3, rawHigh: 7 }))).toBe('Monitoring');
|
||||
});
|
||||
|
||||
it('is Monitoring when only review/info reasons exist', () => {
|
||||
expect(deriveSecurityPosture(facts({ exposedReview: 1, needsReview: 2, staleScans: 1 }))).toBe('Monitoring');
|
||||
});
|
||||
|
||||
it('is Secure when a scan completed and nothing is actionable or severe', () => {
|
||||
expect(deriveSecurityPosture(facts({}))).toBe('Secure');
|
||||
expect(deriveSecurityPosture(facts())).toBe('Secure');
|
||||
});
|
||||
});
|
||||
|
||||
describe('derivePostureReasons', () => {
|
||||
it('returns an empty reason list and null primary action for a clean node', () => {
|
||||
const { reasons, primaryAction } = derivePostureReasons(facts());
|
||||
expect(reasons).toEqual([]);
|
||||
expect(primaryAction).toBeNull();
|
||||
});
|
||||
|
||||
it('returns a blocker reason for fixable findings', () => {
|
||||
const { reasons } = derivePostureReasons(facts({ fixableCriticalHigh: 4 }));
|
||||
expect(reasons).toContainEqual(expect.objectContaining({ kind: 'fixable_cve', count: 4, severity: 'blocker' }));
|
||||
});
|
||||
|
||||
it('returns a blocker reason for known-exploited findings', () => {
|
||||
const { reasons } = derivePostureReasons(facts({ knownExploited: 2 }));
|
||||
expect(reasons).toContainEqual(expect.objectContaining({ kind: 'known_exploited', count: 2, severity: 'blocker' }));
|
||||
});
|
||||
|
||||
it('returns a blocker reason for secrets', () => {
|
||||
const { reasons } = derivePostureReasons(facts({ secrets: 3 }));
|
||||
expect(reasons).toContainEqual(expect.objectContaining({ kind: 'secret', count: 3, severity: 'blocker' }));
|
||||
});
|
||||
|
||||
it('returns a blocker reason for dangerous Compose misconfigs', () => {
|
||||
const { reasons } = derivePostureReasons(facts({ dangerousCompose: 5 }));
|
||||
expect(reasons).toContainEqual(expect.objectContaining({ kind: 'dangerous_compose', count: 5, severity: 'blocker' }));
|
||||
});
|
||||
|
||||
it('returns a blocker reason for exposedBlocker', () => {
|
||||
const { reasons } = derivePostureReasons(facts({ exposedBlocker: 1 }));
|
||||
expect(reasons).toContainEqual(expect.objectContaining({ kind: 'public_exposure', count: 1, severity: 'blocker' }));
|
||||
});
|
||||
|
||||
it('returns a review reason for exposedReview', () => {
|
||||
const { reasons } = derivePostureReasons(facts({ exposedReview: 2 }));
|
||||
expect(reasons).toContainEqual(expect.objectContaining({ kind: 'public_exposure', count: 2, severity: 'review' }));
|
||||
});
|
||||
|
||||
it('returns a review reason for needsReview', () => {
|
||||
const { reasons } = derivePostureReasons(facts({ needsReview: 7 }));
|
||||
expect(reasons).toContainEqual(expect.objectContaining({ kind: 'needs_review', count: 7, severity: 'review' }));
|
||||
});
|
||||
|
||||
it('returns info reasons for stale and failed scans', () => {
|
||||
const { reasons } = derivePostureReasons(facts({ staleScans: 3, failedScans: 1 }));
|
||||
expect(reasons).toContainEqual(expect.objectContaining({ kind: 'stale_scan', count: 3, severity: 'info' }));
|
||||
expect(reasons).toContainEqual(expect.objectContaining({ kind: 'failed_scan', count: 1, severity: 'info' }));
|
||||
});
|
||||
|
||||
it('returns ALL reasons regardless of posture state', () => {
|
||||
// Even with no scanner (Unknown posture), the facts produce reasons.
|
||||
const { reasons } = derivePostureReasons(facts({
|
||||
scannerAvailable: false,
|
||||
fixableCriticalHigh: 4,
|
||||
staleScans: 1,
|
||||
}));
|
||||
expect(reasons).toHaveLength(2);
|
||||
expect(reasons[0].kind).toBe('fixable_cve');
|
||||
expect(reasons[1].kind).toBe('stale_scan');
|
||||
});
|
||||
|
||||
it('sets primaryAction to the first blocker (fixable_cve priority)', () => {
|
||||
const { primaryAction } = derivePostureReasons(facts({
|
||||
fixableCriticalHigh: 3,
|
||||
knownExploited: 1,
|
||||
secrets: 2,
|
||||
}));
|
||||
expect(primaryAction).toEqual({ label: 'Update affected images', targetTab: 'images' });
|
||||
});
|
||||
|
||||
it('falls through to the next blocker when the first is absent', () => {
|
||||
const { primaryAction } = derivePostureReasons(facts({ secrets: 1 }));
|
||||
expect(primaryAction).toEqual({ label: 'Review detected secrets', targetTab: 'secrets' });
|
||||
});
|
||||
|
||||
it('returns null primaryAction when no blockers exist', () => {
|
||||
const { primaryAction } = derivePostureReasons(facts({
|
||||
exposedReview: 1, needsReview: 2, staleScans: 1, failedScans: 0,
|
||||
}));
|
||||
expect(primaryAction).toBeNull();
|
||||
});
|
||||
|
||||
it('each blocker reason has a targetTab matching a valid Security tab', () => {
|
||||
const validTabs = ['images', 'secrets', 'compose', 'history', 'suppressions', 'scanner'];
|
||||
const { reasons } = derivePostureReasons(facts({
|
||||
fixableCriticalHigh: 1, secrets: 1, dangerousCompose: 1,
|
||||
knownExploited: 1, exposedBlocker: 1,
|
||||
}));
|
||||
for (const r of reasons) {
|
||||
expect(validTabs).toContain(r.targetTab);
|
||||
}
|
||||
});
|
||||
|
||||
// Invariant: Action needed posture always has at least one blocker reason.
|
||||
it('Action needed posture always has at least one blocker reason', () => {
|
||||
const f = facts({ fixableCriticalHigh: 1 });
|
||||
const posture = deriveSecurityPosture(f);
|
||||
const { reasons } = derivePostureReasons(f);
|
||||
if (posture === 'Action needed') {
|
||||
expect(reasons.some((r) => r.severity === 'blocker')).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ import { applySuppressions, isTriageStatus, isTriageJustification } from '../uti
|
||||
import { applyMisconfigAcknowledgements } from '../utils/misconfig-ack-filter';
|
||||
import { generateSarif } from '../services/SarifExporter';
|
||||
import { generateOpenVex } from '../services/OpenVexExporter';
|
||||
import { deriveSecurityPosture, type SecurityPostureFacts, type SecurityPostureState } from '../services/securityPosture';
|
||||
import { deriveSecurityPosture, derivePostureReasons, HIGH_EPSS_THRESHOLD, type SecurityPostureFacts, type SecurityPostureState, type PostureReason, type PostureAction } from '../services/securityPosture';
|
||||
import { buildExposedImageMap } from '../services/preflight/exposure';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
@@ -160,6 +160,10 @@ interface SecurityOverviewResponse {
|
||||
posture: SecurityPostureState;
|
||||
/** True when the bounded posture pass hit its row cap on this node. */
|
||||
posturePartial: boolean;
|
||||
/** Structured reasons explaining the posture (blockers, review, info). */
|
||||
postureReasons: PostureReason[];
|
||||
/** Highest-priority action for the masthead CTA, or null when no blockers. */
|
||||
primaryAction: PostureAction | null;
|
||||
}
|
||||
|
||||
export const securityRouter = Router();
|
||||
@@ -773,10 +777,32 @@ securityRouter.get('/overview', authMiddleware, (req: Request, res: Response): v
|
||||
}).filter(Boolean),
|
||||
);
|
||||
let publiclyExposed = 0;
|
||||
for (const [imageRef] of critHighByImage) {
|
||||
if (exposedMap.get(imageRef) === true) publiclyExposed += 1;
|
||||
let exposedBlocker = 0;
|
||||
let exposedReview = 0;
|
||||
for (const [imageRef, group] of critHighByImage) {
|
||||
if (exposedMap.get(imageRef) !== true) continue;
|
||||
publiclyExposed += 1;
|
||||
let hasUnsuppressedFinding = false;
|
||||
let hasKevOrFixOrHighEpss = false;
|
||||
for (const e of applySuppressions(group, imageRef, cveSuppressions)) {
|
||||
if (e.suppressed) continue;
|
||||
hasUnsuppressedFinding = true;
|
||||
if (
|
||||
e.fixed_version
|
||||
|| intel.get(e.vulnerability_id)?.kev
|
||||
|| (intel.get(e.vulnerability_id)?.epssScore ?? 0) >= HIGH_EPSS_THRESHOLD
|
||||
) {
|
||||
hasKevOrFixOrHighEpss = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!hasUnsuppressedFinding) continue; // fully dismissed
|
||||
if (hasKevOrFixOrHighEpss) exposedBlocker += 1;
|
||||
else exposedReview += 1;
|
||||
}
|
||||
|
||||
const failedScans = db.countScansByStatus(req.nodeId, 'failed');
|
||||
|
||||
const postureFacts: SecurityPostureFacts = {
|
||||
scannerAvailable: svc.isTrivyAvailable(),
|
||||
hasCompletedScan: lastSuccessfulScanAt !== null,
|
||||
@@ -785,10 +811,16 @@ securityRouter.get('/overview', authMiddleware, (req: Request, res: Response): v
|
||||
dangerousCompose,
|
||||
knownExploited,
|
||||
publiclyExposed,
|
||||
exposedBlocker,
|
||||
exposedReview,
|
||||
rawCritical: critical,
|
||||
rawHigh: high,
|
||||
staleScans,
|
||||
failedScans,
|
||||
needsReview,
|
||||
};
|
||||
const posture = deriveSecurityPosture(postureFacts);
|
||||
const { reasons: postureReasons, primaryAction } = derivePostureReasons(postureFacts);
|
||||
const actionable = fixableCriticalHigh + secrets + dangerousCompose + knownExploited + publiclyExposed;
|
||||
|
||||
const overview: SecurityOverviewResponse = {
|
||||
@@ -799,7 +831,7 @@ securityRouter.get('/overview', authMiddleware, (req: Request, res: Response): v
|
||||
secrets,
|
||||
misconfigs,
|
||||
staleScans,
|
||||
failedScans: db.countScansByStatus(req.nodeId, 'failed'),
|
||||
failedScans,
|
||||
lastSuccessfulScanAt,
|
||||
scanner: {
|
||||
available: svc.isTrivyAvailable(),
|
||||
@@ -827,6 +859,8 @@ securityRouter.get('/overview', authMiddleware, (req: Request, res: Response): v
|
||||
actionable,
|
||||
posture,
|
||||
posturePartial: critHigh.truncated || highMisconfigs.truncated,
|
||||
postureReasons,
|
||||
primaryAction,
|
||||
};
|
||||
res.json(overview);
|
||||
} catch (error) {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user