/** * 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 { sanitizeForLog } from '../utils/safeLog'; 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 { 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', 'scan_finding', `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 %s:', sanitizeForLog(stackName), sanitizeForLog(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 }; }