diff --git a/backend/src/__tests__/cve-intel-service.test.ts b/backend/src/__tests__/cve-intel-service.test.ts new file mode 100644 index 00000000..55a369c3 --- /dev/null +++ b/backend/src/__tests__/cve-intel-service.test.ts @@ -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 { + 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); + }); +}); diff --git a/backend/src/__tests__/database-scan-list.test.ts b/backend/src/__tests__/database-scan-list.test.ts index 764051ed..4bced822 100644 --- a/backend/src/__tests__/database-scan-list.test.ts +++ b/backend/src/__tests__/database-scan-list.test.ts @@ -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'); + }); +}); diff --git a/backend/src/__tests__/security-overview-route.test.ts b/backend/src/__tests__/security-overview-route.test.ts index 3cbf7690..5dc16e90 100644 --- a/backend/src/__tests__/security-overview-route.test.ts +++ b/backend/src/__tests__/security-overview-route.test.ts @@ -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*'] }); + }); +}); diff --git a/backend/src/__tests__/securityPosture.test.ts b/backend/src/__tests__/securityPosture.test.ts new file mode 100644 index 00000000..14523a3f --- /dev/null +++ b/backend/src/__tests__/securityPosture.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { deriveSecurityPosture, type SecurityPostureFacts } from '../services/securityPosture'; + +function facts(o: Partial): 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'); + }); +}); diff --git a/backend/src/__tests__/suppression-filter.test.ts b/backend/src/__tests__/suppression-filter.test.ts index 939743d4..c2a1db74 100644 --- a/backend/src/__tests__/suppression-filter.test.ts +++ b/backend/src/__tests__/suppression-filter.test.ts @@ -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 diff --git a/backend/src/__tests__/trivy-service.test.ts b/backend/src/__tests__/trivy-service.test.ts index 2a786c68..d939050c 100644 --- a/backend/src/__tests__/trivy-service.test.ts +++ b/backend/src/__tests__/trivy-service.test.ts @@ -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); }); diff --git a/backend/src/bootstrap/shutdown.ts b/backend/src/bootstrap/shutdown.ts index 09421d45..a50f1ffa 100644 --- a/backend/src/bootstrap/shutdown.ts +++ b/backend/src/bootstrap/shutdown.ts @@ -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); } diff --git a/backend/src/bootstrap/startup.ts b/backend/src/bootstrap/startup.ts index a727dd40..f7a78b69 100644 --- a/backend/src/bootstrap/startup.ts +++ b/backend/src/bootstrap/startup.ts @@ -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 { 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 diff --git a/backend/src/routes/security.ts b/backend/src/routes/security.ts index 02da3af5..e630aa5f 100644 --- a/backend/src/routes/security.ts +++ b/backend/src/routes/security.ts @@ -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>(); + 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>(); + 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) { diff --git a/backend/src/services/CveIntelService.ts b/backend/src/services/CveIntelService.ts new file mode 100644 index 00000000..b204942e --- /dev/null +++ b/backend/src/services/CveIntelService.ts @@ -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 { + 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 { + 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 { + 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 { + 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; diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 39ecaf71..a646edcc 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -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 { + const out = new Map(); + 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>, + updates: Partial>, ): 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>) => { 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, ); } }); diff --git a/backend/src/services/FleetSyncService.ts b/backend/src/services/FleetSyncService.ts index 9a961e33..26789f3d 100644 --- a/backend/src/services/FleetSyncService.ts +++ b/backend/src/services/FleetSyncService.ts @@ -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) => ({ diff --git a/backend/src/services/OpenVexExporter.ts b/backend/src/services/OpenVexExporter.ts new file mode 100644 index 00000000..beb0e7cd --- /dev/null +++ b/backend/src/services/OpenVexExporter.ts @@ -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 = { + 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, + }; +} diff --git a/backend/src/services/TrivyService.ts b/backend/src/services/TrivyService.ts index da9f24fc..13e4a5bc 100644 --- a/backend/src/services/TrivyService.ts +++ b/backend/src/services/TrivyService.ts @@ -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; + CVSS?: Record; } 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 | 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 = { 1: 'LOW', 2: 'MEDIUM', 3: 'HIGH', 4: 'CRITICAL' }; +function pickVendorSeverity(map: Record | 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( diff --git a/backend/src/services/securityPosture.ts b/backend/src/services/securityPosture.ts new file mode 100644 index 00000000..5abe4b97 --- /dev/null +++ b/backend/src/services/securityPosture.ts @@ -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'; +} diff --git a/backend/src/utils/suppression-filter.ts b/backend/src/utils/suppression-filter.ts index 8bebe6ae..d1820c85 100644 --- a/backend/src/utils/suppression-filter.ts +++ b/backend/src/utils/suppression-filter.ts @@ -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 = 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( 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, }; }); } diff --git a/docs/features/cve-suppressions.mdx b/docs/features/cve-suppressions.mdx index 520c2e93..58dfe6dc 100644 --- a/docs/features/cve-suppressions.mdx +++ b/docs/features/cve-suppressions.mdx @@ -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 diff --git a/docs/features/security.mdx b/docs/features/security.mdx index 9cb70d69..f6e2d9e1 100644 --- a/docs/features/security.mdx +++ b/docs/features/security.mdx @@ -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 diff --git a/frontend/src/components/SecurityView.tsx b/frontend/src/components/SecurityView.tsx index f5e59dff..249c67f4 100644 --- a/frontend/src/components/SecurityView.tsx +++ b/frontend/src/components/SecurityView.tsx @@ -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} /> +

+ {SCANNER_DETECTIONS_NOTE} +

onTabChange(v as SecurityTab)}> diff --git a/frontend/src/components/VulnerabilityScanSheet.tsx b/frontend/src/components/VulnerabilityScanSheet.tsx index f470c996..e4a061e5 100644 --- a/frontend/src/components/VulnerabilityScanSheet.tsx +++ b/frontend/src/components/VulnerabilityScanSheet.tsx @@ -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 ( + + {children} + + ); +} + +/** + * 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(KEV); + if (typeof d.epss_score === 'number') { + tags.push( + = 0.1 ? 'warn' : 'muted'}> + EPSS {Math.round(d.epss_score * 100)}% + , + ); + } + if (d.status === 'will_not_fix' || d.status === 'end_of_life') { + tags.push({"Won't fix"}); + } + if (typeof d.cvss_score === 'number') { + tags.push(CVSS {d.cvss_score}); + } + if (tags.length === 0) return null; + return {tags}; +} + 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')} > - - - {d.suppressed && ( - - )} - {href ? ( - - {d.vulnerability_id} - - - ) : ( - d.vulnerability_id - )} + + + + {d.suppressed && ( + + )} + {href ? ( + + {d.vulnerability_id} + + + ) : ( + d.vulnerability_id + )} + + +
+ + +

+ How this finding was triaged. Decided states (accepted, not affected, false positive, fixed, ignored) stop driving the posture; needs review stays counted but actionable. +

+