import { describe, expect, test } from 'bun:test'; import { chmod, mkdtemp, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { findReadableSecrets, osUserNameFor, runAsArgv } from './os-user'; // The tests that matter here are the two that prove the MECHANISM rather than the plumbing: that Bun // ignores `uid`, and that `setpriv` does not. Everything else in os-user.ts touches the passwd database // and is exercised by hand — see docs/per-user-linux-accounts.md. describe('Bun.spawn uid', () => { // THE pin. `Bun.spawn` accepts `uid`/`gid` at RUNTIME and silently ignores them, so a privilege drop // written the obvious way runs as the parent while looking correct. This test exists so that: // // - nobody "simplifies" runAs into a uid option, and // - if Bun ever implements it, this fails and tells us we may. // // Bun's own types do not declare `uid`, so typed code cannot reach this by accident — hence the cast, // which stands in for the ways you WOULD reach it: a spread of untyped config, or an `as any` in a // hurry. The runtime is what silently accepts it, and the runtime is what this pins. // // Skipped when running as root, where setuid would actually be permitted and the observation changes. test.skipIf(process.getuid?.() === 0)('is ignored at runtime — this is why runAs exists', async () => { const nobody = 65534; expect(process.getuid?.()).not.toBe(nobody); const options = { uid: nobody, gid: nobody, stdout: 'pipe', stderr: 'pipe' } as unknown as { stdout: 'pipe'; stderr: 'pipe'; }; const proc = Bun.spawn(['id', '-u'], options); const seen = (await new Response(proc.stdout).text()).trim(); const code = await proc.exited; // If this ever fails, read the assertion rather than fixing it: either the child ran as `nobody` // (Bun now honours the option) or it refused with EPERM (Bun now tries). Both are good news. expect(code).toBe(0); expect(seen).toBe(String(process.getuid?.())); expect(seen).not.toBe(String(nobody)); }); }); /** Whether this machine can actually drop privileges. See the sudo note in runAsArgv. */ const canSudo = await (async () => { const proc = Bun.spawn(['sudo', '-n', 'true'], { stdout: 'ignore', stderr: 'ignore' }); return (await proc.exited) === 0; })(); describe('runAsArgv', () => { test('wraps the command in sudo + setpriv with real ids, groups and a reset environment', () => { expect(runAsArgv('officer_ana', ['zsh', '-i'])).toEqual([ 'sudo', '-n', 'setpriv', '--reuid=officer_ana', '--regid=officer_ana', '--init-groups', '--reset-env', '--', 'zsh', '-i', ]); }); // --init-groups and --reset-env are not decoration: without the first the process keeps the owner's // supplementary groups, and without the second it inherits everything Bun loaded from .env. test('never omits --init-groups or --reset-env', () => { const argv = runAsArgv('officer_ana', ['true']); expect(argv).toContain('--init-groups'); expect(argv).toContain('--reset-env'); }); test('refuses an empty user or command rather than running as the owner', () => { expect(() => runAsArgv('', ['true'])).toThrow(); expect(() => runAsArgv('officer_ana', [])).toThrow(); }); // Proves the argv composes and runs end to end. Targets the CURRENT account so no test user has to be // created, which still exercises sudo, setpriv, initgroups and the env reset. // // Skipped where passwordless sudo is unavailable — that is a machine that cannot run this feature at // all, and a red test there would say "the code is broken" instead of "this host is not set up". test.skipIf(!canSudo)('runs the command as the requested account', async () => { const me = process.getuid?.() ?? 0; const proc = Bun.spawn(runAsArgv(String(me), ['id', '-u']), { stdout: 'pipe', stderr: 'pipe' }); const out = (await new Response(proc.stdout).text()).trim(); const err = (await new Response(proc.stderr).text()).trim(); expect(await proc.exited, `setpriv failed: ${err}`).toBe(0); expect(out).toBe(String(me)); }); // The property the whole feature rests on: the platform's environment does not cross the boundary. This // process is started by PM2 in the platform directory, so Bun has auto-loaded `.env` into it — the JWT // secret and POSTGRES_URL are in `process.env` right now. A member's shell must not see them. test.skipIf(!canSudo)('does not pass the platform environment through', async () => { const me = process.getuid?.() ?? 0; const proc = Bun.spawn(runAsArgv(String(me), ['sh', '-c', 'echo "[${OFFICER_LEAK_PROBE:-unset}]"']), { env: { ...process.env, OFFICER_LEAK_PROBE: 'this-must-not-cross' }, stdout: 'pipe', stderr: 'pipe', }); const out = (await new Response(proc.stdout).text()).trim(); expect(await proc.exited).toBe(0); expect(out).toBe('[unset]'); }); // …and HOME is the target account's, not the caller's. This is what makes a member's shell and their // agent's config land in their own directory rather than the owner's. test.skipIf(!canSudo)('sets HOME from the target account, not the caller', async () => { const me = process.getuid?.() ?? 0; const proc = Bun.spawn(runAsArgv(String(me), ['sh', '-c', 'echo "$HOME"']), { stdout: 'pipe', stderr: 'pipe' }); const out = (await new Response(proc.stdout).text()).trim(); expect(await proc.exited).toBe(0); expect(out).toBeTruthy(); // Read from passwd rather than inherited: `--reset-env` cleared the caller's HOME before setting it. const passwd = Bun.spawn(['sh', '-c', `getent passwd ${me} | cut -d: -f6`], { stdout: 'pipe' }); expect(out).toBe((await new Response(passwd.stdout).text()).trim()); }); }); describe('osUserNameFor', () => { // The chosen username, verbatim — so `whoami` in a member's terminal says who they are. Measured on this // host: useradd accepts dots, hyphens, underscores and uppercase, i.e. everything validateUsername lets // through. test('uses the chosen username as-is', () => { expect(osUserNameFor({ username: 'ana', email: 'ana@example.com' })).toBe('ana'); expect(osUserNameFor({ username: 'ana.silva', email: 'a@b.com' })).toBe('ana.silva'); expect(osUserNameFor({ username: 'Ana-Silva_2', email: 'a@b.com' })).toBe('Ana-Silva_2'); }); test('falls back to the email local part when there is no username', () => { expect(osUserNameFor({ username: null, email: 'Ana.Silva@example.com' })).toBe('ana.silva'); expect(osUserNameFor({ username: ' ', email: 'Ana.Silva@example.com' })).toBe('ana.silva'); }); test('stays within the 32-character limit useradd enforces', () => { expect(osUserNameFor({ username: 'a'.repeat(40), email: 'a@b.com' })).toHaveLength(32); }); // No longer defended by a prefix, so it must be defended by adoption rules instead: `ensureOsUser` // refuses a name whose existing passwd home is not the one we are about to confine, and refuses any uid // below 1000 outright. This test records that the NAME itself is no longer the protection. test('does not neutralise a dangerous name — that is ensureOsUser-s job now', () => { expect(osUserNameFor({ username: 'root', email: 'r@b.com' })).toBe('root'); }); }); describe('findReadableSecrets', () => { test('reports a group- or world-readable .env, and nothing when it is 600', async () => { const dir = await mkdtemp(join(tmpdir(), 'officer-secrets-')); const env = join(dir, '.env'); await writeFile(env, 'JWT_SECRET=x\n'); await chmod(env, 0o644); expect(await findReadableSecrets(dir)).toEqual([env]); // Group-only still counts: a member's supplementary groups are not ours to predict. await chmod(env, 0o640); expect(await findReadableSecrets(dir)).toEqual([env]); await chmod(env, 0o600); expect(await findReadableSecrets(dir)).toEqual([]); }); test('an absent .env is not a finding', async () => { const dir = await mkdtemp(join(tmpdir(), 'officer-secrets-')); expect(await findReadableSecrets(dir)).toEqual([]); }); });