Files
sencho/backend/src/__tests__/database-scan-list.test.ts
T
Anso f794702171 feat(security): action-posture Security dashboard with exploit intel and triage (#1424)
* feat(security): reframe masthead as action posture, not worst-CVE severity

Derive the Security masthead from an action posture (Action needed /
Monitoring / Secure / Unknown) instead of raw scanner severity, and label
the raw Critical/High counts as scanner detections. "Secure" now means
nothing is actionable right now, never a claim that no vulnerabilities
exist; Unknown covers a missing scanner or a node with no completed scan.

Phase-1 bootstrap: "actionable" is approximated from the overview facts
that already exist (fixable findings, secrets, misconfigs); a later phase
moves the bucketing to the backend.

* feat(security): derive overview action posture from triaged facts

Add deriveSecurityPosture as the single bucketing function and extend
/security/overview with posture facts (fixableCriticalHigh, dangerousCompose,
accepted, rawCritical/rawHigh, plus knownExploited/publiclyExposed placeholders
that later phases populate) and the derived posture verb.

Suppression- and acknowledgement-aware counts come from one bounded read-time
pass over the latest-scan Critical/High findings, grouped per image so the
existing read-time filters apply unchanged. The pass is capped and flags
posturePartial, so a large node degrades gracefully instead of scanning every
detail row. The masthead now prefers the backend posture and keeps the local
bootstrap only as a fallback for older remote nodes reached through the proxy.

* feat(security): capture Trivy finding enrichment (status, CVSS, vendor, purl, layer)

parseTrivyOutput now keeps the per-finding fields Trivy already returns and we
previously discarded: Status (fixed / will_not_fix / end_of_life / ...), CVSS
(score + vector, preferring the NVD source then falling back), vendor severity,
package URL, package path, and layer digest. Persisted on vulnerability_details
via additive nullable columns (guarded ALTER), bound null when absent, and
carried through the cached-scan reconstruction path.

These fields separate scary from exploitable and feed the action posture and the
per-finding evidence tags. Field paths verified against Trivy's documented
image-scan JSON; covered by parse and insert/read round-trip tests.

* feat(security): add CVE exploit-intel service (CISA KEV + FIRST EPSS)

Add CveIntelService, a daily background cache of CISA KEV membership and FIRST
EPSS scores stored in a new cve_intel table and joined to findings at read time
by CVE id (never frozen onto scan rows, so a CVE entering KEV later lights up on
scans already stored). EPSS is fetched only for CVE ids present in stored
findings, batched; both feeds are best-effort and keep the last cache on
failure, so the Security page degrades gracefully offline. Wired into
startup/shutdown like the other background services.

The overview now counts known-exploited Critical/High findings, and KEV
membership escalates posture to Action needed even when no fix is available.

A per-instance "Exploit intelligence" toggle on the scanner setup surface lets
air-gapped or firewalled hosts disable the outbound fetch; the daily tick keeps
running but skips the fetch body when it is off.

* feat(security): show per-finding evidence tags (KEV, EPSS, vendor status, CVSS)

The vulnerabilities endpoint joins read-time exploit intel (KEV membership and
EPSS score) onto each finding by CVE id, and the scan sheet renders evidence
tags beside each CVE: known-exploited, EPSS probability, vendor will-not-fix /
end-of-life, and the CVSS score. Severity becomes one signal among several so an
operator can tell scary from exploitable, with no invented composite score.

* feat(security): evolve CVE suppressions into triage decisions

Layer a triage status and optional OpenVEX justification onto CVE suppressions.
Statuses: needs review / affected / not affected / accepted risk / fixed / false
positive / ignored. Dismissing states (not affected, accepted, fixed, false
positive, ignored) stop a finding from driving the action posture; needs review
and affected stay actionable and are surfaced as counts. Existing rows default
to "accepted" (the prior suppress behavior), so nothing changes for them.

The overview now reports needsReview / notAffected / accepted as distinct facts
derived from the triage status. The decision replicates across the fleet
(snapshot + replicated-insert carry status + justification) so a replica's
posture matches the control node. The inline suppress dialog gains a triage
decision selector; the read-time filter surfaces the status and justification on
every finding.

* feat(security): export fleet triage decisions as OpenVEX (Admiral)

Add an OpenVEX exporter that turns the instance's CVE triage decisions into a
standard VEX document (not_affected / fixed / affected / under_investigation,
with justifications), and a GET /security/vex/export endpoint to download it.
Authoring fleet VEX is a governance capability, so it is gated to Admiral (paid)
plus admin, mirroring the SARIF export gate; the Suppressions panel shows an
Export VEX action only on Admiral.

* docs(security): document action posture, evidence tags, exploit intel, and triage

Update the Security page and CVE suppressions docs for the action-posture
masthead (scanner detections vs product posture), per-finding evidence tags
(KEV / EPSS / CVSS / vendor status), the exploit-intelligence toggle (CISA KEV +
FIRST EPSS) on scanner setup, triage decisions layered on suppressions, and
OpenVEX export of fleet triage decisions.

* test(security): match intel hosts exactly in CveIntelService test

Route the fetch stub and its call assertions by exact hostname
(www.cisa.gov / api.first.org) instead of a domain substring check.
Resolves the js/incomplete-url-substring-sanitization code-scanning
alerts on the test's URL routing; behavior is unchanged.
2026-06-23 17:42:11 -04:00

289 lines
11 KiB
TypeScript

/**
* Coverage for `getVulnerabilityScans` filtering + pagination, used by
* the scan-history page's server-driven pagination.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
let tmpDir: string;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
});
afterAll(() => cleanupTestDb(tmpDir));
function seedScan(overrides: Partial<{
node_id: number;
image_ref: string;
scanned_at: number;
status: 'completed' | 'in_progress' | 'failed';
}> = {}): number {
const db = DatabaseService.getInstance();
return db.createVulnerabilityScan({
node_id: overrides.node_id ?? 1,
image_ref: overrides.image_ref ?? 'alpine:3.19',
image_digest: `sha256:${Math.random().toString(16).slice(2)}`,
scanned_at: overrides.scanned_at ?? Date.now(),
total_vulnerabilities: 0,
critical_count: 0,
high_count: 0,
medium_count: 0,
low_count: 0,
unknown_count: 0,
fixable_count: 0,
secret_count: 0,
misconfig_count: 0,
scanners_used: 'vuln',
highest_severity: null,
os_info: null,
trivy_version: null,
scan_duration_ms: null,
triggered_by: 'manual',
status: overrides.status ?? 'completed',
error: null,
stack_context: null,
});
}
function resetTable(): void {
(DatabaseService.getInstance() as unknown as {
db: { prepare: (s: string) => { run: () => void } };
}).db.prepare('DELETE FROM vulnerability_scans').run();
}
beforeEach(() => resetTable());
describe('getVulnerabilityScans filters and pagination', () => {
it('filters by status=completed', () => {
const db = DatabaseService.getInstance();
seedScan({ status: 'completed', scanned_at: 1 });
seedScan({ status: 'in_progress', scanned_at: 2 });
seedScan({ status: 'failed', scanned_at: 3 });
const result = db.getVulnerabilityScans(1, { status: 'completed' });
expect(result.total).toBe(1);
expect(result.items).toHaveLength(1);
expect(result.items[0].status).toBe('completed');
});
it('filters by imageRefLike substring, case-sensitive', () => {
const db = DatabaseService.getInstance();
seedScan({ image_ref: 'alpine:3.18', scanned_at: 1 });
seedScan({ image_ref: 'alpine:3.19', scanned_at: 2 });
seedScan({ image_ref: 'nginx:1.25', scanned_at: 3 });
const result = db.getVulnerabilityScans(1, { imageRefLike: 'alpine' });
expect(result.total).toBe(2);
expect(result.items.every((s) => s.image_ref.startsWith('alpine'))).toBe(true);
});
it('returns total independent of limit for pagination', () => {
const db = DatabaseService.getInstance();
for (let i = 0; i < 5; i++) seedScan({ scanned_at: i * 1000 });
const page1 = db.getVulnerabilityScans(1, { limit: 2, offset: 0 });
const page2 = db.getVulnerabilityScans(1, { limit: 2, offset: 2 });
expect(page1.total).toBe(5);
expect(page2.total).toBe(5);
expect(page1.items).toHaveLength(2);
expect(page2.items).toHaveLength(2);
expect(page1.items[0].id).not.toBe(page2.items[0].id);
});
});
describe('getVulnerabilityScans per-image cap', () => {
it('caps rows per image_ref when no imageRef filter is set', () => {
const db = DatabaseService.getInstance();
db.updateGlobalSetting('scan_history_per_image_limit', '10');
for (let i = 0; i < 80; i++) seedScan({ image_ref: 'hot:latest', scanned_at: 1000 + i });
for (let i = 0; i < 5; i++) seedScan({ image_ref: 'cool:latest', scanned_at: 1000 + i });
const result = db.getVulnerabilityScans(1, { limit: 500 });
const hotRows = result.items.filter((s) => s.image_ref === 'hot:latest');
const coolRows = result.items.filter((s) => s.image_ref === 'cool:latest');
expect(hotRows).toHaveLength(10);
expect(coolRows).toHaveLength(5);
expect(result.total).toBe(15);
expect(result.cappedImageRefs).toEqual(['hot:latest']);
expect(result.perImageLimit).toBe(10);
});
it('bypasses the cap when imageRef targets a single image', () => {
const db = DatabaseService.getInstance();
db.updateGlobalSetting('scan_history_per_image_limit', '10');
for (let i = 0; i < 30; i++) seedScan({ image_ref: 'hot:latest', scanned_at: 1000 + i });
const result = db.getVulnerabilityScans(1, { imageRef: 'hot:latest', limit: 500 });
expect(result.items).toHaveLength(30);
expect(result.total).toBe(30);
expect(result.cappedImageRefs).toEqual([]);
});
});
describe('pruneScanHistoryPerImage', () => {
it('keeps the newest N rows per (node_id, image_ref) and deletes the rest', () => {
const db = DatabaseService.getInstance();
for (let i = 0; i < 60; i++) seedScan({ image_ref: 'hot:latest', scanned_at: 1000 + i });
for (let i = 0; i < 5; i++) seedScan({ image_ref: 'cool:latest', scanned_at: 1000 + i });
const deleted = db.pruneScanHistoryPerImage(50);
expect(deleted).toBe(10);
const after = db.getVulnerabilityScans(1, { imageRef: 'hot:latest', limit: 500 });
expect(after.items).toHaveLength(50);
const oldest = Math.min(...after.items.map((s) => s.scanned_at));
expect(oldest).toBe(1010);
const cool = db.getVulnerabilityScans(1, { imageRef: 'cool:latest', limit: 500 });
expect(cool.items).toHaveLength(5);
});
it('is a no-op when no image exceeds the cap', () => {
const db = DatabaseService.getInstance();
for (let i = 0; i < 3; i++) seedScan({ image_ref: 'small:latest', scanned_at: 1000 + i });
const deleted = db.pruneScanHistoryPerImage(50);
expect(deleted).toBe(0);
});
it('partitions by node_id so two nodes scanning the same image keep independent histories', () => {
const db = DatabaseService.getInstance();
db.getDb()
.prepare(`INSERT INTO nodes (id, name, type, compose_dir, is_default, status, created_at)
VALUES (2, 'Peer', 'remote', '/tmp', 0, 'online', ?)`)
.run(Date.now());
for (let i = 0; i < 60; i++) seedScan({ node_id: 1, image_ref: 'alpine:3.19', scanned_at: 1000 + i });
for (let i = 0; i < 60; i++) seedScan({ node_id: 2, image_ref: 'alpine:3.19', scanned_at: 2000 + i });
const deleted = db.pruneScanHistoryPerImage(50);
expect(deleted).toBe(20);
const node1 = db.getVulnerabilityScans(1, { imageRef: 'alpine:3.19', limit: 500 });
const node2 = db.getVulnerabilityScans(2, { imageRef: 'alpine:3.19', limit: 500 });
expect(node1.items).toHaveLength(50);
expect(node2.items).toHaveLength(50);
});
it('deletes child vulnerability_details rows for pruned scans', () => {
const db = DatabaseService.getInstance();
const ids: number[] = [];
for (let i = 0; i < 60; i++) {
ids.push(seedScan({ image_ref: 'hot:latest', scanned_at: 1000 + i }));
}
const oldestScanId = ids[0];
db.insertVulnerabilityDetails(oldestScanId, [{
vulnerability_id: 'CVE-2020-0001',
pkg_name: 'libfoo',
installed_version: '1.0',
fixed_version: '1.1',
severity: 'HIGH',
title: 'Test',
description: null,
primary_url: null,
}]);
const beforeChildren = db.getDb()
.prepare('SELECT COUNT(*) as cnt FROM vulnerability_details WHERE scan_id = ?')
.get(oldestScanId) as { cnt: number };
expect(beforeChildren.cnt).toBe(1);
const deleted = db.pruneScanHistoryPerImage(50);
expect(deleted).toBe(10);
const afterChildren = db.getDb()
.prepare('SELECT COUNT(*) as cnt FROM vulnerability_details WHERE scan_id = ?')
.get(oldestScanId) as { cnt: number };
expect(afterChildren.cnt).toBe(0);
});
});
describe('vulnerability_details enrichment round-trips', () => {
it('persists and reads back status, CVSS, vendor severity, purl, path, and layer', () => {
const db = DatabaseService.getInstance();
const scanId = seedScan({ image_ref: 'enriched:1' });
db.insertVulnerabilityDetails(scanId, [
{
vulnerability_id: 'CVE-2024-1234',
pkg_name: 'libssl',
installed_version: '1.0.0',
fixed_version: '1.0.1',
severity: 'CRITICAL',
title: 'enriched finding',
description: null,
primary_url: null,
status: 'will_not_fix',
cvss_score: 9.8,
cvss_vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H',
cvss_source: 'nvd',
vendor_severity: 'HIGH',
purl: 'pkg:deb/debian/libssl@1.0.0',
pkg_path: 'usr/lib/libssl.so',
layer_digest: 'sha256:cafe',
},
// A finding that omits enrichment stores nulls, not undefined (no crash).
{
vulnerability_id: 'CVE-2024-5678',
pkg_name: 'libbare',
installed_version: '2',
fixed_version: null,
severity: 'HIGH',
title: null,
description: null,
primary_url: null,
},
]);
const { items } = db.getVulnerabilityDetails(scanId);
const enriched = items.find((i) => i.vulnerability_id === 'CVE-2024-1234');
expect(enriched).toMatchObject({
status: 'will_not_fix',
cvss_score: 9.8,
cvss_source: 'nvd',
vendor_severity: 'HIGH',
purl: 'pkg:deb/debian/libssl@1.0.0',
pkg_path: 'usr/lib/libssl.so',
layer_digest: 'sha256:cafe',
});
const bare = items.find((i) => i.vulnerability_id === 'CVE-2024-5678');
expect(bare?.status ?? null).toBeNull();
expect(bare?.cvss_score ?? null).toBeNull();
});
});
describe('cve_suppressions triage replication', () => {
function clearSuppressions(): void {
(DatabaseService.getInstance() as unknown as { db: { prepare: (s: string) => { run: () => void } } })
.db.prepare('DELETE FROM cve_suppressions').run();
}
it('round-trips a non-default triage status through replication', () => {
const db = DatabaseService.getInstance();
clearSuppressions();
db.replaceReplicatedCveSuppressions([{
cve_id: 'CVE-2024-3001', pkg_name: null, image_pattern: null, reason: 'vendor confirmed safe',
created_by: 'control-admin', created_at: 1000, expires_at: null, replicated_from_control: 1,
status: 'not_affected', justification: 'vulnerable_code_not_in_execute_path',
}]);
const row = db.getCveSuppressions().find((s) => s.cve_id === 'CVE-2024-3001');
expect(row).toMatchObject({ status: 'not_affected', justification: 'vulnerable_code_not_in_execute_path', replicated_from_control: 1 });
});
it('defaults replicated rows that omit status to accepted (upgrade path)', () => {
const db = DatabaseService.getInstance();
clearSuppressions();
db.replaceReplicatedCveSuppressions([{
cve_id: 'CVE-2024-3002', pkg_name: null, image_pattern: null, reason: 'legacy push',
created_by: 'control-admin', created_at: 1000, expires_at: null, replicated_from_control: 1,
}]);
const row = db.getCveSuppressions().find((s) => s.cve_id === 'CVE-2024-3002');
expect(row?.status).toBe('accepted');
});
});