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

* feat(images): Trivy-powered vulnerability scanning

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

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

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