mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 08:58:05 +00:00
f794702171
* 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.
106 lines
4.6 KiB
TypeScript
106 lines
4.6 KiB
TypeScript
/**
|
|
* CveIntelService: daily KEV + EPSS refresh, air-gap tolerant, read-time join.
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
|
|
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
|
|
|
let tmpDir: string;
|
|
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
|
let CveIntelService: typeof import('../services/CveIntelService').CveIntelService;
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await setupTestDb();
|
|
({ DatabaseService } = await import('../services/DatabaseService'));
|
|
({ CveIntelService } = await import('../services/CveIntelService'));
|
|
});
|
|
|
|
afterAll(() => cleanupTestDb(tmpDir));
|
|
|
|
function db() {
|
|
return DatabaseService.getInstance();
|
|
}
|
|
|
|
function reset(): void {
|
|
const raw = (db() as unknown as { db: { prepare: (s: string) => { run: () => void } } }).db;
|
|
raw.prepare('DELETE FROM cve_intel').run();
|
|
raw.prepare('DELETE FROM vulnerability_details').run();
|
|
raw.prepare('DELETE FROM vulnerability_scans').run();
|
|
db().updateGlobalSetting('cve_intel_enabled', '1');
|
|
}
|
|
|
|
beforeEach(reset);
|
|
afterEach(() => vi.restoreAllMocks());
|
|
|
|
function jsonOk(body: unknown) {
|
|
return { ok: true, status: 200, json: async () => body } as unknown as Response;
|
|
}
|
|
|
|
/** Routes by exact host: www.cisa.gov -> KEV, api.first.org -> EPSS. */
|
|
function stubFetch(kev: unknown, epss: unknown): ReturnType<typeof vi.fn> {
|
|
const mock = vi.fn(async (url: string | URL) => {
|
|
const host = new URL(String(url)).hostname;
|
|
if (host === 'www.cisa.gov') return jsonOk(kev);
|
|
if (host === 'api.first.org') return jsonOk(epss);
|
|
throw new Error(`unexpected url ${String(url)}`);
|
|
});
|
|
vi.stubGlobal('fetch', mock);
|
|
return mock;
|
|
}
|
|
|
|
function seedFinding(cve: string): void {
|
|
const scanId = db().createVulnerabilityScan({
|
|
node_id: 1, image_ref: `img-${cve}:1`, image_digest: `sha256:${cve}`, scanned_at: Date.now(),
|
|
total_vulnerabilities: 1, critical_count: 1, 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: 'CRITICAL', os_info: null, trivy_version: null, scan_duration_ms: null,
|
|
triggered_by: 'manual', status: 'completed', error: null, stack_context: null,
|
|
});
|
|
db().insertVulnerabilityDetails(scanId, [{
|
|
vulnerability_id: cve, pkg_name: 'pkg', installed_version: '1', fixed_version: null,
|
|
severity: 'CRITICAL', title: null, description: null, primary_url: null,
|
|
}]);
|
|
}
|
|
|
|
describe('CveIntelService.refresh', () => {
|
|
it('upserts KEV membership and joins it at read time', async () => {
|
|
stubFetch({ vulnerabilities: [{ cveID: 'CVE-2024-0001', dateAdded: '2024-01-01' }] }, { data: [] });
|
|
await CveIntelService.getInstance().refresh();
|
|
const intel = db().getCveIntel(['CVE-2024-0001']);
|
|
expect(intel.get('CVE-2024-0001')).toMatchObject({ kev: true, kevDate: '2024-01-01' });
|
|
});
|
|
|
|
it('fetches EPSS only for CVEs present in stored findings', async () => {
|
|
seedFinding('CVE-2024-1111');
|
|
const mock = stubFetch({ vulnerabilities: [] }, { data: [{ cve: 'CVE-2024-1111', epss: '0.5', percentile: '0.9' }] });
|
|
await CveIntelService.getInstance().refresh();
|
|
expect(db().getCveIntel(['CVE-2024-1111']).get('CVE-2024-1111')).toMatchObject({ epssScore: 0.5, epssPercentile: 0.9 });
|
|
expect(mock.mock.calls.some((c) => String(c[0]).includes('CVE-2024-1111'))).toBe(true);
|
|
});
|
|
|
|
it('skips the EPSS fetch entirely when no CVEs are present', async () => {
|
|
const mock = stubFetch({ vulnerabilities: [] }, { data: [] });
|
|
await CveIntelService.getInstance().refresh();
|
|
expect(mock.mock.calls.some((c) => new URL(String(c[0])).hostname === 'api.first.org')).toBe(false);
|
|
// KEV is still attempted.
|
|
expect(mock.mock.calls.some((c) => new URL(String(c[0])).hostname === 'www.cisa.gov')).toBe(true);
|
|
});
|
|
|
|
it('keeps the cached intel when a fetch fails (air-gap tolerant)', async () => {
|
|
db().replaceKev([{ cve_id: 'CVE-2024-0002', date_added: '2023-12-31' }], Date.now());
|
|
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('network down'); }));
|
|
await expect(CveIntelService.getInstance().refresh()).resolves.toBeUndefined();
|
|
expect(db().getCveIntel(['CVE-2024-0002']).get('CVE-2024-0002')?.kev).toBe(true);
|
|
});
|
|
|
|
it('does no network fetch when disabled by setting', async () => {
|
|
db().updateGlobalSetting('cve_intel_enabled', '0');
|
|
const mock = stubFetch({ vulnerabilities: [] }, { data: [] });
|
|
await CveIntelService.getInstance().refresh();
|
|
expect(mock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('getCveIntel returns an empty map for no ids and does not crash', () => {
|
|
expect(db().getCveIntel([]).size).toBe(0);
|
|
});
|
|
});
|