feat(security): enforce scan policies as a pre-deploy gate (#719)

Policies with block_on_deploy=1 now scan every stack image before
docker compose up runs and reject the deploy with HTTP 409 on violation.
The UI opens a dialog listing offending images; admins can override per
deploy with ?ignorePolicy=true, and every bypass is recorded in the
audit log with the originating route, actor, policy, and image list.

When Trivy is not installed on the target node the gate fails open with
a warning notification, so teams are never locked out by tooling state.
Post-deploy and scheduled scans still evaluate matching policies and
dispatch warnings on violations to surface drift on long-running stacks.

Public API additions: policy and suppression CRUD under /api/security,
plus the documented 409 block-response shape on all deploy paths.
This commit is contained in:
Anso
2026-04-21 00:14:11 -04:00
committed by GitHub
parent aa10db1d09
commit 661b9c638b
17 changed files with 1772 additions and 44 deletions
+53
View File
@@ -11,6 +11,7 @@ import { NodeRegistry } from './NodeRegistry';
import { RegistryService } from './RegistryService';
import { isDebugEnabled } from '../utils/debug';
import { isPathWithinBase, isValidStackName } from '../utils/validation';
/**
* ComposeService - local docker compose CLI execution.
@@ -407,4 +408,56 @@ export class ComposeService {
console.warn(`[Teardown] Docker down failed or nothing to clean up for ${stackName}`);
}
}
/**
* Enumerate image references declared in a stack's compose file.
*
* Used by the pre-deploy policy gate to decide which images to scan before
* `docker compose up` runs. Path traversal is guarded against the node's
* compose base directory; missing / unreadable compose files or `.env`
* interpolation failures surface as a rejected Promise so the gate can
* block the deploy rather than silently allow it.
*/
public async listStackImages(stackName: string): Promise<string[]> {
if (!isValidStackName(stackName)) {
throw new Error('Invalid stack path');
}
const stackDir = path.resolve(this.baseDir, stackName);
if (!isPathWithinBase(stackDir, this.baseDir) || path.resolve(this.baseDir) === stackDir) {
throw new Error('Invalid stack path');
}
const stdout = await this.captureCompose(['config', '--images'], stackDir);
const seen = new Set<string>();
const images: string[] = [];
for (const raw of stdout.split(/\r?\n/)) {
const line = raw.trim();
if (!line) continue;
if (line.startsWith('sha256:')) continue;
if (seen.has(line)) continue;
seen.add(line);
images.push(line);
}
return images;
}
private captureCompose(args: string[], cwd: string): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn('docker', ['compose', ...args], {
cwd,
env: {
...process.env,
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
},
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (data: Buffer) => { stdout += data.toString(); });
child.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
child.on('error', (err) => reject(err));
child.on('close', (code) => {
if (code === 0) resolve(stdout);
else reject(new Error(stderr.trim() || `docker compose ${args.join(' ')} failed with code ${code}`));
});
});
}
}
+89 -5
View File
@@ -2,6 +2,7 @@ import Database from 'better-sqlite3';
import path from 'path';
import fs from 'fs';
import { CryptoService } from './CryptoService';
import { isSeverityAtLeast } from '../utils/severity';
export interface Agent {
id?: number;
@@ -309,7 +310,22 @@ export interface NotificationRoute {
export type VulnSeverity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN';
export type VulnScanStatus = 'in_progress' | 'completed' | 'failed';
export type VulnScanTrigger = 'manual' | 'scheduled' | 'deploy';
export type VulnScanTrigger = 'manual' | 'scheduled' | 'deploy' | 'deploy-preflight';
/**
* Decision recorded when a scan is evaluated against the matching policy.
* Persisted as JSON on `vulnerability_scans.policy_evaluation` so the UI
* can surface a banner on the scan details sheet without re-running the
* match. `violated=false` rows exist too (informational), which is why
* presence of the field does not mean "blocked".
*/
export interface PolicyEvaluation {
policyId: number;
policyName: string;
maxSeverity: VulnSeverity;
violated: boolean;
evaluatedAt: number;
}
export interface VulnerabilityScan {
id: number;
@@ -335,6 +351,21 @@ export interface VulnerabilityScan {
status: VulnScanStatus;
error: string | null;
stack_context: string | null;
// JSON-encoded PolicyEvaluation; null if never evaluated.
policy_evaluation: string | null;
}
export function parsePolicyEvaluation(raw: string | null | undefined): PolicyEvaluation | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as PolicyEvaluation;
if (typeof parsed.policyId !== 'number' || typeof parsed.policyName !== 'string') {
return null;
}
return parsed;
} catch {
return null;
}
}
export interface VulnerabilityDetail {
@@ -450,6 +481,7 @@ export class DatabaseService {
this.migrateScanPolicyFleetColumns();
this.migrateSecretMisconfigColumns();
this.migrateAgentsAndNotificationsNodeId();
this.migratePolicyEvaluationColumn();
}
public static getInstance(): DatabaseService {
@@ -1145,6 +1177,16 @@ export class DatabaseService {
);
}
private migratePolicyEvaluationColumn(): void {
try {
this.db
.prepare('ALTER TABLE vulnerability_scans ADD COLUMN policy_evaluation TEXT')
.run();
} catch {
/* column already present */
}
}
// --- Agents ---
public getAgents(nodeId: number): Agent[] {
@@ -2451,7 +2493,9 @@ export class DatabaseService {
// --- Vulnerability Scans ---
public createVulnerabilityScan(
scan: Omit<VulnerabilityScan, 'id'>,
scan: Omit<VulnerabilityScan, 'id' | 'policy_evaluation'> & {
policy_evaluation?: string | null;
},
): number {
const stmt = this.db.prepare(
`INSERT INTO vulnerability_scans (
@@ -2460,8 +2504,8 @@ export class DatabaseService {
low_count, unknown_count, fixable_count,
secret_count, misconfig_count, scanners_used,
highest_severity, os_info, trivy_version, scan_duration_ms,
triggered_by, status, error, stack_context
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
triggered_by, status, error, stack_context, policy_evaluation
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
);
const result = stmt.run(
scan.node_id,
@@ -2486,6 +2530,7 @@ export class DatabaseService {
scan.status,
scan.error,
scan.stack_context,
scan.policy_evaluation ?? null,
);
return result.lastInsertRowid as number;
}
@@ -2500,7 +2545,7 @@ export class DatabaseService {
'medium_count', 'low_count', 'unknown_count', 'fixable_count',
'secret_count', 'misconfig_count', 'scanners_used',
'highest_severity', 'os_info', 'trivy_version', 'scan_duration_ms',
'triggered_by', 'status', 'error', 'stack_context',
'triggered_by', 'status', 'error', 'stack_context', 'policy_evaluation',
]);
const fields: string[] = [];
const values: unknown[] = [];
@@ -3038,6 +3083,45 @@ export class DatabaseService {
return scoped[0];
}
/**
* Evaluate a completed scan against the matching policy for its node and
* stack context. Returns the evaluation that should be persisted to the
* scan row, or null when no policy matches.
*
* The result is informational. `violated=false` means a policy matched
* but the scan was within limits; the UI surfaces a banner only when
* `violated=true`. Blocking enforcement lives in the pre-deploy gate.
*/
public evaluateScanAgainstPolicies(
nodeId: number,
scan: VulnerabilityScan,
selfIdentity: string,
): PolicyEvaluation | null {
const policy = this.getMatchingPolicy(nodeId, scan.stack_context, selfIdentity);
if (!policy) return null;
return {
policyId: policy.id,
policyName: policy.name,
maxSeverity: policy.max_severity,
violated: isSeverityAtLeast(scan.highest_severity, policy.max_severity),
evaluatedAt: Date.now(),
};
}
/**
* Persist a PolicyEvaluation onto a scan row. Pass null to clear.
* Encoded as JSON so consumers round-trip through parsePolicyEvaluation().
*/
public setScanPolicyEvaluation(
scanId: number,
evaluation: PolicyEvaluation | null,
): void {
const json = evaluation ? JSON.stringify(evaluation) : null;
this.db
.prepare('UPDATE vulnerability_scans SET policy_evaluation = ? WHERE id = ?')
.run(json, scanId);
}
// --- Fleet Sync Status ---
public getFleetSyncStatuses(): FleetSyncStatus[] {
+140
View File
@@ -0,0 +1,140 @@
/**
* Pre-deploy policy gate.
*
* Extracted from `index.ts` so route handlers and the scheduler can call a
* single, unit-testable function rather than copy-paste the gate logic.
*
* The gate fails open when Trivy is missing (users are never locked out by
* tooling state) and fails closed when the compose file cannot be parsed
* (a broken stack must not silently bypass a block policy).
*/
import { ComposeService } from './ComposeService';
import { DatabaseService } from './DatabaseService';
import type { ScanPolicy, VulnSeverity } from './DatabaseService';
import { FleetSyncService } from './FleetSyncService';
import { NotificationService } from './NotificationService';
import TrivyService from './TrivyService';
import { isSeverityAtLeast } from '../utils/severity';
import { validateImageRef } from '../utils/image-ref';
import { getErrorMessage } from '../utils/errors';
export interface PolicyViolation {
imageRef: string;
severity: VulnSeverity;
criticalCount: number;
highCount: number;
scanId: number;
}
export interface PolicyEnforcementOptions {
bypass: boolean;
actor: string;
ip?: string;
/** HTTP method of the originating request; used for audit attribution. */
auditMethod?: string;
/** Request path of the originating route; used for audit attribution. */
auditPath?: string;
}
export interface PolicyEnforcementResult {
ok: boolean;
bypassed: boolean;
policy?: ScanPolicy;
violations: PolicyViolation[];
trivyMissing?: boolean;
}
export async function enforcePolicyPreDeploy(
stackName: string,
nodeId: number,
opts: PolicyEnforcementOptions,
): Promise<PolicyEnforcementResult> {
const svc = TrivyService.getInstance();
const db = DatabaseService.getInstance();
const policy = db.getMatchingPolicy(nodeId, stackName, FleetSyncService.getSelfIdentity());
if (!policy || !policy.enabled || !policy.block_on_deploy) {
return { ok: true, bypassed: false, policy: policy ?? undefined, violations: [] };
}
if (!svc.isTrivyAvailable()) {
NotificationService.getInstance().dispatchAlert(
'warning',
`Pre-deploy scan for "${stackName}" skipped: Trivy not installed on this node`,
stackName,
);
return { ok: true, bypassed: false, policy, violations: [], trivyMissing: true };
}
let imageRefs: string[] = [];
try {
imageRefs = await ComposeService.getInstance(nodeId).listStackImages(stackName);
} catch (err) {
const message = getErrorMessage(err, 'compose parse failed');
console.error(`[Policy] listStackImages failed for ${stackName}:`, message);
return {
ok: false,
bypassed: false,
policy,
violations: [{
imageRef: '(compose parse error)',
severity: 'UNKNOWN',
criticalCount: 0,
highCount: 0,
scanId: 0,
}],
};
}
const violations: PolicyViolation[] = [];
for (const imageRef of imageRefs) {
if (!validateImageRef(imageRef)) continue;
try {
const scan = await svc.scanImagePreflight(imageRef, nodeId, stackName);
const severity = scan.highest_severity ?? 'UNKNOWN';
if (isSeverityAtLeast(severity, policy.max_severity)) {
violations.push({
imageRef,
severity,
criticalCount: scan.critical_count,
highCount: scan.high_count,
scanId: scan.id,
});
}
} catch (err) {
const message = getErrorMessage(err, 'pre-flight scan failed');
console.error(`[Policy] scanImagePreflight failed for ${imageRef}:`, message);
violations.push({
imageRef,
severity: 'UNKNOWN',
criticalCount: 0,
highCount: 0,
scanId: 0,
});
}
}
if (violations.length === 0) {
return { ok: true, bypassed: false, policy, violations: [] };
}
if (opts.bypass) {
try {
db.insertAuditLog({
timestamp: Date.now(),
username: opts.actor,
method: opts.auditMethod ?? 'POST',
path: opts.auditPath ?? `/api/stacks/${stackName}/deploy`,
status_code: 200,
node_id: nodeId,
ip_address: opts.ip ?? '',
summary: `policy.bypass stack="${stackName}" policy="${policy.name}" violations=${violations.length} images=[${violations.map((v) => v.imageRef).join(',')}]`,
});
} catch (err) {
console.error('[Policy] Failed to record bypass audit entry:', err);
}
return { ok: true, bypassed: true, policy, violations };
}
return { ok: false, bypassed: false, policy, violations };
}
+11 -1
View File
@@ -659,7 +659,17 @@ export class SchedulerService {
console.log(
`[SchedulerService:debug] executeScan summary: scanned=${summary.scanned} skipped=${summary.skipped} failed=${summary.failed} ` +
`critical=${summary.severity.critical} high=${summary.severity.high} medium=${summary.severity.medium} ` +
`low=${summary.severity.low} unknown=${summary.severity.unknown} durationMs=${Date.now() - scanStart}`,
`low=${summary.severity.low} unknown=${summary.severity.unknown} violations=${summary.violations.length} durationMs=${Date.now() - scanStart}`,
);
}
// Scheduled scans never auto-quarantine; violations surface as alerts
// so an operator can review and remediate. One alert per violation so
// the notification panel keeps per-image granularity.
for (const v of summary.violations ?? []) {
NotificationService.getInstance().dispatchAlert(
'warning',
`Policy "${v.policyName}" violated by ${v.imageRef}: ${v.severity} exceeds ${v.maxSeverity}`,
);
}
+100 -1
View File
@@ -14,6 +14,7 @@ import { FileSystemService } from './FileSystemService';
import { RegistryService } from './RegistryService';
import { disableCapability, enableCapability } from './CapabilityRegistry';
import TrivyInstaller, { type TrivySource } from './TrivyInstaller';
import { FleetSyncService } from './FleetSyncService';
import { getErrorMessage } from '../utils/errors';
import { isDebugEnabled } from '../utils/debug';
import { SEVERITY_ORDER } from '../utils/severity';
@@ -85,11 +86,24 @@ export interface ScanAllNodeImagesSeverityTotals {
unknown: number;
}
export interface ScanAllNodeImagesViolation {
imageRef: string;
scanId: number;
severity: VulnSeverity;
policyName: string;
maxSeverity: VulnSeverity;
}
export interface ScanAllNodeImagesResult {
scanned: number;
skipped: number;
failed: number;
severity: ScanAllNodeImagesSeverityTotals;
/**
* Policy violations observed across the freshly-scanned or cached rows.
* The scheduler uses this to dispatch alerts without re-querying the DB.
*/
violations: ScanAllNodeImagesViolation[];
}
export interface TrivyVulnerability {
@@ -725,6 +739,27 @@ class TrivyService {
);
const stored = db.getVulnerabilityScan(scanId);
if (!stored) throw new Error('Scan vanished after write');
// Evaluate against matching policy and persist the result so the
// UI can render a violation banner without re-running the match.
// This runs for every trigger (manual, deploy, deploy-preflight,
// scheduled, drift) so downstream surfaces stay consistent.
try {
const evaluation = db.evaluateScanAgainstPolicies(
nodeId,
stored,
FleetSyncService.getSelfIdentity(),
);
if (evaluation) {
db.setScanPolicyEvaluation(scanId, evaluation);
stored.policy_evaluation = JSON.stringify(evaluation);
}
} catch (err) {
// Never fail the scan because policy evaluation stumbled.
console.warn(
`[Trivy] policy evaluation failed for scanId=${scanId}:`,
getErrorMessage(err, 'unknown error'),
);
}
diag(
`finishScan: scanId=${scanId} completed vulns=${result.totalVulnerabilities} secrets=${result.secretCount} highest=${result.highestSeverity ?? 'none'} durationMs=${result.metadata.scanDurationMs}`,
);
@@ -752,6 +787,29 @@ class TrivyService {
return this.finishScan(scanId, imageRef, nodeId, opts);
}
/**
* Scan a single image for the pre-deploy policy gate.
*
* Reuses the 24h digest cache (useCache=true) so repeat deploys of a
* known-safe image do not pay full scan cost. Only runs the vulnerability
* scanner (secrets/misconfig are irrelevant to the gate and add latency).
* The scan is persisted as a normal row with triggered_by=deploy-preflight
* so the history and compare views continue to work unchanged.
*/
async scanImagePreflight(
imageRef: string,
nodeId: number,
stackName: string | null,
): Promise<VulnerabilityScan> {
return this.runScanAndPersist(
imageRef,
nodeId,
'deploy-preflight',
stackName,
{ useCache: true, scanners: ['vuln'] },
);
}
/**
* Scan a compose stack directory for misconfigurations. A new scan
* row is persisted with image_ref='stack:<name>' so misconfigs share
@@ -871,6 +929,22 @@ class TrivyService {
);
const stored = db.getVulnerabilityScan(scanId);
if (!stored) throw new Error('Scan vanished after write');
try {
const evaluation = db.evaluateScanAgainstPolicies(
nodeId,
stored,
FleetSyncService.getSelfIdentity(),
);
if (evaluation) {
db.setScanPolicyEvaluation(scanId, evaluation);
stored.policy_evaluation = JSON.stringify(evaluation);
}
} catch (err) {
console.warn(
`[Trivy] policy evaluation failed for stack scanId=${scanId}:`,
getErrorMessage(err, 'unknown error'),
);
}
return stored;
} finally {
cleanup();
@@ -906,6 +980,7 @@ class TrivyService {
let failed = 0;
const severity = { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 };
const countedDigests = new Set<string>();
const violations: ScanAllNodeImagesViolation[] = [];
const addSeverity = (row: VulnerabilityScan | null): void => {
if (!row) return;
@@ -916,6 +991,28 @@ class TrivyService {
severity.unknown += row.unknown_count;
};
const collectViolation = (row: VulnerabilityScan | null): void => {
if (!row || !row.policy_evaluation) return;
try {
const parsed = JSON.parse(row.policy_evaluation) as {
violated: boolean;
policyName: string;
maxSeverity: VulnSeverity;
};
if (parsed.violated) {
violations.push({
imageRef: row.image_ref,
scanId: row.id,
severity: row.highest_severity ?? 'UNKNOWN',
policyName: parsed.policyName,
maxSeverity: parsed.maxSeverity,
});
}
} catch {
// Ignore malformed evaluation JSON; presence is informational.
}
};
for (const ref of imageRefs) {
try {
const digest = await this.getImageDigest(ref, nodeId);
@@ -926,12 +1023,14 @@ class TrivyService {
if (cached && Date.now() - cached.scanned_at < DIGEST_CACHE_TTL_MS) {
skipped++;
addSeverity(cached);
collectViolation(cached);
countedDigests.add(digest);
continue;
}
}
const fresh = await this.runScanAndPersist(ref, nodeId, triggeredBy, null);
addSeverity(fresh);
collectViolation(fresh);
scanned++;
if (digest) countedDigests.add(digest);
} catch (err) {
@@ -940,7 +1039,7 @@ class TrivyService {
}
await new Promise((r) => setTimeout(r, 300));
}
return { scanned, skipped, failed, severity };
return { scanned, skipped, failed, severity, violations };
}
async generateSBOM(imageRef: string, format: SbomFormat): Promise<string> {