Files
AetherForge/server/internal/recon/recon_test.go
AetherForge 49c24e611c
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Add recon form fingerprint library and SSRF canary mode.
Score crawl inputs by SSRF-prone field names and register ping-back canaries for operator paste confirmation.
2026-06-07 12:23:11 -07:00

334 lines
9.5 KiB
Go

package recon
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
func TestScanPortsWithInject(t *testing.T) {
SetPortDialHook(func(host string, port int, _ time.Duration) bool {
if host != "10.0.0.5" {
t.Fatalf("host=%q", host)
}
return port == 22 || port == 443
})
t.Cleanup(func() { SetPortDialHook(nil) })
results := ScanPorts("10.0.0.5", nil)
open := map[int]bool{}
for _, r := range results {
if r.Open {
open[r.Port] = true
}
}
if !open[22] || !open[443] {
t.Fatalf("open=%v", open)
}
if open[445] {
t.Fatal("445 should be closed")
}
}
func TestParseHTMLFixtures(t *testing.T) {
body := `<html><head><title>Upload</title></head><body>
<form action="/import" method="post" enctype="multipart/form-data">
<input type="file" name="payload">
<input type="text" name="webhook_url" value="">
</form>
<a href="/admin/login">Admin</a>
<link href="/wp-content/themes/x/style.css">
</body></html>`
files, multi, fields, score, cms := ParseHTML("http://lab/upload", body)
if len(files) != 1 || !files[0].HasFile {
t.Fatalf("file inputs: %+v", files)
}
if len(multi) != 1 || !multi[0].Multipart {
t.Fatalf("multipart: %+v", multi)
}
if len(fields) == 0 {
t.Fatal("expected url fields")
}
if score < 10 {
t.Fatalf("ssrf score=%d", score)
}
if !containsStr(cms, "wordpress") && !containsStr(cms, "admin_login") {
t.Fatalf("cms=%v", cms)
}
}
func TestCrawlSameOriginDepth(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/":
w.Write([]byte(`<html><a href="/page2">next</a><a href="http://evil.example/x">off</a></html>`))
case "/page2":
w.Write([]byte(`<html><a href="/page3">deep</a></html>`))
case "/page3":
w.Write([]byte(`<html>leaf</html>`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
u, err := url.Parse(srv.URL)
if err != nil {
t.Fatal(err)
}
port := 80
if p := u.Port(); p != "" {
port = atoi(p)
}
SetFetchPageHook(func(rawURL string) (int, string, error) {
resp, err := http.Get(rawURL)
if err != nil {
return 0, "", err
}
defer resp.Body.Close()
body, _ := readBodyLimited(resp.Body, maxHTMLBytes)
return resp.StatusCode, body, nil
})
t.Cleanup(func() { SetFetchPageHook(nil) })
report, err := Crawl(u.Hostname(), port, u.Scheme, []string{"/"})
if err != nil {
t.Fatal(err)
}
if report.PagesFetched < 2 {
t.Fatalf("pages=%d", report.PagesFetched)
}
for _, p := range report.Pages {
if strings.Contains(p.URL, "evil.example") {
t.Fatalf("followed off-origin %s", p.URL)
}
}
}
func TestScanReportRecommendations(t *testing.T) {
ports := []PortResult{
{Port: 22, Open: true},
{Port: 5985, Open: true},
{Port: 80, Open: true},
}
crawl := &CrawlReport{
MultipartForms: []FormFinding{{PageURL: "http://x/", Multipart: true}},
SSRFScore: 40,
}
recs := BuildRecommendations(ports, crawl, nil, "10.0.0.1", false)
if len(recs) < 5 {
t.Fatalf("recs=%+v", recs)
}
keys := map[string]bool{}
for _, r := range recs {
keys[r.Lane+"|"+r.Template] = true
}
for _, want := range []string{"linux_lotl|linux-lotl", "winrm|winrm", "bits_curl|", "stage_fetch|", "|ssrf_probe", "|public_waterhole"} {
if !keys[want] {
t.Fatalf("missing %q in %+v", want, recs)
}
}
}
func TestScanOwnedTarget(t *testing.T) {
SetPortDialHook(func(host string, port int, _ time.Duration) bool {
return port == 80
})
t.Cleanup(func() { SetPortDialHook(nil) })
SetFetchPageHook(func(rawURL string) (int, string, error) {
return 200, `<html><form enctype="multipart/form-data"><input type="file" name="f"></form></html>`, nil
})
t.Cleanup(func() { SetFetchPageHook(nil) })
report, err := Scan(ScanRequest{Host: "owned.lab", Port: 80, Scheme: "http"})
if err != nil {
t.Fatal(err)
}
if report.Host != "owned.lab" {
t.Fatalf("host=%q", report.Host)
}
if report.Crawl == nil || len(report.Recommendations) == 0 {
raw, _ := json.Marshal(report)
t.Fatalf("report=%s", raw)
}
}
func TestNormalizeOwnedHostRejectsEmpty(t *testing.T) {
if _, err := Scan(ScanRequest{}); err == nil {
t.Fatal("expected error")
}
}
func TestUploadHunterMultipartAndJS(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/":
w.Write([]byte(`<html><body><form action="/api/upload" enctype="multipart/form-data"><input type="file"></form><script src="/static/upload.js"></script></body></html>`))
case "/static/upload.js":
w.Write([]byte(`fetch("/api/upload")`))
case "/api/upload":
w.WriteHeader(http.StatusOK)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
port := 80
if p := u.Port(); p != "" {
port = atoi(p)
}
SetFetchPageHook(func(rawURL string) (int, string, error) {
resp, err := http.Get(rawURL)
if err != nil {
return 0, "", err
}
defer resp.Body.Close()
body, _ := readBodyLimited(resp.Body, maxHTMLBytes)
return resp.StatusCode, body, nil
})
t.Cleanup(func() { SetFetchPageHook(nil) })
report, err := Crawl(u.Hostname(), port, u.Scheme, []string{"/"})
if err != nil || len(report.UploadHunter) == 0 {
t.Fatalf("err=%v hunter=%+v", err, report.UploadHunter)
}
}
func TestProbeAdminSurfaceSignals(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/admin", "/admin/":
w.WriteHeader(http.StatusForbidden)
case "/swagger", "/swagger/":
w.WriteHeader(http.StatusOK)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
port := 80
if p := u.Port(); p != "" {
port = atoi(p)
}
SetFetchPageHook(func(rawURL string) (int, string, error) {
resp, err := http.Get(rawURL)
if err != nil {
return 0, "", err
}
defer resp.Body.Close()
body, _ := readBodyLimited(resp.Body, maxHTMLBytes)
return resp.StatusCode, body, nil
})
t.Cleanup(func() { SetFetchPageHook(nil) })
if len(ProbeAdminSurface(u.Hostname(), port, u.Scheme)) == 0 {
t.Fatal("expected admin surface")
}
}
func TestScanReportIncludesAdminSurfaceJSON(t *testing.T) {
SetPortDialHook(func(host string, port int, _ time.Duration) bool { return port == 80 })
t.Cleanup(func() { SetPortDialHook(nil) })
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/":
w.Write([]byte(`<html><form enctype="multipart/form-data" action="/api/upload"><input type="file"></form></html>`))
case "/api/upload":
w.WriteHeader(http.StatusOK)
case "/admin", "/admin/":
w.WriteHeader(http.StatusForbidden)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
port := 80
if p := u.Port(); p != "" {
port = atoi(p)
}
SetFetchPageHook(func(rawURL string) (int, string, error) {
resp, err := http.Get(rawURL)
if err != nil {
return 0, "", err
}
defer resp.Body.Close()
body, _ := readBodyLimited(resp.Body, maxHTMLBytes)
return resp.StatusCode, body, nil
})
t.Cleanup(func() { SetFetchPageHook(nil) })
report, err := Scan(ScanRequest{Host: u.Hostname(), Port: port, Scheme: u.Scheme})
if err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(report)
s := string(raw)
if !strings.Contains(s, `"admin_surface"`) || !strings.Contains(s, `"upload_hunter"`) {
t.Fatalf("json=%s", s)
}
}
func containsStr(list []string, want string) bool {
for _, s := range list {
if s == want {
return true
}
}
return false
}
func atoi(s string) int {
n := 0
for _, c := range s {
if c < '0' || c > '9' {
return 80
}
n = n*10 + int(c-'0')
}
if n == 0 {
return 80
}
return n
}
func TestResolveScanPortsMergesProfiles(t *testing.T) {
ports, used := ResolveScanPorts([]string{"web", "linux", "cloud_metadata"})
if !containsInt(ports, 6262) || len(used) != 3 { t.Fatalf("%v %v", ports, used) }
}
func TestBuildStackFromHeaders(t *testing.T) {
stack := BuildStack([]HTTPHeaderSnap{{Headers: map[string]string{"X-Powered-By":"PHP/8.1"}}}, nil)
if SuggestDeployKitLane(stack) != "php" { t.Fatal(stack) }
}
func TestGrabBannersWithHooks(t *testing.T) {
SetBannerHooks(func(_ string,p int) string { if p==22 {return "SSH"}; return "" }, func(_ string,p int)(string,string){ if p==80 {return "t","s"}; return "","" }, func(_ string,p int) string { if p==5985 {return "w"}; return "" }, nil)
t.Cleanup(func(){SetBannerHooks(nil,nil,nil,nil)})
if len(GrabBanners("h", []PortResult{{22,true},{80,true},{5985,true}})) != 3 { t.Fatal() }
}
func TestCloudMetadataProfileSuggestsSSM(t *testing.T) {
SetBannerHooks(nil,nil,nil,func()bool{return true}); t.Cleanup(func(){SetBannerHooks(nil,nil,nil,nil)})
for _,r := range BuildRecommendations(nil,nil,nil,"ec2.compute.amazonaws.com",true) { if r.Lane=="ssm_document" { return } }
t.Fatal()
}
func containsInt(a []int,w int) bool { for _,n:=range a { if n==w {return true} }; return false }
func TestScoreFormFieldFingerprintKeywords(t *testing.T) {
score, matches := ScoreFormFieldFingerprint("callback_url", "", "")
if score < 14 || !containsStr(matches, "callback") { t.Fatalf("score=%d matches=%v", score, matches) }
}
func TestExtractFormFieldFingerprintsExactNameID(t *testing.T) {
fps := ExtractFormFieldFingerprints("http://lab/", `<input name="avatar_url" id="userAvatar">`)
if len(fps) == 0 || fps[0].Name != "avatar_url" || fps[0].ID != "userAvatar" { t.Fatalf("%+v", fps) }
}
func TestBuildSSRfCanaryURLForcesHTTPS(t *testing.T) {
u := BuildSSRfCanaryURL("http://public.example.com", "abc-123")
if !strings.HasPrefix(u, "https://") || !strings.Contains(u, "/recon/ping/abc-123") { t.Fatalf("url=%q", u) }
}