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
@@ -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', () => {