mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
Merge PR #2625: fix(security): prevent leaking admin bootstrap token to non-TTY logs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -90,6 +90,26 @@ export function resolveBootstrapToken(
|
||||
return { kind: 'ok', token: trimmed, fromEnv: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* #2624: decide whether the generated admin bootstrap token is hidden from
|
||||
* the startup banner. Fail-safe default: a generated token is NOT printed
|
||||
* unless stderr is an interactive TTY, so containerized (non-TTY) deploys
|
||||
* never ship the secret to centralized log storage. Env-sourced tokens are
|
||||
* always hidden (operator already holds them). Explicit --suppress hides
|
||||
* everything; --print-admin-token forces the raw value even on a non-TTY.
|
||||
*/
|
||||
export function shouldSuppressBootstrapPrint(opts: {
|
||||
suppress: boolean;
|
||||
fromEnv: boolean;
|
||||
forcePrint: boolean;
|
||||
isTty: boolean;
|
||||
}): boolean {
|
||||
if (opts.suppress) return true;
|
||||
if (opts.fromEnv) return true;
|
||||
if (opts.forcePrint) return false;
|
||||
return !opts.isTty;
|
||||
}
|
||||
|
||||
export type ProbeHealthResult =
|
||||
| { ok: true; status: 200; body: { status: 'ok'; version: string; engine: string; [k: string]: unknown } }
|
||||
| { ok: false; status: 503; body: { error: 'service_unavailable'; error_description: string } };
|
||||
@@ -304,6 +324,14 @@ interface ServeHttpOptions {
|
||||
* tracking the regenerated value through other means.
|
||||
*/
|
||||
suppressBootstrapToken?: boolean;
|
||||
/**
|
||||
* #2624: force-print the generated admin bootstrap token even on a
|
||||
* non-TTY (containerized) start. By default the raw token is only printed
|
||||
* when stderr is an interactive TTY, so it never lands in centralized log
|
||||
* storage for headless deploys. Set this when you genuinely need the value
|
||||
* captured to a non-interactive log and accept the leak.
|
||||
*/
|
||||
printAdminToken?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -530,7 +558,12 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
|
||||
let bootstrapToken: string = resolved.token;
|
||||
let bootstrapFromEnv: boolean = resolved.fromEnv;
|
||||
const bootstrapHash = createHash('sha256').update(bootstrapToken).digest('hex');
|
||||
const suppressBootstrapPrint = options.suppressBootstrapToken === true;
|
||||
const suppressBootstrapPrint = shouldSuppressBootstrapPrint({
|
||||
suppress: options.suppressBootstrapToken === true,
|
||||
fromEnv: bootstrapFromEnv,
|
||||
forcePrint: options.printAdminToken === true,
|
||||
isTty: process.stderr.isTTY === true,
|
||||
});
|
||||
const adminSessions = new Map<string, number>(); // sessionId → expiresAt
|
||||
|
||||
// SSE clients for live activity feed
|
||||
@@ -2166,10 +2199,10 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
|
||||
║ MCP: http://localhost:${port}/mcp${' '.repeat(Math.max(0, 21 - String(port).length))}║
|
||||
║ Health: http://localhost:${port}/health${' '.repeat(Math.max(0, 18 - String(port).length))}║
|
||||
╠══════════════════════════════════════════════════════╣
|
||||
${suppressBootstrapPrint
|
||||
? '║ Admin Token: suppressed (--suppress-bootstrap-token) ║\n╚══════════════════════════════════════════════════════╝'
|
||||
: bootstrapFromEnv
|
||||
? '║ Admin Token: from $GBRAIN_ADMIN_BOOTSTRAP_TOKEN ║\n╚══════════════════════════════════════════════════════╝'
|
||||
${bootstrapFromEnv
|
||||
? '║ Admin Token: from $GBRAIN_ADMIN_BOOTSTRAP_TOKEN ║\n╚══════════════════════════════════════════════════════╝'
|
||||
: suppressBootstrapPrint
|
||||
? '║ Admin Token: hidden (non-TTY log-leak guard) ║\n║ set $GBRAIN_ADMIN_BOOTSTRAP_TOKEN, or pass ║\n║ --print-admin-token on a trusted terminal. ║\n╚══════════════════════════════════════════════════════╝'
|
||||
: `║ Admin Token (paste into /admin login): ║\n║ ${bootstrapToken.substring(0, 50)} ║\n║ ${bootstrapToken.substring(50).padEnd(50)} ║\n╚══════════════════════════════════════════════════════╝`}
|
||||
`);
|
||||
});
|
||||
|
||||
@@ -118,8 +118,13 @@ export async function runServe(
|
||||
// restart.
|
||||
const suppressBootstrapToken = args.includes('--suppress-bootstrap-token');
|
||||
|
||||
// #2624: by default the generated token only prints on an interactive
|
||||
// TTY (never into container log storage). --print-admin-token forces the
|
||||
// raw value even on a non-TTY start.
|
||||
const printAdminToken = args.includes('--print-admin-token');
|
||||
|
||||
const { runServeHttp } = await import('./serve-http.ts');
|
||||
await runServeHttp(engine, { port, tokenTtl, enableDcr, enableDcrInsecure, publicUrl, logFullParams, bind, suppressBootstrapToken });
|
||||
await runServeHttp(engine, { port, tokenTtl, enableDcr, enableDcrInsecure, publicUrl, logFullParams, bind, suppressBootstrapToken, printAdminToken });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* the rule can't drift without the suite catching it.
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { resolveBootstrapToken } from '../src/commands/serve-http.ts';
|
||||
import { resolveBootstrapToken, shouldSuppressBootstrapPrint } from '../src/commands/serve-http.ts';
|
||||
|
||||
describe('resolveBootstrapToken (v0.36.1.x #1024)', () => {
|
||||
test('unset env → generates a fresh token via the injected RNG', () => {
|
||||
@@ -72,3 +72,27 @@ describe('resolveBootstrapToken (v0.36.1.x #1024)', () => {
|
||||
expect(r.kind).toBe('error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldSuppressBootstrapPrint (#2624 log-leak default)', () => {
|
||||
const base = { suppress: false, fromEnv: false, forcePrint: false, isTty: true };
|
||||
|
||||
test('generated token on non-TTY (container) → hidden by default', () => {
|
||||
expect(shouldSuppressBootstrapPrint({ ...base, isTty: false })).toBe(true);
|
||||
});
|
||||
|
||||
test('generated token on interactive TTY → printed', () => {
|
||||
expect(shouldSuppressBootstrapPrint({ ...base, isTty: true })).toBe(false);
|
||||
});
|
||||
|
||||
test('--print-admin-token forces raw value on non-TTY', () => {
|
||||
expect(shouldSuppressBootstrapPrint({ ...base, isTty: false, forcePrint: true })).toBe(false);
|
||||
});
|
||||
|
||||
test('env-sourced token is never printed', () => {
|
||||
expect(shouldSuppressBootstrapPrint({ ...base, fromEnv: true, isTty: true })).toBe(true);
|
||||
});
|
||||
|
||||
test('--suppress overrides even a forced print', () => {
|
||||
expect(shouldSuppressBootstrapPrint({ ...base, suppress: true, forcePrint: true, isTty: true })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user