mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 00:47:52 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user