diff options
Diffstat (limited to 'plugins')
| -rw-r--r-- | plugins/jslibscan/main.go | 285 |
1 files changed, 285 insertions, 0 deletions
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]) +} |