mirror of
https://github.com/lunchcat/sif.git
synced 2026-06-12 11:01:24 -07:00
320fc3d4e7
the yaml module engine (the user-facing extensibility surface) had 0% test coverage. add table-driven tests for the matcher types (status/word/regex + and/or + negative), checkWords/checkRegex (incl invalid-pattern fail-closed under AND, skip under OR), runExtractors (regex capture groups, group-index bounds, part selection), substituteVariables and generateHTTPRequests (path x payload expansion), and ParseYAMLModule on valid + malformed yaml. drive ExecuteHTTPModule end-to-end against an httptest server through the shared httpx client so matcher hits and extractor captures are exercised for real. coverage 0% -> 93.7%. also: ExecuteDNSModule/ExecuteTCPModule were stubs returning an empty result with nil error, so a type:dns/type:tcp module silently reported "0 findings" - indistinguishable from a real clean scan. make them return ErrUnsupportedModuleType (sentinel, wrapped with the module id) so the existing caller logs a clear failure instead. a test pins the new behavior. bodyclose is excluded for test files in .golangci.yml: the synthetic *http.Response fixtures carry no socket, mirroring the existing _test.go slack for errcheck/noctx/gosec.
401 lines
9.9 KiB
Go
401 lines
9.9 KiB
Go
/*
|
|
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
|
: :
|
|
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
|
: ▄█ █ █▀ · BSD 3-Clause License :
|
|
: :
|
|
: (c) 2022-2026 vmfunc, xyzeva, :
|
|
: lunchcat alumni & contributors :
|
|
: :
|
|
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
|
*/
|
|
|
|
package modules
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// 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,
|
|
},
|
|
}
|
|
}
|
|
|
|
// 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 i := range matchers {
|
|
matched := checkMatcher(&matchers[i], resp, body)
|
|
if matchers[i].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)
|
|
|
|
if e.Type == "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 (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)
|
|
}
|
|
|
|
// ExecuteTCPModule runs a TCP-based module (not yet implemented).
|
|
// returns ErrUnsupportedModuleType so the caller logs a clear failure rather
|
|
// than reporting an empty (but successful-looking) result.
|
|
func ExecuteTCPModule(_ context.Context, _ string, def *YAMLModule, _ Options) (*Result, error) {
|
|
return nil, fmt.Errorf("tcp module %q: %w", def.ID, ErrUnsupportedModuleType)
|
|
}
|