fix(security): harden Trivy scan lifecycle, logging, and docs (#639)

* fix(security): harden Trivy scan lifecycle, logging, and docs

- Call TrivyService.initialize() at startup so capability state is
  accurate before first request; add periodic re-detect to the scheduler
  so newly installed Trivy binaries light up without a restart.
- Add markStaleScansAsFailed sweep (+ idx_vuln_scans_status index) to
  recover any scan row left in_progress after a crash or timeout; sweep
  runs before the paid-tier gate so every tier self-heals.
- Split scanImage persistence into beginScan/finishScan so the manual
  scan route owns a single code path and can return a scanId synchronously
  while work continues asynchronously.
- Validate image refs on /api/security/scan and /sbom via new utility;
  defense-in-depth against shell-metacharacter payloads.
- Dispatch a warning-level alert when a post-deploy scan fails so the
  operator has a user-visible path to the failure instead of a silent log.
- Share DIGEST_CACHE_TTL_MS and severity ordering across service and
  route layers; remove dead invalidateDetection().
- Add [Trivy:diag] logging gated behind developer_mode for support
  diagnostics; production logs unchanged.
- Frontend: defensive toast fallback chain, sr-only SheetDescription,
  and a truncation badge when the 500-item detail fetch is capped.
- Tests: extend trivy-service and vulnerability-db suites; add
  image-ref and severity unit tests.
- Docs: expand vulnerability-scanning troubleshooting with recovery,
  re-detect, and diagnostic-log guidance; link Dockerfile comment to
  trivy-setup.

* fix(security): drop unnecessary escape in image-ref forbidden-char regex
This commit is contained in:
Anso
2026-04-16 20:32:38 -04:00
committed by GitHub
parent f8eb1b4e88
commit dc8370f5a4
16 changed files with 563 additions and 140 deletions
+102 -1
View File
@@ -6,7 +6,7 @@
* when the binary is not available.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import TrivyService from '../services/TrivyService';
import TrivyService, { parseTrivyOutput } from '../services/TrivyService';
describe('TrivyService', () => {
let svc: TrivyService;
@@ -31,6 +31,12 @@ describe('TrivyService', () => {
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);
});
});
describe('scanImage', () => {
@@ -51,4 +57,99 @@ describe('TrivyService', () => {
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('throws a helpful error on malformed JSON', () => {
expect(() => parseTrivyOutput('{not-json')).toThrow(/Malformed/i);
});
});
});