mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-27 12:18:59 +00:00
b91025dc8b
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.
171 lines
6.3 KiB
TypeScript
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);
|
|
}
|
|
}
|