Runner: Docker-Adapter gehärtet (R10)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-09-30 21:53:41 +00:00
1 parent 10ef2c8085
commit bcc0e42374
2 files changed
+161 -56

No files matched your search

+71 -20
View File
@@ -1,43 +1,52 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync, statSync } from 'node:fs';
import { EventEmitter } from 'node:events';
import { PassThrough } from 'node:stream';
import { buildRunArgs, devRunArgs, createDocker, spawnDocker } from '../src/docker.js';
const base = { image: 'node:20', workDir: '/srv/w/build/1', cacheDir: '/srv/c/repo', cmd: ['npm', 'ci'] };
const base = { image: 'node:20', name: 'preview-build-1', workDir: '/srv/w/build/1', cacheDir: '/srv/c/repo', workRoot: '/srv/w', cacheRoot: '/srv/c', cmd: ['npm', 'ci'] };
const pair = (args, flag, val) => args.some((a, i) => a === flag && args[i + 1] === val);
test('buildRunArgs: komplettes Sicherheitsprofil', () => {
const a = buildRunArgs(base);
assert.deepEqual(a.slice(0, 2), ['run', '--rm']);
assert.ok(a.includes('--init'));
for (const [f, v] of [['--memory', '1g'], ['--cpus', '1'], ['--pids-limit', '512'], ['--cap-drop', 'ALL'], ['--security-opt', 'no-new-privileges'], ['--user', '1000:1000'], ['--network', 'preview-sandbox'], ['-w', '/work'], ['-v', '/srv/w/build/1:/work'], ['-v', '/srv/c/repo:/cache'], ['-e', 'HOME=/cache/home'], ['-e', 'npm_config_cache=/cache/npm'], ['-e', 'COREPACK_HOME=/cache/corepack']]) {
for (const [f, v] of [['--memory', '1g'], ['--memory-swap', '1g'], ['--name', 'preview-build-1'], ['--cpus', '1'], ['--pids-limit', '512'], ['--cap-drop', 'ALL'], ['--security-opt', 'no-new-privileges'], ['--user', '1000:1000'], ['--network', 'preview-sandbox'], ['-w', '/work'], ['-v', '/srv/w/build/1:/work'], ['-v', '/srv/c/repo:/cache'], ['-e', 'HOME=/cache/home'], ['-e', 'npm_config_cache=/cache/npm'], ['-e', 'COREPACK_HOME=/cache/corepack']]) {
assert.ok(pair(a, f, v), `${f} ${v}`);
}
assert.equal(a.filter((x) => x === '-v').length, 2);
assert.deepEqual(a.slice(-3), ['node:20', 'npm', 'ci']);
});
test('buildRunArgs: Variablen nur als Name, Werte nie in args', () => {
const a = buildRunArgs({ ...base, env: { API_KEY: 'geheim-wert', X: 'abc' }, memory: '1g' });
assert.ok(pair(a, '-e', 'API_KEY') && pair(a, '-e', 'X'));
test('buildRunArgs: Variablenwerte nie in args, ungültige Werte/Namen abgelehnt', () => {
const a = buildRunArgs({ ...base, env: { API_KEY: 'geheim-wert' } });
assert.ok(!a.join(' ').includes('geheim-wert'));
});
test('buildRunArgs: ungültiger Name/NUL/Name-Validierung', () => {
assert.throws(() => buildRunArgs({ ...base, env: { 'a b': '1' } }));
assert.throws(() => buildRunArgs({ ...base, env: { A: 'x\0y' } }));
assert.throws(() => buildRunArgs({ ...base, env: { A: 'x\ny' } }));
assert.throws(() => buildRunArgs({ ...base, env: { HOME: 'x' } }));
assert.throws(() => buildRunArgs({ ...base, name: '-x' }));
});
test('buildRunArgs: memory/name optional', () => {
const a = buildRunArgs({ ...base, name: 'job-1', memory: '2g' });
assert.ok(pair(a, '--memory', '2g') && pair(a, '--name', 'job-1'));
test('buildRunArgs: Name Pflicht, Memory/Image Allowlist, Network fest', () => {
assert.throws(() => buildRunArgs({ ...base, name: undefined }));
assert.throws(() => buildRunArgs({ ...base, name: '-x' }));
assert.throws(() => buildRunArgs({ ...base, memory: '64g' }));
assert.throws(() => buildRunArgs({ ...base, image: 'evil/img' }));
assert.ok(buildRunArgs({ ...base, image: 'oven/bun:1', memory: '512m' }));
assert.ok(pair(buildRunArgs({ ...base, network: 'host' }), '--network', 'preview-sandbox'));
});
test('Pfade: absolut, unter Wurzel, keine Sonderzeichen', () => {
for (const workDir of ['rel/x', '/etc', '/srv/w/../x', '/srv/w/a:b', '/srv/w/a,b', '/srv/w/a\nb', '/srv/wx/y', '/srv/w/a\0b', '/srv/w/x/../../etc']) {
assert.throws(() => buildRunArgs({ ...base, workDir }), workDir);
}
assert.throws(() => buildRunArgs({ ...base, cacheDir: '/srv/w/x' }));
assert.throws(() => buildRunArgs({ ...base, workRoot: undefined }));
});
test('devRunArgs: detached, Name, Label, ohne --rm, 512m', () => {
const a = devRunArgs({ ...base, sub: 'demo-1', cmd: ['npm', 'run', 'dev'] });
const a = devRunArgs({ ...base, sub: 'demo-1', memory: undefined, cmd: ['npm', 'run', 'dev'] });
assert.ok(!a.includes('--rm'));
assert.ok(a.includes('-d'));
assert.ok(pair(a, '--name', 'preview-dev-demo-1'));
@@ -60,12 +69,19 @@ function fakeChild() {
return c;
}
test('run: streamt Zeilen, liefert Exit-Code, Umgebung minimal + Job-Env', async () => {
test('run: streamt Zeilen, CLI-Umgebung nur minimalBase, Werte nur in env-file (0600, danach gelöscht)', async () => {
const calls = [];
let content; let mode; let filePath;
const child = fakeChild();
const docker = createDocker({ exec: (args, opts) => { calls.push({ args, opts }); return child; }, baseEnv: { PATH: '/bin', DOCKER_HOST: 'unix:///x', PREVIEW_RUNNER_TOKEN: 'leak' } });
const exec = (args, opts) => {
calls.push({ args, opts });
const i = args.indexOf('--env-file');
filePath = args[i + 1]; content = readFileSync(filePath, 'utf8'); mode = statSync(filePath).mode & 0o777;
return child;
};
const docker = createDocker({ exec, baseEnv: { PATH: '/bin', DOCKER_HOST: 'unix:///x', PREVIEW_RUNNER_TOKEN: 'leak' } });
const lines = [];
const p = docker.run(['run', '-e', 'K'], { env: { K: 'v' }, onLine: (l) => lines.push(l) });
const p = docker.run(['run', '--rm', 'img'], { env: { K: 'v', DOCKER_HOST: 'x', PATH: 'y' }, onLine: (l) => lines.push(l) });
child.stdout.write('eins\nzw');
child.stderr.write('fehler\n');
child.stdout.write('ei\n');
@@ -74,27 +90,62 @@ test('run: streamt Zeilen, liefert Exit-Code, Umgebung minimal + Job-Env', async
child.emit('close', 3);
assert.deepEqual(await p, { code: 3 });
assert.deepEqual(lines.sort(), ['eins', 'fehler', 'zwei']);
assert.deepEqual(calls[0].opts.env, { PATH: '/bin', DOCKER_HOST: 'unix:///x', K: 'v' });
assert.deepEqual(calls[0].opts.env, { PATH: '/bin', DOCKER_HOST: 'unix:///x' });
assert.equal(content, 'K=v\nDOCKER_HOST=x\nPATH=y\n');
assert.equal(mode, 0o600);
assert.ok(!calls[0].args.join(' ').includes('=v'));
assert.equal(calls[0].args[0], 'run');
assert.equal(existsSync(filePath), false);
});
test('run: NUL im Wert ⇒ Fehler vor dem Start', async () => {
test('run: NUL/Newline im Wert ⇒ Fehler vor dem Start', async () => {
let n = 0;
const docker = createDocker({ exec: () => { n++; return fakeChild(); } });
await assert.rejects(docker.run(['run'], { env: { A: 'x\0' } }));
await assert.rejects(docker.run(['run'], { env: { A: 'x\ny' } }));
assert.equal(n, 0);
});
test('run: Zeitlimit ⇒ Kill, docker rm -f, code 124', async () => {
const calls = [];
const child = fakeChild();
const docker = createDocker({ exec: (args) => { calls.push(args); return calls.length === 1 ? child : Object.assign(fakeChild(), { __rm: true }); } });
const p = docker.run(['run', '--name', 'job-9', 'img'], { timeoutMs: 20 });
const docker = createDocker({ rmCapMs: 30, exec: (args) => { calls.push(args); return calls.length === 1 ? child : Object.assign(fakeChild(), { __rm: true }); } });
const p = docker.run(['run', '--name', 'job-9', 'node:20', 'x', '--name', 'fake'], { timeoutMs: 20 });
const r = await p;
assert.deepEqual(r, { code: 124 });
assert.deepEqual(child.killed, ['SIGKILL']);
assert.deepEqual(calls[1], ['rm', '-f', 'job-9']);
});
test('Zeitlimit: Name nur aus Optionsteil, ohne Namen kein rm', async () => {
const calls = [];
const docker = createDocker({ rmCapMs: 10, exec: (args) => { calls.push(args); return fakeChild(); } });
assert.deepEqual(await docker.run(['run', 'node:20', 'x', '--name', 'fake'], { timeoutMs: 10 }), { code: 124 });
assert.equal(calls.length, 1);
});
test('ps: nur stdout, nur gültige Namen', async () => {
const exec = () => {
const c = fakeChild();
setImmediate(() => { c.stderr.write('Warnung: foo\n'); c.stdout.write('preview-dev-a\n'); c.stdout.end(); c.stderr.end(); c.emit('close', 0); });
return c;
};
assert.deepEqual(await createDocker({ exec }).ps('webklar.preview'), ['preview-dev-a']);
});
test('lineSplitter: überlange Zeilen werden gekappt', async () => {
const child = fakeChild();
const docker = createDocker({ exec: () => child });
const lines = [];
const p = docker.run(['ps'], { onLine: (l) => lines.push(l) });
child.stdout.write('a'.repeat(200000) + '\n');
child.stdout.end(); child.stderr.end();
await new Promise((r) => setImmediate(r));
child.emit('close', 0);
await p;
assert.ok(lines[0].length <= 65536);
});
test('ps/stop/rm', async () => {
const calls = [];
const exec = (args) => {