From 60d4cf1c3551ce540b3d21f6fcce92e547d3731a Mon Sep 17 00:00:00 2001 From: Harsh Akshit Date: Mon, 27 Apr 2026 12:54:46 -0400 Subject: [PATCH 1/3] feat: add Burp Suite integration Add Burp Suite proxy and MCP support across CLI, TUI, agent tooling, and docs so pentest flows can pair browser traffic with Burp context. Made-with: Cursor --- README.md | 49 +- fern/docs.yml | 2 + fern/docs/cli/burp.mdx | 61 ++ src/cli.ts | 105 ++- src/cli/burp.ts | 355 ++++++++++ .../offSecAgent/offensiveSecurityAgent.ts | 7 + src/core/agents/offSecAgent/prompt.ts | 15 +- .../agents/offSecAgent/tools/browserTools.ts | 12 + .../offSecAgent/tools/burpConfig.test.ts | 86 +++ .../agents/offSecAgent/tools/burpConfig.ts | 109 +++ .../agents/offSecAgent/tools/burpMcp.test.ts | 29 + src/core/agents/offSecAgent/tools/burpMcp.ts | 658 ++++++++++++++++++ .../offSecAgent/tools/httpRequest.test.ts | 59 ++ .../agents/offSecAgent/tools/httpRequest.ts | 255 +++++-- src/core/agents/offSecAgent/tools/index.ts | 32 + .../agents/offSecAgent/tools/playwrightMcp.ts | 30 +- .../offSecAgent/tools/sandboxPlaywright.ts | 45 +- .../attackSurface/blackboxAgent.ts | 6 + src/core/agents/specialized/pentest/agent.ts | 13 + src/core/config/config.ts | 15 + src/core/mcp/client.test.ts | 117 ++++ src/core/mcp/client.ts | 289 ++++++++ src/core/operator/toolClassifier.ts | 14 + src/core/session/index.ts | 20 + src/core/toolset/index.ts | 107 +++ src/tui/command-registry.ts | 64 ++ .../components/operator-dashboard/index.tsx | 24 + .../components/operator-dashboard/logic.ts | 1 + src/tui/context/route.tsx | 10 + src/tui/utils/command-flags.test.ts | 51 ++ src/tui/utils/command-flags.ts | 87 ++- 31 files changed, 2638 insertions(+), 89 deletions(-) create mode 100644 fern/docs/cli/burp.mdx create mode 100644 src/cli/burp.ts create mode 100644 src/core/agents/offSecAgent/tools/burpConfig.test.ts create mode 100644 src/core/agents/offSecAgent/tools/burpConfig.ts create mode 100644 src/core/agents/offSecAgent/tools/burpMcp.test.ts create mode 100644 src/core/agents/offSecAgent/tools/burpMcp.ts create mode 100644 src/core/agents/offSecAgent/tools/httpRequest.test.ts create mode 100644 src/core/mcp/client.test.ts create mode 100644 src/core/mcp/client.ts diff --git a/README.md b/README.md index 7499bdf44..a920ed4fd 100644 --- a/README.md +++ b/README.md @@ -70,19 +70,46 @@ pensar pentest --target https://example.com --cwd ./my-app # Targeted pentest with specific objectives pensar targeted-pentest --target https://example.com --objective "Test authentication bypass" + +# Pair with Burp Suite Proxy and MCP +pensar burp config set --enabled true --url http://127.0.0.1:9876/sse +pensar burp status +pensar pentest --target https://example.com --burp +``` + +| Flag | Command | Description | +| --------------------------------- | ------------------------- | -------------------------------------------------------- | +| `--target ` | pentest, targeted-pentest | Target URL (required) | +| `--cwd ` | pentest | Source code path for whitebox mode | +| `--mode ` | pentest | `exfil` for pivoting and flag extraction | +| `--model ` | pentest, targeted-pentest | AI model (default: auto-selected) | +| `--extended-thinking` | pentest | Enable extended thinking for supported models | +| `--task-driven` | pentest | Enable task-driven architecture (experimental) | +| `--prompt ` | pentest | Custom guidance for the agent | +| `--threat-model ` | pentest | Threat model to guide testing | +| `--objective ` | targeted-pentest | Testing objective (repeatable) | +| `--burp` | pentest, targeted-pentest | Route traffic through Burp and enable Burp MCP | +| `--burp-proxy ` | pentest, targeted-pentest | Burp Proxy URL (default `http://127.0.0.1:8080`) | +| `--burp-mcp-url ` | pentest, targeted-pentest | Burp MCP SSE URL (default `http://127.0.0.1:9876/sse`) | +| `--burp-mcp-proxy-jar ` | pentest, targeted-pentest | Path to PortSwigger's MCP stdio proxy JAR | +| `--burp-mcp-proxy-command ` | pentest, targeted-pentest | Java executable for the MCP stdio proxy (default `java`) | +| `--burp-insecure-tls` | pentest, targeted-pentest | Ignore TLS errors when proxying through Burp | + +### Burp Suite MCP + +Apex can connect to PortSwigger's Burp Suite MCP Server over its local SSE endpoint. Install the MCP Server extension in Burp, enable the MCP tab/server, then configure Apex: + +```bash +pensar burp config set --enabled true --url http://127.0.0.1:9876/sse +pensar burp status +pensar burp tools +pensar burp proxy-history --target https://example.com --limit 20 +pensar burp repeater --request-file request.txt ``` -| Flag | Command | Description | -| ------------------------------ | ------------------------- | ---------------------------------------------- | -| `--target ` | pentest, targeted-pentest | Target URL (required) | -| `--cwd ` | pentest | Source code path for whitebox mode | -| `--mode ` | pentest | `exfil` for pivoting and flag extraction | -| `--model ` | pentest, targeted-pentest | AI model (default: auto-selected) | -| `--extended-thinking` | pentest | Enable extended thinking for supported models | -| `--task-driven` | pentest | Enable task-driven architecture (experimental) | -| `--prompt ` | pentest | Custom guidance for the agent | -| `--threat-model ` | pentest | Threat model to guide testing | -| `--objective ` | targeted-pentest | Testing objective (repeatable) | +In `/operator`, use `/burp status`, `/burp tools`, `/burp history`, and `/burp repeater`. Burp tools are only available to agents when Burp is enabled for the session and actions remain subject to Apex scope and Burp's own target approval model. + +Security notes: proxy history and raw requests may contain cookies, credentials, request bodies, and manually browsed traffic. Apex defaults to localhost Burp MCP endpoints, warns on non-local URLs, redacts sensitive headers in Burp action logs, and blocks config-modifying MCP tools unless explicitly enabled. Data returned from Burp tools can be processed by your configured AI provider. ### W&B Weave Tracing diff --git a/fern/docs.yml b/fern/docs.yml index e24acc748..f399601f1 100644 --- a/fern/docs.yml +++ b/fern/docs.yml @@ -98,6 +98,8 @@ navigation: path: ./docs/cli/fixes.mdx - page: pensar logs path: ./docs/cli/logs.mdx + - page: pensar burp + path: ./docs/cli/burp.mdx - page: pensar upgrade path: ./docs/cli/upgrade.mdx - page: pensar doctor diff --git a/fern/docs/cli/burp.mdx b/fern/docs/cli/burp.mdx new file mode 100644 index 000000000..b02b71795 --- /dev/null +++ b/fern/docs/cli/burp.mdx @@ -0,0 +1,61 @@ +--- +title: pensar burp +--- + +Manage Apex's Burp Suite MCP integration. + +## Setup + +1. Install PortSwigger's MCP Server extension in Burp Suite. +2. Open Burp's MCP tab and enable the MCP server. +3. Keep the default local listener unless you have a reason to expose it elsewhere: + +```bash +http://127.0.0.1:9876/sse +``` + +Some Burp MCP clients use `http://127.0.0.1:9876` without `/sse`; Apex accepts either URL when configured. + +## Configure Apex + +```bash +pensar burp config set --enabled true --url http://127.0.0.1:9876/sse +pensar burp status +pensar burp tools +``` + +Configuration is stored in `~/.pensar/config.json` under `burpMcp`. Config-modifying Burp MCP tools are disabled unless you explicitly set `--allow-config-mutation`. + +## Commands + +```bash +pensar burp status +pensar burp tools +pensar burp config +pensar burp config set --url http://127.0.0.1:9876/sse --enabled true +pensar burp proxy-history --target https://example.com --limit 20 +pensar burp repeater --request-file request.txt +pensar burp send --request-file request.txt +``` + +`proxy-history`, `repeater`, and `send` enforce configured `allowedTargets` when present and fail closed when the integration is disabled or Burp MCP is unreachable. + +## TUI + +In `/operator`, use: + +```text +/burp status +/burp tools +/burp history +/burp repeater +``` + +Burp agent tools are only visible when the session has Burp enabled. + +## Security Notes + +- Apex does not bypass Burp MCP target approval. If Burp rejects an action, Apex surfaces the failure. +- Default endpoints are localhost-only. Non-local MCP URLs produce a warning and should only be used for trusted endpoints. +- Proxy history may include credentials, cookies, request bodies, and traffic from manual browsing. Apex redacts sensitive headers in its Burp action logs, but agent tool inputs and outputs may still be processed by the configured AI provider. +- Do not enable config mutation unless you understand the impact. Apex blocks config-modifying MCP tools by default. diff --git a/src/cli.ts b/src/cli.ts index b26306d00..8e4b27159 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -18,6 +18,7 @@ import { resolveThreatModelPrompt, combinePromptParts, } from "./tui/utils/command-flags"; +import type { SessionConfig } from "./core/session"; const args = process.argv.slice(2); const command = args[0]; @@ -67,6 +68,79 @@ function getAllArgs(flag: string, argv = args): string[] { return values; } +function buildBurpSuiteConfig(argv = args): SessionConfig["burpSuite"] { + const enabled = + hasFlag("--burp", argv) || + getArg("--burp-proxy", argv) !== undefined || + getArg("--burp-transport", argv) !== undefined || + getArg("--burp-mcp-url", argv) !== undefined || + getArg("--burp-mcp-proxy-jar", argv) !== undefined || + getArg("--burp-mcp-proxy-command", argv) !== undefined || + getArg("--burp-timeout-ms", argv) !== undefined || + hasFlag("--burp-allow-config-mutation", argv) || + hasFlag("--burp-insecure-tls", argv); + + if (!enabled) return undefined; + + const mcpSseUrl = getArg("--burp-mcp-url", argv); + const mcpProxyJar = getArg("--burp-mcp-proxy-jar", argv); + const transport = getArg("--burp-transport", argv); + const timeoutMsRaw = getArg("--burp-timeout-ms", argv); + const timeoutMs = timeoutMsRaw ? parseInt(timeoutMsRaw, 10) : undefined; + const mcpProxyArgs = mcpProxyJar + ? [ + "-jar", + mcpProxyJar, + "--sse-url", + mcpSseUrl ?? "http://127.0.0.1:9876/sse", + ] + : undefined; + + return { + enabled: true, + ...(transport === "sse" || transport === "stdio" ? { transport } : {}), + ...(getArg("--burp-proxy", argv) + ? { proxyUrl: getArg("--burp-proxy", argv) } + : {}), + ...(mcpSseUrl ? { mcpSseUrl } : {}), + ...(getArg("--burp-mcp-proxy-command", argv) + ? { mcpProxyCommand: getArg("--burp-mcp-proxy-command", argv) } + : {}), + ...(mcpProxyArgs ? { mcpProxyArgs } : {}), + ...(timeoutMs && Number.isFinite(timeoutMs) ? { timeoutMs } : {}), + ...(hasFlag("--burp-allow-config-mutation", argv) + ? { allowConfigMutation: true } + : {}), + ...(hasFlag("--burp-insecure-tls", argv) ? { ignoreTlsErrors: true } : {}), + }; +} + +function mergeBurpSuiteConfig( + sessionConfig: SessionConfig["burpSuite"], + userConfig: Awaited< + ReturnType + >["burpMcp"], +): SessionConfig["burpSuite"] { + if (!sessionConfig?.enabled) return sessionConfig; + return { + enabled: true, + transport: sessionConfig.transport ?? userConfig?.transport, + proxyUrl: sessionConfig.proxyUrl ?? userConfig?.proxyUrl, + sseUrl: + sessionConfig.sseUrl ?? sessionConfig.mcpSseUrl ?? userConfig?.sseUrl, + mcpSseUrl: + sessionConfig.mcpSseUrl ?? sessionConfig.sseUrl ?? userConfig?.sseUrl, + mcpProxyCommand: sessionConfig.mcpProxyCommand ?? userConfig?.stdioCommand, + mcpProxyArgs: sessionConfig.mcpProxyArgs ?? userConfig?.stdioArgs, + timeoutMs: sessionConfig.timeoutMs ?? userConfig?.timeoutMs, + allowedTargets: sessionConfig.allowedTargets ?? userConfig?.allowedTargets, + allowConfigMutation: + sessionConfig.allowConfigMutation ?? userConfig?.allowConfigMutation, + ignoreTlsErrors: + sessionConfig.ignoreTlsErrors ?? userConfig?.ignoreTlsErrors, + }; +} + function attachCliAgentStreamListeners(bus: AgentEventBus): void { bus.on("text-delta", (d) => process.stdout.write(d.text)); bus.on("tool-call-complete", (d) => console.log(`\n→ ${d.toolName}`)); @@ -106,6 +180,7 @@ Usage: pensar pentests List and manage pentests pensar issues List and manage security issues pensar fixes View security fixes + pensar burp Manage Burp Suite MCP integration pensar logs View agent execution logs pensar upgrade Update pensar to the latest version pensar doctor Check dependencies and install missing tools @@ -121,11 +196,21 @@ pentest options: --task-driven Enable task-driven architecture (experimental) --prompt Guidance for the pentest agent (inline text or @filepath) --threat-model Threat model to guide the pentest (inline or @filepath) + --burp Pair with Burp Suite (proxy + MCP when configured) + --burp-proxy Burp Proxy URL (default: http://127.0.0.1:8080) + --burp-transport Burp MCP transport: sse or stdio (default: sse) + --burp-mcp-url Burp MCP SSE URL (default: http://127.0.0.1:9876/sse) + --burp-mcp-proxy-jar Path to Burp MCP stdio proxy JAR + --burp-mcp-proxy-command Java executable for the Burp MCP proxy (default: java) + --burp-timeout-ms Burp MCP connection/tool timeout + --burp-allow-config-mutation Allow Burp MCP config-modifying tools + --burp-insecure-tls Ignore TLS errors when proxying through Burp targeted-pentest options: --target (required) Target URL / domain / IP --objective (required, repeatable) Testing objective --model AI model (default: claude-sonnet-4-5) + --burp Pair with Burp Suite (proxy + MCP when configured) threat-model options: --output, -o Output file path (default: ./threat-model.md) @@ -161,6 +246,7 @@ async function runPentest() { const threatModelRaw = getArg("--threat-model"); const enableThinking = hasFlag("--extended-thinking"); const taskDriven = hasFlag("--task-driven"); + const burpSuite = buildBurpSuiteConfig(); // Resolve and combine threat model + prompt const resolvedTm = threatModelRaw @@ -170,6 +256,10 @@ async function runPentest() { const prompt = combinePromptParts(resolvedTm, resolvedPrompt); const pensarConfig = await appConfig.get(); + const effectiveBurpSuite = mergeBurpSuiteConfig( + burpSuite, + pensarConfig.burpMcp, + ); const dynamicDefault = getDefaultModelForConfig(pensarConfig)?.id ?? "claude-sonnet-4-5"; const model = (getArg("--model") ?? dynamicDefault) as AIModel; @@ -183,7 +273,7 @@ async function runPentest() { console.log(`${sep} PENTEST ORCHESTRATION ${sep} -Target: ${target}${cwd ? `\nCwd: ${cwd} (whitebox)` : ""}${exfilMode ? "\nMode: exfil" : ""} +Target: ${target}${cwd ? `\nCwd: ${cwd} (whitebox)` : ""}${exfilMode ? "\nMode: exfil" : ""}${effectiveBurpSuite?.enabled ? "\nBurp: enabled" : ""} Model: ${model}${enableThinking ? "\nThinking: enabled" : ""}${taskDriven ? "\nTask-driven: enabled" : ""} `); @@ -195,6 +285,7 @@ Model: ${model}${enableThinking ? "\nThinking: enabled" : ""}${taskDriven ? "\ ...(exfilMode ? { exfilMode: true } : {}), ...(prompt ? { prompt } : {}), ...(taskDriven ? { taskDriven: true } : {}), + ...(effectiveBurpSuite ? { burpSuite: effectiveBurpSuite } : {}), }, }); @@ -238,8 +329,13 @@ async function runTargetedPentest() { const target = getArgRequired("--target"); const objectives = getAllArgs("--objective"); + const burpSuite = buildBurpSuiteConfig(); const pensarConfig = await appConfig.get(); + const effectiveBurpSuite = mergeBurpSuiteConfig( + burpSuite, + pensarConfig.burpMcp, + ); const dynamicDefault = getDefaultModelForConfig(pensarConfig)?.id ?? "claude-sonnet-4-5"; const model = (getArg("--model") ?? dynamicDefault) as AIModel; @@ -257,6 +353,7 @@ async function runTargetedPentest() { TARGETED PENTEST ${sep} Target: ${target} +${effectiveBurpSuite?.enabled ? "Burp: enabled\n" : ""} Model: ${model} Objectives: ${objectivesList} @@ -265,6 +362,9 @@ ${objectivesList} const session = await sessions.create({ name: "Targeted Pentest", targets: [target], + config: { + ...(effectiveBurpSuite ? { burpSuite: effectiveBurpSuite } : {}), + }, }); const { bus: targetedBus, cleanup: wandbCleanup } = @@ -385,6 +485,9 @@ if (command === "version" || command === "--version" || command === "-v") { } else if (command === "logs") { process.argv = [process.argv[0], process.argv[1], ...args.slice(1)]; await import("./cli/logs"); +} else if (command === "burp") { + process.argv = [process.argv[0], process.argv[1], ...args.slice(1)]; + await import("./cli/burp"); } else if (command === "threat-model") { await runThreatModel(); } else if (command === "doctor") { diff --git a/src/cli/burp.ts b/src/cli/burp.ts new file mode 100644 index 000000000..cce02183c --- /dev/null +++ b/src/cli/burp.ts @@ -0,0 +1,355 @@ +#!/usr/bin/env bun + +/** + * pensar burp — Manage Burp Suite MCP integration + */ + +import { readFileSync } from "fs"; +import { config as appConfig } from "../core/config"; +import type { BurpMcpUserConfig } from "../core/config/config"; +import { + DEFAULT_BURP_MCP_SSE_URL, + DEFAULT_BURP_MCP_TIMEOUT_MS, + DEFAULT_BURP_PROXY_URL, + isLocalBurpMcpUrl, + normalizeBurpMcpUrl, + resolveBurpSuiteConfig, + sanitizeBurpConfigForDisplay, +} from "../core/agents/offSecAgent/tools/burpConfig"; +import { + BurpMcpSession, + parseRawHttpTarget, + redactSensitiveHttpText, +} from "../core/agents/offSecAgent/tools/burpMcp"; +import { isHostAllowed } from "../core/agents/offSecAgent/tools/scopeGuard"; + +function getFlag(flag: string, argv: string[]): string | undefined { + const idx = argv.indexOf(flag); + return idx !== -1 && idx + 1 < argv.length ? argv[idx + 1] : undefined; +} + +function hasFlag(flag: string, argv: string[]): boolean { + return argv.includes(flag); +} + +function showHelp(): void { + console.log(`pensar burp — Manage Burp Suite MCP integration + +Usage: + pensar burp status + pensar burp tools + pensar burp proxy-history --target [--limit N] [--regex ] + pensar burp repeater --request-file + pensar burp send --request-file + pensar burp config + pensar burp config set --url --enabled true|false + +Options: + --target In-scope target to filter proxy history + --limit Max history items (default: 20) + --regex Additional regex for proxy history filtering + --request-file Raw HTTP request file + --url Burp MCP SSE URL + --transport MCP transport (default: sse) + --timeout-ms MCP timeout + --allow-config-mutation Enable config-modifying Burp MCP tools + -h, --help Show this help message`); +} + +function parseBoolean(value: string | undefined): boolean | undefined { + if (value === "true") return true; + if (value === "false") return false; + return undefined; +} + +function hostFromTarget(value: string): string { + try { + return new URL(value.includes("://") ? value : `https://${value}`).hostname; + } catch { + throw new Error(`Invalid target: ${value}`); + } +} + +function assertTargetAllowed(hostname: string, cfg: BurpMcpUserConfig): void { + const allowed = cfg.allowedTargets ?? []; + if (allowed.length > 0 && !isHostAllowed(hostname, allowed)) { + throw new Error( + `Target is outside configured scope. Allowed targets: ${allowed.join(", ")}`, + ); + } +} + +async function getResolvedSession(): Promise { + const current = await appConfig.get(); + const resolved = resolveBurpSuiteConfig(undefined, current.burpMcp); + if (!resolved) { + throw new Error( + "Burp MCP integration is disabled. Run `pensar burp config set --enabled true` first.", + ); + } + return new BurpMcpSession(resolved); +} + +async function runStatus(): Promise { + const current = await appConfig.get(); + const resolved = resolveBurpSuiteConfig(undefined, current.burpMcp); + if (!resolved) { + console.log( + JSON.stringify( + { + enabled: false, + transport: current.burpMcp?.transport ?? "sse", + sseUrl: current.burpMcp?.sseUrl ?? DEFAULT_BURP_MCP_SSE_URL, + }, + null, + 2, + ), + ); + return; + } + + const session = new BurpMcpSession(resolved); + try { + const tools = await session.listTools(); + const names = new Set(tools.map((t) => t.name)); + console.log( + JSON.stringify( + { + ...sanitizeBurpConfigForDisplay(resolved), + reachable: true, + toolCount: tools.length, + capabilities: { + proxyHistory: names.has("get_proxy_http_history"), + proxyHistoryRegex: names.has("get_proxy_http_history_regex"), + repeater: names.has("create_repeater_tab"), + intruder: names.has("send_to_intruder"), + collaborator: + names.has("generate_collaborator_payload") || + names.has("poll_collaborator_interactions"), + intercept: + names.has("get_proxy_intercept") || + names.has("set_proxy_intercept"), + }, + }, + null, + 2, + ), + ); + } finally { + await session.disconnect(); + } +} + +async function runTools(): Promise { + const session = await getResolvedSession(); + try { + const tools = await session.listTools(); + console.log(JSON.stringify(tools, null, 2)); + } finally { + await session.disconnect(); + } +} + +async function runConfig(args: string[]): Promise { + const sub = args[0]; + if (sub !== "set") { + const current = await appConfig.get(); + const resolved = resolveBurpSuiteConfig(undefined, current.burpMcp); + console.log( + JSON.stringify( + resolved + ? sanitizeBurpConfigForDisplay(resolved) + : { + enabled: current.burpMcp?.enabled ?? false, + transport: current.burpMcp?.transport ?? "sse", + sseUrl: current.burpMcp?.sseUrl ?? DEFAULT_BURP_MCP_SSE_URL, + proxyUrl: current.burpMcp?.proxyUrl ?? DEFAULT_BURP_PROXY_URL, + timeoutMs: + current.burpMcp?.timeoutMs ?? DEFAULT_BURP_MCP_TIMEOUT_MS, + allowConfigMutation: + current.burpMcp?.allowConfigMutation ?? false, + allowedTargets: current.burpMcp?.allowedTargets ?? [], + }, + null, + 2, + ), + ); + return; + } + + const current = await appConfig.get(); + const next: BurpMcpUserConfig = { ...(current.burpMcp ?? {}) }; + const enabled = parseBoolean(getFlag("--enabled", args)); + const url = getFlag("--url", args); + const transport = getFlag("--transport", args); + const timeoutMs = getFlag("--timeout-ms", args); + const allowedTargets = getFlag("--allowed-targets", args); + + if (enabled !== undefined) next.enabled = enabled; + if (url) next.sseUrl = normalizeBurpMcpUrl(url); + if (transport === "sse" || transport === "stdio") next.transport = transport; + if (timeoutMs) next.timeoutMs = parseInt(timeoutMs, 10); + if (allowedTargets) { + next.allowedTargets = allowedTargets + .split(",") + .map((target) => target.trim()) + .filter(Boolean); + } + if (hasFlag("--allow-config-mutation", args)) { + next.allowConfigMutation = true; + } + + await appConfig.update({ burpMcp: next }); + if (next.sseUrl && !isLocalBurpMcpUrl(next.sseUrl)) { + console.warn( + `Warning: Burp MCP URL is non-local (${next.sseUrl}). Ensure this endpoint is trusted.`, + ); + } + console.log(JSON.stringify({ burpMcp: next }, null, 2)); +} + +async function callFirstAvailable( + session: BurpMcpSession, + candidates: string[], + args: Record, +): Promise<{ toolName: string; result: string }> { + for (const candidate of candidates) { + if (await session.hasTool(candidate)) { + return { + toolName: candidate, + result: await session.callTool(candidate, args), + }; + } + } + throw new Error( + `Burp MCP tool '${candidates[0]}' is not available. Check extension version or enabled permissions.`, + ); +} + +async function runProxyHistory(args: string[]): Promise { + const target = getFlag("--target", args); + if (!target) throw new Error("--target is required"); + const targetHost = hostFromTarget(target); + const current = await appConfig.get(); + assertTargetAllowed(targetHost, current.burpMcp ?? {}); + + const limit = parseInt(getFlag("--limit", args) ?? "20", 10); + const regex = getFlag("--regex", args); + const effectiveRegex = regex + ? `(?=.*(?:${targetHost}))(?=.*(?:${regex}))` + : targetHost; + + const session = await getResolvedSession(); + try { + const { toolName, result } = await callFirstAvailable( + session, + ["get_proxy_http_history_regex"], + { regex: effectiveRegex, count: limit, offset: 0 }, + ); + console.log( + JSON.stringify( + { + toolName, + target: targetHost, + result: redactSensitiveHttpText(result), + }, + null, + 2, + ), + ); + } finally { + await session.disconnect(); + } +} + +async function runRepeater(args: string[]): Promise { + const requestFile = getFlag("--request-file", args); + if (!requestFile) throw new Error("--request-file is required"); + const content = readFileSync(requestFile, "utf-8"); + const target = parseRawHttpTarget(content); + if (!target) { + throw new Error( + "Raw request must include a Host header or explicit target fields.", + ); + } + const current = await appConfig.get(); + assertTargetAllowed(target.targetHostname, current.burpMcp ?? {}); + + const session = await getResolvedSession(); + try { + const { toolName, result } = await callFirstAvailable( + session, + ["create_repeater_tab"], + { + content, + targetHostname: target.targetHostname, + targetPort: target.targetPort, + usesHttps: target.usesHttps, + }, + ); + console.log(JSON.stringify({ toolName, result }, null, 2)); + } finally { + await session.disconnect(); + } +} + +async function runSend(args: string[]): Promise { + const requestFile = getFlag("--request-file", args); + if (!requestFile) throw new Error("--request-file is required"); + const content = readFileSync(requestFile, "utf-8"); + const target = parseRawHttpTarget(content); + if (!target) { + throw new Error( + "Raw request must include a Host header or explicit target fields.", + ); + } + const current = await appConfig.get(); + assertTargetAllowed(target.targetHostname, current.burpMcp ?? {}); + + const session = await getResolvedSession(); + try { + const { toolName, result } = await callFirstAvailable( + session, + ["send_http_request", "send_http1_request", "send_request"], + { + content, + targetHostname: target.targetHostname, + targetPort: target.targetPort, + usesHttps: target.usesHttps, + }, + ); + console.log( + JSON.stringify( + { toolName, result: redactSensitiveHttpText(result) }, + null, + 2, + ), + ); + } finally { + await session.disconnect(); + } +} + +async function main(): Promise { + const args = process.argv.slice(2); + const sub = args[0]; + if (!sub || sub === "--help" || sub === "-h" || sub === "help") { + showHelp(); + return; + } + + try { + if (sub === "status") await runStatus(); + else if (sub === "tools") await runTools(); + else if (sub === "config") await runConfig(args.slice(1)); + else if (sub === "proxy-history") await runProxyHistory(args.slice(1)); + else if (sub === "repeater") await runRepeater(args.slice(1)); + else if (sub === "send") await runSend(args.slice(1)); + else throw new Error(`Unknown burp command '${sub}'`); + } catch (err) { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } +} + +main(); diff --git a/src/core/agents/offSecAgent/offensiveSecurityAgent.ts b/src/core/agents/offSecAgent/offensiveSecurityAgent.ts index ff389ab93..dcf61e355 100644 --- a/src/core/agents/offSecAgent/offensiveSecurityAgent.ts +++ b/src/core/agents/offSecAgent/offensiveSecurityAgent.ts @@ -10,6 +10,7 @@ import { hasToolCall } from "ai"; import type { OffensiveSecurityAgentInput, CreateAgentInput } from "./types"; import { createAllTools, + BURP_TOOL_NAMES_ACTIVE, EMAIL_TOOL_NAMES_ACTIVE, PLAN_MODE_TOOL_NAMES, } from "./tools"; @@ -261,6 +262,12 @@ export class OffensiveSecurityAgent { ? (input.activeTools as string[]) : (input.activeTools as string[]).filter((t) => !emailToolSet.has(t)); + const hasBurp = input.session.config?.burpSuite?.enabled === true; + const burpToolSet = new Set(BURP_TOOL_NAMES_ACTIVE); + activeTools = hasBurp + ? activeTools + : activeTools.filter((t) => !burpToolSet.has(t)); + // -- Plan mode: restrict to read-only tools ----------------------------- if (input.mode === "plan") { const planSet = new Set(PLAN_MODE_TOOL_NAMES); diff --git a/src/core/agents/offSecAgent/prompt.ts b/src/core/agents/offSecAgent/prompt.ts index a54411336..f7b4f8f67 100644 --- a/src/core/agents/offSecAgent/prompt.ts +++ b/src/core/agents/offSecAgent/prompt.ts @@ -152,6 +152,16 @@ You can perform the full lifecycle of a penetration test and support a wide rang - **browser_console** — Retrieve browser console messages. - **browser_get_cookies** — Extract all cookies (including httpOnly) from the browser context. +## Burp Suite +- **burp_check_connection** — Verify Burp MCP connectivity and discover exposed Burp tools when Burp pairing is configured. +- **burp_get_proxy_http_history** / **burp_search_proxy_http_history** — Inspect Burp Proxy HTTP history captured from Apex or user-driven traffic. +- **burp_get_proxy_websocket_history** — Inspect Burp Proxy WebSocket history. +- **burp_send_http_request** — Send an in-scope raw HTTP request through Burp MCP when available and approved. +- **burp_send_to_repeater** / **burp_send_to_intruder** — Hand interesting in-scope requests to Burp for manual follow-up. Use only when the request is relevant and authorized. +- **burp_generate_collaborator_payload** / **burp_poll_collaborator_interactions** — Use Burp Collaborator for authorized out-of-band testing when available. +- **burp_get_proxy_intercept_state** / **burp_set_proxy_intercept_state** — Inspect or explicitly change proxy intercept state when the operator asks. +- **burp_get_scanner_issues** — Read Burp Scanner issues when available. Do not assume this exists; Burp Community typically provides proxy history but not scanner findings. + ## Filesystem & Search - **read_file** — Read file contents (whole file or line range). - **list_files** — List directory contents (optionally recursive). @@ -203,8 +213,9 @@ For long-running processes (servers, listeners, watchers), background them with 1. **Evidence over assumptions.** Every claim must be backed by actual tool output. Never hallucinate findings or fabricate evidence. 2. **Stay in scope.** Only test targets and systems explicitly provided by the user or discovered within the authorized scope. Respect any scope constraints in the session config. 3. **Handle failures gracefully.** If a tool call fails or a technique doesn't work, try alternative approaches. If your PoC approach fails repeatedly, pivot to a different technique. -4. **Summarize results.** After completing a task, give the user a clear summary of what you found, what you tried, and what the next steps could be. -5. **No reports in scratchpad.** Do NOT write synthesized report documents to scratchpad/ (e.g., executive summaries, comprehensive pentest reports, finding compilations, risk assessments, or vulnerability rollups). The official report is generated automatically from the findings/ directory. Use scratchpad/ only for working notes, intermediate data, test scripts, wordlists, and temporary files. Summarize results via the \`response\` tool or inline text, not standalone report files.`; +4. **Use Burp as evidence, not authority.** When Burp tools are available, use HTTP history to inspect traffic already generated by Apex or the user. Treat scanner/imported data as supporting evidence and verify anything important before documenting a finding. +5. **Summarize results.** After completing a task, give the user a clear summary of what you found, what you tried, and what the next steps could be. +6. **No reports in scratchpad.** Do NOT write synthesized report documents to scratchpad/ (e.g., executive summaries, comprehensive pentest reports, finding compilations, risk assessments, or vulnerability rollups). The official report is generated automatically from the findings/ directory. Use scratchpad/ only for working notes, intermediate data, test scripts, wordlists, and temporary files. Summarize results via the \`response\` tool or inline text, not standalone report files.`; } /** diff --git a/src/core/agents/offSecAgent/tools/browserTools.ts b/src/core/agents/offSecAgent/tools/browserTools.ts index 87f95439c..78e66bdb2 100644 --- a/src/core/agents/offSecAgent/tools/browserTools.ts +++ b/src/core/agents/offSecAgent/tools/browserTools.ts @@ -23,6 +23,7 @@ import { join } from "path"; import { createBrowserTools } from "./playwrightMcp"; import { createSandboxBrowserTools } from "./sandboxPlaywright"; import type { ToolContext } from "./types"; +import { resolveBurpSuiteConfig } from "./burpConfig"; /** * All browser tool names that get registered in the harness. @@ -54,6 +55,8 @@ export type BrowserToolName = (typeof BROWSER_TOOL_NAMES)[number]; * credential-aware wrapper that resolves secrets from IDs at execution time. */ export function createBrowserToolset(ctx: ToolContext) { + const burp = resolveBurpSuiteConfig(ctx.session.config?.burpSuite); + // Sandbox mode: use direct Playwright execution inside the sandbox const tools = ctx.sandbox ? createSandboxBrowserTools(ctx) @@ -63,6 +66,15 @@ export function createBrowserToolset(ctx: ToolContext) { "operator", undefined, ctx.abortSignal, + undefined, + undefined, + undefined, + burp + ? { + proxyServer: burp.proxyUrl, + ignoreHTTPSErrors: burp.ignoreTlsErrors, + } + : undefined, ); if (!ctx.credentialManager) { diff --git a/src/core/agents/offSecAgent/tools/burpConfig.test.ts b/src/core/agents/offSecAgent/tools/burpConfig.test.ts new file mode 100644 index 000000000..663377edd --- /dev/null +++ b/src/core/agents/offSecAgent/tools/burpConfig.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { + isLocalBurpMcpUrl, + normalizeBurpMcpUrl, + resolveBurpSuiteConfig, +} from "./burpConfig"; + +describe("resolveBurpSuiteConfig", () => { + it("returns undefined when Burp is not enabled", () => { + expect(resolveBurpSuiteConfig(undefined)).toBeUndefined(); + expect(resolveBurpSuiteConfig({ enabled: false })).toBeUndefined(); + }); + + it("applies default Burp endpoints when enabled", () => { + expect(resolveBurpSuiteConfig({ enabled: true })).toEqual({ + enabled: true, + transport: "sse", + proxyUrl: "http://127.0.0.1:8080", + sseUrl: "http://127.0.0.1:9876/sse", + mcpSseUrl: "http://127.0.0.1:9876/sse", + mcpProxyCommand: "java", + mcpProxyArgs: undefined, + timeoutMs: 15000, + allowedTargets: [], + allowConfigMutation: false, + ignoreTlsErrors: false, + warnings: [], + }); + }); + + it("preserves explicit Burp settings", () => { + expect( + resolveBurpSuiteConfig({ + enabled: true, + proxyUrl: "http://127.0.0.1:8081", + mcpSseUrl: "http://127.0.0.1:9877", + mcpProxyCommand: "/path/to/java", + mcpProxyArgs: ["-jar", "/tmp/mcp-proxy.jar"], + ignoreTlsErrors: true, + }), + ).toEqual({ + enabled: true, + transport: "sse", + proxyUrl: "http://127.0.0.1:8081", + mcpSseUrl: "http://127.0.0.1:9877", + sseUrl: "http://127.0.0.1:9877", + mcpProxyCommand: "/path/to/java", + mcpProxyArgs: ["-jar", "/tmp/mcp-proxy.jar"], + timeoutMs: 15000, + allowedTargets: [], + allowConfigMutation: false, + ignoreTlsErrors: true, + warnings: [], + }); + }); + + it("merges user config with session overrides", () => { + expect( + resolveBurpSuiteConfig( + { enabled: true, timeoutMs: 10_000 }, + { + enabled: true, + sseUrl: "http://127.0.0.1:9878/sse", + allowedTargets: ["example.com"], + allowConfigMutation: true, + }, + ), + ).toMatchObject({ + sseUrl: "http://127.0.0.1:9878/sse", + timeoutMs: 10_000, + allowedTargets: ["example.com"], + allowConfigMutation: true, + }); + }); + + it("validates URL format and local endpoint detection", () => { + expect(normalizeBurpMcpUrl("http://127.0.0.1:9876/sse/")).toBe( + "http://127.0.0.1:9876/sse", + ); + expect(isLocalBurpMcpUrl("http://localhost:9876/sse")).toBe(true); + expect(isLocalBurpMcpUrl("https://burp.example.com/sse")).toBe(false); + expect(() => normalizeBurpMcpUrl("file:///tmp/socket")).toThrow( + "Burp MCP URL must use http or https.", + ); + }); +}); diff --git a/src/core/agents/offSecAgent/tools/burpConfig.ts b/src/core/agents/offSecAgent/tools/burpConfig.ts new file mode 100644 index 000000000..5185a1ee7 --- /dev/null +++ b/src/core/agents/offSecAgent/tools/burpConfig.ts @@ -0,0 +1,109 @@ +import type { BurpSuiteIntegrationConfig } from "../../../session"; +import type { BurpMcpUserConfig } from "../../../config/config"; + +export const DEFAULT_BURP_PROXY_URL = "http://127.0.0.1:8080"; +export const DEFAULT_BURP_MCP_SSE_URL = "http://127.0.0.1:9876/sse"; +export const DEFAULT_BURP_MCP_PROXY_COMMAND = "java"; +export const DEFAULT_BURP_MCP_TIMEOUT_MS = 15_000; + +export type ResolvedBurpSuiteConfig = { + enabled: true; + transport: "sse" | "stdio"; + proxyUrl: string; + sseUrl: string; + /** Backwards-compatible alias for older call sites. */ + mcpSseUrl: string; + mcpProxyCommand: string; + mcpProxyArgs?: string[]; + timeoutMs: number; + allowedTargets: string[]; + allowConfigMutation: boolean; + ignoreTlsErrors: boolean; + warnings: string[]; +}; + +function isLocalHostname(hostname: string): boolean { + return ( + hostname === "localhost" || + hostname === "127.0.0.1" || + hostname === "::1" || + hostname.endsWith(".localhost") + ); +} + +export function isLocalBurpMcpUrl(url: string): boolean { + try { + return isLocalHostname(new URL(url).hostname); + } catch { + return false; + } +} + +export function normalizeBurpMcpUrl(url: string): string { + const parsed = new URL(url); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error("Burp MCP URL must use http or https."); + } + return parsed.toString().replace(/\/$/, ""); +} + +export function sanitizeBurpConfigForDisplay(config: ResolvedBurpSuiteConfig) { + return { + enabled: true, + transport: config.transport, + sseUrl: config.sseUrl, + proxyUrl: config.proxyUrl, + timeoutMs: config.timeoutMs, + allowedTargets: config.allowedTargets, + allowConfigMutation: config.allowConfigMutation, + ignoreTlsErrors: config.ignoreTlsErrors, + warnings: config.warnings, + }; +} + +export function resolveBurpSuiteConfig( + config: BurpSuiteIntegrationConfig | undefined, + userConfig?: BurpMcpUserConfig | undefined, +): ResolvedBurpSuiteConfig | undefined { + const enabled = config?.enabled ?? userConfig?.enabled ?? false; + if (!enabled) return undefined; + + const warnings: string[] = []; + const rawSseUrl = + config?.sseUrl ?? + config?.mcpSseUrl ?? + userConfig?.sseUrl ?? + DEFAULT_BURP_MCP_SSE_URL; + const sseUrl = normalizeBurpMcpUrl(rawSseUrl); + + if (!isLocalBurpMcpUrl(sseUrl)) { + warnings.push( + `Burp MCP URL is non-local (${sseUrl}). Ensure this is intentional and trusted.`, + ); + } + + const transport = config?.transport ?? userConfig?.transport ?? "sse"; + const mcpProxyArgs = config?.mcpProxyArgs ?? userConfig?.stdioArgs; + + return { + enabled: true, + transport, + proxyUrl: + config?.proxyUrl ?? userConfig?.proxyUrl ?? DEFAULT_BURP_PROXY_URL, + sseUrl, + mcpSseUrl: sseUrl, + mcpProxyCommand: + config?.mcpProxyCommand ?? + userConfig?.stdioCommand ?? + DEFAULT_BURP_MCP_PROXY_COMMAND, + mcpProxyArgs, + timeoutMs: + config?.timeoutMs ?? userConfig?.timeoutMs ?? DEFAULT_BURP_MCP_TIMEOUT_MS, + allowedTargets: config?.allowedTargets ?? userConfig?.allowedTargets ?? [], + allowConfigMutation: + config?.allowConfigMutation ?? userConfig?.allowConfigMutation ?? false, + ignoreTlsErrors: + config?.ignoreTlsErrors ?? userConfig?.ignoreTlsErrors ?? false, + warnings, + }; +} diff --git a/src/core/agents/offSecAgent/tools/burpMcp.test.ts b/src/core/agents/offSecAgent/tools/burpMcp.test.ts new file mode 100644 index 000000000..a414e78d3 --- /dev/null +++ b/src/core/agents/offSecAgent/tools/burpMcp.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { extractMcpText, parseRawHttpTarget } from "./burpMcp"; + +describe("Burp MCP helpers", () => { + it("extracts text content from MCP tool results", () => { + expect( + extractMcpText({ + content: [ + { type: "text", text: "first" }, + { type: "text", text: "second" }, + ], + }), + ).toBe("first\nsecond"); + }); + + it("parses target details from raw HTTP Host header", () => { + expect( + parseRawHttpTarget("GET / HTTP/1.1\r\nHost: example.com:8080\r\n\r\n"), + ).toEqual({ + targetHostname: "example.com", + targetPort: 8080, + usesHttps: true, + }); + }); + + it("returns null when a raw HTTP request has no Host header", () => { + expect(parseRawHttpTarget("GET / HTTP/1.1\r\n\r\n")).toBeNull(); + }); +}); diff --git a/src/core/agents/offSecAgent/tools/burpMcp.ts b/src/core/agents/offSecAgent/tools/burpMcp.ts new file mode 100644 index 000000000..806fdeab7 --- /dev/null +++ b/src/core/agents/offSecAgent/tools/burpMcp.ts @@ -0,0 +1,658 @@ +import { tool } from "ai"; +import { z } from "zod"; +import { appendFileSync, mkdirSync } from "fs"; +import { join } from "path"; +import { GenericMcpClient, type McpToolDefinition } from "../../../mcp/client"; +import type { ToolContext } from "./types"; +import { + resolveBurpSuiteConfig, + type ResolvedBurpSuiteConfig, +} from "./burpConfig"; +import { + getAllowedHosts, + isHostAllowed, + ScopeViolationError, +} from "./scopeGuard"; + +const CONFIG_MUTATION_TOOL_PATTERN = + /\b(set|update|modify|export|import).*config\b/i; +const MAX_BURP_RESULT_CHARS = 12_000; + +export function extractMcpText(result: unknown): string { + if (result && typeof result === "object" && "content" in result) { + const content = (result as { content?: unknown }).content; + if (Array.isArray(content)) { + return content + .map((item) => { + if (item && typeof item === "object" && "text" in item) { + return String((item as { text: unknown }).text); + } + return JSON.stringify(item); + }) + .join("\n"); + } + } + + if (typeof result === "string") return result; + return JSON.stringify(result, null, 2) ?? String(result); +} + +export function parseRawHttpTarget(content: string): { + targetHostname: string; + targetPort: number; + usesHttps: boolean; +} | null { + const hostMatch = content.match(/^Host:\s*([^\r\n]+)$/im); + if (!hostMatch) return null; + + const host = hostMatch[1].trim(); + const [hostname, portText] = host.split(":"); + if (!hostname) return null; + + const targetPort = portText ? parseInt(portText, 10) : 443; + return { + targetHostname: hostname, + targetPort: Number.isFinite(targetPort) ? targetPort : 443, + usesHttps: targetPort !== 80, + }; +} + +export function redactSensitiveHttpText(text: string): string { + return text + .replace( + /^(Authorization|Proxy-Authorization|Cookie|Set-Cookie|X-Api-Key|Api-Key):\s*.*$/gim, + "$1: ", + ) + .replace( + /("(?:password|passwd|token|secret|apiKey|accessToken|refreshToken)"\s*:\s*)"[^"]*"/gi, + '$1""', + ); +} + +function truncateResult(text: string): string { + if (text.length <= MAX_BURP_RESULT_CHARS) return text; + return `${text.slice(0, MAX_BURP_RESULT_CHARS)}\n\n(truncated Burp MCP result; full response omitted to avoid storing excessive proxy data)`; +} + +function sanitizeArgs(args: Record): Record { + return Object.fromEntries( + Object.entries(args).map(([key, value]) => { + if (typeof value === "string") { + return [key, redactSensitiveHttpText(value).slice(0, 2_000)]; + } + return [key, value]; + }), + ); +} + +function summarizeBurpResult(result: string): string { + const redacted = redactSensitiveHttpText(result); + return truncateResult(redacted); +} + +function resolveRawHttpTarget(input: { + content: string; + targetHostname?: string; + targetPort?: number; + usesHttps?: boolean; +}): + | { + success: true; + targetHostname: string; + targetPort: number; + usesHttps: boolean; + } + | { success: false; error: string } { + const parsed = parseRawHttpTarget(input.content); + const targetHostname = input.targetHostname ?? parsed?.targetHostname; + const targetPort = input.targetPort ?? parsed?.targetPort; + const usesHttps = input.usesHttps ?? parsed?.usesHttps; + + if (!targetHostname || targetPort == null || usesHttps == null) { + return { + success: false, + error: + "targetHostname, targetPort, and usesHttps are required when the raw request has no usable Host header.", + }; + } + + return { + success: true, + targetHostname, + targetPort, + usesHttps, + }; +} + +export class BurpMcpSession { + private client: GenericMcpClient; + private toolsCache: McpToolDefinition[] | null = null; + + constructor(private readonly config: ResolvedBurpSuiteConfig) { + this.client = new GenericMcpClient({ + name: "apex-burp", + version: "1.0.0", + transport: config.transport, + url: config.sseUrl, + command: config.mcpProxyCommand, + args: config.mcpProxyArgs, + timeoutMs: config.timeoutMs, + }); + } + + async disconnect(): Promise { + await this.client.disconnect(); + } + + async listTools(): Promise { + if (!this.toolsCache) { + this.toolsCache = (await this.client.listTools()).sort((a, b) => + a.name.localeCompare(b.name), + ); + } + return this.toolsCache; + } + + async listToolNames(): Promise { + return (await this.listTools()).map((tool) => tool.name); + } + + async hasTool(toolName: string): Promise { + return (await this.listToolNames()).includes(toolName); + } + + async callTool( + toolName: string, + args: Record, + abortSignal?: AbortSignal, + ): Promise { + if (!(await this.hasTool(toolName))) { + throw new Error( + `Burp MCP tool '${toolName}' is not available. Check the Burp MCP extension version or enabled permissions.`, + ); + } + + if ( + !this.config.allowConfigMutation && + CONFIG_MUTATION_TOOL_PATTERN.test(toolName) + ) { + throw new Error( + `Burp MCP tool '${toolName}' can modify Burp configuration. Set allowConfigMutation explicitly before using config-modifying tools.`, + ); + } + + const result = await this.client.callTool(toolName, args, abortSignal); + return extractMcpText(result); + } +} + +function createUnavailableTool(description: string) { + return tool({ + description, + inputSchema: z.object({ + toolCallDescription: z.string().describe("Why you need to use Burp"), + }), + execute: async () => ({ + success: false, + error: "Burp Suite integration is not enabled for this session.", + }), + }); +} + +export const BURP_TOOL_NAMES = [ + "burp_check_connection", + "burp_get_proxy_http_history", + "burp_search_proxy_http_history", + "burp_get_proxy_websocket_history", + "burp_send_to_repeater", + "burp_send_to_intruder", + "burp_send_http_request", + "burp_generate_collaborator_payload", + "burp_poll_collaborator_interactions", + "burp_get_proxy_intercept_state", + "burp_set_proxy_intercept_state", + "burp_get_scanner_issues", +] as const; + +export type BurpToolName = (typeof BURP_TOOL_NAMES)[number]; + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function scopedHistoryRegex(ctx: ToolContext): string | undefined { + const allowedHosts = getAllowedHosts(ctx); + if (allowedHosts.length === 0) return undefined; + return allowedHosts.map(escapeRegex).join("|"); +} + +function assertRawHttpTargetInScope( + target: { targetHostname: string }, + ctx: ToolContext, +): void { + const allowedHosts = getAllowedHosts(ctx); + if (allowedHosts.length === 0) return; + if (!isHostAllowed(target.targetHostname, allowedHosts)) { + throw new ScopeViolationError(target.targetHostname, allowedHosts); + } +} + +function writeBurpActionLog( + ctx: ToolContext, + entry: { + toolName: string; + target?: string; + args: Record; + success: boolean; + resultSummary?: string; + error?: string; + }, +): void { + try { + const dir = join(ctx.session.logsPath, "burp"); + mkdirSync(dir, { recursive: true }); + appendFileSync( + join(dir, "actions.jsonl"), + `${JSON.stringify({ + timestamp: new Date().toISOString(), + ...entry, + args: sanitizeArgs(entry.args), + })}\n`, + ); + } catch { + // Burp logging should never make the user-facing action fail. + } +} + +export function createBurpToolset(ctx: ToolContext) { + const burp = resolveBurpSuiteConfig(ctx.session.config?.burpSuite); + + if (!burp) { + return Object.fromEntries( + BURP_TOOL_NAMES.map((name) => [ + name, + createUnavailableTool(`${name} is unavailable until Burp is enabled.`), + ]), + ) as Record>; + } + + const session = new BurpMcpSession(burp); + if (ctx.abortSignal) { + const onAbort = () => session.disconnect().catch(() => {}); + ctx.abortSignal.addEventListener("abort", onAbort, { once: true }); + } + + const callBurp = async ( + toolName: string, + args: Record, + target?: string, + ) => { + try { + const text = await session.callTool(toolName, args, ctx.abortSignal); + const result = summarizeBurpResult(text); + writeBurpActionLog(ctx, { + toolName, + target, + args, + success: true, + resultSummary: result.slice(0, 1_000), + }); + return { success: true, result }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + writeBurpActionLog(ctx, { + toolName, + target, + args, + success: false, + error: message, + }); + return { + success: false, + error: message, + }; + } + }; + + const callFirstAvailable = async ( + toolNames: string[], + args: Record, + target?: string, + ) => { + for (const toolName of toolNames) { + if (await session.hasTool(toolName)) { + return callBurp(toolName, args, target); + } + } + return { + success: false, + error: `Burp MCP tool '${toolNames[0]}' is not available. Check the Burp MCP extension version or enabled permissions.`, + }; + }; + + return { + burp_check_connection: tool({ + description: + "Verify the Burp MCP connection and return the MCP tool names exposed by Burp.", + inputSchema: z.object({ + toolCallDescription: z + .string() + .describe("Why you are checking the Burp MCP connection"), + }), + execute: async () => { + try { + const tools = await session.listTools(); + return { + success: true, + endpoint: burp.sseUrl, + transport: burp.transport, + warnings: burp.warnings, + toolCount: tools.length, + tools, + }; + } catch (error: unknown) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }, + }), + + burp_get_proxy_http_history: tool({ + description: + "Read paginated items from Burp Proxy HTTP history. Community edition can expose proxy history when Burp MCP history access is allowed.", + inputSchema: z.object({ + count: z.number().default(20).describe("Number of history items"), + offset: z.number().default(0).describe("History offset"), + toolCallDescription: z + .string() + .describe("Why you need to inspect Burp HTTP history"), + }), + execute: async ({ count, offset }) => { + const regex = scopedHistoryRegex(ctx); + if (regex && (await session.hasTool("get_proxy_http_history_regex"))) { + return callBurp("get_proxy_http_history_regex", { + regex, + count, + offset, + }); + } + if (regex) { + return { + success: false, + error: + "Target-scoped Burp history requires the get_proxy_http_history_regex tool, which is not available.", + }; + } + return callBurp("get_proxy_http_history", { count, offset }); + }, + }), + + burp_search_proxy_http_history: tool({ + description: + "Search Burp Proxy HTTP history with a regex and return paginated matching items.", + inputSchema: z.object({ + regex: z.string().describe("Regex to match against HTTP history items"), + count: z.number().default(20).describe("Number of matching items"), + offset: z.number().default(0).describe("Match offset"), + toolCallDescription: z + .string() + .describe("Why you need to search Burp HTTP history"), + }), + execute: async ({ regex, count, offset }) => { + const scoped = scopedHistoryRegex(ctx); + const effectiveRegex = scoped + ? `(?=.*(?:${scoped}))(?=.*(?:${regex}))` + : regex; + return callBurp("get_proxy_http_history_regex", { + regex: effectiveRegex, + count, + offset, + }); + }, + }), + + burp_get_proxy_websocket_history: tool({ + description: + "Read paginated items from Burp Proxy WebSocket history when available.", + inputSchema: z.object({ + count: z.number().default(20).describe("Number of history items"), + offset: z.number().default(0).describe("History offset"), + toolCallDescription: z + .string() + .describe("Why you need to inspect Burp WebSocket history"), + }), + execute: async ({ count, offset }) => + callBurp("get_proxy_websocket_history", { count, offset }), + }), + + burp_send_to_repeater: tool({ + description: + "Create a Burp Repeater tab from a raw HTTP/1.1 request. The Host header is used when target fields are omitted.", + inputSchema: z.object({ + content: z + .string() + .describe("Raw HTTP request content with CRLF or LF line endings"), + tabName: z.string().optional().describe("Optional Repeater tab name"), + targetHostname: z.string().optional(), + targetPort: z.number().optional(), + usesHttps: z.boolean().optional(), + toolCallDescription: z + .string() + .describe("Why this request should be sent to Repeater"), + }), + execute: async ({ + content, + tabName, + targetHostname, + targetPort, + usesHttps, + }) => { + const target = resolveRawHttpTarget({ + content, + targetHostname, + targetPort, + usesHttps, + }); + if (!target.success) return target; + try { + assertRawHttpTargetInScope(target, ctx); + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + + return callBurp( + "create_repeater_tab", + { + tabName, + content, + targetHostname: target.targetHostname, + targetPort: target.targetPort, + usesHttps: target.usesHttps, + }, + target.targetHostname, + ); + }, + }), + + burp_send_to_intruder: tool({ + description: + "Send a raw HTTP/1.1 request to Burp Intruder. Availability depends on Burp edition and MCP tool exposure.", + inputSchema: z.object({ + content: z + .string() + .describe("Raw HTTP request content with CRLF or LF line endings"), + tabName: z.string().optional().describe("Optional Intruder tab name"), + targetHostname: z.string().optional(), + targetPort: z.number().optional(), + usesHttps: z.boolean().optional(), + toolCallDescription: z + .string() + .describe("Why this request should be sent to Intruder"), + }), + execute: async ({ + content, + tabName, + targetHostname, + targetPort, + usesHttps, + }) => { + const target = resolveRawHttpTarget({ + content, + targetHostname, + targetPort, + usesHttps, + }); + if (!target.success) return target; + try { + assertRawHttpTargetInScope(target, ctx); + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + + return callBurp( + "send_to_intruder", + { + tabName, + content, + targetHostname: target.targetHostname, + targetPort: target.targetPort, + usesHttps: target.usesHttps, + }, + target.targetHostname, + ); + }, + }), + + burp_send_http_request: tool({ + description: + "Send a raw HTTP request through Burp MCP when the server exposes a request-sending tool. Respects Apex scope and Burp target approval.", + inputSchema: z.object({ + content: z + .string() + .describe("Raw HTTP request content with CRLF or LF line endings"), + targetHostname: z.string().optional(), + targetPort: z.number().optional(), + usesHttps: z.boolean().optional(), + toolCallDescription: z + .string() + .describe("Why this request should be sent through Burp"), + }), + execute: async ({ content, targetHostname, targetPort, usesHttps }) => { + const target = resolveRawHttpTarget({ + content, + targetHostname, + targetPort, + usesHttps, + }); + if (!target.success) return target; + try { + assertRawHttpTargetInScope(target, ctx); + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + return callFirstAvailable( + ["send_http_request", "send_http1_request", "send_request"], + { + content, + targetHostname: target.targetHostname, + targetPort: target.targetPort, + usesHttps: target.usesHttps, + }, + target.targetHostname, + ); + }, + }), + + burp_generate_collaborator_payload: tool({ + description: + "Generate a Burp Collaborator payload when available. Use only for authorized out-of-band testing.", + inputSchema: z.object({ + toolCallDescription: z + .string() + .describe("Why an out-of-band Collaborator payload is needed"), + }), + execute: async () => + callFirstAvailable(["generate_collaborator_payload"], {}), + }), + + burp_poll_collaborator_interactions: tool({ + description: + "Poll Burp Collaborator interactions when available and relevant to the current authorized test.", + inputSchema: z.object({ + toolCallDescription: z + .string() + .describe("Why you need to poll Collaborator interactions"), + }), + execute: async () => + callFirstAvailable(["poll_collaborator_interactions"], {}), + }), + + burp_get_proxy_intercept_state: tool({ + description: "Read Burp Proxy intercept state when exposed by Burp MCP.", + inputSchema: z.object({ + toolCallDescription: z + .string() + .describe("Why you need to read proxy intercept state"), + }), + execute: async () => + callFirstAvailable( + ["get_proxy_intercept", "get_proxy_intercept_state"], + {}, + ), + }), + + burp_set_proxy_intercept_state: tool({ + description: + "Set Burp Proxy intercept state when exposed by Burp MCP. Use only when the operator explicitly asks for it.", + inputSchema: z.object({ + enabled: z + .boolean() + .describe("Whether proxy intercept should be enabled"), + toolCallDescription: z + .string() + .describe("Why changing proxy intercept state is needed"), + }), + execute: async ({ enabled }) => + callFirstAvailable( + ["set_proxy_intercept", "set_proxy_intercept_state"], + { enabled }, + ), + }), + + burp_get_scanner_issues: tool({ + description: + "Read Burp Scanner issues when available. Burp Community usually does not expose scanner issues.", + inputSchema: z.object({ + count: z.number().default(20).describe("Number of issues"), + offset: z.number().default(0).describe("Issue offset"), + toolCallDescription: z + .string() + .describe("Why you need to inspect Burp scanner issues"), + }), + execute: async ({ count, offset }) => { + const available = await session + .listToolNames() + .catch(() => [] as string[]); + if (!available.includes("get_scanner_issues")) { + return { + success: false, + error: + "Burp Scanner issues are not available from this Burp MCP server. This is expected on Burp Community.", + }; + } + return callBurp("get_scanner_issues", { count, offset }); + }, + }), + }; +} diff --git a/src/core/agents/offSecAgent/tools/httpRequest.test.ts b/src/core/agents/offSecAgent/tools/httpRequest.test.ts new file mode 100644 index 000000000..4df602b73 --- /dev/null +++ b/src/core/agents/offSecAgent/tools/httpRequest.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { buildCurlArgs, parseCurlResponse } from "./httpRequest"; + +describe("httpRequest curl helpers", () => { + it("builds curl args with Burp proxy options", () => { + expect( + buildCurlArgs({ + url: "https://example.com/login", + method: "POST", + headers: { "Content-Type": "application/json" }, + body: '{"username":"test"}', + followRedirects: true, + timeout: 10_000, + proxyUrl: "http://127.0.0.1:8080", + ignoreTlsErrors: true, + }), + ).toEqual([ + "-i", + "-X", + "POST", + "-H", + "Content-Type: application/json", + "-d", + '{"username":"test"}', + "-L", + "--proxy", + "http://127.0.0.1:8080", + "-k", + "--max-time", + "10", + "https://example.com/login", + ]); + }); + + it("parses the final response when curl follows redirects", () => { + const parsed = parseCurlResponse( + [ + "HTTP/1.1 302 Found", + "Location: /login", + "", + "", + "HTTP/1.1 200 OK", + "Content-Type: text/plain", + "", + "done", + ].join("\n"), + "https://example.com", + ); + + expect(parsed).toMatchObject({ + success: true, + status: 200, + statusText: "OK", + headers: { "content-type": "text/plain" }, + body: "done", + redirected: true, + }); + }); +}); diff --git a/src/core/agents/offSecAgent/tools/httpRequest.ts b/src/core/agents/offSecAgent/tools/httpRequest.ts index 581d83cde..0d88d80dc 100644 --- a/src/core/agents/offSecAgent/tools/httpRequest.ts +++ b/src/core/agents/offSecAgent/tools/httpRequest.ts @@ -2,8 +2,10 @@ import { tool } from "ai"; import { z } from "zod"; import { join } from "path"; import { writeFileSync, mkdirSync, existsSync } from "fs"; +import { execFile } from "child_process"; import type { ToolContext } from "./types"; import { assertUrlInScope, ScopeViolationError } from "./scopeGuard"; +import { resolveBurpSuiteConfig } from "./burpConfig"; const MAX_INLINE_BODY = 5_000; @@ -93,6 +95,158 @@ function parseHeaders(raw: string | undefined): Record { return {}; } +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +export function buildCurlArgs(opts: { + url: string; + method: string; + headers?: Record; + body?: string; + followRedirects: boolean; + timeout: number; + proxyUrl?: string; + ignoreTlsErrors?: boolean; +}): string[] { + const args = ["-i", "-X", opts.method]; + + if (opts.headers) { + for (const [key, value] of Object.entries(opts.headers)) { + args.push("-H", `${key}: ${value}`); + } + } + + if (opts.body && ["POST", "PUT", "PATCH"].includes(opts.method)) { + args.push("-d", opts.body); + } + + if (opts.followRedirects) args.push("-L"); + if (opts.proxyUrl) args.push("--proxy", opts.proxyUrl); + if (opts.ignoreTlsErrors) args.push("-k"); + + args.push("--max-time", String(Math.ceil(opts.timeout / 1000))); + args.push(opts.url); + return args; +} + +export function parseCurlResponse(output: string, url: string): HttpRequestResult { + const normalized = output.replace(/\r\n/g, "\n"); + const headerMatches = [...normalized.matchAll(/^HTTP\/[\d.]+ .+$/gm)]; + const lastHeader = headerMatches[headerMatches.length - 1]; + + if (!lastHeader || lastHeader.index == null) { + return { + success: false, + error: "No HTTP response headers found", + status: 0, + statusText: "Unknown", + headers: {}, + body: normalized, + url, + redirected: false, + }; + } + + const responseText = normalized.slice(lastHeader.index); + const lines = responseText.split("\n"); + const statusLine = lines[0] ?? ""; + const responseHeaders: Record = {}; + let bodyStartIndex = 1; + + for (let i = 1; i < lines.length; i++) { + if (lines[i].trim() === "") { + bodyStartIndex = i + 1; + break; + } + const headerMatch = lines[i].match(/^([^:]+):\s*(.+)$/); + if (headerMatch) { + responseHeaders[headerMatch[1].toLowerCase()] = headerMatch[2]; + } + } + + const statusMatch = statusLine.match(/HTTP\/[\d.]+\s+(\d+)\s*(.*)/); + const status = statusMatch ? parseInt(statusMatch[1], 10) : 0; + const statusText = statusMatch ? statusMatch[2] || "OK" : "Unknown"; + + return { + success: status >= 200 && status < 400, + status, + statusText, + headers: responseHeaders, + body: lines.slice(bodyStartIndex).join("\n"), + url, + redirected: headerMatches.length > 1, + }; +} + +async function executeLocalCurlHttpRequest( + ctx: ToolContext, + opts: { + url: string; + method: string; + headers?: Record; + body?: string; + followRedirects: boolean; + timeout: number; + proxyUrl?: string; + ignoreTlsErrors?: boolean; + }, +): Promise { + const args = buildCurlArgs(opts); + + return new Promise((resolve) => { + const child = execFile( + "curl", + args, + { + timeout: opts.timeout + 1_000, + maxBuffer: 10 * 1024 * 1024, + }, + (error, stdout, stderr) => { + if (ctx.abortSignal?.aborted) { + resolve({ + success: false, + error: "Request aborted by user", + status: 0, + statusText: "", + headers: {}, + body: "", + url: opts.url, + redirected: false, + method: opts.method, + }); + return; + } + + const output = [stdout, stderr].filter(Boolean).join("\n"); + const parsed = parseCurlResponse(output, opts.url); + const { text: truncatedBody } = maybeSaveBody(parsed.body, ctx); + + resolve({ + ...parsed, + body: truncatedBody, + method: opts.method, + error: + error && !parsed.status + ? error instanceof Error + ? error.message + : String(error) + : undefined, + }); + }, + ); + + if (ctx.abortSignal) { + const onAbort = () => child.kill("SIGTERM"); + ctx.abortSignal.addEventListener("abort", onAbort, { once: true }); + child.on("exit", () => + ctx.abortSignal?.removeEventListener("abort", onAbort), + ); + } + }); +} + export function httpRequest(ctx: ToolContext) { return tool({ description: `Make HTTP requests with detailed response analysis for web application testing. @@ -149,6 +303,7 @@ COMMON TESTING PATTERNS: } const headers = parseHeaders(rawHeaders); + const burp = resolveBurpSuiteConfig(ctx.session.config?.burpSuite); // Sandbox mode: build a curl command and run it inside the sandbox if (ctx.sandbox) { @@ -159,6 +314,21 @@ COMMON TESTING PATTERNS: body, followRedirects, timeout, + proxyUrl: burp?.proxyUrl, + ignoreTlsErrors: burp?.ignoreTlsErrors, + }); + } + + if (burp) { + return executeLocalCurlHttpRequest(ctx, { + url, + method, + headers, + body, + followRedirects, + timeout, + proxyUrl: burp.proxyUrl, + ignoreTlsErrors: burp.ignoreTlsErrors, }); } @@ -261,76 +431,49 @@ async function executeSandboxHttpRequest( body?: string; followRedirects: boolean; timeout: number; + proxyUrl?: string; + ignoreTlsErrors?: boolean; }, ): Promise { - const { url, method, headers, body, followRedirects, timeout } = opts; + const { + url, + method, + headers, + body, + followRedirects, + timeout, + proxyUrl, + ignoreTlsErrors, + } = opts; try { - let curlCommand = `curl -i -X ${method}`; - - if (headers) { - for (const [key, value] of Object.entries(headers)) { - curlCommand += ` -H "${key}: ${value}"`; - } - } - - if (body && ["POST", "PUT", "PATCH"].includes(method)) { - const escapedBody = body.replace(/"/g, '\\"').replace(/\$/g, "\\$"); - curlCommand += ` -d "${escapedBody}"`; - } - - if (followRedirects) { - curlCommand += " -L"; - } - const timeoutSeconds = Math.ceil(timeout / 1000); - curlCommand += ` --max-time ${timeoutSeconds}`; - curlCommand += ` "${url}" 2>&1`; + const curlCommand = [ + "curl", + ...buildCurlArgs({ + url, + method, + headers, + body, + followRedirects, + timeout, + proxyUrl, + ignoreTlsErrors, + }).map(shellQuote), + "2>&1", + ].join(" "); const ssmTimeout = Math.max(timeoutSeconds, 30); const result = await ctx.sandbox!.execute(curlCommand, { timeout: ssmTimeout, }); - const output = result.stdout || ""; - const lines = output.split("\n"); - let statusLine = ""; - const responseHeaders: Record = {}; - let bodyStartIndex = 0; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - if (line.startsWith("HTTP/")) { - statusLine = line; - for (let j = i + 1; j < lines.length; j++) { - if (lines[j].trim() === "") { - bodyStartIndex = j + 1; - break; - } - const headerMatch = lines[j].match(/^([^:]+):\s*(.+)$/); - if (headerMatch) { - responseHeaders[headerMatch[1].toLowerCase()] = headerMatch[2]; - } - } - break; - } - } - - const statusMatch = statusLine.match(/HTTP\/[\d.]+\s+(\d+)\s+(.+)/); - const status = statusMatch ? parseInt(statusMatch[1]) : 0; - const statusText = statusMatch ? statusMatch[2] : "Unknown"; - const responseBody = lines.slice(bodyStartIndex).join("\n"); - - const { text: truncatedBody } = maybeSaveBody(responseBody, ctx); + const parsed = parseCurlResponse(result.stdout || "", url); + const { text: truncatedBody } = maybeSaveBody(parsed.body, ctx); return { - success: status >= 200 && status < 400, - status, - statusText, - headers: responseHeaders, + ...parsed, body: truncatedBody, - url, - redirected: false, }; } catch (error: unknown) { const msg = error instanceof Error ? error.message : String(error); diff --git a/src/core/agents/offSecAgent/tools/index.ts b/src/core/agents/offSecAgent/tools/index.ts index a11484ea7..2bf7b3cf1 100644 --- a/src/core/agents/offSecAgent/tools/index.ts +++ b/src/core/agents/offSecAgent/tools/index.ts @@ -80,6 +80,10 @@ export type { EmailToolName } from "./email"; export { webSearch } from "./webSearch"; export { getPage } from "./getPage"; +// Burp Suite tools +export { createBurpToolset, BURP_TOOL_NAMES } from "./burpMcp"; +export type { BurpToolName } from "./burpMcp"; + // Skill tools export { readSkill } from "./readSkill"; @@ -138,6 +142,7 @@ import { emailSearchMessages } from "./email/searchMessages"; import { emailGetMessage } from "./email/getMessage"; import { webSearch } from "./webSearch"; import { getPage } from "./getPage"; +import { createBurpToolset } from "./burpMcp"; import { readSkill } from "./readSkill"; import { checkpointState } from "./checkpointState"; import { createTask } from "./createTask"; @@ -217,6 +222,9 @@ export function createAllTools(ctx: ToolContext & { subagentId?: string }) { web_search: webSearch(ctx), get_page: getPage(ctx), + // Burp Suite integration tools + ...createBurpToolset(ctx), + // Skill tools (conditional — only when registry is provided) ...(ctx.skillsRegistry ? { read_skill: readSkill(ctx) } : {}), @@ -290,6 +298,19 @@ export const ALL_TOOL_NAMES: ToolName[] = [ // Web search (requires Pensar account) "web_search", "get_page", + // Burp Suite + "burp_check_connection", + "burp_get_proxy_http_history", + "burp_search_proxy_http_history", + "burp_get_proxy_websocket_history", + "burp_send_to_repeater", + "burp_send_to_intruder", + "burp_send_http_request", + "burp_generate_collaborator_payload", + "burp_poll_collaborator_interactions", + "burp_get_proxy_intercept_state", + "burp_set_proxy_intercept_state", + "burp_get_scanner_issues", // Observability "checkpoint_state", // Task decomposition @@ -348,6 +369,14 @@ export const PLAN_MODE_TOOL_NAMES: ToolName[] = [ // Web search "web_search", "get_page", + // Burp Suite (read-only history / connection checks only) + "burp_check_connection", + "burp_get_proxy_http_history", + "burp_search_proxy_http_history", + "burp_get_proxy_websocket_history", + "burp_poll_collaborator_interactions", + "burp_get_proxy_intercept_state", + "burp_get_scanner_issues", // Plan mode tools "write_plan", "submit_plan", @@ -361,3 +390,6 @@ export const SKILL_TOOL_NAMES = ["read_skill"] as const; /** Email tool names — auto-appended to activeTools by the base class when inboxes are configured. */ export { EMAIL_TOOL_NAMES as EMAIL_TOOL_NAMES_ACTIVE } from "./email"; + +/** Burp tool names — auto-filtered by the base class when Burp is not configured. */ +export { BURP_TOOL_NAMES as BURP_TOOL_NAMES_ACTIVE } from "./burpMcp"; diff --git a/src/core/agents/offSecAgent/tools/playwrightMcp.ts b/src/core/agents/offSecAgent/tools/playwrightMcp.ts index 04231755c..587f308ce 100644 --- a/src/core/agents/offSecAgent/tools/playwrightMcp.ts +++ b/src/core/agents/offSecAgent/tools/playwrightMcp.ts @@ -270,11 +270,21 @@ export class PlaywrightMcpSession { private readonly headless: boolean; private readonly userAgent: string | undefined; private readonly viewportSize: string | undefined; - - constructor(headless = true, userAgent?: string, viewportSize?: string) { + private readonly proxyServer: string | undefined; + private readonly ignoreHTTPSErrors: boolean; + + constructor( + headless = true, + userAgent?: string, + viewportSize?: string, + proxyServer?: string, + ignoreHTTPSErrors = false, + ) { this.headless = headless; this.userAgent = userAgent; this.viewportSize = viewportSize; + this.proxyServer = proxyServer; + this.ignoreHTTPSErrors = ignoreHTTPSErrors; } /** Immediately reset all instance state. Synchronous — no I/O. */ @@ -348,6 +358,14 @@ export class PlaywrightMcpSession { args.push(`--viewport-size=${this.viewportSize}`); } + if (this.proxyServer) { + args.push("--proxy-server", this.proxyServer); + } + + if (this.ignoreHTTPSErrors) { + args.push("--ignore-https-errors"); + } + // Disable Chromium sandbox when running as root (e.g., in Docker/ECS containers). if (process.getuid?.() === 0) { args.push("--no-sandbox"); @@ -743,6 +761,11 @@ Use this to check for: * this session (defaults to the value set by {@link setViewportSize}). * Passing `null` forces Chromium's default viewport. */ +export interface BrowserProxyOptions { + proxyServer?: string; + ignoreHTTPSErrors?: boolean; +} + export function createBrowserTools( targetUrl: string, evidenceDir: string, @@ -752,6 +775,7 @@ export function createBrowserTools( headless?: boolean, userAgent?: string | null, viewportSize?: string | null, + proxyOptions?: BrowserProxyOptions, ) { const resolvedUserAgent = userAgent === null ? undefined : (userAgent ?? defaultUserAgent); @@ -762,6 +786,8 @@ export function createBrowserTools( headless ?? defaultHeadless, resolvedUserAgent, resolvedViewportSize, + proxyOptions?.proxyServer, + proxyOptions?.ignoreHTTPSErrors ?? false, ); if (abortSignal) { diff --git a/src/core/agents/offSecAgent/tools/sandboxPlaywright.ts b/src/core/agents/offSecAgent/tools/sandboxPlaywright.ts index 624740854..960f4802e 100644 --- a/src/core/agents/offSecAgent/tools/sandboxPlaywright.ts +++ b/src/core/agents/offSecAgent/tools/sandboxPlaywright.ts @@ -24,6 +24,7 @@ import { join, dirname } from "path"; import { mkdirSync, existsSync, writeFileSync } from "fs"; import type { UnifiedSandbox } from "./sandbox"; import type { ToolContext } from "./types"; +import { resolveBurpSuiteConfig } from "./burpConfig"; import type { BrowserNavigateResult, BrowserScreenshotResult, @@ -229,7 +230,14 @@ async function runPlaywrightScript( sandbox: UnifiedSandbox, body: string, timeout = 60, + options?: { proxyServer?: string; ignoreHTTPSErrors?: boolean }, ): Promise { + const proxyOption = options?.proxyServer + ? `proxy: { server: ${JSON.stringify(options.proxyServer)} },` + : ""; + const ignoreHttpsOption = options?.ignoreHTTPSErrors + ? "ignoreHTTPSErrors: true," + : ""; const script = ` const { chromium } = require('playwright'); const fs = require('fs'); @@ -251,6 +259,8 @@ const fs = require('fs'); context = await chromium.launchPersistentContext('/tmp/pw-user-data', { headless: true, ...(executablePath ? { executablePath } : {}), + ${proxyOption} + ${ignoreHttpsOption} args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'], }); const pages = context.pages(); @@ -420,6 +430,17 @@ export function createSandboxBrowserTools(ctx: ToolContext) { const sandbox = ctx.sandbox!; const evidenceDir = join(ctx.session.rootPath, "evidence"); const targetUrl = ctx.target ?? ""; + const burp = resolveBurpSuiteConfig(ctx.session.config?.burpSuite); + const playwrightOptions = burp + ? { + proxyServer: burp.proxyUrl, + ignoreHTTPSErrors: burp.ignoreTlsErrors, + } + : undefined; + + function runScript(body: string, timeout?: number): Promise { + return runPlaywrightScript(sandbox, body, timeout, playwrightOptions); + } if (!existsSync(evidenceDir)) { mkdirSync(evidenceDir, { recursive: true }); @@ -447,8 +468,7 @@ Target base URL: ${targetUrl}`, execute: async ({ url }): Promise => { try { await setup(); - const result = (await runPlaywrightScript( - sandbox, + const result = (await runScript( ` await page.goto(${JSON.stringify(url)}, { waitUntil: 'domcontentloaded', timeout: 30000 }); // Persist current URL so subsequent tool calls can restore the page @@ -485,8 +505,7 @@ Use this to document: const screenshotFilename = `${filename}_${timestamp}.png`; const sandboxPath = `${SANDBOX_EVIDENCE_DIR}/${screenshotFilename}`; - const result = (await runPlaywrightScript( - sandbox, + const result = (await runScript( ` const buf = await page.screenshot({ fullPage: false }); const b64 = buf.toString('base64'); @@ -545,8 +564,7 @@ Example workflow: }> => { try { await setup(); - const result = (await runPlaywrightScript( - sandbox, + const result = (await runScript( ` // Build accessibility snapshot via page.evaluate — works on all // Playwright versions and doesn't depend on the deprecated @@ -661,8 +679,7 @@ IMPORTANT: For reliable clicking, first call browser_snapshot to get element ref execute: async ({ element, ref }): Promise => { try { await setup(); - const result = (await runPlaywrightScript( - sandbox, + const result = (await runScript( ` const ref = ${JSON.stringify(ref || "")}; const element = ${JSON.stringify(element)}; @@ -739,8 +756,7 @@ IMPORTANT: For reliable form filling, first call browser_snapshot to get element execute: async ({ element, ref, value }): Promise => { try { await setup(); - const result = (await runPlaywrightScript( - sandbox, + const result = (await runScript( ` const ref = ${JSON.stringify(ref || "")}; const element = ${JSON.stringify(element)}; @@ -828,8 +844,7 @@ The JavaScript is executed in the page context and the result is returned.`, /^\s*(async\s+)?function\s*\(/.test(script); const fnScript = isFunction ? script : `() => (${script})`; - const result = (await runPlaywrightScript( - sandbox, + const result = (await runScript( ` const fnStr = ${JSON.stringify(fnScript)}; const fn = new Function('return (' + fnStr + ')')(); @@ -862,8 +877,7 @@ Use this to check for: execute: async (): Promise => { try { await setup(); - const result = (await runPlaywrightScript( - sandbox, + const result = (await runScript( ` const fs = require('fs'); let persisted = []; @@ -915,8 +929,7 @@ The returned cookies can be formatted as a Cookie header for use with http_reque }> => { try { await setup(); - const result = (await runPlaywrightScript( - sandbox, + const result = (await runScript( ` const urls = ${JSON.stringify(urls || [])}; const cookies = urls.length > 0 diff --git a/src/core/agents/specialized/attackSurface/blackboxAgent.ts b/src/core/agents/specialized/attackSurface/blackboxAgent.ts index 0090e408e..e37627ae2 100644 --- a/src/core/agents/specialized/attackSurface/blackboxAgent.ts +++ b/src/core/agents/specialized/attackSurface/blackboxAgent.ts @@ -136,6 +136,12 @@ export class BlackboxAttackSurfaceAgent extends OffensiveSecurityAgent "browser_screenshot", "browser_click", "browser_fill", + // Burp Suite tools (filtered out by base class when Burp is not configured) + "burp_check_connection", + "burp_get_proxy_http_history", + "burp_search_proxy_http_history", + "burp_get_proxy_websocket_history", + "burp_send_to_repeater", + "burp_send_to_intruder", + "burp_send_http_request", + "burp_generate_collaborator_payload", + "burp_poll_collaborator_interactions", + "burp_get_proxy_intercept_state", + "burp_set_proxy_intercept_state", + "burp_get_scanner_issues", // Email tools (filtered out by base class when no inboxes configured) "email_list_inboxes", "email_list_messages", diff --git a/src/core/config/config.ts b/src/core/config/config.ts index 0210cc236..c94798f06 100644 --- a/src/core/config/config.ts +++ b/src/core/config/config.ts @@ -7,6 +7,19 @@ const DEFAULT_CONFIG: Config = { responsibleUseAccepted: false, }; +export interface BurpMcpUserConfig { + enabled?: boolean; + transport?: "sse" | "stdio"; + sseUrl?: string; + proxyUrl?: string; + timeoutMs?: number; + allowedTargets?: string[]; + allowConfigMutation?: boolean; + ignoreTlsErrors?: boolean; + stdioCommand?: string; + stdioArgs?: string[]; +} + export interface Config { version?: string; openAiAPIKey?: string | null; @@ -40,6 +53,8 @@ export interface Config { gatewaySigningKey?: string | null; // Gateway URL for inference (server-issued, bypasses CloudFront timeout) gatewayUrl?: string | null; + // Burp Suite MCP integration + burpMcp?: BurpMcpUserConfig; } export async function init() { diff --git a/src/core/mcp/client.test.ts b/src/core/mcp/client.test.ts new file mode 100644 index 000000000..e1d73ad73 --- /dev/null +++ b/src/core/mcp/client.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { GenericMcpClient, McpConnectionError, McpToolError } from "./client"; + +function createMockClient(options?: { + connectFails?: boolean; + tools?: Array<{ name: string; description?: string; inputSchema?: unknown }>; + toolResult?: unknown; +}) { + return { + connect: async () => { + if (options?.connectFails) throw new Error("connect failed"); + }, + close: async () => {}, + listTools: async () => ({ + tools: options?.tools ?? [{ name: "ping", description: "Ping" }], + }), + callTool: async ({ name }: { name: string }) => { + if (name === "fail") throw new Error("tool failed"); + return options?.toolResult ?? { content: [{ type: "text", text: "ok" }] }; + }, + }; +} + +function createMockTransport() { + return { + start: async () => {}, + close: async () => {}, + send: async () => {}, + }; +} + +describe("GenericMcpClient", () => { + it("lists tools through the configured transport", async () => { + const client = new GenericMcpClient( + { + name: "test", + version: "1.0.0", + transport: "sse", + url: "http://127.0.0.1:9876/sse", + }, + { + createClient: () => + createMockClient({ + tools: [{ name: "first" }, { name: "second" }], + }) as never, + createStreamableHttpTransport: () => createMockTransport() as never, + }, + ); + + await expect(client.listTools()).resolves.toMatchObject([ + { name: "first" }, + { name: "second" }, + ]); + }); + + it("falls back from streamable HTTP to legacy SSE", async () => { + let attempts = 0; + const client = new GenericMcpClient( + { + name: "test", + version: "1.0.0", + transport: "sse", + url: "http://127.0.0.1:9876/sse", + }, + { + createClient: () => + ({ + ...createMockClient(), + connect: async () => { + attempts++; + if (attempts === 1) throw new Error("streamable unsupported"); + }, + }) as never, + createStreamableHttpTransport: () => createMockTransport() as never, + createSseTransport: () => createMockTransport() as never, + }, + ); + + await expect(client.listTools()).resolves.toHaveLength(1); + expect(attempts).toBe(2); + }); + + it("surfaces connection and tool errors with structured error classes", async () => { + const connection = new GenericMcpClient( + { + name: "test", + version: "1.0.0", + transport: "sse", + url: "http://127.0.0.1:9876/sse", + }, + { + createClient: () => createMockClient({ connectFails: true }) as never, + createStreamableHttpTransport: () => createMockTransport() as never, + createSseTransport: () => createMockTransport() as never, + }, + ); + await expect(connection.listTools()).rejects.toBeInstanceOf( + McpConnectionError, + ); + + const tool = new GenericMcpClient( + { + name: "test", + version: "1.0.0", + transport: "sse", + url: "http://127.0.0.1:9876/sse", + }, + { + createClient: () => createMockClient() as never, + createStreamableHttpTransport: () => createMockTransport() as never, + }, + ); + await expect(tool.callTool("fail", {})).rejects.toBeInstanceOf( + McpToolError, + ); + }); +}); diff --git a/src/core/mcp/client.ts b/src/core/mcp/client.ts new file mode 100644 index 000000000..67a0b3913 --- /dev/null +++ b/src/core/mcp/client.ts @@ -0,0 +1,289 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; + +export type McpTransportType = "sse" | "stdio"; + +export interface McpToolDefinition { + name: string; + description?: string; + inputSchema?: unknown; + annotations?: { + readOnlyHint?: boolean; + destructiveHint?: boolean; + idempotentHint?: boolean; + openWorldHint?: boolean; + title?: string; + }; +} + +export interface McpClientConfig { + name: string; + version: string; + transport: McpTransportType; + url?: string; + command?: string; + args?: string[]; + timeoutMs?: number; +} + +export interface McpClientFactories { + createClient?: () => Pick< + Client, + "connect" | "close" | "listTools" | "callTool" + >; + createStreamableHttpTransport?: (url: URL) => Transport; + createSseTransport?: (url: URL) => Transport; + createStdioTransport?: (opts: { + command: string; + args?: string[]; + }) => Transport; +} + +export class McpError extends Error { + constructor( + message: string, + public readonly code: string, + public readonly originalCause?: unknown, + ) { + super(message); + this.name = "McpError"; + } +} + +export class McpConnectionError extends McpError { + constructor(message: string, cause?: unknown) { + super(message, "MCP_CONNECTION_ERROR", cause); + this.name = "McpConnectionError"; + } +} + +export class McpTimeoutError extends McpError { + constructor(message: string, cause?: unknown) { + super(message, "MCP_TIMEOUT", cause); + this.name = "McpTimeoutError"; + } +} + +export class McpToolError extends McpError { + constructor( + message: string, + public readonly toolName: string, + cause?: unknown, + ) { + super(message, "MCP_TOOL_ERROR", cause); + this.name = "McpToolError"; + } +} + +const DEFAULT_TIMEOUT_MS = 15_000; + +export function withMcpTimeout( + promise: Promise, + timeoutMs: number, + message: string, +): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new McpTimeoutError(message)), timeoutMs); + }); + return Promise.race([promise, timeout]).finally(() => { + if (timer) clearTimeout(timer); + }); +} + +export class GenericMcpClient { + private client: Pick< + Client, + "connect" | "close" | "listTools" | "callTool" + > | null = null; + private transport: Transport | null = null; + private connectionPromise: Promise | null = null; + + constructor( + private readonly config: McpClientConfig, + private readonly factories: McpClientFactories = {}, + ) {} + + private get timeoutMs(): number { + return this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS; + } + + private createClient() { + return ( + this.factories.createClient?.() ?? + new Client({ name: this.config.name, version: this.config.version }) + ); + } + + private async connectHttp( + client: Pick, + ): Promise { + if (!this.config.url) { + throw new McpConnectionError("MCP SSE URL is required"); + } + + const url = new URL(this.config.url); + const streamable = + this.factories.createStreamableHttpTransport?.(url) ?? + new StreamableHTTPClientTransport(url); + + try { + await withMcpTimeout( + client.connect(streamable), + this.timeoutMs, + `MCP connection timed out after ${this.timeoutMs}ms`, + ); + return streamable; + } catch (streamableError) { + const sse = + this.factories.createSseTransport?.(url) ?? new SSEClientTransport(url); + try { + await withMcpTimeout( + client.connect(sse), + this.timeoutMs, + `MCP SSE connection timed out after ${this.timeoutMs}ms`, + ); + return sse; + } catch (sseError) { + throw new McpConnectionError( + `MCP server is not reachable at ${this.config.url}`, + sseError instanceof McpTimeoutError ? sseError : streamableError, + ); + } + } + } + + private async connectStdio( + client: Pick, + ): Promise { + if (!this.config.command) { + throw new McpConnectionError("MCP stdio command is required"); + } + + const transport = + this.factories.createStdioTransport?.({ + command: this.config.command, + args: this.config.args, + }) ?? + new StdioClientTransport({ + command: this.config.command, + args: this.config.args, + stderr: "pipe", + }); + + await withMcpTimeout( + client.connect(transport), + this.timeoutMs, + `MCP stdio connection timed out after ${this.timeoutMs}ms`, + ); + return transport; + } + + async connect(): Promise { + if (this.client) return; + if (this.connectionPromise) return this.connectionPromise; + + this.connectionPromise = (async () => { + const client = this.createClient(); + try { + const transport = + this.config.transport === "stdio" + ? await this.connectStdio(client) + : await this.connectHttp(client); + this.client = client; + this.transport = transport; + } catch (error) { + this.client = null; + this.transport = null; + this.connectionPromise = null; + if (error instanceof McpError) throw error; + throw new McpConnectionError( + error instanceof Error ? error.message : String(error), + error, + ); + } + })(); + + return this.connectionPromise; + } + + async disconnect(): Promise { + const client = this.client; + const transport = this.transport; + this.client = null; + this.transport = null; + this.connectionPromise = null; + + try { + await client?.close(); + } finally { + await transport?.close?.(); + } + } + + async listTools(): Promise { + await this.connect(); + try { + const result = await withMcpTimeout( + this.client!.listTools(), + this.timeoutMs, + `MCP list tools timed out after ${this.timeoutMs}ms`, + ); + return result.tools.map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + annotations: tool.annotations, + })); + } catch (error) { + if (error instanceof McpError) throw error; + throw new McpToolError( + error instanceof Error ? error.message : String(error), + "listTools", + error, + ); + } + } + + async callTool( + name: string, + args: Record, + abortSignal?: AbortSignal, + ): Promise { + if (abortSignal?.aborted) { + throw new McpToolError("MCP tool call aborted", name); + } + + await this.connect(); + const controller = new AbortController(); + let cleanup: (() => void) | undefined; + + if (abortSignal) { + const onAbort = () => controller.abort(abortSignal.reason); + abortSignal.addEventListener("abort", onAbort, { once: true }); + cleanup = () => abortSignal.removeEventListener("abort", onAbort); + } + + try { + return await withMcpTimeout( + this.client!.callTool({ name, arguments: args }, undefined, { + signal: controller.signal, + }), + this.timeoutMs, + `MCP tool "${name}" timed out after ${this.timeoutMs}ms`, + ); + } catch (error) { + if (error instanceof McpError) throw error; + throw new McpToolError( + error instanceof Error ? error.message : String(error), + name, + error, + ); + } finally { + cleanup?.(); + } + } +} diff --git a/src/core/operator/toolClassifier.ts b/src/core/operator/toolClassifier.ts index 48d36784b..788acf425 100644 --- a/src/core/operator/toolClassifier.ts +++ b/src/core/operator/toolClassifier.ts @@ -44,6 +44,20 @@ const TOOL_BASE_TIERS: Record = { browser_click: 3, // T3 - Probing (user interaction simulation) browser_fill: 3, // T3 - Probing (form filling with payloads) browser_evaluate: 4, // T4 - Intrusive (JavaScript execution) + + // Burp MCP tools + burp_check_connection: 1, + burp_get_proxy_http_history: 1, + burp_search_proxy_http_history: 1, + burp_get_proxy_websocket_history: 1, + burp_get_scanner_issues: 1, + burp_get_proxy_intercept_state: 1, + burp_poll_collaborator_interactions: 1, + burp_send_to_repeater: 2, + burp_send_to_intruder: 3, + burp_send_http_request: 3, + burp_generate_collaborator_payload: 3, + burp_set_proxy_intercept_state: 3, }; /** diff --git a/src/core/session/index.ts b/src/core/session/index.ts index 5a3725333..7c29d9ea9 100644 --- a/src/core/session/index.ts +++ b/src/core/session/index.ts @@ -134,6 +134,24 @@ export type EmailIntegrationConfig = z.infer< typeof EmailIntegrationConfigObject >; +const BurpSuiteIntegrationConfigObject = z.object({ + enabled: z.boolean(), + transport: z.enum(["sse", "stdio"]).optional(), + proxyUrl: z.string().optional(), + sseUrl: z.string().optional(), + mcpSseUrl: z.string().optional(), + mcpProxyCommand: z.string().optional(), + mcpProxyArgs: z.array(z.string()).optional(), + timeoutMs: z.number().positive().optional(), + allowedTargets: z.array(z.string()).optional(), + allowConfigMutation: z.boolean().optional(), + ignoreTlsErrors: z.boolean().optional(), +}); + +export type BurpSuiteIntegrationConfig = z.infer< + typeof BurpSuiteIntegrationConfigObject +>; + const SessionConfigObject = z.object({ offensiveHeaders: OffensiveHeadersConfigObject.optional(), sessionType: z.enum(["web-app"]).optional(), @@ -154,6 +172,8 @@ const SessionConfigObject = z.object({ codebasePath: z.string().optional(), /** Email inboxes available to the agent for monitoring/reading email */ emailIntegration: EmailIntegrationConfigObject.optional(), + /** Burp Suite pairing for proxy capture and MCP access */ + burpSuite: BurpSuiteIntegrationConfigObject.optional(), /** Enable exfiltration mode — allows internal pivoting and flag extraction through confirmed vulnerabilities */ exfilMode: z.boolean().optional(), /** Agent working directory — resolved to process.cwd() by default, undefined in sandbox mode */ diff --git a/src/core/toolset/index.ts b/src/core/toolset/index.ts index 878c8579d..faae4efd5 100644 --- a/src/core/toolset/index.ts +++ b/src/core/toolset/index.ts @@ -196,6 +196,113 @@ export const ALL_TOOLS: ToolDefinition[] = [ category: "browser", defaultEnabled: true, }, + { + id: "burp_check_connection", + name: "Burp Check", + description: "Check Burp MCP", + detail: + "Verify that Apex can connect to Burp Suite through the configured MCP server or stdio proxy.", + category: "utility", + defaultEnabled: true, + }, + { + id: "burp_get_proxy_http_history", + name: "Burp HTTP History", + description: "Read proxy history", + detail: + "Read paginated items from Burp Proxy HTTP history for traffic captured during the session.", + category: "reconnaissance", + defaultEnabled: true, + }, + { + id: "burp_search_proxy_http_history", + name: "Search Burp History", + description: "Regex search history", + detail: + "Search Burp Proxy HTTP history with a regular expression and return matching captured traffic.", + category: "reconnaissance", + defaultEnabled: true, + }, + { + id: "burp_get_proxy_websocket_history", + name: "Burp WebSocket History", + description: "Read WS history", + detail: + "Read paginated items from Burp Proxy WebSocket history when available.", + category: "reconnaissance", + defaultEnabled: true, + }, + { + id: "burp_send_to_repeater", + name: "Send To Repeater", + description: "Open in Burp Repeater", + detail: + "Create a Burp Repeater tab for an interesting in-scope HTTP request.", + category: "exploitation", + defaultEnabled: true, + }, + { + id: "burp_send_to_intruder", + name: "Send To Intruder", + description: "Open in Burp Intruder", + detail: + "Send an interesting in-scope HTTP request to Burp Intruder when available.", + category: "exploitation", + defaultEnabled: true, + }, + { + id: "burp_send_http_request", + name: "Burp Send Request", + description: "Send via Burp MCP", + detail: + "Send an in-scope raw HTTP request through Burp MCP when request-sending tools are exposed and Burp approval allows it.", + category: "exploitation", + defaultEnabled: true, + }, + { + id: "burp_generate_collaborator_payload", + name: "Collaborator Payload", + description: "Generate OOB payload", + detail: + "Generate a Burp Collaborator payload when available for authorized out-of-band testing.", + category: "exploitation", + defaultEnabled: true, + }, + { + id: "burp_poll_collaborator_interactions", + name: "Poll Collaborator", + description: "Read OOB interactions", + detail: + "Poll Burp Collaborator interactions when available and relevant to the current authorized test.", + category: "reconnaissance", + defaultEnabled: true, + }, + { + id: "burp_get_proxy_intercept_state", + name: "Get Burp Intercept", + description: "Read intercept state", + detail: "Read Burp Proxy intercept state when exposed by Burp MCP.", + category: "utility", + defaultEnabled: true, + }, + { + id: "burp_set_proxy_intercept_state", + name: "Set Burp Intercept", + description: "Change intercept state", + detail: + "Set Burp Proxy intercept state when exposed by Burp MCP. Requires explicit operator intent.", + category: "utility", + defaultEnabled: false, + }, + { + id: "burp_get_scanner_issues", + name: "Burp Scanner Issues", + description: "Read scanner issues", + detail: + "Read Burp Scanner issues when available. Burp Community usually does not expose scanner findings.", + category: "reporting", + defaultEnabled: true, + }, // Reporting tools { diff --git a/src/tui/command-registry.ts b/src/tui/command-registry.ts index 39358c760..3b2324025 100644 --- a/src/tui/command-registry.ts +++ b/src/tui/command-registry.ts @@ -4,10 +4,12 @@ import { parseWebFlags, hasEnoughFlagsToSkipWizard, combinePromptParts, + buildOperatorSessionConfig, } from "./utils/command-flags"; import { getAllThemeNames } from "./theme"; import { config } from "../core/config"; import { isObfuscationEnabled } from "../core/obfuscation"; +import type { SessionConfig } from "../core/session"; /** * Define your application's CommandContext type with specific methods */ @@ -72,6 +74,30 @@ export interface CommandConfig { handler: (args: string[], ctx: AppCommandContext) => void | Promise; } +function mergeBurpSuiteConfig( + sessionConfig: SessionConfig["burpSuite"], + userConfig: Awaited>["burpMcp"], +): SessionConfig["burpSuite"] { + if (!sessionConfig?.enabled) return sessionConfig; + return { + enabled: true, + transport: sessionConfig.transport ?? userConfig?.transport, + proxyUrl: sessionConfig.proxyUrl ?? userConfig?.proxyUrl, + sseUrl: + sessionConfig.sseUrl ?? sessionConfig.mcpSseUrl ?? userConfig?.sseUrl, + mcpSseUrl: + sessionConfig.mcpSseUrl ?? sessionConfig.sseUrl ?? userConfig?.sseUrl, + mcpProxyCommand: sessionConfig.mcpProxyCommand ?? userConfig?.stdioCommand, + mcpProxyArgs: sessionConfig.mcpProxyArgs ?? userConfig?.stdioArgs, + timeoutMs: sessionConfig.timeoutMs ?? userConfig?.timeoutMs, + allowedTargets: sessionConfig.allowedTargets ?? userConfig?.allowedTargets, + allowConfigMutation: + sessionConfig.allowConfigMutation ?? userConfig?.allowConfigMutation, + ignoreTlsErrors: + sessionConfig.ignoreTlsErrors ?? userConfig?.ignoreTlsErrors, + }; +} + /** * All available commands. * Array order = display order in autocomplete dropdown and help dialog. @@ -143,6 +169,12 @@ export const commands: CommandConfig[] = [ valueHint: "", description: "Threat model to guide the pentest (inline or @filepath)", }, + { name: "--burp", description: "Enable Burp Suite integration" }, + { + name: "--burp-mcp-url", + valueHint: "", + description: "Burp MCP SSE URL", + }, ], handler: async (args, ctx) => { const flags = parseWebFlags(args); @@ -164,6 +196,11 @@ export const commands: CommandConfig[] = [ flags.prompt, ); if (combinedPrompt) skillArgs.prompt = combinedPrompt; + const currentConfig = await config.get(); + const burpSuite = mergeBurpSuiteConfig( + buildOperatorSessionConfig(flags).config.burpSuite, + currentConfig.burpMcp, + ); ctx.navigate({ type: "operator", @@ -172,6 +209,7 @@ export const commands: CommandConfig[] = [ requireApproval: false, target: flags.target, sandbox: true, + burpSuite, }, initialSkill: { slug: "pentest", args: skillArgs }, }); @@ -248,9 +286,20 @@ export const commands: CommandConfig[] = [ name: "--task-driven", description: "Structured task tracking (experimental)", }, + { name: "--burp", description: "Enable Burp Suite integration" }, + { + name: "--burp-mcp-url", + valueHint: "", + description: "Burp MCP SSE URL", + }, ], handler: async (args, ctx) => { const flags = parseWebFlags(args); + const currentConfig = await config.get(); + const burpSuite = mergeBurpSuiteConfig( + buildOperatorSessionConfig(flags).config.burpSuite, + currentConfig.burpMcp, + ); ctx.navigate({ type: "operator", nonce: Date.now(), @@ -259,6 +308,7 @@ export const commands: CommandConfig[] = [ target: flags.target, sandbox: flags.sandbox, taskDriven: flags.taskDriven, + burpSuite, }, }); }, @@ -271,6 +321,20 @@ export const commands: CommandConfig[] = [ // Handled by the operator dashboard — this is a no-op for routing }, }, + { + name: "burp", + description: "Use Burp MCP tools in operator sessions", + category: "Pentesting", + options: [ + { name: "status", description: "Check Burp MCP status" }, + { name: "tools", description: "List Burp MCP tools" }, + { name: "history", description: "Inspect in-scope proxy history" }, + { name: "repeater", description: "Send an in-scope request to Repeater" }, + ], + handler: async () => { + // Handled by the operator dashboard. + }, + }, { name: "threat-model", aliases: ["tm"], diff --git a/src/tui/components/operator-dashboard/index.tsx b/src/tui/components/operator-dashboard/index.tsx index ad3c80ac2..d00d76eec 100644 --- a/src/tui/components/operator-dashboard/index.tsx +++ b/src/tui/components/operator-dashboard/index.tsx @@ -130,6 +130,7 @@ export default function OperatorDashboard({ operatorMode?: OperatorMode; sandbox?: boolean; taskDriven?: boolean; + burpSuite?: SessionConfig["burpSuite"]; }; }) { const { colors } = useTheme(); @@ -1241,6 +1242,9 @@ export default function OperatorDashboard({ }, agentCwd: initialConfig?.sandbox ? undefined : process.cwd(), taskDriven: initialConfig?.taskDriven, + ...(initialConfig?.burpSuite + ? { burpSuite: initialConfig.burpSuite } + : {}), }; agentResult = await runOffensiveSecurityAgent({ ...commonInput, @@ -1544,6 +1548,26 @@ This three-phase flow is specific to the TUI \`/threat-model\` command. The same const handleCommandExecute = useCallback( async (command: string) => { + const burpCommand = command.trim().replace(/^\/+/, ""); + if (burpCommand === "burp" || burpCommand.startsWith("burp ")) { + const subcommand = burpCommand.split(/\s+/)[1] ?? "status"; + const promptBySubcommand: Record = { + status: + "Check the Burp MCP integration status using burp_check_connection and summarize the endpoint, transport, discovered tool count, and key capabilities.", + tools: + "List the available Burp MCP tools using burp_check_connection and summarize what capabilities are available.", + history: + "Inspect in-scope Burp proxy HTTP history for the current target using burp_search_proxy_http_history. Do not read unrelated targets.", + repeater: + "Create a Burp Repeater tab only for an in-scope request that is relevant to the current investigation. Ask me for the raw request if you do not already have one.", + }; + handleSubmit( + promptBySubcommand[subcommand] ?? + `Handle the Burp MCP operator command: ${command}`, + ); + return; + } + const action = routeCommand(command, resolveSkillContent); switch (action.type) { diff --git a/src/tui/components/operator-dashboard/logic.ts b/src/tui/components/operator-dashboard/logic.ts index 9edbfa751..406fe5960 100644 --- a/src/tui/components/operator-dashboard/logic.ts +++ b/src/tui/components/operator-dashboard/logic.ts @@ -14,6 +14,7 @@ const OPERATOR_ALLOWED_COMMANDS = new Set([ "/new", "/operator", "/pentest", + "/burp", "/skills", "/plan", "/obfuscate", diff --git a/src/tui/context/route.tsx b/src/tui/context/route.tsx index a476cddb2..37f8fc787 100644 --- a/src/tui/context/route.tsx +++ b/src/tui/context/route.tsx @@ -42,6 +42,15 @@ export interface WebCommandOptions { model?: string; prompt?: string; threatModel?: string; + burp?: boolean; + burpTransport?: "sse" | "stdio"; + burpProxy?: string; + burpMcpUrl?: string; + burpMcpProxyJar?: string; + burpMcpProxyCommand?: string; + burpTimeoutMs?: number; + burpAllowConfigMutation?: boolean; + burpInsecureTls?: boolean; } export type Route = @@ -69,6 +78,7 @@ export type Route = operatorMode?: import("../../core/operator").OperatorMode; sandbox?: boolean; taskDriven?: boolean; + burpSuite?: SessionConfig["burpSuite"]; }; /** Skill to automatically submit on mount */ initialSkill?: { slug: string; args?: Record }; diff --git a/src/tui/utils/command-flags.test.ts b/src/tui/utils/command-flags.test.ts index 29d547e0b..f29675e53 100644 --- a/src/tui/utils/command-flags.test.ts +++ b/src/tui/utils/command-flags.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from "os"; import { resolveFlagValue, resolveThreatModelPrompt, + buildOperatorSessionConfig, buildSwarmSessionConfig, parseWebFlags, } from "./command-flags"; @@ -46,6 +47,56 @@ describe("parseWebFlags", () => { expect(flags.hosts).toEqual(["api.example.com", "example.com"]); expect(flags.ports).toEqual([8443, 8080]); }); + + it("parses Burp Suite integration flags", () => { + const flags = parseWebFlags([ + "--target", + "https://example.com", + "--burp-proxy", + "http://127.0.0.1:8081", + "--burp-mcp-url", + "http://127.0.0.1:9877", + "--burp-mcp-proxy-jar", + "/tmp/mcp-proxy-all.jar", + "--burp-transport", + "stdio", + "--burp-timeout-ms", + "30000", + "--burp-allow-config-mutation", + "--burp-insecure-tls", + ]); + + expect(flags.burp).toBe(true); + expect(flags.burpProxy).toBe("http://127.0.0.1:8081"); + expect(flags.burpMcpUrl).toBe("http://127.0.0.1:9877"); + expect(flags.burpMcpProxyJar).toBe("/tmp/mcp-proxy-all.jar"); + expect(flags.burpTransport).toBe("stdio"); + expect(flags.burpTimeoutMs).toBe(30000); + expect(flags.burpAllowConfigMutation).toBe(true); + expect(flags.burpInsecureTls).toBe(true); + }); + + it("maps Burp Suite flags into session config", () => { + const flags = parseWebFlags([ + "--target", + "https://example.com", + "--burp", + "--burp-mcp-proxy-jar", + "/tmp/mcp-proxy-all.jar", + ]); + + const params = buildOperatorSessionConfig(flags); + + expect(params.config.burpSuite).toEqual({ + enabled: true, + mcpProxyArgs: [ + "-jar", + "/tmp/mcp-proxy-all.jar", + "--sse-url", + "http://127.0.0.1:9876/sse", + ], + }); + }); }); // --------------------------------------------------------------------------- diff --git a/src/tui/utils/command-flags.ts b/src/tui/utils/command-flags.ts index f04119470..94e54ef1f 100644 --- a/src/tui/utils/command-flags.ts +++ b/src/tui/utils/command-flags.ts @@ -208,6 +208,17 @@ export interface WebCommandFlags { // Task-driven mode (experimental — structured task tracking for training data) taskDriven?: boolean; + + // Burp Suite integration + burp?: boolean; + burpTransport?: "sse" | "stdio"; + burpProxy?: string; + burpMcpUrl?: string; + burpMcpProxyJar?: string; + burpMcpProxyCommand?: string; + burpTimeoutMs?: number; + burpAllowConfigMutation?: boolean; + burpInsecureTls?: boolean; } /** @@ -237,6 +248,15 @@ const webFlagSchema: FlagSchema = { prompt: { type: "string" }, "threat-model": { type: "string" }, "task-driven": { type: "boolean" }, + burp: { type: "boolean" }, + "burp-transport": { type: "string" }, + "burp-proxy": { type: "string" }, + "burp-mcp-url": { type: "string" }, + "burp-mcp-proxy-jar": { type: "string" }, + "burp-mcp-proxy-command": { type: "string" }, + "burp-timeout-ms": { type: "string" }, + "burp-allow-config-mutation": { type: "boolean" }, + "burp-insecure-tls": { type: "boolean" }, }; /** @@ -342,6 +362,37 @@ export function parseWebFlags(args: string[]): WebCommandFlags { // Task-driven mode if (raw.taskDriven) flags.taskDriven = true; + // Burp Suite integration + if (raw.burp) flags.burp = true; + if (raw.burpTransport === "sse" || raw.burpTransport === "stdio") { + flags.burpTransport = raw.burpTransport; + } + if (raw.burpProxy) flags.burpProxy = String(raw.burpProxy); + if (raw.burpMcpUrl) flags.burpMcpUrl = String(raw.burpMcpUrl); + if (raw.burpMcpProxyJar) flags.burpMcpProxyJar = String(raw.burpMcpProxyJar); + if (raw.burpMcpProxyCommand) + flags.burpMcpProxyCommand = String(raw.burpMcpProxyCommand); + if (raw.burpTimeoutMs) { + const timeoutMs = parseInt(String(raw.burpTimeoutMs), 10); + if (Number.isFinite(timeoutMs) && timeoutMs > 0) { + flags.burpTimeoutMs = timeoutMs; + } + } + if (raw.burpAllowConfigMutation) flags.burpAllowConfigMutation = true; + if (raw.burpInsecureTls) flags.burpInsecureTls = true; + if ( + flags.burpTransport || + flags.burpProxy || + flags.burpMcpUrl || + flags.burpMcpProxyJar || + flags.burpMcpProxyCommand || + flags.burpTimeoutMs || + flags.burpAllowConfigMutation || + flags.burpInsecureTls + ) { + flags.burp = true; + } + return flags; } @@ -365,7 +416,8 @@ export function hasEnoughFlagsToSkipWizard(flags: WebCommandFlags): boolean { flags._hostsExplicitlyProvided || flags.strict || flags.headersMode || - flags.model + flags.model || + flags.burp ); } @@ -379,6 +431,36 @@ export interface SessionCreateParams { config: SessionConfig; } +function applyBurpSuiteConfig( + sessionConfig: SessionConfig, + flags: WebCommandFlags, +): void { + if (!flags.burp) return; + + const mcpProxyArgs = flags.burpMcpProxyJar + ? [ + "-jar", + flags.burpMcpProxyJar, + "--sse-url", + flags.burpMcpUrl ?? "http://127.0.0.1:9876/sse", + ] + : undefined; + + sessionConfig.burpSuite = { + enabled: true, + ...(flags.burpTransport ? { transport: flags.burpTransport } : {}), + ...(flags.burpProxy ? { proxyUrl: flags.burpProxy } : {}), + ...(flags.burpMcpUrl ? { mcpSseUrl: flags.burpMcpUrl } : {}), + ...(flags.burpMcpProxyCommand + ? { mcpProxyCommand: flags.burpMcpProxyCommand } + : {}), + ...(mcpProxyArgs ? { mcpProxyArgs } : {}), + ...(flags.burpTimeoutMs ? { timeoutMs: flags.burpTimeoutMs } : {}), + ...(flags.burpAllowConfigMutation ? { allowConfigMutation: true } : {}), + ...(flags.burpInsecureTls ? { ignoreTlsErrors: true } : {}), + }; +} + /** * Build operator session config from CLI flags (no session creation). */ @@ -424,6 +506,7 @@ export function buildOperatorSessionConfig( sessionConfig.agentCwd = flags.sandbox ? undefined : process.cwd(); if (flags.taskDriven) sessionConfig.taskDriven = true; + applyBurpSuiteConfig(sessionConfig, flags); // Combine threat model and prompt into a single prompt field const combinedPrompt = combinePromptParts(flags.threatModel, flags.prompt); @@ -476,6 +559,8 @@ export function buildSwarmSessionConfig( }; } + applyBurpSuiteConfig(sessionConfig, flags); + // Combine threat model and prompt into a single prompt field const combinedPrompt = combinePromptParts(flags.threatModel, flags.prompt); if (combinedPrompt) { From 679dc9cebe4a80dbe2a0df82ea3aaa31acc2a238 Mon Sep 17 00:00:00 2001 From: Harsh Akshit Date: Mon, 27 Apr 2026 13:18:42 -0400 Subject: [PATCH 2/3] fix: infer raw Burp request protocol conservatively Made-with: Cursor --- .../agents/offSecAgent/tools/burpMcp.test.ts | 32 ++++++++++++++++++- src/core/agents/offSecAgent/tools/burpMcp.ts | 10 ++++-- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/core/agents/offSecAgent/tools/burpMcp.test.ts b/src/core/agents/offSecAgent/tools/burpMcp.test.ts index a414e78d3..7792c40e2 100644 --- a/src/core/agents/offSecAgent/tools/burpMcp.test.ts +++ b/src/core/agents/offSecAgent/tools/burpMcp.test.ts @@ -13,16 +13,46 @@ describe("Burp MCP helpers", () => { ).toBe("first\nsecond"); }); - it("parses target details from raw HTTP Host header", () => { + it("parses target details from raw HTTP Host header with common HTTP port", () => { expect( parseRawHttpTarget("GET / HTTP/1.1\r\nHost: example.com:8080\r\n\r\n"), ).toEqual({ targetHostname: "example.com", targetPort: 8080, + usesHttps: false, + }); + }); + + it("defaults raw HTTP Host headers without ports to HTTP", () => { + expect( + parseRawHttpTarget("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n"), + ).toEqual({ + targetHostname: "example.com", + targetPort: 80, + usesHttps: false, + }); + }); + + it("preserves HTTPS inference for explicit port 443", () => { + expect( + parseRawHttpTarget("GET / HTTP/1.1\r\nHost: example.com:443\r\n\r\n"), + ).toEqual({ + targetHostname: "example.com", + targetPort: 443, usesHttps: true, }); }); + it("treats common development ports as HTTP", () => { + expect( + parseRawHttpTarget("GET / HTTP/1.1\r\nHost: example.com:3000\r\n\r\n"), + ).toEqual({ + targetHostname: "example.com", + targetPort: 3000, + usesHttps: false, + }); + }); + it("returns null when a raw HTTP request has no Host header", () => { expect(parseRawHttpTarget("GET / HTTP/1.1\r\n\r\n")).toBeNull(); }); diff --git a/src/core/agents/offSecAgent/tools/burpMcp.ts b/src/core/agents/offSecAgent/tools/burpMcp.ts index 806fdeab7..c167ed252 100644 --- a/src/core/agents/offSecAgent/tools/burpMcp.ts +++ b/src/core/agents/offSecAgent/tools/burpMcp.ts @@ -49,11 +49,15 @@ export function parseRawHttpTarget(content: string): { const [hostname, portText] = host.split(":"); if (!hostname) return null; - const targetPort = portText ? parseInt(portText, 10) : 443; + const parsedPort = portText ? parseInt(portText, 10) : undefined; + const targetPort = + typeof parsedPort === "number" && Number.isFinite(parsedPort) + ? parsedPort + : 80; return { targetHostname: hostname, - targetPort: Number.isFinite(targetPort) ? targetPort : 443, - usesHttps: targetPort !== 80, + targetPort, + usesHttps: targetPort === 443, }; } From fb3e740bb55d2478eb89c21bc7d8af5ffe179ebf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Apr 2026 17:39:10 +0000 Subject: [PATCH 3/3] style: fix prettier formatting in httpRequest.ts Co-authored-by: KeremP --- src/core/agents/offSecAgent/tools/httpRequest.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/core/agents/offSecAgent/tools/httpRequest.ts b/src/core/agents/offSecAgent/tools/httpRequest.ts index 0d88d80dc..bcb5078c5 100644 --- a/src/core/agents/offSecAgent/tools/httpRequest.ts +++ b/src/core/agents/offSecAgent/tools/httpRequest.ts @@ -130,7 +130,10 @@ export function buildCurlArgs(opts: { return args; } -export function parseCurlResponse(output: string, url: string): HttpRequestResult { +export function parseCurlResponse( + output: string, + url: string, +): HttpRequestResult { const normalized = output.replace(/\r\n/g, "\n"); const headerMatches = [...normalized.matchAll(/^HTTP\/[\d.]+ .+$/gm)]; const lastHeader = headerMatches[headerMatches.length - 1];