Files
sif/pkg/scan/js/supabase.go
Celeste Hickenlooper 09347bc908 feat: add generic types and type-safe result handling
introduce ScanResult interface and generic NewModuleResult constructor
for compile-time type safety when creating module results.

- add pkg/scan/result.go with ScanResult interface and named slice types
- add typed shodanMetadata struct to replace map[string]interface{}
- refactor supabase.go with typed response structs and json.RawMessage
- add ResultType() methods to all scan result types
- update sif.go to use NewModuleResult generic constructor

this provides type safety without breaking JSON serialization.
2026-01-03 05:57:09 -08:00

284 lines
8.0 KiB
Go

/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2025 vmfunc (Celeste Hickenlooper), xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
// todo: scan for storage and auth vulns
package js
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"io"
"net/http"
"os"
"regexp"
"slices"
"strconv"
"strings"
"time"
"github.com/charmbracelet/log"
)
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) (*supabaseArrayResponse, error) {
body, resp, err := doSupabaseRequest(projectId, path, apikey, auth)
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) (*supabaseOpenAPIResponse, error) {
body, _, err := doSupabaseRequest(projectId, "/rest/v1/", apikey, auth)
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) ([]byte, *http.Response, error) {
client := http.Client{}
req, err := http.NewRequest("GET", "https://"+projectId+".supabase.co"+path, nil)
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(resp.Body)
if err != nil {
return nil, nil, err
}
return body, resp, nil
}
func ScanSupabase(jsContent string, jsUrl string) ([]supabaseScanResult, error) {
supabaselog := log.NewWithOptions(os.Stderr, log.Options{
Prefix: "🚧 JavaScript > Supabase ⚡️",
}).With("url", jsUrl)
jwtRegex, err := regexp.Compile("[\"|'|`](ey[A-Za-z0-9_-]{2,}(?:\\.[A-Za-z0-9_-]{2,}){2})[\"|'|`]")
if err != nil {
return nil, err
}
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([]byte(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 := http.Client{}
req, err := http.NewRequest("POST", "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(resp.Body)
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)
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)
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
}