mirror of
https://github.com/lunchcat/sif.git
synced 2026-07-28 14:37:01 -07:00
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>
183 lines
6.4 KiB
Go
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))
|
|
}
|
|
}
|