diff --git a/cmd/args.go b/cmd/args.go index a70205f6..c94c30e5 100644 --- a/cmd/args.go +++ b/cmd/args.go @@ -6,6 +6,7 @@ type Args struct { Header []string // Custom HTTP headers to add to requests P []string // Parameters to test for XSS vulnerabilities IgnoreParams []string // Parameters to ignore during scanning + OutOfScope []string // Domains to exclude from scanning // String options Config string // Path to configuration file @@ -33,6 +34,7 @@ type Args struct { ReportFormat string // Report format (plain, json, markdown, md) HarFilePath string // Path to save HAR files CustomBlindXSSPayloadFile string // Path to custom blind XSS payload file + OutOfScopeFile string // File containing domains to exclude // Integer options Timeout int // Request timeout in seconds diff --git a/cmd/file.go b/cmd/file.go index cf6b0698..18586645 100644 --- a/cmd/file.go +++ b/cmd/file.go @@ -10,6 +10,7 @@ import ( "time" spinner "github.com/briandowns/spinner" + "github.com/hahwul/dalfox/v2/internal/optimization" "github.com/hahwul/dalfox/v2/internal/printing" model "github.com/hahwul/dalfox/v2/pkg/model" "github.com/hahwul/dalfox/v2/pkg/scanning" @@ -111,6 +112,10 @@ func runRawDataMode(filePath string, cmd *cobra.Command) { target = "https://" + host + path } } + if optimization.IsOutOfScope(options, target) { + printing.DalLog("INFO", "Target is out of scope, skipping", options) + return + } _, _ = scanning.Scan(target, options, "single") } @@ -144,6 +149,10 @@ func runHarMode(filePath string, _ *cobra.Command, sf bool) { options.Data = entry.Request.PostData.Text } options.Method = entry.Request.Method + if optimization.IsOutOfScope(options, turl) { + updateSpinner(options, sf, i, len(harObject.Log.Entries)) + continue + } _, _ = scanning.Scan(turl, options, strconv.Itoa(i)) updateSpinner(options, sf, i, len(harObject.Log.Entries)) } @@ -170,6 +179,9 @@ func runFileMode(filePath string, cmd *cobra.Command, sf bool) { return } targets := voltUtils.UniqueStringSlice(ff) + if len(options.OutOfScope) > 0 { + targets = optimization.FilterOutOfScopeTargets(options, targets) + } printing.DalLog("SYSTEM", "Loaded "+strconv.Itoa(len(targets))+" target URLs", options) multi, _ := cmd.Flags().GetBool("multicast") mass, _ := cmd.Flags().GetBool("mass") diff --git a/cmd/pipe.go b/cmd/pipe.go index 7b532d10..0d7a6581 100644 --- a/cmd/pipe.go +++ b/cmd/pipe.go @@ -12,6 +12,7 @@ import ( "time" "github.com/briandowns/spinner" + "github.com/hahwul/dalfox/v2/internal/optimization" "github.com/hahwul/dalfox/v2/internal/printing" "github.com/hahwul/dalfox/v2/internal/utils" model "github.com/hahwul/dalfox/v2/pkg/model" @@ -58,6 +59,9 @@ func runPipeCmd(cmd *cobra.Command, args []string) { targets = append(targets, target) } targets = voltUtils.UniqueStringSlice(targets) + if len(options.OutOfScope) > 0 { + targets = optimization.FilterOutOfScopeTargets(options, targets) + } printing.DalLog("SYSTEM", "Loaded "+strconv.Itoa(len(targets))+" target urls", options) multi, _ := cmd.Flags().GetBool("multicast") @@ -132,6 +136,10 @@ func runRawDataPipeMode(cmd *cobra.Command) { target = "https://" + host + path } } + if optimization.IsOutOfScope(options, target) { + printing.DalLog("INFO", "Target is out of scope, skipping", options) + return + } _, _ = scanning.Scan(target, options, "single") } diff --git a/cmd/root.go b/cmd/root.go index cb7b5cfd..f9f990c9 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "runtime" + "strconv" "strings" "text/template" "time" @@ -16,6 +17,7 @@ import ( "github.com/hahwul/dalfox/v2/internal/har" "github.com/hahwul/dalfox/v2/internal/printing" "github.com/hahwul/dalfox/v2/pkg/model" + voltFile "github.com/hahwul/volt/file" "github.com/logrusorgru/aurora" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -89,6 +91,8 @@ func init() { rootCmd.PersistentFlags().StringSliceVarP(&args.Header, "header", "H", []string{}, "Add custom headers to the request. Example: -H 'Authorization: Bearer '") rootCmd.PersistentFlags().StringSliceVarP(&args.P, "param", "p", []string{}, "Specify parameters to test. Example: -p 'username' -p 'password'") rootCmd.PersistentFlags().StringSliceVar(&args.IgnoreParams, "ignore-param", []string{}, "Ignore specific parameters during scanning. Example: --ignore-param 'api_token' --ignore-param 'csrf_token'") + rootCmd.PersistentFlags().StringSliceVar(&args.OutOfScope, "out-of-scope", []string{}, "Exclude domains from scanning. Supports wildcards. Example: --out-of-scope 'stg.example.com' --out-of-scope '*.dev.example.com'") + rootCmd.PersistentFlags().StringVar(&args.OutOfScopeFile, "out-of-scope-file", "", "Load out-of-scope domains from a file (one per line). Example: --out-of-scope-file 'exclusions.txt'") // String rootCmd.PersistentFlags().StringVar(&args.Config, "config", "", "Load configuration from a file. Example: --config 'config.json'") @@ -248,7 +252,7 @@ func initializeFlagGroups() { flagMap := map[string][]string{ "Input": {"config", "custom-payload", "custom-blind-xss-payload", "data", "grep", "remote-payloads", "remote-wordlists", "har-file-path"}, "Request": {"header", "cookie", "user-agent", "method", "cookie-from-raw"}, - "Scanning": {"param", "ignore-param", "blind", "timeout", "delay", "worker", "skip-headless", "deep-domxss", "waf-evasion", "skip-discovery", "force-headless-verification", "use-bav", "skip-bav", "skip-mining-dom", "skip-mining-dict", "skip-mining-all", "skip-xss-scanning", "only-custom-payload", "skip-grepping", "detailed-analysis", "fast-scan", "magic-char-test", "context-aware"}, + "Scanning": {"param", "ignore-param", "out-of-scope", "out-of-scope-file", "blind", "timeout", "delay", "worker", "skip-headless", "deep-domxss", "waf-evasion", "skip-discovery", "force-headless-verification", "use-bav", "skip-bav", "skip-mining-dom", "skip-mining-dict", "skip-mining-all", "skip-xss-scanning", "only-custom-payload", "skip-grepping", "detailed-analysis", "fast-scan", "magic-char-test", "context-aware"}, "Mining": {"mining-dict-word", "mining-dict", "mining-dom"}, "Output": {"output", "format", "only-poc", "report", "output-all", "output-request", "output-response", "poc-type", "report-format", "silence", "no-color", "no-spinner"}, "Advanced": {"custom-alert-value", "custom-alert-type", "found-action", "found-action-shell", "proxy", "ignore-return", "max-cpu", "only-discovery", "follow-redirects", "debug"}, @@ -317,6 +321,7 @@ func initConfig() { Grep: args.Grep, IgnoreReturn: args.IgnoreReturn, IgnoreParams: args.IgnoreParams, + OutOfScope: args.OutOfScope, Timeout: args.Timeout, Concurrence: args.Concurrence, MaxCPU: args.MaxCPU, @@ -416,6 +421,9 @@ func initConfig() { if len(args.IgnoreParams) == 0 && len(cfgOptions.IgnoreParams) > 0 { options.IgnoreParams = cfgOptions.IgnoreParams } + if len(args.OutOfScope) == 0 && len(cfgOptions.OutOfScope) > 0 { + options.OutOfScope = cfgOptions.OutOfScope + } if args.Timeout == DefaultTimeout && cfgOptions.Timeout != 0 { options.Timeout = cfgOptions.Timeout } @@ -456,6 +464,17 @@ func initConfig() { } } + // Load out-of-scope domains from file if specified + if args.OutOfScopeFile != "" { + domains, err := voltFile.ReadLinesOrLiteral(args.OutOfScopeFile) + if err != nil { + printing.DalLog("ERROR", "Failed to read out-of-scope file: "+err.Error(), options) + os.Exit(1) + } + options.OutOfScope = append(options.OutOfScope, domains...) + printing.DalLog("SYSTEM", "Loaded "+strconv.Itoa(len(domains))+" domains from out-of-scope file", options) + } + // If HarFilePath is specified via CLI or configuration file, initialize HAR writer if options.HarFilePath != "" { harFilePath = options.HarFilePath diff --git a/cmd/sxss.go b/cmd/sxss.go index 7097bfdb..acc7303a 100644 --- a/cmd/sxss.go +++ b/cmd/sxss.go @@ -1,6 +1,7 @@ package cmd import ( + "github.com/hahwul/dalfox/v2/internal/optimization" "github.com/hahwul/dalfox/v2/internal/printing" "github.com/hahwul/dalfox/v2/pkg/scanning" "github.com/spf13/cobra" @@ -36,6 +37,10 @@ func runSxssCmd(cmd *cobra.Command, args []string) { if options.Trigger != "" { printing.DalLog("SYSTEM", "Using Stored XSS mode", options) + if optimization.IsOutOfScope(options, args[0]) { + printing.DalLog("INFO", "Target is out of scope, skipping", options) + return + } if options.Format == "json" { printing.DalLog("PRINT", "[", options) } diff --git a/cmd/url.go b/cmd/url.go index 6e76130b..914fd925 100644 --- a/cmd/url.go +++ b/cmd/url.go @@ -1,6 +1,7 @@ package cmd import ( + "github.com/hahwul/dalfox/v2/internal/optimization" "github.com/hahwul/dalfox/v2/internal/printing" "github.com/hahwul/dalfox/v2/pkg/scanning" "github.com/spf13/cobra" @@ -23,6 +24,10 @@ func runURLCmd(cmd *cobra.Command, args []string) { printing.Summary(options, args[0]) printing.DalLog("SYSTEM", "Using single target mode", options) + if optimization.IsOutOfScope(options, args[0]) { + printing.DalLog("INFO", "Target is out of scope, skipping", options) + return + } if options.Format == "json" { printing.DalLog("PRINT", "[", options) } diff --git a/internal/optimization/inspectionDomain.go b/internal/optimization/inspectionDomain.go new file mode 100644 index 00000000..901e4686 --- /dev/null +++ b/internal/optimization/inspectionDomain.go @@ -0,0 +1,67 @@ +package optimization + +import ( + "net/url" + "strings" + + "github.com/hahwul/dalfox/v2/pkg/model" +) + +// IsOutOfScope checks if a URL's host matches any out-of-scope pattern. +// Supports wildcard matching: +// - "stg.example.com" = exact match only +// - "*.stg.example.com" = matches subdomains (api.stg.example.com, devapi.stg.example.com) +func IsOutOfScope(options model.Options, targetURL string) bool { + if len(options.OutOfScope) == 0 { + return false + } + + parsedURL, err := url.Parse(targetURL) + if err != nil { + // Treat malformed URLs as out-of-scope for safety + return true + } + + host := strings.ToLower(parsedURL.Hostname()) + // Handle URLs without scheme - url.Parse puts them in Path with empty Host + if host == "" && parsedURL.Path != "" { + parsedURL, err = url.Parse("http://" + targetURL) + if err != nil { + return true + } + host = strings.ToLower(parsedURL.Hostname()) + } + for _, pattern := range options.OutOfScope { + pattern = strings.ToLower(strings.TrimSpace(pattern)) + if matchDomainPattern(host, pattern) { + return true + } + } + return false +} + +// matchDomainPattern checks if host matches the pattern. +// Pattern "*.example.com" matches "sub.example.com" but not "example.com" +// Pattern "example.com" matches only "example.com" exactly +func matchDomainPattern(host, pattern string) bool { + if strings.HasPrefix(pattern, "*.") { + suffix := pattern[1:] // ".example.com" + return strings.HasSuffix(host, suffix) + } + return host == pattern +} + +// FilterOutOfScopeTargets removes out-of-scope URLs from a target list +func FilterOutOfScopeTargets(options model.Options, targets []string) []string { + if len(options.OutOfScope) == 0 { + return targets + } + + filtered := make([]string, 0, len(targets)) + for _, target := range targets { + if !IsOutOfScope(options, target) { + filtered = append(filtered, target) + } + } + return filtered +} diff --git a/internal/optimization/inspectionDomain_test.go b/internal/optimization/inspectionDomain_test.go new file mode 100644 index 00000000..7c681e2b --- /dev/null +++ b/internal/optimization/inspectionDomain_test.go @@ -0,0 +1,246 @@ +package optimization + +import ( + "testing" + + "github.com/hahwul/dalfox/v2/pkg/model" +) + +func TestIsOutOfScope(t *testing.T) { + type args struct { + options model.Options + targetURL string + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "exact match - should be out of scope", + args: args{ + options: model.Options{ + OutOfScope: []string{"stg.hahwul.com"}, + }, + targetURL: "https://stg.hahwul.com/path", + }, + want: true, + }, + { + name: "exact match - subdomain should NOT match", + args: args{ + options: model.Options{ + OutOfScope: []string{"stg.hahwul.com"}, + }, + targetURL: "https://api.stg.hahwul.com/path", + }, + want: false, + }, + { + name: "wildcard match - subdomain should match", + args: args{ + options: model.Options{ + OutOfScope: []string{"*.stg.hahwul.com"}, + }, + targetURL: "https://api.stg.hahwul.com/path", + }, + want: true, + }, + { + name: "wildcard match - base domain should NOT match", + args: args{ + options: model.Options{ + OutOfScope: []string{"*.stg.hahwul.com"}, + }, + targetURL: "https://stg.hahwul.com/path", + }, + want: false, + }, + { + name: "wildcard match - nested subdomain should match", + args: args{ + options: model.Options{ + OutOfScope: []string{"*.hahwul.com"}, + }, + targetURL: "https://api.stg.hahwul.com/path", + }, + want: true, + }, + { + name: "non-matching domain - should NOT be out of scope", + args: args{ + options: model.Options{ + OutOfScope: []string{"stg.hahwul.com"}, + }, + targetURL: "https://www.hahwul.com/path", + }, + want: false, + }, + { + name: "empty out-of-scope list - should NOT be out of scope", + args: args{ + options: model.Options{ + OutOfScope: []string{}, + }, + targetURL: "https://stg.hahwul.com/path", + }, + want: false, + }, + { + name: "invalid URL - should be out of scope for safety", + args: args{ + options: model.Options{ + OutOfScope: []string{"stg.hahwul.com"}, + }, + targetURL: "://invalid-url", + }, + want: true, + }, + { + name: "case insensitive - should match", + args: args{ + options: model.Options{ + OutOfScope: []string{"STG.HAHWUL.COM"}, + }, + targetURL: "https://stg.hahwul.com/path", + }, + want: true, + }, + { + name: "multiple patterns - first matches", + args: args{ + options: model.Options{ + OutOfScope: []string{"stg.hahwul.com", "dev.hahwul.com"}, + }, + targetURL: "https://stg.hahwul.com/path", + }, + want: true, + }, + { + name: "multiple patterns - second matches", + args: args{ + options: model.Options{ + OutOfScope: []string{"stg.hahwul.com", "dev.hahwul.com"}, + }, + targetURL: "https://dev.hahwul.com/path", + }, + want: true, + }, + { + name: "scheme-less URL - should match", + args: args{ + options: model.Options{ + OutOfScope: []string{"stg.hahwul.com"}, + }, + targetURL: "stg.hahwul.com/path", + }, + want: true, + }, + { + name: "scheme-less URL with wildcard - should match", + args: args{ + options: model.Options{ + OutOfScope: []string{"*.hahwul.com"}, + }, + targetURL: "stg.hahwul.com/path", + }, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsOutOfScope(tt.args.options, tt.args.targetURL); got != tt.want { + t.Errorf("IsOutOfScope() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_matchDomainPattern(t *testing.T) { + tests := []struct { + name string + host string + pattern string + want bool + }{ + {"exact match", "hahwul.com", "hahwul.com", true}, + {"exact no match", "other.com", "hahwul.com", false}, + {"wildcard matches subdomain", "api.hahwul.com", "*.hahwul.com", true}, + {"wildcard matches nested subdomain", "api.stg.hahwul.com", "*.hahwul.com", true}, + {"wildcard does not match base", "hahwul.com", "*.hahwul.com", false}, + {"partial match should fail", "nothahwul.com", "hahwul.com", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := matchDomainPattern(tt.host, tt.pattern); got != tt.want { + t.Errorf("matchDomainPattern() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestFilterOutOfScopeTargets(t *testing.T) { + tests := []struct { + name string + options model.Options + targets []string + want []string + }{ + { + name: "filter out exact match", + options: model.Options{ + OutOfScope: []string{"stg.hahwul.com"}, + }, + targets: []string{ + "https://www.hahwul.com/path", + "https://stg.hahwul.com/path", + "https://dev.hahwul.com/path", + }, + want: []string{ + "https://www.hahwul.com/path", + "https://dev.hahwul.com/path", + }, + }, + { + name: "filter out wildcard matches", + options: model.Options{ + OutOfScope: []string{"*.stg.hahwul.com"}, + }, + targets: []string{ + "https://www.hahwul.com/path", + "https://api.stg.hahwul.com/path", + "https://stg.hahwul.com/path", + }, + want: []string{ + "https://www.hahwul.com/path", + "https://stg.hahwul.com/path", + }, + }, + { + name: "empty out-of-scope returns original", + options: model.Options{ + OutOfScope: []string{}, + }, + targets: []string{ + "https://www.hahwul.com/path", + }, + want: []string{ + "https://www.hahwul.com/path", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := FilterOutOfScopeTargets(tt.options, tt.targets) + if len(got) != len(tt.want) { + t.Errorf("FilterOutOfScopeTargets() returned %d items, want %d", len(got), len(tt.want)) + return + } + for i, v := range got { + if v != tt.want[i] { + t.Errorf("FilterOutOfScopeTargets()[%d] = %v, want %v", i, v, tt.want[i]) + } + } + }) + } +} diff --git a/pkg/model/options.go b/pkg/model/options.go index d0808dc3..7a8fc7fe 100644 --- a/pkg/model/options.go +++ b/pkg/model/options.go @@ -16,6 +16,7 @@ type Options struct { // Target Related UniqParam []string `json:"param,omitempty"` IgnoreParams []string `json:"ignore-params,omitempty"` + OutOfScope []string `json:"out-of-scope,omitempty"` Method string `json:"method,omitempty"` IgnoreReturn string `json:"ignore-return,omitempty"`