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.
This commit is contained in:
vmfunc
2026-01-02 23:55:17 -08:00
parent 2002509ab5
commit 1b27250b05
6 changed files with 193 additions and 73 deletions
+3
View File
@@ -37,6 +37,9 @@ type FrameworkResult struct {
RiskLevel string `json:"risk_level,omitempty"`
}
// ResultType implements the ScanResult interface.
func (r *FrameworkResult) ResultType() string { return "framework" }
type FrameworkSignature struct {
Pattern string
Weight float32
+3
View File
@@ -32,6 +32,9 @@ type JavascriptScanResult struct {
FoundEnvironmentVars map[string]string `json:"environment_variables"`
}
// ResultType implements the ScanResult interface.
func (r *JavascriptScanResult) ResultType() string { return "js" }
func JavascriptScan(url string, timeout time.Duration, threads int, logdir string) (*JavascriptScanResult, error) {
jslog := log.NewWithOptions(os.Stderr, log.Options{
Prefix: "🚧 JavaScript",
+92 -58
View File
@@ -20,7 +20,6 @@ import (
"encoding/json"
"errors"
"io"
"math"
"net/http"
"os"
"regexp"
@@ -36,24 +35,82 @@ 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 []interface{} `json:"sample"`
Count int `json:"count"`
Name string `json:"name"`
Sample []json.RawMessage `json:"sample"` // raw JSON for deferred parsing
Count int `json:"count"`
}
func GetSupabaseJsonResponse(projectId string, path string, apikey string, auth *string) (map[string]interface{}, error) {
// 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, err
return nil, nil, err
}
log.Debugf("Sending request to %s", req.URL.String())
@@ -65,44 +122,20 @@ func GetSupabaseJsonResponse(projectId string, path string, apikey string, auth
resp, err := client.Do(req)
if err != nil {
return nil, err
return nil, nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, errors.New("Request to " + resp.Request.URL.String() + " failed with status code " + strconv.Itoa(resp.StatusCode))
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, err
}
content := string(body)
var data interface{}
err = json.Unmarshal([]byte(content), &data)
if err != nil {
return nil, err
return nil, nil, err
}
arr, ok := data.([]interface{})
if ok {
wrappedData := map[string]interface{}{}
contentRange := resp.Header.Get("Content-Range")
count, err := strconv.Atoi(strings.Split(contentRange, "/")[1])
if err != nil {
return nil, err
}
wrappedData["count"] = count
wrappedData["array"] = arr
return wrappedData, nil
}
return data.(map[string]interface{}), nil
return body, resp, nil
}
func ScanSupabase(jsContent string, jsUrl string) ([]supabaseScanResult, error) {
@@ -170,64 +203,65 @@ func ScanSupabase(jsContent string, jsUrl string) ([]supabaseScanResult, error)
if resp.StatusCode == http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
resp.Body.Close()
return nil, err
}
content := string(body)
resp.Body.Close()
var data map[string]interface{}
err = json.Unmarshal([]byte(content), &data)
if err != nil {
var authResp supabaseAuthResponse
if err := json.Unmarshal(body, &authResp); err != nil {
return nil, err
}
auth = data["access_token"].(string)
auth = authResp.AccessToken
supabaselog.Infof("Created account with JWT %s", auth)
} else {
resp.Body.Close()
}
var collections = []supabaseCollection{}
res, err := GetSupabaseJsonResponse(*supabaseJwt.ProjectId, "/rest/v1/", jwt, &auth)
openAPI, err := getSupabaseOpenAPI(*supabaseJwt.ProjectId, jwt, &auth)
if err != nil {
return nil, err
}
index := res
if index["paths"] == nil {
if openAPI.Paths == nil {
return nil, errors.New("paths not found in supabase openapi")
}
var paths = index["paths"].(map[string]interface{})
for k := range paths {
if k == "/" {
for path := range openAPI.Paths {
if path == "/" {
continue
}
// todo: support for scanning rpc calls
if strings.HasPrefix(k, "/rpc/") {
if strings.HasPrefix(path, "/rpc/") {
continue
}
sampleObj, err := GetSupabaseJsonResponse(*supabaseJwt.ProjectId, "/rest/v1"+k, jwt, &auth)
sampleResp, err := getSupabaseArrayResponse(*supabaseJwt.ProjectId, "/rest/v1"+path, jwt, &auth)
if err != nil {
continue
}
samples := sampleObj["array"].([]interface{})
marshalled, err := json.Marshal(samples)
marshalled, err := json.Marshal(sampleResp.Array)
if err != nil {
supabaselog.Errorf("Failed to marshal sample data for %s: %s", k, err)
supabaselog.Errorf("Failed to marshal sample data for %s: %s", path, err)
}
supabaselog.Infof("Got sample (1000 entries) for collection %s: %s", k, string(marshalled))
supabaselog.Infof("Got sample (1000 entries) for collection %s: %s", path, string(marshalled))
limitedSample := samples[0:int(math.Min(float64(len(samples)), 10))]
// limit to first 10 samples
sampleLimit := len(sampleResp.Array)
if sampleLimit > 10 {
sampleLimit = 10
}
collection := supabaseCollection{
Name: strings.TrimPrefix(k, "/"),
Sample: limitedSample, // passed to local LLM for scope
Count: sampleObj["count"].(int),
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 */ {
+58
View File
@@ -0,0 +1,58 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2025 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package scan
// Named slice types for scan results.
// These provide better type safety and allow method implementations.
type (
HeaderResults []HeaderResult
DirectoryResults []DirectoryResult
CloudStorageResults []CloudStorageResult
DorkResults []DorkResult
SubdomainTakeoverResults []SubdomainTakeoverResult
)
// ScanResult is the interface that all scan result types implement.
// This enables type-safe handling of heterogeneous scan results.
type ScanResult interface {
// ResultType returns the unique identifier for this result type.
ResultType() string
}
// ResultType implementations for pointer result types.
func (r *ShodanResult) ResultType() string { return "shodan" }
func (r *SQLResult) ResultType() string { return "sql" }
func (r *LFIResult) ResultType() string { return "lfi" }
func (r *CMSResult) ResultType() string { return "cms" }
// ResultType implementations for slice result types.
func (r HeaderResults) ResultType() string { return "headers" }
func (r DirectoryResults) ResultType() string { return "dirlist" }
func (r CloudStorageResults) ResultType() string { return "cloudstorage" }
func (r DorkResults) ResultType() string { return "dork" }
func (r SubdomainTakeoverResults) ResultType() string { return "subdomain_takeover" }
// Compile-time interface satisfaction checks.
var (
_ ScanResult = (*ShodanResult)(nil)
_ ScanResult = (*SQLResult)(nil)
_ ScanResult = (*LFIResult)(nil)
_ ScanResult = (*CMSResult)(nil)
_ ScanResult = HeaderResults(nil)
_ ScanResult = DirectoryResults(nil)
_ ScanResult = CloudStorageResults(nil)
_ ScanResult = DorkResults(nil)
_ ScanResult = SubdomainTakeoverResults(nil)
)
+16 -9
View File
@@ -72,13 +72,22 @@ type shodanHostResponse struct {
LastUpdate string `json:"last_update"`
}
// shodanMetadata represents the _shodan field in Shodan API responses.
// This provides type safety instead of using map[string]interface{}.
type shodanMetadata struct {
Module string `json:"module"`
Crawler string `json:"crawler,omitempty"`
ID string `json:"id,omitempty"`
Ptr bool `json:"ptr,omitempty"`
}
type shodanData struct {
Port int `json:"port"`
Transport string `json:"transport"`
Product string `json:"product"`
Version string `json:"version"`
Data string `json:"data"`
Shodan map[string]interface{} `json:"_shodan"`
Port int `json:"port"`
Transport string `json:"transport"`
Product string `json:"product"`
Version string `json:"version"`
Data string `json:"data"`
Shodan shodanMetadata `json:"_shodan"`
}
// Shodan performs a Shodan lookup for the given URL
@@ -217,9 +226,7 @@ func queryShodanHost(ip string, apiKey string, timeout time.Duration) (*ShodanRe
Product: data.Product,
Version: data.Version,
Banner: truncateBanner(data.Data, 200),
}
if module, ok := data.Shodan["module"].(string); ok {
service.Module = module
Module: data.Shodan.Module,
}
result.Services = append(result.Services, service)
}
+21 -6
View File
@@ -49,6 +49,21 @@ type ModuleResult struct {
Data interface{} `json:"data"`
}
// ScanResult is the interface that all scan result types must implement.
// This mirrors the definition in pkg/scan/result.go for use by the main package.
type ScanResult interface {
ResultType() string
}
// NewModuleResult creates a ModuleResult with compile-time type safety.
// The data parameter must implement ScanResult, which is enforced at compile time.
func NewModuleResult[T ScanResult](data T) ModuleResult {
return ModuleResult{
Id: data.ResultType(),
Data: data,
}
}
// New creates a new App struct by parsing the configuration options,
// figuring out the targets from list or file, etc.
//
@@ -131,7 +146,7 @@ func (app *App) Run() error {
if err != nil {
log.Errorf("Error while running framework detection: %s", err)
} else if result != nil {
moduleResults = append(moduleResults, ModuleResult{"framework", result})
moduleResults = append(moduleResults, NewModuleResult(result))
scansRun = append(scansRun, "Framework Detection")
}
}
@@ -223,7 +238,7 @@ func (app *App) Run() error {
if err != nil {
log.Errorf("Error while running JS module: %s", err)
} else {
moduleResults = append(moduleResults, ModuleResult{"js", result})
moduleResults = append(moduleResults, NewModuleResult(result))
scansRun = append(scansRun, "JS")
}
}
@@ -234,7 +249,7 @@ func (app *App) Run() error {
log.Errorf("Error while running CMS detection: %s", err)
scansRun = append(scansRun, "CMS")
} else if result != nil {
moduleResults = append(moduleResults, ModuleResult{"cms", result})
moduleResults = append(moduleResults, NewModuleResult(result))
}
}
@@ -263,7 +278,7 @@ func (app *App) Run() error {
if err != nil {
log.Errorf("Error while running Shodan lookup: %s", err)
} else if result != nil {
moduleResults = append(moduleResults, ModuleResult{"shodan", result})
moduleResults = append(moduleResults, NewModuleResult(result))
scansRun = append(scansRun, "Shodan")
}
}
@@ -273,7 +288,7 @@ func (app *App) Run() error {
if err != nil {
log.Errorf("Error while running SQL reconnaissance: %s", err)
} else if result != nil {
moduleResults = append(moduleResults, ModuleResult{"sql", result})
moduleResults = append(moduleResults, NewModuleResult(result))
scansRun = append(scansRun, "SQL Recon")
}
}
@@ -283,7 +298,7 @@ func (app *App) Run() error {
if err != nil {
log.Errorf("Error while running LFI reconnaissance: %s", err)
} else if result != nil {
moduleResults = append(moduleResults, ModuleResult{"lfi", result})
moduleResults = append(moduleResults, NewModuleResult(result))
scansRun = append(scansRun, "LFI Recon")
}
}