diff --git a/internal/modules/executor.go b/internal/modules/executor.go index 2e165e04..ea267c24 100644 --- a/internal/modules/executor.go +++ b/internal/modules/executor.go @@ -189,7 +189,9 @@ func executeHTTPChain(ctx context.Context, client *http.Client, target string, d req.Header.Set("User-Agent", defaultUserAgent) } + start := time.Now() resp, err := client.Do(req) + elapsed := time.Since(start) if err != nil { // a transport error breaks the chain; return whatever matched earlier. return result, nil @@ -210,7 +212,14 @@ func executeHTTPChain(ctx context.Context, client *http.Client, target string, d // a step with matchers gates the chain: a match records a finding, a miss // means the precondition failed so the chain stops here. if len(step.Matchers) > 0 { - if !checkMatchers(step.Matchers, step.MatchersCondition, resp, respStr) { + mc := &MatchContext{ + Resp: resp, + Body: respStr, + URL: url, + Duration: elapsed, + Extracted: vars, + } + if !checkMatchers(step.Matchers, step.MatchersCondition, mc) { break } result.Findings = append(result.Findings, Finding{ @@ -413,7 +422,9 @@ func executeHTTPRequest(ctx context.Context, client *http.Client, r *httpRequest req.Header.Set("User-Agent", defaultUserAgent) } + start := time.Now() resp, err := client.Do(req) + elapsed := time.Since(start) if err != nil { return Finding{}, false } @@ -426,13 +437,22 @@ func executeHTTPRequest(ctx context.Context, client *http.Client, r *httpRequest } bodyStr := string(respBody) - // Check matchers - if !checkMatchers(cfg.Matchers, cfg.MatchersCondition, resp, bodyStr) { - return Finding{}, false + // extract before matching: runExtractors is side-effect-free and the finding + // is only built on a match, so hoisting it is behavior-preserving and lets a + // matcher read extractor values out of the context. + extracted := runExtractors(cfg.Extractors, resp, bodyStr) + + mc := &MatchContext{ + Resp: resp, + Body: bodyStr, + URL: r.URL, + Duration: elapsed, + Extracted: extracted, } - // Extract data - extracted := runExtractors(cfg.Extractors, resp, bodyStr) + if !checkMatchers(cfg.Matchers, cfg.MatchersCondition, mc) { + return Finding{}, false + } // favicon-only matches fire on binary icon bytes; report the hash, not the body. evidence := truncateEvidence(bodyStr) @@ -449,14 +469,14 @@ func executeHTTPRequest(ctx context.Context, client *http.Client, r *httpRequest } // checkMatchers combines matchers with condition "and" (default, all match) or "or" (any). -func checkMatchers(matchers []Matcher, condition string, resp *http.Response, body string) bool { +func checkMatchers(matchers []Matcher, condition string, mc *MatchContext) bool { if len(matchers) == 0 { return false } or := strings.EqualFold(condition, "or") for i := range matchers { - matched := checkMatcher(&matchers[i], resp, body) + matched := checkMatcher(&matchers[i], mc) if matchers[i].Negative { matched = !matched } @@ -483,29 +503,29 @@ func validateMatchersCondition(condition string) error { } // checkMatcher evaluates a single matcher. -func checkMatcher(m *Matcher, resp *http.Response, body string) bool { +func checkMatcher(m *Matcher, mc *MatchContext) bool { switch m.Type { case "status": for _, status := range m.Status { - if resp.StatusCode == status { + if mc.Resp.StatusCode == status { return true } } return false case "word": - return checkWords(getPart(m.Part, resp, body), m.Words, m.Condition, m.CaseInsensitive) + return checkWords(getPart(m.Part, mc.Resp, mc.Body), m.Words, m.Condition, m.CaseInsensitive) case "regex": - return checkRegex(getPart(m.Part, resp, body), m.Regex, m.Condition) + return checkRegex(getPart(m.Part, mc.Resp, mc.Body), m.Regex, m.Condition) case "favicon": - return checkFaviconHash(body, m.Hash) + return checkFaviconHash(mc.Body, m.Hash) case "size": // size matches the response body length against any listed value. for _, n := range m.Size { - if len(body) == n { + if len(mc.Body) == n { return true } } @@ -514,9 +534,9 @@ func checkMatcher(m *Matcher, resp *http.Response, body string) bool { case "range": switch strings.ToLower(m.Source) { case "status": - return inRange(resp.StatusCode, m.Min, m.Max) + return inRange(mc.Resp.StatusCode, m.Min, m.Max) case "size", "": - return inRange(len(body), m.Min, m.Max) + return inRange(len(mc.Body), m.Min, m.Max) default: return false } diff --git a/internal/modules/favicon_test.go b/internal/modules/favicon_test.go index 9f23c9a6..a536b2e2 100644 --- a/internal/modules/favicon_test.go +++ b/internal/modules/favicon_test.go @@ -54,7 +54,7 @@ func TestCheckMatcherFavicon(t *testing.T) { t.Run(tt.name, func(t *testing.T) { m := &Matcher{Type: "favicon", Hash: tt.hashes} resp := fakeResponse(t, 200, nil) - if got := checkMatcher(m, resp, body); got != tt.expect { + if got := checkMatcher(m, &MatchContext{Resp: resp, Body: body}); got != tt.expect { t.Errorf("checkMatcher favicon = %v, want %v", got, tt.expect) } }) @@ -212,7 +212,7 @@ func TestCheckMatcherFaviconNegative(t *testing.T) { signed := int64(fingerprint.FaviconHash(faviconFixture)) matchers := []Matcher{{Type: "favicon", Hash: []int64{signed}, Negative: true}} resp := fakeResponse(t, 200, nil) - if checkMatchers(matchers, "", resp, string(faviconFixture)) { + if checkMatchers(matchers, "", &MatchContext{Resp: resp, Body: string(faviconFixture)}) { t.Error("negative favicon matcher should not match its own hash") } } diff --git a/internal/modules/matchers_condition_test.go b/internal/modules/matchers_condition_test.go index 31a63229..131eac6d 100644 --- a/internal/modules/matchers_condition_test.go +++ b/internal/modules/matchers_condition_test.go @@ -53,7 +53,7 @@ func TestCheckMatchersCondition(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := checkMatchers(tt.matchers, tt.condition, resp, body); got != tt.expect { + if got := checkMatchers(tt.matchers, tt.condition, &MatchContext{Resp: resp, Body: body}); got != tt.expect { t.Errorf("checkMatchers(%q) = %v, want %v", tt.condition, got, tt.expect) } }) diff --git a/internal/modules/matchers_range_test.go b/internal/modules/matchers_range_test.go index f270794f..b2335a24 100644 --- a/internal/modules/matchers_range_test.go +++ b/internal/modules/matchers_range_test.go @@ -51,7 +51,7 @@ func TestCheckMatcherRange(t *testing.T) { t.Run("status source in range", func(t *testing.T) { resp := fakeResponse(t, 503, nil) m := &Matcher{Type: "range", Source: "status", Min: intp(500), Max: intp(599)} - if !checkMatcher(m, resp, "") { + if !checkMatcher(m, &MatchContext{Resp: resp}) { t.Error("expected 503 to be within 500-599") } }) @@ -59,7 +59,7 @@ func TestCheckMatcherRange(t *testing.T) { t.Run("status source out of range", func(t *testing.T) { resp := fakeResponse(t, 200, nil) m := &Matcher{Type: "range", Source: "status", Min: intp(500), Max: intp(599)} - if checkMatcher(m, resp, "") { + if checkMatcher(m, &MatchContext{Resp: resp}) { t.Error("expected 200 to be outside 500-599") } }) @@ -67,7 +67,7 @@ func TestCheckMatcherRange(t *testing.T) { t.Run("size source default", func(t *testing.T) { resp := fakeResponse(t, 200, nil) m := &Matcher{Type: "range", Min: intp(5), Max: intp(20)} - if !checkMatcher(m, resp, "twelve chars") { + if !checkMatcher(m, &MatchContext{Resp: resp, Body: "twelve chars"}) { t.Error("expected body length within bounds to match") } }) @@ -75,7 +75,7 @@ func TestCheckMatcherRange(t *testing.T) { t.Run("size source explicit", func(t *testing.T) { resp := fakeResponse(t, 200, nil) m := &Matcher{Type: "range", Source: "size", Min: intp(1000)} - if checkMatcher(m, resp, "short") { + if checkMatcher(m, &MatchContext{Resp: resp, Body: "short"}) { t.Error("expected short body to miss a high min bound") } }) diff --git a/internal/modules/matchers_test.go b/internal/modules/matchers_test.go index 5217d3f6..9139a631 100644 --- a/internal/modules/matchers_test.go +++ b/internal/modules/matchers_test.go @@ -50,7 +50,7 @@ func TestCheckMatcherStatus(t *testing.T) { t.Run(tt.name, func(t *testing.T) { m := &Matcher{Type: "status", Status: tt.want} resp := fakeResponse(t, tt.status, nil) - if got := checkMatcher(m, resp, ""); got != tt.expect { + if got := checkMatcher(m, &MatchContext{Resp: resp, Body: ""}); got != tt.expect { t.Errorf("checkMatcher status = %v, want %v", got, tt.expect) } }) @@ -77,7 +77,7 @@ func TestCheckMatcherWord(t *testing.T) { t.Run(tt.name, func(t *testing.T) { m := &Matcher{Type: "word", Part: "body", Words: tt.words, Condition: tt.condition} resp := fakeResponse(t, 200, nil) - if got := checkMatcher(m, resp, body); got != tt.expect { + if got := checkMatcher(m, &MatchContext{Resp: resp, Body: body}); got != tt.expect { t.Errorf("checkMatcher word = %v, want %v", got, tt.expect) } }) @@ -107,7 +107,7 @@ func TestCheckMatcherRegex(t *testing.T) { t.Run(tt.name, func(t *testing.T) { m := &Matcher{Type: "regex", Part: "body", Regex: tt.patterns, Condition: tt.condition} resp := fakeResponse(t, 200, nil) - if got := checkMatcher(m, resp, body); got != tt.expect { + if got := checkMatcher(m, &MatchContext{Resp: resp, Body: body}); got != tt.expect { t.Errorf("checkMatcher regex = %v, want %v", got, tt.expect) } }) @@ -119,13 +119,13 @@ func TestCheckMatcherHeaderPart(t *testing.T) { resp := fakeResponse(t, 200, header) m := &Matcher{Type: "word", Part: "header", Words: []string{"PHP/8.1"}} - if !checkMatcher(m, resp, "body-content") { + if !checkMatcher(m, &MatchContext{Resp: resp, Body: "body-content"}) { t.Error("expected header-part word matcher to hit on header value") } // the same word lives only in the header, so a body-part matcher must miss. mBody := &Matcher{Type: "word", Part: "body", Words: []string{"PHP/8.1"}} - if checkMatcher(mBody, resp, "body-content") { + if checkMatcher(mBody, &MatchContext{Resp: resp, Body: "body-content"}) { t.Error("body-part matcher should not see header-only value") } } @@ -133,7 +133,7 @@ func TestCheckMatcherHeaderPart(t *testing.T) { func TestCheckMatcherUnknownType(t *testing.T) { m := &Matcher{Type: "size", Part: "body"} resp := fakeResponse(t, 200, nil) - if checkMatcher(m, resp, "anything") { + if checkMatcher(m, &MatchContext{Resp: resp, Body: "anything"}) { t.Error("unknown matcher type should not match") } } @@ -186,7 +186,7 @@ func TestCheckMatchers(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := checkMatchers(tt.matchers, "", resp, body); got != tt.expect { + if got := checkMatchers(tt.matchers, "", &MatchContext{Resp: resp, Body: body}); got != tt.expect { t.Errorf("checkMatchers = %v, want %v", got, tt.expect) } }) diff --git a/internal/modules/module.go b/internal/modules/module.go index 29d4d3d3..6cda7dcc 100644 --- a/internal/modules/module.go +++ b/internal/modules/module.go @@ -107,6 +107,19 @@ type Matcher struct { CaseInsensitive bool `yaml:"case-insensitive,omitempty"` } +// MatchContext carries everything a matcher can evaluate against one response, +// so a new matcher type can read a field the classic ones ignore without +// changing the signature of the whole engine again. +type MatchContext struct { + Resp *http.Response + Body string + URL string + Duration time.Duration + // Extracted is the running variable set in a request chain, so it also + // carries earlier steps' values. + Extracted map[string]string +} + // Extractor defines data extraction from responses. // Extractors pull specific data from matched responses for reporting. type Extractor struct {