mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 17:36:42 +00:00
feat(security): prioritization-led Overview charts (posture + exploit intel) (#1427)
* feat(security): rework Overview charts around posture and exploit intel Replace the three severity-variation charts (severity donut, top exposed images, findings by type) with prioritization views, keeping the risk trend for context: - Action posture: bars of fixable / known-exploited / needs-review / accepted / not-affected with a known-exploited headline, derived from the existing overview facts (no new fetch). - Top exploit-risk findings: ranked actionable Critical/High by KEV, then EPSS, then CVSS, each row opening its scan. - Severity x exploitability: a CVSS-by-EPSS quadrant that separates scary-but-not-exploitable from act-first. The two intel panels are fed by a new bounded GET /security/overview/exploit-intel (latest-scan Critical/High, suppression-filtered, KEV/EPSS joined at read time) and degrade to clear empty states until exploit intel is fetched and images are rescanned. * fix(security): drop the redundant SECURITY kicker from the desktop masthead The desktop nav strip already names the page, so the masthead's "SECURITY" label above the posture word was redundant. Make PageMasthead's kicker optional (pages that pass one render unchanged) and omit it on the Security masthead. The mobile masthead keeps its kicker, since on the phone layout it is the page identity and there is no nav strip. * docs(security): describe the prioritization-led Overview charts Update the Security overview docs for the reworked chart set (risk trend, action posture, top exploit-risk, and the severity-by-exploitability quadrant) and note the exploit-risk charts populate once exploit intelligence is enabled. * fix(security): move the scanner-detections note into a masthead info icon Replace the standing "scanner detections show vulnerable components..." caption below the masthead (desktop and mobile) with an info affordance next to the scanned-images count in the masthead subtitle. Declutters the overview while keeping the disclaimer one hover away. * fix(security): apply "assume it's automatable" to exploit-risk ranking Absence of exploitability evidence must not be treated as low risk. Rank the top exploit-risk list by tier (known-exploited > known-high EPSS > unknown EPSS > known-low EPSS) so an unrated finding outranks one with evidence of low likelihood; label unrated findings "EPSS n/a"; and reword the quadrant footnote so excluded findings read as unrated rather than lower risk.
This commit is contained in:
@@ -395,3 +395,49 @@ describe('GET /api/security/vex/export (Admiral)', () => {
|
||||
expect(stmt).toMatchObject({ status: 'not_affected', justification: 'component_not_present', products: ['nginx*'] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/security/overview/exploit-intel', () => {
|
||||
beforeEach(() => resetSecurity());
|
||||
|
||||
it('returns actionable Crit/High findings with KEV/EPSS joined and dismissed excluded', async () => {
|
||||
const now = Date.now();
|
||||
const scanId = db().createVulnerabilityScan({
|
||||
node_id: 1, image_ref: 'app:1', image_digest: 'sha256:app', scanned_at: now,
|
||||
total_vulnerabilities: 3, critical_count: 2, high_count: 1, medium_count: 0, low_count: 0,
|
||||
unknown_count: 0, fixable_count: 2, secret_count: 0, misconfig_count: 0, scanners_used: 'vuln',
|
||||
highest_severity: 'CRITICAL', os_info: null, trivy_version: null, scan_duration_ms: null,
|
||||
triggered_by: 'manual', status: 'completed', error: null, stack_context: null,
|
||||
});
|
||||
const d = (id: string, severity: 'CRITICAL' | 'HIGH', cvss: number | null, fixed: string | null) => ({
|
||||
vulnerability_id: id, pkg_name: `p-${id}`, installed_version: '1', fixed_version: fixed,
|
||||
severity, title: null, description: null, primary_url: null, cvss_score: cvss,
|
||||
});
|
||||
db().insertVulnerabilityDetails(scanId, [
|
||||
d('CVE-2024-AAAA', 'CRITICAL', 9.8, '2'), // actionable, has KEV + EPSS
|
||||
d('CVE-2024-BBBB', 'HIGH', 7.2, null), // actionable, no intel yet
|
||||
d('CVE-2024-CCCC', 'CRITICAL', 8.1, '3'), // dismissed -> excluded
|
||||
]);
|
||||
db().replaceKev([{ cve_id: 'CVE-2024-AAAA', date_added: '2024-01-01' }], now);
|
||||
db().upsertEpss([{ cve_id: 'CVE-2024-AAAA', epss_score: 0.6, epss_percentile: 0.97 }], now);
|
||||
db().createCveSuppression({
|
||||
cve_id: 'CVE-2024-CCCC', pkg_name: null, image_pattern: null, reason: 'accepted',
|
||||
created_by: 'admin', created_at: now, expires_at: null, replicated_from_control: 0, status: 'accepted',
|
||||
});
|
||||
|
||||
const res = await request(app).get('/api/security/overview/exploit-intel').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
const items = res.body.items as Array<{ vulnerability_id: string; cvss_score: number | null; epss_score: number | null; kev: boolean; severity: string; scan_id: number }>;
|
||||
const ids = items.map((i) => i.vulnerability_id);
|
||||
expect(ids).toContain('CVE-2024-AAAA');
|
||||
expect(ids).toContain('CVE-2024-BBBB');
|
||||
expect(ids).not.toContain('CVE-2024-CCCC'); // dismissed triage decision
|
||||
expect(items.find((i) => i.vulnerability_id === 'CVE-2024-AAAA')).toMatchObject({ cvss_score: 9.8, epss_score: 0.6, kev: true, severity: 'CRITICAL', scan_id: scanId });
|
||||
expect(items.find((i) => i.vulnerability_id === 'CVE-2024-BBBB')).toMatchObject({ cvss_score: 7.2, epss_score: null, kev: false });
|
||||
expect(res.body.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it('requires authentication', async () => {
|
||||
const res = await request(app).get('/api/security/overview/exploit-intel');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -828,6 +828,61 @@ securityRouter.get('/overview/trend', authMiddleware, (req: Request, res: Respon
|
||||
}
|
||||
});
|
||||
|
||||
// One actionable Critical/High finding for the overview exploit-intel charts.
|
||||
interface ExploitIntelFinding {
|
||||
vulnerability_id: string;
|
||||
image_ref: string;
|
||||
scan_id: number;
|
||||
severity: VulnSeverity;
|
||||
cvss_score: number | null;
|
||||
epss_score: number | null;
|
||||
epss_percentile: number | null;
|
||||
kev: boolean;
|
||||
fixed_version: string | null;
|
||||
}
|
||||
|
||||
// Node-scoped, auth-only (Community). Returns the latest-scan Critical/High
|
||||
// findings that are still actionable (dismissed triage decisions filtered out),
|
||||
// enriched at read time with KEV/EPSS intel. Powers the Top exploit-risk list
|
||||
// and the CVSS-by-EPSS quadrant on the Security overview. Bounded; `truncated`
|
||||
// flags a capped node.
|
||||
securityRouter.get('/overview/exploit-intel', authMiddleware, (req: Request, res: Response): void => {
|
||||
try {
|
||||
const db = DatabaseService.getInstance();
|
||||
const found = db.getLatestCritHighFindingsWithCvssForNode(req.nodeId);
|
||||
const suppressions = db.getCveSuppressions();
|
||||
const intel = db.getCveIntel(found.items.map((f) => f.vulnerability_id));
|
||||
const byImage = new Map<string, typeof found.items>();
|
||||
for (const f of found.items) {
|
||||
const group = byImage.get(f.image_ref);
|
||||
if (group) group.push(f);
|
||||
else byImage.set(f.image_ref, [f]);
|
||||
}
|
||||
const items: ExploitIntelFinding[] = [];
|
||||
for (const [imageRef, group] of byImage) {
|
||||
for (const e of applySuppressions(group, imageRef, suppressions)) {
|
||||
if (e.suppressed) continue; // decided findings are not part of the act-first view
|
||||
const i = intel.get(e.vulnerability_id);
|
||||
items.push({
|
||||
vulnerability_id: e.vulnerability_id,
|
||||
image_ref: imageRef,
|
||||
scan_id: e.scan_id,
|
||||
severity: e.severity,
|
||||
cvss_score: e.cvss_score,
|
||||
epss_score: i?.epssScore ?? null,
|
||||
epss_percentile: i?.epssPercentile ?? null,
|
||||
kev: i?.kev ?? false,
|
||||
fixed_version: e.fixed_version,
|
||||
});
|
||||
}
|
||||
}
|
||||
res.json({ items, truncated: found.truncated });
|
||||
} catch (error) {
|
||||
console.error('[Security] Failed to build exploit-intel overview:', error);
|
||||
res.status(500).json({ error: 'Failed to build exploit-intel overview' });
|
||||
}
|
||||
});
|
||||
|
||||
// Static, read-only policy-pack catalog. Auth-only (Community), no DB, no
|
||||
// enforcement. The frontend fetches this with localOnly so the global catalog
|
||||
// is available regardless of which node is active.
|
||||
|
||||
@@ -4775,6 +4775,57 @@ export class DatabaseService {
|
||||
return { items: truncated ? rows.slice(0, limit) : rows, truncated };
|
||||
}
|
||||
|
||||
/**
|
||||
* Critical/High findings from the latest completed scan per image, with the
|
||||
* severity + CVSS the overview's exploit-intel charts need. Same bounded
|
||||
* shape as getLatestCritHighVulnFindingsForNode (single latest-per-image
|
||||
* JOIN, capped, `truncated` flagged). Intel (KEV/EPSS) and suppression
|
||||
* filtering are applied by the caller at read time.
|
||||
*/
|
||||
public getLatestCritHighFindingsWithCvssForNode(
|
||||
nodeId: number,
|
||||
limit = 2000,
|
||||
): {
|
||||
items: Array<{
|
||||
image_ref: string;
|
||||
scan_id: number;
|
||||
vulnerability_id: string;
|
||||
pkg_name: string;
|
||||
severity: VulnSeverity;
|
||||
cvss_score: number | null;
|
||||
fixed_version: string | null;
|
||||
}>;
|
||||
truncated: boolean;
|
||||
} {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT vs.image_ref, vs.id AS scan_id, vd.vulnerability_id, vd.pkg_name,
|
||||
vd.severity, vd.cvss_score, vd.fixed_version
|
||||
FROM vulnerability_details vd
|
||||
INNER JOIN vulnerability_scans vs ON vs.id = vd.scan_id
|
||||
INNER JOIN (
|
||||
SELECT image_ref, MAX(scanned_at) AS max_scanned
|
||||
FROM vulnerability_scans
|
||||
WHERE node_id = ? AND status = 'completed'
|
||||
GROUP BY image_ref
|
||||
) latest ON latest.image_ref = vs.image_ref AND latest.max_scanned = vs.scanned_at
|
||||
WHERE vs.node_id = ? AND vs.status = 'completed'
|
||||
AND vd.severity IN ('CRITICAL', 'HIGH')
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(nodeId, nodeId, limit + 1) as Array<{
|
||||
image_ref: string;
|
||||
scan_id: number;
|
||||
vulnerability_id: string;
|
||||
pkg_name: string;
|
||||
severity: VulnSeverity;
|
||||
cvss_score: number | null;
|
||||
fixed_version: string | null;
|
||||
}>;
|
||||
const truncated = rows.length > limit;
|
||||
return { items: truncated ? rows.slice(0, limit) : rows, truncated };
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct CVE ids present in stored findings, for the intel service to fetch
|
||||
* EPSS only for what exists (EPSS covers CVEs, not GHSA, so filter to CVE-*).
|
||||
|
||||
Reference in New Issue
Block a user