mirror of
https://github.com/projectdiscovery/subfinder
synced 2026-06-21 14:05:24 +00:00
978addb8bd
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
270 lines
13 KiB
Go
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",
|
|
}
|