mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-21 15:46:43 +00:00
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.
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
@@ -204,3 +204,85 @@ describe('pruneScanHistoryPerImage', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -83,8 +83,12 @@ function seedScan(o: {
|
||||
|
||||
function resetSecurity(): void {
|
||||
const raw = (db() as unknown as { db: { prepare: (s: string) => { run: () => void } } }).db;
|
||||
raw.prepare('DELETE FROM vulnerability_details').run();
|
||||
raw.prepare('DELETE FROM vulnerability_scans').run();
|
||||
raw.prepare('DELETE FROM scan_policies').run();
|
||||
raw.prepare('DELETE FROM cve_suppressions').run();
|
||||
raw.prepare('DELETE FROM misconfig_acknowledgements').run();
|
||||
raw.prepare('DELETE FROM cve_intel').run();
|
||||
}
|
||||
|
||||
describe('GET /api/security/overview', () => {
|
||||
@@ -128,6 +132,126 @@ describe('GET /api/security/overview', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('derives suppression- and ack-aware posture facts from detail rows', async () => {
|
||||
const now = Date.now();
|
||||
const scanId = db().createVulnerabilityScan({
|
||||
node_id: 1,
|
||||
image_ref: 'app:1',
|
||||
image_digest: `sha256:app-${Math.random().toString(16).slice(2)}`,
|
||||
scanned_at: now,
|
||||
total_vulnerabilities: 3,
|
||||
critical_count: 2,
|
||||
high_count: 1,
|
||||
medium_count: 0,
|
||||
low_count: 0,
|
||||
unknown_count: 0,
|
||||
fixable_count: 2,
|
||||
secret_count: 0,
|
||||
misconfig_count: 2,
|
||||
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,
|
||||
});
|
||||
const detail = (vulnerability_id: string, severity: 'CRITICAL' | 'HIGH', fixed_version: string | null) => ({
|
||||
vulnerability_id, pkg_name: `pkg-${vulnerability_id}`, installed_version: '1', fixed_version,
|
||||
severity, title: null, description: null, primary_url: null,
|
||||
});
|
||||
db().insertVulnerabilityDetails(scanId, [
|
||||
detail('CVE-2024-0001', 'CRITICAL', '2'), // fixable, counts
|
||||
detail('CVE-2024-0002', 'HIGH', null), // unfixable, does not count
|
||||
detail('CVE-2024-0003', 'CRITICAL', '9'), // fixable but suppressed -> accepted, not fixable
|
||||
]);
|
||||
db().createCveSuppression({
|
||||
cve_id: 'CVE-2024-0003', pkg_name: null, image_pattern: null, reason: 'accepted risk',
|
||||
created_by: 'admin', created_at: now, expires_at: null, replicated_from_control: 0,
|
||||
});
|
||||
db().insertMisconfigFindings(scanId, [
|
||||
{ rule_id: 'DS001', check_id: null, severity: 'HIGH', title: null, message: null, resolution: null, target: 'app', primary_url: null },
|
||||
{ rule_id: 'DS002', check_id: null, severity: 'CRITICAL', title: null, message: null, resolution: null, target: 'app', primary_url: null },
|
||||
]);
|
||||
db().createMisconfigAcknowledgement({
|
||||
rule_id: 'DS001', stack_pattern: null, reason: 'acknowledged',
|
||||
created_by: 'admin', created_at: now, expires_at: null, replicated_from_control: 0,
|
||||
});
|
||||
|
||||
const res = await request(app).get('/api/security/overview').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
rawCritical: 2,
|
||||
rawHigh: 1,
|
||||
fixableCriticalHigh: 1, // 0001 only (0003 suppressed, 0002 unfixable)
|
||||
accepted: 1, // 0003 suppressed
|
||||
dangerousCompose: 1, // DS002 (DS001 acknowledged)
|
||||
knownExploited: 0,
|
||||
publiclyExposed: 0,
|
||||
needsReview: 0,
|
||||
notAffected: 0,
|
||||
posture: 'Action needed',
|
||||
posturePartial: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('separates not_affected and needs_review triage facts in the overview', async () => {
|
||||
const now = Date.now();
|
||||
const scanId = db().createVulnerabilityScan({
|
||||
node_id: 1, image_ref: 'triage:1', image_digest: 'sha256:triage', scanned_at: now,
|
||||
total_vulnerabilities: 2, critical_count: 2, 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-2024-0010', pkg_name: 'a', installed_version: '1', fixed_version: null, severity: 'CRITICAL', title: null, description: null, primary_url: null },
|
||||
{ vulnerability_id: 'CVE-2024-0011', pkg_name: 'b', installed_version: '1', fixed_version: null, severity: 'CRITICAL', title: null, description: null, primary_url: null },
|
||||
]);
|
||||
db().createCveSuppression({ cve_id: 'CVE-2024-0010', pkg_name: null, image_pattern: null, reason: 'not affected', created_by: 'admin', created_at: now, expires_at: null, replicated_from_control: 0, status: 'not_affected' });
|
||||
db().createCveSuppression({ cve_id: 'CVE-2024-0011', pkg_name: null, image_pattern: null, reason: 'reviewing', created_by: 'admin', created_at: now, expires_at: null, replicated_from_control: 0, status: 'needs_review' });
|
||||
|
||||
const res = await request(app).get('/api/security/overview').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ notAffected: 1, needsReview: 1, accepted: 0, fixableCriticalHigh: 0, posture: 'Monitoring' });
|
||||
});
|
||||
|
||||
it('escalates an unfixable known-exploited (KEV) finding to Action needed', async () => {
|
||||
const now = Date.now();
|
||||
const scanId = db().createVulnerabilityScan({
|
||||
node_id: 1, image_ref: 'kev:1', image_digest: 'sha256:kev', scanned_at: 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-2024-9999', pkg_name: 'libkev', installed_version: '1', fixed_version: null,
|
||||
severity: 'CRITICAL', title: null, description: null, primary_url: null,
|
||||
}]);
|
||||
// No fix available, but the CVE is known-exploited: KEV overrides "no fix".
|
||||
db().replaceKev([{ cve_id: 'CVE-2024-9999', date_added: '2024-01-01' }], now);
|
||||
|
||||
const res = await request(app).get('/api/security/overview').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ knownExploited: 1, fixableCriticalHigh: 0, posture: 'Action needed' });
|
||||
});
|
||||
|
||||
it('reads Secure when a scan completed with nothing actionable or severe', async () => {
|
||||
db().createVulnerabilityScan({
|
||||
node_id: 1, image_ref: 'clean:1', image_digest: 'sha256:clean', 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: 'completed', error: null, stack_context: null,
|
||||
});
|
||||
const res = await request(app).get('/api/security/overview').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.posture).toBe('Secure');
|
||||
});
|
||||
|
||||
it('is reachable by a Community viewer (read-only, auth-only)', async () => {
|
||||
const res = await request(app).get('/api/security/overview').set('Cookie', viewerCookie);
|
||||
expect(res.status).toBe(200);
|
||||
@@ -214,3 +338,60 @@ describe('GET /api/security/policy-packs', () => {
|
||||
expect(paid.body).toEqual(community.body);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/security/scans/:scanId/vulnerabilities', () => {
|
||||
beforeEach(() => resetSecurity());
|
||||
|
||||
it('attaches read-time exploit intel (KEV/EPSS) to each finding', async () => {
|
||||
const now = Date.now();
|
||||
const scanId = db().createVulnerabilityScan({
|
||||
node_id: 1, image_ref: 'vex:1', image_digest: 'sha256:vex', scanned_at: 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-2024-7777', pkg_name: 'p', installed_version: '1', fixed_version: null,
|
||||
severity: 'CRITICAL', title: null, description: null, primary_url: null,
|
||||
}]);
|
||||
db().replaceKev([{ cve_id: 'CVE-2024-7777', date_added: '2024-02-02' }], now);
|
||||
db().upsertEpss([{ cve_id: 'CVE-2024-7777', epss_score: 0.42, epss_percentile: 0.95 }], now);
|
||||
|
||||
const res = await request(app).get(`/api/security/scans/${scanId}/vulnerabilities`).set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
const item = (res.body.items as Array<{ vulnerability_id: string; kev: boolean; epss_score: number }>)
|
||||
.find((i) => i.vulnerability_id === 'CVE-2024-7777');
|
||||
expect(item).toMatchObject({ kev: true, epss_score: 0.42, epss_percentile: 0.95 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/security/vex/export (Admiral)', () => {
|
||||
beforeEach(() => {
|
||||
resetSecurity();
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
||||
});
|
||||
afterAll(() => {
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
||||
});
|
||||
|
||||
it('is gated to Admiral: 403 for Community', async () => {
|
||||
const res = await request(app).get('/api/security/vex/export').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('exports an OpenVEX document from triage decisions for Admiral', async () => {
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
db().createCveSuppression({
|
||||
cve_id: 'CVE-2024-2222', pkg_name: null, image_pattern: 'nginx*', reason: 'not present in build',
|
||||
created_by: 'admin', created_at: Date.now(), expires_at: null, replicated_from_control: 0,
|
||||
status: 'not_affected', justification: 'component_not_present',
|
||||
});
|
||||
const res = await request(app).get('/api/security/vex/export').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body['@context']).toContain('openvex');
|
||||
const stmt = (res.body.statements as Array<{ vulnerability: { name: string }; status: string; justification?: string; products: string[] }>)
|
||||
.find((s) => s.vulnerability.name === 'CVE-2024-2222');
|
||||
expect(stmt).toMatchObject({ status: 'not_affected', justification: 'component_not_present', products: ['nginx*'] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { deriveSecurityPosture, type SecurityPostureFacts } from '../services/securityPosture';
|
||||
|
||||
function facts(o: Partial<SecurityPostureFacts>): SecurityPostureFacts {
|
||||
return {
|
||||
scannerAvailable: true,
|
||||
hasCompletedScan: true,
|
||||
fixableCriticalHigh: 0,
|
||||
secrets: 0,
|
||||
dangerousCompose: 0,
|
||||
knownExploited: 0,
|
||||
publiclyExposed: 0,
|
||||
rawCritical: 0,
|
||||
rawHigh: 0,
|
||||
...o,
|
||||
};
|
||||
}
|
||||
|
||||
describe('deriveSecurityPosture', () => {
|
||||
it('is Unknown when the scanner is unavailable', () => {
|
||||
expect(deriveSecurityPosture(facts({ scannerAvailable: false, rawCritical: 9 }))).toBe('Unknown');
|
||||
});
|
||||
|
||||
it('is Unknown when no scan has completed', () => {
|
||||
expect(deriveSecurityPosture(facts({ hasCompletedScan: false, rawCritical: 9 }))).toBe('Unknown');
|
||||
});
|
||||
|
||||
it('is Action needed when a Critical/High is fixable', () => {
|
||||
expect(deriveSecurityPosture(facts({ fixableCriticalHigh: 1, rawCritical: 5, rawHigh: 5 }))).toBe('Action needed');
|
||||
});
|
||||
|
||||
it('is Action needed for a detected secret', () => {
|
||||
expect(deriveSecurityPosture(facts({ secrets: 1 }))).toBe('Action needed');
|
||||
});
|
||||
|
||||
it('is Action needed for a dangerous Compose misconfiguration', () => {
|
||||
expect(deriveSecurityPosture(facts({ dangerousCompose: 1 }))).toBe('Action needed');
|
||||
});
|
||||
|
||||
it('is Action needed when a finding is known-exploited even if unfixable', () => {
|
||||
// KEV escalates: no fix available, but exploited in the wild.
|
||||
expect(deriveSecurityPosture(facts({ knownExploited: 1, fixableCriticalHigh: 0, rawCritical: 1 }))).toBe('Action needed');
|
||||
});
|
||||
|
||||
it('is Action needed when an affected service is publicly exposed', () => {
|
||||
expect(deriveSecurityPosture(facts({ publiclyExposed: 1 }))).toBe('Action needed');
|
||||
});
|
||||
|
||||
it('is Monitoring when Critical/High exist but nothing is actionable', () => {
|
||||
expect(deriveSecurityPosture(facts({ rawCritical: 3, rawHigh: 7 }))).toBe('Monitoring');
|
||||
});
|
||||
|
||||
it('is Secure when a scan completed and nothing is actionable or severe', () => {
|
||||
expect(deriveSecurityPosture(facts({}))).toBe('Secure');
|
||||
});
|
||||
});
|
||||
@@ -163,6 +163,46 @@ describe('applySuppressions', () => {
|
||||
expect(applySuppressions([], 'nginx:1.25', [], NOW)).toEqual([]);
|
||||
});
|
||||
|
||||
it('treats a missing status as accepted (back-compat) and dismisses it', () => {
|
||||
const [r] = applySuppressions(
|
||||
[{ vulnerability_id: 'CVE-2024-1234', pkg_name: 'openssl' }],
|
||||
'nginx:1.25',
|
||||
[makeSuppression({})],
|
||||
NOW,
|
||||
);
|
||||
expect(r).toMatchObject({ suppressed: true, triage_status: 'accepted' });
|
||||
});
|
||||
|
||||
it('surfaces a dismissing status (not_affected) as suppressed with the status', () => {
|
||||
const [r] = applySuppressions(
|
||||
[{ vulnerability_id: 'CVE-2024-1234', pkg_name: 'openssl' }],
|
||||
'nginx:1.25',
|
||||
[makeSuppression({ status: 'not_affected', justification: 'component_not_present' })],
|
||||
NOW,
|
||||
);
|
||||
expect(r).toMatchObject({ suppressed: true, triage_status: 'not_affected', triage_justification: 'component_not_present' });
|
||||
});
|
||||
|
||||
it('does NOT dismiss a needs_review decision (stays actionable, still tagged)', () => {
|
||||
const [r] = applySuppressions(
|
||||
[{ vulnerability_id: 'CVE-2024-1234', pkg_name: 'openssl' }],
|
||||
'nginx:1.25',
|
||||
[makeSuppression({ status: 'needs_review' })],
|
||||
NOW,
|
||||
);
|
||||
expect(r).toMatchObject({ suppressed: false, triage_status: 'needs_review' });
|
||||
});
|
||||
|
||||
it('does NOT dismiss an affected decision', () => {
|
||||
const [r] = applySuppressions(
|
||||
[{ vulnerability_id: 'CVE-2024-1234', pkg_name: 'openssl' }],
|
||||
'nginx:1.25',
|
||||
[makeSuppression({ status: 'affected' })],
|
||||
NOW,
|
||||
);
|
||||
expect(r.suppressed).toBe(false);
|
||||
});
|
||||
|
||||
// Regression guard for the cve_id bucketing optimization. A naive O(N*M)
|
||||
// implementation drifts into the tens of millions of comparisons at this
|
||||
// scale; the bucketed implementation lands in low-tens of milliseconds on
|
||||
|
||||
@@ -196,6 +196,66 @@ describe('TrivyService', () => {
|
||||
expect(parsedEmpty.vulnerabilities).toEqual([]);
|
||||
});
|
||||
|
||||
it('captures scan-intrinsic enrichment (status, CVSS, vendor severity, purl, path, layer)', () => {
|
||||
// Shape mirrors Trivy's documented image-scan JSON for a single finding.
|
||||
const raw = JSON.stringify({
|
||||
Results: [
|
||||
{
|
||||
Target: 'app',
|
||||
Vulnerabilities: [
|
||||
{
|
||||
VulnerabilityID: 'CVE-2024-9143',
|
||||
PkgName: 'libcrypto3',
|
||||
PkgPath: 'usr/lib/libcrypto.so.3',
|
||||
PkgIdentifier: { PURL: 'pkg:apk/alpine/libcrypto3@3.3.2-r0' },
|
||||
InstalledVersion: '3.3.2-r0',
|
||||
FixedVersion: '3.3.2-r1',
|
||||
Status: 'fixed',
|
||||
Severity: 'LOW',
|
||||
Layer: { DiffID: 'sha256:deadbeef' },
|
||||
VendorSeverity: { amazon: 3, redhat: 1, ubuntu: 1 },
|
||||
CVSS: {
|
||||
nvd: { V3Vector: 'CVSS:3.1/AV:N', V3Score: 9.8 },
|
||||
redhat: { V3Vector: 'CVSS:3.1/AV:L', V3Score: 3.7 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const v = parseTrivyOutput(raw).vulnerabilities[0];
|
||||
expect(v.status).toBe('fixed');
|
||||
expect(v.cvssScore).toBe(9.8); // prefers nvd over redhat
|
||||
expect(v.cvssVector).toBe('CVSS:3.1/AV:N');
|
||||
expect(v.cvssSource).toBe('nvd');
|
||||
expect(v.vendorSeverity).toBe('HIGH'); // max vendor rating (amazon=3)
|
||||
expect(v.purl).toBe('pkg:apk/alpine/libcrypto3@3.3.2-r0');
|
||||
expect(v.pkgPath).toBe('usr/lib/libcrypto.so.3');
|
||||
expect(v.layerDigest).toBe('sha256:deadbeef');
|
||||
});
|
||||
|
||||
it('falls back to a non-nvd CVSS source and nulls absent enrichment', () => {
|
||||
const onlyRedhat = JSON.stringify({
|
||||
Results: [{ Vulnerabilities: [{ VulnerabilityID: 'CVE-R', PkgName: 'p', Severity: 'HIGH', CVSS: { redhat: { V3Vector: 'X', V3Score: 7.5 } } }] }],
|
||||
});
|
||||
const a = parseTrivyOutput(onlyRedhat).vulnerabilities[0];
|
||||
expect(a.cvssSource).toBe('redhat');
|
||||
expect(a.cvssScore).toBe(7.5);
|
||||
|
||||
const bare = JSON.stringify({
|
||||
Results: [{ Vulnerabilities: [{ VulnerabilityID: 'CVE-N', PkgName: 'p', Severity: 'HIGH' }] }],
|
||||
});
|
||||
const b = parseTrivyOutput(bare).vulnerabilities[0];
|
||||
expect(b.status).toBeNull();
|
||||
expect(b.cvssScore).toBeNull();
|
||||
expect(b.cvssVector).toBeNull();
|
||||
expect(b.cvssSource).toBeNull();
|
||||
expect(b.vendorSeverity).toBeNull();
|
||||
expect(b.purl).toBeNull();
|
||||
expect(b.pkgPath).toBeNull();
|
||||
expect(b.layerDigest).toBeNull();
|
||||
});
|
||||
|
||||
it('throws a helpful error on malformed JSON', () => {
|
||||
expect(() => parseTrivyOutput('{not-json')).toThrow(/Malformed/i);
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import { SchedulerService } from '../services/SchedulerService';
|
||||
import { MfaService } from '../services/MfaService';
|
||||
import { MeshService } from '../services/MeshService';
|
||||
import { BlueprintReconciler } from '../services/BlueprintReconciler';
|
||||
import { CveIntelService } from '../services/CveIntelService';
|
||||
import { PilotMetrics } from '../services/PilotMetrics';
|
||||
|
||||
/**
|
||||
@@ -52,6 +53,9 @@ export function installShutdownHandlers(server: Server): void {
|
||||
try { BlueprintReconciler.getInstance().stop(); } catch (e) {
|
||||
console.warn('[Shutdown] BlueprintReconciler cleanup failed:', (e as Error).message);
|
||||
}
|
||||
try { CveIntelService.getInstance().stop(); } catch (e) {
|
||||
console.warn('[Shutdown] CveIntelService cleanup failed:', (e as Error).message);
|
||||
}
|
||||
try { PilotMetrics.flush(); } catch (e) {
|
||||
console.warn('[Shutdown] PilotMetrics flush failed:', (e as Error).message);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { SchedulerService } from '../services/SchedulerService';
|
||||
import { MfaService } from '../services/MfaService';
|
||||
import { MeshService } from '../services/MeshService';
|
||||
import { BlueprintReconciler } from '../services/BlueprintReconciler';
|
||||
import { CveIntelService } from '../services/CveIntelService';
|
||||
import { applyPilotModeCapabilityFilter } from '../services/CapabilityRegistry';
|
||||
import { PilotTunnelManager } from '../services/PilotTunnelManager';
|
||||
import { PilotMetrics } from '../services/PilotMetrics';
|
||||
@@ -128,6 +129,7 @@ export async function startServer(server: Server): Promise<void> {
|
||||
console.warn('[Startup] MeshService start failed:', (err as Error).message);
|
||||
});
|
||||
BlueprintReconciler.getInstance().start();
|
||||
CveIntelService.getInstance().start();
|
||||
|
||||
// Drop the cached /api/meta entry on tunnel reconnect so the next
|
||||
// /api/nodes/:id/meta refetches fresh capabilities and version through
|
||||
|
||||
@@ -10,9 +10,11 @@ import { isValidStackName } from '../utils/validation';
|
||||
import { FleetSyncService } from '../services/FleetSyncService';
|
||||
import { LicenseService } from '../services/LicenseService';
|
||||
import { validateImageRef } from '../utils/image-ref';
|
||||
import { applySuppressions } from '../utils/suppression-filter';
|
||||
import { applySuppressions, isTriageStatus, isTriageJustification } from '../utils/suppression-filter';
|
||||
import { applyMisconfigAcknowledgements } from '../utils/misconfig-ack-filter';
|
||||
import { generateSarif } from '../services/SarifExporter';
|
||||
import { generateOpenVex } from '../services/OpenVexExporter';
|
||||
import { deriveSecurityPosture, type SecurityPostureFacts, type SecurityPostureState } from '../services/securityPosture';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
@@ -137,6 +139,25 @@ interface SecurityOverviewResponse {
|
||||
lastSuccessfulScanAt: number | null;
|
||||
scanner: { available: boolean; version: string | null; source: 'managed' | 'host' | 'none'; autoUpdate: boolean };
|
||||
deployEnforcement: { honorSuppressionsOnDeploy: boolean; eligibleBlockPolicies: number };
|
||||
// Posture facts. Counts are facts; the verb (`posture`) is derived in one
|
||||
// place (`deriveSecurityPosture`). `critical`/`high` above stay for back-compat
|
||||
// and are relabeled "scanner detections" in the UI; `rawCritical`/`rawHigh`
|
||||
// are their posture-named aliases. `knownExploited` and `publiclyExposed` come
|
||||
// online with the CVE-intel and Compose-exposure phases (0 until then).
|
||||
rawCritical: number;
|
||||
rawHigh: number;
|
||||
fixableCriticalHigh: number;
|
||||
knownExploited: number;
|
||||
publiclyExposed: number;
|
||||
dangerousCompose: number;
|
||||
needsReview: number;
|
||||
accepted: number;
|
||||
notAffected: number;
|
||||
/** Total actionable items, for the "N actions" affordance. */
|
||||
actionable: number;
|
||||
posture: SecurityPostureState;
|
||||
/** True when the bounded posture pass hit its row cap on this node. */
|
||||
posturePartial: boolean;
|
||||
}
|
||||
|
||||
export const securityRouter = Router();
|
||||
@@ -152,6 +173,7 @@ securityRouter.get('/trivy-status', authMiddleware, (_req: Request, res: Respons
|
||||
autoUpdate: settings.trivy_auto_update === '1',
|
||||
honorSuppressionsOnDeploy: settings.deploy_block_honor_suppressions === '1',
|
||||
preDeployScanAdvisory: settings.pre_deploy_scan_advisory === '1',
|
||||
cveIntelEnabled: settings.cve_intel_enabled !== '0',
|
||||
busy: installer.isBusy(),
|
||||
});
|
||||
});
|
||||
@@ -243,6 +265,22 @@ securityRouter.put('/trivy-auto-update', authMiddleware, (req: Request, res: Res
|
||||
}
|
||||
});
|
||||
|
||||
// Outbound CVE exploit-intel (KEV + EPSS) fetch toggle. Per-instance: the
|
||||
// background CveIntelService on each node reads its own local setting, so this
|
||||
// configures whichever node is active. Default on; off suits air-gapped hosts.
|
||||
securityRouter.put('/cve-intel-enabled', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const enabled = req.body?.enabled === true;
|
||||
try {
|
||||
DatabaseService.getInstance().updateGlobalSetting('cve_intel_enabled', enabled ? '1' : '0');
|
||||
res.json({ cveIntelEnabled: enabled });
|
||||
} catch (err) {
|
||||
const msg = getErrorMessage(err, 'Failed to update setting');
|
||||
console.error('[Security] CVE intel toggle failed:', msg);
|
||||
res.status(500).json({ error: msg });
|
||||
}
|
||||
});
|
||||
|
||||
// When enabled, the pre-deploy block policy re-derives image severity from
|
||||
// suppression-filtered findings, so an accepted CVE no longer blocks a deploy.
|
||||
// Per-instance setting (not fleet-replicated): the gate runs on the node that
|
||||
@@ -547,7 +585,19 @@ securityRouter.get(
|
||||
const result = db.getVulnerabilityDetails(scanId, { severity, limit, offset });
|
||||
const suppressions = db.getCveSuppressions();
|
||||
const enriched = applySuppressions(result.items, scan.image_ref, suppressions);
|
||||
res.json({ ...result, items: enriched });
|
||||
// Join time-varying exploit intel at read time (KEV/EPSS), keyed by CVE id;
|
||||
// never frozen onto the row, so a CVE entering KEV later surfaces on this scan.
|
||||
const intel = db.getCveIntel(enriched.map((v) => v.vulnerability_id));
|
||||
const withIntel = enriched.map((v) => {
|
||||
const i = intel.get(v.vulnerability_id);
|
||||
return {
|
||||
...v,
|
||||
kev: i?.kev ?? false,
|
||||
epss_score: i?.epssScore ?? null,
|
||||
epss_percentile: i?.epssPercentile ?? null,
|
||||
};
|
||||
});
|
||||
res.json({ ...result, items: withIntel });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -652,6 +702,72 @@ securityRouter.get('/overview', authMiddleware, (req: Request, res: Response): v
|
||||
}
|
||||
}
|
||||
|
||||
// Posture facts that depend on suppressions/acks (which change without a
|
||||
// rescan) are computed at read time over a bounded set of Critical/High
|
||||
// findings, grouped per image so the existing read-time filters apply
|
||||
// unchanged. The pass is capped; `posturePartial` flags a truncated node.
|
||||
const cveSuppressions = db.getCveSuppressions();
|
||||
const critHigh = db.getLatestCritHighVulnFindingsForNode(req.nodeId);
|
||||
// Exploit intel is joined at read time by CVE id (never frozen onto the row).
|
||||
const intel = db.getCveIntel(critHigh.items.map((f) => f.vulnerability_id));
|
||||
const critHighByImage = new Map<string, Array<{ vulnerability_id: string; pkg_name: string; fixed_version: string | null }>>();
|
||||
for (const f of critHigh.items) {
|
||||
const group = critHighByImage.get(f.image_ref);
|
||||
if (group) group.push(f);
|
||||
else critHighByImage.set(f.image_ref, [f]);
|
||||
}
|
||||
let fixableCriticalHigh = 0;
|
||||
let accepted = 0;
|
||||
let notAffected = 0;
|
||||
let needsReview = 0;
|
||||
let knownExploited = 0;
|
||||
for (const [imageRef, group] of critHighByImage) {
|
||||
for (const e of applySuppressions(group, imageRef, cveSuppressions)) {
|
||||
if (e.triage_status === 'needs_review') needsReview += 1;
|
||||
if (e.suppressed) {
|
||||
// A dismissing decision: not_affected is its own fact, the rest are "accepted".
|
||||
if (e.triage_status === 'not_affected') notAffected += 1;
|
||||
else accepted += 1;
|
||||
continue;
|
||||
}
|
||||
// Not dismissed (no decision, needs_review, or affected): still actionable.
|
||||
if (e.fixed_version) fixableCriticalHigh += 1;
|
||||
if (intel.get(e.vulnerability_id)?.kev) knownExploited += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const acks = db.getMisconfigAcknowledgements();
|
||||
const highMisconfigs = db.getLatestHighMisconfigFindingsForNode(req.nodeId);
|
||||
const misconfigByStack = new Map<string | null, Array<{ rule_id: string }>>();
|
||||
for (const f of highMisconfigs.items) {
|
||||
const group = misconfigByStack.get(f.stack_context);
|
||||
if (group) group.push(f);
|
||||
else misconfigByStack.set(f.stack_context, [f]);
|
||||
}
|
||||
let dangerousCompose = 0;
|
||||
for (const [stackContext, group] of misconfigByStack) {
|
||||
for (const e of applyMisconfigAcknowledgements(group, stackContext, acks)) {
|
||||
if (!e.acknowledged) dangerousCompose += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Compose exposure is joined in a later phase; until then it is honestly zero.
|
||||
const publiclyExposed = 0;
|
||||
|
||||
const postureFacts: SecurityPostureFacts = {
|
||||
scannerAvailable: svc.isTrivyAvailable(),
|
||||
hasCompletedScan: lastSuccessfulScanAt !== null,
|
||||
fixableCriticalHigh,
|
||||
secrets,
|
||||
dangerousCompose,
|
||||
knownExploited,
|
||||
publiclyExposed,
|
||||
rawCritical: critical,
|
||||
rawHigh: high,
|
||||
};
|
||||
const posture = deriveSecurityPosture(postureFacts);
|
||||
const actionable = fixableCriticalHigh + secrets + dangerousCompose + knownExploited + publiclyExposed;
|
||||
|
||||
const overview: SecurityOverviewResponse = {
|
||||
scannedImages,
|
||||
critical,
|
||||
@@ -676,6 +792,18 @@ securityRouter.get('/overview', authMiddleware, (req: Request, res: Response): v
|
||||
FleetSyncService.getSelfIdentity(),
|
||||
),
|
||||
},
|
||||
rawCritical: critical,
|
||||
rawHigh: high,
|
||||
fixableCriticalHigh,
|
||||
knownExploited,
|
||||
publiclyExposed,
|
||||
dangerousCompose,
|
||||
needsReview,
|
||||
accepted,
|
||||
notAffected,
|
||||
actionable,
|
||||
posture,
|
||||
posturePartial: critHigh.truncated || highMisconfigs.truncated,
|
||||
};
|
||||
res.json(overview);
|
||||
} catch (error) {
|
||||
@@ -803,6 +931,24 @@ securityRouter.get(
|
||||
},
|
||||
);
|
||||
|
||||
// Export the instance's CVE triage decisions as an OpenVEX document. Authoring
|
||||
// fleet VEX is a governance feature, so it is Admiral (paid) + admin, mirroring
|
||||
// the SARIF export gate.
|
||||
securityRouter.get('/vex/export', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const suppressions = DatabaseService.getInstance().getCveSuppressions();
|
||||
const doc = generateOpenVex(suppressions, req.user?.username || 'sencho', new Date().toISOString());
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="sencho-fleet.openvex.json"');
|
||||
res.send(JSON.stringify(doc));
|
||||
} catch (error) {
|
||||
console.error('[Security] OpenVEX export failed:', error);
|
||||
res.status(500).json({ error: (error as Error).message || 'Failed to generate OpenVEX' });
|
||||
}
|
||||
});
|
||||
|
||||
securityRouter.get('/policies', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
// Replicas see only policies that apply to themselves: local-only rows plus
|
||||
@@ -943,6 +1089,15 @@ securityRouter.post('/suppressions', authMiddleware, (req: Request, res: Respons
|
||||
if (expiresAt !== null && !Number.isFinite(expiresAt)) {
|
||||
res.status(400).json({ error: 'expires_at must be a timestamp or null' }); return;
|
||||
}
|
||||
// Triage decision: default 'accepted' (a plain suppress = accepted risk).
|
||||
const status = body.status === undefined ? 'accepted' : body.status;
|
||||
if (!isTriageStatus(status)) {
|
||||
res.status(400).json({ error: 'invalid triage status' }); return;
|
||||
}
|
||||
const justification = body.justification == null || body.justification === '' ? null : body.justification;
|
||||
if (justification !== null && !isTriageJustification(justification)) {
|
||||
res.status(400).json({ error: 'invalid triage justification' }); return;
|
||||
}
|
||||
try {
|
||||
const suppression = DatabaseService.getInstance().createCveSuppression({
|
||||
cve_id: cveId,
|
||||
@@ -953,6 +1108,8 @@ securityRouter.post('/suppressions', authMiddleware, (req: Request, res: Respons
|
||||
created_at: Date.now(),
|
||||
expires_at: expiresAt,
|
||||
replicated_from_control: 0,
|
||||
status,
|
||||
justification,
|
||||
});
|
||||
FleetSyncService.getInstance().pushResourceAsync('cve_suppressions');
|
||||
res.status(201).json(suppression);
|
||||
@@ -976,13 +1133,24 @@ securityRouter.put('/suppressions/:id', authMiddleware, (req: Request, res: Resp
|
||||
res.status(400).json({ error: 'Invalid suppression id' }); return;
|
||||
}
|
||||
const body = req.body ?? {};
|
||||
const updates: Partial<{ reason: string; image_pattern: string | null; expires_at: number | null }> = {};
|
||||
const updates: Partial<{ reason: string; image_pattern: string | null; expires_at: number | null; status: string; justification: string | null }> = {};
|
||||
if (body.reason !== undefined) {
|
||||
const reason = typeof body.reason === 'string' ? body.reason.trim() : '';
|
||||
if (!reason) { res.status(400).json({ error: 'reason is required' }); return; }
|
||||
if (reason.length > 2000) { res.status(400).json({ error: 'reason is too long' }); return; }
|
||||
updates.reason = reason;
|
||||
}
|
||||
if (body.status !== undefined) {
|
||||
if (!isTriageStatus(body.status)) { res.status(400).json({ error: 'invalid triage status' }); return; }
|
||||
updates.status = body.status;
|
||||
}
|
||||
if (body.justification !== undefined) {
|
||||
const justification = body.justification == null || body.justification === '' ? null : body.justification;
|
||||
if (justification !== null && !isTriageJustification(justification)) {
|
||||
res.status(400).json({ error: 'invalid triage justification' }); return;
|
||||
}
|
||||
updates.justification = justification;
|
||||
}
|
||||
if (body.image_pattern !== undefined) {
|
||||
const pattern = body.image_pattern == null || body.image_pattern === '' ? null : String(body.image_pattern).trim();
|
||||
if (pattern !== null && pattern.length > 300) {
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { DatabaseService } from './DatabaseService';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
|
||||
/**
|
||||
* Background exploit-intelligence cache: CISA KEV (known-exploited) membership
|
||||
* and FIRST EPSS (exploitation probability), refreshed daily and joined to
|
||||
* findings at read time by CVE id.
|
||||
*
|
||||
* Design constraints:
|
||||
* - Time-varying: never frozen onto scan rows, so a CVE that enters KEV next
|
||||
* week lights up on a scan stored today.
|
||||
* - Optional and air-gap tolerant: every fetch is isolated and best-effort. A
|
||||
* failure keeps the last cache and never blocks scans or the Security page.
|
||||
* - Bounded: EPSS is fetched only for the CVE ids actually present in stored
|
||||
* findings, batched, so we never download the full ~250k-row EPSS dataset.
|
||||
*
|
||||
* Hosts contacted (documented for firewalled operators):
|
||||
* - https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json
|
||||
* - https://api.first.org/data/v1/epss (public, no API key)
|
||||
*/
|
||||
const KEV_URL = 'https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json';
|
||||
const EPSS_API = 'https://api.first.org/data/v1/epss';
|
||||
const FETCH_TIMEOUT_MS = 15_000;
|
||||
const REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000; // daily
|
||||
const INITIAL_DELAY_MS = 30_000;
|
||||
const EPSS_BATCH = 100; // FIRST API accepts a comma-separated batch per request
|
||||
const EPSS_BATCH_DELAY_MS = 250; // be polite to the public API between batches
|
||||
|
||||
interface KevFeed {
|
||||
vulnerabilities?: Array<{ cveID?: string; dateAdded?: string }>;
|
||||
}
|
||||
interface EpssResponse {
|
||||
data?: Array<{ cve?: string; epss?: string; percentile?: string }>;
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms).unref();
|
||||
});
|
||||
}
|
||||
|
||||
export class CveIntelService {
|
||||
private static instance: CveIntelService;
|
||||
private intervalId: NodeJS.Timeout | null = null;
|
||||
private firstTickId: NodeJS.Timeout | null = null;
|
||||
private refreshing = false;
|
||||
|
||||
public static getInstance(): CveIntelService {
|
||||
if (!CveIntelService.instance) CveIntelService.instance = new CveIntelService();
|
||||
return CveIntelService.instance;
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
if (this.intervalId) return;
|
||||
this.firstTickId = setTimeout(() => void this.refresh(), INITIAL_DELAY_MS);
|
||||
this.firstTickId.unref();
|
||||
this.intervalId = setInterval(() => void this.refresh(), REFRESH_INTERVAL_MS);
|
||||
this.intervalId.unref();
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
if (this.firstTickId) {
|
||||
clearTimeout(this.firstTickId);
|
||||
this.firstTickId = null;
|
||||
}
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh both feeds. Public for the scheduled tick and tests. Never throws;
|
||||
* each source is isolated so one failing does not skip the other. Honors the
|
||||
* `cve_intel_enabled` setting (read locally on this instance), so the daily
|
||||
* timer keeps firing but the fetch body is skipped when disabled.
|
||||
*/
|
||||
public async refresh(): Promise<void> {
|
||||
if (this.refreshing) return;
|
||||
const db = DatabaseService.getInstance();
|
||||
if (db.getGlobalSettings().cve_intel_enabled === '0') {
|
||||
if (isDebugEnabled()) console.log('[CveIntel] disabled by setting; skipping refresh');
|
||||
return;
|
||||
}
|
||||
this.refreshing = true;
|
||||
try {
|
||||
await this.refreshKev();
|
||||
await this.refreshEpss();
|
||||
} finally {
|
||||
this.refreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshKev(): Promise<void> {
|
||||
try {
|
||||
const res = await fetch(KEV_URL, {
|
||||
headers: { 'User-Agent': 'Sencho' },
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) throw new Error(`KEV feed returned ${res.status}`);
|
||||
const body = (await res.json()) as KevFeed;
|
||||
const entries = (body.vulnerabilities ?? [])
|
||||
.map((v) => ({
|
||||
cve_id: typeof v.cveID === 'string' ? v.cveID : '',
|
||||
date_added: typeof v.dateAdded === 'string' ? v.dateAdded : null,
|
||||
}))
|
||||
.filter((e) => e.cve_id.startsWith('CVE-'));
|
||||
DatabaseService.getInstance().replaceKev(entries, Date.now());
|
||||
if (isDebugEnabled()) console.log(`[CveIntel] KEV refreshed: ${entries.length} entries`);
|
||||
} catch (err) {
|
||||
console.warn('[CveIntel] KEV refresh failed (keeping cache):', (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshEpss(): Promise<void> {
|
||||
const db = DatabaseService.getInstance();
|
||||
const cveIds = db.getDistinctVulnerabilityCveIds();
|
||||
if (cveIds.length === 0) {
|
||||
if (isDebugEnabled()) console.log('[CveIntel] no CVEs in stored scans; skipping EPSS fetch');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
for (let i = 0; i < cveIds.length; i += EPSS_BATCH) {
|
||||
const chunk = cveIds.slice(i, i + EPSS_BATCH);
|
||||
const res = await fetch(`${EPSS_API}?cve=${chunk.join(',')}`, {
|
||||
headers: { 'User-Agent': 'Sencho' },
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) throw new Error(`EPSS API returned ${res.status}`);
|
||||
const body = (await res.json()) as EpssResponse;
|
||||
const entries = (body.data ?? [])
|
||||
.map((d) => ({
|
||||
cve_id: typeof d.cve === 'string' ? d.cve : '',
|
||||
epss_score: d.epss != null ? Number(d.epss) : NaN,
|
||||
epss_percentile: d.percentile != null ? Number(d.percentile) : NaN,
|
||||
}))
|
||||
.filter((e) => e.cve_id.startsWith('CVE-') && Number.isFinite(e.epss_score) && Number.isFinite(e.epss_percentile));
|
||||
db.upsertEpss(entries, Date.now());
|
||||
if (i + EPSS_BATCH < cveIds.length) await delay(EPSS_BATCH_DELAY_MS);
|
||||
}
|
||||
if (isDebugEnabled()) console.log(`[CveIntel] EPSS refreshed for ${cveIds.length} CVEs`);
|
||||
} catch (err) {
|
||||
console.warn('[CveIntel] EPSS refresh failed (keeping cache):', (err as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default CveIntelService;
|
||||
@@ -638,6 +638,17 @@ export interface VulnerabilityDetail {
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
primary_url: string | null;
|
||||
// Scan-intrinsic enrichment captured from Trivy. Optional because older rows
|
||||
// (pre-enrichment) and callers that don't enrich omit them; the insert binds
|
||||
// null. `status` is the posture-relevant one (fixed / will_not_fix / ...).
|
||||
status?: string | null;
|
||||
cvss_score?: number | null;
|
||||
cvss_vector?: string | null;
|
||||
cvss_source?: string | null;
|
||||
vendor_severity?: VulnSeverity | null;
|
||||
purl?: string | null;
|
||||
pkg_path?: string | null;
|
||||
layer_digest?: string | null;
|
||||
}
|
||||
|
||||
export interface SecretFinding {
|
||||
@@ -701,6 +712,11 @@ export interface CveSuppression {
|
||||
created_at: number;
|
||||
expires_at: number | null;
|
||||
replicated_from_control: number;
|
||||
// Triage decision layered on the suppression. Optional on inputs (callers may
|
||||
// omit them; the insert defaults `status` to 'accepted', the back-compat value
|
||||
// for pre-triage rows). `justification` is an optional OpenVEX reason code.
|
||||
status?: string;
|
||||
justification?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -719,6 +735,14 @@ export interface MisconfigAcknowledgement {
|
||||
replicated_from_control: number;
|
||||
}
|
||||
|
||||
/** Read-time exploit intelligence joined to a CVE id (CveIntelService cache). */
|
||||
export interface CveIntel {
|
||||
kev: boolean;
|
||||
kevDate: string | null;
|
||||
epssScore: number | null;
|
||||
epssPercentile: number | null;
|
||||
}
|
||||
|
||||
export interface ScanSummary {
|
||||
image_ref: string;
|
||||
highest_severity: VulnSeverity | null;
|
||||
@@ -1169,6 +1193,20 @@ export class DatabaseService {
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_misconfig_ack_unique
|
||||
ON misconfig_acknowledgements(rule_id, COALESCE(stack_pattern, ''));
|
||||
|
||||
-- Time-varying exploit intelligence (CISA KEV + FIRST EPSS), refreshed by
|
||||
-- CveIntelService and joined to findings at read time by CVE id. Never
|
||||
-- frozen onto vulnerability_details, so a CVE entering KEV later lights up
|
||||
-- on scans already stored.
|
||||
CREATE TABLE IF NOT EXISTS cve_intel (
|
||||
cve_id TEXT PRIMARY KEY,
|
||||
kev INTEGER NOT NULL DEFAULT 0,
|
||||
kev_date TEXT,
|
||||
epss_score REAL,
|
||||
epss_percentile REAL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cve_intel_kev ON cve_intel(kev);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stack_labels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
node_id INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -1436,6 +1474,23 @@ export class DatabaseService {
|
||||
// Captured Stack Dossier metadata (opt-in documentation snapshots)
|
||||
maybeAddCol('fleet_snapshots', 'documentation', "TEXT NOT NULL DEFAULT ''");
|
||||
|
||||
// Scan finding enrichment: scan-intrinsic fields Trivy returns that the
|
||||
// triage/action posture surfaces (status, CVSS, vendor severity, purl,
|
||||
// package path, layer). Nullable; older rows simply have no enrichment.
|
||||
maybeAddCol('vulnerability_details', 'status', 'TEXT');
|
||||
maybeAddCol('vulnerability_details', 'cvss_score', 'REAL');
|
||||
maybeAddCol('vulnerability_details', 'cvss_vector', 'TEXT');
|
||||
maybeAddCol('vulnerability_details', 'cvss_source', 'TEXT');
|
||||
maybeAddCol('vulnerability_details', 'vendor_severity', 'TEXT');
|
||||
maybeAddCol('vulnerability_details', 'purl', 'TEXT');
|
||||
maybeAddCol('vulnerability_details', 'pkg_path', 'TEXT');
|
||||
maybeAddCol('vulnerability_details', 'layer_digest', 'TEXT');
|
||||
|
||||
// Triage decisions layered on CVE suppressions (status + optional OpenVEX
|
||||
// justification). Existing rows default to 'accepted' (the prior behavior).
|
||||
maybeAddCol('cve_suppressions', 'status', "TEXT NOT NULL DEFAULT 'accepted'");
|
||||
maybeAddCol('cve_suppressions', 'justification', 'TEXT');
|
||||
|
||||
// Scheduled operations migrations
|
||||
maybeAddCol('scheduled_task_runs', 'triggered_by', "TEXT NOT NULL DEFAULT 'scheduler'");
|
||||
maybeAddCol('scheduled_tasks', 'prune_targets', 'TEXT DEFAULT NULL');
|
||||
@@ -1490,6 +1545,10 @@ export class DatabaseService {
|
||||
stmt.run('trivy_last_notified_version', '');
|
||||
stmt.run('deploy_block_honor_suppressions', '0');
|
||||
stmt.run('pre_deploy_scan_advisory', '0');
|
||||
// Outbound CVE exploit-intel (KEV + EPSS) fetch. On by default (a safe
|
||||
// convenience that degrades gracefully offline); operators on air-gapped
|
||||
// or firewalled hosts can turn it off.
|
||||
stmt.run('cve_intel_enabled', '1');
|
||||
stmt.run('mesh_auto_recreate', '0');
|
||||
stmt.run('prune_on_update', '1');
|
||||
stmt.run('reclaim_hero', '1');
|
||||
@@ -4401,8 +4460,10 @@ export class DatabaseService {
|
||||
const stmt = this.db.prepare(
|
||||
`INSERT INTO vulnerability_details (
|
||||
scan_id, vulnerability_id, pkg_name, installed_version,
|
||||
fixed_version, severity, title, description, primary_url
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
fixed_version, severity, title, description, primary_url,
|
||||
status, cvss_score, cvss_vector, cvss_source, vendor_severity,
|
||||
purl, pkg_path, layer_digest
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
);
|
||||
const txn = this.db.transaction((rows: typeof details) => {
|
||||
for (const d of rows) {
|
||||
@@ -4416,6 +4477,14 @@ export class DatabaseService {
|
||||
d.title,
|
||||
d.description,
|
||||
d.primary_url,
|
||||
d.status ?? null,
|
||||
d.cvss_score ?? null,
|
||||
d.cvss_vector ?? null,
|
||||
d.cvss_source ?? null,
|
||||
d.vendor_severity ?? null,
|
||||
d.purl ?? null,
|
||||
d.pkg_path ?? null,
|
||||
d.layer_digest ?? null,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -4636,6 +4705,161 @@ export class DatabaseService {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Critical/High vulnerability findings from the latest completed scan per
|
||||
* image on a node, for read-time posture math (suppression-aware fixable and
|
||||
* accepted counts). Selects only the identity columns posture needs and is
|
||||
* capped: `truncated` is set when the cap is hit so the caller can mark the
|
||||
* posture partial rather than silently undercount. Phase 2 intentionally
|
||||
* omits `status` (added by the findings-enrichment phase) so this runs
|
||||
* standalone against a not-yet-migrated `vulnerability_details`.
|
||||
*/
|
||||
public getLatestCritHighVulnFindingsForNode(
|
||||
nodeId: number,
|
||||
limit = 5000,
|
||||
): {
|
||||
items: Array<{ image_ref: string; vulnerability_id: string; pkg_name: string; fixed_version: string | null }>;
|
||||
truncated: boolean;
|
||||
} {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT vs.image_ref, vd.vulnerability_id, vd.pkg_name, vd.fixed_version
|
||||
FROM vulnerability_details vd
|
||||
INNER JOIN vulnerability_scans vs ON vs.id = vd.scan_id
|
||||
INNER JOIN (
|
||||
SELECT image_ref, MAX(scanned_at) AS max_scanned
|
||||
FROM vulnerability_scans
|
||||
WHERE node_id = ? AND status = 'completed'
|
||||
GROUP BY image_ref
|
||||
) latest ON latest.image_ref = vs.image_ref AND latest.max_scanned = vs.scanned_at
|
||||
WHERE vs.node_id = ? AND vs.status = 'completed'
|
||||
AND vd.severity IN ('CRITICAL', 'HIGH')
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(nodeId, nodeId, limit + 1) as Array<{
|
||||
image_ref: string;
|
||||
vulnerability_id: string;
|
||||
pkg_name: string;
|
||||
fixed_version: string | null;
|
||||
}>;
|
||||
const truncated = rows.length > limit;
|
||||
return { items: truncated ? rows.slice(0, limit) : rows, truncated };
|
||||
}
|
||||
|
||||
/**
|
||||
* High-severity misconfiguration findings from the latest completed scan per
|
||||
* image on a node, for the acknowledgement-aware `dangerousCompose` posture
|
||||
* fact. Same bounded shape as `getLatestCritHighVulnFindingsForNode`.
|
||||
*/
|
||||
public getLatestHighMisconfigFindingsForNode(
|
||||
nodeId: number,
|
||||
limit = 5000,
|
||||
): { items: Array<{ rule_id: string; stack_context: string | null }>; truncated: boolean } {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT mf.rule_id, vs.stack_context
|
||||
FROM misconfig_findings mf
|
||||
INNER JOIN vulnerability_scans vs ON vs.id = mf.scan_id
|
||||
INNER JOIN (
|
||||
SELECT image_ref, MAX(scanned_at) AS max_scanned
|
||||
FROM vulnerability_scans
|
||||
WHERE node_id = ? AND status = 'completed'
|
||||
GROUP BY image_ref
|
||||
) latest ON latest.image_ref = vs.image_ref AND latest.max_scanned = vs.scanned_at
|
||||
WHERE vs.node_id = ? AND vs.status = 'completed'
|
||||
AND mf.severity IN ('CRITICAL', 'HIGH')
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(nodeId, nodeId, limit + 1) as Array<{ rule_id: string; stack_context: string | null }>;
|
||||
const truncated = rows.length > limit;
|
||||
return { items: truncated ? rows.slice(0, limit) : rows, truncated };
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct CVE ids present in stored findings, for the intel service to fetch
|
||||
* EPSS only for what exists (EPSS covers CVEs, not GHSA, so filter to CVE-*).
|
||||
*/
|
||||
public getDistinctVulnerabilityCveIds(limit = 20000): string[] {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT DISTINCT vulnerability_id FROM vulnerability_details
|
||||
WHERE vulnerability_id LIKE 'CVE-%' LIMIT ?`,
|
||||
)
|
||||
.all(limit) as Array<{ vulnerability_id: string }>;
|
||||
return rows.map((r) => r.vulnerability_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the KEV membership set. Clears kev on every row first, then marks
|
||||
* the supplied CVEs, so a CVE removed from CISA's feed stops being flagged.
|
||||
* Preserves EPSS columns (ON CONFLICT only touches the kev fields).
|
||||
*/
|
||||
public replaceKev(entries: Array<{ cve_id: string; date_added: string | null }>, now: number): void {
|
||||
const clear = this.db.prepare('UPDATE cve_intel SET kev = 0');
|
||||
const upsert = this.db.prepare(
|
||||
`INSERT INTO cve_intel (cve_id, kev, kev_date, updated_at)
|
||||
VALUES (?, 1, ?, ?)
|
||||
ON CONFLICT(cve_id) DO UPDATE SET kev = 1, kev_date = excluded.kev_date, updated_at = excluded.updated_at`,
|
||||
);
|
||||
const txn = this.db.transaction((rows: typeof entries) => {
|
||||
clear.run();
|
||||
for (const e of rows) upsert.run(e.cve_id, e.date_added, now);
|
||||
});
|
||||
txn(entries);
|
||||
}
|
||||
|
||||
/** Upsert EPSS scores. Preserves kev columns (ON CONFLICT touches only EPSS). */
|
||||
public upsertEpss(entries: Array<{ cve_id: string; epss_score: number; epss_percentile: number }>, now: number): void {
|
||||
if (entries.length === 0) return;
|
||||
const upsert = this.db.prepare(
|
||||
`INSERT INTO cve_intel (cve_id, epss_score, epss_percentile, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(cve_id) DO UPDATE SET epss_score = excluded.epss_score,
|
||||
epss_percentile = excluded.epss_percentile, updated_at = excluded.updated_at`,
|
||||
);
|
||||
const txn = this.db.transaction((rows: typeof entries) => {
|
||||
for (const e of rows) upsert.run(e.cve_id, e.epss_score, e.epss_percentile, now);
|
||||
});
|
||||
txn(entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-time intel join. Returns a map keyed by CVE id for the supplied ids
|
||||
* only (chunked to stay under SQLite's bound-parameter ceiling). Absent ids
|
||||
* simply have no entry.
|
||||
*/
|
||||
public getCveIntel(cveIds: string[]): Map<string, CveIntel> {
|
||||
const out = new Map<string, CveIntel>();
|
||||
if (cveIds.length === 0) return out;
|
||||
const unique = [...new Set(cveIds)];
|
||||
const CHUNK = 900;
|
||||
for (let i = 0; i < unique.length; i += CHUNK) {
|
||||
const chunk = unique.slice(i, i + CHUNK);
|
||||
const placeholders = chunk.map(() => '?').join(', ');
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT cve_id, kev, kev_date, epss_score, epss_percentile
|
||||
FROM cve_intel WHERE cve_id IN (${placeholders})`,
|
||||
)
|
||||
.all(...chunk) as Array<{
|
||||
cve_id: string;
|
||||
kev: number;
|
||||
kev_date: string | null;
|
||||
epss_score: number | null;
|
||||
epss_percentile: number | null;
|
||||
}>;
|
||||
for (const r of rows) {
|
||||
out.set(r.cve_id, {
|
||||
kev: r.kev === 1,
|
||||
kevDate: r.kev_date,
|
||||
epssScore: r.epss_score,
|
||||
epssPercentile: r.epss_percentile,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uncapped count of scans in a given status for a node. Unlike
|
||||
* `getVulnerabilityScans`, this never applies the per-image history cap, so
|
||||
@@ -5106,8 +5330,8 @@ export class DatabaseService {
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`INSERT INTO cve_suppressions
|
||||
(cve_id, pkg_name, image_pattern, reason, created_by, created_at, expires_at, replicated_from_control)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
(cve_id, pkg_name, image_pattern, reason, created_by, created_at, expires_at, replicated_from_control, status, justification)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
suppression.cve_id,
|
||||
@@ -5118,17 +5342,24 @@ export class DatabaseService {
|
||||
suppression.created_at,
|
||||
suppression.expires_at,
|
||||
suppression.replicated_from_control ?? 0,
|
||||
suppression.status ?? 'accepted',
|
||||
suppression.justification ?? null,
|
||||
);
|
||||
return { ...suppression, id: result.lastInsertRowid as number };
|
||||
return {
|
||||
...suppression,
|
||||
status: suppression.status ?? 'accepted',
|
||||
justification: suppression.justification ?? null,
|
||||
id: result.lastInsertRowid as number,
|
||||
};
|
||||
}
|
||||
|
||||
public updateCveSuppression(
|
||||
id: number,
|
||||
updates: Partial<Pick<CveSuppression, 'reason' | 'image_pattern' | 'expires_at'>>,
|
||||
updates: Partial<Pick<CveSuppression, 'reason' | 'image_pattern' | 'expires_at' | 'status' | 'justification'>>,
|
||||
): CveSuppression | null {
|
||||
const existing = this.getCveSuppression(id);
|
||||
if (!existing) return null;
|
||||
const ALLOWED = new Set(['reason', 'image_pattern', 'expires_at']);
|
||||
const ALLOWED = new Set(['reason', 'image_pattern', 'expires_at', 'status', 'justification']);
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
@@ -5156,8 +5387,8 @@ export class DatabaseService {
|
||||
const deleteStmt = this.db.prepare('DELETE FROM cve_suppressions WHERE replicated_from_control = 1');
|
||||
const insertStmt = this.db.prepare(
|
||||
`INSERT INTO cve_suppressions
|
||||
(cve_id, pkg_name, image_pattern, reason, created_by, created_at, expires_at, replicated_from_control)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 1)`,
|
||||
(cve_id, pkg_name, image_pattern, reason, created_by, created_at, expires_at, replicated_from_control, status, justification)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?)`,
|
||||
);
|
||||
const txn = this.db.transaction((items: Array<Omit<CveSuppression, 'id'>>) => {
|
||||
deleteStmt.run();
|
||||
@@ -5170,6 +5401,9 @@ export class DatabaseService {
|
||||
s.created_by,
|
||||
s.created_at,
|
||||
s.expires_at,
|
||||
// Back-compat: a control on an older version omits these in its push.
|
||||
s.status ?? 'accepted',
|
||||
s.justification ?? null,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -577,6 +577,9 @@ export class FleetSyncService {
|
||||
created_by: s.created_by,
|
||||
created_at: s.created_at,
|
||||
expires_at: s.expires_at,
|
||||
// Replicate the triage decision so a replica's posture matches control.
|
||||
status: s.status,
|
||||
justification: s.justification,
|
||||
}));
|
||||
} else if (resource === 'misconfig_acknowledgements') {
|
||||
rows = db.getLocalMisconfigAcknowledgements().map((a) => ({
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Builds an OpenVEX document from the instance's CVE triage decisions.
|
||||
*
|
||||
* Each suppression carries a triage status; this maps it to an OpenVEX status so
|
||||
* downstream scanners (and other Sencho nodes) can consume our authored
|
||||
* not-affected / fixed statements rather than re-deciding. Emitting from the
|
||||
* stored decisions (not a live scan) keeps the export consistent with the UI.
|
||||
*/
|
||||
import type { CveSuppression } from './DatabaseService';
|
||||
|
||||
export interface OpenVexStatement {
|
||||
vulnerability: { name: string };
|
||||
products: string[];
|
||||
status: 'not_affected' | 'affected' | 'fixed' | 'under_investigation';
|
||||
justification?: string;
|
||||
action_statement?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface OpenVexDocument {
|
||||
'@context': string;
|
||||
'@id': string;
|
||||
author: string;
|
||||
timestamp: string;
|
||||
version: number;
|
||||
statements: OpenVexStatement[];
|
||||
}
|
||||
|
||||
// Triage status -> OpenVEX status. OpenVEX has four statuses; "accepted"/"ignored"
|
||||
// risk is "affected" with an action statement, "needs_review" maps to the
|
||||
// in-flight "under_investigation".
|
||||
const STATUS_MAP: Record<string, OpenVexStatement['status']> = {
|
||||
not_affected: 'not_affected',
|
||||
false_positive: 'not_affected',
|
||||
fixed: 'fixed',
|
||||
affected: 'affected',
|
||||
accepted: 'affected',
|
||||
ignored: 'affected',
|
||||
needs_review: 'under_investigation',
|
||||
};
|
||||
|
||||
export function generateOpenVex(
|
||||
suppressions: CveSuppression[],
|
||||
author: string,
|
||||
timestamp: string,
|
||||
): OpenVexDocument {
|
||||
const statements: OpenVexStatement[] = suppressions.map((s) => {
|
||||
const status = STATUS_MAP[s.status ?? 'accepted'] ?? 'affected';
|
||||
const stmt: OpenVexStatement = {
|
||||
vulnerability: { name: s.cve_id },
|
||||
// Glob image pattern as the product scope; '*' means fleet-wide.
|
||||
products: [s.image_pattern ?? '*'],
|
||||
status,
|
||||
timestamp,
|
||||
};
|
||||
// OpenVEX requires a justification (or impact statement) for not_affected.
|
||||
if (status === 'not_affected') {
|
||||
stmt.justification = s.justification ?? 'vulnerable_code_not_present';
|
||||
}
|
||||
// An accepted risk is "affected" with the operator's reason as the action.
|
||||
if (status === 'affected' && s.reason) {
|
||||
stmt.action_statement = s.reason;
|
||||
}
|
||||
return stmt;
|
||||
});
|
||||
return {
|
||||
'@context': 'https://openvex.dev/ns/v0.2.0',
|
||||
'@id': `https://sencho.io/vex/${timestamp}`,
|
||||
author,
|
||||
timestamp,
|
||||
version: 1,
|
||||
statements,
|
||||
};
|
||||
}
|
||||
@@ -72,12 +72,18 @@ function diag(msg: string, ...args: unknown[]): void {
|
||||
interface TrivyRawVulnerability {
|
||||
VulnerabilityID?: string;
|
||||
PkgName?: string;
|
||||
PkgPath?: string;
|
||||
PkgIdentifier?: { PURL?: string };
|
||||
InstalledVersion?: string;
|
||||
FixedVersion?: string;
|
||||
Status?: string;
|
||||
Severity?: string;
|
||||
Title?: string;
|
||||
Description?: string;
|
||||
PrimaryURL?: string;
|
||||
Layer?: { Digest?: string; DiffID?: string };
|
||||
VendorSeverity?: Record<string, number>;
|
||||
CVSS?: Record<string, { V3Vector?: string; V3Score?: number }>;
|
||||
}
|
||||
|
||||
interface TrivyRawSecret {
|
||||
@@ -178,6 +184,18 @@ export interface TrivyVulnerability {
|
||||
title: string;
|
||||
description: string;
|
||||
primaryUrl: string | null;
|
||||
// Scan-intrinsic enrichment Trivy returns per finding. These separate scary
|
||||
// from exploitable: `status` (fixed / will_not_fix / end_of_life / ...) drives
|
||||
// posture, the others power evidence tags. Captured here, joined with
|
||||
// time-varying intel (KEV/EPSS) only at read time.
|
||||
status: string | null;
|
||||
cvssScore: number | null;
|
||||
cvssVector: string | null;
|
||||
cvssSource: string | null;
|
||||
vendorSeverity: VulnSeverity | null;
|
||||
purl: string | null;
|
||||
pkgPath: string | null;
|
||||
layerDigest: string | null;
|
||||
}
|
||||
|
||||
export interface TrivySecret {
|
||||
@@ -274,6 +292,37 @@ function normalizeSeverity(raw: string | undefined): VulnSeverity {
|
||||
return 'UNKNOWN';
|
||||
}
|
||||
|
||||
// Trivy reports CVSS keyed by source (nvd, redhat, ...). Prefer NVD, else the
|
||||
// first available source. Returns nulls when no V3 score/vector is present.
|
||||
function pickCvss(
|
||||
cvss: Record<string, { V3Vector?: string; V3Score?: number }> | undefined,
|
||||
): { score: number | null; vector: string | null; source: string | null } {
|
||||
if (!cvss) return { score: null, vector: null, source: null };
|
||||
const source = cvss.nvd ? 'nvd' : Object.keys(cvss)[0];
|
||||
const entry = source ? cvss[source] : undefined;
|
||||
if (!entry || (typeof entry.V3Score !== 'number' && !entry.V3Vector)) {
|
||||
return { score: null, vector: null, source: null };
|
||||
}
|
||||
return {
|
||||
score: typeof entry.V3Score === 'number' ? entry.V3Score : null,
|
||||
vector: entry.V3Vector ?? null,
|
||||
source: source ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// Trivy's VendorSeverity is a vendor->numeric map (1=Low..4=Critical). Collapse
|
||||
// to the highest vendor rating as a label so the UI can flag a vendor that rates
|
||||
// a finding differently from NVD.
|
||||
const VENDOR_SEVERITY_LABEL: Record<number, VulnSeverity> = { 1: 'LOW', 2: 'MEDIUM', 3: 'HIGH', 4: 'CRITICAL' };
|
||||
function pickVendorSeverity(map: Record<string, number> | undefined): VulnSeverity | null {
|
||||
if (!map) return null;
|
||||
let max = 0;
|
||||
for (const v of Object.values(map)) {
|
||||
if (typeof v === 'number' && v > max) max = v;
|
||||
}
|
||||
return VENDOR_SEVERITY_LABEL[max] ?? null;
|
||||
}
|
||||
|
||||
function computeHighestSeverity(vulns: TrivyVulnerability[]): VulnSeverity | null {
|
||||
if (vulns.length === 0) return null;
|
||||
let highestIdx = -1;
|
||||
@@ -310,6 +359,7 @@ export function parseTrivyOutput(raw: string): {
|
||||
const key = `${id}::${pkg}`;
|
||||
if (vulnSeen.has(key)) continue;
|
||||
vulnSeen.add(key);
|
||||
const cvss = pickCvss(v.CVSS);
|
||||
vulnerabilities.push({
|
||||
vulnerabilityId: id,
|
||||
pkgName: pkg,
|
||||
@@ -319,6 +369,14 @@ export function parseTrivyOutput(raw: string): {
|
||||
title: v.Title ?? '',
|
||||
description: v.Description ?? '',
|
||||
primaryUrl: v.PrimaryURL ? v.PrimaryURL : null,
|
||||
status: v.Status ? v.Status : null,
|
||||
cvssScore: cvss.score,
|
||||
cvssVector: cvss.vector,
|
||||
cvssSource: cvss.source,
|
||||
vendorSeverity: pickVendorSeverity(v.VendorSeverity),
|
||||
purl: v.PkgIdentifier?.PURL ?? null,
|
||||
pkgPath: v.PkgPath ? v.PkgPath : null,
|
||||
layerDigest: v.Layer?.Digest ?? v.Layer?.DiffID ?? null,
|
||||
});
|
||||
}
|
||||
for (const s of result.Secrets ?? []) {
|
||||
@@ -615,6 +673,14 @@ class TrivyService {
|
||||
title: d.title ?? '',
|
||||
description: d.description ?? '',
|
||||
primaryUrl: d.primary_url,
|
||||
status: d.status ?? null,
|
||||
cvssScore: d.cvss_score ?? null,
|
||||
cvssVector: d.cvss_vector ?? null,
|
||||
cvssSource: d.cvss_source ?? null,
|
||||
vendorSeverity: d.vendor_severity ?? null,
|
||||
purl: d.purl ?? null,
|
||||
pkgPath: d.pkg_path ?? null,
|
||||
layerDigest: d.layer_digest ?? null,
|
||||
})),
|
||||
secrets: cachedSecrets.map((s) => ({
|
||||
ruleId: s.rule_id,
|
||||
@@ -809,6 +875,14 @@ class TrivyService {
|
||||
title: v.title || null,
|
||||
description: v.description || null,
|
||||
primary_url: v.primaryUrl,
|
||||
status: v.status,
|
||||
cvss_score: v.cvssScore,
|
||||
cvss_vector: v.cvssVector,
|
||||
cvss_source: v.cvssSource,
|
||||
vendor_severity: v.vendorSeverity,
|
||||
purl: v.purl,
|
||||
pkg_path: v.pkgPath,
|
||||
layer_digest: v.layerDigest,
|
||||
})),
|
||||
);
|
||||
db.insertSecretFindings(
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Single source of truth for the Security page's action posture.
|
||||
*
|
||||
* The overview route gathers the facts (suppression-, acknowledgement-, and
|
||||
* intel-aware) and this function buckets them into one of four product verbs.
|
||||
* Keeping the bucketing here, separate from storage, means copy or threshold
|
||||
* changes never require a schema migration, and the same verdict can be reused
|
||||
* by other surfaces (action queue, per-stack blast radius).
|
||||
*
|
||||
* Posture is deliberately NOT raw severity: a page is never "Secure" merely
|
||||
* because counts are zero-weighted, and never "Action needed" merely because a
|
||||
* Critical exists with nothing to do about it. "Secure" means nothing is
|
||||
* actionable right now, not a claim that no vulnerabilities exist.
|
||||
*/
|
||||
export type SecurityPostureState = 'Action needed' | 'Monitoring' | 'Secure' | 'Unknown';
|
||||
|
||||
export interface SecurityPostureFacts {
|
||||
/** The scanner is installed and usable on this node. */
|
||||
scannerAvailable: boolean;
|
||||
/** At least one scan has completed (a freshly installed node has none). */
|
||||
hasCompletedScan: boolean;
|
||||
/** Critical/High findings with a fix available, net of suppressions. */
|
||||
fixableCriticalHigh: number;
|
||||
/** Detected secrets (not suppressible in the current model). */
|
||||
secrets: number;
|
||||
/** High-severity Compose misconfigurations, net of acknowledgements. */
|
||||
dangerousCompose: number;
|
||||
/** Known-exploited (CISA KEV) findings among non-suppressed Critical/High. */
|
||||
knownExploited: number;
|
||||
/** Affected services published to a non-loopback address. */
|
||||
publiclyExposed: number;
|
||||
/** Raw Critical scanner detections (for the Monitoring fallback). */
|
||||
rawCritical: number;
|
||||
/** Raw High scanner detections (for the Monitoring fallback). */
|
||||
rawHigh: number;
|
||||
}
|
||||
|
||||
export function deriveSecurityPosture(f: SecurityPostureFacts): SecurityPostureState {
|
||||
if (!f.scannerAvailable || !f.hasCompletedScan) return 'Unknown';
|
||||
if (
|
||||
f.fixableCriticalHigh > 0
|
||||
|| f.secrets > 0
|
||||
|| f.dangerousCompose > 0
|
||||
|| f.knownExploited > 0
|
||||
|| f.publiclyExposed > 0
|
||||
) {
|
||||
return 'Action needed';
|
||||
}
|
||||
if (f.rawCritical > 0 || f.rawHigh > 0) return 'Monitoring';
|
||||
return 'Secure';
|
||||
}
|
||||
@@ -12,10 +12,41 @@
|
||||
*/
|
||||
import type { CveSuppression } from '../services/DatabaseService';
|
||||
|
||||
/** Triage decision states layered on top of a suppression row. `accepted` is the
|
||||
* back-compat default for rows created before triage existed. */
|
||||
export const TRIAGE_STATUSES = [
|
||||
'needs_review', 'affected', 'not_affected', 'accepted', 'fixed', 'false_positive', 'ignored',
|
||||
] as const;
|
||||
export type TriageStatus = typeof TRIAGE_STATUSES[number];
|
||||
|
||||
/** Statuses that DISMISS a finding from the actionable posture (a decision was
|
||||
* made not to act). `needs_review` and `affected` are NOT dismissing: the
|
||||
* finding stays actionable and surfaces as a count. */
|
||||
export const DISMISSING_STATUSES: ReadonlySet<TriageStatus> = new Set([
|
||||
'not_affected', 'accepted', 'fixed', 'false_positive', 'ignored',
|
||||
]);
|
||||
|
||||
/** Optional OpenVEX-aligned justification taxonomy (never required). */
|
||||
export const TRIAGE_JUSTIFICATIONS = [
|
||||
'vulnerable_code_not_in_execute_path', 'vulnerable_code_not_present',
|
||||
'component_not_present', 'inline_mitigations_already_exist',
|
||||
] as const;
|
||||
export type TriageJustification = typeof TRIAGE_JUSTIFICATIONS[number];
|
||||
|
||||
export function isTriageStatus(v: unknown): v is TriageStatus {
|
||||
return typeof v === 'string' && (TRIAGE_STATUSES as readonly string[]).includes(v);
|
||||
}
|
||||
export function isTriageJustification(v: unknown): v is TriageJustification {
|
||||
return typeof v === 'string' && (TRIAGE_JUSTIFICATIONS as readonly string[]).includes(v);
|
||||
}
|
||||
|
||||
export interface SuppressionDecision {
|
||||
/** True only when the matched decision DISMISSES the finding (see DISMISSING_STATUSES). */
|
||||
suppressed: boolean;
|
||||
suppression_id?: number;
|
||||
suppression_reason?: string;
|
||||
triage_status?: TriageStatus;
|
||||
triage_justification?: string | null;
|
||||
}
|
||||
|
||||
export interface SuppressibleFinding {
|
||||
@@ -113,11 +144,14 @@ export function applySuppressions<T extends SuppressibleFinding>(
|
||||
const bucket = buckets.get(f.vulnerability_id);
|
||||
const match = bucket ? pickFromBucket(bucket, f, imageRef, now) : null;
|
||||
if (!match) return { ...f, suppressed: false };
|
||||
const status = (isTriageStatus(match.status) ? match.status : 'accepted');
|
||||
return {
|
||||
...f,
|
||||
suppressed: true,
|
||||
suppressed: DISMISSING_STATUSES.has(status),
|
||||
suppression_id: match.id,
|
||||
suppression_reason: match.reason,
|
||||
triage_status: status,
|
||||
triage_justification: match.justification ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user