Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 36 additions & 16 deletions internal/modules/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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{
Expand Down Expand Up @@ -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
}
Expand All @@ -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)
Expand All @@ -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
}
Expand All @@ -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
}
}
Expand All @@ -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
}
Expand Down
4 changes: 2 additions & 2 deletions internal/modules/favicon_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
Expand Down Expand Up @@ -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")
}
}
Expand Down
2 changes: 1 addition & 1 deletion internal/modules/matchers_condition_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
Expand Down
8 changes: 4 additions & 4 deletions internal/modules/matchers_range_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,31 +51,31 @@ 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")
}
})

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")
}
})

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")
}
})

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")
}
})
Expand Down
14 changes: 7 additions & 7 deletions internal/modules/matchers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
Expand All @@ -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)
}
})
Expand Down Expand Up @@ -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)
}
})
Expand All @@ -119,21 +119,21 @@ 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")
}
}

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")
}
}
Expand Down Expand Up @@ -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)
}
})
Expand Down
13 changes: 13 additions & 0 deletions internal/modules/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading