Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
115 changes: 115 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,115 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

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 file = path.join(root, relativePath);
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("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