srdusr
aboutsummaryrefslogtreecommitdiffstats

mitmux - Intercepting Proxy TUI (Burp/Caido replacement)

Overview

Daily-driver intercepting proxy for manual pentest work, terminal-based. Prior art to read before writing code: Cruster (Rust, built on hudsucker) - same problem, worth studying even though this build is Go.

Stack

  • Language: Go - memory safety on hostile input matters here more than in the other projects, since this parses attacker-adjacent traffic
  • TLS interception: Go's own crypto/tls + a CA cert generator (analogous to rcgen) for per-domain leaf certs
  • Proxy core: net/http + manual CONNECT handling, or a MITM proxy library if one fits without fighting Go's aggressive header normalization. Upstream requests are round-tripped manually (write the request, read the response off the same connection) rather than through http.Transport - Transport's automatic HTTP/2 dispatch keys off a literal *tls.Conn type assertion on the dialed connection, which a raw-byte-capturing wrapper around that connection defeats (found by testing: it silently parsed HTTP/2 framing as HTTP/1.1).
  • Storage: SQLite in WAL mode - blob columns for raw request/response bytes, FTS5 index for search across bodies
  • UI: Bubble Tea + Lipgloss (TUI), same family as the packet analyzer's Go sibling if that ever gets built

Architecture sketch (important - don't skip this)

  • Split proxy engine from TUI. Headless daemon owns the listening socket and the DB; TUI is a client over a Unix socket. The proxy keeps running when the UI restarts, and a web UI or CLI scanner can be bolted on later without touching the engine.
  • Store raw bytes as the source of truth. Parse into a display view, never re-serialize for storage - request smuggling, header injection, and parser-differential bugs depend on the original malformed framing surviving. For Repeater specifically, write requests as raw bytes over the socket rather than through a normalizing HTTP client.

Build order

  1. Proxy + CA cert generation + plaintext HTTP passthrough
  2. TLS interception (per-host cert generation, install CA)
  3. History view (SQLite storage, raw bytes preserved) in the TUI
  4. Repeater (raw-byte send/resend, the feature used daily)
  5. Search/filter (FTS5)
  6. Match-and-replace rules
  7. Intruder-equivalent (last, optional)

Open questions

  • HTTP/2: handle natively (decided) - full fidelity over MITM'd connections rather than downgrading to HTTP/1.1. Adds complexity to CONNECT handling, stream framing, and step 3 storage (multiplexed streams over one connection need per-stream request/response boundaries, not just per-connection ones).
  • CA install UX per OS (Linux/macOS/Windows trust stores)
  • WebSocket interception landed as a later addition, not v1 - see its own section below
  • Step 6 match-and-replace now covers headers and bodies. Body rules materialize the body into memory (bounded by the same maxCaptureBytes cap as history capture) rather than streaming it straight through - the opposite of the normal path, so it's only paid when a body rule is actually configured. A body over the cap is passed through byte-exact and unmodified rather than partially rewritten. Response Content-Length is recomputed explicitly when a rule changes body length, since http.ResponseWriter (unlike http.Request.Write) doesn't derive it automatically. Still single-line-text-field limited in the TUI (bubbles/textinput can't hold a literal CRLF), so injecting a brand-new header line via the form isn't possible yet - only rewriting/removing existing ones. The underlying engine (rules.ApplyHeaders) already supports arbitrary text-block edits; it's specifically the form UI that's constrained.
  • Step 7 (Intruder-equivalent) now covers all four of Burp's attack modes (proxy.AttackMode: Sniper, BatteringRam, Pitchfork, ClusterBomb). Sniper and BatteringRam only ever need one shared payload set; Pitchfork and ClusterBomb are inherently per-position, so they need one set per §marked§ position - the request-generation logic (intrudeValues) is pure and side-effect free specifically so the request count (positions × payloads for Sniper, a product for ClusterBomb) can be validated against the 1000-request cap before anything is dispatched, and so it's unit-testable without a live target. The TUI reuses the single Payloads pane for per-position sets too, split by a --- delimiter line, rather than adding a multi-widget payload-set editor. Sequential sending only (no concurrency). Reuses the Repeater send primitive (proxy.Server.sendRaw) directly - an attack is just that primitive run in a loop with generated bytes - and results land in the same history table tagged source="intruder", same as Repeater's source="repeater", rather than a separate results store.

Post-build-order: Burp/ZAP/Caido parity pass

Build order 1-7 is done. Researched what those three actually offer (features and basic UI/UX) and triaged the gap into "should build soon" / "worth considering" / "skip" - see commit history for the full list; tracking what's shipped vs. deferred here.

Shipped: vi-modal editing for the raw request textareas (table and viewport already had vi nav by default - this was specifically about textarea/textinput, which don't); a persistent status bar and a '?' keybinding reference; display-only response JSON pretty-printing; structured search filters (status:, source:, flagged:) alongside the existing FTS5 text search; a flagged marker (★) for "revisit this" - deliberately simpler than full free-text notes/comments, which would need their own text-input overlay for comparatively modest extra value over a boolean; noted as a real follow-up, not dropped silently; a Comparer tool - mark an entry with 'c' (from history list or detail view), 'c' again on a different entry opens a unified diff (git-diff style, colored) of either side's request or response. Unified rather than Burp's side-by-side: a two-column layout fights terminal width for anything but a narrow window, and unified reuses the same scrollable- viewport pattern already used everywhere else in the TUI. CRLF is normalized to LF before diffing (display-only, same reasoning as the JSON pretty-printer) so an HTTP/1.1 exact capture doesn't show every line as changed from an invisible trailing \r; a standalone Decoder tool ('d') - URL/Base64/Hex/HTML encode and decode, live output as you type, tab to cycle transforms. Deliberately single-transform, not chained/pipelined like Burp's Decoder - v1 scope, and pipeline-building UI is real added complexity for a feature that's already useful without it. Base64 decode tries standard/URL-safe/padded/unpadded variants in turn rather than making the user pick, since real pasted data is as likely to be one as the other. URL encode/decode uses strict RFC 3986 percent-encoding (space <-> %20), not Go's url.QueryEscape's form- encoding behavior (space <-> '+'), since "URL encode" for a pentester almost always means the former.

Shipped since: a standalone Decoder tool ('d') - see above; multiple concurrent Repeater tabs - 'r' from the history list or detail view now opens a NEW tab rather than overwriting whatever was already open, ]/[ switch tabs, ctrl+w closes the active one (all three gated to normal mode, so they're inert while typing - [/] are common JSON body characters and ctrl+w is a textarea binding for delete-word- backward that must still work while composing a request). Each tab owns its own request buffer, response view, and send-in-flight state; a slow send whose result lands after the user has switched away still updates the correct tab (send results carry the tab index they belong to), and the status line/response pane it's shown in only updates live if that tab is still the one on screen.

Shipped since: Intruder payload processing and grep-match/grep-extract. Payload processing - an optional case rule (upper/lower) and an optional encode rule (URL/Base64/Hex/HTML), cycled with c/e - is applied client-side to each payload line before it ever crosses the IPC socket, case first then encode (encoding an already-case-folded value is safe; the reverse would corrupt e.g. Base64 padding), since it's a pure string transform with no proxy-side state involved and reuses the Decoder's own urlEncodeAll. Grep-match/grep-extract are optional Go regexps (m/v to edit, both gated to normal mode and both revert-on-esc / validate-on-enter the same way the history list's / search box works), evaluated server-side in internal/ipc/server.go's "intrude" handler against each result's actual response bytes - chosen over a client-side implementation because the daemon already has entry. ResponseRaw in hand right where the result is built, and Burp's own grep options work the same way (matched against the real response, not a client-refetched copy). Grep-match flags a result (shown as a Match column); grep-extract captures the first submatch (or the whole match if the pattern has no capturing group) into an Extract column. Both are configured once before ctrl+r starts an attack and apply for that run only - matching Burp, which doesn't retroactively re-grep already-fired requests if you change the options mid-attack.

Shipped since: CA install UX per OS (mitmuxd -install-ca) - generates the CA if needed, prints copy-pasteable install steps for the detected platform, and exits without starting the proxy. Deliberately instructions-only, never auto-executing: trust-store tooling varies enough across Linux distros that guessing wrong and running the wrong command unattended is worse than asking, and installing a root CA is a system-wide trust change that affects every TLS connection on the machine, not just mitmux's own traffic - the user running the printed command themselves keeps them in control of that. On Linux, detects trust (p11-kit - Arch, and Fedora also ships it)/update-ca-trust (RHEL/Fedora/CentOS)/update-ca-certificates (Debian/Ubuntu/Gentoo) via PATH lookup and picks whichever is actually present, plus separate certutil (NSS) instructions for Firefox/Chrome's own certificate store, which doesn't always follow the system trust store on Linux. macOS (security add-trusted-cert) and Windows (certutil -addstore / Import-Certificate) instructions are implemented but, unlike the Linux path, not verified live - no macOS/Windows machine was available to test against; only the command text itself (sourced from each platform's standard, documented tooling) is confirmed correct by inspection.

Shipped since: multiple proxy listeners and upstream proxy chaining - the last two items from the original "worth considering" list.

Multiple listeners: -listen takes a comma-separated address list (-listen "127.0.0.1:8080,127.0.0.1:8081"); all bound addresses share the same handler, history store, CA and rules - one logical proxy reachable on more than one address/port, not several independent proxies in one process. Every address is bound up front before any of them start serving, so a bad address fails startup immediately instead of leaving the daemon partially listening.

Upstream proxy chaining: -upstream-proxy host:port (optional http:// prefix, stripped) routes every outbound connection through another HTTP CONNECT proxy instead of dialing origins directly - chaining mitmux into Burp, a corporate proxy, or any other CONNECT-speaking proxy. A socks5://[user:pass@]host:port prefix routes through a SOCKS5 proxy instead (Tor, ssh -D, any other SOCKS5 relay), via golang.org/x/net/proxy - already an indirect dependency through http2, so no new module. parseSOCKS5 is the single place that decides which kind of upstream a given UpstreamProxy string names; dialViaProxy (CONNECT/TLS path) and dialUpstreamPlain (plain-HTTP path) both check it first and fall through to the existing HTTP-proxy behavior otherwise. SOCKS5 is transport-level and protocol-agnostic - the tunnel it hands back behaves exactly like a direct connection to the target, so unlike HTTP-proxy chaining it needs no absolute-form request adjustment on the plain-HTTP path either. For the CONNECT/HTTPS path, chaining is transparent below the tunnel: once the CONNECT handshake to the upstream proxy succeeds, TLS and the request/response on top of it are identical to a direct connection, so no other code needed to change. The plain-HTTP path is different: an absolute-form request line ("GET http://host/path HTTP/1.1") has to be sent to the upstream proxy instead of origin-form, so roundTripH1 gained a proxyForm parameter and forward() selects it based on whether the request is plain HTTP and an upstream proxy is configured.

Chaining into another intercepting/MITM proxy (including another mitmuxd instance) will fail TLS verification unless that proxy's own CA is separately trusted - expected, not a mitmux-specific gap: the upstream MITM terminates and re-signs the connection with its own CA, which mitmux's outbound TLS client (verifying against the system root store) has no reason to trust. Confirmed live: chaining through a genuine passthrough CONNECT proxy (tunnels raw bytes, no MITM) works correctly for both plain HTTP and HTTPS; chaining through a second mitmuxd instance correctly fails with a clear "certificate signed by unknown authority" error recorded in history, rather than hanging or crashing. SOCKS5 chaining verified the same way, live, against a real standalone SOCKS5 relay process (not an in-test mock): both a plain HTTP request and an HTTPS request through mitmux were confirmed - via the relay's own log, not just mitmux's success response - to have actually traversed it end to end.

This closes every item from the original "worth considering" list.

Post-audit hardening and history management

A hands-on robustness audit (two parallel passes, backend and frontend, actually driving the daemon/TUI against adversarial input rather than reading code - "run it, don't read it") found and fixed six real bugs: raw ANSI/control-character injection from captured traffic reaching the operator's actual terminal (severe - confirmed a malicious Host value changed the real tmux pane title); a table-cursor desync that left enter/r/i/f/c inert on a live-captured entry until an unrelated navigation keypress; Intruder silently hanging 60s per payload on body-parameter fuzzing because Content-Length was never recalculated after marker substitution; match-and-replace rules accepting an invalid regex with zero validation or feedback, silently never firing; captures that hit the 10 MiB cap being marked "exact" anyway, hiding data loss from exactly the kind of investigation that needs the tail of a large body; and no timeouts anywhere, so a slow-loris connection or a client that completed CONNECT and never sent a TLS ClientHello held a connection and goroutine open forever. See commit history for full detail on each - every fix was verified against the actual failure mode, not just code-reviewed.

Also added: history deletion. Store had full CRUD for rules but no way to delete or prune history - it only ever grew, with no way to remove an accidental capture or start fresh for a new engagement short of manually deleting the DB file outside the tool. x deletes the selected entry, X clears the entire database (explicitly not scoped to an active search filter - the confirmation always states the true total count, since understating it would make the prompt itself misleading about what's about to happen). Both gated behind a y/n confirmation: a small reusable confirmPrompt/confirmYes pattern in the TUI model, checked first in the history list's key handling, so any key other than y/Y safely cancels rather than falling through to whatever that key normally does.

Shipped since: single-entry export. e from Detail view writes the selected entry's raw request and response bytes to a plain-text file - a modal path-prompt (same pattern as the Intruder grep-match/extract edit buffers: enter writes and confirms, esc cancels), prefilled with a sensible default filename. Deliberately plain text, not a structured format: for "attach this to a report" or "grep it later," the raw bytes as text are the point, matching this tool's own raw-bytes-first philosophy rather than reformatting them into something else. Each side is annotated when it isn't a wire-exact capture (reconstructed vs. truncated, matching the Detail view's own labels) so the exported file carries the same trust information the UI already shows, not a blanker claim.

Shipped since: bulk export. E from the history list exports the current view (respecting an active search filter - explicitly the filtered set, not always everything, unlike X clear-all which is deliberately the opposite) as a HAR 1.2 file, chosen specifically for interop: DevTools, Burp, Postman, and others can all import it, which a mitmux-specific format couldn't do. Building it means parsing each entry's raw request/response bytes back into structured HAR fields (method, url, headers, status, body) via the same net/http parsing the capture path and prettyResponse already use - reused, not reimplemented. A binary body is base64-encoded (HAR's "encoding" field) rather than passed through as a JSON string, which would silently corrupt it: encoding/json replaces invalid UTF-8 with U+FFFD by default, exactly the failure mode that would quietly corrupt an exported image or protobuf body with no error anywhere. An entry that fails to fetch (daemon round trip) or parse (a deliberately malformed Repeater request, say) is skipped rather than aborting the whole export - the status line reports how many, so a partial export is visible, not silent.

Shipped since: target scope. s from the history list opens scope management - add/toggle/delete rules matching a host by substring (case-insensitive, so "example.com" matches "www.example.com" and "api.example.com" too, covering "this domain and its subdomains" without a separate wildcard syntax) or by regex, mirroring the same Match-text-or-regex toggle match-and-replace rules already use for one consistent mental model. No rules configured (or none enabled) means everything is recorded - today's behavior before scope existed at all, unchanged, so a fresh install or a user who never opens the scope view keeps recording everything rather than silently nothing.

Scope only filters what gets recorded, not what gets proxied: an out-of-scope request still reaches its destination and its response still reaches the client completely normally (see internal/proxy's forward() - the response is already written to the client by the time the scope check runs; skipping the record step only skips storage). This was a deliberate choice over blocking out-of-scope traffic outright, which would be a materially different, much riskier feature - an access-control mechanism, not a noise filter, and a wrong scope pattern could silently break the very traffic the user is trying to test. Repeater and Intruder deliberately bypass the scope check entirely (recordRaw, a separate code path from the passive-capture record()): a user explicitly resending or fuzzing a specific request wants to see the result regardless of scope, which exists to cut passive-capture noise (CDNs, analytics, trackers, unrelated third-party hosts), not to second-guess a deliberate action. Verified live: added a substring scope rule for one host, confirmed a request to a non-matching host still proxied successfully (200 response reached the client) but was never recorded, confirmed the matching host's requests were recorded, confirmed a Repeater resend of the excluded host WAS recorded despite being out of scope, confirmed toggling the rule off resumed recording everything, and confirmed both the substring and regex pattern forms save and match correctly.

Shipped since: CSV export and copy-as-curl, both extending the existing export system by dispatching on the file extension the user types rather than adding a separate format-selection control - ".har" (the existing default) or ".csv" for bulk export from the history list, ".txt" (the existing default) or ".sh"/".curl" for single-entry export from Detail view. CSV is deliberately a lighter, faster path than HAR: a summary table (id/method/host/path/status/sizes/timing/flag/source) built straight from the already-loaded Summary rows, no per-entry fetch from the daemon needed, matching Burp's own "export as CSV" being a listing rather than a full-fidelity capture - HAR already covers that. Copy-as-curl parses the raw request (same net/http parsing used everywhere else in this codebase) and re-serializes it as a runnable curl command line rather than another copy of the raw bytes, which the plain-text format already gives you; verified by actually executing a generated command against the real target and confirming the response matched the original.

CSV export required one more thing HAR didn't: guarding against CSV/ formula injection. Method, host, path, and error all ultimately trace back to a request line or Host header - content this tool exists specifically to inspect from potentially hostile traffic - and a field starting with =, +, -, @, tab, or CR is a formula to Excel/LibreOffice/ Sheets when the exported file is later opened. csvSafe prefixes any such field with a single quote, the standard mitigation (OWASP's own guidance), so every affected spreadsheet application treats it as literal text instead. This is the same class of bug as the terminal- injection fix from the robustness audit, just for a different output format - captured content controlling the tool that later processes it, rather than the terminal that renders it.

Shipped since: import. I from the history list reads a HAR file and inserts its entries into history, tagged source="import" (so source:import finds them, same as source:repeater/source:intruder already do). This closes the interop loop HAR export opened: traffic can now move both directions between mitmux and any other HAR-producing tool (browser DevTools, Burp, Postman), not just out.

The reverse conversion (HAR entry -> raw HTTP/1.1 bytes) is the mirror image of HAR export's harEntryFromDetail, deliberately written to tolerate a HAR file that didn't come from mitmux at all - lowercase header names, HTTP/2 in httpVersion, missing optional fields like postData, a redirectURL nobody bothered filling in. Content-Encoding and Transfer-Encoding headers are stripped from the reconstructed response before writing it (HAR's content.text is already decoded per spec - re-emitting those headers would describe framing the body no longer has, breaking any client that tried to decode it again), and a Content-Length is computed if the HAR didn't carry a consistent one. Imported entries are always request_exact=false/response_exact=false - reconstructed from structured HAR fields, same situation an HTTP/2 capture is already in, never claiming to be the literal bytes that were on the wire for the original request. An entry that fails to convert (an unparseable URL, say) is skipped rather than failing the whole import, same reasoning as export's own skip-and-continue.

Verified live: exported real captured traffic to HAR, cleared history entirely, imported the same file back and got both entries back with correct content (byte-different from the original - headers get reordered/reformatted through the round trip - but semantically identical, and correctly labeled "reconstructed" rather than falsely claiming "exact"); separately hand-built a HAR mimicking a real Chrome DevTools export (lowercase headers, HTTP/2, a base64-encoded binary PNG body, several optional fields omitted) and confirmed it imports cleanly with the binary body correctly decoded (PNG magic bytes verified byte-for-byte); confirmed a missing file and invalid JSON both fail with a clear error and no crash, history left untouched.

This closes every item from the expanded "worth considering" list.

Skipped deliberately (from the research, matches this tool's stated scope): active/passive vulnerability scanning, plugin marketplace, Collaborator/OAST, team collaboration, CI integration, invisible/non-proxy-aware proxying. Client (mutual-TLS) certificates were later added - see below.

Client (mutual-TLS) certificates

internal/clientcert stores cert/key pairs matched to hosts by the same substring-or-regex pattern model as scope.Rule (internal/scope) - one consistent mental model across every "which rule applies to this host" decision in the tool. A match is looked up in proxy.go's handleConnect (live proxied HTTPS) and repeat.go's dialForRepeat (Repeater/Intruder resends), both funneling through Server.clientCertFor, and passed into the outbound tls.Config's Certificates field when non-nil. Deliberately add-only in the TUI, no edit-in-place, same reasoning as scope: delete and re-add covers changing anything, and it's a rarely-touched, low-cardinality list. The TUI form takes file paths and reads them once at save time - the PEM content itself, not the path, is what's stored, so a cert keeps working even if the original file later moves.

Licensing, packaging, and browser/mobile support

Licensed GPL-3.0 (see LICENSE) - deliberate for a security tool specifically: keeps derivatives open, a common and well-regarded choice in that community, and doesn't foreclose the author dual-licensing the code commercially later (remains available as sole copyright holder, independent of the public license) or relicensing outright in the future (unconstrained for as long as the codebase has no outside contributors - the harder case only starts once other people's copyrighted changes are in it).

Added a -version flag to both binaries (internal/version, set via -ldflags at build time, defaulting to "dev" otherwise) and a Makefile (make build/make test/make release/make install). Cross-platform support turned out to already be ~95% there by accident of earlier choices - pure-Go SQLite (no CGO), os.UserConfigDir() instead of a hardcoded XDG path, nothing Linux-specific anywhere in the codebase - so making it explicit was verification and packaging work, not a rewrite: make release was run for real and produced correctly- formatted binaries (confirmed with file, not just an exit code) for linux/darwin/windows/freebsd across amd64+arm64 where applicable, all CGO_ENABLED=0. Linux remains the only platform actually run during development, though - macOS/Windows/FreeBSD compile clean and pass go vet but haven't touched real hardware, documented honestly as such in the README's Platforms section rather than as a tested claim.

CA certificate distribution for browsers/mobile: mitmuxd now recognizes the magic hostname mitmux.cert on its plain-HTTP proxy path and serves its own CA certificate as a download (internal/proxy/proxy.go's serveCACert/isCertDownloadHost) - http://mitmux.cert/ from any client already configured to proxy through mitmux gets the cert with Content-Type: application/x-x509-ca-cert, which triggers iOS/Android's native "install this certificate" prompt directly. Same idea as mitmproxy's own http://mitm.it/, arrived at independently rather than reusing their domain - .cert isn't a registered TLD, so it can never collide with a real site. Deliberately HTTP-only: fetching it over HTTPS would require the client to already trust mitmux's CA to MITM that connection, the exact chicken-and-egg problem this exists to solve. This matters most for mobile devices, which otherwise have no convenient way to get a certificate file onto the device at all short of emailing it to yourself or similar. Verified live: fetched http://mitmux.cert/ through a real running proxy, confirmed the downloaded bytes are byte-identical to the actual ca.pem on disk, confirmed a request with an explicit port and path still matches, confirmed normal proxying to an unrelated host is unaffected, and confirmed the cert-download request itself never gets recorded to history (it's answered before record() is ever reached).

Any standard proxy-switcher extension (FoxyProxy, etc.) or a phone/ tablet's own Wi-Fi proxy setting already works with mitmux exactly like it would with Burp/ZAP/Caido - mitmux is a normal forward proxy speaking the standard protocol, nothing proxy-switcher-specific to support. No code needed here, just documented clearly in the README's Quick start (previously this was implied but never actually spelled out for a phone/tablet setup, which is a real, common daily workflow this tool hadn't explicitly walked through before).

Considered building an actual in-house browser (a GUI) and decided against it: a bundled GUI browser is a different, much larger project (a Chromium/WebView embed and all the maintenance that implies), works against this tool's own positioning as a terminal-native daily driver, and duplicates what a real browser already does far better. The useful part of "in-house browser" isn't rendering - it's zero-friction setup: a browser already pointed at the proxy with the CA one click away, without touching the user's real browser profile. cmd/mitmux/browser.go delivers exactly that instead: -launch-browser=chrome|firefox|auto finds an installed browser (PATH first, then common per-OS install locations for the cases PATH won't have - an unregistered macOS .app bundle or a Windows Program Files install), spins up a brand new throwaway profile (os.MkdirTemp, never reused, never cleaned up explicitly - that's what "throwaway" means: a fresh identity every launch, not something this code should delete out from under a still- open browser), configures it to proxy through the daemon, and opens straight to http://mitmux.cert/. Chrome takes --proxy-server as a flag; Firefox has none, so its profile gets a generated user.js setting network.proxy.* prefs instead - the only non-interactive way to configure it. Verified against this machine's real, installed Chrome and Firefox: binary discovery resolves both correctly, auto prefers chrome-family when both are present, and the generated Firefox prefs file is well-formed (integer port pref unquoted, matching what Firefox expects). Actually spawning a visible browser window wasn't done as part of automated verification - that's a live GUI popping up on whoever's running it, not something to trigger without them asking for it in the moment; the -launch-browser flag is there to try by hand.

Mouse support

Explicitly requested - this is a real terminal app meant to work in any terminal (not tmux-only; the name is a naming convention, not a runtime dependency), and should be genuinely mouse-driven, not keyboard-only. Enabled via tea.WithMouseCellMotion() (SGR mouse mode, the same protocol nvim and most modern TUI apps use) - coexists with tmux's own mouse mode the same way it would for any other terminal app, no mitmux-specific code needed for that part; documented clearly instead (README's new Mouse section) since it does need tmux's own set -g mouse on to forward events at all.

Scope was decided by a real, verified library constraint, not convenience: bubbles/table exposes no way to learn its own scroll offset (confirmed by reading its source - no YOffset accessor, and the rendered window is computed from unexported fields via a second, internal layer of viewport scrolling on top of that). Mapping a click's screen coordinates to a specific table row therefore can't be done without reaching into that library's private internals, which this deliberately doesn't do - silently selecting the wrong row on a misjudged click is worse than not supporting precise click-to-row at all. What's actually shipped, chosen to be the reliable subset:

  • Wheel scroll everywhere there's something to scroll - tables via MoveUp/MoveDown (exported, no scroll-state assumptions needed), viewports via their own native wheel handling (bubbles/viewport already has this, just needed tea.MouseMsg routed to it, which nothing in the codebase was doing yet), and vi-modal text editors via new viTextarea.ScrollUp/ScrollDown (bubbles/textarea has zero native mouse handling at all, confirmed the same way - the value here is feeding it as repeated up/down keypresses through the same tested movement path h/j/k/l already use, not reimplementing cursor math).
  • Right-click opens a context menu - a horizontal strip taking over the status/help line (cmd/mitmux/contextmenu.go), the same "replace the bottom of the screen" pattern confirmPrompt and the export/import prompts already use, rather than a floating popup positioned at the click. That's also a deliberate simplification: lipgloss/bubbletea have no compositor for splicing an ANSI-styled overlay into an arbitrary screen position, and building one just for this would be a lot of new, fragile machinery for what's fundamentally a nice-to-have. Wired into history (view/repeater/intruder/flag-unflag/delete), rules (edit/enable-disable/delete), and scope (enable-disable/delete) - every list-based view. Menu items ARE reliably clickable, unlike table rows: the menu renders its own strip, so every item's on-screen width is fully known rather than hidden behind unexported scroll state. Navigable by mouse click, or j/k/arrows + enter; esc or right-clicking again dismisses without acting.

Deferred, not silently dropped: click-to-select-a-specific-different- row in a table (blocked by the same scroll-offset limitation above), and click-to-switch-pane-focus in Repeater/Intruder (would need Y-coordinate matching against the exact same split-point math WindowSizeMsg already computes - tractable, just not done yet, lower priority than what shipped since keyboard tab already covers it).

Verified live in tmux by injecting real SGR mouse escape sequences directly into the pane (tmux send-keys -l with hand-built ESC [ < Cb;Cx;Cy M/m sequences - there's no built-in "synthesize a mouse click" primitive in tmux's own tooling) against a running daemon with real captured entries: wheel-down/wheel-up on the history table correctly moved the selected-row highlight (confirmed via ANSI-aware capture, not just "no crash" - the actual background-color-highlighted row changed), right-click opened the menu with the right actions, clicking directly on a specific menu item (computed its expected X position from the same label-width logic contextMenuAt uses) correctly triggered that exact action - confirmed by clicking "delete" and seeing the correct entry's ID in the resulting confirmation prompt - keyboard navigation (j) inside the menu moved the highlight correctly, esc and a second right-click both dismissed cleanly without side effects, wheel events in Repeater's text pane and Detail's viewport caused no crash and left vi-mode state intact, an empty rules table's right-click correctly no-opped (no crash, no menu), and adding a real rule then right-clicking it and clicking "disable" correctly toggled it off (confirmed via the rendered checkmark disappearing).

WebSocket interception

The last of the "known limitations" list. A WebSocket connection stops being one-shot HTTP request/response the instant a 101 Switching Protocols comes back - it becomes a long-lived, bidirectional, message-framed (RFC 6455) stream instead, which forward()'s normal write-response-then-record flow has no way to represent. Scoped to HTTP/1.1 client legs only: an HTTP/2 client connection can't be hijacked for raw post-response access the way HTTP/1.1 can, and RFC 8441 (WebSocket-over-HTTP/2 Extended CONNECT) is rare enough in practice - browsers open a dedicated HTTP/1.1 connection for a WebSocket even when the surrounding page is HTTP/2 - that excluding it isn't a real-world gap.

internal/proxy/websocket.go holds a minimal RFC 6455 frame codec (relayWSFrame, pumpWS) - deliberately relay-first: it decodes a frame's opcode and payload for capture while writing the exact same raw bytes it read to the other side, unmodified. This is capture, not tampering, matching the rest of the codebase's "raw bytes are the source of truth" stance; there's no live WS message editing. One row per frame, not per reassembled logical message - RFC 6455 lets a message span several frames (opcode 0x0 continuation, FIN unset until the last one), which isn't reassembled here. Real-world WebSocket traffic is overwhelmingly single-frame; buffering an unbounded number of pending fragmented messages per connection to handle the rare case wasn't a trade worth making.

forward() branches right after the response comes back: a 101 matching isWebSocketUpgradeResponse and an HTTP/1.1-negotiated connection hands off to handleWebSocketUpgrade instead of the normal body-copy path - none of match-and-replace, body-rule capture, or stripHopByHop's usual header stripping make sense for a protocol upgrade. handleWebSocketUpgrade hijacks the client connection, writes the 101 response's raw bytes through unmodified, records the upgrade request/response pair to history exactly like a normal exchange, then relays frames bidirectionally - each captured into a new ws_messages table (entry_id, direction, opcode, payload), reachable from the TUI's detail view via w.

Two real bugs surfaced only by actually driving a WebSocket connection through a running daemon, not by reading the code - exactly the "run it, don't read it" pattern that's caught every prior bug like this in this project:

  • stripHopByHop was already stripping Connection and Upgrade from the outgoing request - correct for an ordinary request per RFC 7230 (they're hop-by-hop headers), catastrophic for one asking to upgrade, since those two headers are the upgrade request. Every WebSocket connection attempt silently became a 426 before this was caught: the origin never saw the upgrade at all. isWebSocketUpgradeRequest + stripHopByHopKeepingUpgrade fix it - every other hop-by-hop header still stripped, just not these two, and only for a request that's actually asking to upgrade.
  • The relay originally ended the whole handler the instant either direction saw a close frame pass through. In practice this meant: a client sends a close frame, that direction's pump relays it upstream and returns, the handler tears the connection down immediately - before the origin's own close-frame reply (which it sends after receiving the client's) can be read and relayed back. The test client's close handshake failed with an abrupt EOF instead of a close frame. Fixed by waiting for the first direction to stop, then giving the other one a bounded 5-second window to finish its own close sequence before forcing both connections closed via a deadline - long enough for a well-behaved peer's reply to get through, bounded so a peer that never replies can't leak the goroutine indefinitely.

Verified live end to end against real servers, not mocks, on both paths a real client actually uses:

  • ws:// (plain HTTP forward-proxying): a Python websockets-based echo server, and a hand-built raw-socket test client speaking RFC 6455 directly (masked client frames, unmasked server frames, the 16-bit extended-length form for a 500-byte message, a binary message, and a full close handshake) sent through mitmux via an absolute-form GET http://host/ HTTP/1.1 - exactly how a real proxy-configured WebSocket client negotiates one. Every message round-tripped correctly and the close handshake completed with both directions' close frames present.
  • wss:// (CONNECT-tunneled, TLS-intercepted): same echo server behind TLS (a throwaway self-signed cert, trusted for the test via a process-scoped SSL_CERT_FILE, never touching the real system trust store), reached through mitmux's own CONNECT handling and MITM leaf certificate. Confirmed the handshake, a message round-trip, and the close handshake all work identically over the hijacked *tls.Conn the CONNECT path hands back - this is the path real browsers actually use for wss://, so this was worth checking separately from the plain-HTTP path rather than assuming it'd behave the same.

In both cases, go run ./cmd/livetest (a throwaway program, deleted after use - never part of the build) confirmed the captured messages in ws_messages via ipc.Client.ListWSMessages, matching what the test client actually sent and received, correctly attributed to direction and opcode.

Sorting and highlighting

Filtering already existed (FTS5 search plus status:/source:/ flagged: and column filters - see Search above); sorting and highlighting didn't, at all, until now.

Sorting is client-side, applied on top of whatever order List/Search already returned (newest-first, or FTS5 relevance) - o cycles the sort column (the default "captured" - no override - then status, size, time taken, method, host, path), O reverses it. refreshTable() doesn't just re-render sorted rows, it reorders m.entries itself to match: every "act on the selected row" key handler (enter/r/i/f/c/x, and the mouse equivalents) reads m.table.Cursor() and indexes straight into m.entries at that position, with no indirection layer. If the table's rendered order and m.entries's order ever diverged, the highlighted row and the entry an action actually targets would silently disagree. Keeping them identical sidesteps that whole bug class rather than updating every one of those call sites to go through a lookup.

Highlighting has two parts, and they needed genuinely different solutions because they render through different paths:

  • JSON syntax highlighting (cmd/mitmux/jsoncolor.go): walks the token stream via json.Decoder.Token() with an explicit stack rather than recursive calls, so depth is bounded by available memory rather than Go's call stack for deeply nested attacker-controlled JSON. Every string value and object key gets re-escaped via json.Marshal before being written - which is also the entire safety argument for not running the colorized output through sanitizeControl afterward the way every other raw-text view in this codebase does: JSON's own encoding rules forbid a literal control character (ESC included) in a string, so re-marshaling one neutralizes it as a side effect of just producing valid JSON text. Running the already-colored output through sanitizeControl afterward would instead corrupt the ANSI codes this function adds - it treats ESC like any other control byte, correctly, for content that hasn't been through this treatment. prettyResponse sanitizes the header/status block (still server-controlled, still raw text) separately from the colorized body for exactly this reason. This path renders into a viewport, a plain scrolling text pane with no fixed-width cell model, so ANSI content survives untouched.
  • Status-code color-coding (2xx green through 5xx red, Burp/ Caido's own convention) does not live in the history or Intruder- results tables, despite an initial attempt to put it there. Confirmed live, not guessed: bubbles/table v1.0.0 - the newest version available, there is no newer one to upgrade to - fits cell text to its column width via go-runewidth's Truncate, which has zero ANSI awareness; it counts every visible character of a "\x1b[38;5;42m" escape sequence as real display width. Coloring the Status cell didn't misalign the table, it silently deleted the status text from the row entirely - the width-fitting truncation cut into the escape sequence itself. styledStatus exists but is deliberately unused inside any table.Row; it's used once, in detailView's title, which is a plain string lipgloss-renders whole with no width constraint - nesting one nested Render() call inside another works correctly there (confirmed live via raw escape-code inspection), at the cost of one trailing space losing the outer title's bold/color after the inner reset code, placed at the very end of the string specifically to keep that cost minimal.

Verified live in tmux against a running daemon with six real captured entries spanning 200/302/404/500 responses: o sorted ascending by status (200, 200, 200, 302, 404, 500), O reversed it; the detail view's title rendered the status code in the correct color per class (confirmed red for the 500 entry via raw escape-code capture, not just visually); pretty-printing a real JSON response produced correctly colored, correctly indented output - keys blue, string values green, numbers orange, booleans/null magenta, punctuation gray, confirmed against the raw captured ANSI codes, not just eyeballed - and the request tab (never JSON, never pretty-printed) rendered as plain unstyled text, unaffected.

CI and release automation

.github/workflows/ci.yml: make test (build/vet/gofmt/test) on every push/PR, Linux only - matches the README's own honest claim that Linux is the only platform actually run and verified, rather than pretending untested platforms are behaviorally covered by CI. A separate make release job cross-compiles every platform in the Makefile's matrix on the same Linux runner (CGO_ENABLED=0 + pure-Go SQLite means this needs no macOS/Windows runner at all) as a build-only check - catches a platform-specific compile break without paying for real hardware to do it.

.github/workflows/release.yml: fires on a v* tag push, runs make release, packages each platform's directory into a single archive (.tar.gz Unix, .zip Windows - whatever each platform's own tools already handle, no extra install required to unpack one) alongside a SHA256SUMS file, and attaches them to a GitHub Release created from the tag. Uses the gh CLI (preinstalled and pre-authenticated via the runner's own GITHUB_TOKEN on every GitHub-hosted runner) rather than a third-party Marketplace action for the actual release creation - matches this project's own general preference for minimizing external dependencies (see e.g. pure-Go SQLite over CGO). Verified locally: ran the exact packaging shell logic (not the YAML itself, which needs a real Actions run to execute) against a real make release output - confirmed all 6 platform archives (2 macOS, 2 Linux, 1 Windows, 1 FreeBSD) build with correct internal structure, zip selected only for the Windows target, and SHA256SUMS covers all of them.

Plugin ecosystem

The goal, per direct request: parity with the extensions/Pro features working Burp users actually rely on - "the best plugins, especially the paid ones" - not a single proof-of-concept. Researched the current BApp Store landscape and Burp Pro's paid-tier feature set to ground this rather than guess from memory; see the grouping below.

Architecture decision

Considered two shapes: an embedded scripting language (Lua/Starlark interpreter in mitmuxd, plugins as hooked scripts) versus external processes speaking mitmux's own existing IPC protocol. Chose the latter, explicitly: any language (a plugin doing crypto work might want Python's jwcrypto, a fast passive scanner might want Go - no reason to lock authors into one embedded language), no new interpreter dependency or sandboxing model to design and maintain, and it mirrors the daemon/TUI split already in place - a plugin is architecturally just another TUI, watching and acting on the same socket. Documented in full, protocol-first (any-language authors need a real spec, not Go doc comments), in PLUGINS.md.

Phase 1: tag + stored data (shipped)

The prerequisite for everything else: a tag_entry IPC request lets any connected process mark a history entry with a short string tag and an opaque, plugin-defined JSON data blob, stored in a new entry_tags table. Deliberately NOT a live round-trip to a still-connected plugin process for every view - a plugin does its analysis once, tags the entry with whatever data a human will want to see later, and the panel just reads what got stored. Simpler, and works even if the plugin has long since exited by the time someone reviews the entry.

This one capability, paired with the IPC surface that already existed (subscribe for live traffic, repeat for sending probe requests, list/search/get for reading history), is enough for an entire tier of plugins with no further protocol work: Autorize (replay with a different session token, diff, tag likely IDORs), Param Miner (probe for hidden parameters/cache poisoning, tag hits), Backslash Powered Scanner (payload-variant diffing for injection), Retire.js (passive scan of response bodies against a known-vulnerable-JS-library list). Logger++/Content-Type-Converter/JSON-Beautifier-class extensions were considered and skipped - mitmux's own History+FTS5 search and Decoder tool already outperform what they'd add.

TUI integration: Summary.Tags (a comma-joined, correlated-subquery aggregate - cheap enough per row that List/Search need no N+1 query) renders as a new Tags column in the history table, sortable via o/O like any other column, and matchable via a new tag:name search filter (same extractStructured mechanism as status:/ source:/flagged:). EntryDetail.Tags carries the full record (plugin name + tag + data) for the entry currently open; T from detail view opens a list of them (mirroring the WebSocket-messages view's own table-then-detail-viewport pattern, added earlier), enter on one shows its data - JSON-colorized via the same jsoncolor.go already built for pretty-printed response bodies if it parses as JSON, plain sanitized text otherwise. Tag data goes through the same sanitizeBlock treatment as any other externally-sourced text reaching the real terminal - a plugin can echo back attacker-influenced content (e.g. a header value it parsed), so it's not implicitly trusted just because it came from a plugin rather than raw traffic.

What's grouped where

  • Cheap IPC-plugin wins (Phase 1 covers these fully): Autorize, Param Miner, Backslash Powered Scanner, Retire.js.
  • Needs a second protocol addition - a live round-trip to a specific still-connected plugin, for an interactive action rather than a passive tag (JWT Editor's "re-sign this with a different key, right now" button; SAML Raider's equivalent XML re-signing): JWT Editor, SAML Raider. Not yet built - Phase 1's tag+data display already covers the decode and view half of what these do; only the live re-sign half needs the new capability.
  • Needs real UI beyond a data panel: InQL/GraphQL Raider (a schema browser/query explorer, not just a decode view) - bigger lift, later.
  • Deserves its own separate project, not a plugin: Burp Scanner and Burp Bounty's custom active-check DSL (active vulnerability scanning is a whole subsystem - check-diffing engines, hundreds of injection variants - and already explicitly out of scope per this README's own "a manual-testing tool, not a scanner"); Collaborator/OAST (needs real internet-reachable DNS/HTTP infrastructure running somewhere, not something an in-process plugin can be); a crawler (spidering is its own substantial subsystem). Turbo Intruder's actual value is raw throughput - mitmux's own Intruder already covers all four of Burp's attack modes, so if extreme concurrency is ever wanted, that's a core-engine change to how requests get dispatched, not something a plugin protocol should be shaped around.

Wire-format consistency fix (found while building the first plugin)

Writing PLUGINS.md as an authoritative external spec surfaced a real, pre-existing inconsistency: store.Summary/Entry/EntryTag/ WSMessage, rules.Rule, scope.Rule, and clientcert.Cert had no JSON struct tags at all, so Go's default marshaling serialized them as PascalCase ("ID", "StartedAt") while the rest of the protocol (EntryDetail's own fields, every Request/Response wrapper field) uses snake_case. Confirmed live against a real daemon before touching anything: a raw socket list request came back with "ID", "StartedAt", "StatusCode" - exactly the mismatch suspected. Nothing outside this repo's own Go code consumes this wire format yet, so adding tags now (rather than documenting the wart) was a free, purely additive fix - every affected struct now tags snake_case throughout, consistent with the rest of the protocol, verified with a full build/vet/test pass afterward.

First plugin shipped: plugins/authcheck

An Autorize-style authorization checker, and the reference implementation PLUGINS.md points to. Deliberately speaks the wire protocol directly - its own local request/response/summary/ entryDetail structs mirroring the real ones field-for-field, not imported - rather than reaching into internal/ipc, even though nothing stops a Go plugin from doing that. The point isn't style: it's proof that the documented protocol is actually sufficient on its own, since that's the only thing a non-Go plugin author has to work with, and the best available check against silently depending on some Go-internal convenience that never made it into the docs.

For every proxied (not source: "repeater" - see below) request carrying an Authorization or Cookie header, resends it via repeat with that header stripped and compares status classes: if the original succeeded (2xx/3xx) and the anonymous resend also succeeded, that's a likely missing-function-level-access-control bug, tagged authcheck:bypass with the two status codes and which header was stripped as the tag's JSON data. Matches Burp's own Autorize default of only surfacing likely findings, not logging every check performed. Explicitly guards against reprocessing its own resends (Repeat records source: "repeater", filtered out on the subscribe feed) - without that, the plugin would try to authcheck its own control-group requests, which is at best wasted work and at worst misattributes a finding to the wrong entry.

Simplified relative to real Autorize: Autorize's other mode swaps in a SECOND, lower-privileged identity's session rather than going fully anonymous, which catches cross-account IDORs an anonymous-only check can't (an endpoint might correctly reject "no credentials" while still leaking another user's data to a validly-authenticated-but-wrong-user request). That needs a second credential as input this reference version doesn't take - a natural, small extension (a -low-priv-cookie flag, one more resend variant, one more tag) once wanted.

Verified live end to end against a real daemon, a real Python origin server with one endpoint that looks like it enforces auth but doesn't (vulnerable by design) and one that actually does (the control case): the broken endpoint was correctly tagged, the properly-secured one was correctly left untouched - no false positive - confirmed both via the stored tag data (go run against the daemon directly) and visually in the TUI (tmux, real keystrokes): the Tags column badge, T's tag list, and the tag detail view's JSON-colorized data, ANSI-verified, all showing the plugin's actual findings.

Second plugin shipped: plugins/paramminer

A Param Miner-style hidden parameter prober. For every distinct GET endpoint (deduplicated in-memory by method+scheme+host+path for the plugin's own runtime, so re-visiting the same URL doesn't re-run the whole wordlist against it every time), sends a fresh baseline resend plus one probe per candidate from a hand-picked ~40-entry wordlist (debug, admin, redirect, role, token, and similar - the parameter names real backends most often surprisingly read even when never part of any observed request), each adding exactly that one query parameter. A probe whose response differs from baseline by more than a small absolute-and-relative body-length threshold (or a different status outright) is a likely hit, tagged paramminer:hit with the parameter name and both response sizes as evidence. Deliberately GET-only with a modest wordlist, not the exhaustive POST/JSON-aware probing real Param Miner does - same "small honest v1" reasoning as authcheck's single-identity simplification.

Two things worth knowing, found while building and verifying this one: http.Request.Write (used to rebuild each probe request after mutating its query string) silently adds a default User-Agent header if the cloned request didn't already have one and completely ignores the Request.RequestURI field when serializing - confirmed directly rather than assumed, by writing a request with a deliberately stale RequestURI and observing the output still came out correct because Write derives the request line from Request.URL instead. Neither affects correctness here (baseline and every probe get the identical treatment, so it can't produce a false diff), but both are worth knowing before reusing this pattern elsewhere. Also: every probe becomes its own source: "repeater" history row, same as authcheck's resends - expected (every resend is auditable, the same as a human using Repeater by hand) but worth calling out, since a wordlist-driven plugin can visibly fill the history view; source:proxy in search filters the noise back out. Documented in PLUGINS.md.

Verified live end to end: a real daemon, a real Python origin with one endpoint that has a genuinely hidden debug parameter changing its response substantially (20 bytes -> 58 bytes direct; 164 -> 202 through the full probe pipeline) and one that's stable regardless of any extra parameter (the control case) - the hidden-parameter endpoint was correctly tagged with exactly the right parameter name, the stable endpoint was correctly left alone, confirmed via tag: search and visually in the TUI with the JSON-array tag data rendering correctly (the first tag payload to exercise jsoncolor.go's top-level-array path outside its own unit tests).

Third plugin shipped: plugins/jslibscan

A Retire.js-style passive scanner for known-vulnerable JavaScript library versions - and, deliberately, the first purely passive plugin: subscribe, inspect a response body already captured by ordinary proxying, tag, nothing else. No repeat calls at all, unlike authcheck and paramminer. Worth having one of these in the reference set specifically because it's the shape least likely to surprise anyone running it against traffic they can't afford to actively probe - the safest possible plugin to try first.

Checks any JS library version string it can find in a response body against a small, explicitly-illustrative built-in table (five libraries - jQuery, Lodash, Handlebars, Moment.js, AngularJS - one well-known vulnerable-version threshold each), tagging a match jslibscan:hit with the version found, the first fixed version, and a one-line advisory. Deliberately NOT a maintained vulnerability feed the way real Retire.js's continuously-updated JSON database is; documented as such in the plugin's own package doc, since claiming otherwise would be misleading. CVE numbers deliberately omitted from the advisory text in favor of a plain description of the vulnerability class - precise enough to be useful, without asserting a specific CVE identifier this reference implementation hasn't independently verified against.

Found and fixed a real regex bug while verifying this one live rather than trusting it from the code: the first version matched jQuery's own actual banner comment ("jQuery v1.8.3") against nothing at all, because the separator pattern only allowed a single non-digit character between the library name and its version digits, and that banner has two (a space, then "v"). Confirmed the failure directly, then confirmed the fix against three real-world version-string shapes at once - banner comment, minified filename ("jquery-3.4.1.min.js"), and cache-busting query string ("jquery.min.js?v=1.11.0") - before it went back into the plugin, using a bounded non-greedy gap (.{0,15}?) between name and version rather than trying to enumerate every separator combination.

Verified live end to end: a real daemon, a real origin serving both an outdated jQuery 1.8.3 banner and a current 3.7.1 one - the outdated file was correctly tagged with the right version and fix threshold, the current one correctly left alone, confirmed via the JSON-array tag data in the TUI. The same run also incidentally reconfirmed the regex fix was real: an entry captured before the fix (same file, same content, requested moments earlier against the buggy binary) sat right next to the correctly-tagged one in the history list, untagged.

Fourth plugin shipped: plugins/bpscanner - Phase 1 complete

A Backslash Powered Scanner-style generic injection detector, and the last of the four "cheap IPC win" plugins identified in the original research pass. Different mechanism from the other three, deliberately: where paramminer finds parameters that shouldn't exist, bpscanner tests parameters that already do, by asking a more general question than a signature-based scanner does - does the backend treat syntactically-significant characters ('"\<>(){}$;|&, covering SQL quoting, HTML/JS, shell metacharacters, and template syntax at once) differently than an equal-length string of inert filler? That question doesn't need to know what the backend is built on, which is the whole appeal of the real tool this borrows its name and idea from.

For each existing query parameter, sends two same-length replacement values wrapped in a stable marker (zzMARKzz) - one filler, one special-character - and checks not just whether the response looks different (length/status diffing, what the other three probing-based plugins use) but whether the marker itself came back intact: if the filler value survives unmodified but the special-character one doesn't (stripped, escaped, or altered), or the two produce different status codes outright, something downstream is interpreting those characters rather than treating the parameter as inert data. An endpoint that doesn't reflect input at all naturally produces "both intact: false," which correctly isn't a finding - the marker-reflection design avoids false-positiving on the (very common) case of a parameter that's read but never echoed anywhere.

Verified live end to end against a deliberately realistic scenario: a real origin with one endpoint that reflects a query parameter after stripping a few special characters (a naive-sanitizer/WAF-like pattern, a genuinely common real-world shape) and one that reflects verbatim with no stripping (the control case) - the sanitizing endpoint was correctly tagged (control_marker_intact: true, special_marker_intact: false, exactly the differential the plugin is built to catch), the verbatim endpoint was correctly left alone, confirmed via the JSON tag data in the TUI.

This closes Phase 1: every plugin identified as a "cheap IPC win" - no new protocol capability needed beyond tag_entry itself - is now real, working, and live-verified (authcheck, paramminer, jslibscan, bpscanner). Next: the live-RPC protocol addition for JWT Editor/SAML Raider - a genuinely different shape of plugin (interactive, on-demand from the TUI, not just subscribe-and-tag) - or the bigger separate- project items (active scanning, Collaborator/OAST, a crawler) if that's the higher priority instead.