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
@@ -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.