From ce3075ad9114e40b234769cbbbc4de125cf297d2 Mon Sep 17 00:00:00 2001 From: vmfunc Date: Tue, 9 Jun 2026 14:32:26 -0700 Subject: [PATCH] test: hermetic e2e integration suite - make the four wordlist base urls (dirlist/dnslist/git/ports) package vars instead of consts so tests can repoint them at a local fixture; the default values are byte-for-byte unchanged - add internal/scan/integration_test.go behind a //go:build integration tag: it stands up a local "vulnerable app" httptest server with planted artifacts and runs git/dirlist/cms/headers/sql/lfi/ports against it, asserting real findings - go.yml runs them via `go test -tags=integration`; the default test run is untouched (the tag keeps them out) - document the integration run in docs/development.md --- .github/workflows/go.yml | 2 + docs/development.md | 9 ++ internal/scan/dirlist.go | 10 +- internal/scan/dnslist.go | 4 +- internal/scan/git.go | 8 +- internal/scan/integration_test.go | 219 ++++++++++++++++++++++++++++++ internal/scan/ports.go | 3 +- 7 files changed, 245 insertions(+), 10 deletions(-) create mode 100644 internal/scan/integration_test.go diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index f83666b..fce5561 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -47,3 +47,5 @@ jobs: with: files: ./coverage.out fail_ci_if_error: false + - name: run integration tests + run: go test -tags=integration -race ./internal/scan/... diff --git a/docs/development.md b/docs/development.md index 0b3ef69..1777609 100644 --- a/docs/development.md +++ b/docs/development.md @@ -137,6 +137,15 @@ the module system is in `internal/modules/`: go test ./internal/... ``` +### integration tests + +run the scanners against a local testbed that plants the artifacts each one +should find (network-free, behind a build tag): + +```bash +go test -tags=integration ./internal/scan/... +``` + ### functional test ```bash diff --git a/internal/scan/dirlist.go b/internal/scan/dirlist.go index 10033f3..102c523 100644 --- a/internal/scan/dirlist.go +++ b/internal/scan/dirlist.go @@ -26,11 +26,13 @@ import ( "github.com/dropalldatabases/sif/internal/output" ) +// directoryURL is a var so integration tests can repoint it at a fixture. +var directoryURL = "https://raw.githubusercontent.com/dropalldatabases/sif-runtime/main/dirlist/" + const ( - directoryURL = "https://raw.githubusercontent.com/dropalldatabases/sif-runtime/main/dirlist/" - smallFile = "directory-list-2.3-small.txt" - mediumFile = "directory-list-2.3-medium.txt" - bigFile = "directory-list-2.3-big.txt" + smallFile = "directory-list-2.3-small.txt" + mediumFile = "directory-list-2.3-medium.txt" + bigFile = "directory-list-2.3-big.txt" ) type DirectoryResult struct { diff --git a/internal/scan/dnslist.go b/internal/scan/dnslist.go index 83321bd..c23f3e3 100644 --- a/internal/scan/dnslist.go +++ b/internal/scan/dnslist.go @@ -25,8 +25,10 @@ import ( "github.com/dropalldatabases/sif/internal/output" ) +// dnsURL is a var so integration tests can repoint it at a fixture. +var dnsURL = "https://raw.githubusercontent.com/dropalldatabases/sif-runtime/main/dnslist/" + const ( - dnsURL = "https://raw.githubusercontent.com/dropalldatabases/sif-runtime/main/dnslist/" dnsSmallFile = "subdomains-100.txt" dnsMediumFile = "subdomains-1000.txt" dnsBigFile = "subdomains-10000.txt" diff --git a/internal/scan/git.go b/internal/scan/git.go index bece147..0a4d3a7 100644 --- a/internal/scan/git.go +++ b/internal/scan/git.go @@ -26,10 +26,10 @@ import ( "github.com/dropalldatabases/sif/internal/output" ) -const ( - gitURL = "https://raw.githubusercontent.com/dropalldatabases/sif-runtime/main/git/" - gitFile = "git.txt" -) +// gitURL is a var so integration tests can repoint it at a fixture. +var gitURL = "https://raw.githubusercontent.com/dropalldatabases/sif-runtime/main/git/" + +const gitFile = "git.txt" func Git(url string, timeout time.Duration, threads int, logdir string) ([]string, error) { log := output.Module("GIT") diff --git a/internal/scan/integration_test.go b/internal/scan/integration_test.go new file mode 100644 index 0000000..5e09ca4 --- /dev/null +++ b/internal/scan/integration_test.go @@ -0,0 +1,219 @@ +//go:build integration + +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +// These tests run the real scanners against a local server standing in for a +// deliberately-vulnerable app, asserting the findings each one should produce. +// They're behind the `integration` build tag so the default `go test` stays +// network-free; run with `go test -tags=integration ./internal/scan/...`. +package scan + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" +) + +// newVulnApp serves the planted artifacts each scanner is meant to find, plus +// the wordlists the remote-list scanners fetch. +func newVulnApp() *httptest.Server { + mux := http.NewServeMux() + + // wordlists the remote-list scanners download + mux.HandleFunc("/git.txt", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(".git/HEAD\n.git/config\n")) + }) + mux.HandleFunc("/directory-list-2.3-small.txt", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("admin\nlogin\nnope\n")) + }) + + // an exposed git repo: HEAD is a real find, config is html so it's excluded + mux.HandleFunc("/.git/HEAD", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/octet-stream") + w.Write([]byte("ref: refs/heads/main\n")) + }) + mux.HandleFunc("/.git/config", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.Write([]byte("nope")) + }) + + // live directories for dirlist + mux.HandleFunc("/admin", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + + // an exposed db admin panel for sql recon + mux.HandleFunc("/phpmyadmin/", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("phpMyAdmin")) + }) + + // homepage doubles as the cms fingerprint and the lfi sink + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + if strings.Contains(r.URL.RawQuery, "passwd") || strings.Contains(r.URL.RawQuery, "etc") { + w.Write([]byte("root:x:0:0:root:/root:/bin/bash\n")) + return + } + w.Header().Set("X-Powered-By", "PHP/8.1.0") + w.Write([]byte(`hi`)) + }) + + return httptest.NewServer(mux) +} + +func TestIntegrationGit(t *testing.T) { + srv := newVulnApp() + defer srv.Close() + orig := gitURL + gitURL = srv.URL + "/" + defer func() { gitURL = orig }() + + found, err := Git(srv.URL, 5*time.Second, 2, "") + if err != nil { + t.Fatalf("Git: %v", err) + } + if len(found) != 1 { + t.Fatalf("expected 1 git find (HEAD, not the html config), got %d: %v", len(found), found) + } + if !strings.HasSuffix(found[0], ".git/HEAD") { + t.Errorf("expected .git/HEAD, got %s", found[0]) + } +} + +func TestIntegrationDirlist(t *testing.T) { + srv := newVulnApp() + defer srv.Close() + orig := directoryURL + directoryURL = srv.URL + "/" + defer func() { directoryURL = orig }() + + results, err := Dirlist("small", srv.URL, 5*time.Second, 3, "") + if err != nil { + t.Fatalf("Dirlist: %v", err) + } + + got := map[string]bool{} + for _, r := range results { + got[r.Url] = true + } + if !hasSuffixIn(got, "/admin") || !hasSuffixIn(got, "/login") { + t.Errorf("expected admin and login to be found, got %v", results) + } + if hasSuffixIn(got, "/nope") { + t.Errorf("404 path nope should not be reported, got %v", results) + } +} + +func TestIntegrationCMS(t *testing.T) { + srv := newVulnApp() + defer srv.Close() + + result, err := CMS(srv.URL, 5*time.Second, "") + if err != nil { + t.Fatalf("CMS: %v", err) + } + if result == nil || result.Name != "WordPress" { + t.Errorf("expected WordPress, got %+v", result) + } +} + +func TestIntegrationHeaders(t *testing.T) { + srv := newVulnApp() + defer srv.Close() + + results, err := Headers(srv.URL, 5*time.Second, "") + if err != nil { + t.Fatalf("Headers: %v", err) + } + if len(results) == 0 { + t.Error("expected at least one header back") + } +} + +func TestIntegrationSQL(t *testing.T) { + srv := newVulnApp() + defer srv.Close() + + result, err := SQL(srv.URL, 5*time.Second, 5, "") + if err != nil { + t.Fatalf("SQL: %v", err) + } + if result == nil || len(result.AdminPanels) == 0 { + t.Fatalf("expected an admin panel finding, got %+v", result) + } + if result.AdminPanels[0].Type != "phpMyAdmin" { + t.Errorf("expected phpMyAdmin, got %s", result.AdminPanels[0].Type) + } +} + +func TestIntegrationLFI(t *testing.T) { + srv := newVulnApp() + defer srv.Close() + + result, err := LFI(srv.URL, 5*time.Second, 5, "") + if err != nil { + t.Fatalf("LFI: %v", err) + } + if result == nil || len(result.Vulnerabilities) == 0 { + t.Errorf("expected an lfi finding from the passwd sink, got %+v", result) + } +} + +func TestIntegrationPorts(t *testing.T) { + // a real listener stands in for an open port; a tiny server hands its number + // to Ports via the commonPorts wordlist. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer ln.Close() + port := ln.Addr().(*net.TCPAddr).Port + + list := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(strconv.Itoa(port) + "\n")) + })) + defer list.Close() + orig := commonPorts + commonPorts = list.URL + defer func() { commonPorts = orig }() + + open, err := Ports(context.Background(), "common", "tcp://127.0.0.1", 2*time.Second, 1, "") + if err != nil { + t.Fatalf("Ports: %v", err) + } + found := false + for _, p := range open { + if p == strconv.Itoa(port) { + found = true + } + } + if !found { + t.Errorf("expected open port %d in %v", port, open) + } +} + +func hasSuffixIn(set map[string]bool, suffix string) bool { + for k := range set { + if strings.HasSuffix(k, suffix) { + return true + } + } + return false +} diff --git a/internal/scan/ports.go b/internal/scan/ports.go index 5d9d19d..87110d7 100644 --- a/internal/scan/ports.go +++ b/internal/scan/ports.go @@ -27,7 +27,8 @@ import ( "github.com/dropalldatabases/sif/internal/output" ) -const commonPorts = "https://raw.githubusercontent.com/dropalldatabases/sif-runtime/main/ports/top-ports.txt" +// commonPorts is a var so integration tests can repoint it at a fixture. +var commonPorts = "https://raw.githubusercontent.com/dropalldatabases/sif-runtime/main/ports/top-ports.txt" func Ports(ctx context.Context, scope string, url string, timeout time.Duration, threads int, logdir string) ([]string, error) { log := output.Module("PORTS")