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",