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
+66
View File
@@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest';
import { validateImageRef } from '../utils/image-ref';
describe('validateImageRef', () => {
it('accepts canonical image references', () => {
const valid = [
'alpine',
'alpine:3.19',
'nginx:latest',
'library/postgres:16',
'ghcr.io/owner/project:v1.2.3',
'registry.example.com:5000/team/image:tag',
'docker.io/library/redis@sha256:abcdef1234567890',
'node:20-alpine',
'my-image_v2.final',
];
for (const ref of valid) {
expect(validateImageRef(ref), `expected ${ref} to be valid`).toBe(true);
}
});
it('rejects shell-injection payloads', () => {
const invalid = [
'; rm -rf /',
'$(whoami)',
'`id`',
'alpine && curl evil.com',
'image | tee file',
'image; ls',
'image$VAR',
'image`cmd`',
'image\nother',
'image"name"',
"image'name'",
];
for (const ref of invalid) {
expect(validateImageRef(ref), `expected ${ref} to be invalid`).toBe(false);
}
});
it('rejects empty and whitespace-only strings', () => {
expect(validateImageRef('')).toBe(false);
expect(validateImageRef(' ')).toBe(false);
expect(validateImageRef('\t\n')).toBe(false);
});
it('rejects non-string inputs', () => {
expect(validateImageRef(null)).toBe(false);
expect(validateImageRef(undefined)).toBe(false);
expect(validateImageRef(42)).toBe(false);
expect(validateImageRef({})).toBe(false);
expect(validateImageRef([])).toBe(false);
});
it('rejects path traversal attempts', () => {
expect(validateImageRef('../../etc/passwd')).toBe(false);
expect(validateImageRef('image..name')).toBe(false);
});
it('rejects references exceeding 255 characters', () => {
const longRef = 'a'.repeat(256);
expect(validateImageRef(longRef)).toBe(false);
const atLimit = 'a'.repeat(255);
expect(validateImageRef(atLimit)).toBe(true);
});
});
+39
View File
@@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest';
import { severityRank, isSeverityAtLeast, SEVERITY_ORDER } from '../utils/severity';
describe('severityRank', () => {
it('orders severities CRITICAL > HIGH > MEDIUM > LOW > UNKNOWN', () => {
expect(severityRank('CRITICAL')).toBeGreaterThan(severityRank('HIGH'));
expect(severityRank('HIGH')).toBeGreaterThan(severityRank('MEDIUM'));
expect(severityRank('MEDIUM')).toBeGreaterThan(severityRank('LOW'));
expect(severityRank('LOW')).toBeGreaterThan(severityRank('UNKNOWN'));
});
it('returns -1 for null/undefined so missing severities sort below UNKNOWN', () => {
expect(severityRank(null)).toBe(-1);
expect(severityRank(undefined)).toBe(-1);
expect(severityRank(null)).toBeLessThan(severityRank('UNKNOWN'));
});
it('exports a SEVERITY_ORDER array that covers every known severity', () => {
expect(SEVERITY_ORDER).toEqual(['UNKNOWN', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL']);
});
});
describe('isSeverityAtLeast', () => {
it('returns true when actual meets or exceeds the threshold', () => {
expect(isSeverityAtLeast('CRITICAL', 'HIGH')).toBe(true);
expect(isSeverityAtLeast('HIGH', 'HIGH')).toBe(true);
expect(isSeverityAtLeast('MEDIUM', 'LOW')).toBe(true);
});
it('returns false when actual is below the threshold', () => {
expect(isSeverityAtLeast('LOW', 'HIGH')).toBe(false);
expect(isSeverityAtLeast('MEDIUM', 'CRITICAL')).toBe(false);
});
it('returns false for missing severities regardless of threshold', () => {
expect(isSeverityAtLeast(null, 'LOW')).toBe(false);
expect(isSeverityAtLeast(undefined, 'UNKNOWN')).toBe(false);
});
});
+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);
});
});
});
@@ -296,6 +296,68 @@ describe('Vulnerability scan storage (in-memory SQLite)', () => {
});
});
// ── markStaleScansAsFailed ───────────────────────────────────────
describe('markStaleScansAsFailed', () => {
function markStale(olderThanMs: number): number {
const cutoff = Date.now() - olderThanMs;
const result = db
.prepare(
`UPDATE vulnerability_scans
SET status = 'failed',
error = 'Scan did not complete within expected time',
scan_duration_ms = ? - scanned_at
WHERE status = 'in_progress' AND scanned_at < ?`,
)
.run(Date.now(), cutoff);
return result.changes;
}
it('flips in_progress scans older than the cutoff to failed', () => {
const now = Date.now();
const stale = insertScan({ status: 'in_progress', scanned_at: now - 30 * 60 * 1000 });
const fresh = insertScan({ status: 'in_progress', scanned_at: now - 1_000 });
const changed = markStale(15 * 60 * 1000);
expect(changed).toBe(1);
const staleRow = db.prepare('SELECT status, error FROM vulnerability_scans WHERE id = ?').get(stale) as {
status: string;
error: string;
};
const freshRow = db.prepare('SELECT status FROM vulnerability_scans WHERE id = ?').get(fresh) as {
status: string;
};
expect(staleRow.status).toBe('failed');
expect(staleRow.error).toMatch(/did not complete/i);
expect(freshRow.status).toBe('in_progress');
});
it('leaves completed and failed rows untouched', () => {
const now = Date.now();
const completed = insertScan({ status: 'completed', scanned_at: now - 30 * 60 * 1000 });
const failed = insertScan({ status: 'failed', scanned_at: now - 30 * 60 * 1000 });
const changed = markStale(15 * 60 * 1000);
expect(changed).toBe(0);
const rows = db
.prepare('SELECT id, status FROM vulnerability_scans WHERE id IN (?, ?)')
.all(completed, failed) as Array<{ id: number; status: string }>;
expect(rows.find((r) => r.id === completed)?.status).toBe('completed');
expect(rows.find((r) => r.id === failed)?.status).toBe('failed');
});
it('is idempotent when called repeatedly', () => {
const now = Date.now();
insertScan({ status: 'in_progress', scanned_at: now - 30 * 60 * 1000 });
expect(markStale(15 * 60 * 1000)).toBe(1);
expect(markStale(15 * 60 * 1000)).toBe(0);
expect(markStale(15 * 60 * 1000)).toBe(0);
});
});
// ── vulnerability_details cascade ────────────────────────────────
describe('vulnerability_details cascade', () => {