agap-mcp: stop leaking BW master password via bw error messages

vaultwarden.js run() passed BW_PASSWORD as argv to `bw login`/`bw unlock`.
On any non-zero exit, execFileSync throws an Error whose message is
"Command failed: bw unlock <BW_PASSWORD> --raw", which propagated to
server.js's `console.error('Init failed:', e.message)` -> the master password
landed in container stdout / `docker logs` on every Vaultwarden init failure
(wrong password, server down, TLS reset). Empirically confirmed by a security
audit 2026-07-24.

run() now catches the exec error and re-throws with the argv stripped: only the
subcommand, exit code, and stderr survive, and BW_PASSWORD is scrubbed from
stderr defensively. Verified: a forced failure yields "bw unlock failed (exit 1)"
with no password substring.

Not yet active: the running agap-mcp container predates this file; a rebuild
(`docker compose build agap-mcp && docker compose up -d agap-mcp`) is needed to
deploy it. The live container is still vulnerable until then.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 06:07:51 +00:00
parent b548a8f345
commit 0f9d83f3db

View File

@@ -15,12 +15,29 @@ function bwEnv() {
}
function run(args, input) {
return execFileSync(BW, args, {
env: bwEnv(),
encoding: 'utf8',
input,
stdio: input ? ['pipe','pipe','pipe'] : ['ignore','pipe','pipe'],
}).trim();
try {
return execFileSync(BW, args, {
env: bwEnv(),
encoding: 'utf8',
input,
stdio: input ? ['pipe','pipe','pipe'] : ['ignore','pipe','pipe'],
}).trim();
} catch (e) {
// SECURITY: execFileSync puts the FULL argv in e.message
// ("Command failed: bw unlock <BW_PASSWORD> --raw"), and login/unlock pass
// the master password as argv. That message propagates to console.error /
// tool errors -> docker logs. Re-throw with the argv stripped: keep only the
// subcommand + exit code + stderr, and scrub the password out of stderr too
// (defensive; bw doesn't normally echo it). Never let argv reach a log.
const sub = Array.isArray(args) && args.length ? args[0] : '?';
let stderr = (e && e.stderr ? e.stderr.toString() : '').trim();
const secret = process.env.BW_PASSWORD;
if (secret && stderr.includes(secret)) stderr = stderr.split(secret).join('<redacted>');
const err = new Error(`bw ${sub} failed (exit ${e && e.status != null ? e.status : '?'})` +
(stderr ? `: ${stderr}` : ''));
err.status = e && e.status;
throw err;
}
}
export async function initVaultwarden() {