srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/ipc/ipc.go73
-rw-r--r--internal/ipc/server.go43
-rw-r--r--internal/proxy/proxy.go13
-rw-r--r--internal/scope/scope.go61
-rw-r--r--internal/scope/scope_test.go96
-rw-r--r--internal/store/store.go59
6 files changed, 339 insertions, 6 deletions
diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go
index c2be080..2abce89 100644
--- a/internal/ipc/ipc.go
+++ b/internal/ipc/ipc.go
@@ -13,6 +13,7 @@ import (
"time"
"mitmux/internal/rules"
+ "mitmux/internal/scope"
"mitmux/internal/store"
)
@@ -55,6 +56,14 @@ type Request struct {
RuleID int64 `json:"rule_id,omitempty"`
RuleEnabled bool `json:"rule_enabled,omitempty"`
+ // For "scope_add": the new rule (always an add - scope rules are
+ // simple enough that edit-in-place isn't worth a separate update
+ // path; delete and re-add covers it). For "scope_delete"/
+ // "scope_toggle": ScopeRuleID (and RuleEnabled for toggle) identify
+ // the target.
+ ScopeRule *scope.Rule `json:"scope_rule,omitempty"`
+ ScopeRuleID int64 `json:"scope_rule_id,omitempty"`
+
// For "set_flagged" and "delete_entry": ID identifies the history
// entry. "clear_history" needs no fields at all.
Flagged bool `json:"flagged,omitempty"`
@@ -62,12 +71,13 @@ type Request struct {
// 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", "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)
- Rules []rules.Rule `json:"rules,omitempty"` // for "rules"
- Status *StatusMsg `json:"status,omitempty"` // for "status"
+ Type string `json:"type"` // "list", "get", "new", "repeat", "rules", "scope_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)
+ Rules []rules.Rule `json:"rules,omitempty"` // for "rules"
+ ScopeRules []scope.Rule `json:"scope_rules,omitempty"` // for "scope_rules"
+ Status *StatusMsg `json:"status,omitempty"` // for "status"
// For "intrude_result": one completed attack request.
IntrudeResult *IntrudeResultMsg `json:"intrude_result,omitempty"`
@@ -326,6 +336,57 @@ func (c *Client) rulesRoundTrip(req Request) ([]rules.Rule, error) {
return resp.Rules, nil
}
+// ListScopeRules returns every scope rule, enabled or not.
+func (c *Client) ListScopeRules() ([]scope.Rule, error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.scopeRoundTrip(Request{Type: "scope_list"})
+}
+
+// AddScopeRule adds r and returns its assigned ID.
+func (c *Client) AddScopeRule(r scope.Rule) (int64, error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ saved, err := c.scopeRoundTrip(Request{Type: "scope_add", ScopeRule: &r})
+ if err != nil {
+ return 0, err
+ }
+ if len(saved) == 0 {
+ return 0, errors.New("scope_add: daemon returned no rule")
+ }
+ return saved[0].ID, nil
+}
+
+// DeleteScopeRule removes a scope rule.
+func (c *Client) DeleteScopeRule(id int64) error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ _, err := c.scopeRoundTrip(Request{Type: "scope_delete", ScopeRuleID: id})
+ return err
+}
+
+// SetScopeRuleEnabled toggles a scope rule without touching its pattern.
+func (c *Client) SetScopeRuleEnabled(id int64, enabled bool) error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ _, err := c.scopeRoundTrip(Request{Type: "scope_toggle", ScopeRuleID: id, RuleEnabled: enabled})
+ return err
+}
+
+func (c *Client) scopeRoundTrip(req Request) ([]scope.Rule, error) {
+ if err := c.enc.Encode(req); err != nil {
+ return nil, err
+ }
+ var resp Response
+ if err := c.dec.Decode(&resp); err != nil {
+ return nil, err
+ }
+ if resp.Type == "error" {
+ return nil, errors.New(resp.Error)
+ }
+ return resp.ScopeRules, nil
+}
+
// Subscribe opens a dedicated connection that streams newly captured
// history entries as they happen. The returned channel is closed when
// the connection ends; call the returned close func to stop early.
diff --git a/internal/ipc/server.go b/internal/ipc/server.go
index 7f77ceb..303fdef 100644
--- a/internal/ipc/server.go
+++ b/internal/ipc/server.go
@@ -10,6 +10,7 @@ import (
"sync"
"mitmux/internal/rules"
+ "mitmux/internal/scope"
"mitmux/internal/store"
)
@@ -281,6 +282,48 @@ func (s *Server) handleConn(conn net.Conn) {
}
enc.Encode(Response{Type: "rules"})
+ case "scope_list":
+ rs, err := s.db.ListScopeRules()
+ if err != nil {
+ enc.Encode(Response{Type: "error", Error: err.Error()})
+ continue
+ }
+ enc.Encode(Response{Type: "scope_rules", ScopeRules: rs})
+
+ case "scope_add":
+ if req.ScopeRule == nil {
+ enc.Encode(Response{Type: "error", Error: "scope_add: missing rule"})
+ continue
+ }
+ r := *req.ScopeRule
+ if r.IsRegex {
+ if _, err := regexp.Compile(r.Pattern); err != nil {
+ enc.Encode(Response{Type: "error", Error: "invalid regex: " + err.Error()})
+ continue
+ }
+ }
+ id, err := s.db.AddScopeRule(r)
+ if err != nil {
+ enc.Encode(Response{Type: "error", Error: err.Error()})
+ continue
+ }
+ r.ID = id
+ enc.Encode(Response{Type: "scope_rules", ScopeRules: []scope.Rule{r}})
+
+ case "scope_delete":
+ if err := s.db.DeleteScopeRule(req.ScopeRuleID); err != nil {
+ enc.Encode(Response{Type: "error", Error: err.Error()})
+ continue
+ }
+ enc.Encode(Response{Type: "scope_rules"})
+
+ case "scope_toggle":
+ if err := s.db.SetScopeRuleEnabled(req.ScopeRuleID, req.RuleEnabled); err != nil {
+ enc.Encode(Response{Type: "error", Error: err.Error()})
+ continue
+ }
+ enc.Encode(Response{Type: "scope_rules"})
+
case "subscribe":
sub := s.hub.subscribe()
defer s.hub.unsubscribe(sub)
diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go
index 216e947..2e80ed2 100644
--- a/internal/proxy/proxy.go
+++ b/internal/proxy/proxy.go
@@ -38,6 +38,7 @@ import (
"mitmux/internal/ca"
"mitmux/internal/rules"
+ "mitmux/internal/scope"
"mitmux/internal/store"
)
@@ -509,6 +510,18 @@ func (s *Server) record(started time.Time, duration time.Duration, scheme, host
if s.store == nil {
return
}
+ // Scope only filters what gets recorded here - the request has
+ // already been forwarded and its response already written to the
+ // client by the time record() runs (see forward()), so an
+ // out-of-scope host still proxies completely normally, it just
+ // doesn't clutter history. Repeat/Intrude (recordRaw, a different
+ // function) deliberately don't go through this check: 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 to second-guess a deliberate action.
+ if scopeRules, err := s.store.ListScopeRules(); err == nil && !scope.InScope(scopeRules, host) {
+ return
+ }
e := &store.Entry{
StartedAt: started,
diff --git a/internal/scope/scope.go b/internal/scope/scope.go
new file mode 100644
index 0000000..d8d2e80
--- /dev/null
+++ b/internal/scope/scope.go
@@ -0,0 +1,61 @@
+// Package scope filters which captured traffic gets recorded to
+// history - a target scope, in Burp's sense: out-of-scope requests
+// still proxy through completely normally (nothing is blocked), they
+// just aren't stored, so unrelated CDN/analytics/tracker noise doesn't
+// pollute history and search on a real engagement. Deliberately not an
+// access-control mechanism; that would be a materially different,
+// riskier feature (breaking a workflow by silently blocking traffic is
+// a much worse failure mode than a noisier history).
+package scope
+
+import (
+ "regexp"
+ "strings"
+)
+
+// Rule is one scope entry. A non-regex Pattern matches by substring
+// containment against the host (case-insensitive) - "example.com"
+// matches "example.com", "www.example.com", and "api.example.com"
+// alike, covering the common "this domain and its subdomains" case
+// without inventing a separate wildcard syntax. IsRegex switches to a
+// full regex match against the host, mirroring the same toggle
+// match-and-replace rules already use, for the same reason: one
+// consistent mental model across both rule types in this tool.
+type Rule struct {
+ ID int64
+ Enabled bool
+ Pattern string
+ IsRegex bool
+}
+
+// InScope reports whether host should be recorded, given rules.
+// An empty rule set (or one with nothing enabled) means "no scope
+// configured" - everything is in scope, matching this tool's behavior
+// before scope existed at all, so a fresh install or a user who never
+// opens the scope view keeps recording everything, not silently
+// nothing. Once at least one rule is enabled, only a host matching one
+// of them is in scope.
+func InScope(rules []Rule, host string) bool {
+ anyEnabled := false
+ for _, r := range rules {
+ if !r.Enabled {
+ continue
+ }
+ anyEnabled = true
+ if ruleMatches(r, host) {
+ return true
+ }
+ }
+ return !anyEnabled
+}
+
+func ruleMatches(r Rule, host string) bool {
+ if r.IsRegex {
+ re, err := regexp.Compile(r.Pattern)
+ if err != nil {
+ return false
+ }
+ return re.MatchString(host)
+ }
+ return strings.Contains(strings.ToLower(host), strings.ToLower(r.Pattern))
+}
diff --git a/internal/scope/scope_test.go b/internal/scope/scope_test.go
new file mode 100644
index 0000000..fee5d44
--- /dev/null
+++ b/internal/scope/scope_test.go
@@ -0,0 +1,96 @@
+package scope
+
+import "testing"
+
+func TestInScopeEmptyRulesMeansEverything(t *testing.T) {
+ if !InScope(nil, "example.com") {
+ t.Error("empty rule set should mean everything is in scope")
+ }
+ if !InScope([]Rule{}, "anything.at.all") {
+ t.Error("empty rule set should mean everything is in scope")
+ }
+}
+
+func TestInScopeAllDisabledMeansEverything(t *testing.T) {
+ rules := []Rule{{Enabled: false, Pattern: "example.com"}}
+ if !InScope(rules, "unrelated.org") {
+ t.Error("no enabled rules should mean everything is in scope")
+ }
+}
+
+func TestInScopeSubstringMatch(t *testing.T) {
+ rules := []Rule{{Enabled: true, Pattern: "example.com"}}
+ tests := []struct {
+ host string
+ want bool
+ }{
+ {"example.com", true},
+ {"www.example.com", true},
+ {"api.example.com", true},
+ {"example.com.evil.org", true}, // substring containment, deliberately simple
+ {"other.org", false},
+ }
+ for _, tt := range tests {
+ if got := InScope(rules, tt.host); got != tt.want {
+ t.Errorf("InScope(%q) = %v, want %v", tt.host, got, tt.want)
+ }
+ }
+}
+
+func TestInScopeCaseInsensitive(t *testing.T) {
+ rules := []Rule{{Enabled: true, Pattern: "Example.COM"}}
+ if !InScope(rules, "www.EXAMPLE.com") {
+ t.Error("substring match should be case-insensitive")
+ }
+}
+
+func TestInScopeRegex(t *testing.T) {
+ rules := []Rule{{Enabled: true, Pattern: `(^|\.)example\.com$`, IsRegex: true}}
+ tests := []struct {
+ host string
+ want bool
+ }{
+ {"example.com", true},
+ {"api.example.com", true},
+ {"notexample.com", false},
+ {"example.com.evil.org", false},
+ }
+ for _, tt := range tests {
+ if got := InScope(rules, tt.host); got != tt.want {
+ t.Errorf("InScope(%q) = %v, want %v", tt.host, got, tt.want)
+ }
+ }
+}
+
+func TestInScopeInvalidRegexNeverMatches(t *testing.T) {
+ rules := []Rule{{Enabled: true, Pattern: "(unclosed", IsRegex: true}}
+ if InScope(rules, "example.com") {
+ t.Error("an invalid regex rule should never match, not panic or false-positive")
+ }
+}
+
+func TestInScopeMultipleRulesAnyMatch(t *testing.T) {
+ rules := []Rule{
+ {Enabled: true, Pattern: "example.com"},
+ {Enabled: true, Pattern: "other.org"},
+ }
+ if !InScope(rules, "other.org") {
+ t.Error("should match the second rule")
+ }
+ if InScope(rules, "unrelated.net") {
+ t.Error("should not match either rule")
+ }
+}
+
+func TestInScopeDisabledRuleIgnored(t *testing.T) {
+ rules := []Rule{
+ {Enabled: false, Pattern: "example.com"},
+ {Enabled: true, Pattern: "other.org"},
+ }
+ if InScope(rules, "example.com") {
+ t.Error("a disabled rule should not match")
+ }
+ if !InScope(rules, "other.org") {
+ t.Error("the enabled rule should still match")
+ }
+}
diff --git a/internal/store/store.go b/internal/store/store.go
index c0cdea5..befd5a6 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -16,6 +16,7 @@ import (
_ "modernc.org/sqlite"
"mitmux/internal/rules"
+ "mitmux/internal/scope"
)
const schema = `
@@ -55,6 +56,13 @@ CREATE TABLE IF NOT EXISTS rules (
is_regex INTEGER NOT NULL DEFAULT 0,
position INTEGER NOT NULL DEFAULT 0
);
+
+CREATE TABLE IF NOT EXISTS scope_rules (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ enabled INTEGER NOT NULL DEFAULT 1,
+ pattern TEXT NOT NULL,
+ is_regex INTEGER NOT NULL DEFAULT 0
+);
`
// Store is a handle to the history database. Safe for concurrent use.
@@ -560,6 +568,57 @@ func (s *Store) SetRuleEnabled(id int64, enabled bool) error {
return nil
}
+// ListScopeRules returns every scope rule, including disabled ones (the
+// scope management view needs to show and let you re-enable those too).
+func (s *Store) ListScopeRules() ([]scope.Rule, error) {
+ rows, err := s.db.Query(`SELECT id, enabled, pattern, is_regex FROM scope_rules ORDER BY id`)
+ if err != nil {
+ return nil, fmt.Errorf("list scope rules: %w", err)
+ }
+ defer rows.Close()
+
+ var out []scope.Rule
+ for rows.Next() {
+ var r scope.Rule
+ var enabled, isRegex int
+ if err := rows.Scan(&r.ID, &enabled, &r.Pattern, &isRegex); err != nil {
+ return nil, fmt.Errorf("scan scope rule row: %w", err)
+ }
+ r.Enabled = enabled != 0
+ r.IsRegex = isRegex != 0
+ out = append(out, r)
+ }
+ return out, rows.Err()
+}
+
+// AddScopeRule stores r and returns its assigned ID.
+func (s *Store) AddScopeRule(r scope.Rule) (int64, error) {
+ res, err := s.db.Exec(
+ `INSERT INTO scope_rules (enabled, pattern, is_regex) VALUES (?, ?, ?)`,
+ boolToInt(r.Enabled), r.Pattern, boolToInt(r.IsRegex),
+ )
+ if err != nil {
+ return 0, fmt.Errorf("add scope rule: %w", err)
+ }
+ return res.LastInsertId()
+}
+
+// SetScopeRuleEnabled toggles a scope rule without touching its pattern.
+func (s *Store) SetScopeRuleEnabled(id int64, enabled bool) error {
+ if _, err := s.db.Exec(`UPDATE scope_rules SET enabled = ? WHERE id = ?`, boolToInt(enabled), id); err != nil {
+ return fmt.Errorf("set scope rule %d enabled: %w", id, err)
+ }
+ return nil
+}
+
+// DeleteScopeRule removes a scope rule.
+func (s *Store) DeleteScopeRule(id int64) error {
+ if _, err := s.db.Exec(`DELETE FROM scope_rules WHERE id = ?`, id); err != nil {
+ return fmt.Errorf("delete scope rule %d: %w", id, err)
+ }
+ 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()