diff --git a/internal/modules/executor.go b/internal/modules/executor.go new file mode 100644 index 0000000..44dde7f --- /dev/null +++ b/internal/modules/executor.go @@ -0,0 +1,401 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2025 vmfunc (Celeste Hickenlooper), xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package modules + +import ( + "context" + "fmt" + "io" + "net/http" + "regexp" + "strings" + "sync" + "time" +) + +// MaxBodySize limits response body to prevent memory exhaustion. +const MaxBodySize = 5 * 1024 * 1024 + +// 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, + }, + } + } + + // Generate requests based on paths and payloads + requests := generateHTTPRequests(target, cfg) + + // 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 +} + +// generateHTTPRequests creates all requests based on paths and payloads. +func generateHTTPRequests(target string, cfg *HTTPConfig) []*httpRequest { + var requests []*httpRequest + + // 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 cfg.Paths { + url := substituteVariables(path, target, "") + requests = append(requests, &httpRequest{ + Method: method, + URL: url, + Headers: cfg.Headers, + Body: cfg.Body, + Original: path, + }) + } + return requests + } + + // Generate requests with payloads + for _, path := range cfg.Paths { + for _, payload := range cfg.Payloads { + url := substituteVariables(path, target, payload) + body := substituteVariables(cfg.Body, target, payload) + requests = append(requests, &httpRequest{ + Method: method, + URL: url, + Headers: cfg.Headers, + Body: body, + Payload: payload, + Original: path, + }) + } + } + + return requests +} + +// 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 +} + +// 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", "Mozilla/5.0 (compatible; sif/1.0)") + } + + 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, resp, bodyStr) { + return Finding{}, false + } + + // Extract data + extracted := runExtractors(cfg.Extractors, resp, bodyStr) + + return Finding{ + URL: r.URL, + Severity: severity, + Evidence: truncateEvidence(bodyStr), + Extracted: extracted, + }, true +} + +// checkMatchers evaluates all matchers against the response. +func checkMatchers(matchers []Matcher, resp *http.Response, body string) bool { + if len(matchers) == 0 { + return false + } + + // Default to AND condition across matchers + for _, m := range matchers { + matched := checkMatcher(m, resp, body) + if m.Negative { + matched = !matched + } + if !matched { + return false // AND logic + } + } + + return true +} + +// checkMatcher evaluates a single matcher. +func checkMatcher(m Matcher, resp *http.Response, body string) bool { + part := getPart(m.Part, resp, body) + + switch m.Type { + case "status": + for _, status := range m.Status { + if resp.StatusCode == status { + return true + } + } + return false + + case "word": + return checkWords(part, m.Words, m.Condition) + + case "regex": + return checkRegex(part, m.Regex, m.Condition) + + default: + return false + } +} + +// 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) bool { + if condition == "or" { + for _, word := range words { + if strings.Contains(content, word) { + return true + } + } + return false + } + // Default to AND + for _, word := range words { + if !strings.Contains(content, 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 { + part := getPart(e.Part, resp, body) + + switch e.Type { + case "regex": + for _, pattern := range e.Regex { + re, err := regexp.Compile(pattern) + if err != nil { + continue + } + matches := re.FindStringSubmatch(part) + if len(matches) > e.Group { + result[e.Name] = matches[e.Group] + 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 (stub for now). +func ExecuteDNSModule(ctx context.Context, target string, def *YAMLModule, opts Options) (*Result, error) { + // TODO: Implement DNS module execution + return &Result{ + ModuleID: def.ID, + Target: target, + Findings: []Finding{}, + }, nil +} + +// ExecuteTCPModule runs a TCP-based module (stub for now). +func ExecuteTCPModule(ctx context.Context, target string, def *YAMLModule, opts Options) (*Result, error) { + // TODO: Implement TCP module execution + return &Result{ + ModuleID: def.ID, + Target: target, + Findings: []Finding{}, + }, nil +} diff --git a/internal/modules/yaml.go b/internal/modules/yaml.go new file mode 100644 index 0000000..c1c5048 --- /dev/null +++ b/internal/modules/yaml.go @@ -0,0 +1,143 @@ +/* +------------------------------------------------------------------------------------------------- +: : +: SIF - Blazing-fast pentesting suite : +: Blaze - BSD 3-Clause License : +: : +: (c) 2022-2025 vmfunc (Celeste Hickenlooper), xyzeva, : +: lunchcat alumni & contributors : +: : +------------------------------------------------------------------------------------------------- +*/ + +package modules + +import ( + "context" + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +// YAMLModule represents a parsed YAML module file +type YAMLModule struct { + ID string `yaml:"id"` + Info YAMLModuleInfo `yaml:"info"` + Type ModuleType `yaml:"type"` + HTTP *HTTPConfig `yaml:"http,omitempty"` + DNS *DNSConfig `yaml:"dns,omitempty"` + TCP *TCPConfig `yaml:"tcp,omitempty"` +} + +// YAMLModuleInfo contains module metadata +type YAMLModuleInfo struct { + Name string `yaml:"name"` + Author string `yaml:"author"` + Severity string `yaml:"severity"` + Description string `yaml:"description"` + Tags []string `yaml:"tags"` +} + +// HTTPConfig defines HTTP module settings +type HTTPConfig struct { + Method string `yaml:"method"` + Paths []string `yaml:"paths"` + Payloads []string `yaml:"payloads,omitempty"` + Headers map[string]string `yaml:"headers,omitempty"` + Body string `yaml:"body,omitempty"` + Attack string `yaml:"attack,omitempty"` // sniper, pitchfork, clusterbomb + Threads int `yaml:"threads,omitempty"` + Matchers []Matcher `yaml:"matchers"` + Extractors []Extractor `yaml:"extractors,omitempty"` +} + +// DNSConfig defines DNS module settings +type DNSConfig struct { + Type string `yaml:"type"` // A, AAAA, MX, TXT, NS, etc. + Name string `yaml:"name"` + Matchers []Matcher `yaml:"matchers"` + Extractors []Extractor `yaml:"extractors,omitempty"` +} + +// TCPConfig defines TCP module settings +type TCPConfig struct { + Port int `yaml:"port"` + Data string `yaml:"data,omitempty"` + Matchers []Matcher `yaml:"matchers"` + Extractors []Extractor `yaml:"extractors,omitempty"` +} + +// ParseYAMLModule parses a YAML file into a module definition +func ParseYAMLModule(path string) (*YAMLModule, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read module file: %w", err) + } + + var ym YAMLModule + if err := yaml.Unmarshal(data, &ym); err != nil { + return nil, fmt.Errorf("parse yaml: %w", err) + } + + if ym.ID == "" { + return nil, fmt.Errorf("module missing required field: id") + } + + if ym.Type == "" { + return nil, fmt.Errorf("module missing required field: type") + } + + return &ym, nil +} + +// yamlModuleWrapper wraps YAMLModule to implement the Module interface +type yamlModuleWrapper struct { + def *YAMLModule + path string +} + +// newYAMLModuleWrapper creates a Module from a YAMLModule definition +func newYAMLModuleWrapper(def *YAMLModule, path string) *yamlModuleWrapper { + return &yamlModuleWrapper{def: def, path: path} +} + +// Info returns the module metadata +func (m *yamlModuleWrapper) Info() Info { + return Info{ + ID: m.def.ID, + Name: m.def.Info.Name, + Author: m.def.Info.Author, + Severity: m.def.Info.Severity, + Description: m.def.Info.Description, + Tags: m.def.Info.Tags, + } +} + +// Type returns the module type +func (m *yamlModuleWrapper) Type() ModuleType { + return m.def.Type +} + +// Execute runs the module (delegates to appropriate executor) +func (m *yamlModuleWrapper) Execute(ctx context.Context, target string, opts Options) (*Result, error) { + switch m.def.Type { + case TypeHTTP: + if m.def.HTTP == nil { + return nil, fmt.Errorf("HTTP module missing http configuration") + } + return ExecuteHTTPModule(ctx, target, m.def, opts) + case TypeDNS: + if m.def.DNS == nil { + return nil, fmt.Errorf("DNS module missing dns configuration") + } + return ExecuteDNSModule(ctx, target, m.def, opts) + case TypeTCP: + if m.def.TCP == nil { + return nil, fmt.Errorf("TCP module missing tcp configuration") + } + return ExecuteTCPModule(ctx, target, m.def, opts) + default: + return nil, fmt.Errorf("unsupported module type: %s", m.def.Type) + } +}