mirror of
https://github.com/lunchcat/sif.git
synced 2026-07-04 03:45:08 -07:00
af337bd094
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.
249 lines
9.1 KiB
Go
249 lines
9.1 KiB
Go
/*
|
|
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
|
: :
|
|
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
|
: ▄█ █ █▀ · BSD 3-Clause License :
|
|
: :
|
|
: (c) 2022-2026 vmfunc, xyzeva, :
|
|
: lunchcat alumni & contributors :
|
|
: :
|
|
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
|
*/
|
|
|
|
package scan
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// representative bodies, not padded to force a size delta: a generic homepage and a
|
|
// real admin login just have different content, hence different shapes. the catch-all
|
|
// body carries no db keyword, so the comparison turns on the baseline, not isAdminPanel.
|
|
const (
|
|
catchAllHome = `<!DOCTYPE html><html><head><title>Acme</title></head>` +
|
|
`<body><nav>Home About Contact</nav><main>Welcome to Acme.</main></body></html>`
|
|
|
|
realPMALogin = `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">` +
|
|
`<title>phpMyAdmin</title><link rel="stylesheet" href="phpmyadmin.css.php"></head>` +
|
|
`<body class="loginform"><form method="post" action="index.php"><fieldset>` +
|
|
`<legend>Log in to phpMyAdmin</legend>` +
|
|
`<label>Username</label><input type="text" name="pma_username">` +
|
|
`<label>Password</label><input type="password" name="pma_password">` +
|
|
`<input type="submit" value="Go"></fieldset></form></body></html>`
|
|
|
|
realDBAdmin = `<!DOCTYPE html><html><head><title>Database Manager</title></head>` +
|
|
`<body><h1>MySQL Server 8.0</h1><table><tr><th>Database</th><th>Tables</th></tr>` +
|
|
`<tr><td>app_production</td><td>42</td></tr></table>` +
|
|
`<form action="/run"><textarea name="sql">SELECT 1</textarea><button>Run</button></form>` +
|
|
`</body></html>`
|
|
)
|
|
|
|
// countAdminPanels is nil-safe: SQL returns a nil result when nothing is found.
|
|
func countAdminPanels(r *SQLResult) int {
|
|
if r == nil {
|
|
return 0
|
|
}
|
|
return len(r.AdminPanels)
|
|
}
|
|
|
|
// hasPanelType reports whether a panel of the given type was found.
|
|
func hasPanelType(r *SQLResult, panelType string) bool {
|
|
if r == nil {
|
|
return false
|
|
}
|
|
for _, p := range r.AdminPanels {
|
|
if p.Type == panelType {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// a 200 catch-all (the SPA wildcard) is calibrated as a baseline shape.
|
|
func TestCalibrateSQLBaseline_CatchAll(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte(catchAllHome))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
baselines := calibrateSQLBaseline(srv.URL, &http.Client{Timeout: 5 * time.Second})
|
|
if len(baselines) == 0 {
|
|
t.Fatal("a 200 catch-all should produce at least one baseline shape")
|
|
}
|
|
}
|
|
|
|
// a server that hard-404s every bogus path needs no baseline (status already filters).
|
|
func TestCalibrateSQLBaseline_HardNotFound(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
baselines := calibrateSQLBaseline(srv.URL, &http.Client{Timeout: 5 * time.Second})
|
|
if len(baselines) != 0 {
|
|
t.Errorf("hard-404 server should yield no baseline, got %d", len(baselines))
|
|
}
|
|
}
|
|
|
|
// with -ac on, a 200 catch-all serving a db-topical page at every path yields no
|
|
// admin-panel finding once the wildcard shape is calibrated.
|
|
func TestSQL_CatchAllSuppressed(t *testing.T) {
|
|
page := "<html><body>database dashboard for our service</body></html>"
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte(page))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
result, err := SQL(srv.URL, 5*time.Second, 4, "", true)
|
|
if err != nil {
|
|
t.Fatalf("SQL: %v", err)
|
|
}
|
|
if n := countAdminPanels(result); n != 0 {
|
|
t.Errorf("catch-all produced %d admin-panel finding(s) with -ac, want 0", n)
|
|
}
|
|
}
|
|
|
|
// a 403 WAF that blanket-blocks with a db-mentioning page is also a catch-all and
|
|
// must be suppressed (covers the 403 branch of the status set).
|
|
func TestSQL_403WAFCatchAllSuppressed(t *testing.T) {
|
|
page := "Request blocked: possible sql injection attempt detected"
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
_, _ = w.Write([]byte(page))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
result, err := SQL(srv.URL, 5*time.Second, 4, "", true)
|
|
if err != nil {
|
|
t.Fatalf("SQL: %v", err)
|
|
}
|
|
if n := countAdminPanels(result); n != 0 {
|
|
t.Errorf("403 WAF catch-all produced %d admin-panel finding(s) with -ac, want 0", n)
|
|
}
|
|
}
|
|
|
|
// suppression is opt-in: with -ac off (the default) the same catch-all is still
|
|
// reported, matching dirlist's behavior.
|
|
func TestSQL_CalibrateDisabledStillReports(t *testing.T) {
|
|
page := "<html><body>database dashboard for our service</body></html>"
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte(page))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
result, err := SQL(srv.URL, 5*time.Second, 4, "", false)
|
|
if err != nil {
|
|
t.Fatalf("SQL: %v", err)
|
|
}
|
|
if countAdminPanels(result) == 0 {
|
|
t.Error("with -ac off, the catch-all should still be reported (suppression is opt-in)")
|
|
}
|
|
}
|
|
|
|
// a real phpMyAdmin hosted on a catch-all is still reported under -ac: a genuine
|
|
// login page is a different shape than the wildcard homepage, so it is not dropped.
|
|
func TestSQL_RealPanelAmongCatchAll(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/phpmyadmin/" {
|
|
_, _ = w.Write([]byte(realPMALogin))
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(catchAllHome))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
result, err := SQL(srv.URL, 5*time.Second, 4, "", true)
|
|
if err != nil {
|
|
t.Fatalf("SQL: %v", err)
|
|
}
|
|
if !hasPanelType(result, "phpMyAdmin") {
|
|
t.Errorf("real phpMyAdmin on a catch-all should still be reported; panels=%d",
|
|
countAdminPanels(result))
|
|
}
|
|
}
|
|
|
|
// a real generic interface (a distinct db admin page at /db/) is still reported
|
|
// under -ac, so calibration does not over-suppress genuine findings.
|
|
func TestSQL_RealGenericPanelAmongCatchAll(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/db/" {
|
|
_, _ = w.Write([]byte(realDBAdmin))
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(catchAllHome))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
result, err := SQL(srv.URL, 5*time.Second, 4, "", true)
|
|
if err != nil {
|
|
t.Fatalf("SQL: %v", err)
|
|
}
|
|
if countAdminPanels(result) == 0 {
|
|
t.Error("a real database interface at /db/ on a catch-all should still be reported")
|
|
}
|
|
}
|
|
|
|
// the normal case (no catch-all): a host that 404s everything except a real
|
|
// phpMyAdmin still reports it, since calibration finds no baseline.
|
|
func TestSQL_HardNotFoundRealPanelReported(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/phpmyadmin/" {
|
|
_, _ = w.Write([]byte(realPMALogin))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
result, err := SQL(srv.URL, 5*time.Second, 4, "", true)
|
|
if err != nil {
|
|
t.Fatalf("SQL: %v", err)
|
|
}
|
|
if !hasPanelType(result, "phpMyAdmin") {
|
|
t.Error("phpMyAdmin on a non-catch-all host should be reported")
|
|
}
|
|
}
|
|
|
|
// a catch-all that reflects the request path varies its size per path; the shared
|
|
// reflection-tolerant calibration suppresses it under -ac.
|
|
func TestSQL_ReflectedPathCatchAllSuppressed(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
fmt.Fprintf(w, "<html><body>no such page: %s (database)</body></html>", r.URL.Path)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
result, err := SQL(srv.URL, 5*time.Second, 4, "", true)
|
|
if err != nil {
|
|
t.Fatalf("SQL: %v", err)
|
|
}
|
|
if n := countAdminPanels(result); n != 0 {
|
|
t.Errorf("reflected-path catch-all should be suppressed under -ac, got %d panel(s)", n)
|
|
}
|
|
}
|
|
|
|
// the word-count-tolerant baseline must not nuke real findings: a genuine phpMyAdmin
|
|
// hosted behind a reflecting catch-all has a distinct word count, so it still surfaces.
|
|
func TestSQL_ReflectingCatchAllRealPanelStillReported(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/phpmyadmin/" {
|
|
_, _ = w.Write([]byte(realPMALogin))
|
|
return
|
|
}
|
|
fmt.Fprintf(w, "<html><body>no such page: %s (database)</body></html>", r.URL.Path)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
result, err := SQL(srv.URL, 5*time.Second, 4, "", true)
|
|
if err != nil {
|
|
t.Fatalf("SQL: %v", err)
|
|
}
|
|
if !hasPanelType(result, "phpMyAdmin") {
|
|
t.Errorf("real phpMyAdmin on a reflecting catch-all should still surface; panels=%d",
|
|
countAdminPanels(result))
|
|
}
|
|
}
|