Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions nemoclaw/src/security/secret-scanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const FAKE = {
openai: "sk-" + "abc123def456ghi789jkl012mno",
openaiProject: "sk-proj-" + "abc123_def456-ghi789_jkl012-mno345",
github: "ghp_" + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn",
githubFineGrained: "github_pat_" + "ABCDEFGHIJKLMNO_PQRSTUVWXYZabc",
aws: "AKIA" + "IOSFODNN7EXAMPLE",
slack: "xoxb-" + "123456789-abcdefghij",
slackApp: "xapp-" + "1-A0000-12345-abcdef",
Expand Down Expand Up @@ -54,6 +55,12 @@ describe("scanForSecrets", () => {
expect(matches[0].pattern).toBe("GitHub token");
});

it("detects an underscore-bearing fine-grained GitHub personal access token", () => {
const matches = scanForSecrets(`token: ${FAKE.githubFineGrained}`);
expect(matches).toHaveLength(1);
expect(matches[0].pattern).toBe("GitHub token");
});

it("detects an AWS access key", () => {
const matches = scanForSecrets(`aws_access_key_id = ${FAKE.aws}`);
expect(matches).toHaveLength(1);
Expand Down
52 changes: 44 additions & 8 deletions nemoclaw/src/security/secret-scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,55 @@ interface SecretPattern {
regex: RegExp;
}

/** Provider token formats shared by the in-process and sandbox scanners. */
export const HIGH_CONFIDENCE_PREFIXED_TOKEN_SPECS = [
{
name: "NVIDIA API key",
prefixes: ["nvapi-"],
payloadCharacterClass: "A-Za-z0-9_-",
minimumPayloadLength: 20,
},
{
name: "GitHub token",
prefixes: ["ghp_", "gho_", "ghu_", "ghs_", "ghr_"],
payloadCharacterClass: "A-Za-z0-9",
Comment thread
cv marked this conversation as resolved.
minimumPayloadLength: 36,
},
{
name: "GitHub token",
prefixes: ["github_pat_"],
payloadCharacterClass: "A-Za-z0-9_",
minimumPayloadLength: 30,
},
{
name: "npm token",
prefixes: ["npm_"],
payloadCharacterClass: "A-Za-z0-9",
minimumPayloadLength: 36,
},
] as const;

const HIGH_CONFIDENCE_PREFIXED_TOKEN_ALTERNATIVES = HIGH_CONFIDENCE_PREFIXED_TOKEN_SPECS.flatMap(
({ prefixes, payloadCharacterClass, minimumPayloadLength }) =>
prefixes.map((prefix) => `${prefix}[${payloadCharacterClass}]{${minimumPayloadLength},}`),
).join("|");

/** POSIX ERE for standalone high-confidence provider tokens in sandbox shell scans. */
export const HIGH_CONFIDENCE_PREFIXED_TOKEN_ERE = `(^|[^[:alnum:]_])(${HIGH_CONFIDENCE_PREFIXED_TOKEN_ALTERNATIVES})([^[:alnum:]_]|$)`;

const SECRET_PATTERNS: SecretPattern[] = [
// NVIDIA
{ name: "NVIDIA API key", regex: /\bnvapi-[A-Za-z0-9_-]{20,}\b/ },
...HIGH_CONFIDENCE_PREFIXED_TOKEN_SPECS.map(
({ name, prefixes, payloadCharacterClass, minimumPayloadLength }) => ({
name,
regex: new RegExp(
`\\b(?:${prefixes.join("|")})[${payloadCharacterClass}]{${minimumPayloadLength},}\\b`,
),
}),
),

// OpenAI — exclude sk-ant- (Anthropic) to avoid double-matching
{ name: "OpenAI API key", regex: /\bsk-(?!ant-)[A-Za-z0-9_-]{20,}\b/ },

// GitHub
{ name: "GitHub token", regex: /\b(ghp|gho|ghu|ghs|ghr|github_pat)_[A-Za-z0-9]{36,}\b/ },

// AWS
{ name: "AWS access key", regex: /\bAKIA[0-9A-Z]{16}\b/ },
{
Expand All @@ -48,9 +87,6 @@ const SECRET_PATTERNS: SecretPattern[] = [
/(?<=(?:discord|bot|DISCORD_TOKEN|BOT_TOKEN|token)\s*[=:]\s*["']?)[A-Za-z0-9]{24}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,}/,
},

// npm
{ name: "npm token", regex: /\bnpm_[A-Za-z0-9]{36,}\b/ },

// Private keys (PEM)
{
name: "Private key",
Expand Down
32 changes: 32 additions & 0 deletions test/e2e/live/cloud-inference-credential-boundary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { shellQuote } from "../../../src/lib/core/shell-quote.ts";
import { HIGH_CONFIDENCE_PREFIXED_TOKEN_ERE } from "../../../nemoclaw/src/security/secret-scanner.ts";

const DEFAULT_SANDBOX_STATE_DIRECTORIES = ["/sandbox/.openclaw", "/sandbox/.nemoclaw"];

/** Build a path-only scan for concrete credential values in sandbox state. */
export function buildSandboxCredentialScanCommand(
directories: readonly string[] = DEFAULT_SANDBOX_STATE_DIRECTORIES,
): string {
const roots = directories.map((directory) => shellQuote(directory)).join(" ");
return [
`for dir in ${roots}; do`,
' [ -d "$dir" ] || continue',
` matches=$(grep -rlE '${HIGH_CONFIDENCE_PREFIXED_TOKEN_ERE}' "$dir")`,
" scan_status=$?",
' case "$scan_status" in',
` 0) printf '%s\\n' "$matches" | grep -Ev '/policies/|/plugin-runtime-deps/|/extensions/[^/]+/(dist|node_modules)/'`,
" filter_status=$?",
' case "$filter_status" in',
" 0|1) ;;",
' *) exit "$filter_status" ;;',
" esac",
" ;;",
" 1) ;;",
' *) exit "$scan_status" ;;',
" esac",
"done",
].join("\n");
}
46 changes: 2 additions & 44 deletions test/e2e/live/cloud-inference.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
parseCloudChatResponse,
type PreContractExternalProviderFailure,
} from "./cloud-inference-provider-skip.ts";
import { buildSandboxCredentialScanCommand } from "./cloud-inference-credential-boundary.ts";

const REPO_SKILL_VALIDATOR = path.join(
REPO_ROOT,
Expand Down Expand Up @@ -302,50 +303,7 @@ async function expectSandboxCredentialBoundary(
"",
);

const secretScanCommand = [
"for dir in /sandbox/.openclaw /sandbox/.nemoclaw; do",
' [ -d "$dir" ] || continue',
` matches=$(grep -rIlE 'nvapi-|ghp_|npm_' "$dir")`,
" scan_status=$?",
' case "$scan_status" in',
` 0) filtered=$(printf '%s\\n' "$matches" | grep -Ev '/policies/|/plugin-runtime-deps/|/extensions/[^/]+/(dist|node_modules)/')`,
" filter_status=$?",
' case "$filter_status" in',
" 0) filtered_file=$(mktemp)",
" temp_status=$?",
' case "$temp_status" in 0) ;; *) exit "$temp_status" ;; esac',
` trap 'rm -f "$filtered_file"' EXIT HUP INT TERM`,
` printf '%s\\n' "$filtered" > "$filtered_file"`,
" write_status=$?",
' case "$write_status" in 0) ;; *) exit "$write_status" ;; esac',
" while IFS= read -r file; do",
` matching_lines=$(grep -IE 'nvapi-|ghp_|npm_' "$file")`,
" match_status=$?",
' case "$match_status" in',
` 0) printf '%s' "$matching_lines" | grep -qv 'STRIPPED'`,
" unstripped_status=$?",
' case "$unstripped_status" in',
` 0) printf '%s\\n' "$file" ;;`,
" 1) ;;",
' *) exit "$unstripped_status" ;;',
" esac",
" ;;",
" 1) ;;",
' *) exit "$match_status" ;;',
" esac",
' done < "$filtered_file"',
' rm -f "$filtered_file"',
" trap - EXIT HUP INT TERM",
" ;;",
" 1) ;;",
' *) exit "$filter_status" ;;',
" esac",
" ;;",
" 1) ;;",
' *) exit "$scan_status" ;;',
" esac",
"done",
].join("\n");
const secretScanCommand = buildSandboxCredentialScanCommand();

const secretProbe = await sandbox.exec(SANDBOX_NAME, ["sh", "-lc", secretScanCommand], {
artifactName: "phase-3-sandbox-secret-pattern-probe",
Expand Down
134 changes: 134 additions & 0 deletions test/e2e/support/cloud-inference-credential-boundary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

import { afterEach, describe, expect, it } from "vitest";

import { HIGH_CONFIDENCE_PREFIXED_TOKEN_SPECS } from "../../../nemoclaw/src/security/secret-scanner.ts";
import { buildSandboxCredentialScanCommand } from "../live/cloud-inference-credential-boundary.ts";

const roots: string[] = [];

afterEach(() => {
for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
});

/** Create and track an isolated sandbox-state fixture root. */
function createScanRoot(): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cloud-credential-scan-"));
roots.push(root);
return root;
}

/** Write one text or binary sandbox-state fixture and return its path. */
function writeFixture(root: string, relativePath: string, body: string | Uint8Array): string {
const rootPath = path.resolve(root);
assert(!path.isAbsolute(relativePath), "Fixture path must be relative to the scan root");
const file = path.resolve(rootPath, relativePath);
const relativeFile = path.relative(rootPath, file);
assert(
relativeFile !== ".." && !relativeFile.startsWith(`..${path.sep}`),
"Fixture path must stay inside the scan root",
);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, body);
Comment thread
ericksoa marked this conversation as resolved.
return file;
}

/** Run the exact live credential scan command against a fixture root. */
function scan(root: string): string {
return execFileSync("sh", ["-lc", buildSandboxCredentialScanCommand([root])], {
encoding: "utf8",
});
}

describe("cloud inference sandbox credential scan", () => {
it("rejects fixture paths outside the temporary scan root", () => {
const root = createScanRoot();

expect(() => writeFixture(root, "../outside.txt", "outside\n")).toThrow(
"Fixture path must stay inside the scan root",
);
expect(() => writeFixture(root, path.join(root, "absolute.txt"), "outside\n")).toThrow(
"Fixture path must be relative to the scan root",
);
});

it("accepts npm dependency metadata that does not contain a credential value (#9363)", () => {
const root = createScanRoot();
writeFixture(
root,
"npm/projects/openclaw-whatsapp/node_modules/thread-stream/test/ts/transpile.sh",
'echo "${npm_config_user_agent}"\n',
);
writeFixture(
root,
"npm/projects/openclaw-msteams/node_modules/jwks-rsa/package.json",
'{"scripts":{"release":"git tag $npm_package_version"}}\n',
);
writeFixture(root, "configuration/token-key-path.txt", "ordinary dependency metadata\n");

expect(scan(root)).toBe("");
});

it.each([
["NVIDIA", "nvapi-nemoclaw-credential-boundary-canary"],
["GitHub", `ghp_${"a".repeat(36)}`],
["GitHub fine-grained", `github_pat_${"a".repeat(15)}_${"b".repeat(14)}`],
["npm", `npm_${"b".repeat(36)}`],
])("reports only the path of a file that contains a %s credential canary", (_label, canary) => {
const root = createScanRoot();
const leakedFile = writeFixture(root, "openclaw.json", `{"apiKey":"${canary}"}\n`);

const output = scan(root);

expect(output.trim()).toBe(leakedFile);
expect(output).not.toContain(canary);
});

it.each(
HIGH_CONFIDENCE_PREFIXED_TOKEN_SPECS.flatMap(({ prefixes, minimumPayloadLength }) =>
prefixes.map((prefix) => [prefix, minimumPayloadLength] as const),
),
)("enforces the shared minimum payload for %s", (prefix, minimumPayloadLength) => {
const root = createScanRoot();
writeFixture(root, "short.txt", `${prefix}${"a".repeat(minimumPayloadLength - 1)}\n`);

expect(scan(root)).toBe("");

const detectedFile = writeFixture(
root,
"minimum.txt",
`${prefix}${"a".repeat(minimumPayloadLength)}\n`,
);
expect(scan(root).trim()).toBe(detectedFile);
});

it.each([
["prefixed GitHub token", `prefixghp_${"a".repeat(36)}`],
["suffixed GitHub token", `ghp_${"a".repeat(36)}_suffix`],
["prefixed npm token", `prefixnpm_${"b".repeat(36)}`],
["suffixed npm token", `npm_${"b".repeat(36)}_suffix`],
])("does not report a token embedded in a larger identifier: %s", (_label, value) => {
const root = createScanRoot();
writeFixture(root, "embedded.txt", `${value}\n`);

expect(scan(root)).toBe("");
});

it("reports a credential canary in a NUL-containing file", () => {
const root = createScanRoot();
const canary = "nvapi-nemoclaw-binary-credential-canary";
const leakedFile = writeFixture(root, "state.bin", Buffer.from(`prefix\0${canary}\n`));

const output = scan(root);

expect(output.trim()).toBe(leakedFile);
expect(output).not.toContain(canary);
});
});
Loading