Files
Anso b91025dc8b fix: unlock Community deploy policy hard-blocking (#1643)
Remove the leftover paid-only blockingEnabled switch so enabled
block-on-deploy policies enforce on every tier, matching the
documented every-tier security surface. Existing Community policies
begin blocking immediately with no migration.
2026-07-16 15:24:05 -04:00

171 lines
6.3 KiB
TypeScript

import type { Request, Response } from 'express';
import { enforcePolicyPreDeploy, type PolicyEnforcementOptions, type PolicyViolation } from '../services/PolicyEnforcement';
import DockerController from '../services/DockerController';
import { DatabaseService } from '../services/DatabaseService';
import type { ScanPolicy } from '../services/DatabaseService';
import { NotificationService } from '../services/NotificationService';
import TrivyService, { DIGEST_CACHE_TTL_MS } from '../services/TrivyService';
import { getErrorMessage } from '../utils/errors';
import { sanitizeForLog } from '../utils/safeLog';
import { summarizeBlockReasons } from '../utils/policy-risk';
type BlockableAction = 'deploy' | 'update' | 'rollback';
/**
* One-line block message naming the risk inputs that matched, shared by the
* thrown-error and 409-response paths so they never drift. Falls back to a
* generic phrase when no reason was recorded (e.g. an image that could not be
* scanned).
*/
export function describePolicyBlock(
policy: ScanPolicy | undefined,
violations: PolicyViolation[],
action: BlockableAction = 'deploy',
): string {
return `Policy "${policy?.name ?? 'policy'}" blocked ${action}: ${violations.length} image(s) matched ${summarizeBlockReasons(violations)}`;
}
// Bypass requires `?ignorePolicy=true` AND `req.user.role === 'admin'`. The
// `stack:deploy` permission alone is not sufficient because the `deployer`
// role has that permission for day-to-day deploys.
export function buildPolicyGateOptions(
req: Request,
overrides: { bypass?: boolean; actor?: string } = {},
): PolicyEnforcementOptions {
const defaultBypass = req.query.ignorePolicy === 'true' && req.user?.role === 'admin';
return {
bypass: overrides.bypass ?? defaultBypass,
actor: overrides.actor ?? req.user?.username ?? 'unknown',
ip: (req.ip ?? req.socket.remoteAddress ?? '') as string,
auditMethod: req.method,
auditPath: req.originalUrl || req.url,
};
}
export function buildSystemPolicyGateOptions(
actor: string,
overrides: { bypass?: boolean; auditPath?: string; auditMethod?: string } = {},
): PolicyEnforcementOptions {
return {
bypass: overrides.bypass ?? false,
actor,
auditMethod: overrides.auditMethod ?? 'POST',
auditPath: overrides.auditPath,
};
}
export async function assertPolicyGateAllows(
stackName: string,
nodeId: number,
options: PolicyEnforcementOptions,
): Promise<void> {
const gate = await enforcePolicyPreDeploy(stackName, nodeId, options);
if (!gate.ok) {
throw new Error(describePolicyBlock(gate.policy, gate.violations));
}
}
/**
* Returns true if the deploy may proceed. Returns false after sending a 409,
* in which case the caller must return immediately.
*/
export async function runPolicyGate(
req: Request,
res: Response,
stackName: string,
nodeId: number,
): Promise<boolean> {
const gate = await enforcePolicyPreDeploy(stackName, nodeId, buildPolicyGateOptions(req));
if (!gate.ok) {
res.status(409).json({
error: describePolicyBlock(gate.policy, gate.violations),
policy: gate.policy && {
id: gate.policy.id,
name: gate.policy.name,
maxSeverity: gate.policy.max_severity,
blockOnSeverity: gate.policy.block_on_severity,
blockOnKev: gate.policy.block_on_kev,
blockOnFixable: gate.policy.block_on_fixable,
},
violations: gate.violations,
});
return false;
}
return true;
}
export async function triggerPostDeployScan(
stackName: string,
nodeId: number,
): Promise<void> {
const svc = TrivyService.getInstance();
const db = DatabaseService.getInstance();
if (!svc.isTrivyAvailable()) {
db.recordStackScanAttempt(nodeId, stackName, 'skipped', 'Trivy is not available on this node');
return;
}
let imageFailures = 0;
let imageSuccesses = 0;
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) {
db.recordStackScanAttempt(nodeId, stackName, 'skipped', 'No images to scan');
return;
}
for (const imageRef of imageRefs) {
try {
const digest = await svc.getImageDigest(imageRef, nodeId);
if (digest) {
const cached = db.getLatestScanByDigest(digest, 'vuln');
if (cached && Date.now() - cached.scanned_at < DIGEST_CACHE_TTL_MS) {
imageSuccesses += 1;
continue;
}
}
const scan = await svc.runScanAndPersist(imageRef, nodeId, 'deploy', stackName);
imageSuccesses += 1;
if (scan.critical_count > 0 || scan.high_count > 0) {
NotificationService.getInstance().dispatchAlert(
scan.critical_count > 0 ? 'error' : 'warning',
'scan_finding',
`Vulnerability scan for ${imageRef}: ${scan.critical_count} critical, ${scan.high_count} high`,
{ stackName, actor: 'system:policy' },
);
}
} catch (err) {
imageFailures += 1;
const message = getErrorMessage(err, 'unknown error');
console.error(`[Security] Post-deploy scan failed for ${imageRef}:`, message);
NotificationService.getInstance().dispatchAlert(
'warning',
'scan_finding',
`Post-deploy scan failed for ${imageRef} (${stackName}): ${message}`,
{ stackName, actor: 'system:policy' },
);
}
}
if (imageFailures === 0) {
db.recordStackScanAttempt(nodeId, stackName, 'ok', null);
} else if (imageSuccesses === 0) {
db.recordStackScanAttempt(nodeId, stackName, 'failed', `${imageFailures} image(s) failed to scan`);
} else {
db.recordStackScanAttempt(nodeId, stackName, 'partial', `${imageFailures} of ${imageFailures + imageSuccesses} image(s) failed`);
}
} catch (err) {
const message = getErrorMessage(err, 'unknown error');
console.error('[Security] triggerPostDeployScan error for %s:', sanitizeForLog(stackName), sanitizeForLog(message));
db.recordStackScanAttempt(nodeId, stackName, 'failed', message);
}
}