Files

676 lines
40 KiB
JavaScript

// Row 210 (#1707, design section 8): host.run over a scratch sshd on 127.0.0.1 that this test starts,
// with keys this test makes and a fake sudo that records its argv and runs the command as the test
// user. Nothing runs as root and nothing reaches another host.
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync, spawn } from 'node:child_process';
import { createServer } from 'node:net';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir, userInfo } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Credentials, parseHostKey } from '../src/credentials.mjs';
import { addKey, hostExecutor, runHost, sshArgs, startTime, sweepRunDir, quote, remoteCommand } from '../src/host-run.mjs';
import { fixture } from './spec-fixtures.mjs';
import { HUMAN_YES, hostGrantRecord, hostRecord } from './host-fixtures.mjs';
import { Broker, ORGANIZATION_KEYS } from '../src/broker.mjs';
import { Store } from '../src/store.mjs';
import { hostGrant } from '../src/host-grant.mjs';
import { startBroker } from '../src/runtime.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
import { ephemeralRange, installSleepStub, pickPort, startScratchSshd } from './scratch-sshd.mjs';
const SSHD = '/usr/bin/sshd';
const REPO = fileURLToPath(new URL('../../../', import.meta.url));
const USER = userInfo().username;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const keygen = (file, ...extra) => execFileSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', 'test', ...extra, '-f', file]);
const pubLine = (file) => readFileSync(file + '.pub', 'utf8').trim().split(' ').slice(0, 2).join(' ');
// Every pid whose argv names `text`, with its argv and environment. ssh-agent makes itself
// non-dumpable, so its environ reads as null. Its environment is the PATH runHost passes.
const readOrNull = (file) => {
try {
return readFileSync(file, 'utf8');
} catch {
return null;
}
};
function procsNaming(text) {
const out = [];
for (const pid of readdirSync('/proc').filter((p) => /^\d+$/.test(p))) {
const cmdline = readOrNull(`/proc/${pid}/cmdline`);
if (cmdline?.includes(text)) out.push({ pid: Number(pid), cmdline, environ: readOrNull(`/proc/${pid}/environ`) });
}
return out;
}
async function until(fn, ms = 5000) {
const end = Date.now() + ms;
while (!fn()) {
if (Date.now() > end) return false;
await sleep(20);
}
return true;
}
// A scratch sshd. ForceCommand puts the fake sudo first on PATH and hands the command to /bin/sh, so
// the remote side parses the command line the way a real host's POSIX shell does.
async function sshd(t, dir, options) {
if (!existsSync(SSHD)) throw new Error(`${SSHD} is missing: the executor tests need it and fail, never skip`);
keygen(join(dir, 'hostkey'));
keygen(join(dir, 'otherhost'));
keygen(join(dir, 'user'));
writeFileSync(join(dir, 'authorized_keys'), readFileSync(join(dir, 'user.pub')), { mode: 0o600 });
mkdirSync(join(dir, 'fake'));
installSleepStub(dir);
writeFileSync(
join(dir, 'fake', 'sudo'),
`#!/bin/sh\n'${process.execPath}' -e 'require("fs").appendFileSync(process.argv[1], JSON.stringify(process.argv.slice(2)) + "\\n")' '${dir}/argv.json' "$@"\nshift 2\nexec "$@"\n`,
);
chmodSync(join(dir, 'fake', 'sudo'), 0o755);
// A remote command that prints ssh's pin refusal text and exits 255 (N3).
writeFileSync(join(dir, 'fake', 'fail255'), '#!/bin/sh\necho "Host key verification failed." >&2\nexit 255\n', { mode: 0o755 });
writeFileSync(join(dir, 'wrap.sh'), `#!/bin/sh\nPATH=${dir}/fake:/usr/bin:/bin; export PATH\nexec /bin/sh -c "$SSH_ORIGINAL_COMMAND"\n`);
chmodSync(join(dir, 'wrap.sh'), 0o755);
return startScratchSshd(t, dir, [
'ListenAddress 127.0.0.1', `HostKey ${dir}/hostkey`,
`AuthorizedKeysFile ${dir}/authorized_keys`, `PidFile ${dir}/sshd.pid`,
'UsePAM no', 'StrictModes no', 'PasswordAuthentication no', 'KbdInteractiveAuthentication no',
`ForceCommand ${dir}/wrap.sh`,
], options);
}
test('row 280: an occupied TCP port is attempted, refused, and retried with our sshd', async t => {
const dir = mkdtempSync(join(tmpdir(), 'sshd280-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const server = createServer(socket => socket.end());
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
t.after(() => new Promise(resolve => server.close(resolve)));
const occupied = server.address().port;
const port = await sshd(t, dir, { firstPort: occupied });
assert.match(readFileSync(join(dir, 'sshd-0.conf'), 'utf8'), new RegExp(`^Port ${occupied}\\n`));
assert.equal(readFileSync(join(dir, 'sshd-0.log'), 'utf8').includes(`Server listening on 127.0.0.1 port ${occupied}.`), false);
assert.notEqual(port, occupied);
assert.ok([1, 2, 3, 4].some(n => existsSync(join(dir, `sshd-${n}.log`)) &&
readFileSync(join(dir, `sshd-${n}.log`), 'utf8').includes(`Server listening on 127.0.0.1 port ${port}.`)));
});
test('row 280: every injected picker sample is unprivileged and outside the ephemeral range', () => {
const [lo, hi] = ephemeralRange();
for (const sample of [0, 0.25, 0.5, 0.6384, 0.75, 0.999999]) {
const port = pickPort(() => sample);
assert.ok(port >= 1024 && port <= 65535);
assert.ok(port < lo || port > hi, `sample ${sample}: ${port} inside ${lo}-${hi}`);
}
});
const GRANT = [['true'], ['false'], ['echo'], ['sleep'], ['head'], ['cat'], ['systemctl', 'status']];
async function hostFixture(t, { commands = GRANT, reviewerHosts, wrongHostKey = false, coderWeb2 = false } = {}) {
const dir = mkdtempSync(join(tmpdir(), 'h210-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const port = await sshd(t, dir);
const keyFile = join(dir, 'user');
const credentials = new Credentials({
references: { 'demo/coder': { 'host.web1': { file: keyFile, rotateBy: '2099-01-01' } } },
dataRoot: join(dir, 'data'),
});
t.after(() => credentials.close());
const root = join(dir, 'run');
const executor = hostExecutor({ credentials, root });
const entry = { address: '127.0.0.1', port, user: USER, hostKeys: [pubLine(join(dir, wrongHostKey ? 'otherhost' : 'hostkey'))] };
const authority = { withinRole: ['message.send', 'record.write'], crossRole: [] };
const f = await fixture(t, { roles: { coder: { definition: 'coder', authority } } });
// Row 234 (#1740, r6 X4): host.run reads store records, so the hosts and grants are fixtures there.
for (const host of ['web1', 'web2']) hostRecord(f.store, { host, entry });
hostGrantRecord(f.store, { agent: 'coder', host: 'web1', commands });
if (coderWeb2) hostGrantRecord(f.store, { agent: 'coder', host: 'web2', commands });
for (const [host, g] of Object.entries(reviewerHosts ?? {})) hostGrantRecord(f.store, { agent: 'reviewer', host, commands: g.commands });
const approve = async (argv, stdin, host = 'web1') => {
const d = await f.raise({ action: 'host.sudo', target: host, argv, ...(stdin !== undefined ? { stdin } : {}) });
f.call(f.human, 'decision.resolve', { id: d.id, choice: 'yes' });
return d.id;
};
const run = (args, cap = f.coder) => f.broker.requestHost(cap, { verb: 'host.run', args }, executor);
const call = async (argv, { stdin, timeout } = {}) =>
run({ host: 'web1', argv, ...(stdin !== undefined ? { stdin } : {}), ...(timeout ? { timeout } : {}), decision: await approve(argv, stdin) });
const events = () =>
f.store.all("SELECT kind, body FROM events WHERE business='demo' ORDER BY seq").map((e) => ({ kind: e.kind, ...JSON.parse(e.body) }));
const argvSeen = () => (existsSync(join(dir, 'argv.json')) ? readFileSync(join(dir, 'argv.json'), 'utf8').trim().split('\n').map((l) => JSON.parse(l)) : []);
const key = parseHostKey(readFileSync(keyFile, 'utf8'));
return { ...f, dir, root, port, entry, keyFile, key, credentials, executor, approve, run, call, events, argvSeen };
}
const refusesWith = async (p, code) => assert.rejects(p, (e) => (assert.equal(e.code, code), true));
test('the executor tests need sshd, ssh, ssh-agent, ssh-add, ssh-keygen and setpriv: a missing one fails, never skips', () => {
for (const bin of [SSHD, '/usr/bin/ssh', '/usr/bin/ssh-agent', '/usr/bin/ssh-add', '/usr/bin/ssh-keygen', '/usr/bin/setpriv'])
assert.ok(existsSync(bin), `${bin} is missing`);
});
test('T3: organization.set, limits.set and instances.set refuse hosts, and the writer never runs', (t) => {
const root = mkdtempSync(join(tmpdir(), 'h210-'));
const store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const chiefAuthority = JSON.parse(readFileSync(join(REPO, 'roles', 'chief-of-staff.json'), 'utf8')).authority;
const businesses = {
demo: {
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, definitions: {},
roles: { chief: { definition: 'chief-of-staff', authority: chiefAuthority } }, projects: {},
},
};
const calls = [];
const b = new Broker({ store, businesses, organization: (...a) => (calls.push(a), { ok: true }) });
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
b.request(chief, { verb: 'role.claim' });
const revision = 'a'.repeat(64);
const hosts = { web1: { address: '10.0.0.5', port: 22, user: 'admin', hostKeys: ['ssh-ed25519 AAAA'] } };
assert.equal(ORGANIZATION_KEYS.includes('hosts'), false);
assert.throws(() => b.request(chief, { verb: 'organization.set', args: { revision, hosts } }), /invalid-request/);
assert.throws(() => b.request(human, { verb: 'limits.set', args: { revision, hosts } }), /invalid-request/);
assert.throws(() => b.request(human, { verb: 'instances.set', args: { revision, instance: 'chief', op: 'change', hosts } }), /invalid-request/);
assert.equal(calls.length, 0);
// Controls: the same calls without hosts reach the writer.
b.request(chief, { verb: 'organization.set', args: { revision, models: {} } });
b.request(human, { verb: 'limits.set', args: { revision, models: {} } });
b.request(human, { verb: 'instances.set', args: { revision, instance: 'chief', op: 'change', launch: true } });
// organization.set reaches the writer as limits.set (broker.mjs:1379).
assert.deepEqual(calls.map((c) => c[1]), ['limits.set', 'limits.set', 'instances.set']);
});
test('T4: the value check takes one unencrypted ed25519 key and nothing else', () => {
const dir = mkdtempSync(join(tmpdir(), 'h210-'));
try {
keygen(join(dir, 'good'));
execFileSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', 'secret-pass', '-f', join(dir, 'enc')]);
execFileSync('ssh-keygen', ['-q', '-t', 'rsa', '-b', '2048', '-N', '', '-f', join(dir, 'rsa')]);
const good = readFileSync(join(dir, 'good'), 'utf8');
// Two keys: the same blob with nkeys set to 2.
const lines = good.trim().split('\n');
const blob = Buffer.from(lines.slice(1, -1).join(''), 'base64');
// magic (15), cipher "none" (4+4), kdf "none" (4+4), empty kdfoptions (4), then nkeys.
blob.writeUInt32BE(2, 15 + 8 + 8 + 4);
const pem = (b) => [lines[0], ...b.toString('base64').match(/.{1,70}/g), lines.at(-1)].join('\n') + '\n';
// One check each: a field renamed in place, so every other check still passes. Offsets: magic 15,
// cipher name 19, kdf name 27, nkeys 35, public type 47, private section 98, its type 110.
const renamed = (at, text) => {
const copy = Buffer.from(lines.slice(1, -1).join(''), 'base64');
copy.write(text, at, 'latin1');
return pem(copy);
};
const write = (name, text) => (writeFileSync(join(dir, name), text, { mode: 0o600 }), join(dir, name));
const load = (file, service = 'host.web1') =>
new Credentials({ references: { 'demo/coder': { [service]: { file, rotateBy: '2099-01-01' } } }, dataRoot: join(dir, 'data') });
const ok = load(join(dir, 'good'));
assert.match(ok.status()[0].fingerprint, /^SHA256:/);
assert.equal(ok.status()[0].publicKey, pubLine(join(dir, 'good')));
ok.close();
for (const file of [
join(dir, 'enc'),
join(dir, 'rsa'),
write('two', pem(blob)),
write('cipher', renamed(19, 'aes1')),
write('kdf', renamed(27, 'bcry')),
write('public-type', renamed(47, 'ssh-ed25518')),
write('private-type', renamed(110, 'ssh-ed25518')),
write('crlf', good.replaceAll('\n', '\r\n')),
write('token', 'fake_gitea_token_0123456789\n'),
])
assert.throws(() => load(file), /credential-format/, file);
assert.throws(() => load(join(dir, 'good'), 'gitea'), /credential-format/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('T5 to T8: registry, grant, argv shape and allowlist refusals', async (t) => {
const f = await hostFixture(t, { reviewerHosts: { web2: { commands: [['true']] } } });
await refusesWith(f.run({ host: 'web9', argv: ['true'] }), 'host-unknown');
await refusesWith(f.run({ host: 'web2', argv: ['true'] }), 'host-not-granted');
for (const argv of [[], ['a\0'], ['a'.repeat(4097)], Array(257).fill('true'), ['bin/true'], [7], 'true'])
await refusesWith(f.run({ host: 'web1', argv }), 'invalid-request');
await refusesWith(f.run({ host: 'web1', argv: ['true'], stdin: 'x'.repeat(65537) }), 'invalid-request');
await refusesWith(f.run({ host: 'web1', argv: ['true'], timeout: 901 }), 'invalid-request');
await refusesWith(f.run({ host: 'web1', argv: ['systemctl'] }), 'command-not-granted');
await refusesWith(f.run({ host: 'web1', argv: ['systemctl', 'restart', 'nginx'] }), 'command-not-granted');
await refusesWith(f.run({ host: 'web1', argv: ['ls'] }), 'command-not-granted');
// N1: the raise refuses the same way, so the human is never asked about a call host.run refuses.
assert.throws(() => f.raise({ action: 'host.sudo', target: 'web2', argv: ['true'] }), /host-not-granted/);
assert.throws(() => f.raise({ action: 'host.sudo', target: 'web1', argv: ['ls'] }), /command-not-granted/);
assert.equal(f.store.get("SELECT count(*) AS n FROM decisions WHERE action='host.sudo'").n, 0);
// Granted prefixes run, a longer argv too.
await f.call(['systemctl', 'status']);
assert.equal((await f.call(['echo', 'hi', 'there'])).stdout, 'hi there\n');
assert.deepEqual(f.argvSeen(), [['-n', '--', 'systemctl', 'status'], ['-n', '--', 'echo', 'hi', 'there']]);
const refused = f.events().filter((e) => e.kind === 'action.refused' && e.verb === 'host.run').map((e) => e.code);
assert.ok(refused.includes('host-unknown') && refused.includes('command-not-granted'));
});
test('T9: under 1.7 every call needs a decision bound to its host, argv and stdin, spent once', async (t) => {
const f = await hostFixture(t, { coderWeb2: true });
await refusesWith(f.run({ host: 'web1', argv: ['true'] }), 'decision-required');
const other = await f.approve(['echo', 'a']);
await refusesWith(f.run({ host: 'web1', argv: ['echo', 'b'], decision: other }), 'decision-mismatch');
const stdin = await f.approve(['cat'], 'one');
await refusesWith(f.run({ host: 'web1', argv: ['cat'], stdin: 'two', decision: stdin }), 'decision-mismatch');
await refusesWith(f.run({ host: 'web1', argv: ['cat'], decision: stdin }), 'decision-mismatch');
const host = await f.approve(['true'], undefined, 'web2');
await refusesWith(f.run({ host: 'web1', argv: ['true'], decision: host }), 'decision-mismatch');
const once = await f.approve(['cat'], 'one');
assert.equal((await f.run({ host: 'web1', argv: ['cat'], stdin: 'one', decision: once })).stdout, 'one');
await refusesWith(f.run({ host: 'web1', argv: ['cat'], stdin: 'one', decision: once }), 'decision-consumed');
// An unresolved decision doesn't run.
const open = (await f.raise({ action: 'host.sudo', target: 'web1', argv: ['true'] })).id;
await refusesWith(f.run({ host: 'web1', argv: ['true'], decision: open }), 'decision-not-approved');
// The human sees host, user, each argv element and all of stdin, appended by the broker.
const q = f.store.get('SELECT question FROM decisions WHERE id=?', once).question;
assert.ok(q.includes(`Host web1, ${USER} at 127.0.0.1 port ${f.port}`), q);
assert.ok(q.includes('argv: "cat"'), q);
assert.ok(q.endsWith('stdin: "one"'), q);
assert.throws(() => f.raise({ action: 'host.sudo', target: 'web1', argv: ['cat'], stdin: 'x'.repeat(4096) }), /invalid-request/);
assert.throws(() => f.raise({ action: 'host.sudo', target: 'web9', argv: ['cat'] }), /host-unknown/);
assert.throws(() => f.raise({ action: 'deploy', argv: ['cat'] }), /invalid-request/);
assert.equal(f.store.get("SELECT class FROM decisions WHERE id=?", once).class, 'gated');
});
test('T10, T11: argv reaches sudo unchanged after sudo -n --', async (t) => {
const f = await hostFixture(t);
const argv = ['true', 'a b', "it's", '"q"', '$(touch x)', '`id`', 'a;b', 'line\nbreak', '*', '?[a]', '$HOME', '\\'];
assert.equal((await f.call(argv)).exit, 0);
assert.deepEqual(f.argvSeen(), [['-n', '--', ...argv]]);
assert.equal(existsSync(join(f.dir, 'x')), false);
assert.equal(quote("it's"), "'it'\\''s'");
assert.equal(remoteCommand(['a b']), "sudo -n -- 'a b'");
});
test('T12, T13: exit code passes through, the timeout kills the group, output is capped', async (t) => {
const f = await hostFixture(t);
assert.equal((await f.call(['false'])).exit, 1);
const began = Date.now();
const slow = await f.call(['sleep', '30'], { timeout: 1 });
// D6: the timeout answers signal. ssh exits 255 on SIGTERM, which the result can't tell from a remote 255.
assert.equal(slow.signal, 'SIGTERM');
assert.ok(Date.now() - began < 10000);
const big = await f.call(['head', '-c', '2097152', '/dev/zero']);
assert.equal(Buffer.byteLength(big.stdout), 1024 * 1024);
assert.deepEqual(big.truncated, { stdout: true, stderr: false });
});
test('T14: a host key not in the registry refuses and nothing runs', async (t) => {
const f = await hostFixture(t, { wrongHostKey: true });
await refusesWith(f.call(['true']), 'host-key-mismatch');
assert.deepEqual(f.argvSeen(), []);
});
// Row 234 (#1740, r6 G18, duckworth-0081 B2): hosts.check's `list` goes through runHost with the record's
// pinned keys. A stub `list` on the scratch sshd's PATH records each run.
test('G18: hosts.check with a wrong pin refuses host-key-mismatch and runs nothing on the host', async (t) => {
const f = await hostFixture(t);
writeFileSync(join(f.dir, 'fake', 'list'), `#!/bin/sh\necho "$@" >> '${f.dir}/list.log'\n`, { mode: 0o755 });
const configDir = join(f.dir, 'cfg');
const keyDir = join(configDir, 'secrets', 'hosts', 'demo', 'web1');
mkdirSync(keyDir, { recursive: true, mode: 0o700 });
for (const d of [join(configDir, 'secrets'), join(configDir, 'secrets', 'hosts'), join(configDir, 'secrets', 'hosts', 'demo'), keyDir]) chmodSync(d, 0o700);
writeFileSync(join(keyDir, 'installer'), readFileSync(f.keyFile), { mode: 0o600 });
const hg = hostGrant({ configDir, runRoot: f.root });
const record = { address: '127.0.0.1', port: f.port, user: 'jarvis', hostKeys: [pubLine(join(f.dir, 'otherhost'))] };
await refusesWith(hg.check('demo', 'web1', record), 'host-key-mismatch');
assert.equal(existsSync(join(f.dir, 'list.log')), false);
// Control: with the right pin the same command reaches the stub.
const out = await runHost({ root: f.root, hostId: 'web1', entry: f.entry, key: f.key, timeout: 10, command: 'list jarvis' });
assert.equal(out.exit, 0);
assert.equal(readFileSync(join(f.dir, 'list.log'), 'utf8'), 'jarvis\n');
});
test('N3: a remote exit 255 that prints the pin refusal text is the command result, and an ssh failure keeps its message', async (t) => {
const f = await hostFixture(t, { commands: [['fail255']] });
const out = await f.call(['fail255']);
assert.equal(out.exit, 255);
assert.match(out.stderr, /Host key verification failed/);
assert.deepEqual(f.argvSeen(), [['-n', '--', 'fail255']]);
// A closed port: ssh's message reaches the agent from the run dir's log.
const closed = await runHost({ root: f.root, hostId: 'web1', entry: { ...f.entry, port: 1 }, key: f.key, argv: ['true'], stdin: '', timeout: 10 });
assert.equal(closed.exit, 255);
assert.match(closed.stderr, /port 1: Connection refused/);
});
test('T15: ssh reads no config file and no user known_hosts', async (t) => {
// ssh finds ~/.ssh from the passwd entry, not $HOME, and the test may not write the real home, so
// the options are checked here and the call runs with an alias no user file holds (BUILD.md).
const args = sshArgs({ hostId: 'web1', address: '10.0.0.5', port: 22, user: 'admin', dir: '/r' });
assert.deepEqual(args.slice(0, 2), ['-F', '/dev/null']);
assert.deepEqual(args.slice(3, 5), ['-E', '/r/ssh.log']);
for (const o of ['StrictHostKeyChecking=yes', 'UserKnownHostsFile=/r/known_hosts', 'GlobalKnownHostsFile=/dev/null',
'HostKeyAlias=mosaic-host-web1', 'BatchMode=yes', 'IdentitiesOnly=yes', 'ForwardAgent=no', 'ProxyCommand=none', 'ControlMaster=no'])
assert.ok(args.includes(o), o);
assert.deepEqual(args.slice(-4), ['-l', 'admin', '--', '10.0.0.5']);
const f = await hostFixture(t);
assert.equal((await f.call(['true'])).exit, 0);
});
test('T16, T17: key material in output or stdin refuses credential-leak, and no event holds it', async (t) => {
const f = await hostFixture(t, { commands: [['cat']] });
const line = f.key.text.split('\n')[2];
writeFileSync(join(f.dir, 'one-line'), line + '\n');
writeFileSync(join(f.dir, 'seed-hex'), f.key.seed.toString('hex') + '\n');
for (const file of [f.keyFile, join(f.dir, 'one-line'), join(f.dir, 'seed-hex')]) await refusesWith(f.call(['cat', file]), 'credential-leak');
const ran = f.argvSeen().length;
await refusesWith(f.call(['cat'], { stdin: 'x' + line }), 'credential-leak');
assert.equal(f.argvSeen().length, ran, 'nothing ran for a stdin leak');
const text = JSON.stringify(f.events());
for (const needle of [line, f.key.seed.toString('hex')]) assert.equal(text.includes(needle), false);
const failed = f.events().filter((e) => e.action === 'host.sudo' && e.phase === 'completed');
assert.ok(failed.every((e) => e.outcome === 'failed' && e.code === 'credential-leak'));
});
test('T18, T19, T20: one call at a time, no key in any argv or env, and nothing left after', async (t) => {
const f = await hostFixture(t);
const first = f.call(['sleep', '3']);
assert.ok(await until(() => f.argvSeen().length === 1), 'fake sudo started');
const procs = procsNaming(f.root);
assert.ok(procs.length >= 2, 'ssh-agent and ssh name the run dir');
const needles = [...f.key.needles, f.key.text];
assert.ok(procs.some((p) => p.environ !== null), 'ssh environ read');
for (const p of procs) for (const n of needles) assert.equal(p.cmdline.includes(n) || (p.environ ?? '').includes(n), false);
// host-busy refuses before the decision is spent, so it still runs after.
const held = await f.approve(['true']);
await refusesWith(f.run({ host: 'web1', argv: ['true'], decision: held }), 'host-busy');
assert.equal((await first).exit, 0);
assert.equal((await f.run({ host: 'web1', argv: ['true'], decision: held })).exit, 0);
// Success, a refusal, a timeout and a thrown error each leave no run dir and no process.
await refusesWith(f.call(['cat', f.keyFile]), 'credential-leak');
await f.call(['sleep', '30'], { timeout: 1 });
await assert.rejects(
runHost({ root: f.root, hostId: 'web1', entry: { ...f.entry, hostKeys: null }, key: f.key, argv: ['true'], stdin: '', timeout: 5 }),
);
assert.deepEqual(readdirSync(f.root), []);
assert.ok(await until(() => procsNaming(f.root).length === 0, 2000));
});
test('T21: revoke kills the call in flight, and the next call refuses', async (t) => {
const f = await hostFixture(t);
const slow = f.call(['sleep', '30']);
assert.ok(await until(() => f.argvSeen().length === 1));
f.credentials.revoke('demo/coder', 'host.web1');
await refusesWith(slow, 'credential-unavailable');
await refusesWith(f.call(['true']), 'credential-unavailable');
assert.ok(await until(() => procsNaming(f.root).length === 0, 2000));
});
test('T22: events carry host, address, user and argv in clear, and sizes and hashes only', async (t) => {
const f = await hostFixture(t);
await f.call(['cat'], { stdin: 'secret-ish stdin' });
const [requested, completed] = f.events().filter((e) => e.action === 'host.sudo' && e.phase);
assert.deepEqual(
{ host: requested.host, address: requested.address, user: requested.user, argv: requested.argv, stdinBytes: requested.stdinBytes },
{ host: 'web1', address: '127.0.0.1', user: USER, argv: ['cat'], stdinBytes: 16 },
);
assert.match(requested.stdinSha256, /^[0-9a-f]{64}$/);
assert.equal(completed.outcome, 'ran');
assert.equal(completed.stdoutBytes, 16);
assert.match(completed.stdoutSha256, /^[0-9a-f]{64}$/);
assert.equal(JSON.stringify(f.events()).includes('secret-ish stdin'), false);
});
test('T24: no literal gitea and vikunja kind list outside vocabulary.mjs, and the credential use sites are known', () => {
const files = readdirSync(join(REPO, 'packages')).flatMap((pkg) => {
const src = join(REPO, 'packages', pkg, 'src');
return existsSync(src) ? readdirSync(src).filter((n) => n.endsWith('.mjs')).map((n) => `packages/${pkg}/src/${n}`) : [];
});
const code = (file) => readFileSync(join(REPO, file), 'utf8').split('\n').filter((l) => !/^\s*(\/\/|\*)/.test(l)).join('\n');
const lists = files.filter((file) => !file.endsWith('organization/src/vocabulary.mjs') &&
/\[\s*['"]gitea['"]\s*,\s*['"]vikunja['"]\s*\]/.test(code(file)));
assert.deepEqual(lists, []);
const uses = files.filter((file) => /credentials\.(use|useFor)\(/.test(code(file))).sort();
assert.deepEqual(uses, ['packages/bus/src/host-run.mjs', 'packages/bus/src/runtime.mjs', 'packages/tasks/src/adapter.mjs']);
});
test('T25: no role definition lists host.sudo while it is a hard wait', () => {
const dir = join(REPO, 'roles');
for (const name of readdirSync(dir).filter((n) => n.endsWith('.json'))) {
const role = JSON.parse(readFileSync(join(dir, name), 'utf8'));
const authority = role.authority ?? {};
for (const list of [authority.withinRole ?? [], authority.crossRole ?? []]) assert.equal(list.includes('host.sudo'), false, name);
}
});
test('T27: the bus child dying takes ssh and ssh-agent with it', async (t) => {
const f = await hostFixture(t);
// A separate bus child runs one call; the test SIGKILLs it while fake sudo sleeps.
const script = join(f.dir, 'child.mjs');
const src = new URL('../src/', import.meta.url).href;
writeFileSync(script, `
import { Credentials } from '${src}credentials.mjs';
import { hostExecutor } from '${src}host-run.mjs';
const credentials = new Credentials({ references: { 'demo/coder': { 'host.web1': { file: ${JSON.stringify(f.keyFile)}, rotateBy: '2099-01-01' } } }, dataRoot: ${JSON.stringify(join(f.dir, 'data'))} });
const executor = hostExecutor({ credentials, root: ${JSON.stringify(join(f.dir, 'run2'))} });
await executor.run({ business: 'demo', role: 'coder' }, 'web1', ${JSON.stringify(f.entry)}, { argv: ['sleep', '60'], stdin: '', timeout: 120 });
`);
const child = spawn(process.execPath, [script], { stdio: 'ignore' });
t.after(() => child.kill('SIGKILL'));
assert.ok(await until(() => f.argvSeen().length === 1, 10000), 'fake sudo started');
const [run] = readdirSync(join(f.dir, 'run2'));
const pids = readFileSync(join(f.dir, 'run2', run, 'pids'), 'utf8').trim().split('\n').map((l) => Number(l.split(' ')[0]));
assert.equal(pids.length, 3);
assert.equal(pids[0], child.pid);
child.kill('SIGKILL');
assert.ok(await until(() => pids.slice(1).every((p) => !existsSync(`/proc/${p}`)), 2000), 'ssh and ssh-agent are gone');
// The next start sweeps the dir.
assert.deepEqual(sweepRunDir(join(f.dir, 'run2')).removed, [run]);
assert.deepEqual(readdirSync(join(f.dir, 'run2')), []);
});
test('T27: ssh-add -t drops the key from an agent the test keeps alive', async (t) => {
const dir = mkdtempSync(join(tmpdir(), 'h210-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
keygen(join(dir, 'k'));
const sock = join(dir, 'a.sock');
const agent = spawn('ssh-agent', ['-D', '-a', sock], { stdio: 'ignore' });
t.after(() => agent.kill('SIGKILL'));
assert.ok(await until(() => existsSync(sock)));
// A failed list names its cause: when each step ran on the monotonic clock (hrtime) and on
// /proc/uptime (CLOCK_BOOTTIME, which ssh-agent's lifetime counts in whole seconds), and
// whether the agent and its socket were still there.
const marks = [];
const mark = (name) => marks.push({ name, ns: process.hrtime.bigint(), uptime: readFileSync('/proc/uptime', 'utf8').split(' ')[0] });
const timing = () => {
const at = (m) => `${m.name} +${(Number(m.ns - marks[0].ns) / 1e6).toFixed(1)} ms (hrtime ${(Number(m.ns % 1_000_000_000n) / 1e9).toFixed(4)} s into its second, uptime ${m.uptime})`;
const alive = agent.exitCode === null && agent.signalCode === null;
return `${marks.map(at).join('; ')}; agent ${alive ? 'alive' : `gone (${agent.exitCode ?? agent.signalCode})`}, socket ${existsSync(sock) ? 'present' : 'missing'}`;
};
// ssh-agent sets a key's death to the whole second of CLOCK_BOOTTIME at the add plus the
// lifetime, and drops it once that whole second is reached. So `-t N` keeps a key between N - 1
// and N seconds: `-t 1` added just before a second boundary was gone within milliseconds (#453).
// `-t 2` keeps it at least 1 s, and the sleep waits past the 2 s ceiling.
mark('addKey start');
await addKey(sock, readFileSync(join(dir, 'k'), 'utf8'), 2);
mark('addKey resolved');
const list = (name) => {
mark(name);
try {
return execFileSync('ssh-add', ['-l'], { env: { PATH: '/usr/bin:/bin', SSH_AUTH_SOCK: sock }, encoding: 'utf8' });
} catch (e) {
return e.stdout;
}
};
const first = list('first list');
assert.match(first, /SHA256:/, `first list got ${JSON.stringify(first)}: ${timing()}`);
await sleep(3500);
const second = list('second list');
assert.match(second, /no identities/, `second list got ${JSON.stringify(second)}: ${timing()}`);
});
// The test's own limit makes a hung addKey (M37a, M37b) fail in seconds, not at the batch timeout.
test('N2: a hung ssh-add is killed at its limit or on abort, and nothing is left running', { timeout: 10000 }, async (t) => {
const dir = mkdtempSync(join(tmpdir(), 'h210-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
keygen(join(dir, 'k'));
const text = readFileSync(join(dir, 'k'), 'utf8');
// An agent socket that accepts and never answers.
const closed = [];
const server = createServer((c) => c.resume().on('close', () => closed.push(Date.now())));
const sock = join(dir, 'a.sock');
await new Promise((r) => server.listen(sock, r));
t.after(() => server.close());
const began = Date.now();
await refusesWith(addKey(sock, text, 1, { limitMs: 300 }), 'host-failed');
assert.ok(Date.now() - began < 2000);
const controller = new AbortController();
setTimeout(() => controller.abort(), 100);
await refusesWith(addKey(sock, text, 1, { limitMs: 60000, signal: controller.signal }), 'credential-unavailable');
// Both ssh-add processes are gone: the socket saw each connection close.
assert.ok(await until(() => closed.length === 2), `closed ${closed.length}`);
});
test('T27: the start sweep keeps a live bus dir and kills only a matching live process', async (t) => {
const root = mkdtempSync(join(tmpdir(), 'h210-run-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
chmodSync(root, 0o700);
const dead = spawn('true');
await new Promise((r) => dead.on('close', r));
const make = (name, lines) => {
mkdirSync(join(root, name), { mode: 0o700 });
writeFileSync(join(root, name, 'pids'), lines.map((l) => l.join(' ') + '\n').join(''));
return join(root, name);
};
// A dir whose bus is gone, with a planted live process that names it.
const gone = join(root, 'h-aaaaaa');
const planted = spawn('/bin/sh', ['-c', 'sleep 60; :', gone], { stdio: 'ignore', detached: true });
t.after(() => { try { process.kill(planted.pid, 'SIGKILL'); } catch {} });
await until(() => startTime(planted.pid) !== null);
make('h-aaaaaa', [[dead.pid, 'x'], [planted.pid, startTime(planted.pid)]]);
// A dir whose bus is alive.
make('h-bbbbbb', [[process.pid, startTime(process.pid)]]);
// A dir whose bus is gone and whose recorded pid is alive but with another start time.
const other = join(root, 'h-cccccc');
const decoy = spawn('/bin/sh', ['-c', 'sleep 60; :', other], { stdio: 'ignore', detached: true });
t.after(() => { try { process.kill(decoy.pid, 'SIGKILL'); } catch {} });
await until(() => startTime(decoy.pid) !== null);
make('h-cccccc', [[dead.pid, 'x'], [decoy.pid, '1']]);
assert.deepEqual(sweepRunDir(root).removed.sort(), ['h-aaaaaa', 'h-cccccc']);
assert.ok(await until(() => planted.exitCode !== null || planted.signalCode !== null, 2000), 'planted process killed');
assert.equal(existsSync(`/proc/${decoy.pid}`), true, 'decoy kept');
assert.deepEqual(readdirSync(root), ['h-bbbbbb']);
writeFileSync(join(root, 'stray'), '');
assert.throws(() => sweepRunDir(root), /credential-file/);
});
test('T27: the bus start sweeps <configDir>/secrets/run before it listens, and a stray entry stops the start', async (t) => {
const root = mkdtempSync(join(tmpdir(), 'h210-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const rolesDir = rolesCopy(root);
writeJson(join(root, 'businesses/demo.json'), businessDoc(root, 'demo'));
mkdirSync(join(root, 'data'), { mode: 0o700 });
const run = join(root, 'secrets', 'run');
mkdirSync(join(run, 'h-dddddd'), { recursive: true, mode: 0o700 });
chmodSync(join(root, 'secrets'), 0o700);
const dead = spawn('true');
await new Promise((r) => dead.on('close', r));
writeFileSync(join(run, 'h-dddddd', 'pids'), `${dead.pid} x\n`);
const demo = {
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: ['message.send'], crossRole: [] }, credentials: {} } },
};
const start = () => startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) } });
const bus = await start();
try {
assert.deepEqual(readdirSync(run), []);
} finally {
await bus.close();
}
writeFileSync(join(run, 'stray'), '');
// A bus that starts anyway is closed, so a failure here ends the file instead of holding it open.
await assert.rejects(async () => { const again = await start(); await again.close(); }, /credential-file/);
});
// Row 234 (#1740, duckworth-0079 M3, r6 X4 first bullet): host.run takes a grant record only behind the
// Approve record check. Each case breaks one part of it. The refusals come before the executor, so none
// needs sshd. The control passes the check and stops at host-not-configured.
test('M3: host.run refuses host-not-granted unless the grant record has an approved, spent host grant', async (t) => {
const f = await fixture(t, { roles: { coder: { definition: 'coder', authority: { withinRole: ['message.send'], crossRole: [] } } } });
const entry = { address: '10.0.0.5', port: 22, user: 'admin', hostKeys: ['ssh-ed25519 AAAA'] };
for (const host of ['web1', 'web2']) hostRecord(f.store, { host, entry });
const run = (cap = f.coder, host = 'web1') => f.broker.requestHost(cap, { verb: 'host.run', args: { host, argv: ['true'] } }, null);
const commands = [['true']];
const cases = {
'no record': null,
unresolved: { close: null },
'resolved no': { close: { ...HUMAN_YES, choice: 'no' } },
withdrawn: { close: { op: 'withdrawn', by: 'jason', via: 'cli' } },
'an arbiter': { close: { ...HUMAN_YES, by: 'reviewer', via: 'broker' } },
'the tester': { close: { ...HUMAN_YES, by: 'tester' } },
'an agent': { close: { ...HUMAN_YES, by: 'coder', via: 'broker' } },
'not via cli': { close: { ...HUMAN_YES, via: 'web' } },
'consumed as grant.host-enroll': { spend: 'grant.host-enroll' },
unspent: { spend: null },
'not a credential.mint': { action: 'deploy' },
'no grant in the raise': { grant: null },
'a host-enroll grant': { grant: { method: 'host-enroll' } },
'a grant for another agent': { grant: { agent: 'reviewer' } },
'a grant for another host': { grant: { host: 'web2' } },
};
for (const [name, opts] of Object.entries(cases)) {
f.store.run('DELETE FROM host_grants');
if (opts) hostGrantRecord(f.store, { agent: 'coder', host: 'web1', commands, ...opts });
await refusesWith(run(), 'host-not-granted').catch((e) => assert.fail(`${name}: ${e.message}`));
}
f.store.run('DELETE FROM host_grants');
hostGrantRecord(f.store, { agent: 'coder', host: 'web1', commands });
await refusesWith(run(), 'host-not-configured');
// The record is per agent: the reviewer holds none on web1, and web9 has no host record.
await refusesWith(run(f.reviewer), 'host-not-granted');
await refusesWith(run(f.coder, 'web9'), 'host-unknown');
// The host.sudo raise takes the same check (N1).
f.store.run('DELETE FROM host_grants');
hostGrantRecord(f.store, { agent: 'coder', host: 'web1', commands, spend: null });
assert.throws(() => f.raise({ action: 'host.sudo', target: 'web1', argv: ['true'] }), /host-not-granted/);
});
// Row 234 (#1740, M3, r6 X4 third bullet): at start each host_grants record gives Credentials a store
// reference to host.<id>, so a restart finds the key the grant executor wrote.
test('M3: a host_grants record gives Credentials a store reference at start, and a restart reads its key', async (t) => {
const root = mkdtempSync(join(tmpdir(), 'h234-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const rolesDir = rolesCopy(root);
mkdirSync(join(root, 'data'), { mode: 0o700 });
mkdirSync(join(root, 'secrets', 'agents'), { recursive: true, mode: 0o700 });
chmodSync(join(root, 'secrets'), 0o700);
const demo = {
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: ['message.send'], crossRole: [] }, credentials: {} } },
};
const start = () => startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) } });
const hostKeys = (bus) => bus.credentials.status().filter((c) => c.service.startsWith('host.'));
let bus = await start();
assert.deepEqual(hostKeys(bus), []);
await bus.close();
const store = new Store(join(root, 'data'));
hostRecord(store, { host: 'web1', entry: { address: '10.0.0.5', port: 22, user: 'admin', hostKeys: ['ssh-ed25519 AAAA'] } });
hostGrantRecord(store, { agent: 'coder', host: 'web1', commands: [['true']], rotateBy: '2099-02-03' });
store.close();
bus = await start();
try {
assert.deepEqual(hostKeys(bus).map(({ instance, service, date, state }) => ({ instance, service, date, state })), [
{ instance: 'demo/coder', service: 'host.web1', date: '2099-02-03', state: 'not-issued' },
]);
} finally {
await bus.close();
}
const dir = join(root, 'secrets', 'agents', 'demo', 'coder');
mkdirSync(dir, { recursive: true, mode: 0o700 });
chmodSync(join(root, 'secrets', 'agents', 'demo'), 0o700);
keygen(join(dir, 'host.web1'));
bus = await start();
try {
const [key] = hostKeys(bus);
assert.equal(key.publicKey, pubLine(join(dir, 'host.web1')));
assert.notEqual(key.state, 'not-issued');
} finally {
await bus.close();
}
});