mirror of
https://github.com/lunchcat/sif.git
synced 2026-06-12 19:11:25 -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.
271 lines
9.6 KiB
Go
271 lines
9.6 KiB
Go
/*
|
|
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
|
: :
|
|
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
|
: ▄█ █ █▀ · BSD 3-Clause License :
|
|
: :
|
|
: (c) 2022-2026 vmfunc, xyzeva, :
|
|
: lunchcat alumni & contributors :
|
|
: :
|
|
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
|
*/
|
|
|
|
package modules
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/dropalldatabases/sif/internal/httpx"
|
|
)
|
|
|
|
const testTimeout = 5 * time.Second
|
|
|
|
// TestExecuteHTTPModuleMatchAndExtract drives the full executor against a live
|
|
// httptest server: a request hits a path, a matcher fires, an extractor captures.
|
|
func TestExecuteHTTPModuleMatchAndExtract(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/admin" {
|
|
w.Header().Set("X-App", "demo")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`flag{found-it} session=sess-4242`))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
def := &YAMLModule{
|
|
ID: "test-http-hit",
|
|
Type: TypeHTTP,
|
|
Info: YAMLModuleInfo{Severity: "high"},
|
|
HTTP: &HTTPConfig{
|
|
Method: "GET",
|
|
Paths: []string{"{{BaseURL}}/admin", "{{BaseURL}}/missing"},
|
|
Matchers: []Matcher{
|
|
{Type: "status", Status: []int{200}},
|
|
{Type: "word", Part: "body", Words: []string{"flag{found-it}"}},
|
|
},
|
|
Extractors: []Extractor{
|
|
{Type: "regex", Name: "session", Part: "body", Regex: []string{`session=(\S+)`}, Group: 1},
|
|
},
|
|
},
|
|
}
|
|
|
|
// route through the shared httpx client so proxy/-H/-rate-limit would apply.
|
|
opts := Options{Timeout: testTimeout, Client: httpx.Client(testTimeout)}
|
|
|
|
result, err := ExecuteHTTPModule(context.Background(), srv.URL, def, opts)
|
|
if err != nil {
|
|
t.Fatalf("ExecuteHTTPModule: %v", err)
|
|
}
|
|
|
|
// only /admin satisfies status+word, /missing returns 404.
|
|
if len(result.Findings) != 1 {
|
|
t.Fatalf("got %d findings, want 1", len(result.Findings))
|
|
}
|
|
f := result.Findings[0]
|
|
if f.Severity != "high" {
|
|
t.Errorf("severity = %q, want high (carried from Info)", f.Severity)
|
|
}
|
|
if f.Extracted["session"] != "sess-4242" {
|
|
t.Errorf("extracted session = %q, want sess-4242", f.Extracted["session"])
|
|
}
|
|
if f.URL != srv.URL+"/admin" {
|
|
t.Errorf("finding url = %q, want %q", f.URL, srv.URL+"/admin")
|
|
}
|
|
}
|
|
|
|
// TestExecuteHTTPModuleNoMatch confirms a module that matches nothing reports
|
|
// zero findings without erroring.
|
|
func TestExecuteHTTPModuleNoMatch(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("nothing interesting"))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
def := &YAMLModule{
|
|
ID: "test-http-miss",
|
|
Type: TypeHTTP,
|
|
HTTP: &HTTPConfig{
|
|
Paths: []string{"{{BaseURL}}/"},
|
|
Matchers: []Matcher{
|
|
{Type: "word", Part: "body", Words: []string{"never-present"}},
|
|
},
|
|
},
|
|
}
|
|
|
|
result, err := ExecuteHTTPModule(context.Background(), srv.URL, def, Options{Timeout: testTimeout, Client: httpx.Client(testTimeout)})
|
|
if err != nil {
|
|
t.Fatalf("ExecuteHTTPModule: %v", err)
|
|
}
|
|
if len(result.Findings) != 0 {
|
|
t.Fatalf("got %d findings, want 0", len(result.Findings))
|
|
}
|
|
}
|
|
|
|
// TestExecuteHTTPModulePayloadExpansion verifies payload templates reach the
|
|
// server and the matching response is captured.
|
|
func TestExecuteHTTPModulePayloadExpansion(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// only the "boom" payload triggers the vulnerable branch.
|
|
if r.URL.Query().Get("q") == "boom" {
|
|
_, _ = w.Write([]byte("error: sql syntax near boom"))
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte("ok"))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
def := &YAMLModule{
|
|
ID: "test-http-payload",
|
|
Type: TypeHTTP,
|
|
HTTP: &HTTPConfig{
|
|
Paths: []string{"{{BaseURL}}/search?q={{payload}}"},
|
|
Payloads: []string{"safe", "boom"},
|
|
Matchers: []Matcher{
|
|
{Type: "word", Part: "body", Words: []string{"sql syntax"}},
|
|
},
|
|
},
|
|
}
|
|
|
|
result, err := ExecuteHTTPModule(context.Background(), srv.URL, def, Options{Timeout: testTimeout, Client: httpx.Client(testTimeout)})
|
|
if err != nil {
|
|
t.Fatalf("ExecuteHTTPModule: %v", err)
|
|
}
|
|
if len(result.Findings) != 1 {
|
|
t.Fatalf("got %d findings, want 1 (only boom payload)", len(result.Findings))
|
|
}
|
|
}
|
|
|
|
func TestExecuteHTTPModuleNoConfig(t *testing.T) {
|
|
def := &YAMLModule{ID: "x", Type: TypeHTTP}
|
|
if _, err := ExecuteHTTPModule(context.Background(), "http://h", def, Options{}); err == nil {
|
|
t.Fatal("expected error when HTTP config is nil")
|
|
}
|
|
}
|
|
|
|
// TestExecuteHTTPModuleContextCancel pins the cancellation path. The dispatch
|
|
// loop selects between ctx.Done() and the concurrency semaphore, so a cancelled
|
|
// context can either short-circuit with ctx.Err() or let the in-flight request
|
|
// fail on the dead context. Both are correct: the contract is "never hang, never
|
|
// invent a finding", which is what we assert here rather than forcing one race
|
|
// winner (that made this test flaky under -count).
|
|
func TestExecuteHTTPModuleContextCancel(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
def := &YAMLModule{
|
|
ID: "test-http-cancel",
|
|
Type: TypeHTTP,
|
|
HTTP: &HTTPConfig{
|
|
Paths: []string{"{{BaseURL}}/a"},
|
|
Matchers: []Matcher{{Type: "status", Status: []int{200}}},
|
|
},
|
|
}
|
|
|
|
result, err := ExecuteHTTPModule(ctx, srv.URL, def, Options{Timeout: testTimeout, Client: httpx.Client(testTimeout)})
|
|
if err != nil {
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("err = %v, want context.Canceled or nil", err)
|
|
}
|
|
return
|
|
}
|
|
// no error means the request was dispatched but failed on the dead context;
|
|
// either way a cancelled scan must not surface findings.
|
|
if len(result.Findings) != 0 {
|
|
t.Fatalf("cancelled scan produced %d findings, want 0", len(result.Findings))
|
|
}
|
|
}
|
|
|
|
// TestExecuteDNSModuleUnsupported pins the current behavior: DNS execution is
|
|
// not implemented and must signal it via ErrUnsupportedModuleType, not by
|
|
// quietly returning an empty (successful-looking) result.
|
|
func TestExecuteDNSModuleUnsupported(t *testing.T) {
|
|
def := &YAMLModule{ID: "dns-mod", Type: TypeDNS, DNS: &DNSConfig{Type: "A"}}
|
|
result, err := ExecuteDNSModule(context.Background(), "example.com", def, Options{})
|
|
if result != nil {
|
|
t.Errorf("result = %v, want nil for unsupported type", result)
|
|
}
|
|
if !errors.Is(err, ErrUnsupportedModuleType) {
|
|
t.Fatalf("err = %v, want ErrUnsupportedModuleType", err)
|
|
}
|
|
}
|
|
|
|
func TestExecuteTCPModuleUnsupported(t *testing.T) {
|
|
def := &YAMLModule{ID: "tcp-mod", Type: TypeTCP, TCP: &TCPConfig{Port: 22}}
|
|
result, err := ExecuteTCPModule(context.Background(), "example.com", def, Options{})
|
|
if result != nil {
|
|
t.Errorf("result = %v, want nil for unsupported type", result)
|
|
}
|
|
if !errors.Is(err, ErrUnsupportedModuleType) {
|
|
t.Fatalf("err = %v, want ErrUnsupportedModuleType", err)
|
|
}
|
|
}
|
|
|
|
// TestWrapperExecuteRoutesByType confirms the Module wrapper dispatches each
|
|
// type to the right executor and propagates the unsupported-type sentinel.
|
|
func TestWrapperExecuteRoutesByType(t *testing.T) {
|
|
t.Run("dns routes to unsupported", func(t *testing.T) {
|
|
def := &YAMLModule{ID: "d", Type: TypeDNS, DNS: &DNSConfig{}}
|
|
w := newYAMLModuleWrapper(def, "d.yaml")
|
|
if _, err := w.Execute(context.Background(), "t", Options{}); !errors.Is(err, ErrUnsupportedModuleType) {
|
|
t.Fatalf("err = %v, want ErrUnsupportedModuleType", err)
|
|
}
|
|
})
|
|
|
|
t.Run("tcp routes to unsupported", func(t *testing.T) {
|
|
def := &YAMLModule{ID: "t", Type: TypeTCP, TCP: &TCPConfig{}}
|
|
w := newYAMLModuleWrapper(def, "t.yaml")
|
|
if _, err := w.Execute(context.Background(), "t", Options{}); !errors.Is(err, ErrUnsupportedModuleType) {
|
|
t.Fatalf("err = %v, want ErrUnsupportedModuleType", err)
|
|
}
|
|
})
|
|
|
|
t.Run("missing http config errors", func(t *testing.T) {
|
|
def := &YAMLModule{ID: "h", Type: TypeHTTP}
|
|
w := newYAMLModuleWrapper(def, "h.yaml")
|
|
if _, err := w.Execute(context.Background(), "t", Options{}); err == nil {
|
|
t.Fatal("expected error for missing http config")
|
|
}
|
|
})
|
|
|
|
t.Run("unknown type errors", func(t *testing.T) {
|
|
def := &YAMLModule{ID: "z", Type: ModuleType("bogus")}
|
|
w := newYAMLModuleWrapper(def, "z.yaml")
|
|
if _, err := w.Execute(context.Background(), "t", Options{}); err == nil {
|
|
t.Fatal("expected error for unknown module type")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestTruncateEvidence(t *testing.T) {
|
|
short := "short evidence"
|
|
if got := truncateEvidence(short); got != short {
|
|
t.Errorf("short evidence changed: %q", got)
|
|
}
|
|
|
|
long := make([]byte, 600)
|
|
for i := range long {
|
|
long[i] = 'a'
|
|
}
|
|
got := truncateEvidence(string(long))
|
|
// 500 chars of content plus the ellipsis marker.
|
|
if len(got) != 503 {
|
|
t.Errorf("truncated len = %d, want 503", len(got))
|
|
}
|
|
if got[len(got)-3:] != "..." {
|
|
t.Errorf("truncated evidence missing ellipsis: %q", got[len(got)-3:])
|
|
}
|
|
}
|