mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* 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>
189 lines
5.9 KiB
TypeScript
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;
|
|
});
|