package runner import ( "bufio" "bytes" "context" "encoding/csv" "encoding/json" "fmt" "html/template" "image" "io" "net" "net/http" "net/http/httputil" "net/url" "os" "path" "path/filepath" "regexp" "slices" "sort" "strconv" "strings" "sync" "time" "golang.org/x/exp/maps" "github.com/PuerkitoBio/goquery" "github.com/corona10/goimagehash" "github.com/gocarina/gocsv" "github.com/happyhackingspace/dit" "github.com/mfonda/simhash" asnmap "github.com/projectdiscovery/asnmap/libs" "github.com/projectdiscovery/fastdialer/fastdialer" "github.com/projectdiscovery/httpx/common/authprovider" "github.com/projectdiscovery/httpx/common/customextract" "github.com/projectdiscovery/httpx/common/hashes/jarm" "github.com/projectdiscovery/httpx/common/inputformats" "github.com/projectdiscovery/httpx/static" "github.com/projectdiscovery/mapcidr/asn" "github.com/projectdiscovery/networkpolicy" osutil "github.com/projectdiscovery/utils/os" "github.com/projectdiscovery/utils/structs" "github.com/Mzack9999/gcache" "github.com/logrusorgru/aurora" "github.com/pkg/errors" "github.com/projectdiscovery/clistats" "github.com/projectdiscovery/goconfig" "github.com/projectdiscovery/httpx/common/hashes" "github.com/projectdiscovery/retryablehttp-go" sliceutil "github.com/projectdiscovery/utils/slice" stringsutil "github.com/projectdiscovery/utils/strings" urlutil "github.com/projectdiscovery/utils/url" "github.com/projectdiscovery/ratelimit" // automatic fd max increase if running as root _ "github.com/projectdiscovery/fdmax/autofdmax" "github.com/projectdiscovery/gologger" "github.com/projectdiscovery/hmap/store/hybrid" customport "github.com/projectdiscovery/httpx/common/customports" fileutilz "github.com/projectdiscovery/httpx/common/fileutil" "github.com/projectdiscovery/httpx/common/httputilz" "github.com/projectdiscovery/httpx/common/httpx" "github.com/projectdiscovery/httpx/common/stringz" "github.com/projectdiscovery/mapcidr" "github.com/projectdiscovery/rawhttp" converstionutil "github.com/projectdiscovery/utils/conversion" errkit "github.com/projectdiscovery/utils/errkit" fileutil "github.com/projectdiscovery/utils/file" pdhttputil "github.com/projectdiscovery/utils/http" iputil "github.com/projectdiscovery/utils/ip" syncutil "github.com/projectdiscovery/utils/sync" wappalyzer "github.com/projectdiscovery/wappalyzergo" ) // Runner is a client for running the enumeration process. type Runner struct { seenMux sync.Mutex options *Options hp *httpx.HTTPX wappalyzer *wappalyzer.Wappalyze cpeDetector *CPEDetector wpDetector *WordPressDetector scanopts ScanOptions hm *hybrid.HybridMap excludeCdn bool stats clistats.StatisticsClient ratelimiter ratelimit.Limiter HostErrorsCache gcache.Cache[string, int] browser *Browser ditClassifier *dit.Classifier pHashClusters []pHashCluster simHashes gcache.Cache[uint64, []string] httpApiEndpoint *Server authProvider authprovider.AuthProvider interruptCh chan struct{} } func (r *Runner) HTTPX() *httpx.HTTPX { return r.hp } // Interrupt signals the runner to stop dispatching new items. func (r *Runner) Interrupt() { select { case <-r.interruptCh: default: close(r.interruptCh) } } // IsInterrupted returns true if the runner was interrupted. func (r *Runner) IsInterrupted() bool { select { case <-r.interruptCh: return true default: return false } } // picked based on try-fail but it seems to close to one it's used https://www.hackerfactor.com/blog/index.php?/archives/432-Looks-Like-It.html#c1992 const hammingDistanceThreshold = 22 // regex for stripping ANSI codes var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`) type pHashCluster struct { BasePHash uint64 `json:"base_phash,omitempty" csv:"base_phash"` Hashes []pHashUrl `json:"hashes,omitempty" csv:"hashes"` } type pHashUrl struct { PHash uint64 `json:"phash,omitempty" csv:"phash"` Url string `json:"url,omitempty" csv:"url"` } // New creates a new client for running enumeration process. func New(options *Options) (*Runner, error) { runner := &Runner{ options: options, interruptCh: make(chan struct{}), } var err error if options.Wappalyzer != nil { runner.wappalyzer = options.Wappalyzer } else if options.TechDetect || options.JSONOutput || options.CSVOutput || options.AssetUpload { runner.wappalyzer, err = func() (*wappalyzer.Wappalyze, error) { if options.CustomFingerprintFile != "" { return wappalyzer.NewFromFile(options.CustomFingerprintFile, true, true) } return wappalyzer.New() }() } if err != nil { return nil, errors.Wrap(err, "could not create wappalyzer client") } if options.CPEDetect || options.JSONOutput || options.CSVOutput { runner.cpeDetector, err = NewCPEDetector() if err != nil { gologger.Warning().Msgf("Could not create CPE detector: %s", err) } } if options.WordPress || options.JSONOutput || options.CSVOutput { runner.wpDetector, err = NewWordPressDetector() if err != nil { gologger.Warning().Msgf("Could not create WordPress detector: %s", err) } } if options.StoreResponseDir != "" { // Don't remove index files if skip-dedupe is enabled (we want to append, not truncate) if !options.SkipDedupe { _ = os.RemoveAll(filepath.Join(options.StoreResponseDir, "response", "index.txt")) _ = os.RemoveAll(filepath.Join(options.StoreResponseDir, "screenshot", "index_screenshot.txt")) } } httpxOptions := httpx.DefaultOptions httpxOptions.Trace = options.Trace var np *networkpolicy.NetworkPolicy if options.Networkpolicy != nil { np = options.Networkpolicy } else { np, err = runner.createNetworkpolicyInstance(options) } if err != nil { return nil, err } httpxOptions.NetworkPolicy = np httpxOptions.CDNCheckClient = options.CDNCheckClient // Enables automatically tlsgrab if tlsprobe is requested httpxOptions.TLSGrab = options.TLSGrab || options.TLSProbe httpxOptions.Timeout = time.Duration(options.Timeout) * time.Second httpxOptions.RetryMax = options.Retries httpxOptions.FollowRedirects = options.FollowRedirects httpxOptions.FollowHostRedirects = options.FollowHostRedirects httpxOptions.RespectHSTS = options.RespectHSTS httpxOptions.MaxRedirects = options.MaxRedirects if options.HTTPProxy != "" { options.Proxy = options.HTTPProxy } else if options.SocksProxy != "" { options.Proxy = options.SocksProxy } httpxOptions.Proxy = options.Proxy httpxOptions.Unsafe = options.Unsafe httpxOptions.UnsafeURI = options.RequestURI httpxOptions.CdnCheck = options.OutputCDN httpxOptions.ExcludeCdn = runner.excludeCdn httpxOptions.ExtractFqdn = options.ExtractFqdn if options.CustomHeaders.Has("User-Agent:") { httpxOptions.RandomAgent = false } else { httpxOptions.RandomAgent = options.RandomAgent } if options.CustomHeaders.Has("Referer:") { httpxOptions.AutoReferer = false } else { httpxOptions.AutoReferer = options.AutoReferer } httpxOptions.ZTLS = options.ZTLS httpxOptions.MaxResponseBodySizeToSave = int64(options.MaxResponseBodySizeToSave) httpxOptions.MaxResponseBodySizeToRead = int64(options.MaxResponseBodySizeToRead) // adjust response size saved according to the max one read by the server if httpxOptions.MaxResponseBodySizeToSave > httpxOptions.MaxResponseBodySizeToRead { httpxOptions.MaxResponseBodySizeToSave = httpxOptions.MaxResponseBodySizeToRead } httpxOptions.Resolvers = options.Resolvers httpxOptions.TlsImpersonate = options.TlsImpersonate httpxOptions.Protocol = httpx.Proto(options.Protocol) var key, value string httpxOptions.CustomHeaders = make(map[string][]string) for _, customHeader := range options.CustomHeaders { tokens := strings.SplitN(customHeader, ":", two) // rawhttp skips all checks if options.Unsafe { httpxOptions.CustomHeaders[customHeader] = []string{""} continue } // Continue normally if len(tokens) < two { continue } key = strings.TrimSpace(tokens[0]) value = strings.TrimSpace(tokens[1]) httpxOptions.CustomHeaders[key] = append(httpxOptions.CustomHeaders[key], value) } httpxOptions.SniName = options.SniName runner.hp, err = httpx.New(&httpxOptions) if err != nil { gologger.Fatal().Msgf("Could not create httpx instance: %s\n", err) } var scanopts ScanOptions if options.InputRawRequest != "" { var rawRequest []byte rawRequest, err = os.ReadFile(options.InputRawRequest) if err != nil { gologger.Fatal().Msgf("Could not read raw request from path '%s': %s\n", options.InputRawRequest, err) } rrMethod, rrPath, rrHeaders, rrBody, errParse := httputilz.ParseRequest(string(rawRequest), options.Unsafe) if errParse != nil { gologger.Fatal().Msgf("Could not parse raw request: %s\n", err) } scanopts.Methods = append(scanopts.Methods, rrMethod) scanopts.RequestURI = rrPath for name, value := range rrHeaders { httpxOptions.CustomHeaders[name] = append(httpxOptions.CustomHeaders[name], value...) } scanopts.RequestBody = rrBody options.rawRequest = string(rawRequest) options.RequestBody = rrBody } // disable automatic host header for rawhttp if manually specified // as it can be malformed the best approach is to remove spaces and check for lowercase "host" word if options.Unsafe { for name := range runner.hp.CustomHeaders { nameLower := strings.TrimSpace(strings.ToLower(name)) if strings.HasPrefix(nameLower, "host") { rawhttp.AutomaticHostHeader(false) } } } if strings.EqualFold(options.Methods, "all") { scanopts.Methods = pdhttputil.AllHTTPMethods() } else if options.Methods != "" { // if unsafe is specified then converts the methods to uppercase if !options.Unsafe { options.Methods = strings.ToUpper(options.Methods) } scanopts.Methods = append(scanopts.Methods, stringz.SplitByCharAndTrimSpace(options.Methods, ",")...) } if len(scanopts.Methods) == 0 { scanopts.Methods = append(scanopts.Methods, http.MethodGet) } runner.options.protocol = httpx.HTTPorHTTPS scanopts.VHost = options.VHost scanopts.OutputTitle = options.ExtractTitle scanopts.OutputStatusCode = options.StatusCode scanopts.OutputLocation = options.Location scanopts.OutputContentLength = options.ContentLength scanopts.StoreResponse = options.StoreResponse scanopts.StoreResponseDirectory = options.StoreResponseDir scanopts.OutputServerHeader = options.OutputServerHeader scanopts.ResponseHeadersInStdout = options.ResponseHeadersInStdout scanopts.OutputWithNoColor = options.NoColor scanopts.ResponseInStdout = options.ResponseInStdout scanopts.Base64ResponseInStdout = options.Base64ResponseInStdout scanopts.ChainInStdout = options.ChainInStdout scanopts.OutputWebSocket = options.OutputWebSocket scanopts.TLSProbe = options.TLSProbe scanopts.CSPProbe = options.CSPProbe if options.RequestURI != "" { scanopts.RequestURI = options.RequestURI } scanopts.VHostInput = options.VHostInput scanopts.OutputContentType = options.OutputContentType scanopts.RequestBody = options.RequestBody scanopts.Unsafe = options.Unsafe scanopts.Pipeline = options.Pipeline scanopts.HTTP2Probe = options.HTTP2Probe scanopts.OutputMethod = options.OutputMethod scanopts.OutputIP = options.OutputIP scanopts.OutputCName = options.OutputCName scanopts.OutputCDN = options.OutputCDN scanopts.OutputResponseTime = options.OutputResponseTime scanopts.NoFallback = options.NoFallback scanopts.NoFallbackScheme = options.NoFallbackScheme scanopts.TechDetect = options.TechDetect || options.JSONOutput || options.CSVOutput || options.AssetUpload scanopts.CPEDetect = options.CPEDetect || options.JSONOutput || options.CSVOutput scanopts.WordPress = options.WordPress || options.JSONOutput || options.CSVOutput scanopts.StoreChain = options.StoreChain scanopts.StoreVisionReconClusters = options.StoreVisionReconClusters scanopts.MaxResponseBodySizeToSave = options.MaxResponseBodySizeToSave scanopts.MaxResponseBodySizeToRead = options.MaxResponseBodySizeToRead scanopts.extractRegexps = make(map[string]*regexp.Regexp) if options.Screenshot { browser, err := NewBrowser(options.HTTPProxy, options.UseInstalledChrome, options.ParseHeadlessOptionalArguments()) if err != nil { return nil, err } runner.browser = browser } scanopts.Screenshot = options.Screenshot scanopts.NoScreenshotBytes = options.NoScreenshotBytes scanopts.NoHeadlessBody = options.NoHeadlessBody scanopts.NoScreenshotFullPage = options.NoScreenshotFullPage scanopts.UseInstalledChrome = options.UseInstalledChrome scanopts.ScreenshotTimeout = options.ScreenshotTimeout scanopts.ScreenshotIdle = options.ScreenshotIdle if options.OutputExtractRegexs != nil { for _, regex := range options.OutputExtractRegexs { if compiledRegex, err := regexp.Compile(regex); err != nil { return nil, err } else { scanopts.extractRegexps[regex] = compiledRegex } } } if options.OutputExtractPresets != nil { for _, regexName := range options.OutputExtractPresets { if regex, ok := customextract.ExtractPresets[regexName]; ok { scanopts.extractRegexps[regexName] = regex } else { availablePresets := strings.Join(maps.Keys(customextract.ExtractPresets), ",") gologger.Warning().Msgf("Could not find preset: '%s'. Available presets are: %s\n", regexName, availablePresets) } } } // output verb if more than one is specified if len(scanopts.Methods) > 1 && !options.Silent { scanopts.OutputMethod = true } scanopts.ExcludeCDN = runner.excludeCdn scanopts.HostMaxErrors = options.HostMaxErrors scanopts.ProbeAllIPS = options.ProbeAllIPS scanopts.Favicon = options.Favicon scanopts.LeaveDefaultPorts = options.LeaveDefaultPorts scanopts.OutputLinesCount = options.OutputLinesCount scanopts.OutputWordsCount = options.OutputWordsCount scanopts.Hashes = options.Hashes runner.scanopts = scanopts if options.ShowStatistics { runner.stats, err = clistats.New() if err != nil { return nil, err } if options.StatsInterval == 0 { options.StatsInterval = 5 } } hm, err := hybrid.New(hybrid.DefaultDiskOptions) if err != nil { return nil, err } runner.hm = hm if options.RateLimitMinute > 0 { runner.ratelimiter = *ratelimit.New(context.Background(), uint(options.RateLimitMinute), time.Minute) } else if options.RateLimit > 0 { runner.ratelimiter = *ratelimit.New(context.Background(), uint(options.RateLimit), time.Second) } else { runner.ratelimiter = *ratelimit.NewUnlimited(context.Background()) } if options.HostMaxErrors >= 0 { gc := gcache.New[string, int](1000). ARC(). Build() runner.HostErrorsCache = gc } runner.simHashes = gcache.New[uint64, []string](1000).ARC().Build() if options.JSONOutput || options.CSVOutput || len(options.OutputFilterPageType) > 0 { ditClassifier, err := dit.New() if err != nil { gologger.Warning().Msgf("Could not initialize page classifier: %s", err) } runner.ditClassifier = ditClassifier } if options.SecretFile != "" { authProviderOpts := &authprovider.AuthProviderOptions{ SecretsFiles: []string{options.SecretFile}, } runner.authProvider, err = authprovider.NewAuthProvider(authProviderOpts) if err != nil { return nil, errors.Wrap(err, "could not create auth provider") } } if options.HttpApiEndpoint != "" { apiServer := NewServer(options.HttpApiEndpoint, options) gologger.Info().Msgf("Listening api endpoint on: %s", options.HttpApiEndpoint) runner.httpApiEndpoint = apiServer go func() { if err := apiServer.Start(); err != nil { gologger.Error().Msgf("Failed to start API server: %s", err) } }() } return runner, nil } func (runner *Runner) createNetworkpolicyInstance(options *Options) (*networkpolicy.NetworkPolicy, error) { var npOptions networkpolicy.Options for _, exclude := range options.Exclude { switch { case exclude == "cdn": //implement cdn check in netoworkpolicy pkg?? runner.excludeCdn = true continue case exclude == "private-ips": npOptions.DenyList = append(npOptions.DenyList, networkpolicy.DefaultIPv4Denylist...) npOptions.DenyList = append(npOptions.DenyList, networkpolicy.DefaultIPv4DenylistRanges...) npOptions.DenyList = append(npOptions.DenyList, networkpolicy.DefaultIPv6Denylist...) npOptions.DenyList = append(npOptions.DenyList, networkpolicy.DefaultIPv6DenylistRanges...) case iputil.IsCIDR(exclude): npOptions.DenyList = append(npOptions.DenyList, exclude) case asn.IsASN(exclude): // update this to use networkpolicy pkg once https://github.com/projectdiscovery/networkpolicy/pull/55 is merged ips := expandASNInputValue(exclude) npOptions.DenyList = append(npOptions.DenyList, ips...) case iputil.IsPort(exclude): port, _ := strconv.Atoi(exclude) npOptions.DenyPortList = append(npOptions.DenyPortList, port) default: npOptions.DenyList = append(npOptions.DenyList, exclude) } } npOptions.AllowList = appendToList(npOptions.AllowList, options.Allow...) npOptions.DenyList = appendToList(npOptions.DenyList, options.Deny...) np, err := networkpolicy.New(npOptions) return np, err } func appendToList(list []string, values ...string) []string { for _, value := range values { switch { case asn.IsASN(value): ips := expandASNInputValue(value) list = append(list, ips...) default: list = append(list, value) } } return list } func expandCIDRInputValue(value string) []string { var ips []string ipsCh, _ := mapcidr.IPAddressesAsStream(value) for ip := range ipsCh { ips = append(ips, ip) } return ips } func expandASNInputValue(value string) []string { var ips []string cidrs, _ := asn.GetCIDRsForASNNum(value) for _, cidr := range cidrs { ips = append(ips, expandCIDRInputValue(cidr.String())...) } return ips } func (r *Runner) prepareInputPaths() { // most likely, the user would provide the most simplified path to an existing file isAbsoluteOrRelativePath := filepath.Clean(r.options.RequestURIs) == r.options.RequestURIs // Check if the user requested multiple paths if isAbsoluteOrRelativePath && fileutil.FileExists(r.options.RequestURIs) { r.options.requestURIs = fileutilz.LoadFile(r.options.RequestURIs) } else if r.options.RequestURIs != "" { r.options.requestURIs = strings.Split(r.options.RequestURIs, ",") } } var duplicateTargetErr = errors.New("duplicate target") func (r *Runner) prepareInput() { var numHosts int // check if input target host(s) have been provided if len(r.options.InputTargetHost) > 0 { for _, target := range r.options.InputTargetHost { expandedTarget, err := r.countTargetFromRawTarget(target) if err == nil && expandedTarget > 0 { numHosts += expandedTarget r.hm.Set(target, []byte("1")) //nolint } else if r.options.SkipDedupe && errors.Is(err, duplicateTargetErr) { if v, ok := r.hm.Get(target); ok { cnt, _ := strconv.Atoi(string(v)) _ = r.hm.Set(target, []byte(strconv.Itoa(cnt+1))) numHosts += 1 } } } } // check if file has been provided if fileutil.FileExists(r.options.InputFile) { // check if input mode is specified for special format handling if format := r.getInputFormat(); format != nil { numTargets, err := r.loadFromFormat(r.options.InputFile, format) if err != nil { gologger.Fatal().Msgf("Could not parse input file '%s': %s\n", r.options.InputFile, err) } numHosts = numTargets } else { finput, err := os.Open(r.options.InputFile) if err != nil { gologger.Fatal().Msgf("Could not read input file '%s': %s\n", r.options.InputFile, err) } numHosts, err = r.loadAndCloseFile(finput) if err != nil { gologger.Fatal().Msgf("Could not read input file '%s': %s\n", r.options.InputFile, err) } } } else if r.options.InputFile != "" { files, err := fileutilz.ListFilesWithPattern(r.options.InputFile) if err != nil { gologger.Fatal().Msgf("No input provided: %s", err) } for _, file := range files { finput, err := os.Open(file) if err != nil { gologger.Fatal().Msgf("Could not read input file '%s': %s\n", r.options.InputFile, err) } numTargetsFile, err := r.loadAndCloseFile(finput) if err != nil { gologger.Fatal().Msgf("Could not read input file '%s': %s\n", r.options.InputFile, err) } numHosts += numTargetsFile } } if !r.options.DisableStdin && fileutil.HasStdin() { numTargetsStdin, err := r.loadAndCloseFile(os.Stdin) if err != nil { gologger.Fatal().Msgf("Could not read input from stdin: %s\n", err) } numHosts += numTargetsStdin } // Adjust total hosts based on the number of paths if len(r.options.requestURIs) > 0 { numHosts *= len(r.options.requestURIs) } if r.options.ShowStatistics { r.stats.AddStatic("totalHosts", numHosts) r.stats.AddCounter("hosts", 0) r.stats.AddStatic("startedAt", time.Now()) r.stats.AddCounter("requests", 0) r.stats.AddDynamic("summary", makePrintCallback()) err := r.stats.Start() if err != nil { gologger.Warning().Msgf("Could not create statistics: %s\n", err) } r.stats.GetStatResponse(time.Duration(r.options.StatsInterval)*time.Second, func(s string, err error) error { if err != nil && r.options.Verbose { gologger.Error().Msgf("Could not read statistics: %s\n", err) } return nil }) } } func (r *Runner) setSeen(k string) { _ = r.hm.Set(k, nil) } func (r *Runner) seen(k string) bool { _, ok := r.hm.Get(k) return ok } func (r *Runner) duplicate(result *Result) bool { respSimHash := simhash.Simhash(simhash.NewWordFeatureSet(converstionutil.Bytes(result.Raw))) ip := result.HostIP for storedHash, storedIPs := range r.simHashes.GetALL(false) { if simhash.Compare(storedHash, respSimHash) > 3 { continue } if ip == "" || sliceutil.Contains(storedIPs, ip) { gologger.Debug().Msgf("Skipping duplicate response (simhash %d, ip %s) for URL %s\n", respSimHash, ip, result.URL) return true } _ = r.simHashes.Set(storedHash, append(storedIPs, ip)) return false } _ = r.simHashes.Set(respSimHash, []string{ip}) return false } func (r *Runner) classifyPage(headlessBody, body string, pHash uint64) map[string]any { kb := map[string]any{"pHash": pHash} if r.ditClassifier == nil { return kb } html := body if headlessBody != "" { html = headlessBody } result, err := r.ditClassifier.ExtractPageType(html) if err != nil { return kb } kb["PageType"] = fmt.Sprint(result.Type) if len(result.Forms) > 0 { kb["Forms"] = result.Forms } return kb } func (r *Runner) testAndSet(k string) bool { r.seenMux.Lock() defer r.seenMux.Unlock() // skip empty lines k = strings.TrimSpace(k) if k == "" { return false } if r.seen(k) { return false } r.setSeen(k) return true } // getInputFormat returns the format for the configured input mode. // Returns nil if no input mode is configured, or logs fatal if the format is invalid. func (r *Runner) getInputFormat() inputformats.Format { if r.options.InputMode == "" { return nil } format := inputformats.GetFormat(r.options.InputMode) if format == nil { gologger.Fatal().Msgf("Invalid input mode '%s'. Supported: %s\n", r.options.InputMode, inputformats.SupportedFormats()) } return format } func (r *Runner) streamInput() (chan string, error) { out := make(chan string) go func() { defer close(out) // trySend sends item to out, returning false if interrupted trySend := func(item string) bool { select { case <-r.interruptCh: return false case out <- item: return true } } if fileutil.FileExists(r.options.InputFile) { // check if input mode is specified for special format handling if format := r.getInputFormat(); format != nil { finput, err := os.Open(r.options.InputFile) if err != nil { gologger.Error().Msgf("Could not open input file '%s': %s\n", r.options.InputFile, err) return } defer finput.Close() //nolint:errcheck if err := format.Parse(finput, func(item string) bool { item = strings.TrimSpace(item) if r.options.SkipDedupe || r.testAndSet(item) { return trySend(item) } return !r.IsInterrupted() }); err != nil { gologger.Error().Msgf("Could not parse input file '%s': %s\n", r.options.InputFile, err) return } } else { for item, err := range fileutil.Lines(r.options.InputFile) { if err != nil { return } if r.options.SkipDedupe || r.testAndSet(item) { if !trySend(item) { return } } } } } else if r.options.InputFile != "" { files, err := fileutilz.ListFilesWithPattern(r.options.InputFile) if err != nil { gologger.Fatal().Msgf("No input provided: %s", err) } for _, file := range files { for item, err := range fileutil.Lines(file) { if err != nil { return } if r.options.SkipDedupe || r.testAndSet(item) { if !trySend(item) { return } } } } } if fileutil.HasStdin() { for item, err := range fileutil.LinesReader(os.Stdin) { if err != nil { return } if r.options.SkipDedupe || r.testAndSet(item) { if !trySend(item) { return } } } } }() return out, nil } func (r *Runner) loadAndCloseFile(finput *os.File) (numTargets int, err error) { scanner := bufio.NewScanner(finput) for scanner.Scan() { target := strings.TrimSpace(scanner.Text()) // Used just to get the exact number of targets expandedTarget, err := r.countTargetFromRawTarget(target) if err == nil && expandedTarget > 0 { numTargets += expandedTarget r.hm.Set(target, []byte("1")) //nolint } else if r.options.SkipDedupe && errors.Is(err, duplicateTargetErr) { if v, ok := r.hm.Get(target); ok { cnt, _ := strconv.Atoi(string(v)) _ = r.hm.Set(target, []byte(strconv.Itoa(cnt+1))) numTargets += 1 } } } err = finput.Close() return numTargets, err } func (r *Runner) loadFromFormat(filePath string, format inputformats.Format) (numTargets int, err error) { finput, err := os.Open(filePath) if err != nil { return 0, err } defer finput.Close() //nolint:errcheck err = format.Parse(finput, func(target string) bool { target = strings.TrimSpace(target) expandedTarget, countErr := r.countTargetFromRawTarget(target) if countErr == nil && expandedTarget > 0 { numTargets += expandedTarget r.hm.Set(target, []byte("1")) //nolint } else if r.options.SkipDedupe && errors.Is(countErr, duplicateTargetErr) { if v, ok := r.hm.Get(target); ok { cnt, _ := strconv.Atoi(string(v)) _ = r.hm.Set(target, []byte(strconv.Itoa(cnt+1))) numTargets += 1 } } return true }) return numTargets, err } func (r *Runner) countTargetFromRawTarget(rawTarget string) (numTargets int, err error) { if rawTarget == "" { return 0, nil } if _, ok := r.hm.Get(rawTarget); ok { return 0, duplicateTargetErr } expandedTarget := 0 switch { case iputil.IsCIDR(rawTarget): if ipsCount, err := mapcidr.AddressCount(rawTarget); err == nil && ipsCount > 0 { expandedTarget = int(ipsCount) } case asn.IsASN(rawTarget): cidrs, err := asn.GetCIDRsForASNNum(rawTarget) if err != nil { return 0, err } for _, cidr := range cidrs { expandedTarget += int(mapcidr.AddressCountIpnet(cidr)) } default: expandedTarget = 1 } return expandedTarget, nil } var ( lastRequestsCount float64 ) func makePrintCallback() func(stats clistats.StatisticsClient) interface{} { builder := &strings.Builder{} return func(stats clistats.StatisticsClient) interface{} { startedAt, _ := stats.GetStatic("startedAt") duration := time.Since(startedAt.(time.Time)) builder.WriteRune('[') builder.WriteString(clistats.FmtDuration(duration)) builder.WriteRune(']') var currentRequests float64 if reqs, _ := stats.GetCounter("requests"); reqs > 0 { currentRequests = float64(reqs) } builder.WriteString(" | RPS: ") incrementRequests := currentRequests - lastRequestsCount builder.WriteString(clistats.String(uint64(incrementRequests / duration.Seconds()))) builder.WriteString(" | Requests: ") _, _ = fmt.Fprintf(builder, "%.0f", currentRequests) hosts, _ := stats.GetCounter("hosts") totalHosts, _ := stats.GetStatic("totalHosts") builder.WriteString(" | Hosts: ") builder.WriteString(clistats.String(hosts)) builder.WriteRune('/') builder.WriteString(clistats.String(totalHosts)) builder.WriteRune(' ') builder.WriteRune('(') builder.WriteString(clistats.String(uint64(float64(hosts) / float64(totalHosts.(int)) * 100.0))) builder.WriteRune('%') builder.WriteRune(')') builder.WriteRune('\n') statString := builder.String() fmt.Fprintf(os.Stderr, "%s", statString) builder.Reset() lastRequestsCount = currentRequests return statString } } // Close closes the httpx scan instance func (r *Runner) Close() { // nolint:errcheck // ignore r.hm.Close() r.hp.Dialer.Close() r.ratelimiter.Stop() if r.options.HostMaxErrors >= 0 { r.HostErrorsCache.Purge() } if r.options.Screenshot { r.browser.Close() } if r.options.ShowStatistics { _ = r.stats.Stop() } if r.options.HttpApiEndpoint != "" { _ = r.httpApiEndpoint.Stop() } if r.options.OnClose != nil { r.options.OnClose() } } // RunEnumeration on targets for httpx client func (r *Runner) RunEnumeration() { // Try to create output folders if it doesn't exist if r.options.StoreResponse && !fileutil.FolderExists(r.options.StoreResponseDir) { // main folder if err := os.MkdirAll(r.options.StoreResponseDir, os.ModePerm); err != nil { gologger.Fatal().Msgf("Could not create output directory '%s': %s\n", r.options.StoreResponseDir, err) } // response folder responseFolder := filepath.Join(r.options.StoreResponseDir, "response") if err := os.MkdirAll(responseFolder, os.ModePerm); err != nil { gologger.Fatal().Msgf("Could not create output response directory '%s': %s\n", r.options.StoreResponseDir, err) } } // screenshot folder if r.options.Screenshot { screenshotFolder := filepath.Join(r.options.StoreResponseDir, "screenshot") if err := os.MkdirAll(screenshotFolder, os.ModePerm); err != nil { gologger.Fatal().Msgf("Could not create output screenshot directory '%s': %s\n", r.options.StoreResponseDir, err) } } r.prepareInputPaths() var streamChan chan string if r.options.Stream { var err error streamChan, err = r.streamInput() if err != nil { gologger.Fatal().Msgf("Could not stream input: %s\n", err) } } else { r.prepareInput() // if resume is enabled inform the user if r.options.ShouldLoadResume() && r.options.resumeCfg.Index > 0 { gologger.Debug().Msgf("Resuming at position %d: %s\n", r.options.resumeCfg.Index, r.options.resumeCfg.ResumeFrom) } } // output routine var wgoutput sync.WaitGroup output := make(chan Result) nextStep := make(chan Result) wgoutput.Add(1) go func(output chan Result, nextSteps ...chan Result) { defer wgoutput.Done() defer func() { for _, nextStep := range nextSteps { close(nextStep) } }() var plainFile, jsonFile, csvFile, mdFile, indexFile, indexScreenshotFile *os.File markdownHeaderWritten := false // guard to prevent writing the header multiple times if r.options.Output != "" && r.options.OutputAll { plainFile = openOrCreateFile(r.options.Resume, r.options.Output) defer func() { _ = plainFile.Close() }() jsonFile = openOrCreateFile(r.options.Resume, r.options.Output+".json") defer func() { _ = jsonFile.Close() }() csvFile = openOrCreateFile(r.options.Resume, r.options.Output+".csv") defer func() { _ = csvFile.Close() }() mdFile = openOrCreateFile(r.options.Resume, r.options.Output+".md") defer func() { _ = mdFile.Close() }() } jsonOrCsvOrMD := (r.options.JSONOutput || r.options.CSVOutput || r.options.MarkDownOutput) jsonAndCsvAndMD := (r.options.JSONOutput && r.options.CSVOutput && r.options.MarkDownOutput) if r.options.Output != "" && plainFile == nil && !jsonOrCsvOrMD { plainFile = openOrCreateFile(r.options.Resume, r.options.Output) defer func() { _ = plainFile.Close() }() } if r.options.Output != "" && r.options.JSONOutput && jsonFile == nil { ext := "" if jsonAndCsvAndMD { ext = ".json" } jsonFile = openOrCreateFile(r.options.Resume, r.options.Output+ext) defer func() { _ = jsonFile.Close() }() } if r.options.Output != "" && r.options.CSVOutput && csvFile == nil { ext := "" if jsonAndCsvAndMD { ext = ".csv" } csvFile = openOrCreateFile(r.options.Resume, r.options.Output+ext) defer func() { _ = csvFile.Close() }() } if r.options.Output != "" && r.options.MarkDownOutput && mdFile == nil { ext := "" if jsonAndCsvAndMD { ext = ".md" } mdFile = openOrCreateFile(r.options.Resume, r.options.Output+ext) defer func() { _ = mdFile.Close() }() } if r.options.CSVOutput { outEncoding := strings.ToLower(r.options.CSVOutputEncoding) switch outEncoding { case "": // no encoding do nothing case "utf-8", "utf8": bomUtf8 := []byte{0xEF, 0xBB, 0xBF} _, err := csvFile.Write(bomUtf8) if err != nil { gologger.Fatal().Msgf("err on file write: %s\n", err) } default: // unknown encoding gologger.Fatal().Msgf("unknown csv output encoding: %s\n", r.options.CSVOutputEncoding) } headers := Result{}.CSVHeader() if !r.options.OutputAll && !jsonAndCsvAndMD { gologger.Silent().Msgf("%s\n", headers) } if csvFile != nil { //nolint:errcheck // this method needs a small refactor to reduce complexity csvFile.WriteString(headers + "\n") } } if r.options.StoreResponseDir != "" { var err error responseDirPath := filepath.Join(r.options.StoreResponseDir, "response") if err := os.MkdirAll(responseDirPath, 0755); err != nil { gologger.Fatal().Msgf("Could not create response directory '%s': %s\n", responseDirPath, err) } indexPath := filepath.Join(responseDirPath, "index.txt") // Append if resume is enabled or skip-dedupe is enabled (never truncate with -sd) if r.options.Resume || r.options.SkipDedupe { indexFile, err = os.OpenFile(indexPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) } else { indexFile, err = os.Create(indexPath) } if err != nil { gologger.Fatal().Msgf("Could not open/create index file '%s': %s\n", r.options.Output, err) } defer indexFile.Close() //nolint } if r.options.Screenshot { var err error indexScreenshotPath := filepath.Join(r.options.StoreResponseDir, "screenshot", "index_screenshot.txt") // Append if resume is enabled or skip-dedupe is enabled (never truncate with -sd) if r.options.Resume || r.options.SkipDedupe { indexScreenshotFile, err = os.OpenFile(indexScreenshotPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) } else { indexScreenshotFile, err = os.Create(indexScreenshotPath) } if err != nil { gologger.Fatal().Msgf("Could not open/create index screenshot file '%s': %s\n", r.options.Output, err) } defer indexScreenshotFile.Close() //nolint } for resp := range output { if r.options.SniName != "" { resp.SNI = r.options.SniName } if resp.Err != nil { // Change the error message if any port value passed explicitly if url, err := r.parseURL(resp.URL); err == nil && url.Port() != "" { resp.Err = errors.New(strings.ReplaceAll(resp.Err.Error(), "address", "port")) } gologger.Debug().Msgf("Failed '%s': %s\n", resp.URL, resp.Err) } if resp.str == "" { continue } // apply matchers and filters if r.options.OutputFilterCondition != "" || r.options.OutputMatchCondition != "" { if r.options.OutputMatchCondition != "" { matched := evalDslExpr(resp, r.options.OutputMatchCondition) if !matched { continue } } if r.options.OutputFilterCondition != "" { matched := evalDslExpr(resp, r.options.OutputFilterCondition) if matched { continue } } } if len(r.options.OutputFilterPageType) > 0 { if pageType, ok := resp.KnowledgeBase["PageType"].(string); ok { if stringsutil.EqualFoldAny(pageType, r.options.OutputFilterPageType...) { logFilteredErrorPage(r.options.OutputFilterErrorPagePath, resp.URL) continue } } } if r.options.FilterOutDuplicates && r.duplicate(&resp) { continue } if len(r.options.filterStatusCode) > 0 && sliceutil.Contains(r.options.filterStatusCode, resp.StatusCode) { continue } if len(r.options.filterContentLength) > 0 && sliceutil.Contains(r.options.filterContentLength, resp.ContentLength) { continue } if len(r.options.filterLinesCount) > 0 && sliceutil.Contains(r.options.filterLinesCount, resp.Lines) { continue } if len(r.options.filterWordsCount) > 0 && sliceutil.Contains(r.options.filterWordsCount, resp.Words) { continue } if r.options.filterRegexes != nil { shouldContinue := false for _, filterRegex := range r.options.filterRegexes { if filterRegex.MatchString(resp.Raw) { shouldContinue = true break } } if shouldContinue { continue } } if len(r.options.OutputFilterString) > 0 && stringsutil.ContainsAnyI(resp.Raw, r.options.OutputFilterString...) { continue } if len(r.options.OutputFilterFavicon) > 0 && stringsutil.ContainsAnyI(resp.FavIconMMH3, r.options.OutputFilterFavicon...) { continue } if len(r.options.matchStatusCode) > 0 && !sliceutil.Contains(r.options.matchStatusCode, resp.StatusCode) { continue } if len(r.options.matchContentLength) > 0 && !sliceutil.Contains(r.options.matchContentLength, resp.ContentLength) { continue } if r.options.matchRegexes != nil { shouldContinue := false for _, matchRegex := range r.options.matchRegexes { if !matchRegex.MatchString(resp.Raw) { shouldContinue = true break } } if shouldContinue { continue } } if len(r.options.OutputMatchString) > 0 && !stringsutil.ContainsAnyI(resp.Raw, r.options.OutputMatchString...) { continue } if len(r.options.OutputMatchFavicon) > 0 && !stringsutil.EqualFoldAny(resp.FavIconMMH3, r.options.OutputMatchFavicon...) { continue } if len(r.options.matchLinesCount) > 0 && !sliceutil.Contains(r.options.matchLinesCount, resp.Lines) { continue } if len(r.options.matchWordsCount) > 0 && !sliceutil.Contains(r.options.matchWordsCount, resp.Words) { continue } if len(r.options.OutputMatchCdn) > 0 && !stringsutil.EqualFoldAny(resp.CDNName, r.options.OutputMatchCdn...) { continue } if len(r.options.OutputFilterCdn) > 0 && stringsutil.EqualFoldAny(resp.CDNName, r.options.OutputFilterCdn...) { continue } if r.options.OutputMatchResponseTime != "" { filterOps := FilterOperator{flag: "-mrt, -match-response-time"} operator, value, err := filterOps.Parse(r.options.OutputMatchResponseTime) if err != nil { gologger.Fatal().Msg(err.Error()) } respTimeTaken, _ := time.ParseDuration(resp.ResponseTime) switch operator { // take negation of >= and > case greaterThanEq, greaterThan: if respTimeTaken < value { continue } // take negation of <= and < case lessThanEq, lessThan: if respTimeTaken > value { continue } // take negation of = case equal: if respTimeTaken != value { continue } // take negation of != case notEq: if respTimeTaken == value { continue } } } if r.options.OutputFilterResponseTime != "" { filterOps := FilterOperator{flag: "-frt, -filter-response-time"} operator, value, err := filterOps.Parse(r.options.OutputFilterResponseTime) if err != nil { gologger.Fatal().Msg(err.Error()) } respTimeTaken, _ := time.ParseDuration(resp.ResponseTime) switch operator { case greaterThanEq: if respTimeTaken >= value { continue } case lessThanEq: if respTimeTaken <= value { continue } case equal: if respTimeTaken == value { continue } case lessThan: if respTimeTaken < value { continue } case greaterThan: if respTimeTaken > value { continue } case notEq: if respTimeTaken != value { continue } } } if !r.options.DisableStdout && (!jsonOrCsvOrMD || jsonAndCsvAndMD || r.options.OutputAll) { gologger.Silent().Msgf("%s\n", resp.str) } // store responses or chain in directory if resp.Err == nil { URL, _ := urlutil.Parse(resp.URL) domainResponseFile := fmt.Sprintf("%s.txt", resp.FileNameHash) screenshotResponseFile := fmt.Sprintf("%s.png", resp.FileNameHash) hostFilename := strings.ReplaceAll(URL.Host, ":", "_") domainResponseBaseDir := filepath.Join(r.options.StoreResponseDir, "response") domainScreenshotBaseDir := filepath.Join(r.options.StoreResponseDir, "screenshot") responseBaseDir := filepath.Join(domainResponseBaseDir, hostFilename) screenshotBaseDir := filepath.Join(domainScreenshotBaseDir, hostFilename) var responsePath, screenshotPath, screenshotPathRel string // store response if r.scanopts.StoreResponse || r.scanopts.StoreChain { if r.scanopts.OmitBody { resp.Raw = strings.ReplaceAll(resp.Raw, resp.ResponseBody, "") } responsePath = fileutilz.AbsPathOrDefault(filepath.Join(responseBaseDir, domainResponseFile)) // URL.EscapedString returns that can be used as filename respRaw := resp.Raw reqRaw := resp.RequestRaw if len(respRaw) > r.scanopts.MaxResponseBodySizeToSave { respRaw = respRaw[:r.scanopts.MaxResponseBodySizeToSave] } data := reqRaw if r.options.StoreChain && resp.Response != nil && resp.Response.HasChain() { data = append(data, append([]byte("\n"), []byte(resp.Response.GetChain())...)...) } data = append(data, respRaw...) data = append(data, []byte("\n\n\n")...) data = append(data, []byte(resp.URL)...) _ = fileutil.CreateFolder(responseBaseDir) writeErr := os.WriteFile(responsePath, data, 0644) if writeErr != nil { gologger.Error().Msgf("Could not write response at path '%s', to disk: %s", responsePath, writeErr) } resp.StoredResponsePath = responsePath } if r.scanopts.Screenshot { screenshotPath = fileutilz.AbsPathOrDefault(filepath.Join(screenshotBaseDir, screenshotResponseFile)) screenshotPathRel = filepath.Join(hostFilename, screenshotResponseFile) _ = fileutil.CreateFolder(screenshotBaseDir) err := os.WriteFile(screenshotPath, resp.ScreenshotBytes, 0644) if err != nil { gologger.Error().Msgf("Could not write screenshot at path '%s', to disk: %s", screenshotPath, err) } resp.ScreenshotPath = screenshotPath resp.ScreenshotPathRel = screenshotPathRel if r.scanopts.NoScreenshotBytes { resp.ScreenshotBytes = []byte{} } } if indexFile != nil { indexData := fmt.Sprintf("%s %s (%d %s)\n", resp.StoredResponsePath, resp.URL, resp.StatusCode, http.StatusText(resp.StatusCode)) _, _ = indexFile.WriteString(indexData) } if indexScreenshotFile != nil && resp.ScreenshotPathRel != "" { indexData := fmt.Sprintf("%s %s (%d %s)\n", resp.ScreenshotPathRel, resp.URL, resp.StatusCode, http.StatusText(resp.StatusCode)) _, _ = indexScreenshotFile.WriteString(indexData) } if r.scanopts.StoreVisionReconClusters { foundCluster := false pHash, _ := resp.KnowledgeBase["pHash"].(uint64) for i, cluster := range r.pHashClusters { distance, _ := goimagehash.NewImageHash(pHash, goimagehash.PHash).Distance(goimagehash.NewImageHash(cluster.BasePHash, goimagehash.PHash)) if distance <= hammingDistanceThreshold { r.pHashClusters[i].Hashes = append(r.pHashClusters[i].Hashes, pHashUrl{PHash: pHash, Url: resp.URL}) foundCluster = true break } } if !foundCluster { newCluster := pHashCluster{ BasePHash: pHash, Hashes: []pHashUrl{{PHash: pHash, Url: resp.URL}}, } r.pHashClusters = append(r.pHashClusters, newCluster) } } } //nolint:errcheck // this method needs a small refactor to reduce complexity if plainFile != nil { plainFile.WriteString(handleStripAnsiCharacters(resp.str, r.options.NoColor) + "\n") } if len(r.options.ExcludeOutputFields) > 0 { if filteredData, err := structs.FilterStruct(resp, nil, r.options.ExcludeOutputFields); err == nil { resp = filteredData } } // call the callback function if any // be careful and check for result.Err if r.options.OnResult != nil { r.options.OnResult(resp) } if r.options.JSONOutput { row := resp.JSON(&r.scanopts) if !r.options.OutputAll && !jsonAndCsvAndMD { gologger.Silent().Msgf("%s\n", row) } //nolint:errcheck // this method needs a small refactor to reduce complexity if jsonFile != nil { jsonFile.WriteString(row + "\n") } } if r.options.CSVOutput { row := resp.CSVRow(&r.scanopts) if !r.options.OutputAll && !jsonAndCsvAndMD { gologger.Silent().Msgf("%s\n", row) } //nolint:errcheck // this method needs a small refactor to reduce complexity if csvFile != nil { csvFile.WriteString(row + "\n") } } if r.options.MarkDownOutput || r.options.OutputAll { if !markdownHeaderWritten { header := resp.MarkdownHeader() if !r.options.OutputAll { gologger.Silent().Msgf("%s", header) } if mdFile != nil { _, _ = mdFile.WriteString(header) } markdownHeaderWritten = true } row := resp.MarkdownRow(&r.scanopts) if !r.options.OutputAll { gologger.Silent().Msgf("%s", row) } if mdFile != nil { _, _ = mdFile.WriteString(row) } } for _, nextStep := range nextSteps { nextStep <- resp } } }(output, nextStep) // HTML Summary // - needs output of previous routine // - separate goroutine due to incapability of go templates to render from file wgoutput.Add(1) go func(output chan Result) { defer wgoutput.Done() if r.options.Screenshot { screenshotHtmlPath := filepath.Join(r.options.StoreResponseDir, "screenshot", "screenshot.html") screenshotHtml, err := os.Create(screenshotHtmlPath) if err != nil { gologger.Warning().Msgf("Could not create HTML file %s\n", err) } defer func() { _ = screenshotHtml.Close() }() templateMap := template.FuncMap{ "safeURL": func(u string) template.URL { if osutil.IsWindows() { u = filepath.ToSlash(u) } return template.URL(u) }, } tmpl, err := template. New("screenshotTemplate"). Funcs(templateMap). Parse(static.HtmlTemplate) if err != nil { gologger.Warning().Msgf("Could not create HTML template: %v\n", err) } if err = tmpl.Execute(screenshotHtml, struct { Options Options Output chan Result }{ Options: *r.options, Output: output, }); err != nil { gologger.Warning().Msgf("Could not execute HTML template: %v\n", err) } } // fallthrough if anything is left in the buffer unblocks if screenshot is false for range output { } }(nextStep) wg, _ := syncutil.New(syncutil.WithSize(r.options.Threads)) processItem := func(k string) error { select { case <-r.interruptCh: return nil default: } if r.options.resumeCfg != nil { r.options.resumeCfg.current = k r.options.resumeCfg.currentIndex++ if r.options.resumeCfg.currentIndex <= r.options.resumeCfg.Index { return nil } } protocol := r.options.protocol // attempt to parse url as is if u, err := r.parseURL(k); err == nil { if r.options.NoFallbackScheme && u.Scheme == httpx.HTTP || u.Scheme == httpx.HTTPS { protocol = u.Scheme } } runProcess := func(times int) { for i := 0; i < times; i++ { if len(r.options.requestURIs) > 0 { for _, p := range r.options.requestURIs { scanopts := r.scanopts.Clone() scanopts.RequestURI = p r.process(k, wg, r.hp, protocol, scanopts, output) } } else { r.process(k, wg, r.hp, protocol, &r.scanopts, output) } } } if r.options.Stream { runProcess(1) } else if v, ok := r.hm.Get(k); ok { cnt, err := strconv.Atoi(string(v)) if err != nil || cnt <= 0 { cnt = 1 } runProcess(cnt) } return nil } if r.options.Stream { for item := range streamChan { if r.IsInterrupted() { break } _ = processItem(item) } } else { r.hm.Scan(func(k, _ []byte) error { return processItem(string(k)) }) } wg.Wait() close(output) wgoutput.Wait() if r.scanopts.StoreVisionReconClusters { visionReconClusters := filepath.Join(r.options.StoreResponseDir, "vision_recon_clusters.json") clusterReportJSON, err := json.Marshal(r.pHashClusters) if err != nil { gologger.Fatal().Msgf("Failed to marshal report to JSON: %v", err) } file, err := os.Create(visionReconClusters) if err != nil { gologger.Fatal().Msgf("Failed to create JSON file: %v", err) } defer func() { _ = file.Close() }() _, err = file.Write(clusterReportJSON) if err != nil { gologger.Fatal().Msgf("Failed to write to JSON file: %v", err) } } } func handleStripAnsiCharacters(data string, skip bool) string { if skip { return data } return stripANSI(data) } func logFilteredErrorPage(fileName, url string) { dir := filepath.Dir(fileName) if !fileutil.FolderExists(dir) { err := fileutil.CreateFolder(dir) if err != nil { gologger.Fatal().Msgf("Could not create directory '%s': %s\n", dir, err) return } } file, err := fileutil.OpenOrCreateFile(fileName) if err != nil { gologger.Fatal().Msgf("Could not open/create output file '%s': %s\n", fileName, err) return } defer func() { _ = file.Close() }() info := map[string]interface{}{ "url": url, "time_filtered": time.Now(), } data, err := json.Marshal(info) if err != nil { fmt.Println("Failed to marshal JSON:", err) return } if _, err := file.Write(data); err != nil { gologger.Fatal().Msgf("Failed to write to '%s': %s\n", fileName, err) return } if _, err := file.WriteString("\n"); err != nil { gologger.Fatal().Msgf("Failed to write newline to '%s': %s\n", fileName, err) return } } func openOrCreateFile(resume bool, filename string) *os.File { var err error var f *os.File if resume { f, err = os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) } else { f, err = os.Create(filename) } if err != nil { gologger.Fatal().Msgf("Could not open/create output file '%s': %s\n", filename, err) } return f } func (r *Runner) GetScanOpts() ScanOptions { return r.scanopts } func (r *Runner) Process(t string, wg *syncutil.AdaptiveWaitGroup, protocol string, scanopts *ScanOptions, output chan Result) { r.process(t, wg, r.hp, protocol, scanopts, output) } func (r *Runner) process(t string, wg *syncutil.AdaptiveWaitGroup, hp *httpx.HTTPX, protocol string, scanopts *ScanOptions, output chan Result) { // attempts to set the workpool size to the number of threads if r.options.Threads > 0 && wg.Size != r.options.Threads { if err := wg.Resize(context.Background(), r.options.Threads); err != nil { gologger.Error().Msgf("Could not resize workpool: %s\n", err) } } protocols := []string{protocol} if scanopts.NoFallback || protocol == httpx.HTTPandHTTPS { protocols = []string{httpx.HTTPS, httpx.HTTP} } for target := range r.targets(hp, t) { // if no custom ports specified then test the default ones if len(customport.Ports) == 0 { for _, method := range scanopts.Methods { for _, prot := range protocols { // sleep for delay time time.Sleep(r.options.Delay) wg.Add() go func(target httpx.Target, method, protocol string) { defer wg.Done() result := r.analyze(hp, protocol, target, method, t, scanopts) output <- result if scanopts.TLSProbe && result.TLSData != nil { for _, tt := range result.TLSData.SubjectAN { if !r.testAndSet(tt) { continue } r.process(tt, wg, hp, protocol, scanopts, output) } if r.testAndSet(result.TLSData.SubjectCN) { r.process(result.TLSData.SubjectCN, wg, hp, protocol, scanopts, output) } } if scanopts.CSPProbe && result.CSPData != nil { scanopts.CSPProbe = false domains := result.CSPData.Domains domains = append(domains, result.CSPData.Fqdns...) for _, tt := range domains { if !r.testAndSet(tt) { continue } r.process(tt, wg, hp, protocol, scanopts, output) } } }(target, method, prot) } } } for port, wantedProtocolForPort := range customport.Ports { // NoFallbackScheme overrides custom ports scheme // Example: httpx -u https://www.example.com -ports http:8080,https:443 --no-fallback-scheme // In this case, the requests will be created with the target scheme (ignoring the custom ports scheme) // Examples: https://www.example.com:8080 and https://www.example.com:443 if scanopts.NoFallbackScheme { wantedProtocolForPort = protocol } wantedProtocols := []string{wantedProtocolForPort} if wantedProtocolForPort == httpx.HTTPandHTTPS { wantedProtocols = []string{httpx.HTTPS, httpx.HTTP} } for _, wantedProtocol := range wantedProtocols { for _, method := range scanopts.Methods { // sleep for delay time time.Sleep(r.options.Delay) wg.Add() go func(port int, target httpx.Target, method, protocol string) { defer wg.Done() if urlx, err := r.parseURL(target.Host); err != nil { gologger.Warning().Msgf("failed to update port of %v got %v", target.Host, err) } else { urlx.UpdatePort(fmt.Sprint(port)) target.Host = urlx.String() } result := r.analyze(hp, protocol, target, method, t, scanopts) output <- result if scanopts.TLSProbe && result.TLSData != nil { for _, tt := range result.TLSData.SubjectAN { if !r.testAndSet(tt) { continue } r.process(tt, wg, hp, protocol, scanopts, output) } if r.testAndSet(result.TLSData.SubjectCN) { r.process(result.TLSData.SubjectCN, wg, hp, protocol, scanopts, output) } } }(port, target, method, wantedProtocol) } } } if r.options.ShowStatistics { r.stats.IncrementCounter("hosts", 1) } } } // returns all the targets within a cidr range or the single target func (r *Runner) targets(hp *httpx.HTTPX, target string) chan httpx.Target { results := make(chan httpx.Target) go func() { defer close(results) target = strings.TrimSpace(target) switch { case stringsutil.HasPrefixAny(target, "*", "."): // A valid target does not contain: // trim * and/or . (prefix) from the target to return the domain instead of wildcard target = stringsutil.TrimPrefixAny(target, "*", ".") if !r.testAndSet(target) { return } results <- httpx.Target{Host: target} case asn.IsASN(target): cidrIps, err := asn.GetIPAddressesAsStream(target) if err != nil { gologger.Warning().Msgf("Could not get ASN targets for '%s': %s\n", target, err) return } for ip := range cidrIps { results <- httpx.Target{Host: ip} } case iputil.IsCIDR(target): cidrIps, err := mapcidr.IPAddressesAsStream(target) if err != nil { return } for ip := range cidrIps { results <- httpx.Target{Host: ip} } case r.options.ProbeAllIPS: URL, err := r.parseURL(target) if err != nil { results <- httpx.Target{Host: target} return } ips, _, _, err := getDNSData(hp, URL.Hostname()) if err != nil || len(ips) == 0 { results <- httpx.Target{Host: target} return } for _, ip := range ips { results <- httpx.Target{Host: target, CustomIP: ip} } case !stringsutil.HasPrefixAny(target, "http://", "https://") && stringsutil.ContainsAny(target, ","): idxComma := strings.Index(target, ",") results <- httpx.Target{Host: target[idxComma+1:], CustomHost: target[:idxComma]} default: results <- httpx.Target{Host: target} } }() return results } func (r *Runner) analyze(hp *httpx.HTTPX, protocol string, target httpx.Target, method, origInput string, scanopts *ScanOptions) Result { origProtocol := protocol if protocol == httpx.HTTPorHTTPS || protocol == httpx.HTTPandHTTPS { protocol = determineMostLikelySchemeOrder(target.Host) } retried := false retry: if scanopts.VHostInput && target.CustomHost == "" { return Result{Input: origInput} } URL, err := r.parseURL(target.Host) if err != nil { return Result{URL: target.Host, Input: origInput, Err: err} } // check if we have to skip the host:port as a result of a previous failure hostPort := net.JoinHostPort(URL.Host, URL.Port()) if r.options.HostMaxErrors >= 0 && r.HostErrorsCache.Has(hostPort) { numberOfErrors, err := r.HostErrorsCache.GetIFPresent(hostPort) if err == nil && numberOfErrors >= r.options.HostMaxErrors { return Result{URL: target.Host, Err: errors.New("skipping as previously unresponsive")} } } // check if the combination host:port should be skipped if belonging to a cdn skip, reason := r.skip(URL, target, origInput) if skip { return reason } URL.Scheme = protocol if !strings.Contains(target.Host, URL.Port()) { URL.TrimPort() } var reqURI string // retry with unsafe if err := URL.MergePath(scanopts.RequestURI, scanopts.Unsafe); err != nil { gologger.Debug().Msgf("failed to merge paths of url %v and %v", URL.String(), scanopts.RequestURI) } var ( req *retryablehttp.Request ctx context.Context ) if target.CustomIP != "" { var requestIP string if iputil.IsIPv6(target.CustomIP) { requestIP = fmt.Sprintf("[%s]", target.CustomIP) } else { requestIP = target.CustomIP } ctx = context.WithValue(context.Background(), fastdialer.IP, requestIP) } else { ctx = context.Background() } req, err = hp.NewRequestWithContext(ctx, method, URL.String()) if err != nil { return Result{URL: URL.String(), Input: origInput, Err: err} } if target.CustomHost != "" { req.Host = target.CustomHost } if !scanopts.LeaveDefaultPorts { switch { case protocol == httpx.HTTP && strings.HasSuffix(req.Host, ":80"): req.Host = strings.TrimSuffix(req.Host, ":80") case protocol == httpx.HTTPS && strings.HasSuffix(req.Host, ":443"): req.Host = strings.TrimSuffix(req.Host, ":443") } } hp.SetCustomHeaders(req, hp.CustomHeaders) // Apply auth strategies if auth provider is configured if r.authProvider != nil { if strategies := r.authProvider.LookupURLX(URL); len(strategies) > 0 { for _, strategy := range strategies { strategy.ApplyOnRR(req) } } } // We set content-length even if zero to allow net/http to follow 307/308 redirects (it fails on unknown size) if scanopts.RequestBody != "" { req.ContentLength = int64(len(scanopts.RequestBody)) req.Body = io.NopCloser(strings.NewReader(scanopts.RequestBody)) } else { req.ContentLength = 0 req.Body = nil } r.ratelimiter.Take() // with rawhttp we should say to the server to close the connection, otherwise it will remain open if scanopts.Unsafe { req.Header.Add("Connection", "close") } resp, err := hp.Do(req, httpx.UnsafeOptions{URIPath: reqURI}) if r.options.ShowStatistics { r.stats.IncrementCounter("requests", 1) } var requestDump []byte if scanopts.Unsafe { var errDump error requestDump, errDump = rawhttp.DumpRequestRaw(req.Method, req.String(), reqURI, req.Header, req.Body, rawhttp.DefaultOptions) if errDump != nil { return Result{URL: URL.String(), Input: origInput, Err: errDump} } } else { // Create a copy on the fly of the request body if scanopts.RequestBody != "" { req.ContentLength = int64(len(scanopts.RequestBody)) req.Body = io.NopCloser(strings.NewReader(scanopts.RequestBody)) } var errDump error requestDump, errDump = httputil.DumpRequestOut(req.Request, true) if errDump != nil { return Result{URL: URL.String(), Input: origInput, Err: errDump} } // The original req.Body gets modified indirectly by httputil.DumpRequestOut so we set it again to nil if it was empty // Otherwise redirects like 307/308 would fail (as they require the body to be sent along) if len(scanopts.RequestBody) == 0 { req.ContentLength = 0 req.Body = nil } } // fix the final output url fullURL := req.String() if parsedURL, errParse := r.parseURL(fullURL); errParse != nil { return Result{URL: URL.String(), Input: origInput, Err: errParse} } else { if r.options.Unsafe { parsedURL.Path = reqURI // if the full url doesn't end with the custom path we pick the original input value } else if !stringsutil.HasSuffixAny(fullURL, scanopts.RequestURI) { parsedURL.Path = scanopts.RequestURI } fullURL = parsedURL.String() } if r.options.Debug || r.options.DebugRequests { gologger.Info().Msgf("Dumped HTTP request for %s\n\n", fullURL) gologger.Print().Msgf("%s", string(requestDump)) } if (r.options.Debug || r.options.DebugResponse) && resp != nil { gologger.Info().Msgf("Dumped HTTP response for %s\n\n", fullURL) gologger.Print().Msgf("%s", string(resp.Raw)) } builder := &strings.Builder{} if scanopts.LeaveDefaultPorts { builder.WriteString(stringz.AddURLDefaultPort(fullURL)) } else { builder.WriteString(stringz.RemoveURLDefaultPort(fullURL)) } if r.options.Probe { builder.WriteString(" [") outputStatus := "SUCCESS" if err != nil { outputStatus = "FAILED" } if !scanopts.OutputWithNoColor && err != nil { builder.WriteString(aurora.Red(outputStatus).String()) } else if !scanopts.OutputWithNoColor && err == nil { builder.WriteString(aurora.Green(outputStatus).String()) } else { builder.WriteString(outputStatus) } builder.WriteRune(']') } if err != nil { errString := "" errString = err.Error() splitErr := strings.Split(errString, ":") errString = strings.TrimSpace(splitErr[len(splitErr)-1]) if !retried && origProtocol == httpx.HTTPorHTTPS { // switch protocol and adjust port accordingly if protocol == httpx.HTTPS { protocol = httpx.HTTP // if port is 443 (default HTTPS), switch to 80 (default HTTP) if URL.Port() == "443" { URL.UpdatePort("80") target.Host = net.JoinHostPort(URL.Hostname(), "80") } } else { protocol = httpx.HTTPS // if port is 80 (default HTTP), switch to 443 (default HTTPS) if URL.Port() == "80" { URL.UpdatePort("443") target.Host = net.JoinHostPort(URL.Hostname(), "443") } } retried = true goto retry } // mark the host:port as failed to avoid further checks if r.options.HostMaxErrors >= 0 { errorCount, err := r.HostErrorsCache.GetIFPresent(hostPort) if err != nil || errorCount == 0 { _ = r.HostErrorsCache.Set(hostPort, 1) } else if errorCount > 0 { _ = r.HostErrorsCache.Set(hostPort, errorCount+1) } } if r.options.Probe { return Result{URL: URL.String(), Input: origInput, Timestamp: time.Now(), Err: err, Failed: err != nil, Error: errString, str: builder.String()} } else { return Result{URL: URL.String(), Input: origInput, Timestamp: time.Now(), Err: err} } } if scanopts.OutputStatusCode { builder.WriteString(" [") setColor := func(statusCode int) { if !scanopts.OutputWithNoColor { // Color the status code based on its value switch { case statusCode >= http.StatusOK && statusCode < http.StatusMultipleChoices: builder.WriteString(aurora.Green(strconv.Itoa(statusCode)).String()) case statusCode >= http.StatusMultipleChoices && statusCode < http.StatusBadRequest: builder.WriteString(aurora.Yellow(strconv.Itoa(statusCode)).String()) case statusCode >= http.StatusBadRequest && statusCode < http.StatusInternalServerError: builder.WriteString(aurora.Red(strconv.Itoa(statusCode)).String()) case resp.StatusCode > http.StatusInternalServerError: builder.WriteString(aurora.Bold(aurora.Yellow(strconv.Itoa(statusCode))).String()) } } else { builder.WriteString(strconv.Itoa(statusCode)) } } for i, chainItem := range resp.Chain { setColor(chainItem.StatusCode) if i != len(resp.Chain)-1 { builder.WriteRune(',') } } if r.options.Unsafe { setColor(resp.StatusCode) } builder.WriteRune(']') } if scanopts.OutputLocation { builder.WriteString(" [") if !scanopts.OutputWithNoColor { builder.WriteString(aurora.Magenta(resp.GetHeaderPart("Location", ";")).String()) } else { builder.WriteString(resp.GetHeaderPart("Location", ";")) } builder.WriteRune(']') } if scanopts.OutputMethod { builder.WriteString(" [") if !scanopts.OutputWithNoColor { builder.WriteString(aurora.Magenta(method).String()) } else { builder.WriteString(method) } builder.WriteRune(']') } if scanopts.OutputContentLength { builder.WriteString(" [") if !scanopts.OutputWithNoColor { builder.WriteString(aurora.Magenta(strconv.Itoa(resp.ContentLength)).String()) } else { builder.WriteString(strconv.Itoa(resp.ContentLength)) } builder.WriteRune(']') } if scanopts.OutputContentType { builder.WriteString(" [") if !scanopts.OutputWithNoColor { builder.WriteString(aurora.Magenta(resp.GetHeaderPart("Content-Type", ";")).String()) } else { builder.WriteString(resp.GetHeaderPart("Content-Type", ";")) } builder.WriteRune(']') } var title string if httpx.CanHaveTitleTag(resp.GetHeaderPart("Content-Type", ";")) { title = httpx.ExtractTitle(resp) } if scanopts.OutputTitle && title != "" { builder.WriteString(" [") if !scanopts.OutputWithNoColor { builder.WriteString(aurora.Cyan(title).String()) } else { builder.WriteString(title) } builder.WriteRune(']') } var bodyPreview string if r.options.ResponseBodyPreviewSize > 0 && resp != nil { bodyPreview = string(resp.Data) if stringsutil.EqualFoldAny(r.options.StripFilter, "html", "xml") { bodyPreview = r.hp.Sanitize(bodyPreview, true, true) } else { bodyPreview = strings.ReplaceAll(bodyPreview, "\n", "\\n") bodyPreview = httputilz.NormalizeSpaces(bodyPreview) } if len(bodyPreview) > r.options.ResponseBodyPreviewSize { bodyPreview = bodyPreview[:r.options.ResponseBodyPreviewSize] } bodyPreview = strings.TrimSpace(bodyPreview) builder.WriteString(" [") if !scanopts.OutputWithNoColor { builder.WriteString(aurora.Blue(bodyPreview).String()) } else { builder.WriteString(bodyPreview) } builder.WriteRune(']') } serverHeader := resp.GetHeader("Server") if scanopts.OutputServerHeader { _, _ = fmt.Fprintf(builder, " [%s]", serverHeader) } var ( serverResponseRaw string request string rawResponseHeaders string responseHeaders map[string]interface{} linkRequest []NetworkRequest ) if scanopts.ResponseHeadersInStdout { responseHeaders = normalizeHeaders(resp.Headers) } respData := string(resp.Data) if r.options.NoDecode { respData = string(resp.RawData) } if scanopts.ResponseInStdout || r.options.OutputMatchCondition != "" || r.options.OutputFilterCondition != "" { serverResponseRaw = string(respData) request = string(requestDump) responseHeaders = normalizeHeaders(resp.Headers) rawResponseHeaders = resp.RawHeaders } else if scanopts.Base64ResponseInStdout { serverResponseRaw = stringz.Base64([]byte(respData)) request = stringz.Base64(requestDump) responseHeaders = normalizeHeaders(resp.Headers) rawResponseHeaders = stringz.Base64([]byte(resp.RawHeaders)) } // check for virtual host isvhost := false if scanopts.VHost { r.ratelimiter.Take() isvhost, _ = hp.IsVirtualHost(req, httpx.UnsafeOptions{}) if isvhost { builder.WriteString(" [vhost]") } } // web socket isWebSocket := isWebSocket(resp) if scanopts.OutputWebSocket && isWebSocket { builder.WriteString(" [websocket]") } pipeline := false if scanopts.Pipeline { port := 0 if portStr := URL.Port(); portStr != "" { if p, err := strconv.Atoi(portStr); err == nil { port = p } } r.ratelimiter.Take() pipeline = hp.SupportPipeline(protocol, method, URL.Host, port) if pipeline { builder.WriteString(" [pipeline]") } if r.options.ShowStatistics { r.stats.IncrementCounter("requests", 1) } } var http2 bool // if requested probes for http2 if scanopts.HTTP2Probe { r.ratelimiter.Take() http2 = hp.SupportHTTP2(protocol, method, URL.String()) if http2 { builder.WriteString(" [http2]") } if r.options.ShowStatistics { r.stats.IncrementCounter("requests", 1) } } var ip string if target.CustomIP != "" { ip = target.CustomIP } else { if onlyHost, _, err := net.SplitHostPort(URL.Host); err == nil && iputil.IsIP(onlyHost) { ip = onlyHost } else { // hp.Dialer.GetDialedIP would return only the last dialed one ip = hp.Dialer.GetDialedIP(URL.Host) if ip == "" { ip = hp.Dialer.GetDialedIP(onlyHost) } } } var asnResponse *AsnResponse if r.options.Asn { results, _ := asnmap.DefaultClient.GetData(ip) if len(results) > 0 { var cidrs []string ipnets, _ := asnmap.GetCIDR(results) for _, ipnet := range ipnets { cidrs = append(cidrs, ipnet.String()) } asnResponse = &AsnResponse{ AsNumber: fmt.Sprintf("AS%v", results[0].ASN), AsName: results[0].Org, AsCountry: results[0].Country, AsRange: cidrs, } builder.WriteString(" [") if !scanopts.OutputWithNoColor { builder.WriteString(aurora.Magenta(asnResponse.String()).String()) } else { builder.WriteString(asnResponse.String()) } builder.WriteRune(']') } } if scanopts.OutputIP || scanopts.ProbeAllIPS { _, _ = fmt.Fprintf(builder, " [%s]", ip) } var onlyHost string onlyHost, _, err = net.SplitHostPort(URL.Host) if err != nil { onlyHost = URL.Host } allIps, cnames, resolvers, err := getDNSData(hp, onlyHost) if err != nil { allIps = append(allIps, ip) } var ips4, ips6 []string for _, ip := range allIps { switch { case iputil.IsIPv4(ip): ips4 = append(ips4, ip) case iputil.IsIPv6(ip): ips6 = append(ips6, ip) } } if scanopts.OutputCName && len(cnames) > 0 { // Print only the first CNAME (full list in json) _, _ = fmt.Fprintf(builder, " [%s]", cnames[0]) } isCDN, cdnName, cdnType, err := hp.CdnCheck(ip) if scanopts.OutputCDN == "true" && isCDN && err == nil { _, _ = fmt.Fprintf(builder, " [%s]", cdnName) } if scanopts.OutputResponseTime { _, _ = fmt.Fprintf(builder, " [%s]", resp.Duration) } technologyDetails := make(map[string]wappalyzer.AppInfo) var technologies []string if scanopts.TechDetect { matches := r.wappalyzer.FingerprintWithInfo(resp.Headers, resp.Data) for match, data := range matches { technologies = append(technologies, match) technologyDetails[match] = data } } var extractRegex []string // extract regex var extractResult = map[string][]string{} if scanopts.extractRegexps != nil { for regex, compiledRegex := range scanopts.extractRegexps { matches := compiledRegex.FindAllString(string(resp.Raw), -1) if len(matches) > 0 { matches = sliceutil.Dedupe(matches) builder.WriteString(" [" + strings.Join(matches, ",") + "]") extractResult[regex] = matches } } } var finalURL string if resp.HasChain() { // Populate finalURL with the last URL in the chain, but just print it out in CLI mode if OutputLocation is set. // This way, we can still use the finalURL in JSON output. finalURL = resp.GetChainLastURL() if scanopts.OutputLocation { builder.WriteString(" [") if !scanopts.OutputWithNoColor { builder.WriteString(aurora.Magenta(finalURL).String()) } else { builder.WriteString(finalURL) } builder.WriteRune(']') } } var faviconMMH3, faviconMD5, faviconPath, faviconURL string var faviconData []byte if scanopts.Favicon { var err error faviconMMH3, faviconMD5, faviconPath, faviconData, faviconURL, err = r.HandleFaviconHash(hp, req, resp.Data, finalURL, true) if err == nil { builder.WriteString(" [") if !scanopts.OutputWithNoColor { builder.WriteString(aurora.Magenta(faviconMMH3).String()) } else { builder.WriteString(faviconMMH3) } builder.WriteRune(']') } else { gologger.Warning().Msgf("could not calculate favicon hash for path %v : %s", faviconPath, err) } } hashesMap := make(map[string]interface{}) if scanopts.Hashes != "" { hs := strings.Split(scanopts.Hashes, ",") outputHashes := !(r.options.JSONOutput || r.options.OutputAll) //nolint if outputHashes { builder.WriteString(" [") } for index, hashType := range hs { var ( hashHeader, hashBody string ) hashType = strings.ToLower(hashType) switch hashType { case "md5": hashBody = hashes.Md5(resp.Data) hashHeader = hashes.Md5([]byte(resp.RawHeaders)) case "mmh3": hashBody = hashes.Mmh3(resp.Data) hashHeader = hashes.Mmh3([]byte(resp.RawHeaders)) case "sha1": hashBody = hashes.Sha1(resp.Data) hashHeader = hashes.Sha1([]byte(resp.RawHeaders)) case "sha256": hashBody = hashes.Sha256(resp.Data) hashHeader = hashes.Sha256([]byte(resp.RawHeaders)) case "sha512": hashBody = hashes.Sha512(resp.Data) hashHeader = hashes.Sha512([]byte(resp.RawHeaders)) case "simhash": hashBody = hashes.Simhash(resp.Data) hashHeader = hashes.Simhash([]byte(resp.RawHeaders)) } if hashBody != "" { hashesMap[fmt.Sprintf("body_%s", hashType)] = hashBody hashesMap[fmt.Sprintf("header_%s", hashType)] = hashHeader if outputHashes { if !scanopts.OutputWithNoColor { builder.WriteString(aurora.Magenta(hashBody).String()) } else { builder.WriteString(hashBody) } if index != len(hs)-1 { builder.WriteString(",") } } } } if outputHashes { builder.WriteRune(']') } } if scanopts.OutputLinesCount { builder.WriteString(" [") if !scanopts.OutputWithNoColor { builder.WriteString(aurora.Magenta(resp.Lines).String()) } else { _, _ = fmt.Fprintf(builder, "%d", resp.Lines) } builder.WriteRune(']') } jarmhash := "" if r.options.Jarm { jarmhash = jarm.Jarm(r.hp.Dialer, fullURL, r.options.Timeout) builder.WriteString(" [") if !scanopts.OutputWithNoColor { builder.WriteString(aurora.Magenta(jarmhash).String()) } else { _, _ = fmt.Fprintf(builder, "%s", jarmhash) } builder.WriteRune(']') } if scanopts.OutputWordsCount { builder.WriteString(" [") if !scanopts.OutputWithNoColor { builder.WriteString(aurora.Magenta(resp.Words).String()) } else { _, _ = fmt.Fprintf(builder, "%d", resp.Words) } builder.WriteRune(']') } // store responses or chain in directory domainFile := method + ":" + URL.EscapedString() hash := hashes.Sha1([]byte(domainFile)) domainResponseFile := fmt.Sprintf("%s.txt", hash) hostFilename := strings.ReplaceAll(URL.Host, ":", "_") domainResponseBaseDir := filepath.Join(scanopts.StoreResponseDirectory, "response") responseBaseDir := filepath.Join(domainResponseBaseDir, hostFilename) var responsePath, fileNameHash string // store response — when matchers/filters are active, defer writing to the // output loop so only matched responses are persisted to disk. if scanopts.StoreResponse || scanopts.StoreChain { fileNameHash = hash if !r.options.HasMatcherOrFilter() { if r.options.OmitBody { resp.Raw = strings.ReplaceAll(resp.Raw, string(resp.Data), "") } responsePath = fileutilz.AbsPathOrDefault(filepath.Join(responseBaseDir, domainResponseFile)) // URL.EscapedString returns that can be used as filename respRaw := resp.Raw reqRaw := requestDump if len(respRaw) > scanopts.MaxResponseBodySizeToSave { respRaw = respRaw[:scanopts.MaxResponseBodySizeToSave] } data := reqRaw if scanopts.StoreChain && resp.HasChain() { data = append(data, append([]byte("\n"), []byte(resp.GetChain())...)...) } data = append(data, respRaw...) data = append(data, []byte("\n\n\n")...) data = append(data, []byte(fullURL)...) _ = fileutil.CreateFolder(responseBaseDir) basePath := strings.TrimSuffix(responsePath, ".txt") var idx int for idx = 0; ; idx++ { targetPath := responsePath if idx > 0 { targetPath = fmt.Sprintf("%s_%d.txt", basePath, idx) } f, err := os.OpenFile(targetPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644) if err == nil { _, writeErr := f.Write(data) _ = f.Close() if writeErr != nil { gologger.Error().Msgf("Could not write to '%s': %s", targetPath, writeErr) } break } if !os.IsExist(err) { gologger.Error().Msgf("Failed to create file '%s': %s", targetPath, err) break } } if idx > 0 { fileNameHash = fmt.Sprintf("%s_%d", hash, idx) } } } parsed, err := r.parseURL(fullURL) if err != nil { return Result{URL: fullURL, Input: origInput, Err: errors.Wrap(err, "could not parse url")} } finalPort := parsed.Port() if finalPort == "" { if parsed.Scheme == "http" { finalPort = "80" } else { finalPort = "443" } } finalPath := parsed.RequestURI() if finalPath == "" { finalPath = "/" } var chainStatusCodes []int if resp.HasChain() { chainStatusCodes = append(chainStatusCodes, resp.GetChainStatusCodes()...) } var chainItems []httpx.ChainItem if scanopts.ChainInStdout && resp.HasChain() { chainItems = append(chainItems, resp.GetChainAsSlice()...) } // screenshot var ( screenshotBytes []byte headlessBody string ) var pHash uint64 if scanopts.Screenshot { var err error screenshotBytes, headlessBody, linkRequest, err = r.browser.ScreenshotWithBody( fullURL, scanopts.ScreenshotTimeout, scanopts.ScreenshotIdle, r.options.CustomHeaders, scanopts.IsScreenshotFullPage(), r.options.JavascriptCodes, ) if err != nil { gologger.Warning().Msgf("Could not take screenshot '%s': %s", fullURL, err) } else { pHash, err = calculatePerceptionHash(screenshotBytes) if err != nil { gologger.Warning().Msgf("%v: %s", err, fullURL) } // As we now have headless body, we can also use it for detecting // more technologies in the response. This is a quick trick to get // more detected technologies. if r.options.TechDetect || r.options.JSONOutput || r.options.CSVOutput { moreMatches := r.wappalyzer.FingerprintWithInfo(resp.Headers, []byte(headlessBody)) for match, data := range moreMatches { technologies = append(technologies, match) technologyDetails[match] = data } technologies = sliceutil.Dedupe(technologies) } } if scanopts.NoHeadlessBody { headlessBody = "" } } if scanopts.TechDetect && len(technologies) > 0 { sort.Strings(technologies) technologies := strings.Join(technologies, ",") // only print to console if tech-detect flag is enabled // scanopts.TechDetect implicitly enabled for json , csv and asset-upload if r.options.TechDetect { builder.WriteString(" [") if !scanopts.OutputWithNoColor { builder.WriteString(aurora.Magenta(technologies).String()) } else { builder.WriteString(technologies) } builder.WriteRune(']') } } var cpeMatches []CPEInfo if r.cpeDetector != nil { cpeMatches = r.cpeDetector.Detect(title, string(resp.Data), faviconMMH3) if len(cpeMatches) > 0 && r.options.CPEDetect { for _, cpe := range cpeMatches { builder.WriteString(" [") if !scanopts.OutputWithNoColor { builder.WriteString(aurora.Cyan(cpe.CPE).String()) } else { builder.WriteString(cpe.CPE) } builder.WriteRune(']') } } } var wpInfo *WordPressInfo if r.wpDetector != nil { wpInfo = r.wpDetector.Detect(string(resp.Data)) if wpInfo.HasData() && r.options.WordPress { if len(wpInfo.Plugins) > 0 { builder.WriteString(" [") if !scanopts.OutputWithNoColor { builder.WriteString(aurora.Green("wp-plugins:" + strings.Join(wpInfo.Plugins, ",")).String()) } else { builder.WriteString("wp-plugins:" + strings.Join(wpInfo.Plugins, ",")) } builder.WriteRune(']') } if len(wpInfo.Themes) > 0 { builder.WriteString(" [") if !scanopts.OutputWithNoColor { builder.WriteString(aurora.Green("wp-themes:" + strings.Join(wpInfo.Themes, ",")).String()) } else { builder.WriteString("wp-themes:" + strings.Join(wpInfo.Themes, ",")) } builder.WriteRune(']') } } } result := Result{ Timestamp: time.Now(), Request: request, LinkRequest: linkRequest, ResponseHeaders: responseHeaders, RawHeaders: rawResponseHeaders, Scheme: parsed.Scheme, Port: finalPort, Path: finalPath, Raw: resp.Raw, URL: fullURL, Input: origInput, ContentLength: resp.ContentLength, ChainStatusCodes: chainStatusCodes, Chain: chainItems, StatusCode: resp.StatusCode, Location: resp.GetHeaderPart("Location", ";"), ContentType: resp.GetHeaderPart("Content-Type", ";"), Title: title, str: builder.String(), VHost: isvhost, WebServer: serverHeader, ResponseBody: serverResponseRaw, BodyPreview: bodyPreview, WebSocket: isWebSocket, TLSData: resp.TLSData, CSPData: resp.CSPData, Pipeline: pipeline, HTTP2: http2, Method: method, Host: parsed.Hostname(), HostIP: ip, A: ips4, AAAA: ips6, CNAMEs: cnames, CDN: isCDN, CDNName: cdnName, CDNType: cdnType, ResponseTime: resp.Duration.String(), Technologies: technologies, FinalURL: finalURL, FavIconMMH3: faviconMMH3, FavIconMD5: faviconMD5, FaviconPath: faviconPath, FaviconURL: faviconURL, Hashes: hashesMap, Extracts: extractResult, JarmHash: jarmhash, Lines: resp.Lines, Words: resp.Words, ASN: asnResponse, ExtractRegex: extractRegex, ScreenshotBytes: screenshotBytes, HeadlessBody: headlessBody, KnowledgeBase: r.classifyPage(headlessBody, respData, pHash), TechnologyDetails: technologyDetails, Resolvers: resolvers, RequestRaw: requestDump, Response: resp, FaviconData: faviconData, FileNameHash: fileNameHash, CPE: cpeMatches, WordPress: wpInfo, } if resp.BodyDomains != nil { result.Fqdns = resp.BodyDomains.Fqdns result.Domains = resp.BodyDomains.Domains } if r.options.Trace { result.Trace = req.TraceInfo } return result } func (r *Runner) skip(URL *urlutil.URL, target httpx.Target, origInput string) (bool, Result) { if r.skipCDNPort(URL.Hostname(), URL.Port()) { gologger.Debug().Msgf("Skipping cdn target: %s:%s\n", URL.Host, URL.Port()) return true, Result{URL: target.Host, Input: origInput, Err: errors.New("cdn target only allows ports 80 and 443")} } if !r.hp.NetworkPolicy.Validate(URL.Host) { gologger.Debug().Msgf("Skipping target due to network policy: %s\n", URL.Hostname()) return true, Result{URL: target.Host, Input: origInput, Err: errors.New("target host is not allowed by network policy")} } return false, Result{} } func calculatePerceptionHash(screenshotBytes []byte) (uint64, error) { reader := bytes.NewReader(screenshotBytes) img, _, err := image.Decode(reader) if err != nil { return 0, errors.Wrap(err, "failed to decode screenshot") } pHash, err := goimagehash.PerceptionHash(img) if err != nil { return 0, errors.Wrap(err, "failed to calculate perceptual hash") } return pHash.GetHash(), nil } func (r *Runner) HandleFaviconHash(hp *httpx.HTTPX, req *retryablehttp.Request, currentResp []byte, finalURL string, defaultProbe bool) (string, string, string, []byte, string, error) { // Check if current URI is ending with .ico => use current body without additional requests if path.Ext(req.Path) == ".ico" { mmh3, md5h, err := r.calculateFaviconHashWithRaw(currentResp) return mmh3, md5h, req.Path, currentResp, req.String(), err } // Parse HTML: collect hrefs + optional hrefs, baseHref, err := extractPotentialFavIconsURLs(currentResp) if err != nil { return "", "", "", nil, "", err } // If none found and probing allowed, add default /favicon.ico if len(hrefs) == 0 && defaultProbe { hrefs = append(hrefs, "/favicon.ico") } // Determine base URL: prefer finalURL (redirect target) then apply baseNet, _ := url.Parse(req.String()) if finalURL != "" { if u, err := url.Parse(finalURL); err == nil { baseNet = u } } if baseHref != "" { if bu, err := url.Parse(baseHref); err == nil { baseNet = baseNet.ResolveReference(bu) } } // Clone original request (reuse headers/cookies) clone := req.Clone(context.Background()) var ( faviconMMH3 string faviconMD5 string faviconPath string faviconURL string faviconData []byte tries int // network fetch attempts ) // Iterate candidates (.ico first ordering handled in extractPotentialFavIconsURLs) for _, raw := range hrefs { if tries == 2 { break } raw = strings.TrimSpace(raw) if raw == "" { continue } // data: URL (base64) favicon if stringz.IsBase64Icon(raw) { data, err := stringz.DecodeBase64Icon(raw) if err != nil { continue } mmh3, md5h, err := r.calculateFaviconHashWithRaw(data) if err != nil { continue } return mmh3, md5h, "data:", data, "", nil } // Resolve relative/absolute href parsedHref, err := url.Parse(raw) if err != nil { continue } resolvedNet := baseNet.ResolveReference(parsedHref) resolvedURL, err := urlutil.ParseURL(resolvedNet.String(), r.options.Unsafe) if err != nil { continue } clone.SetURL(resolvedURL) // Update Host header to match resolved URL host (important after redirects) if resolvedURL.Host != "" && resolvedURL.Host != clone.Host { clone.Host = resolvedURL.Host } respFav, err := hp.Do(clone, httpx.UnsafeOptions{}) if err != nil || len(respFav.Data) == 0 { tries++ // Root fallback: directory-relative failed and raw had no leading slash if !strings.HasPrefix(raw, "/") { rootResolvedNet := baseNet.ResolveReference(&url.URL{Path: "/" + raw}) rootResolvedURL, err2 := urlutil.ParseURL(rootResolvedNet.String(), r.options.Unsafe) if err2 != nil { continue } clone.SetURL(rootResolvedURL) if respFav2, err3 := hp.Do(clone, httpx.UnsafeOptions{}); err3 == nil && len(respFav2.Data) > 0 { respFav = respFav2 } else { continue } } else { continue } } // Hash favicon bytes mmh3, md5h, err := r.calculateFaviconHashWithRaw(respFav.Data) if err != nil { continue } faviconMMH3 = mmh3 faviconMD5 = md5h faviconPath = raw faviconURL = clone.String() faviconData = respFav.Data gologger.Debug().Msgf("favicon resolved url=%s raw_href=%s size=%d bytes", faviconURL, faviconPath, len(faviconData)) break } return faviconMMH3, faviconMD5, faviconPath, faviconData, faviconURL, nil } func (r *Runner) calculateFaviconHashWithRaw(data []byte) (string, string, error) { hashNum, md5Hash, err := stringz.FaviconHash(data) if err != nil { return "", "", errkit.Wrapf(err, "could not calculate favicon hash") } return fmt.Sprintf("%d", hashNum), md5Hash, nil } func extractPotentialFavIconsURLs(resp []byte) (candidates []string, baseHref string, err error) { doc, err := goquery.NewDocumentFromReader(bytes.NewReader(resp)) if err != nil { return nil, "", err } if b := doc.Find("base[href]").First(); b.Length() == 1 { if v, ok := b.Attr("href"); ok { baseHref = strings.TrimSpace(v) } } doc.Find("link[rel]").Each(func(_ int, s *goquery.Selection) { rel := strings.ToLower(strings.TrimSpace(s.AttrOr("rel", ""))) href := strings.TrimSpace(s.AttrOr("href", "")) if href == "" { return } for _, tok := range strings.Fields(rel) { switch tok { case "icon", "shortcut", "shortcut-icon", "apple-touch-icon", "mask-icon", "alternate": candidates = append(candidates, href) return } } }) sort.SliceStable(candidates, func(i, j int) bool { ai := strings.HasSuffix(strings.ToLower(candidates[i]), ".ico") aj := strings.HasSuffix(strings.ToLower(candidates[j]), ".ico") if ai == aj { return candidates[i] < candidates[j] } return ai && !aj }) return candidates, baseHref, nil } // SaveResumeConfig to file func (r *Runner) SaveResumeConfig() error { var resumeCfg ResumeCfg resumeCfg.Index = r.options.resumeCfg.currentIndex resumeCfg.ResumeFrom = r.options.resumeCfg.current return goconfig.Save(resumeCfg, DefaultResumeFile) } // JSON the result func (r Result) JSON(scanopts *ScanOptions) string { //nolint if scanopts != nil && len(r.ResponseBody) > scanopts.MaxResponseBodySizeToSave { r.ResponseBody = r.ResponseBody[:scanopts.MaxResponseBodySizeToSave] } if js, err := json.Marshal(r); err == nil { return string(js) } return "" } // CSVHeader the CSV headers func (r Result) CSVHeader() string { //nolint var header string if h, err := gocsv.MarshalString([]Result{}); err == nil { header = h } header = strings.TrimSpace(header) return header } // CSVRow the CSV Row func (r Result) CSVRow(scanopts *ScanOptions) string { //nolint var res string if scanopts != nil && len(r.ResponseBody) > scanopts.MaxResponseBodySizeToSave { r.ResponseBody = r.ResponseBody[:scanopts.MaxResponseBodySizeToSave] } if row, err := gocsv.MarshalStringWithoutHeaders([]Result{r}); err == nil { reader := csv.NewReader(strings.NewReader(row)) records, err := reader.ReadAll() if err == nil && len(records) > 0 { buf := &bytes.Buffer{} writer := csv.NewWriter(buf) for _, record := range records { for i, field := range record { if len(field) > 0 { firstChar := field[0] // NOTE(dwisiswant0): Sanitize (prevent CSV injection). if firstChar == '=' || firstChar == '+' || firstChar == '-' || firstChar == '@' { record[i] = "'" + field } } } _ = writer.Write(record) //nolint } writer.Flush() res = buf.String() } else { res = row } res = strings.TrimSpace(res) } return res } func (r *Runner) skipCDNPort(host string, port string) bool { // if the option is not enabled we don't skip if !r.scanopts.ExcludeCDN { return false } // uses the dealer to pre-resolve the target dnsData, err := r.hp.Dialer.GetDNSData(host) // if we get an error the target cannot be resolved, so we return false so that the program logic continues as usual and handles the errors accordingly if err != nil { return false } if len(dnsData.A) == 0 { return false } // pick the first ip as target hostIP := dnsData.A[0] isCdnIP, _, _, err := r.hp.CdnCheck(hostIP) if err != nil { return false } if isCdnIP && slices.Contains(r.options.CustomPorts, port) { return true } // If the target is part of the CDN ips range - only ports 80 and 443 are allowed if isCdnIP && port != "80" && port != "443" { return true } return false } // parseURL parses url based on cli option(unsafe) func (r *Runner) parseURL(url string) (*urlutil.URL, error) { urlx, err := urlutil.ParseURL(url, r.options.Unsafe) if err != nil { gologger.Debug().Msgf("failed to parse url %v got %v in unsafe:%v", url, err, r.options.Unsafe) } return urlx, err } func getDNSData(hp *httpx.HTTPX, hostname string) (ips, cnames, resolvers []string, err error) { dnsData, err := hp.Dialer.GetDNSData(hostname) if err != nil { return nil, nil, nil, err } ips = make([]string, 0, len(dnsData.A)+len(dnsData.AAAA)) ips = append(ips, dnsData.A...) ips = append(ips, dnsData.AAAA...) cnames = dnsData.CNAME resolvers = append(resolvers, dnsData.Resolver...) return } func normalizeHeaders(headers map[string][]string) map[string]interface{} { normalized := make(map[string]interface{}, len(headers)) for k, v := range headers { normalized[strings.ReplaceAll(strings.ToLower(k), "-", "_")] = strings.Join(v, ", ") } return normalized } func isWebSocket(resp *httpx.Response) bool { if resp.StatusCode == 101 { return true } // TODO: improve this checks // Check for specific headers that indicate WebSocket support keyHeaders := []string{`^Sec-WebSocket-Accept:\s+.+`, `^Upgrade:\s+websocket`, `^Connection:\s+upgrade`} for _, header := range keyHeaders { re := regexp.MustCompile(header) if re.MatchString(resp.RawHeaders) { return true } } // Check for specific data that indicates WebSocket support keyData := []string{`{"socket":true,"socketUrl":"(?:wss?|ws)://.*"}`, `{"sid":"[^"]*","upgrades":\["websocket"\].*}`} for _, data := range keyData { re := regexp.MustCompile(data) if re.Match(resp.RawData) { return true } } return false } // stripANSI removes ANSI color codes from a string using pre-compiled regex func stripANSI(str string) string { return ansiRegex.ReplaceAllString(str, "") }