diff --git a/README.md b/README.md index 530d041..a493d38 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,9 @@ sif has a modular architecture. modules are defined in yaml and can be extended | `-securitytrails` | domain discovery + target expansion (requires SECURITYTRAILS_API_KEY) | | `-sql` | sql recon | | `-lfi` | local file inclusion | +| `-jwt` | jwt discovery + offline weakness analysis (alg:none, weak hmac, exp, sensitive claims) | +| `-openapi` | openapi/swagger spec exposure probe (enumerates paths + unauth endpoints) | +| `-favicon` | favicon hash fingerprinting (shodan-style mmh3, tech match + pivot query) | | `-cors` | cors misconfiguration probe | | `-redirect` | open redirect probe | | `-xss` | reflected xss probe | @@ -216,6 +219,7 @@ write the run's findings out to a file for ci/cd or triage: |------|-------------| | `-sarif` | write a sarif 2.1.0 report to this file | | `-markdown`, `-md` | write a markdown report to this file | +| `-silent` | plain output: chrome to stderr, one finding per line to stdout (for pipelines) | ```bash # scan and emit both a sarif and markdown report @@ -224,6 +228,21 @@ write the run's findings out to a file for ci/cd or triage: sarif output is ingestable by github code scanning; markdown is a readable per-target summary. +### pipe mode + +sif reads targets from stdin and accepts naked hosts, so it drops into a unix pipeline. `-silent` routes all banner/spinner/log chrome to stderr and prints one normalized finding per line (`[severity] target module title`) to stdout: + +```bash +# subfinder feeds hosts, sif probes them, notify ships the findings +subfinder -d example.com | sif -silent -probe | notify +``` + +| flag | description | +|------|-------------| +| stdin | a piped target stream (one host/url per line) is read alongside `-u`/`-f` | + +scheme-less hosts default to `https://`; an explicit `http://`/`https://` is kept; any other scheme (`ftp://`, ...) is rejected. + ### yaml modules list available modules: diff --git a/cmd/sif/main.go b/cmd/sif/main.go index 79d176b..7d94269 100644 --- a/cmd/sif/main.go +++ b/cmd/sif/main.go @@ -52,7 +52,9 @@ func main() { log.Fatal(err) } - if !settings.ApiMode { + // patchnotes print to stdout; skip them in api/silent mode so the only thing + // on stdout is the machine-readable result stream. + if !settings.ApiMode && !settings.Silent { patchnotes.ShowOnce(version) } diff --git a/docs/usage.md b/docs/usage.md index 5bf504e..8325e75 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -21,6 +21,23 @@ read targets from a file (one url per line): ./sif -f targets.txt ``` +### stdin (pipe mode) + +when stdin is a pipe, sif reads one target per line from it, alongside any `-u`/`-f` targets. this lets sif slot into a unix pipeline: + +```bash +subfinder -d example.com | sif -silent -probe | notify +``` + +### naked hosts + +targets without a scheme default to `https://`; an explicit `http://`/`https://` is kept as given. any other scheme (`ftp://`, `file://`, ...) is rejected: + +```bash +./sif -u example.com # scanned as https://example.com +echo example.com | sif -probe # same, over stdin +``` + ## scan options ### directory fuzzing @@ -214,6 +231,32 @@ export SHODAN_API_KEY=your-api-key ./sif -u https://example.com/search?q=test -xss ``` +### jwt analysis + +`-jwt` - fetch the target once, harvest jwts from response headers, cookies and body, then analyze each one entirely offline + +flags alg:none, the rs256->hs256 confusion surface, missing/expired exp, plaintext sensitive claims, and cracks a small bundled weak-hmac wordlist. no token is ever sent off-box. + +```bash +./sif -u https://example.com -jwt +``` + +### openapi/swagger exposure + +`-openapi` - probe the conventional spec paths (`/swagger.json`, `/openapi.json`, `/v3/api-docs`, ...), parse the first hit (json or yaml) and enumerate every path+method, flagging operations with no security requirement + +```bash +./sif -u https://example.com -openapi +``` + +### favicon fingerprint + +`-favicon` - fetch `/favicon.ico` (or the declared ``), compute the shodan-style mmh3 hash, match it against a bundled tech map and print the `http.favicon.hash:` pivot query + +```bash +./sif -u https://example.com -favicon +``` + ### framework detection `-framework` - detect web frameworks with version and cve lookup @@ -391,6 +434,14 @@ write a readable markdown report grouped by target, then by module: ./sif -u https://example.com -headers -cors -md report.md ``` +### -silent + +plain output for pipelines: all banner/spinner/log chrome goes to stderr and stdout carries one normalized finding per line, formatted `[severity] target module title`. implies non-interactive (no spinners), so a downstream consumer sees nothing but findings: + +```bash +subfinder -d example.com | sif -silent -probe -sh | notify +``` + ## api options ### -api diff --git a/go.mod b/go.mod index 14b15d1..cb47e5a 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/projectdiscovery/retryabledns v1.0.114 github.com/projectdiscovery/utils v0.10.1 github.com/rocketlaunchr/google-search v1.1.6 + github.com/twmb/murmur3 v1.1.6 golang.org/x/net v0.53.0 golang.org/x/time v0.14.0 gopkg.in/yaml.v3 v3.0.1 diff --git a/internal/config/config.go b/internal/config/config.go index d9742b7..e0f7583 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -55,6 +55,9 @@ type Settings struct { SecurityTrails bool SQL bool LFI bool + JWT bool + OpenAPI bool + Favicon bool CORS bool Redirect bool XSS bool @@ -65,6 +68,7 @@ type Settings struct { Probe bool SARIF string // path to write a sarif 2.1.0 report to ("" = off) Markdown string // path to write a markdown report to ("" = off) + Silent bool // route chrome to stderr, print one finding per line to stdout Modules string // Comma-separated list of module IDs to run ModuleTags string // Run modules matching these tags AllModules bool // Run all loaded modules @@ -138,6 +142,9 @@ func Parse() *Settings { flagSet.BoolVar(&settings.SecurityTrails, "securitytrails", false, "Enable SecurityTrails domain discovery (requires SECURITYTRAILS_API_KEY env var)"), flagSet.BoolVar(&settings.SQL, "sql", false, "Enable SQL reconnaissance (admin panels, error disclosure)"), flagSet.BoolVar(&settings.LFI, "lfi", false, "Enable LFI (Local File Inclusion) reconnaissance"), + flagSet.BoolVar(&settings.JWT, "jwt", false, "Enable JWT discovery + offline weakness analysis"), + flagSet.BoolVar(&settings.OpenAPI, "openapi", false, "Enable OpenAPI/Swagger spec exposure probe"), + flagSet.BoolVar(&settings.Favicon, "favicon", false, "Enable favicon hash fingerprinting (shodan-style)"), flagSet.BoolVar(&settings.CORS, "cors", false, "Enable CORS misconfiguration probe"), flagSet.BoolVar(&settings.Redirect, "redirect", false, "Enable open redirect probe"), flagSet.BoolVar(&settings.XSS, "xss", false, "Enable reflected XSS probe"), @@ -166,6 +173,7 @@ func Parse() *Settings { flagSet.CreateGroup("output", "Output", flagSet.StringVar(&settings.SARIF, "sarif", "", "Write a SARIF 2.1.0 report to this file"), flagSet.StringVarP(&settings.Markdown, "markdown", "md", "", "Write a markdown report to this file"), + flagSet.BoolVar(&settings.Silent, "silent", false, "Plain output: chrome to stderr, one finding per line to stdout (for pipelines)"), ) flagSet.CreateGroup("api", "API", diff --git a/internal/finding/finding.go b/internal/finding/finding.go index 7c8a4d7..882ef42 100644 --- a/internal/finding/finding.go +++ b/internal/finding/finding.go @@ -41,6 +41,15 @@ type Finding struct { Raw string // short evidence string, not the full body } +// Line renders a finding as one stable, terse, machine-friendly line for the +// -silent plain sink: "[severity] target module title". no styling, no color - +// a downstream pipe (notify, grep, awk) keys off the bracketed severity and the +// fixed field order, so the shape stays frozen. pointer receiver: Finding is +// wide enough that copying it per line is wasteful. +func (f *Finding) Line() string { + return fmt.Sprintf("[%s] %s %s %s", f.Severity, f.Target, f.Module, f.Title) +} + // static per-module severities for results that carry no severity field of // their own. these are the editorial baseline; a scanner that emits its own // severity (cors, xss, nuclei, ...) overrides this on a per-item basis. @@ -80,6 +89,12 @@ func Flatten(target, module string, result any) []Finding { return flattenSQL(target, r) case *scan.LFIResult: return flattenLFI(target, r) + case *scan.JWTResult: + return flattenJWT(target, r) + case *scan.OpenAPIResult: + return flattenOpenAPI(target, r) + case *scan.FaviconResult: + return flattenFavicon(target, r) case *scan.CMSResult: return flattenCMS(target, r) case *scan.SecurityTrailsResult: @@ -233,6 +248,66 @@ func flattenLFI(target string, r *scan.LFIResult) []Finding { return out } +func flattenJWT(target string, r *scan.JWTResult) []Finding { + if r == nil { + return nil + } + out := make([]Finding, 0, len(r.Tokens)) + for i := 0; i < len(r.Tokens); i++ { + t := r.Tokens[i] + // one finding per weakness, not per token: a token with alg:none and a + // weak key is two distinct issues a consumer wants to diff separately. + for j := 0; j < len(t.Issues); j++ { + iss := t.Issues[j] + out = append(out, Finding{ + Target: target, + Module: "jwt", + Severity: ParseSeverity(iss.Severity), + Key: key("jwt", t.Source+":"+iss.Kind), + Title: "jwt " + iss.Kind, + Raw: iss.Detail, + }) + } + } + return out +} + +func flattenOpenAPI(target string, r *scan.OpenAPIResult) []Finding { + if r == nil { + return nil + } + return []Finding{{ + Target: target, + Module: "openapi", + Severity: ParseSeverity(r.Severity), + Key: key("openapi", r.SpecURL), + Title: "openapi spec exposed", + Raw: fmt.Sprintf("%s (%d endpoints)", r.SpecURL, len(r.Endpoints)), + }} +} + +func flattenFavicon(target string, r *scan.FaviconResult) []Finding { + if r == nil { + return nil + } + // a matched fingerprint is a real signal; an unmatched hash is just inventory + // (still useful as a shodan pivot, so we keep it at recon). + sev := sevRecon + title := fmt.Sprintf("favicon hash %d", r.Hash) + if r.Tech != "" { + sev = SeverityLow + title = r.Tech + " (favicon)" + } + return []Finding{{ + Target: target, + Module: "favicon", + Severity: sev, + Key: key("favicon", fmt.Sprintf("%d", r.Hash)), + Title: title, + Raw: r.ShodanQ, + }} +} + func flattenCMS(target string, r *scan.CMSResult) []Finding { if r == nil || r.Name == "" { return nil diff --git a/internal/finding/finding_test.go b/internal/finding/finding_test.go index 596fcf5..68b3e80 100644 --- a/internal/finding/finding_test.go +++ b/internal/finding/finding_test.go @@ -72,6 +72,35 @@ func coverageCases() []coverageCase { module: "lfi", wantItems: 1, }, + { + value: &scan.JWTResult{Tokens: []scan.JWTToken{{ + Source: "header:Authorization", + Alg: "none", + Issues: []scan.JWTIssue{ + {Kind: "alg:none", Severity: "critical", Detail: "no signature"}, + {Kind: "missing exp", Severity: "medium", Detail: "no expiry"}, + }, + }}}, + typed: &scan.JWTResult{}, + module: "jwt", + wantItems: 2, + }, + { + value: &scan.OpenAPIResult{ + SpecURL: "http://x/openapi.json", + Severity: "high", + Endpoints: []scan.OpenAPIEndpoint{{Path: "/users", Method: "GET", Unauth: true}}, + }, + typed: &scan.OpenAPIResult{}, + module: "openapi", + wantItems: 1, + }, + { + value: &scan.FaviconResult{Hash: 116323821, Tech: "Apache Tomcat", ShodanQ: "http.favicon.hash:116323821"}, + typed: &scan.FaviconResult{}, + module: "favicon", + wantItems: 1, + }, { value: &scan.CMSResult{Name: "WordPress", Version: "6.1"}, typed: &scan.CMSResult{}, @@ -245,7 +274,7 @@ func TestEveryResultTypeIsInCoverageTable(t *testing.T) { // lockstep with the ScanResult implementers; a missing entry means the table // (and very likely Flatten) skipped a scanner. want := []string{ - "shodan", "sql", "lfi", "cms", "securitytrails", + "shodan", "sql", "lfi", "jwt", "openapi", "favicon", "cms", "securitytrails", "cors", "redirect", "xss", "crawl", "passive", "probe", "headers", "security_headers", "dirlist", "cloudstorage", "dork", "subdomain_takeover", "framework", "js", "custom-mod", diff --git a/internal/finding/line_test.go b/internal/finding/line_test.go new file mode 100644 index 0000000..a14beda --- /dev/null +++ b/internal/finding/line_test.go @@ -0,0 +1,48 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package finding + +import "testing" + +// Line is the -silent wire format; its shape is frozen, so pin it. +func TestFindingLine(t *testing.T) { + tests := []struct { + name string + f Finding + want string + }{ + { + name: "high severity", + f: Finding{Target: "https://x.com", Module: "sql", Severity: SeverityHigh, Title: "admin panel"}, + want: "[high] https://x.com sql admin panel", + }, + { + name: "info recon", + f: Finding{Target: "https://y.com", Module: "headers", Severity: SeverityInfo, Title: "Server"}, + want: "[info] https://y.com headers Server", + }, + { + name: "unknown severity", + f: Finding{Target: "z.com", Module: "mystery", Severity: SeverityUnknown, Title: "?"}, + want: "[unknown] z.com mystery ?", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.f.Line(); got != tt.want { + t.Errorf("Line() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/output/output.go b/internal/output/output.go index 6f1e01d..251756b 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -14,6 +14,7 @@ package output import ( "fmt" + "io" "os" "strings" @@ -126,13 +127,47 @@ func SetAPIMode(enabled bool) { apiMode = enabled } +// sink is where all banner/spinner/log chrome is written. it defaults to stdout +// so normal runs are unchanged; -silent repoints it at stderr so stdout carries +// nothing but the machine-readable findings a downstream pipe consumes. +var sink io.Writer = os.Stdout + +// silent is the plain-sink mode: chrome goes to stderr and interactive widgets +// (spinners, live progress) are suppressed so a piped consumer never sees them. +var silent bool + +// SetSilent routes all chrome to stderr and marks the run non-interactive. +// findings are printed to stdout by the caller via Finding/PrintFinding; the +// output package itself never touches stdout once silent is on. +func SetSilent(enabled bool) { + silent = enabled + if enabled { + sink = os.Stderr + return + } + sink = os.Stdout +} + +// Silent reports whether plain-sink mode is active. callers gate interactive +// behaviour (spinners, prompts) on this. +func Silent() bool { + return silent +} + +// Writer is the current chrome sink (stdout normally, stderr under -silent). +// callers that render their own chrome (the startup banner) write here so it +// follows the same routing as everything else. +func Writer() io.Writer { + return sink +} + // Info prints an informational message with [*] prefix func Info(format string, args ...interface{}) { if apiMode { return } msg := fmt.Sprintf(format, args...) - fmt.Printf("%s %s\n", prefixInfo.Render("[*]"), msg) + fmt.Fprintf(sink, "%s %s\n", prefixInfo.Render("[*]"), msg) } // Success prints a success message with [+] prefix @@ -141,7 +176,7 @@ func Success(format string, args ...interface{}) { return } msg := fmt.Sprintf(format, args...) - fmt.Printf("%s %s\n", prefixSuccess.Render("[+]"), msg) + fmt.Fprintf(sink, "%s %s\n", prefixSuccess.Render("[+]"), msg) } // Warn prints a warning message with [!] prefix @@ -150,7 +185,7 @@ func Warn(format string, args ...interface{}) { return } msg := fmt.Sprintf(format, args...) - fmt.Printf("%s %s\n", prefixWarning.Render("[!]"), msg) + fmt.Fprintf(sink, "%s %s\n", prefixWarning.Render("[!]"), msg) } // Error prints an error message with [-] prefix @@ -159,7 +194,7 @@ func Error(format string, args ...interface{}) { return } msg := fmt.Sprintf(format, args...) - fmt.Printf("%s %s\n", prefixError.Render("[-]"), msg) + fmt.Fprintf(sink, "%s %s\n", prefixError.Render("[-]"), msg) } // ScanStart prints a styled scan start message @@ -167,7 +202,7 @@ func ScanStart(scanName string) { if apiMode { return } - fmt.Printf("%s starting %s\n", prefixInfo.Render("[*]"), scanName) + fmt.Fprintf(sink, "%s starting %s\n", prefixInfo.Render("[*]"), scanName) } // ScanComplete prints a styled scan completion message @@ -175,7 +210,7 @@ func ScanComplete(scanName string, resultCount int, resultType string) { if apiMode { return } - fmt.Printf("%s %s complete (%d %s)\n", prefixInfo.Render("[*]"), scanName, resultCount, resultType) + fmt.Fprintf(sink, "%s %s complete (%d %s)\n", prefixInfo.Render("[*]"), scanName, resultCount, resultType) } // Module creates a prefixed logger for a specific module/tool @@ -202,7 +237,7 @@ func (m *ModuleLogger) Info(format string, args ...interface{}) { return } msg := fmt.Sprintf(format, args...) - fmt.Printf("%s %s\n", m.prefix(), msg) + fmt.Fprintf(sink, "%s %s\n", m.prefix(), msg) } // Success prints a success message with module prefix @@ -211,7 +246,7 @@ func (m *ModuleLogger) Success(format string, args ...interface{}) { return } msg := fmt.Sprintf(format, args...) - fmt.Printf("%s %s %s\n", m.prefix(), prefixSuccess.Render("✓"), msg) + fmt.Fprintf(sink, "%s %s %s\n", m.prefix(), prefixSuccess.Render("✓"), msg) } // Warn prints a warning message with module prefix @@ -220,7 +255,7 @@ func (m *ModuleLogger) Warn(format string, args ...interface{}) { return } msg := fmt.Sprintf(format, args...) - fmt.Printf("%s %s %s\n", m.prefix(), prefixWarning.Render("!"), msg) + fmt.Fprintf(sink, "%s %s %s\n", m.prefix(), prefixWarning.Render("!"), msg) } // Error prints an error message with module prefix @@ -229,7 +264,7 @@ func (m *ModuleLogger) Error(format string, args ...interface{}) { return } msg := fmt.Sprintf(format, args...) - fmt.Printf("%s %s %s\n", m.prefix(), prefixError.Render("✗"), msg) + fmt.Fprintf(sink, "%s %s %s\n", m.prefix(), prefixError.Render("✗"), msg) } // Start prints a scan start message with module prefix (adds newline before for separation) @@ -237,7 +272,7 @@ func (m *ModuleLogger) Start() { if apiMode { return } - fmt.Printf("\n%s starting scan\n", m.prefix()) + fmt.Fprintf(sink, "\n%s starting scan\n", m.prefix()) } // Complete prints a scan complete message with module prefix @@ -245,15 +280,16 @@ func (m *ModuleLogger) Complete(resultCount int, resultType string) { if apiMode { return } - fmt.Printf("%s complete (%d %s)\n", m.prefix(), resultCount, resultType) + fmt.Fprintf(sink, "%s complete (%d %s)\n", m.prefix(), resultCount, resultType) } -// ClearLine clears the current line (for progress bar updates) +// ClearLine clears the current line (for progress bar updates). silent mode is +// non-interactive, so there's no live line to clear and stdout stays untouched. func ClearLine() { - if !IsTTY { + if !IsTTY || silent { return } - fmt.Print("\033[2K\r") + fmt.Fprint(sink, "\033[2K\r") } // Summary styles @@ -274,22 +310,22 @@ func PrintSummary(scans []string, logFiles []string) { return } - fmt.Println() - fmt.Println(summaryLine.Render("────────────────────────────────────────────────────────────")) - fmt.Println() - fmt.Printf(" %s\n", summaryHeader.Render("SCAN COMPLETE")) - fmt.Println() + fmt.Fprintln(sink) + fmt.Fprintln(sink, summaryLine.Render("────────────────────────────────────────────────────────────")) + fmt.Fprintln(sink) + fmt.Fprintf(sink, " %s\n", summaryHeader.Render("SCAN COMPLETE")) + fmt.Fprintln(sink) // Print scans scanList := strings.Join(scans, ", ") - fmt.Printf(" %s %s\n", Muted.Render("Scans:"), scanList) + fmt.Fprintf(sink, " %s %s\n", Muted.Render("Scans:"), scanList) // Print log files if any if len(logFiles) > 0 { - fmt.Printf(" %s %s\n", Muted.Render("Output:"), strings.Join(logFiles, ", ")) + fmt.Fprintf(sink, " %s %s\n", Muted.Render("Output:"), strings.Join(logFiles, ", ")) } - fmt.Println() - fmt.Println(summaryLine.Render("────────────────────────────────────────────────────────────")) - fmt.Println() + fmt.Fprintln(sink) + fmt.Fprintln(sink, summaryLine.Render("────────────────────────────────────────────────────────────")) + fmt.Fprintln(sink) } diff --git a/internal/output/progress.go b/internal/output/progress.go index 18bab09..ca34fd6 100644 --- a/internal/output/progress.go +++ b/internal/output/progress.go @@ -98,7 +98,7 @@ func (p *Progress) Done() { } func (p *Progress) render() { - if apiMode { + if apiMode || silent { return } @@ -135,7 +135,7 @@ func (p *Progress) render() { p.mu.Unlock() if advanced { - fmt.Printf(" [%d%%] %d/%d\n", percent, current, total) + fmt.Fprintf(sink, " [%d%%] %d/%d\n", percent, current, total) } return } @@ -190,5 +190,5 @@ func (p *Progress) render() { ) ClearLine() - fmt.Print(line) + fmt.Fprint(sink, line) } diff --git a/internal/output/silent_test.go b/internal/output/silent_test.go new file mode 100644 index 0000000..04b5b2e --- /dev/null +++ b/internal/output/silent_test.go @@ -0,0 +1,113 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package output + +import ( + "os" + "strings" + "testing" +) + +// in silent mode chrome must land on stderr and leave stdout untouched, so a +// piped consumer downstream never sees a banner or log line. +func TestSetSilentRoutesChromeToStderr(t *testing.T) { + defer SetSilent(false) + + outStr, errStr := captureStdoutStderr(t, func() { + // SetSilent reads os.Stderr at call time, so swap then set. + SetSilent(true) + Info("scanning %s", "example.com") + Success("done") + }) + + if outStr != "" { + t.Errorf("silent mode wrote chrome to stdout: %q", outStr) + } + if !strings.Contains(errStr, "scanning example.com") { + t.Errorf("silent chrome missing from stderr: %q", errStr) + } +} + +// the default (non-silent) sink is stdout; flipping silent off must restore it. +func TestSetSilentOffRoutesChromeToStdout(t *testing.T) { + outStr, errStr := captureStdoutStderr(t, func() { + SetSilent(false) + Info("hello") + }) + + if !strings.Contains(outStr, "hello") { + t.Errorf("non-silent chrome missing from stdout: %q", outStr) + } + if strings.Contains(errStr, "hello") { + t.Errorf("non-silent chrome leaked to stderr: %q", errStr) + } +} + +// Silent() reflects the toggle so callers can gate interactive widgets. +func TestSilentToggle(t *testing.T) { + defer SetSilent(false) + SetSilent(true) + if !Silent() { + t.Error("Silent() = false after SetSilent(true)") + } + SetSilent(false) + if Silent() { + t.Error("Silent() = true after SetSilent(false)") + } +} + +// captureStdoutStderr swaps both real streams for pipes, runs fn, and returns +// what landed on each. SetSilent reads os.Stdout/os.Stderr at call time, so the +// swap has to happen before fn flips the sink - fn does that itself. +func captureStdoutStderr(t *testing.T, fn func()) (string, string) { + t.Helper() + + outR, outW, err := os.Pipe() + if err != nil { + t.Fatalf("pipe stdout: %v", err) + } + errR, errW, err := os.Pipe() + if err != nil { + t.Fatalf("pipe stderr: %v", err) + } + + savedOut, savedErr := os.Stdout, os.Stderr + os.Stdout, os.Stderr = outW, errW + + outCh := drain(outR) + errCh := drain(errR) + + fn() + + os.Stdout, os.Stderr = savedOut, savedErr + outW.Close() + errW.Close() + return <-outCh, <-errCh +} + +func drain(r *os.File) <-chan string { + ch := make(chan string, 1) + go func() { + buf := make([]byte, 0, 4096) + tmp := make([]byte, 1024) + for { + n, rerr := r.Read(tmp) + buf = append(buf, tmp[:n]...) + if rerr != nil { + break + } + } + ch <- string(buf) + }() + return ch +} diff --git a/internal/output/spinner.go b/internal/output/spinner.go index 4aff1bc..019ef1a 100644 --- a/internal/output/spinner.go +++ b/internal/output/spinner.go @@ -14,7 +14,6 @@ package output import ( "fmt" - "os" "sync" "time" ) @@ -42,7 +41,7 @@ func NewSpinner(message string) *Spinner { // Start begins the spinner animation func (s *Spinner) Start() { - if apiMode { + if apiMode || silent { return } @@ -57,7 +56,7 @@ func (s *Spinner) Start() { // In non-TTY mode, just print the message once if !IsTTY { - fmt.Printf(" %s...\n", s.message) + fmt.Fprintf(sink, " %s...\n", s.message) return } @@ -66,7 +65,7 @@ func (s *Spinner) Start() { // Stop halts the spinner and clears the line func (s *Spinner) Stop() { - if apiMode { + if apiMode || silent { return } @@ -112,8 +111,8 @@ func (s *Spinner) animate() { spinnerChar := prefixInfo.Render(spinnerFrames[frame]) line := fmt.Sprintf("\r %s %s", spinnerChar, msg) - fmt.Fprint(os.Stdout, "\033[2K") // Clear line - fmt.Fprint(os.Stdout, line) + fmt.Fprint(sink, "\033[2K") // Clear line + fmt.Fprint(sink, line) frame = (frame + 1) % len(spinnerFrames) } diff --git a/internal/scan/cloudstorage.go b/internal/scan/cloudstorage.go index d1d0f90..d8b4811 100644 --- a/internal/scan/cloudstorage.go +++ b/internal/scan/cloudstorage.go @@ -104,11 +104,12 @@ func checkS3Bucket(ctx context.Context, bucket string, client *http.Client) (boo if err != nil { return false, err } - resp, err := client.Do(req) + resp, err := client.Do(req) //nolint:bodyclose // drained and closed via httpx.DrainClose if err != nil { return false, err } - defer resp.Body.Close() + // status only; drain on close so the conn returns to the pool. + defer httpx.DrainClose(resp) // If we can access the bucket listing, it's public return resp.StatusCode == http.StatusOK, nil diff --git a/internal/scan/cms.go b/internal/scan/cms.go index d07cc95..dd3247a 100644 --- a/internal/scan/cms.go +++ b/internal/scan/cms.go @@ -128,10 +128,11 @@ func detectWordPress(url string, client *http.Client, bodyString string) bool { if err != nil { continue } - resp, err := client.Do(req) + resp, err := client.Do(req) //nolint:bodyclose // drained and closed via httpx.DrainClose if err == nil { found := resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusFound - resp.Body.Close() + // status only; drain so the conn returns to the pool. + httpx.DrainClose(resp) if found { return true } diff --git a/internal/scan/cors.go b/internal/scan/cors.go index 3828628..c974376 100644 --- a/internal/scan/cors.go +++ b/internal/scan/cors.go @@ -175,13 +175,13 @@ func probeCORS(client *http.Client, targetURL, origin, note string) (CORSFinding } req.Header.Set("Origin", origin) - resp, err := client.Do(req) + resp, err := client.Do(req) //nolint:bodyclose // drained and closed via httpx.DrainClose if err != nil { charmlog.Debugf("cors: request %s with origin %s: %v", targetURL, origin, err) return CORSFinding{}, false } - // headers are all we need; drain nothing, just close. - resp.Body.Close() + // headers are all we need; drain the body so the conn returns to the pool. + httpx.DrainClose(resp) allowOrigin := resp.Header.Get("Access-Control-Allow-Origin") if allowOrigin == "" { diff --git a/internal/scan/dnslist.go b/internal/scan/dnslist.go index ba1e1ab..9f2ef72 100644 --- a/internal/scan/dnslist.go +++ b/internal/scan/dnslist.go @@ -229,14 +229,15 @@ func probeSubdomain(client *http.Client, host string) (string, dnsScheme) { charmlog.Debugf("Error %s: %s", host, err) continue } - resp, err := client.Do(req) + resp, err := client.Do(req) //nolint:bodyclose // drained and closed via httpx.DrainClose if err != nil { charmlog.Debugf("Error %s: %s", host, err) continue } code := resp.StatusCode resolved := resp.Request.URL.String() - resp.Body.Close() + // status/url only; drain so the conn returns to the pool. + httpx.DrainClose(resp) if meaningfulStatus(code) { return resolved, schemes[i].label diff --git a/internal/scan/favicon.go b/internal/scan/favicon.go new file mode 100644 index 0000000..6455739 --- /dev/null +++ b/internal/scan/favicon.go @@ -0,0 +1,254 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package scan + +import ( + "context" + "encoding/base64" + "fmt" + "io" + "net/http" + "regexp" + "strings" + "time" + + "github.com/dropalldatabases/sif/internal/httpx" + "github.com/dropalldatabases/sif/internal/logger" + "github.com/dropalldatabases/sif/internal/output" + "github.com/twmb/murmur3" +) + +// FaviconResult is the computed shodan-style favicon hash plus the pivot query +// and any matched tech. +type FaviconResult struct { + FaviconURL string `json:"favicon_url"` // where the icon was fetched + Hash int32 `json:"hash"` // shodan mmh3 hash (signed int32) + Tech string `json:"tech"` // matched technology, empty when unknown + ShodanQ string `json:"shodan_query"` +} + +// faviconBodyReadCap bounds the icon read. real favicons are tens of kilobytes; +// a megabyte ceiling covers oversized ones without letting a hostile endpoint +// stream forever. +const faviconBodyReadCap = 1 << 20 + +// b64LineLen is python's base64.encodebytes line width. mmh3/shodan hash the +// chunked base64 (newline every 76 chars, trailing newline), so we must wrap at +// exactly this width to land on the same hash. +const b64LineLen = 76 + +// faviconLinkRegex pulls the href off a tag so we can +// fall back to a declared icon when /favicon.ico is absent. +var faviconLinkRegex = regexp.MustCompile(`(?i)]+rel=["'][^"']*icon[^"']*["'][^>]*>`) + +// faviconHrefRegex extracts the href attribute value from a matched link tag. +var faviconHrefRegex = regexp.MustCompile(`(?i)href=["']([^"']+)["']`) + +// faviconHashes maps a known shodan favicon hash to the tech that ships it. +// these are stable default icons for panels/frameworks/c2; a hit is a strong +// fingerprint. kept small on purpose - high-signal defaults, not an exhaustive db. +var faviconHashes = map[int32]string{ + 116323821: "Apache Tomcat", + 81586312: "Spring Boot (default whitelabel)", + -235701012: "Jenkins", + -1255347784: "GitLab", + 1278322581: "Grafana", + 743365239: "Kibana", + -1462443472: "phpMyAdmin", + 999357577: "Cobalt Strike (default beacon)", + -1521704893: "Metasploit", + -1893514588: "Gitea", +} + +// Favicon fetches the target's favicon, computes the shodan mmh3 hash and matches +// it against the bundled fingerprint map. +func Favicon(targetURL string, timeout time.Duration, logdir string) (*FaviconResult, error) { + log := output.Module("FAVICON") + log.Start() + + sanitizedURL := stripScheme(targetURL) + + if logdir != "" { + if err := logger.WriteHeader(sanitizedURL, logdir, "Favicon hash fingerprint"); err != nil { + log.Error("error creating log file: %v", err) + return nil, fmt.Errorf("create favicon log: %w", err) + } + } + + client := httpx.Client(timeout) + base := strings.TrimRight(targetURL, "/") + + iconURL, data, err := fetchFavicon(client, base) + if err != nil { + log.Info("no favicon found: %v", err) + log.Complete(0, "found") + return nil, nil //nolint:nilnil // a missing favicon is not an error + } + + hash := FaviconHash(data) + result := &FaviconResult{ + FaviconURL: iconURL, + Hash: hash, + Tech: faviconHashes[hash], + ShodanQ: fmt.Sprintf("http.favicon.hash:%d", hash), + } + + if result.Tech != "" { + log.Warn("favicon hash %d matches %s", hash, output.Highlight.Render(result.Tech)) + } else { + log.Info("favicon hash %d (no fingerprint match)", hash) + } + log.Info("shodan pivot: %s", output.Highlight.Render(result.ShodanQ)) + + if logdir != "" { + _ = logger.Write(sanitizedURL, logdir, + fmt.Sprintf("Favicon %s hash=%d tech=%q query=%s\n", iconURL, hash, result.Tech, result.ShodanQ)) + } + + log.Complete(1, "hashed") + return result, nil +} + +// fetchFavicon tries /favicon.ico first, then the declared in the +// homepage html. it returns the url it pulled the bytes from so the report shows +// exactly which icon was hashed. +func fetchFavicon(client *http.Client, base string) (string, []byte, error) { + iconURL := base + "/favicon.ico" + if data, err := getFaviconBytes(client, iconURL); err == nil { + return iconURL, data, nil + } + + // no /favicon.ico; parse the homepage for a declared icon link. + href, err := declaredFaviconHref(client, base) + if err != nil { + return "", nil, err + } + iconURL = resolveFaviconURL(base, href) + data, err := getFaviconBytes(client, iconURL) + if err != nil { + return "", nil, err + } + return iconURL, data, nil +} + +// getFaviconBytes GETs an icon url and returns the body, erroring on a non-200 or +// an empty body so a soft-404 html page isn't hashed as if it were an icon. +func getFaviconBytes(client *http.Client, iconURL string) ([]byte, error) { + req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, iconURL, http.NoBody) + if err != nil { + return nil, fmt.Errorf("build favicon request: %w", err) + } + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch favicon: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("favicon status %d", resp.StatusCode) + } + data, err := io.ReadAll(io.LimitReader(resp.Body, faviconBodyReadCap)) + if err != nil { + return nil, fmt.Errorf("read favicon: %w", err) + } + if len(data) == 0 { + return nil, fmt.Errorf("empty favicon body") + } + return data, nil +} + +// declaredFaviconHref fetches the homepage and extracts the href of the first +// tag. +func declaredFaviconHref(client *http.Client, base string) (string, error) { + req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, base, http.NoBody) + if err != nil { + return "", fmt.Errorf("build homepage request: %w", err) + } + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("fetch homepage: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, faviconBodyReadCap)) + if err != nil { + return "", fmt.Errorf("read homepage: %w", err) + } + + link := faviconLinkRegex.Find(body) + if link == nil { + return "", fmt.Errorf("no favicon link in homepage") + } + href := faviconHrefRegex.FindSubmatch(link) + if href == nil { + return "", fmt.Errorf("favicon link has no href") + } + return string(href[1]), nil +} + +// resolveFaviconURL turns a possibly-relative href into an absolute url against +// the target base. an absolute href is returned as-is. +func resolveFaviconURL(base, href string) string { + if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") { + return href + } + if strings.HasPrefix(href, "//") { + // scheme-relative; inherit the base scheme. + scheme := "https:" + if strings.HasPrefix(base, "http://") { + scheme = "http:" + } + return scheme + href + } + if strings.HasPrefix(href, "/") { + return base + href + } + return base + "/" + href +} + +// FaviconHash computes shodan's favicon hash: murmur3 32-bit over the python +// base64.encodebytes encoding of the raw icon (newline every 76 chars plus a +// trailing newline), reinterpreted as a signed int32. the chunking and the sign +// are both load-bearing - shodan stores the value python's mmh3.hash() returns, +// which is signed, over the wrapped base64, not the raw bytes. the golden test +// pins this exactly. +func FaviconHash(data []byte) int32 { + encoded := encodeFaviconBase64(data) + return int32(murmur3.Sum32(encoded)) //nolint:gosec // shodan stores the signed reinterpretation on purpose +} + +// encodeFaviconBase64 mirrors python's base64.encodebytes: standard base64 with +// a newline inserted every 76 output characters and a trailing newline. this is +// the exact byte stream shodan feeds to mmh3, so it must match byte-for-byte. +func encodeFaviconBase64(data []byte) []byte { + raw := base64.StdEncoding.EncodeToString(data) + + var b strings.Builder + // final size: the base64 body plus one '\n' per (full or partial) 76-char + // line. preallocate so the builder never regrows mid-loop. + b.Grow(len(raw) + len(raw)/b64LineLen + 1) + for i := 0; i < len(raw); i += b64LineLen { + end := i + b64LineLen + if end > len(raw) { + end = len(raw) + } + b.WriteString(raw[i:end]) + b.WriteByte('\n') + } + return []byte(b.String()) +} + +// ResultType identifies favicon findings for the result registry. +func (r *FaviconResult) ResultType() string { return "favicon" } + +var _ ScanResult = (*FaviconResult)(nil) diff --git a/internal/scan/favicon_test.go b/internal/scan/favicon_test.go new file mode 100644 index 0000000..d5d3bd3 --- /dev/null +++ b/internal/scan/favicon_test.go @@ -0,0 +1,160 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package scan + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// goldenFaviconBytes is a fixed payload long enough to span multiple base64 +// lines, so the python-style 76-char chunking is actually exercised by the hash. +var goldenFaviconBytes = []byte(strings.Repeat("sif-favicon-golden-test-bytes-", 8)) + +// goldenFaviconHash is the shodan mmh3 hash of goldenFaviconBytes. it is pinned: +// the value comes from feeding the python base64.encodebytes byte stream (newline +// every 76 chars + trailing newline) through murmur3-32 and reinterpreting the +// result as a signed int32 - exactly what shodan stores. if the chunking or the +// signedness regress, this number changes and the test fails. +const goldenFaviconHash int32 = -1554620260 + +// goldenHelloHash pins a short single-line case so a regression in the trailing +// newline (which the small case still has) is caught independently. +const goldenHelloHash int32 = 1155597304 + +func TestFaviconHash_Golden(t *testing.T) { + tests := []struct { + name string + in []byte + want int32 + }{ + {name: "multi-line fixture", in: goldenFaviconBytes, want: goldenFaviconHash}, + {name: "single-line hello", in: []byte("hello"), want: goldenHelloHash}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := FaviconHash(tt.in) + if got != tt.want { + t.Errorf("FaviconHash = %d, want %d", got, tt.want) + } + }) + } +} + +// TestFaviconBase64Chunking pins the encode step against python's +// base64.encodebytes: a 50-byte input encodes to >76 base64 chars, so it must +// wrap into two newline-terminated lines. +func TestFaviconBase64Chunking(t *testing.T) { + in := []byte(strings.Repeat("A", 60)) // 60 bytes -> 80 base64 chars -> two lines + got := string(encodeFaviconBase64(in)) + + lines := strings.Split(strings.TrimRight(got, "\n"), "\n") + if len(lines) != 2 { + t.Fatalf("expected 2 wrapped lines, got %d: %q", len(lines), got) + } + if len(lines[0]) != b64LineLen { + t.Errorf("first line = %d chars, want %d", len(lines[0]), b64LineLen) + } + if !strings.HasSuffix(got, "\n") { + t.Errorf("encoding must end in a trailing newline, got %q", got) + } +} + +// fixtureFaviconServer serves the golden bytes at /favicon.ico. +func fixtureFaviconServer() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/favicon.ico" { + w.Header().Set("Content-Type", "image/x-icon") + _, _ = w.Write(goldenFaviconBytes) + return + } + w.WriteHeader(http.StatusNotFound) + })) +} + +func TestFavicon_FetchAndHash(t *testing.T) { + srv := fixtureFaviconServer() + defer srv.Close() + + result, err := Favicon(srv.URL, 5*time.Second, "") + if err != nil { + t.Fatalf("Favicon: %v", err) + } + if result == nil { + t.Fatal("expected a favicon result, got nil") + } + if result.Hash != goldenFaviconHash { + t.Errorf("Hash = %d, want %d", result.Hash, goldenFaviconHash) + } + wantQ := "http.favicon.hash:-1554620260" + if result.ShodanQ != wantQ { + t.Errorf("ShodanQ = %q, want %q", result.ShodanQ, wantQ) + } +} + +// TestFavicon_LinkFallback covers the path when /favicon.ico is +// absent: the homepage points at /static/icon.png and that's what gets hashed. +func TestFavicon_LinkFallback(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/favicon.ico": + w.WriteHeader(http.StatusNotFound) + case "/static/icon.png": + _, _ = w.Write(goldenFaviconBytes) + default: + _, _ = w.Write([]byte(``)) + } + })) + defer srv.Close() + + result, err := Favicon(srv.URL, 5*time.Second, "") + if err != nil { + t.Fatalf("Favicon: %v", err) + } + if result == nil { + t.Fatal("expected a favicon result via link fallback, got nil") + } + if !strings.HasSuffix(result.FaviconURL, "/static/icon.png") { + t.Errorf("FaviconURL = %q, want it to end in /static/icon.png", result.FaviconURL) + } + if result.Hash != goldenFaviconHash { + t.Errorf("Hash = %d, want %d", result.Hash, goldenFaviconHash) + } +} + +// TestFavicon_NoIcon confirms a target with no favicon at all yields no result +// and no error. +func TestFavicon_NoIcon(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + result, err := Favicon(srv.URL, 5*time.Second, "") + if err != nil { + t.Fatalf("Favicon: %v", err) + } + if result != nil { + t.Errorf("expected nil result for missing favicon, got %+v", result) + } +} + +func TestFaviconResult_ResultType(t *testing.T) { + r := &FaviconResult{} + if r.ResultType() != "favicon" { + t.Errorf("expected result type 'favicon', got %q", r.ResultType()) + } +} diff --git a/internal/scan/git.go b/internal/scan/git.go index b026ac5..9e8b425 100644 --- a/internal/scan/git.go +++ b/internal/scan/git.go @@ -91,7 +91,7 @@ func Git(url string, timeout time.Duration, threads int, logdir string) ([]strin charmlog.Debugf("Error creating request for %s: %s", repourl, err) continue } - resp, err := client.Do(gitReq) + resp, err := client.Do(gitReq) //nolint:bodyclose // drained and closed via httpx.DrainClose if err != nil { charmlog.Debugf("Error %s: %s", repourl, err) continue @@ -109,7 +109,8 @@ func Git(url string, timeout time.Duration, threads int, logdir string) ([]strin foundUrls = append(foundUrls, resp.Request.URL.String()) mu.Unlock() } - resp.Body.Close() + // status/headers only; drain so the conn returns to the pool. + httpx.DrainClose(resp) } }(thread) } diff --git a/internal/scan/headers.go b/internal/scan/headers.go index 35ce693..a481c85 100644 --- a/internal/scan/headers.go +++ b/internal/scan/headers.go @@ -46,11 +46,12 @@ func Headers(url string, timeout time.Duration, logdir string) ([]HeaderResult, if err != nil { return nil, err } - resp, err := client.Do(req) + resp, err := client.Do(req) //nolint:bodyclose // drained and closed via httpx.DrainClose if err != nil { return nil, err } - defer resp.Body.Close() + // header-only scan: drain on close so the conn is returned to the pool. + defer httpx.DrainClose(resp) var results []HeaderResult diff --git a/internal/scan/js/supabase.go b/internal/scan/js/supabase.go index 6e86180..4923865 100644 --- a/internal/scan/js/supabase.go +++ b/internal/scan/js/supabase.go @@ -129,7 +129,9 @@ func doSupabaseRequest(projectId, path, apikey string, auth *string, timeout tim if err != nil { return nil, nil, err } - defer resp.Body.Close() + // the non-200 branch returns before reading the body, so drain on close to + // keep the conn reusable instead of leaking it. + defer httpx.DrainClose(resp) if resp.StatusCode != 200 { return nil, nil, errors.New("request to " + resp.Request.URL.String() + " failed with status code " + strconv.Itoa(resp.StatusCode)) @@ -215,7 +217,8 @@ func ScanSupabase(jsContent string, jsUrl string, timeout time.Duration) ([]supa auth = authResp.AccessToken supabaselog.Infof("Created account with JWT %s", auth) } else { - resp.Body.Close() + // non-200 signup: body never read, so drain to reuse the conn. + httpx.DrainClose(resp) } var collections = []supabaseCollection{} diff --git a/internal/scan/jwt.go b/internal/scan/jwt.go new file mode 100644 index 0000000..371052f --- /dev/null +++ b/internal/scan/jwt.go @@ -0,0 +1,396 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package scan + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "regexp" + "strings" + "time" + + "github.com/dropalldatabases/sif/internal/httpx" + "github.com/dropalldatabases/sif/internal/logger" + "github.com/dropalldatabases/sif/internal/output" +) + +// JWTResult collects every token discovered on the target plus the offline +// analysis of each one. +type JWTResult struct { + Tokens []JWTToken `json:"tokens,omitempty"` +} + +// JWTToken is one decoded jwt and the weaknesses found in it. Token is trimmed +// to a short prefix so we never log a full credential. +type JWTToken struct { + Source string `json:"source"` // where we found it (header name / cookie / body) + Preview string `json:"preview"` // first chars of the raw token, never the whole thing + Alg string `json:"alg"` // header alg claim + Issues []JWTIssue `json:"issues"` // the weaknesses, ranked + Claims map[string]any `json:"claims"` // decoded payload (for reporting) + WeakKey string `json:"weak_key"` // cracked hmac secret, empty when none +} + +// JWTIssue is a single weakness with a severity so the report layer can rank it. +type JWTIssue struct { + Kind string `json:"kind"` + Severity string `json:"severity"` + Detail string `json:"detail"` +} + +// jwtBodyReadCap bounds how much of the response body we slurp looking for +// tokens; a jwt riding in the body is near the top, so a megabyte is plenty +// without letting a huge response exhaust memory. +const jwtBodyReadCap = 1 << 20 + +// jwtPreviewLen is how many leading characters of a token we keep for evidence. +// enough to identify the token in a report, short enough to never be the whole +// credential. +const jwtPreviewLen = 16 + +// the three structural jwt severities. +const ( + jwtSevCritical = "critical" + jwtSevHigh = "high" + jwtSevMedium = "medium" + jwtSevLow = "low" +) + +// jwtRegex matches a compact-serialization jwt: three base64url segments split +// by dots. the header always starts "eyJ" (base64url of `{"`), which anchors the +// match and keeps it from firing on arbitrary dotted tokens. +var jwtRegex = regexp.MustCompile(`eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*`) + +// jwtWeakSecrets is a tiny offline wordlist of hmac secrets seen in tutorials, +// boilerplate and leaked configs. cracking one means anyone can forge tokens, so +// a hit is critical. kept short on purpose - this is a smoke test, not john. +var jwtWeakSecrets = []string{ + "secret", "secretkey", "secret_key", "your-256-bit-secret", + "changeme", "password", "jwt", "jwtsecret", "key", "test", + "admin", "supersecret", "s3cr3t", "qwerty", "123456", +} + +// sensitiveClaimKeys are payload fields that should never travel in a readable +// jwt body (the payload is only base64, not encrypted). a match is a disclosure. +var sensitiveClaimKeys = []string{ + "password", "passwd", "secret", "api_key", "apikey", "ssn", + "credit_card", "card_number", "private_key", "access_key", +} + +// JWT fetches the target once, harvests every jwt from the response headers, +// cookies and body, then analyzes each one entirely offline. +func JWT(targetURL string, timeout time.Duration, logdir string) (*JWTResult, error) { + log := output.Module("JWT") + log.Start() + + sanitizedURL := stripScheme(targetURL) + + if logdir != "" { + if err := logger.WriteHeader(sanitizedURL, logdir, "JWT discovery + offline analysis"); err != nil { + log.Error("error creating log file: %v", err) + return nil, fmt.Errorf("create jwt log: %w", err) + } + } + + client := httpx.Client(timeout) + req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, targetURL, http.NoBody) + if err != nil { + return nil, fmt.Errorf("build jwt request: %w", err) + } + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch jwt target: %w", err) + } + defer resp.Body.Close() + + // one read, capped; everything past this point is offline. + body, err := io.ReadAll(io.LimitReader(resp.Body, jwtBodyReadCap)) + if err != nil { + return nil, fmt.Errorf("read jwt body: %w", err) + } + + raws := harvestJWTs(resp, string(body)) + if len(raws) == 0 { + log.Info("no jwts found on target") + log.Complete(0, "found") + return nil, nil //nolint:nilnil // absence of a token is not an error + } + + result := &JWTResult{Tokens: make([]JWTToken, 0, len(raws))} + for _, hit := range raws { + token, ok := analyzeJWT(hit.source, hit.raw) + if !ok { + continue + } + result.Tokens = append(result.Tokens, token) + + for i := 0; i < len(token.Issues); i++ { + iss := token.Issues[i] + log.Warn("jwt %s: %s (%s)", renderJWTSeverity(iss.Severity), iss.Kind, hit.source) + if logdir != "" { + _ = logger.Write(sanitizedURL, logdir, + fmt.Sprintf("JWT %s: %s - %s [%s]\n", iss.Severity, iss.Kind, iss.Detail, hit.source)) + } + } + } + + if len(result.Tokens) == 0 { + log.Complete(0, "found") + return nil, nil //nolint:nilnil // tokens were malformed, nothing to report + } + + log.Complete(len(result.Tokens), "analyzed") + return result, nil +} + +// jwtHit ties a raw token to where it came from so the report can attribute it. +type jwtHit struct { + source string + raw string +} + +// harvestJWTs pulls every jwt out of the response: Authorization-style headers, +// Set-Cookie values and the body. dedup keys on the raw token so the same value +// echoed in two places is reported once. +func harvestJWTs(resp *http.Response, body string) []jwtHit { + seen := make(map[string]struct{}) + var hits []jwtHit + + add := func(source, raw string) { + if _, ok := seen[raw]; ok { + return + } + seen[raw] = struct{}{} + hits = append(hits, jwtHit{source: source, raw: raw}) + } + + for name, values := range resp.Header { + for i := 0; i < len(values); i++ { + for _, m := range jwtRegex.FindAllString(values[i], -1) { + add("header:"+name, m) + } + } + } + for _, c := range resp.Cookies() { + for _, m := range jwtRegex.FindAllString(c.Value, -1) { + add("cookie:"+c.Name, m) + } + } + for _, m := range jwtRegex.FindAllString(body, -1) { + add("body", m) + } + + return hits +} + +// analyzeJWT decodes the header and payload (offline base64url, never verifying a +// signature against the network) and runs every weakness check. ok is false when +// the token doesn't decode into a real header+payload, so junk that matched the +// regex is dropped rather than reported. +func analyzeJWT(source, raw string) (JWTToken, bool) { + parts := strings.Split(raw, ".") + if len(parts) != 3 { + return JWTToken{}, false + } + + header, err := decodeJWTSegment(parts[0]) + if err != nil { + return JWTToken{}, false + } + payload, err := decodeJWTSegment(parts[1]) + if err != nil { + return JWTToken{}, false + } + + alg, _ := header["alg"].(string) + + token := JWTToken{ + Source: source, + Preview: previewToken(raw), + Alg: alg, + Claims: payload, + } + + token.Issues = append(token.Issues, jwtAlgIssues(alg)...) + token.Issues = append(token.Issues, jwtClaimIssues(payload)...) + + // only bother cracking when the alg is actually hmac; an asymmetric token + // has no shared secret to guess. + if isHMACAlg(alg) { + if secret, ok := crackHMAC(raw); ok { + token.WeakKey = secret + token.Issues = append(token.Issues, JWTIssue{ + Kind: "weak hmac secret", + Severity: jwtSevCritical, + Detail: "signature verifies against bundled weak secret " + secret, + }) + } + } + + return token, true +} + +// jwtAlgIssues flags the algorithm-level weaknesses: alg:none (no signature at +// all) and the RS256->HS256 confusion surface (an asymmetric-looking token whose +// header says HS*, meaning a server that loads the public key as an hmac secret +// can be forged). +func jwtAlgIssues(alg string) []JWTIssue { + var issues []JWTIssue + lower := strings.ToLower(alg) + + if lower == "none" || alg == "" { + issues = append(issues, JWTIssue{ + Kind: "alg:none", + Severity: jwtSevCritical, + Detail: "token declares no signature algorithm; forgeable", + }) + return issues + } + + if isHMACAlg(alg) { + issues = append(issues, JWTIssue{ + Kind: "rs256->hs256 confusion surface", + Severity: jwtSevMedium, + Detail: "token is HMAC-signed; if the server also accepts asymmetric algs " + + "with the same verifier, a public key can be used as the HMAC secret", + }) + } + return issues +} + +// jwtClaimIssues inspects the decoded payload for missing/expired expiry and any +// plaintext sensitive claims (the payload is base64, not encrypted). +func jwtClaimIssues(payload map[string]any) []JWTIssue { + var issues []JWTIssue + + exp, hasExp := numericClaim(payload, "exp") + switch { + case !hasExp: + issues = append(issues, JWTIssue{ + Kind: "missing exp", + Severity: jwtSevMedium, + Detail: "no expiry claim; token never ages out", + }) + case time.Now().After(time.Unix(int64(exp), 0)): + issues = append(issues, JWTIssue{ + Kind: "expired token", + Severity: jwtSevLow, + Detail: "exp is in the past; a server still honoring it is a bug", + }) + } + + for i := 0; i < len(sensitiveClaimKeys); i++ { + key := sensitiveClaimKeys[i] + if _, ok := payload[key]; ok { + issues = append(issues, JWTIssue{ + Kind: "sensitive plaintext claim", + Severity: jwtSevHigh, + Detail: "payload carries readable claim " + key + "; jwt bodies are not encrypted", + }) + } + } + + return issues +} + +// crackHMAC tries every bundled weak secret against the token's HS256 signature +// offline. a verifying secret means the token is forgeable by anyone who knows +// it. only HS256 is attempted; the wordlist exists to catch lazy defaults, not +// to be a real cracker. +func crackHMAC(raw string) (string, bool) { + parts := strings.Split(raw, ".") + if len(parts) != 3 { + return "", false + } + signingInput := parts[0] + "." + parts[1] + want, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return "", false + } + + for i := 0; i < len(jwtWeakSecrets); i++ { + secret := jwtWeakSecrets[i] + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(signingInput)) + if hmac.Equal(mac.Sum(nil), want) { + return secret, true + } + } + return "", false +} + +// decodeJWTSegment base64url-decodes one jwt segment into a claims map. jwt uses +// unpadded base64url, but some emitters pad anyway, so try raw first then padded. +func decodeJWTSegment(seg string) (map[string]any, error) { + data, err := base64.RawURLEncoding.DecodeString(seg) + if err != nil { + data, err = base64.URLEncoding.DecodeString(seg) + if err != nil { + return nil, fmt.Errorf("base64url decode segment: %w", err) + } + } + var claims map[string]any + if err := json.Unmarshal(data, &claims); err != nil { + return nil, fmt.Errorf("unmarshal jwt segment: %w", err) + } + return claims, nil +} + +// numericClaim pulls a numeric claim out of the payload. json numbers decode to +// float64, so that's the only shape we accept. +func numericClaim(payload map[string]any, key string) (float64, bool) { + v, ok := payload[key] + if !ok { + return 0, false + } + f, ok := v.(float64) + return f, ok +} + +// isHMACAlg reports whether alg is one of the HMAC family (HS256/HS384/HS512). +func isHMACAlg(alg string) bool { + return strings.HasPrefix(strings.ToUpper(alg), "HS") +} + +// previewToken trims a raw token to a short prefix so evidence never carries the +// whole credential. +func previewToken(raw string) string { + if len(raw) <= jwtPreviewLen { + return raw + } + return raw[:jwtPreviewLen] + "..." +} + +func renderJWTSeverity(severity string) string { + switch severity { + case jwtSevCritical: + return output.SeverityCritical.Render(severity) + case jwtSevHigh: + return output.SeverityHigh.Render(severity) + case jwtSevMedium: + return output.SeverityMedium.Render(severity) + default: + return output.SeverityLow.Render(severity) + } +} + +// ResultType identifies jwt findings for the result registry. +func (r *JWTResult) ResultType() string { return "jwt" } + +var _ ScanResult = (*JWTResult)(nil) diff --git a/internal/scan/jwt_test.go b/internal/scan/jwt_test.go new file mode 100644 index 0000000..ba143d1 --- /dev/null +++ b/internal/scan/jwt_test.go @@ -0,0 +1,172 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package scan + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// fixed jwt fixtures, generated offline. each exercises a distinct weakness. +const ( + // header {alg:none}, payload {sub:admin}, empty signature - forgeable. + jwtNone = "eyJhbGciOiAibm9uZSIsICJ0eXAiOiAiSldUIn0." + + "eyJzdWIiOiAiYWRtaW4iLCAicm9sZSI6ICJ1c2VyIn0." + + // HS256, no exp claim, signed with the bundled weak secret "secret". + jwtWeakHS256 = "eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9." + + "eyJzdWIiOiAiMTIzNDU2Nzg5MCIsICJuYW1lIjogInRlc3RlciJ9." + + "JOjVfLa8gp3cvFkNVgOnmdrI1MCHZRA_ChBmCPF-Z8w" + + // HS256, exp in 2001 (long past), signed with a secret not in the wordlist. + jwtExpired = "eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9." + + "eyJzdWIiOiAieCIsICJleHAiOiAxMDAwMDAwMDAwfQ." + + "gr28Ffm4wJkonHGSKmMD5Rj7e1pTt2o_EwG6lMWQeSc" + + // HS256 carrying a plaintext password claim (jwt bodies are not encrypted). + jwtSensitive = "eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9." + + "eyJzdWIiOiAieCIsICJwYXNzd29yZCI6ICJodW50ZXIyIiwgImV4cCI6IDk5OTk5OTk5OTl9." + + "rjEf0CUa7_qppuINi6zL9vupJIX0rzSBhul7kKM9uSA" +) + +// hasIssue reports whether the analyzed token carries an issue of the given kind. +func hasIssue(token *JWTToken, kind string) bool { + for i := 0; i < len(token.Issues); i++ { + if token.Issues[i].Kind == kind { + return true + } + } + return false +} + +func TestJWT_AlgNoneAndMissingExpFlagged(t *testing.T) { + // serve the alg:none token in the Authorization header echo. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Authorization", "Bearer "+jwtNone) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + result, err := JWT(srv.URL, 5*time.Second, "") + if err != nil { + t.Fatalf("JWT: %v", err) + } + if result == nil || len(result.Tokens) != 1 { + t.Fatalf("expected exactly one analyzed token, got %+v", result) + } + + token := &result.Tokens[0] + if !hasIssue(token, "alg:none") { + t.Errorf("expected alg:none to be flagged, got issues %+v", token.Issues) + } + if !hasIssue(token, "missing exp") { + t.Errorf("expected missing exp to be flagged, got issues %+v", token.Issues) + } + // the preview must never carry the whole token. + if len(token.Preview) >= len(jwtNone) { + t.Errorf("preview should be trimmed, got full token %q", token.Preview) + } +} + +func TestJWT_WeakSecretCracked(t *testing.T) { + // token rides in a Set-Cookie this time. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.SetCookie(w, &http.Cookie{Name: "session", Value: jwtWeakHS256}) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + result, err := JWT(srv.URL, 5*time.Second, "") + if err != nil { + t.Fatalf("JWT: %v", err) + } + if result == nil || len(result.Tokens) != 1 { + t.Fatalf("expected one token, got %+v", result) + } + + token := &result.Tokens[0] + if token.WeakKey != "secret" { + t.Errorf("expected weak secret 'secret' to be cracked, got %q", token.WeakKey) + } + if !hasIssue(token, "weak hmac secret") { + t.Errorf("expected weak hmac secret issue, got %+v", token.Issues) + } + if !hasIssue(token, "rs256->hs256 confusion surface") { + t.Errorf("expected hmac confusion surface to be flagged, got %+v", token.Issues) + } +} + +func TestJWT_ExpiredFlagged(t *testing.T) { + // token in the response body. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"token":"` + jwtExpired + `"}`)) + })) + defer srv.Close() + + result, err := JWT(srv.URL, 5*time.Second, "") + if err != nil { + t.Fatalf("JWT: %v", err) + } + if result == nil || len(result.Tokens) != 1 { + t.Fatalf("expected one token, got %+v", result) + } + if !hasIssue(&result.Tokens[0], "expired token") { + t.Errorf("expected expired token to be flagged, got %+v", result.Tokens[0].Issues) + } + // a strong, unguessed secret must not be cracked. + if result.Tokens[0].WeakKey != "" { + t.Errorf("did not expect a cracked key on the strong-secret token, got %q", result.Tokens[0].WeakKey) + } +} + +func TestJWT_SensitiveClaimFlagged(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(jwtSensitive)) + })) + defer srv.Close() + + result, err := JWT(srv.URL, 5*time.Second, "") + if err != nil { + t.Fatalf("JWT: %v", err) + } + if result == nil || len(result.Tokens) != 1 { + t.Fatalf("expected one token, got %+v", result) + } + if !hasIssue(&result.Tokens[0], "sensitive plaintext claim") { + t.Errorf("expected sensitive claim to be flagged, got %+v", result.Tokens[0].Issues) + } +} + +func TestJWT_NoTokens(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("nothing to see here")) + })) + defer srv.Close() + + result, err := JWT(srv.URL, 5*time.Second, "") + if err != nil { + t.Fatalf("JWT: %v", err) + } + if result != nil { + t.Errorf("expected nil result when no tokens present, got %+v", result) + } +} + +func TestJWTResult_ResultType(t *testing.T) { + r := &JWTResult{} + if r.ResultType() != "jwt" { + t.Errorf("expected result type 'jwt', got %q", r.ResultType()) + } +} diff --git a/internal/scan/openapi.go b/internal/scan/openapi.go new file mode 100644 index 0000000..88c7013 --- /dev/null +++ b/internal/scan/openapi.go @@ -0,0 +1,322 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package scan + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "strings" + "sync" + "time" + + charmlog "github.com/charmbracelet/log" + "github.com/dropalldatabases/sif/internal/httpx" + "github.com/dropalldatabases/sif/internal/logger" + "github.com/dropalldatabases/sif/internal/output" + "gopkg.in/yaml.v3" +) + +// OpenAPIResult is the parsed spec exposure plus the endpoints enumerated from +// it. +type OpenAPIResult struct { + SpecURL string `json:"spec_url"` // the path the spec was served at + Title string `json:"title"` // info.title from the spec + Version string `json:"version"` // openapi/swagger version string + Endpoints []OpenAPIEndpoint `json:"endpoints"` // every path+method pair + Severity string `json:"severity"` // exposure severity +} + +// OpenAPIEndpoint is one path+method, flagged when nothing in the spec gates it. +type OpenAPIEndpoint struct { + Path string `json:"path"` + Method string `json:"method"` + Unauth bool `json:"unauth"` // no security requirement on this operation +} + +// openapiSpecPaths are the conventional locations a spec is served from. ordered +// most-common first so the typical hit is found early. +var openapiSpecPaths = []string{ + "/swagger.json", + "/openapi.json", + "/v3/api-docs", + "/api-docs", + "/swagger/v1/swagger.json", + "/swagger-ui/", +} + +// openapiBodyReadCap bounds spec body reads. specs are text and rarely huge, but +// an attacker-controlled endpoint could stream forever, so cap it. +const openapiBodyReadCap = 8 << 20 + +// the http methods an openapi path item can declare. anything outside this set +// is metadata (parameters, summary), not an operation. +var openapiHTTPMethods = []string{ + http.MethodGet, http.MethodPut, http.MethodPost, http.MethodDelete, + http.MethodOptions, http.MethodHead, http.MethodPatch, http.MethodTrace, +} + +// exposure severities. an enumerable spec is medium on its own; unauthenticated +// operations bump it to high. +const ( + openapiSevMedium = "medium" + openapiSevHigh = "high" +) + +// openapiSpec is the minimal slice of an openapi/swagger document we care about: +// the version banner, info block, top-level security and the path map. unknown +// fields are ignored by both json and yaml decoders. +type openapiSpec struct { + OpenAPI string `json:"openapi" yaml:"openapi"` + Swagger string `json:"swagger" yaml:"swagger"` + Info openapiInfo `json:"info" yaml:"info"` + Security []map[string][]string `json:"security" yaml:"security"` + Paths map[string]map[string]rawOps `json:"paths" yaml:"paths"` +} + +type openapiInfo struct { + Title string `json:"title" yaml:"title"` + Version string `json:"version" yaml:"version"` +} + +// rawOps captures just the per-operation security block so we can tell whether +// an operation requires auth. the rest of the operation object is irrelevant. +type rawOps struct { + Security []map[string][]string `json:"security" yaml:"security"` +} + +// OpenAPI probes the candidate spec paths concurrently and, on the first hit, +// parses the spec and enumerates its endpoints. +func OpenAPI(targetURL string, timeout time.Duration, threads int, logdir string) (*OpenAPIResult, error) { + log := output.Module("OPENAPI") + log.Start() + + spin := output.NewSpinner("Probing for exposed openapi/swagger specs") + spin.Start() + + sanitizedURL := stripScheme(targetURL) + + if logdir != "" { + if err := logger.WriteHeader(sanitizedURL, logdir, "OpenAPI/Swagger spec exposure"); err != nil { + spin.Stop() + log.Error("error creating log file: %v", err) + return nil, fmt.Errorf("create openapi log: %w", err) + } + } + + client := httpx.Client(timeout) + base := strings.TrimRight(targetURL, "/") + + result := probeOpenAPIPaths(client, base, threads) + + spin.Stop() + + if result == nil { + log.Info("no openapi/swagger spec exposed") + log.Complete(0, "found") + return nil, nil //nolint:nilnil // no exposed spec is not an error + } + + unauth := 0 + for i := 0; i < len(result.Endpoints); i++ { + if result.Endpoints[i].Unauth { + unauth++ + } + } + + log.Warn("openapi %s: spec at %s exposes %d endpoints (%d unauthenticated)", + renderOpenAPISeverity(result.Severity), + output.Highlight.Render(result.SpecURL), + len(result.Endpoints), unauth) + + if logdir != "" { + _ = logger.Write(sanitizedURL, logdir, + fmt.Sprintf("OpenAPI spec exposed at %s: %d endpoints, %d unauthenticated\n", + result.SpecURL, len(result.Endpoints), unauth)) + } + + log.Complete(len(result.Endpoints), "endpoints") + return result, nil +} + +// probeOpenAPIPaths fans the candidate paths across a worker pool and returns the +// first parseable spec. the first hit wins, so once one worker fills the result +// the rest of the channel drains without re-parsing. +func probeOpenAPIPaths(client *http.Client, base string, threads int) *OpenAPIResult { + var ( + mu sync.Mutex + wg sync.WaitGroup + result *OpenAPIResult + ) + + pathChan := make(chan string, len(openapiSpecPaths)) + for i := 0; i < len(openapiSpecPaths); i++ { + pathChan <- openapiSpecPaths[i] + } + close(pathChan) + + wg.Add(threads) + for t := 0; t < threads; t++ { + go func() { + defer wg.Done() + for path := range pathChan { + // a spec already landed; stop spending requests. + mu.Lock() + done := result != nil + mu.Unlock() + if done { + return + } + + hit := fetchOpenAPISpec(client, base+path) + if hit == nil { + continue + } + hit.SpecURL = base + path + + mu.Lock() + if result == nil { + result = hit + } + mu.Unlock() + } + }() + } + wg.Wait() + + return result +} + +// fetchOpenAPISpec GETs one candidate path and parses the body as a spec. it +// returns nil on any failure (non-200, unparseable, zero paths) so a swagger-ui +// html page or a 404 doesn't masquerade as a finding. +func fetchOpenAPISpec(client *http.Client, specURL string) *OpenAPIResult { + req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, specURL, http.NoBody) + if err != nil { + charmlog.Debugf("openapi: build request for %s: %v", specURL, err) + return nil + } + resp, err := client.Do(req) + if err != nil { + charmlog.Debugf("openapi: request %s: %v", specURL, err) + return nil + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, openapiBodyReadCap)) + if err != nil { + charmlog.Debugf("openapi: read %s: %v", specURL, err) + return nil + } + + spec, ok := parseOpenAPISpec(body) + if !ok { + return nil + } + + return specToResult(spec) +} + +// parseOpenAPISpec decodes the body as json first, then yaml. it only accepts a +// document that actually declares an openapi/swagger version and at least one +// path, so an unrelated json/yaml file served at the candidate path is rejected. +func parseOpenAPISpec(body []byte) (*openapiSpec, bool) { + var spec openapiSpec + if err := json.Unmarshal(body, &spec); err != nil { + if err := yaml.Unmarshal(body, &spec); err != nil { + return nil, false + } + } + + versioned := spec.OpenAPI != "" || spec.Swagger != "" + if !versioned || len(spec.Paths) == 0 { + return nil, false + } + return &spec, true +} + +// specToResult flattens the parsed spec into enumerated endpoints and ranks the +// exposure. an operation with no security requirement (and no top-level default) +// is flagged unauthenticated, which bumps the overall severity to high. +func specToResult(spec *openapiSpec) *OpenAPIResult { + hasGlobalSecurity := len(spec.Security) > 0 + + endpoints := make([]OpenAPIEndpoint, 0, len(spec.Paths)) + anyUnauth := false + + // stable order: sort paths so the report is deterministic across runs. + paths := make([]string, 0, len(spec.Paths)) + for p := range spec.Paths { + paths = append(paths, p) + } + sort.Strings(paths) + + for i := 0; i < len(paths); i++ { + path := paths[i] + ops := spec.Paths[path] + for j := 0; j < len(openapiHTTPMethods); j++ { + method := openapiHTTPMethods[j] + op, ok := ops[strings.ToLower(method)] + if !ok { + continue + } + // an operation is unauth when neither it nor the global default + // declares a security requirement. + unauth := len(op.Security) == 0 && !hasGlobalSecurity + if unauth { + anyUnauth = true + } + endpoints = append(endpoints, OpenAPIEndpoint{ + Path: path, + Method: method, + Unauth: unauth, + }) + } + } + + severity := openapiSevMedium + if anyUnauth { + severity = openapiSevHigh + } + + version := spec.OpenAPI + if version == "" { + version = spec.Swagger + } + + return &OpenAPIResult{ + Title: spec.Info.Title, + Version: version, + Endpoints: endpoints, + Severity: severity, + } +} + +func renderOpenAPISeverity(severity string) string { + if severity == openapiSevHigh { + return output.SeverityHigh.Render(severity) + } + return output.SeverityMedium.Render(severity) +} + +// ResultType identifies openapi findings for the result registry. +func (r *OpenAPIResult) ResultType() string { return "openapi" } + +var _ ScanResult = (*OpenAPIResult)(nil) diff --git a/internal/scan/openapi_test.go b/internal/scan/openapi_test.go new file mode 100644 index 0000000..ddc119b --- /dev/null +++ b/internal/scan/openapi_test.go @@ -0,0 +1,210 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package scan + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// a minimal openapi 3 doc with two paths/three operations, no security at all - +// every operation is unauthenticated. +const openapiJSONUnauth = `{ + "openapi": "3.0.1", + "info": {"title": "Test API", "version": "1.0"}, + "paths": { + "/users": { + "get": {"summary": "list"}, + "post": {"summary": "create"} + }, + "/admin": { + "delete": {"summary": "nuke"} + } + } +}` + +// same doc but with a global security requirement, so nothing is flagged unauth. +const openapiJSONSecured = `{ + "openapi": "3.0.1", + "info": {"title": "Secured API", "version": "1.0"}, + "security": [{"bearerAuth": []}], + "paths": { + "/users": {"get": {"summary": "list"}} + } +}` + +// a yaml swagger 2.0 doc, to exercise the yaml parse fallback. +const openapiYAML = `swagger: "2.0" +info: + title: YAML API + version: "2.0" +paths: + /ping: + get: + summary: health +` + +// hasEndpoint reports whether the result enumerated the given path+method. +func hasEndpoint(r *OpenAPIResult, path, method string) (OpenAPIEndpoint, bool) { + for i := 0; i < len(r.Endpoints); i++ { + if r.Endpoints[i].Path == path && r.Endpoints[i].Method == method { + return r.Endpoints[i], true + } + } + return OpenAPIEndpoint{}, false +} + +func TestOpenAPI_EnumeratesEndpoints(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/openapi.json" { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(openapiJSONUnauth)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + result, err := OpenAPI(srv.URL, 5*time.Second, 4, "") + if err != nil { + t.Fatalf("OpenAPI: %v", err) + } + if result == nil { + t.Fatal("expected an openapi result, got nil") + } + if len(result.Endpoints) != 3 { + t.Fatalf("expected 3 enumerated endpoints, got %d: %+v", len(result.Endpoints), result.Endpoints) + } + + for _, want := range []struct{ path, method string }{ + {"/users", http.MethodGet}, + {"/users", http.MethodPost}, + {"/admin", http.MethodDelete}, + } { + ep, ok := hasEndpoint(result, want.path, want.method) + if !ok { + t.Errorf("missing endpoint %s %s", want.method, want.path) + continue + } + if !ep.Unauth { + t.Errorf("expected %s %s to be flagged unauthenticated", want.method, want.path) + } + } + + // no security anywhere -> high exposure. + if result.Severity != openapiSevHigh { + t.Errorf("expected high severity for fully-unauth spec, got %q", result.Severity) + } + if result.Title != "Test API" { + t.Errorf("expected title 'Test API', got %q", result.Title) + } +} + +func TestOpenAPI_SecuredSpecIsMedium(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/swagger.json" { + _, _ = w.Write([]byte(openapiJSONSecured)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + result, err := OpenAPI(srv.URL, 5*time.Second, 4, "") + if err != nil { + t.Fatalf("OpenAPI: %v", err) + } + if result == nil { + t.Fatal("expected a result, got nil") + } + ep, ok := hasEndpoint(result, "/users", http.MethodGet) + if !ok { + t.Fatal("expected /users GET to be enumerated") + } + if ep.Unauth { + t.Errorf("global security should mark the operation authenticated, got unauth") + } + if result.Severity != openapiSevMedium { + t.Errorf("expected medium severity for a secured spec, got %q", result.Severity) + } +} + +func TestOpenAPI_YAMLSpec(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v3/api-docs" { + w.Header().Set("Content-Type", "application/yaml") + _, _ = w.Write([]byte(openapiYAML)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + result, err := OpenAPI(srv.URL, 5*time.Second, 4, "") + if err != nil { + t.Fatalf("OpenAPI: %v", err) + } + if result == nil { + t.Fatal("expected a yaml-parsed result, got nil") + } + if _, ok := hasEndpoint(result, "/ping", http.MethodGet); !ok { + t.Errorf("expected /ping GET from yaml spec, got %+v", result.Endpoints) + } +} + +// TestOpenAPI_NoSpecExposed confirms a server with no spec at any candidate path +// produces no result. +func TestOpenAPI_NoSpecExposed(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + result, err := OpenAPI(srv.URL, 5*time.Second, 4, "") + if err != nil { + t.Fatalf("OpenAPI: %v", err) + } + if result != nil { + t.Errorf("expected nil result when no spec exposed, got %+v", result) + } +} + +// TestOpenAPI_RejectsUnrelatedJSON makes sure a plain json document served at a +// candidate path (no openapi/swagger version) is not treated as a spec. +func TestOpenAPI_RejectsUnrelatedJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/openapi.json" { + _, _ = w.Write([]byte(`{"hello":"world"}`)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + result, err := OpenAPI(srv.URL, 5*time.Second, 4, "") + if err != nil { + t.Fatalf("OpenAPI: %v", err) + } + if result != nil { + t.Errorf("unrelated json should not be parsed as a spec, got %+v", result) + } +} + +func TestOpenAPIResult_ResultType(t *testing.T) { + r := &OpenAPIResult{} + if r.ResultType() != "openapi" { + t.Errorf("expected result type 'openapi', got %q", r.ResultType()) + } +} diff --git a/internal/scan/passive.go b/internal/scan/passive.go index 8d02ced..dac7ebf 100644 --- a/internal/scan/passive.go +++ b/internal/scan/passive.go @@ -205,11 +205,13 @@ func passiveGET(ctx context.Context, client *http.Client, reqURL string) ([]byte } req.Header.Set("Accept", "application/json") - resp, err := client.Do(req) + resp, err := client.Do(req) //nolint:bodyclose // drained and closed via httpx.DrainClose if err != nil { return nil, fmt.Errorf("request failed: %w", err) } - defer resp.Body.Close() + // the non-200 branch returns before reading the body, so drain on close to + // keep the conn reusable instead of leaking it. + defer httpx.DrainClose(resp) if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("unexpected status %d", resp.StatusCode) diff --git a/internal/scan/redirect.go b/internal/scan/redirect.go index 7597c31..cff3596 100644 --- a/internal/scan/redirect.go +++ b/internal/scan/redirect.go @@ -229,12 +229,14 @@ func probeRedirect(client *http.Client, testURL string) (location, via string, o charmlog.Debugf("redirect: build request for %s: %v", testURL, err) return "", "", false } - resp, err := client.Do(req) + resp, err := client.Do(req) //nolint:bodyclose // drained and closed via httpx.DrainClose if err != nil { charmlog.Debugf("redirect: request %s: %v", testURL, err) return "", "", false } - defer resp.Body.Close() + // the header-redirect branch returns before reading the body, so drain on + // close to keep that conn reusable instead of leaking it. + defer httpx.DrainClose(resp) // header redirect: a 30x whose Location resolves to the sentinel host if resp.StatusCode >= http.StatusMultipleChoices && resp.StatusCode < http.StatusBadRequest { diff --git a/internal/scan/scan.go b/internal/scan/scan.go index 487dcaa..4145f2b 100644 --- a/internal/scan/scan.go +++ b/internal/scan/scan.go @@ -74,7 +74,8 @@ func fetchRobotsTXT(url string, client *http.Client) *http.Response { } redirectURL := resp.Header.Get("Location") - resp.Body.Close() + // only the Location header is used here; drain so the conn is reusable. + httpx.DrainClose(resp) if redirectURL == "" { log.Debugf("Redirect location is empty for %s", url) return nil @@ -111,11 +112,13 @@ func Scan(url string, timeout time.Duration, threads int, logdir string) { return http.ErrUseLastResponse } - resp := fetchRobotsTXT(url+"/robots.txt", client) + resp := fetchRobotsTXT(url+"/robots.txt", client) //nolint:bodyclose // drained and closed via httpx.DrainClose if resp == nil { return } - defer resp.Body.Close() + // drain on close: the non-success branch never reads the body, so a bare + // close would leak the conn instead of returning it to the pool. + defer httpx.DrainClose(resp) if resp.StatusCode != 404 && resp.StatusCode != 301 && resp.StatusCode != 302 && resp.StatusCode != 307 { output.Success("File %s found", output.Status.Render("robots.txt")) @@ -149,7 +152,7 @@ func Scan(url string, timeout time.Duration, threads int, logdir string) { log.Debugf("Error creating request for %s: %s", sanitizedRobot, err) continue } - resp, err := client.Do(robotReq) + resp, err := client.Do(robotReq) //nolint:bodyclose // drained and closed via httpx.DrainClose if err != nil { log.Debugf("Error %s: %s", sanitizedRobot, err) continue @@ -161,7 +164,8 @@ func Scan(url string, timeout time.Duration, threads int, logdir string) { logger.Write(sanitizedURL, logdir, strconv.Itoa(resp.StatusCode)+" from robots: ["+sanitizedRobot+"]\n") } } - resp.Body.Close() + // status only; drain so the conn returns to the pool. + httpx.DrainClose(resp) } }(thread) diff --git a/internal/scan/securityheaders.go b/internal/scan/securityheaders.go index dc64fc8..4222180 100644 --- a/internal/scan/securityheaders.go +++ b/internal/scan/securityheaders.go @@ -71,11 +71,12 @@ func SecurityHeaders(url string, timeout time.Duration, logdir string) (Security if err != nil { return nil, err } - resp, err := client.Do(req) + resp, err := client.Do(req) //nolint:bodyclose // drained and closed via httpx.DrainClose if err != nil { return nil, err } - defer resp.Body.Close() + // header-only scan: drain on close so the conn is returned to the pool. + defer httpx.DrainClose(resp) results := gradeSecurityHeaders(resp.Header, strings.HasPrefix(url, "https://")) diff --git a/internal/scan/securitytrails.go b/internal/scan/securitytrails.go index 42aaf62..e132473 100644 --- a/internal/scan/securitytrails.go +++ b/internal/scan/securitytrails.go @@ -187,11 +187,13 @@ func doSTRequest(client *http.Client, reqURL, apiKey string) ([]byte, error) { req.Header.Set("APIKEY", apiKey) req.Header.Set("Accept", "application/json") - resp, err := client.Do(req) + resp, err := client.Do(req) //nolint:bodyclose // drained and closed via httpx.DrainClose if err != nil { return nil, fmt.Errorf("SecurityTrails request failed: %w", err) } - defer resp.Body.Close() + // the auth/rate-limit branches return before reading the body, so drain on + // close to keep the conn reusable instead of leaking it. + defer httpx.DrainClose(resp) if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnauthorized { return nil, fmt.Errorf("invalid SecurityTrails API key (status %d)", resp.StatusCode) diff --git a/internal/scan/shodan.go b/internal/scan/shodan.go index f288b03..fbe943b 100644 --- a/internal/scan/shodan.go +++ b/internal/scan/shodan.go @@ -188,11 +188,13 @@ func queryShodanHost(ip string, apiKey string, timeout time.Duration) (*ShodanRe if err != nil { return nil, fmt.Errorf("failed to create Shodan request: %w", err) } - resp, err := client.Do(req) + resp, err := client.Do(req) //nolint:bodyclose // drained and closed via httpx.DrainClose if err != nil { return nil, fmt.Errorf("failed to query Shodan: %w", err) } - defer resp.Body.Close() + // the unauthorized/not-found branches return before reading the body, so + // drain on close to keep the conn reusable instead of leaking it. + defer httpx.DrainClose(resp) if resp.StatusCode == http.StatusUnauthorized { return nil, fmt.Errorf("invalid Shodan API key") diff --git a/internal/scan/sql.go b/internal/scan/sql.go index 6f0e821..600c28c 100644 --- a/internal/scan/sql.go +++ b/internal/scan/sql.go @@ -208,7 +208,8 @@ func SQL(targetURL string, timeout time.Duration, threads int, logdir string) (* } } } else { - resp.Body.Close() + // uninteresting status; body never read, so drain to reuse the conn. + httpx.DrainClose(resp) } } }() diff --git a/man/sif.1 b/man/sif.1 index e2f2199..9799fbf 100644 --- a/man/sif.1 +++ b/man/sif.1 @@ -1,5 +1,5 @@ .\" man page for sif - the blazing-fast pentesting suite -.TH sif 1 "2026-06-08" "sif" "sif manual" +.TH sif 1 "2026-06-10" "sif" "sif manual" .SH NAME sif \- blazing-fast pentesting suite .SH SYNOPSIS @@ -15,17 +15,25 @@ sif \- blazing-fast pentesting suite .RI [ scans ] .RI [ options ] .br +.I "targets" +| +.B sif +.RI [ scans ] +.RI [ options ] +.br .B sif .RB { patchnote | version } .SH DESCRIPTION .B sif is a modular recon and exploitation suite. it runs multiple scan types concurrently against one or more targets, and can be extended with yaml -modules. targets must include a +modules. a scheme\-less target defaults to +.B https:// +\&; an explicit .B http:// or .B https:// -scheme. +is kept; any other scheme is rejected. .SH TARGETS .TP .BR \-u ", " \-\-urls " \fIlist\fR" @@ -33,6 +41,13 @@ comma\-separated list of urls to scan. .TP .BR \-f ", " \-\-file " \fIpath\fR" file with one url per line. +.TP +.B stdin +when stdin is a pipe, one target per line is read from it, alongside any +.B \-u +/ +.B \-f +targets. lets sif slot into a unix pipeline (e.g. \fBsubfinder | sif \-silent | notify\fR). .SH SCANS .TP .BR \-dirlist " \fIsize\fR" @@ -110,6 +125,15 @@ sql reconnaissance (admin panels, error disclosure). .B \-lfi local file inclusion reconnaissance. .TP +.B \-jwt +jwt discovery plus offline weakness analysis (alg:none, weak hmac secret, missing/expired exp, sensitive plaintext claims). +.TP +.B \-openapi +openapi/swagger spec exposure probe; enumerates paths, methods and unauthenticated operations. +.TP +.B \-favicon +favicon hash fingerprinting (shodan\-style mmh3); matches bundled tech and prints the http.favicon.hash pivot query. +.TP .B \-cors cors misconfiguration probe (reflected/permissive origins). .TP @@ -171,6 +195,11 @@ write a sarif 2.1.0 report of the run to \fIfile\fR. .BR \-md ", " \-\-markdown " \fIfile\fR" write a markdown report of the run to \fIfile\fR. .TP +.B \-silent +plain output for pipelines: route all chrome to stderr and print one +normalized finding per line to stdout as \fB[severity] target module title\fR. +implies non\-interactive (no spinners). +.TP .B \-api emit json results and suppress the interactive output. .SH MODULES diff --git a/sif.go b/sif.go index 4be3e97..5044f89 100644 --- a/sif.go +++ b/sif.go @@ -20,6 +20,7 @@ import ( "context" "encoding/json" "fmt" + "io" "os" "strings" @@ -85,13 +86,19 @@ func NewModuleResult[T ScanResult](data T) ModuleResult { func New(settings *config.Settings) (*App, error) { app := &App{settings: settings} + // -silent reroutes all chrome to stderr (and suppresses spinners) before the + // banner prints, so stdout carries nothing but findings even on the banner. + if settings.Silent { + output.SetSilent(true) + } + if !settings.ApiMode { - fmt.Println(output.Box.Render(" █▀ █ █▀▀\n ▄█ █ █▀ ")) + fmt.Fprintln(output.Writer(), output.Box.Render(" █▀ █ █▀▀\n ▄█ █ █▀ ")) tagline := "blazing-fast pentesting suite" if Version != "dev" { tagline += " · v" + Version } - fmt.Println(output.Subheading.Render("\n" + tagline + "\n\nbsd 3-clause · (c) 2022-2026 vmfunc, xyzeva & contributors\n")) + fmt.Fprintln(output.Writer(), output.Subheading.Render("\n"+tagline+"\n\nbsd 3-clause · (c) 2022-2026 vmfunc, xyzeva & contributors\n")) } else { output.SetAPIMode(true) } @@ -101,10 +108,11 @@ func New(settings *config.Settings) (*App, error) { return app, nil } - switch { - case len(settings.URLs) > 0: - app.targets = settings.URLs - case settings.File != "": + // -u and -f are explicit; stdin is additive so `subfinder | sif -u extra` + // still works. order: flags first, then piped lines appended. + app.targets = append(app.targets, settings.URLs...) + + if settings.File != "" { if _, err := os.Stat(settings.File); err != nil { return nil, err } @@ -120,29 +128,105 @@ func New(settings *config.Settings) (*App, error) { for scanner.Scan() { app.targets = append(app.targets, scanner.Text()) } - default: - return nil, fmt.Errorf("target(s) must be supplied with -u or -f\n\nSee 'sif -h' for more information") } - // Validate all URLs early - for _, url := range app.targets { - if err := validateURL(url); err != nil { + // when stdin is a pipe (not a terminal), drain it for targets so sif slots + // into a unix pipeline: `subfinder -d x | sif -silent | notify`. keyed off + // stdin's mode, never stdout - a redirected stdout (>file) is not a pipe in. + piped, err := stdinPipedFn() + if err != nil { + return nil, err + } + if piped { + stdinTargets, err := readTargets(stdinReader) + if err != nil { + return nil, fmt.Errorf("reading targets from stdin: %w", err) + } + app.targets = append(app.targets, stdinTargets...) + } + + if len(app.targets) == 0 { + return nil, fmt.Errorf("target(s) must be supplied with -u, -f, or stdin\n\nSee 'sif -h' for more information") + } + + // normalize every target in place: a naked host gains a default scheme, an + // explicit scheme is kept, genuinely invalid input is rejected early. + for i := 0; i < len(app.targets); i++ { + normalized, err := normalizeTarget(app.targets[i]) + if err != nil { return nil, err } + app.targets[i] = normalized } return app, nil } -// validateURL checks that a URL has a valid HTTP/HTTPS protocol. -func validateURL(url string) error { - if url == "" { - return fmt.Errorf("empty URL provided") +// defaultScheme is prepended to scheme-less targets. https is the safer default +// for recon: it's what modern hosts serve and avoids a cleartext first hop. +const defaultScheme = "https://" + +// stdin ingestion is wired through two seams so it's hermetically testable: the +// pipe check and the reader can be swapped in tests without touching real fds. +var ( + stdinPipedFn = stdinPiped + stdinReader io.Reader = os.Stdin +) + +// stdinPiped reports whether stdin is a pipe/redirect rather than a terminal. +// a char device (the tty) means interactive with no piped input; anything else +// (pipe, file redirect) is treated as a target stream. +func stdinPiped() (bool, error) { + info, err := os.Stdin.Stat() + if err != nil { + return false, fmt.Errorf("stat stdin: %w", err) } - if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { - return fmt.Errorf("URL %s must include http:// or https:// protocol", url) + return info.Mode()&os.ModeCharDevice == 0, nil +} + +// readTargets scans one target per line from r, dropping blank lines and +// trimming surrounding whitespace. shared by the stdin path; the file path keeps +// its own scanner since it preserves lines verbatim for back-compat. +func readTargets(r io.Reader) ([]string, error) { + var out []string + scanner := bufio.NewScanner(r) + scanner.Split(bufio.ScanLines) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + out = append(out, line) } - return nil + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("scanning targets: %w", err) + } + return out, nil +} + +// normalizeTarget canonicalizes a single target. a scheme-less host gets the +// default scheme; an http:// or https:// target is kept as-is. an empty string +// or a non-http(s) scheme (ftp://, file://, ...) is rejected so junk can't slip +// into the scan loop. +func normalizeTarget(target string) (string, error) { + target = strings.TrimSpace(target) + if target == "" { + return "", fmt.Errorf("empty target provided") + } + if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") { + return target, nil + } + // reject anything that carries some other scheme; "://" present but not + // http(s) is a deliberate non-web target, not a naked host. + if strings.Contains(target, "://") { + return "", fmt.Errorf("target %s must use http:// or https:// scheme", target) + } + // a bare "host:port" or path-only token would also be ambiguous; require at + // least a host-looking first segment (no spaces) before defaulting a scheme. + if strings.ContainsAny(target, " \t") { + return "", fmt.Errorf("invalid target %q", target) + } + return defaultScheme + target, nil } // Run runs the pentesting suite, with the targets specified, according to the @@ -419,6 +503,36 @@ func (app *App) Run() error { } } + if app.settings.JWT { + result, err := scan.JWT(url, app.settings.Timeout, app.settings.LogDir) + if err != nil { + log.Errorf("Error while running JWT analysis: %s", err) + } else if result != nil { + moduleResults = append(moduleResults, NewModuleResult(result)) + scansRun = append(scansRun, "JWT") + } + } + + if app.settings.OpenAPI { + result, err := scan.OpenAPI(url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir) + if err != nil { + log.Errorf("Error while running OpenAPI probe: %s", err) + } else if result != nil { + moduleResults = append(moduleResults, NewModuleResult(result)) + scansRun = append(scansRun, "OpenAPI") + } + } + + if app.settings.Favicon { + result, err := scan.Favicon(url, app.settings.Timeout, app.settings.LogDir) + if err != nil { + log.Errorf("Error while running favicon fingerprint: %s", err) + } else if result != nil { + moduleResults = append(moduleResults, NewModuleResult(result)) + scansRun = append(scansRun, "Favicon") + } + } + if app.settings.CORS { result, err := scan.CORS(url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir) if err != nil { @@ -562,6 +676,13 @@ func (app *App) Run() error { // count now so the path is live and observable without changing output. log.Debugf("normalized %d findings across %d targets", len(allFindings), len(app.targets)) + // -silent: stdout is the findings stream, one terse line each. all chrome + // already went to stderr via the rerouted sink, so this is the only thing a + // downstream pipe sees. + if app.settings.Silent { + printFindings(allFindings) + } + if wantReport { if err := app.writeReports(reportResults); err != nil { return err @@ -575,6 +696,18 @@ func (app *App) Run() error { return nil } +// printFindings writes one normalized finding per line to stdout for the +// -silent plain sink. a single Builder over the run avoids interleaving with +// any stray stderr chrome and keeps the write to one syscall. +func printFindings(findings []finding.Finding) { + var b strings.Builder + for i := 0; i < len(findings); i++ { + b.WriteString(findings[i].Line()) + b.WriteByte('\n') + } + fmt.Print(b.String()) +} + // collectFindings normalizes one target's module results through finding.Flatten // - the single normalization path that notify and diff (later bundles) build on. // every scan result struct collapses to flat, severity-ranked findings here so a diff --git a/sif_test.go b/sif_test.go index 2a4f3ad..c6455b4 100644 --- a/sif_test.go +++ b/sif_test.go @@ -13,11 +13,24 @@ package sif import ( + "io" + "os" + "strings" "testing" "github.com/dropalldatabases/sif/internal/config" + "github.com/dropalldatabases/sif/internal/finding" ) +// TestMain neutralizes the stdin seam for the whole package so tests that build +// an App via New() never block on the test runner's real stdin (a pipe under +// `go test`). tests that exercise ingestion set the seams explicitly. +func TestMain(m *testing.M) { + stdinPipedFn = func() (bool, error) { return false, nil } + stdinReader = strings.NewReader("") + os.Exit(m.Run()) +} + // mockResult is a test implementation of ScanResult type mockResult struct { name string @@ -117,20 +130,16 @@ func TestNew_URLValidation(t *testing.T) { wantErr: false, }, { + // naked host is now accepted and normalized, not rejected name: "missing protocol", url: "example.com", - wantErr: true, + wantErr: false, }, { name: "invalid protocol", url: "ftp://example.com", wantErr: true, }, - { - name: "empty url", - url: "", - wantErr: true, - }, } for _, tt := range tests { @@ -148,6 +157,194 @@ func TestNew_URLValidation(t *testing.T) { } } +func TestNormalizeTarget(t *testing.T) { + tests := []struct { + name string + in string + want string + wantErr bool + }{ + {name: "naked host defaults https", in: "example.com", want: "https://example.com"}, + {name: "naked host with port", in: "example.com:8443", want: "https://example.com:8443"}, + {name: "naked host with path", in: "example.com/admin", want: "https://example.com/admin"}, + {name: "https kept", in: "https://example.com", want: "https://example.com"}, + {name: "http kept", in: "http://example.com", want: "http://example.com"}, + {name: "surrounding whitespace trimmed", in: " example.com\t", want: "https://example.com"}, + {name: "empty rejected", in: "", wantErr: true}, + {name: "blank rejected", in: " ", wantErr: true}, + {name: "ftp scheme rejected", in: "ftp://example.com", wantErr: true}, + {name: "file scheme rejected", in: "file:///etc/passwd", wantErr: true}, + {name: "embedded space rejected", in: "foo bar", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := normalizeTarget(tt.in) + if (err != nil) != tt.wantErr { + t.Fatalf("normalizeTarget(%q) err = %v, wantErr %v", tt.in, err, tt.wantErr) + } + if err != nil { + return + } + if got != tt.want { + t.Errorf("normalizeTarget(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +func TestNew_StdinIngestion(t *testing.T) { + // feed a pipe of targets and assert they're parsed and normalized alongside + // the explicit -u target. the seams stand in for a real stdin pipe. + origPiped, origReader := stdinPipedFn, stdinReader + t.Cleanup(func() { stdinPipedFn, stdinReader = origPiped, origReader }) + + stdinPipedFn = func() (bool, error) { return true, nil } + stdinReader = strings.NewReader("sub1.example.com\nhttps://sub2.example.com\n\n sub3.example.com \n") + + settings := &config.Settings{ + URLs: []string{"https://flag.example.com"}, + ApiMode: true, + } + + app, err := New(settings) + if err != nil { + t.Fatalf("New() unexpected error: %v", err) + } + + want := []string{ + "https://flag.example.com", + "https://sub1.example.com", + "https://sub2.example.com", + "https://sub3.example.com", + } + if len(app.targets) != len(want) { + t.Fatalf("targets = %v (%d), want %d", app.targets, len(app.targets), len(want)) + } + for i := range want { + if app.targets[i] != want[i] { + t.Errorf("target[%d] = %q, want %q", i, app.targets[i], want[i]) + } + } +} + +func TestNew_StdinOnly(t *testing.T) { + // no -u/-f: a piped stream alone must satisfy the target requirement. + origPiped, origReader := stdinPipedFn, stdinReader + t.Cleanup(func() { stdinPipedFn, stdinReader = origPiped, origReader }) + + stdinPipedFn = func() (bool, error) { return true, nil } + stdinReader = strings.NewReader("only.example.com\n") + + app, err := New(&config.Settings{ApiMode: true}) + if err != nil { + t.Fatalf("New() unexpected error: %v", err) + } + if len(app.targets) != 1 || app.targets[0] != "https://only.example.com" { + t.Errorf("targets = %v, want [https://only.example.com]", app.targets) + } +} + +func TestNew_NoTargets_StdinEmpty(t *testing.T) { + // an empty pipe with no flags is still "no targets" and must error. + origPiped, origReader := stdinPipedFn, stdinReader + t.Cleanup(func() { stdinPipedFn, stdinReader = origPiped, origReader }) + + stdinPipedFn = func() (bool, error) { return true, nil } + stdinReader = strings.NewReader("\n \n") + + if _, err := New(&config.Settings{ApiMode: true}); err == nil { + t.Error("New() should error when stdin yields no targets and no flags set") + } +} + +func TestReadTargets(t *testing.T) { + got, err := readTargets(strings.NewReader("a.com\n\n b.com \nc.com\n")) + if err != nil { + t.Fatalf("readTargets() error: %v", err) + } + want := []string{"a.com", "b.com", "c.com"} + if len(got) != len(want) { + t.Fatalf("readTargets() = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("readTargets()[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +// errReader fails on first read; used to assert stdin scan errors propagate. +type errReader struct{} + +func (errReader) Read([]byte) (int, error) { return 0, io.ErrClosedPipe } + +func TestReadTargets_Error(t *testing.T) { + if _, err := readTargets(errReader{}); err == nil { + t.Error("readTargets() should propagate a reader error") + } +} + +func TestPrintFindings(t *testing.T) { + findings := []finding.Finding{ + {Target: "https://a.com", Module: "sql", Severity: finding.SeverityHigh, Title: "admin panel"}, + {Target: "https://b.com", Module: "headers", Severity: finding.SeverityInfo, Title: "Server"}, + } + + out := captureStdout(t, func() { printFindings(findings) }) + + wantLines := []string{ + "[high] https://a.com sql admin panel", + "[info] https://b.com headers Server", + } + got := strings.Split(strings.TrimRight(out, "\n"), "\n") + if len(got) != len(wantLines) { + t.Fatalf("printFindings wrote %d lines, want %d:\n%s", len(got), len(wantLines), out) + } + for i := range wantLines { + if got[i] != wantLines[i] { + t.Errorf("line %d = %q, want %q", i, got[i], wantLines[i]) + } + } +} + +func TestPrintFindings_Empty(t *testing.T) { + out := captureStdout(t, func() { printFindings(nil) }) + if out != "" { + t.Errorf("printFindings(nil) wrote %q, want empty", out) + } +} + +// captureStdout swaps os.Stdout for a pipe, runs fn, and returns what it wrote. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + saved := os.Stdout + os.Stdout = w + + done := make(chan string, 1) + go func() { + buf := make([]byte, 0, 4096) + tmp := make([]byte, 1024) + for { + n, rerr := r.Read(tmp) + buf = append(buf, tmp[:n]...) + if rerr != nil { + break + } + } + done <- string(buf) + }() + + fn() + os.Stdout = saved + w.Close() + return <-done +} + func TestModuleResult_JSON(t *testing.T) { mr := ModuleResult{ Id: "test",