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:
Anso
2026-06-23 17:42:11 -04:00
committed by GitHub
parent 4c47c47a27
commit f794702171
29 changed files with 1685 additions and 72 deletions
@@ -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);
});
+4
View File
@@ -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);
}
+2
View File
@@ -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
+171 -3
View File
@@ -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) {
+148
View File
@@ -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;
+243 -9
View File
@@ -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,
);
}
});
+3
View File
@@ -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) => ({
+74
View File
@@ -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,
};
}
+74
View File
@@ -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(
+51
View File
@@ -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';
}
+35 -1
View File
@@ -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,
};
});
}