mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
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:
@@ -10,7 +10,7 @@ const {
|
||||
mockGetDueScheduledTasks, mockCreateScheduledTaskRun, mockUpdateScheduledTaskRun,
|
||||
mockUpdateScheduledTask, mockCleanupOldTaskRuns, mockGetScheduledTask, mockGetNodes, mockGetNode,
|
||||
mockCreateSnapshot, mockInsertSnapshotFiles, mockClearStackUpdateStatus,
|
||||
mockMarkStaleRunsAsFailed,
|
||||
mockMarkStaleRunsAsFailed, mockDeleteOldScans,
|
||||
mockGetTier, mockGetVariant,
|
||||
mockGetContainersByStack, mockRestartContainer, mockPruneSystem,
|
||||
mockUpdateStack,
|
||||
@@ -31,6 +31,7 @@ const {
|
||||
mockInsertSnapshotFiles: vi.fn(),
|
||||
mockClearStackUpdateStatus: vi.fn(),
|
||||
mockMarkStaleRunsAsFailed: vi.fn().mockReturnValue(0),
|
||||
mockDeleteOldScans: vi.fn().mockReturnValue(0),
|
||||
mockGetTier: vi.fn().mockReturnValue('paid'),
|
||||
mockGetVariant: vi.fn().mockReturnValue('admiral'),
|
||||
mockGetContainersByStack: vi.fn().mockResolvedValue([]),
|
||||
@@ -60,6 +61,7 @@ vi.mock('../services/DatabaseService', () => ({
|
||||
insertSnapshotFiles: mockInsertSnapshotFiles,
|
||||
clearStackUpdateStatus: mockClearStackUpdateStatus,
|
||||
markStaleRunsAsFailed: mockMarkStaleRunsAsFailed,
|
||||
deleteOldScans: mockDeleteOldScans,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 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 } from 'vitest';
|
||||
import TrivyService from '../services/TrivyService';
|
||||
|
||||
describe('TrivyService', () => {
|
||||
let svc: TrivyService;
|
||||
|
||||
beforeEach(() => {
|
||||
svc = TrivyService.getInstance();
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,533 @@
|
||||
/**
|
||||
* Tests for the vulnerability scan / policy storage layer.
|
||||
*
|
||||
* Mirrors the SQL and logic in DatabaseService for the three vulnerability
|
||||
* tables (vulnerability_scans, vulnerability_details, scan_policies) against
|
||||
* an in-memory SQLite database so behavior can be asserted without booting
|
||||
* the real DatabaseService singleton.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
type Severity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN';
|
||||
|
||||
const SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS vulnerability_scans (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
node_id INTEGER NOT NULL,
|
||||
image_ref TEXT NOT NULL,
|
||||
image_digest TEXT,
|
||||
scanned_at INTEGER NOT NULL,
|
||||
total_vulnerabilities INTEGER NOT NULL DEFAULT 0,
|
||||
critical_count INTEGER NOT NULL DEFAULT 0,
|
||||
high_count INTEGER NOT NULL DEFAULT 0,
|
||||
medium_count INTEGER NOT NULL DEFAULT 0,
|
||||
low_count INTEGER NOT NULL DEFAULT 0,
|
||||
unknown_count INTEGER NOT NULL DEFAULT 0,
|
||||
fixable_count INTEGER NOT NULL DEFAULT 0,
|
||||
highest_severity TEXT,
|
||||
os_info TEXT,
|
||||
trivy_version TEXT,
|
||||
scan_duration_ms INTEGER,
|
||||
triggered_by TEXT NOT NULL DEFAULT 'manual',
|
||||
status TEXT NOT NULL DEFAULT 'completed',
|
||||
error TEXT,
|
||||
stack_context TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vulnerability_details (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scan_id INTEGER NOT NULL,
|
||||
vulnerability_id TEXT NOT NULL,
|
||||
pkg_name TEXT NOT NULL,
|
||||
installed_version TEXT NOT NULL,
|
||||
fixed_version TEXT,
|
||||
severity TEXT NOT NULL,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
primary_url TEXT,
|
||||
FOREIGN KEY(scan_id) REFERENCES vulnerability_scans(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scan_policies (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
node_id INTEGER,
|
||||
stack_pattern TEXT,
|
||||
max_severity TEXT NOT NULL DEFAULT 'CRITICAL',
|
||||
block_on_deploy INTEGER NOT NULL DEFAULT 0,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`;
|
||||
|
||||
interface ScanRow {
|
||||
id: number;
|
||||
node_id: number;
|
||||
image_ref: string;
|
||||
image_digest: string | null;
|
||||
scanned_at: number;
|
||||
total_vulnerabilities: number;
|
||||
critical_count: number;
|
||||
high_count: number;
|
||||
medium_count: number;
|
||||
low_count: number;
|
||||
unknown_count: number;
|
||||
fixable_count: number;
|
||||
highest_severity: Severity | null;
|
||||
status: string;
|
||||
triggered_by: string;
|
||||
}
|
||||
|
||||
interface DetailRow {
|
||||
id: number;
|
||||
scan_id: number;
|
||||
vulnerability_id: string;
|
||||
pkg_name: string;
|
||||
installed_version: string;
|
||||
fixed_version: string | null;
|
||||
severity: Severity;
|
||||
}
|
||||
|
||||
interface PolicyRow {
|
||||
id: number;
|
||||
name: string;
|
||||
node_id: number | null;
|
||||
stack_pattern: string | null;
|
||||
max_severity: Severity;
|
||||
block_on_deploy: number;
|
||||
enabled: number;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
describe('Vulnerability scan storage (in-memory SQLite)', () => {
|
||||
let db: Database.Database;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
for (const stmt of SCHEMA.split(';').map((s) => s.trim()).filter(Boolean)) {
|
||||
db.prepare(stmt + ';').run();
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
// Helpers that mirror the DatabaseService implementation.
|
||||
|
||||
function insertScan(overrides: Partial<ScanRow> = {}): number {
|
||||
const scan: Omit<ScanRow, 'id'> = {
|
||||
node_id: 1,
|
||||
image_ref: 'nginx:latest',
|
||||
image_digest: 'sha256:abc',
|
||||
scanned_at: Date.now(),
|
||||
total_vulnerabilities: 0,
|
||||
critical_count: 0,
|
||||
high_count: 0,
|
||||
medium_count: 0,
|
||||
low_count: 0,
|
||||
unknown_count: 0,
|
||||
fixable_count: 0,
|
||||
highest_severity: null,
|
||||
status: 'completed',
|
||||
triggered_by: 'manual',
|
||||
...overrides,
|
||||
};
|
||||
const r = db
|
||||
.prepare(
|
||||
`INSERT INTO vulnerability_scans (
|
||||
node_id, image_ref, image_digest, scanned_at,
|
||||
total_vulnerabilities, critical_count, high_count, medium_count,
|
||||
low_count, unknown_count, fixable_count, highest_severity,
|
||||
os_info, trivy_version, scan_duration_ms, triggered_by, status,
|
||||
error, stack_context
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
scan.node_id,
|
||||
scan.image_ref,
|
||||
scan.image_digest,
|
||||
scan.scanned_at,
|
||||
scan.total_vulnerabilities,
|
||||
scan.critical_count,
|
||||
scan.high_count,
|
||||
scan.medium_count,
|
||||
scan.low_count,
|
||||
scan.unknown_count,
|
||||
scan.fixable_count,
|
||||
scan.highest_severity,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
scan.triggered_by,
|
||||
scan.status,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
return r.lastInsertRowid as number;
|
||||
}
|
||||
|
||||
function insertDetails(scanId: number, rows: Array<Partial<DetailRow>>): void {
|
||||
const stmt = db.prepare(
|
||||
`INSERT INTO vulnerability_details (
|
||||
scan_id, vulnerability_id, pkg_name, installed_version,
|
||||
fixed_version, severity, title, description, primary_url
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
);
|
||||
const txn = db.transaction((items: Array<Partial<DetailRow>>) => {
|
||||
for (const d of items) {
|
||||
stmt.run(
|
||||
scanId,
|
||||
d.vulnerability_id ?? 'CVE-0000-0000',
|
||||
d.pkg_name ?? 'libssl',
|
||||
d.installed_version ?? '1.0.0',
|
||||
d.fixed_version ?? null,
|
||||
d.severity ?? 'LOW',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
}
|
||||
});
|
||||
txn(rows);
|
||||
}
|
||||
|
||||
function insertPolicy(overrides: Partial<PolicyRow> = {}): number {
|
||||
const now = Date.now();
|
||||
const r = db
|
||||
.prepare(
|
||||
`INSERT INTO scan_policies (name, node_id, stack_pattern, max_severity, block_on_deploy, enabled, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
overrides.name ?? 'default',
|
||||
overrides.node_id ?? null,
|
||||
overrides.stack_pattern ?? null,
|
||||
overrides.max_severity ?? 'CRITICAL',
|
||||
overrides.block_on_deploy ?? 0,
|
||||
overrides.enabled ?? 1,
|
||||
overrides.created_at ?? now,
|
||||
overrides.updated_at ?? now,
|
||||
);
|
||||
return r.lastInsertRowid as number;
|
||||
}
|
||||
|
||||
function count(table: string): number {
|
||||
return (db.prepare(`SELECT COUNT(*) as c FROM ${table}`).get() as { c: number }).c;
|
||||
}
|
||||
|
||||
// ── vulnerability_scans CRUD ──────────────────────────────────────
|
||||
|
||||
describe('scan insert / retrieve', () => {
|
||||
it('round-trips a scan record by id', () => {
|
||||
const id = insertScan({
|
||||
image_ref: 'alpine:3.19',
|
||||
critical_count: 2,
|
||||
high_count: 5,
|
||||
total_vulnerabilities: 7,
|
||||
highest_severity: 'CRITICAL',
|
||||
});
|
||||
const row = db.prepare('SELECT * FROM vulnerability_scans WHERE id = ?').get(id) as ScanRow;
|
||||
expect(row).toBeDefined();
|
||||
expect(row.image_ref).toBe('alpine:3.19');
|
||||
expect(row.critical_count).toBe(2);
|
||||
expect(row.high_count).toBe(5);
|
||||
expect(row.total_vulnerabilities).toBe(7);
|
||||
expect(row.highest_severity).toBe('CRITICAL');
|
||||
});
|
||||
|
||||
it('returns undefined for an unknown id', () => {
|
||||
const row = db.prepare('SELECT * FROM vulnerability_scans WHERE id = ?').get(999);
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── getLatestScanByDigest ────────────────────────────────────────
|
||||
|
||||
describe('getLatestScanByDigest', () => {
|
||||
it('returns the most recent completed scan for a digest', () => {
|
||||
insertScan({ image_digest: 'sha256:target', scanned_at: 1000, status: 'completed' });
|
||||
insertScan({ image_digest: 'sha256:target', scanned_at: 2000, status: 'completed' });
|
||||
insertScan({ image_digest: 'sha256:other', scanned_at: 3000, status: 'completed' });
|
||||
|
||||
const row = db
|
||||
.prepare(
|
||||
"SELECT * FROM vulnerability_scans WHERE image_digest = ? AND status = 'completed' ORDER BY scanned_at DESC LIMIT 1",
|
||||
)
|
||||
.get('sha256:target') as ScanRow | undefined;
|
||||
expect(row?.scanned_at).toBe(2000);
|
||||
});
|
||||
|
||||
it('ignores failed or in_progress scans when resolving the cache', () => {
|
||||
insertScan({ image_digest: 'sha256:target', scanned_at: 5000, status: 'in_progress' });
|
||||
insertScan({ image_digest: 'sha256:target', scanned_at: 4000, status: 'failed' });
|
||||
insertScan({ image_digest: 'sha256:target', scanned_at: 1000, status: 'completed' });
|
||||
|
||||
const row = db
|
||||
.prepare(
|
||||
"SELECT * FROM vulnerability_scans WHERE image_digest = ? AND status = 'completed' ORDER BY scanned_at DESC LIMIT 1",
|
||||
)
|
||||
.get('sha256:target') as ScanRow | undefined;
|
||||
expect(row?.scanned_at).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
// ── deleteOldScans ───────────────────────────────────────────────
|
||||
|
||||
describe('deleteOldScans', () => {
|
||||
it('removes scans older than the cutoff and keeps newer ones', () => {
|
||||
const now = Date.now();
|
||||
insertScan({ scanned_at: now - 100_000_000 });
|
||||
insertScan({ scanned_at: now - 200_000_000 });
|
||||
insertScan({ scanned_at: now - 1_000 });
|
||||
|
||||
const cutoff = now - 50_000_000;
|
||||
const deleted = db
|
||||
.prepare('DELETE FROM vulnerability_scans WHERE scanned_at < ?')
|
||||
.run(cutoff).changes;
|
||||
|
||||
expect(deleted).toBe(2);
|
||||
expect(count('vulnerability_scans')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── vulnerability_details cascade ────────────────────────────────
|
||||
|
||||
describe('vulnerability_details cascade', () => {
|
||||
it('cascades deletes when the parent scan is removed', () => {
|
||||
const id = insertScan();
|
||||
insertDetails(id, [
|
||||
{ vulnerability_id: 'CVE-1', severity: 'HIGH' },
|
||||
{ vulnerability_id: 'CVE-2', severity: 'LOW' },
|
||||
{ vulnerability_id: 'CVE-3', severity: 'CRITICAL', fixed_version: '2.0.0' },
|
||||
]);
|
||||
|
||||
expect(count('vulnerability_details')).toBe(3);
|
||||
|
||||
db.prepare('DELETE FROM vulnerability_scans WHERE id = ?').run(id);
|
||||
|
||||
expect(count('vulnerability_scans')).toBe(0);
|
||||
expect(count('vulnerability_details')).toBe(0);
|
||||
});
|
||||
|
||||
it('batches inserts inside a transaction (no partial writes on scan isolation)', () => {
|
||||
const id = insertScan();
|
||||
const other = insertScan({ image_ref: 'other:1.0' });
|
||||
|
||||
insertDetails(id, [
|
||||
{ vulnerability_id: 'CVE-A', severity: 'HIGH' },
|
||||
{ vulnerability_id: 'CVE-B', severity: 'HIGH' },
|
||||
]);
|
||||
|
||||
const rows = db
|
||||
.prepare('SELECT vulnerability_id FROM vulnerability_details WHERE scan_id = ?')
|
||||
.all(id) as { vulnerability_id: string }[];
|
||||
expect(rows.map((r) => r.vulnerability_id).sort()).toEqual(['CVE-A', 'CVE-B']);
|
||||
|
||||
// Other scan unaffected.
|
||||
const otherCount = db
|
||||
.prepare('SELECT COUNT(*) as c FROM vulnerability_details WHERE scan_id = ?')
|
||||
.get(other) as { c: number };
|
||||
expect(otherCount.c).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getVulnerabilityDetails ordering ─────────────────────────────
|
||||
|
||||
describe('getVulnerabilityDetails ordering', () => {
|
||||
it('orders by severity (CRITICAL → HIGH → MEDIUM → LOW → UNKNOWN) then pkg_name', () => {
|
||||
const id = insertScan();
|
||||
insertDetails(id, [
|
||||
{ vulnerability_id: 'CVE-L', severity: 'LOW', pkg_name: 'aaa' },
|
||||
{ vulnerability_id: 'CVE-C', severity: 'CRITICAL', pkg_name: 'bbb' },
|
||||
{ vulnerability_id: 'CVE-H', severity: 'HIGH', pkg_name: 'ccc' },
|
||||
{ vulnerability_id: 'CVE-U', severity: 'UNKNOWN', pkg_name: 'ddd' },
|
||||
{ vulnerability_id: 'CVE-M', severity: 'MEDIUM', pkg_name: 'eee' },
|
||||
]);
|
||||
|
||||
const severityOrder = `CASE severity
|
||||
WHEN 'CRITICAL' THEN 0
|
||||
WHEN 'HIGH' THEN 1
|
||||
WHEN 'MEDIUM' THEN 2
|
||||
WHEN 'LOW' THEN 3
|
||||
ELSE 4 END`;
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT severity, pkg_name FROM vulnerability_details WHERE scan_id = ? ORDER BY ${severityOrder}, pkg_name`,
|
||||
)
|
||||
.all(id) as Array<{ severity: Severity; pkg_name: string }>;
|
||||
|
||||
expect(rows.map((r) => r.severity)).toEqual([
|
||||
'CRITICAL',
|
||||
'HIGH',
|
||||
'MEDIUM',
|
||||
'LOW',
|
||||
'UNKNOWN',
|
||||
]);
|
||||
});
|
||||
|
||||
it('filters by severity when requested', () => {
|
||||
const id = insertScan();
|
||||
insertDetails(id, [
|
||||
{ vulnerability_id: 'CVE-1', severity: 'HIGH' },
|
||||
{ vulnerability_id: 'CVE-2', severity: 'HIGH' },
|
||||
{ vulnerability_id: 'CVE-3', severity: 'LOW' },
|
||||
]);
|
||||
|
||||
const highs = db
|
||||
.prepare('SELECT COUNT(*) as c FROM vulnerability_details WHERE scan_id = ? AND severity = ?')
|
||||
.get(id, 'HIGH') as { c: number };
|
||||
expect(highs.c).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getImageScanSummaries (latest-per-image JOIN) ────────────────
|
||||
|
||||
describe('getImageScanSummaries', () => {
|
||||
const SUMMARIES_SQL = `
|
||||
SELECT vs.image_ref, vs.id as scan_id, vs.highest_severity, vs.total_vulnerabilities,
|
||||
vs.critical_count, vs.high_count, vs.medium_count, vs.low_count,
|
||||
vs.unknown_count, vs.fixable_count, vs.scanned_at
|
||||
FROM vulnerability_scans vs
|
||||
INNER JOIN (
|
||||
SELECT image_ref, MAX(scanned_at) AS max_scanned
|
||||
FROM vulnerability_scans
|
||||
WHERE node_id = ? AND status = 'completed'
|
||||
GROUP BY image_ref
|
||||
) latest ON latest.image_ref = vs.image_ref AND latest.max_scanned = vs.scanned_at
|
||||
WHERE vs.node_id = ? AND vs.status = 'completed'`;
|
||||
|
||||
it('returns only the latest scan per image_ref', () => {
|
||||
insertScan({ image_ref: 'nginx:1', scanned_at: 100, highest_severity: 'LOW' });
|
||||
insertScan({ image_ref: 'nginx:1', scanned_at: 200, highest_severity: 'HIGH' });
|
||||
insertScan({ image_ref: 'nginx:1', scanned_at: 300, highest_severity: 'CRITICAL' });
|
||||
insertScan({ image_ref: 'redis:7', scanned_at: 150, highest_severity: 'MEDIUM' });
|
||||
|
||||
const rows = db.prepare(SUMMARIES_SQL).all(1, 1) as Array<{
|
||||
image_ref: string;
|
||||
scanned_at: number;
|
||||
highest_severity: Severity;
|
||||
}>;
|
||||
|
||||
expect(rows.length).toBe(2);
|
||||
const byImage = Object.fromEntries(rows.map((r) => [r.image_ref, r]));
|
||||
expect(byImage['nginx:1'].scanned_at).toBe(300);
|
||||
expect(byImage['nginx:1'].highest_severity).toBe('CRITICAL');
|
||||
expect(byImage['redis:7'].scanned_at).toBe(150);
|
||||
});
|
||||
|
||||
it('ignores in_progress and failed scans', () => {
|
||||
insertScan({ image_ref: 'alpine:3', scanned_at: 500, status: 'in_progress' });
|
||||
insertScan({ image_ref: 'alpine:3', scanned_at: 100, status: 'completed', highest_severity: 'LOW' });
|
||||
|
||||
const rows = db.prepare(SUMMARIES_SQL).all(1, 1) as Array<{ image_ref: string; scanned_at: number }>;
|
||||
expect(rows.length).toBe(1);
|
||||
expect(rows[0].scanned_at).toBe(100);
|
||||
});
|
||||
|
||||
it('scopes results to the requested node', () => {
|
||||
insertScan({ image_ref: 'nginx:1', scanned_at: 100, node_id: 1 });
|
||||
insertScan({ image_ref: 'nginx:1', scanned_at: 200, node_id: 2 });
|
||||
|
||||
const rows = db.prepare(SUMMARIES_SQL).all(1, 1) as Array<{ scanned_at: number }>;
|
||||
expect(rows.length).toBe(1);
|
||||
expect(rows[0].scanned_at).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
// ── scan_policies CRUD + glob matching ───────────────────────────
|
||||
|
||||
describe('scan_policies CRUD', () => {
|
||||
it('creates and retrieves a policy', () => {
|
||||
const id = insertPolicy({ name: 'prod-gate', stack_pattern: 'prod-*', max_severity: 'HIGH', block_on_deploy: 1 });
|
||||
const row = db.prepare('SELECT * FROM scan_policies WHERE id = ?').get(id) as PolicyRow;
|
||||
expect(row.name).toBe('prod-gate');
|
||||
expect(row.stack_pattern).toBe('prod-*');
|
||||
expect(row.max_severity).toBe('HIGH');
|
||||
expect(row.block_on_deploy).toBe(1);
|
||||
expect(row.enabled).toBe(1);
|
||||
});
|
||||
|
||||
it('deletes a policy by id', () => {
|
||||
const id = insertPolicy();
|
||||
insertPolicy({ name: 'other' });
|
||||
db.prepare('DELETE FROM scan_policies WHERE id = ?').run(id);
|
||||
expect(count('scan_policies')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMatchingPolicy (glob + scope priority)', () => {
|
||||
// Mirrors the matching logic in DatabaseService.getMatchingPolicy.
|
||||
function findMatching(nodeId: number, stackName: string | null): PolicyRow | null {
|
||||
const policies = db
|
||||
.prepare(
|
||||
'SELECT * FROM scan_policies WHERE enabled = 1 AND (node_id IS NULL OR node_id = ?)',
|
||||
)
|
||||
.all(nodeId) as PolicyRow[];
|
||||
const matchesStack = (pattern: string | null): boolean => {
|
||||
if (!pattern) return true;
|
||||
if (!stackName) return false;
|
||||
const regex = new RegExp(
|
||||
'^' + pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$',
|
||||
);
|
||||
return regex.test(stackName);
|
||||
};
|
||||
const scoped = policies.filter((p) => matchesStack(p.stack_pattern));
|
||||
if (scoped.length === 0) return null;
|
||||
scoped.sort((a, b) => {
|
||||
if (a.node_id && !b.node_id) return -1;
|
||||
if (!a.node_id && b.node_id) return 1;
|
||||
if (a.stack_pattern && !b.stack_pattern) return -1;
|
||||
if (!a.stack_pattern && b.stack_pattern) return 1;
|
||||
return 0;
|
||||
});
|
||||
return scoped[0];
|
||||
}
|
||||
|
||||
it('matches a glob pattern against the stack name', () => {
|
||||
insertPolicy({ name: 'prod-policy', stack_pattern: 'prod-*', max_severity: 'HIGH' });
|
||||
expect(findMatching(1, 'prod-web')?.name).toBe('prod-policy');
|
||||
expect(findMatching(1, 'dev-api')).toBeNull();
|
||||
});
|
||||
|
||||
it('null pattern acts as a wildcard (matches any stack)', () => {
|
||||
insertPolicy({ name: 'catch-all', stack_pattern: null });
|
||||
expect(findMatching(1, 'anything')?.name).toBe('catch-all');
|
||||
expect(findMatching(1, null)?.name).toBe('catch-all');
|
||||
});
|
||||
|
||||
it('scoped policy (node_id set) wins over global when both match', () => {
|
||||
insertPolicy({ name: 'global', stack_pattern: null, node_id: null });
|
||||
insertPolicy({ name: 'node-specific', stack_pattern: null, node_id: 1 });
|
||||
expect(findMatching(1, 'whatever')?.name).toBe('node-specific');
|
||||
});
|
||||
|
||||
it('patterned policy wins over wildcard when both match', () => {
|
||||
insertPolicy({ name: 'wildcard', stack_pattern: null });
|
||||
insertPolicy({ name: 'patterned', stack_pattern: 'prod-*' });
|
||||
expect(findMatching(1, 'prod-web')?.name).toBe('patterned');
|
||||
});
|
||||
|
||||
it('ignores disabled policies', () => {
|
||||
insertPolicy({ name: 'disabled', stack_pattern: null, enabled: 0 });
|
||||
expect(findMatching(1, 'prod-web')).toBeNull();
|
||||
});
|
||||
|
||||
it('escapes regex metacharacters in patterns (dots are literal)', () => {
|
||||
insertPolicy({ name: 'literal-dot', stack_pattern: 'app.prod' });
|
||||
expect(findMatching(1, 'app.prod')?.name).toBe('literal-dot');
|
||||
// The dot must NOT act as "any character", otherwise "appXprod" would match.
|
||||
expect(findMatching(1, 'appXprod')).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores policies scoped to a different node', () => {
|
||||
insertPolicy({ name: 'node-2-only', node_id: 2, stack_pattern: null });
|
||||
expect(findMatching(1, 'whatever')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
+393
-6
@@ -69,6 +69,7 @@ import { getErrorMessage } from './utils/errors';
|
||||
import { captureLocalNodeFiles, captureRemoteNodeFiles, SnapshotNodeData } from './utils/snapshot-capture';
|
||||
import { GlobalLogEntry, normalizeContainerName, parseLogTimestamp, detectLogLevel, demuxDockerLog } from './utils/log-parsing';
|
||||
import SelfUpdateService from './services/SelfUpdateService';
|
||||
import TrivyService, { SbomFormat } from './services/TrivyService';
|
||||
import semver from 'semver';
|
||||
import { CronExpressionParser } from 'cron-parser';
|
||||
import { isValidStackName, isValidRemoteUrl, isPathWithinBase, isValidCidr, isValidIPv4, isValidDockerResourceId } from './utils/validation';
|
||||
@@ -1578,12 +1579,78 @@ function isSqliteUniqueViolation(error: unknown): boolean {
|
||||
return error instanceof Error && 'code' in error && (error as { code: string }).code === 'SQLITE_CONSTRAINT_UNIQUE';
|
||||
}
|
||||
|
||||
// Tier gate for scheduled tasks: 'update' action requires Skipper+, everything else requires Admiral.
|
||||
// Tier gate for scheduled tasks: 'update' and 'scan' actions require Skipper+, everything else requires Admiral.
|
||||
const requireScheduledTaskTier = (action: string, req: Request, res: Response): boolean => {
|
||||
if (action === 'update') return requirePaid(req, res);
|
||||
if (action === 'update' || action === 'scan') return requirePaid(req, res);
|
||||
return requireAdmiral(req, res);
|
||||
};
|
||||
|
||||
async function triggerPostDeployScan(
|
||||
stackName: string,
|
||||
nodeId: number,
|
||||
): Promise<void> {
|
||||
const svc = TrivyService.getInstance();
|
||||
if (!svc.isTrivyAvailable()) return;
|
||||
try {
|
||||
const docker = DockerController.getInstance(nodeId).getDocker();
|
||||
const containers = await docker.listContainers({
|
||||
all: true,
|
||||
filters: { label: [`com.docker.compose.project=${stackName}`] },
|
||||
});
|
||||
const imageRefs = new Set<string>();
|
||||
for (const c of containers as Array<{ Image?: string }>) {
|
||||
if (c.Image && !c.Image.startsWith('sha256:')) imageRefs.add(c.Image);
|
||||
}
|
||||
if (imageRefs.size === 0) return;
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
const policy = db.getMatchingPolicy(nodeId, stackName);
|
||||
const severityRank = (s: string | null | undefined): number => {
|
||||
switch ((s ?? '').toUpperCase()) {
|
||||
case 'CRITICAL': return 4;
|
||||
case 'HIGH': return 3;
|
||||
case 'MEDIUM': return 2;
|
||||
case 'LOW': return 1;
|
||||
default: return 0;
|
||||
}
|
||||
};
|
||||
|
||||
for (const imageRef of imageRefs) {
|
||||
try {
|
||||
const digest = await svc.getImageDigest(imageRef, nodeId);
|
||||
if (digest) {
|
||||
const cached = db.getLatestScanByDigest(digest);
|
||||
if (cached && Date.now() - cached.scanned_at < 24 * 60 * 60 * 1000) continue;
|
||||
}
|
||||
const scan = await svc.runScanAndPersist(imageRef, nodeId, 'deploy', stackName);
|
||||
|
||||
if (scan.critical_count > 0 || scan.high_count > 0) {
|
||||
NotificationService.getInstance().dispatchAlert(
|
||||
scan.critical_count > 0 ? 'error' : 'warning',
|
||||
`Vulnerability scan for ${imageRef}: ${scan.critical_count} critical, ${scan.high_count} high`,
|
||||
stackName,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
policy &&
|
||||
severityRank(scan.highest_severity) >= severityRank(policy.max_severity)
|
||||
) {
|
||||
NotificationService.getInstance().dispatchAlert(
|
||||
policy.block_on_deploy ? 'error' : 'warning',
|
||||
`Policy "${policy.name}" triggered for ${imageRef}: ${scan.highest_severity} exceeds ${policy.max_severity}`,
|
||||
stackName,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Security] Post-deploy scan failed for ${imageRef}:`, (err as Error).message);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Security] triggerPostDeployScan error for ${stackName}:`, (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Scoped RBAC Permission Engine (Admiral) ---
|
||||
|
||||
type PermissionAction =
|
||||
@@ -4474,6 +4541,11 @@ app.post('/api/stacks/:stackName/git-source/apply', async (req: Request, res: Re
|
||||
console.log(`[GitSource] Applied commit ${shortSha} to ${stackName}`);
|
||||
}
|
||||
res.json(result);
|
||||
if (result.deployed) {
|
||||
triggerPostDeployScan(stackName, req.nodeId).catch(err =>
|
||||
console.error(`[Security] Post-deploy scan failed for ${stackName}:`, err),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
sendGitSourceError(res, error);
|
||||
}
|
||||
@@ -4641,6 +4713,11 @@ app.post('/api/stacks/from-git', async (req: Request, res: Response) => {
|
||||
deployed,
|
||||
deployError,
|
||||
});
|
||||
if (deployed) {
|
||||
triggerPostDeployScan(stack_name, req.nodeId).catch(err =>
|
||||
console.error(`[Security] Post-deploy scan failed for ${stack_name}:`, err),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (fromGitDiag) {
|
||||
const code = error instanceof GitSourceError ? error.code : 'UNKNOWN';
|
||||
@@ -4791,6 +4868,9 @@ app.post('/api/stacks/:stackName/deploy', async (req: Request, res: Response) =>
|
||||
console.log(`[Stacks] Deploy completed: ${stackName}`);
|
||||
if (debug) console.debug(`[Stacks:debug] Deploy finished in ${Date.now() - t0}ms`);
|
||||
res.json({ message: 'Deployed successfully' });
|
||||
triggerPostDeployScan(stackName, req.nodeId).catch(err =>
|
||||
console.error(`[Security] Post-deploy scan failed for ${stackName}:`, err),
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
console.error(`[Stacks] Deploy failed: ${stackName}`, error);
|
||||
const rolledBack = LicenseService.getInstance().getTier() === 'paid';
|
||||
@@ -4910,6 +4990,9 @@ app.post('/api/stacks/:stackName/update', async (req: Request, res: Response) =>
|
||||
console.log(`[Stacks] Update completed: ${stackName}`);
|
||||
if (debug) console.debug(`[Stacks:debug] Update finished in ${Date.now() - t0}ms`);
|
||||
res.json({ status: 'Update completed' });
|
||||
triggerPostDeployScan(stackName, req.nodeId).catch(err =>
|
||||
console.error(`[Security] Post-deploy scan failed for ${stackName}:`, err),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`[Stacks] Update failed: ${stackName}`, error);
|
||||
const rolledBack = LicenseService.getInstance().getTier() === 'paid';
|
||||
@@ -6033,8 +6116,8 @@ app.post('/api/scheduled-tasks', (req: Request, res: Response): void => {
|
||||
if (!['stack', 'fleet', 'system'].includes(target_type)) {
|
||||
res.status(400).json({ error: 'Invalid target_type. Must be stack, fleet, or system.' }); return;
|
||||
}
|
||||
if (!['restart', 'snapshot', 'prune', 'update'].includes(action)) {
|
||||
res.status(400).json({ error: 'Invalid action. Must be restart, snapshot, prune, or update.' }); return;
|
||||
if (!['restart', 'snapshot', 'prune', 'update', 'scan'].includes(action)) {
|
||||
res.status(400).json({ error: 'Invalid action. Must be restart, snapshot, prune, update, or scan.' }); return;
|
||||
}
|
||||
// Tier gate based on action type
|
||||
if (!requireScheduledTaskTier(action, req, res)) return;
|
||||
@@ -6051,6 +6134,9 @@ app.post('/api/scheduled-tasks', (req: Request, res: Response): void => {
|
||||
if (action === 'prune' && target_type !== 'system') {
|
||||
res.status(400).json({ error: 'Prune action requires target_type "system".' }); return;
|
||||
}
|
||||
if (action === 'scan' && target_type !== 'system') {
|
||||
res.status(400).json({ error: 'Scan action requires target_type "system".' }); return;
|
||||
}
|
||||
if (target_type === 'stack' && (!target_id || !node_id)) {
|
||||
res.status(400).json({ error: 'Stack operations require target_id and node_id.' }); return;
|
||||
}
|
||||
@@ -6151,7 +6237,7 @@ app.put('/api/scheduled-tasks/:id', (req: Request, res: Response): void => {
|
||||
if (target_type && !['stack', 'fleet', 'system'].includes(target_type)) {
|
||||
res.status(400).json({ error: 'Invalid target_type' }); return;
|
||||
}
|
||||
if (action && !['restart', 'snapshot', 'prune', 'update'].includes(action)) {
|
||||
if (action && !['restart', 'snapshot', 'prune', 'update', 'scan'].includes(action)) {
|
||||
res.status(400).json({ error: 'Invalid action' }); return;
|
||||
}
|
||||
|
||||
@@ -6169,6 +6255,9 @@ app.put('/api/scheduled-tasks/:id', (req: Request, res: Response): void => {
|
||||
if (finalAction === 'prune' && finalTargetType !== 'system') {
|
||||
res.status(400).json({ error: 'Prune action requires target_type "system".' }); return;
|
||||
}
|
||||
if (finalAction === 'scan' && finalTargetType !== 'system') {
|
||||
res.status(400).json({ error: 'Scan action requires target_type "system".' }); return;
|
||||
}
|
||||
|
||||
// Validate prune targets
|
||||
const validPruneTargets = ['containers', 'images', 'networks', 'volumes'];
|
||||
@@ -6847,7 +6936,7 @@ app.post('/api/templates/refresh-cache', authMiddleware, (req: Request, res: Res
|
||||
app.post('/api/templates/deploy', authMiddleware, async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const { stackName, template, envVars } = req.body;
|
||||
const { stackName, template, envVars, skip_scan } = req.body;
|
||||
|
||||
if (!stackName || !template) {
|
||||
return res.status(400).json({ error: 'stackName and template are required' });
|
||||
@@ -6906,6 +6995,11 @@ app.post('/api/templates/deploy', authMiddleware, async (req: Request, res: Resp
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
console.log(`[Templates] Deploy completed: ${stackName}`);
|
||||
res.json({ success: true, message: 'Template deployed successfully' });
|
||||
if (!skip_scan) {
|
||||
triggerPostDeployScan(stackName, req.nodeId).catch(err =>
|
||||
console.error(`[Security] Post-deploy scan failed for ${stackName}:`, err),
|
||||
);
|
||||
}
|
||||
} catch (deployError: unknown) {
|
||||
const rawError = getErrorMessage(deployError, String(deployError));
|
||||
console.error(`[Templates] Deploy failed: ${stackName} -`, rawError);
|
||||
@@ -7151,6 +7245,299 @@ app.post('/api/auto-update/execute', authMiddleware, async (req: Request, res: R
|
||||
}
|
||||
});
|
||||
|
||||
// =========================
|
||||
// Vulnerability Scanning Routes
|
||||
// =========================
|
||||
|
||||
app.get('/api/security/trivy-status', authMiddleware, (_req: Request, res: Response) => {
|
||||
const svc = TrivyService.getInstance();
|
||||
res.json({ available: svc.isTrivyAvailable(), version: svc.getVersion() });
|
||||
});
|
||||
|
||||
app.post('/api/security/scan', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const svc = TrivyService.getInstance();
|
||||
if (!svc.isTrivyAvailable()) {
|
||||
res.status(503).json({ error: 'Trivy is not available on this host' });
|
||||
return;
|
||||
}
|
||||
const imageRef = typeof req.body?.imageRef === 'string' ? req.body.imageRef.trim() : '';
|
||||
if (!imageRef) {
|
||||
res.status(400).json({ error: 'imageRef is required' });
|
||||
return;
|
||||
}
|
||||
const stackContext = typeof req.body?.stackName === 'string' ? req.body.stackName : null;
|
||||
const force = req.body?.force === true;
|
||||
const nodeId = req.nodeId;
|
||||
if (svc.isScanning(nodeId, imageRef)) {
|
||||
res.status(409).json({ error: 'Already scanning this image' });
|
||||
return;
|
||||
}
|
||||
const db = DatabaseService.getInstance();
|
||||
const scanId = db.createVulnerabilityScan({
|
||||
node_id: nodeId,
|
||||
image_ref: imageRef,
|
||||
image_digest: null,
|
||||
scanned_at: Date.now(),
|
||||
total_vulnerabilities: 0,
|
||||
critical_count: 0,
|
||||
high_count: 0,
|
||||
medium_count: 0,
|
||||
low_count: 0,
|
||||
unknown_count: 0,
|
||||
fixable_count: 0,
|
||||
highest_severity: null,
|
||||
os_info: null,
|
||||
trivy_version: svc.getVersion(),
|
||||
scan_duration_ms: null,
|
||||
triggered_by: 'manual',
|
||||
status: 'in_progress',
|
||||
error: null,
|
||||
stack_context: stackContext,
|
||||
});
|
||||
res.status(202).json({ scanId });
|
||||
|
||||
const startedAt = Date.now();
|
||||
(async () => {
|
||||
try {
|
||||
const result = await svc.scanImage(imageRef, nodeId, { useCache: !force });
|
||||
db.updateVulnerabilityScan(scanId, {
|
||||
image_digest: result.imageDigest,
|
||||
scanned_at: result.scannedAt,
|
||||
total_vulnerabilities: result.totalVulnerabilities,
|
||||
critical_count: result.criticalCount,
|
||||
high_count: result.highCount,
|
||||
medium_count: result.mediumCount,
|
||||
low_count: result.lowCount,
|
||||
unknown_count: result.unknownCount,
|
||||
fixable_count: result.fixableCount,
|
||||
highest_severity: result.highestSeverity,
|
||||
os_info: result.metadata.os,
|
||||
trivy_version: result.metadata.trivyVersion,
|
||||
scan_duration_ms: result.metadata.scanDurationMs,
|
||||
status: 'completed',
|
||||
});
|
||||
db.insertVulnerabilityDetails(
|
||||
scanId,
|
||||
result.vulnerabilities.map((v) => ({
|
||||
vulnerability_id: v.vulnerabilityId,
|
||||
pkg_name: v.pkgName,
|
||||
installed_version: v.installedVersion,
|
||||
fixed_version: v.fixedVersion,
|
||||
severity: v.severity,
|
||||
title: v.title || null,
|
||||
description: v.description || null,
|
||||
primary_url: v.primaryUrl,
|
||||
})),
|
||||
);
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message || 'Scan failed';
|
||||
console.error(`[Security] Scan failed for ${imageRef}:`, msg);
|
||||
db.updateVulnerabilityScan(scanId, {
|
||||
status: 'failed',
|
||||
error: msg,
|
||||
scan_duration_ms: Date.now() - startedAt,
|
||||
});
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
app.get('/api/security/scans', authMiddleware, (req: Request, res: Response) => {
|
||||
try {
|
||||
const imageRef = typeof req.query.imageRef === 'string' ? req.query.imageRef : undefined;
|
||||
const limit = req.query.limit ? Number(req.query.limit) : undefined;
|
||||
const offset = req.query.offset ? Number(req.query.offset) : undefined;
|
||||
const result = DatabaseService.getInstance().getVulnerabilityScans(req.nodeId, {
|
||||
imageRef,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('[Security] Failed to list scans:', error);
|
||||
res.status(500).json({ error: 'Failed to list scans' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/security/scans/:scanId', authMiddleware, (req: Request, res: Response): void => {
|
||||
const scanId = Number(req.params.scanId);
|
||||
if (!Number.isFinite(scanId)) {
|
||||
res.status(400).json({ error: 'Invalid scan id' }); return;
|
||||
}
|
||||
const scan = DatabaseService.getInstance().getVulnerabilityScan(scanId);
|
||||
if (!scan || scan.node_id !== req.nodeId) {
|
||||
res.status(404).json({ error: 'Scan not found' }); return;
|
||||
}
|
||||
res.json(scan);
|
||||
});
|
||||
|
||||
app.get(
|
||||
'/api/security/scans/:scanId/vulnerabilities',
|
||||
authMiddleware,
|
||||
(req: Request, res: Response): void => {
|
||||
const scanId = Number(req.params.scanId);
|
||||
if (!Number.isFinite(scanId)) {
|
||||
res.status(400).json({ error: 'Invalid scan id' }); return;
|
||||
}
|
||||
const db = DatabaseService.getInstance();
|
||||
const scan = db.getVulnerabilityScan(scanId);
|
||||
if (!scan || scan.node_id !== req.nodeId) {
|
||||
res.status(404).json({ error: 'Scan not found' }); return;
|
||||
}
|
||||
const severity = typeof req.query.severity === 'string'
|
||||
? (req.query.severity.toUpperCase() as 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN')
|
||||
: undefined;
|
||||
const validSeverities = new Set(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'UNKNOWN']);
|
||||
if (severity && !validSeverities.has(severity)) {
|
||||
res.status(400).json({ error: 'Invalid severity filter' }); return;
|
||||
}
|
||||
const limit = req.query.limit ? Number(req.query.limit) : undefined;
|
||||
const offset = req.query.offset ? Number(req.query.offset) : undefined;
|
||||
const result = db.getVulnerabilityDetails(scanId, { severity, limit, offset });
|
||||
res.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
app.get('/api/security/image-summaries', authMiddleware, (req: Request, res: Response) => {
|
||||
try {
|
||||
const summaries = DatabaseService.getInstance().getImageScanSummaries(req.nodeId);
|
||||
res.json(summaries);
|
||||
} catch (error) {
|
||||
console.error('[Security] Failed to fetch image summaries:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch image summaries' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/security/sbom', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
const svc = TrivyService.getInstance();
|
||||
if (!svc.isTrivyAvailable()) {
|
||||
res.status(503).json({ error: 'Trivy is not available on this host' }); return;
|
||||
}
|
||||
const imageRef = typeof req.body?.imageRef === 'string' ? req.body.imageRef.trim() : '';
|
||||
const formatRaw = typeof req.body?.format === 'string' ? req.body.format : 'spdx-json';
|
||||
if (!imageRef) {
|
||||
res.status(400).json({ error: 'imageRef is required' }); return;
|
||||
}
|
||||
if (formatRaw !== 'spdx-json' && formatRaw !== 'cyclonedx') {
|
||||
res.status(400).json({ error: 'format must be spdx-json or cyclonedx' }); return;
|
||||
}
|
||||
try {
|
||||
const sbom = await svc.generateSBOM(imageRef, formatRaw as SbomFormat);
|
||||
const safeName = imageRef.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
const ext = formatRaw === 'spdx-json' ? 'spdx.json' : 'cdx.json';
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${safeName}.${ext}"`);
|
||||
res.send(sbom);
|
||||
} catch (error) {
|
||||
console.error('[Security] SBOM generation failed:', error);
|
||||
res.status(500).json({ error: (error as Error).message || 'Failed to generate SBOM' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/security/policies', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
res.json(DatabaseService.getInstance().getScanPolicies());
|
||||
});
|
||||
|
||||
app.post('/api/security/policies', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
const { name, node_id, stack_pattern, max_severity, block_on_deploy, enabled } = req.body ?? {};
|
||||
if (!name || typeof name !== 'string' || !name.trim()) {
|
||||
res.status(400).json({ error: 'Policy name is required' }); return;
|
||||
}
|
||||
const validSeverities = new Set(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']);
|
||||
if (!validSeverities.has(max_severity)) {
|
||||
res.status(400).json({ error: 'max_severity must be CRITICAL, HIGH, MEDIUM, or LOW' }); return;
|
||||
}
|
||||
try {
|
||||
const policy = DatabaseService.getInstance().createScanPolicy({
|
||||
name: name.trim(),
|
||||
node_id: node_id != null ? Number(node_id) : null,
|
||||
stack_pattern: stack_pattern ? String(stack_pattern) : null,
|
||||
max_severity,
|
||||
block_on_deploy: block_on_deploy ? 1 : 0,
|
||||
enabled: enabled === false ? 0 : 1,
|
||||
});
|
||||
res.status(201).json(policy);
|
||||
} catch (error) {
|
||||
console.error('[Security] Failed to create policy:', error);
|
||||
res.status(500).json({ error: 'Failed to create policy' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/security/policies/:id', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isFinite(id)) {
|
||||
res.status(400).json({ error: 'Invalid policy id' }); return;
|
||||
}
|
||||
const body = req.body ?? {};
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (body.name !== undefined) updates.name = String(body.name).trim();
|
||||
if (body.node_id !== undefined) updates.node_id = body.node_id != null ? Number(body.node_id) : null;
|
||||
if (body.stack_pattern !== undefined) updates.stack_pattern = body.stack_pattern ? String(body.stack_pattern) : null;
|
||||
if (body.max_severity !== undefined) {
|
||||
const validSeverities = new Set(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']);
|
||||
if (!validSeverities.has(body.max_severity)) {
|
||||
res.status(400).json({ error: 'max_severity must be CRITICAL, HIGH, MEDIUM, or LOW' }); return;
|
||||
}
|
||||
updates.max_severity = body.max_severity;
|
||||
}
|
||||
if (body.block_on_deploy !== undefined) updates.block_on_deploy = body.block_on_deploy ? 1 : 0;
|
||||
if (body.enabled !== undefined) updates.enabled = body.enabled ? 1 : 0;
|
||||
const policy = DatabaseService.getInstance().updateScanPolicy(id, updates);
|
||||
if (!policy) {
|
||||
res.status(404).json({ error: 'Policy not found' }); return;
|
||||
}
|
||||
res.json(policy);
|
||||
});
|
||||
|
||||
app.delete('/api/security/policies/:id', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isFinite(id)) {
|
||||
res.status(400).json({ error: 'Invalid policy id' }); return;
|
||||
}
|
||||
DatabaseService.getInstance().deleteScanPolicy(id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.get('/api/security/compare', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
const scanId1 = Number(req.query.scanId1);
|
||||
const scanId2 = Number(req.query.scanId2);
|
||||
if (!Number.isFinite(scanId1) || !Number.isFinite(scanId2)) {
|
||||
res.status(400).json({ error: 'scanId1 and scanId2 are required' }); return;
|
||||
}
|
||||
const db = DatabaseService.getInstance();
|
||||
const a = db.getVulnerabilityScan(scanId1);
|
||||
const b = db.getVulnerabilityScan(scanId2);
|
||||
if (!a || !b || a.node_id !== req.nodeId || b.node_id !== req.nodeId) {
|
||||
res.status(404).json({ error: 'One or both scans not found' }); return;
|
||||
}
|
||||
const aVulns = db.getVulnerabilityDetails(scanId1, { limit: 1000 }).items;
|
||||
const bVulns = db.getVulnerabilityDetails(scanId2, { limit: 1000 }).items;
|
||||
const keyOf = (v: { vulnerability_id: string; pkg_name: string }) =>
|
||||
`${v.vulnerability_id}::${v.pkg_name}`;
|
||||
const aMap = new Map(aVulns.map((v) => [keyOf(v), v]));
|
||||
const bMap = new Map(bVulns.map((v) => [keyOf(v), v]));
|
||||
const added = bVulns.filter((v) => !aMap.has(keyOf(v)));
|
||||
const removed = aVulns.filter((v) => !bMap.has(keyOf(v)));
|
||||
const unchanged = aVulns.filter((v) => bMap.has(keyOf(v)));
|
||||
res.json({
|
||||
scanA: { id: a.id, scanned_at: a.scanned_at, image_ref: a.image_ref },
|
||||
scanB: { id: b.id, scanned_at: b.scanned_at, image_ref: b.image_ref },
|
||||
added,
|
||||
removed,
|
||||
unchanged: unchanged.map((v) => ({ vulnerability_id: v.vulnerability_id, pkg_name: v.pkg_name, severity: v.severity })),
|
||||
});
|
||||
});
|
||||
|
||||
// =========================
|
||||
// Node Management API
|
||||
// =========================
|
||||
|
||||
@@ -33,6 +33,7 @@ export const CAPABILITIES = [
|
||||
'users',
|
||||
'registries',
|
||||
'self-update',
|
||||
'vulnerability-scanning',
|
||||
] as const;
|
||||
|
||||
export type Capability = (typeof CAPABILITIES)[number];
|
||||
|
||||
@@ -212,7 +212,7 @@ export interface ScheduledTask {
|
||||
target_type: 'stack' | 'fleet' | 'system';
|
||||
target_id: string | null;
|
||||
node_id: number | null;
|
||||
action: 'restart' | 'snapshot' | 'prune' | 'update';
|
||||
action: 'restart' | 'snapshot' | 'prune' | 'update' | 'scan';
|
||||
cron_expression: string;
|
||||
enabled: number;
|
||||
created_by: string;
|
||||
@@ -264,6 +264,72 @@ export interface NotificationRoute {
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export type VulnSeverity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN';
|
||||
export type VulnScanStatus = 'in_progress' | 'completed' | 'failed';
|
||||
export type VulnScanTrigger = 'manual' | 'scheduled' | 'deploy';
|
||||
|
||||
export interface VulnerabilityScan {
|
||||
id: number;
|
||||
node_id: number;
|
||||
image_ref: string;
|
||||
image_digest: string | null;
|
||||
scanned_at: number;
|
||||
total_vulnerabilities: number;
|
||||
critical_count: number;
|
||||
high_count: number;
|
||||
medium_count: number;
|
||||
low_count: number;
|
||||
unknown_count: number;
|
||||
fixable_count: number;
|
||||
highest_severity: VulnSeverity | null;
|
||||
os_info: string | null;
|
||||
trivy_version: string | null;
|
||||
scan_duration_ms: number | null;
|
||||
triggered_by: VulnScanTrigger;
|
||||
status: VulnScanStatus;
|
||||
error: string | null;
|
||||
stack_context: string | null;
|
||||
}
|
||||
|
||||
export interface VulnerabilityDetail {
|
||||
id: number;
|
||||
scan_id: number;
|
||||
vulnerability_id: string;
|
||||
pkg_name: string;
|
||||
installed_version: string;
|
||||
fixed_version: string | null;
|
||||
severity: VulnSeverity;
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
primary_url: string | null;
|
||||
}
|
||||
|
||||
export interface ScanPolicy {
|
||||
id: number;
|
||||
name: string;
|
||||
node_id: number | null;
|
||||
stack_pattern: string | null;
|
||||
max_severity: VulnSeverity;
|
||||
block_on_deploy: number;
|
||||
enabled: number;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface ScanSummary {
|
||||
image_ref: string;
|
||||
highest_severity: VulnSeverity | null;
|
||||
total: number;
|
||||
critical: number;
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
unknown: number;
|
||||
fixable: number;
|
||||
scanned_at: number;
|
||||
scan_id: number;
|
||||
}
|
||||
|
||||
export class DatabaseService {
|
||||
private static instance: DatabaseService;
|
||||
private db: Database.Database;
|
||||
@@ -490,6 +556,62 @@ export class DatabaseService {
|
||||
CREATE INDEX IF NOT EXISTS idx_scheduled_task_runs_status ON scheduled_task_runs(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_next_run ON scheduled_tasks(next_run_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vulnerability_scans (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
node_id INTEGER NOT NULL,
|
||||
image_ref TEXT NOT NULL,
|
||||
image_digest TEXT,
|
||||
scanned_at INTEGER NOT NULL,
|
||||
total_vulnerabilities INTEGER NOT NULL DEFAULT 0,
|
||||
critical_count INTEGER NOT NULL DEFAULT 0,
|
||||
high_count INTEGER NOT NULL DEFAULT 0,
|
||||
medium_count INTEGER NOT NULL DEFAULT 0,
|
||||
low_count INTEGER NOT NULL DEFAULT 0,
|
||||
unknown_count INTEGER NOT NULL DEFAULT 0,
|
||||
fixable_count INTEGER NOT NULL DEFAULT 0,
|
||||
highest_severity TEXT,
|
||||
os_info TEXT,
|
||||
trivy_version TEXT,
|
||||
scan_duration_ms INTEGER,
|
||||
triggered_by TEXT NOT NULL DEFAULT 'manual',
|
||||
status TEXT NOT NULL DEFAULT 'completed',
|
||||
error TEXT,
|
||||
stack_context TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_vuln_scans_node_image ON vulnerability_scans(node_id, image_ref);
|
||||
CREATE INDEX IF NOT EXISTS idx_vuln_scans_digest ON vulnerability_scans(image_digest);
|
||||
CREATE INDEX IF NOT EXISTS idx_vuln_scans_scanned_at ON vulnerability_scans(scanned_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vulnerability_details (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scan_id INTEGER NOT NULL,
|
||||
vulnerability_id TEXT NOT NULL,
|
||||
pkg_name TEXT NOT NULL,
|
||||
installed_version TEXT NOT NULL,
|
||||
fixed_version TEXT,
|
||||
severity TEXT NOT NULL,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
primary_url TEXT,
|
||||
FOREIGN KEY(scan_id) REFERENCES vulnerability_scans(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_vuln_details_scan ON vulnerability_details(scan_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_vuln_details_severity ON vulnerability_details(severity);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scan_policies (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
node_id INTEGER,
|
||||
stack_pattern TEXT,
|
||||
max_severity TEXT NOT NULL DEFAULT 'CRITICAL',
|
||||
block_on_deploy INTEGER NOT NULL DEFAULT 0,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stack_labels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
node_id INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -1902,6 +2024,350 @@ export class DatabaseService {
|
||||
this.db.prepare('DELETE FROM scheduled_task_runs WHERE started_at < ?').run(cutoff);
|
||||
}
|
||||
|
||||
// --- Vulnerability Scans ---
|
||||
|
||||
public createVulnerabilityScan(
|
||||
scan: Omit<VulnerabilityScan, 'id'>,
|
||||
): number {
|
||||
const stmt = this.db.prepare(
|
||||
`INSERT INTO vulnerability_scans (
|
||||
node_id, image_ref, image_digest, scanned_at,
|
||||
total_vulnerabilities, critical_count, high_count, medium_count,
|
||||
low_count, unknown_count, fixable_count, highest_severity,
|
||||
os_info, trivy_version, scan_duration_ms, triggered_by, status,
|
||||
error, stack_context
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
);
|
||||
const result = stmt.run(
|
||||
scan.node_id,
|
||||
scan.image_ref,
|
||||
scan.image_digest,
|
||||
scan.scanned_at,
|
||||
scan.total_vulnerabilities,
|
||||
scan.critical_count,
|
||||
scan.high_count,
|
||||
scan.medium_count,
|
||||
scan.low_count,
|
||||
scan.unknown_count,
|
||||
scan.fixable_count,
|
||||
scan.highest_severity,
|
||||
scan.os_info,
|
||||
scan.trivy_version,
|
||||
scan.scan_duration_ms,
|
||||
scan.triggered_by,
|
||||
scan.status,
|
||||
scan.error,
|
||||
scan.stack_context,
|
||||
);
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
|
||||
public updateVulnerabilityScan(
|
||||
id: number,
|
||||
updates: Partial<Omit<VulnerabilityScan, 'id'>>,
|
||||
): void {
|
||||
const ALLOWED_COLUMNS = new Set([
|
||||
'node_id', 'image_ref', 'image_digest', 'scanned_at',
|
||||
'total_vulnerabilities', 'critical_count', 'high_count',
|
||||
'medium_count', 'low_count', 'unknown_count', 'fixable_count',
|
||||
'highest_severity', 'os_info', 'trivy_version', 'scan_duration_ms',
|
||||
'triggered_by', 'status', 'error', 'stack_context',
|
||||
]);
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (!ALLOWED_COLUMNS.has(key)) continue;
|
||||
fields.push(`${key} = ?`);
|
||||
values.push(value);
|
||||
}
|
||||
if (fields.length === 0) return;
|
||||
values.push(id);
|
||||
this.db
|
||||
.prepare(`UPDATE vulnerability_scans SET ${fields.join(', ')} WHERE id = ?`)
|
||||
.run(...(values as never[]));
|
||||
}
|
||||
|
||||
public getVulnerabilityScan(id: number): VulnerabilityScan | null {
|
||||
return (
|
||||
(this.db
|
||||
.prepare('SELECT * FROM vulnerability_scans WHERE id = ?')
|
||||
.get(id) as VulnerabilityScan | undefined) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
public getVulnerabilityScans(
|
||||
nodeId: number,
|
||||
opts: { imageRef?: string; limit?: number; offset?: number } = {},
|
||||
): { items: VulnerabilityScan[]; total: number } {
|
||||
const limit = Math.max(1, Math.min(opts.limit ?? 50, 500));
|
||||
const offset = Math.max(0, opts.offset ?? 0);
|
||||
const where = ['node_id = ?'];
|
||||
const params: unknown[] = [nodeId];
|
||||
if (opts.imageRef) {
|
||||
where.push('image_ref = ?');
|
||||
params.push(opts.imageRef);
|
||||
}
|
||||
const whereSql = where.join(' AND ');
|
||||
const total = (
|
||||
this.db
|
||||
.prepare(`SELECT COUNT(*) as cnt FROM vulnerability_scans WHERE ${whereSql}`)
|
||||
.get(...(params as never[])) as { cnt: number }
|
||||
).cnt;
|
||||
const items = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM vulnerability_scans WHERE ${whereSql} ORDER BY scanned_at DESC LIMIT ? OFFSET ?`,
|
||||
)
|
||||
.all(...(params as never[]), limit, offset) as VulnerabilityScan[];
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
public getLatestScanForImage(
|
||||
nodeId: number,
|
||||
imageRef: string,
|
||||
): VulnerabilityScan | null {
|
||||
return (
|
||||
(this.db
|
||||
.prepare(
|
||||
'SELECT * FROM vulnerability_scans WHERE node_id = ? AND image_ref = ? ORDER BY scanned_at DESC LIMIT 1',
|
||||
)
|
||||
.get(nodeId, imageRef) as VulnerabilityScan | undefined) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
public getLatestScanByDigest(digest: string): VulnerabilityScan | null {
|
||||
if (!digest) return null;
|
||||
return (
|
||||
(this.db
|
||||
.prepare(
|
||||
"SELECT * FROM vulnerability_scans WHERE image_digest = ? AND status = 'completed' ORDER BY scanned_at DESC LIMIT 1",
|
||||
)
|
||||
.get(digest) as VulnerabilityScan | undefined) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
public deleteOldScans(olderThanMs: number): number {
|
||||
const cutoff = Date.now() - olderThanMs;
|
||||
const result = this.db
|
||||
.prepare('DELETE FROM vulnerability_scans WHERE scanned_at < ?')
|
||||
.run(cutoff);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
public isImageBeingScanned(nodeId: number, imageRef: string): boolean {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
"SELECT id FROM vulnerability_scans WHERE node_id = ? AND image_ref = ? AND status = 'in_progress' LIMIT 1",
|
||||
)
|
||||
.get(nodeId, imageRef);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
public insertVulnerabilityDetails(
|
||||
scanId: number,
|
||||
details: Array<Omit<VulnerabilityDetail, 'id' | 'scan_id'>>,
|
||||
): void {
|
||||
if (details.length === 0) return;
|
||||
const stmt = this.db.prepare(
|
||||
`INSERT INTO vulnerability_details (
|
||||
scan_id, vulnerability_id, pkg_name, installed_version,
|
||||
fixed_version, severity, title, description, primary_url
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
);
|
||||
const txn = this.db.transaction((rows: typeof details) => {
|
||||
for (const d of rows) {
|
||||
stmt.run(
|
||||
scanId,
|
||||
d.vulnerability_id,
|
||||
d.pkg_name,
|
||||
d.installed_version,
|
||||
d.fixed_version,
|
||||
d.severity,
|
||||
d.title,
|
||||
d.description,
|
||||
d.primary_url,
|
||||
);
|
||||
}
|
||||
});
|
||||
txn(details);
|
||||
}
|
||||
|
||||
public getVulnerabilityDetails(
|
||||
scanId: number,
|
||||
opts: { severity?: VulnSeverity; limit?: number; offset?: number } = {},
|
||||
): { items: VulnerabilityDetail[]; total: number } {
|
||||
const limit = Math.max(1, Math.min(opts.limit ?? 100, 1000));
|
||||
const offset = Math.max(0, opts.offset ?? 0);
|
||||
const where = ['scan_id = ?'];
|
||||
const params: unknown[] = [scanId];
|
||||
if (opts.severity) {
|
||||
where.push('severity = ?');
|
||||
params.push(opts.severity);
|
||||
}
|
||||
const whereSql = where.join(' AND ');
|
||||
const total = (
|
||||
this.db
|
||||
.prepare(`SELECT COUNT(*) as cnt FROM vulnerability_details WHERE ${whereSql}`)
|
||||
.get(...(params as never[])) as { cnt: number }
|
||||
).cnt;
|
||||
const severityOrder = `CASE severity
|
||||
WHEN 'CRITICAL' THEN 0
|
||||
WHEN 'HIGH' THEN 1
|
||||
WHEN 'MEDIUM' THEN 2
|
||||
WHEN 'LOW' THEN 3
|
||||
ELSE 4 END`;
|
||||
const items = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM vulnerability_details WHERE ${whereSql} ORDER BY ${severityOrder}, pkg_name LIMIT ? OFFSET ?`,
|
||||
)
|
||||
.all(...(params as never[]), limit, offset) as VulnerabilityDetail[];
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
public getImageScanSummaries(nodeId: number): Record<string, ScanSummary> {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT vs.image_ref, vs.id as scan_id, vs.highest_severity, vs.total_vulnerabilities,
|
||||
vs.critical_count, vs.high_count, vs.medium_count, vs.low_count,
|
||||
vs.unknown_count, vs.fixable_count, vs.scanned_at
|
||||
FROM vulnerability_scans vs
|
||||
INNER JOIN (
|
||||
SELECT image_ref, MAX(scanned_at) AS max_scanned
|
||||
FROM vulnerability_scans
|
||||
WHERE node_id = ? AND status = 'completed'
|
||||
GROUP BY image_ref
|
||||
) latest ON latest.image_ref = vs.image_ref AND latest.max_scanned = vs.scanned_at
|
||||
WHERE vs.node_id = ? AND vs.status = 'completed'`,
|
||||
)
|
||||
.all(nodeId, nodeId) as Array<{
|
||||
image_ref: string;
|
||||
scan_id: number;
|
||||
highest_severity: VulnSeverity | null;
|
||||
total_vulnerabilities: number;
|
||||
critical_count: number;
|
||||
high_count: number;
|
||||
medium_count: number;
|
||||
low_count: number;
|
||||
unknown_count: number;
|
||||
fixable_count: number;
|
||||
scanned_at: number;
|
||||
}>;
|
||||
const out: Record<string, ScanSummary> = {};
|
||||
for (const r of rows) {
|
||||
out[r.image_ref] = {
|
||||
image_ref: r.image_ref,
|
||||
highest_severity: r.highest_severity,
|
||||
total: r.total_vulnerabilities,
|
||||
critical: r.critical_count,
|
||||
high: r.high_count,
|
||||
medium: r.medium_count,
|
||||
low: r.low_count,
|
||||
unknown: r.unknown_count,
|
||||
fixable: r.fixable_count,
|
||||
scanned_at: r.scanned_at,
|
||||
scan_id: r.scan_id,
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- Scan Policies ---
|
||||
|
||||
public getScanPolicies(): ScanPolicy[] {
|
||||
return this.db
|
||||
.prepare('SELECT * FROM scan_policies ORDER BY created_at DESC')
|
||||
.all() as ScanPolicy[];
|
||||
}
|
||||
|
||||
public getScanPolicy(id: number): ScanPolicy | null {
|
||||
return (
|
||||
(this.db
|
||||
.prepare('SELECT * FROM scan_policies WHERE id = ?')
|
||||
.get(id) as ScanPolicy | undefined) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
public createScanPolicy(
|
||||
policy: Omit<ScanPolicy, 'id' | 'created_at' | 'updated_at'>,
|
||||
): ScanPolicy {
|
||||
const now = Date.now();
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`INSERT INTO scan_policies (name, node_id, stack_pattern, max_severity, block_on_deploy, enabled, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
policy.name,
|
||||
policy.node_id,
|
||||
policy.stack_pattern,
|
||||
policy.max_severity,
|
||||
policy.block_on_deploy,
|
||||
policy.enabled,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
return { ...policy, id: result.lastInsertRowid as number, created_at: now, updated_at: now };
|
||||
}
|
||||
|
||||
public updateScanPolicy(
|
||||
id: number,
|
||||
updates: Partial<Omit<ScanPolicy, 'id' | 'created_at' | 'updated_at'>>,
|
||||
): ScanPolicy | null {
|
||||
const existing = this.getScanPolicy(id);
|
||||
if (!existing) return null;
|
||||
const ALLOWED_COLUMNS = new Set([
|
||||
'name', 'node_id', 'stack_pattern', 'max_severity',
|
||||
'block_on_deploy', 'enabled',
|
||||
]);
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (!ALLOWED_COLUMNS.has(key)) continue;
|
||||
fields.push(`${key} = ?`);
|
||||
values.push(value);
|
||||
}
|
||||
if (fields.length === 0) return existing;
|
||||
fields.push('updated_at = ?');
|
||||
values.push(Date.now());
|
||||
values.push(id);
|
||||
this.db
|
||||
.prepare(`UPDATE scan_policies SET ${fields.join(', ')} WHERE id = ?`)
|
||||
.run(...(values as never[]));
|
||||
return this.getScanPolicy(id);
|
||||
}
|
||||
|
||||
public deleteScanPolicy(id: number): void {
|
||||
this.db.prepare('DELETE FROM scan_policies WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
public getMatchingPolicy(
|
||||
nodeId: number,
|
||||
stackName: string | null,
|
||||
): ScanPolicy | null {
|
||||
const policies = this.db
|
||||
.prepare(
|
||||
'SELECT * FROM scan_policies WHERE enabled = 1 AND (node_id IS NULL OR node_id = ?)',
|
||||
)
|
||||
.all(nodeId) as ScanPolicy[];
|
||||
const matchesStack = (pattern: string | null): boolean => {
|
||||
if (!pattern) return true;
|
||||
if (!stackName) return false;
|
||||
const regex = new RegExp(
|
||||
'^' + pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$',
|
||||
);
|
||||
return regex.test(stackName);
|
||||
};
|
||||
const scoped = policies.filter((p) => matchesStack(p.stack_pattern));
|
||||
if (scoped.length === 0) return null;
|
||||
scoped.sort((a, b) => {
|
||||
if (a.node_id && !b.node_id) return -1;
|
||||
if (!a.node_id && b.node_id) return 1;
|
||||
if (a.stack_pattern && !b.stack_pattern) return -1;
|
||||
if (!a.stack_pattern && b.stack_pattern) return 1;
|
||||
return 0;
|
||||
});
|
||||
return scoped[0];
|
||||
}
|
||||
|
||||
// --- Stack Labels ---
|
||||
|
||||
public getLabels(nodeId: number): Label[] {
|
||||
|
||||
@@ -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('; ');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import DockerController from './DockerController';
|
||||
import {
|
||||
DatabaseService,
|
||||
VulnSeverity,
|
||||
VulnScanTrigger,
|
||||
VulnerabilityScan,
|
||||
} from './DatabaseService';
|
||||
import { RegistryService } from './RegistryService';
|
||||
import { disableCapability } from './CapabilityRegistry';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const SEVERITY_ORDER: VulnSeverity[] = ['UNKNOWN', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL'];
|
||||
const SCAN_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
const SBOM_TIMEOUT_MS = 3 * 60 * 1000;
|
||||
const DIGEST_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
interface TrivyRawVulnerability {
|
||||
VulnerabilityID?: string;
|
||||
PkgName?: string;
|
||||
InstalledVersion?: string;
|
||||
FixedVersion?: string;
|
||||
Severity?: string;
|
||||
Title?: string;
|
||||
Description?: string;
|
||||
PrimaryURL?: string;
|
||||
}
|
||||
|
||||
interface TrivyRawResult {
|
||||
Target?: string;
|
||||
Vulnerabilities?: TrivyRawVulnerability[];
|
||||
}
|
||||
|
||||
interface TrivyRawOutput {
|
||||
Metadata?: {
|
||||
OS?: { Family?: string; Name?: string };
|
||||
ImageID?: string;
|
||||
RepoDigests?: string[];
|
||||
};
|
||||
Results?: TrivyRawResult[];
|
||||
}
|
||||
|
||||
export interface TrivyVulnerability {
|
||||
vulnerabilityId: string;
|
||||
pkgName: string;
|
||||
installedVersion: string;
|
||||
fixedVersion: string | null;
|
||||
severity: VulnSeverity;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryUrl: string | null;
|
||||
}
|
||||
|
||||
export interface TrivyScanResult {
|
||||
imageRef: string;
|
||||
imageDigest: string | null;
|
||||
scannedAt: number;
|
||||
totalVulnerabilities: number;
|
||||
criticalCount: number;
|
||||
highCount: number;
|
||||
mediumCount: number;
|
||||
lowCount: number;
|
||||
unknownCount: number;
|
||||
fixableCount: number;
|
||||
highestSeverity: VulnSeverity | null;
|
||||
vulnerabilities: TrivyVulnerability[];
|
||||
metadata: {
|
||||
os: string | null;
|
||||
trivyVersion: string | null;
|
||||
scanDurationMs: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type SbomFormat = 'spdx-json' | 'cyclonedx';
|
||||
|
||||
function normalizeSeverity(raw: string | undefined): VulnSeverity {
|
||||
const s = (raw ?? '').toUpperCase();
|
||||
if (s === 'CRITICAL' || s === 'HIGH' || s === 'MEDIUM' || s === 'LOW') return s;
|
||||
return 'UNKNOWN';
|
||||
}
|
||||
|
||||
function computeHighestSeverity(vulns: TrivyVulnerability[]): VulnSeverity | null {
|
||||
if (vulns.length === 0) return null;
|
||||
let highestIdx = -1;
|
||||
for (const v of vulns) {
|
||||
const idx = SEVERITY_ORDER.indexOf(v.severity);
|
||||
if (idx > highestIdx) highestIdx = idx;
|
||||
}
|
||||
return highestIdx >= 0 ? SEVERITY_ORDER[highestIdx] : null;
|
||||
}
|
||||
|
||||
class TrivyService {
|
||||
private static instance: TrivyService;
|
||||
private available = false;
|
||||
private version: string | null = null;
|
||||
private detectionTimestamp = 0;
|
||||
private scanningImages: Set<string> = new Set();
|
||||
|
||||
public static getInstance(): TrivyService {
|
||||
if (!TrivyService.instance) {
|
||||
TrivyService.instance = new TrivyService();
|
||||
}
|
||||
return TrivyService.instance;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
await this.detectTrivy();
|
||||
if (!this.available) {
|
||||
disableCapability('vulnerability-scanning');
|
||||
console.log('[Trivy] Binary not found on PATH; vulnerability scanning disabled');
|
||||
} else {
|
||||
console.log(`[Trivy] Available (version ${this.version})`);
|
||||
}
|
||||
}
|
||||
|
||||
async detectTrivy(): Promise<{ available: boolean; version: string | null }> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('trivy', ['--version'], { timeout: 5000 });
|
||||
const match = stdout.match(/Version:\s*([^\s\n]+)/i);
|
||||
this.version = match ? match[1] : stdout.split('\n')[0]?.trim() || 'unknown';
|
||||
this.available = true;
|
||||
} catch {
|
||||
this.available = false;
|
||||
this.version = null;
|
||||
}
|
||||
this.detectionTimestamp = Date.now();
|
||||
return { available: this.available, version: this.version };
|
||||
}
|
||||
|
||||
isTrivyAvailable(): boolean {
|
||||
return this.available;
|
||||
}
|
||||
|
||||
getVersion(): string | null {
|
||||
return this.version;
|
||||
}
|
||||
|
||||
invalidateDetection(): void {
|
||||
this.detectionTimestamp = 0;
|
||||
}
|
||||
|
||||
private async buildEnv(
|
||||
sendWarning?: (msg: string) => void,
|
||||
): Promise<{ env: Record<string, string | undefined>; cleanup: () => void }> {
|
||||
const registries = DatabaseService.getInstance().getRegistries();
|
||||
const baseEnv: Record<string, string | undefined> = {
|
||||
...process.env,
|
||||
PATH:
|
||||
process.env.PATH ||
|
||||
'/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
|
||||
};
|
||||
if (registries.length === 0) {
|
||||
return { env: baseEnv, cleanup: () => undefined };
|
||||
}
|
||||
const { config, warnings } = await RegistryService.getInstance().resolveDockerConfig();
|
||||
if (sendWarning) {
|
||||
for (const w of warnings) sendWarning(w);
|
||||
}
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-trivy-'));
|
||||
const configPath = path.join(tmpDir, 'config.json');
|
||||
fs.writeFileSync(configPath, JSON.stringify(config), { mode: 0o600 });
|
||||
const cleanup = () => {
|
||||
try {
|
||||
fs.unlinkSync(configPath);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
try {
|
||||
fs.rmdirSync(tmpDir);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
};
|
||||
return { env: { ...baseEnv, DOCKER_CONFIG: tmpDir }, cleanup };
|
||||
}
|
||||
|
||||
async getImageDigest(imageRef: string, nodeId: number): Promise<string | null> {
|
||||
try {
|
||||
const docker = DockerController.getInstance(nodeId).getDocker();
|
||||
const info = (await docker.getImage(imageRef).inspect()) as {
|
||||
RepoDigests?: string[];
|
||||
Id?: string;
|
||||
};
|
||||
if (info.RepoDigests && info.RepoDigests.length > 0) {
|
||||
const digest = info.RepoDigests[0].split('@')[1];
|
||||
if (digest) return digest;
|
||||
}
|
||||
return info.Id ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private scanKey(nodeId: number, imageRef: string): string {
|
||||
return `${nodeId}:${imageRef}`;
|
||||
}
|
||||
|
||||
isScanning(nodeId: number, imageRef: string): boolean {
|
||||
return this.scanningImages.has(this.scanKey(nodeId, imageRef));
|
||||
}
|
||||
|
||||
private parseTrivyOutput(raw: string): {
|
||||
vulnerabilities: TrivyVulnerability[];
|
||||
os: string | null;
|
||||
} {
|
||||
let parsed: TrivyRawOutput;
|
||||
try {
|
||||
parsed = JSON.parse(raw) as TrivyRawOutput;
|
||||
} catch (e) {
|
||||
console.error('[Trivy] Failed to parse output; first 200 chars:', raw.slice(0, 200));
|
||||
throw new Error('Malformed Trivy output: ' + (e as Error).message);
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const vulnerabilities: TrivyVulnerability[] = [];
|
||||
for (const result of parsed.Results ?? []) {
|
||||
for (const v of result.Vulnerabilities ?? []) {
|
||||
const id = v.VulnerabilityID ?? '';
|
||||
const pkg = v.PkgName ?? '';
|
||||
if (!id || !pkg) continue;
|
||||
const key = `${id}::${pkg}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
vulnerabilities.push({
|
||||
vulnerabilityId: id,
|
||||
pkgName: pkg,
|
||||
installedVersion: v.InstalledVersion ?? '',
|
||||
fixedVersion: v.FixedVersion ? v.FixedVersion : null,
|
||||
severity: normalizeSeverity(v.Severity),
|
||||
title: v.Title ?? '',
|
||||
description: v.Description ?? '',
|
||||
primaryUrl: v.PrimaryURL ? v.PrimaryURL : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
const osFamily = parsed.Metadata?.OS?.Family;
|
||||
const osName = parsed.Metadata?.OS?.Name;
|
||||
const osInfo = osFamily
|
||||
? osName
|
||||
? `${osFamily} ${osName}`
|
||||
: osFamily
|
||||
: null;
|
||||
return { vulnerabilities, os: osInfo };
|
||||
}
|
||||
|
||||
async scanImage(
|
||||
imageRef: string,
|
||||
nodeId: number,
|
||||
options: { useCache?: boolean; digest?: string | null } = {},
|
||||
): Promise<TrivyScanResult> {
|
||||
if (!this.available) {
|
||||
throw new Error('Trivy is not available on this host');
|
||||
}
|
||||
const key = this.scanKey(nodeId, imageRef);
|
||||
if (this.scanningImages.has(key)) {
|
||||
throw new Error('Already scanning this image');
|
||||
}
|
||||
this.scanningImages.add(key);
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
const digest = options.digest ?? (await this.getImageDigest(imageRef, nodeId));
|
||||
|
||||
if (options.useCache !== false && digest) {
|
||||
const cached = DatabaseService.getInstance().getLatestScanByDigest(digest);
|
||||
if (cached && startedAt - cached.scanned_at < DIGEST_CACHE_TTL_MS) {
|
||||
const details =
|
||||
DatabaseService.getInstance().getVulnerabilityDetails(cached.id, {
|
||||
limit: 1000,
|
||||
}).items;
|
||||
return {
|
||||
imageRef,
|
||||
imageDigest: digest,
|
||||
scannedAt: cached.scanned_at,
|
||||
totalVulnerabilities: cached.total_vulnerabilities,
|
||||
criticalCount: cached.critical_count,
|
||||
highCount: cached.high_count,
|
||||
mediumCount: cached.medium_count,
|
||||
lowCount: cached.low_count,
|
||||
unknownCount: cached.unknown_count,
|
||||
fixableCount: cached.fixable_count,
|
||||
highestSeverity: cached.highest_severity,
|
||||
vulnerabilities: details.map((d) => ({
|
||||
vulnerabilityId: d.vulnerability_id,
|
||||
pkgName: d.pkg_name,
|
||||
installedVersion: d.installed_version,
|
||||
fixedVersion: d.fixed_version,
|
||||
severity: d.severity,
|
||||
title: d.title ?? '',
|
||||
description: d.description ?? '',
|
||||
primaryUrl: d.primary_url,
|
||||
})),
|
||||
metadata: {
|
||||
os: cached.os_info,
|
||||
trivyVersion: cached.trivy_version,
|
||||
scanDurationMs: cached.scan_duration_ms ?? 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const { env, cleanup } = await this.buildEnv();
|
||||
try {
|
||||
const args = [
|
||||
'image',
|
||||
'--format',
|
||||
'json',
|
||||
'--quiet',
|
||||
'--no-progress',
|
||||
'--scanners',
|
||||
'vuln',
|
||||
imageRef,
|
||||
];
|
||||
const { stdout } = await execFileAsync('trivy', args, {
|
||||
env,
|
||||
timeout: SCAN_TIMEOUT_MS,
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
const { vulnerabilities, os: osInfo } = this.parseTrivyOutput(stdout);
|
||||
|
||||
let critical = 0,
|
||||
high = 0,
|
||||
medium = 0,
|
||||
low = 0,
|
||||
unknown = 0,
|
||||
fixable = 0;
|
||||
for (const v of vulnerabilities) {
|
||||
switch (v.severity) {
|
||||
case 'CRITICAL':
|
||||
critical++;
|
||||
break;
|
||||
case 'HIGH':
|
||||
high++;
|
||||
break;
|
||||
case 'MEDIUM':
|
||||
medium++;
|
||||
break;
|
||||
case 'LOW':
|
||||
low++;
|
||||
break;
|
||||
default:
|
||||
unknown++;
|
||||
}
|
||||
if (v.fixedVersion) fixable++;
|
||||
}
|
||||
|
||||
return {
|
||||
imageRef,
|
||||
imageDigest: digest,
|
||||
scannedAt: Date.now(),
|
||||
totalVulnerabilities: vulnerabilities.length,
|
||||
criticalCount: critical,
|
||||
highCount: high,
|
||||
mediumCount: medium,
|
||||
lowCount: low,
|
||||
unknownCount: unknown,
|
||||
fixableCount: fixable,
|
||||
highestSeverity: computeHighestSeverity(vulnerabilities),
|
||||
vulnerabilities,
|
||||
metadata: {
|
||||
os: osInfo,
|
||||
trivyVersion: this.version,
|
||||
scanDurationMs: Date.now() - startedAt,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
} finally {
|
||||
this.scanningImages.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
async runScanAndPersist(
|
||||
imageRef: string,
|
||||
nodeId: number,
|
||||
triggeredBy: VulnScanTrigger,
|
||||
stackContext: string | null = null,
|
||||
): Promise<VulnerabilityScan> {
|
||||
const db = DatabaseService.getInstance();
|
||||
const startedAt = Date.now();
|
||||
const scanId = db.createVulnerabilityScan({
|
||||
node_id: nodeId,
|
||||
image_ref: imageRef,
|
||||
image_digest: null,
|
||||
scanned_at: Date.now(),
|
||||
total_vulnerabilities: 0,
|
||||
critical_count: 0,
|
||||
high_count: 0,
|
||||
medium_count: 0,
|
||||
low_count: 0,
|
||||
unknown_count: 0,
|
||||
fixable_count: 0,
|
||||
highest_severity: null,
|
||||
os_info: null,
|
||||
trivy_version: this.version,
|
||||
scan_duration_ms: null,
|
||||
triggered_by: triggeredBy,
|
||||
status: 'in_progress',
|
||||
error: null,
|
||||
stack_context: stackContext,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await this.scanImage(imageRef, nodeId);
|
||||
db.updateVulnerabilityScan(scanId, {
|
||||
image_digest: result.imageDigest,
|
||||
scanned_at: result.scannedAt,
|
||||
total_vulnerabilities: result.totalVulnerabilities,
|
||||
critical_count: result.criticalCount,
|
||||
high_count: result.highCount,
|
||||
medium_count: result.mediumCount,
|
||||
low_count: result.lowCount,
|
||||
unknown_count: result.unknownCount,
|
||||
fixable_count: result.fixableCount,
|
||||
highest_severity: result.highestSeverity,
|
||||
os_info: result.metadata.os,
|
||||
trivy_version: result.metadata.trivyVersion,
|
||||
scan_duration_ms: result.metadata.scanDurationMs,
|
||||
status: 'completed',
|
||||
});
|
||||
db.insertVulnerabilityDetails(
|
||||
scanId,
|
||||
result.vulnerabilities.map((v) => ({
|
||||
vulnerability_id: v.vulnerabilityId,
|
||||
pkg_name: v.pkgName,
|
||||
installed_version: v.installedVersion,
|
||||
fixed_version: v.fixedVersion,
|
||||
severity: v.severity,
|
||||
title: v.title || null,
|
||||
description: v.description || null,
|
||||
primary_url: v.primaryUrl,
|
||||
})),
|
||||
);
|
||||
const stored = db.getVulnerabilityScan(scanId);
|
||||
if (!stored) throw new Error('Scan vanished after write');
|
||||
return stored;
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message || 'Scan failed';
|
||||
db.updateVulnerabilityScan(scanId, {
|
||||
status: 'failed',
|
||||
error: msg,
|
||||
scan_duration_ms: Date.now() - startedAt,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async scanAllNodeImages(
|
||||
nodeId: number,
|
||||
triggeredBy: VulnScanTrigger = 'scheduled',
|
||||
): Promise<{ scanned: number; skipped: number; failed: number }> {
|
||||
if (!this.available) {
|
||||
throw new Error('Trivy is not available on this host');
|
||||
}
|
||||
const images = await DockerController.getInstance(nodeId).getImages();
|
||||
const imageRefs = new Set<string>();
|
||||
for (const img of images as Array<{ RepoTags?: string[] }>) {
|
||||
for (const tag of img.RepoTags ?? []) {
|
||||
if (tag && tag !== '<none>:<none>') imageRefs.add(tag);
|
||||
}
|
||||
}
|
||||
|
||||
let scanned = 0;
|
||||
let skipped = 0;
|
||||
let failed = 0;
|
||||
for (const ref of imageRefs) {
|
||||
try {
|
||||
const digest = await this.getImageDigest(ref, nodeId);
|
||||
if (digest) {
|
||||
const cached =
|
||||
DatabaseService.getInstance().getLatestScanByDigest(digest);
|
||||
if (cached && Date.now() - cached.scanned_at < DIGEST_CACHE_TTL_MS) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
await this.runScanAndPersist(ref, nodeId, triggeredBy, null);
|
||||
scanned++;
|
||||
} catch (err) {
|
||||
failed++;
|
||||
console.warn(`[Trivy] Failed to scan ${ref}:`, (err as Error).message);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
}
|
||||
return { scanned, skipped, failed };
|
||||
}
|
||||
|
||||
async generateSBOM(imageRef: string, format: SbomFormat): Promise<string> {
|
||||
if (!this.available) {
|
||||
throw new Error('Trivy is not available on this host');
|
||||
}
|
||||
const { env, cleanup } = await this.buildEnv();
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'trivy',
|
||||
['image', '--format', format, '--quiet', '--no-progress', imageRef],
|
||||
{
|
||||
env,
|
||||
timeout: SBOM_TIMEOUT_MS,
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
return stdout;
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default TrivyService;
|
||||
@@ -115,6 +115,7 @@
|
||||
"features/audit-log",
|
||||
"features/api-tokens",
|
||||
"features/private-registries",
|
||||
"features/vulnerability-scanning",
|
||||
"features/auto-update-policies",
|
||||
"features/scheduled-operations",
|
||||
"features/sso",
|
||||
@@ -140,6 +141,7 @@
|
||||
"operations/backup",
|
||||
"operations/upgrade",
|
||||
"operations/self-hosting",
|
||||
"operations/trivy-setup",
|
||||
"operations/two-factor-admin"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -103,6 +103,10 @@ Create point-in-time snapshots of every compose file and environment file across
|
||||
|
||||
Store credentials for private Docker registries: Docker Hub organizations, GHCR, AWS ECR, and self-hosted registries. Sencho injects them automatically during deploy and pull operations. ECR short-lived tokens are refreshed on every operation. Admiral only. [Learn more →](/features/private-registries)
|
||||
|
||||
## Vulnerability scanning
|
||||
|
||||
Scan container images for known CVEs with [Trivy](https://trivy.dev). On-demand scanning and severity badges are available on every tier; scheduled scans, scan policies that gate deploys, SBOM generation, and scan history are available on Skipper and Admiral. [Learn more →](/features/vulnerability-scanning)
|
||||
|
||||
## Audit log
|
||||
|
||||
Track every mutating action across your Sencho instance with a searchable audit trail. See who deployed, stopped, deleted, or changed settings, with timestamps, user attribution, and node context. Admiral only. [Learn more →](/features/audit-log)
|
||||
|
||||
@@ -23,6 +23,7 @@ Scheduled Operations lets you automate recurring maintenance tasks across your i
|
||||
| **Restart Stack** | A specific stack (or specific services within it) on a specific node | Restarts all or selected containers in the stack |
|
||||
| **Fleet Snapshot** | All nodes | Creates a fleet-wide backup of all compose files and `.env` files |
|
||||
| **System Prune** | The default node | Prunes selected resources, optionally filtered by Docker label |
|
||||
| **Vulnerability Scan** | All images on a specific node | Runs Trivy against every image on the target node and records the results. Requires Trivy to be installed — see [Installing Trivy](/operations/trivy-setup). Available on Skipper and Admiral. |
|
||||
|
||||
## Creating a Scheduled Task
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
---
|
||||
title: "Vulnerability Scanning"
|
||||
description: "Scan container images for known CVEs, surface severity badges in the Resources Hub, and alert on policy violations."
|
||||
---
|
||||
|
||||
Sencho integrates with [Trivy](https://trivy.dev) to scan container images for known vulnerabilities (CVEs), surface severity badges next to your images, and alert when a scan result exceeds a configured threshold. On-demand scanning is available on every tier; automation, policies, and SBOM generation are Skipper and Admiral.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/vulnerability-scanning/resources-badges.png" alt="Resources Hub showing vulnerability severity badges next to image tags" />
|
||||
</Frame>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
The Trivy CLI must be available on the machine running Sencho. Trivy is not bundled with the Sencho Docker image; see [Installing Trivy](/operations/trivy-setup) for mount and installation options. Sencho checks for Trivy on startup and hides scanning UI when the binary is not available.
|
||||
|
||||
## Tier availability
|
||||
|
||||
| Feature | Community | Skipper | Admiral |
|
||||
|---------|:---------:|:-------:|:-------:|
|
||||
| On-demand image scanning | ✓ | ✓ | ✓ |
|
||||
| Severity badges in the Resources Hub | ✓ | ✓ | ✓ |
|
||||
| Scan results drawer with vulnerability table | ✓ | ✓ | ✓ |
|
||||
| Post-deploy automated scanning | ✓ | ✓ | ✓ |
|
||||
| Scheduled fleet scans (all images on a node) | | ✓ | ✓ |
|
||||
| Scan policies (warning and critical alerts) | | ✓ | ✓ |
|
||||
| SBOM generation (SPDX, CycloneDX) | | ✓ | ✓ |
|
||||
| Scan history and comparison | | ✓ | ✓ |
|
||||
|
||||
## On-demand scanning
|
||||
|
||||
Navigate to the **Resources** tab and open the **Images** panel. When Trivy is available, every image row shows a shield icon alongside the delete action.
|
||||
|
||||
1. Click the shield icon on any image row to start a scan.
|
||||
2. The row shows a loading spinner while Trivy runs. Most scans finish in 10 to 60 seconds depending on image size and whether the Trivy database is already cached.
|
||||
3. When the scan completes, a severity badge appears next to the image status (e.g. `CRITICAL`, `HIGH`, `MEDIUM`, `LOW`, or `CLEAN`).
|
||||
4. Click the badge to open the scan results drawer.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/vulnerability-scanning/scan-results.png" alt="Vulnerability scan results drawer showing CVE table with severity, package, and fix columns" />
|
||||
</Frame>
|
||||
|
||||
### Reading severity badges
|
||||
|
||||
Hovering over a severity badge reveals the breakdown of vulnerabilities by severity and the timestamp of the last scan. The badge color reflects the highest severity found:
|
||||
|
||||
| Badge | Meaning |
|
||||
|-------|---------|
|
||||
| **CRITICAL** (red) | At least one vulnerability with a CVSS score of 9.0 or higher |
|
||||
| **HIGH** (amber) | At least one high-severity vulnerability |
|
||||
| **MEDIUM** (blue) | Only medium and lower vulnerabilities |
|
||||
| **LOW** (muted) | Only low-severity vulnerabilities |
|
||||
| **CLEAN** (green) | Scan completed with zero findings |
|
||||
|
||||
Scan results are cached by image digest. If the same digest is scanned again within 24 hours, Sencho returns the cached result instantly instead of re-running Trivy.
|
||||
|
||||
## The scan results drawer
|
||||
|
||||
The drawer shows a full breakdown of the most recent scan for an image:
|
||||
|
||||
- **Summary**: counts per severity (critical, high, medium, low), total vulnerabilities, how many have a fix available, the Trivy version used, and when the scan ran.
|
||||
- **Filter tabs**: narrow the table to a specific severity.
|
||||
- **Vulnerability table**: paginated list of every CVE found, including:
|
||||
- **CVE ID** (linked to the upstream advisory)
|
||||
- **Package** name and installed version
|
||||
- **Severity** badge
|
||||
- **Fixed version** with a green indicator if a fix is available
|
||||
|
||||
### Actions
|
||||
|
||||
From the drawer header you can:
|
||||
|
||||
- **Re-scan**: kick off a fresh scan, ignoring the digest cache.
|
||||
- **Download SBOM**: export a Software Bill of Materials in SPDX JSON or CycloneDX format (Skipper and Admiral).
|
||||
- **Export CSV**: export the full vulnerability list for offline review.
|
||||
|
||||
## Post-deploy automated scanning
|
||||
|
||||
When Trivy is available, Sencho automatically scans every deployed image in the background after a successful deploy. This applies to all deploy paths:
|
||||
|
||||
- Stack deploy and redeploy
|
||||
- Stack update
|
||||
- Template deploy from the App Store
|
||||
- Git source apply
|
||||
- Git source create
|
||||
|
||||
The deploy itself is never blocked by scanning; scans run asynchronously and surface their results via the severity badges in the Resources Hub. If high or critical vulnerabilities are found, an alert is dispatched through your configured [notification channels](/features/alerts-notifications).
|
||||
|
||||
### Opting out per deployment
|
||||
|
||||
The App Store deploy sheet includes an **Scan images for vulnerabilities after deploy** toggle (enabled by default). Uncheck it to skip the post-deploy scan for that single deployment. This does not disable scan policies globally; it simply opts this deployment out of scanning.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/vulnerability-scanning/app-store-toggle.png" alt="App Store deploy sheet showing the auto-scan checkbox enabled by default" />
|
||||
</Frame>
|
||||
|
||||
## Scheduled fleet scans
|
||||
|
||||
<Note>
|
||||
Scheduled scans require a **Skipper** or **Admiral** license.
|
||||
</Note>
|
||||
|
||||
You can run recurring scans of every image on a node through the standard [Scheduled Operations](/features/scheduled-operations) system. Create a new scheduled task with action **Scan** and a cron expression. The scheduler iterates every image on the target node with a short delay between scans and records the result in the task's run history.
|
||||
|
||||
Use scheduled scans to keep CVE badges fresh even for images that are rarely redeployed: nightly (`0 3 * * *`) is a good default for most fleets.
|
||||
|
||||
## Scan policies
|
||||
|
||||
<Note>
|
||||
Scan policies require a **Skipper** or **Admiral** license.
|
||||
</Note>
|
||||
|
||||
Policies let you define severity thresholds that the post-deploy scanner evaluates against. When a deploy's scan exceeds a policy's threshold, Sencho dispatches an alert. The policy's **Block on deploy** toggle controls the alert severity: warning when off, critical when on.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/vulnerability-scanning/security-settings.png" alt="Security section of Settings showing the scan policies list with add policy button" />
|
||||
</Frame>
|
||||
|
||||
### Creating a policy
|
||||
|
||||
Go to **Settings → Security** and click **Add Policy**.
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| **Name** | A descriptive label (e.g. "Production critical block"). |
|
||||
| **Stack pattern** | Optional glob against stack names (e.g. `prod-*`). Leave empty to match every stack. |
|
||||
| **Max severity** | The threshold. If a scan finds any vulnerability at or above this severity, the policy fires. |
|
||||
| **Block on deploy** | When enabled, policy violations are dispatched as critical (error) alerts. When disabled, they are dispatched as warnings. |
|
||||
| **Enabled** | Disabled policies are skipped during evaluation. |
|
||||
|
||||
### Policy scoping
|
||||
|
||||
When multiple policies match a deploy, Sencho picks the most specific one:
|
||||
|
||||
1. Policies scoped to a specific node win over global policies.
|
||||
2. Policies with a stack pattern win over wildcard policies.
|
||||
3. Disabled policies are never applied.
|
||||
|
||||
Only one policy is evaluated per deploy; use a single tight pattern rather than overlapping policies for clarity.
|
||||
|
||||
## SBOM generation
|
||||
|
||||
<Note>
|
||||
SBOM generation requires a **Skipper** or **Admiral** license.
|
||||
</Note>
|
||||
|
||||
A Software Bill of Materials (SBOM) is a machine-readable inventory of every package present in a container image. SBOMs are required by an increasing number of security frameworks (SLSA, Executive Order 14028, EU Cyber Resilience Act) and are useful for offline compliance reviews.
|
||||
|
||||
From the scan results drawer, click **Download SBOM** and choose a format:
|
||||
|
||||
| Format | Use case |
|
||||
|--------|----------|
|
||||
| **SPDX JSON** | Widely supported, best for tooling integration. |
|
||||
| **CycloneDX** | Richer dependency metadata, better for supply-chain analysis. |
|
||||
|
||||
The download starts immediately and uses the image's digest (when available) in the filename.
|
||||
|
||||
## Scan history
|
||||
|
||||
Every scan Sencho runs is stored with its full vulnerability detail. Scan records are automatically pruned after 90 days to keep the database compact. The history is used to power:
|
||||
|
||||
- **Digest caching**: skip re-scanning an image that has already been scanned within 24 hours.
|
||||
- **Trend badges**: surface whether the latest scan added or resolved vulnerabilities compared to the previous scan for the same image.
|
||||
|
||||
## How it works
|
||||
|
||||
1. On startup, Sencho looks for the `trivy` binary on `PATH` and caches its availability.
|
||||
2. When a scan is triggered, Sencho resolves the image digest via Docker and checks the 24-hour cache. If a completed scan exists for that digest, it is returned instantly.
|
||||
3. Otherwise, Sencho spawns `trivy image --format json --quiet <image-ref>` and parses the JSON output into the vulnerability database.
|
||||
4. Private registry credentials are forwarded automatically by writing a temporary `DOCKER_CONFIG` for the Trivy subprocess, then deleting it when the scan completes.
|
||||
5. Scan status, counts per severity, and the full vulnerability list are persisted for display in the drawer and for policy evaluation.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Scan button is not visible
|
||||
|
||||
Sencho hides scanning UI when the Trivy binary is not detected. Check **Settings → Support** for the Trivy availability status, then follow [Installing Trivy](/operations/trivy-setup) if it is missing.
|
||||
|
||||
### Scans time out
|
||||
|
||||
The default scan timeout is 5 minutes. Very large images (2+ GB) over a slow connection may exceed this; pre-pulling the image to the host speeds up the scan significantly because Trivy works against the local image store.
|
||||
|
||||
### Private registry images fail to scan
|
||||
|
||||
Sencho forwards the same registry credentials configured under **Settings → Registries** to Trivy during a scan. If a pull works in Sencho but a scan fails, make sure the image has been pulled at least once (Trivy can then work against the cached local image).
|
||||
|
||||
### Badge is out of date after an image update
|
||||
|
||||
Post-deploy scanning only runs on deploy actions. For long-running images that aren't redeployed, schedule a recurring scan (Skipper+) or click the shield icon in the Resources Hub to re-scan on demand.
|
||||
@@ -0,0 +1,185 @@
|
||||
---
|
||||
title: Installing Trivy
|
||||
description: Install and mount the Trivy CLI so Sencho can scan container images for vulnerabilities.
|
||||
---
|
||||
|
||||
Sencho's [Vulnerability Scanning](/features/vulnerability-scanning) feature uses the [Trivy](https://trivy.dev) CLI. Trivy is not bundled with the Sencho Docker image; operators provide it via a bind mount or a custom image so Sencho can find it on `PATH`. Once Trivy is available, the scanning UI appears automatically.
|
||||
|
||||
## Why Trivy is not bundled
|
||||
|
||||
Trivy's vulnerability database updates multiple times per day and is around 100 MB. Bundling Trivy would force every Sencho instance to carry an out-of-date database in its image, then re-download on first scan. By keeping Trivy external, you can:
|
||||
|
||||
- Pick the Trivy version you want and upgrade it on your own schedule
|
||||
- Persist the Trivy cache (the vulnerability DB) across Sencho container restarts
|
||||
- Pre-seed an air-gapped cache for environments without internet access
|
||||
|
||||
## Installing Trivy on the host
|
||||
|
||||
### Linux (Debian / Ubuntu)
|
||||
|
||||
```bash
|
||||
sudo apt-get install wget gnupg
|
||||
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | sudo apt-key add -
|
||||
echo "deb https://aquasecurity.github.io/trivy-repo/deb generic main" | sudo tee /etc/apt/sources.list.d/trivy.list
|
||||
sudo apt-get update
|
||||
sudo apt-get install trivy
|
||||
```
|
||||
|
||||
### Linux (RHEL / Fedora)
|
||||
|
||||
```bash
|
||||
sudo rpm --import https://aquasecurity.github.io/trivy-repo/rpm/public.key
|
||||
echo "[trivy]
|
||||
name=Trivy repository
|
||||
baseurl=https://aquasecurity.github.io/trivy-repo/rpm/releases/\$basearch/
|
||||
gpgcheck=1
|
||||
enabled=1" | sudo tee /etc/yum.repos.d/trivy.repo
|
||||
sudo dnf install trivy
|
||||
```
|
||||
|
||||
### macOS
|
||||
|
||||
```bash
|
||||
brew install trivy
|
||||
```
|
||||
|
||||
### Verify the install
|
||||
|
||||
```bash
|
||||
trivy --version
|
||||
```
|
||||
|
||||
The command should print a `Version: X.Y.Z` line. Note the path that `which trivy` (or `where trivy` on Windows) returns; you will mount that path into the Sencho container.
|
||||
|
||||
## Making Trivy available to Sencho
|
||||
|
||||
Sencho runs inside a container and looks for `trivy` on its own `PATH`. There are two approaches:
|
||||
|
||||
### Option 1: Bind mount the host binary
|
||||
|
||||
Mount the host's Trivy binary into the Sencho container. This is the simplest option when Sencho and Trivy share the same CPU architecture.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
sencho:
|
||||
image: sencho/sencho:latest
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./sencho-data:/app/data
|
||||
- /opt/compose:/opt/compose
|
||||
- /usr/local/bin/trivy:/usr/local/bin/trivy:ro
|
||||
- trivy-cache:/root/.cache/trivy
|
||||
environment:
|
||||
- COMPOSE_DIR=/opt/compose
|
||||
- TRIVY_CACHE_DIR=/root/.cache/trivy
|
||||
|
||||
volumes:
|
||||
trivy-cache:
|
||||
```
|
||||
|
||||
The `trivy-cache` volume persists the vulnerability database across Sencho container restarts so Trivy does not re-download it every time.
|
||||
|
||||
Adjust the first path if `which trivy` on the host prints something other than `/usr/local/bin/trivy` (for example `/usr/bin/trivy` on some distributions).
|
||||
|
||||
### Option 2: Build a custom Sencho image
|
||||
|
||||
If the host's Trivy binary is not ABI-compatible with the Sencho container (for example because you are running macOS host binaries or a different glibc version), install Trivy inside the image instead:
|
||||
|
||||
```dockerfile
|
||||
FROM sencho/sencho:latest
|
||||
|
||||
RUN apk add --no-cache curl ca-certificates \
|
||||
&& curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \
|
||||
| sh -s -- -b /usr/local/bin
|
||||
|
||||
ENV TRIVY_CACHE_DIR=/root/.cache/trivy
|
||||
```
|
||||
|
||||
Build and run:
|
||||
|
||||
```bash
|
||||
docker build -t sencho-with-trivy .
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Persisting the vulnerability database
|
||||
|
||||
Trivy downloads a ~100 MB vulnerability database on first run and refreshes it every six hours. Without a persistent cache, every Sencho restart re-downloads the database, wasting bandwidth and adding 10–30 seconds to the first scan.
|
||||
|
||||
Set `TRIVY_CACHE_DIR` to a directory inside a named or bind-mounted volume (see Option 1 above). The directory must be writable by the Sencho process.
|
||||
|
||||
## Air-gapped environments
|
||||
|
||||
Trivy supports offline use through pre-built database bundles.
|
||||
|
||||
1. On a networked machine, download the latest DB bundle:
|
||||
|
||||
```bash
|
||||
trivy image --download-db-only
|
||||
cp -r ~/.cache/trivy /path/to/portable/cache
|
||||
```
|
||||
|
||||
2. Transfer the cache directory to the air-gapped host.
|
||||
|
||||
3. Mount it into the Sencho container at `TRIVY_CACHE_DIR`:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- /path/to/portable/cache:/root/.cache/trivy:ro
|
||||
environment:
|
||||
- TRIVY_CACHE_DIR=/root/.cache/trivy
|
||||
```
|
||||
|
||||
4. Set `TRIVY_SKIP_DB_UPDATE=true` to prevent Trivy from attempting a refresh:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- TRIVY_CACHE_DIR=/root/.cache/trivy
|
||||
- TRIVY_SKIP_DB_UPDATE=true
|
||||
```
|
||||
|
||||
Plan to refresh the bundle on a schedule (weekly is typical) so CVE data stays current.
|
||||
|
||||
## Verifying Sencho detects Trivy
|
||||
|
||||
After restarting Sencho with the mount in place:
|
||||
|
||||
1. Open the **Resources** tab.
|
||||
2. Look at the **Images** panel. If Trivy is detected, a shield icon appears in the Actions column next to the delete icon on every row.
|
||||
3. You can also check **Settings → Support** for an explicit Trivy availability status.
|
||||
|
||||
If the shield icon is missing, see the troubleshooting section below.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Sencho does not detect Trivy
|
||||
|
||||
Sencho runs `trivy --version` on startup and caches the result. If you added the mount after Sencho started, restart the container so the check runs again.
|
||||
|
||||
Verify the binary is visible from inside the container:
|
||||
|
||||
```bash
|
||||
docker exec sencho trivy --version
|
||||
```
|
||||
|
||||
If the command returns "not found", the mount path inside the container is wrong. The binary must be on `PATH`. Both `/usr/local/bin/trivy` and `/usr/bin/trivy` work.
|
||||
|
||||
### Binary exists but reports an exec format error
|
||||
|
||||
This means the host binary is not ABI-compatible with the Sencho image. Use Option 2 (custom image) instead; the `install.sh` script pulls the right architecture-specific build.
|
||||
|
||||
### Scans take a long time on first run
|
||||
|
||||
The first scan after a Trivy install downloads the vulnerability database. Expect 10–30 seconds of additional latency. Subsequent scans are near-instant once the cache is warm and `TRIVY_CACHE_DIR` is persisted.
|
||||
|
||||
### Private registry images fail to scan
|
||||
|
||||
Trivy scans the locally-cached image layers. If Sencho can pull the image but a scan fails, pull the image to the host first (a deploy will do this) and retry the scan.
|
||||
|
||||
### Permission denied writing to the Trivy cache
|
||||
|
||||
`TRIVY_CACHE_DIR` must be writable by the Sencho process. If you mounted the directory from the host with restrictive permissions, either loosen them (`chmod -R a+w /path/to/cache`) or use a named Docker volume which inherits the container user's permissions.
|
||||
|
||||
### Trivy version is reported as "unknown"
|
||||
|
||||
Sencho reads the version from `trivy --version` output. Very old Trivy releases (< 0.35) use a different output format. Upgrade to a recent version.
|
||||
@@ -51,6 +51,10 @@ Every self-hosted instance includes the full security stack, with advanced featu
|
||||
Long-lived JWT bearer tokens, encrypted at rest, injected automatically during proxied requests.
|
||||
</Card>
|
||||
|
||||
<Card title="Vulnerability scanning" icon="shield-virus" href="/features/vulnerability-scanning">
|
||||
Trivy-powered CVE scanning of container images, with severity badges, post-deploy automation, and policy gating.
|
||||
</Card>
|
||||
|
||||
</CardGroup>
|
||||
|
||||
## Tier availability
|
||||
@@ -66,7 +70,9 @@ Every Sencho instance includes the foundational security stack. Advanced access-
|
||||
| Encryption at rest (AES-256-GCM) | ✓ | ✓ | ✓ |
|
||||
| Rate limiting (auth + API) | ✓ | ✓ | ✓ |
|
||||
| Node-to-node authentication | ✓ | ✓ | ✓ |
|
||||
| Vulnerability scanning (on-demand + post-deploy) | ✓ | ✓ | ✓ |
|
||||
| Multi-user with RBAC (Admin, Viewer) | | ✓ | ✓ |
|
||||
| Scan policies, scheduled scans, SBOM generation | | ✓ | ✓ |
|
||||
| Advanced RBAC (Deployer, Node Admin, Auditor) | | | ✓ |
|
||||
| Scoped permissions (per-stack, per-node) | | | ✓ |
|
||||
| API tokens (scoped, expiring) | | | ✓ |
|
||||
|
||||
@@ -6,7 +6,8 @@ import { Label } from "@/components/ui/label";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter } from "@/components/ui/sheet";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Search, Rocket, Loader2, Info, ExternalLink, Star } from "lucide-react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Search, Rocket, Loader2, Info, ExternalLink, Star, ShieldCheck } from "lucide-react";
|
||||
import { toast } from "@/components/ui/toast-store";
|
||||
import { cn } from '@/lib/utils';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
@@ -78,9 +79,17 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
||||
const [newEnvKey, setNewEnvKey] = useState('');
|
||||
const [portsInUse, setPortsInUse] = useState<Record<string, PortInUseInfo>>({});
|
||||
const [newEnvVal, setNewEnvVal] = useState('');
|
||||
const [autoScan, setAutoScan] = useState(true);
|
||||
const [trivyAvailable, setTrivyAvailable] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTemplates();
|
||||
apiFetch('/security/trivy-status')
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(d => { if (d) setTrivyAvailable(!!d.available); })
|
||||
.catch((err) => {
|
||||
console.error('Failed to fetch Trivy status:', err);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const fetchTemplates = async () => {
|
||||
@@ -215,7 +224,8 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
||||
body: JSON.stringify({
|
||||
stackName: stackName.trim(),
|
||||
template: modifiedTemplate,
|
||||
envVars: finalEnvVars
|
||||
envVars: finalEnvVars,
|
||||
skip_scan: !autoScan
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
@@ -546,7 +556,23 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
||||
</ScrollArea>
|
||||
|
||||
<SheetFooter className="pt-4 mt-auto border-t sm:justify-start">
|
||||
<div className="flex flex-col w-full gap-2">
|
||||
<div className="flex flex-col w-full gap-3">
|
||||
{trivyAvailable && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="auto-scan"
|
||||
checked={autoScan}
|
||||
onCheckedChange={(checked) => setAutoScan(!!checked)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="auto-scan"
|
||||
className="text-sm text-muted-foreground cursor-pointer flex items-center gap-1.5"
|
||||
>
|
||||
<ShieldCheck className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
Scan images for vulnerabilities after deploy
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleDeploy}
|
||||
disabled={isDeploying || !stackName.trim() || !can('stack:create')}
|
||||
|
||||
@@ -18,6 +18,10 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { Trash2, HardDrive, Network, PackageMinus, MonitorX, MoreVertical, AlertTriangle, ShieldCheck, Plus, Eye, Copy, Container, Loader2 } from 'lucide-react';
|
||||
import { CursorProvider, CursorContainer, Cursor, CursorFollow } from '@/components/animate-ui/primitives/animate/cursor';
|
||||
import { useTrivyStatus } from '@/hooks/useTrivyStatus';
|
||||
import { VulnerabilityScanSheet } from './VulnerabilityScanSheet';
|
||||
import type { ScanSummary, VulnSeverity } from '@/types/security';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
@@ -273,6 +277,86 @@ function ManagedBadge({ status, managedBy }: {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Severity Badge ─────────────────────────────────────────────────────────────
|
||||
|
||||
const SEVERITY_BADGE_CLASSES: Record<VulnSeverity | 'CLEAN', string> = {
|
||||
CRITICAL: 'border-destructive/25 bg-destructive/8 text-destructive',
|
||||
HIGH: 'border-warning/25 bg-warning/8 text-warning',
|
||||
MEDIUM: 'border-info/25 bg-info/8 text-info',
|
||||
LOW: 'border-border bg-muted/30 text-muted-foreground',
|
||||
UNKNOWN: 'border-border bg-muted/20 text-muted-foreground',
|
||||
CLEAN: 'border-success/25 bg-success/8 text-success',
|
||||
};
|
||||
|
||||
const SEVERITY_DOT_CLASSES: Record<VulnSeverity | 'CLEAN', string> = {
|
||||
CRITICAL: 'bg-destructive',
|
||||
HIGH: 'bg-warning',
|
||||
MEDIUM: 'bg-info',
|
||||
LOW: 'bg-muted-foreground/60',
|
||||
UNKNOWN: 'bg-muted-foreground/40',
|
||||
CLEAN: 'bg-success',
|
||||
};
|
||||
|
||||
function SeverityBadge({ summary, onClick }: { summary: ScanSummary; onClick: () => void }) {
|
||||
const key: VulnSeverity | 'CLEAN' = summary.highest_severity ?? 'CLEAN';
|
||||
const label = key === 'CLEAN' ? 'Clean' : key;
|
||||
const [relative, setRelative] = useState<string>('');
|
||||
useEffect(() => {
|
||||
const compute = () => {
|
||||
const scanAge = Math.round((Date.now() - summary.scanned_at) / 60000);
|
||||
setRelative(
|
||||
scanAge < 1 ? 'just now'
|
||||
: scanAge < 60 ? `${scanAge}m ago`
|
||||
: scanAge < 1440 ? `${Math.round(scanAge / 60)}h ago`
|
||||
: `${Math.round(scanAge / 1440)}d ago`,
|
||||
);
|
||||
};
|
||||
compute();
|
||||
const id = setInterval(compute, 60000);
|
||||
return () => clearInterval(id);
|
||||
}, [summary.scanned_at]);
|
||||
|
||||
return (
|
||||
<CursorProvider>
|
||||
<CursorContainer className="inline-flex">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[10px] font-medium cursor-pointer hover:brightness-110 transition',
|
||||
SEVERITY_BADGE_CLASSES[key],
|
||||
)}
|
||||
>
|
||||
<span className={cn('w-1.5 h-1.5 rounded-full shrink-0', SEVERITY_DOT_CLASSES[key])} />
|
||||
{label}
|
||||
</button>
|
||||
</CursorContainer>
|
||||
<Cursor>
|
||||
<div className="h-2 w-2 rounded-full bg-brand" />
|
||||
</Cursor>
|
||||
<CursorFollow side="bottom" align="end" sideOffset={8}>
|
||||
<div className="bg-popover/95 backdrop-blur-[10px] backdrop-saturate-[1.15] border border-card-border shadow-md rounded-md px-3 py-2">
|
||||
<div className="font-mono tabular-nums text-xs space-y-1">
|
||||
<div className="text-stat-subtitle uppercase tracking-wide">Last scanned</div>
|
||||
<div className="text-stat-value">{relative}</div>
|
||||
{summary.total > 0 && (
|
||||
<div className="flex gap-3 mt-1">
|
||||
{summary.critical > 0 && <span className="text-destructive">{summary.critical}C</span>}
|
||||
{summary.high > 0 && <span className="text-warning">{summary.high}H</span>}
|
||||
{summary.medium > 0 && <span className="text-info">{summary.medium}M</span>}
|
||||
{summary.low > 0 && <span className="text-muted-foreground">{summary.low}L</span>}
|
||||
</div>
|
||||
)}
|
||||
{summary.total === 0 && (
|
||||
<div className="text-success">No vulnerabilities</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CursorFollow>
|
||||
</CursorProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Quick Clean Prune Button ───────────────────────────────────────────────────
|
||||
|
||||
interface PruneButtonProps {
|
||||
@@ -380,13 +464,20 @@ export default function ResourcesView() {
|
||||
const [selectedOrphans, setSelectedOrphans] = useState<string[]>([]);
|
||||
const [bulkPurgeConfirm, setBulkPurgeConfirm] = useState(false);
|
||||
|
||||
// Vulnerability scanning state
|
||||
const trivy = useTrivyStatus();
|
||||
const [scanSummaries, setScanSummaries] = useState<Record<string, ScanSummary>>({});
|
||||
const [scanningImageRef, setScanningImageRef] = useState<string | null>(null);
|
||||
const [inspectScanId, setInspectScanId] = useState<number | null>(null);
|
||||
|
||||
const fetchAllData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [usageRes, resourcesRes, orphansRes] = await Promise.all([
|
||||
const [usageRes, resourcesRes, orphansRes, summariesRes] = await Promise.all([
|
||||
apiFetch('/system/docker-df'),
|
||||
apiFetch('/system/resources'),
|
||||
apiFetch('/system/orphans'),
|
||||
apiFetch('/security/image-summaries').catch(() => null),
|
||||
]);
|
||||
|
||||
if (usageRes.ok) setUsage(await usageRes.json());
|
||||
@@ -400,6 +491,10 @@ export default function ResourcesView() {
|
||||
setOrphans(await orphansRes.json());
|
||||
setSelectedOrphans([]);
|
||||
}
|
||||
if (summariesRes && summariesRes.ok) {
|
||||
const data = await summariesRes.json();
|
||||
setScanSummaries(data ?? {});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch data', err);
|
||||
toast.error('Failed to load resources data');
|
||||
@@ -494,6 +589,48 @@ export default function ResourcesView() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleScanImage = async (imageRef: string, force = false) => {
|
||||
setScanningImageRef(imageRef);
|
||||
const loadingId = toast.loading(`Scanning ${imageRef}...`);
|
||||
try {
|
||||
const res = await apiFetch('/security/scan', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ imageRef, force }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.error || 'Failed to start scan');
|
||||
const scanId = data.scanId as number;
|
||||
|
||||
const deadline = Date.now() + 5 * 60 * 1000;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
const poll = await apiFetch(`/security/scans/${scanId}`);
|
||||
if (!poll.ok) continue;
|
||||
const poll_data = await poll.json();
|
||||
if (poll_data.status !== 'in_progress') {
|
||||
if (poll_data.status === 'failed') {
|
||||
throw new Error(poll_data.error || 'Scan failed');
|
||||
}
|
||||
toast.success(`Scan complete: ${poll_data.total_vulnerabilities} vulnerabilities found`);
|
||||
setInspectScanId(scanId);
|
||||
const summariesRes = await apiFetch('/security/image-summaries');
|
||||
if (summariesRes.ok) {
|
||||
const summaries = await summariesRes.json();
|
||||
setScanSummaries(summaries ?? {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new Error('Scan timed out');
|
||||
} catch (error) {
|
||||
const err = error as { message?: string };
|
||||
toast.error(err?.message || 'Scan failed');
|
||||
} finally {
|
||||
toast.dismiss(loadingId);
|
||||
setScanningImageRef(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateNetwork = async () => {
|
||||
setIsCreatingNetwork(true);
|
||||
try {
|
||||
@@ -720,12 +857,36 @@ export default function ResourcesView() {
|
||||
{img.Containers > 0 ? "In Use" : "Unused"}
|
||||
</Badge>
|
||||
<ManagedBadge status={img.managedStatus} managedBy={img.managedBy} />
|
||||
{(() => {
|
||||
const tag = img.RepoTags?.[0];
|
||||
const summary = tag ? scanSummaries[tag] : undefined;
|
||||
if (!summary) return null;
|
||||
return <SeverityBadge summary={summary} onClick={() => setInspectScanId(summary.scan_id)} />;
|
||||
})()}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{isAdmin && <Button variant="ghost" size="icon" className="h-7 w-7 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground transition-colors" onClick={() => setConfirmDelete({ type: 'images', id: img.Id, name: img.RepoTags?.[0] })}>
|
||||
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>}
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{trivy.available && isAdmin && img.RepoTags?.[0] && img.RepoTags[0] !== '<none>:<none>' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-foreground transition-colors"
|
||||
disabled={scanningImageRef === img.RepoTags[0]}
|
||||
onClick={() => handleScanImage(img.RepoTags![0])}
|
||||
title="Scan for vulnerabilities"
|
||||
>
|
||||
{scanningImageRef === img.RepoTags[0] ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" strokeWidth={1.5} />
|
||||
) : (
|
||||
<ShieldCheck className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && <Button variant="ghost" size="icon" className="h-7 w-7 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground transition-colors" onClick={() => setConfirmDelete({ type: 'images', id: img.Id, name: img.RepoTags?.[0] })}>
|
||||
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
@@ -1306,6 +1467,13 @@ export default function ResourcesView() {
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<VulnerabilityScanSheet
|
||||
scanId={inspectScanId}
|
||||
onClose={() => setInspectScanId(null)}
|
||||
onRescan={(imageRef) => { setInspectScanId(null); handleScanImage(imageRef, true); }}
|
||||
canGenerateSbom={isPaid}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
|
||||
import type { SenchoSettingsChangedDetail } from '@/lib/events';
|
||||
import {
|
||||
Shield, Activity, Bell, Code, Server, Package, X,
|
||||
Info, Crown, Webhook, Users, Zap, Database, LifeBuoy, Lock, Tag, Route,
|
||||
Info, Crown, Webhook, Users, Zap, Database, LifeBuoy, Lock, Tag, Route, ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import { NodeManager } from './NodeManager';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
NotificationsSection,
|
||||
NotificationRoutingSection,
|
||||
WebhooksSection,
|
||||
SecuritySection,
|
||||
DeveloperSection,
|
||||
AppStoreSection,
|
||||
SupportSection,
|
||||
@@ -44,7 +45,8 @@ import type { PatchableSettings, SectionId } from './settings';
|
||||
|
||||
const GLOBAL_ONLY_SECTIONS: ReadonlySet<SectionId> = new Set<SectionId>([
|
||||
'account', 'license', 'users', 'sso', 'api-tokens', 'registries',
|
||||
'labels', 'notifications', 'notification-routing', 'webhooks', 'nodes', 'appstore',
|
||||
'labels', 'notifications', 'notification-routing', 'webhooks', 'security',
|
||||
'nodes', 'appstore',
|
||||
]);
|
||||
|
||||
interface SettingsModalProps {
|
||||
@@ -305,6 +307,8 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal
|
||||
return <NotificationRoutingSection />;
|
||||
case 'webhooks':
|
||||
return <WebhooksSection isPaid={isPaid} />;
|
||||
case 'security':
|
||||
return <SecuritySection isPaid={isPaid} />;
|
||||
case 'developer':
|
||||
return (
|
||||
<DeveloperSection
|
||||
@@ -395,6 +399,9 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal
|
||||
{!isRemote && (
|
||||
<NavButton section="webhooks" icon={<Webhook className="w-4 h-4 mr-2" />} label="Webhooks" locked={!isPaid} />
|
||||
)}
|
||||
{!isRemote && isAdmin && (
|
||||
<NavButton section="security" icon={<ShieldCheck className="w-4 h-4 mr-2" />} label="Security" locked={!isPaid} />
|
||||
)}
|
||||
<NavButton
|
||||
section="developer"
|
||||
icon={<Code className="w-4 h-4 mr-2" />}
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
ShieldCheck,
|
||||
ExternalLink,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
RefreshCw,
|
||||
Download,
|
||||
Loader2,
|
||||
Check,
|
||||
} from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type {
|
||||
VulnerabilityScan,
|
||||
VulnerabilityDetail,
|
||||
VulnSeverity,
|
||||
} from '@/types/security';
|
||||
|
||||
interface VulnerabilityScanSheetProps {
|
||||
scanId: number | null;
|
||||
onClose: () => void;
|
||||
onRescan?: (imageRef: string) => void;
|
||||
canGenerateSbom?: boolean;
|
||||
}
|
||||
|
||||
type SeverityFilter = 'ALL' | VulnSeverity;
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
const SEVERITY_CLASSES: Record<VulnSeverity, string> = {
|
||||
CRITICAL: 'text-destructive border-destructive/40 bg-destructive/10',
|
||||
HIGH: 'text-warning border-warning/40 bg-warning/10',
|
||||
MEDIUM: 'text-info border-info/40 bg-info/10',
|
||||
LOW: 'text-muted-foreground border-border bg-muted/30',
|
||||
UNKNOWN: 'text-muted-foreground border-border bg-muted/20',
|
||||
};
|
||||
|
||||
function SeverityChip({ severity }: { severity: VulnSeverity }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] font-mono tabular-nums uppercase tracking-wide',
|
||||
SEVERITY_CLASSES[severity],
|
||||
)}
|
||||
>
|
||||
{severity}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function VulnerabilityScanSheet({
|
||||
scanId,
|
||||
onClose,
|
||||
onRescan,
|
||||
canGenerateSbom = false,
|
||||
}: VulnerabilityScanSheetProps) {
|
||||
const [scan, setScan] = useState<VulnerabilityScan | null>(null);
|
||||
const [details, setDetails] = useState<VulnerabilityDetail[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>('ALL');
|
||||
const [page, setPage] = useState(0);
|
||||
const [downloadingSbom, setDownloadingSbom] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (scanId == null) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const [scanRes, detailsRes] = await Promise.all([
|
||||
apiFetch(`/security/scans/${scanId}`),
|
||||
apiFetch(`/security/scans/${scanId}/vulnerabilities?limit=500`),
|
||||
]);
|
||||
if (!scanRes.ok) throw new Error('Failed to fetch scan');
|
||||
if (!detailsRes.ok) throw new Error('Failed to fetch vulnerabilities');
|
||||
const scanData = await scanRes.json();
|
||||
const detailsData = await detailsRes.json();
|
||||
setScan(scanData);
|
||||
setDetails(Array.isArray(detailsData.items) ? detailsData.items : []);
|
||||
setPage(0);
|
||||
} catch (err) {
|
||||
toast.error((err as Error)?.message || 'Failed to load scan');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [scanId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scanId != null) load();
|
||||
else {
|
||||
setScan(null);
|
||||
setDetails([]);
|
||||
setSeverityFilter('ALL');
|
||||
setPage(0);
|
||||
}
|
||||
}, [scanId, load]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (severityFilter === 'ALL') return details;
|
||||
return details.filter((d) => d.severity === severityFilter);
|
||||
}, [details, severityFilter]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||
const safePage = Math.min(page, totalPages - 1);
|
||||
const pageItems = filtered.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE);
|
||||
const needsPagination = filtered.length > PAGE_SIZE;
|
||||
|
||||
const downloadSbom = useCallback(
|
||||
async (format: 'spdx-json' | 'cyclonedx') => {
|
||||
if (!scan) return;
|
||||
setDownloadingSbom(true);
|
||||
try {
|
||||
const res = await apiFetch('/security/sbom', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ imageRef: scan.image_ref, format }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body?.error || 'Failed to generate SBOM');
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${scan.image_ref.replace(/[^a-z0-9]+/gi, '_')}-sbom-${format}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success('SBOM downloaded');
|
||||
} catch (err) {
|
||||
toast.error((err as Error)?.message || 'SBOM generation failed');
|
||||
} finally {
|
||||
setDownloadingSbom(false);
|
||||
}
|
||||
},
|
||||
[scan],
|
||||
);
|
||||
|
||||
const exportCsv = useCallback(() => {
|
||||
if (!scan || details.length === 0) return;
|
||||
const header = 'CVE,Package,Severity,Installed,Fixed,URL\n';
|
||||
const escape = (v: string) => `"${v.replace(/"/g, '""')}"`;
|
||||
const rows = details
|
||||
.map((d) =>
|
||||
[
|
||||
escape(d.vulnerability_id),
|
||||
escape(d.pkg_name),
|
||||
escape(d.severity),
|
||||
escape(d.installed_version),
|
||||
escape(d.fixed_version ?? ''),
|
||||
escape(d.primary_url ?? ''),
|
||||
].join(','),
|
||||
)
|
||||
.join('\n');
|
||||
const blob = new Blob([header + rows], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${scan.image_ref.replace(/[^a-z0-9]+/gi, '_')}-vulnerabilities.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [scan, details]);
|
||||
|
||||
return (
|
||||
<Sheet open={scanId != null} onOpenChange={(open) => !open && onClose()}>
|
||||
<SheetContent className="sm:max-w-2xl flex flex-col p-0">
|
||||
<SheetHeader className="p-6 pb-4 border-b">
|
||||
<SheetTitle className="flex items-center gap-2 pr-6">
|
||||
<ShieldCheck className="w-4 h-4 text-muted-foreground" strokeWidth={1.5} />
|
||||
<span className="font-mono text-sm truncate">
|
||||
{scan?.image_ref ?? 'Loading...'}
|
||||
</span>
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
{loading && !scan && (
|
||||
<div className="flex items-center justify-center flex-1">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" strokeWidth={1.5} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scan && (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{/* Summary stats */}
|
||||
<div className="px-6 py-4 border-b space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{scan.critical_count > 0 && (
|
||||
<span className={cn('rounded border px-2 py-1 text-xs font-mono tabular-nums', SEVERITY_CLASSES.CRITICAL)}>
|
||||
{scan.critical_count} CRITICAL
|
||||
</span>
|
||||
)}
|
||||
{scan.high_count > 0 && (
|
||||
<span className={cn('rounded border px-2 py-1 text-xs font-mono tabular-nums', SEVERITY_CLASSES.HIGH)}>
|
||||
{scan.high_count} HIGH
|
||||
</span>
|
||||
)}
|
||||
{scan.medium_count > 0 && (
|
||||
<span className={cn('rounded border px-2 py-1 text-xs font-mono tabular-nums', SEVERITY_CLASSES.MEDIUM)}>
|
||||
{scan.medium_count} MEDIUM
|
||||
</span>
|
||||
)}
|
||||
{scan.low_count > 0 && (
|
||||
<span className={cn('rounded border px-2 py-1 text-xs font-mono tabular-nums', SEVERITY_CLASSES.LOW)}>
|
||||
{scan.low_count} LOW
|
||||
</span>
|
||||
)}
|
||||
{scan.total_vulnerabilities === 0 && (
|
||||
<span className="rounded border border-success/40 bg-success/10 text-success px-2 py-1 text-xs font-mono tabular-nums uppercase">
|
||||
No vulnerabilities
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
|
||||
<div>
|
||||
<div className="text-stat-subtitle uppercase tracking-wide">Total</div>
|
||||
<div className="font-mono tabular-nums text-stat-value">{scan.total_vulnerabilities}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-stat-subtitle uppercase tracking-wide">Fixable</div>
|
||||
<div className="font-mono tabular-nums text-success">{scan.fixable_count}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-stat-subtitle uppercase tracking-wide">Triggered</div>
|
||||
<div className="font-mono text-stat-value">{scan.triggered_by}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-stat-subtitle uppercase tracking-wide">Scanned</div>
|
||||
<div className="font-mono text-stat-value">
|
||||
{new Date(scan.scanned_at).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 pt-2">
|
||||
{onRescan && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onRescan(scan.image_ref)}
|
||||
disabled={scan.status === 'in_progress'}
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
|
||||
Re-scan
|
||||
</Button>
|
||||
)}
|
||||
{canGenerateSbom && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={downloadingSbom}>
|
||||
{downloadingSbom ? (
|
||||
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />
|
||||
) : (
|
||||
<Download className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
|
||||
)}
|
||||
SBOM
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => downloadSbom('spdx-json')}>
|
||||
SPDX JSON
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => downloadSbom('cyclonedx')}>
|
||||
CycloneDX
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={exportCsv} disabled={details.length === 0}>
|
||||
<Download className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
|
||||
CSV
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Severity filter tabs */}
|
||||
<div className="px-6 pt-3 flex items-center gap-1 flex-wrap">
|
||||
{(['ALL', 'CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as SeverityFilter[]).map((s) => (
|
||||
<Button
|
||||
key={s}
|
||||
variant={severityFilter === s ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-7 text-xs px-2.5"
|
||||
onClick={() => {
|
||||
setSeverityFilter(s);
|
||||
setPage(0);
|
||||
}}
|
||||
>
|
||||
{s}
|
||||
</Button>
|
||||
))}
|
||||
{needsPagination && (
|
||||
<div className="flex items-center gap-1 ml-auto">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setPage(Math.max(0, safePage - 1))}
|
||||
disabled={safePage === 0}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
<span className="text-xs font-mono tabular-nums text-stat-subtitle min-w-[3rem] text-center">
|
||||
{safePage + 1} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setPage(Math.min(totalPages - 1, safePage + 1))}
|
||||
disabled={safePage >= totalPages - 1}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="px-6 py-3">
|
||||
{pageItems.length === 0 ? (
|
||||
<div className="text-center text-sm text-muted-foreground py-12">
|
||||
{details.length === 0
|
||||
? 'No vulnerabilities found.'
|
||||
: 'No vulnerabilities match the selected filter.'}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[180px]">CVE</TableHead>
|
||||
<TableHead>Package</TableHead>
|
||||
<TableHead className="w-[100px]">Severity</TableHead>
|
||||
<TableHead className="w-[110px]">Installed</TableHead>
|
||||
<TableHead className="w-[110px]">Fixed</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pageItems.map((d) => (
|
||||
<TableRow key={d.id}>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{d.primary_url ? (
|
||||
<a
|
||||
href={d.primary_url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="inline-flex items-center gap-1 hover:underline"
|
||||
>
|
||||
{d.vulnerability_id}
|
||||
<ExternalLink className="w-3 h-3" strokeWidth={1.5} />
|
||||
</a>
|
||||
) : (
|
||||
d.vulnerability_id
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs truncate max-w-[180px]" title={d.pkg_name}>
|
||||
{d.pkg_name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SeverityChip severity={d.severity} />
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{d.installed_version}</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{d.fixed_version ? (
|
||||
<span className="inline-flex items-center gap-1 text-success">
|
||||
<Check className="w-3 h-3" strokeWidth={1.5} />
|
||||
{d.fixed_version}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
export { SeverityChip };
|
||||
export type { VulnerabilityScanSheetProps };
|
||||
@@ -0,0 +1,357 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { PaidGate } from '@/components/PaidGate';
|
||||
import { TierBadge } from '@/components/TierBadge';
|
||||
import { ShieldCheck, Plus, Trash2, Pencil } from 'lucide-react';
|
||||
import type { ScanPolicy, VulnSeverity } from '@/types/security';
|
||||
|
||||
const SEVERITY_OPTIONS: Array<{ value: VulnSeverity; label: string }> = [
|
||||
{ value: 'CRITICAL', label: 'Critical' },
|
||||
{ value: 'HIGH', label: 'High' },
|
||||
{ value: 'MEDIUM', label: 'Medium' },
|
||||
{ value: 'LOW', label: 'Low' },
|
||||
];
|
||||
|
||||
interface PolicyFormState {
|
||||
name: string;
|
||||
stack_pattern: string;
|
||||
max_severity: VulnSeverity;
|
||||
block_on_deploy: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: PolicyFormState = {
|
||||
name: '',
|
||||
stack_pattern: '',
|
||||
max_severity: 'CRITICAL',
|
||||
block_on_deploy: false,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
||||
const [policies, setPolicies] = useState<ScanPolicy[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [form, setForm] = useState<PolicyFormState>(EMPTY_FORM);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||
|
||||
const fetchPolicies = async () => {
|
||||
try {
|
||||
const res = await apiFetch('/security/policies', { localOnly: true });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setPolicies(Array.isArray(data) ? data : []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load scan policies:', err);
|
||||
toast.error('Failed to load scan policies');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isPaid) fetchPolicies();
|
||||
else setLoading(false);
|
||||
}, [isPaid]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingId(null);
|
||||
setForm(EMPTY_FORM);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (policy: ScanPolicy) => {
|
||||
setEditingId(policy.id);
|
||||
setForm({
|
||||
name: policy.name,
|
||||
stack_pattern: policy.stack_pattern ?? '',
|
||||
max_severity: policy.max_severity,
|
||||
block_on_deploy: policy.block_on_deploy === 1,
|
||||
enabled: policy.enabled === 1,
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.name.trim()) {
|
||||
toast.error('Policy name is required');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
stack_pattern: form.stack_pattern.trim() || null,
|
||||
max_severity: form.max_severity,
|
||||
block_on_deploy: form.block_on_deploy ? 1 : 0,
|
||||
enabled: form.enabled ? 1 : 0,
|
||||
};
|
||||
const url = editingId ? `/security/policies/${editingId}` : '/security/policies';
|
||||
const method = editingId ? 'PUT' : 'POST';
|
||||
const res = await apiFetch(url, {
|
||||
method,
|
||||
localOnly: true,
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err?.error || 'Failed to save policy');
|
||||
}
|
||||
toast.success(editingId ? 'Policy updated' : 'Policy created');
|
||||
setDialogOpen(false);
|
||||
fetchPolicies();
|
||||
} catch (err) {
|
||||
toast.error((err as Error)?.message || 'Failed to save policy');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (deleteId == null) return;
|
||||
try {
|
||||
const res = await apiFetch(`/security/policies/${deleteId}`, {
|
||||
method: 'DELETE',
|
||||
localOnly: true,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err?.error || 'Failed to delete policy');
|
||||
}
|
||||
toast.success('Policy deleted');
|
||||
fetchPolicies();
|
||||
} catch (err) {
|
||||
toast.error((err as Error)?.message || 'Failed to delete policy');
|
||||
} finally {
|
||||
setDeleteId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isPaid) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium tracking-tight flex items-center gap-2">
|
||||
Security <TierBadge />
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Define vulnerability scan policies that gate or warn on deploys.
|
||||
</p>
|
||||
</div>
|
||||
<PaidGate featureName="Scan Policies">
|
||||
<div className="space-y-3">
|
||||
<div className="h-16 rounded-lg border bg-card" />
|
||||
<div className="h-16 rounded-lg border bg-card" />
|
||||
</div>
|
||||
</PaidGate>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between pr-8">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium tracking-tight flex items-center gap-2">
|
||||
Security <TierBadge />
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Policies evaluate every post-deploy scan and alert (or block) when severity exceeds the threshold.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<Plus className="w-4 h-4 mr-1.5" />
|
||||
Add Policy
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-20 w-full rounded-lg" />
|
||||
<Skeleton className="h-20 w-full rounded-lg" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && policies.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<ShieldCheck className="w-10 h-10 text-muted-foreground/50 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">No scan policies configured.</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Add one to enforce severity thresholds across your fleet.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
policies.map((policy) => (
|
||||
<div key={policy.id} className="border border-glass-border rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<ShieldCheck className="w-4 h-4 text-muted-foreground shrink-0" strokeWidth={1.5} />
|
||||
<span className="font-medium text-sm truncate">{policy.name}</span>
|
||||
<Badge variant="outline" className="text-[10px] shrink-0">
|
||||
max: {policy.max_severity}
|
||||
</Badge>
|
||||
{policy.block_on_deploy === 1 && (
|
||||
<Badge variant="destructive" className="text-[10px] shrink-0">
|
||||
block
|
||||
</Badge>
|
||||
)}
|
||||
{policy.enabled === 0 && (
|
||||
<Badge variant="secondary" className="text-[10px] shrink-0">
|
||||
disabled
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => openEdit(policy)}
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5 text-muted-foreground" strokeWidth={1.5} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
|
||||
onClick={() => setDeleteId(policy.id)}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Scope: {policy.stack_pattern ? (
|
||||
<code className="font-mono bg-muted px-1.5 py-0.5 rounded text-[11px]">{policy.stack_pattern}</code>
|
||||
) : (
|
||||
<span className="italic">all stacks</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingId ? 'Edit Policy' : 'New Policy'}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Configure the severity threshold and scope for this scan policy.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="policy-name">Name</Label>
|
||||
<Input
|
||||
id="policy-name"
|
||||
placeholder="Production block on critical"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="policy-pattern">Stack pattern (optional)</Label>
|
||||
<Input
|
||||
id="policy-pattern"
|
||||
placeholder="e.g. prod-* or leave blank for all"
|
||||
value={form.stack_pattern}
|
||||
onChange={(e) => setForm({ ...form, stack_pattern: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Glob-style pattern matched against stack names. Leave blank to apply to all stacks.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Max severity</Label>
|
||||
<Combobox
|
||||
options={SEVERITY_OPTIONS}
|
||||
value={form.max_severity}
|
||||
onValueChange={(v) => setForm({ ...form, max_severity: v as VulnSeverity })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg border border-glass-border px-3 py-2.5">
|
||||
<div>
|
||||
<Label className="text-sm">Block on deploy</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Emit a critical alert when this policy is violated after a deploy.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.block_on_deploy}
|
||||
onCheckedChange={(c) => setForm({ ...form, block_on_deploy: c })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg border border-glass-border px-3 py-2.5">
|
||||
<div>
|
||||
<Label className="text-sm">Enabled</Label>
|
||||
<p className="text-xs text-muted-foreground">Disabled policies are skipped during evaluation.</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.enabled}
|
||||
onCheckedChange={(c) => setForm({ ...form, enabled: c })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Saving...' : editingId ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={deleteId != null} onOpenChange={(open) => !open && setDeleteId(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete scan policy?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This removes the policy immediately. Existing scans are not affected.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export { UsersSection } from './UsersSection';
|
||||
export { SystemSection } from './SystemSection';
|
||||
export { NotificationsSection } from './NotificationsSection';
|
||||
export { WebhooksSection } from './WebhooksSection';
|
||||
export { SecuritySection } from './SecuritySection';
|
||||
export { DeveloperSection } from './DeveloperSection';
|
||||
export { AppStoreSection } from './AppStoreSection';
|
||||
export { SupportSection } from './SupportSection';
|
||||
|
||||
@@ -37,6 +37,7 @@ export type SectionId =
|
||||
| 'system'
|
||||
| 'notifications'
|
||||
| 'webhooks'
|
||||
| 'security'
|
||||
| 'developer'
|
||||
| 'nodes'
|
||||
| 'appstore'
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import type { TrivyStatus } from '@/types/security';
|
||||
|
||||
export function useTrivyStatus(): TrivyStatus {
|
||||
const [status, setStatus] = useState<TrivyStatus>({ available: false, version: null });
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
apiFetch('/security/trivy-status')
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((d) => {
|
||||
if (cancelled || !d) return;
|
||||
setStatus({
|
||||
available: !!d.available,
|
||||
version: typeof d.version === 'string' ? d.version : null,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to fetch Trivy status:', err);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return status;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
export type VulnSeverity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN';
|
||||
export type VulnScanStatus = 'in_progress' | 'completed' | 'failed';
|
||||
export type VulnScanTrigger = 'manual' | 'scheduled' | 'deploy';
|
||||
|
||||
export interface TrivyStatus {
|
||||
available: boolean;
|
||||
version: string | null;
|
||||
}
|
||||
|
||||
export interface VulnerabilityScan {
|
||||
id: number;
|
||||
node_id: number;
|
||||
image_ref: string;
|
||||
image_digest: string | null;
|
||||
scanned_at: number;
|
||||
total_vulnerabilities: number;
|
||||
critical_count: number;
|
||||
high_count: number;
|
||||
medium_count: number;
|
||||
low_count: number;
|
||||
unknown_count: number;
|
||||
fixable_count: number;
|
||||
highest_severity: VulnSeverity | null;
|
||||
os_info: string | null;
|
||||
trivy_version: string | null;
|
||||
scan_duration_ms: number | null;
|
||||
triggered_by: VulnScanTrigger;
|
||||
status: VulnScanStatus;
|
||||
error: string | null;
|
||||
stack_context: string | null;
|
||||
}
|
||||
|
||||
export interface VulnerabilityDetail {
|
||||
id: number;
|
||||
scan_id: number;
|
||||
vulnerability_id: string;
|
||||
pkg_name: string;
|
||||
installed_version: string;
|
||||
fixed_version: string | null;
|
||||
severity: VulnSeverity;
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
primary_url: string | null;
|
||||
}
|
||||
|
||||
export interface ScanSummary {
|
||||
image_ref: string;
|
||||
highest_severity: VulnSeverity | null;
|
||||
scanned_at: number;
|
||||
scan_id: number;
|
||||
total: number;
|
||||
critical: number;
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
unknown: number;
|
||||
fixable: number;
|
||||
}
|
||||
|
||||
export interface ScanPolicy {
|
||||
id: number;
|
||||
name: string;
|
||||
node_id: number | null;
|
||||
stack_pattern: string | null;
|
||||
max_severity: VulnSeverity;
|
||||
block_on_deploy: number;
|
||||
enabled: number;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
Reference in New Issue
Block a user