fix(runtime): harden smoke and credential boundaries
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const ecosystem = require('../runtime/ecosystem.config.cjs');
|
||||
|
||||
test('defines exactly four unique, bounded Gateway processes', () => {
|
||||
assert.deepEqual(
|
||||
ecosystem.apps.map((app) => app.name),
|
||||
['sammo:gateway-api', 'sammo:gateway-frontend', 'sammo:gateway-orchestrator', 'sammo:release-controller'],
|
||||
);
|
||||
assert.equal(new Set(ecosystem.apps.map((app) => app.name)).size, 4);
|
||||
for (const app of ecosystem.apps) {
|
||||
assert.equal(app.autorestart, true);
|
||||
assert.equal(app.max_restarts, 5);
|
||||
assert.equal(app.min_uptime, 10_000);
|
||||
assert.equal(app.restart_delay, 2_000);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFile } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
test('Git askpass reads HTTPS credentials from protected files instead of process env', async () => {
|
||||
const authDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-git-auth-'));
|
||||
try {
|
||||
await fs.writeFile(path.join(authDir, 'https-username'), 'deploy-user', { mode: 0o600 });
|
||||
await fs.writeFile(path.join(authDir, 'https-token'), 'deploy-token', { mode: 0o600 });
|
||||
|
||||
const username = await execFileAsync('./runtime/git-askpass.sh', ['Username'], {
|
||||
env: { SAMMO_GIT_AUTH_DIR: authDir },
|
||||
});
|
||||
const token = await execFileAsync('./runtime/git-askpass.sh', ['Password'], {
|
||||
env: { SAMMO_GIT_AUTH_DIR: authDir },
|
||||
});
|
||||
|
||||
assert.equal(username.stdout, 'deploy-user');
|
||||
assert.equal(token.stdout, 'deploy-token');
|
||||
} finally {
|
||||
await fs.rm(authDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('runtime entrypoint removes raw repository secrets before launching PM2', async () => {
|
||||
const entrypoint = await fs.readFile(new URL('../runtime/entrypoint.sh', import.meta.url), 'utf8');
|
||||
const pm2Start = entrypoint.indexOf('"$pm2_bin" start');
|
||||
const httpsUnset = entrypoint.indexOf('unset CORE_REPOSITORY_USERNAME CORE_REPOSITORY_TOKEN');
|
||||
const sshUnset = entrypoint.indexOf('unset CORE_SSH_PRIVATE_KEY_BASE64 CORE_SSH_KNOWN_HOSTS_BASE64');
|
||||
|
||||
assert.ok(pm2Start > 0);
|
||||
assert.ok(httpsUnset > 0 && httpsUnset < pm2Start);
|
||||
assert.ok(sshUnset > 0 && sshUnset < pm2Start);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { validateComposeModel } from '../runtime/validate-compose-model.mjs';
|
||||
|
||||
const safeRuntime = {
|
||||
environment: { CORE_SOURCE_MODE: 'clone' },
|
||||
restart: 'unless-stopped',
|
||||
mem_limit: String(4 * 1024 * 1024 * 1024),
|
||||
memswap_limit: String(4 * 1024 * 1024 * 1024),
|
||||
cpus: 4,
|
||||
pids_limit: 256,
|
||||
};
|
||||
|
||||
test('accepts a bounded production runtime', () => {
|
||||
assert.deepEqual(validateComposeModel({ services: { runtime: safeRuntime } }, 'production'), []);
|
||||
});
|
||||
|
||||
test('accepts a literal, non-restarting development model', () => {
|
||||
const services = Object.fromEntries(['postgres', 'redis', 'caddy'].map((name) => [name, { restart: 'no' }]));
|
||||
services.runtime = {
|
||||
...safeRuntime,
|
||||
restart: 'no',
|
||||
environment: { CORE_SOURCE_MODE: 'bind', RUNTIME_MODE: 'development' },
|
||||
};
|
||||
assert.deepEqual(validateComposeModel({ services }, 'development'), []);
|
||||
});
|
||||
|
||||
test('accepts a bounded, non-restarting production smoke model', () => {
|
||||
const services = Object.fromEntries(['postgres', 'redis', 'caddy'].map((name) => [name, { restart: 'no' }]));
|
||||
services.runtime = { ...safeRuntime, restart: 'no' };
|
||||
assert.deepEqual(validateComposeModel({ services }, 'smoke'), []);
|
||||
});
|
||||
|
||||
test('rejects an unbounded or production-mode development runtime', () => {
|
||||
const services = Object.fromEntries(['postgres', 'redis', 'caddy'].map((name) => [name, { restart: 'unless-stopped' }]));
|
||||
services.runtime = {
|
||||
environment: { CORE_SOURCE_MODE: 'bind', RUNTIME_MODE: 'production' },
|
||||
restart: 'unless-stopped',
|
||||
mem_limit: 0,
|
||||
memswap_limit: 0,
|
||||
cpus: 0,
|
||||
pids_limit: 0,
|
||||
};
|
||||
const errors = validateComposeModel({ services }, 'development');
|
||||
assert.ok(errors.some((error) => error.includes('memory limit must be positive')));
|
||||
assert.ok(errors.some((error) => error.includes('literal development')));
|
||||
assert.ok(errors.some((error) => error.includes('restart policy must be no')));
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { validateEnvironment } from '../runtime/validate-env.mjs';
|
||||
|
||||
const validEnv = {
|
||||
DOMAIN: 'game.test.invalid',
|
||||
ACME_EMAIL: 'admin@test.invalid',
|
||||
CORE_REPOSITORY_URL: 'https://git.test.invalid/team/core2026.git',
|
||||
POSTGRES_PASSWORD: 'p'.repeat(24),
|
||||
REDIS_PASSWORD: 'r'.repeat(24),
|
||||
GAME_TOKEN_SECRET: 'g'.repeat(32),
|
||||
GATEWAY_BOOTSTRAP_TOKEN: 'b'.repeat(32),
|
||||
INITIAL_ADMIN_USERNAME: 'admin',
|
||||
INITIAL_ADMIN_PASSWORD: 'a'.repeat(16),
|
||||
KAKAO_REST_KEY: 'kakao-test-key',
|
||||
};
|
||||
|
||||
test('accepts a complete public repository environment', () => {
|
||||
assert.deepEqual(validateEnvironment(validEnv), []);
|
||||
});
|
||||
|
||||
test('accepts paired HTTPS credentials without embedding them in the URL', () => {
|
||||
assert.deepEqual(
|
||||
validateEnvironment({ ...validEnv, CORE_REPOSITORY_USERNAME: 'deploy', CORE_REPOSITORY_TOKEN: 'token' }),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('accepts a paired SSH deploy key and pinned host data', () => {
|
||||
assert.deepEqual(
|
||||
validateEnvironment({
|
||||
...validEnv,
|
||||
CORE_REPOSITORY_URL: 'ssh://git@git.test.invalid:2222/team/core2026.git',
|
||||
CORE_SSH_PRIVATE_KEY_BASE64: Buffer.from('-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n').toString('base64'),
|
||||
CORE_SSH_KNOWN_HOSTS_BASE64: Buffer.from('git.test.invalid ssh-ed25519 test\n').toString('base64'),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects placeholders, weak secrets, and partial credentials', () => {
|
||||
const errors = validateEnvironment({
|
||||
...validEnv,
|
||||
DOMAIN: 'game.example.com',
|
||||
POSTGRES_PASSWORD: 'short',
|
||||
CORE_REPOSITORY_USERNAME: 'deploy',
|
||||
});
|
||||
assert.ok(errors.some((error) => error.includes('DOMAIN still contains')));
|
||||
assert.ok(errors.some((error) => error.includes('POSTGRES_PASSWORD must contain')));
|
||||
assert.ok(errors.some((error) => error.includes('must be set together')));
|
||||
});
|
||||
|
||||
test('rejects credentials embedded in repository URLs and mixed auth modes', () => {
|
||||
const errors = validateEnvironment({
|
||||
...validEnv,
|
||||
CORE_REPOSITORY_URL: 'https://deploy:secret@git.test.invalid/team/core2026.git',
|
||||
CORE_REPOSITORY_USERNAME: 'deploy',
|
||||
CORE_REPOSITORY_TOKEN: 'token',
|
||||
CORE_SSH_PRIVATE_KEY_BASE64: 'key',
|
||||
CORE_SSH_KNOWN_HOSTS_BASE64: 'hosts',
|
||||
});
|
||||
assert.ok(errors.some((error) => error.includes('must not embed credentials')));
|
||||
assert.ok(errors.some((error) => error.includes('only one Core repository authentication mode')));
|
||||
});
|
||||
Reference in New Issue
Block a user