srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--PLAN.md39
-rw-r--r--README.md2
-rw-r--r--cmd/mitmux/main.go92
-rw-r--r--internal/ipc/ipc.go41
-rw-r--r--internal/ipc/server.go14
-rw-r--r--internal/store/store.go33
6 files changed, 215 insertions, 6 deletions
diff --git a/PLAN.md b/PLAN.md
index edaf599..90a27fd 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -206,6 +206,45 @@ rather than hanging or crashing.
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.
+
+Still open from the expanded "worth considering" list: export/import
+(HAR, copy-as-curl), and scope/target filtering (to keep noise -
+trackers, CDNs, unrelated third-party hosts - out of history and
+search). Both requested explicitly; not started yet.
+
Skipped deliberately (from the research, matches this tool's stated
scope): active/passive vulnerability scanning, plugin marketplace,
Collaborator/OAST, team collaboration, CI integration, client TLS
diff --git a/README.md b/README.md
index b880fc3..ccb770c 100644
--- a/README.md
+++ b/README.md
@@ -156,6 +156,8 @@ below is enough to get going.
| `i` | open in Intruder |
| `f` | toggle flag |
| `c` | mark for comparison - press `c` on another entry to diff |
+| `x` | delete the selected entry (asks `y`/`n` to confirm) |
+| `X` | clear ALL history, not just the current search filter (asks `y`/`n` to confirm) |
| `d` | Decoder |
| `/` | search |
| `m` | match-and-replace rules |
diff --git a/cmd/mitmux/main.go b/cmd/mitmux/main.go
index 83e59cd..dbc919d 100644
--- a/cmd/mitmux/main.go
+++ b/cmd/mitmux/main.go
@@ -203,6 +203,14 @@ type model struct {
decoderInput viTextarea
decoderOutput viewport.Model
+ // confirmPrompt, when non-empty, takes over the status line and
+ // every keypress until resolved: 'y' runs confirmYes, anything else
+ // cancels. Used to gate destructive actions (deleting a history
+ // entry, clearing all of it) behind an explicit second keypress
+ // rather than a single accidental one.
+ confirmPrompt string
+ confirmYes func() tea.Cmd
+
statusMsg string
width int
height int
@@ -352,6 +360,27 @@ func (m *model) setFlagged(id int64, flagged bool) tea.Cmd {
}
}
+type entryDeletedMsg struct {
+ id int64
+ err error
+}
+
+func (m *model) deleteEntry(id int64) tea.Cmd {
+ return func() tea.Msg {
+ return entryDeletedMsg{id: id, err: m.client.DeleteEntry(id)}
+ }
+}
+
+type historyClearedMsg struct {
+ err error
+}
+
+func (m *model) clearHistory() tea.Cmd {
+ return func() tea.Msg {
+ return historyClearedMsg{err: m.client.ClearHistory()}
+ }
+}
+
func (m *model) loadList() tea.Msg {
var entries []store.Summary
var err error
@@ -750,6 +779,24 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
+ case entryDeletedMsg:
+ if msg.err != nil {
+ m.statusMsg = "delete error: " + msg.err.Error()
+ return m, nil
+ }
+ m.statusMsg = fmt.Sprintf("deleted #%d", msg.id)
+ return m, tea.Batch(m.loadList, m.loadStatus)
+
+ case historyClearedMsg:
+ if msg.err != nil {
+ m.statusMsg = "clear error: " + msg.err.Error()
+ return m, nil
+ }
+ m.compareBaseID = 0
+ m.pendingNew = 0
+ m.statusMsg = "history cleared"
+ return m, tea.Batch(m.loadList, m.loadStatus)
+
case detailLoadedMsg:
if msg.err != nil {
m.statusMsg = "get error: " + msg.err.Error()
@@ -850,6 +897,17 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyMsg:
switch m.mode {
case viewList:
+ if m.confirmPrompt != "" {
+ yes := msg.String() == "y" || msg.String() == "Y"
+ action := m.confirmYes
+ m.confirmPrompt = ""
+ m.confirmYes = nil
+ if yes && action != nil {
+ return m, action()
+ }
+ m.statusMsg = "cancelled"
+ return m, nil
+ }
if m.searching {
switch msg.String() {
case "enter":
@@ -902,6 +960,29 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if row := m.table.Cursor(); row >= 0 && row < len(m.entries) {
return m, m.markOrCompare(m.entries[row].ID)
}
+ case "x":
+ if row := m.table.Cursor(); row >= 0 && row < len(m.entries) {
+ id := m.entries[row].ID
+ m.confirmPrompt = fmt.Sprintf("delete #%d? y/n", id)
+ m.confirmYes = func() tea.Cmd { return m.deleteEntry(id) }
+ }
+ return m, nil
+ case "X":
+ // Always clears the whole database, never just the
+ // current filtered view - m.entries under an active
+ // search query would understate the real count and make
+ // the prompt itself misleading about what's about to
+ // happen.
+ count := "all"
+ if m.daemonStatus != nil {
+ count = fmt.Sprintf("all %d", m.daemonStatus.HistoryCount)
+ }
+ if m.daemonStatus != nil && m.daemonStatus.HistoryCount == 0 {
+ return m, nil
+ }
+ m.confirmPrompt = fmt.Sprintf("clear %s history entries (not just this view)? y/n", count)
+ m.confirmYes = func() tea.Cmd { return m.clearHistory() }
+ return m, nil
case "d":
m.mode = viewDecoder
m.statusMsg = ""
@@ -1424,6 +1505,8 @@ func (m *model) helpView() string {
"i open in Intruder",
"f toggle flag (★ mark this, revisit later)",
"c mark for comparison, then press c on another entry to diff",
+ "x delete the selected entry (asks to confirm)",
+ "X clear ALL history, not just the current filter (asks to confirm)",
"d decoder (URL/Base64/Hex/HTML encode/decode)",
"/ search: plain text, host:value, AND/OR/NOT,",
" status:404 / status:4xx / status:>=400,",
@@ -1506,13 +1589,16 @@ func (m *model) listView() string {
}
b.WriteString(m.table.View())
b.WriteString("\n")
- if m.statusMsg != "" {
+ if m.confirmPrompt != "" {
+ b.WriteString(statusStyle.Render(m.confirmPrompt))
+ b.WriteString("\n")
+ } else if m.statusMsg != "" {
b.WriteString(statusStyle.Render(sanitizeLine(m.statusMsg)))
b.WriteString("\n")
}
- help := "enter view · r/i/c/d tools · f flag · / search · m rules · ? help · q quit"
+ help := "enter view · r/i/c/d tools · f flag · x delete · X clear all · / search · m rules · ? help · q quit"
if m.query != "" {
- help = "enter view · r/i/c/d tools · f flag · / search · esc clear filter · ? help · q quit"
+ help = "enter view · r/i/c/d tools · f flag · x delete · X clear all · / search · esc clear filter · ? help · q quit"
}
b.WriteString(helpStyle.Render(help))
return b.String()
diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go
index 5dac21f..c2be080 100644
--- a/internal/ipc/ipc.go
+++ b/internal/ipc/ipc.go
@@ -18,7 +18,7 @@ import (
// Request is sent by a client to the daemon.
type Request struct {
- Type string `json:"type"` // "list", "get", "subscribe", "repeat", "intrude", "rules_list", "rules_save", "rules_delete", or "rules_toggle"
+ Type string `json:"type"` // "list", "get", "subscribe", "repeat", "intrude", "rules_list", "rules_save", "rules_delete", "rules_toggle", "delete_entry", or "clear_history"
Limit int `json:"limit,omitempty"`
BeforeID int64 `json:"before_id,omitempty"`
ID int64 `json:"id,omitempty"`
@@ -55,13 +55,14 @@ type Request struct {
RuleID int64 `json:"rule_id,omitempty"`
RuleEnabled bool `json:"rule_enabled,omitempty"`
- // For "set_flagged": ID identifies the history entry.
+ // For "set_flagged" and "delete_entry": ID identifies the history
+ // entry. "clear_history" needs no fields at all.
Flagged bool `json:"flagged,omitempty"`
}
// Response is sent by the daemon to a client.
type Response struct {
- Type string `json:"type"` // "list", "get", "new", "repeat", "rules", "intrude_result", "intrude_done", "status", "flagged", or "error"
+ Type string `json:"type"` // "list", "get", "new", "repeat", "rules", "intrude_result", "intrude_done", "status", "flagged", "deleted", "cleared", or "error"
Entries []store.Summary `json:"entries,omitempty"` // for "list"
Detail *EntryDetail `json:"detail,omitempty"` // for "get" and "repeat"
New *store.Summary `json:"new,omitempty"` // for "new" (subscribe push)
@@ -158,6 +159,40 @@ func (c *Client) SetFlagged(id int64, flagged bool) error {
return nil
}
+// DeleteEntry removes a single history entry.
+func (c *Client) DeleteEntry(id int64) error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if err := c.enc.Encode(Request{Type: "delete_entry", ID: id}); err != nil {
+ return err
+ }
+ var resp Response
+ if err := c.dec.Decode(&resp); err != nil {
+ return err
+ }
+ if resp.Type == "error" {
+ return errors.New(resp.Error)
+ }
+ return nil
+}
+
+// ClearHistory removes every history entry. Rules are untouched.
+func (c *Client) ClearHistory() error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if err := c.enc.Encode(Request{Type: "clear_history"}); err != nil {
+ return err
+ }
+ var resp Response
+ if err := c.dec.Decode(&resp); err != nil {
+ return err
+ }
+ if resp.Type == "error" {
+ return errors.New(resp.Error)
+ }
+ return nil
+}
+
// Status returns basic daemon info for a status bar.
func (c *Client) Status() (*StatusMsg, error) {
c.mu.Lock()
diff --git a/internal/ipc/server.go b/internal/ipc/server.go
index 2879b24..7f77ceb 100644
--- a/internal/ipc/server.go
+++ b/internal/ipc/server.go
@@ -215,6 +215,20 @@ func (s *Server) handleConn(conn net.Conn) {
}
enc.Encode(Response{Type: "flagged"})
+ case "delete_entry":
+ if err := s.db.DeleteEntry(req.ID); err != nil {
+ enc.Encode(Response{Type: "error", Error: err.Error()})
+ continue
+ }
+ enc.Encode(Response{Type: "deleted"})
+
+ case "clear_history":
+ if err := s.db.ClearHistory(); err != nil {
+ enc.Encode(Response{Type: "error", Error: err.Error()})
+ continue
+ }
+ enc.Encode(Response{Type: "cleared"})
+
case "rules_list":
rs, err := s.db.ListRules()
if err != nil {
diff --git a/internal/store/store.go b/internal/store/store.go
index 19b71b1..c0cdea5 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -560,6 +560,39 @@ func (s *Store) SetRuleEnabled(id int64, enabled bool) error {
return nil
}
+// DeleteEntry removes a single history entry and its search index row.
+func (s *Store) DeleteEntry(id int64) error {
+ tx, err := s.db.Begin()
+ if err != nil {
+ return fmt.Errorf("begin delete entry: %w", err)
+ }
+ defer tx.Rollback()
+ if _, err := tx.Exec(`DELETE FROM history WHERE id = ?`, id); err != nil {
+ return fmt.Errorf("delete history entry %d: %w", id, err)
+ }
+ if _, err := tx.Exec(`DELETE FROM history_fts WHERE rowid = ?`, id); err != nil {
+ return fmt.Errorf("delete search index for entry %d: %w", id, err)
+ }
+ return tx.Commit()
+}
+
+// ClearHistory removes every history entry and resets the search index.
+// Rules are untouched - this only clears captured traffic.
+func (s *Store) ClearHistory() error {
+ tx, err := s.db.Begin()
+ if err != nil {
+ return fmt.Errorf("begin clear history: %w", err)
+ }
+ defer tx.Rollback()
+ if _, err := tx.Exec(`DELETE FROM history`); err != nil {
+ return fmt.Errorf("clear history: %w", err)
+ }
+ if _, err := tx.Exec(`DELETE FROM history_fts`); err != nil {
+ return fmt.Errorf("clear search index: %w", err)
+ }
+ return tx.Commit()
+}
+
// DeleteRule removes a rule.
func (s *Store) DeleteRule(id int64) error {
if _, err := s.db.Exec(`DELETE FROM rules WHERE id = ?`, id); err != nil {