Skip to content
Closed
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
63 changes: 4 additions & 59 deletions nemoclaw/src/commands/migration-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { create as createTar } from "tar";
import { createHash } from "node:crypto";
import JSON5 from "json5";
import type { PluginLogger } from "../index.js";
import { isSensitiveFile, stripCredentials } from "../security/credential-filter.js";
import { isObjectRecord, type UnknownRecord } from "../shared/object-record.js";

const SANDBOX_MIGRATION_DIR = "/sandbox/.nemoclaw/migration";
Expand Down Expand Up @@ -504,65 +505,9 @@ export function detectHostOpenClaw(env: NodeJS.ProcessEnv = process.env): HostOp
}

// ---------------------------------------------------------------------------
// Credential sanitization
// Credential sanitization (shared with nemoclaw/src/security/credential-filter)
// ---------------------------------------------------------------------------

/**
* Basenames that MUST NOT be copied into snapshot bundles.
* These files contain credential references or session tokens
* that should never cross the sandbox boundary.
*/
const CREDENTIAL_SENSITIVE_BASENAMES = new Set(["auth-profiles.json"]);

/**
* Credential field names that MUST be stripped from config files
* before they enter the sandbox. Credentials should be injected
* at runtime via OpenShell's provider credential mechanism.
*/
const CREDENTIAL_FIELDS = new Set([
"apiKey",
"api_key",
"token",
"secret",
"password",
"resolvedKey",
]);

/**
* Pattern-based detection for credential field names not covered by the
* explicit set above. Matches common suffixes like accessToken, privateKey,
* clientSecret, etc.
*/
const CREDENTIAL_FIELD_PATTERN =
/(?:access|refresh|client|bearer|auth|api|private|public|signing|session)(?:Token|Key|Secret|Password)$/;

function isCredentialField(key: string): boolean {
return CREDENTIAL_FIELDS.has(key) || CREDENTIAL_FIELD_PATTERN.test(key);
}

/**
* Recursively strip credential fields from a JSON-like object.
* Returns a new object with sensitive values replaced by a placeholder.
*/
function stripCredentials(obj: unknown): unknown {
if (Array.isArray(obj)) return obj.map(stripCredentials);
if (!isObjectRecord(obj)) return obj;

return stripCredentialsFromRecord(obj);
}

function stripCredentialsFromRecord(obj: UnknownRecord): UnknownRecord {
const result: UnknownRecord = {};
for (const [key, value] of Object.entries(obj)) {
if (isCredentialField(key)) {
result[key] = "[STRIPPED_BY_MIGRATION]";
} else {
result[key] = stripCredentials(value);
}
}
return result;
}

/**
* Strip credential fields from openclaw.json and remove the gateway
* config section (contains auth tokens — regenerated by sandbox entrypoint).
Expand All @@ -571,7 +516,7 @@ function sanitizeConfigFile(configPath: string): void {
const config = loadConfigDocument(configPath);
if (!config) return;
delete config.gateway;
const sanitized = stripCredentialsFromRecord(config);
const sanitized = stripCredentials(config) as UnknownRecord;
writeFileSync(configPath, JSON.stringify(sanitized, null, 2));
chmodSync(configPath, 0o600);
}
Expand All @@ -593,7 +538,7 @@ function copyDirectory(
cpSync(sourcePath, destinationPath, {
recursive: true,
filter: options?.stripCredentials
? (source: string) => !CREDENTIAL_SENSITIVE_BASENAMES.has(path.basename(source).toLowerCase())
? (source: string) => !isSensitiveFile(path.basename(source))
: undefined,
});
}
Expand Down
93 changes: 93 additions & 0 deletions nemoclaw/src/security/credential-filter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

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

import {
CREDENTIAL_PLACEHOLDER,
isCredentialField,
isSafeCredentialPlaceholder,
isSensitiveFile,
stripCredentials,
valueLooksLikeSecret,
} from "./credential-filter.js";

describe("plugin credential-filter", () => {
it("treats Slack botToken, Authorization, and GITHUB_TOKEN as credential fields", () => {
expect(isCredentialField("botToken")).toBe(true);
expect(isCredentialField("appToken")).toBe(true);
expect(isCredentialField("Authorization")).toBe(true);
expect(isCredentialField("GITHUB_TOKEN")).toBe(true);
expect(isCredentialField("DB_PASS")).toBe(true);
expect(isCredentialField("publicKey")).toBe(false);
expect(isCredentialField("NODE_ENV")).toBe(false);
});

it("strips channel tokens, headers, env secrets, and CLI flag args", () => {
const result = stripCredentials({
channels: {
slack: {
accounts: {
default: {
botToken: "xoxb-raw-slack-token",
appToken: "xapp-raw-app-token",
},
},
},
},
mcp: {
headers: { Authorization: "Bearer sk-abcdefghijklmnopqrstuvwxyz" },
env: { GITHUB_TOKEN: "ghp_abcdefghijklmnopqrstuvwxyz0123456789", NODE_ENV: "test" },
args: ["--api-key", "opaque-secret-value", "--verbose"],
},
model: "keep-me",
publicKey: "verify-me",
apiKey: "openshell:resolve:env:NVIDIA_API_KEY",
}) as Record<string, unknown>;

const channels = result.channels as {
slack: { accounts: { default: { botToken: string; appToken: string } } };
};
expect(channels.slack.accounts.default.botToken).toBe(CREDENTIAL_PLACEHOLDER);
expect(channels.slack.accounts.default.appToken).toBe(CREDENTIAL_PLACEHOLDER);

const mcp = result.mcp as {
headers: { Authorization: string };
env: { GITHUB_TOKEN: string; NODE_ENV: string };
args: string[];
};
expect(mcp.headers.Authorization).toBe(CREDENTIAL_PLACEHOLDER);
expect(mcp.env.GITHUB_TOKEN).toBe(CREDENTIAL_PLACEHOLDER);
expect(mcp.env.NODE_ENV).toBe("test");
expect(mcp.args).toEqual(["--api-key", CREDENTIAL_PLACEHOLDER, "--verbose"]);
expect(result.model).toBe("keep-me");
expect(result.publicKey).toBe("verify-me");
expect(result.apiKey).toBe("openshell:resolve:env:NVIDIA_API_KEY");
});

it("preserves safe placeholders and detects secret-shaped values", () => {
expect(isSafeCredentialPlaceholder("unused")).toBe(true);
expect(isSafeCredentialPlaceholder("openshell:resolve:env:TOKEN")).toBe(true);
expect(valueLooksLikeSecret("sk-abcdefghijklmnopqrstuvwxyz")).toBe(true);
expect(valueLooksLikeSecret("glpat-abcdefghijklmnopqrst")).toBe(true);
expect(valueLooksLikeSecret("nvcf-abcdefghij")).toBe(true);
expect(valueLooksLikeSecret("not-a-secret")).toBe(false);
});

it("preserves null and undefined under credential field names", () => {
const result = stripCredentials({ apiKey: null, token: undefined, model: "keep" }) as Record<
string,
unknown
>;
expect(result.apiKey).toBeNull();
expect(result.token).toBeUndefined();
expect(result.model).toBe("keep");
});

it("excludes auth state basenames from migration copies", () => {
expect(isSensitiveFile("auth-profiles.json")).toBe(true);
expect(isSensitiveFile("auth.json")).toBe(true);
expect(isSensitiveFile("chatgpt-auth.json")).toBe(true);
expect(isSensitiveFile("openclaw.json")).toBe(false);
});
});
195 changes: 195 additions & 0 deletions nemoclaw/src/security/credential-filter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//
// Credential stripping for host→sandbox migration snapshots.
// Kept in parity with src/lib/security/credential-filter.ts so migration
// cannot leave channel tokens, env secrets, or auth headers in the sandbox.

import { isObjectRecord, type UnknownRecord } from "../shared/object-record.js";

export const CREDENTIAL_PLACEHOLDER = "[STRIPPED_BY_MIGRATION]";

/**
* Basenames that MUST NOT be copied into snapshot bundles.
*/
export const CREDENTIAL_SENSITIVE_BASENAMES = new Set([
"auth-profiles.json",
"auth.json",
"chatgpt-auth.json",
]);

const CREDENTIAL_FIELDS = new Set([
"apiKey",
"api_key",
"token",
"secret",
"password",
"pass",
"passwd",
"resolvedKey",
]);

const CREDENTIAL_FIELD_PATTERN =
/(?:access|refresh|client|bearer|auth|api|private|public|signing|session|bot|app)(?:Token|Key|Secret|Password)$/;

const ENV_SECRET_FIELD_PATTERN =
/^(?:[A-Z0-9]+_)*(?:TOKEN|KEY|SECRET|PASSWORD|PASSWD|PASS|PASSPHRASE|CREDENTIAL)S?$/;

const CREDENTIAL_HEADER_NAMES: ReadonlySet<string> = new Set([
"authorization",
"proxy-authorization",
"cookie",
"set-cookie",
]);

const HEADER_CREDENTIAL_PATTERN = /-(?:key|token|secret|password|passphrase|credential|auth)s?$/i;

const PUBLIC_KEY_FIELD_PATTERN = /(?:^|[-_])public[-_]?keys?$/i;

const SAFE_CREDENTIAL_PLACEHOLDER_PATTERNS: readonly RegExp[] = [
/^openshell:resolve:env:[A-Za-z0-9_]+$/,
/^Bearer\s+openshell:resolve:env:[A-Za-z0-9_]+$/i,
/^xoxb-OPENSHELL-RESOLVE-ENV-[A-Za-z0-9_]+$/,
/^xapp-OPENSHELL-RESOLVE-ENV-[A-Za-z0-9_]+$/,
];

const SAFE_CREDENTIAL_PLACEHOLDER_LITERALS: ReadonlySet<string> = new Set([
"unused",
CREDENTIAL_PLACEHOLDER,
]);

/**
* High-confidence raw secret shapes used as a value-level backstop.
* Kept aligned with TOKEN_PREFIX / STRUCTURED / SECRET_BLOCK patterns from
* src/lib/security/secret-patterns.ts (plugin package cannot import src/lib).
*/
const VALUE_SECRET_PATTERNS: readonly RegExp[] = [
/nvapi-[A-Za-z0-9_-]{10,}/,
/nvcf-[A-Za-z0-9_-]{10,}/,
/ghp_[A-Za-z0-9_-]{10,}/,
/(?:github_pat_)[A-Za-z0-9_]{30,}/,
/sk-proj-[A-Za-z0-9_-]{10,}/,
/sk-ant-[A-Za-z0-9_-]{10,}/,
/sk-[A-Za-z0-9_-]{20,}/,
/(?:xox[bpas]|xapp)-[A-Za-z0-9-]{10,}/,
/A(?:K|S)IA[A-Z0-9]{16}/,
/hf_[A-Za-z0-9]{10,}/,
/glpat-[A-Za-z0-9_-]{10,}/,
/gsk_[A-Za-z0-9]{10,}/,
/pypi-[A-Za-z0-9_-]{10,}/,
/\bbot\d{8,10}:[A-Za-z0-9_-]{35}\b/,
/\b\d{8,10}:[A-Za-z0-9_-]{35}\b/,
/\b[A-Za-z0-9]{24}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,}\b/,
/tvly-[A-Za-z0-9_-]{10,}/,
/lsv2_(?:pt|sk)_[A-Za-z0-9]{10,}(?:_[A-Za-z0-9]+)*/,
/\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{10,}\b/,
/-----BEGIN (?:[A-Z0-9]+ )?PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z0-9]+ )?PRIVATE KEY-----/,
];

function hasPassCredentialSegment(key: string): boolean {
const normalized = key
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
.replace(/[^A-Za-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "")
.toLowerCase();
return (
normalized === "pass" ||
normalized === "passwd" ||
normalized.endsWith("_pass") ||
normalized.endsWith("_passwd")
);
}

export function isCredentialField(key: string): boolean {
if (PUBLIC_KEY_FIELD_PATTERN.test(key)) return false;
return (
CREDENTIAL_FIELDS.has(key) ||
CREDENTIAL_FIELD_PATTERN.test(key) ||
hasPassCredentialSegment(key) ||
ENV_SECRET_FIELD_PATTERN.test(key) ||
HEADER_CREDENTIAL_PATTERN.test(key) ||
CREDENTIAL_HEADER_NAMES.has(key.toLowerCase())
);
}

export function valueLooksLikeSecret(value: string): boolean {
return VALUE_SECRET_PATTERNS.some((pattern) => pattern.test(value));
}

export function isSafeCredentialPlaceholder(value: unknown): boolean {
if (typeof value !== "string") return false;
const withoutScheme = value.replace(/^Bearer\s+/i, "");
if (
SAFE_CREDENTIAL_PLACEHOLDER_LITERALS.has(value) ||
SAFE_CREDENTIAL_PLACEHOLDER_LITERALS.has(withoutScheme)
) {
return true;
}
return SAFE_CREDENTIAL_PLACEHOLDER_PATTERNS.some((pattern) => pattern.test(value));
}

function scrubConfigValue(value: unknown): unknown {
if (typeof value === "string") {
if (isSafeCredentialPlaceholder(value)) return value;
return valueLooksLikeSecret(value) ? CREDENTIAL_PLACEHOLDER : value;
}
return stripCredentials(value);
}

function cliFlagName(token: string): string | null {
const match = /^--?([A-Za-z0-9][A-Za-z0-9._-]*)$/.exec(token);
return match ? match[1] : null;
}

function scrubArrayElement(value: unknown, previous: unknown): unknown {
if (typeof value !== "string") return stripCredentials(value);
if (isSafeCredentialPlaceholder(value)) return value;

const eq = value.indexOf("=");
if (eq > 0 && value.startsWith("-")) {
const flagName = cliFlagName(value.slice(0, eq));
if (flagName && isCredentialField(flagName)) {
const inlineValue = value.slice(eq + 1);
return isSafeCredentialPlaceholder(inlineValue)
? value
: `${value.slice(0, eq)}=${CREDENTIAL_PLACEHOLDER}`;
}
}

if (!value.startsWith("-") && typeof previous === "string") {
const prevFlag = cliFlagName(previous);
if (prevFlag && isCredentialField(prevFlag)) return CREDENTIAL_PLACEHOLDER;
}

return valueLooksLikeSecret(value) ? CREDENTIAL_PLACEHOLDER : value;
}

/**
* Recursively strip credential fields from a JSON-like object.
*/
export function stripCredentials(obj: unknown): unknown {
if (obj === null || obj === undefined) return obj;
if (typeof obj !== "object") return obj;
if (Array.isArray(obj)) {
return obj.map((value, index) => scrubArrayElement(value, obj[index - 1]));
}
if (!isObjectRecord(obj)) return obj;

const result: UnknownRecord = {};
for (const [key, value] of Object.entries(obj)) {
if (isCredentialField(key)) {
result[key] =
value === null || value === undefined || isSafeCredentialPlaceholder(value)
? value
: CREDENTIAL_PLACEHOLDER;
} else {
result[key] = scrubConfigValue(value);
}
}
return result;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export function isSensitiveFile(filename: string): boolean {
return CREDENTIAL_SENSITIVE_BASENAMES.has(filename.toLowerCase());
}
Loading
Loading