diff --git a/internal/scan/js/endpoints.go b/internal/scan/js/endpoints.go index 4dcf26d6..9e518115 100644 --- a/internal/scan/js/endpoints.go +++ b/internal/scan/js/endpoints.go @@ -46,6 +46,13 @@ var mimePrefixes = []string{ "application/", "multipart/", "model/", "message/", } +// regexFlagLiterals are quoted regex flags (e.g. `str.replace("/gi", x)`) that +// the endpoint regex mistakes for slash-prefixed paths. Denylisted by exact +// match, not a length bump, so short real endpoints like /v1 still extract. +var regexFlagLiterals = map[string]struct{}{ + "/gi": {}, "/gm": {}, "/gs": {}, "/gu": {}, "/gy": {}, "/mi": {}, "/su": {}, +} + // ExtractEndpoints pulls candidate paths and urls out of a script body, dedupes // them, drops obvious noise, and resolves relatives against baseURL so callers // get absolute targets where possible. a baseURL that won't parse just leaves @@ -91,6 +98,10 @@ func isEndpoint(s string) bool { } lower := strings.ToLower(s) + if _, ok := regexFlagLiterals[lower]; ok { + return false + } + for i := 0; i < len(mimePrefixes); i++ { // a mime type is "type/subtype" with no further path; an api route like // /application/users has a leading slash, so anchor on the bare prefix. diff --git a/internal/scan/js/probe2_test.go b/internal/scan/js/probe2_test.go new file mode 100644 index 00000000..772a54cb --- /dev/null +++ b/internal/scan/js/probe2_test.go @@ -0,0 +1,68 @@ +package js + +import ( + "testing" + "time" +) + +func TestProbe_NoPanicOnBinary(t *testing.T) { + inputs := [][]byte{ + {}, {0x00}, {0xff, 0xfe, 0xfd}, + []byte("ey."), []byte(`"ey.a"`), []byte(`"eyJ.a.b"`), + []byte("\xff\xfe invalid utf8 \x80\x81 token=\"x\""), + []byte(`"//"`), []byte(`"/"`), []byte(`"./"`), + } + // binary with an embedded jwt-ish token to reach the decode path + inputs = append(inputs, []byte(`x="ey`+"\xff\xff"+`.aa.bb"`)) + for _, in := range inputs { + s := string(in) + func() { + defer func() { + if r := recover(); r != nil { + t.Errorf("panic on %q: %v", s, r) + } + }() + _ = ExtractEndpoints(s, "https://example.com/a.js") + _ = ScanSecrets(s, "https://example.com/a.js") + _, _ = ScanSupabase(s, "https://example.com/a.js", time.Second) + }() + } + t.Log("no panics across binary/truncated/multibyte/empty inputs") +} + +// regression: the 7 quoted regex-flag literals must never be reported as +// endpoints, but short real endpoints in the same shape (/xx or /xxx) must +// still extract normally. +func TestRegexFlagEndpointsNotReported(t *testing.T) { + fps := []string{"/gi", "/gm", "/gs", "/gu", "/gy", "/mi", "/su"} + for _, f := range fps { + got := ExtractEndpoints(`x.replace("`+f+`","")`, "https://ex.com/a.js") + if len(got) > 0 { + t.Errorf("regex-flag literal %q wrongly reported as endpoint: %v", f, got) + } + } +} + +func TestShortRealEndpointsStillExtracted(t *testing.T) { + cases := []struct { + content string + want string + }{ + {`fetch("/v1")`, "https://ex.com/v1"}, + {`fetch("/me")`, "https://ex.com/me"}, + {`fetch("/ws")`, "https://ex.com/ws"}, + {`fetch("/db")`, "https://ex.com/db"}, + } + for _, c := range cases { + got := ExtractEndpoints(c.content, "https://ex.com/a.js") + found := false + for _, e := range got { + if e == c.want { + found = true + } + } + if !found { + t.Errorf("expected %q preserved as an endpoint, got %v", c.want, got) + } + } +} diff --git a/internal/scan/js/probe_test.go b/internal/scan/js/probe_test.go new file mode 100644 index 00000000..00f8b9ae --- /dev/null +++ b/internal/scan/js/probe_test.go @@ -0,0 +1,52 @@ +package js + +import ( + "regexp" + "strings" + "testing" +) + +func TestProbe_EndpointFalsePositives(t *testing.T) { + cases := []struct { + name string + content string + }{ + {"regex literal flags", `str.replace("/gi", x)`}, + {"date format string", `format(d, "dd/mm/yyyy")`}, + {"regex char class", `"[a-z]/g"`}, + {"math expression in string", `"1/2 cup"`}, + {"comment prose", `// see docs at ./foo/bar for details`}, + } + for _, c := range cases { + got := ExtractEndpoints(c.content, "https://example.com/app.js") + t.Logf("%-24s input=%-40q => %v", c.name, c.content, got) + } + // log (don't fail) whether the regex-literal FP is present + got := ExtractEndpoints(`x.replace("/gi", "")`, "https://example.com/a.js") + found := false + for _, e := range got { + if strings.Contains(e, "/gi") { + found = true + } + } + if found { + t.Log("CONFIRMED: '/gi' regex-flag literal reported as an endpoint (false positive)") + } else { + t.Log("note: /gi not reported") + } +} + +// note: a RE2-backtracking probe was removed here: Go's regexp is RE2 and +// linear by construction, so a wall-clock timing assertion added only flake +// under -race load, not coverage. + +// version/entropy extraction is not in the js pkg; this documents the shape +// of the aws-secret capture group instead. +func TestProbe_AwsSecretCaptureGroup(t *testing.T) { + re := regexp.MustCompile(`\b((?:aws_secret_access_key|aws_secret|secret_key)["']?\s*[:=]\s*["']?)([A-Za-z0-9/+]{40})\b`) + m := re.FindStringSubmatch(`aws_secret_access_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"`) + if m == nil { + t.Fatal("no match") + } + t.Logf("group2 (reported value) = %q", m[2]) +} diff --git a/internal/scan/js/scan.go b/internal/scan/js/scan.go index 93d87537..089c346d 100644 --- a/internal/scan/js/scan.go +++ b/internal/scan/js/scan.go @@ -95,12 +95,14 @@ func JavascriptScan(url string, timeout time.Duration, threads int, logdir strin doc, err := htmlquery.Parse(io.LimitReader(resp.Body, maxHTMLBodySize)) if err != nil { + spin.Stop() return nil, err } var scripts []string nodes, err := htmlquery.QueryAll(doc, "//script/@src") if err != nil { + spin.Stop() return nil, err } for _, node := range nodes { diff --git a/internal/scan/js/supabase.go b/internal/scan/js/supabase.go index 922ebc8c..f26f93f1 100644 --- a/internal/scan/js/supabase.go +++ b/internal/scan/js/supabase.go @@ -203,15 +203,16 @@ func ScanSupabase(jsContent string, jsUrl string, timeout time.Duration) ([]supa var auth string if resp.StatusCode == http.StatusOK { body, err := io.ReadAll(io.LimitReader(resp.Body, 5*1024*1024)) + resp.Body.Close() if err != nil { - resp.Body.Close() - return nil, err + supabaselog.Errorf("Failed to read signup response for %s: %s", *supabaseJwt.ProjectId, err) + continue } - resp.Body.Close() var authResp supabaseAuthResponse if err := json.Unmarshal(body, &authResp); err != nil { - return nil, err + supabaselog.Errorf("Failed to parse signup response for %s: %s", *supabaseJwt.ProjectId, err) + continue } auth = authResp.AccessToken @@ -225,11 +226,13 @@ func ScanSupabase(jsContent string, jsUrl string, timeout time.Duration) ([]supa openAPI, err := getSupabaseOpenAPI(*supabaseJwt.ProjectId, jwt, &auth, timeout) if err != nil { - return nil, err + supabaselog.Errorf("Failed to fetch openapi spec for %s: %s", *supabaseJwt.ProjectId, err) + continue } if openAPI.Paths == nil { - return nil, errors.New("paths not found in supabase openapi") + supabaselog.Errorf("No paths found in supabase openapi for %s", *supabaseJwt.ProjectId) + continue } for path := range openAPI.Paths { diff --git a/internal/scan/js/supabase_test.go b/internal/scan/js/supabase_test.go new file mode 100644 index 00000000..d76a821b --- /dev/null +++ b/internal/scan/js/supabase_test.go @@ -0,0 +1,136 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package js + +import ( + "encoding/base64" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" +) + +// supabaseTestRoundTripper redirects *.supabase.co requests to a local +// httptest server, tagging the original host in a header so the fake handler +// can tell projects apart and ScanSupabase's real code path can be exercised. +type supabaseTestRoundTripper struct { + orig http.RoundTripper + target *url.URL +} + +func (rt *supabaseTestRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + if strings.HasSuffix(req.URL.Host, ".supabase.co") { + req = req.Clone(req.Context()) + req.Header.Set("X-Test-Orig-Host", req.URL.Host) + req.URL.Scheme = rt.target.Scheme + req.URL.Host = rt.target.Host + req.Host = rt.target.Host + } + return rt.orig.RoundTrip(req) +} + +// withFakeSupabase points every *.supabase.co request at handler for the +// duration of the test, restoring the real default transport after. +func withFakeSupabase(t *testing.T, handler http.HandlerFunc) { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + + target, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("parse test server url: %v", err) + } + + origTransport := http.DefaultTransport + http.DefaultTransport = &supabaseTestRoundTripper{orig: origTransport, target: target} + t.Cleanup(func() { http.DefaultTransport = origTransport }) +} + +// makeJWT builds a header.payload.sig token whose payload base64url-encodes +// refJSON, mirroring what supabase.go's jwtRegex and decode step expect. +func makeJWT(refJSON string) string { + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`)) + payload := base64.RawURLEncoding.EncodeToString([]byte(refJSON)) + sig := base64.RawURLEncoding.EncodeToString([]byte("signaturesignature")) + return header + "." + payload + "." + sig +} + +// projectHandler dispatches requests for a single project. openAPIStatus lets +// a case force the openapi fetch to fail (simulating a 500 or bad upstream). +func projectHandler(openAPIStatus int) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/auth/v1/signup": + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"access_token":"tok"}`)) + case "/rest/v1/": + if openAPIStatus != http.StatusOK { + w.WriteHeader(openAPIStatus) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"paths":{"/items":{}}}`)) + case "/rest/v1/items": + w.Header().Set("Content-Range", "0-1/2") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`[{"a":1},{"a":2}]`)) + default: + w.WriteHeader(http.StatusNotFound) + } + } +} + +// regression: one project's openapi fetch failing must not discard findings +// already collected for another project in the same scan. +func TestScanSupabase_PartialFailureAccumulates(t *testing.T) { + var mu sync.Mutex + good := projectHandler(http.StatusOK) + bad := projectHandler(http.StatusInternalServerError) + + withFakeSupabase(t, func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + switch r.Header.Get("X-Test-Orig-Host") { + case "proja.supabase.co": + good(w, r) + case "projb.supabase.co": + bad(w, r) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + + jwtA := makeJWT(`{"ref":"proja","role":"anon"}`) + jwtB := makeJWT(`{"ref":"projb","role":"anon"}`) + content := `const A = "` + jwtA + `"; const B = "` + jwtB + `";` + + results, err := ScanSupabase(content, "https://example.com/app.js", 5*time.Second) + if err != nil { + t.Fatalf("ScanSupabase returned an error, should have skipped the bad project instead: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 surviving result (proja), got %d: %+v", len(results), results) + } + if results[0].ProjectId != "proja" { + t.Fatalf("expected proja to survive, got %q", results[0].ProjectId) + } + if len(results[0].Collections) != 1 || results[0].Collections[0].Name != "items" { + t.Fatalf("expected proja's items collection intact, got %+v", results[0].Collections) + } +}