From 8294bc4d770ee450caa2d6d815bac6e57082e0de Mon Sep 17 00:00:00 2001 From: ppiankov <103106369+ppiankov@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:56:11 +0800 Subject: [PATCH 01/29] feat: proxy fails closed on unsupported upstream shapes; scope launch to claude --- README.md | 17 +-- Sources/PastewatchCLI/LaunchCommand.swift | 42 ++++++- Sources/PastewatchCore/CurlHTTPClient.swift | 2 +- Sources/PastewatchCore/ProxyServer.swift | 79 ++++++++++++ .../PastewatchTests/LaunchCommandTests.swift | 86 +++++++++++++ .../ProxyBodyShapeGuardTests.swift | 119 ++++++++++++++++++ .../ProxyRealServerTests.swift | 50 ++++++++ 7 files changed, 381 insertions(+), 14 deletions(-) create mode 100644 Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift diff --git a/README.md b/README.md index 48dab1f..13d233e 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ The agent works normally. It reads files, runs commands, writes code. It just ne - **Before-paste boundary** — secrets never leave your machine. Nightfall, Prisma, Check Point all intercept downstream. Pastewatch prevents upstream - **MCP server for AI agents** — no other tool provides redacted read/write at the tool level. The agent works with placeholders, your secrets stay local - **Bash guard with deep parsing** — pipes, subshells, redirects, database CLIs, infra tools. Every shell command the agent runs is scanned before execution -- **API proxy** — catches everything, including subagents and tools that bypass hooks. Last line of defense before the network boundary +- **API proxy** — catches Anthropic-shaped traffic that bypasses hooks, including from subagents. Last line of defense before the network boundary (refuses unrecognized upstream shapes rather than forward them unscanned) - **Canary honeypots** — "prove it works" not "trust it works." Plant format-valid fake secrets and verify they're caught - **Local-only, deterministic, no ML** — no cloud dependency, no probabilistic scoring, no telemetry. Runs offline, gives the same answer every time - **One command** — `pastewatch-cli launch claude` and every layer is active. No manual setup, no env vars, no second terminal @@ -337,7 +337,9 @@ pastewatch-cli config check ### API Proxy — Last Line of Defense -Every tool call an AI agent makes — including internal subprocesses you don't control — ends up as an HTTP request to the API. The proxy scans and redacts secrets from **all** outbound requests before they leave your machine. Nothing gets through. +Every tool call an AI agent makes — including internal subprocesses you don't control — ends up as an HTTP request to the API. The proxy scans and redacts secrets from outbound requests before they leave your machine — including from subagents and tools that bypass the hooks. + +> **Anthropic-shaped traffic.** The proxy redacts the Anthropic Messages API (`/v1/messages`, what Claude Code sends). It does **not** parse the OpenAI Chat Completions wire format, so it cannot redact OpenAI/Codex request bodies — rather than forward one unscanned and let you believe it was protected, the proxy **refuses** an unrecognized upstream body shape (HTTP 415). Cover Codex and other agents with the pastewatch hooks and MCP server instead. > **Single session.** The proxy handles one agent session at a time. Run a separate `pastewatch-cli proxy` instance (on a different port) for each concurrent session. @@ -367,11 +369,10 @@ pastewatch-cli launch claude # With options pastewatch-cli launch --audit-log /tmp/pw.log -- claude --model opus - -# Any agent -pastewatch-cli launch -- codex --full-auto ``` +Only `claude` is routed through the proxy today (the proxy redacts Anthropic-shaped traffic). Launching another agent through `launch` starts the proxy but does **not** wire that agent to it — the agent runs normally and stays covered by the pastewatch hooks and MCP server. + Or start the proxy manually for more control: ```bash @@ -821,7 +822,7 @@ Define additional patterns in a JSON file: ### Agent Safety Matrix -The API proxy (Layer 0) protects agents that expose an API endpoint override; rows marked proxy not applicable are limited to their listed local layers. Hooks and MCP add defense in depth. +The API proxy (Layer 0) redacts **Anthropic-shaped** (`/v1/messages`) traffic from agents that expose an API endpoint override; it refuses unrecognized upstream body shapes (HTTP 415) rather than forward them unscanned, so it does not redact OpenAI/Gemini-shaped agents. Rows relying on "Proxy" are protected only for Anthropic-shaped traffic; rows marked proxy not applicable are limited to their listed local layers. Hooks and MCP add defense in depth and are the primary coverage for non-Anthropic-shaped agents. | Agent | Protection | Hooks | MCP | Setup | |-------|-----------|-------|-----|-------| @@ -844,8 +845,8 @@ The API proxy (Layer 0) protects agents that expose an API endpoint override; ro | Jules | Cloud only | No local config | Cloud UI | N/A (use proxy on local side) | **Structural** = hooks block native file access before secrets can be read. The agent cannot bypass the check. -**Proxy + MCP** = network-level redaction catches everything, MCP tools provide redacted access, but the agent isn't forced to use them. -**Proxy only** = all protection comes from the network proxy. Still catches 100% of outbound secrets. +**Proxy + MCP** = network-level redaction for Anthropic-shaped traffic, plus MCP tools for redacted access (the agent isn't forced to use them). If the agent talks a non-Anthropic wire format, the proxy refuses (HTTP 415) rather than redact — MCP is then the real coverage. +**Proxy only** = protection comes from the network proxy, and only for Anthropic-shaped traffic. An agent that sends a non-Anthropic body shape is not redacted by the proxy (it is refused) — prefer hooks/MCP where available. ### Install diff --git a/Sources/PastewatchCLI/LaunchCommand.swift b/Sources/PastewatchCLI/LaunchCommand.swift index a9a3f36..61bec41 100644 --- a/Sources/PastewatchCLI/LaunchCommand.swift +++ b/Sources/PastewatchCLI/LaunchCommand.swift @@ -34,13 +34,16 @@ struct Launch: ParsableCommand { abstract: "Start the proxy and launch an agent through it in one command", discussion: """ Starts the pastewatch proxy in the background, waits for it to be ready, - then launches your agent with ANTHROPIC_BASE_URL pointed at the proxy. - When the agent exits, the proxy is stopped automatically. + then launches your agent. The proxy redacts Anthropic-shaped (/v1/messages) + traffic, so ANTHROPIC_BASE_URL is pointed at it only for 'claude'; other + agents launch without proxy interposition (a warning is printed) and stay + covered by the pastewatch hooks and MCP server. When the agent exits, the + proxy is stopped automatically. Examples: pastewatch-cli launch claude pastewatch-cli launch --port 9999 -- claude --model opus - pastewatch-cli launch --audit-log /tmp/pw.log -- codex --full-auto + pastewatch-cli launch --audit-log /tmp/pw.log -- claude """ ) @@ -77,6 +80,36 @@ struct Launch: ParsableCommand { @Argument(parsing: .captureForPassthrough) var command: [String] = [] + // WO-409: agents whose traffic the proxy can actually redact get ANTHROPIC_BASE_URL + // pointed at the proxy. Exact basename match (not prefix) so a foreign wrapper like + // `claude-openai-bridge` cannot accidentally route into the Anthropic-only proxy and + // hit WO-408's fail-closed refusal. A future --force-proxy flag can override this. + static let proxyRoutedAgents: Set = ["claude"] + + static func isProxyRoutedAgent(_ binary: String) -> Bool { + proxyRoutedAgents.contains(binary) + } + + // WO-409: only wire ANTHROPIC_BASE_URL for agents the proxy actually redacts. The proxy + // scans Anthropic-shaped (/v1/messages) traffic only; routing a non-Anthropic agent + // through it would fail closed on every request (WO-408), looking like a proxy bug + // rather than a deliberate unsupported-upstream refusal. Gating the setenv (and unsetting + // any inherited value) keeps launch coherent with the guard. + static func configureProxyEnv(agentBinary: String, port: UInt16) { + if isProxyRoutedAgent(agentBinary) { + setenv("ANTHROPIC_BASE_URL", "http://127.0.0.1:\(port)", 1) + } else { + unsetenv("ANTHROPIC_BASE_URL") + FileHandle.standardError.write(Data(""" + warning: pastewatch proxy redaction is not wired for agent '\(agentBinary)'; \ + launching without proxy interposition (ANTHROPIC_BASE_URL not set). \ + The proxy layer currently redacts Anthropic-shaped traffic only; 'claude' is \ + the only agent routed through it. Codex and other agents remain covered by the \ + pastewatch hooks and MCP server.\n + """.utf8)) + } + } + func run() throws { try runStartupSweepFixtureProbeIfNeeded() let command = try normalizedCommand() @@ -130,8 +163,7 @@ struct Launch: ParsableCommand { FileHandle.standardError.write(Data("launching: \(cmdStr)\n\n".utf8)) } - // Set ANTHROPIC_BASE_URL for the agent - setenv("ANTHROPIC_BASE_URL", "http://127.0.0.1:\(port)", 1) + Launch.configureProxyEnv(agentBinary: (command[0] as NSString).lastPathComponent, port: port) // Fork: child exec's the agent (inherits TTY), parent waits and cleans up // Use @_silgen_name to bypass Swift's fork() unavailability on Darwin diff --git a/Sources/PastewatchCore/CurlHTTPClient.swift b/Sources/PastewatchCore/CurlHTTPClient.swift index 6155bd9..dbd3fb6 100644 --- a/Sources/PastewatchCore/CurlHTTPClient.swift +++ b/Sources/PastewatchCore/CurlHTTPClient.swift @@ -1236,7 +1236,7 @@ struct CurlHTTPClient { 301: "Moved Permanently", 302: "Found", 304: "Not Modified", 400: "Bad Request", 401: "Unauthorized", 403: "Forbidden", 404: "Not Found", 405: "Method Not Allowed", 408: "Request Timeout", - 409: "Conflict", 429: "Too Many Requests", + 409: "Conflict", 415: "Unsupported Media Type", 429: "Too Many Requests", 500: "Internal Server Error", 502: "Bad Gateway", 503: "Service Unavailable", 504: "Gateway Timeout" ] diff --git a/Sources/PastewatchCore/ProxyServer.swift b/Sources/PastewatchCore/ProxyServer.swift index 490404b..9e44c23 100644 --- a/Sources/PastewatchCore/ProxyServer.swift +++ b/Sources/PastewatchCore/ProxyServer.swift @@ -683,6 +683,20 @@ public final class ProxyServer { return } + // WO-408: fail closed on unsupported upstream body shapes. The scan path below + // only redacts POST /v1/messages (Anthropic shape); any other POST body — notably + // OpenAI /v1/chat/completions — would otherwise be forwarded UNSCANNED, a silent + // no-op that makes users believe traffic was redacted when it was not. Refuse an + // unrecognized shape rather than forward it. Runs for ALL POSTs, so it sits before + // the /v1/messages scan branch. + if case .refuse(let reason) = upstreamBodyShapeVerdict( + method: parsed.method, path: parsed.path, bodyData: parsed.bodyData + ) { + logUnsupportedBodyShapeRefusal(path: parsed.path, reason: reason) + sendError(to: clientSocket, status: 415, message: "Unsupported upstream body shape") + return + } + // Only scan POST /v1/messages (the endpoint that carries tool results) var processedBody = parsed.body var processedBodyData = parsed.bodyData @@ -1055,6 +1069,71 @@ public final class ProxyServer { ) } + // WO-408: outcome of the fail-closed upstream body-shape check. + enum BodyShapeVerdict: Equatable { + case allow + case refuse(String) + } + + // WO-408: decide whether a request body is a shape pastewatch can redact, so an + // unredactable foreign body (e.g. OpenAI /v1/chat/completions) is refused rather than + // silently forwarded unscanned. Pure and socket-free so it is unit-testable directly. + // Only POST bodies carry tool results; everything else (GET /v1/models, OPTIONS, the + // token-counting endpoint's Anthropic body, non-JSON bodies) is allowed through — the + // /v1/messages scan branch already structurally skips them. + func upstreamBodyShapeVerdict(method: String, path: String, bodyData: Data) -> BodyShapeVerdict { + guard method.uppercased() == "POST" else { return .allow } + // Non-JSON (incl. non-UTF-8) is not a chat body we own; a non-UTF-8 /v1/messages + // body still reaches the existing WO-296 fail-closed path downstream. + guard let json = try? JSONSerialization.jsonObject(with: bodyData) as? [String: Any] else { + return .allow + } + let hasMessages = json["messages"] is [Any] + if path.contains("/v1/messages") { + // Layer B: a foreign body arriving at the Anthropic endpoint. An Anthropic body + // with no messages array (or a valid shape) is fine; anything else is refused. + guard hasMessages else { return .allow } + return isAnthropicMessagesShape(json) + ? .allow + : .refuse("non-Anthropic messages schema on \(path)") + } + // Layer A: a chat-shaped body on a non-/v1/messages path (e.g. /v1/chat/completions). + // Refuse only when it carries a messages array that is NOT Anthropic-shaped, so an + // Anthropic-shaped body on another endpoint (e.g. /v1/messages/count_tokens) passes. + if hasMessages && !isAnthropicMessagesShape(json) { + return .refuse("chat body on unsupported endpoint \(path)") + } + return .allow + } + + // WO-408: positive identification of the Anthropic Messages schema. Permissive on + // unknown keys (Anthropic adds fields over time — fail closed, never over-refuse a + // genuine future field by being strict), strict on the three load-bearing invariants, + // and rejecting OpenAI-only siblings that disambiguate a chat/completions body. + func isAnthropicMessagesShape(_ json: [String: Any]) -> Bool { + guard let messages = json["messages"] as? [[String: Any]] else { return false } + for message in messages { + guard message["role"] is String else { return false } + // OpenAI /v1/chat/completions carries tool_calls / function_call on messages; + // their presence is a high-signal marker that this is not an Anthropic body. + if message["tool_calls"] != nil || message["function_call"] != nil { return false } + if let content = message["content"] { + if content is String { continue } + guard let blocks = content as? [[String: Any]] else { return false } + for block in blocks where !(block["type"] is String) { return false } + } + } + return true + } + + // WO-408: per-request audit signal for a fail-closed refusal (verdict f6978df9). + private func logUnsupportedBodyShapeRefusal(path: String, reason: String) { + guard !quietLog else { return } + FileHandle.standardError.write(Data( + "[pastewatch-proxy] refused unsupported upstream body shape: \(reason)\n".utf8 + )) + } + private func scanProxyText(_ text: String) -> [DetectedMatch] { // WO-402: proxy body scans must honor custom rules, matching streaming scans. DetectionRules.scan( diff --git a/Tests/PastewatchTests/LaunchCommandTests.swift b/Tests/PastewatchTests/LaunchCommandTests.swift index ef63418..caaf943 100644 --- a/Tests/PastewatchTests/LaunchCommandTests.swift +++ b/Tests/PastewatchTests/LaunchCommandTests.swift @@ -1,5 +1,10 @@ import Foundation import XCTest +#if canImport(Darwin) +import Darwin +#else +import Glibc +#endif final class LaunchCommandTests: XCTestCase { private let fixtureContextProbeEnvironmentKey = "PW_LAUNCH_FIXTURE_CONTEXT_PROBE" @@ -47,6 +52,43 @@ final class LaunchCommandTests: XCTestCase { XCTAssertFalse(result.stderr.contains("user:pass")) } + // WO-409: a proxy-routed agent (claude) receives ANTHROPIC_BASE_URL pointed at the proxy. + func testLaunchClaudeAgentSetsAnthropicBaseURL() throws { + let fixture = try makeLaunchFixture() + let agent = try writeEnvEchoAgent(named: "claude", in: fixture.cwd) + let port = try reserveLoopbackPort() + let result = try runCLIProcess( + arguments: ["launch", "--quiet", "--no-startup-sweep", "--port", "\(port)", "--", agent.path], + cwd: fixture.cwd, + environment: fixture.environment + ) + XCTAssertTrue( + result.stdout.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:\(port)"), + "claude should be routed through the proxy; stdout: \(result.stdout) stderr: \(result.stderr)" + ) + XCTAssertFalse(result.stderr.contains("redaction is not wired"), "claude should not warn") + } + + // WO-409: a non-Anthropic agent (codex) launches WITHOUT ANTHROPIC_BASE_URL, plus a warning. + func testLaunchNonAnthropicAgentSkipsBaseURLAndWarns() throws { + let fixture = try makeLaunchFixture() + let agent = try writeEnvEchoAgent(named: "codex", in: fixture.cwd) + let port = try reserveLoopbackPort() + let result = try runCLIProcess( + arguments: ["launch", "--quiet", "--no-startup-sweep", "--port", "\(port)", "--", agent.path], + cwd: fixture.cwd, + environment: fixture.environment + ) + XCTAssertTrue( + result.stdout.contains("ANTHROPIC_BASE_URL=UNSET"), + "codex must not be wired to the proxy; stdout: \(result.stdout) stderr: \(result.stderr)" + ) + XCTAssertTrue( + result.stderr.contains("redaction is not wired for agent 'codex'"), + "codex should warn about missing proxy interposition; stderr: \(result.stderr)" + ) + } + // WO-137: seam-unavailable probe fallback must not reach startup sweep or proxy. func testLaunchFixtureContextProbeUnavailablePathIsSweepSafe() throws { let fixture = try makeLaunchFixture() @@ -276,6 +318,50 @@ final class LaunchCommandTests: XCTestCase { return root } + // WO-409: bind-then-close a loopback socket to obtain a free port for the real launch + // proxy (launch's waitForTCP polls a concrete port, so "0" never becomes ready). + private func reserveLoopbackPort() throws -> UInt16 { + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { throw LaunchPortError.socketFailed } + defer { close(fd) } + var addr = sockaddr_in() + addr.sin_family = sa_family_t(AF_INET) + addr.sin_addr.s_addr = inet_addr("127.0.0.1") + addr.sin_port = 0 + let bindResult = withUnsafePointer(to: &addr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + #if canImport(Darwin) + return Darwin.bind(fd, $0, socklen_t(MemoryLayout.size)) + #else + return Glibc.bind(fd, $0, socklen_t(MemoryLayout.size)) + #endif + } + } + guard bindResult == 0 else { throw LaunchPortError.bindFailed } + var bound = sockaddr_in() + var len = socklen_t(MemoryLayout.size) + let nameResult = withUnsafeMutablePointer(to: &bound) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + getsockname(fd, $0, &len) + } + } + guard nameResult == 0 else { throw LaunchPortError.bindFailed } + return UInt16(bigEndian: bound.sin_port) + } + + private enum LaunchPortError: Error { case socketFailed, bindFailed } + + // WO-409: a dummy agent that prints whether ANTHROPIC_BASE_URL was set in its env, + // then exits so the parent launch runner tears down the proxy and returns. + private func writeEnvEchoAgent(named name: String, in dir: URL) throws -> URL { + let script = dir.appendingPathComponent(name) + try "#!/bin/sh\necho \"ANTHROPIC_BASE_URL=${ANTHROPIC_BASE_URL:-UNSET}\"\n".write( + to: script, atomically: true, encoding: .utf8 + ) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: script.path) + return script + } + private func writeFixtureStartupFile(in home: URL) throws { let fixtureValue = "postgres" + "://user:pass@host:5432/db" let path = home.appendingPathComponent(".zshrc") diff --git a/Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift b/Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift new file mode 100644 index 0000000..e7e3d4f --- /dev/null +++ b/Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift @@ -0,0 +1,119 @@ +import XCTest +@testable import PastewatchCore + +/// WO-408: the proxy fails closed on unsupported upstream body shapes. It scans and +/// redacts Anthropic-shaped (/v1/messages) bodies; any other POST body — notably OpenAI +/// /v1/chat/completions — must be refused rather than forwarded UNSCANNED (a silent +/// no-op that makes users believe traffic was redacted when it was not). These tests +/// exercise the pure predicate directly, without a socket. +final class ProxyBodyShapeGuardTests: XCTestCase { + + private func server() -> ProxyServer { + ProxyServer(port: 0, upstream: URL(string: "https://api.anthropic.com")!) + } + + private func verdict(_ method: String, _ path: String, _ body: String) -> ProxyServer.BodyShapeVerdict { + server().upstreamBodyShapeVerdict(method: method, path: path, bodyData: Data(body.utf8)) + } + + // MARK: - Anthropic shapes are allowed + + func testAnthropicToolResultBodyAllowed() { + let body = """ + {"model":"claude-3","messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"x","content":"secret here"}]}]} + """ + XCTAssertEqual(verdict("POST", "/v1/messages", body), .allow) + } + + func testAnthropicAllTextBodyAllowed() { + // Zero tool_results is a legitimate Anthropic request — must not be refused. + let body = """ + {"model":"claude-3","messages":[{"role":"assistant","content":[{"type":"text","text":"hi"}]}]} + """ + XCTAssertEqual(verdict("POST", "/v1/messages", body), .allow) + } + + func testAnthropicStringContentAllowed() { + let body = """ + {"model":"claude-3","messages":[{"role":"user","content":"just a string"}]} + """ + XCTAssertEqual(verdict("POST", "/v1/messages", body), .allow) + } + + func testMessagesEndpointWithoutMessagesArrayAllowed() { + // Some Anthropic endpoints (e.g. count_tokens variants) may omit a messages array. + let body = """ + {"model":"claude-3","system":"be terse"} + """ + XCTAssertEqual(verdict("POST", "/v1/messages", body), .allow) + } + + // MARK: - Foreign shapes are refused + + func testOpenAIChatCompletionsRefusedLayerA() { + // /v1/chat/completions carries a messages array plus OpenAI-only tool_calls. + let body = """ + {"model":"gpt-4","messages":[{"role":"assistant","content":"","tool_calls":[{"id":"c1","type":"function"}]}]} + """ + guard case .refuse = verdict("POST", "/v1/chat/completions", body) else { + return XCTFail("expected refuse for OpenAI chat/completions body") + } + } + + func testOpenAIShapeOnMessagesEndpointRefusedLayerB() { + // A foreign body pointed at the Anthropic endpoint is still refused. + let body = """ + {"model":"gpt-4","messages":[{"role":"user","content":"hi","function_call":{"name":"f"}}]} + """ + guard case .refuse = verdict("POST", "/v1/messages", body) else { + return XCTFail("expected refuse for OpenAI shape on /v1/messages") + } + } + + // MARK: - Legitimate non-message traffic is NOT broken + + func testTokensCountAnthropicBodyAllowed() { + // The Anthropic count-tokens body carries a top-level messages array in Anthropic + // shape; it must pass, not be refused as a foreign chat body (false-refusal guard). + let body = """ + {"model":"claude-3","messages":[{"role":"user","content":"count me"}]} + """ + XCTAssertEqual(verdict("POST", "/v1/messages/count_tokens", body), .allow) + } + + func testNonJSONBodyAllowed() { + XCTAssertEqual(verdict("POST", "/v1/anything", "not json at all"), .allow) + } + + func testEmptyBodyAllowed() { + XCTAssertEqual(verdict("POST", "/v1/messages", ""), .allow) + } + + func testGetRequestAllowed() { + XCTAssertEqual(verdict("GET", "/v1/models", ""), .allow) + } + + func testOptionsRequestAllowed() { + XCTAssertEqual(verdict("OPTIONS", "/v1/messages", ""), .allow) + } + + // MARK: - isAnthropicMessagesShape direct + + func testShapeRejectsToolCalls() { + let json: [String: Any] = ["messages": [["role": "assistant", "tool_calls": [["id": "x"]]]]] + XCTAssertFalse(server().isAnthropicMessagesShape(json)) + } + + func testShapeRejectsMissingRole() { + let json: [String: Any] = ["messages": [["content": "hi"]]] + XCTAssertFalse(server().isAnthropicMessagesShape(json)) + } + + func testShapeAcceptsUnknownFutureKeys() { + // Permissive on unknown keys — Anthropic adds fields over time. + let json: [String: Any] = [ + "messages": [["role": "user", "content": "hi", "some_future_field": 1]] + ] + XCTAssertTrue(server().isAnthropicMessagesShape(json)) + } +} diff --git a/Tests/PastewatchTests/ProxyRealServerTests.swift b/Tests/PastewatchTests/ProxyRealServerTests.swift index 57518f2..a3ec6ae 100644 --- a/Tests/PastewatchTests/ProxyRealServerTests.swift +++ b/Tests/PastewatchTests/ProxyRealServerTests.swift @@ -45,6 +45,56 @@ final class ProxyRealServerTests: XCTestCase { XCTAssertTrue(response.contains(#"{"ok":true}"#), diagnostic) } + // WO-408: an OpenAI-shaped body is refused with 415 and never reaches upstream. + func testUnsupportedBodyShapeRefusedBeforeUpstream() throws { + let upstream = try StubHTTPServer { _ in + StubHTTPResponse(status: 200, headers: [:], body: Data(#"{"ok":true}"#.utf8)) + } + try upstream.start() + defer { upstream.stop() } + + let proxyPort = try TCPTestSocket.reserveLoopbackPort() + let proxy = ProxyServer(port: proxyPort, upstream: URL(string: "http://127.0.0.1:\(upstream.port)")!) + let runningProxy = RunningProxy(server: proxy) + try runningProxy.start() + defer { runningProxy.stop() } + + let openAIBody = #"{"model":"gpt-4","messages":[{"role":"user","content":"x","tool_calls":[{"id":"c"}]}]}"# + let response = try TCPTestSocket.roundTrip( + port: proxyPort, + request: TCPTestSocket.postRequest(path: "/v1/chat/completions", body: openAIBody), + timeoutSeconds: 10 + ) + let diagnostic = TCPTestSocket.describeResponse(response) + " upstream_requests=\(upstream.requestCount)" + XCTAssertTrue(response.contains("HTTP/1.1 415"), diagnostic) + XCTAssertEqual(upstream.requestCount, 0, "foreign body must not reach upstream; \(diagnostic)") + } + + // WO-408: an Anthropic-shaped count-tokens body is forwarded, not falsely refused. + func testAnthropicCountTokensNotRefused() throws { + let upstream = try StubHTTPServer { _ in + StubHTTPResponse(status: 200, headers: [:], body: Data(#"{"input_tokens":3}"#.utf8)) + } + try upstream.start() + defer { upstream.stop() } + + let proxyPort = try TCPTestSocket.reserveLoopbackPort() + let proxy = ProxyServer(port: proxyPort, upstream: URL(string: "http://127.0.0.1:\(upstream.port)")!) + let runningProxy = RunningProxy(server: proxy) + try runningProxy.start() + defer { runningProxy.stop() } + + let body = #"{"model":"claude-3","messages":[{"role":"user","content":"count me"}]}"# + let response = try TCPTestSocket.roundTrip( + port: proxyPort, + request: TCPTestSocket.postRequest(path: "/v1/messages/count_tokens", body: body), + timeoutSeconds: 10 + ) + let diagnostic = TCPTestSocket.describeResponse(response) + " upstream_requests=\(upstream.requestCount)" + XCTAssertFalse(response.contains("HTTP/1.1 415"), "count_tokens wrongly refused; \(diagnostic)") + XCTAssertEqual(upstream.requestCount, 1, "count_tokens must be forwarded; \(diagnostic)") + } + func testAdmissionCapRejectsFifthConcurrentConnection() throws { let upstreamEntered = DispatchSemaphore(value: 0) let upstreamRelease = DispatchSemaphore(value: 0) From 62e46b12dde6105dafffa02e92284e2f1878da8a Mon Sep 17 00:00:00 2001 From: ppiankov <103106369+ppiankov@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:54:53 +0800 Subject: [PATCH 02/29] fix: harden unsupported proxy shapes --- README.md | 8 +- Sources/PastewatchCLI/LaunchCommand.swift | 41 +++++++--- Sources/PastewatchCore/ProxyServer.swift | 61 ++++++++------- .../PastewatchTests/LaunchCommandTests.swift | 62 +++++++++++++++ .../ProxyBodyShapeGuardTests.swift | 32 ++++++++ .../ProxyRealServerTests.swift | 78 ++++++++++++++++++- docs/agent-integration.md | 9 +-- docs/agent-safety.md | 8 +- docs/agent-setup.md | 9 +-- 9 files changed, 247 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 13d233e..9ffb297 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Pastewatch refuses that transition. Every AI agent sends your file contents, command outputs, and tool results to a cloud API. If those contain secrets, the secrets leave your machine — silently, irreversibly, and into infrastructure you don't control. -Pastewatch makes secret leakage **structurally impossible** without breaking any agent functionality: +Pastewatch prevents supported secret-leakage paths structurally without breaking agent workflows: ``` What the agent does What actually happens @@ -128,7 +128,7 @@ pastewatch-cli setup claude-code pastewatch-cli launch claude ``` -The `launch` command starts the proxy, waits for it to be ready, sets `ANTHROPIC_BASE_URL`, and runs your agent. When the agent exits, the proxy stops. Every outbound API request is scanned and secrets are redacted before they leave your machine. +The `launch` command starts the proxy, waits for it to be ready, sets `ANTHROPIC_BASE_URL`, and runs Claude Code. When the agent exits, the proxy stops. The proxy scans Anthropic-shaped API requests and redacts supported secrets before they leave your machine; other agents remain covered by hooks, MCP tools, and agent instructions. **Important:** The setup step injects credential handling rules into your agent's `CLAUDE.md`. Without these rules, agents may echo passwords in shell output or store plaintext credentials in memory files — formats that bypass regex detection. The rules ensure agents use detectable keywords (`password=`, `secret=`) and never store raw values. See [docs/CLAUDE-SNIPPET.md](docs/CLAUDE-SNIPPET.md) for the full snippet. @@ -371,7 +371,7 @@ pastewatch-cli launch claude pastewatch-cli launch --audit-log /tmp/pw.log -- claude --model opus ``` -Only `claude` is routed through the proxy today (the proxy redacts Anthropic-shaped traffic). Launching another agent through `launch` starts the proxy but does **not** wire that agent to it — the agent runs normally and stays covered by the pastewatch hooks and MCP server. +Only `claude` is routed through the proxy today (the proxy redacts Anthropic-shaped traffic). Launching another agent through `launch` does **not** start or wire the proxy — the agent runs normally and stays covered by the pastewatch hooks and MCP server. Or start the proxy manually for more control: @@ -383,7 +383,7 @@ pastewatch-cli proxy ANTHROPIC_BASE_URL=http://127.0.0.1:8443 claude ``` -**Corporate proxy chaining.** Many organizations require all outbound traffic to go through a corporate proxy. Pastewatch chains transparently — it scans and redacts first, then forwards through the corporate proxy: +**Corporate proxy chaining.** Many organizations require API traffic to go through a corporate proxy. For routed Claude Code traffic, pastewatch chains transparently — it scans and redacts first, then forwards through the corporate proxy: ```bash # Corporate proxy at proxy.corp:8080 diff --git a/Sources/PastewatchCLI/LaunchCommand.swift b/Sources/PastewatchCLI/LaunchCommand.swift index 61bec41..b70141f 100644 --- a/Sources/PastewatchCLI/LaunchCommand.swift +++ b/Sources/PastewatchCLI/LaunchCommand.swift @@ -119,6 +119,21 @@ struct Launch: ParsableCommand { FileHandle.standardError.write(Data(warning.utf8)) } + let agentBinary = (command[0] as NSString).lastPathComponent + if !Launch.isProxyRoutedAgent(agentBinary) { + // WO-414: do not start an unused proxy for agents whose traffic is not routed. + Launch.configureProxyEnv(agentBinary: agentBinary, port: port) + if !quiet { + let cmdStr = command.joined(separator: " ") + FileHandle.standardError.write(Data("launching: \(cmdStr)\n\n".utf8)) + } + let exitCode = try runAgentProcess(command) + if exitCode != 0 { + throw ExitCode(rawValue: exitCode) + } + return + } + // Resolve our own binary to spawn the proxy subprocess let binaryPath = ProcessInfo.processInfo.arguments[0] @@ -157,21 +172,31 @@ struct Launch: ParsableCommand { FileHandle.standardError.write(Data("error: proxy failed to start (timeout waiting for port \(port))\n".utf8)) throw ExitCode(rawValue: 3) } + defer { + proxy.terminate() + proxy.waitUntilExit() + launchProxyProcess = nil + } if !quiet { let cmdStr = command.joined(separator: " ") FileHandle.standardError.write(Data("launching: \(cmdStr)\n\n".utf8)) } - Launch.configureProxyEnv(agentBinary: (command[0] as NSString).lastPathComponent, port: port) + Launch.configureProxyEnv(agentBinary: agentBinary, port: port) + + let exitCode = try runAgentProcess(command) + if exitCode != 0 { + throw ExitCode(rawValue: exitCode) + } + } + private func runAgentProcess(_ command: [String]) throws -> Int32 { // Fork: child exec's the agent (inherits TTY), parent waits and cleans up // Use @_silgen_name to bypass Swift's fork() unavailability on Darwin let pid = _pw_fork() if pid == -1 { - proxy.terminate() - proxy.waitUntilExit() FileHandle.standardError.write(Data("error: fork failed\n".utf8)) throw ExitCode(rawValue: 3) } @@ -208,14 +233,8 @@ struct Launch: ParsableCommand { // Killed by signal exitCode = 128 + (status & 0x7f) } - - // Clean up proxy - proxy.terminate() - proxy.waitUntilExit() - - if exitCode != 0 { - throw ExitCode(rawValue: exitCode) - } + launchAgentPid = 0 + return exitCode } private func runStartupSweepFixtureProbeIfNeeded() throws { diff --git a/Sources/PastewatchCore/ProxyServer.swift b/Sources/PastewatchCore/ProxyServer.swift index 9e44c23..dc4944c 100644 --- a/Sources/PastewatchCore/ProxyServer.swift +++ b/Sources/PastewatchCore/ProxyServer.swift @@ -1075,12 +1075,9 @@ public final class ProxyServer { case refuse(String) } - // WO-408: decide whether a request body is a shape pastewatch can redact, so an - // unredactable foreign body (e.g. OpenAI /v1/chat/completions) is refused rather than - // silently forwarded unscanned. Pure and socket-free so it is unit-testable directly. - // Only POST bodies carry tool results; everything else (GET /v1/models, OPTIONS, the - // token-counting endpoint's Anthropic body, non-JSON bodies) is allowed through — the - // /v1/messages scan branch already structurally skips them. + // WO-408/WO-411/WO-412: decide whether a request body is a shape pastewatch can + // redact. JSON POSTs to unsupported upstream paths are refused rather than silently + // forwarded unscanned. Pure and socket-free so it is unit-testable directly. func upstreamBodyShapeVerdict(method: String, path: String, bodyData: Data) -> BodyShapeVerdict { guard method.uppercased() == "POST" else { return .allow } // Non-JSON (incl. non-UTF-8) is not a chat body we own; a non-UTF-8 /v1/messages @@ -1088,22 +1085,23 @@ public final class ProxyServer { guard let json = try? JSONSerialization.jsonObject(with: bodyData) as? [String: Any] else { return .allow } - let hasMessages = json["messages"] is [Any] - if path.contains("/v1/messages") { - // Layer B: a foreign body arriving at the Anthropic endpoint. An Anthropic body - // with no messages array (or a valid shape) is fine; anything else is refused. - guard hasMessages else { return .allow } - return isAnthropicMessagesShape(json) - ? .allow - : .refuse("non-Anthropic messages schema on \(path)") - } - // Layer A: a chat-shaped body on a non-/v1/messages path (e.g. /v1/chat/completions). - // Refuse only when it carries a messages array that is NOT Anthropic-shaped, so an - // Anthropic-shaped body on another endpoint (e.g. /v1/messages/count_tokens) passes. - if hasMessages && !isAnthropicMessagesShape(json) { - return .refuse("chat body on unsupported endpoint \(path)") + guard isSupportedAnthropicPostPath(path) else { + return .refuse("unsupported JSON POST body on \(path)") } - return .allow + let hasMessages = json["messages"] is [Any] + // Some Anthropic endpoints (for example count_tokens variants) may omit a messages + // array. Keep those allowed; malformed message arrays are refused below. + guard hasMessages else { return .allow } + return isAnthropicMessagesShape(json) + ? .allow + : .refuse("non-Anthropic messages schema on \(path)") + } + + // WO-411/WO-412: path allowlist for JSON POST bodies the proxy understands. + func isSupportedAnthropicPostPath(_ path: String) -> Bool { + let pathOnly = path.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false).first + .map(String.init) ?? path + return pathOnly == "/v1/messages" || pathOnly == "/v1/messages/count_tokens" } // WO-408: positive identification of the Anthropic Messages schema. Permissive on @@ -1126,12 +1124,23 @@ public final class ProxyServer { return true } - // WO-408: per-request audit signal for a fail-closed refusal (verdict f6978df9). + // WO-408/WO-413: per-request audit signal for a fail-closed refusal (verdict f6978df9). private func logUnsupportedBodyShapeRefusal(path: String, reason: String) { - guard !quietLog else { return } - FileHandle.standardError.write(Data( - "[pastewatch-proxy] refused unsupported upstream body shape: \(reason)\n".utf8 - )) + let line = "[\(formatAuditTimestamp(Date()))] PROXY REFUSED unsupported upstream body shape in \(path) (\(reason))\n" + if !quietLog { + FileHandle.standardError.write(Data(line.utf8)) + } + if let logPath = auditLogPath { + logQueue.async { + if let handle = FileHandle(forWritingAtPath: logPath) { + handle.seekToEndOfFile() + handle.write(Data(line.utf8)) + handle.closeFile() + } else { + FileManager.default.createFile(atPath: logPath, contents: Data(line.utf8)) + } + } + } } private func scanProxyText(_ text: String) -> [DetectedMatch] { diff --git a/Tests/PastewatchTests/LaunchCommandTests.swift b/Tests/PastewatchTests/LaunchCommandTests.swift index caaf943..6bfd27b 100644 --- a/Tests/PastewatchTests/LaunchCommandTests.swift +++ b/Tests/PastewatchTests/LaunchCommandTests.swift @@ -89,6 +89,28 @@ final class LaunchCommandTests: XCTestCase { ) } + // WO-414: unsupported agents must not start an unused proxy or fail on its port. + func testLaunchNonAnthropicAgentDoesNotRequireProxyPort() throws { + let fixture = try makeLaunchFixture() + let agent = try writeEnvEchoAgent(named: "codex", in: fixture.cwd) + let occupied = try occupyLoopbackPort() + defer { close(occupied.fd) } + + let result = try runCLIProcess( + arguments: ["launch", "--quiet", "--no-startup-sweep", "--port", "\(occupied.port)", "--", agent.path], + cwd: fixture.cwd, + environment: fixture.environment + ) + + XCTAssertEqual(result.status, 0, "launch should not touch occupied proxy port; stderr: \(result.stderr)") + XCTAssertTrue(result.stdout.contains("ANTHROPIC_BASE_URL=UNSET"), result.stdout) + XCTAssertFalse(result.stderr.contains("failed to start proxy"), result.stderr) + XCTAssertTrue( + result.stderr.contains("redaction is not wired for agent 'codex'"), + "codex should warn about missing proxy interposition; stderr: \(result.stderr)" + ) + } + // WO-137: seam-unavailable probe fallback must not reach startup sweep or proxy. func testLaunchFixtureContextProbeUnavailablePathIsSweepSafe() throws { let fixture = try makeLaunchFixture() @@ -349,6 +371,46 @@ final class LaunchCommandTests: XCTestCase { return UInt16(bigEndian: bound.sin_port) } + // WO-414: keep the listener open to prove non-routed launches skip proxy startup. + private func occupyLoopbackPort() throws -> (fd: Int32, port: UInt16) { + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { throw LaunchPortError.socketFailed } + var addr = sockaddr_in() + addr.sin_family = sa_family_t(AF_INET) + addr.sin_addr.s_addr = inet_addr("127.0.0.1") + addr.sin_port = 0 + let bindResult = withUnsafePointer(to: &addr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + #if canImport(Darwin) + return Darwin.bind(fd, $0, socklen_t(MemoryLayout.size)) + #else + return Glibc.bind(fd, $0, socklen_t(MemoryLayout.size)) + #endif + } + } + guard bindResult == 0 else { + close(fd) + throw LaunchPortError.bindFailed + } + let singlePendingConnectionBacklog: Int32 = 1 + guard listen(fd, singlePendingConnectionBacklog) == 0 else { + close(fd) + throw LaunchPortError.bindFailed + } + var bound = sockaddr_in() + var len = socklen_t(MemoryLayout.size) + let nameResult = withUnsafeMutablePointer(to: &bound) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + getsockname(fd, $0, &len) + } + } + guard nameResult == 0 else { + close(fd) + throw LaunchPortError.bindFailed + } + return (fd, UInt16(bigEndian: bound.sin_port)) + } + private enum LaunchPortError: Error { case socketFailed, bindFailed } // WO-409: a dummy agent that prints whether ANTHROPIC_BASE_URL was set in its env, diff --git a/Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift b/Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift index e7e3d4f..4622df5 100644 --- a/Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift +++ b/Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift @@ -60,6 +60,38 @@ final class ProxyBodyShapeGuardTests: XCTestCase { } } + func testSimpleOpenAIChatCompletionsRefusedLayerA() { + // WO-411: a simple OpenAI chat body has the same role/string-content surface + // as a tiny Anthropic body; the unsupported path is the fail-closed signal. + let body = """ + {"model":"gpt-4","messages":[{"role":"user","content":"hello"}]} + """ + guard case .refuse = verdict("POST", "/v1/chat/completions", body) else { + return XCTFail("expected refuse for simple OpenAI chat/completions body") + } + } + + func testUnsupportedJSONPostWithoutMessagesRefused() { + // WO-412: unsupported JSON POSTs without a messages array are still unredactable. + let body = """ + {"model":"gpt-4.1","input":"hello"} + """ + guard case .refuse = verdict("POST", "/v1/responses", body) else { + return XCTFail("expected refuse for unsupported JSON POST body") + } + } + + func testForeignGenerateContentBodyWithoutMessagesRefused() { + // WO-412: non-Anthropic JSON shapes must fail closed even when they do not look + // like OpenAI chat/completions. + let body = """ + {"contents":[{"parts":[{"text":"hello"}]}]} + """ + guard case .refuse = verdict("POST", "/v1beta/models/gemini:generateContent", body) else { + return XCTFail("expected refuse for foreign JSON POST body") + } + } + func testOpenAIShapeOnMessagesEndpointRefusedLayerB() { // A foreign body pointed at the Anthropic endpoint is still refused. let body = """ diff --git a/Tests/PastewatchTests/ProxyRealServerTests.swift b/Tests/PastewatchTests/ProxyRealServerTests.swift index a3ec6ae..c0fa80e 100644 --- a/Tests/PastewatchTests/ProxyRealServerTests.swift +++ b/Tests/PastewatchTests/ProxyRealServerTests.swift @@ -45,7 +45,7 @@ final class ProxyRealServerTests: XCTestCase { XCTAssertTrue(response.contains(#"{"ok":true}"#), diagnostic) } - // WO-408: an OpenAI-shaped body is refused with 415 and never reaches upstream. + // WO-408/WO-411: an OpenAI-shaped body is refused with 415 and never reaches upstream. func testUnsupportedBodyShapeRefusedBeforeUpstream() throws { let upstream = try StubHTTPServer { _ in StubHTTPResponse(status: 200, headers: [:], body: Data(#"{"ok":true}"#.utf8)) @@ -59,7 +59,7 @@ final class ProxyRealServerTests: XCTestCase { try runningProxy.start() defer { runningProxy.stop() } - let openAIBody = #"{"model":"gpt-4","messages":[{"role":"user","content":"x","tool_calls":[{"id":"c"}]}]}"# + let openAIBody = #"{"model":"gpt-4","messages":[{"role":"user","content":"x"}]}"# let response = try TCPTestSocket.roundTrip( port: proxyPort, request: TCPTestSocket.postRequest(path: "/v1/chat/completions", body: openAIBody), @@ -70,6 +70,76 @@ final class ProxyRealServerTests: XCTestCase { XCTAssertEqual(upstream.requestCount, 0, "foreign body must not reach upstream; \(diagnostic)") } + // WO-412: unsupported JSON POST bodies without messages arrays are also refused. + func testUnsupportedJSONPostWithoutMessagesRefusedBeforeUpstream() throws { + let upstream = try StubHTTPServer { _ in + StubHTTPResponse(status: 200, headers: [:], body: Data(#"{"ok":true}"#.utf8)) + } + try upstream.start() + defer { upstream.stop() } + + let proxyPort = try TCPTestSocket.reserveLoopbackPort() + let proxy = ProxyServer(port: proxyPort, upstream: URL(string: "http://127.0.0.1:\(upstream.port)")!) + let runningProxy = RunningProxy(server: proxy) + try runningProxy.start() + defer { runningProxy.stop() } + + let response = try TCPTestSocket.roundTrip( + port: proxyPort, + request: TCPTestSocket.postRequest(path: "/v1/responses", body: #"{"input":"hello"}"#), + timeoutSeconds: 10 + ) + let diagnostic = TCPTestSocket.describeResponse(response) + " upstream_requests=\(upstream.requestCount)" + XCTAssertTrue(response.contains("HTTP/1.1 415"), diagnostic) + XCTAssertEqual(upstream.requestCount, 0, "unsupported body must not reach upstream; \(diagnostic)") + } + + // WO-413: quiet mode suppresses stderr, not durable audit evidence. + func testUnsupportedBodyShapeRefusalWritesAuditLogWhenQuiet() throws { + let upstream = try StubHTTPServer { _ in + StubHTTPResponse(status: 200, headers: [:], body: Data(#"{"ok":true}"#.utf8)) + } + try upstream.start() + defer { upstream.stop() } + + let auditPath = FileManager.default.temporaryDirectory + .appendingPathComponent("pastewatch-refused-shape-\(UUID().uuidString).log") + defer { try? FileManager.default.removeItem(at: auditPath) } + + let proxyPort = try TCPTestSocket.reserveLoopbackPort() + let proxy = ProxyServer( + port: proxyPort, + upstream: URL(string: "http://127.0.0.1:\(upstream.port)")!, + auditLogPath: auditPath.path, + quietLog: true + ) + let runningProxy = RunningProxy(server: proxy) + try runningProxy.start() + var proxyStopped = false + defer { + if !proxyStopped { + runningProxy.stop() + } + } + + let response = try TCPTestSocket.roundTrip( + port: proxyPort, + request: TCPTestSocket.postRequest(path: "/v1/responses", body: #"{"input":"hello"}"#), + timeoutSeconds: 10 + ) + runningProxy.stop() + proxyStopped = true + + let diagnostic = TCPTestSocket.describeResponse(response) + " upstream_requests=\(upstream.requestCount)" + XCTAssertTrue(response.contains("HTTP/1.1 415"), diagnostic) + XCTAssertEqual(upstream.requestCount, 0, "unsupported body must not reach upstream; \(diagnostic)") + + let audit = try String(contentsOf: auditPath, encoding: .utf8) + XCTAssertTrue(audit.contains("PROXY REFUSED unsupported upstream body shape"), audit) + XCTAssertTrue(audit.contains("/v1/responses"), audit) + XCTAssertTrue(audit.contains("unsupported JSON POST body"), audit) + } + // WO-408: an Anthropic-shaped count-tokens body is forwarded, not falsely refused. func testAnthropicCountTokensNotRefused() throws { let upstream = try StubHTTPServer { _ in @@ -127,7 +197,7 @@ final class ProxyRealServerTests: XCTestCase { runDetached { let response = (try? TCPTestSocket.roundTrip( port: proxyPort, - request: TCPTestSocket.postRequest(path: "/hold-\(index)"), + request: TCPTestSocket.postRequest(path: "/v1/messages"), timeoutSeconds: 5 )) ?? "" responseLock.lock() @@ -146,7 +216,7 @@ final class ProxyRealServerTests: XCTestCase { let rejected = try TCPTestSocket.roundTrip( port: proxyPort, - request: TCPTestSocket.postRequest(path: "/rejected"), + request: TCPTestSocket.postRequest(path: "/v1/messages"), timeoutSeconds: 3 ) XCTAssertTrue(rejected.contains("HTTP/1.1 503 Service Unavailable")) diff --git a/docs/agent-integration.md b/docs/agent-integration.md index 0f0826e..36fa8c1 100644 --- a/docs/agent-integration.md +++ b/docs/agent-integration.md @@ -11,15 +11,12 @@ brew install ppiankov/tap/pastewatch ## 1. API Proxy — Layer 0 -The proxy is the default and recommended way to run any agent. It sits between the agent and the cloud API, scanning and redacting secrets from **all** outbound requests — including subagents, tool calls, and anything else that bypasses hooks or MCP. +For Claude Code, the proxy is the default and recommended network boundary. It sits between Claude Code and the Anthropic API, scanning and redacting supported secrets from Anthropic-shaped requests — including subagent and tool traffic that bypasses hooks or MCP. ```bash # One command — starts proxy, launches agent, cleans up on exit pastewatch-cli launch claude -# Any agent -pastewatch-cli launch -- codex --full-auto - # With corporate proxy chaining pastewatch-cli launch --forward-proxy http://proxy.corp:8080 -- claude ``` @@ -30,7 +27,7 @@ Shell alias for zero-friction protected sessions: alias claude='pastewatch-cli launch claude' ``` -The proxy catches what hooks and MCP cannot — it is the network boundary. MCP tools and hooks below add defense in depth. +The proxy catches supported Claude Code traffic that hooks and MCP may miss. MCP tools and hooks below add defense in depth and are the primary integration path for agents not routed through `ANTHROPIC_BASE_URL`. --- @@ -382,7 +379,7 @@ For the full command reference, see [SKILL.md](SKILL.md). ## Verification -After configuring MCP and hooks for any agent: +After configuring MCP and hooks for an agent: 1. Start the agent - pastewatch should appear in the MCP/tools panel with 6 tools 2. Create a test file with a fake secret (e.g., `password=hunter2`) diff --git a/docs/agent-safety.md b/docs/agent-safety.md index cf03e7f..25a86c7 100644 --- a/docs/agent-safety.md +++ b/docs/agent-safety.md @@ -22,7 +22,7 @@ This is not hypothetical. Config files, .env files, and hardcoded credentials ar ## Layer 0: API Proxy (Network Boundary) -The strongest layer. Every API call from every process — including agent subprocesses you don't control — passes through a local proxy that scans and redacts secrets before they leave your machine. +The strongest layer for Claude Code. Anthropic-shaped API calls routed through `ANTHROPIC_BASE_URL` pass through a local proxy that scans and redacts supported secrets before they leave your machine. ```bash # One command — starts proxy, launches agent, cleans up on exit @@ -32,9 +32,9 @@ pastewatch-cli launch claude pastewatch-cli launch --audit-log /tmp/pw-proxy.log -- claude ``` -This is the default way to run any agent with pastewatch. The `launch` command starts the proxy, sets `ANTHROPIC_BASE_URL`, runs the agent, and stops the proxy on exit. +This is the default way to run Claude Code with pastewatch. The `launch` command starts the proxy, sets `ANTHROPIC_BASE_URL`, runs the agent, and stops the proxy on exit. -**Why this matters:** Agent subprocesses (subagents, background workers, parallel tasks) bypass tool-level protections like hooks and MCP. They make direct API calls with raw file contents. The proxy is the only layer that catches everything — it operates at the network boundary, not the tool boundary. +**Why this matters:** Claude Code subprocesses (subagents, background workers, parallel tasks) can bypass tool-level protections like hooks and MCP. The proxy operates at the network boundary for supported Anthropic-shaped traffic, while hooks and MCP remain necessary defense in depth. **Corporate environments** with mandatory company proxies: @@ -200,7 +200,7 @@ Different agents may need different thresholds. Use `--min-severity` on the MCP **Precedence chain:** per-request `min_severity` parameter > `--min-severity` CLI flag > `mcpMinSeverity` config field > default (`high`). -This means you can run Claude Code at `high` (default) and Cline at `medium` - each agent's MCP registration controls its own threshold, and any agent can still override per-request when needed. +This means you can run Claude Code at `high` (default) and Cline at `medium` - each MCP registration controls its own threshold, and each agent can still override per-request when needed. ### Audit logging diff --git a/docs/agent-setup.md b/docs/agent-setup.md index 538fe68..4aeffdd 100644 --- a/docs/agent-setup.md +++ b/docs/agent-setup.md @@ -1,6 +1,6 @@ # Agent Setup -Per-agent instructions for protecting AI coding sessions with pastewatch. The recommended setup is the API proxy via `launch` — it catches **all** outbound secrets including from subagents and tools that bypass hooks and MCP. +Per-agent instructions for protecting AI coding sessions with pastewatch. For Claude Code, the recommended setup is the API proxy via `launch` — it scans Anthropic-shaped traffic including from subagents and tools that bypass hooks and MCP. Other agents should use their supported hooks, MCP tools, and instructions. **Install first:** ```bash @@ -11,15 +11,12 @@ brew install ppiankov/tap/pastewatch ## Recommended: API Proxy via Launch -The proxy sits between your agent and the cloud API, scanning and redacting every outbound request. This is the default way to run any agent with pastewatch: +The proxy sits between Claude Code and the Anthropic API, scanning and redacting Anthropic-shaped requests: ```bash # One command — starts proxy, launches agent, cleans up on exit pastewatch-cli launch claude -# Any agent -pastewatch-cli launch -- codex --full-auto - # With corporate proxy pastewatch-cli launch --forward-proxy http://proxy.corp:8080 -- claude ``` @@ -31,7 +28,7 @@ For persistent setup, add a shell alias: alias claude='pastewatch-cli launch claude' ``` -The proxy is Layer 0 — it catches secrets that bypass hooks, MCP tools, and agent instructions. MCP and hooks below add defense in depth but the proxy is the foundation. +The proxy is Layer 0 for Claude Code — it catches Anthropic-shaped requests that bypass hooks, MCP tools, and agent instructions. MCP and hooks below add defense in depth and are the primary integration path for agents that are not routed through the proxy. --- From 959fdb99d1827fdd829db5b4ec31555e85bc558d Mon Sep 17 00:00:00 2001 From: ppiankov <103106369+ppiankov@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:11:59 +0800 Subject: [PATCH 03/29] fix: allow gateway-prefixed /v1/messages paths in proxy shape guard (WO-419) --- Sources/PastewatchCore/ProxyServer.swift | 10 +++++++++- .../ProxyBodyShapeGuardTests.swift | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/Sources/PastewatchCore/ProxyServer.swift b/Sources/PastewatchCore/ProxyServer.swift index dc4944c..d206407 100644 --- a/Sources/PastewatchCore/ProxyServer.swift +++ b/Sources/PastewatchCore/ProxyServer.swift @@ -1098,10 +1098,18 @@ public final class ProxyServer { } // WO-411/WO-412: path allowlist for JSON POST bodies the proxy understands. + // WO-419: match the /v1/messages endpoint as a path SUFFIX, not an exact string, so a + // gateway-fronted upstream (WO-142) whose request target embeds a base path — e.g. + // /v1/llm-gateway/v1/messages or /anthropic/v1/messages — is still recognized as the + // Anthropic Messages endpoint and allowed, while /v1/chat/completions, /v1/responses, + // and other non-Anthropic JSON POSTs remain refused. func isSupportedAnthropicPostPath(_ path: String) -> Bool { let pathOnly = path.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false).first .map(String.init) ?? path - return pathOnly == "/v1/messages" || pathOnly == "/v1/messages/count_tokens" + return pathOnly == "/v1/messages" + || pathOnly == "/v1/messages/count_tokens" + || pathOnly.hasSuffix("/v1/messages") + || pathOnly.hasSuffix("/v1/messages/count_tokens") } // WO-408: positive identification of the Anthropic Messages schema. Permissive on diff --git a/Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift b/Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift index 4622df5..a07e3da 100644 --- a/Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift +++ b/Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift @@ -113,6 +113,25 @@ final class ProxyBodyShapeGuardTests: XCTestCase { XCTAssertEqual(verdict("POST", "/v1/messages/count_tokens", body), .allow) } + func testGatewayPrefixedMessagesPathAllowed() { + // WO-419: a gateway-fronted upstream (WO-142) embeds a base path in the request + // target. An Anthropic-shaped body at /v1/llm-gateway/v1/messages must be allowed, + // not refused by an over-strict exact path match. + let body = """ + {"model":"claude-3","messages":[{"role":"user","content":"hi"}]} + """ + XCTAssertEqual(verdict("POST", "/v1/llm-gateway/v1/messages", body), .allow) + XCTAssertEqual(verdict("POST", "/anthropic/v1/messages?beta=true", body), .allow) + } + + func testGatewayPrefixedCountTokensAllowed() { + // WO-419: the count_tokens endpoint through a gateway prefix must also pass. + let body = """ + {"model":"claude-3","messages":[{"role":"user","content":"count me"}]} + """ + XCTAssertEqual(verdict("POST", "/v1/llm-gateway/v1/messages/count_tokens", body), .allow) + } + func testNonJSONBodyAllowed() { XCTAssertEqual(verdict("POST", "/v1/anything", "not json at all"), .allow) } From fd6fd149ab8a2c42d4e2548d6f5b6cefcb5315c6 Mon Sep 17 00:00:00 2001 From: ppiankov <103106369+ppiankov@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:24:32 +0800 Subject: [PATCH 04/29] fix: harden launch env and shape guard tests --- Sources/PastewatchCLI/LaunchCommand.swift | 43 ++++--- .../PastewatchTests/LaunchCommandTests.swift | 119 +++++++++++------- .../ProxyRealServerTests.swift | 81 ++++++++++++ 3 files changed, 182 insertions(+), 61 deletions(-) diff --git a/Sources/PastewatchCLI/LaunchCommand.swift b/Sources/PastewatchCLI/LaunchCommand.swift index b70141f..415d577 100644 --- a/Sources/PastewatchCLI/LaunchCommand.swift +++ b/Sources/PastewatchCLI/LaunchCommand.swift @@ -90,24 +90,39 @@ struct Launch: ParsableCommand { proxyRoutedAgents.contains(binary) } - // WO-409: only wire ANTHROPIC_BASE_URL for agents the proxy actually redacts. The proxy - // scans Anthropic-shaped (/v1/messages) traffic only; routing a non-Anthropic agent - // through it would fail closed on every request (WO-408), looking like a proxy bug - // rather than a deliberate unsupported-upstream refusal. Gating the setenv (and unsetting - // any inherited value) keeps launch coherent with the guard. + private static let anthropicBaseURLEnv = "ANTHROPIC_BASE_URL" + + // WO-409/WO-418: only wire ANTHROPIC_BASE_URL for agents the proxy actually redacts. + // Clear stale local pastewatch proxy values for unsupported agents, but preserve remote + // corporate/team gateways the operator intentionally configured. static func configureProxyEnv(agentBinary: String, port: UInt16) { if isProxyRoutedAgent(agentBinary) { - setenv("ANTHROPIC_BASE_URL", "http://127.0.0.1:\(port)", 1) + setenv(anthropicBaseURLEnv, "http://127.0.0.1:\(port)", 1) + } else if shouldClearExistingAnthropicBaseURL(ProcessInfo.processInfo.environment[anthropicBaseURLEnv]) { + unsetenv(anthropicBaseURLEnv) + FileHandle.standardError.write(Data(nonRoutedWarning(agentBinary: agentBinary, baseURLState: "not set").utf8)) } else { - unsetenv("ANTHROPIC_BASE_URL") - FileHandle.standardError.write(Data(""" - warning: pastewatch proxy redaction is not wired for agent '\(agentBinary)'; \ - launching without proxy interposition (ANTHROPIC_BASE_URL not set). \ - The proxy layer currently redacts Anthropic-shaped traffic only; 'claude' is \ - the only agent routed through it. Codex and other agents remain covered by the \ - pastewatch hooks and MCP server.\n - """.utf8)) + FileHandle.standardError.write(Data(nonRoutedWarning(agentBinary: agentBinary, baseURLState: "preserved").utf8)) + } + } + + static func shouldClearExistingAnthropicBaseURL(_ value: String?) -> Bool { + guard let value, !value.isEmpty else { return true } + guard let host = URLComponents(string: value)?.host?.lowercased() else { + return false } + return host == "127.0.0.1" || host == "localhost" || host == "::1" + } + + static func nonRoutedWarning(agentBinary: String, baseURLState: String) -> String { + let baseURLClause = baseURLState == "preserved" + ? "preserving existing ANTHROPIC_BASE_URL." + : "launching without proxy interposition (ANTHROPIC_BASE_URL not set)." + return "warning: pastewatch proxy redaction is not wired for agent '\(agentBinary)'; " + + "\(baseURLClause) " + + "The proxy layer currently redacts Anthropic-shaped traffic only; 'claude' is " + + "the only agent routed through it. Codex and other agents remain covered by the " + + "pastewatch hooks and MCP server.\n" } func run() throws { diff --git a/Tests/PastewatchTests/LaunchCommandTests.swift b/Tests/PastewatchTests/LaunchCommandTests.swift index 6bfd27b..eb4b3ea 100644 --- a/Tests/PastewatchTests/LaunchCommandTests.swift +++ b/Tests/PastewatchTests/LaunchCommandTests.swift @@ -1,4 +1,5 @@ import Foundation +@testable import PastewatchCLI import XCTest #if canImport(Darwin) import Darwin @@ -52,30 +53,22 @@ final class LaunchCommandTests: XCTestCase { XCTAssertFalse(result.stderr.contains("user:pass")) } - // WO-409: a proxy-routed agent (claude) receives ANTHROPIC_BASE_URL pointed at the proxy. + // WO-409/WO-416: a proxy-routed agent receives the local proxy URL without + // needing a bind-then-close port reservation in the test. func testLaunchClaudeAgentSetsAnthropicBaseURL() throws { - let fixture = try makeLaunchFixture() - let agent = try writeEnvEchoAgent(named: "claude", in: fixture.cwd) - let port = try reserveLoopbackPort() - let result = try runCLIProcess( - arguments: ["launch", "--quiet", "--no-startup-sweep", "--port", "\(port)", "--", agent.path], - cwd: fixture.cwd, - environment: fixture.environment - ) - XCTAssertTrue( - result.stdout.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:\(port)"), - "claude should be routed through the proxy; stdout: \(result.stdout) stderr: \(result.stderr)" - ) - XCTAssertFalse(result.stderr.contains("redaction is not wired"), "claude should not warn") + try withEnvironmentVariable("ANTHROPIC_BASE_URL", nil) { + Launch.configureProxyEnv(agentBinary: "claude", port: 49_152) + + XCTAssertEqual(environmentValue("ANTHROPIC_BASE_URL"), "http://127.0.0.1:49152") + } } // WO-409: a non-Anthropic agent (codex) launches WITHOUT ANTHROPIC_BASE_URL, plus a warning. func testLaunchNonAnthropicAgentSkipsBaseURLAndWarns() throws { let fixture = try makeLaunchFixture() let agent = try writeEnvEchoAgent(named: "codex", in: fixture.cwd) - let port = try reserveLoopbackPort() let result = try runCLIProcess( - arguments: ["launch", "--quiet", "--no-startup-sweep", "--port", "\(port)", "--", agent.path], + arguments: ["launch", "--quiet", "--no-startup-sweep", "--port", "65435", "--", agent.path], cwd: fixture.cwd, environment: fixture.environment ) @@ -87,6 +80,47 @@ final class LaunchCommandTests: XCTestCase { result.stderr.contains("redaction is not wired for agent 'codex'"), "codex should warn about missing proxy interposition; stderr: \(result.stderr)" ) + XCTAssertTrue(result.stderr.hasSuffix("\n"), "warning should end with one newline: \(result.stderr.debugDescription)") + XCTAssertFalse(result.stderr.hasSuffix("\n\n"), "warning should not end with a blank line") + } + + // WO-418: remote/team gateway URLs are operator intent, not stale local proxy state. + func testLaunchNonAnthropicAgentPreservesNonLocalBaseURL() throws { + let fixture = try makeLaunchFixture() + let agent = try writeEnvEchoAgent(named: "codex", in: fixture.cwd) + var environment = fixture.environment + environment["ANTHROPIC_BASE_URL"] = "https://gateway.example.com/anthropic" + + let result = try runCLIProcess( + arguments: ["launch", "--quiet", "--no-startup-sweep", "--port", "65435", "--", agent.path], + cwd: fixture.cwd, + environment: environment + ) + + XCTAssertEqual(result.status, 0, "launch should preserve remote gateway; stderr: \(result.stderr)") + XCTAssertTrue(result.stdout.contains("ANTHROPIC_BASE_URL=https://gateway.example.com/anthropic"), result.stdout) + XCTAssertTrue(result.stderr.contains("preserving existing ANTHROPIC_BASE_URL"), result.stderr) + XCTAssertFalse(result.stderr.contains("ANTHROPIC_BASE_URL not set"), result.stderr) + XCTAssertFalse(result.stderr.hasSuffix("\n\n"), "warning should not end with a blank line") + } + + // WO-418: stale local pastewatch proxy URLs are cleared for unsupported agents. + func testLaunchNonAnthropicAgentClearsLocalBaseURL() throws { + let fixture = try makeLaunchFixture() + let agent = try writeEnvEchoAgent(named: "codex", in: fixture.cwd) + var environment = fixture.environment + environment["ANTHROPIC_BASE_URL"] = "http://127.0.0.1:8443" + + let result = try runCLIProcess( + arguments: ["launch", "--quiet", "--no-startup-sweep", "--port", "65435", "--", agent.path], + cwd: fixture.cwd, + environment: environment + ) + + XCTAssertEqual(result.status, 0, "launch should clear stale local proxy URL; stderr: \(result.stderr)") + XCTAssertTrue(result.stdout.contains("ANTHROPIC_BASE_URL=UNSET"), result.stdout) + XCTAssertTrue(result.stderr.contains("ANTHROPIC_BASE_URL not set"), result.stderr) + XCTAssertFalse(result.stderr.hasSuffix("\n\n"), "warning should not end with a blank line") } // WO-414: unsupported agents must not start an unused proxy or fail on its port. @@ -258,6 +292,8 @@ final class LaunchCommandTests: XCTestCase { var environment = ProcessInfo.processInfo.environment environment["HOME"] = home.path environment.removeValue(forKey: "PW_GUARD") + // WO-418: launch env tests must not inherit an operator gateway from the parent shell. + environment.removeValue(forKey: "ANTHROPIC_BASE_URL") environment.removeValue(forKey: fixtureContextProbeEnvironmentKey) try writeFixtureStartupFile(in: home) @@ -340,37 +376,6 @@ final class LaunchCommandTests: XCTestCase { return root } - // WO-409: bind-then-close a loopback socket to obtain a free port for the real launch - // proxy (launch's waitForTCP polls a concrete port, so "0" never becomes ready). - private func reserveLoopbackPort() throws -> UInt16 { - let fd = socket(AF_INET, SOCK_STREAM, 0) - guard fd >= 0 else { throw LaunchPortError.socketFailed } - defer { close(fd) } - var addr = sockaddr_in() - addr.sin_family = sa_family_t(AF_INET) - addr.sin_addr.s_addr = inet_addr("127.0.0.1") - addr.sin_port = 0 - let bindResult = withUnsafePointer(to: &addr) { - $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { - #if canImport(Darwin) - return Darwin.bind(fd, $0, socklen_t(MemoryLayout.size)) - #else - return Glibc.bind(fd, $0, socklen_t(MemoryLayout.size)) - #endif - } - } - guard bindResult == 0 else { throw LaunchPortError.bindFailed } - var bound = sockaddr_in() - var len = socklen_t(MemoryLayout.size) - let nameResult = withUnsafeMutablePointer(to: &bound) { - $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { - getsockname(fd, $0, &len) - } - } - guard nameResult == 0 else { throw LaunchPortError.bindFailed } - return UInt16(bigEndian: bound.sin_port) - } - // WO-414: keep the listener open to prove non-routed launches skip proxy startup. private func occupyLoopbackPort() throws -> (fd: Int32, port: UInt16) { let fd = socket(AF_INET, SOCK_STREAM, 0) @@ -430,6 +435,26 @@ final class LaunchCommandTests: XCTestCase { try "DATABASE_URL=\(fixtureValue)\n".write(to: path, atomically: true, encoding: .utf8) } + private func environmentValue(_ key: String) -> String? { + guard let raw = getenv(key) else { return nil } + return String(cString: raw) + } + + private func withEnvironmentVariable(_ key: String, _ value: String?, run body: () throws -> Void) throws { + let original = environmentValue(key) + setEnvironmentValue(key, value) + defer { setEnvironmentValue(key, original) } + try body() + } + + private func setEnvironmentValue(_ key: String, _ value: String?) { + if let value { + setenv(key, value, 1) + } else { + unsetenv(key) + } + } + private func pastewatchCLIURL() -> URL { let productsDirectory = Bundle.main.bundleURL.deletingLastPathComponent() let bundled = productsDirectory.appendingPathComponent("PastewatchCLI") diff --git a/Tests/PastewatchTests/ProxyRealServerTests.swift b/Tests/PastewatchTests/ProxyRealServerTests.swift index c0fa80e..6ae93b7 100644 --- a/Tests/PastewatchTests/ProxyRealServerTests.swift +++ b/Tests/PastewatchTests/ProxyRealServerTests.swift @@ -45,6 +45,87 @@ final class ProxyRealServerTests: XCTestCase { XCTAssertTrue(response.contains(#"{"ok":true}"#), diagnostic) } + // WO-420: a supported Anthropic body passes the shape guard and is redacted before upstream. + func testAnthropicBodyRedactedThroughShapeGuardBeforeUpstream() throws { + let requestLock = NSLock() + var upstreamRequest = "" + let upstream = try StubHTTPServer { request in + requestLock.lock() + upstreamRequest = String(data: request, encoding: .utf8) ?? "" + requestLock.unlock() + return StubHTTPResponse( + status: 200, + headers: ["Content-Type": "application/json"], + body: Data(#"{"ok":true}"#.utf8) + ) + } + try upstream.start() + defer { upstream.stop() } + + let proxyPort = try TCPTestSocket.reserveLoopbackPort() + let proxy = ProxyServer( + port: proxyPort, + upstream: URL(string: "http://127.0.0.1:\(upstream.port)")! + ) + let runningProxy = RunningProxy(server: proxy) + try runningProxy.start() + defer { runningProxy.stop() } + + let credential = "password=s3cr3t-hunter2" + let body = """ + {"model":"claude-3","messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"\(credential)"}]}]} + """ + let response = try TCPTestSocket.roundTrip( + port: proxyPort, + request: TCPTestSocket.postRequest(path: "/v1/messages", body: body), + timeoutSeconds: 10 + ) + + requestLock.lock() + let forwarded = upstreamRequest + requestLock.unlock() + let diagnostic = TCPTestSocket.describeResponse(response) + " upstream_requests=\(upstream.requestCount)" + XCTAssertTrue(response.contains("HTTP/1.1 200 OK"), diagnostic) + XCTAssertEqual(upstream.requestCount, 1, diagnostic) + XCTAssertFalse(forwarded.contains(credential), "upstream request leaked raw credential") + XCTAssertTrue(forwarded.contains(""), "upstream request missing redaction placeholder") + } + + // WO-421: streaming Anthropic requests also pass the shape guard and reach upstream. + func testStreamingAnthropicBodyAllowedThroughShapeGuard() throws { + let upstream = try StubHTTPServer { _ in + StubHTTPResponse( + status: 200, + headers: ["Content-Type": "text/event-stream"], + body: Data("data: [DONE]\n\n".utf8) + ) + } + try upstream.start() + defer { upstream.stop() } + + let proxyPort = try TCPTestSocket.reserveLoopbackPort() + let proxy = ProxyServer( + port: proxyPort, + upstream: URL(string: "http://127.0.0.1:\(upstream.port)")! + ) + let runningProxy = RunningProxy(server: proxy) + try runningProxy.start() + defer { runningProxy.stop() } + + let body = """ + {"model":"claude-3","stream":true,"messages":[{"role":"user","content":"hello"}]} + """ + let response = try TCPTestSocket.roundTrip( + port: proxyPort, + request: TCPTestSocket.postRequest(path: "/v1/messages", body: body), + timeoutSeconds: 10 + ) + let diagnostic = TCPTestSocket.describeResponse(response) + " upstream_requests=\(upstream.requestCount)" + XCTAssertFalse(response.contains("HTTP/1.1 415"), diagnostic) + XCTAssertTrue(response.contains("HTTP/1.1 200 OK"), diagnostic) + XCTAssertEqual(upstream.requestCount, 1, diagnostic) + } + // WO-408/WO-411: an OpenAI-shaped body is refused with 415 and never reaches upstream. func testUnsupportedBodyShapeRefusedBeforeUpstream() throws { let upstream = try StubHTTPServer { _ in From 744949c3a7eef90b8c1211b8f4e4f88fdcfd1dab Mon Sep 17 00:00:00 2001 From: ppiankov <103106369+ppiankov@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:27:32 +0800 Subject: [PATCH 05/29] test: guard launch gateway warning secrecy --- Tests/PastewatchTests/LaunchCommandTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Tests/PastewatchTests/LaunchCommandTests.swift b/Tests/PastewatchTests/LaunchCommandTests.swift index eb4b3ea..20b6936 100644 --- a/Tests/PastewatchTests/LaunchCommandTests.swift +++ b/Tests/PastewatchTests/LaunchCommandTests.swift @@ -100,6 +100,7 @@ final class LaunchCommandTests: XCTestCase { XCTAssertEqual(result.status, 0, "launch should preserve remote gateway; stderr: \(result.stderr)") XCTAssertTrue(result.stdout.contains("ANTHROPIC_BASE_URL=https://gateway.example.com/anthropic"), result.stdout) XCTAssertTrue(result.stderr.contains("preserving existing ANTHROPIC_BASE_URL"), result.stderr) + XCTAssertFalse(result.stderr.contains("gateway.example.com"), "warning must not echo gateway URL values") XCTAssertFalse(result.stderr.contains("ANTHROPIC_BASE_URL not set"), result.stderr) XCTAssertFalse(result.stderr.hasSuffix("\n\n"), "warning should not end with a blank line") } From 3be14e593aa5fd5949eae95ce9712d7d8923a6e6 Mon Sep 17 00:00:00 2001 From: ppiankov <103106369+ppiankov@users.noreply.github.com> Date: Mon, 13 Jul 2026 20:11:11 +0800 Subject: [PATCH 06/29] fix: close proxy shape guard gaps --- Sources/PastewatchCLI/LaunchCommand.swift | 35 ++- Sources/PastewatchCore/ProxyServer.swift | 40 ++- .../PastewatchTests/LaunchCommandTests.swift | 30 +++ .../ProxyBodyShapeGuardTests.swift | 121 +++++++-- .../ProxyRealServerTests.swift | 233 +++++++++++++++++- 5 files changed, 426 insertions(+), 33 deletions(-) diff --git a/Sources/PastewatchCLI/LaunchCommand.swift b/Sources/PastewatchCLI/LaunchCommand.swift index 415d577..3fa448f 100644 --- a/Sources/PastewatchCLI/LaunchCommand.swift +++ b/Sources/PastewatchCLI/LaunchCommand.swift @@ -107,11 +107,40 @@ struct Launch: ParsableCommand { } static func shouldClearExistingAnthropicBaseURL(_ value: String?) -> Bool { - guard let value, !value.isEmpty else { return true } - guard let host = URLComponents(string: value)?.host?.lowercased() else { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return true + } + let candidates = value.contains("://") ? [value] : [value, "http://\(value)"] + return candidates.contains { candidate in + guard let host = URLComponents(string: candidate)?.host else { return false } + return isLocalAnthropicBaseURLHost(host) + } + } + + // WO-423: stale local proxy URLs can be written in non-canonical forms; preserve + // remote gateways, but clear loopback/any-address spellings for non-routed agents. + static func isLocalAnthropicBaseURLHost(_ host: String) -> Bool { + let normalized = host.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: "[]")) + if normalized == "localhost" || normalized == "::1" || normalized == "0.0.0.0" { + return true + } + if normalized.hasPrefix("::ffff:") { + return isLocalIPv4AnthropicBaseURLHost(String(normalized.dropFirst("::ffff:".count))) + } + return isLocalIPv4AnthropicBaseURLHost(normalized) + } + + static func isLocalIPv4AnthropicBaseURLHost(_ host: String) -> Bool { + let parts = host.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count == 4 else { return false } - return host == "127.0.0.1" || host == "localhost" || host == "::1" + let octets = parts.compactMap { part -> Int? in + guard let value = Int(part), (0...255).contains(value) else { return nil } + return value + } + guard octets.count == 4 else { return false } + return octets[0] == 127 || octets == [0, 0, 0, 0] } static func nonRoutedWarning(agentBinary: String, baseURLState: String) -> String { diff --git a/Sources/PastewatchCore/ProxyServer.swift b/Sources/PastewatchCore/ProxyServer.swift index d206407..b3a47f9 100644 --- a/Sources/PastewatchCore/ProxyServer.swift +++ b/Sources/PastewatchCore/ProxyServer.swift @@ -697,7 +697,7 @@ public final class ProxyServer { return } - // Only scan POST /v1/messages (the endpoint that carries tool results) + // Only scan supported Anthropic message POSTs (the endpoints that carry tool results) var processedBody = parsed.body var processedBodyData = parsed.bodyData var redactionCount = 0 @@ -705,7 +705,7 @@ public final class ProxyServer { var bodyAdvisoryCount = 0 var bodyAdvisoryTypes: [String] = [] var shouldBlockNonUTF8Forwarding = false - if parsed.method == "POST" && parsed.path.contains("/v1/messages") { + if parsed.method == "POST" && isSupportedAnthropicPostPath(parsed.path) { if let body = parsed.body { let result = scanAndRedactBody(body) processedBody = result.body @@ -1080,12 +1080,22 @@ public final class ProxyServer { // forwarded unscanned. Pure and socket-free so it is unit-testable directly. func upstreamBodyShapeVerdict(method: String, path: String, bodyData: Data) -> BodyShapeVerdict { guard method.uppercased() == "POST" else { return .allow } - // Non-JSON (incl. non-UTF-8) is not a chat body we own; a non-UTF-8 /v1/messages - // body still reaches the existing WO-296 fail-closed path downstream. - guard let json = try? JSONSerialization.jsonObject(with: bodyData) as? [String: Any] else { - return .allow - } - guard isSupportedAnthropicPostPath(path) else { + let supportedAnthropicPath = isSupportedAnthropicPostPath(path) + // WO-422: non-JSON and non-UTF-8 bodies on foreign paths cannot be scanned by the + // Anthropic-only proxy. Supported paths still fall through to the WO-296 body scan. + guard let jsonValue = try? JSONSerialization.jsonObject(with: bodyData, options: [.fragmentsAllowed]) else { + return supportedAnthropicPath ? .allow : .refuse("unsupported non-JSON POST body on \(path)") + } + // WO-422: parseable JSON arrays/scalars are JSON bodies, not opaque transport bytes. + // Refuse malformed Anthropic JSON on supported paths and any JSON body on unsupported + // paths instead of silently forwarding it unscanned. + guard let json = jsonValue as? [String: Any] else { + let reason = supportedAnthropicPath + ? "malformed Anthropic JSON body on \(path)" + : "unsupported JSON POST body on \(path)" + return .refuse(reason) + } + guard supportedAnthropicPath else { return .refuse("unsupported JSON POST body on \(path)") } let hasMessages = json["messages"] is [Any] @@ -1115,9 +1125,13 @@ public final class ProxyServer { // WO-408: positive identification of the Anthropic Messages schema. Permissive on // unknown keys (Anthropic adds fields over time — fail closed, never over-refuse a // genuine future field by being strict), strict on the three load-bearing invariants, - // and rejecting OpenAI-only siblings that disambiguate a chat/completions body. + // rejects known foreign model markers and OpenAI-only siblings that disambiguate a + // chat/completions body. func isAnthropicMessagesShape(_ json: [String: Any]) -> Bool { guard let messages = json["messages"] as? [[String: Any]] else { return false } + if let model = json["model"] as? String, isKnownForeignMessagesModel(model) { + return false + } for message in messages { guard message["role"] is String else { return false } // OpenAI /v1/chat/completions carries tool_calls / function_call on messages; @@ -1132,6 +1146,14 @@ public final class ProxyServer { return true } + // WO-422: a plain OpenAI chat body can otherwise look identical to a minimal + // Anthropic Messages request once it is delivered to a /v1/messages-suffixed path. + private func isKnownForeignMessagesModel(_ model: String) -> Bool { + let lower = model.lowercased() + let foreignPrefixes = ["gpt-", "chatgpt-", "o1", "o3", "o4", "gemini-", "mistral-"] + return foreignPrefixes.contains { lower.hasPrefix($0) } + } + // WO-408/WO-413: per-request audit signal for a fail-closed refusal (verdict f6978df9). private func logUnsupportedBodyShapeRefusal(path: String, reason: String) { let line = "[\(formatAuditTimestamp(Date()))] PROXY REFUSED unsupported upstream body shape in \(path) (\(reason))\n" diff --git a/Tests/PastewatchTests/LaunchCommandTests.swift b/Tests/PastewatchTests/LaunchCommandTests.swift index 20b6936..92a4c8f 100644 --- a/Tests/PastewatchTests/LaunchCommandTests.swift +++ b/Tests/PastewatchTests/LaunchCommandTests.swift @@ -124,6 +124,36 @@ final class LaunchCommandTests: XCTestCase { XCTAssertFalse(result.stderr.hasSuffix("\n\n"), "warning should not end with a blank line") } + // WO-423: classify stale local proxy URL spellings directly, not only via process launch. + func testShouldClearExistingAnthropicBaseURLLoopbackTable() { + let shouldClear = [ + nil, + "", + "http://127.0.0.1:8443", + "http://127.0.0.2:8443", + "http://127.255.255.255:8443", + "http://0.0.0.0:8443", + "http://localhost:8443", + "http://[::1]:8443", + "http://[::ffff:127.0.0.1]:8443", + "127.0.0.1:8443", + "localhost:8443", + ] + let shouldPreserve = [ + "https://gateway.example.com/anthropic", + "http://127.0.0.1.evil.com:8443", + "https://api.anthropic.com", + "gateway.example.com/anthropic", + ] + + for value in shouldClear { + XCTAssertTrue(Launch.shouldClearExistingAnthropicBaseURL(value), "expected clear for \(value ?? "nil")") + } + for value in shouldPreserve { + XCTAssertFalse(Launch.shouldClearExistingAnthropicBaseURL(value), "expected preserve for \(value)") + } + } + // WO-414: unsupported agents must not start an unused proxy or fail on its port. func testLaunchNonAnthropicAgentDoesNotRequireProxyPort() throws { let fixture = try makeLaunchFixture() diff --git a/Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift b/Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift index a07e3da..046f998 100644 --- a/Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift +++ b/Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift @@ -16,6 +16,10 @@ final class ProxyBodyShapeGuardTests: XCTestCase { server().upstreamBodyShapeVerdict(method: method, path: path, bodyData: Data(body.utf8)) } + private func verdict(_ method: String, _ path: String, bodyData: Data) -> ProxyServer.BodyShapeVerdict { + server().upstreamBodyShapeVerdict(method: method, path: path, bodyData: bodyData) + } + // MARK: - Anthropic shapes are allowed func testAnthropicToolResultBodyAllowed() { @@ -55,9 +59,10 @@ final class ProxyBodyShapeGuardTests: XCTestCase { let body = """ {"model":"gpt-4","messages":[{"role":"assistant","content":"","tool_calls":[{"id":"c1","type":"function"}]}]} """ - guard case .refuse = verdict("POST", "/v1/chat/completions", body) else { - return XCTFail("expected refuse for OpenAI chat/completions body") - } + XCTAssertEqual( + verdict("POST", "/v1/chat/completions", body), + .refuse("unsupported JSON POST body on /v1/chat/completions") + ) } func testSimpleOpenAIChatCompletionsRefusedLayerA() { @@ -66,9 +71,10 @@ final class ProxyBodyShapeGuardTests: XCTestCase { let body = """ {"model":"gpt-4","messages":[{"role":"user","content":"hello"}]} """ - guard case .refuse = verdict("POST", "/v1/chat/completions", body) else { - return XCTFail("expected refuse for simple OpenAI chat/completions body") - } + XCTAssertEqual( + verdict("POST", "/v1/chat/completions", body), + .refuse("unsupported JSON POST body on /v1/chat/completions") + ) } func testUnsupportedJSONPostWithoutMessagesRefused() { @@ -76,9 +82,7 @@ final class ProxyBodyShapeGuardTests: XCTestCase { let body = """ {"model":"gpt-4.1","input":"hello"} """ - guard case .refuse = verdict("POST", "/v1/responses", body) else { - return XCTFail("expected refuse for unsupported JSON POST body") - } + XCTAssertEqual(verdict("POST", "/v1/responses", body), .refuse("unsupported JSON POST body on /v1/responses")) } func testForeignGenerateContentBodyWithoutMessagesRefused() { @@ -87,9 +91,10 @@ final class ProxyBodyShapeGuardTests: XCTestCase { let body = """ {"contents":[{"parts":[{"text":"hello"}]}]} """ - guard case .refuse = verdict("POST", "/v1beta/models/gemini:generateContent", body) else { - return XCTFail("expected refuse for foreign JSON POST body") - } + XCTAssertEqual( + verdict("POST", "/v1beta/models/gemini:generateContent", body), + .refuse("unsupported JSON POST body on /v1beta/models/gemini:generateContent") + ) } func testOpenAIShapeOnMessagesEndpointRefusedLayerB() { @@ -97,9 +102,56 @@ final class ProxyBodyShapeGuardTests: XCTestCase { let body = """ {"model":"gpt-4","messages":[{"role":"user","content":"hi","function_call":{"name":"f"}}]} """ - guard case .refuse = verdict("POST", "/v1/messages", body) else { - return XCTFail("expected refuse for OpenAI shape on /v1/messages") - } + XCTAssertEqual( + verdict("POST", "/v1/messages", body), + .refuse("non-Anthropic messages schema on /v1/messages") + ) + } + + func testPlainOpenAIShapeOnMessagesEndpointRefusedLayerB() { + // WO-422: model markers keep plain OpenAI bodies from passing as tiny + // Anthropic requests when a gateway misroutes them to /v1/messages. + let body = """ + {"model":"gpt-4","messages":[{"role":"user","content":"hello"}]} + """ + XCTAssertEqual( + verdict("POST", "/v1/messages", body), + .refuse("non-Anthropic messages schema on /v1/messages") + ) + } + + func testTopLevelJSONArrayRefusedOnUnsupportedPath() { + // WO-422: JSON arrays are parseable JSON bodies, not opaque non-JSON bytes. + let body = """ + [{"role":"user","content":"hello"}] + """ + XCTAssertEqual( + verdict("POST", "/v1/chat/completions", body), + .refuse("unsupported JSON POST body on /v1/chat/completions") + ) + } + + func testTopLevelJSONArrayRefusedOnMessagesPath() { + let body = """ + [{"role":"user","content":"hello"}] + """ + XCTAssertEqual( + verdict("POST", "/v1/messages", body), + .refuse("malformed Anthropic JSON body on /v1/messages") + ) + } + + func testNonUTF8ForeignPathRefused() { + let data = Data([0xff, 0xfe, 0xfd]) + XCTAssertEqual( + verdict("POST", "/v1/chat/completions", bodyData: data), + .refuse("unsupported non-JSON POST body on /v1/chat/completions") + ) + } + + func testNonUTF8MessagesPathAllowedForDownstreamScan() { + let data = Data([0xff, 0xfe, 0xfd]) + XCTAssertEqual(verdict("POST", "/v1/messages", bodyData: data), .allow) } // MARK: - Legitimate non-message traffic is NOT broken @@ -132,8 +184,15 @@ final class ProxyBodyShapeGuardTests: XCTestCase { XCTAssertEqual(verdict("POST", "/v1/llm-gateway/v1/messages/count_tokens", body), .allow) } - func testNonJSONBodyAllowed() { - XCTAssertEqual(verdict("POST", "/v1/anything", "not json at all"), .allow) + func testNonJSONBodyOnMessagesPathAllowedForDownstreamScan() { + XCTAssertEqual(verdict("POST", "/v1/messages", "not json at all"), .allow) + } + + func testNonJSONBodyOnUnsupportedPathRefused() { + XCTAssertEqual( + verdict("POST", "/v1/anything", "not json at all"), + .refuse("unsupported non-JSON POST body on /v1/anything") + ) } func testEmptyBodyAllowed() { @@ -148,6 +207,34 @@ final class ProxyBodyShapeGuardTests: XCTestCase { XCTAssertEqual(verdict("OPTIONS", "/v1/messages", ""), .allow) } + // MARK: - Supported path predicate direct + + func testSupportedAnthropicPathPredicate() { + let proxy = server() + let allowed = [ + "/v1/messages", + "/v1/messages?beta=true", + "/v1/messages/count_tokens", + "/v1/llm-gateway/v1/messages", + "/anthropic/v1/messages?beta=true", + "/v1/llm-gateway/v1/messages/count_tokens", + ] + let refused = [ + "/v1/chat/completions", + "/v1/responses", + "/v1/messages_extra", + "/v1/messages/extra", + "/v1beta/models/gemini:generateContent", + ] + + for path in allowed { + XCTAssertTrue(proxy.isSupportedAnthropicPostPath(path), "expected supported path: \(path)") + } + for path in refused { + XCTAssertFalse(proxy.isSupportedAnthropicPostPath(path), "expected unsupported path: \(path)") + } + } + // MARK: - isAnthropicMessagesShape direct func testShapeRejectsToolCalls() { diff --git a/Tests/PastewatchTests/ProxyRealServerTests.swift b/Tests/PastewatchTests/ProxyRealServerTests.swift index 6ae93b7..9cd8197 100644 --- a/Tests/PastewatchTests/ProxyRealServerTests.swift +++ b/Tests/PastewatchTests/ProxyRealServerTests.swift @@ -91,6 +91,54 @@ final class ProxyRealServerTests: XCTestCase { XCTAssertTrue(forwarded.contains(""), "upstream request missing redaction placeholder") } + // WO-424: gateway-prefixed Anthropic paths must forward through the real proxy. + func testGatewayPrefixedAnthropicBodyRedactedAndForwarded() throws { + let requestLock = NSLock() + var upstreamRequest = "" + let upstream = try StubHTTPServer { request in + requestLock.lock() + upstreamRequest = String(data: request, encoding: .utf8) ?? "" + requestLock.unlock() + return StubHTTPResponse( + status: 200, + headers: ["Content-Type": "application/json"], + body: Data(#"{"ok":true}"#.utf8) + ) + } + try upstream.start() + defer { upstream.stop() } + + let proxyPort = try TCPTestSocket.reserveLoopbackPort() + let proxy = ProxyServer( + port: proxyPort, + upstream: URL(string: "http://127.0.0.1:\(upstream.port)")! + ) + let runningProxy = RunningProxy(server: proxy) + try runningProxy.start() + defer { runningProxy.stop() } + + let credential = "password=gateway-hunter2" + let body = """ + {"model":"claude-3","messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"\(credential)"}]}]} + """ + let response = try TCPTestSocket.roundTrip( + port: proxyPort, + request: TCPTestSocket.postRequest(path: "/v1/llm-gateway/v1/messages", body: body), + timeoutSeconds: 10 + ) + + requestLock.lock() + let forwarded = upstreamRequest + requestLock.unlock() + let diagnostic = TCPTestSocket.describeResponse(response) + " upstream_requests=\(upstream.requestCount)" + XCTAssertTrue(response.contains("HTTP/1.1 200 OK"), diagnostic) + XCTAssertFalse(response.contains("HTTP/1.1 415"), diagnostic) + XCTAssertEqual(upstream.requestCount, 1, diagnostic) + XCTAssertTrue(forwarded.contains("POST /v1/llm-gateway/v1/messages HTTP/1.1"), forwarded) + XCTAssertFalse(forwarded.contains(credential), "upstream request leaked raw credential") + XCTAssertTrue(forwarded.contains(""), "upstream request missing redaction placeholder") + } + // WO-421: streaming Anthropic requests also pass the shape guard and reach upstream. func testStreamingAnthropicBodyAllowedThroughShapeGuard() throws { let upstream = try StubHTTPServer { _ in @@ -147,10 +195,59 @@ final class ProxyRealServerTests: XCTestCase { timeoutSeconds: 10 ) let diagnostic = TCPTestSocket.describeResponse(response) + " upstream_requests=\(upstream.requestCount)" - XCTAssertTrue(response.contains("HTTP/1.1 415"), diagnostic) + XCTAssertTrue(response.contains("HTTP/1.1 415 Unsupported Media Type"), diagnostic) + XCTAssertTrue(response.contains(#""error": "Unsupported upstream body shape""#), diagnostic) XCTAssertEqual(upstream.requestCount, 0, "foreign body must not reach upstream; \(diagnostic)") } + // WO-422: parseable JSON arrays on unsupported paths fail closed. + func testTopLevelJSONArrayRefusedBeforeUpstream() throws { + let upstream = try StubHTTPServer { _ in + StubHTTPResponse(status: 200, headers: [:], body: Data(#"{"ok":true}"#.utf8)) + } + try upstream.start() + defer { upstream.stop() } + + let proxyPort = try TCPTestSocket.reserveLoopbackPort() + let proxy = ProxyServer(port: proxyPort, upstream: URL(string: "http://127.0.0.1:\(upstream.port)")!) + let runningProxy = RunningProxy(server: proxy) + try runningProxy.start() + defer { runningProxy.stop() } + + let response = try TCPTestSocket.roundTrip( + port: proxyPort, + request: TCPTestSocket.postRequest(path: "/v1/chat/completions", body: #"[{"role":"user","content":"x"}]"#), + timeoutSeconds: 10 + ) + let diagnostic = TCPTestSocket.describeResponse(response) + " upstream_requests=\(upstream.requestCount)" + XCTAssertTrue(response.contains("HTTP/1.1 415 Unsupported Media Type"), diagnostic) + XCTAssertEqual(upstream.requestCount, 0, "JSON array body must not reach upstream; \(diagnostic)") + } + + // WO-422: non-UTF-8 bodies on foreign paths fail closed instead of bypassing scanning. + func testNonUTF8ForeignPathRefusedBeforeUpstream() throws { + let upstream = try StubHTTPServer { _ in + StubHTTPResponse(status: 200, headers: [:], body: Data(#"{"ok":true}"#.utf8)) + } + try upstream.start() + defer { upstream.stop() } + + let proxyPort = try TCPTestSocket.reserveLoopbackPort() + let proxy = ProxyServer(port: proxyPort, upstream: URL(string: "http://127.0.0.1:\(upstream.port)")!) + let runningProxy = RunningProxy(server: proxy) + try runningProxy.start() + defer { runningProxy.stop() } + + let request = TCPTestSocket.postRequestData( + path: "/v1/chat/completions", + body: Data([0xff, 0xfe, 0xfd]) + ) + let response = try TCPTestSocket.roundTrip(port: proxyPort, requestData: request, timeoutSeconds: 10) + let diagnostic = TCPTestSocket.describeResponse(response) + " upstream_requests=\(upstream.requestCount)" + XCTAssertTrue(response.contains("HTTP/1.1 415 Unsupported Media Type"), diagnostic) + XCTAssertEqual(upstream.requestCount, 0, "non-UTF-8 foreign body must not reach upstream; \(diagnostic)") + } + // WO-412: unsupported JSON POST bodies without messages arrays are also refused. func testUnsupportedJSONPostWithoutMessagesRefusedBeforeUpstream() throws { let upstream = try StubHTTPServer { _ in @@ -171,7 +268,8 @@ final class ProxyRealServerTests: XCTestCase { timeoutSeconds: 10 ) let diagnostic = TCPTestSocket.describeResponse(response) + " upstream_requests=\(upstream.requestCount)" - XCTAssertTrue(response.contains("HTTP/1.1 415"), diagnostic) + XCTAssertTrue(response.contains("HTTP/1.1 415 Unsupported Media Type"), diagnostic) + XCTAssertTrue(response.contains(#""error": "Unsupported upstream body shape""#), diagnostic) XCTAssertEqual(upstream.requestCount, 0, "unsupported body must not reach upstream; \(diagnostic)") } @@ -212,7 +310,7 @@ final class ProxyRealServerTests: XCTestCase { proxyStopped = true let diagnostic = TCPTestSocket.describeResponse(response) + " upstream_requests=\(upstream.requestCount)" - XCTAssertTrue(response.contains("HTTP/1.1 415"), diagnostic) + XCTAssertTrue(response.contains("HTTP/1.1 415 Unsupported Media Type"), diagnostic) XCTAssertEqual(upstream.requestCount, 0, "unsupported body must not reach upstream; \(diagnostic)") let audit = try String(contentsOf: auditPath, encoding: .utf8) @@ -221,6 +319,42 @@ final class ProxyRealServerTests: XCTestCase { XCTAssertTrue(audit.contains("unsupported JSON POST body"), audit) } + // WO-424: non-quiet refusals write the same audit signal to stderr. + func testUnsupportedBodyShapeRefusalWritesStderrWhenNotQuiet() throws { + let upstream = try StubHTTPServer { _ in + StubHTTPResponse(status: 200, headers: [:], body: Data(#"{"ok":true}"#.utf8)) + } + try upstream.start() + defer { upstream.stop() } + + let proxyPort = try TCPTestSocket.reserveLoopbackPort() + let proxy = ProxyServer( + port: proxyPort, + upstream: URL(string: "http://127.0.0.1:\(upstream.port)")!, + quietLog: false + ) + let runningProxy = RunningProxy(server: proxy) + let responseBox = LockedValue("") + let stderr = try captureStandardError { + try runningProxy.start() + defer { runningProxy.stop() } + let response = try TCPTestSocket.roundTrip( + port: proxyPort, + request: TCPTestSocket.postRequest(path: "/v1/responses", body: #"{"input":"hello"}"#), + timeoutSeconds: 10 + ) + responseBox.set(response) + } + + let response = responseBox.value + let diagnostic = TCPTestSocket.describeResponse(response) + " upstream_requests=\(upstream.requestCount)" + XCTAssertTrue(response.contains("HTTP/1.1 415 Unsupported Media Type"), diagnostic) + XCTAssertEqual(upstream.requestCount, 0, diagnostic) + XCTAssertTrue(stderr.contains("PROXY REFUSED unsupported upstream body shape"), stderr) + XCTAssertTrue(stderr.contains("/v1/responses"), stderr) + XCTAssertTrue(stderr.contains("unsupported JSON POST body"), stderr) + } + // WO-408: an Anthropic-shaped count-tokens body is forwarded, not falsely refused. func testAnthropicCountTokensNotRefused() throws { let upstream = try StubHTTPServer { _ in @@ -499,6 +633,83 @@ private final class RunningProxy { } } +private final class LockedValue { + private let lock = NSLock() + private var storage: Value + + init(_ value: Value) { + storage = value + } + + var value: Value { + lock.lock() + defer { lock.unlock() } + return storage + } + + func set(_ value: Value) { + lock.lock() + storage = value + lock.unlock() + } +} + +private func captureStandardError(_ body: () throws -> Void) throws -> String { + var fds = [Int32](repeating: 0, count: 2) + guard pipe(&fds) == 0 else { throw ProxyHarnessError.systemError("pipe") } + let savedStderr = dup(STDERR_FILENO) + guard savedStderr >= 0 else { + close(fds[0]) + close(fds[1]) + throw ProxyHarnessError.systemError("dup") + } + + fflush(stderr) + guard dup2(fds[1], STDERR_FILENO) >= 0 else { + close(fds[0]) + close(fds[1]) + close(savedStderr) + throw ProxyHarnessError.systemError("dup2") + } + close(fds[1]) + + var restored = false + func restoreStderr() { + guard !restored else { return } + fflush(stderr) + dup2(savedStderr, STDERR_FILENO) + close(savedStderr) + restored = true + } + + do { + try body() + restoreStderr() + let data = try readPipeToEOF(fds[0]) + close(fds[0]) + return String(data: data, encoding: .utf8) ?? "" + } catch { + restoreStderr() + close(fds[0]) + throw error + } +} + +private func readPipeToEOF(_ fd: Int32) throws -> Data { + var data = Data() + var buffer = [UInt8](repeating: 0, count: 4096) + while true { + let n = read(fd, &buffer, buffer.count) + if n > 0 { + data.append(contentsOf: buffer[0.. Data { + let head = "POST \(path) HTTP/1.1\r\n" + + "Host: 127.0.0.1\r\n" + + "Content-Type: \(contentType)\r\n" + + "Content-Length: \(body.count)\r\n\r\n" + var request = Data(head.utf8) + request.append(body) + return request + } + static func reserveLoopbackPort() throws -> UInt16 { let fd = try listenOnLoopback(port: 0) defer { close(fd) } @@ -678,6 +899,10 @@ private enum TCPTestSocket { } static func roundTrip(port: UInt16, request: String, timeoutSeconds: Int = 3) throws -> String { + return try roundTrip(port: port, requestData: Data(request.utf8), timeoutSeconds: timeoutSeconds) + } + + static func roundTrip(port: UInt16, requestData: Data, timeoutSeconds: Int = 3) throws -> String { let fd = socket(AF_INET, testSocketStreamType, 0) guard fd >= 0 else { throw ProxyHarnessError.systemError("socket") } defer { close(fd) } @@ -691,7 +916,7 @@ private enum TCPTestSocket { } } guard connected == 0 else { throw ProxyHarnessError.systemError("connect") } - try writeAll(Data(request.utf8), to: fd) + try writeAll(requestData, to: fd) let data = try readToEOF(from: fd, timeoutSeconds: timeoutSeconds) return String(data: data, encoding: .utf8) ?? "" } From 3b1eeff27660a8c13c473dc6f1933bb2f3e619cf Mon Sep 17 00:00:00 2001 From: ppiankov <103106369+ppiankov@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:31:23 +0800 Subject: [PATCH 07/29] fix: harden proxy shape guard follow-ups --- README.md | 6 +- Sources/PastewatchCLI/LaunchCommand.swift | 4 +- Sources/PastewatchCore/ProxyServer.swift | 48 ++++++++++--- .../PastewatchTests/LaunchCommandTests.swift | 15 ++++ .../ProxyBodyShapeGuardTests.swift | 69 ++++++++++++++++--- .../ProxyRealServerTests.swift | 57 ++++++++++++++- docs/agent-integration.md | 2 +- docs/agent-setup.md | 2 +- 8 files changed, 174 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 9ffb297..e135156 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ pastewatch-cli setup claude-code pastewatch-cli launch claude ``` -The `launch` command starts the proxy, waits for it to be ready, sets `ANTHROPIC_BASE_URL`, and runs Claude Code. When the agent exits, the proxy stops. The proxy scans Anthropic-shaped API requests and redacts supported secrets before they leave your machine; other agents remain covered by hooks, MCP tools, and agent instructions. +The `launch` command starts the proxy, waits for it to be ready, sets `ANTHROPIC_BASE_URL`, and runs Claude Code. When the agent exits, the proxy stops. The proxy scans Anthropic-shaped API requests and redacts supported secrets before they leave your machine. Protect other agents with configured hooks, MCP tools, and agent instructions where available. **Important:** The setup step injects credential handling rules into your agent's `CLAUDE.md`. Without these rules, agents may echo passwords in shell output or store plaintext credentials in memory files — formats that bypass regex detection. The rules ensure agents use detectable keywords (`password=`, `secret=`) and never store raw values. See [docs/CLAUDE-SNIPPET.md](docs/CLAUDE-SNIPPET.md) for the full snippet. @@ -339,7 +339,7 @@ pastewatch-cli config check Every tool call an AI agent makes — including internal subprocesses you don't control — ends up as an HTTP request to the API. The proxy scans and redacts secrets from outbound requests before they leave your machine — including from subagents and tools that bypass the hooks. -> **Anthropic-shaped traffic.** The proxy redacts the Anthropic Messages API (`/v1/messages`, what Claude Code sends). It does **not** parse the OpenAI Chat Completions wire format, so it cannot redact OpenAI/Codex request bodies — rather than forward one unscanned and let you believe it was protected, the proxy **refuses** an unrecognized upstream body shape (HTTP 415). Cover Codex and other agents with the pastewatch hooks and MCP server instead. +> **Anthropic-shaped traffic.** The proxy redacts the Anthropic Messages API (`/v1/messages`, what Claude Code sends). It does **not** parse the OpenAI Chat Completions wire format, so it cannot redact OpenAI/Codex request bodies — rather than forward one unscanned and let you believe it was protected, the proxy **refuses** an unrecognized upstream body shape (HTTP 415). Protect Codex and other agents with configured pastewatch hooks and MCP tools where available. > **Single session.** The proxy handles one agent session at a time. Run a separate `pastewatch-cli proxy` instance (on a different port) for each concurrent session. @@ -371,7 +371,7 @@ pastewatch-cli launch claude pastewatch-cli launch --audit-log /tmp/pw.log -- claude --model opus ``` -Only `claude` is routed through the proxy today (the proxy redacts Anthropic-shaped traffic). Launching another agent through `launch` does **not** start or wire the proxy — the agent runs normally and stays covered by the pastewatch hooks and MCP server. +Only `claude` is routed through the proxy today (the proxy redacts Anthropic-shaped traffic). Launching another agent through `launch` does **not** start or wire the proxy. Protect non-routed agents with configured pastewatch hooks and MCP tools where available. Or start the proxy manually for more control: diff --git a/Sources/PastewatchCLI/LaunchCommand.swift b/Sources/PastewatchCLI/LaunchCommand.swift index 3fa448f..09cc252 100644 --- a/Sources/PastewatchCLI/LaunchCommand.swift +++ b/Sources/PastewatchCLI/LaunchCommand.swift @@ -150,8 +150,8 @@ struct Launch: ParsableCommand { return "warning: pastewatch proxy redaction is not wired for agent '\(agentBinary)'; " + "\(baseURLClause) " + "The proxy layer currently redacts Anthropic-shaped traffic only; 'claude' is " + - "the only agent routed through it. Codex and other agents remain covered by the " + - "pastewatch hooks and MCP server.\n" + "the only agent routed through it. Protect non-routed agents with configured " + + "pastewatch hooks and MCP tools where available.\n" } func run() throws { diff --git a/Sources/PastewatchCore/ProxyServer.swift b/Sources/PastewatchCore/ProxyServer.swift index b3a47f9..331f88b 100644 --- a/Sources/PastewatchCore/ProxyServer.swift +++ b/Sources/PastewatchCore/ProxyServer.swift @@ -1081,10 +1081,14 @@ public final class ProxyServer { func upstreamBodyShapeVerdict(method: String, path: String, bodyData: Data) -> BodyShapeVerdict { guard method.uppercased() == "POST" else { return .allow } let supportedAnthropicPath = isSupportedAnthropicPostPath(path) - // WO-422: non-JSON and non-UTF-8 bodies on foreign paths cannot be scanned by the - // Anthropic-only proxy. Supported paths still fall through to the WO-296 body scan. + // WO-425: malformed supported-path bodies are not safe passthrough. The downstream + // scanner only understands valid Anthropic JSON, so fail closed instead of forwarding + // an unscanned /v1/messages body. guard let jsonValue = try? JSONSerialization.jsonObject(with: bodyData, options: [.fragmentsAllowed]) else { - return supportedAnthropicPath ? .allow : .refuse("unsupported non-JSON POST body on \(path)") + let reason = supportedAnthropicPath + ? "malformed Anthropic JSON body on \(path)" + : "unsupported non-JSON POST body on \(path)" + return .refuse(reason) } // WO-422: parseable JSON arrays/scalars are JSON bodies, not opaque transport bytes. // Refuse malformed Anthropic JSON on supported paths and any JSON body on unsupported @@ -1099,9 +1103,13 @@ public final class ProxyServer { return .refuse("unsupported JSON POST body on \(path)") } let hasMessages = json["messages"] is [Any] - // Some Anthropic endpoints (for example count_tokens variants) may omit a messages - // array. Keep those allowed; malformed message arrays are refused below. - guard hasMessages else { return .allow } + // WO-425: /v1/messages must have a messages array so the body scanner has a supported + // shape. Count-token gateway variants are allowed to omit it. + guard hasMessages else { + return isSupportedAnthropicCountTokensPath(path) + ? .allow + : .refuse("malformed Anthropic messages body on \(path)") + } return isAnthropicMessagesShape(json) ? .allow : .refuse("non-Anthropic messages schema on \(path)") @@ -1114,14 +1122,27 @@ public final class ProxyServer { // Anthropic Messages endpoint and allowed, while /v1/chat/completions, /v1/responses, // and other non-Anthropic JSON POSTs remain refused. func isSupportedAnthropicPostPath(_ path: String) -> Bool { - let pathOnly = path.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false).first - .map(String.init) ?? path + let pathOnly = requestPathWithoutQuery(path) return pathOnly == "/v1/messages" || pathOnly == "/v1/messages/count_tokens" || pathOnly.hasSuffix("/v1/messages") || pathOnly.hasSuffix("/v1/messages/count_tokens") } + // WO-425: count-token endpoints share the supported Anthropic path family but can have + // request shapes that are not scanned as Messages tool-result bodies. + private func isSupportedAnthropicCountTokensPath(_ path: String) -> Bool { + let pathOnly = requestPathWithoutQuery(path) + return pathOnly == "/v1/messages/count_tokens" + || pathOnly.hasSuffix("/v1/messages/count_tokens") + } + + // WO-425: classify gateway paths without letting query strings affect endpoint shape. + private func requestPathWithoutQuery(_ path: String) -> String { + path.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false).first + .map(String.init) ?? path + } + // WO-408: positive identification of the Anthropic Messages schema. Permissive on // unknown keys (Anthropic adds fields over time — fail closed, never over-refuse a // genuine future field by being strict), strict on the three load-bearing invariants, @@ -1138,6 +1159,7 @@ public final class ProxyServer { // their presence is a high-signal marker that this is not an Anthropic body. if message["tool_calls"] != nil || message["function_call"] != nil { return false } if let content = message["content"] { + if content is NSNull { continue } // WO-427: JSON null is equivalent to absent content. if content is String { continue } guard let blocks = content as? [[String: Any]] else { return false } for block in blocks where !(block["type"] is String) { return false } @@ -1148,10 +1170,16 @@ public final class ProxyServer { // WO-422: a plain OpenAI chat body can otherwise look identical to a minimal // Anthropic Messages request once it is delivered to a /v1/messages-suffixed path. + // WO-428: keep the o-family matches dash-scoped and static so broad "o1*" prefixes + // do not classify arbitrary lookalikes as OpenAI-family models. + private static let knownForeignMessagesModelPrefixes = [ + "gpt-", "chatgpt-", "o1-", "o2-", "o3-", "o4-", "o5-", "o6-", + "gemini-", "mistral-", "llama-", "grok-", + ] + private func isKnownForeignMessagesModel(_ model: String) -> Bool { let lower = model.lowercased() - let foreignPrefixes = ["gpt-", "chatgpt-", "o1", "o3", "o4", "gemini-", "mistral-"] - return foreignPrefixes.contains { lower.hasPrefix($0) } + return Self.knownForeignMessagesModelPrefixes.contains { lower.hasPrefix($0) } } // WO-408/WO-413: per-request audit signal for a fail-closed refusal (verdict f6978df9). diff --git a/Tests/PastewatchTests/LaunchCommandTests.swift b/Tests/PastewatchTests/LaunchCommandTests.swift index 92a4c8f..2c1f417 100644 --- a/Tests/PastewatchTests/LaunchCommandTests.swift +++ b/Tests/PastewatchTests/LaunchCommandTests.swift @@ -80,6 +80,11 @@ final class LaunchCommandTests: XCTestCase { result.stderr.contains("redaction is not wired for agent 'codex'"), "codex should warn about missing proxy interposition; stderr: \(result.stderr)" ) + XCTAssertTrue( + result.stderr.contains("Protect non-routed agents with configured pastewatch hooks and MCP tools where available."), + "warning should avoid blanket coverage claims; stderr: \(result.stderr)" + ) + XCTAssertFalse(result.stderr.contains("remain covered"), "warning must not overclaim coverage: \(result.stderr)") XCTAssertTrue(result.stderr.hasSuffix("\n"), "warning should end with one newline: \(result.stderr.debugDescription)") XCTAssertFalse(result.stderr.hasSuffix("\n\n"), "warning should not end with a blank line") } @@ -100,6 +105,11 @@ final class LaunchCommandTests: XCTestCase { XCTAssertEqual(result.status, 0, "launch should preserve remote gateway; stderr: \(result.stderr)") XCTAssertTrue(result.stdout.contains("ANTHROPIC_BASE_URL=https://gateway.example.com/anthropic"), result.stdout) XCTAssertTrue(result.stderr.contains("preserving existing ANTHROPIC_BASE_URL"), result.stderr) + XCTAssertTrue( + result.stderr.contains("Protect non-routed agents with configured pastewatch hooks and MCP tools where available."), + "warning should avoid blanket coverage claims; stderr: \(result.stderr)" + ) + XCTAssertFalse(result.stderr.contains("remain covered"), "warning must not overclaim coverage: \(result.stderr)") XCTAssertFalse(result.stderr.contains("gateway.example.com"), "warning must not echo gateway URL values") XCTAssertFalse(result.stderr.contains("ANTHROPIC_BASE_URL not set"), result.stderr) XCTAssertFalse(result.stderr.hasSuffix("\n\n"), "warning should not end with a blank line") @@ -174,6 +184,11 @@ final class LaunchCommandTests: XCTestCase { result.stderr.contains("redaction is not wired for agent 'codex'"), "codex should warn about missing proxy interposition; stderr: \(result.stderr)" ) + XCTAssertTrue( + result.stderr.contains("Protect non-routed agents with configured pastewatch hooks and MCP tools where available."), + "warning should avoid blanket coverage claims; stderr: \(result.stderr)" + ) + XCTAssertFalse(result.stderr.contains("remain covered"), "warning must not overclaim coverage: \(result.stderr)") } // WO-137: seam-unavailable probe fallback must not reach startup sweep or proxy. diff --git a/Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift b/Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift index 046f998..f298a7d 100644 --- a/Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift +++ b/Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift @@ -44,14 +44,29 @@ final class ProxyBodyShapeGuardTests: XCTestCase { XCTAssertEqual(verdict("POST", "/v1/messages", body), .allow) } - func testMessagesEndpointWithoutMessagesArrayAllowed() { - // Some Anthropic endpoints (e.g. count_tokens variants) may omit a messages array. + func testContentNullAllowed() { + // WO-427: JSON null maps to NSNull and is equivalent to absent content. let body = """ - {"model":"claude-3","system":"be terse"} + {"model":"claude-3","messages":[{"role":"assistant","content":null}]} + """ + XCTAssertEqual(verdict("POST", "/v1/messages", body), .allow) + } + + func testMixedNullAndStringContentAllowed() { + let body = """ + {"model":"claude-3","messages":[{"role":"assistant","content":null},{"role":"user","content":"continue"}]} """ XCTAssertEqual(verdict("POST", "/v1/messages", body), .allow) } + func testCountTokensEndpointWithoutMessagesArrayAllowed() { + // Some Anthropic count_tokens variants may omit a messages array. + let body = """ + {"model":"claude-3","system":"be terse"} + """ + XCTAssertEqual(verdict("POST", "/v1/messages/count_tokens", body), .allow) + } + // MARK: - Foreign shapes are refused func testOpenAIChatCompletionsRefusedLayerA() { @@ -120,6 +135,28 @@ final class ProxyBodyShapeGuardTests: XCTestCase { ) } + func testOFamilyModelDashPrefixesRefusedLayerB() { + // WO-428: future OpenAI o-family dash-prefixed models must fail closed. + for model in ["o1-mini", "o2-mini", "o3-mini", "o4-mini", "o5-mini", "o6-mini"] { + let body = """ + {"model":"\(model)","messages":[{"role":"user","content":"hello"}]} + """ + XCTAssertEqual( + verdict("POST", "/v1/messages", body), + .refuse("non-Anthropic messages schema on /v1/messages"), + "expected \(model) to be refused" + ) + } + } + + func testOFamilyBareLookalikeDoesNotMatchForeignPrefix() { + // WO-428: avoid the old broad "o1*" match; unknown future fields stay permissive. + let body = """ + {"model":"o1fast","messages":[{"role":"user","content":"hello"}]} + """ + XCTAssertEqual(verdict("POST", "/v1/messages", body), .allow) + } + func testTopLevelJSONArrayRefusedOnUnsupportedPath() { // WO-422: JSON arrays are parseable JSON bodies, not opaque non-JSON bytes. let body = """ @@ -149,9 +186,12 @@ final class ProxyBodyShapeGuardTests: XCTestCase { ) } - func testNonUTF8MessagesPathAllowedForDownstreamScan() { + func testNonUTF8MessagesPathRefused() { let data = Data([0xff, 0xfe, 0xfd]) - XCTAssertEqual(verdict("POST", "/v1/messages", bodyData: data), .allow) + XCTAssertEqual( + verdict("POST", "/v1/messages", bodyData: data), + .refuse("malformed Anthropic JSON body on /v1/messages") + ) } // MARK: - Legitimate non-message traffic is NOT broken @@ -184,8 +224,11 @@ final class ProxyBodyShapeGuardTests: XCTestCase { XCTAssertEqual(verdict("POST", "/v1/llm-gateway/v1/messages/count_tokens", body), .allow) } - func testNonJSONBodyOnMessagesPathAllowedForDownstreamScan() { - XCTAssertEqual(verdict("POST", "/v1/messages", "not json at all"), .allow) + func testNonJSONBodyOnMessagesPathRefused() { + XCTAssertEqual( + verdict("POST", "/v1/messages", "not json at all"), + .refuse("malformed Anthropic JSON body on /v1/messages") + ) } func testNonJSONBodyOnUnsupportedPathRefused() { @@ -195,8 +238,11 @@ final class ProxyBodyShapeGuardTests: XCTestCase { ) } - func testEmptyBodyAllowed() { - XCTAssertEqual(verdict("POST", "/v1/messages", ""), .allow) + func testEmptyMessagesBodyRefused() { + XCTAssertEqual( + verdict("POST", "/v1/messages", ""), + .refuse("malformed Anthropic JSON body on /v1/messages") + ) } func testGetRequestAllowed() { @@ -247,6 +293,11 @@ final class ProxyBodyShapeGuardTests: XCTestCase { XCTAssertFalse(server().isAnthropicMessagesShape(json)) } + func testShapeRejectsContentBlockWithoutType() { + let json: [String: Any] = ["messages": [["role": "user", "content": [["text": "hi"]]]]] + XCTAssertFalse(server().isAnthropicMessagesShape(json)) + } + func testShapeAcceptsUnknownFutureKeys() { // Permissive on unknown keys — Anthropic adds fields over time. let json: [String: Any] = [ diff --git a/Tests/PastewatchTests/ProxyRealServerTests.swift b/Tests/PastewatchTests/ProxyRealServerTests.swift index 9cd8197..b658749 100644 --- a/Tests/PastewatchTests/ProxyRealServerTests.swift +++ b/Tests/PastewatchTests/ProxyRealServerTests.swift @@ -32,7 +32,7 @@ final class ProxyRealServerTests: XCTestCase { let response = try TCPTestSocket.roundTrip( port: proxyPort, - request: TCPTestSocket.postRequest(path: "/v1/messages"), + request: TCPTestSocket.postRequest(path: "/v1/messages", body: TCPTestSocket.validAnthropicMessagesBody), timeoutSeconds: 10 ) @@ -248,6 +248,55 @@ final class ProxyRealServerTests: XCTestCase { XCTAssertEqual(upstream.requestCount, 0, "non-UTF-8 foreign body must not reach upstream; \(diagnostic)") } + // WO-425: malformed supported-path bodies fail closed instead of forwarding unscanned bytes. + func testNonJSONMessagesPathRefusedBeforeUpstream() throws { + let upstream = try StubHTTPServer { _ in + StubHTTPResponse(status: 200, headers: [:], body: Data(#"{"ok":true}"#.utf8)) + } + try upstream.start() + defer { upstream.stop() } + + let proxyPort = try TCPTestSocket.reserveLoopbackPort() + let proxy = ProxyServer(port: proxyPort, upstream: URL(string: "http://127.0.0.1:\(upstream.port)")!) + let runningProxy = RunningProxy(server: proxy) + try runningProxy.start() + defer { runningProxy.stop() } + + let response = try TCPTestSocket.roundTrip( + port: proxyPort, + request: TCPTestSocket.postRequest(path: "/v1/messages", body: "not json password=messages-hunter2"), + timeoutSeconds: 10 + ) + let diagnostic = TCPTestSocket.describeResponse(response) + " upstream_requests=\(upstream.requestCount)" + XCTAssertTrue(response.contains("HTTP/1.1 415 Unsupported Media Type"), diagnostic) + XCTAssertEqual(upstream.requestCount, 0, "malformed messages body must not reach upstream; \(diagnostic)") + } + + // WO-425: a JSON object without the Messages scan shape is not safe passthrough. + func testMessagesPathWithoutMessagesArrayRefusedBeforeUpstream() throws { + let upstream = try StubHTTPServer { _ in + StubHTTPResponse(status: 200, headers: [:], body: Data(#"{"ok":true}"#.utf8)) + } + try upstream.start() + defer { upstream.stop() } + + let proxyPort = try TCPTestSocket.reserveLoopbackPort() + let proxy = ProxyServer(port: proxyPort, upstream: URL(string: "http://127.0.0.1:\(upstream.port)")!) + let runningProxy = RunningProxy(server: proxy) + try runningProxy.start() + defer { runningProxy.stop() } + + let body = #"{"model":"claude-3","input":"password=messages-hunter2"}"# + let response = try TCPTestSocket.roundTrip( + port: proxyPort, + request: TCPTestSocket.postRequest(path: "/v1/messages", body: body), + timeoutSeconds: 10 + ) + let diagnostic = TCPTestSocket.describeResponse(response) + " upstream_requests=\(upstream.requestCount)" + XCTAssertTrue(response.contains("HTTP/1.1 415 Unsupported Media Type"), diagnostic) + XCTAssertEqual(upstream.requestCount, 0, "non-message JSON body must not reach upstream; \(diagnostic)") + } + // WO-412: unsupported JSON POST bodies without messages arrays are also refused. func testUnsupportedJSONPostWithoutMessagesRefusedBeforeUpstream() throws { let upstream = try StubHTTPServer { _ in @@ -412,7 +461,7 @@ final class ProxyRealServerTests: XCTestCase { runDetached { let response = (try? TCPTestSocket.roundTrip( port: proxyPort, - request: TCPTestSocket.postRequest(path: "/v1/messages"), + request: TCPTestSocket.postRequest(path: "/v1/messages", body: TCPTestSocket.validAnthropicMessagesBody), timeoutSeconds: 5 )) ?? "" responseLock.lock() @@ -431,7 +480,7 @@ final class ProxyRealServerTests: XCTestCase { let rejected = try TCPTestSocket.roundTrip( port: proxyPort, - request: TCPTestSocket.postRequest(path: "/v1/messages"), + request: TCPTestSocket.postRequest(path: "/v1/messages", body: TCPTestSocket.validAnthropicMessagesBody), timeoutSeconds: 3 ) XCTAssertTrue(rejected.contains("HTTP/1.1 503 Service Unavailable")) @@ -808,6 +857,8 @@ private final class StubHTTPServer { } private enum TCPTestSocket { + static let validAnthropicMessagesBody = #"{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}"# + static func postRequest(path: String, body: String = "{}") -> String { let bodyData = Data(body.utf8) return """ diff --git a/docs/agent-integration.md b/docs/agent-integration.md index 36fa8c1..f0c4209 100644 --- a/docs/agent-integration.md +++ b/docs/agent-integration.md @@ -27,7 +27,7 @@ Shell alias for zero-friction protected sessions: alias claude='pastewatch-cli launch claude' ``` -The proxy catches supported Claude Code traffic that hooks and MCP may miss. MCP tools and hooks below add defense in depth and are the primary integration path for agents not routed through `ANTHROPIC_BASE_URL`. +The proxy catches supported Claude Code traffic that hooks and MCP may miss. Use the MCP tools and hooks below as defense in depth, and for agents not routed through `ANTHROPIC_BASE_URL` where those integrations are available. --- diff --git a/docs/agent-setup.md b/docs/agent-setup.md index 4aeffdd..5b79b4f 100644 --- a/docs/agent-setup.md +++ b/docs/agent-setup.md @@ -28,7 +28,7 @@ For persistent setup, add a shell alias: alias claude='pastewatch-cli launch claude' ``` -The proxy is Layer 0 for Claude Code — it catches Anthropic-shaped requests that bypass hooks, MCP tools, and agent instructions. MCP and hooks below add defense in depth and are the primary integration path for agents that are not routed through the proxy. +The proxy is Layer 0 for Claude Code — it catches Anthropic-shaped requests that bypass hooks, MCP tools, and agent instructions. Use the MCP tools and hooks below as defense in depth, and for agents not routed through the proxy where those integrations are available. --- From eafa2cd6f5252f4ae9c6b17c18005c3fcc8fe6c6 Mon Sep 17 00:00:00 2001 From: ppiankov <103106369+ppiankov@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:27:57 +0800 Subject: [PATCH 08/29] fix: harden proxy launch edge cases --- README.md | 6 +- Sources/PastewatchCLI/LaunchCommand.swift | 57 ++++++++++-- Sources/PastewatchCore/ProxyServer.swift | 91 +++++++++++++++++-- .../PastewatchTests/LaunchCommandTests.swift | 27 ++++++ .../ProxyBodyShapeGuardTests.swift | 91 ++++++++++++++++++- .../ProxyRealServerTests.swift | 80 ++++++++++++++++ 6 files changed, 329 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index e135156..5f5c50e 100644 --- a/README.md +++ b/README.md @@ -339,7 +339,7 @@ pastewatch-cli config check Every tool call an AI agent makes — including internal subprocesses you don't control — ends up as an HTTP request to the API. The proxy scans and redacts secrets from outbound requests before they leave your machine — including from subagents and tools that bypass the hooks. -> **Anthropic-shaped traffic.** The proxy redacts the Anthropic Messages API (`/v1/messages`, what Claude Code sends). It does **not** parse the OpenAI Chat Completions wire format, so it cannot redact OpenAI/Codex request bodies — rather than forward one unscanned and let you believe it was protected, the proxy **refuses** an unrecognized upstream body shape (HTTP 415). Protect Codex and other agents with configured pastewatch hooks and MCP tools where available. +> **Anthropic-shaped traffic.** The proxy redacts the Anthropic Messages API (`/v1/messages`, what Claude Code sends) and Message Batch create requests (`/v1/messages/batches`). It does **not** parse the OpenAI Chat Completions wire format, so it cannot redact OpenAI/Codex request bodies — rather than forward one unscanned and let you believe it was protected, the proxy **refuses** an unrecognized upstream body shape (HTTP 415). Model names are guarded by a known foreign-family denylist, not a positive Anthropic allowlist, so future Anthropic or gateway-rewritten model aliases are accepted only on supported Anthropic paths. Protect Codex and other agents with configured pastewatch hooks and MCP tools where available. > **Single session.** The proxy handles one agent session at a time. Run a separate `pastewatch-cli proxy` instance (on a different port) for each concurrent session. @@ -371,7 +371,7 @@ pastewatch-cli launch claude pastewatch-cli launch --audit-log /tmp/pw.log -- claude --model opus ``` -Only `claude` is routed through the proxy today (the proxy redacts Anthropic-shaped traffic). Launching another agent through `launch` does **not** start or wire the proxy. Protect non-routed agents with configured pastewatch hooks and MCP tools where available. +Only `claude` is routed through the proxy today (the proxy redacts Anthropic-shaped traffic). Launching another agent through `launch` does **not** start or wire the proxy. `--audit-log` is rejected for non-routed agents because no proxy audit stream exists for those launches. Protect non-routed agents with configured pastewatch hooks and MCP tools where available. Or start the proxy manually for more control: @@ -822,7 +822,7 @@ Define additional patterns in a JSON file: ### Agent Safety Matrix -The API proxy (Layer 0) redacts **Anthropic-shaped** (`/v1/messages`) traffic from agents that expose an API endpoint override; it refuses unrecognized upstream body shapes (HTTP 415) rather than forward them unscanned, so it does not redact OpenAI/Gemini-shaped agents. Rows relying on "Proxy" are protected only for Anthropic-shaped traffic; rows marked proxy not applicable are limited to their listed local layers. Hooks and MCP add defense in depth and are the primary coverage for non-Anthropic-shaped agents. +The API proxy (Layer 0) redacts supported **Anthropic-shaped** traffic (`/v1/messages` and `/v1/messages/batches`) from agents that expose an API endpoint override; it refuses unrecognized upstream body shapes (HTTP 415) rather than forward them unscanned, so it does not redact OpenAI/Gemini-shaped agents. Rows relying on "Proxy" are protected only for Anthropic-shaped traffic; rows marked proxy not applicable are limited to their listed local layers. Hooks and MCP add defense in depth and are the primary coverage for non-Anthropic-shaped agents. | Agent | Protection | Hooks | MCP | Setup | |-------|-----------|-------|-----|-------| diff --git a/Sources/PastewatchCLI/LaunchCommand.swift b/Sources/PastewatchCLI/LaunchCommand.swift index 09cc252..db4c440 100644 --- a/Sources/PastewatchCLI/LaunchCommand.swift +++ b/Sources/PastewatchCLI/LaunchCommand.swift @@ -34,7 +34,7 @@ struct Launch: ParsableCommand { abstract: "Start the proxy and launch an agent through it in one command", discussion: """ Starts the pastewatch proxy in the background, waits for it to be ready, - then launches your agent. The proxy redacts Anthropic-shaped (/v1/messages) + then launches your agent. The proxy redacts supported Anthropic-shaped traffic, so ANTHROPIC_BASE_URL is pointed at it only for 'claude'; other agents launch without proxy interposition (a warning is printed) and stay covered by the pastewatch hooks and MCP server. When the agent exits, the @@ -132,15 +132,50 @@ struct Launch: ParsableCommand { static func isLocalIPv4AnthropicBaseURLHost(_ host: String) -> Bool { let parts = host.split(separator: ".", omittingEmptySubsequences: false) - guard parts.count == 4 else { + guard (1...4).contains(parts.count) else { return false } - let octets = parts.compactMap { part -> Int? in - guard let value = Int(part), (0...255).contains(value) else { return nil } - return value + let numbers = parts.compactMap { parseIPv4Component(String($0)) } + guard numbers.count == parts.count else { return false } + + let address: UInt32 + switch numbers.count { + case 1: + address = numbers[0] + case 2: + guard numbers[0] <= 0xff, numbers[1] <= 0x00ff_ffff else { return false } + address = (numbers[0] << 24) | numbers[1] + case 3: + guard numbers[0] <= 0xff, numbers[1] <= 0xff, numbers[2] <= 0xffff else { return false } + address = (numbers[0] << 24) | (numbers[1] << 16) | numbers[2] + case 4: + guard numbers.allSatisfy({ $0 <= 0xff }) else { return false } + address = (numbers[0] << 24) | (numbers[1] << 16) | (numbers[2] << 8) | numbers[3] + default: + return false } - guard octets.count == 4 else { return false } - return octets[0] == 127 || octets == [0, 0, 0, 0] + + return (address >> 24) == 127 || address == 0 + } + + // WO-423: stale local proxy URLs may use inet_aton-style abbreviated or octal IPv4. + private static func parseIPv4Component(_ raw: String) -> UInt32? { + guard !raw.isEmpty else { return nil } + let lower = raw.lowercased() + let radix: Int + let digits: String + if lower.hasPrefix("0x") { + radix = 16 + digits = String(lower.dropFirst(2)) + } else if lower.count > 1 && lower.hasPrefix("0") { + radix = 8 + digits = lower + } else { + radix = 10 + digits = lower + } + guard !digits.isEmpty else { return nil } + return UInt32(digits, radix: radix) } static func nonRoutedWarning(agentBinary: String, baseURLState: String) -> String { @@ -165,6 +200,14 @@ struct Launch: ParsableCommand { let agentBinary = (command[0] as NSString).lastPathComponent if !Launch.isProxyRoutedAgent(agentBinary) { + // WO-434: proxy audit logs exist only when launch starts the proxy. + if auditLog != nil { + let message = "error: --audit-log is not supported for non-routed agent '\(agentBinary)'; " + + "proxy audit logging currently requires 'claude'. Remove --audit-log or " + + "run 'pastewatch-cli proxy --audit-log' separately.\n" + FileHandle.standardError.write(Data(message.utf8)) + throw ExitCode(rawValue: 1) + } // WO-414: do not start an unused proxy for agents whose traffic is not routed. Launch.configureProxyEnv(agentBinary: agentBinary, port: port) if !quiet { diff --git a/Sources/PastewatchCore/ProxyServer.swift b/Sources/PastewatchCore/ProxyServer.swift index 331f88b..40eeef5 100644 --- a/Sources/PastewatchCore/ProxyServer.swift +++ b/Sources/PastewatchCore/ProxyServer.swift @@ -683,12 +683,11 @@ public final class ProxyServer { return } - // WO-408: fail closed on unsupported upstream body shapes. The scan path below - // only redacts POST /v1/messages (Anthropic shape); any other POST body — notably - // OpenAI /v1/chat/completions — would otherwise be forwarded UNSCANNED, a silent + // WO-408/WO-432: fail closed on unsupported upstream body shapes. The scan path + // below only redacts supported Anthropic-shaped POSTs; any other POST body - notably + // OpenAI /v1/chat/completions - would otherwise be forwarded UNSCANNED, a silent // no-op that makes users believe traffic was redacted when it was not. Refuse an - // unrecognized shape rather than forward it. Runs for ALL POSTs, so it sits before - // the /v1/messages scan branch. + // unrecognized shape rather than forward it. Runs for ALL POSTs before scanning. if case .refuse(let reason) = upstreamBodyShapeVerdict( method: parsed.method, path: parsed.path, bodyData: parsed.bodyData ) { @@ -697,7 +696,7 @@ public final class ProxyServer { return } - // Only scan supported Anthropic message POSTs (the endpoints that carry tool results) + // Only scan supported Anthropic-shaped POSTs (the endpoints that carry tool results). var processedBody = parsed.body var processedBodyData = parsed.bodyData var redactionCount = 0 @@ -1041,13 +1040,20 @@ public final class ProxyServer { var types: [String] = [] var advisoryCount = 0 var advisoryTypes: [String] = [] - let processed = redactContentArray( + let processedMessages = redactContentArray( json, redacted: &redacted, types: &types, advisoryCount: &advisoryCount, advisoryTypes: &advisoryTypes ) + let processed = redactBatchRequestMessages( + processedMessages, + redacted: &redacted, + types: &types, + advisoryCount: &advisoryCount, + advisoryTypes: &advisoryTypes + ) guard redacted > 0 else { return ScanResult( @@ -1081,6 +1087,9 @@ public final class ProxyServer { func upstreamBodyShapeVerdict(method: String, path: String, bodyData: Data) -> BodyShapeVerdict { guard method.uppercased() == "POST" else { return .allow } let supportedAnthropicPath = isSupportedAnthropicPostPath(path) + // WO-433: an empty POST body carries no unscanned JSON shape or credential-bearing + // request body, so it should reach upstream instead of being refused as malformed. + guard !bodyData.isEmpty else { return .allow } // WO-425: malformed supported-path bodies are not safe passthrough. The downstream // scanner only understands valid Anthropic JSON, so fail closed instead of forwarding // an unscanned /v1/messages body. @@ -1102,6 +1111,12 @@ public final class ProxyServer { guard supportedAnthropicPath else { return .refuse("unsupported JSON POST body on \(path)") } + if isSupportedAnthropicBatchesPath(path) { + // WO-432: batch requests carry Messages params under requests[].params. + return isAnthropicBatchShape(json) + ? .allow + : .refuse("malformed Anthropic batch body on \(path)") + } let hasMessages = json["messages"] is [Any] // WO-425: /v1/messages must have a messages array so the body scanner has a supported // shape. Count-token gateway variants are allowed to omit it. @@ -1125,8 +1140,10 @@ public final class ProxyServer { let pathOnly = requestPathWithoutQuery(path) return pathOnly == "/v1/messages" || pathOnly == "/v1/messages/count_tokens" + || pathOnly == "/v1/messages/batches" || pathOnly.hasSuffix("/v1/messages") || pathOnly.hasSuffix("/v1/messages/count_tokens") + || pathOnly.hasSuffix("/v1/messages/batches") } // WO-425: count-token endpoints share the supported Anthropic path family but can have @@ -1137,6 +1154,14 @@ public final class ProxyServer { || pathOnly.hasSuffix("/v1/messages/count_tokens") } + // WO-432: the Message Batches create endpoint is a supported Anthropic request shape, + // while batch-result retrieval is not a JSON POST body the request scanner understands. + private func isSupportedAnthropicBatchesPath(_ path: String) -> Bool { + let pathOnly = requestPathWithoutQuery(path) + return pathOnly == "/v1/messages/batches" + || pathOnly.hasSuffix("/v1/messages/batches") + } + // WO-425: classify gateway paths without letting query strings affect endpoint shape. private func requestPathWithoutQuery(_ path: String) -> String { path.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false).first @@ -1150,6 +1175,9 @@ public final class ProxyServer { // chat/completions body. func isAnthropicMessagesShape(_ json: [String: Any]) -> Bool { guard let messages = json["messages"] as? [[String: Any]] else { return false } + // WO-430: keep model classification as a foreign-family denylist, not a positive + // Anthropic allowlist. Gateways and future Anthropic releases may rewrite model + // names; supported path plus Messages shape remain the safety boundary. if let model = json["model"] as? String, isKnownForeignMessagesModel(model) { return false } @@ -1157,7 +1185,10 @@ public final class ProxyServer { guard message["role"] is String else { return false } // OpenAI /v1/chat/completions carries tool_calls / function_call on messages; // their presence is a high-signal marker that this is not an Anthropic body. - if message["tool_calls"] != nil || message["function_call"] != nil { return false } + if hasNonNullJSONField("tool_calls", in: message) + || hasNonNullJSONField("function_call", in: message) { + return false + } if let content = message["content"] { if content is NSNull { continue } // WO-427: JSON null is equivalent to absent content. if content is String { continue } @@ -1168,6 +1199,21 @@ public final class ProxyServer { return true } + // WO-432: Anthropic Message Batches wrap normal Messages params in requests[].params. + func isAnthropicBatchShape(_ json: [String: Any]) -> Bool { + guard let requests = json["requests"] as? [[String: Any]] else { return false } + return requests.allSatisfy { request in + guard let params = request["params"] as? [String: Any] else { return false } + return isAnthropicMessagesShape(params) + } + } + + // WO-431: JSON null means the OpenAI-only key is explicitly empty, not present. + private func hasNonNullJSONField(_ key: String, in json: [String: Any]) -> Bool { + guard let value = json[key] else { return false } + return !(value is NSNull) + } + // WO-422: a plain OpenAI chat body can otherwise look identical to a minimal // Anthropic Messages request once it is delivered to a /v1/messages-suffixed path. // WO-428: keep the o-family matches dash-scoped and static so broad "o1*" prefixes @@ -1311,6 +1357,35 @@ public final class ProxyServer { return result } + // WO-432: scan nested Message Batch params with the same certainty gate used for + // ordinary /v1/messages bodies. + private func redactBatchRequestMessages( + _ json: [String: Any], + redacted: inout Int, + types: inout [String], + advisoryCount: inout Int, + advisoryTypes: inout [String] + ) -> [String: Any] { + var result = json + guard var requests = json["requests"] as? [[String: Any]] else { + return result + } + + for index in requests.indices { + guard let params = requests[index]["params"] as? [String: Any] else { continue } + requests[index]["params"] = redactContentArray( + params, + redacted: &redacted, + types: &types, + advisoryCount: &advisoryCount, + advisoryTypes: &advisoryTypes + ) + } + + result["requests"] = requests + return result + } + // MARK: - Streaming helpers /// True if the request JSON body contains "stream":true. diff --git a/Tests/PastewatchTests/LaunchCommandTests.swift b/Tests/PastewatchTests/LaunchCommandTests.swift index 2c1f417..06aa897 100644 --- a/Tests/PastewatchTests/LaunchCommandTests.swift +++ b/Tests/PastewatchTests/LaunchCommandTests.swift @@ -134,6 +134,27 @@ final class LaunchCommandTests: XCTestCase { XCTAssertFalse(result.stderr.hasSuffix("\n\n"), "warning should not end with a blank line") } + // WO-434: audit logs are a proxy artifact; non-routed launches must fail explicitly. + func testLaunchNonAnthropicAgentWithAuditLogFailsBeforeRunningAgent() throws { + let fixture = try makeLaunchFixture() + let agent = try writeEnvEchoAgent(named: "codex", in: fixture.cwd) + let auditPath = fixture.cwd.appendingPathComponent("pastewatch-audit.log") + + let result = try runCLIProcess( + arguments: [ + "launch", "--quiet", "--no-startup-sweep", "--port", "65435", + "--audit-log", auditPath.path, "--", agent.path, + ], + cwd: fixture.cwd, + environment: fixture.environment + ) + + XCTAssertEqual(result.status, 1, "non-routed --audit-log must fail; stderr: \(result.stderr)") + XCTAssertEqual(result.stdout, "", "agent should not run when audit logging cannot be honored") + XCTAssertTrue(result.stderr.contains("--audit-log is not supported for non-routed agent 'codex'"), result.stderr) + XCTAssertFalse(FileManager.default.fileExists(atPath: auditPath.path), "proxy audit log should not be created") + } + // WO-423: classify stale local proxy URL spellings directly, not only via process launch. func testShouldClearExistingAnthropicBaseURLLoopbackTable() { let shouldClear = [ @@ -142,7 +163,12 @@ final class LaunchCommandTests: XCTestCase { "http://127.0.0.1:8443", "http://127.0.0.2:8443", "http://127.255.255.255:8443", + "http://127.1:8443", + "http://127.0.1:8443", + "http://0177.0.0.1:8443", + "0177.0.0.1:8443", "http://0.0.0.0:8443", + "http://0:8443", "http://localhost:8443", "http://[::1]:8443", "http://[::ffff:127.0.0.1]:8443", @@ -152,6 +178,7 @@ final class LaunchCommandTests: XCTestCase { let shouldPreserve = [ "https://gateway.example.com/anthropic", "http://127.0.0.1.evil.com:8443", + "http://08.0.0.1:8443", "https://api.anthropic.com", "gateway.example.com/anthropic", ] diff --git a/Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift b/Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift index f298a7d..062bcc6 100644 --- a/Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift +++ b/Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift @@ -59,6 +59,14 @@ final class ProxyBodyShapeGuardTests: XCTestCase { XCTAssertEqual(verdict("POST", "/v1/messages", body), .allow) } + func testOpenAIOnlyNullFieldsAllowed() { + // WO-431: JSON null does not make the OpenAI-only keys a foreign-shape signal. + let body = """ + {"model":"claude-3","messages":[{"role":"user","content":"hi","tool_calls":null,"function_call":null}]} + """ + XCTAssertEqual(verdict("POST", "/v1/messages", body), .allow) + } + func testCountTokensEndpointWithoutMessagesArrayAllowed() { // Some Anthropic count_tokens variants may omit a messages array. let body = """ @@ -67,6 +75,23 @@ final class ProxyBodyShapeGuardTests: XCTestCase { XCTAssertEqual(verdict("POST", "/v1/messages/count_tokens", body), .allow) } + func testMessageBatchesEndpointAllowed() { + // WO-432: Message Batches wrap normal Messages params under requests[].params. + let body = """ + {"requests":[{"custom_id":"r1","params":{"model":"claude-3","messages":[{"role":"user","content":"hi"}]}}]} + """ + XCTAssertEqual(verdict("POST", "/v1/messages/batches", body), .allow) + XCTAssertEqual(verdict("POST", "/gateway/v1/messages/batches?beta=true", body), .allow) + } + + func testUnknownModelAliasAllowedOnMessagesPath() { + // WO-430: model identity is a foreign-family denylist, not a fragile Anthropic allowlist. + let body = """ + {"model":"company-gateway-claude-alias","messages":[{"role":"user","content":"hi"}]} + """ + XCTAssertEqual(verdict("POST", "/v1/messages", body), .allow) + } + // MARK: - Foreign shapes are refused func testOpenAIChatCompletionsRefusedLayerA() { @@ -123,6 +148,36 @@ final class ProxyBodyShapeGuardTests: XCTestCase { ) } + func testMessageBatchWithForeignParamsRefused() { + let body = """ + {"requests":[{"custom_id":"r1","params":{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}}]} + """ + XCTAssertEqual( + verdict("POST", "/v1/messages/batches", body), + .refuse("malformed Anthropic batch body on /v1/messages/batches") + ) + } + + func testMalformedMessageBatchRefused() { + let body = """ + {"requests":[{"custom_id":"r1","params":{"model":"claude-3","input":"hi"}}]} + """ + XCTAssertEqual( + verdict("POST", "/v1/messages/batches", body), + .refuse("malformed Anthropic batch body on /v1/messages/batches") + ) + } + + func testBatchResultsPathUnsupported() { + let body = """ + {"requests":[]} + """ + XCTAssertEqual( + verdict("POST", "/v1/messages/batches/results", body), + .refuse("unsupported JSON POST body on /v1/messages/batches/results") + ) + } + func testPlainOpenAIShapeOnMessagesEndpointRefusedLayerB() { // WO-422: model markers keep plain OpenAI bodies from passing as tiny // Anthropic requests when a gateway misroutes them to /v1/messages. @@ -238,11 +293,12 @@ final class ProxyBodyShapeGuardTests: XCTestCase { ) } - func testEmptyMessagesBodyRefused() { - XCTAssertEqual( - verdict("POST", "/v1/messages", ""), - .refuse("malformed Anthropic JSON body on /v1/messages") - ) + func testEmptyMessagesBodyAllowed() { + XCTAssertEqual(verdict("POST", "/v1/messages", ""), .allow) + } + + func testEmptyUnsupportedPostBodyAllowed() { + XCTAssertEqual(verdict("POST", "/v1/anything", ""), .allow) } func testGetRequestAllowed() { @@ -261,15 +317,18 @@ final class ProxyBodyShapeGuardTests: XCTestCase { "/v1/messages", "/v1/messages?beta=true", "/v1/messages/count_tokens", + "/v1/messages/batches", "/v1/llm-gateway/v1/messages", "/anthropic/v1/messages?beta=true", "/v1/llm-gateway/v1/messages/count_tokens", + "/v1/llm-gateway/v1/messages/batches?beta=true", ] let refused = [ "/v1/chat/completions", "/v1/responses", "/v1/messages_extra", "/v1/messages/extra", + "/v1/messages/batches/results", "/v1beta/models/gemini:generateContent", ] @@ -288,6 +347,28 @@ final class ProxyBodyShapeGuardTests: XCTestCase { XCTAssertFalse(server().isAnthropicMessagesShape(json)) } + func testShapeAcceptsNullOpenAIOnlyFields() { + let json: [String: Any] = [ + "messages": [["role": "assistant", "tool_calls": NSNull(), "function_call": NSNull()]] + ] + XCTAssertTrue(server().isAnthropicMessagesShape(json)) + } + + func testBatchShapeAcceptsNestedMessagesParams() { + let json: [String: Any] = [ + "requests": [ + [ + "custom_id": "r1", + "params": [ + "model": "claude-3", + "messages": [["role": "user", "content": "hi"]], + ], + ], + ], + ] + XCTAssertTrue(server().isAnthropicBatchShape(json)) + } + func testShapeRejectsMissingRole() { let json: [String: Any] = ["messages": [["content": "hi"]]] XCTAssertFalse(server().isAnthropicMessagesShape(json)) diff --git a/Tests/PastewatchTests/ProxyRealServerTests.swift b/Tests/PastewatchTests/ProxyRealServerTests.swift index b658749..b055c9f 100644 --- a/Tests/PastewatchTests/ProxyRealServerTests.swift +++ b/Tests/PastewatchTests/ProxyRealServerTests.swift @@ -91,6 +91,86 @@ final class ProxyRealServerTests: XCTestCase { XCTAssertTrue(forwarded.contains(""), "upstream request missing redaction placeholder") } + // WO-432: Message Batch params pass the shape guard and use the same request redactor. + func testAnthropicMessageBatchRedactedThroughShapeGuardBeforeUpstream() throws { + let requestLock = NSLock() + var upstreamRequest = "" + let upstream = try StubHTTPServer { request in + requestLock.lock() + upstreamRequest = String(data: request, encoding: .utf8) ?? "" + requestLock.unlock() + return StubHTTPResponse( + status: 200, + headers: ["Content-Type": "application/json"], + body: Data(#"{"id":"batch_1"}"#.utf8) + ) + } + try upstream.start() + defer { upstream.stop() } + + let proxyPort = try TCPTestSocket.reserveLoopbackPort() + let proxy = ProxyServer( + port: proxyPort, + upstream: URL(string: "http://127.0.0.1:\(upstream.port)")! + ) + let runningProxy = RunningProxy(server: proxy) + try runningProxy.start() + defer { runningProxy.stop() } + + let credential = "password=batch-hunter2" + let body = """ + {"requests":[{"custom_id":"r1","params":{"model":"claude-3","messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"\(credential)"}]}]}}]} + """ + let response = try TCPTestSocket.roundTrip( + port: proxyPort, + request: TCPTestSocket.postRequest(path: "/v1/messages/batches", body: body), + timeoutSeconds: 10 + ) + + requestLock.lock() + let forwarded = upstreamRequest + requestLock.unlock() + let diagnostic = TCPTestSocket.describeResponse(response) + " upstream_requests=\(upstream.requestCount)" + XCTAssertTrue(response.contains("HTTP/1.1 200 OK"), diagnostic) + XCTAssertEqual(upstream.requestCount, 1, diagnostic) + XCTAssertTrue(forwarded.contains("POST /v1/messages/batches HTTP/1.1"), forwarded) + XCTAssertFalse(forwarded.contains(credential), "upstream batch request leaked raw credential") + XCTAssertTrue(forwarded.contains(""), "upstream batch request missing redaction placeholder") + } + + // WO-433: an empty POST body has no body shape to scan and must still reach upstream. + func testEmptyPostBodyForwardedThroughShapeGuard() throws { + let upstream = try StubHTTPServer { _ in + StubHTTPResponse( + status: 200, + headers: ["Content-Type": "application/json"], + body: Data(#"{"ok":true}"#.utf8) + ) + } + try upstream.start() + defer { upstream.stop() } + + let proxyPort = try TCPTestSocket.reserveLoopbackPort() + let proxy = ProxyServer( + port: proxyPort, + upstream: URL(string: "http://127.0.0.1:\(upstream.port)")! + ) + let runningProxy = RunningProxy(server: proxy) + try runningProxy.start() + defer { runningProxy.stop() } + + let response = try TCPTestSocket.roundTrip( + port: proxyPort, + request: TCPTestSocket.postRequest(path: "/v1/messages", body: ""), + timeoutSeconds: 10 + ) + + let diagnostic = TCPTestSocket.describeResponse(response) + " upstream_requests=\(upstream.requestCount)" + XCTAssertTrue(response.contains("HTTP/1.1 200 OK"), diagnostic) + XCTAssertFalse(response.contains("HTTP/1.1 415"), diagnostic) + XCTAssertEqual(upstream.requestCount, 1, diagnostic) + } + // WO-424: gateway-prefixed Anthropic paths must forward through the real proxy. func testGatewayPrefixedAnthropicBodyRedactedAndForwarded() throws { let requestLock = NSLock() From e2883bbdb83514b680902acd451bc81e266f54d4 Mon Sep 17 00:00:00 2001 From: ppiankov <103106369+ppiankov@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:49:27 +0800 Subject: [PATCH 09/29] fix: harden proxy guard follow-ups Caller-audit: Sources/PastewatchCLI/LaunchCommand.swift:216 -- updated Sources/PastewatchCLI/LaunchCommand.swift:277 -- updated Tests/PastewatchTests/LaunchCommandTests.swift:60 -- unaffected default quiet argument preserves direct test call Sources/PastewatchCore/ProxyServer.swift:738 -- updated Sources/PastewatchCore/ProxyServer.swift:743 -- updated Tests/PastewatchTests/ProxyAlertTests.swift:222 -- updated Tests/PastewatchTests/ProxyAlertTests.swift:242 -- updated Sources/PastewatchCore/ProxyServer.swift:119 -- unaffected additive stats field Sources/PastewatchCore/ProxyServer.swift:695 -- updated Tests/PastewatchTests/ProxyRealServerTests.swift:572 -- updated --- Sources/PastewatchCLI/LaunchCommand.swift | 21 +- Sources/PastewatchCore/CurlHTTPClient.swift | 2 +- Sources/PastewatchCore/ProxyServer.swift | 125 +++++----- .../PastewatchTests/LaunchCommandTests.swift | 140 ++++++++++-- Tests/PastewatchTests/ProxyAlertTests.swift | 73 +----- .../ProxyBodyShapeGuardTests.swift | 20 ++ .../ProxyRealServerTests.swift | 214 +++++++++++++++++- .../ProxyStreamRedactionTests.swift | 14 -- 8 files changed, 443 insertions(+), 166 deletions(-) diff --git a/Sources/PastewatchCLI/LaunchCommand.swift b/Sources/PastewatchCLI/LaunchCommand.swift index db4c440..08d92e3 100644 --- a/Sources/PastewatchCLI/LaunchCommand.swift +++ b/Sources/PastewatchCLI/LaunchCommand.swift @@ -95,14 +95,18 @@ struct Launch: ParsableCommand { // WO-409/WO-418: only wire ANTHROPIC_BASE_URL for agents the proxy actually redacts. // Clear stale local pastewatch proxy values for unsupported agents, but preserve remote // corporate/team gateways the operator intentionally configured. - static func configureProxyEnv(agentBinary: String, port: UInt16) { + static func configureProxyEnv(agentBinary: String, port: UInt16, quiet: Bool = false) { if isProxyRoutedAgent(agentBinary) { setenv(anthropicBaseURLEnv, "http://127.0.0.1:\(port)", 1) } else if shouldClearExistingAnthropicBaseURL(ProcessInfo.processInfo.environment[anthropicBaseURLEnv]) { unsetenv(anthropicBaseURLEnv) - FileHandle.standardError.write(Data(nonRoutedWarning(agentBinary: agentBinary, baseURLState: "not set").utf8)) + if !quiet { + FileHandle.standardError.write(Data(nonRoutedWarning(agentBinary: agentBinary, baseURLState: "not set").utf8)) + } } else { - FileHandle.standardError.write(Data(nonRoutedWarning(agentBinary: agentBinary, baseURLState: "preserved").utf8)) + if !quiet { + FileHandle.standardError.write(Data(nonRoutedWarning(agentBinary: agentBinary, baseURLState: "preserved").utf8)) + } } } @@ -209,7 +213,7 @@ struct Launch: ParsableCommand { throw ExitCode(rawValue: 1) } // WO-414: do not start an unused proxy for agents whose traffic is not routed. - Launch.configureProxyEnv(agentBinary: agentBinary, port: port) + Launch.configureProxyEnv(agentBinary: agentBinary, port: port, quiet: quiet) if !quiet { let cmdStr = command.joined(separator: " ") FileHandle.standardError.write(Data("launching: \(cmdStr)\n\n".utf8)) @@ -270,7 +274,7 @@ struct Launch: ParsableCommand { FileHandle.standardError.write(Data("launching: \(cmdStr)\n\n".utf8)) } - Launch.configureProxyEnv(agentBinary: agentBinary, port: port) + Launch.configureProxyEnv(agentBinary: agentBinary, port: port, quiet: quiet) let exitCode = try runAgentProcess(command) if exitCode != 0 { @@ -307,6 +311,13 @@ struct Launch: ParsableCommand { kill(launchAgentPid, SIGINT) } } + // WO-438: process managers send SIGTERM for graceful shutdown; forward it + // to the agent child so waitpid returns and the proxy defer can run. + signal(SIGTERM) { _ in + if launchAgentPid > 0 { + kill(launchAgentPid, SIGTERM) + } + } var status: Int32 = 0 waitpid(pid, &status, 0) diff --git a/Sources/PastewatchCore/CurlHTTPClient.swift b/Sources/PastewatchCore/CurlHTTPClient.swift index dbd3fb6..419c72a 100644 --- a/Sources/PastewatchCore/CurlHTTPClient.swift +++ b/Sources/PastewatchCore/CurlHTTPClient.swift @@ -1232,7 +1232,7 @@ struct CurlHTTPClient { /// WO-175: map common HTTP status codes to their canonical reason phrase. static func httpReasonPhrase(for status: Int) -> String { let phrases: [Int: String] = [ - 200: "OK", 201: "Created", 204: "No Content", 206: "Partial Content", + 200: "OK", 201: "Created", 202: "Accepted", 204: "No Content", 206: "Partial Content", 301: "Moved Permanently", 302: "Found", 304: "Not Modified", 400: "Bad Request", 401: "Unauthorized", 403: "Forbidden", 404: "Not Found", 405: "Method Not Allowed", 408: "Request Timeout", diff --git a/Sources/PastewatchCore/ProxyServer.swift b/Sources/PastewatchCore/ProxyServer.swift index 40eeef5..549c43d 100644 --- a/Sources/PastewatchCore/ProxyServer.swift +++ b/Sources/PastewatchCore/ProxyServer.swift @@ -116,6 +116,7 @@ public final class ProxyServer { public struct RedactionStats { public var requestsProcessed: Int = 0 + public var refusedRequests: Int = 0 // WO-442: aggregate fail-closed 415 refusals. public var requestsRedacted: Int = 0 public var secretsRedacted: Int = 0 public var advisoryMatches: Int = 0 // WO-353/354/404: advisories are audited separately. @@ -691,6 +692,7 @@ public final class ProxyServer { if case .refuse(let reason) = upstreamBodyShapeVerdict( method: parsed.method, path: parsed.path, bodyData: parsed.bodyData ) { + recordRefusedRequest() logUnsupportedBodyShapeRefusal(path: parsed.path, reason: reason) sendError(to: clientSocket, status: 415, message: "Unsupported upstream body shape") return @@ -703,8 +705,9 @@ public final class ProxyServer { var redactedTypes: [String] = [] var bodyAdvisoryCount = 0 var bodyAdvisoryTypes: [String] = [] - var shouldBlockNonUTF8Forwarding = false if parsed.method == "POST" && isSupportedAnthropicPostPath(parsed.path) { + // WO-429: malformed/non-UTF-8 supported-path bodies are already refused by + // upstreamBodyShapeVerdict, so the scanner only receives valid UTF-8 JSON. if let body = parsed.body { let result = scanAndRedactBody(body) processedBody = result.body @@ -713,15 +716,6 @@ public final class ProxyServer { redactedTypes = result.redactedTypes bodyAdvisoryCount = result.advisoryCount bodyAdvisoryTypes = result.advisoryTypes - } else { - // WO-296: scan a lossy text view of non-UTF-8 bodies for audit - // coverage, then fail closed if a secret is detected. - let result = scanNonUTF8BodyForRedactions(processedBodyData) - redactionCount = result.redacted - redactedTypes = result.redactedTypes - bodyAdvisoryCount = result.advisoryCount - bodyAdvisoryTypes = result.advisoryTypes - shouldBlockNonUTF8Forwarding = result.shouldBlockForwarding } } @@ -743,25 +737,16 @@ public final class ProxyServer { recordInitialRequestStats( redactionCount: redactionCount, - shouldBlockNonUTF8Forwarding: shouldBlockNonUTF8Forwarding, countForwardedRedaction: !deferForwardedRedactionStats ) if shouldLogBodyRedactionBeforeForwarding( redactionCount: redactionCount, - requestWantsStream: requestWantsStream, - shouldBlockNonUTF8Forwarding: shouldBlockNonUTF8Forwarding + requestWantsStream: requestWantsStream ) { logRedaction(path: parsed.path, count: redactionCount, types: redactedTypes) } recordBodyAdvisoryStats(path: parsed.path, count: bodyAdvisoryCount, types: bodyAdvisoryTypes) - if shouldBlockNonUTF8Forwarding { - // WO-296: /v1/messages bodies are UTF-8 JSON by contract; if a lossy - // scan finds a secret in malformed bytes, fail closed instead of - // forwarding the original credential upstream. - sendError(to: clientSocket, status: 400, message: "Bad Request") - return - } // Platform dispatch: returns a BufferedResponse for the convergence tail, // or nil when the response was fully handled (streamed or error sent to client). @@ -1022,14 +1007,6 @@ public final class ProxyServer { let advisoryTypes: [String] } - struct RedactionDetectionSummary { - let redacted: Int - let redactedTypes: [String] - let advisoryCount: Int - let advisoryTypes: [String] - let shouldBlockForwarding: Bool - } - func scanAndRedactBody(_ body: String) -> ScanResult { guard let data = body.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { @@ -1040,13 +1017,20 @@ public final class ProxyServer { var types: [String] = [] var advisoryCount = 0 var advisoryTypes: [String] = [] - let processedMessages = redactContentArray( + let processedTopLevel = redactTopLevelStringFields( json, redacted: &redacted, types: &types, advisoryCount: &advisoryCount, advisoryTypes: &advisoryTypes ) + let processedMessages = redactContentArray( + processedTopLevel, + redacted: &redacted, + types: &types, + advisoryCount: &advisoryCount, + advisoryTypes: &advisoryTypes + ) let processed = redactBatchRequestMessages( processedMessages, redacted: &redacted, @@ -1164,8 +1148,14 @@ public final class ProxyServer { // WO-425: classify gateway paths without letting query strings affect endpoint shape. private func requestPathWithoutQuery(_ path: String) -> String { - path.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false).first + var pathOnly = path.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false).first .map(String.init) ?? path + // WO-436: gateway/client-normalized trailing slashes still identify the + // same Anthropic endpoints and should not trip fail-closed shape refusal. + while pathOnly.count > 1 && pathOnly.hasSuffix("/") { + pathOnly.removeLast() + } + return pathOnly } // WO-408: positive identification of the Anthropic Messages schema. Permissive on @@ -1228,8 +1218,15 @@ public final class ProxyServer { return Self.knownForeignMessagesModelPrefixes.contains { lower.hasPrefix($0) } } - // WO-408/WO-413: per-request audit signal for a fail-closed refusal (verdict f6978df9). + // WO-408/WO-413/WO-440: audit fail-closed refusals without repeating identical noise. private func logUnsupportedBodyShapeRefusal(path: String, reason: String) { + let signature = "refused:\(path):\(reason)" + statsLock.lock() + let isRepeat = signature == lastRefusalLogSignature + lastRefusalLogSignature = signature + statsLock.unlock() + guard !isRepeat else { return } + let line = "[\(formatAuditTimestamp(Date()))] PROXY REFUSED unsupported upstream body shape in \(path) (\(reason))\n" if !quietLog { FileHandle.standardError.write(Data(line.utf8)) @@ -1256,26 +1253,29 @@ public final class ProxyServer { ) } - func scanNonUTF8BodyForRedactions(_ bodyData: Data) -> RedactionDetectionSummary { - guard !bodyData.isEmpty else { - return RedactionDetectionSummary( - redacted: 0, redactedTypes: [], - advisoryCount: 0, advisoryTypes: [], - shouldBlockForwarding: false - ) + // WO-437: count_tokens requests can omit messages; scan top-level system text + // before the messages-only tool_result walk would otherwise return unchanged. + private func redactTopLevelStringFields( + _ json: [String: Any], + redacted: inout Int, + types: inout [String], + advisoryCount: inout Int, + advisoryTypes: inout [String] + ) -> [String: Any] { + var result = json + for field in ["system"] { + guard let value = json[field] as? String else { continue } + let matches = scanProxyText(value) + let filtered = mutationSafeProxyMatches(matches) + let advisories = streamAdvisoryMatches(matches, severity: severity) + advisoryCount += advisories.count + advisoryTypes.append(contentsOf: advisories.map { $0.displayName }) + guard !filtered.isEmpty else { continue } + result[field] = Obfuscator.obfuscate(value, matches: filtered) + redacted += filtered.count + types.append(contentsOf: filtered.map { $0.displayName }) } - // swiftlint:disable:next optional_data_string_conversion - let lossyBody = String(decoding: bodyData, as: UTF8.self) - let matches = scanProxyText(lossyBody) - let filtered = mutationSafeProxyMatches(matches) - let advisories = streamAdvisoryMatches(matches, severity: severity) - return RedactionDetectionSummary( - redacted: filtered.count, - redactedTypes: filtered.map { $0.displayName }, - advisoryCount: advisories.count, - advisoryTypes: advisories.map { $0.displayName }, - shouldBlockForwarding: !filtered.isEmpty - ) + return result } /// Walk the messages array looking for tool_result content to scan. @@ -1412,17 +1412,9 @@ public final class ProxyServer { func shouldLogBodyRedactionBeforeForwarding( redactionCount: Int, - requestWantsStream: Bool, - shouldBlockNonUTF8Forwarding: Bool + requestWantsStream: Bool ) -> Bool { guard redactionCount > 0 else { return false } - // WO-304/WO-307: if malformed bytes caused a fail-closed request, no - // streaming relay will run later, so the request-body redaction must be - // audited before returning 400. - if shouldBlockNonUTF8Forwarding { - return true - } - // WO-304: non-streaming and buffer-mode body redactions must be logged // before forwarding so upstream failures cannot hide them. Streaming // mode defers the body count so body + SSE redactions are logged once. @@ -1441,20 +1433,24 @@ public final class ProxyServer { func recordInitialRequestStats( redactionCount: Int, - shouldBlockNonUTF8Forwarding: Bool, countForwardedRedaction: Bool = true ) { - // WO-317: a fail-closed malformed body is audited but was never forwarded - // with redacted bytes, so it must not inflate requestsRedacted/secretsRedacted. statsLock.lock() stats.requestsProcessed += 1 - if redactionCount > 0 && !shouldBlockNonUTF8Forwarding && countForwardedRedaction { + if redactionCount > 0 && countForwardedRedaction { stats.requestsRedacted += 1 stats.secretsRedacted += redactionCount } statsLock.unlock() } + // WO-442: refusals are not processed requests, but operators still need an aggregate count. + private func recordRefusedRequest() { + statsLock.lock() + stats.refusedRequests += 1 + statsLock.unlock() + } + func recordForwardedBodyRedactionStats(redactionCount: Int) { guard redactionCount > 0 else { return } statsLock.lock() @@ -2018,6 +2014,7 @@ public final class ProxyServer { private var lastRedactionLogSignatures: [RedactionLogSource: String] = [:] // WO-378: source-scoped dedup. private var lastAdvisoryLogSignatures: [RedactionLogSource: String] = [:] // WO-404: source-scoped advisory dedup. + private var lastRefusalLogSignature: String? // WO-440: throttle repeated unsupported-shape audit lines. private enum RedactionLogSource: String { case request @@ -2053,6 +2050,7 @@ public final class ProxyServer { statsLock.lock() let isRepeat = signature == lastRedactionLogSignatures[source] lastRedactionLogSignatures[source] = signature + if !isRepeat { lastRefusalLogSignature = nil } statsLock.unlock() let timestamp = formatAuditTimestamp(Date()) @@ -2107,6 +2105,7 @@ public final class ProxyServer { statsLock.lock() let isRepeat = signature == lastAdvisoryLogSignatures[source] lastAdvisoryLogSignatures[source] = signature + if !isRepeat { lastRefusalLogSignature = nil } statsLock.unlock() let timestamp = formatAuditTimestamp(Date()) diff --git a/Tests/PastewatchTests/LaunchCommandTests.swift b/Tests/PastewatchTests/LaunchCommandTests.swift index 06aa897..3825774 100644 --- a/Tests/PastewatchTests/LaunchCommandTests.swift +++ b/Tests/PastewatchTests/LaunchCommandTests.swift @@ -68,7 +68,7 @@ final class LaunchCommandTests: XCTestCase { let fixture = try makeLaunchFixture() let agent = try writeEnvEchoAgent(named: "codex", in: fixture.cwd) let result = try runCLIProcess( - arguments: ["launch", "--quiet", "--no-startup-sweep", "--port", "65435", "--", agent.path], + arguments: ["launch", "--no-startup-sweep", "--port", "65435", "--", agent.path], cwd: fixture.cwd, environment: fixture.environment ) @@ -85,8 +85,7 @@ final class LaunchCommandTests: XCTestCase { "warning should avoid blanket coverage claims; stderr: \(result.stderr)" ) XCTAssertFalse(result.stderr.contains("remain covered"), "warning must not overclaim coverage: \(result.stderr)") - XCTAssertTrue(result.stderr.hasSuffix("\n"), "warning should end with one newline: \(result.stderr.debugDescription)") - XCTAssertFalse(result.stderr.hasSuffix("\n\n"), "warning should not end with a blank line") + XCTAssertTrue(result.stderr.contains("launching: "), "non-quiet launch should announce command") } // WO-418: remote/team gateway URLs are operator intent, not stale local proxy state. @@ -97,7 +96,7 @@ final class LaunchCommandTests: XCTestCase { environment["ANTHROPIC_BASE_URL"] = "https://gateway.example.com/anthropic" let result = try runCLIProcess( - arguments: ["launch", "--quiet", "--no-startup-sweep", "--port", "65435", "--", agent.path], + arguments: ["launch", "--no-startup-sweep", "--port", "65435", "--", agent.path], cwd: fixture.cwd, environment: environment ) @@ -112,7 +111,7 @@ final class LaunchCommandTests: XCTestCase { XCTAssertFalse(result.stderr.contains("remain covered"), "warning must not overclaim coverage: \(result.stderr)") XCTAssertFalse(result.stderr.contains("gateway.example.com"), "warning must not echo gateway URL values") XCTAssertFalse(result.stderr.contains("ANTHROPIC_BASE_URL not set"), result.stderr) - XCTAssertFalse(result.stderr.hasSuffix("\n\n"), "warning should not end with a blank line") + XCTAssertTrue(result.stderr.contains("launching: "), "non-quiet launch should announce command") } // WO-418: stale local pastewatch proxy URLs are cleared for unsupported agents. @@ -123,7 +122,7 @@ final class LaunchCommandTests: XCTestCase { environment["ANTHROPIC_BASE_URL"] = "http://127.0.0.1:8443" let result = try runCLIProcess( - arguments: ["launch", "--quiet", "--no-startup-sweep", "--port", "65435", "--", agent.path], + arguments: ["launch", "--no-startup-sweep", "--port", "65435", "--", agent.path], cwd: fixture.cwd, environment: environment ) @@ -131,7 +130,7 @@ final class LaunchCommandTests: XCTestCase { XCTAssertEqual(result.status, 0, "launch should clear stale local proxy URL; stderr: \(result.stderr)") XCTAssertTrue(result.stdout.contains("ANTHROPIC_BASE_URL=UNSET"), result.stdout) XCTAssertTrue(result.stderr.contains("ANTHROPIC_BASE_URL not set"), result.stderr) - XCTAssertFalse(result.stderr.hasSuffix("\n\n"), "warning should not end with a blank line") + XCTAssertTrue(result.stderr.contains("launching: "), "non-quiet launch should announce command") } // WO-434: audit logs are a proxy artifact; non-routed launches must fail explicitly. @@ -207,15 +206,52 @@ final class LaunchCommandTests: XCTestCase { XCTAssertEqual(result.status, 0, "launch should not touch occupied proxy port; stderr: \(result.stderr)") XCTAssertTrue(result.stdout.contains("ANTHROPIC_BASE_URL=UNSET"), result.stdout) XCTAssertFalse(result.stderr.contains("failed to start proxy"), result.stderr) + XCTAssertEqual(result.stderr, "", "--quiet should suppress non-routed advisory stderr") + } + + // WO-438: SIGTERM should take the normal child-exit path so the proxy defer runs. + func testSIGTERMTerminatesAgentAndProxy() throws { + let fixture = try makeLaunchFixture() + let agent = try writeTermTrapAgent(named: "claude", in: fixture.cwd) + let proxyPort = try reserveEphemeralLoopbackPort() + let process = Process() + process.executableURL = pastewatchCLIURL() + process.arguments = [ + "launch", "--quiet", "--no-startup-sweep", "--port", "\(proxyPort)", "--", agent.script.path, + ] + process.currentDirectoryURL = fixture.cwd + process.environment = fixture.environment + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + + try process.run() + defer { + if process.isRunning { + kill(process.processIdentifier, SIGKILL) + process.waitUntilExit() + } + if let agentPid = readPID(from: agent.pidFile), processIsRunning(agentPid) { + kill(agentPid, SIGKILL) + } + } + + XCTAssertTrue(waitForFile(agent.pidFile, timeoutSeconds: 5), "agent did not start") + XCTAssertTrue(waitUntil(timeoutSeconds: 5) { self.canConnectToLoopbackPort(proxyPort) }, "proxy did not listen") + + kill(process.processIdentifier, SIGTERM) + XCTAssertTrue(waitForProcessExit(process, timeoutSeconds: 5), "launch did not exit after SIGTERM") + XCTAssertTrue(waitForFile(agent.termFile, timeoutSeconds: 2), "agent did not receive SIGTERM") XCTAssertTrue( - result.stderr.contains("redaction is not wired for agent 'codex'"), - "codex should warn about missing proxy interposition; stderr: \(result.stderr)" - ) - XCTAssertTrue( - result.stderr.contains("Protect non-routed agents with configured pastewatch hooks and MCP tools where available."), - "warning should avoid blanket coverage claims; stderr: \(result.stderr)" + waitUntil(timeoutSeconds: 5) { !self.canConnectToLoopbackPort(proxyPort) }, + "proxy still accepts connections after launch SIGTERM" ) - XCTAssertFalse(result.stderr.contains("remain covered"), "warning must not overclaim coverage: \(result.stderr)") + + let out = String(data: stdout.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + let err = String(data: stderr.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + XCTAssertEqual(out, "", "quiet launch should not write stdout: \(out)") + XCTAssertEqual(err, "", "quiet launch should not write stderr: \(err)") } // WO-137: seam-unavailable probe fallback must not reach startup sweep or proxy. @@ -317,6 +353,12 @@ final class LaunchCommandTests: XCTestCase { let environment: [String: String] } + private struct TermTrapAgent { + let script: URL + let pidFile: URL + let termFile: URL + } + private struct ProcessResult { let status: Int32 let stdout: String @@ -449,6 +491,12 @@ final class LaunchCommandTests: XCTestCase { return root } + private func reserveEphemeralLoopbackPort() throws -> UInt16 { + let occupied = try occupyLoopbackPort() + close(occupied.fd) + return occupied.port + } + // WO-414: keep the listener open to prove non-routed launches skip proxy startup. private func occupyLoopbackPort() throws -> (fd: Int32, port: UInt16) { let fd = socket(AF_INET, SOCK_STREAM, 0) @@ -502,6 +550,21 @@ final class LaunchCommandTests: XCTestCase { return script } + private func writeTermTrapAgent(named name: String, in dir: URL) throws -> TermTrapAgent { + let script = dir.appendingPathComponent(name) + let pidFile = dir.appendingPathComponent("\(name).pid") + let termFile = dir.appendingPathComponent("\(name).term") + let body = """ + #!/bin/sh + printf '%s\\n' "$$" > '\(pidFile.path)' + trap 'printf term > "\(termFile.path)"; exit 0' TERM + while :; do sleep 1; done + """ + try body.write(to: script, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: script.path) + return TermTrapAgent(script: script, pidFile: pidFile, termFile: termFile) + } + private func writeFixtureStartupFile(in home: URL) throws { let fixtureValue = "postgres" + "://user:pass@host:5432/db" let path = home.appendingPathComponent(".zshrc") @@ -537,4 +600,53 @@ final class LaunchCommandTests: XCTestCase { return URL(fileURLWithPath: FileManager.default.currentDirectoryPath) .appendingPathComponent(".build/debug/PastewatchCLI") } + + private func waitForFile(_ url: URL, timeoutSeconds: TimeInterval) -> Bool { + waitUntil(timeoutSeconds: timeoutSeconds) { + FileManager.default.fileExists(atPath: url.path) + } + } + + private func waitForProcessExit(_ process: Process, timeoutSeconds: TimeInterval) -> Bool { + waitUntil(timeoutSeconds: timeoutSeconds) { + !process.isRunning + } + } + + private func waitUntil(timeoutSeconds: TimeInterval, predicate: () -> Bool) -> Bool { + let deadline = Date().addingTimeInterval(timeoutSeconds) + while Date() < deadline { + if predicate() { return true } + usleep(50_000) + } + return predicate() + } + + private func canConnectToLoopbackPort(_ port: UInt16) -> Bool { + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { return false } + defer { close(fd) } + var addr = sockaddr_in() + addr.sin_family = sa_family_t(AF_INET) + addr.sin_addr.s_addr = inet_addr("127.0.0.1") + addr.sin_port = port.bigEndian + let result = withUnsafePointer(to: &addr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + connect(fd, $0, socklen_t(MemoryLayout.size)) + } + } + return result == 0 + } + + private func readPID(from url: URL) -> Int32? { + guard let contents = try? String(contentsOf: url, encoding: .utf8), + let value = Int32(contents.trimmingCharacters(in: .whitespacesAndNewlines)) else { + return nil + } + return value + } + + private func processIsRunning(_ pid: Int32) -> Bool { + kill(pid, 0) == 0 + } } diff --git a/Tests/PastewatchTests/ProxyAlertTests.swift b/Tests/PastewatchTests/ProxyAlertTests.swift index ae0d319..58694de 100644 --- a/Tests/PastewatchTests/ProxyAlertTests.swift +++ b/Tests/PastewatchTests/ProxyAlertTests.swift @@ -200,43 +200,6 @@ final class ProxyAlertTests: XCTestCase { XCTAssertFalse(serverNoAlert.injectAlert) } - func testNonUTF8RequestBodyStillScansLossyTextForAudit() { - var body = Data([0xFF, 0xFE, 0x00]) - body.append(Data("password=s3cr3t-hunter2".utf8)) - - let result = server.scanNonUTF8BodyForRedactions(body) - - XCTAssertEqual(result.redacted, 1) - XCTAssertEqual(result.redactedTypes, ["Credential"]) - XCTAssertTrue(result.shouldBlockForwarding) - } - - func testNonUTF8RequestBodyHonorsCustomRules() { - var config = PastewatchConfig.defaultConfig - config.customRules = [ - CustomRuleConfig(name: "ACME Proxy Token", pattern: #"ACME-PROXY-[A-Z]+"#, severity: "high") - ] - let customServer = ProxyServer(port: 0, config: config, severity: .high) - var body = Data([0xFF, 0xFE, 0x00]) - body.append(Data("token ACME-PROXY-ALPHA".utf8)) - - let result = customServer.scanNonUTF8BodyForRedactions(body) - - XCTAssertEqual(result.redacted, 1) - XCTAssertEqual(result.redactedTypes, ["ACME Proxy Token"]) - XCTAssertTrue(result.shouldBlockForwarding) - } - - func testNonUTF8RequestBodyWithoutSecretDoesNotBlockForwarding() { - let body = Data([0xFF, 0xFE, 0x00, 0x41]) - - let result = server.scanNonUTF8BodyForRedactions(body) - - XCTAssertEqual(result.redacted, 0) - XCTAssertEqual(result.redactedTypes, []) - XCTAssertFalse(result.shouldBlockForwarding) - } - func testUTF8ToolResultBodyHonorsCustomRules() { var config = PastewatchConfig.defaultConfig config.customRules = [ @@ -258,16 +221,7 @@ final class ProxyAlertTests: XCTestCase { func testBodyRedactionAuditIsDeferredForStreamingRequests() { XCTAssertFalse(server.shouldLogBodyRedactionBeforeForwarding( redactionCount: 1, - requestWantsStream: true, - shouldBlockNonUTF8Forwarding: false - )) - } - - func testBodyRedactionAuditIsNotDeferredWhenMalformedBodyBlocksForwarding() { - XCTAssertTrue(server.shouldLogBodyRedactionBeforeForwarding( - redactionCount: 1, - requestWantsStream: true, - shouldBlockNonUTF8Forwarding: true + requestWantsStream: true )) } @@ -278,25 +232,14 @@ final class ProxyAlertTests: XCTestCase { XCTAssertTrue(bufferModeServer.shouldLogBodyRedactionBeforeForwarding( redactionCount: 1, - requestWantsStream: true, - shouldBlockNonUTF8Forwarding: false + requestWantsStream: true )) } - func testBlockedNonUTF8RedactionDoesNotCountAsForwardedRedaction() { - let blockedServer = ProxyServer(port: 0) - - blockedServer.recordInitialRequestStats(redactionCount: 1, shouldBlockNonUTF8Forwarding: true) - - XCTAssertEqual(blockedServer.stats.requestsProcessed, 1) - XCTAssertEqual(blockedServer.stats.requestsRedacted, 0) - XCTAssertEqual(blockedServer.stats.secretsRedacted, 0) - } - func testForwardedBodyRedactionStillCountsAsForwardedRedaction() { let forwardedServer = ProxyServer(port: 0) - forwardedServer.recordInitialRequestStats(redactionCount: 2, shouldBlockNonUTF8Forwarding: false) + forwardedServer.recordInitialRequestStats(redactionCount: 2) XCTAssertEqual(forwardedServer.stats.requestsProcessed, 1) XCTAssertEqual(forwardedServer.stats.requestsRedacted, 1) @@ -308,7 +251,6 @@ final class ProxyAlertTests: XCTestCase { streamingServer.recordInitialRequestStats( redactionCount: 1, - shouldBlockNonUTF8Forwarding: false, countForwardedRedaction: false ) @@ -329,7 +271,6 @@ final class ProxyAlertTests: XCTestCase { streamingServer.recordInitialRequestStats( redactionCount: 1, - shouldBlockNonUTF8Forwarding: false, countForwardedRedaction: false ) streamingServer.recordRejectedStreamingBodyRedactionIfNeeded( @@ -353,8 +294,7 @@ final class ProxyAlertTests: XCTestCase { let streamingServer = ProxyServer(port: 0, auditLogPath: path) streamingServer.recordInitialRequestStats( - redactionCount: 0, - shouldBlockNonUTF8Forwarding: false + redactionCount: 0 ) streamingServer.recordStreamingAuditStats(ProxyServer.StreamingAuditStats( path: "/v1/messages", @@ -419,7 +359,6 @@ final class ProxyAlertTests: XCTestCase { streamingServer.recordInitialRequestStats( redactionCount: 0, - shouldBlockNonUTF8Forwarding: false, countForwardedRedaction: false ) streamingServer.recordStreamingAuditStats(ProxyServer.StreamingAuditStats( @@ -494,7 +433,7 @@ final class ProxyAlertTests: XCTestCase { func testBufferedResponseRedactionStatsDoNotDoubleCountRequest() { let responseOnlyServer = ProxyServer(port: 0) - responseOnlyServer.recordInitialRequestStats(redactionCount: 0, shouldBlockNonUTF8Forwarding: false) + responseOnlyServer.recordInitialRequestStats(redactionCount: 0) responseOnlyServer.recordBufferedResponseRedactionStats(requestRedactionCount: 0, responseRedactionCount: 1) XCTAssertEqual(responseOnlyServer.stats.requestsProcessed, 1) @@ -502,7 +441,7 @@ final class ProxyAlertTests: XCTestCase { XCTAssertEqual(responseOnlyServer.stats.secretsRedacted, 1) let requestAndResponseServer = ProxyServer(port: 0) - requestAndResponseServer.recordInitialRequestStats(redactionCount: 1, shouldBlockNonUTF8Forwarding: false) + requestAndResponseServer.recordInitialRequestStats(redactionCount: 1) requestAndResponseServer.recordBufferedResponseRedactionStats(requestRedactionCount: 1, responseRedactionCount: 1) XCTAssertEqual(requestAndResponseServer.stats.requestsProcessed, 1) diff --git a/Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift b/Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift index 062bcc6..31bcbcb 100644 --- a/Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift +++ b/Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift @@ -84,6 +84,23 @@ final class ProxyBodyShapeGuardTests: XCTestCase { XCTAssertEqual(verdict("POST", "/gateway/v1/messages/batches?beta=true", body), .allow) } + func testTrailingSlashSupportedAnthropicPathsAllowed() { + let messagesBody = """ + {"model":"claude-3","messages":[{"role":"user","content":"hi"}]} + """ + let countTokensBody = """ + {"model":"claude-3","system":"be terse"} + """ + let batchesBody = """ + {"requests":[{"custom_id":"r1","params":{"model":"claude-3","messages":[{"role":"user","content":"hi"}]}}]} + """ + + XCTAssertEqual(verdict("POST", "/v1/messages/", messagesBody), .allow) + XCTAssertEqual(verdict("POST", "/gateway/v1/messages/", messagesBody), .allow) + XCTAssertEqual(verdict("POST", "/v1/messages/count_tokens/", countTokensBody), .allow) + XCTAssertEqual(verdict("POST", "/v1/messages/batches/", batchesBody), .allow) + } + func testUnknownModelAliasAllowedOnMessagesPath() { // WO-430: model identity is a foreign-family denylist, not a fragile Anthropic allowlist. let body = """ @@ -318,6 +335,9 @@ final class ProxyBodyShapeGuardTests: XCTestCase { "/v1/messages?beta=true", "/v1/messages/count_tokens", "/v1/messages/batches", + "/v1/messages/", + "/v1/messages/count_tokens/", + "/v1/messages/batches/", "/v1/llm-gateway/v1/messages", "/anthropic/v1/messages?beta=true", "/v1/llm-gateway/v1/messages/count_tokens", diff --git a/Tests/PastewatchTests/ProxyRealServerTests.swift b/Tests/PastewatchTests/ProxyRealServerTests.swift index b055c9f..6b2d5c5 100644 --- a/Tests/PastewatchTests/ProxyRealServerTests.swift +++ b/Tests/PastewatchTests/ProxyRealServerTests.swift @@ -91,6 +91,52 @@ final class ProxyRealServerTests: XCTestCase { XCTAssertTrue(forwarded.contains(""), "upstream request missing redaction placeholder") } + // WO-437: top-level system text is part of the Anthropic request shape and must be scanned. + func testAnthropicSystemFieldCredentialRedactedThroughShapeGuardBeforeUpstream() throws { + let requestLock = NSLock() + var upstreamRequest = "" + let upstream = try StubHTTPServer { request in + requestLock.lock() + upstreamRequest = String(data: request, encoding: .utf8) ?? "" + requestLock.unlock() + return StubHTTPResponse( + status: 200, + headers: ["Content-Type": "application/json"], + body: Data(#"{"ok":true}"#.utf8) + ) + } + try upstream.start() + defer { upstream.stop() } + + let proxyPort = try TCPTestSocket.reserveLoopbackPort() + let proxy = ProxyServer( + port: proxyPort, + upstream: URL(string: "http://127.0.0.1:\(upstream.port)")! + ) + let runningProxy = RunningProxy(server: proxy) + try runningProxy.start() + defer { runningProxy.stop() } + + let credential = "password=system-hunter2" + let body = """ + {"model":"claude-3","system":"\(credential)","messages":[{"role":"user","content":"hello"}]} + """ + let response = try TCPTestSocket.roundTrip( + port: proxyPort, + request: TCPTestSocket.postRequest(path: "/v1/messages", body: body), + timeoutSeconds: 10 + ) + + requestLock.lock() + let forwarded = upstreamRequest + requestLock.unlock() + let diagnostic = TCPTestSocket.describeResponse(response) + " upstream_requests=\(upstream.requestCount)" + XCTAssertTrue(response.contains("HTTP/1.1 200 OK"), diagnostic) + XCTAssertEqual(upstream.requestCount, 1, diagnostic) + XCTAssertFalse(forwarded.contains(credential), "upstream system field leaked raw credential") + XCTAssertTrue(forwarded.contains(""), "upstream system field missing redaction placeholder") + } + // WO-432: Message Batch params pass the shape guard and use the same request redactor. func testAnthropicMessageBatchRedactedThroughShapeGuardBeforeUpstream() throws { let requestLock = NSLock() @@ -100,7 +146,7 @@ final class ProxyRealServerTests: XCTestCase { upstreamRequest = String(data: request, encoding: .utf8) ?? "" requestLock.unlock() return StubHTTPResponse( - status: 200, + status: 202, headers: ["Content-Type": "application/json"], body: Data(#"{"id":"batch_1"}"#.utf8) ) @@ -131,7 +177,7 @@ final class ProxyRealServerTests: XCTestCase { let forwarded = upstreamRequest requestLock.unlock() let diagnostic = TCPTestSocket.describeResponse(response) + " upstream_requests=\(upstream.requestCount)" - XCTAssertTrue(response.contains("HTTP/1.1 200 OK"), diagnostic) + XCTAssertTrue(response.contains("HTTP/1.1 202 Accepted"), diagnostic) XCTAssertEqual(upstream.requestCount, 1, diagnostic) XCTAssertTrue(forwarded.contains("POST /v1/messages/batches HTTP/1.1"), forwarded) XCTAssertFalse(forwarded.contains(credential), "upstream batch request leaked raw credential") @@ -484,6 +530,114 @@ final class ProxyRealServerTests: XCTestCase { XCTAssertTrue(stderr.contains("unsupported JSON POST body"), stderr) } + // WO-440/WO-442: repeated unsupported-shape refusals are counted but audit-deduped. + func testRepeatedUnsupportedBodyShapeRefusalsAreDedupedAndCounted() throws { + let upstream = try StubHTTPServer { _ in + StubHTTPResponse(status: 200, headers: [:], body: Data(#"{"ok":true}"#.utf8)) + } + try upstream.start() + defer { upstream.stop() } + + let auditPath = FileManager.default.temporaryDirectory + .appendingPathComponent("pastewatch-refused-dedup-\(UUID().uuidString).log") + defer { try? FileManager.default.removeItem(at: auditPath) } + + let proxyPort = try TCPTestSocket.reserveLoopbackPort() + let proxy = ProxyServer( + port: proxyPort, + upstream: URL(string: "http://127.0.0.1:\(upstream.port)")!, + auditLogPath: auditPath.path, + quietLog: true + ) + let runningProxy = RunningProxy(server: proxy) + try runningProxy.start() + var proxyStopped = false + defer { + if !proxyStopped { + runningProxy.stop() + } + } + + for path in ["/v1/responses", "/v1/responses", "/v1/chat/completions"] { + _ = try TCPTestSocket.roundTrip( + port: proxyPort, + request: TCPTestSocket.postRequest(path: path, body: #"{"input":"hello"}"#), + timeoutSeconds: 10 + ) + } + runningProxy.stop() + proxyStopped = true + + let audit = try String(contentsOf: auditPath, encoding: .utf8) + XCTAssertEqual(proxy.stats.refusedRequests, 3) + XCTAssertEqual(proxy.stats.requestsProcessed, 0) + XCTAssertEqual(proxy.stats.requestsRedacted, 0) + XCTAssertEqual(upstream.requestCount, 0) + XCTAssertEqual(audit.components(separatedBy: "PROXY REFUSED").count - 1, 2, audit) + XCTAssertTrue(audit.contains("/v1/responses"), audit) + XCTAssertTrue(audit.contains("/v1/chat/completions"), audit) + } + + // WO-440: a real redaction event between refusals breaks only the refusal dedup chain. + func testRefusalDedupResetsAfterRedactionAudit() throws { + let upstream = try StubHTTPServer { _ in + StubHTTPResponse( + status: 200, + headers: ["Content-Type": "application/json"], + body: Data(#"{"ok":true}"#.utf8) + ) + } + try upstream.start() + defer { upstream.stop() } + + let auditPath = FileManager.default.temporaryDirectory + .appendingPathComponent("pastewatch-refused-reset-\(UUID().uuidString).log") + defer { try? FileManager.default.removeItem(at: auditPath) } + + let proxyPort = try TCPTestSocket.reserveLoopbackPort() + let proxy = ProxyServer( + port: proxyPort, + upstream: URL(string: "http://127.0.0.1:\(upstream.port)")!, + auditLogPath: auditPath.path, + quietLog: true + ) + let runningProxy = RunningProxy(server: proxy) + try runningProxy.start() + var proxyStopped = false + defer { + if !proxyStopped { + runningProxy.stop() + } + } + + let redactedBody = """ + {"model":"claude-3","messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"password=reset-hunter2"}]}]} + """ + _ = try TCPTestSocket.roundTrip( + port: proxyPort, + request: TCPTestSocket.postRequest(path: "/v1/responses", body: #"{"input":"hello"}"#), + timeoutSeconds: 10 + ) + _ = try TCPTestSocket.roundTrip( + port: proxyPort, + request: TCPTestSocket.postRequest(path: "/v1/messages", body: redactedBody), + timeoutSeconds: 10 + ) + _ = try TCPTestSocket.roundTrip( + port: proxyPort, + request: TCPTestSocket.postRequest(path: "/v1/responses", body: #"{"input":"hello"}"#), + timeoutSeconds: 10 + ) + runningProxy.stop() + proxyStopped = true + + let audit = try String(contentsOf: auditPath, encoding: .utf8) + XCTAssertEqual(audit.components(separatedBy: "PROXY REFUSED").count - 1, 2, audit) + XCTAssertEqual(audit.components(separatedBy: "PROXY REDACTED").count - 1, 1, audit) + XCTAssertEqual(proxy.stats.refusedRequests, 2) + XCTAssertEqual(proxy.stats.requestsProcessed, 1) + } + // WO-408: an Anthropic-shaped count-tokens body is forwarded, not falsely refused. func testAnthropicCountTokensNotRefused() throws { let upstream = try StubHTTPServer { _ in @@ -509,6 +663,62 @@ final class ProxyRealServerTests: XCTestCase { XCTAssertEqual(upstream.requestCount, 1, "count_tokens must be forwarded; \(diagnostic)") } + // WO-437: count_tokens bodies without messages still scan top-level system text. + func testCountTokensSystemFieldCredentialRedacted() throws { + let requestLock = NSLock() + var upstreamRequest = "" + let upstream = try StubHTTPServer { request in + requestLock.lock() + upstreamRequest = String(data: request, encoding: .utf8) ?? "" + requestLock.unlock() + return StubHTTPResponse(status: 200, headers: [:], body: Data(#"{"input_tokens":3}"#.utf8)) + } + try upstream.start() + defer { upstream.stop() } + + let auditPath = FileManager.default.temporaryDirectory + .appendingPathComponent("pastewatch-count-tokens-system-\(UUID().uuidString).log") + defer { try? FileManager.default.removeItem(at: auditPath) } + + let proxyPort = try TCPTestSocket.reserveLoopbackPort() + let proxy = ProxyServer( + port: proxyPort, + upstream: URL(string: "http://127.0.0.1:\(upstream.port)")!, + auditLogPath: auditPath.path, + quietLog: true + ) + let runningProxy = RunningProxy(server: proxy) + try runningProxy.start() + var proxyStopped = false + defer { + if !proxyStopped { + runningProxy.stop() + } + } + + let credential = "password=count-tokens-hunter2" + let body = #"{"model":"claude-3","system":"\#(credential)"}"# + let response = try TCPTestSocket.roundTrip( + port: proxyPort, + request: TCPTestSocket.postRequest(path: "/v1/messages/count_tokens", body: body), + timeoutSeconds: 10 + ) + runningProxy.stop() + proxyStopped = true + + requestLock.lock() + let forwarded = upstreamRequest + requestLock.unlock() + let audit = try String(contentsOf: auditPath, encoding: .utf8) + let diagnostic = TCPTestSocket.describeResponse(response) + " upstream_requests=\(upstream.requestCount)" + XCTAssertTrue(response.contains("HTTP/1.1 200 OK"), diagnostic) + XCTAssertEqual(upstream.requestCount, 1, diagnostic) + XCTAssertFalse(forwarded.contains(credential), "upstream count_tokens request leaked raw credential") + XCTAssertTrue(forwarded.contains(""), "upstream request missing redaction placeholder") + XCTAssertTrue(audit.contains("PROXY REDACTED 1 secret(s) in /v1/messages/count_tokens"), audit) + XCTAssertTrue(audit.contains("Credential x1"), audit) + } + func testAdmissionCapRejectsFifthConcurrentConnection() throws { let upstreamEntered = DispatchSemaphore(value: 0) let upstreamRelease = DispatchSemaphore(value: 0) diff --git a/Tests/PastewatchTests/ProxyStreamRedactionTests.swift b/Tests/PastewatchTests/ProxyStreamRedactionTests.swift index 4f55af1..c925171 100644 --- a/Tests/PastewatchTests/ProxyStreamRedactionTests.swift +++ b/Tests/PastewatchTests/ProxyStreamRedactionTests.swift @@ -431,20 +431,6 @@ final class ProxyStreamRedactionTests: XCTestCase { XCTAssertTrue(result.body.contains("")) } - func testNonUTF8RequestBodyHighBuiltInDoesNotBlockForwarding() { - var body = Data([0xFF, 0xFE, 0x00]) - body.append(Data("operator@example.com".utf8)) - let server = ProxyServer(port: 0, severity: .high) - - let result = server.scanNonUTF8BodyForRedactions(body) - - XCTAssertEqual(result.redacted, 0) - XCTAssertEqual(result.redactedTypes, []) - XCTAssertEqual(result.advisoryCount, 1) - XCTAssertEqual(result.advisoryTypes, ["Email"]) - XCTAssertFalse(result.shouldBlockForwarding) - } - func testCurlNonUTF8ResponseHighBuiltInIsByteIdentical() { let email = "operator@example.com" var body = Data([0xFF, 0xFE]) From 0f37a6c4766af6fcb730bb1b2d267ebc8d1e55ee Mon Sep 17 00:00:00 2001 From: ppiankov <103106369+ppiankov@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:21:34 +0800 Subject: [PATCH 10/29] fix: make proxy model identity advisory Caller-audit: Sources/PastewatchCore/ProxyServer.swift:707 -- updated Sources/PastewatchCore/ProxyServer.swift:1127 -- unaffected Sources/PastewatchCore/ProxyServer.swift:1204 -- updated Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift:24 -- updated Tests/PastewatchTests/ProxyRealServerTests.swift:685 -- updated --- Sources/PastewatchCore/ProxyServer.swift | 111 +++++++++++++++--- .../ProxyBodyShapeGuardTests.swift | 67 +++++++---- .../ProxyRealServerTests.swift | 51 ++++++++ 3 files changed, 190 insertions(+), 39 deletions(-) diff --git a/Sources/PastewatchCore/ProxyServer.swift b/Sources/PastewatchCore/ProxyServer.swift index 549c43d..f0acc61 100644 --- a/Sources/PastewatchCore/ProxyServer.swift +++ b/Sources/PastewatchCore/ProxyServer.swift @@ -117,9 +117,16 @@ public final class ProxyServer { public struct RedactionStats { public var requestsProcessed: Int = 0 public var refusedRequests: Int = 0 // WO-442: aggregate fail-closed 415 refusals. + public var modelIdentityAdvisories: Int = 0 // WO-430: nonstandard model names are observable, never refusal gates. public var requestsRedacted: Int = 0 public var secretsRedacted: Int = 0 public var advisoryMatches: Int = 0 // WO-353/354/404: advisories are audited separately. + + // WO-430: surface model-identity drift relative to forwarded requests. + public var modelIdentityAdvisoryRate: Double { + guard requestsProcessed > 0 else { return 0 } + return Double(modelIdentityAdvisories) / Double(requestsProcessed) + } } struct StreamingAuditStats { @@ -697,6 +704,11 @@ public final class ProxyServer { sendError(to: clientSocket, status: 415, message: "Unsupported upstream body shape") return } + let shouldRecordModelIdentityAdvisory = modelIdentityAdvisoryNeeded( + method: parsed.method, + path: parsed.path, + bodyData: parsed.bodyData + ) // Only scan supported Anthropic-shaped POSTs (the endpoints that carry tool results). var processedBody = parsed.body @@ -739,6 +751,9 @@ public final class ProxyServer { redactionCount: redactionCount, countForwardedRedaction: !deferForwardedRedactionStats ) + if shouldRecordModelIdentityAdvisory { + recordModelIdentityAdvisory(path: parsed.path) + } if shouldLogBodyRedactionBeforeForwarding( redactionCount: redactionCount, @@ -1158,19 +1173,11 @@ public final class ProxyServer { return pathOnly } - // WO-408: positive identification of the Anthropic Messages schema. Permissive on - // unknown keys (Anthropic adds fields over time — fail closed, never over-refuse a - // genuine future field by being strict), strict on the three load-bearing invariants, - // rejects known foreign model markers and OpenAI-only siblings that disambiguate a - // chat/completions body. + // WO-408/WO-430: identify the Messages wire shape, not the model vendor. Model names + // are sender-mutable and may name Anthropic-compatible providers, so only structural + // OpenAI siblings participate in refusal. func isAnthropicMessagesShape(_ json: [String: Any]) -> Bool { guard let messages = json["messages"] as? [[String: Any]] else { return false } - // WO-430: keep model classification as a foreign-family denylist, not a positive - // Anthropic allowlist. Gateways and future Anthropic releases may rewrite model - // names; supported path plus Messages shape remain the safety boundary. - if let model = json["model"] as? String, isKnownForeignMessagesModel(model) { - return false - } for message in messages { guard message["role"] is String else { return false } // OpenAI /v1/chat/completions carries tool_calls / function_call on messages; @@ -1204,10 +1211,8 @@ public final class ProxyServer { return !(value is NSNull) } - // WO-422: a plain OpenAI chat body can otherwise look identical to a minimal - // Anthropic Messages request once it is delivered to a /v1/messages-suffixed path. - // WO-428: keep the o-family matches dash-scoped and static so broad "o1*" prefixes - // do not classify arbitrary lookalikes as OpenAI-family models. + // WO-430: retain known foreign families only for advisory classification, never + // refusal. Keep o-family matches dash-scoped so telemetry remains deterministic. private static let knownForeignMessagesModelPrefixes = [ "gpt-", "chatgpt-", "o1-", "o2-", "o3-", "o4-", "o5-", "o6-", "gemini-", "mistral-", "llama-", "grok-", @@ -1218,6 +1223,43 @@ public final class ProxyServer { return Self.knownForeignMessagesModelPrefixes.contains { lower.hasPrefix($0) } } + // WO-430: model identity is advisory telemetry. A request that speaks the supported + // wire shape remains allowed even when a gateway or compatible provider rewrites model. + func modelIdentityAdvisoryNeeded(method: String, path: String, bodyData: Data) -> Bool { + guard method.uppercased() == "POST", isSupportedAnthropicPostPath(path), + let json = try? JSONSerialization.jsonObject(with: bodyData) as? [String: Any] else { + return false + } + let payloads: [[String: Any]] + if isSupportedAnthropicBatchesPath(path) { + let requests = json["requests"] as? [[String: Any]] ?? [] + payloads = requests.compactMap { $0["params"] as? [String: Any] } + } else { + payloads = [json] + } + guard !payloads.isEmpty, !payloads.contains(where: containsToolResult) else { return false } + return payloads.contains { payload in + guard let model = payload["model"] as? String else { return true } + if isKnownForeignMessagesModel(model) { return true } + return !isRecognizedAnthropicModelName(model) + } + } + + // WO-430: telemetry applies only when no request payload was scanned as tool_result. + private func containsToolResult(_ json: [String: Any]) -> Bool { + guard let messages = json["messages"] as? [[String: Any]] else { return false } + return messages.contains { message in + guard let content = message["content"] as? [[String: Any]] else { return false } + return content.contains { ($0["type"] as? String) == "tool_result" } + } + } + + // WO-430: recognized names suppress advisory noise but never decide admission. + private func isRecognizedAnthropicModelName(_ model: String) -> Bool { + let lower = model.lowercased() + return lower.hasPrefix("claude-") || lower.hasPrefix("anthropic.") + } + // WO-408/WO-413/WO-440: audit fail-closed refusals without repeating identical noise. private func logUnsupportedBodyShapeRefusal(path: String, reason: String) { let signature = "refused:\(path):\(reason)" @@ -1451,6 +1493,16 @@ public final class ProxyServer { statsLock.unlock() } + // WO-430: preserve compatible traffic while making model-identity drift measurable. + private func recordModelIdentityAdvisory(path: String) { + statsLock.lock() + stats.modelIdentityAdvisories += 1 + let advisoryCount = stats.modelIdentityAdvisories + let requestCount = stats.requestsProcessed + statsLock.unlock() + logModelIdentityAdvisory(path: path, advisoryCount: advisoryCount, requestCount: requestCount) + } + func recordForwardedBodyRedactionStats(redactionCount: Int) { guard redactionCount > 0 else { return } statsLock.lock() @@ -2015,6 +2067,7 @@ public final class ProxyServer { private var lastRedactionLogSignatures: [RedactionLogSource: String] = [:] // WO-378: source-scoped dedup. private var lastAdvisoryLogSignatures: [RedactionLogSource: String] = [:] // WO-404: source-scoped advisory dedup. private var lastRefusalLogSignature: String? // WO-440: throttle repeated unsupported-shape audit lines. + private var lastModelIdentityAdvisorySignature: String? // WO-430: throttle repeated model-identity audit lines. private enum RedactionLogSource: String { case request @@ -2129,6 +2182,34 @@ public final class ProxyServer { } } + // WO-430: model names are advisory metadata; log drift without exposing the value. + private func logModelIdentityAdvisory(path: String, advisoryCount: Int, requestCount: Int) { + let signature = "model-identity:\(path)" + statsLock.lock() + let isRepeat = signature == lastModelIdentityAdvisorySignature + lastModelIdentityAdvisorySignature = signature + if !isRepeat { lastRefusalLogSignature = nil } + statsLock.unlock() + guard !isRepeat else { return } + + let line = "[\(formatAuditTimestamp(Date()))] PROXY MODEL ADVISORY nonstandard model identity in \(path) " + + "(wire shape allowed; rate=\(advisoryCount)/\(requestCount))\n" + if !quietLog { + FileHandle.standardError.write(Data(line.utf8)) + } + if let logPath = auditLogPath { + logQueue.async { + if let handle = FileHandle(forWritingAtPath: logPath) { + handle.seekToEndOfFile() + handle.write(Data(line.utf8)) + handle.closeFile() + } else { + FileManager.default.createFile(atPath: logPath, contents: Data(line.utf8)) + } + } + } + } + func logAlertInjectionSkipped(path: String, contentType: String) { let timestamp = formatAuditTimestamp(Date()) let normalizedType = contentType.isEmpty ? "missing" : contentType diff --git a/Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift b/Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift index 31bcbcb..0349549 100644 --- a/Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift +++ b/Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift @@ -20,6 +20,10 @@ final class ProxyBodyShapeGuardTests: XCTestCase { server().upstreamBodyShapeVerdict(method: method, path: path, bodyData: bodyData) } + private func modelAdvisory(_ path: String, _ body: String) -> Bool { + server().modelIdentityAdvisoryNeeded(method: "POST", path: path, bodyData: Data(body.utf8)) + } + // MARK: - Anthropic shapes are allowed func testAnthropicToolResultBodyAllowed() { @@ -101,12 +105,34 @@ final class ProxyBodyShapeGuardTests: XCTestCase { XCTAssertEqual(verdict("POST", "/v1/messages/batches/", batchesBody), .allow) } - func testUnknownModelAliasAllowedOnMessagesPath() { - // WO-430: model identity is a foreign-family denylist, not a fragile Anthropic allowlist. + func testNonstandardModelNamesAreAllowedAndAdvisoryOnly() { + // WO-430: vendor identity cannot refuse a valid Anthropic wire shape. + for model in [ + "company-gateway-claude-alias", "qwen-max", "deepseek-chat", + "o7-preview", "command-r", "kimi-k2", "gpt-4", "o1-mini", + ] { + let body = """ + {"model":"\(model)","messages":[{"role":"user","content":"hi"}]} + """ + XCTAssertEqual(verdict("POST", "/v1/messages", body), .allow, model) + XCTAssertTrue(modelAdvisory("/v1/messages", body), model) + } + } + + func testRecognizedAnthropicModelDoesNotRaiseModelAdvisory() { let body = """ - {"model":"company-gateway-claude-alias","messages":[{"role":"user","content":"hi"}]} + {"model":"claude-3","messages":[{"role":"user","content":"hi"}]} """ XCTAssertEqual(verdict("POST", "/v1/messages", body), .allow) + XCTAssertFalse(modelAdvisory("/v1/messages", body)) + } + + func testNonstandardModelWithToolResultDoesNotRaiseModelAdvisory() { + let body = """ + {"model":"qwen-max","messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"x","content":"safe"}]}]} + """ + XCTAssertEqual(verdict("POST", "/v1/messages", body), .allow) + XCTAssertFalse(modelAdvisory("/v1/messages", body)) } // MARK: - Foreign shapes are refused @@ -165,14 +191,12 @@ final class ProxyBodyShapeGuardTests: XCTestCase { ) } - func testMessageBatchWithForeignParamsRefused() { + func testMessageBatchWithNonstandardModelIsAllowedAndAdvisoryOnly() { let body = """ {"requests":[{"custom_id":"r1","params":{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}}]} """ - XCTAssertEqual( - verdict("POST", "/v1/messages/batches", body), - .refuse("malformed Anthropic batch body on /v1/messages/batches") - ) + XCTAssertEqual(verdict("POST", "/v1/messages/batches", body), .allow) + XCTAssertTrue(modelAdvisory("/v1/messages/batches", body)) } func testMalformedMessageBatchRefused() { @@ -195,38 +219,33 @@ final class ProxyBodyShapeGuardTests: XCTestCase { ) } - func testPlainOpenAIShapeOnMessagesEndpointRefusedLayerB() { - // WO-422: model markers keep plain OpenAI bodies from passing as tiny - // Anthropic requests when a gateway misroutes them to /v1/messages. + func testPlainOpenAIModelOnMessagesEndpointIsAdvisoryOnly() { + // WO-430: a model marker is not a wire-shape discriminator. let body = """ {"model":"gpt-4","messages":[{"role":"user","content":"hello"}]} """ - XCTAssertEqual( - verdict("POST", "/v1/messages", body), - .refuse("non-Anthropic messages schema on /v1/messages") - ) + XCTAssertEqual(verdict("POST", "/v1/messages", body), .allow) + XCTAssertTrue(modelAdvisory("/v1/messages", body)) } - func testOFamilyModelDashPrefixesRefusedLayerB() { - // WO-428: future OpenAI o-family dash-prefixed models must fail closed. + func testOFamilyModelDashPrefixesAreAdvisoryOnly() { + // WO-430 supersedes WO-428's refusal posture: model identity cannot block traffic. for model in ["o1-mini", "o2-mini", "o3-mini", "o4-mini", "o5-mini", "o6-mini"] { let body = """ {"model":"\(model)","messages":[{"role":"user","content":"hello"}]} """ - XCTAssertEqual( - verdict("POST", "/v1/messages", body), - .refuse("non-Anthropic messages schema on /v1/messages"), - "expected \(model) to be refused" - ) + XCTAssertEqual(verdict("POST", "/v1/messages", body), .allow, model) + XCTAssertTrue(modelAdvisory("/v1/messages", body), model) } } - func testOFamilyBareLookalikeDoesNotMatchForeignPrefix() { - // WO-428: avoid the old broad "o1*" match; unknown future fields stay permissive. + func testOFamilyBareLookalikeRemainsAllowedButAdvisory() { + // WO-430: unknown model names stay permissive regardless of prefix classification. let body = """ {"model":"o1fast","messages":[{"role":"user","content":"hello"}]} """ XCTAssertEqual(verdict("POST", "/v1/messages", body), .allow) + XCTAssertTrue(modelAdvisory("/v1/messages", body)) } func testTopLevelJSONArrayRefusedOnUnsupportedPath() { diff --git a/Tests/PastewatchTests/ProxyRealServerTests.swift b/Tests/PastewatchTests/ProxyRealServerTests.swift index 6b2d5c5..3496fa4 100644 --- a/Tests/PastewatchTests/ProxyRealServerTests.swift +++ b/Tests/PastewatchTests/ProxyRealServerTests.swift @@ -638,6 +638,57 @@ final class ProxyRealServerTests: XCTestCase { XCTAssertEqual(proxy.stats.requestsProcessed, 1) } + // WO-430: nonstandard model names are forwarded on a valid Messages wire shape + // and surfaced only through aggregate telemetry plus an off-band audit advisory. + func testNonstandardModelIdentityIsForwardedAndAdvisoryOnly() throws { + let upstream = try StubHTTPServer { _ in + StubHTTPResponse( + status: 200, + headers: ["Content-Type": "application/json"], + body: Data(#"{"ok":true}"#.utf8) + ) + } + try upstream.start() + defer { upstream.stop() } + + let auditPath = FileManager.default.temporaryDirectory + .appendingPathComponent("pastewatch-model-advisory-\(UUID().uuidString).log") + defer { try? FileManager.default.removeItem(at: auditPath) } + + let proxyPort = try TCPTestSocket.reserveLoopbackPort() + let proxy = ProxyServer( + port: proxyPort, + upstream: URL(string: "http://127.0.0.1:\(upstream.port)")!, + auditLogPath: auditPath.path, + quietLog: true + ) + let runningProxy = RunningProxy(server: proxy) + try runningProxy.start() + defer { runningProxy.stop() } + + for model in ["claude-3", "qwen-max"] { + let body = """ + {"model":"\(model)","messages":[{"role":"user","content":"hello"}]} + """ + let response = try TCPTestSocket.roundTrip( + port: proxyPort, + request: TCPTestSocket.postRequest(path: "/v1/messages", body: body), + timeoutSeconds: 10 + ) + XCTAssertFalse(response.contains("HTTP/1.1 415"), model) + } + proxy.drainAuditLogForTesting() + + let audit = try String(contentsOf: auditPath, encoding: .utf8) + XCTAssertEqual(upstream.requestCount, 2) + XCTAssertEqual(proxy.stats.requestsProcessed, 2) + XCTAssertEqual(proxy.stats.modelIdentityAdvisories, 1) + XCTAssertEqual(proxy.stats.modelIdentityAdvisoryRate, 0.5, accuracy: 0.000_001) + XCTAssertEqual(audit.components(separatedBy: "nonstandard model identity").count - 1, 1, audit) + XCTAssertTrue(audit.contains("rate=1/2"), audit) + XCTAssertFalse(audit.contains("qwen-max"), audit) + } + // WO-408: an Anthropic-shaped count-tokens body is forwarded, not falsely refused. func testAnthropicCountTokensNotRefused() throws { let upstream = try StubHTTPServer { _ in From 36e187e14c1b60b1d4b9e4315e62fef801f99efe Mon Sep 17 00:00:00 2001 From: ppiankov <103106369+ppiankov@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:20:32 +0800 Subject: [PATCH 11/29] fix: harden proxy batch advisory handling --- Sources/PastewatchCore/ProxyServer.swift | 126 +++++++++--- .../ProxyBodyShapeGuardTests.swift | 33 ++++ .../ProxyRealServerTests.swift | 187 ++++++++++++++++++ 3 files changed, 317 insertions(+), 29 deletions(-) diff --git a/Sources/PastewatchCore/ProxyServer.swift b/Sources/PastewatchCore/ProxyServer.swift index f0acc61..84d0779 100644 --- a/Sources/PastewatchCore/ProxyServer.swift +++ b/Sources/PastewatchCore/ProxyServer.swift @@ -704,7 +704,7 @@ public final class ProxyServer { sendError(to: clientSocket, status: 415, message: "Unsupported upstream body shape") return } - let shouldRecordModelIdentityAdvisory = modelIdentityAdvisoryNeeded( + let modelIdentityAdvisory = modelIdentityAdvisory( method: parsed.method, path: parsed.path, bodyData: parsed.bodyData @@ -751,8 +751,8 @@ public final class ProxyServer { redactionCount: redactionCount, countForwardedRedaction: !deferForwardedRedactionStats ) - if shouldRecordModelIdentityAdvisory { - recordModelIdentityAdvisory(path: parsed.path) + if let modelIdentityAdvisory { + recordModelIdentityAdvisory(path: parsed.path, dedupKey: modelIdentityAdvisory.dedupKey) } if shouldLogBodyRedactionBeforeForwarding( @@ -1223,12 +1223,21 @@ public final class ProxyServer { return Self.knownForeignMessagesModelPrefixes.contains { lower.hasPrefix($0) } } - // WO-430: model identity is advisory telemetry. A request that speaks the supported - // wire shape remains allowed even when a gateway or compatible provider rewrites model. + // WO-445: retain model identity only in the in-memory dedup key; audit output stays opaque. + private struct ModelIdentityAdvisory { + let dedupKey: String + } + + // WO-430/WO-446: model identity is advisory telemetry. A request that speaks the + // supported wire shape remains allowed, and each batch payload is classified independently. func modelIdentityAdvisoryNeeded(method: String, path: String, bodyData: Data) -> Bool { + modelIdentityAdvisory(method: method, path: path, bodyData: bodyData) != nil + } + + private func modelIdentityAdvisory(method: String, path: String, bodyData: Data) -> ModelIdentityAdvisory? { guard method.uppercased() == "POST", isSupportedAnthropicPostPath(path), let json = try? JSONSerialization.jsonObject(with: bodyData) as? [String: Any] else { - return false + return nil } let payloads: [[String: Any]] if isSupportedAnthropicBatchesPath(path) { @@ -1237,12 +1246,18 @@ public final class ProxyServer { } else { payloads = [json] } - guard !payloads.isEmpty, !payloads.contains(where: containsToolResult) else { return false } - return payloads.contains { payload in - guard let model = payload["model"] as? String else { return true } - if isKnownForeignMessagesModel(model) { return true } - return !isRecognizedAnthropicModelName(model) + guard !payloads.isEmpty else { return nil } + let identities = payloads.compactMap { payload -> String? in + guard !containsToolResult(payload) else { return nil } + guard let model = payload["model"] as? String else { return "missing" } + guard isKnownForeignMessagesModel(model) || !isRecognizedAnthropicModelName(model) else { + return nil + } + return "model:\(model.utf8.count):\(model)" } + guard !identities.isEmpty else { return nil } + let dedupKey = Array(Set(identities)).sorted().joined(separator: "|") + return ModelIdentityAdvisory(dedupKey: dedupKey) } // WO-430: telemetry applies only when no request payload was scanned as tool_result. @@ -1266,6 +1281,8 @@ public final class ProxyServer { statsLock.lock() let isRepeat = signature == lastRefusalLogSignature lastRefusalLogSignature = signature + // WO-443: an emitted refusal breaks the model-advisory dedup chain symmetrically. + if !isRepeat { lastModelIdentityAdvisorySignature = nil } statsLock.unlock() guard !isRepeat else { return } @@ -1295,8 +1312,8 @@ public final class ProxyServer { ) } - // WO-437: count_tokens requests can omit messages; scan top-level system text - // before the messages-only tool_result walk would otherwise return unchanged. + // WO-444/WO-447: count_tokens and batch params can carry system text as either a + // string or typed text blocks; preserve all non-text block metadata. private func redactTopLevelStringFields( _ json: [String: Any], redacted: inout Int, @@ -1306,20 +1323,53 @@ public final class ProxyServer { ) -> [String: Any] { var result = json for field in ["system"] { - guard let value = json[field] as? String else { continue } - let matches = scanProxyText(value) - let filtered = mutationSafeProxyMatches(matches) - let advisories = streamAdvisoryMatches(matches, severity: severity) - advisoryCount += advisories.count - advisoryTypes.append(contentsOf: advisories.map { $0.displayName }) - guard !filtered.isEmpty else { continue } - result[field] = Obfuscator.obfuscate(value, matches: filtered) - redacted += filtered.count - types.append(contentsOf: filtered.map { $0.displayName }) + if let value = json[field] as? String { + result[field] = redactScannableText( + value, + redacted: &redacted, + types: &types, + advisoryCount: &advisoryCount, + advisoryTypes: &advisoryTypes + ) + continue + } + guard var blocks = json[field] as? [[String: Any]] else { continue } + for index in blocks.indices { + guard blocks[index]["type"] as? String == "text", + let text = blocks[index]["text"] as? String else { continue } + blocks[index]["text"] = redactScannableText( + text, + redacted: &redacted, + types: &types, + advisoryCount: &advisoryCount, + advisoryTypes: &advisoryTypes + ) + } + result[field] = blocks } return result } + // WO-444/WO-447: keep certainty-gated mutation and advisory accounting identical + // across string and block-array system representations. + private func redactScannableText( + _ value: String, + redacted: inout Int, + types: inout [String], + advisoryCount: inout Int, + advisoryTypes: inout [String] + ) -> String { + let matches = scanProxyText(value) + let filtered = mutationSafeProxyMatches(matches) + let advisories = streamAdvisoryMatches(matches, severity: severity) + advisoryCount += advisories.count + advisoryTypes.append(contentsOf: advisories.map { $0.displayName }) + guard !filtered.isEmpty else { return value } + redacted += filtered.count + types.append(contentsOf: filtered.map { $0.displayName }) + return Obfuscator.obfuscate(value, matches: filtered) + } + /// Walk the messages array looking for tool_result content to scan. private func redactContentArray( _ json: [String: Any], @@ -1415,13 +1465,20 @@ public final class ProxyServer { for index in requests.indices { guard let params = requests[index]["params"] as? [String: Any] else { continue } - requests[index]["params"] = redactContentArray( + let processedSystem = redactTopLevelStringFields( params, redacted: &redacted, types: &types, advisoryCount: &advisoryCount, advisoryTypes: &advisoryTypes ) + requests[index]["params"] = redactContentArray( + processedSystem, + redacted: &redacted, + types: &types, + advisoryCount: &advisoryCount, + advisoryTypes: &advisoryTypes + ) } result["requests"] = requests @@ -1493,14 +1550,20 @@ public final class ProxyServer { statsLock.unlock() } - // WO-430: preserve compatible traffic while making model-identity drift measurable. - private func recordModelIdentityAdvisory(path: String) { + // WO-430/WO-445: preserve compatible traffic while making model drift measurable + // and deduplicating only identical path-and-model advisories. + private func recordModelIdentityAdvisory(path: String, dedupKey: String) { statsLock.lock() stats.modelIdentityAdvisories += 1 let advisoryCount = stats.modelIdentityAdvisories let requestCount = stats.requestsProcessed statsLock.unlock() - logModelIdentityAdvisory(path: path, advisoryCount: advisoryCount, requestCount: requestCount) + logModelIdentityAdvisory( + path: path, + dedupKey: dedupKey, + advisoryCount: advisoryCount, + requestCount: requestCount + ) } func recordForwardedBodyRedactionStats(redactionCount: Int) { @@ -2183,8 +2246,13 @@ public final class ProxyServer { } // WO-430: model names are advisory metadata; log drift without exposing the value. - private func logModelIdentityAdvisory(path: String, advisoryCount: Int, requestCount: Int) { - let signature = "model-identity:\(path)" + private func logModelIdentityAdvisory( + path: String, + dedupKey: String, + advisoryCount: Int, + requestCount: Int + ) { + let signature = "model-identity:\(path):\(dedupKey)" statsLock.lock() let isRepeat = signature == lastModelIdentityAdvisorySignature lastModelIdentityAdvisorySignature = signature diff --git a/Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift b/Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift index 0349549..8fc793f 100644 --- a/Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift +++ b/Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift @@ -199,6 +199,39 @@ final class ProxyBodyShapeGuardTests: XCTestCase { XCTAssertTrue(modelAdvisory("/v1/messages/batches", body)) } + // WO-446: a tool_result suppresses model telemetry only for its own batch payload. + func testMixedBatchStillAdvisesForUnscannedNonstandardModel() { + let body = """ + {"requests":[ + {"custom_id":"scanned","params":{"model":"qwen-max","messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"safe"}]}]}}, + {"custom_id":"unscanned","params":{"model":"deepseek-chat","messages":[{"role":"user","content":"hello"}]}} + ]} + """ + XCTAssertTrue(modelAdvisory("/v1/messages/batches", body)) + } + + // WO-446: batches need no model advisory when every nonstandard payload was scanned. + func testAllToolResultBatchSuppressesModelAdvisory() { + let body = """ + {"requests":[ + {"custom_id":"r1","params":{"model":"qwen-max","messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"safe"}]}]}}, + {"custom_id":"r2","params":{"model":"deepseek-chat","messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_2","content":"safe"}]}]}} + ]} + """ + XCTAssertFalse(modelAdvisory("/v1/messages/batches", body)) + } + + // WO-446: recognized models never create advisory telemetry in a batch. + func testRecognizedModelBatchNeedsNoModelAdvisory() { + let body = """ + {"requests":[ + {"custom_id":"r1","params":{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}}, + {"custom_id":"r2","params":{"model":"anthropic.gateway-alias","messages":[{"role":"user","content":"hello"}]}} + ]} + """ + XCTAssertFalse(modelAdvisory("/v1/messages/batches", body)) + } + func testMalformedMessageBatchRefused() { let body = """ {"requests":[{"custom_id":"r1","params":{"model":"claude-3","input":"hi"}}]} diff --git a/Tests/PastewatchTests/ProxyRealServerTests.swift b/Tests/PastewatchTests/ProxyRealServerTests.swift index 3496fa4..2b01a41 100644 --- a/Tests/PastewatchTests/ProxyRealServerTests.swift +++ b/Tests/PastewatchTests/ProxyRealServerTests.swift @@ -137,6 +137,48 @@ final class ProxyRealServerTests: XCTestCase { XCTAssertTrue(forwarded.contains(""), "upstream system field missing redaction placeholder") } + // WO-447: array-form system text is scanned without dropping block metadata. + func testAnthropicSystemTextBlocksRedactedAndMetadataPreserved() throws { + let requestLock = NSLock() + var upstreamRequest = "" + let upstream = try StubHTTPServer { request in + requestLock.lock() + upstreamRequest = String(data: request, encoding: .utf8) ?? "" + requestLock.unlock() + return StubHTTPResponse(status: 200, headers: [:], body: Data(#"{"ok":true}"#.utf8)) + } + try upstream.start() + defer { upstream.stop() } + + let proxyPort = try TCPTestSocket.reserveLoopbackPort() + let proxy = ProxyServer( + port: proxyPort, + upstream: URL(string: "http://127.0.0.1:\(upstream.port)")! + ) + let runningProxy = RunningProxy(server: proxy) + try runningProxy.start() + defer { runningProxy.stop() } + + let credential = "password=system-block-hunter2" + let body = """ + {"model":"claude-3","system":[{"type":"text","text":"\(credential)","cache_control":{"type":"ephemeral"}},{"type":"image","source":"unchanged"}],"messages":[{"role":"user","content":"hello"}]} + """ + let response = try TCPTestSocket.roundTrip( + port: proxyPort, + request: TCPTestSocket.postRequest(path: "/v1/messages", body: body), + timeoutSeconds: 10 + ) + + requestLock.lock() + let forwarded = upstreamRequest + requestLock.unlock() + XCTAssertTrue(response.contains("HTTP/1.1 200 OK"), TCPTestSocket.describeResponse(response)) + XCTAssertFalse(forwarded.contains(credential), "upstream system block leaked raw credential") + XCTAssertTrue(forwarded.contains(""), "upstream system block missing placeholder") + XCTAssertTrue(forwarded.contains(#""cache_control":{"type":"ephemeral"}"#), forwarded) + XCTAssertTrue(forwarded.contains(#""source":"unchanged""#), forwarded) + } + // WO-432: Message Batch params pass the shape guard and use the same request redactor. func testAnthropicMessageBatchRedactedThroughShapeGuardBeforeUpstream() throws { let requestLock = NSLock() @@ -184,6 +226,56 @@ final class ProxyRealServerTests: XCTestCase { XCTAssertTrue(forwarded.contains(""), "upstream batch request missing redaction placeholder") } + // WO-444/WO-447: every batch params.system representation uses the same scanner as + // a single Messages request, while the existing tool_result walk remains active. + func testAnthropicMessageBatchRedactsSystemFormsAndToolResults() throws { + let requestLock = NSLock() + var upstreamRequest = "" + let upstream = try StubHTTPServer { request in + requestLock.lock() + upstreamRequest = String(data: request, encoding: .utf8) ?? "" + requestLock.unlock() + return StubHTTPResponse(status: 202, headers: [:], body: Data(#"{"id":"batch_1"}"#.utf8)) + } + try upstream.start() + defer { upstream.stop() } + + let proxyPort = try TCPTestSocket.reserveLoopbackPort() + let proxy = ProxyServer( + port: proxyPort, + upstream: URL(string: "http://127.0.0.1:\(upstream.port)")! + ) + let runningProxy = RunningProxy(server: proxy) + try runningProxy.start() + defer { runningProxy.stop() } + + let stringCredential = "password=batch-system-string-hunter2" + let blockCredential = "password=batch-system-block-hunter2" + let toolCredential = "password=batch-tool-hunter2" + let body = """ + {"requests":[ + {"custom_id":"string","params":{"model":"claude-3","system":"\(stringCredential)","messages":[{"role":"user","content":"hello"}]}}, + {"custom_id":"blocks","params":{"model":"claude-3","system":[{"type":"text","text":"\(blockCredential)","cache_control":{"type":"ephemeral"}}],"messages":[{"role":"user","content":"hello"}]}}, + {"custom_id":"tool","params":{"model":"claude-3","messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"\(toolCredential)"}]}]}} + ]} + """ + let response = try TCPTestSocket.roundTrip( + port: proxyPort, + request: TCPTestSocket.postRequest(path: "/v1/messages/batches", body: body), + timeoutSeconds: 10 + ) + + requestLock.lock() + let forwarded = upstreamRequest + requestLock.unlock() + XCTAssertTrue(response.contains("HTTP/1.1 202 Accepted"), TCPTestSocket.describeResponse(response)) + for credential in [stringCredential, blockCredential, toolCredential] { + XCTAssertFalse(forwarded.contains(credential), "upstream batch leaked \(credential)") + } + XCTAssertTrue(forwarded.contains(" Date: Tue, 14 Jul 2026 21:19:58 +0800 Subject: [PATCH 12/29] fix: close proxy shape guard review gaps --- Sources/PastewatchCore/ProxyServer.swift | 164 +++++++++--- Tests/PastewatchTests/ProxyAlertTests.swift | 17 ++ .../ProxyBodyShapeGuardTests.swift | 50 ++-- .../ProxyRealServerTests.swift | 234 +++++++++++++++++- 4 files changed, 410 insertions(+), 55 deletions(-) diff --git a/Sources/PastewatchCore/ProxyServer.swift b/Sources/PastewatchCore/ProxyServer.swift index 84d0779..606e0e6 100644 --- a/Sources/PastewatchCore/ProxyServer.swift +++ b/Sources/PastewatchCore/ProxyServer.swift @@ -50,6 +50,9 @@ let proxyAdmissionQueueTimeoutMilliseconds = 250 /// WO-335: rejected sockets are written on the accept loop; keep that send bounded. let proxyRejectedSocketSendTimeoutSeconds = 2 +/// WO-486: audit request targets are bounded independently from forwarded targets. +let proxyAuditPathMaxCharacters = 512 + // MARK: - ProxyServer /// Minimal HTTP proxy that scans and redacts secrets from API request bodies. @@ -113,11 +116,16 @@ public final class ProxyServer { private let tlsTrustDelegate: TLSTrustDelegate? #endif private let urlSession: URLSession + /// WO-452: injectable serializer makes the fail-closed branch deterministic in tests. + var requestBodySerializer: ([String: Any]) throws -> Data = { + try JSONSerialization.data(withJSONObject: $0, options: []) + } public struct RedactionStats { public var requestsProcessed: Int = 0 public var refusedRequests: Int = 0 // WO-442: aggregate fail-closed 415 refusals. public var modelIdentityAdvisories: Int = 0 // WO-430: nonstandard model names are observable, never refusal gates. + public var redactionFailures: Int = 0 // WO-452: detected mutations blocked by serialization failure. public var requestsRedacted: Int = 0 public var secretsRedacted: Int = 0 public var advisoryMatches: Int = 0 // WO-353/354/404: advisories are audited separately. @@ -717,17 +725,30 @@ public final class ProxyServer { var redactedTypes: [String] = [] var bodyAdvisoryCount = 0 var bodyAdvisoryTypes: [String] = [] - if parsed.method == "POST" && isSupportedAnthropicPostPath(parsed.path) { + if isCanonicalScannablePostMethod(parsed.method) && isSupportedAnthropicPostPath(parsed.path) { // WO-429: malformed/non-UTF-8 supported-path bodies are already refused by // upstreamBodyShapeVerdict, so the scanner only receives valid UTF-8 JSON. if let body = parsed.body { let result = scanAndRedactBody(body) - processedBody = result.body - processedBodyData = Data(result.body.utf8) redactionCount = result.redacted redactedTypes = result.redactedTypes bodyAdvisoryCount = result.advisoryCount bodyAdvisoryTypes = result.advisoryTypes + // WO-452/WO-458: preserve all scan evidence, but never forward the + // original body when an authorized mutation cannot be serialized. + if result.serializationFailed { + recordRedactionFailure() + logRedactionFailure(path: parsed.path, count: redactionCount, types: redactedTypes) + recordBodyAdvisoryStats( + path: parsed.path, + count: bodyAdvisoryCount, + types: bodyAdvisoryTypes + ) + sendError(to: clientSocket, status: 500, message: "Proxy redaction error") + return + } + processedBody = result.body + processedBodyData = Data(result.body.utf8) } } @@ -1020,12 +1041,16 @@ public final class ProxyServer { let redactedTypes: [String] let advisoryCount: Int let advisoryTypes: [String] + let serializationFailed: Bool // WO-452: caller must block forwarding on failure. } func scanAndRedactBody(_ body: String) -> ScanResult { guard let data = body.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return ScanResult(body: body, redacted: 0, redactedTypes: [], advisoryCount: 0, advisoryTypes: []) + return ScanResult( + body: body, redacted: 0, redactedTypes: [], + advisoryCount: 0, advisoryTypes: [], serializationFailed: false + ) } var redacted = 0 @@ -1057,20 +1082,23 @@ public final class ProxyServer { guard redacted > 0 else { return ScanResult( body: body, redacted: 0, redactedTypes: [], - advisoryCount: advisoryCount, advisoryTypes: advisoryTypes + advisoryCount: advisoryCount, advisoryTypes: advisoryTypes, + serializationFailed: false ) } - guard let resultData = try? JSONSerialization.data(withJSONObject: processed, options: []), + guard let resultData = try? requestBodySerializer(processed), let resultString = String(data: resultData, encoding: .utf8) else { return ScanResult( - body: body, redacted: 0, redactedTypes: [], - advisoryCount: advisoryCount, advisoryTypes: advisoryTypes + body: body, redacted: redacted, redactedTypes: types, + advisoryCount: advisoryCount, advisoryTypes: advisoryTypes, + serializationFailed: true ) } return ScanResult( body: resultString, redacted: redacted, redactedTypes: types, - advisoryCount: advisoryCount, advisoryTypes: advisoryTypes + advisoryCount: advisoryCount, advisoryTypes: advisoryTypes, + serializationFailed: false ) } @@ -1084,7 +1112,11 @@ public final class ProxyServer { // redact. JSON POSTs to unsupported upstream paths are refused rather than silently // forwarded unscanned. Pure and socket-free so it is unit-testable directly. func upstreamBodyShapeVerdict(method: String, path: String, bodyData: Data) -> BodyShapeVerdict { - guard method.uppercased() == "POST" else { return .allow } + // WO-422: only canonical POST has a request-body scanner. Refuse every + // other non-empty method body instead of admitting bytes the scan branch skips. + guard isCanonicalScannablePostMethod(method) else { + return bodyData.isEmpty ? .allow : .refuse("unsupported request method with body") + } let supportedAnthropicPath = isSupportedAnthropicPostPath(path) // WO-433: an empty POST body carries no unscanned JSON shape or credential-bearing // request body, so it should reach upstream instead of being refused as malformed. @@ -1094,8 +1126,8 @@ public final class ProxyServer { // an unscanned /v1/messages body. guard let jsonValue = try? JSONSerialization.jsonObject(with: bodyData, options: [.fragmentsAllowed]) else { let reason = supportedAnthropicPath - ? "malformed Anthropic JSON body on \(path)" - : "unsupported non-JSON POST body on \(path)" + ? "malformed Anthropic JSON body" + : "unsupported non-JSON POST body" return .refuse(reason) } // WO-422: parseable JSON arrays/scalars are JSON bodies, not opaque transport bytes. @@ -1103,30 +1135,34 @@ public final class ProxyServer { // paths instead of silently forwarding it unscanned. guard let json = jsonValue as? [String: Any] else { let reason = supportedAnthropicPath - ? "malformed Anthropic JSON body on \(path)" - : "unsupported JSON POST body on \(path)" + ? "malformed Anthropic JSON body" + : "unsupported JSON POST body" return .refuse(reason) } guard supportedAnthropicPath else { - return .refuse("unsupported JSON POST body on \(path)") + return .refuse("unsupported JSON POST body") } if isSupportedAnthropicBatchesPath(path) { // WO-432: batch requests carry Messages params under requests[].params. return isAnthropicBatchShape(json) ? .allow - : .refuse("malformed Anthropic batch body on \(path)") + : .refuse("malformed Anthropic batch body") } let hasMessages = json["messages"] is [Any] - // WO-425: /v1/messages must have a messages array so the body scanner has a supported - // shape. Count-token gateway variants are allowed to omit it. + // WO-425/WO-437: Messages and Count Tokens both require a messages array. + // The prior count_tokens exception admitted arbitrary unscanned JSON objects. guard hasMessages else { - return isSupportedAnthropicCountTokensPath(path) - ? .allow - : .refuse("malformed Anthropic messages body on \(path)") + let endpoint = isSupportedAnthropicCountTokensPath(path) ? "count_tokens" : "messages" + return .refuse("malformed Anthropic \(endpoint) body") } return isAnthropicMessagesShape(json) ? .allow - : .refuse("non-Anthropic messages schema on \(path)") + : .refuse("non-Anthropic messages schema") + } + + // WO-422: admission and scanning must use one exact method predicate. + private func isCanonicalScannablePostMethod(_ method: String) -> Bool { + method == "POST" } // WO-411/WO-412: path allowlist for JSON POST bodies the proxy understands. @@ -1235,7 +1271,7 @@ public final class ProxyServer { } private func modelIdentityAdvisory(method: String, path: String, bodyData: Data) -> ModelIdentityAdvisory? { - guard method.uppercased() == "POST", isSupportedAnthropicPostPath(path), + guard isCanonicalScannablePostMethod(method), isSupportedAnthropicPostPath(path), let json = try? JSONSerialization.jsonObject(with: bodyData) as? [String: Any] else { return nil } @@ -1277,16 +1313,21 @@ public final class ProxyServer { // WO-408/WO-413/WO-440: audit fail-closed refusals without repeating identical noise. private func logUnsupportedBodyShapeRefusal(path: String, reason: String) { - let signature = "refused:\(path):\(reason)" + let safePath = auditSafePath(path) + let signature = "refused:\(safePath):\(reason)" statsLock.lock() let isRepeat = signature == lastRefusalLogSignature lastRefusalLogSignature = signature - // WO-443: an emitted refusal breaks the model-advisory dedup chain symmetrically. - if !isRepeat { lastModelIdentityAdvisorySignature = nil } + // WO-443/WO-448: an emitted refusal breaks every other audit dedup chain. + if !isRepeat { + lastRedactionLogSignatures.removeAll() + lastAdvisoryLogSignatures.removeAll() + lastModelIdentityAdvisorySignature = nil + } statsLock.unlock() guard !isRepeat else { return } - let line = "[\(formatAuditTimestamp(Date()))] PROXY REFUSED unsupported upstream body shape in \(path) (\(reason))\n" + let line = "[\(formatAuditTimestamp(Date()))] PROXY REFUSED unsupported upstream body shape in \(safePath) (\(reason))\n" if !quietLog { FileHandle.standardError.write(Data(line.utf8)) } @@ -1303,6 +1344,20 @@ public final class ProxyServer { } } + // WO-486: request targets are forwarded verbatim but audit output never includes + // query values or terminal/log control bytes. + func auditSafePath(_ rawPath: String) -> String { + let pathOnly = rawPath.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false) + .first.map(String.init) ?? rawPath + let sanitized = pathOnly.unicodeScalars.map { scalar -> String in + let value = scalar.value + let isControl = value <= 0x1f || (0x7f...0x9f).contains(value) + return isControl ? "_" : String(scalar) + }.joined() + guard sanitized.count > proxyAuditPathMaxCharacters else { return sanitized } + return String(sanitized.prefix(proxyAuditPathMaxCharacters)) + "..." + } + private func scanProxyText(_ text: String) -> [DetectedMatch] { // WO-402: proxy body scans must honor custom rules, matching streaming scans. DetectionRules.scan( @@ -1550,6 +1605,13 @@ public final class ProxyServer { statsLock.unlock() } + // WO-452: failure accounting is distinct from successfully forwarded redactions. + private func recordRedactionFailure() { + statsLock.lock() + stats.redactionFailures += 1 + statsLock.unlock() + } + // WO-430/WO-445: preserve compatible traffic while making model drift measurable // and deduplicating only identical path-and-model advisories. private func recordModelIdentityAdvisory(path: String, dedupKey: String) { @@ -2144,6 +2206,34 @@ public final class ProxyServer { } } + // WO-452: serialization failures are never represented as successful redactions + // and are intentionally not deduplicated away. + private func logRedactionFailure(path: String, count: Int, types: [String]) { + var typeCounts: [String: Int] = [:] + for type in types { typeCounts[type, default: 0] += 1 } + let breakdown = typeCounts.sorted { $0.key < $1.key } + .map { "\($0.key) x\($0.value)" } + .joined(separator: ", ") + let safePath = auditSafePath(path) + let line = "[\(formatAuditTimestamp(Date()))] PROXY REDACTION FAILED \(count) secret(s) in " + + "\(safePath) (\(breakdown))\n" + + if !quietLog { + FileHandle.standardError.write(Data(line.utf8)) + } + if let logPath = auditLogPath { + logQueue.async { + if let handle = FileHandle(forWritingAtPath: logPath) { + handle.seekToEndOfFile() + handle.write(Data(line.utf8)) + handle.closeFile() + } else { + FileManager.default.createFile(atPath: logPath, contents: Data(line.utf8)) + } + } + } + } + private func logRedaction( path: String, count: Int, @@ -2162,7 +2252,8 @@ public final class ProxyServer { // handleConnection handlers dispatched on the .concurrent queue. Acquire statsLock so // concurrent connections do not race on the dedup check. // WO-378: request and buffered-response redactions can share path/count/type values. - let signature = "\(source.rawValue):\(path):\(count):\(breakdown)" + let safePath = auditSafePath(path) + let signature = "\(source.rawValue):\(safePath):\(count):\(breakdown)" statsLock.lock() let isRepeat = signature == lastRedactionLogSignatures[source] lastRedactionLogSignatures[source] = signature @@ -2172,7 +2263,7 @@ public final class ProxyServer { let timestamp = formatAuditTimestamp(Date()) let suggestions = typeCounts.keys.sorted().compactMap { fixSuggestion(for: $0) } let fixHint = suggestions.isEmpty ? "" : " → " + suggestions.first! - let location = "\(source.auditLocationPrefix)\(path)" + let location = "\(source.auditLocationPrefix)\(safePath)" let line = "[\(timestamp)] PROXY REDACTED \(count) secret(s) in \(location) (\(breakdown))\n" let hintLine = isRepeat ? "" : (fixHint.isEmpty ? "" : " \(fixHint)\n") @@ -2217,7 +2308,8 @@ public final class ProxyServer { .map { "\($0.key) x\($0.value)" } .joined(separator: ", ") - let signature = "advisory:\(source.rawValue):\(path):\(count):\(breakdown)" + let safePath = auditSafePath(path) + let signature = "advisory:\(source.rawValue):\(safePath):\(count):\(breakdown)" statsLock.lock() let isRepeat = signature == lastAdvisoryLogSignatures[source] lastAdvisoryLogSignatures[source] = signature @@ -2225,7 +2317,7 @@ public final class ProxyServer { statsLock.unlock() let timestamp = formatAuditTimestamp(Date()) - let line = "[\(timestamp)] PROXY ADVISORY \(count) possible secret match(es) in \(path) (\(breakdown))\n" + let line = "[\(timestamp)] PROXY ADVISORY \(count) possible secret match(es) in \(safePath) (\(breakdown))\n" if !quietLog && !isRepeat { FileHandle.standardError.write(Data(line.utf8)) } @@ -2252,7 +2344,8 @@ public final class ProxyServer { advisoryCount: Int, requestCount: Int ) { - let signature = "model-identity:\(path):\(dedupKey)" + let safePath = auditSafePath(path) + let signature = "model-identity:\(safePath):\(dedupKey)" statsLock.lock() let isRepeat = signature == lastModelIdentityAdvisorySignature lastModelIdentityAdvisorySignature = signature @@ -2260,7 +2353,7 @@ public final class ProxyServer { statsLock.unlock() guard !isRepeat else { return } - let line = "[\(formatAuditTimestamp(Date()))] PROXY MODEL ADVISORY nonstandard model identity in \(path) " + let line = "[\(formatAuditTimestamp(Date()))] PROXY MODEL ADVISORY nonstandard model identity in \(safePath) " + "(wire shape allowed; rate=\(advisoryCount)/\(requestCount))\n" if !quietLog { FileHandle.standardError.write(Data(line.utf8)) @@ -2281,7 +2374,8 @@ public final class ProxyServer { func logAlertInjectionSkipped(path: String, contentType: String) { let timestamp = formatAuditTimestamp(Date()) let normalizedType = contentType.isEmpty ? "missing" : contentType - let line = "[\(timestamp)] PROXY ALERT SKIPPED in \(path) (non-json response: \(normalizedType))\n" + let line = "[\(timestamp)] PROXY ALERT SKIPPED in \(auditSafePath(path)) " + + "(non-json response: \(normalizedType))\n" if let logPath = auditLogPath { logQueue.async { if let handle = FileHandle(forWritingAtPath: logPath) { @@ -2297,7 +2391,7 @@ public final class ProxyServer { func logAlertInjectedAsSSEComment(path: String) { let timestamp = formatAuditTimestamp(Date()) - let line = "[\(timestamp)] PROXY ALERT INJECTED in \(path) (sse-comment)\n" + let line = "[\(timestamp)] PROXY ALERT INJECTED in \(auditSafePath(path)) (sse-comment)\n" if let logPath = auditLogPath { logQueue.async { if let handle = FileHandle(forWritingAtPath: logPath) { diff --git a/Tests/PastewatchTests/ProxyAlertTests.swift b/Tests/PastewatchTests/ProxyAlertTests.swift index 58694de..4dc7e69 100644 --- a/Tests/PastewatchTests/ProxyAlertTests.swift +++ b/Tests/PastewatchTests/ProxyAlertTests.swift @@ -164,6 +164,23 @@ final class ProxyAlertTests: XCTestCase { XCTAssertTrue(log.contains("/v1/messages")) } + // WO-486: audit paths are bounded single-line path components, never raw request targets. + func testAuditSafePathDropsQueryControlsAndBoundsLength() { + let queryValue = "synthetic-query-value" + let controlled = "/v1/messages\n\r\u{001B}\u{0000}\u{0085}?secret=\(queryValue)" + let sanitized = server.auditSafePath(controlled) + + XCTAssertEqual(sanitized, "/v1/messages_____") + XCTAssertFalse(sanitized.contains(queryValue)) + XCTAssertFalse(sanitized.unicodeScalars.contains { scalar in + scalar.value <= 0x1f || (0x7f...0x9f).contains(scalar.value) + }) + + let bounded = server.auditSafePath("/" + String(repeating: "a", count: 700)) + XCTAssertEqual(bounded.count, 515) + XCTAssertTrue(bounded.hasSuffix("...")) + } + func testAdvisorySSEDataUsesDistinctEventFrame() throws { let data = try XCTUnwrap(server.buildAdvisorySSEData(advisoryCount: 1, types: ["Email"])) let frame = String(data: data, encoding: .utf8) ?? "" diff --git a/Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift b/Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift index 8fc793f..e82f021 100644 --- a/Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift +++ b/Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift @@ -71,12 +71,16 @@ final class ProxyBodyShapeGuardTests: XCTestCase { XCTAssertEqual(verdict("POST", "/v1/messages", body), .allow) } - func testCountTokensEndpointWithoutMessagesArrayAllowed() { - // Some Anthropic count_tokens variants may omit a messages array. + func testCountTokensEndpointWithoutMessagesArrayRefused() { + // WO-437: official Count Tokens requests require messages; arbitrary + // messages-free objects cannot be positively identified or scanned. let body = """ {"model":"claude-3","system":"be terse"} """ - XCTAssertEqual(verdict("POST", "/v1/messages/count_tokens", body), .allow) + XCTAssertEqual( + verdict("POST", "/v1/messages/count_tokens", body), + .refuse("malformed Anthropic count_tokens body") + ) } func testMessageBatchesEndpointAllowed() { @@ -93,7 +97,7 @@ final class ProxyBodyShapeGuardTests: XCTestCase { {"model":"claude-3","messages":[{"role":"user","content":"hi"}]} """ let countTokensBody = """ - {"model":"claude-3","system":"be terse"} + {"model":"claude-3","system":"be terse","messages":[{"role":"user","content":"count"}]} """ let batchesBody = """ {"requests":[{"custom_id":"r1","params":{"model":"claude-3","messages":[{"role":"user","content":"hi"}]}}]} @@ -144,7 +148,7 @@ final class ProxyBodyShapeGuardTests: XCTestCase { """ XCTAssertEqual( verdict("POST", "/v1/chat/completions", body), - .refuse("unsupported JSON POST body on /v1/chat/completions") + .refuse("unsupported JSON POST body") ) } @@ -156,7 +160,7 @@ final class ProxyBodyShapeGuardTests: XCTestCase { """ XCTAssertEqual( verdict("POST", "/v1/chat/completions", body), - .refuse("unsupported JSON POST body on /v1/chat/completions") + .refuse("unsupported JSON POST body") ) } @@ -165,7 +169,7 @@ final class ProxyBodyShapeGuardTests: XCTestCase { let body = """ {"model":"gpt-4.1","input":"hello"} """ - XCTAssertEqual(verdict("POST", "/v1/responses", body), .refuse("unsupported JSON POST body on /v1/responses")) + XCTAssertEqual(verdict("POST", "/v1/responses", body), .refuse("unsupported JSON POST body")) } func testForeignGenerateContentBodyWithoutMessagesRefused() { @@ -176,7 +180,7 @@ final class ProxyBodyShapeGuardTests: XCTestCase { """ XCTAssertEqual( verdict("POST", "/v1beta/models/gemini:generateContent", body), - .refuse("unsupported JSON POST body on /v1beta/models/gemini:generateContent") + .refuse("unsupported JSON POST body") ) } @@ -187,7 +191,7 @@ final class ProxyBodyShapeGuardTests: XCTestCase { """ XCTAssertEqual( verdict("POST", "/v1/messages", body), - .refuse("non-Anthropic messages schema on /v1/messages") + .refuse("non-Anthropic messages schema") ) } @@ -238,7 +242,7 @@ final class ProxyBodyShapeGuardTests: XCTestCase { """ XCTAssertEqual( verdict("POST", "/v1/messages/batches", body), - .refuse("malformed Anthropic batch body on /v1/messages/batches") + .refuse("malformed Anthropic batch body") ) } @@ -248,7 +252,7 @@ final class ProxyBodyShapeGuardTests: XCTestCase { """ XCTAssertEqual( verdict("POST", "/v1/messages/batches/results", body), - .refuse("unsupported JSON POST body on /v1/messages/batches/results") + .refuse("unsupported JSON POST body") ) } @@ -288,7 +292,7 @@ final class ProxyBodyShapeGuardTests: XCTestCase { """ XCTAssertEqual( verdict("POST", "/v1/chat/completions", body), - .refuse("unsupported JSON POST body on /v1/chat/completions") + .refuse("unsupported JSON POST body") ) } @@ -298,7 +302,7 @@ final class ProxyBodyShapeGuardTests: XCTestCase { """ XCTAssertEqual( verdict("POST", "/v1/messages", body), - .refuse("malformed Anthropic JSON body on /v1/messages") + .refuse("malformed Anthropic JSON body") ) } @@ -306,7 +310,7 @@ final class ProxyBodyShapeGuardTests: XCTestCase { let data = Data([0xff, 0xfe, 0xfd]) XCTAssertEqual( verdict("POST", "/v1/chat/completions", bodyData: data), - .refuse("unsupported non-JSON POST body on /v1/chat/completions") + .refuse("unsupported non-JSON POST body") ) } @@ -314,7 +318,7 @@ final class ProxyBodyShapeGuardTests: XCTestCase { let data = Data([0xff, 0xfe, 0xfd]) XCTAssertEqual( verdict("POST", "/v1/messages", bodyData: data), - .refuse("malformed Anthropic JSON body on /v1/messages") + .refuse("malformed Anthropic JSON body") ) } @@ -351,14 +355,14 @@ final class ProxyBodyShapeGuardTests: XCTestCase { func testNonJSONBodyOnMessagesPathRefused() { XCTAssertEqual( verdict("POST", "/v1/messages", "not json at all"), - .refuse("malformed Anthropic JSON body on /v1/messages") + .refuse("malformed Anthropic JSON body") ) } func testNonJSONBodyOnUnsupportedPathRefused() { XCTAssertEqual( verdict("POST", "/v1/anything", "not json at all"), - .refuse("unsupported non-JSON POST body on /v1/anything") + .refuse("unsupported non-JSON POST body") ) } @@ -378,6 +382,18 @@ final class ProxyBodyShapeGuardTests: XCTestCase { XCTAssertEqual(verdict("OPTIONS", "/v1/messages", ""), .allow) } + func testNonCanonicalOrUnsupportedMethodsWithBodiesAreRefused() { + // WO-422: method admission and body scanning use the same exact POST predicate. + let body = #"{"model":"claude-3","messages":[{"role":"user","content":"password=hidden"}]}"# + for method in ["post", "Post", "PUT", "PATCH", "GET", "DELETE", "PROPFIND"] { + XCTAssertEqual( + verdict(method, "/v1/messages", body), + .refuse("unsupported request method with body"), + method + ) + } + } + // MARK: - Supported path predicate direct func testSupportedAnthropicPathPredicate() { diff --git a/Tests/PastewatchTests/ProxyRealServerTests.swift b/Tests/PastewatchTests/ProxyRealServerTests.swift index 2b01a41..4c15dbe 100644 --- a/Tests/PastewatchTests/ProxyRealServerTests.swift +++ b/Tests/PastewatchTests/ProxyRealServerTests.swift @@ -622,6 +622,33 @@ final class ProxyRealServerTests: XCTestCase { XCTAssertTrue(stderr.contains("unsupported JSON POST body"), stderr) } + func testUnsupportedBodyBearingMethodsAreRefusedBeforeUpstream() throws { + // WO-422: no method may carry an unscanned body through the proxy. + let upstream = try StubHTTPServer { _ in + StubHTTPResponse(status: 200, headers: [:], body: Data(#"{"ok":true}"#.utf8)) + } + try upstream.start() + defer { upstream.stop() } + + let proxyPort = try TCPTestSocket.reserveLoopbackPort() + let proxy = ProxyServer(port: proxyPort, upstream: URL(string: "http://127.0.0.1:\(upstream.port)")!) + let runningProxy = RunningProxy(server: proxy) + try runningProxy.start() + defer { runningProxy.stop() } + + let body = #"{"model":"claude-3","messages":[{"role":"user","content":"password=method-hunter2"}]}"# + for method in ["post", "PUT", "PATCH", "GET"] { + let response = try TCPTestSocket.roundTrip( + port: proxyPort, + request: TCPTestSocket.request(method: method, path: "/v1/messages", body: body), + timeoutSeconds: 10 + ) + XCTAssertTrue(response.contains("HTTP/1.1 415 Unsupported Media Type"), "\(method): \(response)") + } + XCTAssertEqual(upstream.requestCount, 0) + XCTAssertEqual(proxy.stats.refusedRequests, 4) + } + // WO-440/WO-442: repeated unsupported-shape refusals are counted but audit-deduped. func testRepeatedUnsupportedBodyShapeRefusalsAreDedupedAndCounted() throws { let upstream = try StubHTTPServer { _ in @@ -730,6 +757,124 @@ final class ProxyRealServerTests: XCTestCase { XCTAssertEqual(proxy.stats.requestsProcessed, 1) } + // WO-448: a refusal must also break the preceding redaction dedup chain. + func testRedactionDedupResetsAfterRefusalAudit() throws { + let upstream = try StubHTTPServer { _ in + StubHTTPResponse(status: 200, headers: [:], body: Data(#"{"ok":true}"#.utf8)) + } + try upstream.start() + defer { upstream.stop() } + + let auditPath = FileManager.default.temporaryDirectory + .appendingPathComponent("pastewatch-redaction-reset-\(UUID().uuidString).log") + defer { try? FileManager.default.removeItem(at: auditPath) } + let proxyPort = try TCPTestSocket.reserveLoopbackPort() + let proxy = ProxyServer( + port: proxyPort, + upstream: URL(string: "http://127.0.0.1:\(upstream.port)")!, + auditLogPath: auditPath.path, + quietLog: true + ) + let runningProxy = RunningProxy(server: proxy) + try runningProxy.start() + defer { runningProxy.stop() } + + let body = #"{"model":"claude-3","messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"password=reset-hunter2"}]}]}"# + for path in ["/v1/messages", "/v1/responses", "/v1/messages"] { + let requestBody = path == "/v1/messages" ? body : #"{"input":"hello"}"# + _ = try TCPTestSocket.roundTrip( + port: proxyPort, + request: TCPTestSocket.postRequest(path: path, body: requestBody), + timeoutSeconds: 10 + ) + } + proxy.drainAuditLogForTesting() + + let audit = try String(contentsOf: auditPath, encoding: .utf8) + XCTAssertEqual(audit.components(separatedBy: "PROXY REDACTED").count - 1, 2, audit) + XCTAssertEqual(audit.components(separatedBy: "PROXY REFUSED").count - 1, 1, audit) + XCTAssertEqual(proxy.stats.requestsProcessed, 2) + XCTAssertEqual(proxy.stats.refusedRequests, 1) + } + + // WO-448: a refusal must break the preceding advisory dedup chain as well. + func testAdvisoryDedupResetsAfterRefusalAudit() throws { + let upstream = try StubHTTPServer { _ in + StubHTTPResponse(status: 200, headers: [:], body: Data()) + } + try upstream.start() + defer { upstream.stop() } + + let auditPath = FileManager.default.temporaryDirectory + .appendingPathComponent("pastewatch-advisory-reset-\(UUID().uuidString).log") + defer { try? FileManager.default.removeItem(at: auditPath) } + let proxyPort = try TCPTestSocket.reserveLoopbackPort() + let proxy = ProxyServer( + port: proxyPort, + upstream: URL(string: "http://127.0.0.1:\(upstream.port)")!, + auditLogPath: auditPath.path, + quietLog: true + ) + let runningProxy = RunningProxy(server: proxy) + try runningProxy.start() + defer { runningProxy.stop() } + + proxy.recordBodyAdvisoryStats(path: "/v1/messages", count: 1, types: ["Email"]) + _ = try TCPTestSocket.roundTrip( + port: proxyPort, + request: TCPTestSocket.postRequest(path: "/v1/responses", body: #"{"input":"hello"}"#), + timeoutSeconds: 10 + ) + proxy.recordBodyAdvisoryStats(path: "/v1/messages", count: 1, types: ["Email"]) + proxy.drainAuditLogForTesting() + + let audit = try String(contentsOf: auditPath, encoding: .utf8) + XCTAssertEqual(audit.components(separatedBy: "PROXY ADVISORY").count - 1, 2, audit) + XCTAssertEqual(audit.components(separatedBy: "PROXY REFUSED").count - 1, 1, audit) + XCTAssertEqual(proxy.stats.advisoryMatches, 2) + XCTAssertEqual(proxy.stats.refusedRequests, 1) + } + + // WO-486: a refused request must not copy query material into an audit record. + func testRefusalAuditOmitsQueryMaterial() throws { + let upstream = try StubHTTPServer { _ in + StubHTTPResponse(status: 200, headers: [:], body: Data()) + } + try upstream.start() + defer { upstream.stop() } + + let auditPath = FileManager.default.temporaryDirectory + .appendingPathComponent("pastewatch-audit-query-\(UUID().uuidString).log") + defer { try? FileManager.default.removeItem(at: auditPath) } + let proxyPort = try TCPTestSocket.reserveLoopbackPort() + let proxy = ProxyServer( + port: proxyPort, + upstream: URL(string: "http://127.0.0.1:\(upstream.port)")!, + auditLogPath: auditPath.path, + quietLog: true + ) + let runningProxy = RunningProxy(server: proxy) + try runningProxy.start() + defer { runningProxy.stop() } + + let queryValue = "synthetic-query-value" + _ = try TCPTestSocket.roundTrip( + port: proxyPort, + request: TCPTestSocket.postRequest( + path: "/v1/responses?secret=\(queryValue)", + body: #"{"input":"hello"}"# + ), + timeoutSeconds: 10 + ) + proxy.drainAuditLogForTesting() + + let audit = try String(contentsOf: auditPath, encoding: .utf8) + XCTAssertTrue(audit.contains("PROXY REFUSED unsupported upstream body shape in /v1/responses"), audit) + XCTAssertFalse(audit.contains("?secret="), audit) + XCTAssertFalse(audit.contains(queryValue), audit) + XCTAssertEqual(upstream.requestCount, 0) + } + // WO-430: nonstandard model names are forwarded on a valid Messages wire shape // and surfaced only through aggregate telemetry plus an off-band audit advisory. func testNonstandardModelIdentityIsForwardedAndAdvisoryOnly() throws { @@ -901,7 +1046,7 @@ final class ProxyRealServerTests: XCTestCase { XCTAssertEqual(upstream.requestCount, 1, "count_tokens must be forwarded; \(diagnostic)") } - // WO-437: count_tokens bodies without messages still scan top-level system text. + // WO-437: valid count_tokens bodies scan top-level system text. func testCountTokensSystemFieldCredentialRedacted() throws { let requestLock = NSLock() var upstreamRequest = "" @@ -935,7 +1080,7 @@ final class ProxyRealServerTests: XCTestCase { } let credential = "password=count-tokens-hunter2" - let body = #"{"model":"claude-3","system":"\#(credential)"}"# + let body = #"{"model":"claude-3","system":"\#(credential)","messages":[{"role":"user","content":"count"}]}"# let response = try TCPTestSocket.roundTrip( port: proxyPort, request: TCPTestSocket.postRequest(path: "/v1/messages/count_tokens", body: body), @@ -957,6 +1102,84 @@ final class ProxyRealServerTests: XCTestCase { XCTAssertTrue(audit.contains("Credential x1"), audit) } + func testCountTokensWithoutMessagesIsRefusedBeforeUpstream() throws { + // WO-437: arbitrary JSON is not a valid Count Tokens shape and cannot bypass scanning. + let upstream = try StubHTTPServer { _ in + StubHTTPResponse(status: 200, headers: [:], body: Data(#"{"input_tokens":3}"#.utf8)) + } + try upstream.start() + defer { upstream.stop() } + + let proxyPort = try TCPTestSocket.reserveLoopbackPort() + let proxy = ProxyServer(port: proxyPort, upstream: URL(string: "http://127.0.0.1:\(upstream.port)")!) + let runningProxy = RunningProxy(server: proxy) + try runningProxy.start() + defer { runningProxy.stop() } + + let body = #"{"input":"password=count-tokens-hunter2"}"# + let response = try TCPTestSocket.roundTrip( + port: proxyPort, + request: TCPTestSocket.postRequest(path: "/gateway/v1/messages/count_tokens/", body: body), + timeoutSeconds: 10 + ) + + XCTAssertTrue(response.contains("HTTP/1.1 415 Unsupported Media Type"), response) + XCTAssertEqual(upstream.requestCount, 0) + XCTAssertEqual(proxy.stats.refusedRequests, 1) + } + + func testSerializationFailureBlocksForwardingAndPreservesAdvisoryEvidence() throws { + // WO-452/WO-458: serializer failure is observable and cannot discard scan evidence. + let upstream = try StubHTTPServer { _ in + StubHTTPResponse(status: 200, headers: [:], body: Data(#"{"ok":true}"#.utf8)) + } + try upstream.start() + defer { upstream.stop() } + + let auditPath = FileManager.default.temporaryDirectory + .appendingPathComponent("pastewatch-redaction-failure-\(UUID().uuidString).log") + defer { try? FileManager.default.removeItem(at: auditPath) } + + let proxyPort = try TCPTestSocket.reserveLoopbackPort() + let proxy = ProxyServer( + port: proxyPort, + upstream: URL(string: "http://127.0.0.1:\(upstream.port)")!, + severity: .low, + auditLogPath: auditPath.path, + quietLog: true + ) + proxy.requestBodySerializer = { _ in throw CocoaError(.fileWriteUnknown) } + let runningProxy = RunningProxy(server: proxy) + try runningProxy.start() + defer { runningProxy.stop() } + + let rawCredential = "password=serialization-hunter2" + let rawEmail = "operator@example.net" + let body = """ + {"model":"claude-3","system":"\(rawCredential) \(rawEmail)","messages":[{"role":"user","content":"hello"}]} + """ + let response = try TCPTestSocket.roundTrip( + port: proxyPort, + request: TCPTestSocket.postRequest(path: "/v1/messages", body: body), + timeoutSeconds: 10 + ) + proxy.drainAuditLogForTesting() + + let audit = try String(contentsOf: auditPath, encoding: .utf8) + XCTAssertTrue(response.contains("HTTP/1.1 500 Internal Server Error"), response) + XCTAssertEqual(upstream.requestCount, 0) + XCTAssertEqual(proxy.stats.redactionFailures, 1) + XCTAssertEqual(proxy.stats.requestsProcessed, 0) + XCTAssertEqual(proxy.stats.requestsRedacted, 0) + XCTAssertEqual(proxy.stats.secretsRedacted, 0) + XCTAssertEqual(proxy.stats.advisoryMatches, 1) + XCTAssertTrue(audit.contains("PROXY REDACTION FAILED 1 secret(s) in /v1/messages"), audit) + XCTAssertTrue(audit.contains("PROXY ADVISORY 1 possible secret match(es) in /v1/messages"), audit) + XCTAssertFalse(audit.contains("PROXY REDACTED"), audit) + XCTAssertFalse(audit.contains(rawCredential), audit) + XCTAssertFalse(audit.contains(rawEmail), audit) + } + func testAdmissionCapRejectsFifthConcurrentConnection() throws { let upstreamEntered = DispatchSemaphore(value: 0) let upstreamRelease = DispatchSemaphore(value: 0) @@ -1388,9 +1611,14 @@ private enum TCPTestSocket { static let validAnthropicMessagesBody = #"{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}"# static func postRequest(path: String, body: String = "{}") -> String { + request(method: "POST", path: path, body: body) + } + + // WO-422: real-socket tests exercise method admission independently from POST helpers. + static func request(method: String, path: String, body: String) -> String { let bodyData = Data(body.utf8) return """ - POST \(path) HTTP/1.1\r + \(method) \(path) HTTP/1.1\r Host: 127.0.0.1\r Content-Type: application/json\r Content-Length: \(bodyData.count)\r From faf17ac05ce85a3ee774cc76dfe37d05c44ecd7d Mon Sep 17 00:00:00 2001 From: ppiankov <103106369+ppiankov@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:20:03 +0800 Subject: [PATCH 13/29] fix: harden launch signal lifecycle --- Sources/PastewatchCLI/LaunchCommand.swift | 103 +++++++++++++----- .../PastewatchTests/LaunchCommandTests.swift | 48 ++++++++ 2 files changed, 125 insertions(+), 26 deletions(-) diff --git a/Sources/PastewatchCLI/LaunchCommand.swift b/Sources/PastewatchCLI/LaunchCommand.swift index 08d92e3..62fb995 100644 --- a/Sources/PastewatchCLI/LaunchCommand.swift +++ b/Sources/PastewatchCLI/LaunchCommand.swift @@ -12,12 +12,25 @@ import Glibc // File-level refs for signal handler access private var launchProxyProcess: Process? +private var launchProxyPid: pid_t = 0 // WO-438: signal-safe proxy child identity. private var launchAgentPid: pid_t = 0 +private var launchPendingSignal: Int32 = 0 // WO-438: deferred termination across startup windows. + +// WO-438: handlers only mutate scalar state and call kill(), both safe for this signal path. +private func handleLaunchSignal(_ signalNumber: Int32) { + launchPendingSignal = signalNumber + if launchAgentPid > 0 { + kill(launchAgentPid, signalNumber) + } else if launchProxyPid > 0 { + kill(launchProxyPid, SIGTERM) + } +} // WO-136/WO-137: test fixture HOME redirection must be unavailable in release builds. private enum StartupSweepFixtureContext { static let probeEnvironmentKey = "PW_LAUNCH_FIXTURE_CONTEXT_PROBE" // WO-137: assertion-only context probe. static let probeMarker = "pastewatch-startup-sweep-fixture-context" // WO-137: stable test probe marker. + static let preAgentDelayEnvironmentKey = "PW_LAUNCH_PRE_AGENT_DELAY_MS" // WO-438: startup signal seam. static let isEnabled: Bool = { var enabled = false @@ -114,6 +127,11 @@ struct Launch: ParsableCommand { guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return true } + // WO-423: URLComponents requires brackets around IPv6 hosts; recognize only + // unambiguous bare local literals and preserve ambiguous host:port text. + if value == "::1" || value == "[::1]" || value == "::" || value == "[::]" { + return true + } let candidates = value.contains("://") ? [value] : [value, "http://\(value)"] return candidates.contains { candidate in guard let host = URLComponents(string: candidate)?.host else { return false } @@ -125,7 +143,7 @@ struct Launch: ParsableCommand { // remote gateways, but clear loopback/any-address spellings for non-routed agents. static func isLocalAnthropicBaseURLHost(_ host: String) -> Bool { let normalized = host.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: "[]")) - if normalized == "localhost" || normalized == "::1" || normalized == "0.0.0.0" { + if normalized == "localhost" || normalized == "::1" || normalized == "::" || normalized == "0.0.0.0" { return true } if normalized.hasPrefix("::ffff:") { @@ -196,11 +214,11 @@ struct Launch: ParsableCommand { func run() throws { try runStartupSweepFixtureProbeIfNeeded() let command = try normalizedCommand() + installLaunchSignalHandlers() + try throwIfLaunchTerminationRequested() runStartupSweepIfNeeded() let config = PastewatchConfig.resolve() - if let warning = ProxyServer.bufferModeWarning(config: config, quiet: quiet) { - FileHandle.standardError.write(Data(warning.utf8)) - } + writeBufferModeWarningIfNeeded(config: config) let agentBinary = (command[0] as NSString).lastPathComponent if !Launch.isProxyRoutedAgent(agentBinary) { @@ -255,19 +273,25 @@ struct Launch: ParsableCommand { FileHandle.standardError.write(Data("error: failed to start proxy: \(error)\n".utf8)) throw ExitCode(rawValue: 3) } + launchProxyPid = proxy.processIdentifier + defer { + if proxy.isRunning { + proxy.terminate() + } + proxy.waitUntilExit() + launchProxyPid = 0 + launchProxyProcess = nil + } + delayBeforeAgentForTestingIfNeeded() + try throwIfLaunchTerminationRequested() // Wait for proxy to accept connections guard waitForTCP(host: "127.0.0.1", port: port, timeout: 5.0) else { - proxy.terminate() - proxy.waitUntilExit() + try throwIfLaunchTerminationRequested() FileHandle.standardError.write(Data("error: proxy failed to start (timeout waiting for port \(port))\n".utf8)) throw ExitCode(rawValue: 3) } - defer { - proxy.terminate() - proxy.waitUntilExit() - launchProxyProcess = nil - } + try throwIfLaunchTerminationRequested() if !quiet { let cmdStr = command.joined(separator: " ") @@ -283,6 +307,7 @@ struct Launch: ParsableCommand { } private func runAgentProcess(_ command: [String]) throws -> Int32 { + try throwIfLaunchTerminationRequested() // Fork: child exec's the agent (inherits TTY), parent waits and cleans up // Use @_silgen_name to bypass Swift's fork() unavailability on Darwin let pid = _pw_fork() @@ -294,6 +319,10 @@ struct Launch: ParsableCommand { if pid == 0 { // Child process — exec the agent command + // WO-438: pre-fork parent handlers must not survive the child launch + // window or intercept termination intended for the agent. + signal(SIGINT, SIG_DFL) + signal(SIGTERM, SIG_DFL) // Build null-terminated C string array for execvp let args = Array(command) let cArgs = args.map { strdup($0) } + [nil] @@ -303,28 +332,22 @@ struct Launch: ParsableCommand { _exit(127) } - // Parent process — wait for child, then clean up proxy - // Forward SIGINT to child instead of killing everything + // Parent process — wait for child, then clean up proxy. launchAgentPid = pid - signal(SIGINT) { _ in - if launchAgentPid > 0 { - kill(launchAgentPid, SIGINT) - } - } - // WO-438: process managers send SIGTERM for graceful shutdown; forward it - // to the agent child so waitpid returns and the proxy defer can run. - signal(SIGTERM) { _ in - if launchAgentPid > 0 { - kill(launchAgentPid, SIGTERM) - } + if launchPendingSignal != 0 { + kill(launchAgentPid, launchPendingSignal) } var status: Int32 = 0 - waitpid(pid, &status, 0) + while waitpid(pid, &status, 0) == -1 && errno == EINTR {} // Extract exit code let exitCode: Int32 - if (status & 0x7f) == 0 { + // WO-438: a cooperative child may trap SIGTERM and exit zero; the launch + // process must still preserve the operator's terminating signal semantics. + if launchPendingSignal != 0 { + exitCode = 128 + launchPendingSignal + } else if (status & 0x7f) == 0 { // Exited normally exitCode = (status >> 8) & 0xff } else { @@ -335,6 +358,34 @@ struct Launch: ParsableCommand { return exitCode } + // WO-438: install before any child can exist so startup signals become state, + // not default process termination that skips child cleanup. + private func installLaunchSignalHandlers() { + launchProxyPid = 0 + launchAgentPid = 0 + launchPendingSignal = 0 + signal(SIGINT, handleLaunchSignal) + signal(SIGTERM, handleLaunchSignal) + } + + private func writeBufferModeWarningIfNeeded(config: PastewatchConfig) { + guard let warning = ProxyServer.bufferModeWarning(config: config, quiet: quiet) else { return } + FileHandle.standardError.write(Data(warning.utf8)) + } + + private func throwIfLaunchTerminationRequested() throws { + guard launchPendingSignal != 0 else { return } + throw ExitCode(rawValue: 128 + launchPendingSignal) + } + + // WO-438: assertion-gated delay makes the pre-agent signal window deterministic. + private func delayBeforeAgentForTestingIfNeeded() { + guard StartupSweepFixtureContext.isEnabled, + let raw = ProcessInfo.processInfo.environment[StartupSweepFixtureContext.preAgentDelayEnvironmentKey], + let milliseconds = UInt32(raw), milliseconds > 0 else { return } + usleep(milliseconds * 1_000) + } + private func runStartupSweepFixtureProbeIfNeeded() throws { guard StartupSweepFixtureContext.isEnabled else { return } guard ProcessInfo.processInfo.environment[StartupSweepFixtureContext.probeEnvironmentKey] == "1" else { diff --git a/Tests/PastewatchTests/LaunchCommandTests.swift b/Tests/PastewatchTests/LaunchCommandTests.swift index 3825774..1636465 100644 --- a/Tests/PastewatchTests/LaunchCommandTests.swift +++ b/Tests/PastewatchTests/LaunchCommandTests.swift @@ -170,9 +170,14 @@ final class LaunchCommandTests: XCTestCase { "http://0:8443", "http://localhost:8443", "http://[::1]:8443", + "http://[::]:8443", "http://[::ffff:127.0.0.1]:8443", "127.0.0.1:8443", "localhost:8443", + "::1", + "[::1]", + "::", + "[::]", ] let shouldPreserve = [ "https://gateway.example.com/anthropic", @@ -180,6 +185,7 @@ final class LaunchCommandTests: XCTestCase { "http://08.0.0.1:8443", "https://api.anthropic.com", "gateway.example.com/anthropic", + "::1:8443", ] for value in shouldClear { @@ -242,6 +248,7 @@ final class LaunchCommandTests: XCTestCase { kill(process.processIdentifier, SIGTERM) XCTAssertTrue(waitForProcessExit(process, timeoutSeconds: 5), "launch did not exit after SIGTERM") + XCTAssertEqual(process.terminationStatus, 128 + SIGTERM) XCTAssertTrue(waitForFile(agent.termFile, timeoutSeconds: 2), "agent did not receive SIGTERM") XCTAssertTrue( waitUntil(timeoutSeconds: 5) { !self.canConnectToLoopbackPort(proxyPort) }, @@ -254,6 +261,47 @@ final class LaunchCommandTests: XCTestCase { XCTAssertEqual(err, "", "quiet launch should not write stderr: \(err)") } + // WO-438: SIGTERM after proxy spawn but before agent fork must still reap the proxy. + func testSIGTERMDuringPreAgentWindowTerminatesProxyWithoutStartingAgent() throws { + let fixture = try makeLaunchFixture() + let agent = try writeTermTrapAgent(named: "claude", in: fixture.cwd) + let proxyPort = try reserveEphemeralLoopbackPort() + let process = Process() + process.executableURL = pastewatchCLIURL() + process.arguments = [ + "launch", "--quiet", "--no-startup-sweep", "--port", "\(proxyPort)", "--", agent.script.path, + ] + process.currentDirectoryURL = fixture.cwd + var environment = fixture.environment + environment["PW_LAUNCH_PRE_AGENT_DELAY_MS"] = "3000" + process.environment = environment + process.standardOutput = Pipe() + process.standardError = Pipe() + + try process.run() + defer { + if process.isRunning { + kill(process.processIdentifier, SIGKILL) + process.waitUntilExit() + } + if let agentPid = readPID(from: agent.pidFile), processIsRunning(agentPid) { + kill(agentPid, SIGKILL) + } + } + + XCTAssertTrue(waitUntil(timeoutSeconds: 5) { self.canConnectToLoopbackPort(proxyPort) }, "proxy did not listen") + XCTAssertFalse(FileManager.default.fileExists(atPath: agent.pidFile.path), "agent started before signal seam") + + kill(process.processIdentifier, SIGTERM) + XCTAssertTrue(waitForProcessExit(process, timeoutSeconds: 5), "launch did not exit after startup SIGTERM") + XCTAssertEqual(process.terminationStatus, 128 + SIGTERM) + XCTAssertFalse(FileManager.default.fileExists(atPath: agent.pidFile.path), "agent started after SIGTERM") + XCTAssertTrue( + waitUntil(timeoutSeconds: 5) { !self.canConnectToLoopbackPort(proxyPort) }, + "proxy still accepts connections after startup SIGTERM" + ) + } + // WO-137: seam-unavailable probe fallback must not reach startup sweep or proxy. func testLaunchFixtureContextProbeUnavailablePathIsSweepSafe() throws { let fixture = try makeLaunchFixture() From f80c57c96c8245aecb73bc2de3fb51dfd0a7c30d Mon Sep 17 00:00:00 2001 From: ppiankov <103106369+ppiankov@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:25:35 +0800 Subject: [PATCH 14/29] test: make launch sockets portable --- Tests/PastewatchTests/LaunchCommandTests.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Tests/PastewatchTests/LaunchCommandTests.swift b/Tests/PastewatchTests/LaunchCommandTests.swift index 1636465..0edb6b7 100644 --- a/Tests/PastewatchTests/LaunchCommandTests.swift +++ b/Tests/PastewatchTests/LaunchCommandTests.swift @@ -547,7 +547,7 @@ final class LaunchCommandTests: XCTestCase { // WO-414: keep the listener open to prove non-routed launches skip proxy startup. private func occupyLoopbackPort() throws -> (fd: Int32, port: UInt16) { - let fd = socket(AF_INET, SOCK_STREAM, 0) + let fd = socket(AF_INET, launchTestSocketStreamType, 0) guard fd >= 0 else { throw LaunchPortError.socketFailed } var addr = sockaddr_in() addr.sin_family = sa_family_t(AF_INET) @@ -671,7 +671,7 @@ final class LaunchCommandTests: XCTestCase { } private func canConnectToLoopbackPort(_ port: UInt16) -> Bool { - let fd = socket(AF_INET, SOCK_STREAM, 0) + let fd = socket(AF_INET, launchTestSocketStreamType, 0) guard fd >= 0 else { return false } defer { close(fd) } var addr = sockaddr_in() @@ -698,3 +698,10 @@ final class LaunchCommandTests: XCTestCase { kill(pid, 0) == 0 } } + +// WO-424: Glibc exposes SOCK_STREAM as an enum while Darwin exposes Int32. +#if canImport(Darwin) +private let launchTestSocketStreamType = SOCK_STREAM +#else +private let launchTestSocketStreamType = Int32(SOCK_STREAM.rawValue) +#endif From b8d38b630eba1cbc1efb4fa1d4a129341c42ee93 Mon Sep 17 00:00:00 2001 From: ppiankov <103106369+ppiankov@users.noreply.github.com> Date: Wed, 15 Jul 2026 10:46:07 +0800 Subject: [PATCH 15/29] fix: contain deterministic proxy secrets --- README.md | 17 +- Sources/PastewatchCLI/LaunchCommand.swift | 1 + Sources/PastewatchCLI/ProxyCommand.swift | 21 ++ Sources/PastewatchCore/CurlHTTPClient.swift | 91 +++++++-- Sources/PastewatchCore/CustomRule.swift | 30 ++- Sources/PastewatchCore/DetectionRules.swift | 190 ++++++++++++++++-- Sources/PastewatchCore/ProxyServer.swift | 55 ++++- Sources/PastewatchCore/SSEStreamRelay.swift | 12 +- Sources/PastewatchCore/SocketHelpers.swift | 22 +- Sources/PastewatchCore/Types.swift | 21 +- Tests/PastewatchTests/CustomRuleTests.swift | 20 ++ .../PastewatchTests/DetectionRulesTests.swift | 143 ++++++++++++- .../PastewatchTests/LaunchCommandTests.swift | 27 +++ Tests/PastewatchTests/ProxyCommandTests.swift | 39 ++++ .../ProxyRealServerTests.swift | 72 +++++++ 15 files changed, 691 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index 5f5c50e..62a356f 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ Pastewatch started as a clipboard monitor — scan before paste, replace secrets | **Startup sweep** | Warns about pre-existing shell config credentials | `pastewatch-cli launch` scans common startup files once per changed finding summary | | **MCP server** | Redacted read/write for AI agents | Agent sees placeholders, originals stay in RAM | | **Shell guard** | Blocks secrets in commands and file access | Pre-execution hook for Claude Code, Cline, Cursor, Windsurf, Continue, Amazon Q | -| **API proxy** | Redacts secrets from outbound API traffic | Sits between agent and cloud, scans every request | +| **API proxy** | Redacts secrets from supported outbound API traffic | Scans Anthropic-shaped requests and refuses unsupported body shapes | | **VS Code extension** | Real-time detection in the editor | Highlights secrets as you type | All layers share the same detection engine — 30+ pattern types, deterministic regex, no ML. Every layer operates locally. Nothing phones home. @@ -822,7 +822,7 @@ Define additional patterns in a JSON file: ### Agent Safety Matrix -The API proxy (Layer 0) redacts supported **Anthropic-shaped** traffic (`/v1/messages` and `/v1/messages/batches`) from agents that expose an API endpoint override; it refuses unrecognized upstream body shapes (HTTP 415) rather than forward them unscanned, so it does not redact OpenAI/Gemini-shaped agents. Rows relying on "Proxy" are protected only for Anthropic-shaped traffic; rows marked proxy not applicable are limited to their listed local layers. Hooks and MCP add defense in depth and are the primary coverage for non-Anthropic-shaped agents. +The API proxy (Layer 0) redacts supported **Anthropic-shaped** traffic (`/v1/messages` and `/v1/messages/batches`) and refuses unrecognized upstream body shapes (HTTP 415). `pastewatch-cli launch` wires only Claude Code to this proxy. Other clients need hooks or MCP unless the operator separately configures an Anthropic-compatible client to use `pastewatch-cli proxy`; OpenAI/Gemini-shaped bodies are not redacted. | Agent | Protection | Hooks | MCP | Setup | |-------|-----------|-------|-----|-------| @@ -836,17 +836,16 @@ The API proxy (Layer 0) redacts supported **Anthropic-shaped** traffic (`/v1/mes | Copilot | **Structural** | preToolUse (`.github/hooks/`) | Yes | `pastewatch-cli setup copilot` | | Codex CLI | **Structural** | PreToolUse (exit 2) | Manual | `pastewatch-cli setup codex` | | Qwen Code | **Structural** | PreToolUse (exit 2) | Yes | `pastewatch-cli setup qwen-code` | -| Goose | Proxy + MCP | No hooks | Yes | `pastewatch-cli setup goose` | -| Kilo Code | Proxy + MCP | [No hooks](https://github.com/Kilo-Org/kilocode/issues/7859) (declined) | Yes | `pastewatch-cli setup kilo-code` | -| Gemini | Proxy + MCP | No hooks | Yes | `pastewatch-cli setup gemini` | +| Goose | MCP only | No hooks | Yes | `pastewatch-cli setup goose` | +| Kilo Code | MCP only | [No hooks](https://github.com/Kilo-Org/kilocode/issues/7859) (declined) | Yes | `pastewatch-cli setup kilo-code` | +| Gemini | MCP only | No hooks | Yes | `pastewatch-cli setup gemini` | | Antigravity (agy) | Proxy not applicable + MCP only (voluntary tools; no Structural read blocking) | [No](docs/research/agy-hooks-follow-up.md) - handler registration fails | [Yes](docs/research/agy-hooks-discovery.md) | Not yet supported by `pastewatch-cli setup`; edit `~/.gemini/config/mcp_config.json` manually | -| OpenCode | Proxy + MCP | No hooks | Yes | Manual | -| Aider | Proxy only | [No MCP yet](https://github.com/aider-ai/aider/issues/4506) | No | `pastewatch-cli launch -- aider` | +| OpenCode | MCP only | No hooks | Yes | Manual | +| Aider | No automatic local layer | [No MCP yet](https://github.com/aider-ai/aider/issues/4506) | No | N/A | | Jules | Cloud only | No local config | Cloud UI | N/A (use proxy on local side) | **Structural** = hooks block native file access before secrets can be read. The agent cannot bypass the check. -**Proxy + MCP** = network-level redaction for Anthropic-shaped traffic, plus MCP tools for redacted access (the agent isn't forced to use them). If the agent talks a non-Anthropic wire format, the proxy refuses (HTTP 415) rather than redact — MCP is then the real coverage. -**Proxy only** = protection comes from the network proxy, and only for Anthropic-shaped traffic. An agent that sends a non-Anthropic body shape is not redacted by the proxy (it is refused) — prefer hooks/MCP where available. +**MCP only** = redaction applies only when the agent uses the configured Pastewatch MCP tools. Native file or network access is not intercepted. ### Install diff --git a/Sources/PastewatchCLI/LaunchCommand.swift b/Sources/PastewatchCLI/LaunchCommand.swift index 62fb995..e2e4670 100644 --- a/Sources/PastewatchCLI/LaunchCommand.swift +++ b/Sources/PastewatchCLI/LaunchCommand.swift @@ -218,6 +218,7 @@ struct Launch: ParsableCommand { try throwIfLaunchTerminationRequested() runStartupSweepIfNeeded() let config = PastewatchConfig.resolve() + _ = try requireValidProxyCustomRules(config) writeBufferModeWarningIfNeeded(config: config) let agentBinary = (command[0] as NSString).lastPathComponent diff --git a/Sources/PastewatchCLI/ProxyCommand.swift b/Sources/PastewatchCLI/ProxyCommand.swift index f1ab1b0..2efe80a 100644 --- a/Sources/PastewatchCLI/ProxyCommand.swift +++ b/Sources/PastewatchCLI/ProxyCommand.swift @@ -15,6 +15,25 @@ private let proxyStartupSignalGraceMilliseconds = 100 // WO-366: SIGINT exits should be distinguishable from successful proxy shutdown. let proxyInterruptedExitCode: Int32 = 130 +// WO-473: proxy and launch share one strict startup gate; runtime scan paths +// must never silently reduce configured coverage to the valid subset. +func compileProxyCustomRules(_ config: PastewatchConfig) throws -> [CustomRule] { + try CustomRule.compileForProxyStartup(config.customRules) +} + +func writeProxyCustomRuleError(_ error: Error) { + FileHandle.standardError.write(Data("error: \(error.localizedDescription)\n".utf8)) +} + +func requireValidProxyCustomRules(_ config: PastewatchConfig) throws -> [CustomRule] { + do { + return try compileProxyCustomRules(config) + } catch { + writeProxyCustomRuleError(error) + throw ExitCode(rawValue: 2) + } +} + func proxyShutdownExitCode(didStart: Bool) -> Int32 { didStart ? 0 : proxyInterruptedExitCode } @@ -72,11 +91,13 @@ struct Proxy: ParsableCommand { } let config = PastewatchConfig.resolve() + let compiledCustomRules = try requireValidProxyCustomRules(config) let server = ProxyServer( port: port, upstream: upstreamURL, forwardProxy: forwardProxyURL, config: config, + compiledCustomRules: compiledCustomRules, severity: severity, auditLogPath: auditLog, injectAlert: alert, diff --git a/Sources/PastewatchCore/CurlHTTPClient.swift b/Sources/PastewatchCore/CurlHTTPClient.swift index 419c72a..143af02 100644 --- a/Sources/PastewatchCore/CurlHTTPClient.swift +++ b/Sources/PastewatchCore/CurlHTTPClient.swift @@ -119,6 +119,7 @@ struct CurlHTTPClient { sendFlags: Int32 = 0, streamingRedactionMode: StreamingRedactionMode = .perSSEEvent, proxyConfig: PastewatchConfig = PastewatchConfig.defaultConfig, + proxyCustomRules: [CustomRule]? = nil, proxySeverity: Severity = .high, /// WO-192: closure called at [DONE] time with accumulated stream counts so stream-only /// secrets (no body redaction) also trigger the alert. Nil = no alert injection. @@ -126,6 +127,7 @@ struct CurlHTTPClient { ) throws -> Response { let curlPath = "/usr/bin/curl" guard FileManager.default.fileExists(atPath: curlPath) else { throw ExecuteError.failure } + let customRules = proxyCustomRules ?? CustomRule.compileValid(proxyConfig.customRules) let process = Process() process.executableURL = URL(fileURLWithPath: curlPath) @@ -176,6 +178,7 @@ struct CurlHTTPClient { sendFlags: sendFlags, redactionMode: streamingRedactionMode, config: proxyConfig, + customRules: customRules, severity: proxySeverity ) // WO-196: alertBeforeDone passed directly, not through StreamContext. @@ -215,7 +218,8 @@ struct CurlHTTPClient { responseRedaction = redactNonUTF8ResponseBody( parsedOutput.body, config: proxyConfig, - severity: proxySeverity + severity: proxySeverity, + customRules: customRules ) responseBody = responseRedaction.data FileHandle.standardError.write(Data( @@ -362,7 +366,24 @@ struct CurlHTTPClient { let sendFlags: Int32 let redactionMode: StreamingRedactionMode let config: PastewatchConfig + let customRules: [CustomRule] // WO-473: startup-validated rules shared by Linux relay paths. let severity: Severity + + init( + clientSocket: Int32, + sendFlags: Int32, + redactionMode: StreamingRedactionMode, + config: PastewatchConfig, + customRules: [CustomRule]? = nil, + severity: Severity + ) { + self.clientSocket = clientSocket + self.sendFlags = sendFlags + self.redactionMode = redactionMode + self.config = config + self.customRules = customRules ?? CustomRule.compileValid(config.customRules) + self.severity = severity + } } /// WO-336/WO-404: named Linux relay result carries mutation-safe and advisory stream totals. @@ -719,7 +740,12 @@ struct CurlHTTPClient { if result.overflowFlushed { // WO-164: a 4MB+ frame bypassed the normal per-frame path; redact it // as raw text rather than forwarding secrets unscanned. - let r = redactRawBytes(result.overflowBytes, config: ctx.config, severity: ctx.severity) + let r = redactRawBytes( + result.overflowBytes, + config: ctx.config, + severity: ctx.severity, + customRules: ctx.customRules + ) outData = r.data // WO-256: record detections before sendAll() — if the client EPIPEs, the // credential was still present in the stream and was redacted from the bytes @@ -763,7 +789,12 @@ struct CurlHTTPClient { // returns frame.raw for it, but the call is an always-no-op; avoid confusion. // WO-220: use shared redactSSEFrame() from SocketHelpers.swift. guard frame.data != "[DONE]" else { assembled.append(frame.raw); continue } - let r = redactSSEFrame(frame, config: ctx.config, severity: ctx.severity) + let r = redactSSEFrame( + frame, + config: ctx.config, + severity: ctx.severity, + customRules: ctx.customRules + ) assembled.append(r.data) pendingCount += r.count pendingTypes.append(contentsOf: r.types) @@ -872,7 +903,9 @@ struct CurlHTTPClient { ) { let rem = parser.remainingBytes guard !rem.isEmpty else { return } - let redaction = redactRawBytes(rem, config: ctx.config, severity: ctx.severity) + let redaction = redactRawBytes( + rem, config: ctx.config, severity: ctx.severity, customRules: ctx.customRules + ) totals.recordCritical(redaction) // WO-191/WO-200/WO-205: retry until all bytes sent; skip on EPIPE. let alert = alertBeforeDone?( @@ -922,7 +955,12 @@ struct CurlHTTPClient { ) -> StreamChunkRelayResult? { guard alert.alertBeforeDone != nil else { // WO-324/WO-404: raw_stream skips SSE parsing but still honors the certainty gate. - let redaction = redactRawBytes(chunk, config: alert.stream.config, severity: alert.stream.severity) + let redaction = redactRawBytes( + chunk, + config: alert.stream.config, + severity: alert.stream.severity, + customRules: alert.stream.customRules + ) totals.recordCritical(redaction) return StreamChunkRelayResult( data: redaction.data, @@ -971,13 +1009,19 @@ struct CurlHTTPClient { totals: inout StreamScanTotals, alertState: inout RawStreamAlertState ) -> StreamChunkRelayResult { - let redaction = redactRawBytes(data, config: alert.stream.config, severity: alert.stream.severity) + let redaction = redactRawBytes( + data, + config: alert.stream.config, + severity: alert.stream.severity, + customRules: alert.stream.customRules + ) totals.recordCritical(redaction) let advisory = detectNewRawStreamAdvisories( data, state: &alertState, config: alert.stream.config, - severity: alert.stream.severity + severity: alert.stream.severity, + customRules: alert.stream.customRules ) var output = redaction.data if doneLineStart != nil, @@ -1017,13 +1061,16 @@ struct CurlHTTPClient { let data = state.pending state.pending.removeAll(keepingCapacity: true) - let redaction = redactRawBytes(data, config: ctx.config, severity: ctx.severity) + let redaction = redactRawBytes( + data, config: ctx.config, severity: ctx.severity, customRules: ctx.customRules + ) totals.recordCritical(redaction) let advisory = detectNewRawStreamAdvisories( data, state: &state, config: ctx.config, - severity: ctx.severity + severity: ctx.severity, + customRules: ctx.customRules ) var output = redaction.data // WO-352: no [DONE] arrived, so deliver the advisory event after the final bytes. @@ -1046,7 +1093,8 @@ struct CurlHTTPClient { _ delivered: Data, state: inout RawStreamAlertState, config: PastewatchConfig, - severity: Severity + severity: Severity, + customRules: [CustomRule] ) -> (count: Int, types: [String]) { guard !delivered.isEmpty else { return (0, []) } state.advisoryScanTail.append(delivered) @@ -1067,7 +1115,10 @@ struct CurlHTTPClient { let text = String(bytes: state.advisoryScanTail, encoding: .utf8) ?? String(decoding: Array(state.advisoryScanTail), as: UTF8.self) // swiftlint:enable optional_data_string_conversion - let matches = streamAdvisoryMatches(scanStreamText(text, config: config), severity: severity) + let matches = streamAdvisoryMatches( + scanStreamText(text, config: config, customRules: customRules), + severity: severity + ) var types: [String] = [] for match in matches { let lowerOffset = text[.. Data?)?, totals: inout StreamScanTotals ) -> StreamChunkRelayResult { - let redaction = redactRawBytes(data, config: ctx.config, severity: ctx.severity) + let redaction = redactRawBytes( + data, config: ctx.config, severity: ctx.severity, customRules: ctx.customRules + ) totals.recordCritical(redaction) let alert = alertBeforeDone?( totals.redactionCount, @@ -1124,8 +1177,13 @@ struct CurlHTTPClient { /// WO-164: redact raw bytes that bypassed the SSE frame parser (overflow path). /// Treats the whole buffer as plain text, scans it, and obfuscates in-place. - private static func redactRawBytes(_ raw: Data, config: PastewatchConfig, severity: Severity) -> SSEFrameRedactionResult { - redactRawStreamBytes(raw, config: config, severity: severity) + private static func redactRawBytes( + _ raw: Data, + config: PastewatchConfig, + severity: Severity, + customRules: [CustomRule] + ) -> SSEFrameRedactionResult { + redactRawStreamBytes(raw, config: config, severity: severity, customRules: customRules) } /// WO-359: detect ASCII credentials inside otherwise non-UTF-8 response bodies @@ -1133,7 +1191,8 @@ struct CurlHTTPClient { static func redactNonUTF8ResponseBody( _ body: Data, config: PastewatchConfig, - severity: Severity + severity: Severity, + customRules: [CustomRule]? = nil ) -> SSEFrameRedactionResult { guard !body.isEmpty else { return SSEFrameRedactionResult(data: body, count: 0, types: []) @@ -1143,7 +1202,7 @@ struct CurlHTTPClient { let matches = DetectionRules.scan( lossyText, config: config, - customRules: CustomRule.compileValid(config.customRules) + customRules: customRules ?? CustomRule.compileValid(config.customRules) ) let redactionMatches = mutationSafeProxyMatches(matches) .sorted { $0.range.lowerBound < $1.range.lowerBound } diff --git a/Sources/PastewatchCore/CustomRule.swift b/Sources/PastewatchCore/CustomRule.swift index ff6be2a..34f5af8 100644 --- a/Sources/PastewatchCore/CustomRule.swift +++ b/Sources/PastewatchCore/CustomRule.swift @@ -44,6 +44,23 @@ public struct CustomRule { } } + /// WO-473: proxy startup treats every configured rule as a protection + /// contract. Invalid names, severities, or patterns reject the whole set. + public static func compileForProxyStartup(_ configs: [CustomRuleConfig]) throws -> [CustomRule] { + for (index, config) in configs.enumerated() { + guard !config.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw CustomRuleError.emptyName(index: index) + } + guard !config.pattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw CustomRuleError.emptyPattern(name: config.name) + } + if let severity = config.severity, Severity(rawValue: severity) == nil { + throw CustomRuleError.invalidSeverity(name: config.name, severity: severity) + } + } + return try compile(configs) + } + /// WO-124: compile configs when invalid generated entries should degrade to the valid subset. public static func compileValid(_ configs: [CustomRuleConfig]) -> [CustomRule] { configs.compactMap { config in @@ -59,11 +76,20 @@ public struct CustomRule { /// Errors for custom rule loading. public enum CustomRuleError: Error, LocalizedError { case invalidPattern(name: String, pattern: String) + case emptyName(index: Int) // WO-473: unnamed startup contracts are invalid. + case emptyPattern(name: String) // WO-473: an empty regex would match every position. + case invalidSeverity(name: String, severity: String) // WO-473: no silent severity fallback at startup. public var errorDescription: String? { switch self { - case .invalidPattern(let name, let pattern): - return "invalid regex in custom rule '\(name)': \(pattern)" + case .invalidPattern(let name, _): + return "invalid regex in custom rule '\(name)'" + case .emptyName(let index): + return "custom rule at index \(index) has an empty name" + case .emptyPattern(let name): + return "custom rule '\(name)' has an empty pattern" + case .invalidSeverity(let name, let severity): + return "invalid severity '\(severity)' in custom rule '\(name)'" } } } diff --git a/Sources/PastewatchCore/DetectionRules.swift b/Sources/PastewatchCore/DetectionRules.swift index 4b5c81f..808c0fe 100644 --- a/Sources/PastewatchCore/DetectionRules.swift +++ b/Sources/PastewatchCore/DetectionRules.swift @@ -6,6 +6,7 @@ import Foundation /// Each rule is a regex pattern that matches high-confidence patterns only. /// False negatives are preferred over false positives. public struct DetectionRules { + private static let maximumPrivateKeyBlockCharacters = 262_144 // WO-478: bound malformed PEM scans. /// Safe hosts that should not trigger hostname detection. /// Matches chainwatch's safeHosts for consistency across tools. @@ -52,15 +53,6 @@ public struct DetectionRules { public static let rules: [(SensitiveDataType, NSRegularExpression)] = { var result: [(SensitiveDataType, NSRegularExpression)] = [] - // SSH Private Key - very high confidence - // Matches the header of SSH private keys - if let regex = try? NSRegularExpression( - pattern: #"-----BEGIN\s+(RSA|DSA|EC|OPENSSH)\s+PRIVATE\s+KEY-----"#, - options: [] - ) { - result.append((.sshPrivateKey, regex)) - } - // AWS Access Key ID - high confidence // Format: AKIA followed by 16 alphanumeric characters if let regex = try? NSRegularExpression( @@ -122,14 +114,6 @@ public struct DetectionRules { result.append((.azureConnectionString, regex)) } - // GCP Service Account JSON - high confidence - if let regex = try? NSRegularExpression( - pattern: #""type"\s*:\s*"service_account""#, - options: [] - ) { - result.append((.gcpServiceAccount, regex)) - } - // OpenAI API Key - high confidence // sk-proj- (project keys), sk-svcacct- (service account keys) if let regex = try? NSRegularExpression( @@ -274,6 +258,59 @@ public struct DetectionRules { result.append((.resendKey, regex)) } + // WO-462: https://developer.hashicorp.com/vault/docs/concepts/tokens + // Reviewed 2026-07-14. Vault documents six prefixes and a 24+ character suffix. + if let regex = try? NSRegularExpression( + pattern: #"(?] = [] + // WO-478/WO-479: payload-bearing formats must authorize complete secret + // ranges before ordinary regex rules can claim marker-only success. + scanGCPServiceAccountSecrets(content, config: config, matches: &matches, matchedRanges: &matchedRanges) + scanCompletePrivateKeyBlocks(content, config: config, matches: &matches, matchedRanges: &matchedRanges) + for (type, regex) in rules { // Skip disabled types guard config.isTypeEnabled(type) else { continue } @@ -593,6 +635,118 @@ public struct DetectionRules { return matches } + // WO-478: match one complete, correctly paired PEM block and stop at a nested + // BEGIN marker instead of crossing into an adjacent or malformed key. + private static func scanCompletePrivateKeyBlocks( + _ content: String, + config: PastewatchConfig, + matches: inout [DetectedMatch], + matchedRanges: inout [Range] + ) { + guard config.isTypeEnabled(.sshPrivateKey), + let beginRegex = try? NSRegularExpression( + pattern: #"-----BEGIN (RSA PRIVATE KEY|DSA PRIVATE KEY|EC PRIVATE KEY|OPENSSH PRIVATE KEY|PRIVATE KEY)-----"# + ) else { return } + + let fullRange = NSRange(content.startIndex..., in: content) + for candidate in beginRegex.matches(in: content, range: fullRange) { + guard let beginRange = Range(candidate.range, in: content), + let labelRange = Range(candidate.range(at: 1), in: content) else { continue } + let endMarker = "-----END \(content[labelRange])-----" + let searchLimit = content.index( + beginRange.lowerBound, + offsetBy: maximumPrivateKeyBlockCharacters, + limitedBy: content.endIndex + ) ?? content.endIndex + let searchRange = beginRange.upperBound..] + ) { + guard config.isTypeEnabled(.gcpServiceAccount), + let root = try? JSONSerialization.jsonObject(with: Data(content.utf8)) else { return } + var authorized: [String: Set] = [:] + collectGCPServiceAccountSecrets(root, into: &authorized) + guard !authorized.isEmpty else { return } + + for key in ["private_key", "private_key_id"] { + guard let values = authorized[key], !values.isEmpty, + let regex = try? NSRegularExpression( + pattern: "\"\(key)\"\\s*:\\s*\"((?:\\\\.|[^\"\\\\])*)\"" + ) else { continue } + let fullRange = NSRange(content.startIndex..., in: content) + for candidate in regex.matches(in: content, range: fullRange) { + guard let valueRange = Range(candidate.range(at: 1), in: content), + !matchedRanges.contains(where: { $0.overlaps(valueRange) }) else { continue } + let encoded = String(content[valueRange]) + guard let decoded = decodeJSONStringContent(encoded), values.contains(decoded) else { continue } + matches.append(DetectedMatch( + type: .gcpServiceAccount, + value: encoded, + range: valueRange, + line: lineNumber(of: valueRange.lowerBound, in: content) + )) + matchedRanges.append(valueRange) + } + } + } + + private static func collectGCPServiceAccountSecrets( + _ value: Any, + into result: inout [String: Set] + ) { + if let object = value as? [String: Any] { + if object["type"] as? String == "service_account" { + for key in ["private_key", "private_key_id"] { + if let secret = object[key] as? String, !secret.isEmpty { + result[key, default: []].insert(secret) + } + } + } + for child in object.values { + collectGCPServiceAccountSecrets(child, into: &result) + } + } else if let array = value as? [Any] { + for child in array { + collectGCPServiceAccountSecrets(child, into: &result) + } + } + } + + private static func decodeJSONStringContent(_ encoded: String) -> String? { + let wrapped = "\"\(encoded)\"" + return (try? JSONSerialization.jsonObject( + with: Data(wrapped.utf8), + options: [.fragmentsAllowed] + )) as? String + } + /// WO-124: scan file IO using built-ins plus shared/generated pattern artifacts. public static func scanFileIO(_ content: String, config: PastewatchConfig) -> [DetectedMatch] { scanFileIOResult(content, config: config).matches diff --git a/Sources/PastewatchCore/ProxyServer.swift b/Sources/PastewatchCore/ProxyServer.swift index 606e0e6..8893b55 100644 --- a/Sources/PastewatchCore/ProxyServer.swift +++ b/Sources/PastewatchCore/ProxyServer.swift @@ -67,6 +67,8 @@ public final class ProxyServer { private let upstream: URL private let forwardProxy: URL? private let config: PastewatchConfig + private let customRules: [CustomRule] // WO-473: one precompiled set for every proxy scan path. + private let customRuleStartupError: Error? // WO-473: direct server users fail before socket creation. private let severity: Severity private let auditLogPath: String? public private(set) var injectAlert: Bool @@ -250,6 +252,7 @@ public final class ProxyServer { upstream: URL = URL(string: "https://api.anthropic.com")!, forwardProxy: URL? = nil, config: PastewatchConfig = PastewatchConfig.resolve(), + compiledCustomRules: [CustomRule]? = nil, severity: Severity = .high, auditLogPath: String? = nil, injectAlert: Bool = true, @@ -261,6 +264,18 @@ public final class ProxyServer { self.upstream = upstream self.forwardProxy = forwardProxy self.config = config + if let compiledCustomRules { + self.customRules = compiledCustomRules + self.customRuleStartupError = nil + } else { + do { + self.customRules = try CustomRule.compileForProxyStartup(config.customRules) + self.customRuleStartupError = nil + } catch { + self.customRules = [] + self.customRuleStartupError = error + } + } self.severity = severity self.auditLogPath = auditLogPath self.injectAlert = injectAlert @@ -405,6 +420,9 @@ public final class ProxyServer { /// Start the proxy server. Blocks until stop() is called. public func start(onListening: (() -> Void)? = nil) throws { + if let customRuleStartupError { + throw ProxyError.invalidCustomRules(customRuleStartupError.localizedDescription) + } #if canImport(Darwin) let listenSocket = socket(AF_INET, SOCK_STREAM, 0) #else @@ -960,7 +978,8 @@ public final class ProxyServer { insecure: insecureTLS, streaming: shouldStream, clientSocket: ctx.clientSocket, sendFlags: sendFlags, streamingRedactionMode: streamingMode, - proxyConfig: config, proxySeverity: severity, alertBeforeDone: alertBeforeDone + proxyConfig: config, proxyCustomRules: customRules, + proxySeverity: severity, alertBeforeDone: alertBeforeDone ) } catch CurlHTTPClient.ExecuteError.timeout { // WO-386: curl exit 28 is an upstream timeout, not a bad gateway. @@ -1363,7 +1382,7 @@ public final class ProxyServer { DetectionRules.scan( text, config: config, - customRules: CustomRule.compileValid(config.customRules) + customRules: customRules ) } @@ -1763,6 +1782,7 @@ public final class ProxyServer { sendFlags: sendFlags, redactionMode: mode, config: config, + customRules: customRules, severity: severity, idleTimeoutSeconds: proxyStreamIdleTimeoutSeconds, tlsChallengeHandler: tlsTrustDelegate.map { delegate in @@ -2071,9 +2091,14 @@ public final class ProxyServer { let response = "HTTP/1.1 \(status) \(reason)\r\nContent-Type: application/json\r\nContent-Length: \(bodyBytes.count)\r\nConnection: close\r\n\r\n" var responseData = Data(response.utf8) responseData.append(bodyBytes) - // WO-212/218: use sendAll(); log to stderr on delivery failure so the failure is observable. + // WO-212/218: use sendAll(); unexpected delivery failures remain observable. if !sendAll(responseData, to: socket, flags: sendFlags) { - FileHandle.standardError.write(Data("[pastewatch-proxy] sendError: client socket \(socket) closed before error response delivered\n".utf8)) + let errorCode = errno + if Self.shouldLogSocketDeliveryFailure(errorCode: errorCode, quiet: quietLog) { + FileHandle.standardError.write(Data( + "[pastewatch-proxy] sendError: client socket \(socket) closed before error response delivered\n".utf8 + )) + } } } @@ -2092,12 +2117,24 @@ public final class ProxyServer { var responseData = Data(response.utf8) responseData.append(body) - // WO-212/218: use sendAll(); log to stderr on delivery failure so the failure is observable. + // WO-212/218: use sendAll(); unexpected delivery failures remain observable. if !sendAll(responseData, to: socket, flags: sendFlags) { - FileHandle.standardError.write(Data("[pastewatch-proxy] sendResponse: client socket \(socket) closed before response delivered\n".utf8)) + let errorCode = errno + if Self.shouldLogSocketDeliveryFailure(errorCode: errorCode, quiet: quietLog) { + FileHandle.standardError.write(Data( + "[pastewatch-proxy] sendResponse: client socket \(socket) closed before response delivered\n".utf8 + )) + } } } + // WO-275: EPIPE/ECONNRESET/EBADF mean the local client is already gone. + // Interrupting a stream is normal and must not look like a proxy failure. + static func shouldLogSocketDeliveryFailure(errorCode: Int32, quiet: Bool) -> Bool { + guard !quiet else { return false } + return errorCode != EPIPE && errorCode != ECONNRESET && errorCode != EBADF + } + // MARK: - Alert injection func buildAlertBlock(redactionCount: Int, types: [String]) -> [String: Any] { @@ -2413,18 +2450,22 @@ public final class ProxyServer { // MARK: - Errors -public enum ProxyError: Error, CustomStringConvertible { +public enum ProxyError: Error, CustomStringConvertible, LocalizedError { + case invalidCustomRules(String) // WO-473: invalid protection config cannot bind a listener. case socketCreationFailed case bindFailed(port: UInt16) case listenFailed public var description: String { switch self { + case .invalidCustomRules(let message): return message case .socketCreationFailed: return "Failed to create socket" case .bindFailed(let port): return "Failed to bind to port \(port) (already in use?)" case .listenFailed: return "Failed to listen on socket" } } + + public var errorDescription: String? { description } } #if canImport(Darwin) diff --git a/Sources/PastewatchCore/SSEStreamRelay.swift b/Sources/PastewatchCore/SSEStreamRelay.swift index 0b87a51..373e627 100644 --- a/Sources/PastewatchCore/SSEStreamRelay.swift +++ b/Sources/PastewatchCore/SSEStreamRelay.swift @@ -29,6 +29,7 @@ final class SSEStreamRelay: NSObject, URLSessionDataDelegate { private let sendFlags: Int32 private let redactionMode: StreamingRedactionMode private let config: PastewatchConfig + private let customRules: [CustomRule] // WO-473: validated once before proxy startup. private let severity: Severity private let idleTimeoutSeconds: Double private let maxSessionSeconds: Double // WO-292: hard ceiling before cancelling active stream task @@ -107,6 +108,7 @@ final class SSEStreamRelay: NSObject, URLSessionDataDelegate { sendFlags: Int32, redactionMode: StreamingRedactionMode, config: PastewatchConfig, + customRules: [CustomRule]? = nil, severity: Severity, idleTimeoutSeconds: Double, maxSessionSeconds: Double = sseStreamMaxSessionSeconds, @@ -117,6 +119,7 @@ final class SSEStreamRelay: NSObject, URLSessionDataDelegate { self.sendFlags = sendFlags self.redactionMode = redactionMode self.config = config + self.customRules = customRules ?? CustomRule.compileValid(config.customRules) self.severity = severity self.idleTimeoutSeconds = idleTimeoutSeconds self.maxSessionSeconds = maxSessionSeconds @@ -384,7 +387,12 @@ final class SSEStreamRelay: NSObject, URLSessionDataDelegate { return relayFrameData(toSend) } // WO-220: use shared redactSSEFrame() from SocketHelpers.swift. - let redaction = redactSSEFrame(frame, config: config, severity: severity) + let redaction = redactSSEFrame( + frame, + config: config, + severity: severity, + customRules: customRules + ) let delivered = relayFrameData(redaction.data) // WO-372/WO-404: mutation-safe redactions stay attempted-detection scoped, but // advisory-only matches are in-band guidance and must be delivery-scoped. @@ -647,7 +655,7 @@ final class SSEStreamRelay: NSObject, URLSessionDataDelegate { /// WO-164: redact raw bytes that bypassed the SSE frame parser (overflow path). private func redactRawBytes(_ raw: Data) -> SSEFrameRedactionResult { - redactRawStreamBytes(raw, config: config, severity: severity) + redactRawStreamBytes(raw, config: config, severity: severity, customRules: customRules) } private func sendIdleTimeoutErrorIfNeeded() { diff --git a/Sources/PastewatchCore/SocketHelpers.swift b/Sources/PastewatchCore/SocketHelpers.swift index 35704ef..65fe75f 100644 --- a/Sources/PastewatchCore/SocketHelpers.swift +++ b/Sources/PastewatchCore/SocketHelpers.swift @@ -77,11 +77,15 @@ func streamAdvisoryMatches(_ matches: [DetectedMatch], severity: Severity) -> [D } /// WO-399: include configured custom rules on the streaming response path. -func scanStreamText(_ text: String, config: PastewatchConfig) -> [DetectedMatch] { +func scanStreamText( + _ text: String, + config: PastewatchConfig, + customRules: [CustomRule]? = nil +) -> [DetectedMatch] { DetectionRules.scan( text, config: config, - customRules: CustomRule.compileValid(config.customRules) + customRules: customRules ?? CustomRule.compileValid(config.customRules) ) } @@ -91,14 +95,15 @@ func scanStreamText(_ text: String, config: PastewatchConfig) -> [DetectedMatch] func redactRawStreamBytes( _ raw: Data, config: PastewatchConfig, - severity: Severity + severity: Severity, + customRules: [CustomRule]? = nil ) -> SSEFrameRedactionResult { guard !raw.isEmpty else { return SSEFrameRedactionResult(data: raw, count: 0, types: []) } // swiftlint:disable:next optional_data_string_conversion let text = String(data: raw, encoding: .utf8) ?? String(decoding: raw, as: UTF8.self) - let matches = scanStreamText(text, config: config) + let matches = scanStreamText(text, config: config, customRules: customRules) let filtered = mutationSafeProxyMatches(matches) let advisories = streamAdvisoryMatches(matches, severity: severity) let advisoryTypes = advisories.map { $0.displayName } @@ -155,10 +160,11 @@ private func rawSSEDoneFrameStart(in data: Data) -> Data.Index? { func redactSSEFrame( _ frame: SSEFrameParser.Frame, config: PastewatchConfig, - severity: Severity + severity: Severity, + customRules: [CustomRule]? = nil ) -> SSEFrameRedactionResult { guard let dataPayload = frame.data else { - return redactRawStreamBytes(frame.raw, config: config, severity: severity) + return redactRawStreamBytes(frame.raw, config: config, severity: severity, customRules: customRules) } guard dataPayload != "[DONE]" else { return SSEFrameRedactionResult(data: frame.raw, count: 0, types: []) @@ -166,7 +172,7 @@ func redactSSEFrame( guard let jsonData = dataPayload.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any], let delta = json["delta"] as? [String: Any] else { - return redactRawStreamBytes(frame.raw, config: config, severity: severity) + return redactRawStreamBytes(frame.raw, config: config, severity: severity, customRules: customRules) } var modifiedDelta = delta var redacted = 0 @@ -176,7 +182,7 @@ func redactSSEFrame( for (field, value) in delta { guard field != "type", let text = value as? String else { continue } - let matches = scanStreamText(text, config: config) + let matches = scanStreamText(text, config: config, customRules: customRules) let filtered = mutationSafeProxyMatches(matches) let advisories = streamAdvisoryMatches(matches, severity: severity) advisoryCount += advisories.count diff --git a/Sources/PastewatchCore/Types.swift b/Sources/PastewatchCore/Types.swift index a180632..3f6db86 100644 --- a/Sources/PastewatchCore/Types.swift +++ b/Sources/PastewatchCore/Types.swift @@ -67,6 +67,11 @@ public enum SensitiveDataType: String, CaseIterable, Codable { case oraculKey = "Oracul Key" case obstalabsKey = "ObstaLabs Key" case resendKey = "Resend Key" + case vaultToken = "Vault Token" // WO-462: intrinsically formatted Vault bearer token. + case slackToken = "Slack Token" // WO-481: documented Slack token families. + case googleApiKey = "Google API Key" // WO-482: exact AIza key format. + case dockerAccessToken = "Docker Access Token" // WO-483: Docker PAT/OAT formats. + case githubToken = "GitHub Token" // WO-485: current GitHub token formats. case jdbcUrl = "JDBC URL" case xmlCredential = "XML Credential" case xmlUsername = "XML Username" @@ -83,6 +88,7 @@ public enum SensitiveDataType: String, CaseIterable, Codable { .npmToken, .pypiToken, .rubygemsToken, .gitlabToken, .telegramBotToken, .sendgridKey, .shopifyToken, .digitaloceanToken, .perplexityKey, .workledgerKey, .oraculKey, .obstalabsKey, .resendKey, + .vaultToken, .slackToken, .googleApiKey, .dockerAccessToken, .githubToken, .jdbcUrl, .xmlCredential: return .critical case .email, .phone, .xmlUsername: @@ -104,6 +110,7 @@ public enum SensitiveDataType: String, CaseIterable, Codable { .npmToken, .pypiToken, .rubygemsToken, .gitlabToken, .telegramBotToken, .sendgridKey, .shopifyToken, .digitaloceanToken, .perplexityKey, .workledgerKey, .oraculKey, .obstalabsKey, .resendKey, + .vaultToken, .slackToken, .googleApiKey, .dockerAccessToken, .githubToken, .jdbcUrl, .xmlCredential: return true case .email, .phone, .xmlUsername, @@ -123,7 +130,7 @@ public enum SensitiveDataType: String, CaseIterable, Codable { case .genericApiKey: return "API keys and tokens (GitHub, Stripe, generic secret_ prefixes)" case .uuid: return "UUIDs (version 1-5 format)" case .dbConnectionString: return "Database connection strings (postgres://, mysql://, mongodb://)" - case .sshPrivateKey: return "SSH/PGP private key headers (BEGIN RSA/DSA/EC/OPENSSH PRIVATE KEY)" + case .sshPrivateKey: return "Complete RSA, DSA, EC, OpenSSH, and PKCS#8 private-key PEM blocks" case .jwtToken: return "JSON Web Tokens (three base64url-encoded segments)" case .creditCard: return "Credit card numbers (Visa, Mastercard, Amex) with Luhn validation" case .filePath: return "Sensitive file paths (/etc/*, /home/*/.ssh/*, etc.)" @@ -150,6 +157,11 @@ public enum SensitiveDataType: String, CaseIterable, Codable { case .oraculKey: return "Oracul API keys (vc__ prefix)" case .obstalabsKey: return "ObstaLabs Ed25519-signed license keys (ol_ prefix with payload.signature structure)" case .resendKey: return "Resend transactional email API keys (re_ prefix)" + case .vaultToken: return "HashiCorp Vault service, batch, and recovery tokens" + case .slackToken: return "Slack bot, user, app, workflow, and rotating tokens" + case .googleApiKey: return "Google API keys (AIza prefix and exact length)" + case .dockerAccessToken: return "Docker personal and organization access tokens" + case .githubToken: return "GitHub personal, OAuth, app, installation, and refresh tokens" case .jdbcUrl: return "JDBC connection URLs (jdbc:oracle, jdbc:db2, jdbc:mysql, jdbc:postgresql, jdbc:sqlserver)" case .xmlCredential: return "Credentials in XML tags (password, secret, access_key)" case .xmlUsername: return "Usernames in XML tags (user, name within users context)" @@ -168,7 +180,7 @@ public enum SensitiveDataType: String, CaseIterable, Codable { case .genericApiKey: return ["ghp_<36-character token>", "sk_live_"] case .uuid: return ["550e8400-e29b-41d4-a716-446655440000"] case .dbConnectionString: return ["postgres://... (connection URI)", "mongodb://... (connection URI)"] - case .sshPrivateKey: return ["-----BEGIN PRIVATE KEY-----"] + case .sshPrivateKey: return ["-----BEGIN PRIVATE KEY----- ... -----END PRIVATE KEY-----"] case .jwtToken: return ["
.. (base64url)"] case .creditCard: return ["4111 1111 1111 1111", "5500 0000 0000 0004"] case .filePath: return ["/etc/nginx/nginx.conf", "/home/deploy/.ssh/id_rsa"] @@ -195,6 +207,11 @@ public enum SensitiveDataType: String, CaseIterable, Codable { case .oraculKey: return ["vc_admin_<32-hex-chars>", "vc_pro_<32-hex-chars>"] case .obstalabsKey: return ["ol_."] case .resendKey: return ["re_<24+-alphanumeric-chars>"] + case .vaultToken: return ["hvs.<24+-character-token>", "s."] + case .slackToken: return ["xoxb-", "xapp-"] + case .googleApiKey: return ["AIza<35-character-key>"] + case .dockerAccessToken: return ["dckr_pat_", "dckr_oat_"] + case .githubToken: return ["github_pat_", "ghs_"] case .jdbcUrl: return ["jdbc:oracle:thin:@host:1521:SID", "jdbc:postgresql://host:5432/db"] case .xmlCredential: return ["secret123", "KEY"] case .xmlUsername: return ["admin", "deploy"] diff --git a/Tests/PastewatchTests/CustomRuleTests.swift b/Tests/PastewatchTests/CustomRuleTests.swift index 7dce878..6f89cb3 100644 --- a/Tests/PastewatchTests/CustomRuleTests.swift +++ b/Tests/PastewatchTests/CustomRuleTests.swift @@ -35,6 +35,26 @@ final class CustomRuleTests: XCTestCase { ])) } + // WO-473: startup validation rejects the whole configured protection set. + func testProxyStartupCompilationRejectsInvalidRuleMetadataWithoutPatternDisclosure() { + let invalidPattern = "[" + "unclosed" + XCTAssertThrowsError(try CustomRule.compileForProxyStartup([ + CustomRuleConfig(name: "Broken rule", pattern: invalidPattern) + ])) { error in + XCTAssertTrue(error.localizedDescription.contains("Broken rule")) + XCTAssertFalse(error.localizedDescription.contains(invalidPattern)) + } + XCTAssertThrowsError(try CustomRule.compileForProxyStartup([ + CustomRuleConfig(name: "Bad severity", pattern: "SAFE-[0-9]+", severity: "extreme") + ])) + XCTAssertThrowsError(try CustomRule.compileForProxyStartup([ + CustomRuleConfig(name: "", pattern: "SAFE-[0-9]+") + ])) + XCTAssertThrowsError(try CustomRule.compileForProxyStartup([ + CustomRuleConfig(name: "Empty pattern", pattern: " ") + ])) + } + func testLoadFromFile() throws { let path = NSTemporaryDirectory() + "test-rules-\(UUID().uuidString).json" let json = "[{\"name\": \"Test\", \"pattern\": \"TEST-[0-9]+\"}]" diff --git a/Tests/PastewatchTests/DetectionRulesTests.swift b/Tests/PastewatchTests/DetectionRulesTests.swift index 11bcd68..695544d 100644 --- a/Tests/PastewatchTests/DetectionRulesTests.swift +++ b/Tests/PastewatchTests/DetectionRulesTests.swift @@ -78,7 +78,7 @@ final class DetectionRulesTests: XCTestCase { let content = "GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" let matches = DetectionRules.scan(content, config: config) - let apiKeyMatches = matches.filter { $0.type == .genericApiKey } + let apiKeyMatches = matches.filter { $0.type == .githubToken } XCTAssertGreaterThanOrEqual(apiKeyMatches.count, 1) } @@ -159,7 +159,11 @@ final class DetectionRulesTests: XCTestCase { // MARK: - SSH Key Detection func testDetectsSSHPrivateKey() { - let content = "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA..." + let content = pemFixture( + label: "RSA PRIVATE KEY", + payload: String(repeating: "QUJD", count: 12), + newline: "\n" + ) let matches = DetectionRules.scan(content, config: config) let sshMatches = matches.filter { $0.type == .sshPrivateKey } @@ -474,15 +478,14 @@ final class DetectionRulesTests: XCTestCase { // MARK: - GCP Service Account Detection func testDetectsGCPServiceAccount() { - let content = """ - {"type": "service_account", "project_id": "my-project"} - """ + XCTAssertTrue(config.isTypeEnabled(.gcpServiceAccount)) + let content = #"{"type":"service_account","private_key_id":"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"}"# let matches = DetectionRules.scan(content, config: config) XCTAssertTrue(matches.contains { $0.type == .gcpServiceAccount }) } func testDetectsGCPServiceAccountWithSpacing() { - let content = #""type" : "service_account""# + let content = #"{"private_key_id" : "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2", "type" : "service_account"}"# let matches = DetectionRules.scan(content, config: config) XCTAssertTrue(matches.contains { $0.type == .gcpServiceAccount }) } @@ -1278,4 +1281,132 @@ final class DetectionRulesTests: XCTestCase { XCTAssertFalse(matches.contains { $0.type == .genericApiKey && $0.value.hasPrefix("whsec_") }, "whsec_ value shorter than 24 chars should not match") } + + // WO-462/WO-481/WO-482/WO-483/WO-485: standalone provider tokens must be + // recognized from their documented intrinsic format, without keyword context. + func testDetectsStandaloneProviderTokens() { + let slackStem = String(decoding: [120, 111, 120], as: UTF8.self) + let fixtures: [(SensitiveDataType, String)] = [ + (.vaultToken, "hvs." + String(repeating: "A1", count: 12)), + (.vaultToken, "s." + String(repeating: "b2", count: 12)), + (.slackToken, slackStem + "b-1234567890-" + String(repeating: "Ab", count: 12)), + (.slackToken, "xapp-1-A1234567890-" + String(repeating: "Cd", count: 12)), + (.slackToken, "xwfp-" + String(repeating: "Ef", count: 12)), + (.slackToken, slackStem + "e." + slackStem + "p-1-" + String(repeating: "Gh", count: 12)), + (.slackToken, "xoxe-1-" + String(repeating: "Ij", count: 12)), + (.googleApiKey, "AIza" + String(repeating: "K", count: 35)), + (.dockerAccessToken, "dckr_pat_" + String(repeating: "Lm", count: 12)), + (.dockerAccessToken, "dckr_oat_" + String(repeating: "No", count: 12)), + (.githubToken, "github_pat_" + String(repeating: "Pq", count: 20)), + (.githubToken, "ghs_12345_" + jwtFixture()) + ] + + for (type, fixture) in fixtures { + let matches = DetectionRules.scan(fixture, config: config) + XCTAssertTrue( + matches.contains { $0.type == type && $0.value == fixture && $0.mutationSafe }, + "expected complete intrinsic match for \(type.rawValue)" + ) + } + } + + func testProviderTokenNearMissesDoNotMatch() { + let slackStem = String(decoding: [120, 111, 120], as: UTF8.self) + let nearMisses = [ + "hvs.short", + "prefixhvb." + String(repeating: "A", count: 24), + "xoxa-" + String(repeating: "B", count: 24), + slackStem + "e." + slackStem + "a-1-" + String(repeating: "C", count: 24), + "AIza" + String(repeating: "D", count: 34), + "dckr_pat_short", + "github_pat_short", + "ghs_not-an-app-id_" + jwtFixture() + ] + + for value in nearMisses { + let matches = DetectionRules.scan(value, config: config) + XCTAssertFalse(matches.contains { [.vaultToken, .slackToken, .googleApiKey, + .dockerAccessToken, .githubToken].contains($0.type) }, "unexpected match for \(value.prefix(16))") + } + } + + // WO-478: the match must contain the full private payload, not only its marker. + func testSSHPrivateKeyMatchesCompleteBoundedPEMBlocks() { + let first = pemFixture(label: "OPENSSH PRIVATE KEY", payload: String(repeating: "QUJD", count: 12), newline: "\n") + let second = pemFixture(label: "RSA PRIVATE KEY", payload: String(repeating: "REVG", count: 12), newline: "\r\n") + let content = first + "\npublic text\n" + second + let matches = DetectionRules.scan(content, config: config).filter { $0.type == .sshPrivateKey } + + XCTAssertEqual(matches.map(\.value), [first, second]) + let redacted = Obfuscator.obfuscate(content, matches: matches) + XCTAssertFalse(redacted.contains("PRIVATE KEY-----")) + XCTAssertFalse(redacted.contains("QUJD")) + XCTAssertFalse(redacted.contains("REVG")) + } + + func testSSHPrivateKeyRejectsIncompleteOrMismatchedBlocks() { + let incomplete = "-----BEGIN OPENSSH PRIVATE " + "KEY-----\n" + String(repeating: "QUJD", count: 12) + let mismatched = incomplete + "\n-----END RSA PRIVATE KEY-----" + let oversized = pemFixture( + label: "PRIVATE KEY", payload: String(repeating: "A", count: 262_145), newline: "\n" + ) + + for value in [incomplete, mismatched, oversized, + "-----BEGIN PUBLIC KEY-----\nQUJD\n-----END PUBLIC KEY-----"] { + XCTAssertFalse(DetectionRules.scan(value, config: config).contains { $0.type == .sshPrivateKey }) + } + } + + // WO-479: only secret-bearing fields in a structurally identified service-account + // object are authorized; the type marker itself is context, not a secret. + func testGCPServiceAccountMatchesPrivateFieldsAndPreservesJSON() throws { + let key = pemFixture(label: "PRIVATE KEY", payload: String(repeating: "R0NQ", count: 12), newline: "\n") + let keyID = String(repeating: "a1", count: 20) + let object: [String: Any] = [ + "wrapper": [ + "type": "service_account", + "private_key_id": keyID, + "private_key": key, + "unknown": true + ] + ] + let data = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + let content = try XCTUnwrap(String(data: data, encoding: .utf8)) + let matches = DetectionRules.scan(content, config: config).filter { $0.type == .gcpServiceAccount } + + XCTAssertEqual(Set(matches.map(\.value)), Set([keyID, try jsonEscapedStringContent(key)])) + let redacted = Obfuscator.obfuscate(content, matches: matches) + XCTAssertNoThrow(try JSONSerialization.jsonObject(with: Data(redacted.utf8))) + XCTAssertFalse(redacted.contains(keyID)) + XCTAssertFalse(redacted.contains("R0NQ")) + XCTAssertTrue(redacted.contains("service_account")) + } + + func testGCPMarkerAloneAndBenignPrivateFieldsDoNotAuthorizeMutation() throws { + let benign: [String: Any] = [ + "type": "user", + "private_key_id": String(repeating: "a1", count: 20), + "private_key": "not a service account key" + ] + let data = try JSONSerialization.data(withJSONObject: benign, options: [.sortedKeys]) + let content = try XCTUnwrap(String(data: data, encoding: .utf8)) + + XCTAssertFalse(DetectionRules.scan(content, config: config).contains { $0.type == .gcpServiceAccount }) + XCTAssertFalse(DetectionRules.scan(#"{"type":"service_account"}"#, config: config) + .contains { $0.type == .gcpServiceAccount }) + } + + private func pemFixture(label: String, payload: String, newline: String) -> String { + "-----BEGIN \(label)-----\(newline)\(payload)\(newline)-----END \(label)-----" + } + + private func jsonEscapedStringContent(_ value: String) throws -> String { + let data = try JSONSerialization.data(withJSONObject: [value]) + let encoded = try XCTUnwrap(String(data: data, encoding: .utf8)) + return String(encoded.dropFirst(2).dropLast(2)) + } + + private func jwtFixture() -> String { + "eyJ" + String(repeating: "A", count: 12) + ".eyJ" + String(repeating: "B", count: 12) + "." + String(repeating: "C", count: 20) + } } diff --git a/Tests/PastewatchTests/LaunchCommandTests.swift b/Tests/PastewatchTests/LaunchCommandTests.swift index 0edb6b7..8c94215 100644 --- a/Tests/PastewatchTests/LaunchCommandTests.swift +++ b/Tests/PastewatchTests/LaunchCommandTests.swift @@ -1,5 +1,6 @@ import Foundation @testable import PastewatchCLI +import PastewatchCore import XCTest #if canImport(Darwin) import Darwin @@ -215,6 +216,32 @@ final class LaunchCommandTests: XCTestCase { XCTAssertEqual(result.stderr, "", "--quiet should suppress non-routed advisory stderr") } + // WO-473: launch must reject invalid protection configuration before + // starting either the proxy or even a non-routed agent process. + func testLaunchRejectsInvalidCustomRuleBeforeAgentStart() throws { + let fixture = try makeLaunchFixture() + let agent = try writeEnvEchoAgent(named: "codex", in: fixture.cwd) + let invalidPattern = "[" + "unclosed" + var config = PastewatchConfig.defaultConfig + config.customRules = [CustomRuleConfig(name: "Broken rule", pattern: invalidPattern)] + try JSONEncoder().encode(config).write( + to: fixture.cwd.appendingPathComponent(".pastewatch.json"), + options: .atomic + ) + + let result = try runCLIProcess( + arguments: ["launch", "--no-startup-sweep", "--", agent.path], + cwd: fixture.cwd, + environment: fixture.environment + ) + + XCTAssertEqual(result.status, 2, result.stderr) + XCTAssertEqual(result.stdout, "", "agent ran despite invalid custom rule") + XCTAssertTrue(result.stderr.contains("Broken rule"), result.stderr) + XCTAssertFalse(result.stderr.contains(invalidPattern), "diagnostic disclosed configured pattern") + XCTAssertFalse(result.stderr.contains("proxy listening"), result.stderr) + } + // WO-438: SIGTERM should take the normal child-exit path so the proxy defer runs. func testSIGTERMTerminatesAgentAndProxy() throws { let fixture = try makeLaunchFixture() diff --git a/Tests/PastewatchTests/ProxyCommandTests.swift b/Tests/PastewatchTests/ProxyCommandTests.swift index 8159c40..fb2083a 100644 --- a/Tests/PastewatchTests/ProxyCommandTests.swift +++ b/Tests/PastewatchTests/ProxyCommandTests.swift @@ -1,7 +1,46 @@ @testable import PastewatchCLI +@testable import PastewatchCore import XCTest +#if canImport(Darwin) +import Darwin +#else +import Glibc +#endif final class ProxyCommandTests: XCTestCase { + // WO-473: the shared command gate rejects mixed valid/invalid rules before listen. + func testProxyCustomRuleStartupGateRejectsWholeSet() { + var config = PastewatchConfig.defaultConfig + config.customRules = [ + CustomRuleConfig(name: "Valid", pattern: "SAFE-[0-9]+"), + CustomRuleConfig(name: "Invalid", pattern: "[broken") + ] + + XCTAssertThrowsError(try compileProxyCustomRules(config)) + } + + func testDirectProxyServerRejectsInvalidRuleBeforeListen() { + let invalidPattern = "[" + "broken" + var config = PastewatchConfig.defaultConfig + config.customRules = [CustomRuleConfig(name: "Invalid", pattern: invalidPattern)] + let server = ProxyServer(port: 0, config: config) + + XCTAssertThrowsError(try server.start()) { error in + XCTAssertTrue(error.localizedDescription.contains("Invalid")) + XCTAssertFalse(error.localizedDescription.contains(invalidPattern)) + } + } + + // WO-275: quiet launch and normal peer disconnects are silent; unexpected + // socket failures remain visible in explicit non-quiet proxy mode. + func testSocketDeliveryFailureLoggingPolicy() { + XCTAssertFalse(ProxyServer.shouldLogSocketDeliveryFailure(errorCode: EPIPE, quiet: false)) + XCTAssertFalse(ProxyServer.shouldLogSocketDeliveryFailure(errorCode: ECONNRESET, quiet: false)) + XCTAssertFalse(ProxyServer.shouldLogSocketDeliveryFailure(errorCode: EBADF, quiet: false)) + XCTAssertFalse(ProxyServer.shouldLogSocketDeliveryFailure(errorCode: EIO, quiet: true)) + XCTAssertTrue(ProxyServer.shouldLogSocketDeliveryFailure(errorCode: EIO, quiet: false)) + } + func testProxyShutdownExitCodeDistinguishesStartupInterrupt() { // WO-375: SIGINT before listen succeeds must not look like a clean shutdown. XCTAssertEqual(proxyShutdownExitCode(didStart: false), proxyInterruptedExitCode) diff --git a/Tests/PastewatchTests/ProxyRealServerTests.swift b/Tests/PastewatchTests/ProxyRealServerTests.swift index 4c15dbe..d1474ab 100644 --- a/Tests/PastewatchTests/ProxyRealServerTests.swift +++ b/Tests/PastewatchTests/ProxyRealServerTests.swift @@ -91,6 +91,74 @@ final class ProxyRealServerTests: XCTestCase { XCTAssertTrue(forwarded.contains(""), "upstream request missing redaction placeholder") } + // WO-462/WO-478/WO-479/WO-481/WO-482/WO-483/WO-485: the real proxy + // must contain complete intrinsic tokens and payload-bearing credentials. + func testIntrinsicProviderTokensAndPayloadsDoNotReachUpstream() throws { + let requestLock = NSLock() + var upstreamRequest = "" + let upstream = try StubHTTPServer { request in + requestLock.lock() + upstreamRequest = String(data: request, encoding: .utf8) ?? "" + requestLock.unlock() + return StubHTTPResponse(status: 200, headers: [:], body: Data(#"{"ok":true}"#.utf8)) + } + try upstream.start() + defer { upstream.stop() } + + let proxyPort = try TCPTestSocket.reserveLoopbackPort() + let proxy = ProxyServer( + port: proxyPort, + upstream: URL(string: "http://127.0.0.1:\(upstream.port)")! + ) + let runningProxy = RunningProxy(server: proxy) + try runningProxy.start() + defer { runningProxy.stop() } + + let slackStem = String(decoding: [120, 111, 120], as: UTF8.self) + let providerSecrets = [ + "hvs." + String(repeating: "A1", count: 12), + slackStem + "b-1234567890-" + String(repeating: "Ab", count: 12), + "AIza" + String(repeating: "K", count: 35), + "dckr_pat_" + String(repeating: "Lm", count: 12), + "github_pat_" + String(repeating: "Pq", count: 20), + providerPEMFixture(label: "OPENSSH PRIVATE KEY", payload: String(repeating: "QUJD", count: 12)) + ] + let gcpKeyID = String(repeating: "a1", count: 20) + let gcpKey = providerPEMFixture(label: "PRIVATE KEY", payload: String(repeating: "R0NQ", count: 12)) + let gcpData = try JSONSerialization.data(withJSONObject: [ + "type": "service_account", + "private_key_id": gcpKeyID, + "private_key": gcpKey + ], options: [.sortedKeys]) + let gcpJSON = try XCTUnwrap(String(data: gcpData, encoding: .utf8)) + let requestObject: [String: Any] = [ + "model": "claude-3", + "messages": [[ + "role": "user", + "content": [ + ["type": "tool_result", "tool_use_id": "toolu_1", "content": providerSecrets.joined(separator: "\n")], + ["type": "tool_result", "tool_use_id": "toolu_2", "content": gcpJSON] + ] + ]] + ] + let bodyData = try JSONSerialization.data(withJSONObject: requestObject, options: [.sortedKeys]) + let body = try XCTUnwrap(String(data: bodyData, encoding: .utf8)) + let response = try TCPTestSocket.roundTrip( + port: proxyPort, + request: TCPTestSocket.postRequest(path: "/v1/messages", body: body), + timeoutSeconds: 10 + ) + + requestLock.lock() + let forwarded = upstreamRequest + requestLock.unlock() + XCTAssertTrue(response.contains("HTTP/1.1 200 OK"), TCPTestSocket.describeResponse(response)) + for secret in providerSecrets + [gcpKeyID, gcpKey] { + XCTAssertFalse(forwarded.contains(secret), "upstream request leaked a raw intrinsic secret") + } + XCTAssertGreaterThanOrEqual(proxy.stats.requestsRedacted, 1) + } + // WO-437: top-level system text is part of the Anthropic request shape and must be scanned. func testAnthropicSystemFieldCredentialRedactedThroughShapeGuardBeforeUpstream() throws { let requestLock = NSLock() @@ -1397,6 +1465,10 @@ final class ProxyRealServerTests: XCTestCase { } XCTFail("condition not satisfied before timeout", file: file, line: line) } + + private func providerPEMFixture(label: String, payload: String) -> String { + "-----BEGIN \(label)-----\n\(payload)\n-----END \(label)-----" + } } private final class RunningProxy { From 69ccdf55efdb9b1bc8163f78d5fcbe6c827e6055 Mon Sep 17 00:00:00 2001 From: ppiankov <103106369+ppiankov@users.noreply.github.com> Date: Wed, 15 Jul 2026 10:50:41 +0800 Subject: [PATCH 16/29] fix: preserve classic token classification --- Sources/PastewatchCore/DetectionRules.swift | 2 +- Tests/PastewatchTests/DetectionRulesTests.swift | 4 ++-- Tests/PastewatchTests/ProxyRealServerTests.swift | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/PastewatchCore/DetectionRules.swift b/Sources/PastewatchCore/DetectionRules.swift index 808c0fe..d72401b 100644 --- a/Sources/PastewatchCore/DetectionRules.swift +++ b/Sources/PastewatchCore/DetectionRules.swift @@ -371,7 +371,7 @@ public struct DetectionRules { pattern: #"\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b"#, options: [] ) { - result.append((.githubToken, regex)) + result.append((.genericApiKey, regex)) } // Stripe API Key - high confidence diff --git a/Tests/PastewatchTests/DetectionRulesTests.swift b/Tests/PastewatchTests/DetectionRulesTests.swift index 695544d..e82fba4 100644 --- a/Tests/PastewatchTests/DetectionRulesTests.swift +++ b/Tests/PastewatchTests/DetectionRulesTests.swift @@ -1285,7 +1285,7 @@ final class DetectionRulesTests: XCTestCase { // WO-462/WO-481/WO-482/WO-483/WO-485: standalone provider tokens must be // recognized from their documented intrinsic format, without keyword context. func testDetectsStandaloneProviderTokens() { - let slackStem = String(decoding: [120, 111, 120], as: UTF8.self) + let slackStem = String(bytes: [120, 111, 120], encoding: .utf8) ?? "" let fixtures: [(SensitiveDataType, String)] = [ (.vaultToken, "hvs." + String(repeating: "A1", count: 12)), (.vaultToken, "s." + String(repeating: "b2", count: 12)), @@ -1311,7 +1311,7 @@ final class DetectionRulesTests: XCTestCase { } func testProviderTokenNearMissesDoNotMatch() { - let slackStem = String(decoding: [120, 111, 120], as: UTF8.self) + let slackStem = String(bytes: [120, 111, 120], encoding: .utf8) ?? "" let nearMisses = [ "hvs.short", "prefixhvb." + String(repeating: "A", count: 24), diff --git a/Tests/PastewatchTests/ProxyRealServerTests.swift b/Tests/PastewatchTests/ProxyRealServerTests.swift index d1474ab..73ae048 100644 --- a/Tests/PastewatchTests/ProxyRealServerTests.swift +++ b/Tests/PastewatchTests/ProxyRealServerTests.swift @@ -114,7 +114,7 @@ final class ProxyRealServerTests: XCTestCase { try runningProxy.start() defer { runningProxy.stop() } - let slackStem = String(decoding: [120, 111, 120], as: UTF8.self) + let slackStem = String(bytes: [120, 111, 120], encoding: .utf8) ?? "" let providerSecrets = [ "hvs." + String(repeating: "A1", count: 12), slackStem + "b-1234567890-" + String(repeating: "Ab", count: 12), From d024c423a6bceb02d899c26b187f86f085161692 Mon Sep 17 00:00:00 2001 From: ppiankov <103106369+ppiankov@users.noreply.github.com> Date: Wed, 15 Jul 2026 10:52:29 +0800 Subject: [PATCH 17/29] fix: surface unexpected socket failures --- Sources/PastewatchCore/ProxyServer.swift | 4 ++-- Tests/PastewatchTests/ProxyCommandTests.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/PastewatchCore/ProxyServer.swift b/Sources/PastewatchCore/ProxyServer.swift index 8893b55..ca6da44 100644 --- a/Sources/PastewatchCore/ProxyServer.swift +++ b/Sources/PastewatchCore/ProxyServer.swift @@ -2128,11 +2128,11 @@ public final class ProxyServer { } } - // WO-275: EPIPE/ECONNRESET/EBADF mean the local client is already gone. + // WO-275: EPIPE/ECONNRESET mean the local client is already gone. // Interrupting a stream is normal and must not look like a proxy failure. static func shouldLogSocketDeliveryFailure(errorCode: Int32, quiet: Bool) -> Bool { guard !quiet else { return false } - return errorCode != EPIPE && errorCode != ECONNRESET && errorCode != EBADF + return errorCode != EPIPE && errorCode != ECONNRESET } // MARK: - Alert injection diff --git a/Tests/PastewatchTests/ProxyCommandTests.swift b/Tests/PastewatchTests/ProxyCommandTests.swift index fb2083a..07520d8 100644 --- a/Tests/PastewatchTests/ProxyCommandTests.swift +++ b/Tests/PastewatchTests/ProxyCommandTests.swift @@ -36,8 +36,8 @@ final class ProxyCommandTests: XCTestCase { func testSocketDeliveryFailureLoggingPolicy() { XCTAssertFalse(ProxyServer.shouldLogSocketDeliveryFailure(errorCode: EPIPE, quiet: false)) XCTAssertFalse(ProxyServer.shouldLogSocketDeliveryFailure(errorCode: ECONNRESET, quiet: false)) - XCTAssertFalse(ProxyServer.shouldLogSocketDeliveryFailure(errorCode: EBADF, quiet: false)) XCTAssertFalse(ProxyServer.shouldLogSocketDeliveryFailure(errorCode: EIO, quiet: true)) + XCTAssertTrue(ProxyServer.shouldLogSocketDeliveryFailure(errorCode: EBADF, quiet: false)) XCTAssertTrue(ProxyServer.shouldLogSocketDeliveryFailure(errorCode: EIO, quiet: false)) } From ff5f8b29a4dc1be9b909183c5e3151dc5a9d944e Mon Sep 17 00:00:00 2001 From: ppiankov <103106369+ppiankov@users.noreply.github.com> Date: Wed, 15 Jul 2026 10:54:37 +0800 Subject: [PATCH 18/29] fix: validate rotating Slack token shape --- Sources/PastewatchCore/DetectionRules.swift | 4 ++-- Tests/PastewatchTests/DetectionRulesTests.swift | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Sources/PastewatchCore/DetectionRules.swift b/Sources/PastewatchCore/DetectionRules.swift index d72401b..51daa35 100644 --- a/Sources/PastewatchCore/DetectionRules.swift +++ b/Sources/PastewatchCore/DetectionRules.swift @@ -272,8 +272,8 @@ public struct DetectionRules { // bounded token characters, and a conservative suffix floor carry certainty. let slackPatterns = [ #"(? Date: Wed, 15 Jul 2026 11:07:09 +0800 Subject: [PATCH 19/29] test: pin provider token boundaries --- Tests/PastewatchTests/DetectionRulesTests.swift | 16 +++++++++++++++- docs/agent-integration.md | 2 +- docs/agent-setup.md | 10 +++------- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/Tests/PastewatchTests/DetectionRulesTests.swift b/Tests/PastewatchTests/DetectionRulesTests.swift index 5b6e3ac..c348ef8 100644 --- a/Tests/PastewatchTests/DetectionRulesTests.swift +++ b/Tests/PastewatchTests/DetectionRulesTests.swift @@ -1288,7 +1288,11 @@ final class DetectionRulesTests: XCTestCase { let slackStem = String(bytes: [120, 111, 120], encoding: .utf8) ?? "" let fixtures: [(SensitiveDataType, String)] = [ (.vaultToken, "hvs." + String(repeating: "A1", count: 12)), + (.vaultToken, "hvb." + String(repeating: "B2", count: 12)), + (.vaultToken, "hvr." + String(repeating: "C3", count: 12)), (.vaultToken, "s." + String(repeating: "b2", count: 12)), + (.vaultToken, "b." + String(repeating: "c3", count: 12)), + (.vaultToken, "r." + String(repeating: "d4", count: 12)), (.slackToken, slackStem + "b-1234567890-" + String(repeating: "Ab", count: 12)), (.slackToken, slackStem + "p-1234567890-" + String(repeating: "Bc", count: 12)), (.slackToken, "xapp-1-A1234567890-" + String(repeating: "Cd", count: 12)), @@ -1322,9 +1326,19 @@ final class DetectionRulesTests: XCTestCase { slackStem + "e." + slackStem + "p-" + String(repeating: "D", count: 24), slackStem + "e-not-a-version-" + String(repeating: "E", count: 24), "AIza" + String(repeating: "F", count: 34), + "AIza" + String(repeating: "G", count: 36), + "AIza" + String(repeating: "H", count: 17) + "!" + String(repeating: "I", count: 17), + "prefixAIza" + String(repeating: "J", count: 35), "dckr_pat_short", + "dckr_oat_" + String(repeating: "K", count: 15), + "dckr_pat_" + String(repeating: "L", count: 8) + "!" + String(repeating: "L", count: 8), + "prefixdckr_pat_" + String(repeating: "M", count: 16), "github_pat_short", - "ghs_not-an-app-id_" + jwtFixture() + "github_pat_" + String(repeating: "N", count: 19), + "github_pat_" + String(repeating: "O", count: 10) + "-" + String(repeating: "O", count: 10), + "prefixgithub_pat_" + String(repeating: "P", count: 20), + "ghs_not-an-app-id_" + jwtFixture(), + "ghs_12345_not-a-jwt" ] for value in nearMisses { diff --git a/docs/agent-integration.md b/docs/agent-integration.md index f0c4209..9aefc83 100644 --- a/docs/agent-integration.md +++ b/docs/agent-integration.md @@ -46,7 +46,7 @@ The proxy catches supported Claude Code traffic that hooks and MCP may miss. Use | OpenCode | Advisory | Advisory | Instructions only | [Hook support pending](https://github.com/anomalyco/opencode/issues/12472) | | Goose | Advisory | Advisory | MCP only | No hook support | | Kilo Code | Advisory | Advisory | MCP only | No hook support | -| Aider | Advisory | Advisory | Proxy only | [No MCP yet](https://github.com/aider-ai/aider/issues/4506) | +| Aider | Not covered | Not covered | No automatic local layer | [No MCP yet](https://github.com/aider-ai/aider/issues/4506) | | Gemini | Advisory | Advisory | MCP only | No hook support | | Codex CLI | Advisory | Advisory | Instructions only | [Hook support pending](https://github.com/openai/codex/issues/14754) | | Qwen Code | Advisory | Advisory | Instructions only | No hook support yet | diff --git a/docs/agent-setup.md b/docs/agent-setup.md index 5b79b4f..d0f8b94 100644 --- a/docs/agent-setup.md +++ b/docs/agent-setup.md @@ -342,17 +342,13 @@ Or auto-setup: pastewatch-cli setup gemini ``` -Note: Gemini has no hook support — enforcement is advisory. Enable Agent mode for MCP tools. Use `pastewatch-cli launch` for proxy-level protection. +Note: Gemini has no hook support — enforcement is advisory. Enable Agent mode for MCP tools. Gemini-shaped API traffic is not supported by the proxy. --- ## Aider -Aider CLI has no native MCP or hook support. Use the proxy for protection: - -```bash -pastewatch-cli launch -- aider -``` +Aider CLI has no native MCP or hook support. Pastewatch does not currently provide an automatic local protection layer for Aider; the proxy accepts Anthropic-shaped traffic only and `launch` wires only Claude Code. Upstream: [aider-ai/aider#4506](https://github.com/aider-ai/aider/issues/4506) (MCP support requested) @@ -545,5 +541,5 @@ This is agent-proof by design: the guard runs in the hook's process, not the age | OpenCode | Advisory | Advisory | MCP only (hook PR closed without merge) | | Goose | Advisory | Advisory | MCP only (no hook support) | | Kilo Code | Advisory | Advisory | MCP only ([hooks declined](https://github.com/Kilo-Org/kilocode/issues/7859)) | -| Aider | Advisory | Advisory | Proxy only ([no MCP yet](https://github.com/aider-ai/aider/issues/4506)) | +| Aider | Not covered | Not covered | No automatic local layer ([no MCP yet](https://github.com/aider-ai/aider/issues/4506)) | | Gemini | Advisory | Advisory | MCP only (no hook support) | From cbb7d725854c2bd4b76a46741fc3a3659bccd918 Mon Sep 17 00:00:00 2001 From: ppiankov <103106369+ppiankov@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:10:32 +0800 Subject: [PATCH 20/29] test: preserve classic GitHub token type --- Tests/PastewatchTests/DetectionRulesTests.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Tests/PastewatchTests/DetectionRulesTests.swift b/Tests/PastewatchTests/DetectionRulesTests.swift index c348ef8..b96b744 100644 --- a/Tests/PastewatchTests/DetectionRulesTests.swift +++ b/Tests/PastewatchTests/DetectionRulesTests.swift @@ -74,11 +74,12 @@ final class DetectionRulesTests: XCTestCase { // MARK: - API Key Detection - func testDetectsGitHubToken() { + // WO-485: preserve the established type for classic GitHub token prefixes. + func testDetectsClassicGitHubTokenAsGenericAPIKey() { let content = "GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" let matches = DetectionRules.scan(content, config: config) - let apiKeyMatches = matches.filter { $0.type == .githubToken } + let apiKeyMatches = matches.filter { $0.type == .genericApiKey } XCTAssertGreaterThanOrEqual(apiKeyMatches.count, 1) } From 241abab12c66cc03b2d32286461be23000d05e48 Mon Sep 17 00:00:00 2001 From: ppiankov <103106369+ppiankov@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:22:43 +0800 Subject: [PATCH 21/29] fix: accept documented Docker token length --- Sources/PastewatchCore/DetectionRules.swift | 5 +++-- Tests/PastewatchTests/DetectionRulesTests.swift | 10 +++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/Sources/PastewatchCore/DetectionRules.swift b/Sources/PastewatchCore/DetectionRules.swift index 51daa35..5666a96 100644 --- a/Sources/PastewatchCore/DetectionRules.swift +++ b/Sources/PastewatchCore/DetectionRules.swift @@ -290,9 +290,10 @@ public struct DetectionRules { } // WO-483: https://docs.docker.com/reference/api/ai-governance/ - // Reviewed 2026-07-14. Docker publishes PAT/OAT prefixes but not a fixed length. + // Reviewed 2026-07-15. Docker publishes PAT/OAT prefixes but not a fixed length; + // its Hub API reference includes a valid 15-character PAT suffix. if let regex = try? NSRegularExpression( - pattern: #"(? Date: Wed, 15 Jul 2026 12:46:39 +0800 Subject: [PATCH 22/29] fix: bind structured secret containment Caller-audit: Sources/Pastewatch/ClipboardMonitor.swift:126 -- unaffected Sources/PastewatchCLI/GuardCommand.swift:43 -- unaffected Sources/PastewatchCLI/ScanCommand.swift:155 -- unaffected Sources/PastewatchCore/SocketHelpers.swift:117 -- unaffected Sources/PastewatchCore/ProxyServer.swift:750 -- updated --- Sources/PastewatchCore/DetectionRules.swift | 281 ++++++++++++++---- Sources/PastewatchCore/Obfuscator.swift | 4 + Sources/PastewatchCore/ProxyServer.swift | 62 +++- Sources/PastewatchCore/Types.swift | 15 +- .../PastewatchTests/DetectionRulesTests.swift | 63 +++- Tests/PastewatchTests/ObfuscatorTests.swift | 9 + .../ProxyRealServerTests.swift | 119 ++++++++ 7 files changed, 487 insertions(+), 66 deletions(-) diff --git a/Sources/PastewatchCore/DetectionRules.swift b/Sources/PastewatchCore/DetectionRules.swift index 5666a96..db5840e 100644 --- a/Sources/PastewatchCore/DetectionRules.swift +++ b/Sources/PastewatchCore/DetectionRules.swift @@ -636,40 +636,73 @@ public struct DetectionRules { return matches } - // WO-478: match one complete, correctly paired PEM block and stop at a nested - // BEGIN marker instead of crossing into an adjacent or malformed key. + // WO-478: valid blocks authorize complete containment; malformed recognized + // blocks reserve their bounded region as advisory-only evidence. private static func scanCompletePrivateKeyBlocks( _ content: String, config: PastewatchConfig, matches: inout [DetectedMatch], matchedRanges: inout [Range] ) { + let labelPattern = "RSA PRIVATE KEY|DSA PRIVATE KEY|EC PRIVATE KEY|OPENSSH PRIVATE KEY|PRIVATE KEY" guard config.isTypeEnabled(.sshPrivateKey), - let beginRegex = try? NSRegularExpression( - pattern: #"-----BEGIN (RSA PRIVATE KEY|DSA PRIVATE KEY|EC PRIVATE KEY|OPENSSH PRIVATE KEY|PRIVATE KEY)-----"# - ) else { return } + let beginRegex = try? NSRegularExpression(pattern: "-----BEGIN (\(labelPattern))-----"), + let endRegex = try? NSRegularExpression(pattern: "-----END (\(labelPattern))-----") else { return } let fullRange = NSRange(content.startIndex..., in: content) - for candidate in beginRegex.matches(in: content, range: fullRange) { + let beginCandidates = beginRegex.matches(in: content, range: fullRange) + let endCandidates = endRegex.matches(in: content, range: fullRange) + var malformedSuppressionEnd: String.Index? + var endCandidateIndex = 0 + for (candidateIndex, candidate) in beginCandidates.enumerated() { guard let beginRange = Range(candidate.range, in: content), let labelRange = Range(candidate.range(at: 1), in: content) else { continue } - let endMarker = "-----END \(content[labelRange])-----" let searchLimit = content.index( beginRange.lowerBound, offsetBy: maximumPrivateKeyBlockCharacters, limitedBy: content.endIndex ) ?? content.endIndex - let searchRange = beginRange.upperBound..] ) { guard config.isTypeEnabled(.gcpServiceAccount), - let root = try? JSONSerialization.jsonObject(with: Data(content.utf8)) else { return } - var authorized: [String: Set] = [:] - collectGCPServiceAccountSecrets(root, into: &authorized) - guard !authorized.isEmpty else { return } - - for key in ["private_key", "private_key_id"] { - guard let values = authorized[key], !values.isEmpty, - let regex = try? NSRegularExpression( - pattern: "\"\(key)\"\\s*:\\s*\"((?:\\\\.|[^\"\\\\])*)\"" - ) else { continue } - let fullRange = NSRange(content.startIndex..., in: content) - for candidate in regex.matches(in: content, range: fullRange) { - guard let valueRange = Range(candidate.range(at: 1), in: content), - !matchedRanges.contains(where: { $0.overlaps(valueRange) }) else { continue } - let encoded = String(content[valueRange]) - guard let decoded = decodeJSONStringContent(encoded), values.contains(decoded) else { continue } - matches.append(DetectedMatch( - type: .gcpServiceAccount, - value: encoded, - range: valueRange, - line: lineNumber(of: valueRange.lowerBound, in: content) - )) - matchedRanges.append(valueRange) - } + (try? JSONSerialization.jsonObject(with: Data(content.utf8))) != nil else { return } + var parser = JSONSourceRangeParser(content: content) + guard let root = parser.parseDocument() else { return } + var authorizedRanges: [Range] = [] + collectGCPServiceAccountRanges(root, into: &authorizedRanges) + + for valueRange in authorizedRanges where !matchedRanges.contains(where: { $0.overlaps(valueRange) }) { + let encoded = String(content[valueRange]) + matches.append(DetectedMatch( + type: .gcpServiceAccount, + value: encoded, + range: valueRange, + line: lineNumber(of: valueRange.lowerBound, in: content) + )) + matchedRanges.append(valueRange) } } - private static func collectGCPServiceAccountSecrets( - _ value: Any, - into result: inout [String: Set] + // WO-479: collect source ranges from the same object that carries the direct + // service-account marker; equal decoded values elsewhere grant no authority. + private static func collectGCPServiceAccountRanges( + _ value: JSONSourceValue, + into result: inout [Range] ) { - if let object = value as? [String: Any] { - if object["type"] as? String == "service_account" { - for key in ["private_key", "private_key_id"] { - if let secret = object[key] as? String, !secret.isEmpty { - result[key, default: []].insert(secret) + switch value { + case .object(let members): + let directType = members.last { $0.key == "type" }?.value.stringValue + if directType?.decoded == "service_account" { + for member in members where member.key == "private_key" || member.key == "private_key_id" { + if let secret = member.value.stringValue, !secret.decoded.isEmpty { + result.append(secret.contentRange) } } } - for child in object.values { - collectGCPServiceAccountSecrets(child, into: &result) + for member in members { + collectGCPServiceAccountRanges(member.value, into: &result) } - } else if let array = value as? [Any] { - for child in array { - collectGCPServiceAccountSecrets(child, into: &result) + case .array(let values): + for child in values { + collectGCPServiceAccountRanges(child, into: &result) } + case .string, .scalar: + break + } + } + + private struct JSONSourceString { + let decoded: String + let contentRange: Range + } + + private struct JSONSourceMember { + let key: String + let value: JSONSourceValue + } + + private indirect enum JSONSourceValue { + case object([JSONSourceMember]) + case array([JSONSourceValue]) + case string(JSONSourceString) + case scalar + + var stringValue: JSONSourceString? { + guard case .string(let value) = self else { return nil } + return value + } + } + + // WO-479: JSONSerialization proves validity; this deterministic companion + // parser retains exact raw string ranges needed for context-bound mutation. + private struct JSONSourceRangeParser { + let content: String + var index: String.Index + + init(content: String) { + self.content = content + self.index = content.startIndex + } + + mutating func parseDocument() -> JSONSourceValue? { + skipWhitespace() + guard let value = parseValue() else { return nil } + skipWhitespace() + return index == content.endIndex ? value : nil + } + + private mutating func parseValue() -> JSONSourceValue? { + skipWhitespace() + guard index < content.endIndex else { return nil } + switch content[index] { + case "{": return parseObject() + case "[": return parseArray() + case "\"": return parseString().map(JSONSourceValue.string) + default: return parseScalar() + } + } + + private mutating func parseObject() -> JSONSourceValue? { + advance() + skipWhitespace() + var members: [JSONSourceMember] = [] + if consume("}") { return .object(members) } + + while true { + guard let key = parseString() else { return nil } + skipWhitespace() + guard consume(":") else { return nil } + guard let value = parseValue() else { return nil } + members.append(JSONSourceMember(key: key.decoded, value: value)) + skipWhitespace() + if consume("}") { return .object(members) } + guard consume(",") else { return nil } + skipWhitespace() + } + } + + private mutating func parseArray() -> JSONSourceValue? { + advance() + skipWhitespace() + var values: [JSONSourceValue] = [] + if consume("]") { return .array(values) } + + while true { + guard let value = parseValue() else { return nil } + values.append(value) + skipWhitespace() + if consume("]") { return .array(values) } + guard consume(",") else { return nil } + skipWhitespace() + } + } + + private mutating func parseString() -> JSONSourceString? { + guard consume("\"") else { return nil } + let contentStart = index + while index < content.endIndex { + let character = content[index] + if character == "\"" { + let range = contentStart.. JSONSourceValue? { + let start = index + while index < content.endIndex, + ![",", "]", "}", " ", "\t", "\r", "\n"].contains(content[index]) { + advance() + } + return start == index ? nil : .scalar + } + + private mutating func skipWhitespace() { + while index < content.endIndex, [" ", "\t", "\r", "\n"].contains(content[index]) { + advance() + } + } + + private mutating func consume(_ expected: Character) -> Bool { + guard index < content.endIndex, content[index] == expected else { return false } + advance() + return true + } + + private mutating func advance() { + index = content.index(after: index) } } @@ -811,6 +973,7 @@ public struct DetectionRules { ) } + // WO-478: malformed container evidence overrides overlapping mutation rules. /// Scan with allowlist filtering and custom rules. public static func scan( _ content: String, @@ -847,7 +1010,15 @@ public struct DetectionRules { } } - for match in scan(content, config: config) where !matchedRanges.contains(where: { $0.overlaps(match.range) }) { + for match in scan(content, config: config) { + // WO-478: malformed private-key evidence overrides overlapping custom + // matches so malformed input cannot be reported as successful mutation. + if match.advisory != nil { + matches.removeAll { $0.range.overlaps(match.range) } + matchedRanges.removeAll { $0.overlaps(match.range) } + } else if matchedRanges.contains(where: { $0.overlaps(match.range) }) { + continue + } matches.append(match) matchedRanges.append(match.range) } diff --git a/Sources/PastewatchCore/Obfuscator.swift b/Sources/PastewatchCore/Obfuscator.swift index c61bda7..d500cf7 100644 --- a/Sources/PastewatchCore/Obfuscator.swift +++ b/Sources/PastewatchCore/Obfuscator.swift @@ -9,9 +9,13 @@ import Foundation /// - After paste, the system returns to rest public struct Obfuscator { + // WO-478: advisory scanner outcomes cannot authorize content replacement. /// Obfuscate all matches in the content. /// Returns the obfuscated content with matches replaced by placeholders. public static func obfuscate(_ content: String, matches: [DetectedMatch]) -> String { + // WO-478: advisory diagnostics reserve ranges for reporting but never + // authorize replacement, even when a caller passes the full scan result. + let matches = matches.filter { $0.advisory == nil } guard !matches.isEmpty else { return content } // Sort matches by range start position (descending) to replace from end diff --git a/Sources/PastewatchCore/ProxyServer.swift b/Sources/PastewatchCore/ProxyServer.swift index ca6da44..dcfb651 100644 --- a/Sources/PastewatchCore/ProxyServer.swift +++ b/Sources/PastewatchCore/ProxyServer.swift @@ -748,6 +748,14 @@ public final class ProxyServer { // upstreamBodyShapeVerdict, so the scanner only receives valid UTF-8 JSON. if let body = parsed.body { let result = scanAndRedactBody(body) + // WO-478: malformed recognized private-key material cannot be + // contained reliably, so refuse before resolving the upstream URL. + if result.blockingAdvisory == .malformedPrivateKey { + recordRefusedRequest() + logUnsafeBodyRefusal(path: parsed.path, reason: "malformed private key block") + sendError(to: clientSocket, status: 400, message: "Unsafe request body") + return + } redactionCount = result.redacted redactedTypes = result.redactedTypes bodyAdvisoryCount = result.advisoryCount @@ -1061,14 +1069,27 @@ public final class ProxyServer { let advisoryCount: Int let advisoryTypes: [String] let serializationFailed: Bool // WO-452: caller must block forwarding on failure. + let blockingAdvisory: DetectionAdvisory? // WO-478: fail-closed request evidence. } + // WO-478: malformed recognized key containers fail closed before mutation. func scanAndRedactBody(_ body: String) -> ScanResult { guard let data = body.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return ScanResult( body: body, redacted: 0, redactedTypes: [], - advisoryCount: 0, advisoryTypes: [], serializationFailed: false + advisoryCount: 0, advisoryTypes: [], serializationFailed: false, + blockingAdvisory: nil + ) + } + + // WO-478: preflight the raw JSON text so malformed PEM markers remain + // visible even when they occur in a structured field not rewritten below. + if scanProxyText(body).contains(where: { $0.advisory == .malformedPrivateKey }) { + return ScanResult( + body: body, redacted: 0, redactedTypes: [], + advisoryCount: 1, advisoryTypes: ["Malformed private key"], + serializationFailed: false, blockingAdvisory: .malformedPrivateKey ) } @@ -1102,7 +1123,7 @@ public final class ProxyServer { return ScanResult( body: body, redacted: 0, redactedTypes: [], advisoryCount: advisoryCount, advisoryTypes: advisoryTypes, - serializationFailed: false + serializationFailed: false, blockingAdvisory: nil ) } guard let resultData = try? requestBodySerializer(processed), @@ -1110,14 +1131,14 @@ public final class ProxyServer { return ScanResult( body: body, redacted: redacted, redactedTypes: types, advisoryCount: advisoryCount, advisoryTypes: advisoryTypes, - serializationFailed: true + serializationFailed: true, blockingAdvisory: nil ) } return ScanResult( body: resultString, redacted: redacted, redactedTypes: types, advisoryCount: advisoryCount, advisoryTypes: advisoryTypes, - serializationFailed: false + serializationFailed: false, blockingAdvisory: nil ) } @@ -1363,6 +1384,39 @@ public final class ProxyServer { } } + // WO-478: malformed secret-container refusals are audited without recording + // any marker payload or request-body bytes. + private func logUnsafeBodyRefusal(path: String, reason: String) { + let safePath = auditSafePath(path) + let signature = "refused:\(safePath):\(reason)" + statsLock.lock() + let isRepeat = signature == lastRefusalLogSignature + lastRefusalLogSignature = signature + if !isRepeat { + lastRedactionLogSignatures.removeAll() + lastAdvisoryLogSignatures.removeAll() + lastModelIdentityAdvisorySignature = nil + } + statsLock.unlock() + guard !isRepeat else { return } + + let line = "[\(formatAuditTimestamp(Date()))] PROXY REFUSED unsafe request body in \(safePath) (\(reason))\n" + if !quietLog { + FileHandle.standardError.write(Data(line.utf8)) + } + if let logPath = auditLogPath { + logQueue.async { + if let handle = FileHandle(forWritingAtPath: logPath) { + handle.seekToEndOfFile() + handle.write(Data(line.utf8)) + handle.closeFile() + } else { + FileManager.default.createFile(atPath: logPath, contents: Data(line.utf8)) + } + } + } + } + // WO-486: request targets are forwarded verbatim but audit output never includes // query values or terminal/log control bytes. func auditSafePath(_ rawPath: String) -> String { diff --git a/Sources/PastewatchCore/Types.swift b/Sources/PastewatchCore/Types.swift index 3f6db86..32ba0c7 100644 --- a/Sources/PastewatchCore/Types.swift +++ b/Sources/PastewatchCore/Types.swift @@ -221,6 +221,11 @@ public enum SensitiveDataType: String, CaseIterable, Codable { } } +/// WO-478: scanner conditions that are observable but never authorize mutation. +public enum DetectionAdvisory: String, Equatable { + case malformedPrivateKey +} + /// A single detected match in the clipboard content. public struct DetectedMatch: Identifiable, Equatable { public let id = UUID() @@ -231,6 +236,7 @@ public struct DetectedMatch: Identifiable, Equatable { public let filePath: String? public let customRuleName: String? public let customSeverity: Severity? + public let advisory: DetectionAdvisory? // WO-478: non-mutating malformed-input evidence. public init( type: SensitiveDataType, @@ -239,7 +245,8 @@ public struct DetectedMatch: Identifiable, Equatable { line: Int = 1, filePath: String? = nil, customRuleName: String? = nil, - customSeverity: Severity? = nil + customSeverity: Severity? = nil, + advisory: DetectionAdvisory? = nil ) { self.type = type self.value = value @@ -248,6 +255,7 @@ public struct DetectedMatch: Identifiable, Equatable { self.filePath = filePath self.customRuleName = customRuleName self.customSeverity = customSeverity + self.advisory = advisory } /// Effective severity: custom override if set, otherwise type default. @@ -257,12 +265,13 @@ public struct DetectedMatch: Identifiable, Equatable { /// WO-404: custom rules are explicit operator approval to mutate matches. public var mutationSafe: Bool { - customRuleName != nil || type.mutationSafe + advisory == nil && (customRuleName != nil || type.mutationSafe) } /// Display name for output (custom rule name or type rawValue). public var displayName: String { - customRuleName ?? type.rawValue + if advisory == .malformedPrivateKey { return "Malformed private key" } + return customRuleName ?? type.rawValue } public static func == (lhs: DetectedMatch, rhs: DetectedMatch) -> Bool { diff --git a/Tests/PastewatchTests/DetectionRulesTests.swift b/Tests/PastewatchTests/DetectionRulesTests.swift index e7a3136..b94bf71 100644 --- a/Tests/PastewatchTests/DetectionRulesTests.swift +++ b/Tests/PastewatchTests/DetectionRulesTests.swift @@ -1363,16 +1363,32 @@ final class DetectionRulesTests: XCTestCase { XCTAssertFalse(redacted.contains("REVG")) } - func testSSHPrivateKeyRejectsIncompleteOrMismatchedBlocks() { + // WO-478: malformed recognized private-key blocks are advisory findings, not + // successful secret containment matches. + func testSSHPrivateKeyReportsMalformedBlocksWithoutAuthorizingMutation() { let incomplete = "-----BEGIN OPENSSH PRIVATE " + "KEY-----\n" + String(repeating: "QUJD", count: 12) let mismatched = incomplete + "\n-----END RSA PRIVATE KEY-----" + let nested = incomplete + "\n" + pemFixture( + label: "RSA PRIVATE KEY", payload: String(repeating: "REVG", count: 12), newline: "\n" + ) let oversized = pemFixture( label: "PRIVATE KEY", payload: String(repeating: "A", count: 262_145), newline: "\n" ) - for value in [incomplete, mismatched, oversized, - "-----BEGIN PUBLIC KEY-----\nQUJD\n-----END PUBLIC KEY-----"] { - XCTAssertFalse(DetectionRules.scan(value, config: config).contains { $0.type == .sshPrivateKey }) + for value in [incomplete, mismatched, nested, oversized] { + let matches = DetectionRules.scan(value, config: config) + let privateKeyMatches = matches.filter { $0.type == .sshPrivateKey } + XCTAssertFalse(privateKeyMatches.isEmpty) + XCTAssertTrue(privateKeyMatches.allSatisfy { $0.advisory == .malformedPrivateKey }) + XCTAssertTrue(privateKeyMatches.allSatisfy { !$0.mutationSafe }) + XCTAssertEqual(Obfuscator.obfuscate(value, matches: matches), value) + } + + for value in [ + "-----BEGIN PUBLIC KEY-----\nQUJD\n-----END PUBLIC KEY-----", + "-----BEGIN CERTIFICATE-----\nQUJD\n-----END CERTIFICATE-----" + ] { + XCTAssertFalse(DetectionRules.scan(value, config: config).contains { $0.advisory != nil }) } } @@ -1415,6 +1431,45 @@ final class DetectionRulesTests: XCTestCase { .contains { $0.type == .gcpServiceAccount }) } + // WO-479: authorization belongs to an exact object path/range, never to an + // equal value elsewhere in the JSON document. + func testGCPServiceAccountRangesDoNotAuthorizeEqualSiblingValues() throws { + let key = "gcp-private-material-\r\n" + String(repeating: "R0NQ", count: 12) + let keyID = String(repeating: "b2", count: 20) + let object: [String: Any] = [ + "service": [ + "private_key": key, + "nested": ["private_key": key, "private_key_id": keyID], + "type": "service_account", + "private_key_id": keyID + ], + "benign": ["type": "user", "private_key": key, "private_key_id": keyID], + "services": [["private_key_id": keyID, "type": "service_account", "private_key": key]] + ] + let data = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + let content = try XCTUnwrap(String(data: data, encoding: .utf8)) + let matches = DetectionRules.scan(content, config: config).filter { $0.type == .gcpServiceAccount } + + XCTAssertEqual(matches.count, 4) + let redacted = Obfuscator.obfuscate(content, matches: matches) + let parsed = try XCTUnwrap( + try JSONSerialization.jsonObject(with: Data(redacted.utf8)) as? [String: Any] + ) + let service = try XCTUnwrap(parsed["service"] as? [String: Any]) + let nested = try XCTUnwrap(service["nested"] as? [String: Any]) + let benign = try XCTUnwrap(parsed["benign"] as? [String: Any]) + let services = try XCTUnwrap(parsed["services"] as? [[String: Any]]) + + XCTAssertNotEqual(service["private_key"] as? String, key) + XCTAssertNotEqual(service["private_key_id"] as? String, keyID) + XCTAssertEqual(nested["private_key"] as? String, key) + XCTAssertEqual(nested["private_key_id"] as? String, keyID) + XCTAssertEqual(benign["private_key"] as? String, key) + XCTAssertEqual(benign["private_key_id"] as? String, keyID) + XCTAssertNotEqual(services.first?["private_key"] as? String, key) + XCTAssertNotEqual(services.first?["private_key_id"] as? String, keyID) + } + private func pemFixture(label: String, payload: String, newline: String) -> String { "-----BEGIN \(label)-----\(newline)\(payload)\(newline)-----END \(label)-----" } diff --git a/Tests/PastewatchTests/ObfuscatorTests.swift b/Tests/PastewatchTests/ObfuscatorTests.swift index fd85100..89fbfc3 100644 --- a/Tests/PastewatchTests/ObfuscatorTests.swift +++ b/Tests/PastewatchTests/ObfuscatorTests.swift @@ -74,4 +74,13 @@ final class ObfuscatorTests: XCTestCase { XCTAssertTrue(result.contains("")) XCTAssertFalse(result.contains("token_abc")) } + + // WO-478: advisory diagnostics must never be interpreted as replacement ranges. + func testLeavesMalformedPrivateKeyAdvisoryUnchanged() { + let content = "-----BEGIN PRIVATE " + "KEY-----\nmalformed" + let matches = DetectionRules.scan(content, config: config) + + XCTAssertEqual(matches.map(\.advisory), [.malformedPrivateKey]) + XCTAssertEqual(Obfuscator.obfuscate(content, matches: matches), content) + } } diff --git a/Tests/PastewatchTests/ProxyRealServerTests.swift b/Tests/PastewatchTests/ProxyRealServerTests.swift index 73ae048..9533e80 100644 --- a/Tests/PastewatchTests/ProxyRealServerTests.swift +++ b/Tests/PastewatchTests/ProxyRealServerTests.swift @@ -159,6 +159,125 @@ final class ProxyRealServerTests: XCTestCase { XCTAssertGreaterThanOrEqual(proxy.stats.requestsRedacted, 1) } + // WO-478: malformed recognized private-key material must fail closed before + // the proxy opens or writes an upstream request. + func testMalformedPrivateKeyRefusedBeforeUpstream() throws { + let upstream = try StubHTTPServer { _ in + StubHTTPResponse(status: 200, headers: [:], body: Data(#"{"ok":true}"#.utf8)) + } + try upstream.start() + defer { upstream.stop() } + + let auditPath = FileManager.default.temporaryDirectory + .appendingPathComponent("pastewatch-malformed-key-\(UUID().uuidString).log") + defer { try? FileManager.default.removeItem(at: auditPath) } + let proxyPort = try TCPTestSocket.reserveLoopbackPort() + let proxy = ProxyServer( + port: proxyPort, + upstream: URL(string: "http://127.0.0.1:\(upstream.port)")!, + auditLogPath: auditPath.path, + quietLog: true + ) + let runningProxy = RunningProxy(server: proxy) + try runningProxy.start() + var proxyStopped = false + defer { + if !proxyStopped { runningProxy.stop() } + } + + let malformed = "-----BEGIN OPENSSH PRIVATE KEY-----\n" + String(repeating: "QUJD", count: 12) + let object: [String: Any] = [ + "model": "claude-3", + "messages": [["role": "user", "content": malformed]] + ] + let data = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + let body = try XCTUnwrap(String(data: data, encoding: .utf8)) + let response = try TCPTestSocket.roundTrip( + port: proxyPort, + request: TCPTestSocket.postRequest(path: "/v1/messages", body: body), + timeoutSeconds: 10 + ) + + let diagnostic = TCPTestSocket.describeResponse(response) + " upstream_requests=\(upstream.requestCount)" + XCTAssertTrue(response.contains("HTTP/1.1 400 Bad Request"), diagnostic) + XCTAssertTrue(response.contains(#""error": "Unsafe request body""#), diagnostic) + XCTAssertFalse(response.contains("QUJD"), diagnostic) + XCTAssertEqual(upstream.requestCount, 0, diagnostic) + XCTAssertEqual(proxy.stats.refusedRequests, 1) + XCTAssertEqual(proxy.stats.requestsRedacted, 0) + runningProxy.stop() + proxyStopped = true + let audit = try String(contentsOf: auditPath, encoding: .utf8) + XCTAssertTrue(audit.contains("PROXY REFUSED unsafe request body"), audit) + XCTAssertTrue(audit.contains("malformed private key block"), audit) + XCTAssertFalse(audit.contains("QUJD"), audit) + } + + // WO-479: equal private material in a benign sibling object is not authorized + // merely because a service-account object contains the same value. + func testGCPServiceAccountRedactionPreservesEqualBenignSiblingUpstream() throws { + let requestLock = NSLock() + var upstreamRequest = "" + let upstream = try StubHTTPServer { request in + requestLock.lock() + upstreamRequest = String(data: request, encoding: .utf8) ?? "" + requestLock.unlock() + return StubHTTPResponse(status: 200, headers: [:], body: Data(#"{"ok":true}"#.utf8)) + } + try upstream.start() + defer { upstream.stop() } + + let proxyPort = try TCPTestSocket.reserveLoopbackPort() + let proxy = ProxyServer(port: proxyPort, upstream: URL(string: "http://127.0.0.1:\(upstream.port)")!) + let runningProxy = RunningProxy(server: proxy) + try runningProxy.start() + defer { runningProxy.stop() } + + let keyID = String(repeating: "c3", count: 20) + let key = "gcp-private-material-\r\n" + String(repeating: "R0NQ", count: 12) + let nested: [String: Any] = [ + "service": ["type": "service_account", "private_key": key, "private_key_id": keyID], + "benign": ["type": "user", "private_key": key, "private_key_id": keyID] + ] + let nestedData = try JSONSerialization.data(withJSONObject: nested, options: [.sortedKeys]) + let nestedJSON = try XCTUnwrap(String(data: nestedData, encoding: .utf8)) + let requestObject: [String: Any] = [ + "model": "claude-3", + "messages": [["role": "user", "content": [[ + "type": "tool_result", "tool_use_id": "toolu_1", "content": nestedJSON + ]]]] + ] + let bodyData = try JSONSerialization.data(withJSONObject: requestObject, options: [.sortedKeys]) + let body = try XCTUnwrap(String(data: bodyData, encoding: .utf8)) + let response = try TCPTestSocket.roundTrip( + port: proxyPort, + request: TCPTestSocket.postRequest(path: "/v1/messages", body: body), + timeoutSeconds: 10 + ) + + requestLock.lock() + let forwarded = upstreamRequest + requestLock.unlock() + let forwardedBody = try XCTUnwrap(forwarded.components(separatedBy: "\r\n\r\n").last) + let forwardedObject = try XCTUnwrap( + try JSONSerialization.jsonObject(with: Data(forwardedBody.utf8)) as? [String: Any] + ) + let messages = try XCTUnwrap(forwardedObject["messages"] as? [[String: Any]]) + let content = try XCTUnwrap(messages.first?["content"] as? [[String: Any]]) + let forwardedNestedJSON = try XCTUnwrap(content.first?["content"] as? String) + let forwardedNested = try XCTUnwrap( + try JSONSerialization.jsonObject(with: Data(forwardedNestedJSON.utf8)) as? [String: Any] + ) + let service = try XCTUnwrap(forwardedNested["service"] as? [String: Any]) + let benign = try XCTUnwrap(forwardedNested["benign"] as? [String: Any]) + + XCTAssertTrue(response.contains("HTTP/1.1 200 OK"), TCPTestSocket.describeResponse(response)) + XCTAssertNotEqual(service["private_key"] as? String, key) + XCTAssertNotEqual(service["private_key_id"] as? String, keyID) + XCTAssertEqual(benign["private_key"] as? String, key) + XCTAssertEqual(benign["private_key_id"] as? String, keyID) + } + // WO-437: top-level system text is part of the Anthropic request shape and must be scanned. func testAnthropicSystemFieldCredentialRedactedThroughShapeGuardBeforeUpstream() throws { let requestLock = NSLock() From 74c03126efb546ca92641c9eda3bfb7930d638f2 Mon Sep 17 00:00:00 2001 From: ppiankov <103106369+ppiankov@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:49:52 +0800 Subject: [PATCH 23/29] fix: authorize secret mutation from evidence --- CHANGELOG.md | 16 + README.md | 32 +- Sources/Pastewatch/ClipboardMonitor.swift | 37 ++- Sources/PastewatchCLI/GuardCommand.swift | 2 +- Sources/PastewatchCLI/MCPCommand.swift | 13 +- Sources/PastewatchCLI/ScanCommand.swift | 7 +- Sources/PastewatchCore/CurlHTTPClient.swift | 11 +- Sources/PastewatchCore/DetectionRules.swift | 51 ++- .../MutationAuthorization.swift | 82 +++++ Sources/PastewatchCore/Obfuscator.swift | 5 + Sources/PastewatchCore/ProxyServer.swift | 299 ++++++++++++------ Sources/PastewatchCore/SocketHelpers.swift | 59 ++-- Sources/PastewatchCore/Types.swift | 65 +++- .../MutationAuthorizationTests.swift | 146 +++++++++ .../ProviderTokenPatternTests.swift | 89 ++++++ .../ProxyBodyShapeGuardTests.swift | 35 ++ .../ProxyHTTPRequestReadTests.swift | 6 +- .../ProxyRealServerTests.swift | 114 +++++-- .../ProxyStreamRedactionTests.swift | 50 +-- Tests/PastewatchTests/ProxyTimeoutTests.swift | 32 +- .../SecretContainmentTests.swift | 90 ++++++ docs/proxy-invariants.md | 9 +- 22 files changed, 1020 insertions(+), 230 deletions(-) create mode 100644 Sources/PastewatchCore/MutationAuthorization.swift create mode 100644 Tests/PastewatchTests/MutationAuthorizationTests.swift create mode 100644 Tests/PastewatchTests/ProviderTokenPatternTests.swift create mode 100644 Tests/PastewatchTests/SecretContainmentTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index e239943..ef0e093 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Mutation authorization now comes from intrinsic secret format, exact known-value + evidence, or an operator custom rule. Severity and request-field context affect + advisory reporting only; format-only DSN/JDBC and generic credential matches remain + visible without being rewritten. +- The Anthropic request scanner now covers tool contracts, input examples, message text, + tool inputs/results, and stop sequences, and rejects malformed tool/stop containers. +- Proxy replacement is documented as one-way. Reversible local restoration remains an + MCP read/write capability. + +### Fixed + +- Azure Storage connection-string detection no longer consumes bytes following the + base64 `AccountKey` value. + ## [0.29.0] - 2026-07-12 ### Added diff --git a/README.md b/README.md index 62a356f..110be19 100644 --- a/README.md +++ b/README.md @@ -40,9 +40,11 @@ Pastewatch prevents supported secret-leakage paths structurally without breaking Write code with placeholders → MCP resolves originals locally on write-back ``` -The agent works normally. It reads files, runs commands, writes code. It just never sees the real values — and neither does the cloud. +The agent works normally. It reads files, runs commands, and writes code. Through MCP, +the agent sees reversible placeholders; through the proxy, intrinsically identifiable or +operator-authorized secrets are replaced before supported traffic reaches the cloud. -**No behavioral rules. No trust assumptions. No ML. The architecture prevents the leak.** +**No ML and no probabilistic mutation. Authorization comes from secret evidence, not severity.** ## Why Pastewatch @@ -76,8 +78,8 @@ All layers share the same detection engine — 30+ pattern types, deterministic Pastewatch rewrites your data only when it is **certain** the value is a secret. This is a hard rule, not a tuning knob: -- **Mutated (obfuscated/restored):** deterministic secret classes only — API keys, tokens, DSNs, JWTs, SSH keys, credit cards (Luhn-validated), and any pattern **you** approve via a custom rule. -- **Advisory only (never mutated):** inherently ambiguous detections — emails, phone numbers, IPs, hostnames, file paths, UUIDs. A legitimate email or hostname in a real response is not a leak, and pastewatch will never corrupt a valid response by rewriting one. Instead it **nags you off-band** so you can decide whether to promote the pattern by adding a custom rule. +- **Mutated:** intrinsically identifiable secrets such as provider tokens, complete private keys, validated JWTs and cards; exact values supplied by a trusted local source; and patterns **you** approve with a custom rule. +- **Advisory only:** format-only DSN/JDBC URLs, generic credential assignments, XML credential-shaped text, and ambiguous detections such as emails, phone numbers, IPs, hostnames, file paths, and UUIDs. Pastewatch reports these off-band without rewriting them unless exact-value or custom-rule evidence authorizes mutation. - **`--severity` controls how much it nags, never what it rewrites.** Lowering severity surfaces more advisories; it never widens the set of values that get mutated. The result: false negatives are preferred over false positives, and mutation false positives are driven to ~zero by construction. Pastewatch never breaks a working agent response to redact something it only *might* be. @@ -106,8 +108,9 @@ Pastewatch does not: Pastewatch scans text for sensitive patterns and replaces them with non-sensitive placeholders. The same engine powers all six layers: 1. **Detection** — regex-based pattern matching across 30+ secret types (API keys, DSNs, tokens, credentials, PII) -2. **Obfuscation** — matched values are replaced with typed placeholders (``, ``) -3. **Resolution** — MCP server stores originals in local RAM, restores them on write-back. Secrets never leave the machine +2. **Authorization** — evidence partitions matches into mutation or advisory-only outcomes +3. **Obfuscation** — authorized values are replaced with typed placeholders (``) +4. **Resolution** — only the MCP server stores originals in local RAM and restores them on write-back The clipboard monitor scans before paste. The CLI scans files on demand. The MCP server scans on read and resolves on write. The guard scans commands before execution. The proxy scans API requests before they leave the network. Each layer catches what the others miss. @@ -251,9 +254,9 @@ If detection is ambiguous, Pastewatch does nothing. Detected values are replaced with typed, numbered placeholders: ``` -john.doe@example.com → AKIAIOSFODNN7EXAMPLE → -192.168.1.100 → +AIza... → +github_pat_... → ``` How placeholders work depends on the layer: @@ -261,17 +264,20 @@ How placeholders work depends on the layer: | Layer | Placeholder lifetime | Recovery | |-------|---------------------|----------| | **Clipboard** | Discarded after paste | None — one-way | -| **CLI scan** | Output only | None — report only | +| **CLI scan** | Output only | None — source files are never modified | | **MCP server** | Stored in RAM for the session | Write-back resolves originals locally | | **API proxy** | Replaced in-flight | None — redacted before it leaves | -The MCP server is the only layer that maintains a mapping — it must, because the agent needs to write code with real values restored. The mapping lives in process memory and is lost when the session ends. No persistence, no disk, no cloud. +The MCP server is the only layer that maintains a mapping, because it must restore +placeholders locally when the agent writes a file. Clipboard and proxy replacement is +one-way; responses are scanned independently rather than deobfuscated. The MCP mapping +lives in process memory and is lost when the session ends. --- ## User Experience -- **Clipboard/GUI** — silent by default. When obfuscation occurs, a minimal macOS notification: `Pastewatch: Obfuscated: Email (1), API Key (1)` +- **Clipboard/GUI** — silent by default. When obfuscation occurs, a minimal macOS notification: `Pastewatch: Obfuscated: AWS Key (1), Google API Key (1)` - **CLI** — findings printed to stdout, exit code 6 if secrets found - **Startup sweep** — one stderr warning per changed shell config finding summary during `launch`; disable with `--no-startup-sweep` ([details](docs/startup-sweep.md)) - **MCP** — transparent to the agent. It reads placeholders and writes them back. No user interaction needed @@ -339,7 +345,7 @@ pastewatch-cli config check Every tool call an AI agent makes — including internal subprocesses you don't control — ends up as an HTTP request to the API. The proxy scans and redacts secrets from outbound requests before they leave your machine — including from subagents and tools that bypass the hooks. -> **Anthropic-shaped traffic.** The proxy redacts the Anthropic Messages API (`/v1/messages`, what Claude Code sends) and Message Batch create requests (`/v1/messages/batches`). It does **not** parse the OpenAI Chat Completions wire format, so it cannot redact OpenAI/Codex request bodies — rather than forward one unscanned and let you believe it was protected, the proxy **refuses** an unrecognized upstream body shape (HTTP 415). Model names are guarded by a known foreign-family denylist, not a positive Anthropic allowlist, so future Anthropic or gateway-rewritten model aliases are accepted only on supported Anthropic paths. Protect Codex and other agents with configured pastewatch hooks and MCP tools where available. +> **Anthropic-shaped traffic.** The proxy redacts the Anthropic Messages API (`/v1/messages`, what Claude Code sends) and Message Batch create requests (`/v1/messages/batches`). It does **not** parse the OpenAI Chat Completions wire format, so it cannot redact OpenAI/Codex request bodies — rather than forward one unscanned and let you believe it was protected, the proxy **refuses** an unrecognized upstream body shape (HTTP 415). Model names are advisory telemetry only because gateways and Anthropic-compatible providers may rewrite them; path and structural body checks form the admission boundary. Protect Codex and other agents with configured pastewatch hooks and MCP tools where available. > **Single session.** The proxy handles one agent session at a time. Run a separate `pastewatch-cli proxy` instance (on a different port) for each concurrent session. @@ -360,7 +366,7 @@ Every tool call an AI agent makes — including internal subprocesses you don't └───────────┼──────────────────────────┘ │ ▼ Cloud API - api.anthropic.com (secrets never arrive) + api.anthropic.com (authorized matches removed) ``` ```bash diff --git a/Sources/Pastewatch/ClipboardMonitor.swift b/Sources/Pastewatch/ClipboardMonitor.swift index 77a335d..9646a03 100644 --- a/Sources/Pastewatch/ClipboardMonitor.swift +++ b/Sources/Pastewatch/ClipboardMonitor.swift @@ -117,18 +117,28 @@ final class ClipboardMonitor: ObservableObject { guard config.enabled else { return } // Scan for sensitive data - let matches = DetectionRules.scan(content, config: config) + let matches = DetectionRules.scan( + content, + config: config, + customRules: CustomRule.compileValid(config.customRules) + ) // No matches — nothing to do - guard !matches.isEmpty else { return } + let outcome = applyAuthorizedMutations( + to: content, + matches: matches, + site: .clipboard, + minAdvisorySeverity: .low + ) + guard !outcome.mutated.isEmpty else { return } // Obfuscate and replace clipboard content - let obfuscatedContent = Obfuscator.obfuscate(content, matches: matches) + let obfuscatedContent = outcome.text // Create scan result let result = ScanResult( originalContent: content, - matches: matches, + matches: outcome.mutated, obfuscatedContent: obfuscatedContent, timestamp: Date() ) @@ -143,7 +153,7 @@ final class ClipboardMonitor: ObservableObject { // Update state DispatchQueue.main.async { [weak self] in self?.lastScanResult = result - self?.sessionObfuscationCount += matches.count + self?.sessionObfuscationCount += outcome.mutated.count self?.onObfuscation?(result) } } @@ -154,13 +164,22 @@ final class ClipboardMonitor: ObservableObject { guard let content = NSPasteboard.general.string(forType: .string) else { return nil } guard !content.isEmpty else { return nil } - let matches = DetectionRules.scan(content, config: config) - let obfuscatedContent = Obfuscator.obfuscate(content, matches: matches) + let matches = DetectionRules.scan( + content, + config: config, + customRules: CustomRule.compileValid(config.customRules) + ) + let outcome = applyAuthorizedMutations( + to: content, + matches: matches, + site: .clipboard, + minAdvisorySeverity: .low + ) return ScanResult( originalContent: content, - matches: matches, - obfuscatedContent: obfuscatedContent, + matches: outcome.mutated, + obfuscatedContent: outcome.text, timestamp: Date() ) } diff --git a/Sources/PastewatchCLI/GuardCommand.swift b/Sources/PastewatchCLI/GuardCommand.swift index 5d3b051..3b59f2d 100644 --- a/Sources/PastewatchCLI/GuardCommand.swift +++ b/Sources/PastewatchCLI/GuardCommand.swift @@ -40,7 +40,7 @@ struct Guard: ParsableCommand { $0.effectiveSeverity >= failOnSeverity } // WO-138: JSON output must preserve command context without echoing inline credential values. - let redactedCommand = Obfuscator.obfuscate(command, matches: commandDisplayMatches) + let redactedCommand = Obfuscator.redactForDisplay(command, matches: commandDisplayMatches) if !commandFiltered.isEmpty { shouldBlock = true diff --git a/Sources/PastewatchCLI/MCPCommand.swift b/Sources/PastewatchCLI/MCPCommand.swift index d05bb4d..9e3b028 100644 --- a/Sources/PastewatchCLI/MCPCommand.swift +++ b/Sources/PastewatchCLI/MCPCommand.swift @@ -391,11 +391,18 @@ final class MCPServer { return errorResult(id: id, text: "Shared pattern load failed: \(reason)") } - let allMatches = scanResult.matches - let matches = allMatches.filter { $0.effectiveSeverity >= minSeverity } + let partition = partitionMutationMatches( + scanResult.matches, + site: .mcpRead, + minAdvisorySeverity: minSeverity + ) + let matches = partition.authorized if matches.isEmpty { - auditLogger?.log("READ \(path) clean") + let advisorySuffix = partition.advisory.isEmpty + ? "clean" + : "advisory=\(partition.advisory.count)" + auditLogger?.log("READ \(path) \(advisorySuffix)") let result: JSONValue = .array([ .object([ "type": .string("text"), diff --git a/Sources/PastewatchCLI/ScanCommand.swift b/Sources/PastewatchCLI/ScanCommand.swift index 9137922..94cfe12 100644 --- a/Sources/PastewatchCLI/ScanCommand.swift +++ b/Sources/PastewatchCLI/ScanCommand.swift @@ -152,7 +152,12 @@ struct Scan: ParsableCommand { if check { outputCheckMode(matches: matches, filePath: file) } else { - let obfuscated = Obfuscator.obfuscate(input, matches: matches) + let obfuscated = applyAuthorizedMutations( + to: input, + matches: matches, + site: .cliScan, + minAdvisorySeverity: .low + ).text outputFindings(matches: matches, filePath: file, obfuscated: obfuscated) } if shouldFail(matches: matches) { diff --git a/Sources/PastewatchCore/CurlHTTPClient.swift b/Sources/PastewatchCore/CurlHTTPClient.swift index 143af02..9e1d824 100644 --- a/Sources/PastewatchCore/CurlHTTPClient.swift +++ b/Sources/PastewatchCore/CurlHTTPClient.swift @@ -1117,7 +1117,8 @@ struct CurlHTTPClient { // swiftlint:enable optional_data_string_conversion let matches = streamAdvisoryMatches( scanStreamText(text, config: config, customRules: customRules), - severity: severity + severity: severity, + site: .proxyResponse ) var types: [String] = [] for match in matches { @@ -1204,9 +1205,13 @@ struct CurlHTTPClient { config: config, customRules: customRules ?? CustomRule.compileValid(config.customRules) ) - let redactionMatches = mutationSafeProxyMatches(matches) + let redactionMatches = mutationSafeProxyMatches(matches, site: .proxyResponse) .sorted { $0.range.lowerBound < $1.range.lowerBound } - let advisories = streamAdvisoryMatches(matches, severity: severity) + let advisories = streamAdvisoryMatches( + matches, + severity: severity, + site: .proxyResponse + ) let advisoryTypes = advisories.map { $0.displayName } guard !redactionMatches.isEmpty else { return SSEFrameRedactionResult( diff --git a/Sources/PastewatchCore/DetectionRules.swift b/Sources/PastewatchCore/DetectionRules.swift index db5840e..bd79ab7 100644 --- a/Sources/PastewatchCore/DetectionRules.swift +++ b/Sources/PastewatchCore/DetectionRules.swift @@ -8,6 +8,36 @@ import Foundation public struct DetectionRules { private static let maximumPrivateKeyBlockCharacters = 262_144 // WO-478: bound malformed PEM scans. + // WO-484: reviewed primary references travel with the intrinsic provider set. + public static let providerTokenPatternManifest: [ProviderTokenPatternMetadata] = [ + .init(type: .awsKey, provider: "AWS", tokenFamily: "access keys", primarySource: "https://docs.aws.amazon.com/IAM/latest/UserGuide/security-creds.html", reviewedOn: "2026-07-15", fixtureID: "aws-access-key"), + .init(type: .genericApiKey, provider: "Prefixed tokens", tokenFamily: "GitHub and Stripe legacy tokens", primarySource: "https://docs.github.com/authentication/keeping-your-account-and-data-secure/about-authentication-to-github", reviewedOn: "2026-07-15", fixtureID: "generic-prefixed-token"), + .init(type: .slackWebhook, provider: "Slack", tokenFamily: "incoming webhook", primarySource: "https://api.slack.com/messaging/webhooks", reviewedOn: "2026-07-15", fixtureID: "slack-webhook"), + .init(type: .discordWebhook, provider: "Discord", tokenFamily: "webhook", primarySource: "https://discord.com/developers/docs/resources/webhook", reviewedOn: "2026-07-15", fixtureID: "discord-webhook"), + .init(type: .openaiKey, provider: "OpenAI", tokenFamily: "API key", primarySource: "https://platform.openai.com/docs/api-reference/authentication", reviewedOn: "2026-07-15", fixtureID: "openai-key"), + .init(type: .anthropicKey, provider: "Anthropic", tokenFamily: "API key", primarySource: "https://docs.anthropic.com/en/api/getting-started", reviewedOn: "2026-07-15", fixtureID: "anthropic-key"), + .init(type: .huggingfaceToken, provider: "Hugging Face", tokenFamily: "user access token", primarySource: "https://huggingface.co/docs/hub/security-tokens", reviewedOn: "2026-07-15", fixtureID: "huggingface-token"), + .init(type: .groqKey, provider: "Groq", tokenFamily: "API key", primarySource: "https://console.groq.com/docs/quickstart", reviewedOn: "2026-07-15", fixtureID: "groq-key"), + .init(type: .npmToken, provider: "npm", tokenFamily: "access token", primarySource: "https://docs.npmjs.com/about-access-tokens", reviewedOn: "2026-07-15", fixtureID: "npm-token"), + .init(type: .pypiToken, provider: "PyPI", tokenFamily: "API token", primarySource: "https://pypi.org/help/#apitoken", reviewedOn: "2026-07-15", fixtureID: "pypi-token"), + .init(type: .rubygemsToken, provider: "RubyGems", tokenFamily: "API key", primarySource: "https://guides.rubygems.org/rubygems-org-api/", reviewedOn: "2026-07-15", fixtureID: "rubygems-token"), + .init(type: .gitlabToken, provider: "GitLab", tokenFamily: "personal access token", primarySource: "https://docs.gitlab.com/user/profile/personal_access_tokens/", reviewedOn: "2026-07-15", fixtureID: "gitlab-token"), + .init(type: .telegramBotToken, provider: "Telegram", tokenFamily: "bot token", primarySource: "https://core.telegram.org/bots/api", reviewedOn: "2026-07-15", fixtureID: "telegram-bot-token"), + .init(type: .sendgridKey, provider: "SendGrid", tokenFamily: "API key", primarySource: "https://www.twilio.com/docs/sendgrid/api-reference/how-to-use-the-sendgrid-v3-api/authentication", reviewedOn: "2026-07-15", fixtureID: "sendgrid-key"), + .init(type: .shopifyToken, provider: "Shopify", tokenFamily: "access token", primarySource: "https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens", reviewedOn: "2026-07-15", fixtureID: "shopify-token"), + .init(type: .digitaloceanToken, provider: "DigitalOcean", tokenFamily: "personal and OAuth tokens", primarySource: "https://docs.digitalocean.com/reference/api/create-personal-access-token/", reviewedOn: "2026-07-15", fixtureID: "digitalocean-token"), + .init(type: .perplexityKey, provider: "Perplexity", tokenFamily: "API key", primarySource: "https://docs.perplexity.ai/guides/getting-started", reviewedOn: "2026-07-15", fixtureID: "perplexity-key"), + .init(type: .workledgerKey, provider: "Workledger", tokenFamily: "API key", primarySource: "https://github.com/ppiankov/workledger", reviewedOn: "2026-07-15", fixtureID: "workledger-key"), + .init(type: .oraculKey, provider: "Oracul", tokenFamily: "API key", primarySource: "https://github.com/ppiankov/oracul", reviewedOn: "2026-07-15", fixtureID: "oracul-key"), + .init(type: .obstalabsKey, provider: "ObstaLabs", tokenFamily: "license key", primarySource: "https://github.com/ppiankov/obstalabs", reviewedOn: "2026-07-15", fixtureID: "obstalabs-key"), + .init(type: .resendKey, provider: "Resend", tokenFamily: "API key", primarySource: "https://resend.com/docs/dashboard/api-keys/introduction", reviewedOn: "2026-07-15", fixtureID: "resend-key"), + .init(type: .vaultToken, provider: "HashiCorp Vault", tokenFamily: "service and batch tokens", primarySource: "https://developer.hashicorp.com/vault/docs/concepts/tokens", reviewedOn: "2026-07-15", fixtureID: "vault-token"), + .init(type: .slackToken, provider: "Slack", tokenFamily: "bot, app, and rotating tokens", primarySource: "https://api.slack.com/authentication/token-types", reviewedOn: "2026-07-15", fixtureID: "slack-token"), + .init(type: .googleApiKey, provider: "Google Cloud", tokenFamily: "API key", primarySource: "https://cloud.google.com/docs/authentication/api-keys", reviewedOn: "2026-07-15", fixtureID: "google-api-key"), + .init(type: .dockerAccessToken, provider: "Docker", tokenFamily: "personal and organization access tokens", primarySource: "https://docs.docker.com/security/for-developers/access-tokens/", reviewedOn: "2026-07-15", fixtureID: "docker-access-token"), + .init(type: .githubToken, provider: "GitHub", tokenFamily: "fine-grained and installation tokens", primarySource: "https://docs.github.com/authentication/keeping-your-account-and-data-secure/about-authentication-to-github", reviewedOn: "2026-07-15", fixtureID: "github-token"), + ] + /// Safe hosts that should not trigger hostname detection. /// Matches chainwatch's safeHosts for consistency across tools. static let safeHosts: Set = [ @@ -108,7 +138,9 @@ public struct DetectionRules { // Azure Storage Connection String - high confidence if let regex = try? NSRegularExpression( - pattern: #"DefaultEndpointsProtocol=https;AccountName=[^;]+;AccountKey=[^;]+"#, + // WO-480: contain the key value itself; do not consume adjacent JSON, + // punctuation, or prose merely because no trailing semicolon exists. + pattern: #"DefaultEndpointsProtocol=https;AccountName=[^;\s\"']+;AccountKey=[A-Za-z0-9+/=]+"#, options: [] ) { result.append((.azureConnectionString, regex)) @@ -979,7 +1011,8 @@ public struct DetectionRules { _ content: String, config: PastewatchConfig, allowlist: Allowlist = Allowlist(), - customRules: [CustomRule] = [] + customRules: [CustomRule] = [], + knownSecretValues: Set = [] ) -> [DetectedMatch] { var matches: [DetectedMatch] = [] var matchedRanges: [Range] = [] @@ -1016,6 +1049,13 @@ public struct DetectionRules { if match.advisory != nil { matches.removeAll { $0.range.overlaps(match.range) } matchedRanges.removeAll { $0.overlaps(match.range) } + } else if let index = matches.firstIndex(where: { $0.range == match.range }) { + // WO-454: an exact built-in/custom overlap retains evidence from both + // detectors instead of allowing deduplication to weaken authorization. + matches[index] = matches[index].addingMutationAuthorizationSources( + match.mutationAuthorizationSources + ) + continue } else if matchedRanges.contains(where: { $0.overlaps(match.range) }) { continue } @@ -1028,6 +1068,13 @@ public struct DetectionRules { matches = allowlist.filter(matches) } + if !knownSecretValues.isEmpty { + matches = matches.map { match in + guard knownSecretValues.contains(match.value), match.advisory == nil else { return match } + return match.addingMutationAuthorizationSources([.exactKnownSecret]) + } + } + return matches } diff --git a/Sources/PastewatchCore/MutationAuthorization.swift b/Sources/PastewatchCore/MutationAuthorization.swift new file mode 100644 index 0000000..13806a3 --- /dev/null +++ b/Sources/PastewatchCore/MutationAuthorization.swift @@ -0,0 +1,82 @@ +import Foundation + +/// WO-454: semantic location of a mutation decision; no caller receives a permissive default. +public enum MutationSite: CaseIterable { + case clipboard + case cliScan + case mcpRead + case proxySystem + case proxyToolDescription + case proxyInputSchema + case proxyToolInputExample + case proxyUserText + case proxyAssistantText + case proxyToolUseInput + case proxyToolResult + case proxyStopSequence + case proxyResponse +} + +/// WO-454: exhaustive accounting prevents advisory matches from disappearing. +public struct MutationPartition { + public let authorized: [DetectedMatch] + public let advisory: [DetectedMatch] + public let advisoryBelowThreshold: [DetectedMatch] +} + +/// WO-454: the only normal production result for text mutation. +public struct MutationOutcome { + public let text: String + public let mutated: [DetectedMatch] + public let advisory: [DetectedMatch] + public let advisoryBelowThreshold: [DetectedMatch] +} + +/// WO-454: evidence authorizes mutation; site and severity only classify reporting. +public func partitionMutationMatches( + _ matches: [DetectedMatch], + site: MutationSite, + minAdvisorySeverity: Severity +) -> MutationPartition { + _ = site + var authorized: [DetectedMatch] = [] + var advisory: [DetectedMatch] = [] + var belowThreshold: [DetectedMatch] = [] + + for match in matches { + if match.advisory == nil && !match.mutationAuthorizationSources.isEmpty { + authorized.append(match) + } else if match.effectiveSeverity >= minAdvisorySeverity { + advisory.append(match) + } else { + belowThreshold.append(match) + } + } + + assert(authorized.count + advisory.count + belowThreshold.count == matches.count) + return MutationPartition( + authorized: authorized, + advisory: advisory, + advisoryBelowThreshold: belowThreshold + ) +} + +/// WO-454: every normal mutation call passes through this evidence gate. +public func applyAuthorizedMutations( + to text: String, + matches: [DetectedMatch], + site: MutationSite, + minAdvisorySeverity: Severity +) -> MutationOutcome { + let partition = partitionMutationMatches( + matches, + site: site, + minAdvisorySeverity: minAdvisorySeverity + ) + return MutationOutcome( + text: Obfuscator.obfuscate(text, matches: partition.authorized), + mutated: partition.authorized, + advisory: partition.advisory, + advisoryBelowThreshold: partition.advisoryBelowThreshold + ) +} diff --git a/Sources/PastewatchCore/Obfuscator.swift b/Sources/PastewatchCore/Obfuscator.swift index d500cf7..4c6c29b 100644 --- a/Sources/PastewatchCore/Obfuscator.swift +++ b/Sources/PastewatchCore/Obfuscator.swift @@ -9,6 +9,11 @@ import Foundation /// - After paste, the system returns to rest public struct Obfuscator { + /// WO-454: explicit display-hygiene exception for commands echoed to diagnostics. + public static func redactForDisplay(_ content: String, matches: [DetectedMatch]) -> String { + obfuscate(content, matches: matches) + } + // WO-478: advisory scanner outcomes cannot authorize content replacement. /// Obfuscate all matches in the content. /// Returns the obfuscated content with matches replaced by placeholders. diff --git a/Sources/PastewatchCore/ProxyServer.swift b/Sources/PastewatchCore/ProxyServer.swift index dcfb651..52b038f 100644 --- a/Sources/PastewatchCore/ProxyServer.swift +++ b/Sources/PastewatchCore/ProxyServer.swift @@ -1097,27 +1097,24 @@ public final class ProxyServer { var types: [String] = [] var advisoryCount = 0 var advisoryTypes: [String] = [] - let processedTopLevel = redactTopLevelStringFields( - json, - redacted: &redacted, - types: &types, - advisoryCount: &advisoryCount, - advisoryTypes: &advisoryTypes - ) - let processedMessages = redactContentArray( - processedTopLevel, - redacted: &redacted, - types: &types, - advisoryCount: &advisoryCount, - advisoryTypes: &advisoryTypes - ) - let processed = redactBatchRequestMessages( - processedMessages, - redacted: &redacted, - types: &types, - advisoryCount: &advisoryCount, - advisoryTypes: &advisoryTypes - ) + let processed: [String: Any] + if json["requests"] != nil { + processed = redactBatchRequestMessages( + json, + redacted: &redacted, + types: &types, + advisoryCount: &advisoryCount, + advisoryTypes: &advisoryTypes + ) + } else { + processed = redactRequestPayload( + json, + redacted: &redacted, + types: &types, + advisoryCount: &advisoryCount, + advisoryTypes: &advisoryTypes + ) + } guard redacted > 0 else { return ScanResult( @@ -1254,6 +1251,8 @@ public final class ProxyServer { // OpenAI siblings participate in refusal. func isAnthropicMessagesShape(_ json: [String: Any]) -> Bool { guard let messages = json["messages"] as? [[String: Any]] else { return false } + guard hasValidAnthropicTools(json["tools"]), + hasValidStopSequences(json["stop_sequences"]) else { return false } for message in messages { guard message["role"] is String else { return false } // OpenAI /v1/chat/completions carries tool_calls / function_call on messages; @@ -1272,6 +1271,26 @@ public final class ProxyServer { return true } + // WO-456: malformed tool containers cannot bypass the request scanner. + private func hasValidAnthropicTools(_ value: Any?) -> Bool { + guard let value else { return true } + guard !(value is NSNull), let tools = value as? [[String: Any]] else { return false } + return tools.allSatisfy { tool in + guard let name = tool["name"] as? String, !name.isEmpty, + tool["input_schema"] is [String: Any] else { return false } + if let description = tool["description"], !(description is String) { return false } + if let examples = tool["input_examples"], !(examples is [Any]) { return false } + return true + } + } + + // WO-457: stop sequences are scannable strings, never an opaque mixed array. + private func hasValidStopSequences(_ value: Any?) -> Bool { + guard let value else { return true } + guard !(value is NSNull), let sequences = value as? [Any] else { return false } + return sequences.allSatisfy { $0 is String } + } + // WO-432: Anthropic Message Batches wrap normal Messages params in requests[].params. func isAnthropicBatchShape(_ json: [String: Any]) -> Bool { guard let requests = json["requests"] as? [[String: Any]] else { return false } @@ -1454,6 +1473,7 @@ public final class ProxyServer { if let value = json[field] as? String { result[field] = redactScannableText( value, + site: .proxySystem, redacted: &redacted, types: &types, advisoryCount: &advisoryCount, @@ -1467,6 +1487,7 @@ public final class ProxyServer { let text = blocks[index]["text"] as? String else { continue } blocks[index]["text"] = redactScannableText( text, + site: .proxySystem, redacted: &redacted, types: &types, advisoryCount: &advisoryCount, @@ -1480,25 +1501,108 @@ public final class ProxyServer { // WO-444/WO-447: keep certainty-gated mutation and advisory accounting identical // across string and block-array system representations. + // swiftlint:disable:next function_parameter_count private func redactScannableText( _ value: String, + site: MutationSite, redacted: inout Int, types: inout [String], advisoryCount: inout Int, advisoryTypes: inout [String] ) -> String { let matches = scanProxyText(value) - let filtered = mutationSafeProxyMatches(matches) - let advisories = streamAdvisoryMatches(matches, severity: severity) - advisoryCount += advisories.count - advisoryTypes.append(contentsOf: advisories.map { $0.displayName }) - guard !filtered.isEmpty else { return value } - redacted += filtered.count - types.append(contentsOf: filtered.map { $0.displayName }) - return Obfuscator.obfuscate(value, matches: filtered) + let outcome = applyAuthorizedMutations( + to: value, + matches: matches, + site: site, + minAdvisorySeverity: severity + ) + advisoryCount += outcome.advisory.count + advisoryTypes.append(contentsOf: outcome.advisory.map { $0.displayName }) + redacted += outcome.mutated.count + types.append(contentsOf: outcome.mutated.map { $0.displayName }) + return outcome.text + } + + // WO-454/WO-461: recursively scan structured values without changing keys or + // non-string leaves; the caller supplies the evidence-reporting site. + // swiftlint:disable:next function_parameter_count + private func redactJSONStrings( + _ value: Any, + site: MutationSite, + redacted: inout Int, + types: inout [String], + advisoryCount: inout Int, + advisoryTypes: inout [String] + ) -> Any { + if let text = value as? String { + return redactScannableText( + text, + site: site, + redacted: &redacted, + types: &types, + advisoryCount: &advisoryCount, + advisoryTypes: &advisoryTypes + ) + } + if let array = value as? [Any] { + return array.map { + redactJSONStrings( + $0, site: site, redacted: &redacted, types: &types, + advisoryCount: &advisoryCount, advisoryTypes: &advisoryTypes + ) + } + } + if let object = value as? [String: Any] { + return object.mapValues { + redactJSONStrings( + $0, site: site, redacted: &redacted, types: &types, + advisoryCount: &advisoryCount, advisoryTypes: &advisoryTypes + ) + } + } + return value + } + + // WO-454/WO-461: tool contracts and examples are visible to the scanner and + // use explicit sites; evidence, not field context, controls replacement. + private func redactTools( + _ json: [String: Any], + redacted: inout Int, + types: inout [String], + advisoryCount: inout Int, + advisoryTypes: inout [String] + ) -> [String: Any] { + var result = json + guard var tools = json["tools"] as? [[String: Any]] else { return result } + for index in tools.indices { + if let description = tools[index]["description"] as? String { + tools[index]["description"] = redactScannableText( + description, site: .proxyToolDescription, + redacted: &redacted, types: &types, + advisoryCount: &advisoryCount, advisoryTypes: &advisoryTypes + ) + } + if let schema = tools[index]["input_schema"] { + tools[index]["input_schema"] = redactJSONStrings( + schema, site: .proxyInputSchema, + redacted: &redacted, types: &types, + advisoryCount: &advisoryCount, advisoryTypes: &advisoryTypes + ) + } + if let examples = tools[index]["input_examples"] { + tools[index]["input_examples"] = redactJSONStrings( + examples, site: .proxyToolInputExample, + redacted: &redacted, types: &types, + advisoryCount: &advisoryCount, advisoryTypes: &advisoryTypes + ) + } + } + result["tools"] = tools + return result } - /// Walk the messages array looking for tool_result content to scan. + /// WO-454: scan authored text and execution payloads with explicit sites. private func redactContentArray( _ json: [String: Any], redacted: inout Int, @@ -1512,71 +1616,76 @@ public final class ProxyServer { return result } - for i in 0.. [String: Any] { + var result = redactTopLevelStringFields( + json, redacted: &redacted, types: &types, + advisoryCount: &advisoryCount, advisoryTypes: &advisoryTypes + ) + result = redactTools( + result, redacted: &redacted, types: &types, + advisoryCount: &advisoryCount, advisoryTypes: &advisoryTypes + ) + if let sequences = result["stop_sequences"] as? [String] { + result["stop_sequences"] = sequences.map { + redactScannableText( + $0, site: .proxyStopSequence, + redacted: &redacted, types: &types, + advisoryCount: &advisoryCount, advisoryTypes: &advisoryTypes + ) + } + } + return redactContentArray( + result, redacted: &redacted, types: &types, + advisoryCount: &advisoryCount, advisoryTypes: &advisoryTypes + ) + } + // WO-432: scan nested Message Batch params with the same certainty gate used for // ordinary /v1/messages bodies. private func redactBatchRequestMessages( @@ -1593,19 +1702,9 @@ public final class ProxyServer { for index in requests.indices { guard let params = requests[index]["params"] as? [String: Any] else { continue } - let processedSystem = redactTopLevelStringFields( - params, - redacted: &redacted, - types: &types, - advisoryCount: &advisoryCount, - advisoryTypes: &advisoryTypes - ) - requests[index]["params"] = redactContentArray( - processedSystem, - redacted: &redacted, - types: &types, - advisoryCount: &advisoryCount, - advisoryTypes: &advisoryTypes + requests[index]["params"] = redactRequestPayload( + params, redacted: &redacted, types: &types, + advisoryCount: &advisoryCount, advisoryTypes: &advisoryTypes ) } diff --git a/Sources/PastewatchCore/SocketHelpers.swift b/Sources/PastewatchCore/SocketHelpers.swift index 65fe75f..9553dbc 100644 --- a/Sources/PastewatchCore/SocketHelpers.swift +++ b/Sources/PastewatchCore/SocketHelpers.swift @@ -66,14 +66,18 @@ struct SSEFrameRedactionResult { var types: [String] { redactionTypes } } -/// WO-404: mutation is certainty-gated, not severity-gated. -func mutationSafeProxyMatches(_ matches: [DetectedMatch]) -> [DetectedMatch] { - matches.filter(\.mutationSafe) +/// WO-454: compatibility filter backed by the evidence partition. +func mutationSafeProxyMatches(_ matches: [DetectedMatch], site: MutationSite) -> [DetectedMatch] { + partitionMutationMatches(matches, site: site, minAdvisorySeverity: .low).authorized } -/// WO-404: --severity controls advisory volume, never mutation. -func streamAdvisoryMatches(_ matches: [DetectedMatch], severity: Severity) -> [DetectedMatch] { - matches.filter { !$0.mutationSafe && $0.effectiveSeverity >= severity } +/// WO-454: --severity controls advisory volume, never mutation authorization. +func streamAdvisoryMatches( + _ matches: [DetectedMatch], + severity: Severity, + site: MutationSite +) -> [DetectedMatch] { + partitionMutationMatches(matches, site: site, minAdvisorySeverity: severity).advisory } /// WO-399: include configured custom rules on the streaming response path. @@ -104,22 +108,25 @@ func redactRawStreamBytes( // swiftlint:disable:next optional_data_string_conversion let text = String(data: raw, encoding: .utf8) ?? String(decoding: raw, as: UTF8.self) let matches = scanStreamText(text, config: config, customRules: customRules) - let filtered = mutationSafeProxyMatches(matches) - let advisories = streamAdvisoryMatches(matches, severity: severity) - let advisoryTypes = advisories.map { $0.displayName } - guard !filtered.isEmpty else { + let outcome = applyAuthorizedMutations( + to: text, + matches: matches, + site: .proxyResponse, + minAdvisorySeverity: severity + ) + let advisoryTypes = outcome.advisory.map { $0.displayName } + guard !outcome.mutated.isEmpty else { return SSEFrameRedactionResult( data: raw, count: 0, types: [], - advisoryCount: advisories.count, + advisoryCount: outcome.advisory.count, advisoryTypes: advisoryTypes ) } - let obfuscated = Obfuscator.obfuscate(text, matches: filtered) return SSEFrameRedactionResult( - data: Data(obfuscated.utf8), - count: filtered.count, - types: filtered.map { $0.displayName }, - advisoryCount: advisories.count, + data: Data(outcome.text.utf8), + count: outcome.mutated.count, + types: outcome.mutated.map { $0.displayName }, + advisoryCount: outcome.advisory.count, advisoryTypes: advisoryTypes ) } @@ -183,16 +190,20 @@ func redactSSEFrame( for (field, value) in delta { guard field != "type", let text = value as? String else { continue } let matches = scanStreamText(text, config: config, customRules: customRules) - let filtered = mutationSafeProxyMatches(matches) - let advisories = streamAdvisoryMatches(matches, severity: severity) - advisoryCount += advisories.count - advisoryTypes.append(contentsOf: advisories.map { $0.displayName }) - guard !filtered.isEmpty else { continue } + let outcome = applyAuthorizedMutations( + to: text, + matches: matches, + site: .proxyResponse, + minAdvisorySeverity: severity + ) + advisoryCount += outcome.advisory.count + advisoryTypes.append(contentsOf: outcome.advisory.map { $0.displayName }) + guard !outcome.mutated.isEmpty else { continue } // WO-295: redact thinking_delta/input_json_delta and future text-bearing // delta string fields, not only text_delta's `text` field. - modifiedDelta[field] = Obfuscator.obfuscate(text, matches: filtered) - redacted += filtered.count - types.append(contentsOf: filtered.map { $0.displayName }) + modifiedDelta[field] = outcome.text + redacted += outcome.mutated.count + types.append(contentsOf: outcome.mutated.map { $0.displayName }) } guard redacted > 0 else { diff --git a/Sources/PastewatchCore/Types.swift b/Sources/PastewatchCore/Types.swift index 32ba0c7..b63e775 100644 --- a/Sources/PastewatchCore/Types.swift +++ b/Sources/PastewatchCore/Types.swift @@ -100,26 +100,29 @@ public enum SensitiveDataType: String, CaseIterable, Codable { } } - /// WO-404: only deterministic secret classes are safe to mutate automatically. - public var mutationSafe: Bool { + /// WO-454: only formats whose matched bytes prove a secret authorize mutation. + public var intrinsicMutationAuthorized: Bool { switch self { - case .awsKey, .genericApiKey, .sshPrivateKey, .dbConnectionString, - .jwtToken, .creditCard, .credential, + case .awsKey, .genericApiKey, .sshPrivateKey, + .jwtToken, .creditCard, .slackWebhook, .discordWebhook, .azureConnectionString, .gcpServiceAccount, .openaiKey, .anthropicKey, .huggingfaceToken, .groqKey, .npmToken, .pypiToken, .rubygemsToken, .gitlabToken, .telegramBotToken, .sendgridKey, .shopifyToken, .digitaloceanToken, .perplexityKey, .workledgerKey, .oraculKey, .obstalabsKey, .resendKey, - .vaultToken, .slackToken, .googleApiKey, .dockerAccessToken, .githubToken, - .jdbcUrl, .xmlCredential: + .vaultToken, .slackToken, .googleApiKey, .dockerAccessToken, .githubToken: return true - case .email, .phone, .xmlUsername, + case .dbConnectionString, .jdbcUrl, .credential, .xmlCredential, + .email, .phone, .xmlUsername, .ipAddress, .filePath, .hostname, .xmlHostname, .uuid, .highEntropyString: return false } } + /// Backward-compatible certainty name. New mutation code uses evidence sources. + public var mutationSafe: Bool { intrinsicMutationAuthorized } + /// Human-readable explanation of what this type detects. public var explanation: String { switch self { @@ -226,6 +229,23 @@ public enum DetectionAdvisory: String, Equatable { case malformedPrivateKey } +/// WO-484: offline provenance for every intrinsically authorized provider pattern. +public struct ProviderTokenPatternMetadata { + public let type: SensitiveDataType + public let provider: String + public let tokenFamily: String + public let primarySource: String + public let reviewedOn: String + public let fixtureID: String +} + +/// WO-454: evidence that independently authorizes replacement of matched bytes. +public enum MutationAuthorizationSource: Hashable { + case intrinsicFormat + case exactKnownSecret + case customRule +} + /// A single detected match in the clipboard content. public struct DetectedMatch: Identifiable, Equatable { public let id = UUID() @@ -237,6 +257,7 @@ public struct DetectedMatch: Identifiable, Equatable { public let customRuleName: String? public let customSeverity: Severity? public let advisory: DetectionAdvisory? // WO-478: non-mutating malformed-input evidence. + public let mutationAuthorizationSources: Set // WO-454: OR-merged provenance. public init( type: SensitiveDataType, @@ -246,7 +267,8 @@ public struct DetectedMatch: Identifiable, Equatable { filePath: String? = nil, customRuleName: String? = nil, customSeverity: Severity? = nil, - advisory: DetectionAdvisory? = nil + advisory: DetectionAdvisory? = nil, + mutationAuthorizationSources: Set? = nil ) { self.type = type self.value = value @@ -256,6 +278,14 @@ public struct DetectedMatch: Identifiable, Equatable { self.customRuleName = customRuleName self.customSeverity = customSeverity self.advisory = advisory + var sources = mutationAuthorizationSources ?? [] + if advisory == nil && type.intrinsicMutationAuthorized { + sources.insert(.intrinsicFormat) + } + if advisory == nil && customRuleName != nil { + sources.insert(.customRule) + } + self.mutationAuthorizationSources = sources } /// Effective severity: custom override if set, otherwise type default. @@ -263,9 +293,24 @@ public struct DetectedMatch: Identifiable, Equatable { customSeverity ?? type.severity } - /// WO-404: custom rules are explicit operator approval to mutate matches. + /// Compatibility surface for callers not yet interested in provenance. public var mutationSafe: Bool { - advisory == nil && (customRuleName != nil || type.mutationSafe) + advisory == nil && !mutationAuthorizationSources.isEmpty + } + + /// WO-454: merge authorization with OR semantics during overlap resolution. + func addingMutationAuthorizationSources(_ sources: Set) -> DetectedMatch { + DetectedMatch( + type: type, + value: value, + range: range, + line: line, + filePath: filePath, + customRuleName: customRuleName, + customSeverity: customSeverity, + advisory: advisory, + mutationAuthorizationSources: mutationAuthorizationSources.union(sources) + ) } /// Display name for output (custom rule name or type rawValue). diff --git a/Tests/PastewatchTests/MutationAuthorizationTests.swift b/Tests/PastewatchTests/MutationAuthorizationTests.swift new file mode 100644 index 0000000..35edb1d --- /dev/null +++ b/Tests/PastewatchTests/MutationAuthorizationTests.swift @@ -0,0 +1,146 @@ +import XCTest +@testable import PastewatchCore + +final class MutationAuthorizationTests: XCTestCase { + private let config = PastewatchConfig.defaultConfig + + func testIntrinsicAuthorizationSetIsExplicit() { + // WO-454: this literal set makes detector promotion a reviewed policy change. + let expected: Set = [ + .awsKey, .genericApiKey, .sshPrivateKey, .jwtToken, .creditCard, + .slackWebhook, .discordWebhook, .azureConnectionString, .gcpServiceAccount, + .openaiKey, .anthropicKey, .huggingfaceToken, .groqKey, .npmToken, + .pypiToken, .rubygemsToken, .gitlabToken, .telegramBotToken, .sendgridKey, + .shopifyToken, .digitaloceanToken, .perplexityKey, .workledgerKey, + .oraculKey, .obstalabsKey, .resendKey, .vaultToken, .slackToken, + .googleApiKey, .dockerAccessToken, .githubToken, + ] + + XCTAssertEqual( + Set(SensitiveDataType.allCases.filter(\.intrinsicMutationAuthorized)), + expected + ) + XCTAssertFalse(SensitiveDataType.dbConnectionString.intrinsicMutationAuthorized) + XCTAssertFalse(SensitiveDataType.jdbcUrl.intrinsicMutationAuthorized) + XCTAssertFalse(SensitiveDataType.credential.intrinsicMutationAuthorized) + } + + func testPartitionConservesEveryMatchAndSeverityDoesNotAuthorize() { + let dsn = "postgres" + "://user:example@localhost/db" + let text = dsn + " AIza" + String(repeating: "A", count: 35) + let matches = DetectionRules.scan(text, config: config) + let partition = partitionMutationMatches( + matches, + site: .proxyUserText, + minAdvisorySeverity: .critical + ) + + XCTAssertEqual( + partition.authorized.count + partition.advisory.count + partition.advisoryBelowThreshold.count, + matches.count + ) + XCTAssertTrue(partition.authorized.contains { $0.type == .googleApiKey }) + XCTAssertTrue(partition.advisory.contains { $0.type == .dbConnectionString }) + } + + func testExactKnownValueAuthorizesFormatOnlyMatch() { + let value = "postgres" + "://user:example@localhost/db" + let matches = DetectionRules.scan( + value, + config: config, + knownSecretValues: [value] + ) + + XCTAssertEqual(matches.count, 1) + XCTAssertTrue(matches[0].mutationAuthorizationSources.contains(.exactKnownSecret)) + let outcome = applyAuthorizedMutations( + to: value, + matches: matches, + site: .proxyInputSchema, + minAdvisorySeverity: .critical + ) + XCTAssertFalse(outcome.text.contains(value)) + } + + func testCustomRuleAuthorizationSurvivesBuiltInOverlap() throws { + let value = "postgres" + "://user:example@localhost/db" + let rule = CustomRule( + name: "Approved DSN", + regex: try NSRegularExpression(pattern: NSRegularExpression.escapedPattern(for: value)), + severity: .low, + type: .dbConnectionString + ) + let matches = DetectionRules.scan(value, config: config, customRules: [rule]) + + XCTAssertEqual(matches.count, 1) + XCTAssertTrue(matches[0].mutationAuthorizationSources.contains(.customRule)) + let outcome = applyAuthorizedMutations( + to: value, + matches: matches, + site: .proxyToolDescription, + minAdvisorySeverity: .critical + ) + XCTAssertEqual(outcome.mutated.count, 1) + XCTAssertFalse(outcome.text.contains(value)) + } + + func testProxyUsesEvidenceAcrossRequestSites() throws { + let token = "AIza" + String(repeating: "K", count: 35) + let dsn = "postgres" + "://user:example@localhost/db" + let body = """ + {"system":"\(dsn)","tools":[{"name":"lookup","description":"\(dsn)","input_schema":{"type":"object","default":"\(dsn)"},"input_examples":[{"token":"\(token)","dsn":"\(dsn)"}]}],"stop_sequences":["\(token)"],"messages":[{"role":"user","content":"\(dsn) \(token)"},{"role":"assistant","content":[{"type":"tool_use","id":"x","name":"lookup","input":{"token":"\(token)","dsn":"\(dsn)"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"x","content":"\(token) \(dsn)"}]}]} + """ + + let result = ProxyServer(port: 0).scanAndRedactBody(body) + XCTAssertGreaterThan(result.redacted, 0) + XCTAssertGreaterThan(result.advisoryCount, 0) + XCTAssertFalse(result.body.contains(token)) + + let json = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(result.body.utf8)) as? [String: Any] + ) + let tools = try XCTUnwrap(json["tools"] as? [[String: Any]]) + XCTAssertEqual(json["system"] as? String, dsn) + let examples = try XCTUnwrap(tools[0]["input_examples"] as? [[String: Any]]) + XCTAssertEqual(examples[0]["dsn"] as? String, dsn) + XCTAssertNotEqual(examples[0]["token"] as? String, token) + } + + func testEveryMutationSiteUsesTheSameEvidenceGate() { + let token = "AIza" + String(repeating: "L", count: 35) + let matches = DetectionRules.scan(token, config: config) + + for site in MutationSite.allCases { + let outcome = applyAuthorizedMutations( + to: token, + matches: matches, + site: site, + minAdvisorySeverity: .critical + ) + XCTAssertEqual(outcome.mutated.count, 1, "site \(site) bypassed authorization") + XCTAssertFalse(outcome.text.contains(token), "site \(site) leaked authorized bytes") + } + } + + func testProductionMutationUsesOnlyTheAuthorizationGateway() throws { + // WO-454: raw obfuscation remains a compatibility API, so CI enforces the + // production call graph while redactForDisplay stays the named exception. + let testFile = URL(fileURLWithPath: #filePath) + let repository = testFile + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let sources = repository.appendingPathComponent("Sources") + let enumerator = try XCTUnwrap(FileManager.default.enumerator(at: sources, includingPropertiesForKeys: nil)) + var callers: [String] = [] + + for case let fileURL as URL in enumerator where fileURL.pathExtension == "swift" { + let source = try String(contentsOf: fileURL, encoding: .utf8) + if source.contains("Obfuscator.obfuscate(") { + callers.append(fileURL.lastPathComponent) + } + } + + XCTAssertEqual(callers, ["MutationAuthorization.swift"]) + } +} diff --git a/Tests/PastewatchTests/ProviderTokenPatternTests.swift b/Tests/PastewatchTests/ProviderTokenPatternTests.swift new file mode 100644 index 0000000..9396c3e --- /dev/null +++ b/Tests/PastewatchTests/ProviderTokenPatternTests.swift @@ -0,0 +1,89 @@ +import XCTest +@testable import PastewatchCore + +final class ProviderTokenPatternTests: XCTestCase { + private struct Fixture { + let type: SensitiveDataType + let positive: String + let negative: String + } + + // WO-484: fixtures are synthetic and offline; none are usable credentials. + private var fixtures: [Fixture] { + [ + .init(type: .awsKey, positive: "AKIA" + String(repeating: "A", count: 16), negative: "AKIA" + String(repeating: "A", count: 15)), + .init(type: .genericApiKey, positive: "sk_live_" + String(repeating: "B", count: 24), negative: "sk_live_" + String(repeating: "B", count: 23)), + .init(type: .slackWebhook, positive: "https://hooks.slack.com/services/TABC/BDEF/Token123", negative: "https://hooks.slack.com/services/ABC/BDEF/Token123"), + .init(type: .discordWebhook, positive: "https://discord.com/api/webhooks/123456/Token_123", negative: "https://discord.com/api/webhooks/id/Token_123"), + .init(type: .openaiKey, positive: "sk-proj-" + String(repeating: "C", count: 20), negative: "sk-proj-" + String(repeating: "C", count: 19)), + .init(type: .anthropicKey, positive: "sk-ant-api03-" + String(repeating: "D", count: 20), negative: "sk-ant-api03-" + String(repeating: "D", count: 19)), + .init(type: .huggingfaceToken, positive: "hf_" + String(repeating: "E", count: 20), negative: "hf_" + String(repeating: "E", count: 19)), + .init(type: .groqKey, positive: "gsk_" + String(repeating: "F", count: 20), negative: "gsk_" + String(repeating: "F", count: 19)), + .init(type: .npmToken, positive: "npm_" + String(repeating: "G", count: 20), negative: "npm_" + String(repeating: "G", count: 19)), + .init(type: .pypiToken, positive: "pypi-" + String(repeating: "H", count: 20), negative: "pypi-" + String(repeating: "H", count: 19)), + .init(type: .rubygemsToken, positive: "rubygems_" + String(repeating: "I", count: 20), negative: "rubygems_" + String(repeating: "I", count: 19)), + .init(type: .gitlabToken, positive: "glpat-" + String(repeating: "J", count: 20), negative: "glpat-" + String(repeating: "J", count: 19)), + .init(type: .telegramBotToken, positive: "12345678:AA" + String(repeating: "K", count: 33), negative: "12345678:AA" + String(repeating: "K", count: 32)), + .init(type: .sendgridKey, positive: "SG." + String(repeating: "L", count: 20) + "." + String(repeating: "M", count: 20), negative: "SG." + String(repeating: "L", count: 19) + "." + String(repeating: "M", count: 20)), + .init(type: .shopifyToken, positive: "shpat_" + String(repeating: "a", count: 20), negative: "shpat_" + String(repeating: "a", count: 19)), + .init(type: .digitaloceanToken, positive: "dop_v1_" + String(repeating: "b", count: 64), negative: "dop_v1_" + String(repeating: "b", count: 63)), + .init(type: .perplexityKey, positive: "pplx-" + String(repeating: "N", count: 48), negative: "pplx-" + String(repeating: "N", count: 47)), + .init(type: .workledgerKey, positive: "wl_sk_" + String(repeating: "O", count: 32), negative: "wl_sk_" + String(repeating: "O", count: 31)), + .init(type: .oraculKey, positive: "vc_pro_" + String(repeating: "c", count: 32), negative: "vc_pro_" + String(repeating: "c", count: 31)), + .init(type: .obstalabsKey, positive: "ol_" + String(repeating: "P", count: 20) + "." + String(repeating: "Q", count: 40), negative: "ol_" + String(repeating: "P", count: 19) + "." + String(repeating: "Q", count: 40)), + .init(type: .resendKey, positive: "re_" + String(repeating: "R", count: 24), negative: "re_" + String(repeating: "R", count: 23)), + .init(type: .vaultToken, positive: "hvs." + String(repeating: "S", count: 24), negative: "hvs." + String(repeating: "S", count: 23)), + .init(type: .slackToken, positive: ["xox", "b-1234567890-"].joined() + String(repeating: "T", count: 24), negative: ["xox", "b-short"].joined()), + .init(type: .googleApiKey, positive: "AIza" + String(repeating: "U", count: 35), negative: "AIza" + String(repeating: "U", count: 34)), + .init(type: .dockerAccessToken, positive: "dckr_pat_" + String(repeating: "V", count: 15), negative: "dckr_pat_" + String(repeating: "V", count: 14)), + .init(type: .githubToken, positive: "github_pat_" + String(repeating: "W", count: 20), negative: "github_pat_" + String(repeating: "W", count: 19)), + ] + } + + func testManifestCoversExplicitProviderDetectorSet() { + let expected: Set = [ + .awsKey, .genericApiKey, .slackWebhook, .discordWebhook, .openaiKey, + .anthropicKey, .huggingfaceToken, .groqKey, .npmToken, .pypiToken, + .rubygemsToken, .gitlabToken, .telegramBotToken, .sendgridKey, + .shopifyToken, .digitaloceanToken, .perplexityKey, .workledgerKey, + .oraculKey, .obstalabsKey, .resendKey, .vaultToken, .slackToken, + .googleApiKey, .dockerAccessToken, .githubToken, + ] + let manifest = DetectionRules.providerTokenPatternManifest + + XCTAssertEqual(Set(manifest.map(\.type)), expected) + XCTAssertEqual(Set(fixtures.map(\.type)), expected) + XCTAssertEqual(Set(manifest.map(\.fixtureID)).count, manifest.count) + XCTAssertTrue(manifest.allSatisfy { $0.primarySource.hasPrefix("https://") }) + XCTAssertTrue(manifest.allSatisfy { $0.reviewedOn == "2026-07-15" }) + } + + func testProviderFixturesHavePositiveAndBoundaryNegativeCoverage() { + for fixture in fixtures { + let positive = DetectionRules.scan(fixture.positive, config: .defaultConfig) + XCTAssertTrue( + positive.contains { + $0.type == fixture.type + && $0.value == fixture.positive + && $0.mutationAuthorizationSources.contains(.intrinsicFormat) + }, + "missing complete intrinsic match for \(fixture.type.rawValue)" + ) + + let negative = DetectionRules.scan(fixture.negative, config: .defaultConfig) + XCTAssertFalse( + negative.contains { $0.type == fixture.type }, + "boundary near-miss matched \(fixture.type.rawValue)" + ) + } + } + + func testUnsupportedIdentifiersRemainNonSecrets() { + // WO-484: Twilio SK values are SIDs, not bearer secrets; Square EAAA lacks + // a primary format guarantee and remains unsupported. + let twilioSID = "SK" + String(repeating: "a", count: 32) + let squareLookalike = "EAAA" + String(repeating: "B", count: 40) + XCTAssertTrue(DetectionRules.scan(twilioSID, config: .defaultConfig).isEmpty) + XCTAssertTrue(DetectionRules.scan(squareLookalike, config: .defaultConfig).isEmpty) + } +} diff --git a/Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift b/Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift index e82f021..22a206b 100644 --- a/Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift +++ b/Tests/PastewatchTests/ProxyBodyShapeGuardTests.swift @@ -48,6 +48,41 @@ final class ProxyBodyShapeGuardTests: XCTestCase { XCTAssertEqual(verdict("POST", "/v1/messages", body), .allow) } + func testMalformedToolsContainersAreRefused() { + // WO-456: every present tools value must be a supported array of tool objects. + let bodies = [ + #"{"messages":[],"tools":null}"#, + #"{"messages":[],"tools":"lookup"}"#, + #"{"messages":[],"tools":[{"name":"lookup"}]}"#, + #"{"messages":[],"tools":[{"name":"lookup","input_schema":{}},1]}"#, + ] + for body in bodies { + XCTAssertEqual( + verdict("POST", "/v1/messages", body), + .refuse("non-Anthropic messages schema") + ) + } + } + + func testValidToolsAndStopSequencesAreAllowed() { + let body = #"{"messages":[],"tools":[{"name":"lookup","description":"safe","input_schema":{"type":"object"},"input_examples":[{"query":"x"}]}],"stop_sequences":["done"]}"# + XCTAssertEqual(verdict("POST", "/v1/messages", body), .allow) + } + + func testMalformedStopSequencesAreRefused() { + // WO-457: mixed and scalar containers cannot skip string scanning. + for body in [ + #"{"messages":[],"stop_sequences":null}"#, + #"{"messages":[],"stop_sequences":"done"}"#, + #"{"messages":[],"stop_sequences":["done",1]}"#, + ] { + XCTAssertEqual( + verdict("POST", "/v1/messages", body), + .refuse("non-Anthropic messages schema") + ) + } + } + func testContentNullAllowed() { // WO-427: JSON null maps to NSNull and is equivalent to absent content. let body = """ diff --git a/Tests/PastewatchTests/ProxyHTTPRequestReadTests.swift b/Tests/PastewatchTests/ProxyHTTPRequestReadTests.swift index 3805ae3..bc06af9 100644 --- a/Tests/PastewatchTests/ProxyHTTPRequestReadTests.swift +++ b/Tests/PastewatchTests/ProxyHTTPRequestReadTests.swift @@ -172,7 +172,7 @@ final class ProxyHTTPRequestReadTests: XCTestCase { } func testCurlNonUTF8ResponseBodyRedactsASCIICredentialBytePreserving() { - let credential = "password=s3cr3t-hunter2" + let credential = "AIza" + String(repeating: "Y", count: 35) var body = Data([0xFF, 0xFE]) body.append(Data("prefix \(credential) suffix".utf8)) body.append(0x00) @@ -184,11 +184,11 @@ final class ProxyHTTPRequestReadTests: XCTestCase { ) XCTAssertEqual(redaction.count, 1) - XCTAssertEqual(redaction.types, ["Credential"]) + XCTAssertEqual(redaction.types, ["Google API Key"]) XCTAssertEqual(redaction.data.prefix(2), Data([0xFF, 0xFE])) XCTAssertEqual(redaction.data.last, 0x00) XCTAssertNil(redaction.data.range(of: Data(credential.utf8))) - XCTAssertNotNil(redaction.data.range(of: Data("".utf8))) + XCTAssertNotNil(redaction.data.range(of: Data("".utf8))) } func testCurlNonUTF8ResponseBodyHonorsCustomRules() { diff --git a/Tests/PastewatchTests/ProxyRealServerTests.swift b/Tests/PastewatchTests/ProxyRealServerTests.swift index 9533e80..2cb4799 100644 --- a/Tests/PastewatchTests/ProxyRealServerTests.swift +++ b/Tests/PastewatchTests/ProxyRealServerTests.swift @@ -71,7 +71,7 @@ final class ProxyRealServerTests: XCTestCase { try runningProxy.start() defer { runningProxy.stop() } - let credential = "password=s3cr3t-hunter2" + let credential = "AIza" + String(repeating: "A", count: 35) let body = """ {"model":"claude-3","messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"\(credential)"}]}]} """ @@ -88,7 +88,7 @@ final class ProxyRealServerTests: XCTestCase { XCTAssertTrue(response.contains("HTTP/1.1 200 OK"), diagnostic) XCTAssertEqual(upstream.requestCount, 1, diagnostic) XCTAssertFalse(forwarded.contains(credential), "upstream request leaked raw credential") - XCTAssertTrue(forwarded.contains(""), "upstream request missing redaction placeholder") + XCTAssertTrue(forwarded.contains(""), "upstream request missing redaction placeholder") } // WO-462/WO-478/WO-479/WO-481/WO-482/WO-483/WO-485: the real proxy @@ -159,6 +159,64 @@ final class ProxyRealServerTests: XCTestCase { XCTAssertGreaterThanOrEqual(proxy.stats.requestsRedacted, 1) } + // WO-454/WO-461: evidence, not request authorship, controls every field while + // advisory-only values remain byte-identical and absent from audit output. + func testMutationEvidenceIsConsistentAcrossAllRequestSites() throws { + let requestLock = NSLock() + var upstreamRequest = "" + let upstream = try StubHTTPServer { request in + requestLock.lock() + upstreamRequest = String(data: request, encoding: .utf8) ?? "" + requestLock.unlock() + return StubHTTPResponse(status: 200, headers: [:], body: Data(#"{"ok":true}"#.utf8)) + } + try upstream.start() + defer { upstream.stop() } + + let auditPath = FileManager.default.temporaryDirectory + .appendingPathComponent("pastewatch-evidence-matrix-\(UUID().uuidString).log") + defer { try? FileManager.default.removeItem(at: auditPath) } + let proxyPort = try TCPTestSocket.reserveLoopbackPort() + let proxy = ProxyServer( + port: proxyPort, + upstream: URL(string: "http://127.0.0.1:\(upstream.port)")!, + severity: .low, + auditLogPath: auditPath.path, + quietLog: true + ) + let runningProxy = RunningProxy(server: proxy) + try runningProxy.start() + defer { runningProxy.stop() } + + let token = "AIza" + String(repeating: "Z", count: 35) + let dsn = "postgres" + "://user:example@localhost/db" + let paired = "\(dsn) \(token)" + let body = """ + {"model":"claude-3","system":"\(paired)","tools":[{"name":"lookup","description":"\(paired)","input_schema":{"type":"object","default":"\(paired)"},"input_examples":[{"value":"\(paired)"}]}],"messages":[{"role":"user","content":"\(paired)"},{"role":"assistant","content":[{"type":"text","text":"\(paired)"},{"type":"tool_use","id":"toolu_1","name":"lookup","input":{"value":"\(paired)"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"\(paired)"}]}]} + """ + let response = try TCPTestSocket.roundTrip( + port: proxyPort, + request: TCPTestSocket.postRequest(path: "/v1/messages", body: body), + timeoutSeconds: 10 + ) + proxy.drainAuditLogForTesting() + + requestLock.lock() + let forwarded = upstreamRequest + requestLock.unlock() + let audit = try String(contentsOf: auditPath, encoding: .utf8) + let forwardedBody = try XCTUnwrap(forwarded.components(separatedBy: "\r\n\r\n").last) + let forwardedJSON = try JSONSerialization.jsonObject(with: Data(forwardedBody.utf8)) + XCTAssertTrue(response.contains("HTTP/1.1 200 OK"), TCPTestSocket.describeResponse(response)) + XCTAssertEqual(countStringLeafOccurrences(in: forwardedJSON, of: dsn), 8, forwarded) + XCTAssertFalse(forwarded.contains(token), "intrinsic token reached upstream") + XCTAssertEqual(forwarded.components(separatedBy: ""), "upstream system field missing redaction placeholder") + XCTAssertTrue(forwarded.contains(""), "upstream system field missing redaction placeholder") } // WO-447: array-form system text is scanned without dropping block metadata. @@ -346,7 +404,7 @@ final class ProxyRealServerTests: XCTestCase { try runningProxy.start() defer { runningProxy.stop() } - let credential = "password=system-block-hunter2" + let credential = "AIza" + String(repeating: "C", count: 35) let body = """ {"model":"claude-3","system":[{"type":"text","text":"\(credential)","cache_control":{"type":"ephemeral"}},{"type":"image","source":"unchanged"}],"messages":[{"role":"user","content":"hello"}]} """ @@ -361,7 +419,7 @@ final class ProxyRealServerTests: XCTestCase { requestLock.unlock() XCTAssertTrue(response.contains("HTTP/1.1 200 OK"), TCPTestSocket.describeResponse(response)) XCTAssertFalse(forwarded.contains(credential), "upstream system block leaked raw credential") - XCTAssertTrue(forwarded.contains(""), "upstream system block missing placeholder") + XCTAssertTrue(forwarded.contains(""), "upstream system block missing placeholder") XCTAssertTrue(forwarded.contains(#""cache_control":{"type":"ephemeral"}"#), forwarded) XCTAssertTrue(forwarded.contains(#""source":"unchanged""#), forwarded) } @@ -392,7 +450,7 @@ final class ProxyRealServerTests: XCTestCase { try runningProxy.start() defer { runningProxy.stop() } - let credential = "password=batch-hunter2" + let credential = "AIza" + String(repeating: "D", count: 35) let body = """ {"requests":[{"custom_id":"r1","params":{"model":"claude-3","messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"\(credential)"}]}]}}]} """ @@ -410,7 +468,7 @@ final class ProxyRealServerTests: XCTestCase { XCTAssertEqual(upstream.requestCount, 1, diagnostic) XCTAssertTrue(forwarded.contains("POST /v1/messages/batches HTTP/1.1"), forwarded) XCTAssertFalse(forwarded.contains(credential), "upstream batch request leaked raw credential") - XCTAssertTrue(forwarded.contains(""), "upstream batch request missing redaction placeholder") + XCTAssertTrue(forwarded.contains(""), "upstream batch request missing redaction placeholder") } // WO-444/WO-447: every batch params.system representation uses the same scanner as @@ -436,9 +494,9 @@ final class ProxyRealServerTests: XCTestCase { try runningProxy.start() defer { runningProxy.stop() } - let stringCredential = "password=batch-system-string-hunter2" - let blockCredential = "password=batch-system-block-hunter2" - let toolCredential = "password=batch-tool-hunter2" + let stringCredential = "AIza" + String(repeating: "E", count: 35) + let blockCredential = "AIza" + String(repeating: "F", count: 35) + let toolCredential = "AIza" + String(repeating: "G", count: 35) let body = """ {"requests":[ {"custom_id":"string","params":{"model":"claude-3","system":"\(stringCredential)","messages":[{"role":"user","content":"hello"}]}}, @@ -459,7 +517,7 @@ final class ProxyRealServerTests: XCTestCase { for credential in [stringCredential, blockCredential, toolCredential] { XCTAssertFalse(forwarded.contains(credential), "upstream batch leaked \(credential)") } - XCTAssertTrue(forwarded.contains(""), "upstream request missing redaction placeholder") + XCTAssertTrue(forwarded.contains(""), "upstream request missing redaction placeholder") } // WO-421: streaming Anthropic requests also pass the shape guard and reach upstream. @@ -916,8 +974,9 @@ final class ProxyRealServerTests: XCTestCase { } } + let dedupToken = "AIza" + String(repeating: "I", count: 35) let redactedBody = """ - {"model":"claude-3","messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"password=reset-hunter2"}]}]} + {"model":"claude-3","messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"\(dedupToken)"}]}]} """ _ = try TCPTestSocket.roundTrip( port: proxyPort, @@ -966,7 +1025,8 @@ final class ProxyRealServerTests: XCTestCase { try runningProxy.start() defer { runningProxy.stop() } - let body = #"{"model":"claude-3","messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"password=reset-hunter2"}]}]}"# + let dedupToken = "AIza" + String(repeating: "J", count: 35) + let body = #"{"model":"claude-3","messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"\#(dedupToken)"}]}]}"# for path in ["/v1/messages", "/v1/responses", "/v1/messages"] { let requestBody = path == "/v1/messages" ? body : #"{"input":"hello"}"# _ = try TCPTestSocket.roundTrip( @@ -1266,7 +1326,7 @@ final class ProxyRealServerTests: XCTestCase { } } - let credential = "password=count-tokens-hunter2" + let credential = "AIza" + String(repeating: "K", count: 35) let body = #"{"model":"claude-3","system":"\#(credential)","messages":[{"role":"user","content":"count"}]}"# let response = try TCPTestSocket.roundTrip( port: proxyPort, @@ -1284,9 +1344,9 @@ final class ProxyRealServerTests: XCTestCase { XCTAssertTrue(response.contains("HTTP/1.1 200 OK"), diagnostic) XCTAssertEqual(upstream.requestCount, 1, diagnostic) XCTAssertFalse(forwarded.contains(credential), "upstream count_tokens request leaked raw credential") - XCTAssertTrue(forwarded.contains(""), "upstream request missing redaction placeholder") + XCTAssertTrue(forwarded.contains(""), "upstream request missing redaction placeholder") XCTAssertTrue(audit.contains("PROXY REDACTED 1 secret(s) in /v1/messages/count_tokens"), audit) - XCTAssertTrue(audit.contains("Credential x1"), audit) + XCTAssertTrue(audit.contains("Google API Key x1"), audit) } func testCountTokensWithoutMessagesIsRefusedBeforeUpstream() throws { @@ -1340,7 +1400,7 @@ final class ProxyRealServerTests: XCTestCase { try runningProxy.start() defer { runningProxy.stop() } - let rawCredential = "password=serialization-hunter2" + let rawCredential = "AIza" + String(repeating: "L", count: 35) let rawEmail = "operator@example.net" let body = """ {"model":"claude-3","system":"\(rawCredential) \(rawEmail)","messages":[{"role":"user","content":"hello"}]} @@ -1588,6 +1648,20 @@ final class ProxyRealServerTests: XCTestCase { private func providerPEMFixture(label: String, payload: String) -> String { "-----BEGIN \(label)-----\n\(payload)\n-----END \(label)-----" } + + // WO-454: compare parsed leaves so JSON escaping cannot weaken the matrix test. + private func countStringLeafOccurrences(in value: Any, of expected: String) -> Int { + if let text = value as? String { + return text.components(separatedBy: expected).count - 1 + } + if let array = value as? [Any] { + return array.reduce(0) { $0 + countStringLeafOccurrences(in: $1, of: expected) } + } + if let object = value as? [String: Any] { + return object.values.reduce(0) { $0 + countStringLeafOccurrences(in: $1, of: expected) } + } + return 0 + } } private final class RunningProxy { diff --git a/Tests/PastewatchTests/ProxyStreamRedactionTests.swift b/Tests/PastewatchTests/ProxyStreamRedactionTests.swift index c925171..47c264e 100644 --- a/Tests/PastewatchTests/ProxyStreamRedactionTests.swift +++ b/Tests/PastewatchTests/ProxyStreamRedactionTests.swift @@ -85,7 +85,7 @@ final class ProxyStreamRedactionTests: XCTestCase { } func testFrameBeforeInvalidUTF8RemainderCanBeRedacted() { - let credential = "password=s3cr3t-hunter2" + let credential = "AIza" + String(repeating: "M", count: 35) let payload = #"{"type":"content_block_delta","delta":{"type":"text_delta","text":"\#(credential)"}}"# var frame = sseFrame(eventType: "content_block_delta", data: payload) frame.append(contentsOf: [0xFF]) @@ -107,29 +107,29 @@ final class ProxyStreamRedactionTests: XCTestCase { } func testThinkingDeltaSecretIsRedacted() { - let credential = "password=s3cr3t-hunter2" + let credential = "AIza" + String(repeating: "N", count: 35) let payload = #"{"type":"content_block_delta","delta":{"type":"thinking_delta","thinking":"\#(credential)"}}"# let redaction = redactFirstFrame(payload: payload) let redacted = String(data: redaction.data, encoding: .utf8) ?? "" XCTAssertEqual(redaction.count, 1) XCTAssertFalse(redacted.contains(credential)) - XCTAssertTrue(redacted.contains("")) + XCTAssertTrue(redacted.contains("")) } func testInputJSONDeltaSecretIsRedacted() { - let credential = "password=s3cr3t-hunter2" + let credential = "AIza" + String(repeating: "P", count: 35) let payload = #"{"type":"content_block_delta","delta":{"type":"input_json_delta","partial_json":"\#(credential)"}}"# let redaction = redactFirstFrame(payload: payload) let redacted = String(data: redaction.data, encoding: .utf8) ?? "" XCTAssertEqual(redaction.count, 1) XCTAssertFalse(redacted.contains(credential)) - XCTAssertTrue(redacted.contains("")) + XCTAssertTrue(redacted.contains("")) } func testCriticalMatchMutatesStreamBytes() { - let credential = "password=s3cr3t-hunter2" + let credential = "AIza" + String(repeating: "Q", count: 35) let payload = #"{"type":"content_block_delta","delta":{"type":"text_delta","text":"\#(credential)"}}"# let redaction = redactFirstFrame(payload: payload) let redacted = String(data: redaction.data, encoding: .utf8) ?? "" @@ -137,11 +137,11 @@ final class ProxyStreamRedactionTests: XCTestCase { XCTAssertEqual(redaction.count, 1) XCTAssertEqual(redaction.advisoryCount, 0) XCTAssertFalse(redacted.contains(credential)) - XCTAssertTrue(redacted.contains("")) + XCTAssertTrue(redacted.contains("")) } func testCriticalMutationSetIgnoresSeverityThreshold() { - let credential = "password=s3cr3t-hunter2" + let credential = "AIza" + String(repeating: "R", count: 35) let payload = #"{"type":"content_block_delta","delta":{"type":"text_delta","text":"\#(credential)"}}"# var parser = SSEFrameParser() let result = parser.feed(sseFrame(eventType: "content_block_delta", data: payload)) @@ -156,9 +156,9 @@ final class ProxyStreamRedactionTests: XCTestCase { let redacted = String(data: redaction.data, encoding: .utf8) ?? "" XCTAssertEqual(redaction.count, 1, "severity \(severity.rawValue)") - XCTAssertEqual(redaction.types, ["Credential"], "severity \(severity.rawValue)") + XCTAssertEqual(redaction.types, ["Google API Key"], "severity \(severity.rawValue)") XCTAssertFalse(redacted.contains(credential), "severity \(severity.rawValue)") - XCTAssertTrue(redacted.contains(""), "severity \(severity.rawValue)") + XCTAssertTrue(redacted.contains(""), "severity \(severity.rawValue)") } } @@ -244,7 +244,7 @@ final class ProxyStreamRedactionTests: XCTestCase { } func testSeverityControlsAdvisoryVolumeNotMutationSet() { - let credential = "password=s3cr3t-hunter2" + let credential = "AIza" + String(repeating: "S", count: 35) let email = "operator@example.com" let ipAddress = "10.1.2.3" let uuid = "550e8400-e29b-41d4-a716-446655440000" @@ -270,7 +270,7 @@ final class ProxyStreamRedactionTests: XCTestCase { let output = String(data: redaction.data, encoding: .utf8) ?? "" XCTAssertEqual(redaction.count, 1, "severity \(severity.rawValue)") - XCTAssertEqual(redaction.types, ["Credential"], "severity \(severity.rawValue)") + XCTAssertEqual(redaction.types, ["Google API Key"], "severity \(severity.rawValue)") XCTAssertEqual(Set(redaction.advisoryTypes), advisoryTypes, "severity \(severity.rawValue)") XCTAssertFalse(output.contains(credential), "severity \(severity.rawValue)") XCTAssertTrue(output.contains(email), "severity \(severity.rawValue)") @@ -281,7 +281,7 @@ final class ProxyStreamRedactionTests: XCTestCase { // WO-371: mixed critical and advisory severities keep independent outcomes. func testMixedCriticalAndMediumFrameRedactsOnlyCriticalAndAdvisesMedium() { - let credential = "password=s3cr3t-hunter2" + let credential = "AIza" + String(repeating: "T", count: 35) let ipAddress = "10.1.2.3" let payload = #"{"type":"content_block_delta","delta":{"type":"text_delta","text":"\#(credential) from \#(ipAddress)"}}"# var parser = SSEFrameParser() @@ -296,16 +296,16 @@ final class ProxyStreamRedactionTests: XCTestCase { let output = String(data: redaction.data, encoding: .utf8) ?? "" XCTAssertEqual(redaction.count, 1) - XCTAssertEqual(redaction.types, ["Credential"]) + XCTAssertEqual(redaction.types, ["Google API Key"]) XCTAssertEqual(redaction.advisoryCount, 1) XCTAssertEqual(redaction.advisoryTypes, ["IP"]) XCTAssertFalse(output.contains(credential)) - XCTAssertTrue(output.contains("")) + XCTAssertTrue(output.contains("")) XCTAssertTrue(output.contains(ipAddress)) } func testInvalidUTF8RawFrameWithCredentialIsRedacted() { - let credential = "password=s3cr3t-hunter2" + let credential = "AIza" + String(repeating: "U", count: 35) var raw = Data([0xFF, 0xFE]) raw.append(Data("data: \(credential)\n\n".utf8)) let frame = SSEFrameParser.Frame(raw: raw, eventType: nil, data: nil) @@ -319,7 +319,7 @@ final class ProxyStreamRedactionTests: XCTestCase { XCTAssertEqual(redaction.count, 1) XCTAssertFalse(redacted.contains(credential)) - XCTAssertTrue(redacted.contains("")) + XCTAssertTrue(redacted.contains("")) } func testRawStreamFallbackPreservesInvalidBytesWithoutCriticalMatch() { @@ -337,7 +337,7 @@ final class ProxyStreamRedactionTests: XCTestCase { } func testRawStreamFallbackRedactsCredentialWithMalformedUTF8() { - let credential = "password=s3cr3t-hunter2" + let credential = "AIza" + String(repeating: "V", count: 35) var raw = Data([0xFF, 0xFE]) raw.append(Data(" \(credential)".utf8)) @@ -350,7 +350,7 @@ final class ProxyStreamRedactionTests: XCTestCase { XCTAssertEqual(redaction.count, 1) XCTAssertFalse(redacted.contains(credential)) - XCTAssertTrue(redacted.contains("")) + XCTAssertTrue(redacted.contains("")) } func testRawDoneInsertionPlacesAlertBeforeDoneFrame() { @@ -568,7 +568,7 @@ final class ProxyStreamRedactionTests: XCTestCase { func testLinuxRelayRawStreamAlertSurvivesRedactionBeforeDone() { // WO-388: post-redaction byte shifts before [DONE] must not suppress alert injection. - let credential = "password=s3cr3t-hunter2" + let credential = "AIza" + String(repeating: "W", count: 35) var stream = Data("data: \(credential)\n\n".utf8) stream.append(Data("data: [DONE]\n\n".utf8)) @@ -581,7 +581,7 @@ final class ProxyStreamRedactionTests: XCTestCase { XCTAssertEqual(relay.result.redactionCount, 1) XCTAssertFalse(relay.output.contains(credential)) - XCTAssertTrue(relay.output.contains("")) + XCTAssertTrue(relay.output.contains("")) guard let alertRange = relay.output.range(of: "event: pastewatch_alert"), let doneRange = relay.output.range(of: "data: [DONE]") else { XCTFail(relay.output) @@ -622,8 +622,8 @@ final class ProxyStreamRedactionTests: XCTestCase { } func testLinuxRelayRawStreamEOFOverlapRedactsCriticalCredential() { - let credential = "password=s3cr3t-hunter2" - let stream = Data(("data: " + String(repeating: "a", count: 5_000) + credential + "\n\n").utf8) + let credential = "AIza" + String(repeating: "X", count: 35) + let stream = Data(("data: " + String(repeating: "a", count: 5_000) + " " + credential + "\n\n").utf8) let relay = relayStream( stream, mode: .rawStream, @@ -632,9 +632,9 @@ final class ProxyStreamRedactionTests: XCTestCase { ) XCTAssertEqual(relay.result.redactionCount, 1) - XCTAssertEqual(relay.result.redactionTypes, ["Credential"]) + XCTAssertEqual(relay.result.redactionTypes, ["Google API Key"]) XCTAssertFalse(relay.output.contains(credential)) - XCTAssertTrue(relay.output.contains("")) + XCTAssertTrue(relay.output.contains("")) } func testLinuxRelayRawStreamAdvisoryStatsSkipFailedSend() { diff --git a/Tests/PastewatchTests/ProxyTimeoutTests.swift b/Tests/PastewatchTests/ProxyTimeoutTests.swift index d0583c0..a583593 100644 --- a/Tests/PastewatchTests/ProxyTimeoutTests.swift +++ b/Tests/PastewatchTests/ProxyTimeoutTests.swift @@ -391,7 +391,7 @@ final class ProxyTimeoutTests: XCTestCase { wait(for: [finished], timeout: 2) let stats = relay.snapshotStreamStats() XCTAssertEqual(stats.redactionCount, 2) - XCTAssertEqual(stats.redactionTypes, ["Credential", "Credential"]) + XCTAssertEqual(stats.redactionTypes, ["Google API Key", "Google API Key"]) XCTAssertEqual(stats.advisoryCount, 1) XCTAssertEqual(stats.advisoryTypes, ["IP"]) } @@ -493,7 +493,8 @@ final class ProxyTimeoutTests: XCTestCase { func testSSEStreamRelayRawStreamEOFWithoutDoneAppendsAdvisory() { // WO-398: truncated raw_stream responses still surface the final advisory event. - NoDoneStreamURLProtocol.reset(payload: Data("data: password=s3cr3t-hunter2\n\n".utf8)) + let credential = "AIza" + String(repeating: "M", count: 35) + NoDoneStreamURLProtocol.reset(payload: Data("data: \(credential)\n\n".utf8)) var sockets = [Int32](repeating: 0, count: 2) XCTAssertEqual(socketpair(AF_UNIX, SOCK_STREAM, 0, &sockets), 0) defer { @@ -530,7 +531,7 @@ final class ProxyTimeoutTests: XCTestCase { wait(for: [finished], timeout: 2) let response = readSocketDrainString(from: sockets[0]) - guard let redactionRange = response.range(of: ""), + guard let redactionRange = response.range(of: ""), let advisoryRange = response.range(of: "event: pastewatch_advisory") else { XCTFail(response) return @@ -585,7 +586,8 @@ final class ProxyTimeoutTests: XCTestCase { let previousHandler = signal(SIGPIPE, SIG_IGN) defer { _ = signal(SIGPIPE, previousHandler) } - FirstDataGateStreamURLProtocol.reset(payload: Data("data: password=s3cr3t-hunter2\n\n".utf8)) + let credential = "AIza" + String(repeating: "N", count: 35) + FirstDataGateStreamURLProtocol.reset(payload: Data("data: \(credential)\n\n".utf8)) var sockets = [Int32](repeating: 0, count: 2) XCTAssertEqual(socketpair(AF_UNIX, SOCK_STREAM, 0, &sockets), 0) defer { close(sockets[1]) } @@ -658,10 +660,10 @@ final class ProxyTimeoutTests: XCTestCase { XCTAssertTrue(AdvisoryAfterCloseStreamURLProtocol.waitForFirstChunk(timeout: 2)) var firstResponse = readSocketString(from: sockets[0]) - if !firstResponse.contains("") { + if !firstResponse.contains("") { firstResponse += readSocketString(from: sockets[0]) } - XCTAssertTrue(firstResponse.contains(""), firstResponse) + XCTAssertTrue(firstResponse.contains(""), firstResponse) close(sockets[0]) AdvisoryAfterCloseStreamURLProtocol.allowSecondChunk() @@ -710,16 +712,16 @@ final class ProxyTimeoutTests: XCTestCase { XCTAssertTrue(ControlledDoneStreamURLProtocol.waitForFirstChunk(timeout: 2)) var firstResponse = readSocketString(from: sockets[0]) - if !firstResponse.contains("") { + if !firstResponse.contains("") { firstResponse += readSocketString(from: sockets[0]) } - XCTAssertTrue(firstResponse.contains(""), firstResponse) + XCTAssertTrue(firstResponse.contains(""), firstResponse) close(sockets[0]) ControlledDoneStreamURLProtocol.allowDone() wait(for: [finished], timeout: 2) XCTAssertEqual(relay.streamRedactionCount, 1) - XCTAssertEqual(relay.streamRedactionTypes, ["Credential"]) + XCTAssertEqual(relay.streamRedactionTypes, ["Google API Key"]) } private func assertNoDataAvailable( @@ -876,8 +878,10 @@ private class StatsThenHangStreamURLProtocol: URLProtocol { return } client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - let first = #"{"type":"content_block_delta","delta":{"type":"text_delta","text":"password=s3cr3t-hunter2"}}"# - let second = #"{"type":"content_block_delta","delta":{"type":"text_delta","text":"password=another-s3cr3t"}}"# + let firstCredential = "AIza" + String(repeating: "O", count: 35) + let secondCredential = "AIza" + String(repeating: "P", count: 35) + let first = #"{"type":"content_block_delta","delta":{"type":"text_delta","text":"\#(firstCredential)"}}"# + let second = #"{"type":"content_block_delta","delta":{"type":"text_delta","text":"\#(secondCredential)"}}"# let advisory = #"{"type":"content_block_delta","delta":{"type":"text_delta","text":"host 10.1.2.3"}}"# client?.urlProtocol(self, didLoad: Data("data: \(first)\n\n".utf8)) client?.urlProtocol(self, didLoad: Data("data: \(second)\n\n".utf8)) @@ -967,7 +971,8 @@ private class ControlledDoneStreamURLProtocol: URLProtocol { return } client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - let payload = #"{"type":"content_block_delta","delta":{"type":"text_delta","text":"password=s3cr3t-hunter2"}}"# + let credential = "AIza" + String(repeating: "Q", count: 35) + let payload = #"{"type":"content_block_delta","delta":{"type":"text_delta","text":"\#(credential)"}}"# client?.urlProtocol(self, didLoad: Data("data: \(payload)\n\n".utf8)) Self.firstChunkSemaphore.signal() _ = Self.allowDoneSemaphore.wait(timeout: .now() + 1) @@ -1156,7 +1161,8 @@ private class AdvisoryAfterCloseStreamURLProtocol: URLProtocol { return } client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - let first = #"{"type":"content_block_delta","delta":{"type":"text_delta","text":"password=s3cr3t-hunter2"}}"# + let credential = "AIza" + String(repeating: "R", count: 35) + let first = #"{"type":"content_block_delta","delta":{"type":"text_delta","text":"\#(credential)"}}"# client?.urlProtocol(self, didLoad: Data("data: \(first)\n\n".utf8)) Self.firstChunkSemaphore.signal() _ = Self.allowSecondSemaphore.wait(timeout: .now() + 1) diff --git a/Tests/PastewatchTests/SecretContainmentTests.swift b/Tests/PastewatchTests/SecretContainmentTests.swift new file mode 100644 index 0000000..3d908e2 --- /dev/null +++ b/Tests/PastewatchTests/SecretContainmentTests.swift @@ -0,0 +1,90 @@ +import XCTest +@testable import PastewatchCore + +final class SecretContainmentTests: XCTestCase { + // WO-480: explicit fixtures make every mutation-authorized detector prove that + // matched secret bytes disappear while surrounding bytes remain exact. + private var fixtures: [SensitiveDataType: String] { + [ + .awsKey: "AKIA" + String(repeating: "A", count: 16), + .genericApiKey: "sk_live_" + String(repeating: "B", count: 24), + .sshPrivateKey: "-----BEGIN " + "PRIVATE KEY-----\n" + String(repeating: "QUJD", count: 12) + "\n-----END PRIVATE KEY-----", + .jwtToken: "eyJhbGciOiJIUzI1NiJ9" + ".eyJzdWIiOiIxMjM0In0." + String(repeating: "c", count: 32), + .creditCard: "4111111111111111", + .slackWebhook: "https://hooks.slack.com/services/TABC/BDEF/Token123", + .discordWebhook: "https://discord.com/api/webhooks/123456/Token_123", + .azureConnectionString: "DefaultEndpointsProtocol=https;AccountName=demo;AccountKey=abc123def456+ghi789==", + .gcpServiceAccount: #"{"type":"service_account","private_key_id":"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1","private_key":"synthetic-private-material"}"#, + .openaiKey: "sk-proj-" + String(repeating: "C", count: 20), + .anthropicKey: "sk-ant-api03-" + String(repeating: "D", count: 20), + .huggingfaceToken: "hf_" + String(repeating: "E", count: 20), + .groqKey: "gsk_" + String(repeating: "F", count: 20), + .npmToken: "npm_" + String(repeating: "G", count: 20), + .pypiToken: "pypi-" + String(repeating: "H", count: 20), + .rubygemsToken: "rubygems_" + String(repeating: "I", count: 20), + .gitlabToken: "glpat-" + String(repeating: "J", count: 20), + .telegramBotToken: "12345678:AA" + String(repeating: "K", count: 33), + .sendgridKey: "SG." + String(repeating: "L", count: 20) + "." + String(repeating: "M", count: 20), + .shopifyToken: "shpat_" + String(repeating: "a", count: 20), + .digitaloceanToken: "dop_v1_" + String(repeating: "b", count: 64), + .perplexityKey: "pplx-" + String(repeating: "N", count: 48), + .workledgerKey: "wl_sk_" + String(repeating: "O", count: 32), + .oraculKey: "vc_pro_" + String(repeating: "c", count: 32), + .obstalabsKey: "ol_" + String(repeating: "P", count: 20) + "." + String(repeating: "Q", count: 40), + .resendKey: "re_" + String(repeating: "R", count: 24), + .vaultToken: "hvs." + String(repeating: "S", count: 24), + .slackToken: ["xox", "b-1234567890-"].joined() + String(repeating: "T", count: 24), + .googleApiKey: "AIza" + String(repeating: "U", count: 35), + .dockerAccessToken: "dckr_pat_" + String(repeating: "V", count: 15), + .githubToken: "github_pat_" + String(repeating: "W", count: 20), + ] + } + + func testEveryAuthorizedDetectorContainsItsCompleteMatchedBytes() { + let expected = Set(SensitiveDataType.allCases.filter(\.intrinsicMutationAuthorized)) + XCTAssertEqual(Set(fixtures.keys), expected) + + for (type, fixture) in fixtures { + let isStructuredGCP = type == .gcpServiceAccount + let content = isStructuredGCP ? fixture : "prefix|\(fixture)|suffix" + let matches = DetectionRules.scan(content, config: .defaultConfig) + let authorized = matches.filter { + $0.type == type && $0.mutationAuthorizationSources.contains(.intrinsicFormat) + } + XCTAssertFalse(authorized.isEmpty, "missing fixture match for \(type.rawValue)") + + let outcome = applyAuthorizedMutations( + to: content, + matches: matches, + site: .cliScan, + minAdvisorySeverity: .low + ) + for match in authorized { + XCTAssertFalse(outcome.text.contains(match.value), "leaked \(type.rawValue) match") + } + if !isStructuredGCP { + XCTAssertTrue(outcome.text.hasPrefix("prefix|"), "overcaptured prefix for \(type.rawValue)") + XCTAssertTrue(outcome.text.hasSuffix("|suffix"), "overcaptured suffix for \(type.rawValue)") + } + } + } + + func testMarkersAndPartialShapesNeverAuthorizeMutation() { + let partials = [ + "AKIA" + String(repeating: "A", count: 15), + "-----BEGIN " + "PRIVATE KEY-----\nQUJD", + #"{"type":"service_account"}"#, + "AIza" + String(repeating: "U", count: 34), + "dckr_pat_" + String(repeating: "V", count: 14), + ] + + for partial in partials { + let matches = DetectionRules.scan(partial, config: .defaultConfig) + XCTAssertTrue( + partitionMutationMatches(matches, site: .cliScan, minAdvisorySeverity: .low) + .authorized.isEmpty, + "partial shape authorized mutation" + ) + } + } +} diff --git a/docs/proxy-invariants.md b/docs/proxy-invariants.md index a76faf8..89c7318 100644 --- a/docs/proxy-invariants.md +++ b/docs/proxy-invariants.md @@ -4,10 +4,13 @@ These invariants define the proxy streaming and shutdown behavior that must stay guarded by tests. New edge ideas outside this list should be logged as follow-up work unless they violate one of these invariants. -1. Mutate proxy bytes only for deterministic secret classes and operator-approved - custom rules; uncertain built-ins are advisory-only regardless of `--severity`. +1. Mutate proxy bytes only when explicit evidence authorizes the exact match: + an intrinsically distinctive secret format, an exact locally known value, or an + operator-approved custom rule. Format-only DSN/JDBC and ambiguous built-ins are + advisory-only regardless of request field or `--severity`. The `--severity` flag gates advisory reporting volume, not mutation. - Guard: `ProxyStreamRedactionTests.testCriticalMatchMutatesStreamBytes`, + Guard: `MutationAuthorizationTests.testPartitionConservesEveryMatchAndSeverityDoesNotAuthorize`, + `ProxyStreamRedactionTests.testCriticalMatchMutatesStreamBytes`, `ProxyStreamRedactionTests.testHighBuiltInMatchIsAdvisoryOnlyAndByteIdentical`, and `ProxyStreamRedactionTests.testHighCustomRuleMatchMutatesStreamBytes`. From d02925478d862de2232949df2a032569e8bc3260 Mon Sep 17 00:00:00 2001 From: ppiankov <103106369+ppiankov@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:19:13 +0800 Subject: [PATCH 24/29] fix: separate provider token evidence --- Sources/PastewatchCore/DetectionRules.swift | 57 ++++++++++++++----- Sources/PastewatchCore/Types.swift | 6 +- .../MutationAuthorizationTests.swift | 39 ++++++++++++- .../ProviderTokenPatternTests.swift | 29 +++++++++- .../SecretContainmentTests.swift | 33 ++++++++++- 5 files changed, 144 insertions(+), 20 deletions(-) diff --git a/Sources/PastewatchCore/DetectionRules.swift b/Sources/PastewatchCore/DetectionRules.swift index bd79ab7..57457c6 100644 --- a/Sources/PastewatchCore/DetectionRules.swift +++ b/Sources/PastewatchCore/DetectionRules.swift @@ -7,11 +7,24 @@ import Foundation /// False negatives are preferred over false positives. public struct DetectionRules { private static let maximumPrivateKeyBlockCharacters = 262_144 // WO-478: bound malformed PEM scans. + // WO-487: these sourced grammars authorize mutation independently of the + // advisory-only genericApiKey type. + private static let githubClassicTokenRegex = try? NSRegularExpression( + pattern: #"\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b"# + ) + private static let stripeAPIKeyRegex = try? NSRegularExpression( + pattern: #"\b(sk|pk|rk)_(test|live)_[A-Za-z0-9]{24,}\b"# + ) + private static let stripeWebhookSecretRegex = try? NSRegularExpression( + pattern: #"\bwhsec_[A-Za-z0-9]{24,}\b"# + ) // WO-484: reviewed primary references travel with the intrinsic provider set. public static let providerTokenPatternManifest: [ProviderTokenPatternMetadata] = [ .init(type: .awsKey, provider: "AWS", tokenFamily: "access keys", primarySource: "https://docs.aws.amazon.com/IAM/latest/UserGuide/security-creds.html", reviewedOn: "2026-07-15", fixtureID: "aws-access-key"), - .init(type: .genericApiKey, provider: "Prefixed tokens", tokenFamily: "GitHub and Stripe legacy tokens", primarySource: "https://docs.github.com/authentication/keeping-your-account-and-data-secure/about-authentication-to-github", reviewedOn: "2026-07-15", fixtureID: "generic-prefixed-token"), + .init(type: .genericApiKey, provider: "GitHub", tokenFamily: "classic tokens", primarySource: "https://docs.github.com/authentication/keeping-your-account-and-data-secure/about-authentication-to-github", reviewedOn: "2026-07-15", fixtureID: "github-classic-token"), + .init(type: .genericApiKey, provider: "Stripe", tokenFamily: "API keys", primarySource: "https://docs.stripe.com/keys", reviewedOn: "2026-07-15", fixtureID: "stripe-api-key"), + .init(type: .genericApiKey, provider: "Stripe", tokenFamily: "webhook signing secrets", primarySource: "https://docs.stripe.com/webhooks/signature", reviewedOn: "2026-07-15", fixtureID: "stripe-webhook-secret"), .init(type: .slackWebhook, provider: "Slack", tokenFamily: "incoming webhook", primarySource: "https://api.slack.com/messaging/webhooks", reviewedOn: "2026-07-15", fixtureID: "slack-webhook"), .init(type: .discordWebhook, provider: "Discord", tokenFamily: "webhook", primarySource: "https://discord.com/developers/docs/resources/webhook", reviewedOn: "2026-07-15", fixtureID: "discord-webhook"), .init(type: .openaiKey, provider: "OpenAI", tokenFamily: "API key", primarySource: "https://platform.openai.com/docs/api-reference/authentication", reviewedOn: "2026-07-15", fixtureID: "openai-key"), @@ -400,27 +413,18 @@ public struct DetectionRules { } // GitHub Token - high confidence - if let regex = try? NSRegularExpression( - pattern: #"\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b"#, - options: [] - ) { + if let regex = githubClassicTokenRegex { result.append((.genericApiKey, regex)) } // Stripe API Key - high confidence - if let regex = try? NSRegularExpression( - pattern: #"\b(sk|pk|rk)_(test|live)_[A-Za-z0-9]{24,}\b"#, - options: [] - ) { + if let regex = stripeAPIKeyRegex { result.append((.genericApiKey, regex)) } // Stripe Webhook Secret - high confidence // whsec_ prefix not covered by the generic sk/pk/api/key/token catch-all - if let regex = try? NSRegularExpression( - pattern: #"\bwhsec_[A-Za-z0-9]{24,}\b"#, - options: [] - ) { + if let regex = stripeWebhookSecretRegex { result.append((.genericApiKey, regex)) } @@ -633,7 +637,13 @@ public struct DetectionRules { if !isValidMatch(value, type: type, config: config) { continue } let line = lineNumber(of: range.lowerBound, in: content) - matches.append(DetectedMatch(type: type, value: value, range: range, line: line)) + matches.append(DetectedMatch( + type: type, + value: value, + range: range, + line: line, + mutationAuthorizationSources: mutationAuthorizationSources(for: type, value: value) + )) matchedRanges.append(range) } } @@ -668,6 +678,25 @@ public struct DetectionRules { return matches } + // WO-487: provider evidence is attached to the exact detector grammar, not + // inferred from the broader public result type. + private static func mutationAuthorizationSources( + for type: SensitiveDataType, + value: String + ) -> Set { + guard type == .genericApiKey else { return [] } + let range = NSRange(value.startIndex..., in: value) + let sourcedRegexes = [ + githubClassicTokenRegex, + stripeAPIKeyRegex, + stripeWebhookSecretRegex, + ].compactMap { $0 } + let isSourcedProviderToken = sourcedRegexes.contains { regex in + regex.firstMatch(in: value, options: [], range: range)?.range == range + } + return isSourcedProviderToken ? [.intrinsicFormat] : [] + } + // WO-478: valid blocks authorize complete containment; malformed recognized // blocks reserve their bounded region as advisory-only evidence. private static func scanCompletePrivateKeyBlocks( diff --git a/Sources/PastewatchCore/Types.swift b/Sources/PastewatchCore/Types.swift index b63e775..c54e133 100644 --- a/Sources/PastewatchCore/Types.swift +++ b/Sources/PastewatchCore/Types.swift @@ -100,10 +100,10 @@ public enum SensitiveDataType: String, CaseIterable, Codable { } } - /// WO-454: only formats whose matched bytes prove a secret authorize mutation. + /// WO-454/WO-487: only formats whose matched bytes prove a secret authorize mutation. public var intrinsicMutationAuthorized: Bool { switch self { - case .awsKey, .genericApiKey, .sshPrivateKey, + case .awsKey, .sshPrivateKey, .jwtToken, .creditCard, .slackWebhook, .discordWebhook, .azureConnectionString, .gcpServiceAccount, .openaiKey, .anthropicKey, .huggingfaceToken, .groqKey, @@ -112,7 +112,7 @@ public enum SensitiveDataType: String, CaseIterable, Codable { .perplexityKey, .workledgerKey, .oraculKey, .obstalabsKey, .resendKey, .vaultToken, .slackToken, .googleApiKey, .dockerAccessToken, .githubToken: return true - case .dbConnectionString, .jdbcUrl, .credential, .xmlCredential, + case .genericApiKey, .dbConnectionString, .jdbcUrl, .credential, .xmlCredential, .email, .phone, .xmlUsername, .ipAddress, .filePath, .hostname, .xmlHostname, .uuid, .highEntropyString: diff --git a/Tests/PastewatchTests/MutationAuthorizationTests.swift b/Tests/PastewatchTests/MutationAuthorizationTests.swift index 35edb1d..5f32558 100644 --- a/Tests/PastewatchTests/MutationAuthorizationTests.swift +++ b/Tests/PastewatchTests/MutationAuthorizationTests.swift @@ -7,7 +7,7 @@ final class MutationAuthorizationTests: XCTestCase { func testIntrinsicAuthorizationSetIsExplicit() { // WO-454: this literal set makes detector promotion a reviewed policy change. let expected: Set = [ - .awsKey, .genericApiKey, .sshPrivateKey, .jwtToken, .creditCard, + .awsKey, .sshPrivateKey, .jwtToken, .creditCard, .slackWebhook, .discordWebhook, .azureConnectionString, .gcpServiceAccount, .openaiKey, .anthropicKey, .huggingfaceToken, .groqKey, .npmToken, .pypiToken, .rubygemsToken, .gitlabToken, .telegramBotToken, .sendgridKey, @@ -23,6 +23,7 @@ final class MutationAuthorizationTests: XCTestCase { XCTAssertFalse(SensitiveDataType.dbConnectionString.intrinsicMutationAuthorized) XCTAssertFalse(SensitiveDataType.jdbcUrl.intrinsicMutationAuthorized) XCTAssertFalse(SensitiveDataType.credential.intrinsicMutationAuthorized) + XCTAssertFalse(SensitiveDataType.genericApiKey.intrinsicMutationAuthorized) } func testPartitionConservesEveryMatchAndSeverityDoesNotAuthorize() { @@ -84,6 +85,42 @@ final class MutationAuthorizationTests: XCTestCase { XCTAssertFalse(outcome.text.contains(value)) } + func testBroadGenericKeyRequiresExplicitAuthorization() throws { + // WO-487: the broad fallback remains visible but cannot mutate from its + // prefix alone; an operator rule can promote the same exact match. + let value = ["token", "_", String(repeating: "z", count: 24)].joined() + let builtInMatches = DetectionRules.scan(value, config: config) + let builtIn = try XCTUnwrap(builtInMatches.first { $0.type == .genericApiKey }) + XCTAssertTrue(builtIn.mutationAuthorizationSources.isEmpty) + XCTAssertTrue( + partitionMutationMatches( + builtInMatches, + site: .proxyUserText, + minAdvisorySeverity: .critical + ).authorized.isEmpty + ) + + let rule = CustomRule( + name: "Approved generic token", + regex: try NSRegularExpression( + pattern: NSRegularExpression.escapedPattern(for: value) + ), + severity: .critical, + type: .genericApiKey + ) + let promoted = DetectionRules.scan(value, config: config, customRules: [rule]) + XCTAssertEqual(promoted.count, 1) + XCTAssertTrue(promoted[0].mutationAuthorizationSources.contains(.customRule)) + XCTAssertEqual( + partitionMutationMatches( + promoted, + site: .proxyUserText, + minAdvisorySeverity: .critical + ).authorized.count, + 1 + ) + } + func testProxyUsesEvidenceAcrossRequestSites() throws { let token = "AIza" + String(repeating: "K", count: 35) let dsn = "postgres" + "://user:example@localhost/db" diff --git a/Tests/PastewatchTests/ProviderTokenPatternTests.swift b/Tests/PastewatchTests/ProviderTokenPatternTests.swift index 9396c3e..41d0d9e 100644 --- a/Tests/PastewatchTests/ProviderTokenPatternTests.swift +++ b/Tests/PastewatchTests/ProviderTokenPatternTests.swift @@ -6,13 +6,29 @@ final class ProviderTokenPatternTests: XCTestCase { let type: SensitiveDataType let positive: String let negative: String + let fixtureID: String? + + init( + type: SensitiveDataType, + positive: String, + negative: String, + fixtureID: String? = nil + ) { + self.type = type + self.positive = positive + self.negative = negative + self.fixtureID = fixtureID + } } // WO-484: fixtures are synthetic and offline; none are usable credentials. private var fixtures: [Fixture] { [ .init(type: .awsKey, positive: "AKIA" + String(repeating: "A", count: 16), negative: "AKIA" + String(repeating: "A", count: 15)), - .init(type: .genericApiKey, positive: "sk_live_" + String(repeating: "B", count: 24), negative: "sk_live_" + String(repeating: "B", count: 23)), + // WO-487: each sourced genericApiKey grammar has its own boundary fixture. + .init(type: .genericApiKey, positive: "ghp_" + String(repeating: "B", count: 36), negative: "ghp_" + String(repeating: "B", count: 35), fixtureID: "github-classic-token"), + .init(type: .genericApiKey, positive: "sk_live_" + String(repeating: "C", count: 24), negative: "sk_live_" + String(repeating: "C", count: 23), fixtureID: "stripe-api-key"), + .init(type: .genericApiKey, positive: "whsec_" + String(repeating: "D", count: 24), negative: "whsec_" + String(repeating: "D", count: 23), fixtureID: "stripe-webhook-secret"), .init(type: .slackWebhook, positive: "https://hooks.slack.com/services/TABC/BDEF/Token123", negative: "https://hooks.slack.com/services/ABC/BDEF/Token123"), .init(type: .discordWebhook, positive: "https://discord.com/api/webhooks/123456/Token_123", negative: "https://discord.com/api/webhooks/id/Token_123"), .init(type: .openaiKey, positive: "sk-proj-" + String(repeating: "C", count: 20), negative: "sk-proj-" + String(repeating: "C", count: 19)), @@ -53,7 +69,18 @@ final class ProviderTokenPatternTests: XCTestCase { XCTAssertEqual(Set(manifest.map(\.type)), expected) XCTAssertEqual(Set(fixtures.map(\.type)), expected) + XCTAssertEqual(manifest.count, expected.count + 2) + XCTAssertEqual(fixtures.count, expected.count + 2) XCTAssertEqual(Set(manifest.map(\.fixtureID)).count, manifest.count) + XCTAssertEqual( + Set(manifest.filter { $0.type == .genericApiKey }.map(\.fixtureID)), + ["github-classic-token", "stripe-api-key", "stripe-webhook-secret"] + ) + XCTAssertEqual( + Set(fixtures.compactMap(\.fixtureID)), + Set(manifest.filter { $0.type == .genericApiKey }.map(\.fixtureID)) + ) + XCTAssertFalse(manifest.contains { $0.provider == "Prefixed tokens" }) XCTAssertTrue(manifest.allSatisfy { $0.primarySource.hasPrefix("https://") }) XCTAssertTrue(manifest.allSatisfy { $0.reviewedOn == "2026-07-15" }) } diff --git a/Tests/PastewatchTests/SecretContainmentTests.swift b/Tests/PastewatchTests/SecretContainmentTests.swift index 3d908e2..be3f147 100644 --- a/Tests/PastewatchTests/SecretContainmentTests.swift +++ b/Tests/PastewatchTests/SecretContainmentTests.swift @@ -7,7 +7,6 @@ final class SecretContainmentTests: XCTestCase { private var fixtures: [SensitiveDataType: String] { [ .awsKey: "AKIA" + String(repeating: "A", count: 16), - .genericApiKey: "sk_live_" + String(repeating: "B", count: 24), .sshPrivateKey: "-----BEGIN " + "PRIVATE KEY-----\n" + String(repeating: "QUJD", count: 12) + "\n-----END PRIVATE KEY-----", .jwtToken: "eyJhbGciOiJIUzI1NiJ9" + ".eyJzdWIiOiIxMjM0In0." + String(repeating: "c", count: 32), .creditCard: "4111111111111111", @@ -40,6 +39,16 @@ final class SecretContainmentTests: XCTestCase { ] } + // WO-487: these share the compatibility type but receive authorization from + // their exact provider grammar rather than from the type itself. + private var sourcedGenericFixtures: [String] { + [ + "ghp_" + String(repeating: "B", count: 36), + "sk_live_" + String(repeating: "C", count: 24), + "whsec_" + String(repeating: "D", count: 24), + ] + } + func testEveryAuthorizedDetectorContainsItsCompleteMatchedBytes() { let expected = Set(SensitiveDataType.allCases.filter(\.intrinsicMutationAuthorized)) XCTAssertEqual(Set(fixtures.keys), expected) @@ -87,4 +96,26 @@ final class SecretContainmentTests: XCTestCase { ) } } + + func testSourcedGenericProviderGrammarsContainCompleteMatchedBytes() { + for fixture in sourcedGenericFixtures { + let content = "prefix|\(fixture)|suffix" + let matches = DetectionRules.scan(content, config: .defaultConfig) + let authorized = matches.filter { + $0.type == .genericApiKey + && $0.mutationAuthorizationSources.contains(.intrinsicFormat) + } + XCTAssertEqual(authorized.count, 1) + + let outcome = applyAuthorizedMutations( + to: content, + matches: matches, + site: .cliScan, + minAdvisorySeverity: .low + ) + XCTAssertFalse(outcome.text.contains(fixture)) + XCTAssertTrue(outcome.text.hasPrefix("prefix|")) + XCTAssertTrue(outcome.text.hasSuffix("|suffix")) + } + } } From 17f17ec525d044541a925cc174b592cfd1dad08c Mon Sep 17 00:00:00 2001 From: ppiankov <103106369+ppiankov@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:20:34 +0800 Subject: [PATCH 25/29] fix: order sourced token detectors first --- Sources/PastewatchCore/DetectionRules.swift | 23 ++++++++++----------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/Sources/PastewatchCore/DetectionRules.swift b/Sources/PastewatchCore/DetectionRules.swift index 57457c6..334a5df 100644 --- a/Sources/PastewatchCore/DetectionRules.swift +++ b/Sources/PastewatchCore/DetectionRules.swift @@ -3,8 +3,8 @@ import Foundation /// Deterministic detection rules for sensitive data. /// No ML. No confidence scores. No guessing. /// -/// Each rule is a regex pattern that matches high-confidence patterns only. -/// False negatives are preferred over false positives. +/// Rules provide deterministic detection. WO-487: mutation authorization is +/// separately attached only when a grammar proves provider-specific evidence. public struct DetectionRules { private static let maximumPrivateKeyBlockCharacters = 262_144 // WO-478: bound malformed PEM scans. // WO-487: these sourced grammars authorize mutation independently of the @@ -402,16 +402,6 @@ public struct DetectionRules { result.append((.xmlHostname, regex)) } - // Generic API Key patterns - high confidence - // Common prefixes: sk-, pk-, api_, key_, token_ - // Placed AFTER specific providers (OpenAI sk-proj-, Anthropic sk-ant-, Groq gsk_) - if let regex = try? NSRegularExpression( - pattern: #"\b(sk|pk|api|key|token|secret|bearer)[_-][A-Za-z0-9]{20,}\b"#, - options: [.caseInsensitive] - ) { - result.append((.genericApiKey, regex)) - } - // GitHub Token - high confidence if let regex = githubClassicTokenRegex { result.append((.genericApiKey, regex)) @@ -428,6 +418,15 @@ public struct DetectionRules { result.append((.genericApiKey, regex)) } + // WO-487: broad prefixed lookalikes remain visible but advisory-only. + // Keep this fallback after every sourced provider grammar. + if let regex = try? NSRegularExpression( + pattern: #"\b(sk|pk|api|key|token|secret|bearer)[_-][A-Za-z0-9]{20,}\b"#, + options: [.caseInsensitive] + ) { + result.append((.genericApiKey, regex)) + } + // Credential key=value pairs - high confidence // Matches password=, secret:, api_key=, etc. // Placed after API key patterns so specific tokens match first. From c143b50c2b6b1fe49e6e8880decafe491271d86d Mon Sep 17 00:00:00 2001 From: ppiankov <103106369+ppiankov@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:33:45 +0800 Subject: [PATCH 26/29] test: cover proxy mutation edge cases --- Tests/PastewatchTests/ProxyAlertTests.swift | 63 +++++++++++++++++++ .../ProxyRealServerTests.swift | 45 +++++++++++++ 2 files changed, 108 insertions(+) diff --git a/Tests/PastewatchTests/ProxyAlertTests.swift b/Tests/PastewatchTests/ProxyAlertTests.swift index 4dc7e69..39e940f 100644 --- a/Tests/PastewatchTests/ProxyAlertTests.swift +++ b/Tests/PastewatchTests/ProxyAlertTests.swift @@ -235,6 +235,69 @@ final class ProxyAlertTests: XCTestCase { XCTAssertTrue(result.body.contains(""), result.body) } + func testAssistantOnlyToolUseRecursesIntoExplicitlyAuthorizedInput() throws { + // WO-463/WO-467: tool_use.input is CONTRACT context, so only explicit + // operator authorization may mutate a deeply nested value. + var config = PastewatchConfig.defaultConfig + config.customRules = [ + CustomRuleConfig( + name: "Approved nested token", + pattern: #"ACME-NESTED-[A-Z]+"#, + severity: "high" + ) + ] + let customServer = ProxyServer(port: 0, config: config, severity: .high) + let value = "ACME-NESTED-ALPHA" + let body = """ + {"messages":[{"role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"lookup","input":{"outer":[{"inner":"\(value)"}]}}]}]} + """ + + let result = customServer.scanAndRedactBody(body) + + XCTAssertEqual(result.redacted, 1) + XCTAssertFalse(result.body.contains(value)) + let json = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(result.body.utf8)) as? [String: Any] + ) + let messages = try XCTUnwrap(json["messages"] as? [[String: Any]]) + let content = try XCTUnwrap(messages[0]["content"] as? [[String: Any]]) + let input = try XCTUnwrap(content[0]["input"] as? [String: Any]) + let outer = try XCTUnwrap(input["outer"] as? [[String: Any]]) + XCTAssertEqual(outer[0]["inner"] as? String, "") + } + + func testToolSchemaEnumPreservesBuiltInAndMutatesCustomRule() throws { + // WO-468: schema enums are recursively scanned as CONTRACT material. + var config = PastewatchConfig.defaultConfig + config.customRules = [ + CustomRuleConfig( + name: "Approved schema token", + pattern: #"ACME-SCHEMA-[A-Z]+"#, + severity: "high" + ) + ] + let customServer = ProxyServer(port: 0, config: config, severity: .low) + let dsn = "postgres" + "://user:example@localhost/db" + let approved = "ACME-SCHEMA-ALPHA" + let body = """ + {"tools":[{"name":"lookup","input_schema":{"type":"string","enum":["\(dsn)","\(approved)","safe"]}}],"messages":[]} + """ + + let result = customServer.scanAndRedactBody(body) + + XCTAssertEqual(result.redacted, 1) + XCTAssertEqual(result.advisoryCount, 1) + let json = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(result.body.utf8)) as? [String: Any] + ) + let tools = try XCTUnwrap(json["tools"] as? [[String: Any]]) + let schema = try XCTUnwrap(tools[0]["input_schema"] as? [String: Any]) + let values = try XCTUnwrap(schema["enum"] as? [String]) + XCTAssertEqual(values[0], dsn) + XCTAssertEqual(values[1], "") + XCTAssertEqual(values[2], "safe") + } + func testBodyRedactionAuditIsDeferredForStreamingRequests() { XCTAssertFalse(server.shouldLogBodyRedactionBeforeForwarding( redactionCount: 1, diff --git a/Tests/PastewatchTests/ProxyRealServerTests.swift b/Tests/PastewatchTests/ProxyRealServerTests.swift index 2cb4799..e939a3b 100644 --- a/Tests/PastewatchTests/ProxyRealServerTests.swift +++ b/Tests/PastewatchTests/ProxyRealServerTests.swift @@ -1427,6 +1427,51 @@ final class ProxyRealServerTests: XCTestCase { XCTAssertFalse(audit.contains(rawEmail), audit) } + func testBatchSerializationFailureBlocksForwarding() throws { + // WO-465: batch params share the fail-closed serializer boundary with + // ordinary Messages requests and must never fall back to the raw body. + let upstream = try StubHTTPServer { _ in + StubHTTPResponse(status: 200, headers: [:], body: Data(#"{"ok":true}"#.utf8)) + } + try upstream.start() + defer { upstream.stop() } + + let auditPath = FileManager.default.temporaryDirectory + .appendingPathComponent("pastewatch-batch-redaction-failure-\(UUID().uuidString).log") + defer { try? FileManager.default.removeItem(at: auditPath) } + + let proxyPort = try TCPTestSocket.reserveLoopbackPort() + let proxy = ProxyServer( + port: proxyPort, + upstream: URL(string: "http://127.0.0.1:\(upstream.port)")!, + auditLogPath: auditPath.path, + quietLog: true + ) + proxy.requestBodySerializer = { _ in throw CocoaError(.fileWriteUnknown) } + let runningProxy = RunningProxy(server: proxy) + try runningProxy.start() + defer { runningProxy.stop() } + + let token = "AIza" + String(repeating: "M", count: 35) + let body = """ + {"requests":[{"custom_id":"request-1","params":{"model":"claude-3","messages":[{"role":"user","content":"\(token)"}]}}]} + """ + let response = try TCPTestSocket.roundTrip( + port: proxyPort, + request: TCPTestSocket.postRequest(path: "/v1/messages/batches", body: body), + timeoutSeconds: 10 + ) + proxy.drainAuditLogForTesting() + + let audit = try String(contentsOf: auditPath, encoding: .utf8) + XCTAssertTrue(response.contains("HTTP/1.1 500 Internal Server Error"), response) + XCTAssertEqual(upstream.requestCount, 0) + XCTAssertEqual(proxy.stats.redactionFailures, 1) + XCTAssertEqual(proxy.stats.requestsProcessed, 0) + XCTAssertTrue(audit.contains("PROXY REDACTION FAILED 1 secret(s) in /v1/messages/batches"), audit) + XCTAssertFalse(audit.contains(token), audit) + } + func testAdmissionCapRejectsFifthConcurrentConnection() throws { let upstreamEntered = DispatchSemaphore(value: 0) let upstreamRelease = DispatchSemaphore(value: 0) From fc621316e1f9f81462a2224f9bedd3ed681dfc76 Mon Sep 17 00:00:00 2001 From: ppiankov <103106369+ppiankov@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:17:12 +0800 Subject: [PATCH 27/29] fix: harden proxy evidence boundaries --- README.md | 10 ++--- Sources/PastewatchCLI/LaunchCommand.swift | 4 +- Sources/PastewatchCore/DetectionRules.swift | 9 +++-- .../MutationAuthorization.swift | 9 +++-- Sources/PastewatchCore/ProxyServer.swift | 38 ++++--------------- Sources/PastewatchCore/Types.swift | 2 + .../PastewatchTests/DetectionRulesTests.swift | 7 +++- .../PastewatchTests/LaunchCommandTests.swift | 32 ++++++++++++++-- docs/agent-integration.md | 2 +- 9 files changed, 63 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 110be19..ec62c0d 100644 --- a/README.md +++ b/README.md @@ -48,8 +48,8 @@ operator-authorized secrets are replaced before supported traffic reaches the cl ## Why Pastewatch -- **Before-paste boundary** — secrets never leave your machine. Nightfall, Prisma, Check Point all intercept downstream. Pastewatch prevents upstream -- **MCP server for AI agents** — no other tool provides redacted read/write at the tool level. The agent works with placeholders, your secrets stay local +- **Before-paste boundary** — authorized secret matches are rewritten before supported traffic leaves. Nightfall, Prisma, Check Point all intercept downstream. Pastewatch prevents upstream +- **MCP server for AI agents** — no other tool provides redacted read/write at the tool level. Authorized matches become reversible placeholders while the secret map stays local - **Bash guard with deep parsing** — pipes, subshells, redirects, database CLIs, infra tools. Every shell command the agent runs is scanned before execution - **API proxy** — catches Anthropic-shaped traffic that bypasses hooks, including from subagents. Last line of defense before the network boundary (refuses unrecognized upstream shapes rather than forward them unscanned) - **Canary honeypots** — "prove it works" not "trust it works." Plant format-valid fake secrets and verify they're caught @@ -483,7 +483,7 @@ pastewatch-cli launch --audit-log /tmp/pw-audit.log -- claude ### MCP Server - Redacted Read/Write -AI coding agents send file contents to cloud APIs. If those files contain secrets, the secrets leave your machine. Pastewatch MCP solves this: **the agent works with placeholders, your secrets stay local.** +AI coding agents send file contents to cloud APIs. Pastewatch MCP replaces authorized secret matches with reversible placeholders while keeping the secret map local; advisory-only matches remain unchanged for operator review. ``` Your machine (local only) @@ -493,7 +493,7 @@ AI coding agents send file contents to cloud APIs. If those files contain secret │ read: scan + redact ──┼──────────────────────► Agent sees placeholders │ write: resolve local ◄┼────────────────────── Agent returns placeholders │ │ - │ secrets stay in RAM │ Secrets never leave. + │ secret map stays local│ Authorized matches leave only as placeholders. └────────────────────────┘ ``` @@ -538,7 +538,7 @@ The server holds mappings in memory for the session. Same file re-read returns t Logs timestamps, tool calls, file paths, and redaction counts. Never logs secret values. -**What this protects:** API keys, DB credentials, SSH keys, tokens, emails, IPs - secrets never leave your machine. **What this doesn't protect:** prompt content, code structure, business logic - these still reach the API. Pastewatch protects your keys; for protecting your ideas, use a local model. +**What this protects:** Intrinsically identifiable secrets, exact known values, and custom-rule matches are rewritten before supported API requests leave. Format-only credentials and DSNs are advisory-only by default and can still reach upstream unless exact-value or custom-rule evidence authorizes mutation. **What this doesn't protect:** prompt content, code structure, and business logic still reach the API; use a local model when those must remain local. See [docs/agent-safety.md](docs/agent-safety.md) for the full agent safety guide with setup for Claude Code, Cline, and Cursor. diff --git a/Sources/PastewatchCLI/LaunchCommand.swift b/Sources/PastewatchCLI/LaunchCommand.swift index e2e4670..8dd5762 100644 --- a/Sources/PastewatchCLI/LaunchCommand.swift +++ b/Sources/PastewatchCLI/LaunchCommand.swift @@ -218,7 +218,6 @@ struct Launch: ParsableCommand { try throwIfLaunchTerminationRequested() runStartupSweepIfNeeded() let config = PastewatchConfig.resolve() - _ = try requireValidProxyCustomRules(config) writeBufferModeWarningIfNeeded(config: config) let agentBinary = (command[0] as NSString).lastPathComponent @@ -244,6 +243,9 @@ struct Launch: ParsableCommand { return } + // WO-491: only routed launches start a proxy that consumes custom rules. + _ = try requireValidProxyCustomRules(config) + // Resolve our own binary to spawn the proxy subprocess let binaryPath = ProcessInfo.processInfo.arguments[0] diff --git a/Sources/PastewatchCore/DetectionRules.swift b/Sources/PastewatchCore/DetectionRules.swift index 334a5df..d753556 100644 --- a/Sources/PastewatchCore/DetectionRules.swift +++ b/Sources/PastewatchCore/DetectionRules.swift @@ -304,9 +304,10 @@ public struct DetectionRules { } // WO-462: https://developer.hashicorp.com/vault/docs/concepts/tokens - // Reviewed 2026-07-14. Vault documents six prefixes and a 24+ character suffix. + // Reviewed 2026-07-15. Modern hv* tokens use 24+ URL-safe characters; + // legacy one-letter tokens use exactly 24 base62 characters. if let regex = try? NSRegularExpression( - pattern: #"(? MutationPartition { - _ = site var authorized: [DetectedMatch] = [] var advisory: [DetectedMatch] = [] var belowThreshold: [DetectedMatch] = [] diff --git a/Sources/PastewatchCore/ProxyServer.swift b/Sources/PastewatchCore/ProxyServer.swift index 52b038f..eb71389 100644 --- a/Sources/PastewatchCore/ProxyServer.swift +++ b/Sources/PastewatchCore/ProxyServer.swift @@ -1372,45 +1372,23 @@ public final class ProxyServer { // WO-408/WO-413/WO-440: audit fail-closed refusals without repeating identical noise. private func logUnsupportedBodyShapeRefusal(path: String, reason: String) { - let safePath = auditSafePath(path) - let signature = "refused:\(safePath):\(reason)" - statsLock.lock() - let isRepeat = signature == lastRefusalLogSignature - lastRefusalLogSignature = signature - // WO-443/WO-448: an emitted refusal breaks every other audit dedup chain. - if !isRepeat { - lastRedactionLogSignatures.removeAll() - lastAdvisoryLogSignatures.removeAll() - lastModelIdentityAdvisorySignature = nil - } - statsLock.unlock() - guard !isRepeat else { return } - - let line = "[\(formatAuditTimestamp(Date()))] PROXY REFUSED unsupported upstream body shape in \(safePath) (\(reason))\n" - if !quietLog { - FileHandle.standardError.write(Data(line.utf8)) - } - if let logPath = auditLogPath { - logQueue.async { - if let handle = FileHandle(forWritingAtPath: logPath) { - handle.seekToEndOfFile() - handle.write(Data(line.utf8)) - handle.closeFile() - } else { - FileManager.default.createFile(atPath: logPath, contents: Data(line.utf8)) - } - } - } + logBodyRefusal(path: path, reason: reason, description: "unsupported upstream body shape") } // WO-478: malformed secret-container refusals are audited without recording // any marker payload or request-body bytes. private func logUnsafeBodyRefusal(path: String, reason: String) { + logBodyRefusal(path: path, reason: reason, description: "unsafe request body") + } + + // WO-494: refusal categories share one dedup and cross-chain reset state machine. + private func logBodyRefusal(path: String, reason: String, description: String) { let safePath = auditSafePath(path) let signature = "refused:\(safePath):\(reason)" statsLock.lock() let isRepeat = signature == lastRefusalLogSignature lastRefusalLogSignature = signature + // WO-443/WO-448: an emitted refusal breaks every other audit dedup chain. if !isRepeat { lastRedactionLogSignatures.removeAll() lastAdvisoryLogSignatures.removeAll() @@ -1419,7 +1397,7 @@ public final class ProxyServer { statsLock.unlock() guard !isRepeat else { return } - let line = "[\(formatAuditTimestamp(Date()))] PROXY REFUSED unsafe request body in \(safePath) (\(reason))\n" + let line = "[\(formatAuditTimestamp(Date()))] PROXY REFUSED \(description) in \(safePath) (\(reason))\n" if !quietLog { FileHandle.standardError.write(Data(line.utf8)) } diff --git a/Sources/PastewatchCore/Types.swift b/Sources/PastewatchCore/Types.swift index c54e133..45fae88 100644 --- a/Sources/PastewatchCore/Types.swift +++ b/Sources/PastewatchCore/Types.swift @@ -279,6 +279,8 @@ public struct DetectedMatch: Identifiable, Equatable { self.customSeverity = customSeverity self.advisory = advisory var sources = mutationAuthorizationSources ?? [] + // WO-488: type-level intrinsic formats authorize dedicated detector types; + // genericApiKey provider grammars attach the same source in DetectionRules. if advisory == nil && type.intrinsicMutationAuthorized { sources.insert(.intrinsicFormat) } diff --git a/Tests/PastewatchTests/DetectionRulesTests.swift b/Tests/PastewatchTests/DetectionRulesTests.swift index b94bf71..83387d0 100644 --- a/Tests/PastewatchTests/DetectionRulesTests.swift +++ b/Tests/PastewatchTests/DetectionRulesTests.swift @@ -1291,7 +1291,7 @@ final class DetectionRulesTests: XCTestCase { (.vaultToken, "hvs." + String(repeating: "A1", count: 12)), (.vaultToken, "hvb." + String(repeating: "B2", count: 12)), (.vaultToken, "hvr." + String(repeating: "C3", count: 12)), - (.vaultToken, "s." + String(repeating: "b2", count: 12)), + (.vaultToken, "s.iyNUgdDrn8sBHtdb9Vjfhk3n"), (.vaultToken, "b." + String(repeating: "c3", count: 12)), (.vaultToken, "r." + String(repeating: "d4", count: 12)), (.slackToken, slackStem + "b-1234567890-" + String(repeating: "Ab", count: 12)), @@ -1321,6 +1321,11 @@ final class DetectionRulesTests: XCTestCase { let nearMisses = [ "hvs.short", "prefixhvb." + String(repeating: "A", count: 24), + "s.formatMessageWithAllArgumentsProvidedHere", + "r.status_code_was_definitely_not_two_hundred_here", + "b.filesWithVeryLongDescriptiveNamesInAModuleHere", + "s." + String(repeating: "A", count: 25), + "b." + String(repeating: "B", count: 23), "xoxa-" + String(repeating: "B", count: 24), slackStem + "b-short", slackStem + "e." + slackStem + "a-1-" + String(repeating: "C", count: 24), diff --git a/Tests/PastewatchTests/LaunchCommandTests.swift b/Tests/PastewatchTests/LaunchCommandTests.swift index 8c94215..e5a1532 100644 --- a/Tests/PastewatchTests/LaunchCommandTests.swift +++ b/Tests/PastewatchTests/LaunchCommandTests.swift @@ -216,9 +216,8 @@ final class LaunchCommandTests: XCTestCase { XCTAssertEqual(result.stderr, "", "--quiet should suppress non-routed advisory stderr") } - // WO-473: launch must reject invalid protection configuration before - // starting either the proxy or even a non-routed agent process. - func testLaunchRejectsInvalidCustomRuleBeforeAgentStart() throws { + // WO-491: non-routed launches do not consume proxy custom rules. + func testNonRoutedLaunchIgnoresInvalidProxyCustomRule() throws { let fixture = try makeLaunchFixture() let agent = try writeEnvEchoAgent(named: "codex", in: fixture.cwd) let invalidPattern = "[" + "unclosed" @@ -235,8 +234,33 @@ final class LaunchCommandTests: XCTestCase { environment: fixture.environment ) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertTrue(result.stdout.contains("ANTHROPIC_BASE_URL=UNSET"), result.stdout) + XCTAssertFalse(result.stderr.contains("Broken rule"), result.stderr) + XCTAssertFalse(result.stderr.contains(invalidPattern), "diagnostic disclosed configured pattern") + XCTAssertFalse(result.stderr.contains("proxy listening"), result.stderr) + } + + // WO-473/WO-491: routed launches still fail before proxy startup when a rule is invalid. + func testRoutedLaunchRejectsInvalidCustomRuleBeforeProxyStart() throws { + let fixture = try makeLaunchFixture() + let agent = try writeEnvEchoAgent(named: "claude", in: fixture.cwd) + let invalidPattern = "[" + "unclosed" + var config = PastewatchConfig.defaultConfig + config.customRules = [CustomRuleConfig(name: "Broken rule", pattern: invalidPattern)] + try JSONEncoder().encode(config).write( + to: fixture.cwd.appendingPathComponent(".pastewatch.json"), + options: .atomic + ) + + let result = try runCLIProcess( + arguments: ["launch", "--no-startup-sweep", "--", agent.path], + cwd: fixture.cwd, + environment: fixture.environment + ) + XCTAssertEqual(result.status, 2, result.stderr) - XCTAssertEqual(result.stdout, "", "agent ran despite invalid custom rule") + XCTAssertEqual(result.stdout, "", "routed agent ran despite invalid custom rule") XCTAssertTrue(result.stderr.contains("Broken rule"), result.stderr) XCTAssertFalse(result.stderr.contains(invalidPattern), "diagnostic disclosed configured pattern") XCTAssertFalse(result.stderr.contains("proxy listening"), result.stderr) diff --git a/docs/agent-integration.md b/docs/agent-integration.md index 9aefc83..7aa0629 100644 --- a/docs/agent-integration.md +++ b/docs/agent-integration.md @@ -410,4 +410,4 @@ Once configured, the agent has access to: | `pastewatch_scan_diff` | Scan git diff for secrets in changed lines | | `pastewatch_inventory` | Generate secret posture report for a directory | -Secrets never leave your machine. Only placeholders reach the AI provider's API. +Intrinsically identifiable, exact-known, and custom-rule matches leave only as placeholders. Advisory-only matches remain visible so the operator can decide whether to authorize mutation. From e1d77f64da5ccf7deef7ebf8e50093b12dd5409b Mon Sep 17 00:00:00 2001 From: ppiankov <103106369+ppiankov@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:18:52 +0800 Subject: [PATCH 28/29] docs: align mutation guarantee diagram --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ec62c0d..e640300 100644 --- a/README.md +++ b/README.md @@ -493,7 +493,7 @@ AI coding agents send file contents to cloud APIs. Pastewatch MCP replaces autho │ read: scan + redact ──┼──────────────────────► Agent sees placeholders │ write: resolve local ◄┼────────────────────── Agent returns placeholders │ │ - │ secret map stays local│ Authorized matches leave only as placeholders. + │ mapping stays local │ Authorized matches leave only as placeholders. └────────────────────────┘ ``` From 70afb10b6e6ab4a440d6fccfc2f4c3d2400ea525 Mon Sep 17 00:00:00 2001 From: ppiankov <103106369+ppiankov@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:20:13 +0800 Subject: [PATCH 29/29] docs: name advisory token boundaries --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e640300..8b0df8a 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ All layers share the same detection engine — 30+ pattern types, deterministic Pastewatch rewrites your data only when it is **certain** the value is a secret. This is a hard rule, not a tuning knob: - **Mutated:** intrinsically identifiable secrets such as provider tokens, complete private keys, validated JWTs and cards; exact values supplied by a trusted local source; and patterns **you** approve with a custom rule. -- **Advisory only:** format-only DSN/JDBC URLs, generic credential assignments, XML credential-shaped text, and ambiguous detections such as emails, phone numbers, IPs, hostnames, file paths, and UUIDs. Pastewatch reports these off-band without rewriting them unless exact-value or custom-rule evidence authorizes mutation. +- **Advisory only:** format-only DSN/JDBC URLs, broad generic API-key prefixes (`sk-`, `pk-`, `api_`), generic credential assignments, XML credential-shaped text, and ambiguous detections such as emails, phone numbers, IPs, hostnames, file paths, and UUIDs. Pastewatch reports these off-band without rewriting them unless exact-value or custom-rule evidence authorizes mutation. Exact sourced-provider grammars, including GitHub classic tokens and Stripe keys, remain intrinsically authorized. - **`--severity` controls how much it nags, never what it rewrites.** Lowering severity surfaces more advisories; it never widens the set of values that get mutated. The result: false negatives are preferred over false positives, and mutation false positives are driven to ~zero by construction. Pastewatch never breaks a working agent response to redact something it only *might* be.