fix: harden deploy enforcement paths (#1030)

* fix: harden deploy enforcement paths

* fix: update Docker toolchain to Go 1.26.3

* fix: repair Dockerfile tr argument split across lines

* fix: bump protobufjs to clear npm audit high-severity advisories

* fix(test): add execFile to child_process mock in compose-images test

* fix: resolve merge conflicts with main

* fix: resolve merge conflicts with main

* fix: resolve merge conflicts with main
This commit is contained in:
Anso
2026-05-12 19:30:49 -04:00
committed by GitHub
parent 74ae2ce0c6
commit b1c5fe8391
19 changed files with 1179 additions and 922 deletions
+170 -170
View File
@@ -1,170 +1,170 @@
/**
* Exercises ComposeService.listStackImages, the helper the policy gate calls
* to enumerate the images a stack will pull before `docker compose up`.
*
* The stdout from `docker compose config --images` can contain duplicates
* (multiple services running the same image), trailing whitespace, blank
* lines, and `sha256:` digest lines we must not pass to Trivy. The gate
* feeds this list directly to `scanImagePreflight`, so dedupe + filter
* correctness here directly affects what gets scanned and what silently
* passes through.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { EventEmitter } from 'events';
const { mockSpawn } = vi.hoisted(() => ({ mockSpawn: vi.fn() }));
vi.mock('child_process', () => ({ spawn: mockSpawn }));
vi.mock('../services/NodeRegistry', () => ({
NodeRegistry: {
getInstance: () => ({
getDefaultNodeId: () => 1,
getComposeDir: () => '/test/compose',
}),
},
}));
vi.mock('../services/DockerController', () => ({
default: {
getInstance: () => ({
getContainersByStack: vi.fn().mockResolvedValue([]),
removeContainers: vi.fn().mockResolvedValue([]),
getDocker: () => ({
listContainers: vi.fn().mockResolvedValue([]),
}),
}),
},
}));
vi.mock('../services/DatabaseService', () => ({
DatabaseService: { getInstance: () => ({ getRegistries: () => [] }) },
}));
vi.mock('../services/RegistryService', () => ({
RegistryService: {
getInstance: () => ({
resolveDockerConfig: vi.fn().mockResolvedValue({ config: { auths: {} }, warnings: [] }),
}),
},
}));
vi.mock('../services/FileSystemService', () => ({
FileSystemService: {
getInstance: () => ({
backupStackFiles: vi.fn().mockResolvedValue(undefined),
restoreStackFiles: vi.fn().mockResolvedValue(undefined),
}),
},
}));
vi.mock('../services/LogFormatter', () => ({
LogFormatter: { formatLine: (line: string) => line },
}));
import { ComposeService } from '../services/ComposeService';
function mockComposeConfig(stdout: string, exitCode = 0): void {
mockSpawn.mockImplementation(() => {
const proc = new EventEmitter() as EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
kill: ReturnType<typeof vi.fn>;
};
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
proc.kill = vi.fn();
Promise.resolve().then(() => {
if (stdout) proc.stdout.emit('data', Buffer.from(stdout));
proc.emit('close', exitCode);
});
return proc;
});
}
describe('ComposeService.listStackImages', () => {
beforeEach(() => {
mockSpawn.mockReset();
});
it('returns the list of images, trimmed and deduped', async () => {
mockComposeConfig('nginx:1.14\nredis:7\nnginx:1.14\n');
const images = await ComposeService.getInstance(1).listStackImages('my-stack');
expect(images).toEqual(['nginx:1.14', 'redis:7']);
});
it('invokes `docker compose config --images` in the stack directory', async () => {
mockComposeConfig('nginx:1.14\n');
await ComposeService.getInstance(1).listStackImages('my-stack');
expect(mockSpawn).toHaveBeenCalledWith(
'docker',
['compose', 'config', '--images'],
expect.objectContaining({ cwd: expect.stringContaining('my-stack') }),
);
});
it('filters out sha256 digest lines', async () => {
mockComposeConfig('nginx:1.14\nsha256:deadbeefcafebabe\nredis:7\n');
const images = await ComposeService.getInstance(1).listStackImages('my-stack');
expect(images).toEqual(['nginx:1.14', 'redis:7']);
});
it('handles trailing / leading whitespace and CRLF endings', async () => {
mockComposeConfig(' nginx:1.14 \r\n\r\n\tredis:7\r\n');
const images = await ComposeService.getInstance(1).listStackImages('my-stack');
expect(images).toEqual(['nginx:1.14', 'redis:7']);
});
it('returns an empty list when stdout is empty', async () => {
mockComposeConfig('');
const images = await ComposeService.getInstance(1).listStackImages('my-stack');
expect(images).toEqual([]);
});
it('rejects stack names that traverse outside the compose base', async () => {
await expect(
ComposeService.getInstance(1).listStackImages('../evil'),
).rejects.toThrow(/Invalid stack path/);
expect(mockSpawn).not.toHaveBeenCalled();
});
it('rejects when docker compose exits non-zero', async () => {
mockSpawn.mockImplementation(() => {
const proc = new EventEmitter() as EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
kill: ReturnType<typeof vi.fn>;
};
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
proc.kill = vi.fn();
Promise.resolve().then(() => {
proc.stderr.emit('data', Buffer.from('compose file missing'));
proc.emit('close', 1);
});
return proc;
});
await expect(
ComposeService.getInstance(1).listStackImages('my-stack'),
).rejects.toThrow(/compose file missing/);
});
it('preserves image-ref ordering for deterministic downstream scans', async () => {
mockComposeConfig('redis:7\npostgres:15\nnginx:1.14\n');
const images = await ComposeService.getInstance(1).listStackImages('my-stack');
expect(images).toEqual(['redis:7', 'postgres:15', 'nginx:1.14']);
});
});
/**
* Exercises ComposeService.listStackImages, the helper the policy gate calls
* to enumerate the images a stack will pull before `docker compose up`.
*
* The stdout from `docker compose config --images` can contain duplicates
* (multiple services running the same image), trailing whitespace, blank
* lines, and `sha256:` digest lines we must not pass to Trivy. The gate
* feeds this list directly to `scanImagePreflight`, so dedupe + filter
* correctness here directly affects what gets scanned and what silently
* passes through.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { EventEmitter } from 'events';
const { mockSpawn } = vi.hoisted(() => ({ mockSpawn: vi.fn() }));
vi.mock('child_process', () => ({ spawn: mockSpawn, execFile: vi.fn() }));
vi.mock('../services/NodeRegistry', () => ({
NodeRegistry: {
getInstance: () => ({
getDefaultNodeId: () => 1,
getComposeDir: () => '/test/compose',
}),
},
}));
vi.mock('../services/DockerController', () => ({
default: {
getInstance: () => ({
getContainersByStack: vi.fn().mockResolvedValue([]),
removeContainers: vi.fn().mockResolvedValue([]),
getDocker: () => ({
listContainers: vi.fn().mockResolvedValue([]),
}),
}),
},
}));
vi.mock('../services/DatabaseService', () => ({
DatabaseService: { getInstance: () => ({ getRegistries: () => [] }) },
}));
vi.mock('../services/RegistryService', () => ({
RegistryService: {
getInstance: () => ({
resolveDockerConfig: vi.fn().mockResolvedValue({ config: { auths: {} }, warnings: [] }),
}),
},
}));
vi.mock('../services/FileSystemService', () => ({
FileSystemService: {
getInstance: () => ({
backupStackFiles: vi.fn().mockResolvedValue(undefined),
restoreStackFiles: vi.fn().mockResolvedValue(undefined),
}),
},
}));
vi.mock('../services/LogFormatter', () => ({
LogFormatter: { formatLine: (line: string) => line },
}));
import { ComposeService } from '../services/ComposeService';
function mockComposeConfig(stdout: string, exitCode = 0): void {
mockSpawn.mockImplementation(() => {
const proc = new EventEmitter() as EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
kill: ReturnType<typeof vi.fn>;
};
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
proc.kill = vi.fn();
Promise.resolve().then(() => {
if (stdout) proc.stdout.emit('data', Buffer.from(stdout));
proc.emit('close', exitCode);
});
return proc;
});
}
describe('ComposeService.listStackImages', () => {
beforeEach(() => {
mockSpawn.mockReset();
});
it('returns the list of images, trimmed and deduped', async () => {
mockComposeConfig('nginx:1.14\nredis:7\nnginx:1.14\n');
const images = await ComposeService.getInstance(1).listStackImages('my-stack');
expect(images).toEqual(['nginx:1.14', 'redis:7']);
});
it('invokes `docker compose config --images` in the stack directory', async () => {
mockComposeConfig('nginx:1.14\n');
await ComposeService.getInstance(1).listStackImages('my-stack');
expect(mockSpawn).toHaveBeenCalledWith(
'docker',
['compose', 'config', '--images'],
expect.objectContaining({ cwd: expect.stringContaining('my-stack') }),
);
});
it('filters out sha256 digest lines', async () => {
mockComposeConfig('nginx:1.14\nsha256:deadbeefcafebabe\nredis:7\n');
const images = await ComposeService.getInstance(1).listStackImages('my-stack');
expect(images).toEqual(['nginx:1.14', 'redis:7']);
});
it('handles trailing / leading whitespace and CRLF endings', async () => {
mockComposeConfig(' nginx:1.14 \r\n\r\n\tredis:7\r\n');
const images = await ComposeService.getInstance(1).listStackImages('my-stack');
expect(images).toEqual(['nginx:1.14', 'redis:7']);
});
it('returns an empty list when stdout is empty', async () => {
mockComposeConfig('');
const images = await ComposeService.getInstance(1).listStackImages('my-stack');
expect(images).toEqual([]);
});
it('rejects stack names that traverse outside the compose base', async () => {
await expect(
ComposeService.getInstance(1).listStackImages('../evil'),
).rejects.toThrow(/Invalid stack path/);
expect(mockSpawn).not.toHaveBeenCalled();
});
it('rejects when docker compose exits non-zero', async () => {
mockSpawn.mockImplementation(() => {
const proc = new EventEmitter() as EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
kill: ReturnType<typeof vi.fn>;
};
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
proc.kill = vi.fn();
Promise.resolve().then(() => {
proc.stderr.emit('data', Buffer.from('compose file missing'));
proc.emit('close', 1);
});
return proc;
});
await expect(
ComposeService.getInstance(1).listStackImages('my-stack'),
).rejects.toThrow(/compose file missing/);
});
it('preserves image-ref ordering for deterministic downstream scans', async () => {
mockComposeConfig('redis:7\npostgres:15\nnginx:1.14\n');
const images = await ComposeService.getInstance(1).listStackImages('my-stack');
expect(images).toEqual(['redis:7', 'postgres:15', 'nginx:1.14']);
});
});
+15 -1
View File
@@ -31,7 +31,7 @@ const {
mockRmdirSync: vi.fn(),
}));
vi.mock('child_process', () => ({ spawn: mockSpawn }));
vi.mock('child_process', () => ({ spawn: mockSpawn, execFile: vi.fn() }));
vi.mock('fs', () => ({
default: {
@@ -186,6 +186,20 @@ describe('ComposeService - runCommand', () => {
await expect(promise).rejects.toThrow('service not found');
});
it('redacts secrets from command failure errors', async () => {
const proc = createMockProcess();
mockSpawn.mockReturnValue(proc);
const svc = ComposeService.getInstance(1);
const promise = svc.runCommand('my-stack', 'stop');
proc.stderr.emit('data', Buffer.from('token=abc123SECRET password=hunter2 Authorization: Bearer abc.def.ghi'));
proc.emit('close', 1);
await expect(promise).rejects.toThrow('token=[redacted]');
await expect(promise).rejects.toThrow('password=[redacted]');
await expect(promise).rejects.not.toThrow('abc.def.ghi');
});
it('sends output to WebSocket when provided', async () => {
const proc = createMockProcess();
mockSpawn.mockReturnValue(proc);
@@ -51,6 +51,7 @@ beforeEach(() => {
// Wipe persisted git sources between tests
const db = DatabaseService.getInstance();
for (const s of db.getGitSources()) db.deleteGitSource(s.stack_name);
for (const p of db.getScanPolicies()) db.deleteScanPolicy(p.id);
});
// ── Helpers ────────────────────────────────────────────────────────────
@@ -852,4 +853,72 @@ describe('GitSourceService.apply', () => {
validateSpy.mockRestore();
saveSpy.mockRestore();
});
it('returns deployError and skips compose deploy when policy blocks apply deploy', async () => {
const sha = 'dddd444dddd444dddd444dddd444dddd444dddd4';
const svc = await seedPending('apply-policy-block', 'services:\n x:\n image: nginx:bad\n', sha);
const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true });
const { FileSystemService } = await import('../services/FileSystemService');
const { ComposeService } = await import('../services/ComposeService');
const { LicenseService } = await import('../services/LicenseService');
const TrivyService = (await import('../services/TrivyService')).default;
const saveSpy = vi.spyOn(FileSystemService.prototype, 'saveStackContent').mockResolvedValue();
const listImagesSpy = vi.spyOn(ComposeService.prototype, 'listStackImages').mockResolvedValue(['nginx:bad']);
const deploySpy = vi.spyOn(ComposeService.prototype, 'deployStack').mockResolvedValue();
const tierSpy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
const trivy = TrivyService.getInstance();
const trivyAvailableSpy = vi.spyOn(trivy, 'isTrivyAvailable').mockReturnValue(true);
const scanSpy = vi.spyOn(trivy, 'scanImagePreflight').mockResolvedValue({
id: 77,
node_id: 1,
image_ref: 'nginx:bad',
image_digest: null,
scanned_at: Date.now(),
total_vulnerabilities: 1,
critical_count: 1,
high_count: 0,
medium_count: 0,
low_count: 0,
unknown_count: 0,
fixable_count: 0,
secret_count: 0,
misconfig_count: 0,
scanners_used: 'vuln',
highest_severity: 'CRITICAL',
os_info: null,
trivy_version: '0.50.0',
scan_duration_ms: null,
triggered_by: 'deploy-preflight',
status: 'completed',
error: null,
stack_context: 'apply-policy-block',
policy_evaluation: null,
});
DatabaseService.getInstance().createScanPolicy({
name: 'block-high',
node_id: null,
node_identity: '',
stack_pattern: 'apply-policy-block',
max_severity: 'HIGH',
block_on_deploy: 1,
enabled: 1,
replicated_from_control: 0,
});
const result = await svc.apply('apply-policy-block', sha, { deploy: true });
expect(result.applied).toBe(true);
expect(result.deployed).toBe(false);
expect(result.deployError).toContain('Policy "block-high" blocked deploy');
expect(deploySpy).not.toHaveBeenCalled();
validateSpy.mockRestore();
saveSpy.mockRestore();
listImagesSpy.mockRestore();
deploySpy.mockRestore();
tierSpy.mockRestore();
trivyAvailableSpy.mockRestore();
scanSpy.mockRestore();
});
});
@@ -150,6 +150,21 @@ describe('enforcePolicyPreDeploy', () => {
expect(composeStub.listStackImages).not.toHaveBeenCalled();
});
it('allows deploy without scanning when paid-tier blocking is disabled', async () => {
dbStub.getMatchingPolicy.mockReturnValue(mkPolicy());
const result = await enforcePolicyPreDeploy('web', 1, {
bypass: false,
actor: 'u',
blockingEnabled: false,
});
expect(result.ok).toBe(true);
expect(result.violations).toEqual([]);
expect(trivyStub.isTrivyAvailable).not.toHaveBeenCalled();
expect(composeStub.listStackImages).not.toHaveBeenCalled();
});
it('fails open with a warning alert when Trivy is not installed', async () => {
dbStub.getMatchingPolicy.mockReturnValue(mkPolicy());
trivyStub.isTrivyAvailable.mockReturnValue(false);
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { redactSensitiveText } from '../utils/safeLog';
describe('redactSensitiveText', () => {
it('redacts credentials from durable log text', () => {
const text = redactSensitiveText(
'connect https://user:pass@example.invalid failed Authorization: Bearer abc.def.ghi token=secret123 password=hunter2',
);
expect(text).toContain('https://[redacted]@example.invalid');
expect(text).toContain('Authorization: [redacted]');
expect(text).toContain('token=[redacted]');
expect(text).toContain('password=[redacted]');
expect(text).not.toContain('user:pass');
expect(text).not.toContain('abc.def.ghi');
expect(text).not.toContain('secret123');
expect(text).not.toContain('hunter2');
});
});
@@ -23,6 +23,7 @@ const {
mockScanAllNodeImages,
mockGetStackAutoUpdateSettingsForNode,
mockDeleteScheduledTask,
mockGetMatchingPolicy,
mockRunCommand,
mockDeployStack,
mockBackupStackFiles,
@@ -63,6 +64,7 @@ const {
}),
mockGetStackAutoUpdateSettingsForNode: vi.fn().mockReturnValue({}),
mockDeleteScheduledTask: vi.fn(),
mockGetMatchingPolicy: vi.fn().mockReturnValue(null),
mockRunCommand: vi.fn().mockResolvedValue(undefined),
mockDeployStack: vi.fn().mockResolvedValue(undefined),
mockBackupStackFiles: vi.fn().mockResolvedValue(undefined),
@@ -86,10 +88,17 @@ vi.mock('../services/DatabaseService', () => ({
deleteOldScans: mockDeleteOldScans,
getStackAutoUpdateSettingsForNode: mockGetStackAutoUpdateSettingsForNode,
deleteScheduledTask: mockDeleteScheduledTask,
getMatchingPolicy: mockGetMatchingPolicy,
}),
},
}));
vi.mock('../services/FleetSyncService', () => ({
FleetSyncService: {
getSelfIdentity: () => 'self-node',
},
}));
vi.mock('../services/LicenseService', () => ({
LicenseService: {
getInstance: () => ({
+27
View File
@@ -4,6 +4,8 @@ import DockerController from '../services/DockerController';
import { DatabaseService } from '../services/DatabaseService';
import { NotificationService } from '../services/NotificationService';
import TrivyService, { DIGEST_CACHE_TTL_MS } from '../services/TrivyService';
import { LicenseService } from '../services/LicenseService';
import { effectiveTier } from '../middleware/tierGates';
import { getErrorMessage } from '../utils/errors';
import { sanitizeForLog } from '../utils/safeLog';
@@ -18,12 +20,37 @@ export function buildPolicyGateOptions(
return {
bypass: overrides.bypass ?? defaultBypass,
actor: overrides.actor ?? req.user?.username ?? 'unknown',
blockingEnabled: effectiveTier(req) === 'paid',
ip: (req.ip ?? req.socket.remoteAddress ?? '') as string,
auditMethod: req.method,
auditPath: req.originalUrl || req.url,
};
}
export function buildSystemPolicyGateOptions(
actor: string,
overrides: { bypass?: boolean; blockingEnabled?: boolean; auditPath?: string; auditMethod?: string } = {},
): PolicyEnforcementOptions {
return {
bypass: overrides.bypass ?? false,
actor,
blockingEnabled: overrides.blockingEnabled ?? LicenseService.getInstance().getTier() === 'paid',
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(`Policy "${gate.policy?.name}" blocked deploy: ${gate.violations.length} image(s) exceed ${gate.policy?.max_severity}`);
}
}
/**
* Returns true if the deploy may proceed. Returns false after sending a 409,
* in which case the caller must return immediately.
+20 -1
View File
@@ -5,6 +5,8 @@ import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from '../services/license-hea
import { LicenseService } from '../services/LicenseService';
import { isProxyExemptPath } from '../helpers/proxyExemptPaths';
import { getErrorMessage } from '../utils/errors';
import { DatabaseService } from '../services/DatabaseService';
import { redactSensitiveText } from '../utils/safeLog';
/**
* Build the remote-node HTTP proxy middleware. Mount once at `/api/` after
@@ -74,8 +76,25 @@ export function createRemoteProxyMiddleware(): RequestHandler {
// a bad token causes an immediate logout loop.
proxyRes.headers['x-sencho-proxy'] = '1';
},
error: (err, _req, proxyRes) => {
error: (err, req, proxyRes) => {
console.error('[Proxy] Remote node error:', getErrorMessage(err, 'unknown'));
const path = req.originalUrl || req.url;
if (req.method === 'POST' && /^\/api\/stacks\/[^/]+\/(?:deploy|update)(?:\?|$)/.test(path)) {
try {
DatabaseService.getInstance().insertAuditLog({
timestamp: Date.now(),
username: req.user?.username ?? 'unknown',
method: req.method,
path,
status_code: 502,
node_id: req.nodeId,
ip_address: req.ip ?? '',
summary: `remote deploy proxy error: ${redactSensitiveText(getErrorMessage(err, 'unknown'))}`,
});
} catch (auditErr) {
console.warn('[Proxy] Failed to record remote deploy proxy error:', getErrorMessage(auditErr, 'unknown'));
}
}
// proxyRes can be either a ServerResponse (HTTP) or a raw Socket
// (WS/TCP errors). Only attempt to send an HTTP 502 if it is a
// proper ServerResponse with a headersSent flag; otherwise silently
+7 -1
View File
@@ -16,6 +16,7 @@ import { fetchRemoteMeta, getSenchoVersion, isValidVersion } from '../services/C
import { authMiddleware } from '../middleware/auth';
import { requirePaid, requireAdmin, requireNodeProxy } from '../middleware/tierGates';
import { scheduleLocalUpdate } from './license';
import { runPolicyGate } from '../helpers/policyGate';
import { captureLocalNodeFiles, captureRemoteNodeFiles, type SnapshotNodeData } from '../utils/snapshot-capture';
import { getLatestVersion } from '../utils/version-check';
import { isValidStackName } from '../utils/validation';
@@ -1325,6 +1326,7 @@ fleetRouter.post('/snapshots/:id/restore', authMiddleware, async (req: Request,
}
if (redeploy) {
if (!(await runPolicyGate(req, res, stackName, node.id))) return;
const composeService = ComposeService.getInstance(node.id);
await composeService.deployStack(stackName);
}
@@ -1335,9 +1337,12 @@ fleetRouter.post('/snapshots/:id/restore', authMiddleware, async (req: Request,
}
const baseUrl = node.api_url.replace(/\/$/, '');
const proxyHeaders = LicenseService.getInstance().getProxyHeaders();
const headers: Record<string, string> = {
Authorization: `Bearer ${node.api_token}`,
'Content-Type': 'application/json',
[PROXY_TIER_HEADER]: proxyHeaders.tier,
[PROXY_VARIANT_HEADER]: proxyHeaders.variant ?? '',
};
for (const file of files) {
@@ -1361,11 +1366,12 @@ fleetRouter.post('/snapshots/:id/restore', authMiddleware, async (req: Request,
}
if (redeploy) {
await fetch(`${baseUrl}/api/compose/${encodeURIComponent(stackName)}/up`, {
const deployRes = await fetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}/deploy`, {
method: 'POST',
headers,
signal: AbortSignal.timeout(30000),
});
if (!deployRes.ok) throw new Error('Failed to redeploy stack on remote node');
}
}
+9 -1
View File
@@ -2,6 +2,7 @@ import { Router, type Request, type Response } from 'express';
import path from 'path';
import { GitSourceService } from '../services/GitSourceService';
import { FileSystemService } from '../services/FileSystemService';
import { DatabaseService } from '../services/DatabaseService';
import { checkPermission, requirePermission } from '../middleware/permissions';
import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
import { triggerPostDeployScan } from '../helpers/policyGate';
@@ -199,10 +200,17 @@ stackGitSourceRouter.post('/:stackName/git-source/apply', async (req: Request, r
res.status(400).json({ error: 'commitSha is required' });
return;
}
const source = DatabaseService.getInstance().getGitSource(stackName);
const willDeploy = typeof deploy === 'boolean' ? deploy : source?.auto_deploy_on_apply === true;
if (willDeploy && !requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
const result = await GitSourceService.getInstance().apply(
stackName,
commitSha.trim(),
{ deploy: typeof deploy === 'boolean' ? deploy : undefined },
{
deploy: typeof deploy === 'boolean' ? deploy : undefined,
actor: req.user?.username ?? 'unknown',
bypassPolicy: req.query.ignorePolicy === 'true' && req.user?.role === 'admin',
},
);
invalidateNodeCaches(req.nodeId);
const shortSha = commitSha.trim().slice(0, 7);
+1
View File
@@ -805,6 +805,7 @@ stacksRouter.post('/:stackName/rollback', async (req: Request, res: Response) =>
}
console.log(`[Stacks] Rollback initiated: ${sanitizeForLog(stackName)}`);
await fsSvc.restoreStackFiles(stackName);
if (!(await runPolicyGate(req, res, stackName, req.nodeId))) return;
await ComposeService.getInstance(req.nodeId).deployStack(stackName, getTerminalWs(), false);
invalidateNodeCaches(req.nodeId);
console.log(`[Stacks] Rollback completed: ${sanitizeForLog(stackName)}`);
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -14,7 +14,7 @@ import { RegistryService } from './RegistryService';
import { isDebugEnabled } from '../utils/debug';
import { getErrorMessage } from '../utils/errors';
import { isPathWithinBase, isValidStackName } from '../utils/validation';
import { sanitizeForLog } from '../utils/safeLog';
import { redactSensitiveText, sanitizeForLog } from '../utils/safeLog';
export class ComposeRollbackError extends Error {
public readonly rollbackAttempted: boolean;
@@ -115,15 +115,15 @@ export class ComposeService {
ws.send(`Command exited with code ${code}\n`);
}
if (code === 0) resolve();
else if (throwOnError) reject(new Error(errorLog.trim() || `Command failed with code ${code}`));
else if (throwOnError) reject(new Error(redactSensitiveText(errorLog.trim()) || `Command failed with code ${code}`));
else resolve();
});
child.on('error', (error: Error) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(`Error: ${error.message}\n`);
ws.send(`Error: ${redactSensitiveText(error.message)}\n`);
}
if (throwOnError) reject(error);
if (throwOnError) reject(new Error(redactSensitiveText(error.message)));
else resolve();
});
});
+12 -1
View File
@@ -8,6 +8,8 @@ import { CryptoService } from './CryptoService';
import { DatabaseService, type StackGitSource, type GitSourceAuthType } from './DatabaseService';
import { FileSystemService } from './FileSystemService';
import { ComposeService } from './ComposeService';
import { NodeRegistry } from './NodeRegistry';
import { assertPolicyGateAllows, buildSystemPolicyGateOptions } from '../helpers/policyGate';
import { isDebugEnabled } from '../utils/debug';
import { sanitizeForLog } from '../utils/safeLog';
@@ -751,7 +753,7 @@ export class GitSourceService {
public async apply(
stackName: string,
commitSha: string,
opts: { deploy?: boolean } = {},
opts: { deploy?: boolean; actor?: string; bypassPolicy?: boolean } = {},
): Promise<{ applied: boolean; deployed: boolean; deployError?: string }> {
return this.withStackLock(stackName, async () => {
const diag = isDebugEnabled();
@@ -795,6 +797,15 @@ export class GitSourceService {
if (shouldDeploy) {
try {
const nodeId = NodeRegistry.getInstance().getDefaultNodeId();
await assertPolicyGateAllows(
stackName,
nodeId,
buildSystemPolicyGateOptions(opts.actor ?? 'git-source', {
bypass: opts.bypassPolicy === true,
auditPath: `/api/stacks/${stackName}/git-source/apply`,
}),
);
await ComposeService.getInstance().deployStack(stackName);
console.log(`[GitSource] Applied and deployed ${stackName} at ${commitSha.slice(0, 7)}`);
return { applied: true, deployed: true };
+8
View File
@@ -16,6 +16,7 @@ import { generateOverrideYaml, MeshAlias, SENCHO_MESH_NETWORK } from './MeshComp
import { sanitizeForLog } from '../utils/safeLog';
import { isPathWithinBase, isValidStackName } from '../utils/validation';
import { PORT as SENCHO_LISTEN_PORT } from '../helpers/constants';
import { assertPolicyGateAllows, buildSystemPolicyGateOptions } from '../helpers/policyGate';
const ACTIVITY_BUFFER_SIZE = 1000;
const ALIAS_REFRESH_INTERVAL_MS = 60_000;
@@ -1188,6 +1189,13 @@ export class MeshService extends EventEmitter implements MeshForwarderHost {
if (!node) throw new Error(`unknown node ${nodeId}`);
if (node.type !== 'remote') {
await assertPolicyGateAllows(
stackName,
nodeId,
buildSystemPolicyGateOptions(actor, {
auditPath: `/api/mesh/nodes/${nodeId}/stacks/${stackName}/redeploy`,
}),
);
await ComposeService.getInstance(nodeId).deployStack(stackName);
this.logActivity({
source: 'mesh', level: 'info', type: 'mesh.enable',
+191 -182
View File
@@ -1,182 +1,191 @@
/**
* 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<PolicyEnforcementResult> {
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: [] };
}
const svc = TrivyService.getInstance();
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,
}],
};
}
return enforcePolicyForImageRefs(stackName, nodeId, imageRefs, opts, policy);
}
export async function enforcePolicyForImageRefs(
stackName: string,
nodeId: number,
imageRefs: string[],
opts: PolicyEnforcementOptions,
matchedPolicy?: ScanPolicy,
failClosedInvalidRefs = false,
): Promise<PolicyEnforcementResult> {
const db = DatabaseService.getInstance();
const policy = matchedPolicy ?? db.getMatchingPolicy(nodeId, stackName, FleetSyncService.getSelfIdentity());
if (!policy || !policy.enabled || !policy.block_on_deploy) {
return { ok: true, bypassed: false, policy: policy ?? undefined, violations: [] };
}
const svc = TrivyService.getInstance();
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 };
}
const violations: PolicyViolation[] = [];
for (const imageRef of imageRefs) {
if (!validateImageRef(imageRef)) {
if (failClosedInvalidRefs) {
violations.push({
imageRef,
severity: 'UNKNOWN',
criticalCount: 0,
highCount: 0,
scanId: 0,
});
}
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 };
}
/**
* 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;
/**
* Paid-tier deploy enforcement switch. Community keeps policies as
* evaluation-only and must not block compose starts.
*/
blockingEnabled?: boolean;
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 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 (opts.blockingEnabled === false) {
return { ok: true, bypassed: false, policy, violations: [] };
}
const svc = TrivyService.getInstance();
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,
}],
};
}
return enforcePolicyForImageRefs(stackName, nodeId, imageRefs, opts, policy);
}
export async function enforcePolicyForImageRefs(
stackName: string,
nodeId: number,
imageRefs: string[],
opts: PolicyEnforcementOptions,
matchedPolicy?: ScanPolicy,
failClosedInvalidRefs = false,
): Promise<PolicyEnforcementResult> {
const db = DatabaseService.getInstance();
const policy = matchedPolicy ?? db.getMatchingPolicy(nodeId, stackName, FleetSyncService.getSelfIdentity());
if (!policy || !policy.enabled || !policy.block_on_deploy) {
return { ok: true, bypassed: false, policy: policy ?? undefined, violations: [] };
}
const svc = TrivyService.getInstance();
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 };
}
const violations: PolicyViolation[] = [];
for (const imageRef of imageRefs) {
if (!validateImageRef(imageRef)) {
if (failClosedInvalidRefs) {
violations.push({
imageRef,
severity: 'UNKNOWN',
criticalCount: 0,
highCount: 0,
scanId: 0,
});
}
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 };
}
+15
View File
@@ -18,6 +18,7 @@ import TrivyService from './TrivyService';
import type { ScanAllNodeImagesResult } from './TrivyService';
import TrivyInstaller from './TrivyInstaller';
import { CloudBackupService } from './CloudBackupService';
import { assertPolicyGateAllows, buildSystemPolicyGateOptions } from '../helpers/policyGate';
const TRIVY_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
const TRIVY_UPDATE_CHECK_STARTUP_DELAY_MS = 5 * 60 * 1000;
@@ -441,6 +442,13 @@ export class SchedulerService {
private async executeAutoStart(task: ScheduledTask): Promise<string> {
this.assertStackTarget(task, 'Auto-start');
await assertPolicyGateAllows(
task.target_id,
task.node_id,
buildSystemPolicyGateOptions('scheduler:auto-start', {
auditPath: `/api/scheduled-tasks/${task.id}/run`,
}),
);
await ComposeService.getInstance(task.node_id).deployStack(task.target_id);
return `Started stack "${task.target_id}"`;
}
@@ -717,6 +725,13 @@ export class SchedulerService {
return `Stack "${stackName}": all images up to date.`;
}
await assertPolicyGateAllows(
stackName,
nodeId,
buildSystemPolicyGateOptions('scheduler:auto-update', {
auditPath: `/api/scheduled-tasks/auto-update/${stackName}`,
}),
);
await compose.updateStack(stackName, undefined, true);
db.clearStackUpdateStatus(nodeId, stackName);
+11
View File
@@ -4,6 +4,7 @@ import { ComposeService } from './ComposeService';
import { FileSystemService } from './FileSystemService';
import { GitSourceService } from './GitSourceService';
import { NodeRegistry } from './NodeRegistry';
import { assertPolicyGateAllows, buildSystemPolicyGateOptions } from '../helpers/policyGate';
export class WebhookService {
private static instance: WebhookService;
@@ -63,6 +64,11 @@ export class WebhookService {
const compose = ComposeService.getInstance(defaultNodeId);
switch (action) {
case 'deploy':
await assertPolicyGateAllows(
webhook.stack_name,
defaultNodeId,
buildSystemPolicyGateOptions('webhook', { auditPath: `/api/webhooks/${webhookId}/execute` }),
);
await compose.deployStack(webhook.stack_name, undefined, atomic);
break;
case 'restart':
@@ -75,6 +81,11 @@ export class WebhookService {
await compose.runCommand(webhook.stack_name, 'start');
break;
case 'pull':
await assertPolicyGateAllows(
webhook.stack_name,
defaultNodeId,
buildSystemPolicyGateOptions('webhook', { auditPath: `/api/webhooks/${webhookId}/execute` }),
);
await compose.updateStack(webhook.stack_name, undefined, atomic);
break;
case 'git-pull': {
+9
View File
@@ -13,3 +13,12 @@ export function sanitizeForLog(value: unknown): string {
const s = typeof value === 'string' ? value : String(value);
return s.replace(CONTROL_CHARS_REGEX, '');
}
export function redactSensitiveText(value: unknown): string {
const s = typeof value === 'string' ? value : String(value);
return s
.replace(/Bearer\s+[A-Za-z0-9\-._~+/=]+/gi, 'Bearer [redacted]')
.replace(/[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, '[redacted-jwt]')
.replace(/https?:\/\/[^/\s:@]+:[^/\s@]+@/gi, 'https://[redacted]@')
.replace(/((?:authorization|token|password|secret|api[_-]?key)\s*[:=]\s*)[^\s,;]+/gi, '$1[redacted]');
}