mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-30 03:59:41 +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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user