mirror of
https://github.com/lunchcat/sif.git
synced 2026-06-12 19:11:25 -07:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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 */ {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user