mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-29 19:57:12 +00:00
feat(security): action-posture Security dashboard with exploit intel and triage (#1424)
* feat(security): reframe masthead as action posture, not worst-CVE severity Derive the Security masthead from an action posture (Action needed / Monitoring / Secure / Unknown) instead of raw scanner severity, and label the raw Critical/High counts as scanner detections. "Secure" now means nothing is actionable right now, never a claim that no vulnerabilities exist; Unknown covers a missing scanner or a node with no completed scan. Phase-1 bootstrap: "actionable" is approximated from the overview facts that already exist (fixable findings, secrets, misconfigs); a later phase moves the bucketing to the backend. * feat(security): derive overview action posture from triaged facts Add deriveSecurityPosture as the single bucketing function and extend /security/overview with posture facts (fixableCriticalHigh, dangerousCompose, accepted, rawCritical/rawHigh, plus knownExploited/publiclyExposed placeholders that later phases populate) and the derived posture verb. Suppression- and acknowledgement-aware counts come from one bounded read-time pass over the latest-scan Critical/High findings, grouped per image so the existing read-time filters apply unchanged. The pass is capped and flags posturePartial, so a large node degrades gracefully instead of scanning every detail row. The masthead now prefers the backend posture and keeps the local bootstrap only as a fallback for older remote nodes reached through the proxy. * feat(security): capture Trivy finding enrichment (status, CVSS, vendor, purl, layer) parseTrivyOutput now keeps the per-finding fields Trivy already returns and we previously discarded: Status (fixed / will_not_fix / end_of_life / ...), CVSS (score + vector, preferring the NVD source then falling back), vendor severity, package URL, package path, and layer digest. Persisted on vulnerability_details via additive nullable columns (guarded ALTER), bound null when absent, and carried through the cached-scan reconstruction path. These fields separate scary from exploitable and feed the action posture and the per-finding evidence tags. Field paths verified against Trivy's documented image-scan JSON; covered by parse and insert/read round-trip tests. * feat(security): add CVE exploit-intel service (CISA KEV + FIRST EPSS) Add CveIntelService, a daily background cache of CISA KEV membership and FIRST EPSS scores stored in a new cve_intel table and joined to findings at read time by CVE id (never frozen onto scan rows, so a CVE entering KEV later lights up on scans already stored). EPSS is fetched only for CVE ids present in stored findings, batched; both feeds are best-effort and keep the last cache on failure, so the Security page degrades gracefully offline. Wired into startup/shutdown like the other background services. The overview now counts known-exploited Critical/High findings, and KEV membership escalates posture to Action needed even when no fix is available. A per-instance "Exploit intelligence" toggle on the scanner setup surface lets air-gapped or firewalled hosts disable the outbound fetch; the daily tick keeps running but skips the fetch body when it is off. * feat(security): show per-finding evidence tags (KEV, EPSS, vendor status, CVSS) The vulnerabilities endpoint joins read-time exploit intel (KEV membership and EPSS score) onto each finding by CVE id, and the scan sheet renders evidence tags beside each CVE: known-exploited, EPSS probability, vendor will-not-fix / end-of-life, and the CVSS score. Severity becomes one signal among several so an operator can tell scary from exploitable, with no invented composite score. * feat(security): evolve CVE suppressions into triage decisions Layer a triage status and optional OpenVEX justification onto CVE suppressions. Statuses: needs review / affected / not affected / accepted risk / fixed / false positive / ignored. Dismissing states (not affected, accepted, fixed, false positive, ignored) stop a finding from driving the action posture; needs review and affected stay actionable and are surfaced as counts. Existing rows default to "accepted" (the prior suppress behavior), so nothing changes for them. The overview now reports needsReview / notAffected / accepted as distinct facts derived from the triage status. The decision replicates across the fleet (snapshot + replicated-insert carry status + justification) so a replica's posture matches the control node. The inline suppress dialog gains a triage decision selector; the read-time filter surfaces the status and justification on every finding. * feat(security): export fleet triage decisions as OpenVEX (Admiral) Add an OpenVEX exporter that turns the instance's CVE triage decisions into a standard VEX document (not_affected / fixed / affected / under_investigation, with justifications), and a GET /security/vex/export endpoint to download it. Authoring fleet VEX is a governance capability, so it is gated to Admiral (paid) plus admin, mirroring the SARIF export gate; the Suppressions panel shows an Export VEX action only on Admiral. * docs(security): document action posture, evidence tags, exploit intel, and triage Update the Security page and CVE suppressions docs for the action-posture masthead (scanner detections vs product posture), per-finding evidence tags (KEV / EPSS / CVSS / vendor status), the exploit-intelligence toggle (CISA KEV + FIRST EPSS) on scanner setup, triage decisions layered on suppressions, and OpenVEX export of fleet triage decisions. * test(security): match intel hosts exactly in CveIntelService test Route the fetch stub and its call assertions by exact hostname (www.cisa.gov / api.first.org) instead of a domain substring check. Resolves the js/incomplete-url-substring-sanitization code-scanning alerts on the test's URL routing; behavior is unchanged.
This commit is contained in:
@@ -10,9 +10,11 @@ import { isValidStackName } from '../utils/validation';
|
||||
import { FleetSyncService } from '../services/FleetSyncService';
|
||||
import { LicenseService } from '../services/LicenseService';
|
||||
import { validateImageRef } from '../utils/image-ref';
|
||||
import { applySuppressions } from '../utils/suppression-filter';
|
||||
import { applySuppressions, isTriageStatus, isTriageJustification } from '../utils/suppression-filter';
|
||||
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 { sanitizeForLog } from '../utils/safeLog';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
@@ -137,6 +139,25 @@ interface SecurityOverviewResponse {
|
||||
lastSuccessfulScanAt: number | null;
|
||||
scanner: { available: boolean; version: string | null; source: 'managed' | 'host' | 'none'; autoUpdate: boolean };
|
||||
deployEnforcement: { honorSuppressionsOnDeploy: boolean; eligibleBlockPolicies: number };
|
||||
// Posture facts. Counts are facts; the verb (`posture`) is derived in one
|
||||
// place (`deriveSecurityPosture`). `critical`/`high` above stay for back-compat
|
||||
// and are relabeled "scanner detections" in the UI; `rawCritical`/`rawHigh`
|
||||
// are their posture-named aliases. `knownExploited` and `publiclyExposed` come
|
||||
// online with the CVE-intel and Compose-exposure phases (0 until then).
|
||||
rawCritical: number;
|
||||
rawHigh: number;
|
||||
fixableCriticalHigh: number;
|
||||
knownExploited: number;
|
||||
publiclyExposed: number;
|
||||
dangerousCompose: number;
|
||||
needsReview: number;
|
||||
accepted: number;
|
||||
notAffected: number;
|
||||
/** Total actionable items, for the "N actions" affordance. */
|
||||
actionable: number;
|
||||
posture: SecurityPostureState;
|
||||
/** True when the bounded posture pass hit its row cap on this node. */
|
||||
posturePartial: boolean;
|
||||
}
|
||||
|
||||
export const securityRouter = Router();
|
||||
@@ -152,6 +173,7 @@ securityRouter.get('/trivy-status', authMiddleware, (_req: Request, res: Respons
|
||||
autoUpdate: settings.trivy_auto_update === '1',
|
||||
honorSuppressionsOnDeploy: settings.deploy_block_honor_suppressions === '1',
|
||||
preDeployScanAdvisory: settings.pre_deploy_scan_advisory === '1',
|
||||
cveIntelEnabled: settings.cve_intel_enabled !== '0',
|
||||
busy: installer.isBusy(),
|
||||
});
|
||||
});
|
||||
@@ -243,6 +265,22 @@ securityRouter.put('/trivy-auto-update', authMiddleware, (req: Request, res: Res
|
||||
}
|
||||
});
|
||||
|
||||
// Outbound CVE exploit-intel (KEV + EPSS) fetch toggle. Per-instance: the
|
||||
// background CveIntelService on each node reads its own local setting, so this
|
||||
// configures whichever node is active. Default on; off suits air-gapped hosts.
|
||||
securityRouter.put('/cve-intel-enabled', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const enabled = req.body?.enabled === true;
|
||||
try {
|
||||
DatabaseService.getInstance().updateGlobalSetting('cve_intel_enabled', enabled ? '1' : '0');
|
||||
res.json({ cveIntelEnabled: enabled });
|
||||
} catch (err) {
|
||||
const msg = getErrorMessage(err, 'Failed to update setting');
|
||||
console.error('[Security] CVE intel toggle failed:', msg);
|
||||
res.status(500).json({ error: msg });
|
||||
}
|
||||
});
|
||||
|
||||
// When enabled, the pre-deploy block policy re-derives image severity from
|
||||
// suppression-filtered findings, so an accepted CVE no longer blocks a deploy.
|
||||
// Per-instance setting (not fleet-replicated): the gate runs on the node that
|
||||
@@ -547,7 +585,19 @@ securityRouter.get(
|
||||
const result = db.getVulnerabilityDetails(scanId, { severity, limit, offset });
|
||||
const suppressions = db.getCveSuppressions();
|
||||
const enriched = applySuppressions(result.items, scan.image_ref, suppressions);
|
||||
res.json({ ...result, items: enriched });
|
||||
// Join time-varying exploit intel at read time (KEV/EPSS), keyed by CVE id;
|
||||
// never frozen onto the row, so a CVE entering KEV later surfaces on this scan.
|
||||
const intel = db.getCveIntel(enriched.map((v) => v.vulnerability_id));
|
||||
const withIntel = enriched.map((v) => {
|
||||
const i = intel.get(v.vulnerability_id);
|
||||
return {
|
||||
...v,
|
||||
kev: i?.kev ?? false,
|
||||
epss_score: i?.epssScore ?? null,
|
||||
epss_percentile: i?.epssPercentile ?? null,
|
||||
};
|
||||
});
|
||||
res.json({ ...result, items: withIntel });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -652,6 +702,72 @@ securityRouter.get('/overview', authMiddleware, (req: Request, res: Response): v
|
||||
}
|
||||
}
|
||||
|
||||
// Posture facts that depend on suppressions/acks (which change without a
|
||||
// rescan) are computed at read time over a bounded set of Critical/High
|
||||
// findings, grouped per image so the existing read-time filters apply
|
||||
// unchanged. The pass is capped; `posturePartial` flags a truncated node.
|
||||
const cveSuppressions = db.getCveSuppressions();
|
||||
const critHigh = db.getLatestCritHighVulnFindingsForNode(req.nodeId);
|
||||
// Exploit intel is joined at read time by CVE id (never frozen onto the row).
|
||||
const intel = db.getCveIntel(critHigh.items.map((f) => f.vulnerability_id));
|
||||
const critHighByImage = new Map<string, Array<{ vulnerability_id: string; pkg_name: string; fixed_version: string | null }>>();
|
||||
for (const f of critHigh.items) {
|
||||
const group = critHighByImage.get(f.image_ref);
|
||||
if (group) group.push(f);
|
||||
else critHighByImage.set(f.image_ref, [f]);
|
||||
}
|
||||
let fixableCriticalHigh = 0;
|
||||
let accepted = 0;
|
||||
let notAffected = 0;
|
||||
let needsReview = 0;
|
||||
let knownExploited = 0;
|
||||
for (const [imageRef, group] of critHighByImage) {
|
||||
for (const e of applySuppressions(group, imageRef, cveSuppressions)) {
|
||||
if (e.triage_status === 'needs_review') needsReview += 1;
|
||||
if (e.suppressed) {
|
||||
// A dismissing decision: not_affected is its own fact, the rest are "accepted".
|
||||
if (e.triage_status === 'not_affected') notAffected += 1;
|
||||
else accepted += 1;
|
||||
continue;
|
||||
}
|
||||
// Not dismissed (no decision, needs_review, or affected): still actionable.
|
||||
if (e.fixed_version) fixableCriticalHigh += 1;
|
||||
if (intel.get(e.vulnerability_id)?.kev) knownExploited += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const acks = db.getMisconfigAcknowledgements();
|
||||
const highMisconfigs = db.getLatestHighMisconfigFindingsForNode(req.nodeId);
|
||||
const misconfigByStack = new Map<string | null, Array<{ rule_id: string }>>();
|
||||
for (const f of highMisconfigs.items) {
|
||||
const group = misconfigByStack.get(f.stack_context);
|
||||
if (group) group.push(f);
|
||||
else misconfigByStack.set(f.stack_context, [f]);
|
||||
}
|
||||
let dangerousCompose = 0;
|
||||
for (const [stackContext, group] of misconfigByStack) {
|
||||
for (const e of applyMisconfigAcknowledgements(group, stackContext, acks)) {
|
||||
if (!e.acknowledged) dangerousCompose += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Compose exposure is joined in a later phase; until then it is honestly zero.
|
||||
const publiclyExposed = 0;
|
||||
|
||||
const postureFacts: SecurityPostureFacts = {
|
||||
scannerAvailable: svc.isTrivyAvailable(),
|
||||
hasCompletedScan: lastSuccessfulScanAt !== null,
|
||||
fixableCriticalHigh,
|
||||
secrets,
|
||||
dangerousCompose,
|
||||
knownExploited,
|
||||
publiclyExposed,
|
||||
rawCritical: critical,
|
||||
rawHigh: high,
|
||||
};
|
||||
const posture = deriveSecurityPosture(postureFacts);
|
||||
const actionable = fixableCriticalHigh + secrets + dangerousCompose + knownExploited + publiclyExposed;
|
||||
|
||||
const overview: SecurityOverviewResponse = {
|
||||
scannedImages,
|
||||
critical,
|
||||
@@ -676,6 +792,18 @@ securityRouter.get('/overview', authMiddleware, (req: Request, res: Response): v
|
||||
FleetSyncService.getSelfIdentity(),
|
||||
),
|
||||
},
|
||||
rawCritical: critical,
|
||||
rawHigh: high,
|
||||
fixableCriticalHigh,
|
||||
knownExploited,
|
||||
publiclyExposed,
|
||||
dangerousCompose,
|
||||
needsReview,
|
||||
accepted,
|
||||
notAffected,
|
||||
actionable,
|
||||
posture,
|
||||
posturePartial: critHigh.truncated || highMisconfigs.truncated,
|
||||
};
|
||||
res.json(overview);
|
||||
} catch (error) {
|
||||
@@ -803,6 +931,24 @@ securityRouter.get(
|
||||
},
|
||||
);
|
||||
|
||||
// Export the instance's CVE triage decisions as an OpenVEX document. Authoring
|
||||
// fleet VEX is a governance feature, so it is Admiral (paid) + admin, mirroring
|
||||
// the SARIF export gate.
|
||||
securityRouter.get('/vex/export', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const suppressions = DatabaseService.getInstance().getCveSuppressions();
|
||||
const doc = generateOpenVex(suppressions, req.user?.username || 'sencho', new Date().toISOString());
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="sencho-fleet.openvex.json"');
|
||||
res.send(JSON.stringify(doc));
|
||||
} catch (error) {
|
||||
console.error('[Security] OpenVEX export failed:', error);
|
||||
res.status(500).json({ error: (error as Error).message || 'Failed to generate OpenVEX' });
|
||||
}
|
||||
});
|
||||
|
||||
securityRouter.get('/policies', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
// Replicas see only policies that apply to themselves: local-only rows plus
|
||||
@@ -943,6 +1089,15 @@ securityRouter.post('/suppressions', authMiddleware, (req: Request, res: Respons
|
||||
if (expiresAt !== null && !Number.isFinite(expiresAt)) {
|
||||
res.status(400).json({ error: 'expires_at must be a timestamp or null' }); return;
|
||||
}
|
||||
// Triage decision: default 'accepted' (a plain suppress = accepted risk).
|
||||
const status = body.status === undefined ? 'accepted' : body.status;
|
||||
if (!isTriageStatus(status)) {
|
||||
res.status(400).json({ error: 'invalid triage status' }); return;
|
||||
}
|
||||
const justification = body.justification == null || body.justification === '' ? null : body.justification;
|
||||
if (justification !== null && !isTriageJustification(justification)) {
|
||||
res.status(400).json({ error: 'invalid triage justification' }); return;
|
||||
}
|
||||
try {
|
||||
const suppression = DatabaseService.getInstance().createCveSuppression({
|
||||
cve_id: cveId,
|
||||
@@ -953,6 +1108,8 @@ securityRouter.post('/suppressions', authMiddleware, (req: Request, res: Respons
|
||||
created_at: Date.now(),
|
||||
expires_at: expiresAt,
|
||||
replicated_from_control: 0,
|
||||
status,
|
||||
justification,
|
||||
});
|
||||
FleetSyncService.getInstance().pushResourceAsync('cve_suppressions');
|
||||
res.status(201).json(suppression);
|
||||
@@ -976,13 +1133,24 @@ securityRouter.put('/suppressions/:id', authMiddleware, (req: Request, res: Resp
|
||||
res.status(400).json({ error: 'Invalid suppression id' }); return;
|
||||
}
|
||||
const body = req.body ?? {};
|
||||
const updates: Partial<{ reason: string; image_pattern: string | null; expires_at: number | null }> = {};
|
||||
const updates: Partial<{ reason: string; image_pattern: string | null; expires_at: number | null; status: string; justification: string | null }> = {};
|
||||
if (body.reason !== undefined) {
|
||||
const reason = typeof body.reason === 'string' ? body.reason.trim() : '';
|
||||
if (!reason) { res.status(400).json({ error: 'reason is required' }); return; }
|
||||
if (reason.length > 2000) { res.status(400).json({ error: 'reason is too long' }); return; }
|
||||
updates.reason = reason;
|
||||
}
|
||||
if (body.status !== undefined) {
|
||||
if (!isTriageStatus(body.status)) { res.status(400).json({ error: 'invalid triage status' }); return; }
|
||||
updates.status = body.status;
|
||||
}
|
||||
if (body.justification !== undefined) {
|
||||
const justification = body.justification == null || body.justification === '' ? null : body.justification;
|
||||
if (justification !== null && !isTriageJustification(justification)) {
|
||||
res.status(400).json({ error: 'invalid triage justification' }); return;
|
||||
}
|
||||
updates.justification = justification;
|
||||
}
|
||||
if (body.image_pattern !== undefined) {
|
||||
const pattern = body.image_pattern == null || body.image_pattern === '' ? null : String(body.image_pattern).trim();
|
||||
if (pattern !== null && pattern.length > 300) {
|
||||
|
||||
Reference in New Issue
Block a user