Files
gstack/lib/cso/verifier.ts
Garry Tanandcapy-ai[bot] 4c6fa89cdc v1.91.159.0 security: gstack's Bun entry points ignore the audited repo's .env and bunfig.toml (S1) (#3312)
* security: isolate every gstack Bun entry point from the audited checkout's .env and bunfig.toml

Bun auto-loads .env, .env.local and .env.<NODE_ENV> from its cwd and runs a
cwd bunfig.toml preload before the program starts. gstack runs inside the repo
it audits, so a committed .env could steer state roots, the claude binary or
spend caps, and a bunfig preload ran arbitrary code in every bin and hook.

Every bun shebang, shell and skill caller, TypeScript spawn and compiled build
now disables both (--no-env-file --config=/dev/null, bunIsolationArgs(),
--no-compile-autoload-dotenv/bunfig), with a ratchet and hostile-fixture
regressions.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: name the directly executed scripts in the isolation ratchet

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: follow the isolation flags in touchfiles, copy lists, recipe assertions and the harness pin

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: keep browse/src/bun-child-config.ts unchanged so the native cookie qualification still binds

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(windows): the empty bunfig outlives its process so the timeline hook's detached repair child can read it

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* v1.91.159.0 security: gstack's Bun entry points ignore the audited repo's .env and bunfig.toml (S1)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: plan-mode render pattern accepts the isolation flags

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-20 00:24:28 +00:00

189 lines
5.9 KiB
TypeScript

#!/usr/bin/env -S bun --no-env-file --config=/dev/null
import * as fs from 'node:fs';
import { connect } from 'node:net';
import {
HttpAssertion,
MAX_SECURITY_ASSERTIONS,
VerificationObservation,
VerificationRequest,
object,
securityAssertions,
} from './contracts';
interface Config {
phase: 'before' | 'after';
port: number;
legitimate: HttpAssertion[];
security: VerificationRequest['security'];
}
function matches(status: number, body: string, oracle: HttpAssertion['expected']): boolean {
return (
(Array.isArray(oracle.status) ? oracle.status.includes(status) : status === oracle.status) &&
(oracle.includes === undefined || body.includes(oracle.includes)) &&
(oracle.excludes === undefined || !body.includes(oracle.excludes))
);
}
export async function boundedResponseBody(response: Response, limit = 65536): Promise<string> {
if (!Number.isSafeInteger(limit) || limit < 1) throw new Error('invalid response limit');
const declared = response.headers.get('content-length');
if (declared !== null && (/^\d+$/.test(declared) ? Number(declared) > limit : true)) {
await response.body?.cancel();
throw new Error('response too large');
}
if (!response.body) return '';
const reader = response.body.getReader(),
chunks: Uint8Array[] = [];
let total = 0;
try {
for (;;) {
const next = await reader.read();
if (next.done) break;
if (!next.value) continue;
total += next.value.byteLength;
if (total > limit) {
await reader.cancel();
throw new Error('response too large');
}
chunks.push(next.value);
}
} finally {
reader.releaseLock();
}
const bytes = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return new TextDecoder().decode(bytes);
}
async function request(a: HttpAssertion, port: number): Promise<{ status: number; body: string }> {
const controller = new AbortController(),
timer = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch(`http://127.0.0.1:${port}${a.path}`, {
method: a.method,
headers: a.headers,
body: ['GET'].includes(a.method) ? undefined : a.body,
redirect: 'manual',
signal: controller.signal,
});
return { status: response.status, body: await boundedResponseBody(response) };
} finally {
clearTimeout(timer);
}
}
export async function judgeSecurity(
phase: Config['phase'],
security: Config['security'],
port: number,
): Promise<{
security: VerificationObservation['security'];
members: VerificationObservation['security'][];
}> {
const members: VerificationObservation['security'][] = [];
for (const assertion of securityAssertions(security)) {
const r = await request(assertion, port),
fixed = matches(r.status, r.body, assertion.expected),
vulnerable = matches(r.status, r.body, assertion.vulnerable!);
members.push(
phase === 'before'
? vulnerable && !fixed
? 'intended_failure'
: fixed && !vulnerable
? 'pass'
: 'inconclusive'
: fixed && !vulnerable
? 'pass'
: 'inconclusive',
);
}
const agreed = members.every((member) => member === members[0]) ? members[0] : 'inconclusive';
return { security: agreed, members };
}
async function ready(port: number): Promise<boolean> {
return await new Promise((resolve) => {
const socket = connect({ host: '127.0.0.1', port }),
done = (value: boolean) => {
socket.removeAllListeners();
socket.destroy();
resolve(value);
},
timer = setTimeout(() => done(false), 500);
socket.once('connect', () => {
clearTimeout(timer);
done(true);
});
socket.once('error', () => {
clearTimeout(timer);
done(false);
});
});
}
async function main() {
const file = process.argv[2];
if (!file || !file.startsWith('/policy/')) throw new Error('trusted policy path required');
const raw = fs.readFileSync(file, 'utf8');
if (Buffer.byteLength(raw) > 1024 * 1024) throw new Error('policy too large');
const v = object(JSON.parse(raw), 'verifier policy') as any;
if (
!['before', 'after'].includes(v.phase) ||
!Number.isInteger(v.port) ||
v.port < 1024 ||
v.port > 65535 ||
!Array.isArray(v.legitimate) ||
!v.security ||
(Array.isArray(v.security) && (!v.security.length || v.security.length > MAX_SECURITY_ASSERTIONS))
)
throw new Error('invalid verifier policy');
const config = v as Config;
let booted = false;
for (let attempt = 0; attempt < 60; attempt++) {
if (await ready(config.port)) {
booted = true;
break;
}
await Bun.sleep(250);
}
let legitimate = false,
security: VerificationObservation['security'] = 'inconclusive',
summary = 'application did not answer a legitimate control';
if (booted) {
try {
legitimate = (
await Promise.all(
config.legitimate.map(async (a) => {
const r = await request(a, config.port);
return matches(r.status, r.body, a.expected);
}),
)
).every(Boolean);
const judged = await judgeSecurity(config.phase, config.security, config.port);
security = judged.security;
summary = `boot=true legitimate=${legitimate} security=${security}${
Array.isArray(config.security) ? ` members=${judged.members.join(',')}` : ''
}`;
} catch {
summary = 'bounded verifier request failed';
}
}
process.stdout.write(
JSON.stringify({ booted, legitimate, security, existingTests: false, output: summary, inputHash: '' }) +
'\n',
);
}
if (import.meta.main)
main().catch(() => {
process.stdout.write(
JSON.stringify({
booted: false,
legitimate: false,
security: 'inconclusive',
existingTests: false,
output: 'verifier setup failed',
inputHash: '',
}) + '\n',
);
process.exitCode = 1;
});