diff options
| -rw-r--r-- | PLAN.md | 39 | ||||
| -rw-r--r-- | README.md | 2 | ||||
| -rw-r--r-- | cmd/mitmux/main.go | 92 | ||||
| -rw-r--r-- | internal/ipc/ipc.go | 41 | ||||
| -rw-r--r-- | internal/ipc/server.go | 14 | ||||
| -rw-r--r-- | internal/store/store.go | 33 |
6 files changed, 215 insertions, 6 deletions
@@ -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 @@ -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 { |