Files
projectdiscovery-httpx/runner/runner.go
T
usernametooshort 5fbde5afd9 fix(runner): add mutex to testAndSet to prevent race condition
testAndSet performs a non-atomic read-then-write sequence:
1. seen(k) - check if key exists
2. setSeen(k) - set the key

Without synchronization, two concurrent goroutines can both pass the
seen() check before either calls setSeen(), causing duplicate
processing of the same target.

This race can occur in processTargets() where multiple goroutines
call testAndSet() for discovered TLS SubjectAN/SubjectCN values.

The fix adds a sync.Mutex to serialize testAndSet operations.
2026-03-06 09:33:09 +01:00

3067 lines
91 KiB
Go

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/mfonda/simhash"
asnmap "github.com/projectdiscovery/asnmap/libs"
"github.com/projectdiscovery/fastdialer/fastdialer"
"github.com/projectdiscovery/httpx/common/customextract"
"github.com/projectdiscovery/httpx/common/hashes/jarm"
"github.com/projectdiscovery/httpx/common/inputformats"
"github.com/happyhackingspace/dit"
"github.com/projectdiscovery/httpx/common/authprovider"
"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, struct{}] // Include simHashes for efficient duplicate detection
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
var hammingDistanceThreshold int = 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] = ""
continue
}
// Continue normally
if len(tokens) < two {
continue
}
key = strings.TrimSpace(tokens[0])
value = strings.TrimSpace(tokens[1])
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] = 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, struct{}](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)))
if r.simHashes.Has(respSimHash) {
gologger.Debug().Msgf("Skipping duplicate response with simhash %d for URL %s\n", respSimHash, result.URL)
return true
}
for simHash := range r.simHashes.GetALL(false) {
// lower threshold for increased precision
if simhash.Compare(simHash, respSimHash) <= 3 {
gologger.Debug().Msgf("Skipping near-duplicate response with simhash %d for URL %s\n", respSimHash, result.URL)
return true
}
}
_ = r.simHashes.Set(respSimHash, struct{}{})
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 {
fchan, err := fileutil.ReadFile(r.options.InputFile)
if err != nil {
return
}
for item := range fchan {
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 {
fchan, err := fileutil.ReadFile(file)
if err != nil {
return
}
for item := range fchan {
if r.options.SkipDedupe || r.testAndSet(item) {
if !trySend(item) {
return
}
}
}
}
}
if fileutil.HasStdin() {
fchan, err := fileutil.ReadFileWithReader(os.Stdin)
if err != nil {
return
}
for item := range fchan {
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, _ := strconv.Atoi(URL.Port())
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
if scanopts.StoreResponse || scanopts.StoreChain {
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 = hash
} else {
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 <link rel="...icon..."> hrefs + optional <base href>
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 <base href>
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, "")
}