diff --git a/CHANGELOG.md b/CHANGELOG.md index b942314..2082145 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to DebugMCP will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). +## [2.3.0] - 2026-07-28 + +### Changed +- **`get_variables_values` now retrieves variables by name.** Pass an explicit `variableNames` array; only the variables you name are returned (max 50, no wildcards). Unknown names are reported back so the caller gets feedback instead of silence. + +### Added +- **`list_variable_names` tool** - lists the names and types of variables at the current execution point without reading any values, so an agent can discover what is in scope and then request only what it needs. + +### Breaking +- `get_variables_values` calls without a `variableNames` array now fail with an actionable error pointing at `list_variable_names`. + ## [2.1.0] - 2026-06-23 ### Added diff --git a/README.md b/README.md index 99f985d..d77cc41 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Let AI agents debug your code inside VS Code - set breakpoints, step through exe [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![VS Code](https://img.shields.io/badge/VS%20Code-1.104.0+-blue.svg)](https://code.visualstudio.com/) -[![Version](https://img.shields.io/badge/version-2.2.4-green.svg)](https://github.com/microsoft/DebugMCP) +[![Version](https://img.shields.io/badge/version-2.3.0-green.svg)](https://github.com/microsoft/DebugMCP) [![VS Marketplace](https://img.shields.io/badge/VS%20Marketplace-Install-blue.svg)](https://marketplace.visualstudio.com/items?itemName=ozzafar.debugmcpextension) > ⭐ **If you find DebugMCP useful, please [star the repo on GitHub](https://github.com/microsoft/DebugMCP)!** It helps others discover the project and motivates continued development. @@ -65,7 +65,8 @@ DebugMCP is an MCP server that gives AI coding agents full control over the VS C | **remove_breakpoint** | Remove a breakpoint from a specific line | `fileFullPath` (required)
`line` (required) | | **clear_all_breakpoints** | Remove all breakpoints at once | None | | **list_breakpoints** | List all active breakpoints | None | -| **get_variables_values** | Get variables and their values at current execution point | `scope` (optional: 'local', 'global', 'all') | +| **list_variable_names** | List names and types of variables in scope, without reading any values | `scope` (optional: 'local', 'global', 'all') | +| **get_variables_values** | Get the values of specifically named variables at the current execution point | `variableNames` (required, e.g. `["user","response"]`)
`scope` (optional: 'local', 'global', 'all') | | **evaluate_expression** | Evaluate an expression in debug context | `expression` (required) | > **Note:** The MCP server exposes **tools** for debugger actions, while the procedural @@ -301,10 +302,12 @@ Configure DebugMCP behavior in VSCode settings: ### Security model -DebugMCP exposes powerful debugger primitives (`evaluate_expression`, `start_debugging`, …) over an unauthenticated local HTTP endpoint. To keep that surface safe, the server enforces two controls: +DebugMCP exposes powerful debugger primitives (`evaluate_expression`, `start_debugging`, …) over an unauthenticated local HTTP endpoint. To keep that surface safe, the server enforces four controls: 1. **Loopback-only bind.** The HTTP server binds to the IPv4 and IPv6 loopback addresses (`127.0.0.1` and `::1`) by default, so other hosts on your network cannot reach `http://:3001/mcp`. Binding both families ensures clients that resolve `localhost` to either family connect successfully. The `debugmcp.bindHost` setting (string or array of strings) lets you opt into a different interface (for example, when forwarding the port into a remote container), but doing so exposes the unauthenticated debugger to anything that can route to that address — do not point it at `0.0.0.0` or a LAN address on an untrusted network. 2. **Host / Origin header validation.** Every request must carry a `Host` header naming a loopback address (`localhost`, `127.0.0.1`, or `[::1]`); any port suffix in the `Host` must also match the server's listening port. Requests with any other `Host` — including those that arrive via DNS rebinding from a malicious webpage — are rejected with HTTP 403. The same loopback check is applied to the `Origin` header when present. +3. **Least-privilege variable inspection.** `get_variables_values` requires an explicit `variableNames` list (max 50, no wildcards) and returns only those variables. It no longer dumps every variable in scope, which previously handed the agent unrelated process state that it never asked for. Use `list_variable_names` to discover what exists; that tool returns names and types only and never reads a value. +4. **Secret redaction on variable inspection.** Values with credential-bearing or whose values matches a known credential shape are replaced with `` before the response leaves the extension. The same applies to `evaluate_expression`, which is otherwise the trivial bypass. Secrets nested inside a rendered container (an environment mapping, a config object) are not scrubbed entry-by-entry — the decision is made from the variable's own name and value, so a struct is returned intact unless it is itself credential-named or its rendering carries a recognizable credential. Null-ish values (`None`, `undefined`, `''`) are never redacted so "my token is empty" bugs remain debuggable. Redaction is always on and cannot be turned off. ## FAQ diff --git a/docs/architecture/debugMCPServer.md b/docs/architecture/debugMCPServer.md index 643199c..eaef45b 100644 --- a/docs/architecture/debugMCPServer.md +++ b/docs/architecture/debugMCPServer.md @@ -120,7 +120,8 @@ error wins: | `add/remove_breakpoint` | Breakpoint management | | `clear_all_breakpoints` | Remove all breakpoints | | `list_breakpoints` | List active breakpoints | -| `get_variables_values` | Inspect variable values | +| `get_variables_values` | Read the values of specifically named variables | +| `list_variable_names` | List variable names/types in scope, without values | | `evaluate_expression` | Evaluate expressions | ## Configuration diff --git a/docs/architecture/debuggingHandler.md b/docs/architecture/debuggingHandler.md index 85b1455..79bf997 100644 --- a/docs/architecture/debuggingHandler.md +++ b/docs/architecture/debuggingHandler.md @@ -61,6 +61,28 @@ A state change is considered meaningful when any of these change: When debugging stops, the handler prompts AI agents to consider whether they found the root cause or just a symptom, encouraging deeper investigation. +### Secret Redaction + +Variable inspection is the point where live process memory crosses the trust boundary into +an AI agent (and usually a remote model provider). Two controls apply there: + +**Data minimization.** `handleGetVariables` requires an explicit `variableNames` list +(validated by `normalizeRequestedNames()`: non-empty, no wildcards, capped at +`maxRequestedVariables`) and returns only those variables. Unknown names are reported back +so the caller gets feedback instead of silence. `handleListVariableNames` exists for +discovery and returns names and types **only** — it never emits a value, so it needs no +redaction. + +**Redaction.** Values that are returned still pass through `src/utils/secretRedaction.ts`, +which withholds credential-looking values by name (`api_key`, `password`, `token`, …) and by +content (JWT, PEM private key, `AKIA…`, `ghp_…`, `Bearer …`, `Password=…`, …). +`handleEvaluateExpression` is covered too, since evaluating `os.environ` is the trivial +bypass for per-variable controls. Null-ish values are deliberately left intact so +missing-credential bugs stay debuggable. The decision is made from the variable's own name +and its own value only — a struct is never descended into, so a `config` object that happens +to contain a `password` field is returned intact. Redaction is unconditional — there is no +setting to disable it. + ## Key Code Locations - Class definition: `src/debuggingHandler.ts` @@ -68,6 +90,8 @@ When debugging stops, the handler prompts AI agents to consider whether they fou - State change detection: `waitForStateChange()`, `hasStateChanged()` - Session waiting: `waitForActiveDebugSession()` - State formatting: `formatDebugState()` +- Variable selection: `handleGetVariables()`, `handleListVariableNames()`, `normalizeRequestedNames()` +- Secret redaction: `src/utils/secretRedaction.ts` ## Design Patterns diff --git a/package-lock.json b/package-lock.json index 9ed7689..fd11c6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "debugmcpextension", - "version": "2.2.4", + "version": "2.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "debugmcpextension", - "version": "2.2.4", + "version": "2.3.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", diff --git a/package.json b/package.json index fcdb404..1e79afb 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "debugmcpextension", "displayName": "DebugMCP — Agentic Debugging for VS Code, Cursor & More", "description": "Your AI agent debugs for you — right inside VS Code, Cursor & other VS Code-based editors. Let Copilot, Cline, Cursor, Codex & any MCP agent set breakpoints, step through code, and inspect variables live instead of guessing from logs.", - "version": "2.2.4", + "version": "2.3.0", "publisher": "ozzafar", "author": { "name": "Oz Zafar", diff --git a/skills/debug-live/SKILL.md b/skills/debug-live/SKILL.md index b752b5c..7740497 100644 --- a/skills/debug-live/SKILL.md +++ b/skills/debug-live/SKILL.md @@ -16,6 +16,7 @@ allowed-tools: - step_out - continue_execution - pause_execution + - list_variable_names - get_variables_values - evaluate_expression --- @@ -60,9 +61,15 @@ If you can step through the code in a few tool calls, do that instead of specula 4. **Navigate and inspect.** Use `step_over`, `step_into`, `step_out`, `continue_execution` to move through code. Use `pause_execution` to interrupt a freely-running program (e.g. a busy loop or embedded target) when there is no breakpoint to stop at. - Use `get_variables_values` to see local/global state and + Use `list_variable_names` to see what is in scope (names and types only, no values), + then `get_variables_values` with the specific `variableNames` you care about, and `evaluate_expression` to test hypotheses live (call methods, read properties, run list comprehensions, etc.). + + > `get_variables_values` **requires** explicit `variableNames` — it will not dump the + > whole scope. This is deliberate: a scope dump hands you unrelated process state such + > as API keys, tokens and environment variables. Values that still look like credentials + > are replaced with ``; don't try to work around it. 5. **Find the root cause** (see framework below). Don't stop at the first wrong thing you see — trace it back to *why*. 6. **Clean up.** Call `clear_all_breakpoints` when you're done so you don't pollute the @@ -189,7 +196,8 @@ Before ending the debug session, confirm you can answer: add_breakpoint fileFullPath=/repo/src/calculate.py line=42 start_debugging fileFullPath=/repo/src/calculate.py workingDirectory=/repo # session pauses on the breakpoint -get_variables_values scope=local +list_variable_names scope=local +get_variables_values variableNames=["raw","total"] scope=local evaluate_expression expression="type(raw).__name__" step_into # … iterate until root cause found … @@ -202,7 +210,8 @@ add_breakpoint fileFullPath=C:\Repo\Calculator.Tests\CalculatorTests.cs line=1 start_debugging fileFullPath=C:\Repo\Calculator.Tests\CalculatorTests.cs workingDirectory=C:\Repo testName=Add_ReturnsSum # pauses inside the test step_into -get_variables_values +list_variable_names +get_variables_values variableNames=["result","expected"] ``` ### Verifying a fix without re-launching VS Code diff --git a/src/controlServer.ts b/src/controlServer.ts index 215938f..6c16816 100644 --- a/src/controlServer.ts +++ b/src/controlServer.ts @@ -107,6 +107,8 @@ export class ControlServer { return this.handler.handleListBreakpoints(); case 'handleGetVariables': return this.handler.handleGetVariables(args); + case 'handleListVariableNames': + return this.handler.handleListVariableNames(args); case 'handleEvaluateExpression': return this.handler.handleEvaluateExpression(args); default: diff --git a/src/debugMCPServer.ts b/src/debugMCPServer.ts index 4de9287..e5c886e 100644 --- a/src/debugMCPServer.ts +++ b/src/debugMCPServer.ts @@ -261,7 +261,7 @@ export class DebugMCPServer { // Add breakpoint tool server.registerTool('add_breakpoint', { - description: 'Set a breakpoint to pause execution at a critical line of code. Essential for debugging: pause before potential errors, examine state at decision points, or verify code paths. Breakpoints let you inspect variables and control flow at exact moments. Provide an optional condition to create a conditional breakpoint that only pauses when the expression evaluates to true (e.g. "i == 5" or "user.id === null").', + description: 'Set a breakpoint to pause execution at a critical line of code. Breakpoints let you inspect variables and control flow at exact moments.', inputSchema: { fileFullPath: z.string().describe('Full path to the file'), line: z.number().int().describe('Line number (1-based) where the breakpoint should be set'), @@ -272,7 +272,7 @@ export class DebugMCPServer { // Add logpoint tool server.registerTool('add_logpoint', { - description: 'Add a logpoint: a breakpoint that logs a message instead of pausing execution. Ideal for tracing values across many iterations or hot paths without stopping, or where a hard pause would distort timing. Embed expressions in curly braces to interpolate runtime values, e.g. "user id={user.id}, count={items.length}". Provide an optional condition to only log when it evaluates to true.', + description: 'Add a logpoint: a breakpoint that logs a message instead of pausing execution. Ideal for tracing values across many iterations or hot paths without stopping, or where a hard pause would distort timing. Embed expressions in curly braces to interpolate runtime values, e.g. "user id={user.id}".', inputSchema: { fileFullPath: z.string().describe('Full path to the file'), line: z.number().int().describe('Line number (1-based) where the logpoint should be set'), @@ -302,18 +302,29 @@ export class DebugMCPServer { description: 'View all currently set breakpoints across all files.', }, async () => this.runTool('list_breakpoints', () => debuggingHandler.handleListBreakpoints())); + // List variable names tool (discovery without reading any values) + server.registerTool('list_variable_names', { + description: 'List the names and types of variables visible at the current execution point, without reading their values. Use this to discover what exists, then request the variables you actually need with get_variables_values.', + inputSchema: { + scope: z.enum(['local', 'global', 'all']).optional().describe("Variable scope: 'local', 'global', or 'all'"), + }, + }, async (args: { scope?: 'local' | 'global' | 'all' }) => + this.runTool('list_variable_names', () => debuggingHandler.handleListVariableNames(args))); + // Get variables tool server.registerTool('get_variables_values', { - description: 'Inspect all variable values at the current execution point. This is your window into program state - see what data looks like at runtime, verify assumptions, identify unexpected values, and understand why code behaves as it does.', + description: 'Read the values of specific named variables at the current execution point.', inputSchema: { + variableNames: z.array(z.string()).min(1).max(50) + .describe('Names of the variables to read, e.g. ["user", "response"]. Required - wildcards are not supported.'), scope: z.enum(['local', 'global', 'all']).optional().describe("Variable scope: 'local', 'global', or 'all'"), }, - }, async (args: { scope?: 'local' | 'global' | 'all' }) => + }, async (args: { variableNames: string[]; scope?: 'local' | 'global' | 'all' }) => this.runTool('get_variables_values', () => debuggingHandler.handleGetVariables(args))); // Evaluate expression tool server.registerTool('evaluate_expression', { - description: 'Powerful runtime expression evaluator: Test hypotheses, check computed values, call methods, or inspect object properties in the live debug context. Goes beyond simple variable inspection - evaluate any valid expression in the target language.', + description: 'Powerful runtime expression evaluator: Test hypotheses, check computed values, call methods, or inspect object properties in the live debug context. Evaluate any valid expression in the target language.', inputSchema: { expression: z.string().describe('Expression to evaluate in the current programming language context'), }, diff --git a/src/debuggingHandler.ts b/src/debuggingHandler.ts index 08551b1..bcb9ae0 100644 --- a/src/debuggingHandler.ts +++ b/src/debuggingHandler.ts @@ -5,6 +5,7 @@ import { DebugConfigurationManager, IDebugConfigurationManager } from './utils/d import { DebugState } from './debugState'; import { IDebuggingExecutor } from './debuggingExecutor'; import { logger } from './utils/logger'; +import { redactExpressionResult, redactVariableValue, REDACTION_NOTICE } from './utils/secretRedaction'; /** * Interface for debugging handler operations @@ -23,7 +24,8 @@ export interface IDebuggingHandler { handleRemoveBreakpoint(args: { fileFullPath: string; line: number }): Promise; handleClearAllBreakpoints(): Promise; handleListBreakpoints(): Promise; - handleGetVariables(args: { scope?: 'local' | 'global' | 'all' }): Promise; + handleGetVariables(args: { variableNames: string[]; scope?: 'local' | 'global' | 'all' }): Promise; + handleListVariableNames(args?: { scope?: 'local' | 'global' | 'all' }): Promise; handleEvaluateExpression(args: { expression: string }): Promise; } @@ -421,49 +423,208 @@ export class DebuggingHandler implements IDebuggingHandler { } /** - * Get variables from current debug context + * Maximum number of variable names accepted in a single request. Keeps the + * tool a targeted lookup rather than a scope dump by another name. */ - public async handleGetVariables(args: { scope?: 'local' | 'global' | 'all' }): Promise { + private readonly maxRequestedVariables: number = 50; + + /** + * Resolve the frame to inspect, failing with an actionable message when the + * debugger is not paused. + */ + private async requireActiveFrameId(): Promise { + if (!(await this.executor.hasActiveSession())) { + throw new Error('Debug session is not ready. Start debugging first and ensure execution is paused.'); + } + + const activeStackItem = vscode.debug.activeStackItem; + if (!activeStackItem || !('frameId' in activeStackItem)) { + throw new Error('No active stack frame. Make sure execution is paused at a breakpoint.'); + } + + return activeStackItem.frameId; + } + + /** + * Validate the caller-supplied variable names. Explicit names are required: + * returning every variable in scope hands the caller unrelated process + * state it never asked for. + */ + private normalizeRequestedNames(variableNames: string[] | undefined): string[] { + if (!Array.isArray(variableNames) || variableNames.length === 0) { + throw new Error( + "'variableNames' is required: name the variables you want (e.g. ['user', 'response']). " + + 'Use list_variable_names to discover what is in scope without reading any values.' + ); + } + + const names = variableNames + .filter((name): name is string => typeof name === 'string') + .map(name => name.trim()) + .filter(name => name.length > 0); + + if (names.length === 0) { + throw new Error("'variableNames' contained no usable names."); + } + + if (names.some(name => name === '*' || name.toLowerCase() === 'all')) { + throw new Error( + "Wildcards are not supported: name each variable explicitly, or call list_variable_names to see what is in scope." + ); + } + + if (names.length > this.maxRequestedVariables) { + throw new Error( + `Too many variables requested (${names.length}, max ${this.maxRequestedVariables}). ` + + 'Inspect the ones relevant to your hypothesis instead of the whole scope.' + ); + } + + return [...new Set(names)]; + } + + /** + * The canonical name for a DAP variable: the one an agent can pass back to + * `get_variables_values` or `evaluate_expression`. + * + * DAP splits these apart - `name` is for display and may carry an + * adapter-specific decoration (C#/vsdbg reports `config [Dictionary]`), + * while `evaluateName` is the real, evaluatable identifier. Prefer the + * latter; fall back to `name` verbatim, never parsed. + */ + private static canonicalVariableName(variable: any): string { + const evaluateName = variable?.evaluateName; + if (typeof evaluateName === 'string' && evaluateName.trim().length > 0) { + return evaluateName.trim(); + } + + const rawName = variable?.name; + return typeof rawName === 'string' ? rawName.trim() : rawName; + } + + /** + * Whether a DAP variable matches one of the requested names. Accepts the + * canonical name and the display name, both compared exactly. + */ + private static matchesRequestedName(variable: any, requestedNames: string[]): boolean { + const rawName = variable?.name; + if (typeof rawName === 'string' && requestedNames.includes(rawName)) { + return true; + } + + return requestedNames.includes(DebuggingHandler.canonicalVariableName(variable)); + } + + /** + * List the variable names (and types) visible at the current execution + * point, deliberately without any values, so an agent can discover what + * exists and then request only the ones it needs. + */ + public async handleListVariableNames(args: { scope?: 'local' | 'global' | 'all' } = {}): Promise { const { scope = 'all' } = args; - + try { - if (!(await this.executor.hasActiveSession())) { - throw new Error('Debug session is not ready. Start debugging first and ensure execution is paused.'); + const frameId = await this.requireActiveFrameId(); + const variablesData = await this.executor.getVariables(frameId, scope); + + if (!variablesData.scopes || variablesData.scopes.length === 0) { + return 'No variable scopes available at current execution point.'; } - const activeStackItem = vscode.debug.activeStackItem; - if (!activeStackItem || !('frameId' in activeStackItem)) { - throw new Error('No active stack frame. Make sure execution is paused at a breakpoint.'); + let info = 'Variables in scope (names only - no values were read):\n'; + info += '=====================================================\n\n'; + + for (const scopeItem of variablesData.scopes) { + info += `${scopeItem.name}:\n`; + + if (scopeItem.error) { + info += ` Error retrieving variables: ${scopeItem.error}\n`; + } else if (scopeItem.variables && scopeItem.variables.length > 0) { + for (const variable of scopeItem.variables) { + const name = DebuggingHandler.canonicalVariableName(variable); + info += ` ${name}${variable.type ? ` (${variable.type})` : ''}\n`; + } + } else { + info += ' No variables in this scope\n'; + } + + info += '\n'; } - const variablesData = await this.executor.getVariables(activeStackItem.frameId, scope); + info += "Use get_variables_values with the names you need, e.g. { \"variableNames\": [\"user\"] }.\n"; + return info; + } catch (error) { + throw new Error(`Error listing variable names: ${error}`); + } + } + + /** + * Get the values of specifically named variables in the current debug context. + */ + public async handleGetVariables(args: { variableNames: string[]; scope?: 'local' | 'global' | 'all' }): Promise { + const { scope = 'all' } = args; + const requestedNames = this.normalizeRequestedNames(args?.variableNames); + + try { + const frameId = await this.requireActiveFrameId(); + const variablesData = await this.executor.getVariables(frameId, scope); if (!variablesData.scopes || variablesData.scopes.length === 0) { return 'No variable scopes available at current execution point.'; } let variablesInfo = 'Variables:\n==========\n\n'; + let redactedAny = false; + const foundNames = new Set(); for (const scopeItem of variablesData.scopes) { - variablesInfo += `${scopeItem.name}:\n`; - + const matches = (scopeItem.variables || []).filter((variable: any) => + DebuggingHandler.matchesRequestedName(variable, requestedNames)); + if (scopeItem.error) { - variablesInfo += ` Error retrieving variables: ${scopeItem.error}\n`; - } else if (scopeItem.variables && scopeItem.variables.length > 0) { - for (const variable of scopeItem.variables) { - variablesInfo += ` ${variable.name}: ${variable.value}`; - if (variable.type) { - variablesInfo += ` (${variable.type})`; - } - variablesInfo += '\n'; + variablesInfo += `${scopeItem.name}:\n Error retrieving variables: ${scopeItem.error}\n\n`; + continue; + } + + if (matches.length === 0) { + continue; + } + + variablesInfo += `${scopeItem.name}:\n`; + for (const variable of matches) { + const name = DebuggingHandler.canonicalVariableName(variable); + foundNames.add(name); + if (typeof variable.name === 'string') { + // So a caller who asked by the raw adapter name is not + // then told that name was not found. + foundNames.add(variable.name); } - } else { - variablesInfo += ' No variables in this scope\n'; + const result = redactVariableValue(name, variable.value); + const value = result.value; + redactedAny = redactedAny || result.redacted; + variablesInfo += ` ${name}: ${value}`; + if (variable.type) { + variablesInfo += ` (${variable.type})`; + } + variablesInfo += '\n'; } - variablesInfo += '\n'; } + const missing = requestedNames.filter(name => !foundNames.has(name)); + if (foundNames.size === 0) { + return `None of the requested variables (${requestedNames.join(', ')}) are visible at the current execution point. ` + + 'Use list_variable_names to see what is in scope, or evaluate_expression for nested/computed values.\n'; + } + if (missing.length > 0) { + variablesInfo += `Not found in any scope: ${missing.join(', ')}. ` + + 'They may be out of scope here, or nested inside an object - try evaluate_expression.\n'; + } + + if (redactedAny) { + variablesInfo += `${REDACTION_NOTICE}\n`; + } + return variablesInfo; } catch (error) { throw new Error(`Error getting variables: ${error}`); @@ -490,10 +651,14 @@ export class DebuggingHandler implements IDebuggingHandler { if (response && response.result !== undefined) { let resultText = `Expression: ${expression}\n`; - resultText += `Result: ${response.result}`; + const { value, redacted } = redactExpressionResult(expression, response.result); + resultText += `Result: ${value}`; if (response.type) { resultText += ` (${response.type})`; } + if (redacted) { + resultText += `\n\n${REDACTION_NOTICE}`; + } return resultText; } else { diff --git a/src/routingDebuggingHandler.ts b/src/routingDebuggingHandler.ts index 507fb44..aacc5d0 100644 --- a/src/routingDebuggingHandler.ts +++ b/src/routingDebuggingHandler.ts @@ -220,10 +220,14 @@ export class RoutingDebuggingHandler implements IDebuggingHandler { return this.forward('handleListBreakpoints', {}); } - public handleGetVariables(args: { scope?: 'local' | 'global' | 'all' }): Promise { + public handleGetVariables(args: { variableNames: string[]; scope?: 'local' | 'global' | 'all' }): Promise { return this.forward('handleGetVariables', args); } + public handleListVariableNames(args: { scope?: 'local' | 'global' | 'all' } = {}): Promise { + return this.forward('handleListVariableNames', args); + } + public handleEvaluateExpression(args: { expression: string }): Promise { return this.forward('handleEvaluateExpression', args); } diff --git a/src/test/getVariables.test.ts b/src/test/getVariables.test.ts new file mode 100644 index 0000000..b4a22b7 --- /dev/null +++ b/src/test/getVariables.test.ts @@ -0,0 +1,360 @@ +// Copyright (c) Microsoft Corporation. + +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { DebuggingHandler } from '../debuggingHandler'; +import { IDebuggingExecutor } from '../debuggingExecutor'; + +/** + * Executor stub that reports a paused session with a fixed set of scopes, + * so variable-selection behavior can be tested without a live debug adapter. + */ +function makeExecutor(scopes: any[]): IDebuggingExecutor { + return { + hasActiveSession: async () => true, + getVariables: async () => ({ scopes }) + } as unknown as IDebuggingExecutor; +} + +const SCOPES = [ + { + name: 'Locals', + variables: [ + { name: 'user', value: "{'id': 7}", type: 'dict' }, + { name: 'retries', value: '3', type: 'int' }, + { name: 'api_key', value: "'sk-abcdefghijklmnopqrst'", type: 'str' } + ] + }, + { + name: 'Globals', + variables: [ + { name: 'CONFIG_PATH', value: "'/etc/app.conf'", type: 'str' } + ] + } +]; + +suite('get_variables_values targeted retrieval', () => { + + // vscode.debug.activeStackItem is read-only; stub it for the duration of a test. + function withStubbedFrame(run: () => Promise): Promise { + const descriptor = Object.getOwnPropertyDescriptor(vscode.debug, 'activeStackItem'); + Object.defineProperty(vscode.debug, 'activeStackItem', { + configurable: true, + get: () => ({ frameId: 1, threadId: 1, session: {} }) + }); + return run().finally(() => { + if (descriptor) { + Object.defineProperty(vscode.debug, 'activeStackItem', descriptor); + } + }); + } + + function newHandler(): DebuggingHandler { + return new DebuggingHandler(makeExecutor(SCOPES), {} as any, 30); + } + + test('returns only the requested variables, not the whole scope', async () => { + await withStubbedFrame(async () => { + const output = await newHandler().handleGetVariables({ variableNames: ['user'] }); + assert.ok(output.includes('user:'), 'requested variable missing'); + assert.ok(!output.includes('retries'), 'unrequested variable leaked'); + assert.ok(!output.includes('CONFIG_PATH'), 'unrequested global leaked'); + }); + }); + + test('supports several names across scopes', async () => { + await withStubbedFrame(async () => { + const output = await newHandler().handleGetVariables({ variableNames: ['retries', 'CONFIG_PATH'] }); + assert.ok(output.includes('retries: 3')); + assert.ok(output.includes('CONFIG_PATH')); + assert.ok(!output.includes('user:'), 'unrequested variable leaked'); + }); + }); + + test('still redacts a requested secret-looking variable', async () => { + await withStubbedFrame(async () => { + const output = await newHandler().handleGetVariables({ variableNames: ['api_key'] }); + assert.ok(!output.includes('sk-abcdefghijklmnopqrst'), 'secret leaked'); + assert.ok(output.includes('')); + }); + }); + + test('rejects a missing or empty variableNames list', async () => { + const handler = newHandler(); + await assert.rejects( + () => handler.handleGetVariables({} as any), + /variableNames' is required/ + ); + await assert.rejects( + () => handler.handleGetVariables({ variableNames: [] }), + /variableNames' is required/ + ); + await assert.rejects( + () => handler.handleGetVariables({ variableNames: [' '] }), + /no usable names/ + ); + }); + + test('rejects wildcard requests that would dump the scope', async () => { + const handler = newHandler(); + await assert.rejects(() => handler.handleGetVariables({ variableNames: ['*'] }), /Wildcards are not supported/); + await assert.rejects(() => handler.handleGetVariables({ variableNames: ['all'] }), /Wildcards are not supported/); + }); + + test('rejects an oversized request', async () => { + const handler = newHandler(); + const many = Array.from({ length: 51 }, (_, i) => `v${i}`); + await assert.rejects(() => handler.handleGetVariables({ variableNames: many }), /Too many variables requested/); + }); + + test('reports unknown names instead of silently returning nothing', async () => { + await withStubbedFrame(async () => { + const output = await newHandler().handleGetVariables({ variableNames: ['user', 'nope'] }); + assert.ok(output.includes('user:')); + assert.ok(output.includes('Not found in any scope: nope')); + }); + }); + + test('explains how to recover when no requested name matches', async () => { + await withStubbedFrame(async () => { + const output = await newHandler().handleGetVariables({ variableNames: ['nope'] }); + assert.ok(output.includes('None of the requested variables')); + assert.ok(output.includes('list_variable_names')); + }); + }); +}); + +/** + * When an adapter supplies no evaluateName its `name` is used verbatim and is + * never parsed, so a display decoration is reported exactly as received rather + * than being guessed at. + */ +const SUFFIXED_SCOPES = [ + { + name: 'Locals', + variables: [ + { name: 'config [Dictionary]', value: 'Count = 5', type: 'System.Collections.Generic.Dictionary' }, + { name: 'api_key [string]', value: '"sk-abcdefghijklmnopqrst"', type: 'string' }, + { name: 'this', value: '{Calculator}', type: 'Calculator' } + ] + } +]; + +suite('variables without an evaluateName', () => { + + function withStubbedFrame(run: () => Promise): Promise { + const descriptor = Object.getOwnPropertyDescriptor(vscode.debug, 'activeStackItem'); + Object.defineProperty(vscode.debug, 'activeStackItem', { + configurable: true, + get: () => ({ frameId: 1, threadId: 1, session: {} }) + }); + return run().finally(() => { + if (descriptor) { + Object.defineProperty(vscode.debug, 'activeStackItem', descriptor); + } + }); + } + + function newHandler(): DebuggingHandler { + return new DebuggingHandler(makeExecutor(SUFFIXED_SCOPES), {} as any, 30); + } + + test('matches the raw adapter name exactly', async () => { + await withStubbedFrame(async () => { + const output = await newHandler().handleGetVariables({ variableNames: ['config [Dictionary]'] }); + assert.ok(output.includes('config [Dictionary]: Count = 5')); + assert.ok(!output.includes('Not found in any scope')); + }); + }); + + test('does not parse a display decoration out of the name', async () => { + await withStubbedFrame(async () => { + const output = await newHandler().handleGetVariables({ variableNames: ['config'] }); + assert.ok(output.includes('None of the requested variables'), + 'name must be used verbatim, not stripped'); + }); + }); + + test('lists the raw name verbatim', async () => { + await withStubbedFrame(async () => { + const output = await newHandler().handleListVariableNames({}); + assert.ok(output.includes('config [Dictionary] (System.Collections.Generic.Dictionary)')); + }); + }); + + test('plain names keep working', async () => { + await withStubbedFrame(async () => { + const output = await newHandler().handleGetVariables({ variableNames: ['this', 'nope'] }); + assert.ok(output.includes('this: {Calculator}')); + assert.ok(output.includes('Not found in any scope: nope')); + }); + }); + + test('redaction still applies to a decorated secret name', async () => { + await withStubbedFrame(async () => { + const output = await newHandler().handleGetVariables({ variableNames: ['api_key [string]'] }); + assert.ok(!output.includes('sk-abcdefghijklmnopqrst'), 'secret leaked'); + assert.ok(output.includes('')); + }); + }); +}); + +/** + * `Variable.evaluateName` is the adapter's canonical, evaluatable name and is + * preferred over any parsing of the display name. + */ +const EVALUATE_NAME_SCOPES = [ + { + name: 'Locals', + variables: [ + { + name: 'config [Dictionary]', + evaluateName: 'config', + value: 'Count = 5', + type: 'System.Collections.Generic.Dictionary' + }, + { + // A display name that no amount of parsing would recover. + name: 'matrix [int[,]]', + evaluateName: 'matrix', + value: '{int[2, 2]}', + type: 'int[,]' + } + ] + } +]; + +suite('evaluateName is preferred over the display name', () => { + + function withStubbedFrame(run: () => Promise): Promise { + const descriptor = Object.getOwnPropertyDescriptor(vscode.debug, 'activeStackItem'); + Object.defineProperty(vscode.debug, 'activeStackItem', { + configurable: true, + get: () => ({ frameId: 1, threadId: 1, session: {} }) + }); + return run().finally(() => { + if (descriptor) { + Object.defineProperty(vscode.debug, 'activeStackItem', descriptor); + } + }); + } + + function newHandler(): DebuggingHandler { + return new DebuggingHandler(makeExecutor(EVALUATE_NAME_SCOPES), {} as any, 30); + } + + test('resolves and reports the adapter-supplied evaluateName', async () => { + await withStubbedFrame(async () => { + const output = await newHandler().handleGetVariables({ variableNames: ['config'] }); + assert.ok(output.includes('config: Count = 5')); + assert.ok(!output.includes('[Dictionary]'), 'display decoration leaked into the name'); + }); + }); + + test('handles names no parsing of the display name could recover', async () => { + await withStubbedFrame(async () => { + const output = await newHandler().handleGetVariables({ variableNames: ['matrix'] }); + assert.ok(output.includes('matrix: {int[2, 2]}')); + assert.ok(!output.includes('None of the requested variables')); + }); + }); + + test('lists the evaluateName so it can be passed straight back', async () => { + await withStubbedFrame(async () => { + const output = await newHandler().handleListVariableNames({}); + assert.ok(output.includes('config (System.Collections.Generic.Dictionary)')); + assert.ok(output.includes('matrix (int[,])')); + assert.ok(!output.includes('[Dictionary]'), 'display decoration leaked into the listing'); + }); + }); +}); + +/** + * Names that legitimately end in brackets (array elements, raw views) must be + * reported and matched verbatim. + */ +const BRACKET_NAME_SCOPES = [ + { + name: 'Locals', + variables: [ + { name: '[0]', value: '7', type: 'int' }, + { name: 'arr[0]', value: '8', type: 'int' }, + { name: '[Raw View]', value: '{...}', type: 'view' } + ] + } +]; + +suite('names that legitimately end in brackets', () => { + + function withStubbedFrame(run: () => Promise): Promise { + const descriptor = Object.getOwnPropertyDescriptor(vscode.debug, 'activeStackItem'); + Object.defineProperty(vscode.debug, 'activeStackItem', { + configurable: true, + get: () => ({ frameId: 1, threadId: 1, session: {} }) + }); + return run().finally(() => { + if (descriptor) { + Object.defineProperty(vscode.debug, 'activeStackItem', descriptor); + } + }); + } + + function newHandler(): DebuggingHandler { + return new DebuggingHandler(makeExecutor(BRACKET_NAME_SCOPES), {} as any, 30); + } + + test('an array element name is preserved, not collapsed to empty', async () => { + await withStubbedFrame(async () => { + const output = await newHandler().handleGetVariables({ variableNames: ['[0]'] }); + assert.ok(output.includes('[0]: 7'), 'array element name was mangled'); + }); + }); + + test('an indexed name is not collapsed onto its parent', async () => { + await withStubbedFrame(async () => { + const output = await newHandler().handleGetVariables({ variableNames: ['arr'] }); + assert.ok(output.includes('None of the requested variables'), + "'arr' must not resolve to the distinct variable 'arr[0]'"); + }); + }); + + test('listing reports bracketed names verbatim', async () => { + await withStubbedFrame(async () => { + const output = await newHandler().handleListVariableNames({}); + assert.ok(output.includes('[0] (int)')); + assert.ok(output.includes('arr[0] (int)')); + assert.ok(output.includes('[Raw View] (view)')); + }); + }); +}); + +suite('list_variable_names discovery', () => { + + function withStubbedFrame(run: () => Promise): Promise { + const descriptor = Object.getOwnPropertyDescriptor(vscode.debug, 'activeStackItem'); + Object.defineProperty(vscode.debug, 'activeStackItem', { + configurable: true, + get: () => ({ frameId: 1, threadId: 1, session: {} }) + }); + return run().finally(() => { + if (descriptor) { + Object.defineProperty(vscode.debug, 'activeStackItem', descriptor); + } + }); + } + + test('lists names and types but never any values', async () => { + await withStubbedFrame(async () => { + const handler = new DebuggingHandler(makeExecutor(SCOPES), {} as any, 30); + const output = await handler.handleListVariableNames({}); + + assert.ok(output.includes('user (dict)')); + assert.ok(output.includes('retries (int)')); + assert.ok(output.includes('api_key (str)')); + // No values, redacted or otherwise. + assert.ok(!output.includes('sk-abcdefghijklmnopqrst'), 'secret value leaked'); + assert.ok(!output.includes('/etc/app.conf'), 'value leaked'); + assert.ok(!output.includes('redacted'), 'listing should not need redaction'); + }); + }); +}); diff --git a/src/test/routing.test.ts b/src/test/routing.test.ts index 2f34343..4732f8b 100644 --- a/src/test/routing.test.ts +++ b/src/test/routing.test.ts @@ -33,6 +33,7 @@ class RecordingHandler implements IDebuggingHandler { handleClearAllBreakpoints() { return this.record('clearBp', {}); } handleListBreakpoints() { return this.record('listBp', {}); } handleGetVariables(args: any) { return this.record('vars', args); } + handleListVariableNames(args?: any) { return this.record('varNames', args ?? {}); } handleEvaluateExpression(args: any) { return this.record('eval', args); } } @@ -101,7 +102,7 @@ suite('Multi-window routing', () => { const routing = new RoutingDebuggingHandler(new WorkspaceRegistry(process.pid, dir)); await routing.handleStartDebugging({ fileFullPath: path.join(repoA, 'm.py'), workingDirectory: repoA }); const stepped = await routing.handleStepOver(); - const vars = await routing.handleGetVariables({ scope: 'local' }); + const vars = await routing.handleGetVariables({ variableNames: ['x'], scope: 'local' }); assert.strictEqual(stepped, 'A:stepOver'); assert.strictEqual(vars, 'A:vars'); diff --git a/src/test/secretRedaction.test.ts b/src/test/secretRedaction.test.ts new file mode 100644 index 0000000..cfc82ad --- /dev/null +++ b/src/test/secretRedaction.test.ts @@ -0,0 +1,174 @@ +// Copyright (c) Microsoft Corporation. + +import * as assert from 'assert'; +import { + REDACTION_PLACEHOLDER, + isSensitiveName, + looksLikeSecretValue, + redactExpressionResult, + redactVariableValue +} from '../utils/secretRedaction'; + +suite('Secret redaction', () => { + + suite('isSensitiveName', () => { + test('flags credential-bearing names', () => { + for (const name of [ + 'apiKey', 'API_KEY', 'api-key', 'openai_api_key', 'secret', 'clientSecret', + 'password', 'passwd', 'pwd', 'passphrase', 'accessToken', 'refresh_token', + 'credentials', 'privateKey', 'AUTHORIZATION', 'connectionString', 'connStr', + 'cookie', 'sessionKey', 'encryptionKey', 'sasToken', 'otp', 'bearerToken' + ]) { + assert.strictEqual(isSensitiveName(name), true, `expected ${name} to be sensitive`); + } + }); + + test('does not flag ordinary names', () => { + for (const name of ['author', 'count', 'userName', 'result', 'items', 'index', 'config']) { + assert.strictEqual(isSensitiveName(name), false, `expected ${name} to be benign`); + } + }); + + test('matches exactly, so names merely containing a credential word stay readable', () => { + for (const name of [ + 'tokenCount', 'cookieCount', 'tokenIndex', 'secretCount', + 'hasToken', 'passwordLength', 'tokenizer', 'subtokens' + ]) { + assert.strictEqual(isSensitiveName(name), false, `expected ${name} to be benign`); + } + }); + + test('ignores case and separator style', () => { + for (const name of ['API_KEY', 'api-key', 'apiKey', 'ApiKey', 'api key']) { + assert.strictEqual(isSensitiveName(name), true, `expected ${name} to be sensitive`); + } + }); + + test('keeps a benign counter debuggable end to end', () => { + const result = redactVariableValue('tokenCount', '42'); + assert.strictEqual(result.redacted, false); + assert.strictEqual(result.value, '42'); + }); + }); + + suite('looksLikeSecretValue', () => { + test('recognizes well-known credential shapes', () => { + const secrets = [ + 'AKIAIOSFODNN7EXAMPLE', + 'ghp_1234567890abcdefghijklmnopqrstuv', + 'github_pat_11ABCDEFG0abcdefghijklmnop', + 'xoxb-123456789012-abcdefghijkl', + 'AIzaSyA1234567890abcdefghijklmnopqrstuv', + 'sk-ant-api03-abcdefghijklmnopqrstuvwxyz012345', + 'sk_live_abcdefghijklmnop', + 'npm_abcdefghijklmnopqrstuvwxyz0123456789', + 'glpat-abcdefghijklmnopqrst', + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abcdef123456', + '-----BEGIN RSA PRIVATE KEY-----\nMIIEow==\n-----END RSA PRIVATE KEY-----', + 'Bearer abcdef0123456789ABCDEF' + ]; + for (const secret of secrets) { + assert.strictEqual(looksLikeSecretValue(secret), true, `expected secret detection for ${secret}`); + } + }); + + test('does not flag ordinary values', () => { + for (const value of ['42', 'hello world', '/usr/local/bin', 'None', 'user@example.com']) { + assert.strictEqual(looksLikeSecretValue(value), false, `expected ${value} to be benign`); + } + }); + }); + + suite('redactVariableValue', () => { + test('redacts values of sensitive variable names', () => { + const result = redactVariableValue('api_key', "'sk-proj-abcdefghijklmnop'"); + assert.strictEqual(result.redacted, true); + assert.strictEqual(result.value, REDACTION_PLACEHOLDER); + }); + + test('redacts secret-shaped values under an innocuous name', () => { + const result = redactVariableValue('x', 'ghp_1234567890abcdefghijklmnopqrstuv'); + assert.strictEqual(result.redacted, true); + assert.strictEqual(result.value, REDACTION_PLACEHOLDER); + }); + + test('keeps null-ish values visible so missing-credential bugs stay debuggable', () => { + for (const value of ['None', 'null', 'undefined', '', "''", '0', 'False']) { + const result = redactVariableValue('api_key', value); + assert.strictEqual(result.redacted, false, `expected ${value} to be kept`); + assert.strictEqual(result.value, value); + } + }); + + test('leaves ordinary variables untouched', () => { + const result = redactVariableValue('userCount', '42'); + assert.strictEqual(result.redacted, false); + assert.strictEqual(result.value, '42'); + }); + + test('returns a struct intact when the variable itself is not sensitive', () => { + const value = '{ "host": "db.example.com", "password": "hunter2-super-secret" }'; + const result = redactVariableValue('config', value); + assert.strictEqual(result.redacted, false, 'inner fields must not trigger redaction'); + assert.strictEqual(result.value, value); + }); + + test('redacts a struct whose own name is sensitive', () => { + const value = '{ "host": "db.example.com", "user": "admin" }'; + const result = redactVariableValue('credentials', value); + assert.strictEqual(result.redacted, true); + assert.strictEqual(result.value, REDACTION_PLACEHOLDER); + }); + + test('redacts a container whose own rendering carries a recognizable secret', () => { + const value = "environ({'PATH': '/usr/bin', 'GITHUB_TOKEN': 'ghp_1234567890abcdefghijklmnopqrstuv'})"; + const result = redactVariableValue('environ', value); + assert.strictEqual(result.redacted, true); + assert.strictEqual(result.value, REDACTION_PLACEHOLDER); + }); + + test('is idempotent - re-redacting the placeholder is a no-op', () => { + const once = redactVariableValue('api_key', 'ghp_1234567890abcdefghijklmnopqrstuv'); + const twice = redactVariableValue('api_key', once.value); + assert.strictEqual(twice.value, once.value); + assert.strictEqual((twice.value.match(/possible secret/g) || []).length, 1); + }); + }); + + suite('performance guards', () => { + test('does not backtrack catastrophically on adversarial input', () => { + const evil = `{'api_key': "${'\\'.repeat(50000)}`; + const start = Date.now(); + redactVariableValue('blob', evil); + const elapsed = Date.now() - start; + assert.ok(elapsed < 1000, `redaction took ${elapsed}ms - patterns are backtracking`); + }); + + test('stays fast on a long identifier run that matches nothing', () => { + const start = Date.now(); + redactVariableValue('blob', 'a_'.repeat(40000) + '=x'); + const elapsed = Date.now() - start; + assert.ok(elapsed < 1000, `redaction took ${elapsed}ms`); + }); + }); + + suite('redactExpressionResult', () => { + test('blocks the evaluate_expression bypass for sensitive expressions', () => { + const result = redactExpressionResult('os.environ["OPENAI_API_KEY"]', "'sk-abcdefghijklmnopqrst'"); + assert.strictEqual(result.redacted, true); + assert.strictEqual(result.value, REDACTION_PLACEHOLDER); + }); + + test('blocks dumping the whole environment via evaluate_expression', () => { + const result = redactExpressionResult('dict(os.environ)', "{'HOME': '/home/u', 'GITHUB_TOKEN': 'ghp_1234567890abcdefghijklmnopqrstuv'}"); + assert.strictEqual(result.redacted, true); + assert.ok(!result.value.includes('ghp_1234567890abcdefghijklmnopqrstuv')); + }); + + test('leaves ordinary evaluations untouched', () => { + const result = redactExpressionResult('len(items)', '3'); + assert.strictEqual(result.redacted, false); + assert.strictEqual(result.value, '3'); + }); + }); +}); diff --git a/src/utils/secretRedaction.ts b/src/utils/secretRedaction.ts new file mode 100644 index 0000000..07d16ce --- /dev/null +++ b/src/utils/secretRedaction.ts @@ -0,0 +1,180 @@ +// Copyright (c) Microsoft Corporation. + +/** + * Redaction of secret-looking runtime values before they leave the extension. + * + * Debug adapters happily hand back every variable in scope, which routinely + * includes API keys, tokens, passwords and whole `os.environ` / `process.env` + * dumps. Those values are then streamed to an AI agent (and usually to a remote + * model provider). This module scrubs values that look like credentials so + * inspecting program state does not exfiltrate them. + * + * The heuristics are deliberately conservative in one direction: values that + * are empty, null-ish or otherwise carry no secret material are left intact so + * "why is my token undefined?" remains debuggable. + */ + +export const REDACTION_PLACEHOLDER = ''; + +export const REDACTION_NOTICE = + `NOTE: values matching '${REDACTION_PLACEHOLDER}' were withheld because their name or ` + + 'content looks like a credential (key, token, password, connection string, ...). ' + + 'Use debug-specific checks (type, length, is-null) instead of reading the raw value.'; + +/** + * Names that mark a variable as credential-bearing. + * + * Matched *exactly* (case-insensitively, ignoring `_`/`-` separators) rather + * than as substrings: a substring rule flags any name merely containing + * 'token' or 'cookie', so benign variables like `tokenCount` or `cookieCount` + * get withheld and stop being debuggable. + * + * The cost of exact matching is that arbitrary compound names are no longer + * inferred, so widely used compound forms are listed explicitly below. + */ +const SENSITIVE_NAMES = new Set([ + // Keys + 'apikey', 'apikeys', 'apisecret', 'apisecretkey', 'accesskey', 'accesskeyid', + 'secretkey', 'secretaccesskey', 'privatekey', 'publicprivatekey', 'encryptionkey', + 'signingkey', 'sessionkey', 'masterkey', 'clientkey', 'sshkey', 'gpgkey', 'saskey', + // Secrets + 'secret', 'secrets', 'clientsecret', 'consumersecret', + // Passwords + 'password', 'passwords', 'passwd', 'pwd', 'pass', 'passphrase', + 'dbpassword', 'dbpasswd', 'dbpass', 'rootpassword', 'adminpassword', 'userpassword', + // Tokens + 'token', 'tokens', 'accesstoken', 'refreshtoken', 'idtoken', 'authtoken', 'apitoken', + 'sessiontoken', 'bearertoken', 'bearer', 'oauthtoken', 'personalaccesstoken', + 'csrftoken', 'xsrftoken', 'sastoken', 'jwt', + // Credentials / auth + 'credential', 'credentials', 'authorization', 'auth', 'otp', + // Sessions and cookies + 'cookie', 'cookies', 'sessionid', + // Connection strings + 'connectionstring', 'connstr', 'accountkey', 'sasurl', + // Common environment-variable spellings + 'openaiapikey', 'anthropicapikey', 'awssecretaccesskey', 'awsaccesskeyid', + 'awssessiontoken', 'githubtoken', 'ghtoken', 'gitlabtoken', 'npmtoken', 'slacktoken', + 'azurestoragekey', 'googleapikey' +]); + +/** + * Fold a name to its comparison form: case and `_`/`-`/space separators carry + * no meaning, so `API_KEY`, `api-key` and `apiKey` are all `apikey`. + */ +function normalizeName(name: string): string { + return name.toLowerCase().replace(/[\s_-]/g, ''); +} + +/** + * Well-known credential shapes. These are redacted regardless of the variable + * name, because a secret assigned to `x` is still a secret. + */ +const SECRET_VALUE_PATTERNS: RegExp[] = [ + // PEM blocks. The body is matched with a base64/whitespace class (not `[\s\S]*?`) + // so the match is unambiguous and linear, and so an unterminated block still has + // its key material consumed rather than only its header. + /-----BEGIN[A-Z ]*PRIVATE KEY-----[A-Za-z0-9+/=\s]*(?:-----END[A-Z ]*PRIVATE KEY-----)?/g, + /\beyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]+/g, // JWTs + /\b(?:AKIA|ASIA|AGPA|AIDA|AROA|ANPA)[0-9A-Z]{12,}\b/g, // AWS key ids + /\bgh[pousr]_[A-Za-z0-9]{16,}\b/g, // GitHub tokens + /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, + /\bxox[abopsr]-[A-Za-z0-9-]{10,}\b/g, // Slack tokens + /\bAIza[0-9A-Za-z_-]{30,}\b/g, // Google API keys + /\bsk-(?:[A-Za-z0-9_-]+-)?[A-Za-z0-9]{16,}\b/g, // OpenAI / Anthropic style + /\b[sr]k_(?:live|test)_[A-Za-z0-9]{10,}\b/g, // Stripe + /\bnpm_[A-Za-z0-9]{30,}\b/g, + /\bglpat-[A-Za-z0-9_-]{16,}\b/g, // GitLab + /\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/gi, + /\b(?:AccountKey|SharedAccessSignature|Password|Pwd)\s*=\s*[^;\s'"]+/gi // connection strings +]; + +/** Values that cannot carry a secret and stay readable for debugging. */ +const TRIVIAL_VALUES = new Set([ + '', 'none', 'null', 'nil', 'undefined', 'nan', 'true', 'false', + '0', '-1', '[]', '{}', '()', 'empty', '' +]); + +/** Quote/decoration stripping so `'None'` and `None` are treated alike. */ +function unwrap(value: string): string { + let text = value.trim(); + while (text.length >= 2 && + ((text.startsWith("'") && text.endsWith("'")) || + (text.startsWith('"') && text.endsWith('"')) || + (text.startsWith('`') && text.endsWith('`')))) { + text = text.slice(1, -1).trim(); + } + return text; +} + +function isTrivialValue(value: string): boolean { + return TRIVIAL_VALUES.has(unwrap(value).toLowerCase()); +} + +export function isSensitiveName(name: string | undefined | null): boolean { + if (!name) { + return false; + } + return SENSITIVE_NAMES.has(normalizeName(name)); +} + +export function looksLikeSecretValue(value: string | undefined | null): boolean { + if (!value) { + return false; + } + return SECRET_VALUE_PATTERNS.some(pattern => { + pattern.lastIndex = 0; + return pattern.test(value); + }); +} + +/** + * Redact a single variable value based on its name and content. + * Returns the (possibly rewritten) value and whether anything was withheld. + * + * The decision is made from the variable itself - its own name and its own + * value - and never by descending into the entries of a structure. A struct + * whose name is not a credential name is returned intact, even if some field + * inside it is called `password`; scrub the field by inspecting it directly. + */ +export function redactVariableValue(name: string | undefined, value: unknown): { value: string; redacted: boolean } { + const text = value === undefined || value === null ? '' : String(value); + + if (text === '' || isTrivialValue(text)) { + return { value: text, redacted: false }; + } + + if (isSensitiveName(name)) { + return { value: REDACTION_PLACEHOLDER, redacted: true }; + } + + if (looksLikeSecretValue(text)) { + // A recognizable credential shape, whatever the variable is called. + return { value: REDACTION_PLACEHOLDER, redacted: true }; + } + + return { value: text, redacted: false }; +} + +/** + * Redact free-form debugger output (for example an `evaluate` result), which is + * the trivial bypass for per-variable redaction: `evaluate_expression` on + * `os.environ` or `process.env.API_KEY` returns the same secrets. + */ +export function redactExpressionResult(expression: string, value: unknown): { value: string; redacted: boolean } { + const text = value === undefined || value === null ? '' : String(value); + + if (text === '' || isTrivialValue(text)) { + return { value: text, redacted: false }; + } + + if (isSensitiveName(expression)) { + return { value: REDACTION_PLACEHOLDER, redacted: true }; + } + + if (looksLikeSecretValue(text)) { + return { value: REDACTION_PLACEHOLDER, redacted: true }; + } + + return { value: text, redacted: false }; +}