feat(images): Trivy-powered vulnerability scanning (#635)

* feat(images): Trivy-powered vulnerability scanning

Scan container images for known CVEs via Trivy. On-demand scanning and
severity badges are available on every tier; scheduled scans, scan
policies, SBOM generation, and scan history are gated to Skipper+.

- New TrivyService (binary detection, per-image scan, SBOM, digest cache)
- Three new tables: vulnerability_scans, vulnerability_details, scan_policies
- 12 routes under /api/security (scan, results, summaries, SBOM, policies, compare)
- Post-deploy async scans wired into all five deploy paths, with a
  per-deploy opt-out toggle in the App Store deploy sheet
- "scan" action type added to SchedulerService for fleet-wide recurring scans
- Frontend: severity badges in Resources Hub with animated cursor detail,
  scan results drawer with vulnerability table and filters, and a new
  Security section in Settings for scan policy CRUD
- Policy threshold violations dispatch a warning or critical alert based on
  the policy's block_on_deploy flag; deploys themselves are never blocked

* fix(security): compute scan age in useEffect to satisfy react-hooks/purity
This commit is contained in:
Anso
2026-04-16 15:03:36 -04:00
committed by GitHub
parent 4c5aa73196
commit c9cd6990d2
23 changed files with 3452 additions and 18 deletions
+25 -1
View File
@@ -12,6 +12,7 @@ import { getErrorMessage } from '../utils/errors';
import { captureLocalNodeFiles, captureRemoteNodeFiles } from '../utils/snapshot-capture';
import { NodeRegistry } from './NodeRegistry';
import { NotificationService } from './NotificationService';
import TrivyService from './TrivyService';
export class SchedulerService {
private static instance: SchedulerService;
@@ -82,9 +83,10 @@ export class SchedulerService {
// Clean up old runs periodically (piggyback on tick)
db.cleanupOldTaskRuns(30);
db.deleteOldScans(90 * 24 * 60 * 60 * 1000);
for (const task of dueTasks) {
if (!isAdmiral && task.action !== 'update') {
if (!isAdmiral && task.action !== 'update' && task.action !== 'scan') {
if (isDebugEnabled()) console.log(`[SchedulerService] Task ${task.id} skipped: action "${task.action}" requires Admiral tier`);
continue;
}
@@ -159,6 +161,9 @@ export class SchedulerService {
case 'update':
output = await this.executeUpdate(task);
break;
case 'scan':
output = await this.executeScan(task);
break;
}
if (isDebugEnabled()) console.log(`[SchedulerService:debug] Task ${task.id} action completed in ${Date.now() - actionStart}ms`);
@@ -498,4 +503,23 @@ export class SchedulerService {
return `Stack "${stackName}": updated (${updatedImages.join(', ')}).`;
}
private async executeScan(task: ScheduledTask): Promise<string> {
const trivy = TrivyService.getInstance();
if (!trivy.isTrivyAvailable()) {
throw new Error('Trivy binary is not available on this node');
}
const nodeId = task.node_id ?? NodeRegistry.getInstance().getDefaultNodeId();
if (task.node_id == null && isDebugEnabled()) {
console.log(`[SchedulerService:debug] Scan task ${task.id}: no node_id specified, using default node ${nodeId}`);
}
const summary = await trivy.scanAllNodeImages(nodeId, 'scheduled');
const parts: string[] = [`Scanned ${summary.scanned} image(s)`];
if (summary.skipped > 0) parts.push(`${summary.skipped} skipped (cached)`);
if (summary.failed > 0) parts.push(`${summary.failed} failed`);
return parts.join('; ');
}
}