Files
sif/internal/modules/executor.go
T
c2dbe4a19f feat(modules): add dynamic-variable request chaining (#317)
http modules could only fire independent one-shot requests: an extractor
recorded a value for reporting, but nothing could feed it back into a
later request. add a requests: chain where steps run in order sharing a
variable map, so a step's extractors populate {{name}} references in the
path, headers and body of later steps.

a step with matchers records a finding on a match and halts the chain on
a miss, so a login or setup step can gate an authenticated follow-up. the
single-request form is unchanged and stays the concurrent default when no
chain is defined.

Co-authored-by: vmfunc <vmfunc.lc@gmail.com>
2026-07-22 22:45:50 +00:00

689 lines
18 KiB
Go

/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package modules
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"regexp"
"strings"
"sync"
"time"
"github.com/tidwall/gjson"
)
// MaxBodySize limits response body to prevent memory exhaustion.
const MaxBodySize = 5 * 1024 * 1024
// ErrUnsupportedModuleType signals an executor for a module type that is not
// yet implemented. Returning it (rather than an empty result) keeps callers
// from mistaking "not implemented" for "scanned, found nothing".
var ErrUnsupportedModuleType = errors.New("unsupported module type")
// httpRequest represents a generated HTTP request.
type httpRequest struct {
Method string
URL string
Headers map[string]string
Body string
Payload string
Original string // Original path template
}
// ExecuteHTTPModule runs an HTTP-based module.
func ExecuteHTTPModule(ctx context.Context, target string, def *YAMLModule, opts Options) (*Result, error) {
if def.HTTP == nil {
return nil, fmt.Errorf("no HTTP configuration")
}
cfg := def.HTTP
result := &Result{
ModuleID: def.ID,
Target: target,
Findings: make([]Finding, 0),
}
// Create HTTP client
client := opts.Client
if client == nil {
client = &http.Client{
Timeout: opts.Timeout,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
}
}
// disable-redirects only applies to this module's requests; opts.Client may
// be the shared httpx client reused by every other module in the run, so a
// module-scoped policy shallow-copies it (keeping the pooled Transport)
// rather than mutating CheckRedirect on the shared instance.
if cfg.DisableRedirects {
scoped := *client
scoped.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
}
client = &scoped
}
// a module with an explicit request chain runs its steps in order, threading
// extracted variables between them; the concurrent single-request path below
// stays the default when no chain is defined.
if len(cfg.Requests) > 0 {
return executeHTTPChain(ctx, client, target, def)
}
// Generate requests based on paths and payloads
requests, err := generateHTTPRequests(target, cfg)
if err != nil {
return nil, err
}
// Determine thread count
threads := cfg.Threads
if threads == 0 {
threads = opts.Threads
}
if threads == 0 {
threads = 10
}
// Execute requests concurrently
var wg sync.WaitGroup
var mu sync.Mutex
resultsChan := make(chan Finding, len(requests))
// Limit concurrency
sem := make(chan struct{}, threads)
for _, req := range requests {
select {
case <-ctx.Done():
return result, ctx.Err()
case sem <- struct{}{}:
}
wg.Add(1)
go func(r *httpRequest) {
defer wg.Done()
defer func() { <-sem }()
finding, ok := executeHTTPRequest(ctx, client, r, cfg, def.Info.Severity)
if ok {
resultsChan <- finding
}
}(req)
}
// Collect results
go func() {
wg.Wait()
close(resultsChan)
}()
for finding := range resultsChan {
mu.Lock()
result.Findings = append(result.Findings, finding)
mu.Unlock()
}
return result, nil
}
// executeHTTPChain runs a module's ordered request chain. steps run in sequence
// sharing one variable map: each step's extractors feed {{name}} references in
// later steps' path, headers and body. a step with matchers records a finding
// on a match and halts the chain on a miss, so a login/setup step can gate an
// authenticated follow-up. steps are sequential by construction (a later step
// depends on an earlier one), so this path is not concurrent.
func executeHTTPChain(ctx context.Context, client *http.Client, target string, def *YAMLModule) (*Result, error) {
result := &Result{
ModuleID: def.ID,
Target: target,
Findings: make([]Finding, 0),
}
base := strings.TrimSuffix(target, "/")
vars := make(map[string]string)
for i := range def.HTTP.Requests {
if err := ctx.Err(); err != nil {
return result, err
}
step := &def.HTTP.Requests[i]
method := step.Method
if method == "" {
method = "GET"
}
url := substituteVariablesWithVars(step.Path, base, "", vars)
var bodyReader io.Reader
if bodyStr := substituteVariablesWithVars(step.Body, base, "", vars); bodyStr != "" {
bodyReader = strings.NewReader(bodyStr)
}
req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
if err != nil {
return result, nil
}
for k, v := range step.Headers {
req.Header.Set(k, substituteVariablesWithVars(v, base, "", vars))
}
if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", defaultUserAgent)
}
resp, err := client.Do(req)
if err != nil {
// a transport error breaks the chain; return whatever matched earlier.
return result, nil
}
respBody, err := io.ReadAll(io.LimitReader(resp.Body, MaxBodySize))
resp.Body.Close()
if err != nil {
return result, nil
}
respStr := string(respBody)
// feed this step's extractions into the shared vars for later steps,
// regardless of whether the step also carries matchers.
for k, v := range runExtractors(step.Extractors, resp, respStr) {
vars[k] = v
}
// 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) {
break
}
result.Findings = append(result.Findings, Finding{
URL: url,
Severity: def.Info.Severity,
Evidence: truncateEvidence(respStr),
Extracted: snapshotVars(vars),
})
}
}
return result, nil
}
// snapshotVars copies the running chain variables so each finding carries an
// independent view rather than aliasing the map that later steps keep mutating.
func snapshotVars(vars map[string]string) map[string]string {
if len(vars) == 0 {
return nil
}
out := make(map[string]string, len(vars))
for k, v := range vars {
out[k] = v
}
return out
}
// generateHTTPRequests creates all requests based on paths and payloads.
func generateHTTPRequests(target string, cfg *HTTPConfig) ([]*httpRequest, error) {
var requests []*httpRequest
paths, err := resolvePaths(cfg)
if err != nil {
return nil, err
}
// Ensure target has no trailing slash
target = strings.TrimSuffix(target, "/")
method := cfg.Method
if method == "" {
method = "GET"
}
// If no payloads, just use paths directly
if len(cfg.Payloads) == 0 {
for _, path := range paths {
url := substituteVariables(path, target, "")
requests = append(requests, &httpRequest{
Method: method,
URL: url,
Headers: cfg.Headers,
Body: cfg.Body,
Original: path,
})
}
return requests, nil
}
// pitchfork pairs path[i] with payload[i] and stops at the shorter list;
// clusterbomb (default) crosses every path with every payload.
if strings.EqualFold(cfg.Attack, "pitchfork") {
n := len(paths)
if len(cfg.Payloads) < n {
n = len(cfg.Payloads)
}
for i := 0; i < n; i++ {
requests = append(requests, newPayloadRequest(method, target, paths[i], cfg.Payloads[i], cfg))
}
return requests, nil
}
for _, path := range paths {
for _, payload := range cfg.Payloads {
requests = append(requests, newPayloadRequest(method, target, path, payload, cfg))
}
}
return requests, nil
}
// resolvePaths expands a wordlist over any {{word}} path templates so one
// "{{BaseURL}}/{{word}}" path fuzzes the whole list; paths without {{word}}
// pass through literally. no wordlist leaves cfg.Paths untouched.
func resolvePaths(cfg *HTTPConfig) ([]string, error) {
if cfg.Wordlist == "" {
return cfg.Paths, nil
}
words, err := loadWordlist(cfg.Wordlist)
if err != nil {
return nil, err
}
var paths []string
for _, path := range cfg.Paths {
if !strings.Contains(path, "{{word}}") && !strings.Contains(path, "{{Word}}") {
paths = append(paths, path)
continue
}
for _, word := range words {
expanded := strings.ReplaceAll(path, "{{word}}", word)
expanded = strings.ReplaceAll(expanded, "{{Word}}", word)
paths = append(paths, expanded)
}
}
return paths, nil
}
// loadWordlist reads non-empty lines from a local wordlist file, mirroring the
// dirlist scanner's scanLines so a converted module fuzzes the identical words.
func loadWordlist(path string) ([]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open wordlist %q: %w", path, err)
}
defer f.Close()
var words []string
scanner := bufio.NewScanner(f)
scanner.Split(bufio.ScanLines)
for scanner.Scan() {
if line := scanner.Text(); line != "" {
words = append(words, line)
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("read wordlist %q: %w", path, err)
}
return words, nil
}
// newPayloadRequest builds one request with the path and body templates
// substituted for the given payload.
func newPayloadRequest(method, target, path, payload string, cfg *HTTPConfig) *httpRequest {
return &httpRequest{
Method: method,
URL: substituteVariables(path, target, payload),
Headers: cfg.Headers,
Body: substituteVariables(cfg.Body, target, payload),
Payload: payload,
Original: path,
}
}
// validateAttack rejects an attack mode that is not "", "clusterbomb", or
// "pitchfork"; an empty value defaults to clusterbomb.
func validateAttack(attack string) error {
switch strings.ToLower(attack) {
case "", "clusterbomb", "pitchfork":
return nil
default:
return fmt.Errorf("invalid attack %q (want \"clusterbomb\" or \"pitchfork\")", attack)
}
}
// substituteVariables replaces template variables in a string.
func substituteVariables(template, baseURL, payload string) string {
result := template
result = strings.ReplaceAll(result, "{{BaseURL}}", baseURL)
result = strings.ReplaceAll(result, "{{baseurl}}", baseURL)
result = strings.ReplaceAll(result, "{{payload}}", payload)
result = strings.ReplaceAll(result, "{{Payload}}", payload)
return result
}
// substituteVariablesWithVars extends substituteVariables with the named values
// a request chain accumulates: after {{BaseURL}}/{{payload}} it replaces every
// {{name}} with the value an earlier step extracted under that name.
func substituteVariablesWithVars(template, baseURL, payload string, vars map[string]string) string {
result := substituteVariables(template, baseURL, payload)
for name, value := range vars {
result = strings.ReplaceAll(result, "{{"+name+"}}", value)
}
return result
}
// defaultUserAgent is sent when a module doesn't set its own User-Agent header.
const defaultUserAgent = "Mozilla/5.0 (compatible; sif/1.0)"
// executeHTTPRequest executes a single HTTP request and checks matchers.
func executeHTTPRequest(ctx context.Context, client *http.Client, r *httpRequest, cfg *HTTPConfig, severity string) (Finding, bool) {
var body io.Reader
if r.Body != "" {
body = strings.NewReader(r.Body)
}
req, err := http.NewRequestWithContext(ctx, r.Method, r.URL, body)
if err != nil {
return Finding{}, false
}
// Set headers
for k, v := range r.Headers {
req.Header.Set(k, v)
}
if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", defaultUserAgent)
}
resp, err := client.Do(req)
if err != nil {
return Finding{}, false
}
defer resp.Body.Close()
// Read body with limit
respBody, err := io.ReadAll(io.LimitReader(resp.Body, MaxBodySize))
if err != nil {
return Finding{}, false
}
bodyStr := string(respBody)
// Check matchers
if !checkMatchers(cfg.Matchers, cfg.MatchersCondition, resp, bodyStr) {
return Finding{}, false
}
// Extract data
extracted := runExtractors(cfg.Extractors, resp, bodyStr)
// favicon-only matches fire on binary icon bytes; report the hash, not the body.
evidence := truncateEvidence(bodyStr)
if fav, ok := faviconEvidence(cfg.Matchers, bodyStr); ok {
evidence = fav
}
return Finding{
URL: r.URL,
Severity: severity,
Evidence: evidence,
Extracted: extracted,
}, true
}
// checkMatchers combines matchers with condition "and" (default, all match) or "or" (any).
func checkMatchers(matchers []Matcher, condition string, resp *http.Response, body string) bool {
if len(matchers) == 0 {
return false
}
or := strings.EqualFold(condition, "or")
for i := range matchers {
matched := checkMatcher(&matchers[i], resp, body)
if matchers[i].Negative {
matched = !matched
}
if or && matched {
return true
}
if !or && !matched {
return false
}
}
// and: all matched; or: none matched.
return !or
}
// validateMatchersCondition rejects a matchers-condition that is not "", "and", or "or".
func validateMatchersCondition(condition string) error {
switch strings.ToLower(condition) {
case "", "and", "or":
return nil
default:
return fmt.Errorf("invalid matchers-condition %q (want \"and\" or \"or\")", condition)
}
}
// checkMatcher evaluates a single matcher.
func checkMatcher(m *Matcher, resp *http.Response, body string) bool {
switch m.Type {
case "status":
for _, status := range m.Status {
if resp.StatusCode == status {
return true
}
}
return false
case "word":
return checkWords(getPart(m.Part, resp, body), m.Words, m.Condition, m.CaseInsensitive)
case "regex":
return checkRegex(getPart(m.Part, resp, body), m.Regex, m.Condition)
case "favicon":
return checkFaviconHash(body, m.Hash)
case "size":
// size matches the response body length against any listed value.
for _, n := range m.Size {
if len(body) == n {
return true
}
}
return false
case "range":
switch strings.ToLower(m.Source) {
case "status":
return inRange(resp.StatusCode, m.Min, m.Max)
case "size", "":
return inRange(len(body), m.Min, m.Max)
default:
return false
}
default:
return false
}
}
// inRange reports whether v is within the inclusive bounds; a nil bound is open.
func inRange(v int, lo, hi *int) bool {
if lo != nil && v < *lo {
return false
}
if hi != nil && v > *hi {
return false
}
return true
}
// getPart extracts the relevant part of the response.
func getPart(part string, resp *http.Response, body string) string {
switch part {
case "header", "headers":
var sb strings.Builder
for k, v := range resp.Header {
sb.WriteString(k)
sb.WriteString(": ")
sb.WriteString(strings.Join(v, ", "))
sb.WriteString("\n")
}
return sb.String()
case "body":
return body
case "all", "":
var sb strings.Builder
for k, v := range resp.Header {
sb.WriteString(k)
sb.WriteString(": ")
sb.WriteString(strings.Join(v, ", "))
sb.WriteString("\n")
}
sb.WriteString("\n")
sb.WriteString(body)
return sb.String()
default:
return body
}
}
// checkWords checks if any/all words are found.
func checkWords(content string, words []string, condition string, caseInsensitive bool) bool {
if caseInsensitive {
content = strings.ToLower(content)
}
fold := func(w string) string {
if caseInsensitive {
return strings.ToLower(w)
}
return w
}
if condition == "or" {
for _, word := range words {
if strings.Contains(content, fold(word)) {
return true
}
}
return false
}
// Default to AND
for _, word := range words {
if !strings.Contains(content, fold(word)) {
return false
}
}
return true
}
// checkRegex checks if any/all regex patterns match.
func checkRegex(content string, patterns []string, condition string) bool {
if condition == "or" {
for _, pattern := range patterns {
re, err := regexp.Compile(pattern)
if err != nil {
continue
}
if re.MatchString(content) {
return true
}
}
return false
}
// Default to AND
for _, pattern := range patterns {
re, err := regexp.Compile(pattern)
if err != nil {
return false
}
if !re.MatchString(content) {
return false
}
}
return true
}
// runExtractors extracts data from the response.
func runExtractors(extractors []Extractor, resp *http.Response, body string) map[string]string {
if len(extractors) == 0 {
return nil
}
result := make(map[string]string)
for _, e := range extractors {
switch e.Type {
case "regex":
part := getPart(e.Part, resp, body)
for _, pattern := range e.Regex {
re, err := regexp.Compile(pattern)
if err != nil {
continue
}
matches := re.FindStringSubmatch(part)
if e.Group >= 0 && len(matches) > e.Group {
result[e.Name] = matches[e.Group]
break
}
}
case "kv":
// kv records response header key/values, namespaced by the extractor
// name when set (e.g. a headers module surfacing every header).
for k, v := range resp.Header {
key := k
if e.Name != "" {
key = e.Name + "." + k
}
result[key] = strings.Join(v, ", ")
}
case "json":
part := getPart(e.Part, resp, body)
for _, path := range e.JSON {
if r := gjson.Get(part, path); r.Exists() {
result[e.Name] = r.String()
break
}
}
}
}
return result
}
// truncateEvidence limits evidence length for storage.
func truncateEvidence(s string) string {
const maxLen = 500
if len(s) > maxLen {
return s[:maxLen] + "..."
}
return s
}
// ExecuteDNSModule runs a DNS-based module (not yet implemented).
// returns ErrUnsupportedModuleType so the caller logs a clear failure rather
// than reporting an empty (but successful-looking) result.
func ExecuteDNSModule(_ context.Context, _ string, def *YAMLModule, _ Options) (*Result, error) {
return nil, fmt.Errorf("dns module %q: %w", def.ID, ErrUnsupportedModuleType)
}