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: () => ({