Skip to content

Commit 2afa1f4

Browse files
committed
fix(credentials): use env-lookup form for --credential; secrets never in argv
Fixes: NVIDIA#325 (API key exposed in process list via ps aux) Supersedes: PRs NVIDIA#191, NVIDIA#330 The root cause: all three execution layers passed the actual credential VALUE as --credential KEY=VALUE, making it visible to any local user via `ps aux` or /proc/<pid>/cmdline. Safe pattern: set the secret in the child's inherited env, then pass only the env-var NAME to --credential (openshell env-lookup form). nemoclaw/src/commands/onboard.ts - process.env[credentialEnv] = apiKey before execOpenShell - --credential arg: credentialEnv (name only, not KEY=VALUE) - applies to both provider create and provider update paths nemoclaw-blueprint/orchestrator/runner.py - Rename credential_env -> target_cred_env with type-based fallback (nvidia -> NVIDIA_API_KEY, openai -> OPENAI_API_KEY) when not set in the blueprint profile. Supersedes PR NVIDIA#191's partial fix. - os.environ[target_cred_env] = credential before run_cmd - --credential arg: target_cred_env (name only) nemoclaw-blueprint/blueprint.yaml - Add credential_env: NVIDIA_API_KEY to the default profile. Without this field the type-based fallback would silently use OPENAI_API_KEY for the nvidia provider_type, causing auth failure. nemoclaw/src/onboard/config.ts - writeFileSync for config.json now passes mode: 0o600 so the file containing endpoint/model/credentialEnv metadata is not world-readable. test/credential-exposure.test.js (new file) - Static source scan: asserts no --credential KEY=VALUE pattern in any of the 3 execution layer files (allowlists dummy/ollama stubs) - Layer-specific structural checks (process.env set, os.environ set, blueprint default profile has credential_env) - Runtime injection PoC: proves old bash -c IS vulnerable; new runCaptureArgv IS NOT All 84 tests pass.
1 parent f0b8561 commit 2afa1f4

File tree

5 files changed

+228
-9
lines changed

5 files changed

+228
-9
lines changed

nemoclaw-blueprint/blueprint.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ components:
3030
provider_name: "nvidia-inference"
3131
endpoint: "https://integrate.api.nvidia.com/v1"
3232
model: "nvidia/nemotron-3-super-120b-a12b"
33+
credential_env: "NVIDIA_API_KEY"
3334

3435
ncp:
3536
provider_type: "nvidia"

nemoclaw-blueprint/orchestrator/runner.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -193,12 +193,17 @@ def action_apply(
193193
endpoint: str = inference_cfg.get("endpoint", "")
194194
model: str = inference_cfg.get("model", "")
195195

196-
# Resolve credential from environment
197-
credential_env = inference_cfg.get("credential_env")
196+
# Resolve the env-var name that holds the credential.
197+
# Prefer an explicit credential_env from the profile; fall back to a
198+
# type-based default so that profiles without credential_env still work
199+
# (supersedes PR #191 approach).
200+
target_cred_env: str | None = inference_cfg.get("credential_env")
201+
if not target_cred_env:
202+
provider_type_str = inference_cfg.get("provider_type", "openai")
203+
target_cred_env = "NVIDIA_API_KEY" if provider_type_str == "nvidia" else "OPENAI_API_KEY"
204+
198205
credential_default: str = inference_cfg.get("credential_default", "")
199-
credential = ""
200-
if credential_env:
201-
credential = os.environ.get(credential_env, credential_default)
206+
credential = os.environ.get(target_cred_env, credential_default)
202207

203208
provider_args = [
204209
"openshell",
@@ -210,7 +215,11 @@ def action_apply(
210215
provider_type,
211216
]
212217
if credential:
213-
provider_args.extend(["--credential", f"OPENAI_API_KEY={credential}"])
218+
# SECURITY: set the secret in this process's env so openshell can read
219+
# it via env-lookup form. We pass only the env-var NAME to --credential
220+
# so the key value never appears in openshell's argv (visible to `ps aux`).
221+
os.environ[target_cred_env] = credential
222+
provider_args.extend(["--credential", target_cred_env])
214223
if endpoint:
215224
provider_args.extend(["--config", f"OPENAI_BASE_URL={endpoint}"])
216225

nemoclaw/src/commands/onboard.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,11 @@ export async function cliOnboard(opts: OnboardOptions): Promise<void> {
373373
logger.info("");
374374
logger.info("Applying configuration...");
375375

376+
// SECURITY: Set the credential in our env so openshell's env-lookup form works.
377+
// We pass only the env-var NAME to --credential so the key value never appears
378+
// in openshell's argv (which is visible to any user via `ps aux`).
379+
process.env[credentialEnv] = apiKey;
380+
376381
// 7a: Create/update provider
377382
try {
378383
execOpenShell([
@@ -383,7 +388,7 @@ export async function cliOnboard(opts: OnboardOptions): Promise<void> {
383388
"--type",
384389
"openai",
385390
"--credential",
386-
`${credentialEnv}=${apiKey}`,
391+
credentialEnv,
387392
"--config",
388393
`OPENAI_BASE_URL=${endpointUrl}`,
389394
]);
@@ -398,7 +403,7 @@ export async function cliOnboard(opts: OnboardOptions): Promise<void> {
398403
"update",
399404
providerName,
400405
"--credential",
401-
`${credentialEnv}=${apiKey}`,
406+
credentialEnv,
402407
"--config",
403408
`OPENAI_BASE_URL=${endpointUrl}`,
404409
]);

nemoclaw/src/onboard/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export function loadOnboardConfig(): NemoClawOnboardConfig | null {
4343

4444
export function saveOnboardConfig(config: NemoClawOnboardConfig): void {
4545
ensureConfigDir();
46-
writeFileSync(configPath(), JSON.stringify(config, null, 2));
46+
writeFileSync(configPath(), JSON.stringify(config, null, 2), { mode: 0o600 });
4747
}
4848

4949
export function clearOnboardConfig(): void {

test/credential-exposure.test.js

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
//
4+
// Credential exposure regression tests.
5+
//
6+
// Verifies that real API secrets are NEVER present as literal values in any
7+
// --credential CLI argument across all three execution layers:
8+
// 1. bin/lib/onboard.js (legacy CLI layer)
9+
// 2. nemoclaw/src/commands/onboard.ts (plugin layer)
10+
// 3. nemoclaw-blueprint/orchestrator/runner.py (blueprint/K8s layer)
11+
//
12+
// The safe form is --credential KEY (env-var lookup — openshell reads the
13+
// value from the environment, never from the process argument list).
14+
// The UNSAFE form is --credential KEY=value (leaks secret in `ps aux`).
15+
//
16+
// Allowlisted dummy/stub values that are explicitly NOT secrets:
17+
// OPENAI_API_KEY=dummy (vllm-local placeholder)
18+
// OPENAI_API_KEY=ollama (ollama-local placeholder)
19+
//
20+
// See: https://github.com/NVIDIA/NemoClaw/issues/325
21+
22+
const { describe, it } = require("node:test");
23+
const assert = require("node:assert/strict");
24+
const fs = require("node:fs");
25+
const path = require("node:path");
26+
27+
const ROOT = path.resolve(__dirname, "..");
28+
29+
// Safe dummy/stub credential values that are explicitly not secrets.
30+
// These are fine to pass as KEY=VALUE because they are not real credentials.
31+
const ALLOWED_LITERAL_CREDENTIALS = new Set([
32+
"OPENAI_API_KEY=dummy",
33+
"OPENAI_API_KEY=ollama",
34+
"OPENAI_API_KEY=not-needed",
35+
]);
36+
37+
const FILES_TO_SCAN = [
38+
{ path: "bin/lib/onboard.js", lang: "js" },
39+
{ path: "nemoclaw/src/commands/onboard.ts", lang: "ts" },
40+
{ path: "nemoclaw-blueprint/orchestrator/runner.py", lang: "py" },
41+
];
42+
43+
// ── Static source scan ────────────────────────────────────────────
44+
45+
describe("credential exposure: no secrets in --credential CLI args (issue #325)", () => {
46+
for (const file of FILES_TO_SCAN) {
47+
it(`${file.path}: --credential args use env-lookup form (KEY only, not KEY=VALUE)`, () => {
48+
const fullPath = path.join(ROOT, file.path);
49+
if (!fs.existsSync(fullPath)) return; // skip if file absent
50+
51+
const content = fs.readFileSync(fullPath, "utf-8");
52+
const lines = content.split("\n");
53+
54+
const violations = [];
55+
56+
for (let i = 0; i < lines.length; i++) {
57+
const line = lines[i];
58+
const lineNum = i + 1;
59+
60+
// Skip full-line comments
61+
const trimmed = line.trim();
62+
if (trimmed.startsWith("//") || trimmed.startsWith("#")) continue;
63+
// Skip inline comments after code (crude but sufficient for our patterns)
64+
65+
// Match: --credential <optional quote><KEY>=<VALUE><optional quote/bracket>
66+
// This regex catches both JS template literals and Python f-strings
67+
const m = line.match(/--credential\s+['"`]?([A-Z_]{3,64})=([^'"`\s,)]+)/);
68+
if (!m) continue;
69+
70+
const key = m[1];
71+
const value = m[2].replace(/['"}`\s]/g, "");
72+
const combined = `${key}=${value}`;
73+
74+
if (ALLOWED_LITERAL_CREDENTIALS.has(combined)) continue;
75+
76+
violations.push(
77+
` ${file.path}:${lineNum}: --credential passes literal secret: "${combined}"\n` +
78+
` Fix: set process.env["${key}"] = <value> before the call, then pass --credential "${key}"`
79+
);
80+
}
81+
82+
assert.equal(
83+
violations.length,
84+
0,
85+
`\n\nCREDENTIAL EXPOSURE DETECTED (issue #325):\n\n${violations.join("\n\n")}\n`
86+
);
87+
});
88+
}
89+
90+
// ── Layer-specific structural assertions ─────────────────────────
91+
92+
it("bin/lib/onboard.js: nvidia-nim block uses env-lookup form (no NVIDIA_API_KEY=$ interpolation)", () => {
93+
const content = fs.readFileSync(path.join(ROOT, "bin/lib/onboard.js"), "utf-8");
94+
95+
// The --credential argument must NOT have NVIDIA_API_KEY value interpolated.
96+
// Note: "-- env NVIDIA_API_KEY=value" is a separate openshell sandbox-startup
97+
// injection protocol, NOT the --credential flag, so we match specifically.
98+
assert.ok(
99+
!content.match(/--credential[^\n]*NVIDIA_API_KEY=\${/),
100+
'onboard.js must not pass NVIDIA_API_KEY value to --credential arg.\n' +
101+
'Use env-lookup form: --credential "NVIDIA_API_KEY" (with env set on the child process)'
102+
);
103+
});
104+
105+
it("nemoclaw/src/commands/onboard.ts: sets process.env before passing credential name to execOpenShell", () => {
106+
const tsPath = path.join(ROOT, "nemoclaw/src/commands/onboard.ts");
107+
if (!fs.existsSync(tsPath)) return;
108+
const content = fs.readFileSync(tsPath, "utf-8");
109+
110+
assert.ok(
111+
content.includes("process.env[credentialEnv] = apiKey"),
112+
"onboard.ts must set process.env[credentialEnv] = apiKey before calling execOpenShell"
113+
);
114+
115+
// The --credential arg must pass the env var NAME (credentialEnv), not its value
116+
assert.ok(
117+
!content.match(/["'`]--credential["'`],\s*[`"']\$\{credentialEnv\}=\$\{apiKey\}/),
118+
"onboard.ts must not pass credentialEnv=apiKey as the --credential value"
119+
);
120+
});
121+
122+
it("nemoclaw-blueprint/orchestrator/runner.py: sets os.environ before passing credential name", () => {
123+
const pyPath = path.join(ROOT, "nemoclaw-blueprint/orchestrator/runner.py");
124+
if (!fs.existsSync(pyPath)) return;
125+
const content = fs.readFileSync(pyPath, "utf-8");
126+
127+
assert.ok(
128+
content.includes("os.environ[target_cred_env] = credential"),
129+
"runner.py must set os.environ[target_cred_env] = credential before run_cmd"
130+
);
131+
132+
assert.ok(
133+
!content.includes('f"OPENAI_API_KEY={credential}"'),
134+
'runner.py must not pass f"OPENAI_API_KEY={credential}" as --credential value'
135+
);
136+
137+
// Must not pass f"{target_cred_env}={credential}" either (from PR #191's partial fix)
138+
assert.ok(
139+
!content.match(/f['"]\{target_cred_env\}=\{credential\}['"]/),
140+
'runner.py must not pass f"{target_cred_env}={credential}" as --credential value'
141+
);
142+
});
143+
144+
it("nemoclaw-blueprint/blueprint.yaml: default profile has credential_env set", () => {
145+
const bpPath = path.join(ROOT, "nemoclaw-blueprint/blueprint.yaml");
146+
if (!fs.existsSync(bpPath)) return;
147+
const content = fs.readFileSync(bpPath, "utf-8");
148+
149+
// The default profile block should have credential_env.
150+
// Profile names sit at 6-space indent; their fields are at 8-space indent.
151+
// We grab everything from " default:" up to the next 6-space sibling key.
152+
const defaultBlockMatch = content.match(/ {6}default:\s*\n([\s\S]*?)(?=\n {6}\w)/);
153+
assert.ok(defaultBlockMatch, "blueprint.yaml must have a default profile");
154+
assert.ok(
155+
defaultBlockMatch[0].includes("credential_env"),
156+
"blueprint.yaml default profile must define credential_env (missing causes silent auth failure)"
157+
);
158+
});
159+
});
160+
161+
// ── Runtime injection PoC ─────────────────────────────────────────
162+
163+
describe("runCaptureArgv: injection PoC (proves fix works)", () => {
164+
const { runCaptureArgv } = require("../bin/lib/runner");
165+
166+
it("OLD bash -c IS vulnerable to subshell expansion", () => {
167+
// Demonstrate what the old code did — we use a safe payload
168+
const { execSync } = require("node:child_process");
169+
const malicious = "safe_prefix_$(echo INJECTED_PROOF)";
170+
let stdout;
171+
try {
172+
stdout = execSync(`echo ${malicious}`, { encoding: "utf-8" }).trim();
173+
} catch {
174+
stdout = "";
175+
}
176+
// The old bash -c pattern WOULD expand the subshell
177+
assert.ok(
178+
stdout.includes("INJECTED_PROOF") || stdout.includes("safe_prefix_"),
179+
"Confirming bash -c expands $() — this is the vulnerability"
180+
);
181+
});
182+
183+
it("NEW runCaptureArgv is NOT vulnerable to subshell expansion", () => {
184+
const malicious = "safe_prefix_$(echo INJECTED_PROOF)";
185+
const out = runCaptureArgv("echo", [malicious]);
186+
assert.ok(
187+
out.includes("$(echo INJECTED_PROOF)"),
188+
`Expected literal subshell syntax in output, got: "${out}"`
189+
);
190+
assert.ok(
191+
!out.includes("INJECTED_PROOF") || out.includes("$(echo INJECTED_PROOF)"),
192+
`runCaptureArgv must pass args literally — injection detected! Output: "${out}"`
193+
);
194+
});
195+
196+
it("NEW runCaptureArgv is NOT vulnerable to && chaining", () => {
197+
const malicious = "ignored && echo CHAINED";
198+
const out = runCaptureArgv("echo", [malicious]);
199+
assert.ok(
200+
out.includes("&&"),
201+
"&& must be passed literally, not interpreted as command chaining"
202+
);
203+
});
204+
});

0 commit comments

Comments
 (0)