mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 18:32:52 +00:00
f794702171
* feat(security): reframe masthead as action posture, not worst-CVE severity Derive the Security masthead from an action posture (Action needed / Monitoring / Secure / Unknown) instead of raw scanner severity, and label the raw Critical/High counts as scanner detections. "Secure" now means nothing is actionable right now, never a claim that no vulnerabilities exist; Unknown covers a missing scanner or a node with no completed scan. Phase-1 bootstrap: "actionable" is approximated from the overview facts that already exist (fixable findings, secrets, misconfigs); a later phase moves the bucketing to the backend. * feat(security): derive overview action posture from triaged facts Add deriveSecurityPosture as the single bucketing function and extend /security/overview with posture facts (fixableCriticalHigh, dangerousCompose, accepted, rawCritical/rawHigh, plus knownExploited/publiclyExposed placeholders that later phases populate) and the derived posture verb. Suppression- and acknowledgement-aware counts come from one bounded read-time pass over the latest-scan Critical/High findings, grouped per image so the existing read-time filters apply unchanged. The pass is capped and flags posturePartial, so a large node degrades gracefully instead of scanning every detail row. The masthead now prefers the backend posture and keeps the local bootstrap only as a fallback for older remote nodes reached through the proxy. * feat(security): capture Trivy finding enrichment (status, CVSS, vendor, purl, layer) parseTrivyOutput now keeps the per-finding fields Trivy already returns and we previously discarded: Status (fixed / will_not_fix / end_of_life / ...), CVSS (score + vector, preferring the NVD source then falling back), vendor severity, package URL, package path, and layer digest. Persisted on vulnerability_details via additive nullable columns (guarded ALTER), bound null when absent, and carried through the cached-scan reconstruction path. These fields separate scary from exploitable and feed the action posture and the per-finding evidence tags. Field paths verified against Trivy's documented image-scan JSON; covered by parse and insert/read round-trip tests. * feat(security): add CVE exploit-intel service (CISA KEV + FIRST EPSS) Add CveIntelService, a daily background cache of CISA KEV membership and FIRST EPSS scores stored in a new cve_intel table and joined to findings at read time by CVE id (never frozen onto scan rows, so a CVE entering KEV later lights up on scans already stored). EPSS is fetched only for CVE ids present in stored findings, batched; both feeds are best-effort and keep the last cache on failure, so the Security page degrades gracefully offline. Wired into startup/shutdown like the other background services. The overview now counts known-exploited Critical/High findings, and KEV membership escalates posture to Action needed even when no fix is available. A per-instance "Exploit intelligence" toggle on the scanner setup surface lets air-gapped or firewalled hosts disable the outbound fetch; the daily tick keeps running but skips the fetch body when it is off. * feat(security): show per-finding evidence tags (KEV, EPSS, vendor status, CVSS) The vulnerabilities endpoint joins read-time exploit intel (KEV membership and EPSS score) onto each finding by CVE id, and the scan sheet renders evidence tags beside each CVE: known-exploited, EPSS probability, vendor will-not-fix / end-of-life, and the CVSS score. Severity becomes one signal among several so an operator can tell scary from exploitable, with no invented composite score. * feat(security): evolve CVE suppressions into triage decisions Layer a triage status and optional OpenVEX justification onto CVE suppressions. Statuses: needs review / affected / not affected / accepted risk / fixed / false positive / ignored. Dismissing states (not affected, accepted, fixed, false positive, ignored) stop a finding from driving the action posture; needs review and affected stay actionable and are surfaced as counts. Existing rows default to "accepted" (the prior suppress behavior), so nothing changes for them. The overview now reports needsReview / notAffected / accepted as distinct facts derived from the triage status. The decision replicates across the fleet (snapshot + replicated-insert carry status + justification) so a replica's posture matches the control node. The inline suppress dialog gains a triage decision selector; the read-time filter surfaces the status and justification on every finding. * feat(security): export fleet triage decisions as OpenVEX (Admiral) Add an OpenVEX exporter that turns the instance's CVE triage decisions into a standard VEX document (not_affected / fixed / affected / under_investigation, with justifications), and a GET /security/vex/export endpoint to download it. Authoring fleet VEX is a governance capability, so it is gated to Admiral (paid) plus admin, mirroring the SARIF export gate; the Suppressions panel shows an Export VEX action only on Admiral. * docs(security): document action posture, evidence tags, exploit intel, and triage Update the Security page and CVE suppressions docs for the action-posture masthead (scanner detections vs product posture), per-finding evidence tags (KEV / EPSS / CVSS / vendor status), the exploit-intelligence toggle (CISA KEV + FIRST EPSS) on scanner setup, triage decisions layered on suppressions, and OpenVEX export of fleet triage decisions. * test(security): match intel hosts exactly in CveIntelService test Route the fetch stub and its call assertions by exact hostname (www.cisa.gov / api.first.org) instead of a domain substring check. Resolves the js/incomplete-url-substring-sanitization code-scanning alerts on the test's URL routing; behavior is unchanged.
149 lines
6.2 KiB
TypeScript
149 lines
6.2 KiB
TypeScript
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;
|