mirror of
https://github.com/lunchcat/sif.git
synced 2026-01-13 05:16:44 -08:00
402 lines
9.4 KiB
Go
402 lines
9.4 KiB
Go
/*
|
|
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
|
: :
|
|
: █▀ █ █▀▀ · 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
|
|
}
|