Files
sif/internal/modules/chain_test.go
T
c2dbe4a19f feat(modules): add dynamic-variable request chaining (#317)
http modules could only fire independent one-shot requests: an extractor
recorded a value for reporting, but nothing could feed it back into a
later request. add a requests: chain where steps run in order sharing a
variable map, so a step's extractors populate {{name}} references in the
path, headers and body of later steps.

a step with matchers records a finding on a match and halts the chain on
a miss, so a login or setup step can gate an authenticated follow-up. the
single-request form is unchanged and stays the concurrent default when no
chain is defined.

Co-authored-by: vmfunc <vmfunc.lc@gmail.com>
2026-07-22 22:45:50 +00:00

183 lines
6.4 KiB
Go

/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package modules
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/vmfunc/sif/internal/httpx"
)
// TestParseYAMLChainModule pins that the requests: chain schema round-trips from
// yaml into HTTPStep, so a module file can express a chain end to end.
func TestParseYAMLChainModule(t *testing.T) {
const src = `
id: chain-example
type: http
info:
name: chain example
severity: high
http:
requests:
- name: login
path: "{{BaseURL}}/login"
extractors:
- type: json
name: token
json: ["token"]
- name: authed
path: "{{BaseURL}}/secret"
headers:
Authorization: "Bearer {{token}}"
matchers:
- type: word
part: body
words: ["TOP SECRET"]
`
path := filepath.Join(t.TempDir(), "chain-example.yaml")
if err := os.WriteFile(path, []byte(src), 0o600); err != nil {
t.Fatalf("write module: %v", err)
}
def, err := ParseYAMLModule(path)
if err != nil {
t.Fatalf("ParseYAMLModule: %v", err)
}
if def.HTTP == nil || len(def.HTTP.Requests) != 2 {
t.Fatalf("want 2 chain steps, got %+v", def.HTTP)
}
if def.HTTP.Requests[0].Extractors[0].Name != "token" {
t.Errorf("step 1 extractor name = %q, want token", def.HTTP.Requests[0].Extractors[0].Name)
}
if def.HTTP.Requests[1].Headers["Authorization"] != "Bearer {{token}}" {
t.Errorf("step 2 auth header = %q, want templated bearer", def.HTTP.Requests[1].Headers["Authorization"])
}
}
// chainServer is a two-endpoint app: /login hands back a token, /secret only
// serves its body when that token rides in the Authorization header. it's the
// canonical fetch-token-then-use-it flow the request chain exists to express.
func chainServer() *httptest.Server {
const token = "tok-abc123"
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/login":
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"token":"` + token + `"}`))
case "/secret":
if r.Header.Get("Authorization") == "Bearer "+token {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("TOP SECRET DATA"))
return
}
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte("denied"))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
}
func chainModule() *YAMLModule {
return &YAMLModule{
ID: "test-chain",
Type: TypeHTTP,
Info: YAMLModuleInfo{Severity: "high"},
HTTP: &HTTPConfig{
Requests: []HTTPStep{
{
Name: "login",
Path: "{{BaseURL}}/login",
Extractors: []Extractor{{Type: "json", Name: "token", Part: "body", JSON: []string{"token"}}},
},
{
Name: "authed",
Path: "{{BaseURL}}/secret",
Headers: map[string]string{"Authorization": "Bearer {{token}}"},
Matchers: []Matcher{{Type: "word", Part: "body", Words: []string{"TOP SECRET"}}},
},
},
},
}
}
// TestExecuteHTTPChainPropagatesVariable checks the core of chaining: step 1
// extracts a token, step 2 injects it into a header and only then can match the
// protected body. it fails unless the value actually crossed between requests.
func TestExecuteHTTPChainPropagatesVariable(t *testing.T) {
srv := chainServer()
defer srv.Close()
opts := Options{Timeout: testTimeout, Client: httpx.Client(testTimeout)}
result, err := ExecuteHTTPModule(context.Background(), srv.URL, chainModule(), opts)
if err != nil {
t.Fatalf("ExecuteHTTPModule: %v", err)
}
if len(result.Findings) != 1 {
t.Fatalf("got %d findings, want 1 (chain should reach the protected step)", len(result.Findings))
}
f := result.Findings[0]
if f.URL != srv.URL+"/secret" {
t.Errorf("finding url = %q, want the protected step %q", f.URL, srv.URL+"/secret")
}
if f.Extracted["token"] != "tok-abc123" {
t.Errorf("extracted token = %q, want tok-abc123", f.Extracted["token"])
}
}
// TestExecuteHTTPChainHaltsWithoutToken proves the value really gates access:
// drop the extractor so {{token}} never resolves, and the authed step must miss
// its matcher, leaving no finding.
func TestExecuteHTTPChainHaltsWithoutToken(t *testing.T) {
srv := chainServer()
defer srv.Close()
def := chainModule()
def.HTTP.Requests[0].Extractors = nil // never learn the token
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)
}
if len(result.Findings) != 0 {
t.Fatalf("got %d findings, want 0 (unauthed step must not match)", len(result.Findings))
}
}
// TestExecuteHTTPChainStepMatcherHalts confirms a failed matcher on an early
// step stops the chain before the later step records a finding.
func TestExecuteHTTPChainStepMatcherHalts(t *testing.T) {
srv := chainServer()
defer srv.Close()
def := chainModule()
// gate the login step on a body word it never returns; the chain must stop
// there and never reach /secret.
def.HTTP.Requests[0].Matchers = []Matcher{{Type: "word", Part: "body", Words: []string{"nonexistent-marker"}}}
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)
}
if len(result.Findings) != 0 {
t.Fatalf("got %d findings, want 0 (chain should halt on the failed login matcher)", len(result.Findings))
}
}