From 6eafbf68f2d2ef9c6099b9ca310fd325efa955a5 Mon Sep 17 00:00:00 2001 From: Human and Agent dVPN <271368948+Sentinel-Autonomybuilder@users.noreply.github.com> Date: Mon, 27 Apr 2026 01:05:19 -0700 Subject: [PATCH] security: redact mnemonic-shaped sequences from SDK default logger output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDK's default loggers (`defaultLog` in connection/connect.js and node-connect.js, plus inline `opts.log || console.log` fallbacks in recoverSession and tryFastReconnect) wrote directly to console.log. The SDK itself never logs the mnemonic today, but a future template-string bug (e.g. `log(`opts: ${JSON.stringify(opts)}`)` or `log(`derive failed for ${opts.mnemonic.slice(0,4)}...`)`) would leak the BIP-39 phrase to stdout — and from there to terminal scrollback, CI logs, log aggregators, and shell history. Add connection/logger.js exporting `withMnemonicRedaction(logFn)`, which: - runs the underlying logger after replacing any 12/15/18/21/24-word lowercase BIP-39-shaped sequence with `[REDACTED MNEMONIC]`, - short-circuits on strings shorter than 60 characters (cheap fast path), - only inspects string args (does not recurse into objects — avoids triggering side-effecting custom getters), - pass-through for non-function input so callers can still disable logging by passing `null`. Wire it into: - connection/connect.js defaultLog assignment + 1 stale fallback - node-connect.js defaultLog assignment + 2 stale fallbacks - connection/disconnect.js new module-local defaultLog (recoverSession) - connection/resilience.js new module-local defaultLog (tryFastReconnect) User-supplied `opts.log` is still honored verbatim — we do not wrap external loggers, only our own defaults. This is defense-in-depth against future bugs in OUR code, not a constraint on the consumer's logging stack. --- connection/connect.js | 10 ++++-- connection/disconnect.js | 7 ++++- connection/logger.js | 66 ++++++++++++++++++++++++++++++++++++++++ connection/resilience.js | 7 ++++- node-connect.js | 14 ++++++--- 5 files changed, 96 insertions(+), 8 deletions(-) create mode 100644 connection/logger.js diff --git a/connection/connect.js b/connection/connect.js index 56b5e1c..e171946 100644 --- a/connection/connect.js +++ b/connection/connect.js @@ -40,8 +40,14 @@ import { import { performHandshake, validateTunnelRequirements, killV2RayProc, verifyDependencies } from './tunnel.js'; import { verifyConnection } from './state.js'; import { registerCleanupHandlers } from './disconnect.js'; +import { withMnemonicRedaction } from './logger.js'; -let defaultLog = console.log; +// Default logger wraps console.log with a mnemonic redactor. Anything that +// resembles a 12–24 word BIP-39 phrase in a log argument is replaced with +// `[REDACTED MNEMONIC]` before it reaches stdout. Defense-in-depth — the SDK +// does not currently log the mnemonic, but a future template-string bug or +// careless `JSON.stringify(opts)` won't leak it through the default logger. +let defaultLog = withMnemonicRedaction(console.log); // ─── Shared Validation ─────────────────────────────────────────────────────── @@ -548,7 +554,7 @@ export async function connectAuto(opts) { if (opts.circuitBreaker) configureCircuitBreaker(opts.circuitBreaker); const maxAttempts = opts.maxAttempts || 3; - const logFn = opts.log || console.log; + const logFn = opts.log || defaultLog; const errors = []; // If nodeAddress specified, try it first (skip circuit breaker check for explicit choice) diff --git a/connection/disconnect.js b/connection/disconnect.js index 6b3b7ab..be1d683 100644 --- a/connection/disconnect.js +++ b/connection/disconnect.js @@ -26,6 +26,11 @@ import { DEFAULT_LCD, DEFAULT_TIMEOUTS } from '../defaults.js'; import { nodeStatusV3 } from '../v3protocol.js'; import { queryNode, privKeyFromMnemonic } from '../cosmjs-setup.js'; import { createNodeHttpsAgent } from '../tls-trust.js'; +import { withMnemonicRedaction } from './logger.js'; + +// Default logger wraps console.log so any 12–24 word BIP-39 phrase that ends +// up in a log argument is replaced before reaching stdout. See connection/logger.js. +const defaultLog = withMnemonicRedaction(console.log); // ─── Disconnect ────────────────────────────────────────────────────────────── // @@ -204,7 +209,7 @@ export async function recoverSession(opts) { if (!opts?.nodeAddress) throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'recoverSession requires opts.nodeAddress'); if (!opts?.mnemonic) throw new ValidationError(ErrorCodes.INVALID_MNEMONIC, 'recoverSession requires opts.mnemonic'); - const logFn = opts.log || console.log; + const logFn = opts.log || defaultLog; const onProgress = opts.onProgress || null; const sessionId = BigInt(opts.sessionId); const timeouts = { ...DEFAULT_TIMEOUTS, ...opts.timeouts }; diff --git a/connection/logger.js b/connection/logger.js new file mode 100644 index 0000000..14a05d2 --- /dev/null +++ b/connection/logger.js @@ -0,0 +1,66 @@ +/** + * Redacting logger wrapper — defense-in-depth against accidental mnemonic leaks + * in SDK log output. + * + * The SDK's default logger is `console.log`. Any future bug that interpolates + * a mnemonic into a log template string (e.g. `log(`opts: ${JSON.stringify(opts)}`)`) + * would leak the BIP-39 phrase to stdout/stderr — and from there to terminal + * scrollback, CI logs, log-aggregation tools (Datadog, Loki, Sentry breadcrumbs), + * and shell history. + * + * This module wraps any logger function with a regex-based redactor that matches + * BIP-39 word sequences and replaces them with `[REDACTED MNEMONIC]` before the + * underlying logger sees them. It is NOT a substitute for the rule "do not log + * the mnemonic" — it is a safety net so that violation does not produce a leak. + * + * Performance: the regex runs only on string arguments and short-circuits if the + * argument has fewer than ~60 characters (a 12-word mnemonic is ~80 characters). + * Negligible overhead on the SDK's hot path (a few connect-time progress logs). + */ + +/** + * Match 12 / 15 / 18 / 21 / 24 lowercase BIP-39-shaped words separated by single + * spaces. We deliberately don't try to validate against the full 2048-word list + * here — the goal is to catch anything that *looks* like a mnemonic and redact + * it. False positives (e.g. a long lowercase sentence) are acceptable since the + * SDK's own log strings never contain 12+ consecutive lowercase-only ASCII words. + * + * BIP-39 words are 3–8 lowercase ASCII letters (a–z), no digits, no diacritics. + */ +const MNEMONIC_REGEX = /\b(?:[a-z]{3,8}\s+){11,23}[a-z]{3,8}\b/g; + +const REDACTED = '[REDACTED MNEMONIC]'; + +/** + * Redact mnemonic-shaped substrings from a single value. Strings are scanned; + * everything else is returned unchanged. We do NOT recurse into objects — the + * SDK's loggers are called with scalar args, and walking arbitrary objects + * would risk triggering custom getters that have side effects. + * + * @param {*} value + * @returns {*} + */ +function redactValue(value) { + if (typeof value !== 'string') return value; + if (value.length < 60) return value; // shortest plausible 12-word phrase ~ 60 chars + return value.replace(MNEMONIC_REGEX, REDACTED); +} + +/** + * Wrap a logger function so that mnemonic-shaped strings in its arguments are + * replaced with `[REDACTED MNEMONIC]` before they reach the wrapped logger. + * Pass-through for non-function input (returns it unchanged) so callers can + * disable logging by passing `null`. + * + * @param {Function|null|undefined} logFn - underlying logger (typically console.log) + * @returns {Function|null|undefined} + */ +export function withMnemonicRedaction(logFn) { + if (typeof logFn !== 'function') return logFn; + return function redactedLog(...args) { + return logFn(...args.map(redactValue)); + }; +} + +// Exported for tests. +export const _internal = { MNEMONIC_REGEX, redactValue }; diff --git a/connection/resilience.js b/connection/resilience.js index f5da0e7..34d8e71 100644 --- a/connection/resilience.js +++ b/connection/resilience.js @@ -30,6 +30,11 @@ import { findV2RayExe } from './tunnel.js'; import { enableKillSwitch, isKillSwitchEnabled as _isKillSwitchEnabled } from './security.js'; import { setSystemProxy, clearSystemProxy, checkPortFree } from './proxy.js'; import { connectAuto, connectViaSubscription, connectViaPlan } from './connect.js'; +import { withMnemonicRedaction } from './logger.js'; + +// Default logger wraps console.log so any 12–24 word BIP-39 phrase that ends +// up in a log argument is replaced before reaching stdout. See connection/logger.js. +const defaultLog = withMnemonicRedaction(console.log); // ─── Circuit Breaker ───────────────────────────────────────────────────────── // v22: Skip nodes that repeatedly fail. Resets after TTL expires. @@ -108,7 +113,7 @@ export async function tryFastReconnect(opts, state = _defaultState) { if (!saved) return null; const onProgress = opts.onProgress || null; - const logFn = opts.log || console.log; + const logFn = opts.log || defaultLog; const fullTunnel = opts.fullTunnel !== false; const killSwitch = opts.killSwitch === true; const systemProxy = opts.systemProxy === true; diff --git a/node-connect.js b/node-connect.js index 13af10b..0f819bf 100644 --- a/node-connect.js +++ b/node-connect.js @@ -62,6 +62,7 @@ import { SentinelError, ValidationError, NodeError, ChainError, TunnelError, ErrorCodes, } from './errors.js'; import { createNodeHttpsAgent, publicEndpointAgent } from './tls-trust.js'; +import { withMnemonicRedaction } from './connection/logger.js'; // CA-validated agent for LCD/RPC public endpoints (valid CA certs) const httpsAgent = publicEndpointAgent; @@ -128,8 +129,13 @@ export class ConnectionState { const _activeStates = new Set(); const _defaultState = new ConnectionState(); -// Default logger — can be overridden per-call via opts.log -let defaultLog = console.log; +// Default logger — can be overridden per-call via opts.log. +// Wrapped with mnemonic redaction so any 12–24 word BIP-39 phrase that ends up +// in a log argument is replaced with `[REDACTED MNEMONIC]` before reaching +// stdout. Defense-in-depth — the SDK does not currently log the mnemonic, but +// a future template-string bug or careless `JSON.stringify(opts)` won't leak +// it through the default logger. +let defaultLog = withMnemonicRedaction(console.log); // ─── Wallet Cache ──────────────────────────────────────────────────────────── // v21: Cache wallet derivation (BIP39 → SLIP-10 is CPU-bound, ~300ms). @@ -1152,7 +1158,7 @@ export async function connectAuto(opts) { if (opts.circuitBreaker) configureCircuitBreaker(opts.circuitBreaker); const maxAttempts = opts.maxAttempts || 3; - const logFn = opts.log || console.log; + const logFn = opts.log || defaultLog; const errors = []; // If nodeAddress specified, try it first (skip circuit breaker check for explicit choice) @@ -2465,7 +2471,7 @@ export async function recoverSession(opts) { if (!opts?.nodeAddress) throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'recoverSession requires opts.nodeAddress'); if (!opts?.mnemonic) throw new ValidationError(ErrorCodes.INVALID_MNEMONIC, 'recoverSession requires opts.mnemonic'); - const logFn = opts.log || console.log; + const logFn = opts.log || defaultLog; const onProgress = opts.onProgress || null; const sessionId = BigInt(opts.sessionId); const timeouts = { ...DEFAULT_TIMEOUTS, ...opts.timeouts };