diff --git a/bin/lib/runner.js b/bin/lib/runner.js index d0ca4cee..591dc26a 100644 --- a/bin/lib/runner.js +++ b/bin/lib/runner.js @@ -22,7 +22,7 @@ function run(cmd, opts = {}) { env: { ...process.env, ...opts.env }, }); if (result.status !== 0 && !opts.ignoreError) { - console.error(` Command failed (exit ${result.status}): ${cmd.slice(0, 80)}`); + console.error(` Command failed (exit ${result.status}): ${redact(cmd.slice(0, 80))}`); process.exit(result.status || 1); } return result; @@ -37,7 +37,7 @@ function runInteractive(cmd, opts = {}) { env: { ...process.env, ...opts.env }, }); if (result.status !== 0 && !opts.ignoreError) { - console.error(` Command failed (exit ${result.status}): ${cmd.slice(0, 80)}`); + console.error(` Command failed (exit ${result.status}): ${redact(cmd.slice(0, 80))}`); process.exit(result.status || 1); } return result; @@ -54,10 +54,40 @@ function runCapture(cmd, opts = {}) { }).trim(); } catch (err) { if (opts.ignoreError) return ""; - throw err; + throw redactError(err); } } +/** + * Redact known secret patterns from a string to prevent accidental leaks + * in CLI log and error output. Covers NVIDIA API keys, bearer tokens, + * generic API key assignments, and base64-style long tokens. + */ +const SECRET_PATTERNS = [ + /nvapi-[A-Za-z0-9_-]{10,}/g, + /nvcf-[A-Za-z0-9_-]{10,}/g, + /ghp_[A-Za-z0-9_-]{10,}/g, + /(?<=Bearer\s)[A-Za-z0-9_.+/=-]{10,}/gi, + /(?<=(?:_KEY|API_KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL)[=: ]['"]?)[A-Za-z0-9_.+/=-]{10,}/gi, +]; + +function redact(str) { + if (typeof str !== "string") return str; + let out = str; + for (const pat of SECRET_PATTERNS) { + out = out.replace(pat, (match) => match.slice(0, 4) + "*".repeat(Math.min(match.length - 4, 20))); + } + return out; +} + +function redactError(err) { + if (!err || typeof err !== "object") return err; + if (typeof err.message === "string") err.message = redact(err.message); + if (typeof err.stdout === "string") err.stdout = redact(err.stdout); + if (typeof err.stderr === "string") err.stderr = redact(err.stderr); + return err; +} + /** * Shell-quote a value for safe interpolation into bash -c strings. * Wraps in single quotes and escapes embedded single quotes. @@ -85,4 +115,4 @@ function validateName(name, label = "name") { return name; } -module.exports = { ROOT, SCRIPTS, run, runCapture, runInteractive, shellQuote, validateName }; +module.exports = { ROOT, SCRIPTS, redact, run, runCapture, runInteractive, shellQuote, validateName }; diff --git a/test/runner.test.js b/test/runner.test.js index a0547181..ae546d9b 100644 --- a/test/runner.test.js +++ b/test/runner.test.js @@ -141,7 +141,90 @@ describe("runner helpers", () => { }); }); + describe("redact", () => { + it("masks NVIDIA API keys", () => { + const { redact } = require(runnerPath); + expect(redact("key is nvapi-abc123XYZ_def456")).toBe( + "key is nvap******************" + ); + }); + + it("masks NVCF keys", () => { + const { redact } = require(runnerPath); + expect(redact("nvcf-abcdef1234567890")).toBe("nvcf*****************"); + }); + + it("masks bearer tokens", () => { + const { redact } = require(runnerPath); + expect(redact("Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.payload")).toBe( + "Authorization: Bearer eyJh********************" + ); + }); + + it("masks key assignments in commands", () => { + const { redact } = require(runnerPath); + expect(redact("export NVIDIA_API_KEY=nvapi-realkey12345")).toContain("nvap"); + expect(redact("export NVIDIA_API_KEY=nvapi-realkey12345")).not.toContain("realkey12345"); + }); + + it("masks variables ending in _KEY", () => { + const { redact } = require(runnerPath); + const output = redact('export SERVICE_KEY="supersecretvalue12345"'); + expect(output).not.toContain("supersecretvalue12345"); + expect(output).toContain('export SERVICE_KEY="supe'); + }); + + it("masks bare GitHub personal access tokens", () => { + const { redact } = require(runnerPath); + const output = redact("token ghp_abcdefghijklmnopqrstuvwxyz1234567890"); + expect(output).toContain("ghp_"); + expect(output).not.toContain("abcdefghijklmnopqrstuvwxyz1234567890"); + }); + + it("leaves non-secret strings untouched", () => { + const { redact } = require(runnerPath); + expect(redact("docker run --name my-sandbox")).toBe("docker run --name my-sandbox"); + expect(redact("openshell sandbox list")).toBe("openshell sandbox list"); + }); + + it("handles non-string input gracefully", () => { + const { redact } = require(runnerPath); + expect(redact(null)).toBe(null); + expect(redact(undefined)).toBe(undefined); + expect(redact(42)).toBe(42); + }); + }); + describe("regression guards", () => { + it("runCapture redacts secrets before rethrowing errors", () => { + const originalExecSync = childProcess.execSync; + childProcess.execSync = () => { + throw new Error( + 'command failed: export SERVICE_KEY="supersecretvalue12345" ghp_abcdefghijklmnopqrstuvwxyz1234567890' + ); + }; + + try { + delete require.cache[require.resolve(runnerPath)]; + const { runCapture } = require(runnerPath); + + let error; + try { + runCapture("echo nope"); + } catch (err) { + error = err; + } + + expect(error).toBeInstanceOf(Error); + expect(error.message).toContain("ghp_"); + expect(error.message).not.toContain("supersecretvalue12345"); + expect(error.message).not.toContain("abcdefghijklmnopqrstuvwxyz1234567890"); + } finally { + childProcess.execSync = originalExecSync; + delete require.cache[require.resolve(runnerPath)]; + } + }); + it("nemoclaw.js does not use execSync", () => { const fs = require("fs"); const src = fs.readFileSync(path.join(__dirname, "..", "bin", "nemoclaw.js"), "utf-8");