Skip to content

Commit 45c8ecd

Browse files
committed
refactor(security): share credential filter boundary
Signed-off-by: Ho Lim <subhoya@gmail.com>
1 parent f5198b8 commit 45c8ecd

12 files changed

Lines changed: 545 additions & 805 deletions
Lines changed: 17 additions & 234 deletions
Original file line numberDiff line numberDiff line change
@@ -1,238 +1,21 @@
11
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
22
// SPDX-License-Identifier: Apache-2.0
3-
//
4-
// Credential stripping for host→sandbox migration snapshots.
5-
// Kept in parity with src/lib/security/credential-filter.ts so migration
6-
// cannot leave channel tokens, env secrets, or auth headers in the sandbox.
73

8-
import { isObjectRecord, type UnknownRecord } from "../shared/object-record.js";
9-
10-
export const CREDENTIAL_PLACEHOLDER = "[STRIPPED_BY_MIGRATION]";
11-
12-
/**
13-
* Basenames that MUST NOT be copied into snapshot bundles.
14-
*/
15-
export const CREDENTIAL_SENSITIVE_BASENAMES = new Set([
16-
"auth-profiles.json",
17-
"auth.json",
18-
"chatgpt-auth.json",
19-
]);
20-
21-
const CREDENTIAL_FIELDS = new Set([
22-
"apikey",
23-
"api_key",
24-
"token",
25-
"secret",
26-
"password",
27-
"pass",
28-
"passwd",
29-
"resolvedkey",
30-
]);
31-
32-
const CREDENTIAL_FIELD_PATTERN =
33-
/^(?:(?:personal[._-]?)?access|refresh|client|bearer|oauth|auth|api|private|public|signing|session|bot|app|resolved)[._-]?(?:tokens?|keys?|secrets?|passwords?|passphrases?|credentials?)$/i;
34-
35-
const ENV_SECRET_FIELD_PATTERN =
36-
/^(?:[A-Z0-9]+_)*(?:TOKEN|KEY|SECRET|PASSWORD|PASSWD|PASS|PASSPHRASE|CREDENTIAL)S?$/;
37-
38-
const CREDENTIAL_HEADER_NAMES: ReadonlySet<string> = new Set([
39-
"authorization",
40-
"proxy-authorization",
41-
"cookie",
42-
"set-cookie",
43-
]);
44-
45-
const HEADER_CREDENTIAL_PATTERN = /-(?:key|token|secret|password|passphrase|credential|auth)s?$/i;
46-
47-
const PUBLIC_KEY_FIELD_PATTERN = /(?:^|[._-])public[._-]?keys?$/i;
48-
49-
const SAFE_CREDENTIAL_PLACEHOLDER_PATTERNS: readonly RegExp[] = [
50-
/^openshell:resolve:env:[A-Za-z0-9_]+$/,
51-
/^Bearer\s+openshell:resolve:env:[A-Za-z0-9_]+$/i,
52-
/^xoxb-OPENSHELL-RESOLVE-ENV-[A-Za-z0-9_]+$/,
53-
/^xapp-OPENSHELL-RESOLVE-ENV-[A-Za-z0-9_]+$/,
54-
];
55-
56-
const SAFE_CREDENTIAL_PLACEHOLDER_LITERALS: ReadonlySet<string> = new Set([
57-
"unused",
4+
// sourceOfTruth: nemoclaw/src/shared/credential-filter-boundary.cts
5+
// Keep this package entry wrapper implementation-free so migration and the CLI
6+
// execute the same credential-stripping rules.
7+
export {
8+
CONTEXT_PATTERNS as CONTEXT_SECRET_PATTERNS,
589
CREDENTIAL_PLACEHOLDER,
59-
]);
60-
61-
/**
62-
* Context-anchored secret shapes mirrored from
63-
* src/lib/security/secret-patterns.ts. The plugin package cannot import
64-
* src/lib at runtime, so a repository-level parity test pins this copy.
65-
*/
66-
export const CONTEXT_SECRET_PATTERNS: readonly RegExp[] = [
67-
/(?<=Bearer\s+)[A-Za-z0-9_.+/=-]{10,}/gi,
68-
/(?<=(?:^|[^A-Za-z0-9])(?:[A-Za-z0-9]{1,128}_(?:KEY|TOKEN|SECRET|CREDENTIAL|PASSWORD|PASSWD|PASS)|(?:X[-_])?API[-_]KEY|TOKEN|SECRET|CREDENTIAL|PASSWORD|PASSWD|PASS)["']?(?:[ \t]{0,32}[=:][ \t]{0,32}|[ \t]{1,32})["']?)[^\s'"]{10,}/gi,
69-
/(?<=(?:^|[^A-Za-z0-9])(?:[A-Za-z0-9]{1,128}(?:Token|Secret|Credential)|[A-Za-z0-9]{0,128}(?:[Aa]ccess|[Rr]efresh|[Cc]lient|[Bb]earer|[Aa]uth|[Aa][Pp][Ii]|[Pp]rivate|[Ss]igning|[Ss]ession|[Bb]ot|[Aa]pp|[Rr]esolved)Key|[A-Za-z0-9]{1,128}(?:Password|Passwd|Pass))["']?(?:[ \t]{0,32}[=:][ \t]{0,32}|[ \t]{1,32})["']?)[^\s'"]{10,}/g,
70-
/(?<=(?:^|[^A-Za-z0-9])KEY["']?(?:[ \t]{0,32}[=:][ \t]{0,32}|[ \t]{1,32})["']?)[^\s'"]{10,}/g,
71-
];
72-
73-
/**
74-
* High-confidence raw secret shapes used as a value-level backstop.
75-
* Kept aligned with TOKEN_PREFIX / STRUCTURED / SECRET_BLOCK patterns from
76-
* src/lib/security/secret-patterns.ts (plugin package cannot import src/lib).
77-
*/
78-
const VALUE_SECRET_PATTERNS: readonly RegExp[] = [
79-
/nvapi-[A-Za-z0-9_-]{10,}/,
80-
/nvcf-[A-Za-z0-9_-]{10,}/,
81-
/ghp_[A-Za-z0-9_-]{10,}/,
82-
/(?:github_pat_)[A-Za-z0-9_]{30,}/,
83-
/sk-proj-[A-Za-z0-9_-]{10,}/,
84-
/sk-ant-[A-Za-z0-9_-]{10,}/,
85-
/sk-[A-Za-z0-9_-]{20,}/,
86-
/(?:xox[bpas]|xapp)-[A-Za-z0-9-]{10,}/,
87-
/A(?:K|S)IA[A-Z0-9]{16}/,
88-
/hf_[A-Za-z0-9]{10,}/,
89-
/glpat-[A-Za-z0-9_-]{10,}/,
90-
/gsk_[A-Za-z0-9]{10,}/,
91-
/pypi-[A-Za-z0-9_-]{10,}/,
92-
/\bbot\d{8,10}:[A-Za-z0-9_-]{35}\b/,
93-
/\b\d{8,10}:[A-Za-z0-9_-]{35}\b/,
94-
/\b[A-Za-z0-9]{24}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,}\b/,
95-
/tvly-[A-Za-z0-9_-]{10,}/,
96-
/lsv2_(?:pt|sk)_[A-Za-z0-9]{10,}(?:_[A-Za-z0-9]+)*/,
97-
/\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{10,}\b/,
98-
/-----BEGIN (?:[A-Z0-9]+ )?PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z0-9]+ )?PRIVATE KEY-----/,
99-
...CONTEXT_SECRET_PATTERNS,
100-
];
101-
102-
function hasPassCredentialSegment(key: string): boolean {
103-
const normalized = key
104-
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
105-
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
106-
.replace(/[^A-Za-z0-9]+/g, "_")
107-
.replace(/^_+|_+$/g, "")
108-
.toLowerCase();
109-
return (
110-
normalized === "pass" ||
111-
normalized === "passwd" ||
112-
normalized.endsWith("_pass") ||
113-
normalized.endsWith("_passwd")
114-
);
115-
}
116-
117-
export function isCredentialField(key: string): boolean {
118-
if (PUBLIC_KEY_FIELD_PATTERN.test(key)) return false;
119-
return (
120-
CREDENTIAL_FIELDS.has(key.toLowerCase()) ||
121-
CREDENTIAL_FIELD_PATTERN.test(key) ||
122-
hasPassCredentialSegment(key) ||
123-
ENV_SECRET_FIELD_PATTERN.test(key) ||
124-
HEADER_CREDENTIAL_PATTERN.test(key) ||
125-
CREDENTIAL_HEADER_NAMES.has(key.toLowerCase())
126-
);
127-
}
128-
129-
export function valueLooksLikeSecret(value: string): boolean {
130-
return VALUE_SECRET_PATTERNS.some((pattern) => {
131-
pattern.lastIndex = 0;
132-
const matched = pattern.test(value);
133-
pattern.lastIndex = 0;
134-
return matched;
135-
});
136-
}
137-
138-
export function isSafeCredentialPlaceholder(value: unknown): boolean {
139-
if (typeof value !== "string") return false;
140-
const withoutScheme = value.replace(/^Bearer\s+/i, "");
141-
if (
142-
SAFE_CREDENTIAL_PLACEHOLDER_LITERALS.has(value) ||
143-
SAFE_CREDENTIAL_PLACEHOLDER_LITERALS.has(withoutScheme)
144-
) {
145-
return true;
146-
}
147-
return SAFE_CREDENTIAL_PLACEHOLDER_PATTERNS.some((pattern) => pattern.test(value));
148-
}
149-
150-
function scrubConfigValue(value: unknown): unknown {
151-
if (typeof value === "string") {
152-
if (isSafeCredentialPlaceholder(value)) return value;
153-
return valueLooksLikeSecret(value) ? CREDENTIAL_PLACEHOLDER : value;
154-
}
155-
return stripCredentials(value);
156-
}
157-
158-
function cliFlagName(token: string): string | null {
159-
const match = /^--?([A-Za-z0-9][A-Za-z0-9._-]*)$/.exec(token);
160-
return match ? match[1] : null;
161-
}
162-
163-
function scrubArrayElement(value: unknown, previous: unknown): unknown {
164-
if (typeof value !== "string") return stripCredentials(value);
165-
if (isSafeCredentialPlaceholder(value)) return value;
166-
167-
const eq = value.indexOf("=");
168-
if (eq > 0 && value.startsWith("-")) {
169-
const flagName = cliFlagName(value.slice(0, eq));
170-
if (flagName && isCredentialField(flagName)) {
171-
const inlineValue = value.slice(eq + 1);
172-
return isSafeCredentialPlaceholder(inlineValue)
173-
? value
174-
: `${value.slice(0, eq)}=${CREDENTIAL_PLACEHOLDER}`;
175-
}
176-
}
177-
178-
if (!value.startsWith("-") && typeof previous === "string") {
179-
const prevFlag = cliFlagName(previous);
180-
if (prevFlag && isCredentialField(prevFlag)) return CREDENTIAL_PLACEHOLDER;
181-
}
182-
183-
return valueLooksLikeSecret(value) ? CREDENTIAL_PLACEHOLDER : value;
184-
}
185-
186-
/**
187-
* Recursively strip credential fields from a JSON-like object.
188-
*/
189-
export function stripCredentials(obj: unknown): unknown {
190-
if (obj === null || obj === undefined) return obj;
191-
if (typeof obj !== "object") return obj;
192-
if (Array.isArray(obj)) {
193-
return obj.map((value, index) => scrubArrayElement(value, obj[index - 1]));
194-
}
195-
if (!isObjectRecord(obj)) return obj;
196-
197-
const result: UnknownRecord = {};
198-
for (const [key, value] of Object.entries(obj)) {
199-
if (isCredentialField(key)) {
200-
result[key] =
201-
value === null || value === undefined || isSafeCredentialPlaceholder(value)
202-
? value
203-
: CREDENTIAL_PLACEHOLDER;
204-
} else {
205-
result[key] = scrubConfigValue(value);
206-
}
207-
}
208-
return result;
209-
}
210-
211-
/**
212-
* Strip credentials from a shell-style environment file body.
213-
*
214-
* Field-name matching handles ordinary secret variables while the value-shape
215-
* backstop catches provider tokens stored under an otherwise benign key.
216-
*/
217-
export function sanitizeEnvFileContent(content: string): string {
218-
return content
219-
.split("\n")
220-
.map((line) => {
221-
const trimmed = line.trim();
222-
if (trimmed === "" || trimmed.startsWith("#")) return line;
223-
const eq = line.indexOf("=");
224-
if (eq <= 0) return line;
225-
const rawKey = line.slice(0, eq).trim();
226-
const key = rawKey.replace(/^export\s+/i, "").trim();
227-
const value = line.slice(eq + 1);
228-
if (isSafeCredentialPlaceholder(value)) return line;
229-
if (!key) return line;
230-
if (!isCredentialField(key) && !valueLooksLikeSecret(value)) return line;
231-
return `${line.slice(0, eq)}=${CREDENTIAL_PLACEHOLDER}`;
232-
})
233-
.join("\n");
234-
}
235-
236-
export function isSensitiveFile(filename: string): boolean {
237-
return CREDENTIAL_SENSITIVE_BASENAMES.has(filename.toLowerCase());
238-
}
10+
CREDENTIAL_SENSITIVE_BASENAMES,
11+
isConfigObject,
12+
isConfigValue,
13+
isCredentialField,
14+
isSafeCredentialPlaceholder,
15+
isSensitiveFile,
16+
sanitizeEnvFileContent,
17+
stripCredentials,
18+
valueLooksLikeSecret,
19+
} from "../shared/credential-filter-boundary.cjs";
20+
21+
export type { ConfigObject, ConfigValue } from "../shared/credential-filter-boundary.cjs";

0 commit comments

Comments
 (0)