From b89951837025c300a710f42b96a733df96a2c31c Mon Sep 17 00:00:00 2001 From: peteryuqin Date: Sun, 22 Mar 2026 19:50:36 -0400 Subject: [PATCH 1/2] fix(security): redact secret patterns from CLI log and error output Add a redact() helper to bin/lib/runner.js that masks known secret patterns before they reach CLI error messages. Covers: - NVIDIA API keys (nvapi-*) - NVCF keys (nvcf-*) - Bearer tokens - Generic key/token/password assignments Applied to all run() and runInteractive() error output where the failing command string is logged. Non-secret strings pass through unchanged. Fixes #664 Signed-off-by: peteryuqin --- bin/lib/runner.js | 27 ++++++++++++++++++++++++--- test/runner.test.js | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/bin/lib/runner.js b/bin/lib/runner.js index d0ca4ceea..e07444a0e 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; @@ -58,6 +58,27 @@ function runCapture(cmd, opts = {}) { } } +/** + * 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, + /(?<=Bearer\s)[A-Za-z0-9_.+/=-]{10,}/gi, + /(?<=(?: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; +} + /** * Shell-quote a value for safe interpolation into bash -c strings. * Wraps in single quotes and escapes embedded single quotes. @@ -85,4 +106,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 a05471817..ae5725897 100644 --- a/test/runner.test.js +++ b/test/runner.test.js @@ -141,6 +141,46 @@ 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("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("nemoclaw.js does not use execSync", () => { const fs = require("fs"); From 64f468a196784fb9d18d1318d35783b4842b4d90 Mon Sep 17 00:00:00 2001 From: peteryuqin Date: Mon, 23 Mar 2026 11:41:02 -0400 Subject: [PATCH 2/2] fix(security): cover runCapture and additional key patterns --- bin/lib/runner.js | 13 +++++++++++-- test/runner.test.js | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/bin/lib/runner.js b/bin/lib/runner.js index e07444a0e..591dc26a2 100644 --- a/bin/lib/runner.js +++ b/bin/lib/runner.js @@ -54,7 +54,7 @@ function runCapture(cmd, opts = {}) { }).trim(); } catch (err) { if (opts.ignoreError) return ""; - throw err; + throw redactError(err); } } @@ -66,8 +66,9 @@ function runCapture(cmd, opts = {}) { 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, - /(?<=(?:API_KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL)[=: ]['"]?)[A-Za-z0-9_.+/=-]{10,}/gi, + /(?<=(?:_KEY|API_KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL)[=: ]['"]?)[A-Za-z0-9_.+/=-]{10,}/gi, ]; function redact(str) { @@ -79,6 +80,14 @@ function redact(str) { 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. diff --git a/test/runner.test.js b/test/runner.test.js index ae5725897..ae546d9b2 100644 --- a/test/runner.test.js +++ b/test/runner.test.js @@ -167,6 +167,20 @@ describe("runner helpers", () => { 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"); @@ -182,6 +196,35 @@ describe("runner helpers", () => { }); 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");