Files
Jigardjain 978addb8bd feat: add URLScan.io as passive subdomain source
Add URLScan.io as a new passive subdomain enumeration source with full
pagination support and robust rate limiting handling.

Features:
- Fetches subdomains from URLScan.io Search API
- Implements cursor-based pagination using search_after parameter
- Extracts domains from task.domain, task.url, page.domain, page.url fields
- Requires API key (free tier available at urlscan.io)

Rate Limiting:
- Conservative pagination delay (10s between pages) to respect strict burst limits
- Exponential backoff retry logic for 429/503 responses
- Respects X-Rate-Limit-Reset-After header for dynamic backoff
- Limited to 5 pages max (500 results) to avoid quota exhaustion

Configuration:
- Max 5 pages per enumeration (configurable via maxPages constant)
- 100 results per page (configurable via maxPerPage constant)
- 2 retry attempts for rate-limited requests
- 20 second initial backoff, doubles on each retry

Changes:
- pkg/subscraping/sources/urlscan/urlscan.go: New URLScan source implementation
- pkg/passive/sources.go: Register URLScan source
- pkg/passive/sources_test.go: Add URLScan to test lists
- pkg/runner/options.go: Add urlscan to source options
- .github/workflows/build-test.yml: Add URLSCAN_API_KEY secret

Closes: Feature request for URLScan.io integration
2026-01-30 17:02:44 +05:30

270 lines
13 KiB
Go

package runner
import (
"errors"
"fmt"
"io"
"math"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/projectdiscovery/chaos-client/pkg/chaos"
"github.com/projectdiscovery/goflags"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/subfinder/v2/pkg/passive"
"github.com/projectdiscovery/subfinder/v2/pkg/resolve"
"github.com/projectdiscovery/subfinder/v2/pkg/subscraping"
envutil "github.com/projectdiscovery/utils/env"
fileutil "github.com/projectdiscovery/utils/file"
folderutil "github.com/projectdiscovery/utils/folder"
logutil "github.com/projectdiscovery/utils/log"
updateutils "github.com/projectdiscovery/utils/update"
)
var (
configDir = folderutil.AppConfigDirOrDefault(".", "subfinder")
defaultConfigLocation = envutil.GetEnvOrDefault("SUBFINDER_CONFIG", filepath.Join(configDir, "config.yaml"))
defaultProviderConfigLocation = envutil.GetEnvOrDefault("SUBFINDER_PROVIDER_CONFIG", filepath.Join(configDir, "provider-config.yaml"))
)
// Options contains the configuration options for tuning
// the subdomain enumeration process.
type Options struct {
Verbose bool // Verbose flag indicates whether to show verbose output or not
NoColor bool // NoColor disables the colored output
JSON bool // JSON specifies whether to use json for output format or text file
HostIP bool // HostIP specifies whether to write subdomains in host:ip format
Silent bool // Silent suppresses any extra text and only writes subdomains to screen
ListSources bool // ListSources specifies whether to list all available sources
RemoveWildcard bool // RemoveWildcard specifies whether to remove potential wildcard or dead subdomains from the results.
CaptureSources bool // CaptureSources specifies whether to save all sources that returned a specific domains or just the first source
Stdin bool // Stdin specifies whether stdin input was given to the process
Version bool // Version specifies if we should just show version and exit
OnlyRecursive bool // Recursive specifies whether to use only recursive subdomain enumeration sources
All bool // All specifies whether to use all (slow) sources.
Statistics bool // Statistics specifies whether to report source statistics
Threads int // Threads controls the number of threads to use for active enumerations
Timeout int // Timeout is the seconds to wait for sources to respond
MaxEnumerationTime int // MaxEnumerationTime is the maximum amount of time in minutes to wait for enumeration
Domain goflags.StringSlice // Domain is the domain to find subdomains for
DomainsFile string // DomainsFile is the file containing list of domains to find subdomains for
Output io.Writer
OutputFile string // Output is the file to write found subdomains to.
OutputDirectory string // OutputDirectory is the directory to write results to in case list of domains is given
Sources goflags.StringSlice `yaml:"sources,omitempty"` // Sources contains a comma-separated list of sources to use for enumeration
ExcludeSources goflags.StringSlice `yaml:"exclude-sources,omitempty"` // ExcludeSources contains the comma-separated sources to not include in the enumeration process
Resolvers goflags.StringSlice `yaml:"resolvers,omitempty"` // Resolvers is the comma-separated resolvers to use for enumeration
ResolverList string // ResolverList is a text file containing list of resolvers to use for enumeration
Config string // Config contains the location of the config file
ProviderConfig string // ProviderConfig contains the location of the provider config file
Proxy string // HTTP proxy
RateLimit int // Global maximum number of HTTP requests to send per second
RateLimits goflags.RateLimitMap // Maximum number of HTTP requests to send per second
ExcludeIps bool
Match goflags.StringSlice
Filter goflags.StringSlice
matchRegexes []*regexp.Regexp
filterRegexes []*regexp.Regexp
ResultCallback OnResultCallback // OnResult callback
DisableUpdateCheck bool // DisableUpdateCheck disable update checking
}
// OnResultCallback (hostResult)
type OnResultCallback func(result *resolve.HostEntry)
// ParseOptions parses the command line flags provided by a user
func ParseOptions() *Options {
logutil.DisableDefaultLogger()
options := &Options{}
var err error
flagSet := goflags.NewFlagSet()
flagSet.SetDescription(`Subfinder is a subdomain discovery tool that discovers subdomains for websites by using passive online sources.`)
flagSet.CreateGroup("input", "Input",
flagSet.StringSliceVarP(&options.Domain, "domain", "d", nil, "domains to find subdomains for", goflags.NormalizedStringSliceOptions),
flagSet.StringVarP(&options.DomainsFile, "list", "dL", "", "file containing list of domains for subdomain discovery"),
)
flagSet.CreateGroup("source", "Source",
flagSet.StringSliceVarP(&options.Sources, "sources", "s", nil, "specific sources to use for discovery (-s crtsh,github). Use -ls to display all available sources.", goflags.NormalizedStringSliceOptions),
flagSet.BoolVar(&options.OnlyRecursive, "recursive", false, "use only sources that can handle subdomains recursively rather than both recursive and non-recursive sources"),
flagSet.BoolVar(&options.All, "all", false, "use all sources for enumeration (slow)"),
flagSet.StringSliceVarP(&options.ExcludeSources, "exclude-sources", "es", nil, "sources to exclude from enumeration (-es alienvault,zoomeyeapi)", goflags.NormalizedStringSliceOptions),
)
flagSet.CreateGroup("filter", "Filter",
flagSet.StringSliceVarP(&options.Match, "match", "m", nil, "subdomain or list of subdomain to match (file or comma separated)", goflags.FileNormalizedStringSliceOptions),
flagSet.StringSliceVarP(&options.Filter, "filter", "f", nil, " subdomain or list of subdomain to filter (file or comma separated)", goflags.FileNormalizedStringSliceOptions),
)
flagSet.CreateGroup("rate-limit", "Rate-limit",
flagSet.IntVarP(&options.RateLimit, "rate-limit", "rl", 0, "maximum number of http requests to send per second (global)"),
flagSet.RateLimitMapVarP(&options.RateLimits, "rate-limits", "rls", defaultRateLimits, "maximum number of http requests to send per second for providers in key=value format (-rls hackertarget=10/m)", goflags.NormalizedStringSliceOptions),
flagSet.IntVar(&options.Threads, "t", 10, "number of concurrent goroutines for resolving (-active only)"),
)
flagSet.CreateGroup("update", "Update",
flagSet.CallbackVarP(GetUpdateCallback(), "update", "up", "update subfinder to latest version"),
flagSet.BoolVarP(&options.DisableUpdateCheck, "disable-update-check", "duc", false, "disable automatic subfinder update check"),
)
flagSet.CreateGroup("output", "Output",
flagSet.StringVarP(&options.OutputFile, "output", "o", "", "file to write output to"),
flagSet.BoolVarP(&options.JSON, "json", "oJ", false, "write output in JSONL(ines) format"),
flagSet.StringVarP(&options.OutputDirectory, "output-dir", "oD", "", "directory to write output (-dL only)"),
flagSet.BoolVarP(&options.CaptureSources, "collect-sources", "cs", false, "include all sources in the output (-json only)"),
flagSet.BoolVarP(&options.HostIP, "ip", "oI", false, "include host IP in output (-active only)"),
)
flagSet.CreateGroup("configuration", "Configuration",
flagSet.StringVar(&options.Config, "config", defaultConfigLocation, "flag config file"),
flagSet.StringVarP(&options.ProviderConfig, "provider-config", "pc", defaultProviderConfigLocation, "provider config file"),
flagSet.StringSliceVar(&options.Resolvers, "r", nil, "comma separated list of resolvers to use", goflags.NormalizedStringSliceOptions),
flagSet.StringVarP(&options.ResolverList, "rlist", "rL", "", "file containing list of resolvers to use"),
flagSet.BoolVarP(&options.RemoveWildcard, "active", "nW", false, "display active subdomains only"),
flagSet.StringVar(&options.Proxy, "proxy", "", "http proxy to use with subfinder"),
flagSet.BoolVarP(&options.ExcludeIps, "exclude-ip", "ei", false, "exclude IPs from the list of domains"),
)
flagSet.CreateGroup("debug", "Debug",
flagSet.BoolVar(&options.Silent, "silent", false, "show only subdomains in output"),
flagSet.BoolVar(&options.Version, "version", false, "show version of subfinder"),
flagSet.BoolVar(&options.Verbose, "v", false, "show verbose output"),
flagSet.BoolVarP(&options.NoColor, "no-color", "nc", false, "disable color in output"),
flagSet.BoolVarP(&options.ListSources, "list-sources", "ls", false, "list all available sources"),
flagSet.BoolVar(&options.Statistics, "stats", false, "report source statistics"),
)
flagSet.CreateGroup("optimization", "Optimization",
flagSet.IntVar(&options.Timeout, "timeout", 30, "seconds to wait before timing out"),
flagSet.IntVar(&options.MaxEnumerationTime, "max-time", 10, "minutes to wait for enumeration results"),
)
if err := flagSet.Parse(); err != nil {
fmt.Println(err.Error())
os.Exit(1)
}
// set chaos mode
chaos.IsSDK = false
if exists := fileutil.FileExists(defaultProviderConfigLocation); !exists {
if err := createProviderConfigYAML(defaultProviderConfigLocation); err != nil {
gologger.Error().Msgf("Could not create provider config file: %s\n", err)
}
}
if options.Config != defaultConfigLocation {
// An empty source file is not a fatal error
if err := flagSet.MergeConfigFile(options.Config); err != nil && !errors.Is(err, io.EOF) {
gologger.Fatal().Msgf("Could not read config: %s\n", err)
}
}
// Default output is stdout
options.Output = os.Stdout
// Check if stdin pipe was given
options.Stdin = fileutil.HasStdin()
if options.Version {
gologger.Info().Msgf("Current Version: %s\n", version)
gologger.Info().Msgf("Subfinder Config Directory: %s", configDir)
os.Exit(0)
}
options.preProcessDomains()
options.ConfigureOutput()
showBanner()
if !options.DisableUpdateCheck {
latestVersion, err := updateutils.GetToolVersionCallback("subfinder", version)()
if err != nil {
if options.Verbose {
gologger.Error().Msgf("subfinder version check failed: %v", err.Error())
}
} else {
gologger.Info().Msgf("Current subfinder version %v %v", version, updateutils.GetVersionDescription(version, latestVersion))
}
}
if options.ListSources {
listSources(options)
os.Exit(0)
}
// Validate the options passed by the user and if any
// invalid options have been used, exit.
err = options.validateOptions()
if err != nil {
gologger.Fatal().Msgf("Program exiting: %s\n", err)
}
return options
}
// loadProvidersFrom runs the app with source config
func (options *Options) loadProvidersFrom(location string) {
// todo: move elsewhere
if len(options.Resolvers) == 0 {
options.Resolvers = resolve.DefaultResolvers
}
// We skip bailing out if file doesn't exist because we'll create it
// at the end of options parsing from default via goflags.
if err := UnmarshalFrom(location); err != nil && (!strings.Contains(err.Error(), "file doesn't exist") || errors.Is(err, os.ErrNotExist)) {
gologger.Error().Msgf("Could not read providers from %s: %s\n", location, err)
}
}
func listSources(options *Options) {
gologger.Info().Msgf("Current list of available sources. [%d]\n", len(passive.AllSources))
gologger.Info().Msgf("Sources marked with an * require key(s) or token(s) to work.\n")
gologger.Info().Msgf("Sources marked with a ~ optionally support key(s) for better results.\n")
gologger.Info().Msgf("You can modify %s to configure your keys/tokens.\n\n", options.ProviderConfig)
for _, source := range passive.AllSources {
sourceName := source.Name()
switch source.KeyRequirement() {
case subscraping.RequiredKey:
gologger.Silent().Msgf("%s *\n", sourceName)
case subscraping.OptionalKey:
gologger.Silent().Msgf("%s ~\n", sourceName)
default:
gologger.Silent().Msgf("%s\n", sourceName)
}
}
}
func (options *Options) preProcessDomains() {
for i, domain := range options.Domain {
options.Domain[i] = preprocessDomain(domain)
}
}
var defaultRateLimits = []string{
"github=30/m",
"fullhunt=60/m",
"pugrecon=10/s",
fmt.Sprintf("robtex=%d/ms", uint(math.MaxUint)),
"securitytrails=1/s",
"shodan=1/s",
"virustotal=4/m",
"hackertarget=2/s",
// "threatminer=10/m",
"waybackarchive=15/m",
"whoisxmlapi=50/s",
"securitytrails=2/s",
"sitedossier=8/m",
"netlas=1/s",
// "gitlab=2/s",
"github=83/m",
"hudsonrock=5/s",
"urlscan=1/s",
}