diff --git a/README.md b/README.md
index 906c50d..d780762 100644
--- a/README.md
+++ b/README.md
@@ -122,6 +122,9 @@ makepkg -si
# sql recon + lfi scanning
./sif -u https://example.com -sql -lfi
+# web vuln probes (cors, open redirect, reflected xss)
+./sif -u https://example.com -cors -redirect -xss
+
# framework detection (with cve lookup)
./sif -u https://example.com -framework
@@ -170,6 +173,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 |
+| `-cors` | cors misconfiguration probe |
+| `-redirect` | open redirect probe |
+| `-xss` | reflected xss probe |
| `-framework` | framework detection with cve lookup |
### http options
diff --git a/docs/usage.md b/docs/usage.md
index 6f49b74..c58e16a 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -154,6 +154,30 @@ export SHODAN_API_KEY=your-api-key
./sif -u https://example.com -lfi
```
+### cors probe
+
+`-cors` - probe for cors misconfigurations (reflected/permissive origins)
+
+```bash
+./sif -u https://example.com -cors
+```
+
+### open redirect probe
+
+`-redirect` - probe redirect-prone params for open redirects
+
+```bash
+./sif -u https://example.com/login?next=home -redirect
+```
+
+### reflected xss probe
+
+`-xss` - inject a canary into params and report unescaped reflections
+
+```bash
+./sif -u https://example.com/search?q=test -xss
+```
+
### framework detection
`-framework` - detect web frameworks with version and cve lookup
@@ -339,6 +363,9 @@ the first time you run a new release sif also prints that release's notes once.
-git \
-sql \
-lfi \
+ -cors \
+ -redirect \
+ -xss \
-am
```
diff --git a/internal/config/config.go b/internal/config/config.go
index 7e6755d..c692925 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -46,6 +46,9 @@ type Settings struct {
SecurityTrails bool
SQL bool
LFI bool
+ CORS bool
+ Redirect bool
+ XSS bool
Framework bool
Modules string // Comma-separated list of module IDs to run
ModuleTags string // Run modules matching these tags
@@ -107,6 +110,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.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"),
flagSet.BoolVar(&settings.Framework, "framework", false, "Enable framework detection"),
)
diff --git a/internal/scan/cors.go b/internal/scan/cors.go
new file mode 100644
index 0000000..3828628
--- /dev/null
+++ b/internal/scan/cors.go
@@ -0,0 +1,236 @@
+/*
+·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
+: :
+: █▀ █ █▀▀ · Blazing-fast pentesting suite :
+: ▄█ █ █▀ · BSD 3-Clause License :
+: :
+: (c) 2022-2026 vmfunc, xyzeva, :
+: lunchcat alumni & contributors :
+: :
+·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
+*/
+
+package scan
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/url"
+ "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"
+)
+
+// CORSResult collects every cors misconfiguration found on the target.
+type CORSResult struct {
+ Findings []CORSFinding `json:"findings,omitempty"`
+}
+
+// CORSFinding is a single reflecting/permissive cors response.
+type CORSFinding struct {
+ URL string `json:"url"`
+ OriginTested string `json:"origin_tested"`
+ AllowOrigin string `json:"allow_origin"`
+ AllowCredentials bool `json:"allow_credentials"`
+ Severity string `json:"severity"`
+ Note string `json:"note"`
+}
+
+// corsMaxRedirects caps the redirect chain so we read the cors headers off the
+// host we actually asked about, not whatever it bounces us to.
+const corsMaxRedirects = 3
+
+// the sentinel attacker origin; if it comes back in Access-Control-Allow-Origin
+// the target reflects arbitrary origins and any site can read the response.
+const corsEvilOrigin = "https://sif-cors-probe.evil.com"
+
+// corsOrigin is a header to inject + why it matters. {host} expands to the
+// target host so the prefix/suffix bypasses key off the real name.
+var corsOrigins = []struct {
+ origin string // crafted Origin header, {host} -> target host
+ note string // why this case is interesting
+ reflects bool // true when a literal echo of this origin is exploitable
+}{
+ // arbitrary attacker origin - the classic "reflects anything" bug
+ {corsEvilOrigin, "arbitrary origin reflected", true},
+ // the literal null origin (sandboxed iframes, redirects, file://) is forgeable
+ {"null", "null origin allowed", true},
+ // suffix bypass: attacker registers {host}.evil.com, naive endswith checks pass
+ {"https://{host}.evil.com", "suffix bypass (attacker subdomain)", true},
+ // prefix bypass: attacker registers evil-{host}, naive startswith checks pass
+ {"https://evil-{host}", "prefix bypass", true},
+ // embedded bypass: {host} appears inside an attacker domain
+ {"https://evil.com.{host}", "embedded-host bypass", true},
+ // scheme downgrade: http origin trusted lets a mitm read cross-origin data
+ {"http://{host}", "http scheme downgrade trusted", true},
+}
+
+// CORS probes the target for cross-origin resource sharing misconfigurations.
+func CORS(targetURL string, timeout time.Duration, threads int, logdir string) (*CORSResult, error) {
+ log := output.Module("CORS")
+ log.Start()
+
+ spin := output.NewSpinner("Scanning for CORS misconfigurations")
+ spin.Start()
+
+ sanitizedURL := stripScheme(targetURL)
+
+ if logdir != "" {
+ if err := logger.WriteHeader(sanitizedURL, logdir, "CORS misconfiguration probe"); err != nil {
+ spin.Stop()
+ log.Error("error creating log file: %v", err)
+ return nil, fmt.Errorf("create cors log: %w", err)
+ }
+ }
+
+ parsedURL, err := url.Parse(targetURL)
+ if err != nil {
+ spin.Stop()
+ return nil, fmt.Errorf("parse url: %w", err)
+ }
+ host := parsedURL.Host
+
+ client := httpx.Client(timeout)
+ client.CheckRedirect = func(_ *http.Request, via []*http.Request) error {
+ if len(via) >= corsMaxRedirects {
+ return http.ErrUseLastResponse
+ }
+ return nil
+ }
+
+ result := &CORSResult{Findings: make([]CORSFinding, 0, len(corsOrigins))}
+
+ var mu sync.Mutex
+ var wg sync.WaitGroup
+
+ // one origin per worker item; the set is small so a buffered channel is plenty
+ originChan := make(chan int, len(corsOrigins))
+ for i := 0; i < len(corsOrigins); i++ {
+ originChan <- i
+ }
+ close(originChan)
+
+ wg.Add(threads)
+ for t := 0; t < threads; t++ {
+ go func() {
+ defer wg.Done()
+ for idx := range originChan {
+ spec := corsOrigins[idx]
+ // {host} is the seam that turns a template into a real attacker origin
+ origin := strings.ReplaceAll(spec.origin, "{host}", host)
+
+ finding, ok := probeCORS(client, targetURL, origin, spec.note)
+ if !ok {
+ continue
+ }
+
+ mu.Lock()
+ result.Findings = append(result.Findings, finding)
+ mu.Unlock()
+
+ spin.Stop()
+ log.Warn("cors %s: origin %s reflected (creds=%t)",
+ renderCORSSeverity(finding.Severity),
+ output.Highlight.Render(origin),
+ finding.AllowCredentials)
+ spin.Start()
+
+ if logdir != "" {
+ logger.Write(sanitizedURL, logdir,
+ fmt.Sprintf("CORS: %s - origin [%s] reflected as [%s] creds=%t\n",
+ finding.Note, origin, finding.AllowOrigin, finding.AllowCredentials))
+ }
+ }
+ }()
+ }
+ wg.Wait()
+
+ spin.Stop()
+
+ if len(result.Findings) == 0 {
+ log.Info("no cors misconfigurations detected")
+ log.Complete(0, "found")
+ return nil, nil //nolint:nilnil // no finding is not an error, mirrors the other scanners
+ }
+
+ log.Complete(len(result.Findings), "found")
+ return result, nil
+}
+
+// probeCORS sends one request with the crafted Origin and decides whether the
+// response trusts it. It returns the finding and true only when the server
+// reflects the origin (or "null"/"*" with credentials), which is the exploitable
+// shape - a server that ignores Origin or returns its own host is fine.
+func probeCORS(client *http.Client, targetURL, origin, note string) (CORSFinding, bool) {
+ req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, targetURL, http.NoBody)
+ if err != nil {
+ charmlog.Debugf("cors: build request for %s: %v", targetURL, err)
+ return CORSFinding{}, false
+ }
+ req.Header.Set("Origin", origin)
+
+ resp, err := client.Do(req)
+ 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()
+
+ allowOrigin := resp.Header.Get("Access-Control-Allow-Origin")
+ if allowOrigin == "" {
+ return CORSFinding{}, false
+ }
+
+ allowCreds := strings.EqualFold(resp.Header.Get("Access-Control-Allow-Credentials"), "true")
+
+ // a wildcard with credentials is forbidden by browsers, so it isn't directly
+ // exploitable; a plain wildcard exposes only public data. neither is a finding.
+ if allowOrigin == "*" {
+ return CORSFinding{}, false
+ }
+
+ // the bug is reflection: the server echoed our attacker origin back. if it
+ // returned something else (its own host) it isn't trusting us.
+ reflected := allowOrigin == origin
+
+ if !reflected {
+ return CORSFinding{}, false
+ }
+
+ return CORSFinding{
+ URL: targetURL,
+ OriginTested: origin,
+ AllowOrigin: allowOrigin,
+ AllowCredentials: allowCreds,
+ Severity: corsSeverity(allowCreds),
+ Note: note,
+ }, true
+}
+
+// corsSeverity ranks the finding: reflection + credentials lets an attacker read
+// authenticated responses, which is the high-impact case.
+func corsSeverity(allowCreds bool) string {
+ if allowCreds {
+ return "high"
+ }
+ return "medium"
+}
+
+func renderCORSSeverity(severity string) string {
+ if severity == "high" {
+ return output.SeverityHigh.Render(severity)
+ }
+ return output.SeverityMedium.Render(severity)
+}
+
+// ResultType identifies cors findings for the result registry.
+func (r *CORSResult) ResultType() string { return "cors" }
+
+var _ ScanResult = (*CORSResult)(nil)
diff --git a/internal/scan/cors_test.go b/internal/scan/cors_test.go
new file mode 100644
index 0000000..ac5afce
--- /dev/null
+++ b/internal/scan/cors_test.go
@@ -0,0 +1,140 @@
+/*
+·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
+: :
+: █▀ █ █▀▀ · 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"
+)
+
+// reflectingCORS echoes the Origin into Access-Control-Allow-Origin and sets
+// credentials, the exploitable misconfiguration.
+func reflectingCORS() *httptest.Server {
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if origin := r.Header.Get("Origin"); origin != "" {
+ w.Header().Set("Access-Control-Allow-Origin", origin)
+ w.Header().Set("Access-Control-Allow-Credentials", "true")
+ }
+ w.WriteHeader(http.StatusOK)
+ }))
+}
+
+func TestCORS_ReflectsArbitraryOrigin(t *testing.T) {
+ srv := reflectingCORS()
+ defer srv.Close()
+
+ result, err := CORS(srv.URL, 5*time.Second, 3, "")
+ if err != nil {
+ t.Fatalf("CORS: %v", err)
+ }
+ if result == nil || len(result.Findings) == 0 {
+ t.Fatalf("expected cors findings on reflecting server, got %+v", result)
+ }
+
+ // the reflecting server echoes every crafted origin with credentials,
+ // so each finding should be high severity.
+ var sawEvil bool
+ for _, f := range result.Findings {
+ if f.OriginTested == corsEvilOrigin {
+ sawEvil = true
+ if !f.AllowCredentials {
+ t.Errorf("expected credentials flagged for evil origin, got %+v", f)
+ }
+ if f.Severity != "high" {
+ t.Errorf("expected high severity for reflection+creds, got %s", f.Severity)
+ }
+ }
+ }
+ if !sawEvil {
+ t.Errorf("expected the sentinel evil origin to be reflected, got %+v", result.Findings)
+ }
+}
+
+func TestCORS_SeverityWithoutCredentials(t *testing.T) {
+ // reflects the origin but never grants credentials - medium, not high.
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if origin := r.Header.Get("Origin"); origin != "" {
+ w.Header().Set("Access-Control-Allow-Origin", origin)
+ }
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer srv.Close()
+
+ result, err := CORS(srv.URL, 5*time.Second, 3, "")
+ if err != nil {
+ t.Fatalf("CORS: %v", err)
+ }
+ if result == nil || len(result.Findings) == 0 {
+ t.Fatalf("expected reflection findings, got %+v", result)
+ }
+ for _, f := range result.Findings {
+ if f.AllowCredentials {
+ t.Errorf("did not expect credentials, got %+v", f)
+ }
+ if f.Severity != "medium" {
+ t.Errorf("expected medium severity without creds, got %s", f.Severity)
+ }
+ }
+}
+
+func TestCORS_NoFalsePositiveOnSafeServer(t *testing.T) {
+ tests := []struct {
+ name string
+ handler http.HandlerFunc
+ }{
+ {
+ name: "ignores origin entirely",
+ handler: func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ },
+ },
+ {
+ name: "returns its own fixed origin",
+ handler: func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Access-Control-Allow-Origin", "https://trusted.example.com")
+ w.WriteHeader(http.StatusOK)
+ },
+ },
+ {
+ name: "plain wildcard, no credentials",
+ handler: func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ w.WriteHeader(http.StatusOK)
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ srv := httptest.NewServer(tt.handler)
+ defer srv.Close()
+
+ result, err := CORS(srv.URL, 5*time.Second, 3, "")
+ if err != nil {
+ t.Fatalf("CORS: %v", err)
+ }
+ if result != nil && len(result.Findings) > 0 {
+ t.Errorf("expected no findings on safe server, got %+v", result.Findings)
+ }
+ })
+ }
+}
+
+func TestCORSResult_ResultType(t *testing.T) {
+ r := &CORSResult{}
+ if r.ResultType() != "cors" {
+ t.Errorf("expected result type 'cors', got %q", r.ResultType())
+ }
+}
diff --git a/internal/scan/integration_test.go b/internal/scan/integration_test.go
index 7894a7f..9e0072f 100644
--- a/internal/scan/integration_test.go
+++ b/internal/scan/integration_test.go
@@ -65,6 +65,32 @@ func newVulnApp() *httptest.Server {
w.Write([]byte("
phpMyAdmin"))
})
+ // reflecting-origin endpoint for the cors probe
+ mux.HandleFunc("/cors", func(w http.ResponseWriter, r *http.Request) {
+ if origin := r.Header.Get("Origin"); origin != "" {
+ w.Header().Set("Access-Control-Allow-Origin", origin)
+ w.Header().Set("Access-Control-Allow-Credentials", "true")
+ }
+ w.WriteHeader(http.StatusOK)
+ })
+
+ // open-redirect endpoint: echoes the next param into Location
+ mux.HandleFunc("/redirect", func(w http.ResponseWriter, r *http.Request) {
+ if next := r.URL.Query().Get("next"); next != "" {
+ w.Header().Set("Location", next)
+ w.WriteHeader(http.StatusFound)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ })
+
+ // reflecting endpoint for the xss probe: echoes q raw into html text
+ mux.HandleFunc("/xss", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html")
+ //nolint:gosec // deliberate reflected-xss fixture for the probe under test
+ w.Write([]byte("" + r.URL.Query().Get("q") + "
"))
+ })
+
// homepage doubles as the cms fingerprint and the lfi sink
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
@@ -180,6 +206,45 @@ func TestIntegrationLFI(t *testing.T) {
}
}
+func TestIntegrationCORS(t *testing.T) {
+ srv := newVulnApp()
+ defer srv.Close()
+
+ result, err := CORS(srv.URL+"/cors", 5*time.Second, 3, "")
+ if err != nil {
+ t.Fatalf("CORS: %v", err)
+ }
+ if result == nil || len(result.Findings) == 0 {
+ t.Fatalf("expected a cors finding from the reflecting endpoint, got %+v", result)
+ }
+}
+
+func TestIntegrationRedirect(t *testing.T) {
+ srv := newVulnApp()
+ defer srv.Close()
+
+ result, err := Redirect(srv.URL+"/redirect", 5*time.Second, 4, "")
+ if err != nil {
+ t.Fatalf("Redirect: %v", err)
+ }
+ if result == nil || len(result.Findings) == 0 {
+ t.Fatalf("expected an open-redirect finding from the next sink, got %+v", result)
+ }
+}
+
+func TestIntegrationXSS(t *testing.T) {
+ srv := newVulnApp()
+ defer srv.Close()
+
+ result, err := XSS(srv.URL+"/xss", 5*time.Second, 4, "")
+ if err != nil {
+ t.Fatalf("XSS: %v", err)
+ }
+ if result == nil || len(result.Findings) == 0 {
+ t.Fatalf("expected a reflected-xss finding from the q sink, got %+v", result)
+ }
+}
+
func TestIntegrationPorts(t *testing.T) {
// a real listener stands in for an open port; a tiny server hands its number
// to Ports via the commonPorts wordlist.
diff --git a/internal/scan/redirect.go b/internal/scan/redirect.go
new file mode 100644
index 0000000..7597c31
--- /dev/null
+++ b/internal/scan/redirect.go
@@ -0,0 +1,305 @@
+/*
+·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
+: :
+: █▀ █ █▀▀ · Blazing-fast pentesting suite :
+: ▄█ █ █▀ · BSD 3-Clause License :
+: :
+: (c) 2022-2026 vmfunc, xyzeva, :
+: lunchcat alumni & contributors :
+: :
+·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
+*/
+
+package scan
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "regexp"
+ "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"
+)
+
+// RedirectResult collects every open-redirect found on the target.
+type RedirectResult struct {
+ Findings []RedirectFinding `json:"findings,omitempty"`
+ TestedParams int `json:"tested_params"`
+}
+
+// RedirectFinding is a single param/payload that sends the user off-site.
+type RedirectFinding struct {
+ URL string `json:"url"`
+ Parameter string `json:"parameter"`
+ Payload string `json:"payload"`
+ Location string `json:"location"`
+ Via string `json:"via"` // header, meta-refresh, or javascript
+ Severity string `json:"severity"`
+}
+
+// redirectMaxBody caps the body we scan for meta/js redirects (100KB).
+const redirectMaxBody = 1024 * 100
+
+// the controlled sentinel host we steer redirects toward; a Location that lands
+// on it proves the param is attacker-controlled.
+const redirectSentinel = "sif-redirect-probe.evil.com"
+
+// params that commonly drive a server-side redirect.
+var redirectParams = []string{
+ "url", "next", "redirect", "redirect_uri", "redirect_url",
+ "return", "return_url", "returnurl", "returnto", "return_to",
+ "dest", "destination", "continue", "goto", "go", "target",
+ "to", "out", "view", "image_url", "checkout_url", "rurl", "u",
+}
+
+// payload variants: a plain sentinel plus filter bypasses that browsers still
+// resolve as an absolute off-site target. {host} expands to the sentinel.
+var redirectPayloads = []string{
+ "https://{host}", // plain absolute
+ "//{host}", // scheme-relative
+ "https:/{host}", // missing slash, browsers normalise it
+ "https:{host}", // no slashes
+ "/\\{host}", // backslash trick
+ "/%2f%2f{host}", // encoded scheme-relative
+ "https://{host}%00.x.com", // null-byte truncation
+ "https://x.com@{host}", // userinfo confusion - real host is after @
+}
+
+// meta refresh redirect:
+var metaRefreshRe = regexp.MustCompile(`(?i)]+http-equiv=["']?refresh["']?[^>]+content=["'][^"']*url=([^"'>\s]+)`)
+
+// client-side redirects baked into a script body
+var jsRedirectRe = regexp.MustCompile(`(?i)(?:location\.(?:href|replace|assign)\s*(?:=|\()|window\.location\s*=)\s*["']([^"']+)["']`)
+
+// Redirect probes the target's redirect-prone params for open-redirect.
+func Redirect(targetURL string, timeout time.Duration, threads int, logdir string) (*RedirectResult, error) {
+ log := output.Module("REDIRECT")
+ log.Start()
+
+ spin := output.NewSpinner("Scanning for open redirects")
+ spin.Start()
+
+ sanitizedURL := stripScheme(targetURL)
+
+ if logdir != "" {
+ if err := logger.WriteHeader(sanitizedURL, logdir, "open redirect probe"); err != nil {
+ spin.Stop()
+ log.Error("error creating log file: %v", err)
+ return nil, fmt.Errorf("create redirect log: %w", err)
+ }
+ }
+
+ parsedURL, err := url.Parse(targetURL)
+ if err != nil {
+ spin.Stop()
+ return nil, fmt.Errorf("parse url: %w", err)
+ }
+ existingParams := parsedURL.Query()
+
+ // merge target's own params with the common redirect names so we cover both
+ paramsToTest := make(map[string]bool, len(existingParams)+len(redirectParams))
+ for param := range existingParams {
+ paramsToTest[param] = true
+ }
+ for _, param := range redirectParams {
+ paramsToTest[param] = true
+ }
+
+ // don't auto-follow: a 30x Location is exactly what we want to inspect.
+ client := httpx.Client(timeout)
+ client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
+ return http.ErrUseLastResponse
+ }
+
+ result := &RedirectResult{
+ Findings: make([]RedirectFinding, 0, 8),
+ TestedParams: len(paramsToTest),
+ }
+
+ type workItem struct {
+ param string
+ payload string
+ }
+ workItems := make([]workItem, 0, len(paramsToTest)*len(redirectPayloads))
+ for param := range paramsToTest {
+ for _, raw := range redirectPayloads {
+ workItems = append(workItems, workItem{param: param, payload: strings.ReplaceAll(raw, "{host}", redirectSentinel)})
+ }
+ }
+
+ log.Info("testing %d params with %d payloads", len(paramsToTest), len(redirectPayloads))
+
+ workChan := make(chan workItem, len(workItems))
+ for _, item := range workItems {
+ workChan <- item
+ }
+ close(workChan)
+
+ seen := make(map[string]bool)
+ var mu sync.Mutex
+ var wg sync.WaitGroup
+
+ wg.Add(threads)
+ for t := 0; t < threads; t++ {
+ go func() {
+ defer wg.Done()
+ for item := range workChan {
+ testURL := buildRedirectURL(parsedURL, existingParams, item.param, item.payload)
+
+ location, via, ok := probeRedirect(client, testURL)
+ if !ok {
+ continue
+ }
+
+ key := item.param + "|" + item.payload
+ mu.Lock()
+ if seen[key] {
+ mu.Unlock()
+ continue
+ }
+ seen[key] = true
+ finding := RedirectFinding{
+ URL: testURL,
+ Parameter: item.param,
+ Payload: item.payload,
+ Location: location,
+ Via: via,
+ Severity: "medium",
+ }
+ result.Findings = append(result.Findings, finding)
+ mu.Unlock()
+
+ spin.Stop()
+ log.Warn("open redirect via %s in param %s -> %s",
+ output.SeverityMedium.Render(via),
+ output.Highlight.Render(item.param),
+ output.Status.Render(location))
+ spin.Start()
+
+ if logdir != "" {
+ logger.Write(sanitizedURL, logdir,
+ fmt.Sprintf("open redirect: param [%s] via %s -> [%s] (payload %s)\n",
+ item.param, via, location, item.payload))
+ }
+ }
+ }()
+ }
+ wg.Wait()
+
+ spin.Stop()
+
+ if len(result.Findings) == 0 {
+ log.Info("no open redirects detected")
+ log.Complete(0, "found")
+ return nil, nil //nolint:nilnil // no finding is not an error, mirrors the other scanners
+ }
+
+ log.Complete(len(result.Findings), "found")
+ return result, nil
+}
+
+// buildRedirectURL rebuilds the target with the payload injected into one param,
+// preserving the rest of the original query.
+func buildRedirectURL(parsedURL *url.URL, existing url.Values, param, payload string) string {
+ testParams := url.Values{}
+ for k, v := range existing {
+ if k != param {
+ testParams[k] = v
+ }
+ }
+ testParams.Set(param, payload)
+ return fmt.Sprintf("%s://%s%s?%s", parsedURL.Scheme, parsedURL.Host, parsedURL.Path, testParams.Encode())
+}
+
+// probeRedirect requests testURL and reports the first off-site redirect it
+// finds, whether that's a 30x Location header, a meta-refresh, or a js
+// location assignment. via names the channel; ok is false when nothing points
+// at the sentinel.
+func probeRedirect(client *http.Client, testURL string) (location, via string, ok bool) {
+ req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, testURL, http.NoBody)
+ if err != nil {
+ charmlog.Debugf("redirect: build request for %s: %v", testURL, err)
+ return "", "", false
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ charmlog.Debugf("redirect: request %s: %v", testURL, err)
+ return "", "", false
+ }
+ defer resp.Body.Close()
+
+ // header redirect: a 30x whose Location resolves to the sentinel host
+ if resp.StatusCode >= http.StatusMultipleChoices && resp.StatusCode < http.StatusBadRequest {
+ if loc := resp.Header.Get("Location"); pointsAtSentinel(loc) {
+ return loc, "header", true
+ }
+ }
+
+ // body redirects: meta refresh or a client-side location assignment
+ body, err := io.ReadAll(io.LimitReader(resp.Body, redirectMaxBody))
+ if err != nil {
+ return "", "", false
+ }
+ bodyStr := string(body)
+
+ if m := metaRefreshRe.FindStringSubmatch(bodyStr); len(m) > 1 && pointsAtSentinel(m[1]) {
+ return m[1], "meta-refresh", true
+ }
+ if m := jsRedirectRe.FindStringSubmatch(bodyStr); len(m) > 1 && pointsAtSentinel(m[1]) {
+ return m[1], "javascript", true
+ }
+
+ return "", "", false
+}
+
+// pointsAtSentinel reports whether a redirect target lands on our controlled
+// host. We resolve the value the way a browser would so scheme-relative ("//x")
+// and backslash tricks are caught, then compare hostnames - a sentinel that only
+// shows up in a path or query (still same-origin) is not a redirect off-site.
+func pointsAtSentinel(location string) bool {
+ if location == "" {
+ return false
+ }
+
+ // browsers treat backslashes in the authority as forward slashes
+ normalized := strings.ReplaceAll(location, "\\", "/")
+
+ parsed, err := url.Parse(normalized)
+ if err != nil {
+ // unparseable but still naming the sentinel as the leading authority is a hit
+ return strings.HasPrefix(strings.TrimLeft(normalized, "/:"), redirectSentinel)
+ }
+
+ // the resolved host is what the navigation actually targets
+ if strings.EqualFold(parsed.Hostname(), redirectSentinel) {
+ return true
+ }
+
+ // scheme-relative "//host" parses with an empty scheme but a populated host
+ if parsed.Host != "" && strings.EqualFold(stripPort(parsed.Host), redirectSentinel) {
+ return true
+ }
+
+ return false
+}
+
+// stripPort drops a trailing :port so host comparisons ignore it.
+func stripPort(host string) string {
+ if h, _, ok := strings.Cut(host, ":"); ok {
+ return h
+ }
+ return host
+}
+
+// ResultType identifies open-redirect findings for the result registry.
+func (r *RedirectResult) ResultType() string { return "redirect" }
+
+var _ ScanResult = (*RedirectResult)(nil)
diff --git a/internal/scan/redirect_test.go b/internal/scan/redirect_test.go
new file mode 100644
index 0000000..ddb83c5
--- /dev/null
+++ b/internal/scan/redirect_test.go
@@ -0,0 +1,163 @@
+/*
+·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
+: :
+: █▀ █ █▀▀ · 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"
+)
+
+func TestRedirect_HeaderLocation(t *testing.T) {
+ // echoes the "next" param straight into Location, the textbook open redirect.
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if next := r.URL.Query().Get("next"); next != "" {
+ w.Header().Set("Location", next)
+ w.WriteHeader(http.StatusFound)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer srv.Close()
+
+ result, err := Redirect(srv.URL, 5*time.Second, 4, "")
+ if err != nil {
+ t.Fatalf("Redirect: %v", err)
+ }
+ if result == nil || len(result.Findings) == 0 {
+ t.Fatalf("expected open redirect findings, got %+v", result)
+ }
+
+ var sawHeader bool
+ for _, f := range result.Findings {
+ if f.Parameter == "next" && f.Via == "header" {
+ sawHeader = true
+ }
+ }
+ if !sawHeader {
+ t.Errorf("expected a header redirect via 'next', got %+v", result.Findings)
+ }
+}
+
+func TestRedirect_MetaRefresh(t *testing.T) {
+ // body-based redirect: a meta refresh pointing at the injected url.
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ dest := r.URL.Query().Get("url")
+ w.Header().Set("Content-Type", "text/html")
+ w.WriteHeader(http.StatusOK)
+ if dest != "" {
+ //nolint:gosec // deliberate open-redirect fixture for the probe under test
+ w.Write([]byte(``))
+ return
+ }
+ w.Write([]byte("home"))
+ }))
+ defer srv.Close()
+
+ result, err := Redirect(srv.URL, 5*time.Second, 4, "")
+ if err != nil {
+ t.Fatalf("Redirect: %v", err)
+ }
+ if result == nil {
+ t.Fatalf("expected meta-refresh findings, got nil")
+ }
+ var sawMeta bool
+ for _, f := range result.Findings {
+ if f.Via == "meta-refresh" {
+ sawMeta = true
+ }
+ }
+ if !sawMeta {
+ t.Errorf("expected a meta-refresh redirect finding, got %+v", result.Findings)
+ }
+}
+
+func TestRedirect_NoFalsePositive(t *testing.T) {
+ tests := []struct {
+ name string
+ handler http.HandlerFunc
+ }{
+ {
+ name: "never redirects",
+ handler: func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("home"))
+ },
+ },
+ {
+ name: "only redirects to a fixed safe path",
+ handler: func(w http.ResponseWriter, _ *http.Request) {
+ // ignores the param, always sends users to its own login page.
+ w.Header().Set("Location", "/login")
+ w.WriteHeader(http.StatusFound)
+ },
+ },
+ {
+ name: "reflects param into body but not as a redirect",
+ handler: func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ // the value lands in plain text, no meta/js redirect mechanism.
+ //nolint:gosec // intentional reflection fixture; asserts no false positive
+ w.Write([]byte("you searched for " + r.URL.Query().Get("next") + "
"))
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ srv := httptest.NewServer(tt.handler)
+ defer srv.Close()
+
+ result, err := Redirect(srv.URL, 5*time.Second, 4, "")
+ if err != nil {
+ t.Fatalf("Redirect: %v", err)
+ }
+ if result != nil && len(result.Findings) > 0 {
+ t.Errorf("expected no findings, got %+v", result.Findings)
+ }
+ })
+ }
+}
+
+func TestPointsAtSentinel(t *testing.T) {
+ tests := []struct {
+ name string
+ location string
+ want bool
+ }{
+ {"absolute https", "https://" + redirectSentinel + "/path", true},
+ {"scheme-relative", "//" + redirectSentinel, true},
+ {"backslash trick", "/\\" + redirectSentinel, true},
+ {"with port", "https://" + redirectSentinel + ":443/", true},
+ {"empty", "", false},
+ {"same-site path", "/dashboard", false},
+ {"sentinel only in path", "https://safe.example.com/" + redirectSentinel, false},
+ {"sentinel only in query", "https://safe.example.com/?to=" + redirectSentinel, false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := pointsAtSentinel(tt.location); got != tt.want {
+ t.Errorf("pointsAtSentinel(%q) = %v, want %v", tt.location, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestRedirectResult_ResultType(t *testing.T) {
+ r := &RedirectResult{}
+ if r.ResultType() != "redirect" {
+ t.Errorf("expected result type 'redirect', got %q", r.ResultType())
+ }
+}
diff --git a/internal/scan/xss.go b/internal/scan/xss.go
new file mode 100644
index 0000000..8ccb98d
--- /dev/null
+++ b/internal/scan/xss.go
@@ -0,0 +1,342 @@
+/*
+·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
+: :
+: █▀ █ █▀▀ · Blazing-fast pentesting suite :
+: ▄█ █ █▀ · BSD 3-Clause License :
+: :
+: (c) 2022-2026 vmfunc, xyzeva, :
+: lunchcat alumni & contributors :
+: :
+·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
+*/
+
+package scan
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "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"
+)
+
+// XSSResult collects every likely reflected-xss point on the target.
+type XSSResult struct {
+ Findings []XSSFinding `json:"findings,omitempty"`
+ TestedParams int `json:"tested_params"`
+}
+
+// XSSFinding is a reflection where one or more breaking chars survived
+// unescaped in a context that makes injection plausible.
+type XSSFinding struct {
+ URL string `json:"url"`
+ Parameter string `json:"parameter"`
+ Context string `json:"context"` // html, attribute, or script
+ SurvivedRaw []string `json:"survived_raw"` // breaking chars echoed unescaped
+ Severity string `json:"severity"`
+}
+
+// xssMaxBody caps the body we scan for the canary (100KB).
+const xssMaxBody = 1024 * 100
+
+// canaryToken is a unique, alnum-only marker we can grep for unambiguously; it
+// survives every output encoder so a missing reflection means no echo at all.
+const canaryToken = "sifxss9173canary" //nolint:gosec // not a credential, just a reflection marker
+
+// the chars that let an attacker break out of a context; we inject the canary
+// wrapped in each and check which come back raw.
+var xssBreakChars = []string{"<", ">", "\"", "'", "`"}
+
+// params we test when the target carries none of its own.
+var xssParams = []string{
+ "q", "s", "search", "query", "id", "name", "page",
+ "keyword", "lang", "redirect", "url", "return", "ref",
+ "message", "msg", "error", "title", "text", "comment",
+}
+
+// XSS probes the target's params for reflected cross-site scripting.
+func XSS(targetURL string, timeout time.Duration, threads int, logdir string) (*XSSResult, error) {
+ log := output.Module("XSS")
+ log.Start()
+
+ spin := output.NewSpinner("Scanning for reflected XSS")
+ spin.Start()
+
+ sanitizedURL := stripScheme(targetURL)
+
+ if logdir != "" {
+ if err := logger.WriteHeader(sanitizedURL, logdir, "reflected XSS probe"); err != nil {
+ spin.Stop()
+ log.Error("error creating log file: %v", err)
+ return nil, fmt.Errorf("create xss log: %w", err)
+ }
+ }
+
+ parsedURL, err := url.Parse(targetURL)
+ if err != nil {
+ spin.Stop()
+ return nil, fmt.Errorf("parse url: %w", err)
+ }
+ existingParams := parsedURL.Query()
+
+ paramsToTest := make(map[string]bool, len(existingParams)+len(xssParams))
+ for param := range existingParams {
+ paramsToTest[param] = true
+ }
+ for _, param := range xssParams {
+ paramsToTest[param] = true
+ }
+
+ client := httpx.Client(timeout)
+ client.CheckRedirect = func(_ *http.Request, via []*http.Request) error {
+ if len(via) >= corsMaxRedirects {
+ return http.ErrUseLastResponse
+ }
+ return nil
+ }
+
+ result := &XSSResult{
+ Findings: make([]XSSFinding, 0, 8),
+ TestedParams: len(paramsToTest),
+ }
+
+ params := make([]string, 0, len(paramsToTest))
+ for param := range paramsToTest {
+ params = append(params, param)
+ }
+
+ log.Info("testing %d params with reflection canary", len(paramsToTest))
+
+ paramChan := make(chan string, len(params))
+ for _, param := range params {
+ paramChan <- param
+ }
+ close(paramChan)
+
+ seen := make(map[string]bool)
+ var mu sync.Mutex
+ var wg sync.WaitGroup
+
+ wg.Add(threads)
+ for t := 0; t < threads; t++ {
+ go func() {
+ defer wg.Done()
+ for param := range paramChan {
+ finding, ok := probeXSS(client, parsedURL, existingParams, param)
+ if !ok {
+ continue
+ }
+
+ mu.Lock()
+ if seen[param] {
+ mu.Unlock()
+ continue
+ }
+ seen[param] = true
+ result.Findings = append(result.Findings, finding)
+ mu.Unlock()
+
+ spin.Stop()
+ log.Warn("reflected xss in param %s (%s context, raw: %s)",
+ output.Highlight.Render(param),
+ output.SeverityHigh.Render(finding.Context),
+ strings.Join(finding.SurvivedRaw, ""))
+ spin.Start()
+
+ if logdir != "" {
+ logger.Write(sanitizedURL, logdir,
+ fmt.Sprintf("reflected XSS: param [%s] in %s context, unescaped chars [%s]\n",
+ param, finding.Context, strings.Join(finding.SurvivedRaw, "")))
+ }
+ }
+ }()
+ }
+ wg.Wait()
+
+ spin.Stop()
+
+ if len(result.Findings) == 0 {
+ log.Info("no reflected xss detected")
+ log.Complete(0, "found")
+ return nil, nil //nolint:nilnil // no finding is not an error, mirrors the other scanners
+ }
+
+ log.Complete(len(result.Findings), "found")
+ return result, nil
+}
+
+// probeXSS injects a canary wrapped in the breaking chars into one param, then
+// inspects the reflection: it classifies where the canary landed and which
+// breaking chars came back unescaped there. ok is false unless at least one
+// dangerous char survived in an exploitable context.
+func probeXSS(client *http.Client, parsedURL *url.URL, existing url.Values, param string) (XSSFinding, bool) {
+ // wrap the canary so a single request tells us both that it reflected and
+ // which surrounding chars survived: "canary' `canary`
+ payload := fmt.Sprintf("<%s>\"%s'`%s`", canaryToken, canaryToken, canaryToken)
+
+ testParams := url.Values{}
+ for k, v := range existing {
+ if k != param {
+ testParams[k] = v
+ }
+ }
+ testParams.Set(param, payload)
+ testURL := fmt.Sprintf("%s://%s%s?%s", parsedURL.Scheme, parsedURL.Host, parsedURL.Path, testParams.Encode())
+
+ req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, testURL, http.NoBody)
+ if err != nil {
+ charmlog.Debugf("xss: build request for %s: %v", testURL, err)
+ return XSSFinding{}, false
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ charmlog.Debugf("xss: request %s: %v", testURL, err)
+ return XSSFinding{}, false
+ }
+ body, err := io.ReadAll(io.LimitReader(resp.Body, xssMaxBody))
+ resp.Body.Close()
+ if err != nil {
+ return XSSFinding{}, false
+ }
+ bodyStr := string(body)
+
+ // no echo of the canary at all means the param isn't reflected; bail early.
+ if !strings.Contains(bodyStr, canaryToken) {
+ return XSSFinding{}, false
+ }
+
+ reflectCtx := classifyXSSContext(bodyStr)
+ survived := survivingBreakChars(bodyStr)
+
+ // a reflection that escaped every dangerous char can't break out, so it's not
+ // reported - only raw chars that matter in the detected context count.
+ survived = relevantForContext(reflectCtx, survived)
+ if len(survived) == 0 {
+ return XSSFinding{}, false
+ }
+
+ return XSSFinding{
+ URL: testURL,
+ Parameter: param,
+ Context: reflectCtx,
+ SurvivedRaw: survived,
+ Severity: "high",
+ }, true
+}
+
+// classifyXSSContext guesses where the canary was reflected. We look at the
+// markup immediately around the token: a live tag means html text, a
+// reflection inside a is a script context
+ for {
+ open := strings.Index(body, ""):]
+ }
+
+ // default: echoed inside an html attribute value
+ return "attribute"
+}
+
+// survivingBreakChars reports which dangerous chars came back next to the canary
+// unescaped. We only trust occurrences adjacent to the token so unrelated chars
+// elsewhere on the page don't create false positives.
+func survivingBreakChars(body string) []string {
+ survived := make([]string, 0, len(xssBreakChars))
+ markers := []string{
+ "<" + canaryToken, // leading < survived
+ canaryToken + ">", // trailing > survived
+ "\"" + canaryToken, // leading " survived
+ canaryToken + "'", // trailing ' survived
+ "`" + canaryToken, // backtick wrap survived (token + ` and ` + token)
+ canaryToken + "`",
+ }
+ present := make(map[string]bool, len(xssBreakChars))
+ for i := 0; i < len(markers); i++ {
+ if !strings.Contains(body, markers[i]) {
+ continue
+ }
+ switch {
+ case strings.HasPrefix(markers[i], "<"):
+ present["<"] = true
+ case strings.HasSuffix(markers[i], ">"):
+ present[">"] = true
+ case strings.HasPrefix(markers[i], "\""):
+ present["\""] = true
+ case strings.HasSuffix(markers[i], "'"):
+ present["'"] = true
+ default:
+ present["`"] = true
+ }
+ }
+
+ // keep the canonical order for stable output
+ for i := 0; i < len(xssBreakChars); i++ {
+ if present[xssBreakChars[i]] {
+ survived = append(survived, xssBreakChars[i])
+ }
+ }
+ return survived
+}
+
+// relevantForContext filters surviving chars to the ones that actually enable a
+// breakout in the detected context: angle brackets matter in html, quotes and
+// backticks matter inside attributes/scripts.
+func relevantForContext(reflectCtx string, survived []string) []string {
+ wanted := make(map[string]bool, len(survived))
+ switch reflectCtx {
+ case "html":
+ wanted["<"] = true
+ wanted[">"] = true
+ case "attribute":
+ // breaking out of an attribute value needs the quote that delimits it; a
+ // bare backtick isn't a delimiter in html, so it doesn't count here.
+ wanted["\""] = true
+ wanted["'"] = true
+ case "script":
+ // a quote, backtick, or angle bracket all let you close/escape the script
+ wanted["\""] = true
+ wanted["'"] = true
+ wanted["`"] = true
+ wanted["<"] = true
+ wanted[">"] = true
+ }
+
+ filtered := make([]string, 0, len(survived))
+ for i := 0; i < len(survived); i++ {
+ if wanted[survived[i]] {
+ filtered = append(filtered, survived[i])
+ }
+ }
+ return filtered
+}
+
+// ResultType identifies reflected-xss findings for the result registry.
+func (r *XSSResult) ResultType() string { return "xss" }
+
+var _ ScanResult = (*XSSResult)(nil)
diff --git a/internal/scan/xss_test.go b/internal/scan/xss_test.go
new file mode 100644
index 0000000..66ade5a
--- /dev/null
+++ b/internal/scan/xss_test.go
@@ -0,0 +1,153 @@
+/*
+·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
+: :
+: █▀ █ █▀▀ · Blazing-fast pentesting suite :
+: ▄█ █ █▀ · BSD 3-Clause License :
+: :
+: (c) 2022-2026 vmfunc, xyzeva, :
+: lunchcat alumni & contributors :
+: :
+·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
+*/
+
+package scan
+
+import (
+ "html"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+)
+
+// reflectsRaw echoes the named param straight into html text, so the breaking
+// chars survive unescaped - a reflected xss sink.
+func reflectsRaw(param string) *httptest.Server {
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ v := r.URL.Query().Get(param)
+ w.Header().Set("Content-Type", "text/html")
+ w.WriteHeader(http.StatusOK)
+ //nolint:gosec // deliberate reflected-xss fixture for the probe under test
+ w.Write([]byte("" + v + "
"))
+ }))
+}
+
+func TestXSS_DetectsRawHTMLReflection(t *testing.T) {
+ srv := reflectsRaw("q")
+ defer srv.Close()
+
+ result, err := XSS(srv.URL, 5*time.Second, 4, "")
+ if err != nil {
+ t.Fatalf("XSS: %v", err)
+ }
+ if result == nil || len(result.Findings) == 0 {
+ t.Fatalf("expected reflected xss findings, got %+v", result)
+ }
+
+ var found *XSSFinding
+ for i := range result.Findings {
+ if result.Findings[i].Parameter == "q" {
+ found = &result.Findings[i]
+ }
+ }
+ if found == nil {
+ t.Fatalf("expected a finding on param 'q', got %+v", result.Findings)
+ }
+ if found.Context != "html" {
+ t.Errorf("expected html context, got %s", found.Context)
+ }
+ if len(found.SurvivedRaw) == 0 {
+ t.Errorf("expected surviving breaking chars, got none")
+ }
+}
+
+func TestXSS_NoFalsePositiveWhenEscaped(t *testing.T) {
+ // the server html-escapes the reflection, so no breaking char survives raw.
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ v := r.URL.Query().Get("q")
+ w.Header().Set("Content-Type", "text/html")
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("" + html.EscapeString(v) + "
"))
+ }))
+ defer srv.Close()
+
+ result, err := XSS(srv.URL, 5*time.Second, 4, "")
+ if err != nil {
+ t.Fatalf("XSS: %v", err)
+ }
+ if result != nil && len(result.Findings) > 0 {
+ t.Errorf("expected no findings when reflection is escaped, got %+v", result.Findings)
+ }
+}
+
+func TestXSS_NoFalsePositiveWhenNotReflected(t *testing.T) {
+ // never echoes the input back, so nothing is injectable.
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "text/html")
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("static page"))
+ }))
+ defer srv.Close()
+
+ result, err := XSS(srv.URL, 5*time.Second, 4, "")
+ if err != nil {
+ t.Fatalf("XSS: %v", err)
+ }
+ if result != nil && len(result.Findings) > 0 {
+ t.Errorf("expected no findings on static page, got %+v", result.Findings)
+ }
+}
+
+func TestClassifyXSSContext(t *testing.T) {
+ tests := []struct {
+ name string
+ body string
+ want string
+ }{
+ {
+ name: "live html tag",
+ body: "<" + canaryToken + ">
",
+ want: "html",
+ },
+ {
+ name: "inside script block",
+ body: "",
+ want: "script",
+ },
+ {
+ name: "attribute value",
+ body: ``,
+ want: "attribute",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := classifyXSSContext(tt.body); got != tt.want {
+ t.Errorf("classifyXSSContext() = %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestSurvivingBreakChars(t *testing.T) {
+ // the canary is wrapped exactly as the probe injects it; all five chars survive.
+ body := "<" + canaryToken + ">\"" + canaryToken + "'`" + canaryToken + "`"
+ got := survivingBreakChars(body)
+ want := map[string]bool{"<": true, ">": true, "\"": true, "'": true, "`": true}
+ if len(got) != len(want) {
+ t.Fatalf("expected %d surviving chars, got %v", len(want), got)
+ }
+ for _, c := range got {
+ if !want[c] {
+ t.Errorf("unexpected surviving char %q", c)
+ }
+ }
+}
+
+func TestXSSResult_ResultType(t *testing.T) {
+ r := &XSSResult{}
+ if r.ResultType() != "xss" {
+ t.Errorf("expected result type 'xss', got %q", r.ResultType())
+ }
+}
diff --git a/man/sif.1 b/man/sif.1
index 968430e..4646086 100644
--- a/man/sif.1
+++ b/man/sif.1
@@ -86,6 +86,15 @@ sql reconnaissance (admin panels, error disclosure).
.B \-lfi
local file inclusion reconnaissance.
.TP
+.B \-cors
+cors misconfiguration probe (reflected/permissive origins).
+.TP
+.B \-redirect
+open redirect probe.
+.TP
+.B \-xss
+reflected xss probe.
+.TP
.B \-framework
framework detection with cve lookup.
.TP
diff --git a/sif.go b/sif.go
index cee06fd..e1c6b09 100644
--- a/sif.go
+++ b/sif.go
@@ -391,6 +391,36 @@ func (app *App) Run() error {
}
}
+ if app.settings.CORS {
+ result, err := scan.CORS(url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir)
+ if err != nil {
+ log.Errorf("Error while running CORS probe: %s", err)
+ } else if result != nil {
+ moduleResults = append(moduleResults, NewModuleResult(result))
+ scansRun = append(scansRun, "CORS")
+ }
+ }
+
+ if app.settings.Redirect {
+ result, err := scan.Redirect(url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir)
+ if err != nil {
+ log.Errorf("Error while running open redirect probe: %s", err)
+ } else if result != nil {
+ moduleResults = append(moduleResults, NewModuleResult(result))
+ scansRun = append(scansRun, "Open Redirect")
+ }
+ }
+
+ if app.settings.XSS {
+ result, err := scan.XSS(url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir)
+ if err != nil {
+ log.Errorf("Error while running reflected XSS probe: %s", err)
+ } else if result != nil {
+ moduleResults = append(moduleResults, NewModuleResult(result))
+ scansRun = append(scansRun, "Reflected XSS")
+ }
+ }
+
// Load and run modules
if app.settings.AllModules || app.settings.Modules != "" || app.settings.ModuleTags != "" {
loader, err := modules.NewLoader()