srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/cmd
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2026-05-20 09:29:00 +0200
committersrdusr <[email protected]>2026-05-20 09:29:00 +0200
commit51811b67018515366bd56f3c3b21aed11d906db2 (patch)
tree86f0bb0a95be73e7e6ca45ce82c3989932d82844 /cmd
parent1ac926d9936947e7b7e1cadaf2fcdeeda52b8c83 (diff)
downloadmitmux-51811b67018515366bd56f3c3b21aed11d906db2.tar.gz
mitmux-51811b67018515366bd56f3c3b21aed11d906db2.zip
Target scope: filter what gets recorded, not what gets proxied
The proxy captured and stored literally everything with no way to exclude unrelated traffic - every CDN asset, analytics beacon, and third-party tracker request on a real engagement pollutes history and search right alongside the traffic that actually matters. internal/scope: Rule{Enabled, Pattern, IsRegex} and InScope(rules, host). A non-regex pattern matches by case-insensitive substring against the host - "example.com" matches "example.com", "www.example.com", and "api.example.com" alike, covering "this domain and its subdomains" without inventing a separate wildcard syntax. IsRegex mirrors the same toggle match-and-replace rules already use, for one consistent mental model across both rule types in this tool. An empty or all-disabled rule set means everything is in scope - the 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. Deliberately a recording filter, not access control: out-of-scope traffic still proxies completely normally, reaching its destination and the client exactly as before. internal/proxy's forward() already writes the response to the client before record() ever runs, so the scope check (new in record()) can only affect whether the exchange gets stored, never whether it happens. Blocking out-of-scope traffic outright would be a materially different, much riskier feature - a wrong scope pattern could silently break the very traffic someone's trying to test, which is a far worse failure mode than a noisier history. Repeat/Intrude (recordRaw, a separate function from record()) deliberately don't go through the scope check at all: a user explicitly resending or fuzzing a specific request wants to see the result regardless of scope, which exists to cut passive-capture noise, not second-guess a deliberate action. internal/store: new scope_rules table (CREATE TABLE IF NOT EXISTS, no migration needed - it's a new table, not a new column on an existing one) plus List/Add/SetEnabled/Delete, mirroring the existing match-and-replace rules CRUD exactly. internal/ipc: scope_list/ scope_add/scope_delete/scope_toggle request types and matching Client methods; scope_add validates a regex pattern compiles before persisting, same reasoning and same fix as the earlier rules_save validation (an invalid regex should be rejected up front, not silently never match at apply time with zero feedback). TUI: 's' from the history list opens scope management, mirroring the Rules view's own list+form pattern but simpler (add-only, no edit-in-place - a pattern and a regex toggle don't need a five-field form, delete-and-re-add covers changing one). Verified live in tmux against a running daemon: added a substring scope rule for one host, sent requests to both a matching and a non-matching host - the non-matching one proxied successfully (client got its 200) but was never recorded, the matching one was recorded normally; confirmed a Repeater resend of the excluded host WAS recorded despite being out of scope; toggled the rule off and confirmed recording resumed for everything; added and confirmed a regex-mode rule saves and displays correctly; deleted a rule and confirmed the list returns to empty ("no rules means everything is recorded"). go build/vet/gofmt/test/mod tidy all clean.
Diffstat (limited to 'cmd')
-rw-r--r--cmd/mitmux/main.go223
1 files changed, 221 insertions, 2 deletions
diff --git a/cmd/mitmux/main.go b/cmd/mitmux/main.go
index 467f0d5..21209bf 100644
--- a/cmd/mitmux/main.go
+++ b/cmd/mitmux/main.go
@@ -21,6 +21,7 @@ import (
"mitmux/internal/ca"
"mitmux/internal/ipc"
"mitmux/internal/rules"
+ "mitmux/internal/scope"
"mitmux/internal/store"
)
@@ -76,6 +77,7 @@ const (
viewIntruder
viewCompare
viewDecoder
+ viewScope
viewHelp
)
@@ -160,6 +162,16 @@ type model struct {
ruleRegex bool
ruleField ruleField
+ // Target scope: which captured traffic gets recorded to history.
+ // Simpler than match-and-replace rules (one pattern + a regex
+ // toggle, no name/scope/replace fields), so add-only - no
+ // edit-in-place, delete and re-add covers changing a pattern.
+ scopeTable table.Model
+ scopeRows []scope.Rule
+ scopeForm bool
+ scopePattern textinput.Model
+ scopeIsRegex bool
+
intruderScheme string
intruderHost string
intruderTemplate viTextarea
@@ -269,6 +281,17 @@ func newModel(client *ipc.Client, subCh <-chan store.Summary, socketPath string)
replaceIn := textinput.New()
replaceIn.Placeholder = "replacement"
+ scopeCols := []table.Column{
+ {Title: "On", Width: 3},
+ {Title: "Pattern", Width: 40},
+ {Title: "Regex", Width: 5},
+ }
+ scopeTbl := table.New(table.WithColumns(scopeCols), table.WithFocused(true))
+ scopeTbl.SetStyles(st)
+
+ scopePatternIn := textinput.New()
+ scopePatternIn.Placeholder = "host substring, or a regex - e.g. example.com"
+
itmpl := newViTextarea()
itmpl.ta.Placeholder = "raw request bytes - wrap positions to fuzz in § markers, e.g. /users/§123§"
itmpl.ta.ShowLineNumbers = false
@@ -311,6 +334,8 @@ func newModel(client *ipc.Client, subCh <-chan store.Summary, socketPath string)
repeaterIndex: -1,
searchInput: si,
rulesTable: rt,
+ scopeTable: scopeTbl,
+ scopePattern: scopePatternIn,
ruleName: nameIn,
ruleMatch: matchIn,
ruleReplace: replaceIn,
@@ -744,6 +769,64 @@ func (m *model) focusRuleField() {
}
}
+type scopeRulesLoadedMsg struct {
+ rules []scope.Rule
+ err error
+}
+
+type scopeRuleWriteDoneMsg struct {
+ action string // "added", "deleted", "toggled" - for the status line
+ err error
+}
+
+func (m *model) loadScopeRules() tea.Msg {
+ rs, err := m.client.ListScopeRules()
+ return scopeRulesLoadedMsg{rules: rs, err: err}
+}
+
+func (m *model) addScopeRule(r scope.Rule) tea.Cmd {
+ return func() tea.Msg {
+ _, err := m.client.AddScopeRule(r)
+ return scopeRuleWriteDoneMsg{action: "added", err: err}
+ }
+}
+
+func (m *model) deleteSelectedScopeRule() tea.Cmd {
+ row := m.scopeTable.Cursor()
+ if row < 0 || row >= len(m.scopeRows) {
+ return nil
+ }
+ id := m.scopeRows[row].ID
+ return func() tea.Msg {
+ err := m.client.DeleteScopeRule(id)
+ return scopeRuleWriteDoneMsg{action: "deleted", err: err}
+ }
+}
+
+func (m *model) toggleSelectedScopeRule() tea.Cmd {
+ row := m.scopeTable.Cursor()
+ if row < 0 || row >= len(m.scopeRows) {
+ return nil
+ }
+ r := m.scopeRows[row]
+ return func() tea.Msg {
+ err := m.client.SetScopeRuleEnabled(r.ID, !r.Enabled)
+ return scopeRuleWriteDoneMsg{action: "toggled", err: err}
+ }
+}
+
+// enterScopeForm opens the (add-only) scope rule form.
+func (m *model) enterScopeForm() {
+ m.scopeForm = true
+ m.scopePattern.SetValue("")
+ m.scopeIsRegex = false
+ m.scopePattern.Focus()
+}
+
+func (m *model) scopeRuleFromForm() scope.Rule {
+ return scope.Rule{Enabled: true, Pattern: m.scopePattern.Value(), IsRegex: m.scopeIsRegex}
+}
+
func (m *model) Init() tea.Cmd {
return tea.Batch(m.loadList, m.waitForEntry, m.loadStatus)
}
@@ -779,6 +862,10 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.ruleMatch.Width = formWidth
m.ruleReplace.Width = formWidth
+ m.scopeTable.SetWidth(msg.Width)
+ m.scopeTable.SetHeight(h - 5)
+ m.scopePattern.Width = formWidth
+
// h-9 rather than h-8: one extra line reserved for the payload
// rules / grep-match-extract status line in intruderView.
itmplHeight := (h - 9) / 3
@@ -937,6 +1024,24 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.statusMsg = "rule " + msg.action
return m, m.loadRules
+ case scopeRulesLoadedMsg:
+ if msg.err != nil {
+ m.statusMsg = "scope error: " + msg.err.Error()
+ return m, nil
+ }
+ m.scopeRows = msg.rules
+ setTableRows(&m.scopeTable, scopeRowsFor(m.scopeRows))
+ return m, nil
+
+ case scopeRuleWriteDoneMsg:
+ if msg.err != nil {
+ m.statusMsg = "scope " + msg.action + " error: " + msg.err.Error()
+ return m, nil
+ }
+ m.scopeForm = false
+ m.statusMsg = "scope rule " + msg.action
+ return m, m.loadScopeRules
+
case intrudeStartedMsg:
if msg.err != nil {
m.intruderRunning = false
@@ -1097,6 +1202,10 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.mode = viewRules
m.statusMsg = ""
return m, m.loadRules
+ case "s":
+ m.mode = viewScope
+ m.statusMsg = ""
+ return m, m.loadScopeRules
case "esc":
if m.query != "" {
m.query = ""
@@ -1344,6 +1453,49 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.rulesTable, cmd = m.rulesTable.Update(msg)
return m, cmd
+ case viewScope:
+ if m.scopeForm {
+ switch msg.String() {
+ case "esc":
+ m.scopeForm = false
+ return m, nil
+ case "ctrl+c":
+ return m, tea.Quit
+ case "ctrl+s":
+ return m, m.addScopeRule(m.scopeRuleFromForm())
+ case "tab":
+ m.scopeIsRegex = !m.scopeIsRegex
+ return m, nil
+ }
+ var cmd tea.Cmd
+ m.scopePattern, cmd = m.scopePattern.Update(msg)
+ return m, cmd
+ }
+
+ switch msg.String() {
+ case "q", "esc":
+ m.mode = viewList
+ return m, nil
+ case "ctrl+c":
+ return m, tea.Quit
+ case "?":
+ m.prevMode = viewScope
+ m.mode = viewHelp
+ return m, nil
+ case "a":
+ m.enterScopeForm()
+ return m, nil
+ case "d":
+ m.statusMsg = ""
+ return m, m.deleteSelectedScopeRule()
+ case " ":
+ m.statusMsg = ""
+ return m, m.toggleSelectedScopeRule()
+ }
+ var cmd tea.Cmd
+ m.scopeTable, cmd = m.scopeTable.Update(msg)
+ return m, cmd
+
case viewIntruder:
// Editing a grep pattern is a modal overlay on top of the
// normal template/payloads/results panes, same pattern as
@@ -1584,6 +1736,12 @@ func (m *model) View() string {
} else {
body = m.rulesView()
}
+ case viewScope:
+ if m.scopeForm {
+ body = m.scopeFormView()
+ } else {
+ body = m.scopeView()
+ }
case viewIntruder:
body = m.intruderView()
case viewCompare:
@@ -1610,6 +1768,7 @@ func (m *model) statusBar() string {
view := map[viewMode]string{
viewList: "history", viewDetail: "detail", viewRepeater: "repeater",
viewRules: "rules", viewIntruder: "intruder", viewCompare: "comparer", viewDecoder: "decoder",
+ viewScope: "scope",
}[m.mode]
return statusBarStyle.Render(fmt.Sprintf(" mitmux · proxy %s%s · %s ", proxy, count, view))
}
@@ -1648,6 +1807,7 @@ func (m *model) helpView() string {
" source:repeater, flagged:true",
"esc clear active search filter",
"m match-and-replace rules",
+ "s target scope (what gets recorded)",
"q quit",
)
section("Detail view",
@@ -1691,6 +1851,15 @@ func (m *model) helpView() string {
"tab/shift+tab move between form fields ctrl+s save form",
"◀▶ change scope/regex toggle esc cancel/back",
)
+ section("Scope",
+ "What gets recorded to history - out-of-scope traffic still",
+ "proxies normally, it's just not stored. No rules means",
+ "everything is recorded (today's default, unchanged).",
+ "a add rule (pattern + regex toggle)",
+ "d delete selected space toggle enabled",
+ "tab toggle regex (in the add form)",
+ "ctrl+s save form esc cancel/back",
+ )
b.WriteString(helpStyle.Render("press any key to go back"))
return b.String()
@@ -1739,9 +1908,9 @@ func (m *model) listView() string {
b.WriteString(statusStyle.Render(sanitizeLine(m.statusMsg)))
b.WriteString("\n")
}
- help := "enter view · r/i/c/d tools · f flag · x delete · X clear all · E export HAR · / search · m rules · ? help · q quit"
+ help := "enter view · r/i/c/d tools · f flag · x delete · X clear all · E export HAR · / search · m rules · s scope · ? help · q quit"
if m.query != "" {
- help = "enter view · r/i/c/d tools · f flag · x delete · X clear all · E export HAR (this filter) · / search · esc clear filter · ? help · q quit"
+ help = "enter view · r/i/c/d tools · f flag · x delete · X clear all · E export HAR (this filter) · / search · esc clear filter · s scope · ? help · q quit"
}
b.WriteString(helpStyle.Render(help))
return b.String()
@@ -1909,6 +2078,40 @@ func (m *model) ruleFormView() string {
return b.String()
}
+func (m *model) scopeView() string {
+ var b strings.Builder
+ title := fmt.Sprintf(" scope (%d) - no rules means everything is recorded ", len(m.scopeRows))
+ b.WriteString(titleStyle.Render(title))
+ b.WriteString("\n")
+ b.WriteString(m.scopeTable.View())
+ b.WriteString("\n")
+ if m.statusMsg != "" {
+ b.WriteString(statusStyle.Render(sanitizeLine(m.statusMsg)))
+ b.WriteString("\n")
+ }
+ b.WriteString(helpStyle.Render("a add · d delete · space toggle · esc back · q quit"))
+ return b.String()
+}
+
+func (m *model) scopeFormView() string {
+ var b strings.Builder
+ b.WriteString(titleStyle.Render(" add scope rule "))
+ b.WriteString("\n\n")
+ b.WriteString(tabActive.Render("Pattern (substring match, or a regex against the host)") + "\n")
+ b.WriteString(m.scopePattern.View() + "\n\n")
+ regexText := "Regex: off (tab to toggle)"
+ if m.scopeIsRegex {
+ regexText = "Regex: on (tab to toggle)"
+ }
+ b.WriteString(helpStyle.Render(regexText) + "\n\n")
+ if m.statusMsg != "" {
+ b.WriteString(statusStyle.Render(sanitizeLine(m.statusMsg)))
+ b.WriteString("\n")
+ }
+ b.WriteString(helpStyle.Render("tab toggle regex · ctrl+s save · esc cancel · ctrl+c quit"))
+ return b.String()
+}
+
func rulesRowsFor(rs []rules.Rule) []table.Row {
rows := make([]table.Row, len(rs))
for i, r := range rs {
@@ -1925,6 +2128,22 @@ func rulesRowsFor(rs []rules.Rule) []table.Row {
return rows
}
+func scopeRowsFor(rs []scope.Rule) []table.Row {
+ rows := make([]table.Row, len(rs))
+ for i, r := range rs {
+ on := " "
+ if r.Enabled {
+ on = "✓"
+ }
+ regex := ""
+ if r.IsRegex {
+ regex = "yes"
+ }
+ rows[i] = table.Row{on, r.Pattern, regex}
+ }
+ return rows
+}
+
func (m *model) intruderView() string {
var b strings.Builder
title := fmt.Sprintf(" intruder - %s://%s ", sanitizeLine(m.intruderScheme), sanitizeLine(m.intruderHost))