mirror of
https://github.com/lunchcat/sif.git
synced 2026-06-12 19:11:25 -07:00
d0bdcf1690
every scanner spun up its own &http.Client, so there was no single place to apply a proxy, custom headers, a cookie or a rate limit. add an internal/httpx package that builds one configured transport at startup and hand it to every scanner via httpx.Client(timeout), keeping behavior identical when nothing is set (plain client when Configure was never called). - httpx.Configure wires -proxy (http/https/socks5), -H/--header, -cookie and -rate-limit into a package-level RoundTripper that paces via a rate.Limiter and only sets headers the caller hasn't already, so a scanner's explicit api key still wins. - route the scan/wordlist downloads that used http.DefaultClient through the shared client too; ports tcp dialing is left untouched. - clamp -threads to a floor of 1: it feeds wg.Add across the scanners, so 0 was a silent no-op and a negative value panicked the waitgroup. document the new flags in the readme, usage docs and man page.
283 lines
8.4 KiB
Go
283 lines
8.4 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/dropalldatabases/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
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
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))
|
|
if err != nil {
|
|
resp.Body.Close()
|
|
return nil, err
|
|
}
|
|
resp.Body.Close()
|
|
|
|
var authResp supabaseAuthResponse
|
|
if err := json.Unmarshal(body, &authResp); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
auth = authResp.AccessToken
|
|
supabaselog.Infof("Created account with JWT %s", auth)
|
|
} else {
|
|
resp.Body.Close()
|
|
}
|
|
|
|
var collections = []supabaseCollection{}
|
|
|
|
openAPI, err := getSupabaseOpenAPI(*supabaseJwt.ProjectId, jwt, &auth, timeout)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if openAPI.Paths == nil {
|
|
return nil, errors.New("paths not found in supabase openapi")
|
|
}
|
|
|
|
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
|
|
}
|