401 lines
12 KiB
TypeScript
401 lines
12 KiB
TypeScript
import { spawnSync } from 'node:child_process';
|
|
import { existsSync } from 'node:fs';
|
|
import {
|
|
copyFile,
|
|
mkdir,
|
|
mkdtemp,
|
|
readFile,
|
|
rm,
|
|
writeFile,
|
|
} from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join, resolve } from 'node:path';
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
|
|
const repositoryRoot = resolve(import.meta.dirname, '..');
|
|
const schedulerToken = 'scheduler-token-with-at-least-32-characters';
|
|
|
|
const infisicalStub = `#!/bin/sh
|
|
set -eu
|
|
|
|
case "\${1:-}" in
|
|
login)
|
|
printf '%s\n' "$@" > "$STUB_LOGIN_LOG"
|
|
if [ "\${INFISICAL_UNIVERSAL_AUTH_CLIENT_ID:-}" != "$STUB_EXPECTED_CLIENT_ID" ] || \
|
|
[ "\${INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET:-}" != "$STUB_EXPECTED_CLIENT_SECRET" ]; then
|
|
printf '%s\n' 'credentials=incorrect' >> "$STUB_LOGIN_LOG"
|
|
exit 26
|
|
fi
|
|
printf '%s\n' 'credentials=ok' >> "$STUB_LOGIN_LOG"
|
|
case "\${STUB_LOGIN_RESULT:-success}" in
|
|
fail) exit 23 ;;
|
|
empty) exit 0 ;;
|
|
*) printf '%s\n' stub-access-token ;;
|
|
esac
|
|
;;
|
|
export)
|
|
[ "\${INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN:-}" = stub-access-token ] || exit 27
|
|
secret_path=
|
|
output_file=
|
|
for argument in "$@"; do
|
|
case "$argument" in
|
|
--path=*) secret_path=\${argument#--path=} ;;
|
|
--output-file=*) output_file=\${argument#--output-file=} ;;
|
|
esac
|
|
done
|
|
[ -n "$secret_path" ] && [ -n "$output_file" ] || exit 28
|
|
printf '%s|%s\n' "$secret_path" "$output_file" >> "$STUB_EXPORT_LOG"
|
|
case "$secret_path" in
|
|
/app) cp "$STUB_APP_EXPORT" "$output_file" ;;
|
|
/shared/scheduler) cp "$STUB_SHARED_EXPORT" "$output_file" ;;
|
|
*) exit 29 ;;
|
|
esac
|
|
;;
|
|
*) exit 25 ;;
|
|
esac
|
|
`;
|
|
|
|
const environmentCapture = `#!/bin/sh
|
|
env > "$CAPTURE_ENV_PATH"
|
|
`;
|
|
|
|
const gosuStub = `#!/bin/sh
|
|
set -eu
|
|
[ "\${1:-}" = shd-runtime ] || exit 30
|
|
printf '%s\n' 'target=shd-runtime' > "$STUB_GOSU_LOG"
|
|
shift
|
|
exec "$@"
|
|
`;
|
|
|
|
const idStub = `#!/bin/sh
|
|
if [ "\${1:-}" = -u ] && [ "\${2:-}" = shd-runtime ]; then
|
|
printf '%s\n' 10001
|
|
exit 0
|
|
fi
|
|
exec /usr/bin/id "$@"
|
|
`;
|
|
|
|
const ownershipStub = `#!/bin/sh
|
|
exit 0
|
|
`;
|
|
|
|
describe('container runtime startup', () => {
|
|
let temporaryRoot: string;
|
|
let clientIdFile: string;
|
|
let clientSecretFile: string;
|
|
let loginLog: string;
|
|
let exportLog: string;
|
|
let gosuLog: string;
|
|
let capturedEnvironment: string;
|
|
let appExport: string;
|
|
let sharedExport: string;
|
|
|
|
beforeEach(async () => {
|
|
temporaryRoot = await mkdtemp(join(tmpdir(), 'shd-runtime-start-'));
|
|
const deployDirectory = join(temporaryRoot, 'deploy');
|
|
const scriptsDirectory = join(temporaryRoot, 'scripts');
|
|
await mkdir(deployDirectory);
|
|
await mkdir(scriptsDirectory);
|
|
await Promise.all([
|
|
copyFile(
|
|
join(repositoryRoot, 'deploy/container-entrypoint.sh'),
|
|
join(deployDirectory, 'container-entrypoint.sh'),
|
|
),
|
|
copyFile(
|
|
join(repositoryRoot, 'scripts/runtime-launcher.mjs'),
|
|
join(scriptsDirectory, 'runtime-launcher.mjs'),
|
|
),
|
|
copyFile(
|
|
join(repositoryRoot, 'scripts/runtime-environment.js'),
|
|
join(scriptsDirectory, 'runtime-environment.js'),
|
|
),
|
|
]);
|
|
|
|
clientIdFile = join(temporaryRoot, 'client-id');
|
|
clientSecretFile = join(temporaryRoot, 'client-secret');
|
|
loginLog = join(temporaryRoot, 'login.log');
|
|
exportLog = join(temporaryRoot, 'export.log');
|
|
gosuLog = join(temporaryRoot, 'gosu.log');
|
|
capturedEnvironment = join(temporaryRoot, 'child.env');
|
|
appExport = join(temporaryRoot, 'app-export.json');
|
|
sharedExport = join(temporaryRoot, 'shared-export.json');
|
|
|
|
await Promise.all([
|
|
writeFile(clientIdFile, 'universal-client-id\n', { mode: 0o600 }),
|
|
writeFile(clientSecretFile, 'universal-client-secret\n', { mode: 0o600 }),
|
|
writeFile(join(temporaryRoot, 'infisical'), infisicalStub, {
|
|
mode: 0o755,
|
|
}),
|
|
writeFile(
|
|
join(temporaryRoot, 'capture-environment'),
|
|
environmentCapture,
|
|
{
|
|
mode: 0o755,
|
|
},
|
|
),
|
|
writeFile(join(temporaryRoot, 'gosu'), gosuStub, { mode: 0o755 }),
|
|
writeFile(join(temporaryRoot, 'id'), idStub, { mode: 0o755 }),
|
|
writeFile(join(temporaryRoot, 'chgrp'), ownershipStub, { mode: 0o755 }),
|
|
writeFile(join(temporaryRoot, 'chmod'), ownershipStub, { mode: 0o755 }),
|
|
writeFile(join(temporaryRoot, 'chown'), ownershipStub, { mode: 0o755 }),
|
|
writeExport(appExport, [
|
|
{ key: 'APP_ORIGIN', value: 'https://exported.invalid' },
|
|
{ key: 'DISCORD_CLIENT_ID', value: 'exported-discord-client' },
|
|
{ key: 'DISCORD_CLIENT_SECRET', value: 'exported-discord-secret' },
|
|
{
|
|
key: 'MFA_TOTP_ENCRYPTION_KEYS',
|
|
value: 'totp-encryption-keyring',
|
|
},
|
|
]),
|
|
writeExport(sharedExport, [
|
|
{ key: 'SCHEDULER_TOKEN', value: schedulerToken },
|
|
]),
|
|
]);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await rm(temporaryRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
function runtimeEnvironment(
|
|
scope: 'app' | 'scheduler',
|
|
): Record<string, string> {
|
|
return {
|
|
PATH: `${temporaryRoot}:${process.env.PATH ?? ''}`,
|
|
INFISICAL_PROJECT_ID: 'project-id',
|
|
INFISICAL_ENVIRONMENT: 'staging',
|
|
INFISICAL_RUNTIME_SCOPE: scope,
|
|
INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE: clientIdFile,
|
|
INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_FILE: clientSecretFile,
|
|
INFISICAL_UNIVERSAL_AUTH_CLIENT_ID: 'bootstrap-client-id',
|
|
INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET: 'bootstrap-client-secret',
|
|
INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN: 'bootstrap-access-token',
|
|
INFISICAL_TOKEN: 'bootstrap-token',
|
|
TOKEN: 'generic-bootstrap-token',
|
|
STUB_EXPECTED_CLIENT_ID: 'universal-client-id',
|
|
STUB_EXPECTED_CLIENT_SECRET: 'universal-client-secret',
|
|
STUB_LOGIN_LOG: loginLog,
|
|
STUB_EXPORT_LOG: exportLog,
|
|
STUB_GOSU_LOG: gosuLog,
|
|
STUB_APP_EXPORT: appExport,
|
|
STUB_SHARED_EXPORT: sharedExport,
|
|
CAPTURE_ENV_PATH: capturedEnvironment,
|
|
SHD_BUILD_NUMBER: '42',
|
|
SHD_COMMIT_SHA: 'abc123',
|
|
APP_ORIGIN: 'https://parent.invalid',
|
|
SCHEDULER_URL: 'https://parent.invalid/scheduler',
|
|
BASH_ENV: '',
|
|
CDPATH: temporaryRoot,
|
|
ENV: '',
|
|
IFS: 'hostile-ifs',
|
|
LD_LIBRARY_PATH: temporaryRoot,
|
|
LD_PRELOAD: '',
|
|
NODE_OPTIONS: '--no-warnings',
|
|
NODE_PATH: temporaryRoot,
|
|
PYTHONPATH: temporaryRoot,
|
|
};
|
|
}
|
|
|
|
function start(environment: Record<string, string>) {
|
|
return spawnSync(
|
|
'/bin/sh',
|
|
['./deploy/container-entrypoint.sh', 'capture-environment'],
|
|
{
|
|
cwd: temporaryRoot,
|
|
env: environment,
|
|
encoding: 'utf8',
|
|
},
|
|
);
|
|
}
|
|
|
|
it('exports app and shared secrets into a scrubbed app child environment', async () => {
|
|
const result = start(runtimeEnvironment('app'));
|
|
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(await exportPaths()).toEqual(['/app', '/shared/scheduler']);
|
|
expect(await readFile(loginLog, 'utf8')).toBe(
|
|
'login\n--method=universal-auth\n--silent\n--plain\ncredentials=ok\n',
|
|
);
|
|
|
|
const childEnvironment = parseEnvironment(
|
|
await readFile(capturedEnvironment, 'utf8'),
|
|
);
|
|
expect(childEnvironment).toMatchObject({
|
|
APP_ORIGIN: 'https://stage.shd.build',
|
|
DISCORD_CLIENT_ID: 'exported-discord-client',
|
|
DISCORD_CLIENT_SECRET: 'exported-discord-secret',
|
|
MFA_TOTP_ENCRYPTION_KEYS: 'totp-encryption-keyring',
|
|
ORIGIN: 'https://stage.shd.build',
|
|
PASSKEY_RP_ID: 'shd.build',
|
|
SCHEDULER_TOKEN: schedulerToken,
|
|
SHD_BUILD_NUMBER: '42',
|
|
SHD_COMMIT_SHA: 'abc123',
|
|
TDR_AVATAR_STORAGE_PATH: '/app/data/avatars',
|
|
TDR_DATABASE_PATH: '/app/data/shd-build-staging.db',
|
|
TDR_GAME_ICON_STORAGE_PATH: '/app/data/game-icons',
|
|
});
|
|
for (const name of removedEnvironmentNames) {
|
|
expect(childEnvironment).not.toHaveProperty(name);
|
|
}
|
|
await expectSecretFilesRemoved();
|
|
await expectRootPrivilegeDrop();
|
|
});
|
|
|
|
it('exports only shared secrets into the scheduler child environment', async () => {
|
|
const result = start(runtimeEnvironment('scheduler'));
|
|
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(await exportPaths()).toEqual(['/shared/scheduler']);
|
|
expect(
|
|
parseEnvironment(await readFile(capturedEnvironment, 'utf8')),
|
|
).toMatchObject({
|
|
SCHEDULER_TOKEN: schedulerToken,
|
|
SCHEDULER_URL: 'http://app:3000/api/internal/scheduler',
|
|
});
|
|
await expectSecretFilesRemoved();
|
|
await expectRootPrivilegeDrop();
|
|
});
|
|
|
|
it('fails closed on an unknown secret before launching the child', async () => {
|
|
await writeExport(appExport, [
|
|
{ key: 'DISCORD_CLIENT_ID', value: 'exported-discord-client' },
|
|
{ key: 'UNKNOWN_SECRET', value: 'must-not-reach-child' },
|
|
]);
|
|
|
|
const result = start(runtimeEnvironment('app'));
|
|
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.stderr).toContain(
|
|
'Infisical secret UNKNOWN_SECRET is not allowed in this runtime scope.',
|
|
);
|
|
expect(existsSync(capturedEnvironment)).toBe(false);
|
|
await expectSecretFilesRemoved();
|
|
});
|
|
|
|
it('fails closed when a credential file setting is missing', () => {
|
|
const environment = runtimeEnvironment('app');
|
|
delete environment.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE;
|
|
|
|
const result = start(environment);
|
|
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.stderr).toContain(
|
|
'INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE is required',
|
|
);
|
|
expect(existsSync(exportLog)).toBe(false);
|
|
expect(existsSync(capturedEnvironment)).toBe(false);
|
|
});
|
|
|
|
it('fails closed when either credential is empty', async () => {
|
|
for (const credentialFile of [clientIdFile, clientSecretFile]) {
|
|
await writeFile(credentialFile, '');
|
|
|
|
const result = start(runtimeEnvironment('app'));
|
|
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.stderr).toContain('credential files must not be empty');
|
|
expect(existsSync(exportLog)).toBe(false);
|
|
expect(existsSync(capturedEnvironment)).toBe(false);
|
|
await writeFile(
|
|
credentialFile,
|
|
credentialFile === clientIdFile
|
|
? 'universal-client-id\n'
|
|
: 'universal-client-secret\n',
|
|
);
|
|
}
|
|
});
|
|
|
|
it.each(['fail', 'empty'])(
|
|
'fails closed when login returns %s',
|
|
(loginResult) => {
|
|
const result = start({
|
|
...runtimeEnvironment('app'),
|
|
STUB_LOGIN_RESULT: loginResult,
|
|
});
|
|
|
|
expect(result.status).not.toBe(0);
|
|
expect(existsSync(exportLog)).toBe(false);
|
|
expect(existsSync(capturedEnvironment)).toBe(false);
|
|
},
|
|
);
|
|
|
|
it('fails closed on a malformed export and removes every secret file', async () => {
|
|
await writeFile(appExport, '{not-json');
|
|
|
|
const result = start(runtimeEnvironment('app'));
|
|
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.stderr).toContain(
|
|
'Infisical returned an unreadable secret export.',
|
|
);
|
|
expect(existsSync(capturedEnvironment)).toBe(false);
|
|
await expectSecretFilesRemoved();
|
|
});
|
|
|
|
async function exportPaths() {
|
|
return (await exportRecords()).map(([path]) => path);
|
|
}
|
|
|
|
async function expectSecretFilesRemoved() {
|
|
for (const [, file] of await exportRecords()) {
|
|
expect(existsSync(file)).toBe(false);
|
|
}
|
|
}
|
|
|
|
async function exportRecords() {
|
|
return (await readFile(exportLog, 'utf8'))
|
|
.trim()
|
|
.split('\n')
|
|
.map((line) => line.split('|'));
|
|
}
|
|
|
|
async function expectRootPrivilegeDrop() {
|
|
if (typeof process.getuid === 'function' && process.getuid() === 0) {
|
|
expect(await readFile(gosuLog, 'utf8')).toBe('target=shd-runtime\n');
|
|
} else {
|
|
expect(existsSync(gosuLog)).toBe(false);
|
|
}
|
|
}
|
|
});
|
|
|
|
const removedEnvironmentNames = [
|
|
'BASH_ENV',
|
|
'CDPATH',
|
|
'ENV',
|
|
'IFS',
|
|
'INFISICAL_ENVIRONMENT',
|
|
'INFISICAL_PROJECT_ID',
|
|
'INFISICAL_RUNTIME_SCOPE',
|
|
'INFISICAL_TOKEN',
|
|
'INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN',
|
|
'INFISICAL_UNIVERSAL_AUTH_CLIENT_ID',
|
|
'INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE',
|
|
'INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET',
|
|
'INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_FILE',
|
|
'LD_LIBRARY_PATH',
|
|
'LD_PRELOAD',
|
|
'NODE_OPTIONS',
|
|
'NODE_PATH',
|
|
'PYTHONPATH',
|
|
'TOKEN',
|
|
];
|
|
|
|
function writeExport(
|
|
file: string,
|
|
entries: Array<{ key: string; value: string }>,
|
|
) {
|
|
return writeFile(file, `${JSON.stringify(entries)}\n`);
|
|
}
|
|
|
|
function parseEnvironment(value: string) {
|
|
return Object.fromEntries(
|
|
value
|
|
.trim()
|
|
.split('\n')
|
|
.map((line) => {
|
|
const separator = line.indexOf('=');
|
|
return [line.slice(0, separator), line.slice(separator + 1)];
|
|
}),
|
|
);
|
|
}
|