fix: rank exploit-risk findings before the cap and disclose truncation (#1482)

The Security overview's top exploit-risk list is built from a query capped at
2000 rows. The query had no ORDER BY, so when a node had more findings than the
cap the rows kept were arbitrary: the list could rank and display a subset that
omitted higher-risk findings, and the frontend discarded the truncated flag the
endpoint already returned, so nothing told the operator the list was partial.

- The query now orders by known-exploited, then EPSS, then CVSS before the cap,
  so the rows that survive truncation are the highest-risk ones, matching the
  client-side ranking the list applies.
- SecurityView keeps the truncated flag and threads it through to the list,
  which now shows a short "more exist than can be listed here" note when the set
  was capped.

Also fixes a presentation regression: the list colored every non-Critical
severity dot with the High color, so a Medium or Low known-exploited finding
(now surfaced alongside Critical/High) showed as High. The dot now maps to the
finding's actual severity.
This commit is contained in:
Anso
2026-06-26 21:12:06 -04:00
committed by GitHub
parent 7c12081645
commit 7c9c640625
6 changed files with 92 additions and 8 deletions
@@ -284,6 +284,43 @@ describe('getLatestKevFindingsForNode', () => {
});
});
describe('getLatestCritHighFindingsWithCvssForNode ranking', () => {
function rawDb2() {
return (db() as unknown as { db: { prepare: (s: string) => { run: () => void } } }).db;
}
beforeEach(() => {
rawDb2().prepare('DELETE FROM vulnerability_details').run();
rawDb2().prepare('DELETE FROM cve_intel').run();
rawDb2().prepare('DELETE FROM vulnerability_scans').run();
});
it('keeps the highest-risk findings (KEV, then EPSS, then CVSS) when the cap truncates', () => {
const now = Date.now();
const scanId = db().createVulnerabilityScan({
node_id: 1, image_ref: 'app:1', image_digest: 'sha256:rank', scanned_at: now,
total_vulnerabilities: 3, critical_count: 0, high_count: 3, medium_count: 0, low_count: 0,
unknown_count: 0, fixable_count: 0, secret_count: 0, misconfig_count: 0, scanners_used: 'vuln',
highest_severity: 'HIGH', os_info: null, trivy_version: null, scan_duration_ms: null,
triggered_by: 'manual', status: 'completed', error: null, stack_context: null,
});
const d = (id: string, cvss: number) => ({
vulnerability_id: id, pkg_name: `p-${id}`, installed_version: '1', fixed_version: null,
severity: 'HIGH' as const, title: null, description: null, primary_url: null, cvss_score: cvss,
});
// Insert the lowest-risk finding FIRST so an unordered LIMIT would wrongly keep it.
db().insertVulnerabilityDetails(scanId, [d('CVE-PLAIN-LOWCVSS', 1.0), d('CVE-KEV-LOWCVSS', 4.0), d('CVE-PLAIN-HIGHCVSS', 9.0)]);
db().replaceKev([{ cve_id: 'CVE-KEV-LOWCVSS', date_added: '2024-01-01' }], now);
// Cap below the finding count: the dropped row must be the lowest-risk one.
const res = db().getLatestCritHighFindingsWithCvssForNode(1, 2);
const ids = res.items.map((i) => i.vulnerability_id);
expect(res.truncated).toBe(true);
expect(ids).toContain('CVE-KEV-LOWCVSS'); // KEV ranks first despite low CVSS
expect(ids).toContain('CVE-PLAIN-HIGHCVSS'); // then highest CVSS
expect(ids).not.toContain('CVE-PLAIN-LOWCVSS'); // lowest-risk is the one dropped
});
});
describe('getDailyRiskTrend', () => {
it('sums latest-per-image critical/high per day and orders days ascending', () => {
const day1 = dayStartMs(3);
+1
View File
@@ -5193,6 +5193,7 @@ export class DatabaseService {
) latest ON latest.image_ref = vs.image_ref AND latest.max_scanned = vs.scanned_at
WHERE vs.node_id = ? AND vs.status = 'completed' AND vs.scanners_used IN (${placeholders})
AND (vd.severity IN ('CRITICAL', 'HIGH') OR ci.kev = 1)
ORDER BY COALESCE(ci.kev, 0) DESC, COALESCE(ci.epss_score, -1) DESC, COALESCE(vd.cvss_score, -1) DESC
LIMIT ?`,
)
.all(nodeId, ...VULN_BEARING_SCANNER_SETS, nodeId, ...VULN_BEARING_SCANNER_SETS, limit + 1) as Array<{