From ad303d503a00cc68c790871688806ee938439d4f Mon Sep 17 00:00:00 2001 From: TBX3D <88289044+TBX3D@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:31:06 -0700 Subject: [PATCH 1/4] fix(js): stop the scanning spinner on early script-parse errors htmlquery.Parse and QueryAll failures returned before spin.Stop(), leaving the "Scanning JavaScript files" spinner running forever on those paths while every other error branch in the function stops it first. --- internal/scan/js/scan.go | 2 ++ 1 file changed, 2 insertions(+) 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 { From 3d15a02ad47e0f6ca6c3b1cc88d718f087dd8ff2 Mon Sep 17 00:00:00 2001 From: TBX3D <88289044+TBX3D@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:31:13 -0700 Subject: [PATCH 2/4] fix(js): stop reporting quoted regex-flag literals as endpoints Quoted regex flag combos like "/gi" or "/su" in ordinary JS (e.g. str.replace("/gi", x)) matched the root-relative path alternative in endpointRegex and were reported as endpoints. Denylist the known flag bigrams by exact match instead of raising minEndpointLen, so real short endpoints like /v1, /me, /ws still extract normally. --- internal/scan/js/endpoints.go | 12 ++++++ internal/scan/js/probe2_test.go | 68 +++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 internal/scan/js/probe2_test.go diff --git a/internal/scan/js/endpoints.go b/internal/scan/js/endpoints.go index 4dcf26d6..00f93609 100644 --- a/internal/scan/js/endpoints.go +++ b/internal/scan/js/endpoints.go @@ -46,6 +46,14 @@ var mimePrefixes = []string{ "application/", "multipart/", "model/", "message/", } +// regexFlagLiterals are quoted regex flag combinations (e.g. `str.replace("/gi", x)`) +// that the root-relative path alternative in endpointRegex mistakes for a +// slash-prefixed endpoint. They're denylisted by exact match rather than by a +// blanket length bump so real short endpoints like /v1 or /me 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 +99,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) + } + } +} From a41618d0e457823d5cbf8b995a87ce3942d31bc9 Mon Sep 17 00:00:00 2001 From: TBX3D <88289044+TBX3D@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:32:31 -0700 Subject: [PATCH 3/4] fix(js): keep supabase findings from other projects on a per-project error ScanSupabase iterates every JWT found in a script and can discover several distinct supabase projects. Four error paths inside that loop (reading the signup response, parsing it, fetching the openapi spec, and a missing Paths field) returned nil and the whole error, discarding any results already collected for earlier, successfully scanned projects. Skip the broken project with continue instead so the scan still returns what it found. --- internal/scan/js/supabase.go | 15 ++-- internal/scan/js/supabase_test.go | 139 ++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 6 deletions(-) create mode 100644 internal/scan/js/supabase_test.go 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..6f477457 --- /dev/null +++ b/internal/scan/js/supabase_test.go @@ -0,0 +1,139 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · 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 any request bound for *.supabase.co to a +// local httptest server, tagging the request with the original host (via a +// header) so the fake handler can tell projects apart. This lets ScanSupabase +// run its real code path (regex match, decode, signup, openapi, sample fetch) +// against a fake backend instead of the real network. +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) + } +} From 6a29010bb529f376f7ae792e78867b2f1dafdc4a Mon Sep 17 00:00:00 2001 From: TBX3D <88289044+TBX3D@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:34:58 -0700 Subject: [PATCH 4/4] test(js): drop flaky wall-clock redos timing probe go's regexp is re2 and linear by construction; the timing threshold added only flake under -race load, not coverage. the regex-flag-literal false positive it sat next to is guarded by TestRegexFlagEndpointsNotReported. --- internal/scan/js/endpoints.go | 7 ++--- internal/scan/js/probe_test.go | 52 +++++++++++++++++++++++++++++++ internal/scan/js/supabase_test.go | 9 ++---- 3 files changed, 58 insertions(+), 10 deletions(-) create mode 100644 internal/scan/js/probe_test.go diff --git a/internal/scan/js/endpoints.go b/internal/scan/js/endpoints.go index 00f93609..9e518115 100644 --- a/internal/scan/js/endpoints.go +++ b/internal/scan/js/endpoints.go @@ -46,10 +46,9 @@ var mimePrefixes = []string{ "application/", "multipart/", "model/", "message/", } -// regexFlagLiterals are quoted regex flag combinations (e.g. `str.replace("/gi", x)`) -// that the root-relative path alternative in endpointRegex mistakes for a -// slash-prefixed endpoint. They're denylisted by exact match rather than by a -// blanket length bump so real short endpoints like /v1 or /me still extract. +// 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": {}, } 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/supabase_test.go b/internal/scan/js/supabase_test.go index 6f477457..d76a821b 100644 --- a/internal/scan/js/supabase_test.go +++ b/internal/scan/js/supabase_test.go @@ -23,11 +23,9 @@ import ( "time" ) -// supabaseTestRoundTripper redirects any request bound for *.supabase.co to a -// local httptest server, tagging the request with the original host (via a -// header) so the fake handler can tell projects apart. This lets ScanSupabase -// run its real code path (regex match, decode, signup, openapi, sample fetch) -// against a fake backend instead of the real network. +// 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 @@ -98,7 +96,6 @@ func projectHandler(openAPIStatus int) http.HandlerFunc { } } - // 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) {