mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 19:57:37 +00:00
f794702171
* feat(security): reframe masthead as action posture, not worst-CVE severity Derive the Security masthead from an action posture (Action needed / Monitoring / Secure / Unknown) instead of raw scanner severity, and label the raw Critical/High counts as scanner detections. "Secure" now means nothing is actionable right now, never a claim that no vulnerabilities exist; Unknown covers a missing scanner or a node with no completed scan. Phase-1 bootstrap: "actionable" is approximated from the overview facts that already exist (fixable findings, secrets, misconfigs); a later phase moves the bucketing to the backend. * feat(security): derive overview action posture from triaged facts Add deriveSecurityPosture as the single bucketing function and extend /security/overview with posture facts (fixableCriticalHigh, dangerousCompose, accepted, rawCritical/rawHigh, plus knownExploited/publiclyExposed placeholders that later phases populate) and the derived posture verb. Suppression- and acknowledgement-aware counts come from one bounded read-time pass over the latest-scan Critical/High findings, grouped per image so the existing read-time filters apply unchanged. The pass is capped and flags posturePartial, so a large node degrades gracefully instead of scanning every detail row. The masthead now prefers the backend posture and keeps the local bootstrap only as a fallback for older remote nodes reached through the proxy. * feat(security): capture Trivy finding enrichment (status, CVSS, vendor, purl, layer) parseTrivyOutput now keeps the per-finding fields Trivy already returns and we previously discarded: Status (fixed / will_not_fix / end_of_life / ...), CVSS (score + vector, preferring the NVD source then falling back), vendor severity, package URL, package path, and layer digest. Persisted on vulnerability_details via additive nullable columns (guarded ALTER), bound null when absent, and carried through the cached-scan reconstruction path. These fields separate scary from exploitable and feed the action posture and the per-finding evidence tags. Field paths verified against Trivy's documented image-scan JSON; covered by parse and insert/read round-trip tests. * feat(security): add CVE exploit-intel service (CISA KEV + FIRST EPSS) Add CveIntelService, a daily background cache of CISA KEV membership and FIRST EPSS scores stored in a new cve_intel table and joined to findings at read time by CVE id (never frozen onto scan rows, so a CVE entering KEV later lights up on scans already stored). EPSS is fetched only for CVE ids present in stored findings, batched; both feeds are best-effort and keep the last cache on failure, so the Security page degrades gracefully offline. Wired into startup/shutdown like the other background services. The overview now counts known-exploited Critical/High findings, and KEV membership escalates posture to Action needed even when no fix is available. A per-instance "Exploit intelligence" toggle on the scanner setup surface lets air-gapped or firewalled hosts disable the outbound fetch; the daily tick keeps running but skips the fetch body when it is off. * feat(security): show per-finding evidence tags (KEV, EPSS, vendor status, CVSS) The vulnerabilities endpoint joins read-time exploit intel (KEV membership and EPSS score) onto each finding by CVE id, and the scan sheet renders evidence tags beside each CVE: known-exploited, EPSS probability, vendor will-not-fix / end-of-life, and the CVSS score. Severity becomes one signal among several so an operator can tell scary from exploitable, with no invented composite score. * feat(security): evolve CVE suppressions into triage decisions Layer a triage status and optional OpenVEX justification onto CVE suppressions. Statuses: needs review / affected / not affected / accepted risk / fixed / false positive / ignored. Dismissing states (not affected, accepted, fixed, false positive, ignored) stop a finding from driving the action posture; needs review and affected stay actionable and are surfaced as counts. Existing rows default to "accepted" (the prior suppress behavior), so nothing changes for them. The overview now reports needsReview / notAffected / accepted as distinct facts derived from the triage status. The decision replicates across the fleet (snapshot + replicated-insert carry status + justification) so a replica's posture matches the control node. The inline suppress dialog gains a triage decision selector; the read-time filter surfaces the status and justification on every finding. * feat(security): export fleet triage decisions as OpenVEX (Admiral) Add an OpenVEX exporter that turns the instance's CVE triage decisions into a standard VEX document (not_affected / fixed / affected / under_investigation, with justifications), and a GET /security/vex/export endpoint to download it. Authoring fleet VEX is a governance capability, so it is gated to Admiral (paid) plus admin, mirroring the SARIF export gate; the Suppressions panel shows an Export VEX action only on Admiral. * docs(security): document action posture, evidence tags, exploit intel, and triage Update the Security page and CVE suppressions docs for the action-posture masthead (scanner detections vs product posture), per-finding evidence tags (KEV / EPSS / CVSS / vendor status), the exploit-intelligence toggle (CISA KEV + FIRST EPSS) on scanner setup, triage decisions layered on suppressions, and OpenVEX export of fleet triage decisions. * test(security): match intel hosts exactly in CveIntelService test Route the fetch stub and its call assertions by exact hostname (www.cisa.gov / api.first.org) instead of a domain substring check. Resolves the js/incomplete-url-substring-sanitization code-scanning alerts on the test's URL routing; behavior is unchanged.
264 lines
10 KiB
TypeScript
264 lines
10 KiB
TypeScript
/**
|
|
* Unit tests for TrivyService parsing, severity computation, and concurrency guard.
|
|
*
|
|
* Focuses on the pure logic exposed on the singleton: output parsing of Trivy JSON,
|
|
* highest-severity rollup, duplicate scan prevention, and graceful handling
|
|
* when the binary is not available.
|
|
*/
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import TrivyService, { parseTrivyOutput } from '../services/TrivyService';
|
|
import TrivyInstaller from '../services/TrivyInstaller';
|
|
import { getActiveCapabilities, enableCapability } from '../services/CapabilityRegistry';
|
|
|
|
describe('TrivyService', () => {
|
|
let svc: TrivyService;
|
|
|
|
beforeEach(() => {
|
|
svc = TrivyService.getInstance();
|
|
});
|
|
|
|
afterEach(() => {
|
|
// detectTrivy() toggles a process-global capability flag. Restore the
|
|
// default (enabled) so this suite cannot leak a disabled state into another
|
|
// suite sharing the worker.
|
|
enableCapability('vulnerability-scanning');
|
|
});
|
|
|
|
describe('isTrivyAvailable', () => {
|
|
it('returns false when binary has not been detected', () => {
|
|
// Service default state: available=false until initialize() runs
|
|
// Tests must not assert true here because CI may or may not have trivy installed.
|
|
const available = svc.isTrivyAvailable();
|
|
expect(typeof available).toBe('boolean');
|
|
});
|
|
});
|
|
|
|
describe('detectTrivy', () => {
|
|
it('returns structured result regardless of binary presence', async () => {
|
|
const result = await svc.detectTrivy();
|
|
expect(result).toHaveProperty('available');
|
|
expect(result).toHaveProperty('version');
|
|
expect(typeof result.available).toBe('boolean');
|
|
});
|
|
|
|
it('records a detection timestamp after running', async () => {
|
|
const before = Date.now();
|
|
await svc.detectTrivy();
|
|
expect(svc.getDetectionTimestamp()).toBeGreaterThanOrEqual(before);
|
|
});
|
|
|
|
it('keeps the vulnerability-scanning capability in lockstep with detected availability', async () => {
|
|
// Regression guard: detection used to toggle the capability only on a
|
|
// state transition, so a process that boots without Trivy (source starts
|
|
// at 'none', wasAvailable === false) never disabled it and kept
|
|
// advertising scanning on a node that cannot scan. Start from the enabled
|
|
// state (the buggy starting point) so that on a Trivy-less runner this
|
|
// proves the disable branch fired; the advertised capability must equal
|
|
// the detected availability after every detection.
|
|
enableCapability('vulnerability-scanning');
|
|
const result = await svc.detectTrivy();
|
|
const advertised = getActiveCapabilities().includes('vulnerability-scanning');
|
|
expect(advertised).toBe(result.available);
|
|
});
|
|
|
|
it('disables the capability from the enabled state when no binary is found (deterministic)', async () => {
|
|
// Force every detection candidate to miss regardless of the runner: bogus
|
|
// managed path, bogus TRIVY_BIN, and an emptied PATH so a bare `trivy`
|
|
// cannot resolve. This reproduces the boot-without-Trivy case the fix
|
|
// targets and proves the disable branch fires even starting from enabled.
|
|
const installerSpy = vi
|
|
.spyOn(TrivyInstaller.getInstance(), 'binaryPath')
|
|
.mockReturnValue('/nonexistent/managed/trivy');
|
|
const prevTrivyBin = process.env.TRIVY_BIN;
|
|
const prevPath = process.env.PATH;
|
|
process.env.TRIVY_BIN = '/nonexistent/env/trivy';
|
|
process.env.PATH = '';
|
|
enableCapability('vulnerability-scanning');
|
|
try {
|
|
const result = await svc.detectTrivy();
|
|
expect(result.available).toBe(false);
|
|
expect(getActiveCapabilities()).not.toContain('vulnerability-scanning');
|
|
} finally {
|
|
installerSpy.mockRestore();
|
|
if (prevTrivyBin === undefined) delete process.env.TRIVY_BIN;
|
|
else process.env.TRIVY_BIN = prevTrivyBin;
|
|
process.env.PATH = prevPath;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('scanImage', () => {
|
|
it('throws when Trivy is not available', async () => {
|
|
// Force availability off for this assertion
|
|
// The service caches state; reset via detectTrivy (will probably return false in CI)
|
|
const detect = await svc.detectTrivy();
|
|
if (!detect.available) {
|
|
await expect(svc.scanImage('alpine:3.19', 1)).rejects.toThrow(
|
|
/Trivy is not available/i,
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('isScanning guard', () => {
|
|
it('reports false for images not currently being scanned', () => {
|
|
expect(svc.isScanning(1, 'nginx:latest')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('parseTrivyOutput', () => {
|
|
it('extracts OS metadata and deduplicates vulnerabilities across targets', () => {
|
|
const raw = JSON.stringify({
|
|
Metadata: { OS: { Family: 'alpine', Name: '3.19.0' } },
|
|
Results: [
|
|
{
|
|
Target: 'alpine:3.19 (alpine)',
|
|
Vulnerabilities: [
|
|
{
|
|
VulnerabilityID: 'CVE-2024-0001',
|
|
PkgName: 'openssl',
|
|
InstalledVersion: '3.0.0',
|
|
FixedVersion: '3.0.1',
|
|
Severity: 'HIGH',
|
|
},
|
|
{
|
|
VulnerabilityID: 'CVE-2024-0002',
|
|
PkgName: 'curl',
|
|
InstalledVersion: '8.0',
|
|
Severity: 'CRITICAL',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
Target: 'other-target',
|
|
Vulnerabilities: [
|
|
{
|
|
VulnerabilityID: 'CVE-2024-0001',
|
|
PkgName: 'openssl',
|
|
InstalledVersion: '3.0.0',
|
|
Severity: 'HIGH',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
const parsed = parseTrivyOutput(raw);
|
|
expect(parsed.os).toBe('alpine 3.19.0');
|
|
expect(parsed.vulnerabilities.length).toBe(2);
|
|
const ids = parsed.vulnerabilities.map((v) => v.vulnerabilityId);
|
|
expect(ids).toContain('CVE-2024-0001');
|
|
expect(ids).toContain('CVE-2024-0002');
|
|
});
|
|
|
|
it('normalizes unknown severities to UNKNOWN', () => {
|
|
const raw = JSON.stringify({
|
|
Results: [
|
|
{
|
|
Vulnerabilities: [
|
|
{
|
|
VulnerabilityID: 'CVE-X',
|
|
PkgName: 'libx',
|
|
InstalledVersion: '1',
|
|
Severity: 'NEGLIGIBLE',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
const parsed = parseTrivyOutput(raw);
|
|
expect(parsed.vulnerabilities[0].severity).toBe('UNKNOWN');
|
|
});
|
|
|
|
it('drops entries missing VulnerabilityID or PkgName', () => {
|
|
const raw = JSON.stringify({
|
|
Results: [
|
|
{
|
|
Vulnerabilities: [
|
|
{ PkgName: 'x', Severity: 'HIGH' },
|
|
{ VulnerabilityID: 'CVE-1', Severity: 'HIGH' },
|
|
{ VulnerabilityID: 'CVE-2', PkgName: 'y', Severity: 'LOW' },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
const parsed = parseTrivyOutput(raw);
|
|
expect(parsed.vulnerabilities.length).toBe(1);
|
|
expect(parsed.vulnerabilities[0].vulnerabilityId).toBe('CVE-2');
|
|
});
|
|
|
|
it('tolerates missing Metadata and empty Results', () => {
|
|
const parsed = parseTrivyOutput(JSON.stringify({ Results: [] }));
|
|
expect(parsed.os).toBeNull();
|
|
expect(parsed.vulnerabilities).toEqual([]);
|
|
|
|
const parsedEmpty = parseTrivyOutput(JSON.stringify({}));
|
|
expect(parsedEmpty.os).toBeNull();
|
|
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);
|
|
});
|
|
});
|
|
});
|