-
Notifications
You must be signed in to change notification settings - Fork 3.1k
fix(security): scrub migration and backup credentials consistently #7765
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Ayush7614
wants to merge
3
commits into
NVIDIA:main
from
Ayush7614:fix/credential-sanitize-migration-backup
Closed
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
|
|
||
| export function isSensitiveFile(filename: string): boolean { | ||
| return CREDENTIAL_SENSITIVE_BASENAMES.has(filename.toLowerCase()); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.