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:
Anso
2026-06-23 21:55:11 -04:00
committed by GitHub
parent f794702171
commit b1630788ba
10 changed files with 527 additions and 212 deletions
@@ -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);
});
});
+55
View File
@@ -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.
+51
View File
@@ -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-*).