mirror of
https://github.com/lunchcat/sif.git
synced 2026-07-28 14:37:01 -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.
217 lines
6.6 KiB
Go
217 lines
6.6 KiB
Go
/*
|
|
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
|
: :
|
|
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
|
: ▄█ █ █▀ · BSD 3-Clause License :
|
|
: :
|
|
: (c) 2022-2026 vmfunc, xyzeva, :
|
|
: lunchcat alumni & contributors :
|
|
: :
|
|
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
|
*/
|
|
|
|
package js
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/antchfx/htmlquery"
|
|
charmlog "github.com/charmbracelet/log"
|
|
urlutil "github.com/projectdiscovery/utils/url"
|
|
"github.com/vmfunc/sif/internal/httpx"
|
|
"github.com/vmfunc/sif/internal/output"
|
|
"github.com/vmfunc/sif/internal/scan/js/frameworks"
|
|
)
|
|
|
|
type JavascriptScanResult struct {
|
|
SupabaseResults []supabaseScanResult `json:"supabase_results"`
|
|
FoundEnvironmentVars map[string]string `json:"environment_variables"`
|
|
SecretMatches []SecretMatch `json:"secret_matches"`
|
|
Endpoints []string `json:"endpoints"`
|
|
}
|
|
|
|
// ResultType implements the ScanResult interface.
|
|
func (r *JavascriptScanResult) ResultType() string { return "js" }
|
|
|
|
// SupabaseFinding is the exported view of one discovered supabase project. the
|
|
// raw supabaseScanResult stays package-private (it carries scan internals), so
|
|
// downstream normalizers consume this projection instead.
|
|
type SupabaseFinding struct {
|
|
ProjectId string
|
|
Role string
|
|
Collections int
|
|
}
|
|
|
|
// SupabaseFindings projects the package-private supabase results into a stable
|
|
// exported shape for the finding normalizer; role is what makes one interesting
|
|
// (a non-anon key is the real bug).
|
|
func (r *JavascriptScanResult) SupabaseFindings() []SupabaseFinding {
|
|
out := make([]SupabaseFinding, 0, len(r.SupabaseResults))
|
|
for i := 0; i < len(r.SupabaseResults); i++ {
|
|
s := r.SupabaseResults[i]
|
|
out = append(out, SupabaseFinding{
|
|
ProjectId: s.ProjectId,
|
|
Role: s.Role,
|
|
Collections: len(s.Collections),
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// maxHTMLBodySize caps how much of a page we read for script extraction so a
|
|
// huge or hostile response cannot exhaust memory.
|
|
const maxHTMLBodySize = 5 * 1024 * 1024
|
|
|
|
func JavascriptScan(url string, timeout time.Duration, threads int, logdir string) (*JavascriptScanResult, error) {
|
|
log := output.Module("JS")
|
|
log.Start()
|
|
|
|
spin := output.NewSpinner("Scanning JavaScript files")
|
|
spin.Start()
|
|
|
|
client := httpx.Client(timeout)
|
|
|
|
baseUrl, err := urlutil.Parse(url)
|
|
if err != nil {
|
|
spin.Stop()
|
|
return nil, err
|
|
}
|
|
req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, url, http.NoBody)
|
|
if err != nil {
|
|
spin.Stop()
|
|
return nil, err
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
spin.Stop()
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
doc, err := htmlquery.Parse(io.LimitReader(resp.Body, maxHTMLBodySize))
|
|
if err != nil {
|
|
spin.Stop()
|
|
return nil, err
|
|
}
|
|
|
|
var scripts []string
|
|
nodes, err := htmlquery.QueryAll(doc, "//script/@src")
|
|
if err != nil {
|
|
spin.Stop()
|
|
return nil, err
|
|
}
|
|
for _, node := range nodes {
|
|
var src = htmlquery.InnerText(node)
|
|
url, err := urlutil.Parse(src)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
if url.IsRelative {
|
|
url.Host = baseUrl.Host
|
|
url.Scheme = baseUrl.Scheme
|
|
}
|
|
scripts = append(scripts, url.String())
|
|
}
|
|
|
|
for _, script := range scripts {
|
|
if strings.Contains(script, "/_buildManifest.js") {
|
|
log.Info("Detected Next.JS pages router! Getting all scripts from %s", script)
|
|
nextScripts, err := frameworks.GetPagesRouterScripts(script, timeout)
|
|
if err != nil {
|
|
spin.Stop()
|
|
return nil, err
|
|
}
|
|
|
|
for _, nextScript := range nextScripts {
|
|
if slices.Contains(scripts, nextScript) {
|
|
continue
|
|
}
|
|
scripts = append(scripts, nextScript)
|
|
}
|
|
}
|
|
}
|
|
|
|
log.Info("Got %d scripts, now running scans on them", len(scripts))
|
|
|
|
supabaseResults := make([]supabaseScanResult, 0, len(scripts))
|
|
secretMatches := make([]SecretMatch, 0)
|
|
endpoints := make([]string, 0)
|
|
// dedupe secrets and endpoints across every script, not just within one.
|
|
seenSecrets := make(map[string]struct{})
|
|
seenEndpoints := make(map[string]struct{})
|
|
for _, script := range scripts {
|
|
charmlog.Debugf("Scanning %s", script)
|
|
req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, script, http.NoBody)
|
|
if err != nil {
|
|
charmlog.Warnf("Failed to create request: %s", err)
|
|
continue
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
charmlog.Warnf("Failed to fetch script: %s", err)
|
|
continue
|
|
}
|
|
|
|
bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 5*1024*1024))
|
|
resp.Body.Close()
|
|
if err != nil {
|
|
charmlog.Errorf("Failed to read script body: %s", err)
|
|
continue
|
|
}
|
|
content := string(bodyBytes)
|
|
|
|
charmlog.Debugf("Running supabase scanner on %s", script)
|
|
scriptSupabaseResults, err := ScanSupabase(content, script, timeout)
|
|
|
|
if err != nil {
|
|
charmlog.Errorf("Error while scanning supabase: %s", err)
|
|
}
|
|
|
|
if scriptSupabaseResults != nil {
|
|
supabaseResults = append(supabaseResults, scriptSupabaseResults...)
|
|
}
|
|
|
|
// reuse the same script buffer for credential and endpoint extraction.
|
|
for _, match := range ScanSecrets(content, script) {
|
|
key := match.Rule + "\x00" + match.Match
|
|
if _, ok := seenSecrets[key]; ok {
|
|
continue
|
|
}
|
|
seenSecrets[key] = struct{}{}
|
|
secretMatches = append(secretMatches, match)
|
|
log.Warn("found %s in %s", match.Rule, script)
|
|
}
|
|
|
|
for _, endpoint := range ExtractEndpoints(content, script) {
|
|
if _, ok := seenEndpoints[endpoint]; ok {
|
|
continue
|
|
}
|
|
seenEndpoints[endpoint] = struct{}{}
|
|
endpoints = append(endpoints, endpoint)
|
|
}
|
|
}
|
|
|
|
spin.Stop()
|
|
|
|
if len(endpoints) > 0 {
|
|
log.Info("extracted %d endpoints", len(endpoints))
|
|
}
|
|
|
|
result := JavascriptScanResult{
|
|
SupabaseResults: supabaseResults,
|
|
FoundEnvironmentVars: map[string]string{},
|
|
SecretMatches: secretMatches,
|
|
Endpoints: endpoints,
|
|
}
|
|
|
|
log.Complete(len(supabaseResults)+len(secretMatches)+len(endpoints), "found")
|
|
|
|
return &result, nil
|
|
}
|