fix(security): prevent leaking admin bootstrap token to non-TTY stdout (#2625)

* fix(security): #2624 don't print admin bootstrap token on non-TTY (log-leak)

serve --http printed the generated admin token in the startup banner
unconditionally. In containerized deploys stderr ships to centralized log
storage, turning the token into a standing secret in logs.

Fail-safe default: the generated token now prints only when stderr is an
interactive TTY. Non-TTY starts hide it (--print-admin-token forces it;
$GBRAIN_ADMIN_BOOTSTRAP_TOKEN + --suppress-bootstrap-token already existed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(security): #2624 banner shows 'from env' before non-TTY hidden guard

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jaehwan Lee
2026-07-16 13:34:28 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 5008b287e4
commit bb3376e3b0
3 changed files with 69 additions and 7 deletions
+38 -5
View File
@@ -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╚══════════════════════════════════════════════════════╝`}
`);
});
+6 -1
View File
@@ -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;
}
+25 -1
View File
@@ -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);
});
});