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
+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';
}