mirror of
https://github.com/lunchcat/sif.git
synced 2026-01-19 08:00:55 -08:00
feat: add yaml module parser and http executor
This commit is contained in:
401
internal/modules/executor.go
Normal file
401
internal/modules/executor.go
Normal file
@@ -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
|
||||
}
|
||||
143
internal/modules/yaml.go
Normal file
143
internal/modules/yaml.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user