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 torcgen) for per-domain leaf certs - Proxy core:
net/http+ manualCONNECThandling, 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 throughhttp.Transport- Transport's automatic HTTP/2 dispatch keys off a literal*tls.Conntype 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
- Proxy + CA cert generation + plaintext HTTP passthrough
- TLS interception (per-host cert generation, install CA)
- History view (SQLite storage, raw bytes preserved) in the TUI
- Repeater (raw-byte send/resend, the feature used daily)
- Search/filter (FTS5)
- Match-and-replace rules
- 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 neededtea.MouseMsgrouted to it, which nothing in the codebase was doing yet), and vi-modal text editors via newviTextarea.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:
stripHopByHopwas already strippingConnectionandUpgradefrom 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+stripHopByHopKeepingUpgradefix 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 Pythonwebsockets-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-formGET 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-scopedSSL_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.Connthe CONNECT path hands back - this is the path real browsers actually use forwss://, 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 viajson.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 viajson.Marshalbefore being written - which is also the entire safety argument for not running the colorized output throughsanitizeControlafterward 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 throughsanitizeControlafterward 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.prettyResponsesanitizes the header/status block (still server-controlled, still raw text) separately from the colorized body for exactly this reason. This path renders into aviewport, 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/tablev1.0.0 - the newest version available, there is no newer one to upgrade to - fits cell text to its column width viago-runewidth'sTruncate, 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.styledStatusexists but is deliberately unused inside anytable.Row; it's used once, indetailView's title, which is a plain string lipgloss-renders whole with no width constraint - nesting one nestedRender()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.