From e3468b7fd516cffcfa7002e0e2ef558091f94a32 Mon Sep 17 00:00:00 2001 From: peteryuqin Date: Sun, 22 Mar 2026 19:50:36 -0400 Subject: [PATCH 1/5] 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 | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/bin/lib/runner.js b/bin/lib/runner.js index d0ca4ceeac..e07444a0e4 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 7bc561738b..8d75f88029 100644 --- a/test/runner.test.js +++ b/test/runner.test.js @@ -168,6 +168,44 @@ describe("validateName", () => { }); }); +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 src = fs.readFileSync(path.join(import.meta.dirname, "..", "bin", "nemoclaw.js"), "utf-8"); From 612ed38ffd3acb6e90ff4406a79205dd6212571e Mon Sep 17 00:00:00 2001 From: peteryuqin Date: Mon, 23 Mar 2026 11:41:02 -0400 Subject: [PATCH 2/5] 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 e07444a0e4..591dc26a25 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 8d75f88029..c80117ff04 100644 --- a/test/runner.test.js +++ b/test/runner.test.js @@ -192,6 +192,20 @@ describe("redact", () => { 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"); @@ -207,6 +221,35 @@ describe("redact", () => { }); 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 src = fs.readFileSync(path.join(import.meta.dirname, "..", "bin", "nemoclaw.js"), "utf-8"); const lines = src.split("\n"); From ea878619367d7622fce872f316b001d349ddf783 Mon Sep 17 00:00:00 2001 From: peteryuqin Date: Wed, 25 Mar 2026 13:52:20 -0400 Subject: [PATCH 3/5] runner: tighten error redaction and extend redact tests --- bin/lib/runner.js | 8 +++++-- test/runner.test.js | 58 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/bin/lib/runner.js b/bin/lib/runner.js index 591dc26a25..e3f5d1856c 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}): ${redact(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}): ${redact(cmd.slice(0, 80))}`); + console.error(` Command failed (exit ${result.status}): ${redact(cmd).slice(0, 80)}`); process.exit(result.status || 1); } return result; @@ -83,8 +83,12 @@ function redact(str) { function redactError(err) { if (!err || typeof err !== "object") return err; if (typeof err.message === "string") err.message = redact(err.message); + if (typeof err.cmd === "string") err.cmd = redact(err.cmd); if (typeof err.stdout === "string") err.stdout = redact(err.stdout); if (typeof err.stderr === "string") err.stderr = redact(err.stderr); + if (Array.isArray(err.output)) { + err.output = err.output.map((value) => (typeof value === "string" ? redact(value) : value)); + } return err; } diff --git a/test/runner.test.js b/test/runner.test.js index c80117ff04..0e9cead3a1 100644 --- a/test/runner.test.js +++ b/test/runner.test.js @@ -206,6 +206,29 @@ describe("redact", () => { expect(output).not.toContain("abcdefghijklmnopqrstuvwxyz1234567890"); }); + it("masks bearer tokens case-insensitively", () => { + const { redact } = require(runnerPath); + expect(redact("authorization: bearer someBearerToken")).toContain("some****"); + expect(redact("authorization: bearer someBearerToken")).not.toContain("someBearerToken"); + expect(redact("AUTHORIZATION: BEARER someBearerToken")).toContain("some****"); + expect(redact("AUTHORIZATION: BEARER someBearerToken")).not.toContain("someBearerToken"); + }); + + it("masks quoted assignment values", () => { + const { redact } = require(runnerPath); + const output = redact('API_KEY="secret123abc"'); + expect(output).not.toContain("secret123abc"); + expect(output).toContain('API_KEY="sec'); + }); + + it("masks multiple secrets in one string", () => { + const { redact } = require(runnerPath); + const output = redact("nvapi-firstkey12345 nvapi-secondkey67890"); + expect(output).not.toContain("firstkey12345"); + expect(output).not.toContain("secondkey67890"); + expect(output).toContain("nvap******************"); + }); + it("leaves non-secret strings untouched", () => { const { redact } = require(runnerPath); expect(redact("docker run --name my-sandbox")).toBe("docker run --name my-sandbox"); @@ -250,6 +273,41 @@ describe("regression guards", () => { } }); + it("runCapture redacts execSync error cmd/output fields", () => { + const originalExecSync = childProcess.execSync; + childProcess.execSync = () => { + const err = new Error("command failed"); + err.cmd = "echo nvapi-aaaabbbbcccc1111 && echo ghp_abcdefghijklmnopqrstuvwxyz123456"; + err.output = ["stdout: nvapi-aaaabbbbcccc1111", "stderr: secret123456"]; + throw err; + }; + + try { + delete require.cache[require.resolve(runnerPath)]; + const { runCapture } = require(runnerPath); + + let error; + try { + runCapture("echo nope"); + } catch (err) { + error = err; + } + + expect(error).toBeDefined(); + expect(error).toBeInstanceOf(Error); + expect(error.cmd).not.toContain("nvapi-aaaabbbbcccc1111"); + expect(error.cmd).not.toContain("ghp_abcdefghijklmnopqrstuvwxyz123456"); + expect(Array.isArray(error.output)).toBe(true); + expect(error.output[0]).not.toContain("nvapi-aaaabbbbcccc1111"); + expect(error.output[1]).not.toContain("secret123456"); + expect(error.output[0]).toContain("****"); + expect(error.output[1]).toContain("****"); + } finally { + childProcess.execSync = originalExecSync; + delete require.cache[require.resolve(runnerPath)]; + } + }); + it("nemoclaw.js does not use execSync", () => { const src = fs.readFileSync(path.join(import.meta.dirname, "..", "bin", "nemoclaw.js"), "utf-8"); const lines = src.split("\n"); From 544c71b8050b8a884ba4a08af2cc2794e51e2d10 Mon Sep 17 00:00:00 2001 From: peteryuqin Date: Wed, 25 Mar 2026 18:38:20 -0400 Subject: [PATCH 4/5] fix(security): redact stack traces and align error-output test --- bin/lib/runner.js | 4 ++++ test/runner.test.js | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/bin/lib/runner.js b/bin/lib/runner.js index e3f5d1856c..677cf73bee 100644 --- a/bin/lib/runner.js +++ b/bin/lib/runner.js @@ -82,6 +82,7 @@ function redact(str) { function redactError(err) { if (!err || typeof err !== "object") return err; + const originalMessage = typeof err.message === "string" ? err.message : null; if (typeof err.message === "string") err.message = redact(err.message); if (typeof err.cmd === "string") err.cmd = redact(err.cmd); if (typeof err.stdout === "string") err.stdout = redact(err.stdout); @@ -89,6 +90,9 @@ function redactError(err) { if (Array.isArray(err.output)) { err.output = err.output.map((value) => (typeof value === "string" ? redact(value) : value)); } + if (originalMessage && typeof err.stack === "string") { + err.stack = err.stack.replaceAll(originalMessage, err.message); + } return err; } diff --git a/test/runner.test.js b/test/runner.test.js index 0e9cead3a1..2ff1a48b98 100644 --- a/test/runner.test.js +++ b/test/runner.test.js @@ -278,7 +278,7 @@ describe("regression guards", () => { childProcess.execSync = () => { const err = new Error("command failed"); err.cmd = "echo nvapi-aaaabbbbcccc1111 && echo ghp_abcdefghijklmnopqrstuvwxyz123456"; - err.output = ["stdout: nvapi-aaaabbbbcccc1111", "stderr: secret123456"]; + err.output = ["stdout: nvapi-aaaabbbbcccc1111", "stderr: PASSWORD=secret123456"]; throw err; }; From 7f135ce20a45c3d0599ee09fb9bbca3cec7a09d0 Mon Sep 17 00:00:00 2001 From: peteryuqin Date: Thu, 26 Mar 2026 00:17:54 -0400 Subject: [PATCH 5/5] chore(security): harden bearer redaction edge cases --- bin/lib/runner.js | 6 +++++- test/runner.test.js | 7 +++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/bin/lib/runner.js b/bin/lib/runner.js index 677cf73bee..9869e99bab 100644 --- a/bin/lib/runner.js +++ b/bin/lib/runner.js @@ -67,7 +67,7 @@ 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, + /(?<=Bearer\s+)[A-Za-z0-9_.+/=-]{10,}/gi, /(?<=(?:_KEY|API_KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL)[=: ]['"]?)[A-Za-z0-9_.+/=-]{10,}/gi, ]; @@ -80,6 +80,10 @@ function redact(str) { return out; } +/** + * Redact sensitive fields on an error object before surfacing it to callers. + * NOTE: this mutates the original error instance in place. + */ function redactError(err) { if (!err || typeof err !== "object") return err; const originalMessage = typeof err.message === "string" ? err.message : null; diff --git a/test/runner.test.js b/test/runner.test.js index 2ff1a48b98..06f66da774 100644 --- a/test/runner.test.js +++ b/test/runner.test.js @@ -214,6 +214,13 @@ describe("redact", () => { expect(redact("AUTHORIZATION: BEARER someBearerToken")).not.toContain("someBearerToken"); }); + it("masks bearer tokens with repeated spacing", () => { + const { redact } = require(runnerPath); + const output = redact("Authorization: Bearer someBearerToken"); + expect(output).toContain("some****"); + expect(output).not.toContain("someBearerToken"); + }); + it("masks quoted assignment values", () => { const { redact } = require(runnerPath); const output = redact('API_KEY="secret123abc"');