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,
};
});
}
+3
View File
@@ -32,6 +32,7 @@ The dialog has the following fields:
| Field | Description |
|-------|-------------|
| **CVE or advisory ID** | Required. Accepts both `CVE-YYYY-NNNN` and `GHSA-xxxx-xxxx-xxxx`. |
| **Triage decision** | How the finding was triaged: accepted risk (default), not affected, false positive, fixed, ignored, or needs review, with an optional OpenVEX justification. A decided state (accepted, not affected, false positive, fixed, ignored) stops the finding from driving the action posture; "needs review" keeps it counted but still actionable. |
| **Package (optional)** | Leave blank to suppress every occurrence of this CVE across every package, or pin a specific package name (e.g. `openssl`) to narrow the scope. |
| **Image pattern (optional)** | Glob applied to image references (`*` matches any sequence, case-sensitive). For example, `lscr.io/linuxserver/*` matches every LinuxServer image, and `*alpine*` matches anything containing `alpine`. Leave blank to apply fleet-wide. |
| **Reason** | Required. A short note explaining why the CVE is accepted. Surfaced on every suppressed row and on the hover title in scan results. Do not paste credentials, tokens, or vendor secrets, since reasons replicate fleet-wide. |
@@ -94,6 +95,8 @@ To change a suppression's scope (for example, to narrow an image pattern or exte
Suppressed findings carry through to the [SARIF export](/features/vulnerability-scanning#sarif-export) with a SARIF `suppressions` entry of `kind: external` and `status: accepted`. Code-scanning dashboards that respect SARIF suppressions dismiss those findings with the Reason you recorded.
Admiral instances can also export the full set of triage decisions as an **OpenVEX** document from the Suppressions panel. Each decision becomes a VEX statement (`not_affected`, `fixed`, `affected`, or `under_investigation`) with its justification, for use with VEX-aware scanners and supply-chain tooling.
## Troubleshooting
<AccordionGroup>
+31 -8
View File
@@ -12,12 +12,24 @@ The page is organized into tabs.
## Overview
The overview opens with a status masthead that reads your worst active severity at a glance,
**Secure**, **At risk**, or **Critical**, alongside the critical and high counts and the time of the
last successful scan. Below it, a signal rail summarizes the supporting numbers: scanned images,
fixable findings, secrets, Compose misconfigurations, stale scans, and failed scans. A status strip
shows scanner health (installed source and version, auto-update) and the active node's deploy
enforcement posture.
The overview opens with a status masthead that reads your **action posture** at a glance, the answer
to "what can and should I do right now?":
- **Action needed**: something concrete to act on, such as a fixable Critical or High finding, a
detected secret, a dangerous Compose setting, or a known-exploited (CISA KEV) CVE.
- **Monitoring**: Critical or High findings exist, but none are currently actionable (no fix
available, or already triaged).
- **Secure**: nothing actionable right now. This is never a claim that no vulnerabilities exist.
- **Unknown**: the scanner is not installed, or no scan has completed yet.
Raw Critical and High counts stay visible next to the posture as **scanner detections**, not as the
posture itself: a vulnerable component being present is not the same as a reachable, exploitable risk.
The masthead carries a standing note to that effect, and posture weighs fix availability, exploit
intelligence, and triage decisions rather than raw severity alone.
Below it, a signal rail summarizes the supporting numbers: scanned images, fixable findings, secrets,
Compose misconfigurations, stale scans, and failed scans. A status strip shows scanner health
(installed source and version, auto-update) and the active node's deploy enforcement posture.
If a node does not report an overview (for example an older remote node), the page falls back to a
clear "overview unavailable" state and the other tabs keep working.
@@ -25,8 +37,10 @@ clear "overview unavailable" state and the other tabs keep working.
## Images
Image findings list every scanned image on the active node with its highest severity. Selecting an
image opens the full scan report, where you can review vulnerabilities, suppress a CVE, and export an
SBOM.
image opens the full scan report, where you can review vulnerabilities, triage a CVE, and export an
SBOM. Each finding carries evidence tags so you can tell scary from exploitable at a glance:
known-exploited (KEV), EPSS exploitation probability, the CVSS score, and vendor "will not fix"
status, alongside whether a fix is available.
## Compose risks
@@ -54,6 +68,11 @@ CVE suppressions and misconfiguration acknowledgements are managed here, the sam
in Settings. These are governed by the local instance, so this tab is shown when you are on the local
node; switch to the local node to manage them.
Suppressing a CVE records a **triage decision**: accepted risk, not affected, false positive, fixed,
ignored, or needs review, with an optional OpenVEX justification. Decided findings stop driving the
action posture; a "needs review" decision stays counted but keeps the finding actionable. Admiral
instances can export the fleet's triage decisions as an OpenVEX document for use with other tooling.
## History
The History tab opens the scan history sheet, listing completed scans grouped by image with search and
@@ -66,6 +85,10 @@ Scanner setup manages the Trivy binary for the active node: install the managed
a new release is available, and toggle automatic updates. Trivy is installed independently on each
node. See [Installing Trivy](/operations/trivy-setup) for the full setup options.
It also toggles **exploit intelligence**: a daily background fetch of the CISA KEV catalog and FIRST
EPSS scores that powers the known-exploited and EPSS evidence tags. It degrades gracefully when
offline and can be turned off for air-gapped or firewalled hosts.
## What stays where
Resources keeps its per-image severity badges and scan actions, so you can still scan and inspect an
+5 -2
View File
@@ -5,7 +5,7 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from '@/components/ui/tabs';
import { PageMasthead, type MastheadTone } from '@/components/ui/PageMasthead';
import { CapabilityGate } from '@/components/CapabilityGate';
import { deriveMasthead } from './security/securityMasthead';
import { deriveMasthead, SCANNER_DETECTIONS_NOTE } from './security/securityMasthead';
import { springs } from '@/lib/motion';
import { apiFetch } from '@/lib/api';
import { formatTimeAgo } from '@/lib/relativeTime';
@@ -334,7 +334,7 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
tone={tone}
pulsing={pulsing}
size="hero"
className="rounded-lg mb-4"
className="rounded-lg mb-2"
subtitle={subtitle}
metadata={overview ? [
{ label: 'CRITICAL', value: String(overview.critical), tone: overview.critical > 0 ? 'error' : 'value' },
@@ -342,6 +342,9 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
{ label: 'LAST SCAN', value: overview.lastSuccessfulScanAt ? formatTimeAgo(overview.lastSuccessfulScanAt) : 'never', tone: 'subtitle' },
] : undefined}
/>
<p className="mb-4 max-w-3xl font-mono text-[11px] leading-snug text-stat-subtitle">
{SCANNER_DETECTIONS_NOTE}
</p>
<Tabs value={activeTab} onValueChange={(v) => onTabChange(v as SecurityTab)}>
<TabsList className="mb-4">
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { type ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
import { SystemSheet, SheetSection } from '@/components/ui/system-sheet';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Button } from '@/components/ui/button';
@@ -53,8 +53,20 @@ import type {
SecretFinding,
MisconfigFinding,
ScanDetailTab,
TriageStatus,
} from '@/types/security';
// Triage decision options for the suppress dialog (value -> label). 'accepted'
// is the default: a plain suppress is an accepted risk.
const TRIAGE_STATUS_OPTIONS: ReadonlyArray<{ value: TriageStatus; label: string }> = [
{ value: 'accepted', label: 'Accepted risk' },
{ value: 'not_affected', label: 'Not affected' },
{ value: 'false_positive', label: 'False positive' },
{ value: 'needs_review', label: 'Needs review' },
{ value: 'fixed', label: 'Fixed' },
{ value: 'ignored', label: 'Ignored until expiry' },
];
interface VulnerabilityScanSheetProps {
scanId: number | null;
onClose: () => void;
@@ -78,6 +90,7 @@ interface SuppressDialogState {
imagePattern: string;
reason: string;
expiresInDays: string;
status: TriageStatus;
}
interface AckDialogState {
@@ -115,6 +128,47 @@ function SeverityChip({ severity }: { severity: VulnSeverity }) {
);
}
const EVIDENCE_TAG_CLASSES = {
danger: 'text-destructive border-destructive/40 bg-destructive/10',
warn: 'text-warning border-warning/40 bg-warning/10',
muted: 'text-stat-subtitle border-border bg-muted/30',
neutral: 'text-stat-value border-border bg-muted/20',
} as const;
function EvidenceTag({ tone, children }: { tone: keyof typeof EVIDENCE_TAG_CLASSES; children: ReactNode }) {
return (
<span className={cn('inline-flex items-center rounded border px-1.5 py-px text-[9px] font-mono uppercase tracking-[0.1em]', EVIDENCE_TAG_CLASSES[tone])}>
{children}
</span>
);
}
/**
* Small, independently-verifiable evidence atoms per finding. Severity is one
* signal among several, not the only one: these surface exploit intel (KEV,
* EPSS), vendor status, and the CVSS score so an operator can tell scary from
* exploitable without an invented composite priority number.
*/
function EvidenceTags({ d }: { d: VulnerabilityDetail }) {
const tags: ReactNode[] = [];
if (d.kev) tags.push(<EvidenceTag key="kev" tone="danger">KEV</EvidenceTag>);
if (typeof d.epss_score === 'number') {
tags.push(
<EvidenceTag key="epss" tone={d.epss_score >= 0.1 ? 'warn' : 'muted'}>
EPSS {Math.round(d.epss_score * 100)}%
</EvidenceTag>,
);
}
if (d.status === 'will_not_fix' || d.status === 'end_of_life') {
tags.push(<EvidenceTag key="wontfix" tone="muted">{"Won't fix"}</EvidenceTag>);
}
if (typeof d.cvss_score === 'number') {
tags.push(<EvidenceTag key="cvss" tone="neutral">CVSS {d.cvss_score}</EvidenceTag>);
}
if (tags.length === 0) return null;
return <span className="mt-1 flex flex-wrap items-center gap-1">{tags}</span>;
}
export function VulnerabilityScanSheet({
scanId,
onClose,
@@ -319,6 +373,7 @@ export function VulnerabilityScanSheet({
imagePattern: '',
reason: '',
expiresInDays: '',
status: 'accepted',
});
}, []);
@@ -350,6 +405,7 @@ export function VulnerabilityScanSheet({
image_pattern: suppressForm.imagePattern.trim() || null,
reason,
expires_at: expiresAt,
status: suppressForm.status,
}),
});
if (!res.ok) {
@@ -737,28 +793,31 @@ export function VulnerabilityScanSheet({
key={d.id}
className={cn(SEVERITY_ROW_TINT[d.severity], d.suppressed && 'opacity-60')}
>
<TableCell className="font-mono text-xs tabular-nums">
<span className="inline-flex items-center gap-1.5">
{d.suppressed && (
<ShieldOff
className="w-3 h-3 text-muted-foreground"
strokeWidth={1.5}
aria-label="Suppressed"
/>
)}
{href ? (
<a
href={href}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-1 hover:underline"
>
{d.vulnerability_id}
<ExternalLink className="w-3 h-3" strokeWidth={1.5} />
</a>
) : (
d.vulnerability_id
)}
<TableCell className="font-mono text-xs tabular-nums align-top">
<span className="flex flex-col">
<span className="inline-flex items-center gap-1.5">
{d.suppressed && (
<ShieldOff
className="w-3 h-3 text-muted-foreground"
strokeWidth={1.5}
aria-label="Suppressed"
/>
)}
{href ? (
<a
href={href}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-1 hover:underline"
>
{d.vulnerability_id}
<ExternalLink className="w-3 h-3" strokeWidth={1.5} />
</a>
) : (
d.vulnerability_id
)}
</span>
<EvidenceTags d={d} />
</span>
</TableCell>
<TableCell
@@ -1070,6 +1129,24 @@ export function VulnerabilityScanSheet({
Glob pattern matched against the image reference. Leave blank to suppress this CVE on any image.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="suppress-status">Triage decision</Label>
<select
id="suppress-status"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={suppressForm.status}
onChange={(e) =>
setSuppressForm((f) => (f ? { ...f, status: e.target.value as TriageStatus } : f))
}
>
{TRIAGE_STATUS_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<p className="text-xs text-muted-foreground">
How this finding was triaged. Decided states (accepted, not affected, false positive, fixed, ignored) stop driving the posture; needs review stays counted but actionable.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="suppress-reason">Reason</Label>
<textarea
@@ -5,6 +5,7 @@ import { cn } from '@/lib/utils';
import { formatTimeAgo } from '@/lib/relativeTime';
import { useIsMobile } from '@/hooks/use-is-mobile';
import { SecuritySevStrip, SecurityTotalsGrid, SecurityFooterBand } from './SecurityMobile';
import { SCANNER_DETECTIONS_NOTE } from './securityMasthead';
import type { SecurityOverview, ScanSummary, SecurityRiskTrendPoint } from '@/types/security';
import type { SecurityTab } from '@/lib/events';
import {
@@ -124,8 +125,14 @@ export function OverviewTab({ overview, loadError, summaries, trend, onNavigate,
)
)}
{/* The masthead hides its stat cluster on a phone; restate it here. */}
{isMobile && <SecuritySevStrip overview={overview} />}
{/* The masthead hides its stat cluster on a phone; restate it here, framed
as scanner detections rather than posture. */}
{isMobile && (
<div className="space-y-2">
<SecuritySevStrip overview={overview} />
<p className="font-mono text-[10px] leading-snug text-stat-subtitle">{SCANNER_DETECTIONS_NOTE}</p>
</div>
)}
{/* Charts lead the dashboard. */}
<div className="grid gap-4 lg:grid-cols-3">
@@ -46,7 +46,7 @@ interface TrivyManagerProps {
*/
export function TrivyManager({ status, updateCheck, refresh, refreshUpdateCheck }: TrivyManagerProps) {
const { isAdmin } = useAuth();
const [trivyBusy, setTrivyBusy] = useState<null | 'install' | 'update' | 'uninstall' | 'auto-update' | 'advisory'>(null);
const [trivyBusy, setTrivyBusy] = useState<null | 'install' | 'update' | 'uninstall' | 'auto-update' | 'advisory' | 'cve-intel'>(null);
const [uninstallConfirm, setUninstallConfirm] = useState(false);
const runTrivyOp = async (
@@ -118,6 +118,25 @@ export function TrivyManager({ status, updateCheck, refresh, refreshUpdateCheck
}
};
const handleCveIntelToggle = async (enabled: boolean) => {
setTrivyBusy('cve-intel');
try {
const res = await apiFetch('/security/cve-intel-enabled', {
method: 'PUT',
body: JSON.stringify({ enabled }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.error || 'Failed to update setting');
}
await refresh();
} catch (err) {
toast.error((err as Error)?.message || 'Failed to update setting');
} finally {
setTrivyBusy(null);
}
};
return (
<>
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4 space-y-3">
@@ -207,6 +226,22 @@ export function TrivyManager({ status, updateCheck, refresh, refreshUpdateCheck
/>
</div>
)}
{isAdmin && (
<div className="flex items-center justify-between rounded-lg border border-glass-border px-3 py-2.5">
<div>
<Label className="text-sm">Exploit intelligence (KEV + EPSS)</Label>
<p className="text-xs text-muted-foreground">
Fetch CISA Known Exploited Vulnerabilities and EPSS scores daily to prioritize findings. Reaches cisa.gov and api.first.org; turn off for air-gapped hosts.
</p>
</div>
<TogglePill
checked={status.cveIntelEnabled}
onChange={handleCveIntelToggle}
disabled={trivyBusy !== null}
/>
</div>
)}
</div>
<ConfirmModal
@@ -34,7 +34,7 @@ function setup({ isPaid }: { isPaid: boolean }) {
vi.mocked(AuthContext.useAuth).mockReturnValue({ isAdmin: true } as unknown as ReturnType<typeof AuthContext.useAuth>);
vi.mocked(NodeContext.useNodes).mockReturnValue({ activeNode: { type: 'local', id: 1, name: 'local' } } as unknown as ReturnType<typeof NodeContext.useNodes>);
vi.mocked(TrivyStatus.useTrivyStatus).mockReturnValue({
status: { available: true, version: '1', source: 'managed', autoUpdate: false, honorSuppressionsOnDeploy: false, preDeployScanAdvisory: false, busy: false },
status: { available: true, version: '1', source: 'managed', autoUpdate: false, honorSuppressionsOnDeploy: false, preDeployScanAdvisory: false, cveIntelEnabled: true, busy: false },
updateCheck: null,
refresh: vi.fn().mockResolvedValue(undefined),
refreshUpdateCheck: vi.fn().mockResolvedValue(undefined),
@@ -1,6 +1,9 @@
/**
* The Security masthead state word is the headline posture signal an operator
* reads first, so its derivation is locked here. Critical must beat High.
* reads first, so its derivation is locked here. Posture is an action verdict
* (Action needed / Monitoring / Secure / Unknown), not a raw severity count: a
* page is never "Secure" merely because counts are non-zero, and never "Action
* needed" merely because a Critical exists with nothing to do about it.
*/
import { it, expect } from 'vitest';
import { deriveMasthead } from '../securityMasthead';
@@ -8,7 +11,7 @@ import type { SecurityOverview } from '@/types/security';
function overview(o: Partial<SecurityOverview>): SecurityOverview {
return {
scannedImages: 0,
scannedImages: 1,
critical: 0,
high: 0,
fixable: 0,
@@ -16,7 +19,8 @@ function overview(o: Partial<SecurityOverview>): SecurityOverview {
misconfigs: 0,
staleScans: 0,
failedScans: 0,
lastSuccessfulScanAt: null,
// Default to "has completed a scan" so cases exercise posture, not Unknown.
lastSuccessfulScanAt: 1700000000000,
scanner: { available: true, version: '1', source: 'managed', autoUpdate: false },
deployEnforcement: { honorSuppressionsOnDeploy: false, eligibleBlockPolicies: 0 },
...o,
@@ -28,14 +32,51 @@ it('reads Unknown/idle when there is no overview or a load error', () => {
expect(deriveMasthead(overview({ critical: 5 }), true)).toEqual({ state: 'Unknown', tone: 'idle' });
});
it('reads Critical/error when any critical finding exists (critical wins over high)', () => {
expect(deriveMasthead(overview({ critical: 1, high: 9 }), false)).toEqual({ state: 'Critical', tone: 'error' });
it('reads Unknown when the scanner is unavailable, even with no findings', () => {
expect(
deriveMasthead(overview({ scanner: { available: false, version: null, source: 'none', autoUpdate: false } }), false),
).toEqual({ state: 'Unknown', tone: 'idle' });
});
it('reads At risk/warn when there are highs but no criticals', () => {
expect(deriveMasthead(overview({ critical: 0, high: 2 }), false)).toEqual({ state: 'At risk', tone: 'warn' });
it('reads Unknown when no scan has ever completed', () => {
expect(deriveMasthead(overview({ lastSuccessfulScanAt: null }), false)).toEqual({ state: 'Unknown', tone: 'idle' });
});
it('reads Secure/live when there are no critical or high findings', () => {
expect(deriveMasthead(overview({ critical: 0, high: 0 }), false)).toEqual({ state: 'Secure', tone: 'live' });
it('reads Action needed/error when a fix is available (even if counts look severe)', () => {
expect(deriveMasthead(overview({ critical: 9, high: 9, fixable: 1 }), false)).toEqual({
state: 'Action needed',
tone: 'error',
});
});
it('reads Action needed when a secret is detected', () => {
expect(deriveMasthead(overview({ secrets: 1 }), false)).toEqual({ state: 'Action needed', tone: 'error' });
});
it('reads Action needed when a misconfiguration is detected', () => {
expect(deriveMasthead(overview({ misconfigs: 1 }), false)).toEqual({ state: 'Action needed', tone: 'error' });
});
it('reads Monitoring/warn when criticals/highs exist but nothing is actionable', () => {
expect(deriveMasthead(overview({ critical: 3, high: 7, fixable: 0 }), false)).toEqual({
state: 'Monitoring',
tone: 'warn',
});
});
it('reads Secure/live when a scan completed and nothing is actionable or severe', () => {
expect(deriveMasthead(overview({}), false)).toEqual({ state: 'Secure', tone: 'live' });
});
it('prefers the backend posture over the local bootstrap when present', () => {
// Bootstrap from these facts would read Action needed (fixable > 0); the
// authoritative backend verdict wins.
expect(deriveMasthead(overview({ fixable: 5, posture: 'Monitoring' }), false)).toEqual({
state: 'Monitoring',
tone: 'warn',
});
});
it('falls back to the local bootstrap when the node reports no posture', () => {
expect(deriveMasthead(overview({ fixable: 1 }), false)).toEqual({ state: 'Action needed', tone: 'error' });
});
@@ -1,16 +1,46 @@
import type { MastheadTone } from '@/components/ui/PageMasthead';
import type { SecurityOverview } from '@/types/security';
import type { SecurityOverview, SecurityPostureState } from '@/types/security';
export type SecurityPosture = SecurityPostureState;
const POSTURE_TONE: Record<SecurityPosture, MastheadTone> = {
'Action needed': 'error',
Monitoring: 'warn',
Secure: 'live',
Unknown: 'idle',
};
/** Standing reframe shown near the masthead: raw counts are scanner detections,
* not the product posture. Kept short enough for a one-to-two-line caption. */
export const SCANNER_DETECTIONS_NOTE =
'Scanner detections show vulnerable components present in images, not proven exploitable risk. Posture weighs fix availability, exposure, and exploit intelligence.';
/**
* Derives the Security page masthead state word and tone from the overview.
* Critical outranks High; an absent overview or a load error reads as Unknown.
* Derives the Security masthead from action posture, not raw severity. Raw
* Critical/High counts are scanner detections shown separately; they no longer
* decide the headline. "Secure" means nothing is actionable right now, never a
* claim that no vulnerabilities exist.
*
* The backend computes the authoritative `posture` (one bucketing function), so
* this prefers `overview.posture` when present. The local bootstrap below is the
* fallback for an older remote node reached through the proxy that does not
* report posture: "actionable" is approximated from the overview facts that
* already exist (fixable findings, secrets, misconfigs); Unknown covers a
* missing scanner or a node that has never completed a scan.
*/
export function deriveMasthead(
overview: SecurityOverview | null,
error: boolean,
): { state: string; tone: MastheadTone } {
if (error || !overview) return { state: 'Unknown', tone: 'idle' };
if (overview.critical > 0) return { state: 'Critical', tone: 'error' };
if (overview.high > 0) return { state: 'At risk', tone: 'warn' };
return { state: 'Secure', tone: 'live' };
): { state: SecurityPosture; tone: MastheadTone } {
const posture = resolvePosture(overview, error);
return { state: posture, tone: POSTURE_TONE[posture] };
}
function resolvePosture(overview: SecurityOverview | null, error: boolean): SecurityPosture {
if (error || !overview) return 'Unknown';
if (overview.posture && overview.posture in POSTURE_TONE) return overview.posture;
if (!overview.scanner.available || overview.lastSuccessfulScanAt === null) return 'Unknown';
if (overview.fixable > 0 || overview.secrets > 0 || overview.misconfigs > 0) return 'Action needed';
if (overview.critical > 0 || overview.high > 0) return 'Monitoring';
return 'Secure';
}
@@ -6,12 +6,13 @@ import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Modal, ModalHeader, ModalBody, ModalFooter, ConfirmModal } from '@/components/ui/modal';
import { ChevronLeft, ChevronRight, Plus, Trash2 } from 'lucide-react';
import { ChevronLeft, ChevronRight, Plus, Trash2, Download } from 'lucide-react';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { FleetTabHeading } from '@/components/fleet/FleetEmptyState';
import type { CveSuppression } from '@/types/security';
import { useAuth } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
const CVE_ID_RE = /^(CVE-\d{4}-\d{4,}|GHSA-[\w-]{14,})$/;
const PAGE_SIZE = 8;
@@ -38,6 +39,7 @@ interface SuppressionsPanelProps {
export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
const { isAdmin } = useAuth();
const { isPaid } = useLicense();
const [rows, setRows] = useState<CveSuppression[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
@@ -148,6 +150,24 @@ export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
return d.toLocaleDateString();
};
const handleExportVex = useCallback(async () => {
try {
const res = await apiFetch('/security/vex/export', { localOnly: true });
if (!res.ok) throw new Error('Failed to export VEX');
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'sencho-fleet.openvex.json';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (err) {
toast.error((err as Error)?.message || 'Failed to export VEX');
}
}, []);
return (
<div className="space-y-4">
<FleetTabHeading
@@ -155,10 +175,18 @@ export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
subtitle="Accept known-benign CVEs so they stop triggering alerts. Suppressions apply at read time across the fleet and never modify stored scan data."
action={
isAdmin && !isReplica ? (
<Button size="sm" onClick={openCreate}>
<Plus className="w-4 h-4 mr-1.5" />
Add suppression
</Button>
<div className="flex items-center gap-2">
{isPaid && (
<Button size="sm" variant="outline" onClick={handleExportVex}>
<Download className="w-4 h-4 mr-1.5" />
Export VEX
</Button>
)}
<Button size="sm" onClick={openCreate}>
<Plus className="w-4 h-4 mr-1.5" />
Add suppression
</Button>
</div>
) : undefined
}
/>
@@ -28,6 +28,10 @@ vi.mock('@/context/AuthContext', () => ({
useAuth: () => ({ isAdmin: true }),
}));
vi.mock('@/context/LicenseContext', () => ({
useLicense: () => ({ isPaid: false }),
}));
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { SuppressionsPanel } from '../SuppressionsPanel';
+2
View File
@@ -9,6 +9,7 @@ const INITIAL_STATUS: TrivyStatus = {
autoUpdate: false,
honorSuppressionsOnDeploy: false,
preDeployScanAdvisory: false,
cveIntelEnabled: true,
busy: false,
};
@@ -35,6 +36,7 @@ export function useTrivyStatus(): UseTrivyStatusResult {
autoUpdate: !!d.autoUpdate,
honorSuppressionsOnDeploy: !!d.honorSuppressionsOnDeploy,
preDeployScanAdvisory: !!d.preDeployScanAdvisory,
cveIntelEnabled: d.cveIntelEnabled !== false,
busy: !!d.busy,
});
} catch (err) {
+44
View File
@@ -18,6 +18,8 @@ export interface TrivyStatus {
autoUpdate: boolean;
honorSuppressionsOnDeploy: boolean;
preDeployScanAdvisory: boolean;
/** Outbound CVE exploit-intel (KEV + EPSS) fetch is enabled for this node. */
cveIntelEnabled: boolean;
busy: boolean;
}
@@ -122,11 +124,30 @@ export interface VulnerabilityDetail {
title: string | null;
description: string | null;
primary_url: string | null;
// Scan-intrinsic enrichment (nullable; absent on older scans). Drives the
// CVSS chip and evidence tags. `status`: fixed / will_not_fix / end_of_life.
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;
// Read-time exploit intel join (KEV / EPSS), attached by the vulnerabilities
// endpoint. Optional: absent until the intel cache has populated.
kev?: boolean;
epss_score?: number | null;
epss_percentile?: number | null;
suppressed?: boolean;
suppression_id?: number;
suppression_reason?: string;
}
/** Triage decision states (mirrors the backend TriageStatus). */
export type TriageStatus =
| 'needs_review' | 'affected' | 'not_affected' | 'accepted' | 'fixed' | 'false_positive' | 'ignored';
export interface CveSuppression {
id: number;
cve_id: string;
@@ -138,6 +159,8 @@ export interface CveSuppression {
expires_at: number | null;
replicated_from_control: number;
active: boolean;
status?: TriageStatus;
justification?: string | null;
}
export interface ScanSummary {
@@ -194,6 +217,10 @@ export interface ScanCompareResult {
row_limit?: number;
}
/** The Security page's action posture (the masthead verdict). Mirrors the
* backend `SecurityPostureState`. */
export type SecurityPostureState = 'Action needed' | 'Monitoring' | 'Secure' | 'Unknown';
/** Node-scoped security posture rollup for the Security page Overview. */
export interface SecurityOverview {
scannedImages: number;
@@ -216,6 +243,23 @@ export interface SecurityOverview {
/** Approximate count of enabled block-on-deploy policies eligible for this node. */
eligibleBlockPolicies: number;
};
// Posture facts. Optional because an older remote node (reached through the
// proxy) may not report them; the masthead falls back to a local derivation.
// Counts are facts; `posture` is the authoritative derived verb.
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;
}
/** Which detail tab the scan sheet opens on. Matches VulnerabilityScanSheet's tabs. */