srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/PLAN.md
blob: 9a53d0ebdef576507c5fb590a6f23a059d6f91c9 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
# 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).

Next: the remaining Phase 1 plugins (Backslash Powered Scanner,
Retire.js - no new protocol capability needed, same pattern `authcheck`
and `paramminer` already validate), then the live-RPC protocol addition
for JWT Editor/SAML Raider.