Two changes, and the second is what makes the first safe. The officer_ prefix is gone: a member's account is the username the owner typed, so whoami says who they are and a commit from their checkout is attributed to something recognisable. Measured first — useradd on this host accepts everything validateUsername permits, including dots, hyphens, underscores and uppercase. The prefix was also load-bearing, though, and not for looks. ensureOsUser REUSES an existing account, which is what makes it re-runnable, and that was safe by construction while only we created officer_* names. Unprefixed, adoption becomes the dangerous path: a platform account named root would have found root in passwd, and every runAs for that member would have been a root shell. So adoption now requires the existing account's passwd home to be exactly the home we are about to confine — that is what makes it ours — and any uid below 1000 is refused outright. Verified: root and daemon refused as system accounts, and the owner's own username refused by name with its real home quoted back. Also, the ancestor trap from the first real install. A member's home is under DATA_PATH, which is under the OWNER'S home, and /home/<owner> is 750 on Debian and Ubuntu — so every mode bit on the account tree was right, the directory existed, and the member still could not reach it for want of x four levels up. It surfaced as "ssh-keygen: Could not stat …/.ssh: Permission denied", which points at the wrong thing entirely. firstUntraversableAncestor now walks the chain as the member before anything uses the home, and the error names the directory and the chmod. The dev machine was already 751, and the probe used /tmp, so it never crossed the ancestor that mattered. Worth remembering as a shape of mistake. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
170 lines
8.1 KiB
TypeScript
170 lines
8.1 KiB
TypeScript
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([]);
|
|
});
|
|
});
|