From bb3376e3b07f7b0dcb48c462a7680bc30d1a5872 Mon Sep 17 00:00:00 2001 From: Jaehwan Lee <51878645+irresi@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:34:28 -0700 Subject: [PATCH] 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 * fix(security): #2624 banner shows 'from env' before non-TTY hidden guard Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- src/commands/serve-http.ts | 43 ++++++++++++++++++++++--- src/commands/serve.ts | 7 +++- test/serve-http-bootstrap-token.test.ts | 26 ++++++++++++++- 3 files changed, 69 insertions(+), 7 deletions(-) diff --git a/src/commands/serve-http.ts b/src/commands/serve-http.ts index 5a0539466..20a165a50 100644 --- a/src/commands/serve-http.ts +++ b/src/commands/serve-http.ts @@ -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(); // 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╚══════════════════════════════════════════════════════╝`} `); }); diff --git a/src/commands/serve.ts b/src/commands/serve.ts index de7352dc4..adfc604f7 100644 --- a/src/commands/serve.ts +++ b/src/commands/serve.ts @@ -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; } diff --git a/test/serve-http-bootstrap-token.test.ts b/test/serve-http-bootstrap-token.test.ts index fcf6b6412..9b30fccd5 100644 --- a/test/serve-http-bootstrap-token.test.ts +++ b/test/serve-http-bootstrap-token.test.ts @@ -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); + }); +});