Files
sif/internal/scan/dirlist_test.go
T
TigahandGitHub af337bd094 fix(scan): apply reflected-path tolerance to soft-404 calibration (#180)
calibrate against reflecting catch-alls whose body size tracks path
length so exact-shape calibration no longer misses them; -ac now drives
both dirlist and sql. updates the admin-panel query test to the new SQL
signature. adds soft-404 + calibration coverage.
2026-06-22 20:26:19 -07:00

497 lines
16 KiB
Go

/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package scan
import (
"bufio"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
func TestMatcher_Matches(t *testing.T) {
tests := []struct {
name string
opts DirlistOptions
meta responseMeta
body string
want bool
}{
{
// default behavior: 404/403 drop, everything else surfaces
name: "default keeps 200",
opts: DirlistOptions{},
meta: responseMeta{status: 200, size: 10, words: 2},
want: true,
},
{
name: "default drops 404",
opts: DirlistOptions{},
meta: responseMeta{status: 404, size: 9, words: 1},
want: false,
},
{
name: "default drops 403",
opts: DirlistOptions{},
meta: responseMeta{status: 403, size: 9, words: 1},
want: false,
},
{
// -mc is allow-list: only listed codes survive
name: "mc allowlist keeps listed",
opts: DirlistOptions{MatchCodes: "200,301"},
meta: responseMeta{status: 301, size: 0, words: 0},
want: true,
},
{
name: "mc allowlist drops unlisted 200 already excluded",
opts: DirlistOptions{MatchCodes: "301"},
meta: responseMeta{status: 200, size: 5, words: 1},
want: false,
},
{
name: "fc drops listed code",
opts: DirlistOptions{FilterCodes: "500"},
meta: responseMeta{status: 500, size: 5, words: 1},
want: false,
},
{
// with an explicit -fc and no -mc, the implicit 404/403 filter is not
// added, so a 200 still surfaces
name: "fc leaves others",
opts: DirlistOptions{FilterCodes: "500"},
meta: responseMeta{status: 200, size: 5, words: 1},
want: true,
},
{
name: "fs drops listed size",
opts: DirlistOptions{FilterSizes: "1024"},
meta: responseMeta{status: 200, size: 1024, words: 50},
want: false,
},
{
name: "fw drops listed word count",
opts: DirlistOptions{FilterWords: "7"},
meta: responseMeta{status: 200, size: 40, words: 7},
want: false,
},
{
name: "fr drops body match",
opts: DirlistOptions{FilterRegex: "not found"},
meta: responseMeta{status: 200, size: 9, words: 2},
body: "page not found",
want: false,
},
{
name: "fr keeps non-match",
opts: DirlistOptions{FilterRegex: "not found"},
meta: responseMeta{status: 200, size: 5, words: 1},
body: "welcome",
want: true,
},
{
// filter precedence: -mc would keep it, but a size filter drops it
name: "filter wins over match",
opts: DirlistOptions{MatchCodes: "200", FilterSizes: "12"},
meta: responseMeta{status: 200, size: 12, words: 3},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m, err := newMatcher(&tt.opts)
if err != nil {
t.Fatalf("newMatcher: %v", err)
}
if got := m.Matches(tt.meta, []byte(tt.body)); got != tt.want {
t.Errorf("Matches(%+v, %q) = %v, want %v", tt.meta, tt.body, got, tt.want)
}
})
}
}
func TestMatcher_BaselineSuppresses(t *testing.T) {
m, err := newMatcher(&DirlistOptions{})
if err != nil {
t.Fatalf("newMatcher: %v", err)
}
// a calibrated soft-404 shape drops an identical response
m.baselines = []responseMeta{{status: 200, size: 42, words: 5}}
soft := responseMeta{status: 200, size: 42, words: 5}
if m.Matches(soft, nil) {
t.Error("baseline-matching response should be suppressed")
}
// a real page with a different size must still surface
livePage := responseMeta{status: 200, size: 99, words: 12}
if !m.Matches(livePage, nil) {
t.Error("distinct response should not be suppressed by baseline")
}
}
func TestNewMatcher_InvalidFlags(t *testing.T) {
tests := []struct {
name string
opts DirlistOptions
}{
{"bad mc", DirlistOptions{MatchCodes: "abc"}},
{"bad fc", DirlistOptions{FilterCodes: "20x"}},
{"bad fs", DirlistOptions{FilterSizes: "big"}},
{"bad fw", DirlistOptions{FilterWords: "-"}},
{"bad regex", DirlistOptions{FilterRegex: "("}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if _, err := newMatcher(&tt.opts); err == nil {
t.Errorf("newMatcher(%+v) expected error, got nil", tt.opts)
}
})
}
}
func TestExpandWords(t *testing.T) {
tests := []struct {
name string
words []string
exts string
want []string
}{
{
name: "no extensions unchanged",
words: []string{"admin", "login"},
exts: "",
want: []string{"admin", "login"},
},
{
name: "appends each extension and keeps bare",
words: []string{"config"},
exts: "php,bak,env",
want: []string{"config", "config.php", "config.bak", "config.env"},
},
{
name: "tolerates leading dot and spaces",
words: []string{"db"},
exts: " .sql , bak ",
want: []string{"db", "db.sql", "db.bak"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := expandWords(tt.words, tt.exts)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("expandWords(%v, %q) = %v, want %v", tt.words, tt.exts, got, tt.want)
}
})
}
}
// softWildcardApp serves a couple of real paths and a catch-all soft-404: every
// unknown path returns a fixed 200 body, the SPA pattern that floods dirlist.
func softWildcardApp() *httptest.Server {
const softBody = "<html><body>app shell - route handled client side</body></html>"
mux := http.NewServeMux()
mux.HandleFunc("/admin", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("<html><body>admin control panel dashboard here</body></html>"))
})
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("<html><body>please sign in with your account credentials now</body></html>"))
})
// catch-all: anything else gets the identical soft-404 shell
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/admin" || r.URL.Path == "/login" {
return
}
w.Write([]byte(softBody))
})
return httptest.NewServer(mux)
}
func TestDirlist_CalibrationSuppressesWildcard(t *testing.T) {
srv := softWildcardApp()
defer srv.Close()
// the wordlist mixes the two real paths with several bogus ones the catch-all
// answers with the soft-404 shell.
dir := t.TempDir()
wordlist := filepath.Join(dir, "words.txt")
if err := os.WriteFile(wordlist, []byte("admin\nlogin\nnope\nbogus\nmissing\n"), 0o600); err != nil {
t.Fatalf("write wordlist: %v", err)
}
// without calibration every bogus path is a soft-404 200 and floods output
noAC, err := Dirlist("small", srv.URL, 5*time.Second, 3, "", DirlistOptions{Wordlist: wordlist})
if err != nil {
t.Fatalf("Dirlist (no -ac): %v", err)
}
if len(noAC) < 5 {
t.Fatalf("expected the wildcard to flood all 5 paths without -ac, got %d", len(noAC))
}
// with -ac the soft-404 baseline is learned and the bogus paths drop
withAC, err := Dirlist("small", srv.URL, 5*time.Second, 3, "", DirlistOptions{
Wordlist: wordlist,
Calibrate: true,
})
if err != nil {
t.Fatalf("Dirlist (-ac): %v", err)
}
got := pathSet(withAC)
if !has(got, "/admin") || !has(got, "/login") {
t.Errorf("real paths admin/login must still surface with -ac, got %v", sortedKeys(got))
}
for _, bogus := range []string{"/nope", "/bogus", "/missing"} {
if has(got, bogus) {
t.Errorf("soft-404 path %s should be suppressed by -ac, got %v", bogus, sortedKeys(got))
}
}
}
func TestDirlist_ExtensionExpansion(t *testing.T) {
// the server only answers config.php; the bare word and other extensions hit
// the catch-all soft-404, so -e must be what surfaces config.php.
const realBody = "<?php // database connection settings live here ?>"
mux := http.NewServeMux()
mux.HandleFunc("/config.php", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(realBody))
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r) // hard 404 for everything but config.php
})
srv := httptest.NewServer(mux)
defer srv.Close()
dir := t.TempDir()
wordlist := filepath.Join(dir, "words.txt")
if err := os.WriteFile(wordlist, []byte("config\n"), 0o600); err != nil {
t.Fatalf("write wordlist: %v", err)
}
results, err := Dirlist("small", srv.URL, 5*time.Second, 2, "", DirlistOptions{
Wordlist: wordlist,
Extensions: "php,bak",
})
if err != nil {
t.Fatalf("Dirlist: %v", err)
}
got := pathSet(results)
if !has(got, "/config.php") {
t.Errorf("expected -e to surface config.php, got %v", sortedKeys(got))
}
if has(got, "/config") || has(got, "/config.bak") {
t.Errorf("only config.php exists; bare word and .bak are 404s, got %v", sortedKeys(got))
}
}
func TestDirlist_LocalWordlistOverridesSize(t *testing.T) {
// a local -w must be used verbatim and never touch directoryURL; point the
// remote at a sink that fails the test if it's ever hit.
orig := directoryURL
directoryURL = "http://127.0.0.1:0/should-not-be-fetched/"
defer func() { directoryURL = orig }()
mux := http.NewServeMux()
mux.HandleFunc("/secret", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("<html>top secret area found</html>"))
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
})
srv := httptest.NewServer(mux)
defer srv.Close()
dir := t.TempDir()
wordlist := filepath.Join(dir, "custom.txt")
if err := os.WriteFile(wordlist, []byte("secret\nabsent\n"), 0o600); err != nil {
t.Fatalf("write wordlist: %v", err)
}
results, err := Dirlist("large", srv.URL, 5*time.Second, 2, "", DirlistOptions{Wordlist: wordlist})
if err != nil {
t.Fatalf("Dirlist: %v", err)
}
got := pathSet(results)
if !has(got, "/secret") {
t.Errorf("expected the custom wordlist to find /secret, got %v", sortedKeys(got))
}
if has(got, "/absent") {
t.Errorf("/absent is a 404 and should not surface, got %v", sortedKeys(got))
}
}
// pathSet collects each result's url path for membership checks. it reuses the
// package-level sortedKeys (crawl.go) for deterministic failure output.
func pathSet(results DirectoryResults) map[string]struct{} {
set := make(map[string]struct{}, len(results))
for i := 0; i < len(results); i++ {
if idx := strings.Index(results[i].Url, "://"); idx >= 0 {
rest := results[i].Url[idx+len("://"):]
if slash := strings.Index(rest, "/"); slash >= 0 {
set[rest[slash:]] = struct{}{}
continue
}
}
set[results[i].Url] = struct{}{}
}
return set
}
// has is a tiny readability helper for set membership in assertions.
func has(set map[string]struct{}, key string) bool {
_, ok := set[key]
return ok
}
func TestScanLines(t *testing.T) {
got, err := scanLines(strings.NewReader("admin\n\nlogin\n"))
if err != nil {
t.Fatalf("scanLines: %v", err)
}
want := []string{"admin", "login"}
if !reflect.DeepEqual(got, want) {
t.Errorf("scanLines = %v, want %v", got, want)
}
}
func TestScanLinesErrorsOnOverlongLine(t *testing.T) {
// a line past bufio's 64k cap must surface as an error, not silently
// truncate the wordlist (dropping that line and everything after it).
huge := strings.Repeat("a", bufio.MaxScanTokenSize+1)
_, err := scanLines(strings.NewReader("first\n" + huge + "\nlast\n"))
if !errors.Is(err, bufio.ErrTooLong) {
t.Fatalf("scanLines err = %v, want bufio.ErrTooLong", err)
}
}
// reflectingWildcardApp serves a catch-all whose body echoes the request path, so
// its size tracks path length; /admin returns a distinct real page.
func reflectingWildcardApp() *httptest.Server {
mux := http.NewServeMux()
mux.HandleFunc("/admin", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("<html><body>admin control panel dashboard credentials</body></html>"))
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/admin" {
return
}
w.Write([]byte("<html><body>page not found: " + r.URL.Path + "</body></html>"))
})
return httptest.NewServer(mux)
}
// a reflecting catch-all hands each path a different size, so exact-shape calibration
// misses it; the varied probe lengths expose it and -ac suppresses the bogus paths.
func TestDirlist_CalibrationSuppressesReflectingWildcard(t *testing.T) {
srv := reflectingWildcardApp()
defer srv.Close()
dir := t.TempDir()
wordlist := filepath.Join(dir, "words.txt")
if err := os.WriteFile(wordlist, []byte("admin\nnope\nbogus\nmissing\n"), 0o600); err != nil {
t.Fatalf("write wordlist: %v", err)
}
results, err := Dirlist("small", srv.URL, 5*time.Second, 3, "", DirlistOptions{
Wordlist: wordlist,
Calibrate: true,
})
if err != nil {
t.Fatalf("Dirlist (-ac): %v", err)
}
got := pathSet(results)
if !has(got, "/admin") {
t.Errorf("real /admin must still surface, got %v", sortedKeys(got))
}
for _, bogus := range []string{"/nope", "/bogus", "/missing"} {
if has(got, bogus) {
t.Errorf("reflecting soft-404 %s should be suppressed by -ac, got %v", bogus, sortedKeys(got))
}
}
}
func TestBaselinesFromProbes(t *testing.T) {
tests := []struct {
name string
probes []responseMeta
want []responseMeta
}{
{
name: "stable catch-all keeps exact shape",
probes: []responseMeta{{status: 200, size: 63, words: 7}, {status: 200, size: 63, words: 7}},
want: []responseMeta{{status: 200, size: 63, words: 7}},
},
{
name: "reflecting catch-all collapses to words-tolerant",
probes: []responseMeta{{status: 200, size: 40, words: 4}, {status: 200, size: 48, words: 4}, {status: 200, size: 56, words: 4}},
want: []responseMeta{{status: 200, size: anySize, words: 4}},
},
{
name: "distinct shapes kept separately",
probes: []responseMeta{{status: 200, size: 40, words: 4}, {status: 301, size: 0, words: 0}},
want: []responseMeta{{status: 200, size: 40, words: 4}, {status: 301, size: 0, words: 0}},
},
{
name: "no probes yields no baseline",
probes: nil,
want: []responseMeta{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := baselinesFromProbes(tt.probes); !reflect.DeepEqual(got, tt.want) {
t.Errorf("baselinesFromProbes(%v) = %v, want %v", tt.probes, got, tt.want)
}
})
}
}
func TestMatchesBaseline_AnySize(t *testing.T) {
tolerant := responseMeta{status: 200, size: anySize, words: 4}
if !tolerant.matchesBaseline(responseMeta{status: 200, size: 999, words: 4}) {
t.Error("anySize baseline should match any size with the same status/words")
}
if tolerant.matchesBaseline(responseMeta{status: 200, size: 999, words: 5}) {
t.Error("anySize baseline must still discriminate on word count")
}
exact := responseMeta{status: 200, size: 42, words: 5}
if exact.matchesBaseline(responseMeta{status: 200, size: 43, words: 5}) {
t.Error("exact baseline should not match a different size")
}
}
func TestCalibrationSuffix_UniqueAndGrowing(t *testing.T) {
prev := -1
seen := make(map[string]struct{})
for i := 0; i < calibrationProbes; i++ {
s := calibrationSuffix(i)
if _, dup := seen[s]; dup {
t.Errorf("suffix %q repeats across probes", s)
}
seen[s] = struct{}{}
if len(s) <= prev {
t.Errorf("suffix %d %q length %d not greater than previous %d", i, s, len(s), prev)
}
prev = len(s)
}
}