|
1 | 1 | /** |
2 | | - * Patterns that indicate a parameter might carry a secret value. |
3 | | - * Case-insensitive matching against JSON Schema property names. |
| 2 | + * Single words that on their own signal "this is a credential". |
| 3 | + * Matched after splitting a parameter name into camelCase/snake/kebab words. |
| 4 | + * So `accessToken` → `['access', 'token']` → matches `token`, |
| 5 | + * but `tokenizer` → `['tokenizer']` → does NOT match `token`. |
4 | 6 | */ |
5 | | -const SECRET_PATTERNS = |
6 | | - /^(api[_-]?key|secret|token|password|credential|auth[_-]?token|access[_-]?key|private[_-]?key)$/i |
| 7 | +const DANGEROUS_WORDS = new Set<string>([ |
| 8 | + 'password', |
| 9 | + 'passwd', |
| 10 | + 'pwd', |
| 11 | + 'passcode', |
| 12 | + 'secret', |
| 13 | + 'token', |
| 14 | + 'credential', |
| 15 | + 'credentials', |
| 16 | + 'authorization', |
| 17 | + 'jwt', |
| 18 | + 'bearer', |
| 19 | +]) |
| 20 | + |
| 21 | +/** |
| 22 | + * Compound patterns matched as substrings of the normalized (lowercased, |
| 23 | + * separator-stripped) parameter name. Catches forms like `openai_api_key`, |
| 24 | + * `x-api-key`, and `webhookSecret`. |
| 25 | + */ |
| 26 | +const COMPOUND_PATTERNS = [ |
| 27 | + 'apikey', |
| 28 | + 'accesskey', |
| 29 | + 'authkey', |
| 30 | + 'privatekey', |
| 31 | + 'clientsecret', |
| 32 | + 'webhooksecret', |
| 33 | +] as const |
| 34 | + |
| 35 | +export interface SecretParameterInfo { |
| 36 | + toolName: string |
| 37 | + paramName: string |
| 38 | + paramPath: Array<string> |
| 39 | +} |
| 40 | + |
| 41 | +export type SecretParameterHandler = |
| 42 | + | 'warn' |
| 43 | + | 'throw' |
| 44 | + | 'ignore' |
| 45 | + | ((info: SecretParameterInfo) => void) |
7 | 46 |
|
8 | 47 | interface ToolLike { |
9 | 48 | name: string |
10 | | - inputSchema?: { type?: string; properties?: Record<string, unknown> } |
| 49 | + inputSchema?: Record<string, unknown> |
| 50 | +} |
| 51 | + |
| 52 | +interface JsonSchemaLike { |
| 53 | + type?: string |
| 54 | + properties?: Record<string, JsonSchemaLike> |
| 55 | + items?: JsonSchemaLike | Array<JsonSchemaLike> |
| 56 | + anyOf?: Array<JsonSchemaLike> |
| 57 | + oneOf?: Array<JsonSchemaLike> |
| 58 | + allOf?: Array<JsonSchemaLike> |
| 59 | + additionalProperties?: boolean | JsonSchemaLike |
| 60 | + $ref?: string |
| 61 | + $defs?: Record<string, JsonSchemaLike> |
| 62 | + definitions?: Record<string, JsonSchemaLike> |
| 63 | +} |
| 64 | + |
| 65 | +function splitIntoWords(name: string): Array<string> { |
| 66 | + return name |
| 67 | + .replace(/[_\-\s]+/g, ' ') |
| 68 | + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') |
| 69 | + .toLowerCase() |
| 70 | + .split(/\s+/) |
| 71 | + .filter(Boolean) |
| 72 | +} |
| 73 | + |
| 74 | +function looksLikeSecret(name: string): boolean { |
| 75 | + const words = splitIntoWords(name) |
| 76 | + if (words.some((w) => DANGEROUS_WORDS.has(w))) return true |
| 77 | + const normalized = name.replace(/[_\-\s]/g, '').toLowerCase() |
| 78 | + return COMPOUND_PATTERNS.some((p) => normalized.includes(p)) |
| 79 | +} |
| 80 | + |
| 81 | +function resolveRef( |
| 82 | + ref: string, |
| 83 | + root: JsonSchemaLike, |
| 84 | +): JsonSchemaLike | undefined { |
| 85 | + const match = ref.match(/^#\/(\$defs|definitions)\/(.+)$/) |
| 86 | + if (!match) return undefined |
| 87 | + const bucket = match[1] as '$defs' | 'definitions' |
| 88 | + return root[bucket]?.[match[2]!] |
| 89 | +} |
| 90 | + |
| 91 | +function findSecretParams( |
| 92 | + schema: JsonSchemaLike | undefined, |
| 93 | + root: JsonSchemaLike, |
| 94 | + seen: Set<object>, |
| 95 | + path: Array<string>, |
| 96 | + found: Array<{ path: Array<string>; name: string }>, |
| 97 | +): void { |
| 98 | + if (!schema || typeof schema !== 'object' || seen.has(schema)) return |
| 99 | + seen.add(schema) |
| 100 | + |
| 101 | + if (schema.properties && typeof schema.properties === 'object') { |
| 102 | + for (const [paramName, sub] of Object.entries(schema.properties)) { |
| 103 | + if (looksLikeSecret(paramName)) { |
| 104 | + found.push({ path: [...path, paramName], name: paramName }) |
| 105 | + } |
| 106 | + findSecretParams(sub, root, seen, [...path, paramName], found) |
| 107 | + } |
| 108 | + } |
| 109 | + |
| 110 | + if (Array.isArray(schema.items)) { |
| 111 | + schema.items.forEach((s, i) => |
| 112 | + findSecretParams(s, root, seen, [...path, `[${i}]`], found), |
| 113 | + ) |
| 114 | + } else if (schema.items && typeof schema.items === 'object') { |
| 115 | + findSecretParams(schema.items, root, seen, [...path, '[]'], found) |
| 116 | + } |
| 117 | + |
| 118 | + if ( |
| 119 | + schema.additionalProperties && |
| 120 | + typeof schema.additionalProperties === 'object' |
| 121 | + ) { |
| 122 | + findSecretParams(schema.additionalProperties, root, seen, path, found) |
| 123 | + } |
| 124 | + |
| 125 | + for (const key of ['anyOf', 'oneOf', 'allOf'] as const) { |
| 126 | + const arr = schema[key] |
| 127 | + if (Array.isArray(arr)) { |
| 128 | + arr.forEach((s) => findSecretParams(s, root, seen, path, found)) |
| 129 | + } |
| 130 | + } |
| 131 | + |
| 132 | + if (typeof schema.$ref === 'string') { |
| 133 | + const target = resolveRef(schema.$ref, root) |
| 134 | + if (target) findSecretParams(target, root, seen, path, found) |
| 135 | + } |
| 136 | +} |
| 137 | + |
| 138 | +function buildMessage(toolName: string, paramPath: Array<string>): string { |
| 139 | + return ( |
| 140 | + `[TanStack AI Code Mode] Tool "${toolName}" has parameter "${paramPath.join('.')}" ` + |
| 141 | + `that looks like a secret. Code Mode executes LLM-generated code — any ` + |
| 142 | + `value passed through this parameter is accessible to generated code and ` + |
| 143 | + `could be exfiltrated. Keep secrets in your server-side tool implementation ` + |
| 144 | + `instead of passing them as tool parameters.` |
| 145 | + ) |
11 | 146 | } |
12 | 147 |
|
13 | 148 | /** |
14 | 149 | * Scan tool input schemas for parameter names that look like secrets. |
15 | | - * Emits console.warn for each match so developers notice during development. |
| 150 | + * Emits a warning (or invokes the configured handler) for each match. |
| 151 | + * |
| 152 | + * Recurses into nested object properties, array items, union branches |
| 153 | + * (anyOf/oneOf/allOf), additionalProperties, and `$ref` targets that |
| 154 | + * resolve within the same schema's `$defs`/`definitions`. |
16 | 155 | * |
17 | | - * This is a best-effort heuristic, not a security boundary. |
| 156 | + * Best-effort heuristic, not a security boundary. |
18 | 157 | */ |
19 | | -export function warnIfBindingsExposeSecrets(tools: Array<ToolLike>): void { |
| 158 | +export function warnIfBindingsExposeSecrets( |
| 159 | + tools: Array<ToolLike>, |
| 160 | + options: { |
| 161 | + handler?: SecretParameterHandler |
| 162 | + dedupCache?: Set<string> |
| 163 | + } = {}, |
| 164 | +): void { |
| 165 | + const { handler = 'warn', dedupCache } = options |
| 166 | + if (handler === 'ignore') return |
| 167 | + |
20 | 168 | for (const tool of tools) { |
21 | | - const properties = tool.inputSchema?.properties |
22 | | - if (!properties) continue |
23 | | - |
24 | | - for (const paramName of Object.keys(properties)) { |
25 | | - if (SECRET_PATTERNS.test(paramName)) { |
26 | | - console.warn( |
27 | | - `[TanStack AI Code Mode] Tool "${tool.name}" has parameter "${paramName}" ` + |
28 | | - `that looks like a secret. Code Mode executes LLM-generated code — any ` + |
29 | | - `value passed through this parameter is accessible to generated code and ` + |
30 | | - `could be exfiltrated. Keep secrets in your server-side tool implementation ` + |
31 | | - `instead of passing them as tool parameters.`, |
32 | | - ) |
| 169 | + const schema = tool.inputSchema as JsonSchemaLike | undefined |
| 170 | + if (!schema) continue |
| 171 | + |
| 172 | + const found: Array<{ path: Array<string>; name: string }> = [] |
| 173 | + findSecretParams(schema, schema, new Set(), [], found) |
| 174 | + |
| 175 | + for (const entry of found) { |
| 176 | + const dedupKey = `${tool.name}::${entry.path.join('.')}` |
| 177 | + if (dedupCache) { |
| 178 | + if (dedupCache.has(dedupKey)) continue |
| 179 | + dedupCache.add(dedupKey) |
| 180 | + } |
| 181 | + |
| 182 | + const info: SecretParameterInfo = { |
| 183 | + toolName: tool.name, |
| 184 | + paramName: entry.name, |
| 185 | + paramPath: entry.path, |
| 186 | + } |
| 187 | + |
| 188 | + if (typeof handler === 'function') { |
| 189 | + handler(info) |
| 190 | + } else if (handler === 'throw') { |
| 191 | + throw new Error(buildMessage(tool.name, entry.path)) |
| 192 | + } else { |
| 193 | + console.warn(buildMessage(tool.name, entry.path)) |
33 | 194 | } |
34 | 195 | } |
35 | 196 | } |
|
0 commit comments