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<{
+13 -5
View File
@@ -76,6 +76,9 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
const [summariesError, setSummariesError] = useState(false);
const [trend, setTrend] = useState<SecurityRiskTrendPoint[]>([]);
const [exploitIntel, setExploitIntel] = useState<ExploitIntelFinding[]>([]);
// True when the exploit-intel query hit its row cap: the list shows the
// highest-risk findings but not every one, so the UI discloses it.
const [exploitTruncated, setExploitTruncated] = useState(false);
const [isReplica, setIsReplica] = useState(false);
// Bumped after a node-wide scan completes to refetch the active node's posture.
const [reloadToken, setReloadToken] = useState(0);
@@ -117,10 +120,13 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
.catch(() => []);
// Exploit-intel powers two overview charts; isolate it like the trend so a
// failure (or an older node without the endpoint) degrades to empty panels.
const exploitIntelPromise: Promise<ExploitIntelFinding[]> = apiFetch('/security/overview/exploit-intel')
.then((r) => (r.ok ? r.json() : { items: [] }))
.then((b) => (b && Array.isArray(b.items) ? b.items : []))
.catch(() => []);
const exploitIntelPromise: Promise<{ items: ExploitIntelFinding[]; truncated: boolean }> = apiFetch('/security/overview/exploit-intel')
.then((r) => (r.ok ? r.json() : { items: [], truncated: false }))
.then((b) => ({
items: b && Array.isArray(b.items) ? b.items : [],
truncated: b?.truncated === true,
}))
.catch(() => ({ items: [], truncated: false }));
try {
const [overviewRes, summariesRes] = await Promise.all([
apiFetch('/security/overview'),
@@ -164,7 +170,8 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
const [trendData, intelData] = await Promise.all([trendPromise, exploitIntelPromise]);
if (!cancelled) {
setTrend(trendData);
setExploitIntel(intelData);
setExploitIntel(intelData.items);
setExploitTruncated(intelData.truncated);
}
})();
return () => { cancelled = true; };
@@ -246,6 +253,7 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
loadError={overviewLoadError}
trend={trend}
exploitIntel={exploitIntel}
exploitTruncated={exploitTruncated}
onNavigate={onTabChange}
onInspect={onInspect}
canScan={canScan}
@@ -22,6 +22,8 @@ interface OverviewTabProps {
trend: SecurityRiskTrendPoint[];
/** Actionable Critical/High findings with KEV/EPSS for the exploit-intel charts. */
exploitIntel: ExploitIntelFinding[];
/** True when the exploit-intel set hit its row cap (highest-risk shown, not all). */
exploitTruncated: boolean;
onNavigate: (tab: SecurityTab) => void;
onInspect: (scanId: number) => void;
/** Admin on a node with a ready scanner; enables the node-scan launcher. */
@@ -124,7 +126,7 @@ function ReviewQueueCard({
);
}
export function OverviewTab({ overview, loadError, trend, exploitIntel, onNavigate, onInspect, canScan, onScanComplete, isPaid }: OverviewTabProps) {
export function OverviewTab({ overview, loadError, trend, exploitIntel, exploitTruncated, onNavigate, onInspect, canScan, onScanComplete, isPaid }: OverviewTabProps) {
const isMobile = useIsMobile();
if (loadError === 'unsupported') {
@@ -218,7 +220,7 @@ export function OverviewTab({ overview, loadError, trend, exploitIntel, onNaviga
card never stretches to a taller exploit table (which left dead space
under the chart). The exploit-risk table owns its own card chrome. */}
<div className="grid items-start gap-4 lg:grid-cols-2">
<TopExploitRiskList items={exploitIntel} onInspect={onInspect} />
<TopExploitRiskList items={exploitIntel} truncated={exploitTruncated} onInspect={onInspect} />
<ChartCard title="Severity × exploitability">
<CvssEpssQuadrantChart items={exploitIntel} />
</ChartCard>
@@ -132,6 +132,22 @@ describe('TopExploitRiskList', () => {
expect(onInspect).toHaveBeenCalledWith(11);
});
it('colors the severity dot by the finding severity (a Medium KEV is not shown as High)', () => {
const { container } = render(
<TopExploitRiskList items={[finding({ vulnerability_id: 'CVE-MED', severity: 'MEDIUM', kev: true, cvss_score: 5 })]} onInspect={vi.fn()} />,
);
const dot = container.querySelector('li[role="button"] span[aria-hidden]') as HTMLElement;
expect(dot.style.background).toContain('sev-medium');
});
it('discloses truncation only when the result was capped', () => {
const item = finding({ vulnerability_id: 'CVE-A', cvss_score: 8 });
const capped = render(<TopExploitRiskList items={[item]} truncated onInspect={vi.fn()} />);
expect(capped.container.textContent).toContain('more exist than can be listed');
const full = render(<TopExploitRiskList items={[item]} onInspect={vi.fn()} />);
expect(full.container.textContent).not.toContain('more exist than can be listed');
});
it('paginates beyond the page size and advances and rewinds pages', () => {
const items = Array.from({ length: 9 }, (_, i) =>
finding({ vulnerability_id: `CVE-${i}`, cvss_score: 9 - i * 0.1, epss_score: 0.5, scan_id: i }),
@@ -162,14 +162,29 @@ const EXPLOIT_PAGE_SIZE = 8;
// horizontally instead; desktop is untouched by the `max-md:` prefix.
const EXPLOIT_GRID = 'grid-cols-[10px_minmax(0,1.4fr)_minmax(0,1fr)_56px_52px] max-md:min-w-[480px]';
// Per-severity dot color. A KEV finding can now be any severity, so the dot must
// reflect the row's real severity rather than collapsing everything non-Critical
// to the High color. UNKNOWN gets the neutral subtitle tone (matching SeverityChip),
// not the low color, so an UNKNOWN-severity finding is not understated as low risk.
const SEV_DOT_VAR: Record<string, string> = {
CRITICAL: 'var(--sev-critical)',
HIGH: 'var(--sev-high)',
MEDIUM: 'var(--sev-medium)',
LOW: 'var(--sev-low)',
UNKNOWN: 'var(--stat-subtitle)',
};
/** Ranked, paginated table of the highest exploit-risk actionable findings; a row opens the scan.
* Renders its own card chrome (header + pagination + column headers) so the Overview reads as a
* table, mirroring the dashboard Stack-health table. */
export function TopExploitRiskList({
items,
truncated = false,
onInspect,
}: {
items: ExploitIntelFinding[];
/** The backend capped the result; the highest-risk findings are shown, not all. */
truncated?: boolean;
onInspect: (scanId: number) => void;
}) {
const [page, setPage] = useState(0);
@@ -231,7 +246,7 @@ export function TopExploitRiskList({
>
<span
className="h-[7px] w-[7px] shrink-0 justify-self-center rounded-full"
style={{ background: f.severity === 'CRITICAL' ? 'var(--sev-critical)' : 'var(--sev-high)' }}
style={{ background: SEV_DOT_VAR[f.severity] ?? 'var(--stat-subtitle)' }}
aria-hidden
/>
<span className="flex min-w-0 items-center gap-1.5">
@@ -254,6 +269,11 @@ export function TopExploitRiskList({
</li>
))}
</ul>
{truncated && (
<p className="border-t border-border/40 px-4 py-2 text-[10px] leading-snug text-stat-subtitle">
Showing the highest-risk findings; more exist than can be listed here.
</p>
)}
{!anyIntel && (
<p className="border-t border-border/40 px-4 py-2 text-[10px] leading-snug text-stat-subtitle">
Ranked by severity. Enable exploit intelligence and re-scan to rank by known-exploited and EPSS.