fix: harden release artifact checks and fixture isolation
This commit is contained in:
@@ -1,16 +1,38 @@
|
||||
import { createServer } from 'node:net';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const port = Number(process.env.SMOKE_WEB_PORT ?? 15173);
|
||||
async function freePort(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const probe = createServer();
|
||||
probe.once('error', reject);
|
||||
probe.listen(0, '127.0.0.1', () => {
|
||||
const address = probe.address();
|
||||
const port = typeof address === 'object' && address ? address.port : 0;
|
||||
probe.close((error) => error ? reject(error) : resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const configuredPort = process.env.SMOKE_WEB_PORT;
|
||||
const port = configuredPort === undefined ? await freePort() : Number(configuredPort);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65_535) throw new Error('SMOKE_WEB_PORT must be a valid TCP port');
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
const root = await mkdtemp(join(tmpdir(), 'eden-fixture-'));
|
||||
let server: Bun.Subprocess | undefined;
|
||||
// Never pass ambient vault/session/provider variables into repository-controlled
|
||||
// fixture processes. The fixture only needs a minimal, non-secret environment.
|
||||
const safeEnv = {
|
||||
PATH: process.env.PATH ?? '/usr/bin:/bin',
|
||||
HOME: process.env.HOME ?? '/tmp',
|
||||
TMPDIR: process.env.TMPDIR ?? '/tmp',
|
||||
};
|
||||
|
||||
async function compileFixture(directory: string): Promise<void> {
|
||||
const child = Bun.spawn(['bun', 'workers/reference.ts', '--fixture', '--directory', directory], {
|
||||
stdout: 'pipe', stderr: 'pipe', stdin: 'ignore',
|
||||
env: { ...process.env, APP_MODE: 'demo', REFERENCE_DIR: directory },
|
||||
env: { ...safeEnv, APP_MODE: 'demo', REFERENCE_DIR: directory },
|
||||
});
|
||||
const code = await child.exited;
|
||||
if (code !== 0) throw new Error('Fixture reference compilation failed');
|
||||
@@ -21,7 +43,7 @@ async function waitForServer(): Promise<void> {
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(base);
|
||||
if (response.status === 200) return;
|
||||
if (response.status === 200 && response.headers.get('x-application-release') === 'fixture') return;
|
||||
} catch { /* startup race */ }
|
||||
await Bun.sleep(250);
|
||||
}
|
||||
@@ -33,10 +55,11 @@ try {
|
||||
server = Bun.spawn(['bun', 'build/index.js'], {
|
||||
stdout: 'pipe', stderr: 'pipe', stdin: 'ignore',
|
||||
env: {
|
||||
...process.env,
|
||||
...safeEnv,
|
||||
APP_MODE: 'demo', APP_ORIGIN: base, ORIGIN: base,
|
||||
HOST: '127.0.0.1', PORT: String(port), REFERENCE_DIR: root,
|
||||
APP_RELEASE_ID: 'fixture',
|
||||
APP_CAPABILITY_ALLOWLIST: 'asset_search,asset_valuation,asset_sync,assistant_dispatch,session_token,identity,subscriptions',
|
||||
},
|
||||
});
|
||||
await waitForServer();
|
||||
|
||||
@@ -1,27 +1,46 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { parseCapabilityAllowlist, releaseIdIsImmutable } from '../src/lib/server/release-gate';
|
||||
|
||||
const RELEASE_ENVIRONMENTS = new Set(['fixture', 'development', 'staging', 'production']);
|
||||
const DIGEST_IMAGE = /@sha256:[a-f0-9]{64}$/i;
|
||||
|
||||
type ReleaseCheckInput = {
|
||||
mode: string;
|
||||
releaseId: string;
|
||||
releaseEnvironment: string;
|
||||
dashboardImage: string;
|
||||
convexImage: string;
|
||||
dockerfile: string;
|
||||
capabilityAllowlist: string | undefined;
|
||||
trackedFiles: readonly string[];
|
||||
browserBundleFiles?: readonly string[];
|
||||
};
|
||||
|
||||
export function releaseSafetyErrors(input: ReleaseCheckInput): string[] {
|
||||
const errors: string[] = [];
|
||||
try { parseCapabilityAllowlist(input.capabilityAllowlist); }
|
||||
catch (error) { errors.push(error instanceof Error ? error.message : 'Invalid capability allowlist'); }
|
||||
if (!RELEASE_ENVIRONMENTS.has(input.releaseEnvironment)) {
|
||||
errors.push('RELEASE_ENV must be explicitly set to fixture, development, staging, or production');
|
||||
}
|
||||
|
||||
const strictEnvironment = input.releaseEnvironment === 'production' || input.releaseEnvironment === 'staging';
|
||||
if (!releaseIdIsImmutable(input.releaseId, strictEnvironment ? 'production' : 'development')) {
|
||||
errors.push('APP_RELEASE_ID must be a non-placeholder immutable identifier for this release environment');
|
||||
}
|
||||
if (strictEnvironment && !input.dashboardImage.includes('@sha256:')) {
|
||||
if (strictEnvironment && !DIGEST_IMAGE.test(input.dashboardImage)) {
|
||||
errors.push('CONVEX_DASHBOARD_IMAGE must be digest-pinned for staging/production');
|
||||
}
|
||||
if (strictEnvironment && !DIGEST_IMAGE.test(input.convexImage)) {
|
||||
errors.push('CONVEX_IMAGE must be digest-pinned for staging/production');
|
||||
}
|
||||
if (strictEnvironment) {
|
||||
const baseImages = [...input.dockerfile.matchAll(/^FROM\s+([^\s]+)(?:\s+AS\s+[^\s]+)?$/gim)].map((match) => match[1]);
|
||||
if (baseImages.length === 0 || baseImages.some((image) => !DIGEST_IMAGE.test(image))) {
|
||||
errors.push('Dockerfile base images must be digest-pinned for staging/production');
|
||||
}
|
||||
}
|
||||
if (input.mode === 'demo' && strictEnvironment) {
|
||||
errors.push('APP_MODE=demo is not permitted for a staging/production release');
|
||||
}
|
||||
@@ -30,6 +49,9 @@ export function releaseSafetyErrors(input: ReleaseCheckInput): string[] {
|
||||
errors.push(`secret-like file is tracked: ${file}`);
|
||||
}
|
||||
}
|
||||
if ((input.browserBundleFiles ?? []).length > 0) {
|
||||
errors.push('browser client artifacts contain managed secret names');
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
@@ -39,15 +61,36 @@ function trackedFiles(): string[] {
|
||||
return result.stdout.split('\n').filter(Boolean);
|
||||
}
|
||||
|
||||
const MANAGED_SECRET_NAMES = ['TOKEN_ENCRYPTION_KEY', 'CONVEX_BRIDGE_SECRET', 'APP_AUTH_PRIVATE_KEY', 'TYPESAFE_API_KEY', 'refresh_token'];
|
||||
function browserBundleFiles(directory = 'build/client'): string[] {
|
||||
try { statSync(directory); } catch { return []; }
|
||||
const files: string[] = [];
|
||||
const visit = (path: string) => {
|
||||
for (const entry of readdirSync(path, { withFileTypes: true })) {
|
||||
const child = `${path}/${entry.name}`;
|
||||
if (entry.isDirectory()) visit(child);
|
||||
else if (entry.isFile()) {
|
||||
const source = readFileSync(child, 'utf8');
|
||||
if (MANAGED_SECRET_NAMES.some((name) => source.includes(name))) files.push(child);
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(directory);
|
||||
return files;
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const releaseEnvironment = process.env.RELEASE_ENV ?? (process.env.APP_MODE === 'demo' ? 'fixture' : 'development');
|
||||
const releaseEnvironment = process.env.RELEASE_ENV ?? (process.env.APP_MODE === 'demo' ? 'fixture' : '');
|
||||
const errors = releaseSafetyErrors({
|
||||
mode: process.env.APP_MODE ?? 'live',
|
||||
releaseId: process.env.APP_RELEASE_ID ?? 'local',
|
||||
releaseEnvironment,
|
||||
dashboardImage: process.env.CONVEX_DASHBOARD_IMAGE ?? '',
|
||||
convexImage: process.env.CONVEX_IMAGE ?? '',
|
||||
dockerfile: readFileSync(new URL('../Dockerfile', import.meta.url), 'utf8'),
|
||||
capabilityAllowlist: process.env.APP_CAPABILITY_ALLOWLIST,
|
||||
trackedFiles: trackedFiles(),
|
||||
browserBundleFiles: browserBundleFiles(),
|
||||
});
|
||||
if (errors.length) {
|
||||
console.error(`Release safety check failed (${errors.length} issue${errors.length === 1 ? '' : 's'})`);
|
||||
|
||||
Reference in New Issue
Block a user