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