mirror of
https://github.com/lunchcat/sif.git
synced 2026-07-28 22:40:54 -07:00
* 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.
* 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.
* 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.
* 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.
289 lines
8.9 KiB
Go
289 lines
8.9 KiB
Go
/*
|
|
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
|
: :
|
|
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
|
: ▄█ █ █▀ · BSD 3-Clause License :
|
|
: :
|
|
: (c) 2022-2026 vmfunc, xyzeva, :
|
|
: lunchcat alumni & contributors :
|
|
: :
|
|
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
|
*/
|
|
|
|
// todo: scan for storage and auth vulns
|
|
|
|
package js
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"regexp"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/log"
|
|
"github.com/vmfunc/sif/internal/httpx"
|
|
)
|
|
|
|
// jwtRegex matches JWT tokens in JavaScript content.
|
|
var jwtRegex = regexp.MustCompile(`["'\x60](ey[A-Za-z0-9_-]{2,}(?:\.[A-Za-z0-9_-]{2,}){2})["'\x60]`)
|
|
|
|
type supabaseJwtBody struct {
|
|
ProjectId *string `json:"ref"`
|
|
Role *string `json:"role"`
|
|
}
|
|
|
|
type supabaseScanResult struct {
|
|
ProjectId string `json:"project_id"`
|
|
ApiKey string `json:"api_key"`
|
|
Role string `json:"role"` // note: if this isnt anon its bad
|
|
Collections []supabaseCollection `json:"collections"`
|
|
}
|
|
|
|
type supabaseCollection struct {
|
|
Name string `json:"name"`
|
|
Sample []json.RawMessage `json:"sample"` // raw JSON for deferred parsing
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
// supabaseArrayResponse represents a response that is an array with count header.
|
|
type supabaseArrayResponse struct {
|
|
Array []json.RawMessage
|
|
Count int
|
|
}
|
|
|
|
// supabaseAuthResponse represents the auth response from Supabase.
|
|
type supabaseAuthResponse struct {
|
|
AccessToken string `json:"access_token"`
|
|
}
|
|
|
|
// supabaseOpenAPIResponse represents the OpenAPI spec response.
|
|
type supabaseOpenAPIResponse struct {
|
|
Paths map[string]json.RawMessage `json:"paths"`
|
|
}
|
|
|
|
// getSupabaseArrayResponse fetches a Supabase endpoint that returns an array.
|
|
func getSupabaseArrayResponse(projectId, path, apikey string, auth *string, timeout time.Duration) (*supabaseArrayResponse, error) {
|
|
body, resp, err := doSupabaseRequest(projectId, path, apikey, auth, timeout) //nolint:bodyclose // closed in doSupabaseRequest
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var arr []json.RawMessage
|
|
if err := json.Unmarshal(body, &arr); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
contentRange := resp.Header.Get("Content-Range")
|
|
parts := strings.Split(contentRange, "/")
|
|
if len(parts) < 2 {
|
|
return nil, errors.New("invalid Content-Range header")
|
|
}
|
|
count, err := strconv.Atoi(parts[1])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &supabaseArrayResponse{Array: arr, Count: count}, nil
|
|
}
|
|
|
|
// getSupabaseOpenAPI fetches the OpenAPI spec from Supabase.
|
|
func getSupabaseOpenAPI(projectId, apikey string, auth *string, timeout time.Duration) (*supabaseOpenAPIResponse, error) {
|
|
body, _, err := doSupabaseRequest(projectId, "/rest/v1/", apikey, auth, timeout) //nolint:bodyclose // closed in doSupabaseRequest
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var spec supabaseOpenAPIResponse
|
|
if err := json.Unmarshal(body, &spec); err != nil {
|
|
return nil, err
|
|
}
|
|
return &spec, nil
|
|
}
|
|
|
|
// doSupabaseRequest performs a GET request to the Supabase API.
|
|
func doSupabaseRequest(projectId, path, apikey string, auth *string, timeout time.Duration) ([]byte, *http.Response, error) {
|
|
client := httpx.Client(timeout)
|
|
|
|
req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, "https://"+projectId+".supabase.co"+path, http.NoBody)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
log.Debugf("Sending request to %s", req.URL.String())
|
|
req.Header.Set("apikey", apikey)
|
|
req.Header.Set("Prefer", "count=exact")
|
|
if auth != nil {
|
|
req.Header.Set("Authorization", "Bearer "+*auth)
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
// the non-200 branch returns before reading the body, so drain on close to
|
|
// keep the conn reusable instead of leaking it.
|
|
defer httpx.DrainClose(resp)
|
|
|
|
if resp.StatusCode != 200 {
|
|
return nil, nil, errors.New("request to " + resp.Request.URL.String() + " failed with status code " + strconv.Itoa(resp.StatusCode))
|
|
}
|
|
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 5*1024*1024))
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
return body, resp, nil
|
|
}
|
|
|
|
func ScanSupabase(jsContent string, jsUrl string, timeout time.Duration) ([]supabaseScanResult, error) {
|
|
supabaselog := log.NewWithOptions(os.Stderr, log.Options{
|
|
Prefix: "JavaScript > Supabase",
|
|
}).With("url", jsUrl)
|
|
|
|
var results = []supabaseScanResult{}
|
|
jwtGroups := jwtRegex.FindAllStringSubmatch(jsContent, -1)
|
|
|
|
var jwts = []string{}
|
|
|
|
for _, jwtGroup := range jwtGroups {
|
|
jwts = append(jwts, jwtGroup[1])
|
|
}
|
|
|
|
slices.Sort(jwts)
|
|
jwts = slices.Compact(jwts)
|
|
|
|
for _, jwt := range jwts {
|
|
parts := strings.Split(jwt, ".")
|
|
body := parts[1]
|
|
|
|
decoded, err := base64.RawStdEncoding.DecodeString(body)
|
|
if err != nil {
|
|
supabaselog.Debugf("Failed to decode JWT %s: %s", body, err)
|
|
continue
|
|
}
|
|
|
|
supabaselog.Debugf("JWT body: %s", decoded)
|
|
var supabaseJwt *supabaseJwtBody
|
|
err = json.Unmarshal(decoded, &supabaseJwt)
|
|
if err != nil {
|
|
supabaselog.Debugf("Failed to json parse JWT %s: %s", jwt, err)
|
|
continue
|
|
}
|
|
|
|
if supabaseJwt.ProjectId == nil || supabaseJwt.Role == nil {
|
|
continue
|
|
}
|
|
|
|
supabaselog.Infof("Found valid supabase project %s with role %s", *supabaseJwt.ProjectId, *supabaseJwt.Role)
|
|
client := httpx.Client(timeout)
|
|
|
|
req, err := http.NewRequestWithContext(context.TODO(), http.MethodPost, "https://"+*supabaseJwt.ProjectId+".supabase.co/auth/v1/signup", bytes.NewBufferString(`{"email":"automated`+strconv.Itoa(int(time.Now().Unix()))+`@sif.sh","password":"automatedacct"}`))
|
|
if err != nil {
|
|
supabaselog.Errorf("Error while creating HTTP req for creating user: %s", err)
|
|
continue
|
|
}
|
|
req.Header.Set("apikey", jwt)
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
supabaselog.Errorf("Error while sending request to create user: %s", err)
|
|
continue
|
|
}
|
|
|
|
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 {
|
|
supabaselog.Errorf("Failed to read signup response for %s: %s", *supabaseJwt.ProjectId, err)
|
|
continue
|
|
}
|
|
|
|
var authResp supabaseAuthResponse
|
|
if err := json.Unmarshal(body, &authResp); err != nil {
|
|
supabaselog.Errorf("Failed to parse signup response for %s: %s", *supabaseJwt.ProjectId, err)
|
|
continue
|
|
}
|
|
|
|
auth = authResp.AccessToken
|
|
supabaselog.Infof("Created account with JWT %s", auth)
|
|
} else {
|
|
// non-200 signup: body never read, so drain to reuse the conn.
|
|
httpx.DrainClose(resp)
|
|
}
|
|
|
|
var collections = []supabaseCollection{}
|
|
|
|
openAPI, err := getSupabaseOpenAPI(*supabaseJwt.ProjectId, jwt, &auth, timeout)
|
|
if err != nil {
|
|
supabaselog.Errorf("Failed to fetch openapi spec for %s: %s", *supabaseJwt.ProjectId, err)
|
|
continue
|
|
}
|
|
|
|
if openAPI.Paths == nil {
|
|
supabaselog.Errorf("No paths found in supabase openapi for %s", *supabaseJwt.ProjectId)
|
|
continue
|
|
}
|
|
|
|
for path := range openAPI.Paths {
|
|
if path == "/" {
|
|
continue
|
|
}
|
|
|
|
// todo: support for scanning rpc calls
|
|
if strings.HasPrefix(path, "/rpc/") {
|
|
continue
|
|
}
|
|
|
|
sampleResp, err := getSupabaseArrayResponse(*supabaseJwt.ProjectId, "/rest/v1"+path, jwt, &auth, timeout)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
marshalled, err := json.Marshal(sampleResp.Array)
|
|
if err != nil {
|
|
supabaselog.Errorf("Failed to marshal sample data for %s: %s", path, err)
|
|
}
|
|
|
|
supabaselog.Infof("Got sample (1000 entries) for collection %s: %s", path, string(marshalled))
|
|
|
|
// limit to first 10 samples
|
|
sampleLimit := len(sampleResp.Array)
|
|
if sampleLimit > 10 {
|
|
sampleLimit = 10
|
|
}
|
|
|
|
collection := supabaseCollection{
|
|
Name: strings.TrimPrefix(path, "/"),
|
|
Sample: sampleResp.Array[:sampleLimit], // passed to local LLM for scope
|
|
Count: sampleResp.Count,
|
|
}
|
|
|
|
if collection.Count > 1 /* one entry may just be for the user */ {
|
|
collections = append(collections, collection)
|
|
}
|
|
}
|
|
|
|
result := supabaseScanResult{
|
|
ProjectId: *supabaseJwt.ProjectId,
|
|
ApiKey: jwt,
|
|
Role: *supabaseJwt.Role,
|
|
Collections: collections,
|
|
}
|
|
results = append(results, result)
|
|
}
|
|
|
|
// todo(eva): implement supabase scanning
|
|
return results, nil
|
|
}
|