mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 02:12:59 +00:00
feat: add Compose Doctor preflight checks for stacks (#1348)
* feat: add Compose Doctor preflight checks for stacks Add an on-demand, advisory preflight that renders a stack's effective Compose model with `docker compose config` and runs a registry of deterministic checks before deploy, surfacing findings grouped by severity (blocker, high, warning, info) with a remediation for each. Findings cover unset env vars, host-port conflicts on the node, broad 0.0.0.0 exposure, missing bind-mount paths, a mounted Docker socket, privileged and host networking, moving image tags, missing restart policy and healthcheck, Swarm-only deploy fields, missing external networks or volumes, and container_name collisions. The report is node-scoped and stored as the last run per stack, and the route auto-proxies to the active node so a remote stack is checked on the node that owns it. A new Doctor tab on the stack detail panel runs preflight and shows the grouped findings, with a severity dot on the tab when the last run has blocker or high findings. The tab is gated on a compose-doctor capability so older nodes hide it. No environment value is ever stored, returned, or logged: only env key names and structural facts are read, and render failures surface a generic message or the missing required-variable names, never raw stderr. * fix: scroll the stack tab strip when its tabs overflow Adding the Doctor tab can push the per-stack Anatomy tab strip past the panel width on narrower layouts. Make the tab row scroll horizontally with subtle edge fades that appear only while there is more to scroll in that direction, so a panel wide enough to show every tab is unchanged. * fix: add clickable arrows and wheel scroll to the stack tab strip Hiding the scrollbar left mouse users with no way to scroll the overflowing tab row: a vertical wheel does not move a horizontal overflow and native rows do not drag-scroll. Replace the passive edge fades with clickable chevron arrows shown only when the row overflows that edge, and translate a vertical wheel over the row into horizontal scroll. * fix: inline the path-injection barrier in renderConfig CodeQL's path-injection check does not credit the wrapped isPathWithinBase helper as a sanitizer, so move the containment check inline at the spawn cwd sink, matching the canonical barrier used elsewhere in the codebase. Behavior is unchanged: the resolved stack directory must be contained in the compose base and may not be the base itself. * fix: hoist the compose-config spawn into the path-barrier scope The earlier inline barrier sat in a different scope than the spawn cwd sink (separated by the Promise-executor closure) and used a compound guard, so CodeQL did not credit it. Use the exact canonical startsWith barrier and hoist the spawn into the same scope as the check. Behavior is unchanged: the executor runs synchronously in the same tick as the spawn, so handlers still attach before any event can fire.
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* ComposeDoctorService: orchestration over the renderer, the rule registry, and
|
||||
* persistence. Docker (render + snapshot) is mocked; the filesystem and database
|
||||
* are real. Covers status derivation, replace-on-run persistence, getLatest, the
|
||||
* unrenderable path, node-deletion cleanup, the renderConfig path guard, and the
|
||||
* hard guarantee that an environment value never reaches a stored row.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
||||
import DockerController from '../services/DockerController';
|
||||
import { ComposeService } from '../services/ComposeService';
|
||||
|
||||
const SECRET = 'pw-7Q2x-never-store';
|
||||
|
||||
let tmpDir: string;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
let ComposeDoctorService: typeof import('../services/ComposeDoctorService').ComposeDoctorService;
|
||||
let parseUnsetEnvVars: typeof import('../services/ComposeDoctorService').parseUnsetEnvVars;
|
||||
let parseMissingRequiredVars: typeof import('../services/ComposeDoctorService').parseMissingRequiredVars;
|
||||
let nodeId: number;
|
||||
|
||||
function db() { return DatabaseService.getInstance(); }
|
||||
function doctor() { return ComposeDoctorService.getInstance(); }
|
||||
|
||||
/** Mock the two Docker calls; render returns the given effective model JSON. */
|
||||
function stubDocker(rendered: object | null, stderr = '', snapshot = { containers: [], networks: [], volumes: [] }) {
|
||||
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
|
||||
renderConfig: vi.fn().mockResolvedValue({
|
||||
rendered: rendered === null ? null : JSON.stringify(rendered),
|
||||
stderr,
|
||||
code: rendered === null ? 1 : 0,
|
||||
timedOut: false,
|
||||
}),
|
||||
} as unknown as ComposeService);
|
||||
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
|
||||
getDependencySnapshot: vi.fn().mockResolvedValue(snapshot),
|
||||
} as unknown as DockerController);
|
||||
}
|
||||
|
||||
function writeStack(stack: string, content = 'services:\n web:\n image: nginx:latest\n ports:\n - "8080:80"\n') {
|
||||
const dir = path.join(process.env.COMPOSE_DIR as string, stack);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'compose.yaml'), content);
|
||||
return dir;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
await import('../index');
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
({ ComposeDoctorService, parseUnsetEnvVars, parseMissingRequiredVars } = await import('../services/ComposeDoctorService'));
|
||||
nodeId = (db().getDb().prepare('SELECT id FROM nodes WHERE is_default = 1').get() as { id: number }).id;
|
||||
});
|
||||
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe('parseUnsetEnvVars', () => {
|
||||
it('extracts variable names from Compose stderr (real escaped, quoted, and bare forms)', () => {
|
||||
// The escaped form is exactly what `docker compose config` emits in logfmt.
|
||||
const stderr =
|
||||
'time="2026-06-10T00:36:15-04:00" level=warning msg="The \\"DB_HOST\\" variable is not set. Defaulting to a blank string."\n'
|
||||
+ 'The "TOKEN" variable is not set.\n'
|
||||
+ 'The PLAIN variable is not set.';
|
||||
expect(parseUnsetEnvVars(stderr).sort()).toEqual(['DB_HOST', 'PLAIN', 'TOKEN']);
|
||||
});
|
||||
it('returns nothing for clean stderr', () => {
|
||||
expect(parseUnsetEnvVars('')).toEqual([]);
|
||||
});
|
||||
it('ignores lines that do not match the unset-variable phrase', () => {
|
||||
expect(parseUnsetEnvVars('the DB connection variable is configured\nNODE_ENV is not set elsewhere')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseMissingRequiredVars', () => {
|
||||
it('extracts the name from the real required-variable error (unquoted)', () => {
|
||||
const stderr = 'error while interpolating services.web.environment.TOKEN: required variable REQ_TOKEN is missing a value: must be provided';
|
||||
expect(parseMissingRequiredVars(stderr)).toEqual(['REQ_TOKEN']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ComposeService.renderConfig path guard', () => {
|
||||
it('rejects an invalid stack name without spawning docker', async () => {
|
||||
await expect(ComposeService.getInstance(nodeId).renderConfig('../escape')).rejects.toThrow('Invalid stack path');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runPreflight', () => {
|
||||
const STACK = 'doctorrun';
|
||||
beforeEach(() => { writeStack(STACK); });
|
||||
afterEach(() => { fs.rmSync(path.join(process.env.COMPOSE_DIR as string, STACK), { recursive: true, force: true }); });
|
||||
|
||||
it('derives status from the highest finding and persists the run', async () => {
|
||||
stubDocker(
|
||||
{ name: STACK, services: { web: { image: 'nginx:latest', ports: [{ target: 80, published: '8080', protocol: 'tcp' }], environment: { APP_SECRET: SECRET } } }, networks: {}, volumes: {} },
|
||||
'WARN The "MISSING" variable is not set. Defaulting to a blank string.',
|
||||
);
|
||||
const report = await doctor().runPreflight(nodeId, STACK, 'tester');
|
||||
expect(report.renderable).toBe(true);
|
||||
expect(report.status).toBe('high'); // env-unset + 0.0.0.0 exposure are high
|
||||
expect(report.highestSeverity).toBe('high');
|
||||
expect(report.findings.map(f => f.ruleId)).toEqual(expect.arrayContaining(['env-unset', 'port-exposed-all-interfaces', 'image-latest', 'no-healthcheck']));
|
||||
expect(report.ranBy).toBe('tester');
|
||||
expect(report.sourceHash).toBeTruthy();
|
||||
|
||||
// Persisted and retrievable.
|
||||
const stored = db().getLatestPreflightRun(nodeId, STACK);
|
||||
expect(stored?.status).toBe('high');
|
||||
expect(db().getPreflightFindings(stored!.id).length).toBe(report.findings.length);
|
||||
const latest = doctor().getLatest(nodeId, STACK);
|
||||
expect(latest.findings.length).toBe(report.findings.length);
|
||||
expect(latest.ranBy).toBe('tester');
|
||||
});
|
||||
|
||||
it('never stores an environment value', async () => {
|
||||
stubDocker({ name: STACK, services: { web: { image: 'nginx:1.27', environment: { APP_SECRET: SECRET } } }, networks: {}, volumes: {} });
|
||||
const report = await doctor().runPreflight(nodeId, STACK, null);
|
||||
const runs = JSON.stringify(db().getDb().prepare('SELECT * FROM preflight_runs').all());
|
||||
const findings = JSON.stringify(db().getDb().prepare('SELECT * FROM preflight_findings').all());
|
||||
expect(runs).not.toContain(SECRET);
|
||||
expect(findings).not.toContain(SECRET);
|
||||
expect(JSON.stringify(report)).not.toContain(SECRET);
|
||||
});
|
||||
|
||||
it('replaces the prior run rather than accumulating', async () => {
|
||||
stubDocker({ name: STACK, services: { web: { image: 'nginx:latest' } }, networks: {}, volumes: {} });
|
||||
await doctor().runPreflight(nodeId, STACK, null);
|
||||
vi.restoreAllMocks();
|
||||
stubDocker({ name: STACK, services: { web: { image: 'nginx:1.27', restart: 'always', healthcheck: { test: ['CMD', 'true'] } } }, networks: {}, volumes: {} });
|
||||
await doctor().runPreflight(nodeId, STACK, null);
|
||||
const allRuns = db().getDb().prepare('SELECT * FROM preflight_runs WHERE node_id = ? AND stack_name = ?').all(nodeId, STACK);
|
||||
expect(allRuns).toHaveLength(1);
|
||||
expect(doctor().getLatest(nodeId, STACK).status).toBe('pass');
|
||||
});
|
||||
|
||||
it('returns an unrenderable report and never stores raw stderr', async () => {
|
||||
stubDocker(null, `bad yaml near ${SECRET}`); // stderr can echo arbitrary file content
|
||||
const report = await doctor().runPreflight(nodeId, STACK, null);
|
||||
expect(report.renderable).toBe(false);
|
||||
expect(report.status).toBe('unrenderable');
|
||||
expect(report.findings.map(f => f.ruleId)).toEqual(['render-failed']);
|
||||
// Raw stderr is never surfaced, so an arbitrary secret in it cannot leak.
|
||||
expect(report.renderError).not.toContain(SECRET);
|
||||
expect(report.renderError).not.toContain('bad yaml');
|
||||
const findings = JSON.stringify(db().getDb().prepare('SELECT * FROM preflight_findings').all());
|
||||
expect(findings).not.toContain(SECRET);
|
||||
});
|
||||
|
||||
it('names a missing required variable in the render error without echoing the value', async () => {
|
||||
stubDocker(null, `required variable "DB_PASS" is missing a value: ${SECRET}`);
|
||||
const report = await doctor().runPreflight(nodeId, STACK, null);
|
||||
expect(report.renderError).toContain('DB_PASS');
|
||||
expect(report.renderError).not.toContain(SECRET);
|
||||
});
|
||||
|
||||
it('getLatest round-trips an unrenderable run from the database', async () => {
|
||||
stubDocker(null, 'boom');
|
||||
await doctor().runPreflight(nodeId, STACK, null);
|
||||
vi.restoreAllMocks(); // getLatest is a pure DB read; no docker needed
|
||||
const latest = doctor().getLatest(nodeId, STACK);
|
||||
expect(latest.renderable).toBe(false);
|
||||
expect(latest.status).toBe('unrenderable');
|
||||
expect(latest.renderError).toBeTruthy();
|
||||
expect(latest.findings.map(f => f.ruleId)).toEqual(['render-failed']);
|
||||
});
|
||||
|
||||
it('degrades to model-only findings when the node snapshot fails', async () => {
|
||||
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
|
||||
renderConfig: vi.fn().mockResolvedValue({
|
||||
rendered: JSON.stringify({ name: STACK, services: { web: { image: 'nginx:latest' } }, networks: {}, volumes: {} }),
|
||||
stderr: '', code: 0, timedOut: false,
|
||||
}),
|
||||
} as unknown as ComposeService);
|
||||
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
|
||||
getDependencySnapshot: vi.fn().mockRejectedValue(new Error('docker down')),
|
||||
} as unknown as DockerController);
|
||||
const report = await doctor().runPreflight(nodeId, STACK, null);
|
||||
expect(report.renderable).toBe(true);
|
||||
expect(report.findings.map(f => f.ruleId)).toContain('image-latest'); // model rule still ran
|
||||
expect(report.findings.map(f => f.ruleId)).not.toContain('port-conflict-node'); // node-state skipped
|
||||
});
|
||||
|
||||
it('orders findings by severity, highest first', async () => {
|
||||
stubDocker({
|
||||
name: STACK,
|
||||
services: {
|
||||
a: { image: 'nginx:latest', ports: [{ target: 80, published: '8080', protocol: 'tcp' }], container_name: 'dup' },
|
||||
b: { image: 'nginx:1.27', container_name: 'dup', restart: 'always', healthcheck: { test: ['CMD', 'x'] } },
|
||||
},
|
||||
networks: {}, volumes: {},
|
||||
});
|
||||
const r = await doctor().runPreflight(nodeId, STACK, null);
|
||||
const rank = { blocker: 3, high: 2, warning: 1, info: 0 } as const;
|
||||
const ranks = r.findings.map(f => rank[f.severity]);
|
||||
expect(ranks).toEqual([...ranks].sort((x, y) => y - x)); // non-increasing
|
||||
expect(r.findings[0].severity).toBe('blocker'); // duplicate container_name
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLatest', () => {
|
||||
it('returns a never-run sentinel before any run', () => {
|
||||
const r = doctor().getLatest(nodeId, 'nostackyet');
|
||||
expect(r.status).toBe('never-run');
|
||||
expect(r.ranAt).toBeNull();
|
||||
expect(r.findings).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('node deletion cleanup', () => {
|
||||
it('removes preflight runs and findings for a deleted node', () => {
|
||||
const ghostNode = 987654;
|
||||
db().replacePreflightRun(
|
||||
{ id: 'run-x', node_id: ghostNode, stack_name: 's', source_hash: null, rendered_hash: null, status: 'pass', highest_severity: null, created_at: 1, created_by: null },
|
||||
[{ id: 'find-x', run_id: 'run-x', rule_id: 'privileged', severity: 'high', title: 't', message: 'm', source_path: null, remediation: null, service: 's', created_at: 1 }],
|
||||
);
|
||||
expect(db().getLatestPreflightRun(ghostNode, 's')).toBeDefined();
|
||||
db().deleteNode(ghostNode);
|
||||
expect(db().getLatestPreflightRun(ghostNode, 's')).toBeUndefined();
|
||||
expect(db().getPreflightFindings('run-x')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* parseEffectiveModel: turns `docker compose config --format json` output into
|
||||
* the structural facts the preflight rules need. The critical property is that
|
||||
* it never retains an environment VALUE (only key names).
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseEffectiveModel } from '../services/preflight/effectiveModel';
|
||||
|
||||
const SECRET = 'topsecret-9f3a-value';
|
||||
|
||||
function render() {
|
||||
return {
|
||||
name: 'myapp',
|
||||
services: {
|
||||
web: {
|
||||
image: 'nginx:latest',
|
||||
ports: [
|
||||
{ mode: 'ingress', target: 80, published: '8080', protocol: 'tcp' },
|
||||
{ target: 53, published: '5300', protocol: 'udp', host_ip: '127.0.0.1' },
|
||||
{ target: 90, published: '9000-9002', protocol: 'tcp' },
|
||||
],
|
||||
volumes: [
|
||||
{ type: 'bind', source: '/srv/data', target: '/data' },
|
||||
{ type: 'volume', source: 'cache', target: '/var/cache' },
|
||||
],
|
||||
privileged: true,
|
||||
network_mode: 'host',
|
||||
restart: 'unless-stopped',
|
||||
healthcheck: { test: ['CMD', 'true'] },
|
||||
deploy: { placement: { constraints: [] } },
|
||||
container_name: 'web1',
|
||||
user: '1000:1000',
|
||||
environment: { DB_PASSWORD: SECRET, PUID: '1000' },
|
||||
},
|
||||
},
|
||||
networks: {
|
||||
default: { name: 'myapp_default' },
|
||||
shared: { name: 'shared_net', external: true },
|
||||
},
|
||||
volumes: {
|
||||
cache: { name: 'myapp_cache' },
|
||||
ext: { name: 'shared_vol', external: true },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('parseEffectiveModel', () => {
|
||||
it('extracts structural facts from the rendered model', () => {
|
||||
const m = parseEffectiveModel(render(), 'fallback');
|
||||
expect(m.projectName).toBe('myapp');
|
||||
const web = m.services[0];
|
||||
expect(web.name).toBe('web');
|
||||
expect(web.image).toBe('nginx:latest');
|
||||
expect(web.privileged).toBe(true);
|
||||
expect(web.networkMode).toBe('host');
|
||||
expect(web.restart).toBe('unless-stopped');
|
||||
expect(web.hasHealthcheck).toBe(true);
|
||||
expect(web.deploy).toBeDefined();
|
||||
expect(web.containerName).toBe('web1');
|
||||
expect(web.user).toBe('1000:1000');
|
||||
expect(web.binds).toEqual([{ source: '/srv/data', target: '/data' }]);
|
||||
expect(web.namedVolumes).toEqual(['cache']);
|
||||
});
|
||||
|
||||
it('parses ports with host IP, protocol, and ranges', () => {
|
||||
const web = parseEffectiveModel(render(), 'fallback').services[0];
|
||||
expect(web.ports).toEqual([
|
||||
{ startPort: 8080, endPort: 8080, hostIp: '', protocol: 'tcp' },
|
||||
{ startPort: 5300, endPort: 5300, hostIp: '127.0.0.1', protocol: 'udp' },
|
||||
{ startPort: 9000, endPort: 9002, hostIp: '', protocol: 'tcp' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves top-level networks and volumes with external flags', () => {
|
||||
const m = parseEffectiveModel(render(), 'fallback');
|
||||
expect(m.networks.shared).toEqual({ name: 'shared_net', external: true });
|
||||
expect(m.networks.default).toEqual({ name: 'myapp_default', external: false });
|
||||
expect(m.volumes.ext).toEqual({ name: 'shared_vol', external: true });
|
||||
expect(m.volumes.cache).toEqual({ name: 'myapp_cache', external: false });
|
||||
});
|
||||
|
||||
it('reads environment KEY names only, never values', () => {
|
||||
const m = parseEffectiveModel(render(), 'fallback');
|
||||
expect(m.services[0].envKeys).toEqual(['DB_PASSWORD', 'PUID']);
|
||||
// The secret value must not survive anywhere in the parsed model.
|
||||
expect(JSON.stringify(m)).not.toContain(SECRET);
|
||||
});
|
||||
|
||||
it('reads env key names from the array form without keeping the value', () => {
|
||||
const m = parseEffectiveModel(
|
||||
{ services: { api: { environment: [`TOKEN=${SECRET}`, 'MODE=prod'] } } },
|
||||
'fallback',
|
||||
);
|
||||
expect(m.services[0].envKeys).toEqual(['TOKEN', 'MODE']);
|
||||
expect(JSON.stringify(m)).not.toContain(SECRET);
|
||||
});
|
||||
|
||||
it('parses the short-string port form and drops container-only EXPOSE', () => {
|
||||
const m = parseEffectiveModel({ services: { s: { ports: ['127.0.0.1:8080:80/udp', '8443:443', '90'] } } }, 'p');
|
||||
expect(m.services[0].ports).toEqual([
|
||||
{ startPort: 8080, endPort: 8080, hostIp: '127.0.0.1', protocol: 'udp' },
|
||||
{ startPort: 8443, endPort: 8443, hostIp: '', protocol: 'tcp' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats a disabled healthcheck as none', () => {
|
||||
const m = parseEffectiveModel({ services: { web: { healthcheck: { disable: true } } } }, 'fallback');
|
||||
expect(m.services[0].hasHealthcheck).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to the provided project name and yields an empty model for garbage', () => {
|
||||
const m = parseEffectiveModel({ services: {} }, 'mystack');
|
||||
expect(m.projectName).toBe('mystack');
|
||||
expect(m.services).toEqual([]);
|
||||
const empty = parseEffectiveModel(null, 'mystack');
|
||||
expect(empty.services).toEqual([]);
|
||||
expect(empty.networks).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Compose Doctor routes: GET returns the stored run (never-run before any run),
|
||||
* POST runs and persists. Both require stack:read and reject unauthenticated and
|
||||
* missing-stack requests. Docker render + snapshot are mocked.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
import DockerController from '../services/DockerController';
|
||||
import { ComposeService } from '../services/ComposeService';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let authHeader: string;
|
||||
|
||||
const STACK = 'preflightroute';
|
||||
|
||||
function stub() {
|
||||
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
|
||||
renderConfig: vi.fn().mockResolvedValue({
|
||||
rendered: JSON.stringify({ name: STACK, services: { web: { image: 'nginx:latest', ports: [{ target: 80, published: '8080', protocol: 'tcp' }] } }, networks: {}, volumes: {} }),
|
||||
stderr: '', code: 0, timedOut: false,
|
||||
}),
|
||||
} as unknown as ComposeService);
|
||||
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
|
||||
getDependencySnapshot: vi.fn().mockResolvedValue({ containers: [], networks: [], volumes: [] }),
|
||||
} as unknown as DockerController);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ app } = await import('../index'));
|
||||
authHeader = `Bearer ${jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '5m' })}`;
|
||||
});
|
||||
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
describe('preflight routes', () => {
|
||||
let stackDir: string;
|
||||
beforeEach(() => {
|
||||
stackDir = path.join(process.env.COMPOSE_DIR as string, STACK);
|
||||
fs.mkdirSync(stackDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n web:\n image: nginx:latest\n ports:\n - "8080:80"\n');
|
||||
stub();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(stackDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('GET returns a never-run report before any run', async () => {
|
||||
const res = await request(app).get(`/api/stacks/${STACK}/preflight`).set('Authorization', authHeader);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('never-run');
|
||||
expect(res.body.findings).toEqual([]);
|
||||
});
|
||||
|
||||
it('POST runs preflight, persists, and GET then returns the stored run', async () => {
|
||||
const run = await request(app).post(`/api/stacks/${STACK}/preflight/run`).set('Authorization', authHeader);
|
||||
expect(run.status).toBe(200);
|
||||
expect(run.body.renderable).toBe(true);
|
||||
expect(run.body.findings.length).toBeGreaterThan(0);
|
||||
expect(run.body.findings.map((f: { ruleId: string }) => f.ruleId)).toContain('port-exposed-all-interfaces');
|
||||
|
||||
const get = await request(app).get(`/api/stacks/${STACK}/preflight`).set('Authorization', authHeader);
|
||||
expect(get.body.status).toBe(run.body.status);
|
||||
expect(get.body.findings.length).toBe(run.body.findings.length);
|
||||
});
|
||||
|
||||
it('rejects an unauthenticated request', async () => {
|
||||
const res = await request(app).get(`/api/stacks/${STACK}/preflight`);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 404 for a stack that does not exist', async () => {
|
||||
const res = await request(app).post('/api/stacks/nope-not-here/preflight/run').set('Authorization', authHeader);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* The preflight rule registry. Each rule is a pure function over a
|
||||
* PreflightContext; these tests assert each fires on its positive case, stays
|
||||
* silent otherwise, and carries the right severity. They also pin the port
|
||||
* conflict semantics (protocol, interface overlap, same-stack, ranges) and keep
|
||||
* the registry aligned with the documented rule set.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { runRules, RULE_IDS } from '../services/preflight/rules';
|
||||
import type { EffService, EffectiveModel } from '../services/preflight/effectiveModel';
|
||||
import type { PreflightContext, PreflightFinding } from '../services/preflight/types';
|
||||
|
||||
function svc(over: Partial<EffService> = {}): EffService {
|
||||
return {
|
||||
name: 'web', image: 'nginx:1.27', ports: [], binds: [], namedVolumes: [],
|
||||
privileged: false, hasHealthcheck: true, restart: 'unless-stopped', envKeys: [], ...over,
|
||||
};
|
||||
}
|
||||
|
||||
function model(services: EffService[], over: Partial<EffectiveModel> = {}): EffectiveModel {
|
||||
return { projectName: 'proj', services, networks: {}, volumes: {}, ...over };
|
||||
}
|
||||
|
||||
function ctx(over: Partial<PreflightContext> = {}): PreflightContext {
|
||||
const m = over.model !== undefined ? over.model : model([]);
|
||||
return {
|
||||
stackName: 'proj', platform: 'linux', model: m, renderable: true, renderError: null, unsetEnvVars: [],
|
||||
sourceServiceNames: m ? m.services.map(s => s.name) : [], sourceReadable: true,
|
||||
nodePorts: [], existingNetworkNames: new Set(), existingVolumeNames: new Set(),
|
||||
existingContainers: [], bindChecks: [], ...over,
|
||||
};
|
||||
}
|
||||
|
||||
const ids = (findings: PreflightFinding[], ruleId: string) => findings.filter(f => f.ruleId === ruleId);
|
||||
|
||||
describe('render-failed', () => {
|
||||
it('fires only when the model is unrenderable', () => {
|
||||
const f = runRules(ctx({ renderable: false, model: null, renderError: 'boom' }));
|
||||
expect(ids(f, 'render-failed')).toHaveLength(1);
|
||||
expect(ids(f, 'render-failed')[0].severity).toBe('blocker');
|
||||
expect(ids(f, 'render-failed')[0].message).toContain('boom');
|
||||
});
|
||||
it('stays silent and runs model rules when renderable', () => {
|
||||
expect(ids(runRules(ctx({ model: model([svc()]) })), 'render-failed')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('env-unset', () => {
|
||||
it('emits one high finding per unset variable name', () => {
|
||||
const f = ids(runRules(ctx({ unsetEnvVars: ['FOO', 'BAR'] })), 'env-unset');
|
||||
expect(f).toHaveLength(2);
|
||||
expect(f[0].severity).toBe('high');
|
||||
expect(f.map(x => x.sourcePath)).toEqual(['FOO', 'BAR']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('port-conflict-node', () => {
|
||||
const withPort = (proto = 'tcp', hostIp = '') => model([svc({ ports: [{ startPort: 8080, endPort: 8080, hostIp, protocol: proto }] })]);
|
||||
|
||||
it('blocks a port held by a different stack', () => {
|
||||
const f = runRules(ctx({ model: withPort(), nodePorts: [{ publishedPort: 8080, protocol: 'tcp', ip: '', stack: 'other' }] }));
|
||||
expect(ids(f, 'port-conflict-node')).toHaveLength(1);
|
||||
expect(ids(f, 'port-conflict-node')[0].severity).toBe('blocker');
|
||||
});
|
||||
it('ignores the same stack reusing its own port', () => {
|
||||
const f = runRules(ctx({ stackName: 'proj', model: withPort(), nodePorts: [{ publishedPort: 8080, protocol: 'tcp', ip: '', stack: 'proj' }] }));
|
||||
expect(ids(f, 'port-conflict-node')).toHaveLength(0);
|
||||
});
|
||||
it('does not conflict TCP with UDP on the same number', () => {
|
||||
const f = runRules(ctx({ model: withPort('tcp'), nodePorts: [{ publishedPort: 8080, protocol: 'udp', ip: '', stack: 'other' }] }));
|
||||
expect(ids(f, 'port-conflict-node')).toHaveLength(0);
|
||||
});
|
||||
it('treats a loopback bind as overlapping an all-interfaces bind', () => {
|
||||
const f = runRules(ctx({ model: withPort('tcp', '127.0.0.1'), nodePorts: [{ publishedPort: 8080, protocol: 'tcp', ip: '', stack: 'other' }] }));
|
||||
expect(ids(f, 'port-conflict-node')).toHaveLength(1);
|
||||
});
|
||||
it('catches a port inside a published range', () => {
|
||||
const m = model([svc({ ports: [{ startPort: 9000, endPort: 9002, hostIp: '', protocol: 'tcp' }] })]);
|
||||
const f = runRules(ctx({ model: m, nodePorts: [{ publishedPort: 9001, protocol: 'tcp', ip: '', stack: 'other' }] }));
|
||||
expect(ids(f, 'port-conflict-node')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('port-conflict-internal', () => {
|
||||
it('blocks two services publishing the same host port', () => {
|
||||
const m = model([
|
||||
svc({ name: 'a', ports: [{ startPort: 80, endPort: 80, hostIp: '', protocol: 'tcp' }] }),
|
||||
svc({ name: 'b', ports: [{ startPort: 80, endPort: 80, hostIp: '', protocol: 'tcp' }] }),
|
||||
]);
|
||||
expect(ids(runRules(ctx({ model: m })), 'port-conflict-internal')).toHaveLength(1);
|
||||
});
|
||||
it('allows the same number on different interfaces', () => {
|
||||
const m = model([
|
||||
svc({ name: 'a', ports: [{ startPort: 80, endPort: 80, hostIp: '127.0.0.1', protocol: 'tcp' }] }),
|
||||
svc({ name: 'b', ports: [{ startPort: 80, endPort: 80, hostIp: '192.168.1.5', protocol: 'tcp' }] }),
|
||||
]);
|
||||
expect(ids(runRules(ctx({ model: m })), 'port-conflict-internal')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('port-exposed-all-interfaces', () => {
|
||||
it('flags an all-interfaces bind but not a loopback bind', () => {
|
||||
const open = model([svc({ ports: [{ startPort: 80, endPort: 80, hostIp: '', protocol: 'tcp' }] })]);
|
||||
const local = model([svc({ ports: [{ startPort: 80, endPort: 80, hostIp: '127.0.0.1', protocol: 'tcp' }] })]);
|
||||
expect(ids(runRules(ctx({ model: open })), 'port-exposed-all-interfaces')).toHaveLength(1);
|
||||
expect(ids(runRules(ctx({ model: local })), 'port-exposed-all-interfaces')).toHaveLength(0);
|
||||
});
|
||||
it('treats :: (IPv6 all-interfaces) as exposed and overlapping', () => {
|
||||
const v6 = model([svc({ ports: [{ startPort: 80, endPort: 80, hostIp: '::', protocol: 'tcp' }] })]);
|
||||
expect(ids(runRules(ctx({ model: v6 })), 'port-exposed-all-interfaces')).toHaveLength(1);
|
||||
const f = runRules(ctx({ model: v6, nodePorts: [{ publishedPort: 80, protocol: 'tcp', ip: '127.0.0.1', stack: 'other' }] }));
|
||||
expect(ids(f, 'port-conflict-node')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bind-path-missing / bind-path-permission', () => {
|
||||
it('flags a missing within-base bind as high', () => {
|
||||
const f = runRules(ctx({ bindChecks: [{ service: 'web', source: '/base/proj/data', target: '/data', withinBase: true, exists: false, ownerUid: null }] }));
|
||||
expect(ids(f, 'bind-path-missing')).toHaveLength(1);
|
||||
expect(ids(f, 'bind-path-missing')[0].severity).toBe('high');
|
||||
});
|
||||
it('does not assert an absolute (outside-base) bind as missing', () => {
|
||||
const f = runRules(ctx({ bindChecks: [{ service: 'web', source: '/mnt/media', target: '/media', withinBase: false, exists: false, ownerUid: null }] }));
|
||||
expect(ids(f, 'bind-path-missing')).toHaveLength(0);
|
||||
});
|
||||
it('warns on a root-owned within-base bind when the service drops privileges', () => {
|
||||
const m = model([svc({ envKeys: ['PUID'] })]);
|
||||
const bind = { service: 'web', source: '/base/proj/data', target: '/data', withinBase: true, exists: true, ownerUid: 0 };
|
||||
expect(ids(runRules(ctx({ model: m, bindChecks: [bind] })), 'bind-path-permission')).toHaveLength(1);
|
||||
});
|
||||
it('skips the ownership heuristic on Windows', () => {
|
||||
const m = model([svc({ envKeys: ['PUID'] })]);
|
||||
const bind = { service: 'web', source: 'C:/base/proj/data', target: '/data', withinBase: true, exists: true, ownerUid: 0 };
|
||||
expect(ids(runRules(ctx({ platform: 'win32', model: m, bindChecks: [bind] })), 'bind-path-permission')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('security rules', () => {
|
||||
it('flags a docker socket mount', () => {
|
||||
const m = model([svc({ binds: [{ source: '/var/run/docker.sock', target: '/var/run/docker.sock' }] })]);
|
||||
expect(ids(runRules(ctx({ model: m })), 'docker-socket-mount')[0].severity).toBe('high');
|
||||
});
|
||||
it('flags privileged and host networking', () => {
|
||||
expect(ids(runRules(ctx({ model: model([svc({ privileged: true })]) })), 'privileged')).toHaveLength(1);
|
||||
expect(ids(runRules(ctx({ model: model([svc({ networkMode: 'host' })]) })), 'network-mode-host')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uid-gid-risk', () => {
|
||||
it('fires only for unverifiable (outside-base) binds', () => {
|
||||
const m = model([svc({ name: 'web', envKeys: ['PUID'] })]);
|
||||
const outside = [{ service: 'web', source: '/mnt/x', target: '/x', withinBase: false, exists: false, ownerUid: null }];
|
||||
const inside = [{ service: 'web', source: '/base/proj/x', target: '/x', withinBase: true, exists: true, ownerUid: 1000 }];
|
||||
expect(ids(runRules(ctx({ model: m, bindChecks: outside })), 'uid-gid-risk')).toHaveLength(1);
|
||||
expect(ids(runRules(ctx({ model: m, bindChecks: inside })), 'uid-gid-risk')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hygiene rules', () => {
|
||||
it('flags a moving image tag but not a pinned one', () => {
|
||||
expect(ids(runRules(ctx({ model: model([svc({ image: 'nginx:latest' })]) })), 'image-latest')).toHaveLength(1);
|
||||
expect(ids(runRules(ctx({ model: model([svc({ image: 'nginx' })]) })), 'image-latest')).toHaveLength(1);
|
||||
expect(ids(runRules(ctx({ model: model([svc({ image: 'nginx:1.27' })]) })), 'image-latest')).toHaveLength(0);
|
||||
expect(ids(runRules(ctx({ model: model([svc({ image: 'nginx@sha256:abc' })]) })), 'image-latest')).toHaveLength(0);
|
||||
});
|
||||
it('flags a missing restart policy and healthcheck', () => {
|
||||
const bare = model([svc({ restart: undefined, hasHealthcheck: false })]);
|
||||
expect(ids(runRules(ctx({ model: bare })), 'no-restart-policy')).toHaveLength(1);
|
||||
expect(ids(runRules(ctx({ model: bare })), 'no-healthcheck')).toHaveLength(1);
|
||||
const withDeployRestart = model([svc({ restart: undefined, deploy: { restart_policy: { condition: 'any' } }})]);
|
||||
expect(ids(runRules(ctx({ model: withDeployRestart })), 'no-restart-policy')).toHaveLength(0);
|
||||
});
|
||||
it('flags swarm-only deploy fields but not honored ones', () => {
|
||||
expect(ids(runRules(ctx({ model: model([svc({ deploy: { placement: {} }})]) })), 'deploy-swarm-only')).toHaveLength(1);
|
||||
expect(ids(runRules(ctx({ model: model([svc({ deploy: { replicas: 3 }})]) })), 'deploy-swarm-only')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('network / volume rules', () => {
|
||||
it('blocks a missing external network and volume', () => {
|
||||
const m = model([svc()], { networks: { ext: { name: 'shared', external: true } }, volumes: { v: { name: 'data', external: true } } });
|
||||
const f = runRules(ctx({ model: m }));
|
||||
expect(ids(f, 'external-network-missing')).toHaveLength(1);
|
||||
expect(ids(f, 'external-volume-missing')).toHaveLength(1);
|
||||
});
|
||||
it('does not block an external resource that exists', () => {
|
||||
const m = model([svc()], { networks: { ext: { name: 'shared', external: true } } });
|
||||
const f = runRules(ctx({ model: m, existingNetworkNames: new Set(['shared']) }));
|
||||
expect(ids(f, 'external-network-missing')).toHaveLength(0);
|
||||
});
|
||||
it('reports a new network/volume as info when absent on the node', () => {
|
||||
const m = model([svc()], { networks: { backend: { name: 'backend', external: false } }, volumes: { data: { name: 'data', external: false } } });
|
||||
const f = runRules(ctx({ model: m }));
|
||||
expect(ids(f, 'new-network')[0].severity).toBe('info');
|
||||
expect(ids(f, 'new-volume')[0].message).toContain('proj_data');
|
||||
});
|
||||
});
|
||||
|
||||
describe('container_name rules', () => {
|
||||
it('blocks a duplicate container_name within the stack', () => {
|
||||
const m = model([svc({ name: 'a', containerName: 'dup' }), svc({ name: 'b', containerName: 'dup' })]);
|
||||
expect(ids(runRules(ctx({ model: m })), 'container-name-internal-dup')[0].severity).toBe('blocker');
|
||||
});
|
||||
it('blocks a container_name owned by a different stack', () => {
|
||||
const m = model([svc({ containerName: 'taken' })]);
|
||||
const f = runRules(ctx({ model: m, existingContainers: [{ name: 'taken', stack: 'other' }] }));
|
||||
expect(ids(f, 'container-name-collision')[0].severity).toBe('blocker');
|
||||
});
|
||||
it('does not flag a container_name owned by the same stack', () => {
|
||||
const m = model([svc({ containerName: 'mine' })]);
|
||||
const f = runRules(ctx({ stackName: 'proj', model: m, existingContainers: [{ name: 'mine', stack: 'proj' }] }));
|
||||
expect(ids(f, 'container-name-collision')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('effective-model-expanded', () => {
|
||||
it('flags services present in the rendered model but not the source', () => {
|
||||
const m = model([svc({ name: 'web' }), svc({ name: 'sidecar' })]);
|
||||
const f = runRules(ctx({ model: m, sourceServiceNames: ['web'] }));
|
||||
expect(ids(f, 'effective-model-expanded')).toHaveLength(1);
|
||||
expect(ids(f, 'effective-model-expanded')[0].message).toContain('sidecar');
|
||||
});
|
||||
it('stays silent when source and effective services match', () => {
|
||||
const m = model([svc({ name: 'web' })]);
|
||||
expect(ids(runRules(ctx({ model: m, sourceServiceNames: ['web'] })), 'effective-model-expanded')).toHaveLength(0);
|
||||
});
|
||||
it('stays silent when the source could not be read (empty != zero services)', () => {
|
||||
const m = model([svc({ name: 'web' }), svc({ name: 'sidecar' })]);
|
||||
const f = runRules(ctx({ model: m, sourceServiceNames: [], sourceReadable: false }));
|
||||
expect(ids(f, 'effective-model-expanded')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rule registry completeness', () => {
|
||||
// The canonical rule set. Adding or removing a rule must update this list,
|
||||
// which forces a deliberate pass over the docs and the frontend severity map.
|
||||
const EXPECTED_RULE_IDS = [
|
||||
'render-failed', 'env-unset', 'port-conflict-node', 'port-conflict-internal', 'port-exposed-all-interfaces',
|
||||
'bind-path-missing', 'bind-path-permission', 'docker-socket-mount', 'privileged', 'network-mode-host',
|
||||
'uid-gid-risk', 'image-latest', 'no-restart-policy', 'no-healthcheck', 'deploy-swarm-only',
|
||||
'external-network-missing', 'external-volume-missing', 'new-network', 'new-volume',
|
||||
'container-name-internal-dup', 'container-name-collision', 'effective-model-expanded',
|
||||
];
|
||||
it('the registry contains exactly the expected rules', () => {
|
||||
expect([...RULE_IDS].sort()).toEqual([...EXPECTED_RULE_IDS].sort());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user