diff options
| -rw-r--r-- | PLAN.md | 53 | ||||
| -rw-r--r-- | PLUGINS.md | 25 | ||||
| -rw-r--r-- | README.md | 7 | ||||
| -rw-r--r-- | plugins/jslibscan/main.go | 285 |
4 files changed, 353 insertions, 17 deletions
@@ -973,7 +973,52 @@ 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. +### Third plugin shipped: `plugins/jslibscan` + +A Retire.js-style passive scanner for known-vulnerable JavaScript +library versions - and, deliberately, the first purely passive plugin: +subscribe, inspect a response body already captured by ordinary +proxying, tag, nothing else. No `repeat` calls at all, unlike +`authcheck` and `paramminer`. Worth having one of these in the +reference set specifically because it's the shape least likely to +surprise anyone running it against traffic they can't afford to +actively probe - the safest possible plugin to try first. + +Checks any JS library version string it can find in a response body +against a small, explicitly-illustrative built-in table (five +libraries - jQuery, Lodash, Handlebars, Moment.js, AngularJS - one +well-known vulnerable-version threshold each), tagging a match +`jslibscan:hit` with the version found, the first fixed version, and a +one-line advisory. Deliberately NOT a maintained vulnerability feed the +way real Retire.js's continuously-updated JSON database is; documented +as such in the plugin's own package doc, since claiming otherwise would +be misleading. CVE numbers deliberately omitted from the advisory text +in favor of a plain description of the vulnerability class - precise +enough to be useful, without asserting a specific CVE identifier this +reference implementation hasn't independently verified against. + +Found and fixed a real regex bug while verifying this one live rather +than trusting it from the code: the first version matched jQuery's own +actual banner comment ("jQuery v1.8.3") against nothing at all, because +the separator pattern only allowed a single non-digit character between +the library name and its version digits, and that banner has two (a +space, then "v"). Confirmed the failure directly, then confirmed the +fix against three real-world version-string shapes at once - banner +comment, minified filename ("jquery-3.4.1.min.js"), and cache-busting +query string ("jquery.min.js?v=1.11.0") - before it went back into the +plugin, using a bounded non-greedy gap (`.{0,15}?`) between name and +version rather than trying to enumerate every separator combination. + +Verified live end to end: a real daemon, a real origin serving both an +outdated jQuery 1.8.3 banner and a current 3.7.1 one - the outdated +file was correctly tagged with the right version and fix threshold, the +current one correctly left alone, confirmed via the JSON-array tag data +in the TUI. The same run also incidentally reconfirmed the regex fix +was real: an entry captured *before* the fix (same file, same content, +requested moments earlier against the buggy binary) sat right next to +the correctly-tagged one in the history list, untagged. + +Next: the remaining Phase 1 plugin (Backslash Powered Scanner - no new +protocol capability needed, same pattern the three shipped plugins +already validate), then the live-RPC protocol addition for JWT +Editor/SAML Raider. @@ -11,16 +11,21 @@ Go), and means a plugin can be developed and tested against the exact same socket the TUI is already using, with `mitmux` itself open in another terminal watching what happens in real time. -`plugins/authcheck` and `plugins/paramminer` are real, working -reference implementations - an Autorize-style authorization checker -(resends a captured request with its auth header stripped, tags the -entry if the response still succeeds) and a Param Miner-style hidden -parameter prober (probes a small wordlist of candidate query -parameters, tags the entry if any noticeably change the response) - -both written to only ever exercise what's documented on this page, not -any of mitmux's own internal Go packages, specifically so they prove -this protocol is sufficient on its own. Worth reading alongside this -document, or just copying as a starting point. +`plugins/authcheck`, `plugins/paramminer`, and `plugins/jslibscan` are +real, working reference implementations - an Autorize-style +authorization checker (resends a captured request with its auth header +stripped, tags the entry if the response still succeeds), a Param +Miner-style hidden parameter prober (probes a small wordlist of +candidate query parameters, tags the entry if any noticeably change the +response), and a Retire.js-style passive scanner for known-vulnerable +JS library versions (reads response bodies already captured by ordinary +proxying, no probing at all) - all written to only ever exercise what's +documented on this page, not any of mitmux's own internal Go packages, +specifically so they prove this protocol is sufficient on its own. +Worth reading alongside this document, or just copying as a starting +point. `jslibscan` in particular is the simplest possible plugin shape +- subscribe, inspect, tag, nothing else - worth starting from if a +plugin idea doesn't need to send any traffic of its own. ## Connecting @@ -70,9 +70,10 @@ the same socket the TUI itself uses - see [`PLUGINS.md`](PLUGINS.md). a badge in the history list, searchable via `tag:name`, viewable (`T` from detail view) with JSON data syntax-highlighted the same way a pretty-printed response is. See [`PLUGINS.md`](PLUGINS.md) and - `plugins/authcheck`/`plugins/paramminer` for real, working ones (an - Autorize-style authorization checker, a Param Miner-style hidden - parameter prober). + `plugins/authcheck`/`plugins/paramminer`/`plugins/jslibscan` for + real, working ones (an Autorize-style authorization checker, a Param + Miner-style hidden parameter prober, a Retire.js-style scanner for + known-vulnerable JS library versions). - **Comparer**: mark one entry (`c`), then `c` on a different entry to see a colored unified diff of either side's request or response. - **Decoder**: standalone URL/Base64/Hex/HTML encode and decode (`d`), diff --git a/plugins/jslibscan/main.go b/plugins/jslibscan/main.go new file mode 100644 index 0000000..0bbcc94 --- /dev/null +++ b/plugins/jslibscan/main.go @@ -0,0 +1,285 @@ +// Command jslibscan is a reference mitmux plugin - a Retire.js-style +// passive scanner for known-vulnerable JavaScript library versions. +// Unlike authcheck and paramminer, this one never sends anything: it +// only reads response bodies already captured by ordinary proxying and +// checks any JS library version string it can find against a small +// built-in table of known-bad ranges, tagging a match "jslibscan:hit". +// Demonstrating a purely passive plugin (subscribe, inspect, tag - no +// repeat calls at all) alongside the two active ones is deliberate: it's +// the simplest possible plugin shape, and the one least likely to +// surprise anyone running it against traffic they can't afford to probe. +// +// The built-in table is a small, illustrative starting set (five +// libraries, one well-known vulnerable-version threshold each) - NOT a +// maintained vulnerability feed. Real Retire.js pulls from a +// continuously updated JSON database with dozens of libraries and many +// more precise version ranges; replicating that here would mean +// committing to keep it current, which this reference plugin doesn't. +// Extending libraries is adding one entry to the libraries slice below. +// +// Speaks the wire protocol directly, no internal/ipc import - see +// plugins/authcheck's package doc for why, and PLUGINS.md for the +// protocol. +package main + +import ( + "encoding/json" + "flag" + "fmt" + "log" + "net" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" +) + +type request struct { + Type string `json:"type"` + ID int64 `json:"id,omitempty"` + TagPlugin string `json:"tag_plugin,omitempty"` + Tag string `json:"tag,omitempty"` + TagData string `json:"tag_data,omitempty"` +} + +type summary struct { + ID int64 `json:"id"` + Source string `json:"source"` +} + +type entryDetail struct { + summary + ResponseRaw []byte `json:"response_raw"` +} + +type response struct { + Type string `json:"type"` + New *summary `json:"new,omitempty"` + Detail *entryDetail `json:"detail,omitempty"` + Error string `json:"error,omitempty"` +} + +type client struct { + conn net.Conn + enc *json.Encoder + dec *json.Decoder +} + +func dial(path string) (*client, error) { + conn, err := net.Dial("unix", path) + if err != nil { + return nil, err + } + return &client{conn: conn, enc: json.NewEncoder(conn), dec: json.NewDecoder(conn)}, nil +} + +func (c *client) call(req request) (response, error) { + if err := c.enc.Encode(req); err != nil { + return response{}, err + } + var resp response + if err := c.dec.Decode(&resp); err != nil { + return response{}, err + } + if resp.Type == "error" { + return response{}, fmt.Errorf("%s", resp.Error) + } + return resp, nil +} + +func defaultSocketPath() string { + if rt := os.Getenv("XDG_RUNTIME_DIR"); rt != "" { + return filepath.Join(rt, "mitmux.sock") + } + cfg, err := os.UserConfigDir() + if err != nil { + return "mitmux.sock" + } + return filepath.Join(cfg, "mitmux", "mitmux.sock") +} + +// library is one entry in the built-in illustrative table: match finds +// a version string for the library (its first capture group is the +// dotted version number, e.g. "3.4.1"), and any version strictly below +// fixedIn is flagged. +type library struct { + name string + match *regexp.Regexp + fixedIn [3]int + advice string +} + +// The 0-to-15-character non-greedy gap between a library's name and its +// version digits (rather than a fixed one-character separator) is +// deliberate - confirmed directly, not assumed: real-world version +// strings show up as "jQuery v1.8.3" (space-then-"v", two separator +// characters, not one), "jquery-3.4.1.min.js" (filename form), and +// "jquery.min.js?v=1.11.0" (cache-busting query string) alike. Kept +// bounded and non-greedy rather than wide open, so it can't walk past +// the library's own version into an unrelated number elsewhere on the +// page. +var libraries = []library{ + { + name: "jQuery", + match: regexp.MustCompile(`(?i)jquery.{0,15}?(\d+\.\d+\.\d+)`), + fixedIn: [3]int{3, 5, 0}, + advice: "known cross-site scripting vulnerability in HTML parsing/prefiltering fixed in 3.5.0", + }, + { + name: "Lodash", + match: regexp.MustCompile(`(?i)lodash.{0,15}?(\d+\.\d+\.\d+)`), + fixedIn: [3]int{4, 17, 21}, + advice: "known prototype pollution / command injection vulnerabilities fixed in 4.17.21", + }, + { + name: "Handlebars", + match: regexp.MustCompile(`(?i)handlebars.{0,15}?(\d+\.\d+\.\d+)`), + fixedIn: [3]int{4, 7, 7}, + advice: "known prototype pollution vulnerability fixed in 4.7.7", + }, + { + name: "Moment.js", + match: regexp.MustCompile(`(?i)moment(?:\.js)?.{0,15}?(\d+\.\d+\.\d+)`), + fixedIn: [3]int{2, 29, 4}, + advice: "known path traversal / ReDoS vulnerabilities fixed in 2.29.4", + }, + { + name: "AngularJS", + match: regexp.MustCompile(`(?i)angular(?:js|\.js)?.{0,15}?(1\.\d+\.\d+)`), + fixedIn: [3]int{1, 8, 3}, + advice: "AngularJS 1.x reached end of life; known sandbox-escape/XSS issues, no fixes past 1.8.3", + }, +} + +type hit struct { + Library string `json:"library"` + VersionFound string `json:"version_found"` + FirstFixedIn string `json:"first_fixed_in"` + Advisory string `json:"advisory"` +} + +func main() { + socketPath := flag.String("socket", "", "daemon control socket path (default: same as mitmux itself)") + pluginName := flag.String("name", "jslibscan", "name this plugin tags entries as") + flag.Parse() + + path := *socketPath + if path == "" { + path = defaultSocketPath() + } + + actor, err := dial(path) + if err != nil { + log.Fatalf("dial %s: %v", path, err) + } + defer actor.conn.Close() + + subConn, err := net.Dial("unix", path) + if err != nil { + log.Fatalf("dial %s (subscribe): %v", path, err) + } + defer subConn.Close() + if err := json.NewEncoder(subConn).Encode(request{Type: "subscribe"}); err != nil { + log.Fatalf("subscribe: %v", err) + } + + log.Printf("jslibscan: watching live traffic on %s", path) + dec := json.NewDecoder(subConn) + for { + var resp response + if err := dec.Decode(&resp); err != nil { + log.Fatalf("subscribe feed closed: %v", err) + } + if resp.Type != "new" || resp.New == nil { + continue + } + // Not a correctness concern here the way it is for authcheck/ + // paramminer (this plugin never calls repeat, so there's no + // loop risk) - just avoids redundant work rescanning response + // bodies that are probably near-identical to an original + // request's, which is what most of a wordlist-driven probe's + // own resends look like. + if resp.New.Source != "proxy" { + continue + } + if err := scanEntry(actor, *pluginName, resp.New.ID); err != nil { + log.Printf("entry #%d: %v", resp.New.ID, err) + } + } +} + +func scanEntry(c *client, pluginName string, id int64) error { + resp, err := c.call(request{Type: "get", ID: id}) + if err != nil { + return fmt.Errorf("get: %w", err) + } + if resp.Detail == nil || len(resp.Detail.ResponseRaw) == 0 { + return nil + } + body := string(resp.Detail.ResponseRaw) + + var hits []hit + for _, lib := range libraries { + m := lib.match.FindStringSubmatch(body) + if m == nil { + continue + } + found, ok := parseVersion(m[1]) + if !ok || !isBelow(found, lib.fixedIn) { + continue + } + hits = append(hits, hit{ + Library: lib.name, + VersionFound: m[1], + FirstFixedIn: joinVersion(lib.fixedIn), + Advisory: lib.advice, + }) + } + if len(hits) == 0 { + return nil + } + + data, err := json.Marshal(hits) + if err != nil { + return fmt.Errorf("marshal hits: %w", err) + } + if _, err := c.call(request{Type: "tag_entry", ID: id, TagPlugin: pluginName, Tag: "jslibscan:hit", TagData: string(data)}); err != nil { + return fmt.Errorf("tag_entry: %w", err) + } + names := make([]string, len(hits)) + for i, h := range hits { + names[i] = fmt.Sprintf("%s %s", h.Library, h.VersionFound) + } + log.Printf("#%d -> outdated JS librar(y/ies): %s", id, strings.Join(names, ", ")) + return nil +} + +func parseVersion(s string) ([3]int, bool) { + parts := strings.SplitN(s, ".", 3) + if len(parts) != 3 { + return [3]int{}, false + } + var v [3]int + for i, p := range parts { + n, err := strconv.Atoi(p) + if err != nil { + return [3]int{}, false + } + v[i] = n + } + return v, true +} + +func isBelow(v, fixedIn [3]int) bool { + for i := 0; i < 3; i++ { + if v[i] != fixedIn[i] { + return v[i] < fixedIn[i] + } + } + return false // exactly equal to the fixed version - not vulnerable +} + +func joinVersion(v [3]int) string { + return fmt.Sprintf("%d.%d.%d", v[0], v[1], v[2]) +} |