From c4b38e8aaa999600e5152a601c3cabd41ae9eb4e Mon Sep 17 00:00:00 2001 From: Serizao Date: Mon, 22 Jun 2026 08:33:34 +0200 Subject: [PATCH 01/22] refactor(perf+reliability): Phase 0/1 - regex cache, fix leaks & detection bugs Phase 0 (safety net): - add analyze/analyze_golden_test.go: detection golden tests that run without Chrome, pinning the detected-technology set across the refactor. Phase 1 (quick wins): - analyze/regexcache.go: compile each Wappalyzer pattern once (process-wide cache) and skip RE2-incompatible patterns instead of panicking; bounds-checked version extraction. Compilation drops from O(pages*patterns) to O(patterns) and the per-match double-compile is removed. - convert all matchers (headers/html/meta/scriptSrc/dom/url/xhr/dns) to the cache. - analyze_javascript.go: fix missing paren in the typeof eval (dead JS branch) and read confidence from regex[2]. - cmd/root.go: fix leaked WithTimeout cancel; cdncheck/goquery errors no longer abort the whole run; reuse the goquery document via Analyze.Doc; surface the previously discarded InitDialer error. - main.go: buffer the results channel (Threads*ChromeThreads). - technologies.go: close fingerprint files per iteration, verify HTTP 200 before writing a shard, and report unmarshal/merge errors. Verified: go build ./..., go vet ./..., go test -race ./analyze/ --- analyze/analyze_dns.go | 9 +- analyze/analyze_dom.go | 39 ++------ analyze/analyze_golden_test.go | 163 +++++++++++++++++++++++++++++++++ analyze/analyze_headers.go | 21 +---- analyze/analyze_html.go | 18 +--- analyze/analyze_javascript.go | 4 +- analyze/analyze_main.go | 8 +- analyze/analyze_meta.go | 18 +--- analyze/analyze_script_src.go | 18 +--- analyze/analyze_url.go | 5 +- analyze/analyze_xhr_src.go | 9 +- analyze/regexcache.go | 72 +++++++++++++++ cmd/root.go | 21 +++-- main.go | 9 +- technologies/technologies.go | 57 ++++++++---- 15 files changed, 339 insertions(+), 132 deletions(-) create mode 100644 analyze/analyze_golden_test.go create mode 100644 analyze/regexcache.go diff --git a/analyze/analyze_dns.go b/analyze/analyze_dns.go index cfff180..8c84db2 100644 --- a/analyze/analyze_dns.go +++ b/analyze/analyze_dns.go @@ -5,7 +5,6 @@ import( "fmt" "github.com/EasyRecon/wappaGo/technologies" - "regexp" ) func (a *Analyze) analyze_dns_main(technoName string,key string){ for key,value := range a.ResultGlobal[technoName].(map[string]interface{})[key].(map[string]interface{}) { @@ -43,10 +42,12 @@ func (a *Analyze) analyze_dns_main(technoName string,key string){ } func (a *Analyze) analyze_dns_regex(regex string,resultsDNS []string)(bool){ - + re, ok := compileCI(regex) + if !ok { + return false + } for _,resultDNS:= range resultsDNS { - findregex, _ := regexp.MatchString("(?i)"+regex, resultDNS) - if findregex { + if re.MatchString(resultDNS) { return true } } diff --git a/analyze/analyze_dom.go b/analyze/analyze_dom.go index a915ffa..b2faa12 100644 --- a/analyze/analyze_dom.go +++ b/analyze/analyze_dom.go @@ -3,8 +3,6 @@ package analyze import ( "fmt" - "regexp" - "strconv" "strings" "github.com/EasyRecon/wappaGo/technologies" "github.com/PuerkitoBio/goquery" @@ -67,21 +65,11 @@ func (a *Analyze) analyze_dom_attribute(technoName string,domKeyElement2 string if dommAttr != "" { if domKeyElement2 != "" { regex := strings.Split(domElement2.(string), "\\;") - findRegex, _ := regexp.MatchString("(?i)"+regex[0], dommAttr) - if findRegex { + re, ok := compileCI(regex[0]) + if ok && re.MatchString(dommAttr) { technoTemp := a.NewTechno(technoName) - compiledregex := regexp.MustCompile("(?i)" + regex[0]) - regexGroup := compiledregex.FindAllStringSubmatch(dommAttr, -1) - - if len(regex) > 1 && strings.HasPrefix(regex[1], "version") { - versionGrp := strings.Split(regex[1], "\\") - - if len(versionGrp) > 1 { - offset, _ := strconv.Atoi(versionGrp[1]) - //fmt.Println(versionGrp) - technoTemp.Version = regexGroup[0][offset] - } - } + regexGroup := re.FindAllStringSubmatch(dommAttr, -1) + technoTemp.Version = versionFromMarker(regex, regexGroup) a.Technos = append(a.Technos, technoTemp) a.Technos = technologies.CheckRequired(technoTemp.Name, a.ResultGlobal, a.Technos) } @@ -101,23 +89,12 @@ func (a *Analyze) analyze_dom_valued(technoName string,domElement interface{}){ a.Technos = technologies.CheckRequired(technoTemp.Name, a.ResultGlobal, a.Technos) } else { regex := strings.Split(domElement.(string), "\\;") - - findregex, _ := regexp.MatchString("(?i)"+regex[0], a.Body) - if findregex { + re, ok := compileCI(regex[0]) + if ok && re.MatchString(a.Body) { //fmt.Println(technoName) technoTemp := a.NewTechno(technoName) - compiledregex := regexp.MustCompile("(?i)" + regex[0]) - regexGroup := compiledregex.FindAllStringSubmatch(a.Body, -1) - - if len(regex) > 1 && strings.HasPrefix(regex[1], "version") { - versionGrp := strings.Split(regex[1], "\\") - - if len(versionGrp) > 1 { - offset, _ := strconv.Atoi(versionGrp[1]) - //fmt.Println(regexGroup[0][offset]) - technoTemp.Version = regexGroup[0][offset] - } - } + regexGroup := re.FindAllStringSubmatch(a.Body, -1) + technoTemp.Version = versionFromMarker(regex, regexGroup) a.Technos = append(a.Technos, technoTemp) a.Technos = technologies.CheckRequired(technoTemp.Name, a.ResultGlobal, a.Technos) } diff --git a/analyze/analyze_golden_test.go b/analyze/analyze_golden_test.go new file mode 100644 index 0000000..f4b44b1 --- /dev/null +++ b/analyze/analyze_golden_test.go @@ -0,0 +1,163 @@ +package analyze + +import ( + "sort" + "testing" + + "github.com/EasyRecon/wappaGo/structure" + "github.com/EasyRecon/wappaGo/technologies" + "github.com/projectdiscovery/retryabledns" +) + +// fixtureResultGlobal mimics the shape of the merged Wappalyzer fingerprint DB +// for a handful of technologies that can be detected without a live browser: +// only the html / headers / scriptSrc / meta signal sources are used, plus an +// "implies" edge. This lets Run() execute end-to-end (goquery parse + the +// regex matchers + CheckRequired + DedupTechno) with no chromedp context. +// +// It is the regression guard for the regex-precompilation refactor: the exact +// set of detected technologies and their versions must not change. +func fixtureResultGlobal() map[string]interface{} { + return map[string]interface{}{ + "Nginx": map[string]interface{}{ + "cpe": "cpe:2.3:a:nginx:nginx:*:*:*:*:*:*:*:*", + "headers": map[string]interface{}{ + "Server": "nginx(?:/([\\d.]+))?\\;version:\\1", + }, + "implies": "Reverse proxy", + }, + "Reverse proxy": map[string]interface{}{}, // referenced only via implies + "jQuery": map[string]interface{}{ + "scriptSrc": "jquery-([\\d.]+)\\.js\\;version:\\1", + }, + "WordPress": map[string]interface{}{ + "meta": map[string]interface{}{ + "generator": "WordPress ?([\\d.]+)?\\;version:\\1", + }, + "html": "wp-content", + }, + } +} + +func technologyVersions(techs []structure.Technologie) map[string]string { + out := map[string]string{} + for _, te := range techs { + // keep the first non-empty version seen for a name + if prev, ok := out[te.Name]; ok && prev != "" { + continue + } + out[te.Name] = te.Version + } + return out +} + +func technologyNames(techs []structure.Technologie) []string { + var out []string + for _, te := range techs { + out = append(out, te.Name+"@"+te.Version) + } + sort.Strings(out) + return out +} + +func TestRunGoldenDetection(t *testing.T) { + a := Analyze{ + ResultGlobal: fixtureResultGlobal(), + Body: `` + + `` + + `hello world`, + Resp: structure.Response{ + Headers: map[string][]string{"Server": {"nginx/1.21.0"}}, + }, + SrcList: []string{"https://code.jquery.com/jquery-3.6.0.js"}, + } + + got := technologies.DedupTechno(a.Run()) + gotVersions := technologyVersions(got) + + want := map[string]string{ + "Nginx": "1.21.0", // version from header capture group + "Reverse proxy": "", // implied by Nginx, no version + "jQuery": "3.6.0", // version from scriptSrc capture group + "WordPress": "6.2", // version from meta generator capture group + } + + if len(gotVersions) != len(want) { + t.Fatalf("detected %d technologies, want %d: got %v", len(gotVersions), len(want), technologyNames(got)) + } + for name, ver := range want { + if gotVersions[name] != ver { + t.Errorf("technology %q version = %q, want %q (all: %v)", name, gotVersions[name], ver, technologyNames(got)) + } + } + + // The CPE attached to a fingerprint must survive detection. + for _, te := range got { + if te.Name == "Nginx" && te.Cpe == "" { + t.Errorf("Nginx detected without its CPE") + } + } +} + +// TestRunGoldenURLAndDNS covers the matchers converted from regexp.MatchString +// to the cached compileCI (url, dns) and pins the panic-safety contract: a +// PCRE-only pattern that Go's RE2 engine rejects must be skipped silently +// rather than aborting the scan. +func TestRunGoldenURLAndDNS(t *testing.T) { + rg := map[string]interface{}{ + "AdminPanel": map[string]interface{}{ + "url": "example\\.com/admin", + }, + "GoogleWorkspace": map[string]interface{}{ + "dns": map[string]interface{}{ + "TXT": "include:_spf\\.google\\.com", + }, + }, + "BrokenPCRE": map[string]interface{}{ + // Go RE2 rejects look-ahead; must be skipped, not matched, not panic. + "html": "foo(?=bar)", + }, + } + a := Analyze{ + ResultGlobal: rg, + Body: `foobar example`, + Hote: structure.Host{Location: "https://example.com/admin"}, + DnsData: &retryabledns.DNSData{TXT: []string{"v=spf1 include:_spf.google.com ~all"}}, + } + + got := technologies.DedupTechno(a.Run()) + gotVersions := technologyVersions(got) + + want := []string{"AdminPanel", "GoogleWorkspace"} + if len(gotVersions) != len(want) { + t.Fatalf("detected %v, want exactly %v", technologyNames(got), want) + } + for _, name := range want { + if _, ok := gotVersions[name]; !ok { + t.Errorf("missing detection %q (got %v)", name, technologyNames(got)) + } + } + if _, ok := gotVersions["BrokenPCRE"]; ok { + t.Errorf("BrokenPCRE matched; an invalid RE2 pattern must be skipped") + } +} + +// TestCompileCICachesAndToleratesBadPatterns documents the contract the +// matchers rely on: valid patterns compile (and are cached), invalid RE2 +// patterns return ok=false instead of panicking. +func TestCompileCICachesAndToleratesBadPatterns(t *testing.T) { + re1, ok := compileCI(`nginx/([\d.]+)`) + if !ok || re1 == nil { + t.Fatalf("valid pattern failed to compile") + } + re2, _ := compileCI(`nginx/([\d.]+)`) + if re1 != re2 { + t.Errorf("compileCI did not return the cached *regexp.Regexp for an identical pattern") + } + if _, ok := compileCI(`(?P`); ok { + t.Errorf("invalid pattern reported ok=true; expected graceful failure") + } + if !re1.MatchString("nginx/1.21.0") { + t.Errorf("cached regex lost its semantics") + } +} diff --git a/analyze/analyze_headers.go b/analyze/analyze_headers.go index 1b7f0d8..64a2d66 100644 --- a/analyze/analyze_headers.go +++ b/analyze/analyze_headers.go @@ -2,8 +2,6 @@ package analyze import ( "fmt" - "regexp" - "strconv" "strings" "github.com/EasyRecon/wappaGo/technologies" ) @@ -16,22 +14,11 @@ func (a *Analyze)analyze_headers_main(technoName string,key string){ //headerValue := a.ResultGlobal[technoName].(map[string]interface{})[key].(map[string]interface{})[header] if a.ResultGlobal[technoName].(map[string]interface{})[key].(map[string]interface{})[headerName] != "" { regex := strings.Split(fmt.Sprintf("%v", a.ResultGlobal[technoName].(map[string]interface{})[key].(map[string]interface{})[headerName]), "\\;") - findregex, _ := regexp.MatchString("(?i)"+regex[0], a.Resp.Headers[headerName][0]) - //fmt.Println(findregex, technoName, headerName, a.Resp.Header[headerName][0]) - if findregex == true { - //fmt.Println(technoName) + re, ok := compileCI(regex[0]) + if ok && re.MatchString(a.Resp.Headers[headerName][0]) { technoTemp := a.NewTechno(technoName) - compiledregex := regexp.MustCompile("(?i)" + regex[0]) - regexGroup := compiledregex.FindAllStringSubmatch(a.Resp.Headers[headerName][0], -1) - - if len(regex) > 1 && strings.HasPrefix(regex[1], "version") { - versionGrp := strings.Split(regex[1], "\\") - if len(versionGrp) > 1 { - offset, _ := strconv.Atoi(versionGrp[1]) - //fmt.Println(regexGroup[0][offset]) - technoTemp.Version = regexGroup[0][offset] - } - } + regexGroup := re.FindAllStringSubmatch(a.Resp.Headers[headerName][0], -1) + technoTemp.Version = versionFromMarker(regex, regexGroup) a.Technos = append(a.Technos, technoTemp) a.Technos = technologies.CheckRequired(technoTemp.Name, a.ResultGlobal, a.Technos) } diff --git a/analyze/analyze_html.go b/analyze/analyze_html.go index 972053d..e34e91c 100644 --- a/analyze/analyze_html.go +++ b/analyze/analyze_html.go @@ -2,8 +2,6 @@ package analyze import ( "fmt" - "regexp" - "strconv" "strings" "github.com/EasyRecon/wappaGo/technologies" ) @@ -20,19 +18,11 @@ func (a *Analyze)analyze_html_main(technoName string,key string){ func (a *Analyze) analyze_html(technoName string,regexStr interface{}) { regex := strings.Split(fmt.Sprintf("%v", regexStr), "\\;") - findregex, _ := regexp.MatchString("(?i)"+regex[0], a.Body) - if findregex == true { + re, ok := compileCI(regex[0]) + if ok && re.MatchString(a.Body) { technoTemp := a.NewTechno(technoName) - compiledregex := regexp.MustCompile("(?i)" + regex[0]) - regexGroup := compiledregex.FindAllStringSubmatch(a.Body, -1) - - if len(regex) > 1 && strings.HasPrefix(regex[1], "version") { - versionGrp := strings.Split(regex[1], "\\") - if len(versionGrp) > 1 { - offset, _ := strconv.Atoi(versionGrp[1]) - technoTemp.Version = regexGroup[0][offset] - } - } + regexGroup := re.FindAllStringSubmatch(a.Body, -1) + technoTemp.Version = versionFromMarker(regex, regexGroup) a.Technos = append(a.Technos, technoTemp) a.Technos = technologies.CheckRequired(technoTemp.Name, a.ResultGlobal, a.Technos) } diff --git a/analyze/analyze_javascript.go b/analyze/analyze_javascript.go index 1d793d6..f3fc115 100644 --- a/analyze/analyze_javascript.go +++ b/analyze/analyze_javascript.go @@ -23,7 +23,7 @@ func (a *Analyze) analyze_js_valued(regexStr string, js string, technoName stri if regex[0] != "" { chromedp.Evaluate("(()=>{return "+js+".match(/"+regex[0]+"/gm)[0]})()", &res).Do(a.Ctx) } else { - chromedp.Evaluate("(()=>{return (typeof "+js+" !== 'undefined' ? true : false})()", &res).Do(a.Ctx) + chromedp.Evaluate("(()=>{return (typeof "+js+" !== 'undefined' ? true : false)})()", &res).Do(a.Ctx) } if res != nil && res != false { technoTemp := a.NewTechno(technoName) @@ -32,7 +32,7 @@ func (a *Analyze) analyze_js_valued(regexStr string, js string, technoName stri technoTemp.Confidence = strings.Split(regex[1], ":")[1] } if len(regex) > 2 && strings.HasPrefix(regex[2], "confidence") { - technoTemp.Confidence = strings.Split(regex[1], ":")[1] + technoTemp.Confidence = strings.Split(regex[2], ":")[1] } } if (len(regex) > 1 && strings.HasPrefix(regex[1], "version")) || (len(regex) > 2 && strings.HasPrefix(regex[2], "version")) { diff --git a/analyze/analyze_main.go b/analyze/analyze_main.go index 53214d5..7d547ce 100644 --- a/analyze/analyze_main.go +++ b/analyze/analyze_main.go @@ -20,6 +20,7 @@ type Analyze struct { CookiesList []*network.Cookie Node *cdp.Node Body string + Doc *goquery.Document Technos []structure.Technologie DnsData *retryabledns.DNSData CertVhost []string @@ -30,7 +31,12 @@ type Analyze struct { func (a *Analyze) Run() []structure.Technologie { - doc, _ := goquery.NewDocumentFromReader(strings.NewReader(a.Body)) + // Reuse the document already parsed by the caller (launchChrome) when + // available; only parse here as a fallback (e.g. unit tests). + doc := a.Doc + if doc == nil { + doc, _ = goquery.NewDocumentFromReader(strings.NewReader(a.Body)) + } //a.Hote := Host{} for technoName, _ := range a.ResultGlobal { for key, _ := range a.ResultGlobal[technoName].(map[string]interface{}) { diff --git a/analyze/analyze_meta.go b/analyze/analyze_meta.go index 93774a1..b0df03a 100644 --- a/analyze/analyze_meta.go +++ b/analyze/analyze_meta.go @@ -2,8 +2,6 @@ package analyze import ( "fmt" - "regexp" - "strconv" "strings" "github.com/EasyRecon/wappaGo/technologies" "github.com/PuerkitoBio/goquery" @@ -30,19 +28,11 @@ func (a *Analyze)analyze_meta_main(technoName string,key string,doc *goquery.Doc func (a *Analyze) analyze_meta(s *goquery.Selection,metaProperties interface{},technoName string){ metaValue, _ := s.Attr("content") regex := strings.Split(fmt.Sprintf("%v", metaProperties), "\\;") - findregex, _ := regexp.MatchString("(?i)"+regex[0], metaValue) - if findregex == true { + re, ok := compileCI(regex[0]) + if ok && re.MatchString(metaValue) { technoTemp := a.NewTechno(technoName) - compiledregex := regexp.MustCompile("(?i)" + regex[0]) - regexGroup := compiledregex.FindAllStringSubmatch(metaValue, -1) - - if len(regex) > 1 && strings.HasPrefix(regex[1], "version") { - versionGrp := strings.Split(regex[1], "\\") - if len(versionGrp) > 1 { - offset, _ := strconv.Atoi(versionGrp[1]) - technoTemp.Version = regexGroup[0][offset] - } - } + regexGroup := re.FindAllStringSubmatch(metaValue, -1) + technoTemp.Version = versionFromMarker(regex, regexGroup) a.Technos = append(a.Technos, technoTemp) a.Technos = technologies.CheckRequired(technoTemp.Name, a.ResultGlobal, a.Technos) } diff --git a/analyze/analyze_script_src.go b/analyze/analyze_script_src.go index 769243a..0c2ede1 100644 --- a/analyze/analyze_script_src.go +++ b/analyze/analyze_script_src.go @@ -2,8 +2,6 @@ package analyze import ( "fmt" - "regexp" - "strconv" "strings" "github.com/EasyRecon/wappaGo/technologies" ) @@ -23,19 +21,11 @@ func (a *Analyze)analyze_scriptSrc_main(technoName string,key string){ func (a *Analyze) analyze_scriptSrc(technoName string,regexStr string,scriptCrc string){ regex := strings.Split(fmt.Sprintf("%v", regexStr), "\\;") - findRegex, _ := regexp.MatchString("(?i)"+regex[0], scriptCrc) - if findRegex { + re, ok := compileCI(regex[0]) + if ok && re.MatchString(scriptCrc) { technoTemp := a.NewTechno(technoName) - compiledregex := regexp.MustCompile("(?i)" + regex[0]) - regexGroup := compiledregex.FindAllStringSubmatch(scriptCrc, -1) - - if len(regex) > 1 && strings.HasPrefix(regex[1], "version") { - versionGrp := strings.Split(regex[1], "\\") - if len(versionGrp) > 1 { - offset, _ := strconv.Atoi(versionGrp[1]) - technoTemp.Version = regexGroup[0][offset] - } - } + regexGroup := re.FindAllStringSubmatch(scriptCrc, -1) + technoTemp.Version = versionFromMarker(regex, regexGroup) a.Technos = append(a.Technos, technoTemp) a.Technos = technologies.CheckRequired(technoTemp.Name, a.ResultGlobal, a.Technos) } diff --git a/analyze/analyze_url.go b/analyze/analyze_url.go index 9818aab..d5bb580 100644 --- a/analyze/analyze_url.go +++ b/analyze/analyze_url.go @@ -2,7 +2,6 @@ package analyze import ( "fmt" - "regexp" "github.com/EasyRecon/wappaGo/technologies" ) @@ -20,8 +19,8 @@ func (a *Analyze)analyze_url_main(technoName string,key string){ } func (a *Analyze) analyze_url(technoName string,regexStr string){ - findregex, _ := regexp.MatchString("(?i)"+regexStr, a.Hote.Location) - if findregex == true { + re, ok := compileCI(regexStr) + if ok && re.MatchString(a.Hote.Location) { technoTemp := a.NewTechno(technoName) a.Technos = append(a.Technos, technoTemp) a.Technos = technologies.CheckRequired(technoTemp.Name, a.ResultGlobal, a.Technos) diff --git a/analyze/analyze_xhr_src.go b/analyze/analyze_xhr_src.go index 8064995..41b0ff5 100644 --- a/analyze/analyze_xhr_src.go +++ b/analyze/analyze_xhr_src.go @@ -2,7 +2,6 @@ package analyze import( "fmt" - "regexp" "github.com/EasyRecon/wappaGo/technologies" ) @@ -10,16 +9,16 @@ import( func (a *Analyze)analyze_xhr_main(technoName string,key string){ for _,url:=range a.XHRUrl { if fmt.Sprintf("%T", a.ResultGlobal[technoName].(map[string]interface{})[key]) == "string" { - findRegex, _ := regexp.MatchString("(?i)"+a.ResultGlobal[technoName].(map[string]interface{})[key].(string), url) - if findRegex { + re, ok := compileCI(a.ResultGlobal[technoName].(map[string]interface{})[key].(string)) + if ok && re.MatchString(url) { technoTemp := a.NewTechno(technoName) a.Technos = append(a.Technos, technoTemp) a.Technos = technologies.CheckRequired(technoTemp.Name, a.ResultGlobal, a.Technos) } } else { for _, XHRArray := range a.ResultGlobal[technoName].(map[string]interface{})[key].([]interface{}) { - findRegex, _ := regexp.MatchString("(?i)"+XHRArray.(string), url) - if findRegex { + re, ok := compileCI(XHRArray.(string)) + if ok && re.MatchString(url) { technoTemp := a.NewTechno(technoName) a.Technos = append(a.Technos, technoTemp) a.Technos = technologies.CheckRequired(technoTemp.Name, a.ResultGlobal, a.Technos) diff --git a/analyze/regexcache.go b/analyze/regexcache.go new file mode 100644 index 0000000..41c1be7 --- /dev/null +++ b/analyze/regexcache.go @@ -0,0 +1,72 @@ +package analyze + +import ( + "regexp" + "strconv" + "strings" + "sync" +) + +// regexCache memoises the compiled, case-insensitive form of every Wappalyzer +// pattern encountered during a run. The fingerprint database is iterated once +// per analysed page, so without this cache each pattern is recompiled on every +// page (and, in the original code, twice per match: once for MatchString and +// once for MustCompile). Compilation therefore dropped from O(pages*patterns) +// to O(patterns) total. +var ( + regexCacheMu sync.RWMutex + regexCache = make(map[string]*regexp.Regexp) +) + +// compileCI returns the cached, case-insensitive *regexp.Regexp for pattern. +// +// Each distinct pattern is compiled at most once for the whole process. If the +// pattern is not valid RE2 — Wappalyzer occasionally ships PCRE-only constructs +// such as look-around that Go's regexp engine rejects — compileCI caches the +// failure and returns (nil, false) so the caller skips the pattern instead of +// panicking (the original code used regexp.MustCompile, which crashed the +// whole scan on the first such pattern). +func compileCI(pattern string) (*regexp.Regexp, bool) { + key := "(?i)" + pattern + + regexCacheMu.RLock() + re, cached := regexCache[key] + regexCacheMu.RUnlock() + if cached { + return re, re != nil + } + + re, err := regexp.Compile(key) + if err != nil { + re = nil // remember the failure so the bad pattern is never retried + } + regexCacheMu.Lock() + regexCache[key] = re + regexCacheMu.Unlock() + return re, re != nil +} + +// versionFromMarker extracts a technology version from regex submatch groups +// using a Wappalyzer "\;version:\N" marker. parts is the pattern string split +// on "\;" (so parts[0] is the regex and parts[1], if present, is the marker). +// +// It reproduces the original behaviour exactly but is bounds-checked: an +// out-of-range capture group or a missing/short marker yields "" instead of +// panicking with an index-out-of-range (which previously aborted the scan). +func versionFromMarker(parts []string, groups [][]string) string { + if len(parts) <= 1 || !strings.HasPrefix(parts[1], "version") { + return "" + } + versionGrp := strings.Split(parts[1], "\\") + if len(versionGrp) <= 1 { + return "" + } + offset, err := strconv.Atoi(versionGrp[1]) + if err != nil { + return "" + } + if len(groups) == 0 || offset < 0 || offset >= len(groups[0]) { + return "" + } + return groups[0][offset] +} diff --git a/cmd/root.go b/cmd/root.go index 444d167..007d97d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -8,7 +8,6 @@ import ( "fmt" "io" "io/ioutil" - "log" "net" "net/http" URL "net/url" @@ -141,7 +140,9 @@ func (c *Cmd) startPortScan(url string, ip string, results chan structure.Data) isCDN, cdnName, _, err := c.Cdn.Check(net.ParseIP(ip)) //fmt.Println(isCDN, ip) if err != nil { - log.Fatal(err) + // A CDN lookup failure for a single IP must not abort the whole + // scan; treat the host as non-CDN and carry on. + isCDN = false } //fmt.Println(isCDN) if isCDN { @@ -253,7 +254,8 @@ func (c *Cmd) launchChrome(TempResp structure.Response, data structure.Data, url data.Infos.Cname = dnsData.CNAME } analyseStruct := analyze.Analyze{} - ctxAlloc1, _ := context.WithTimeout(c.ChromeCtx, 60*time.Second) + ctxAlloc1, cancelTimeout := context.WithTimeout(c.ChromeCtx, 60*time.Second) + defer cancelTimeout() cloneCTX, cancel := chromedp.NewContext(ctxAlloc1) chromedp.ListenTarget(cloneCTX, func(ev interface{}) { if _, ok := ev.(*network.EventResponseReceived); ok { @@ -315,11 +317,14 @@ func (c *Cmd) launchChrome(TempResp structure.Response, data structure.Data, url body, err := dom.GetOuterHTML().WithNodeID(node.NodeID).Do(ctx) if err == nil { reader := strings.NewReader(body) - doc, err := goquery.NewDocumentFromReader(reader) - - if err != nil { - log.Fatal(err) + doc, errDoc := goquery.NewDocumentFromReader(reader) + + if errDoc != nil { + // Malformed DOM: skip analysis for this target rather + // than killing the whole process. + return errDoc } + analyseStruct.Doc = doc var srcList []string doc.Find("script").Each(func(i int, s *goquery.Selection) { srcLink, exist := s.Attr("src") @@ -471,7 +476,7 @@ func (c *Cmd) InitDialer() *fastdialer.Dialer { dialer, err := fastdialer.NewDialer(fastdialerOpts) if err != nil { - fmt.Errorf("could not create resolver cache: %s", err) + fmt.Fprintf(os.Stderr, "could not create resolver cache: %s\n", err) } return dialer } diff --git a/main.go b/main.go index eed08f7..1d67799 100644 --- a/main.go +++ b/main.go @@ -57,7 +57,14 @@ func configure(options structure.Options) { c.Options = options c.Input = input - results := make(chan structure.Data) + // Buffer the results channel so producer goroutines (up to + // Threads*ChromeThreads of them) don't serialise on the single stdout + // consumer. + resultsBuffer := *options.Threads * *options.ChromeThreads + if resultsBuffer < 1 { + resultsBuffer = 1 + } + results := make(chan structure.Data, resultsBuffer) go func() { for result := range results { diff --git a/technologies/technologies.go b/technologies/technologies.go index 8bfbf35..7eacfea 100644 --- a/technologies/technologies.go +++ b/technologies/technologies.go @@ -87,11 +87,25 @@ func DownloadTechnologies() (string, error) { body, err := ioutil.ReadAll(resp.Body) resp.Body.Close() - file, _ := os.OpenFile( + if err != nil { + fmt.Fprintf(os.Stderr, "skip %v.json: read error: %v\n", f, err) + continue + } + // A 404/HTML error page must not be written as fingerprint data and + // then merged silently — skip any shard that didn't return 200 OK. + if resp.StatusCode != http.StatusOK { + fmt.Fprintf(os.Stderr, "skip %v.json: unexpected status %d\n", f, resp.StatusCode) + continue + } + file, err := os.OpenFile( folder+"/"+f+".json", os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0666, ) + if err != nil { + fmt.Fprintf(os.Stderr, "skip %v.json: %v\n", f, err) + continue + } file.Write(body) file.Close() @@ -100,29 +114,36 @@ func DownloadTechnologies() (string, error) { } func LoadTechnologiesFiles(folder string) map[string]interface{} { - - // Open our jsonFile - var resultGlobal map[string]interface{} + resultGlobal := make(map[string]interface{}) for _, s := range lib.Find(folder, ".json") { - - jsonFile, err := os.Open(s) - // if we os.Open returns an error then handle it + result, err := loadTechnologyFile(s) if err != nil { - fmt.Println(err) + // A corrupt or unreadable shard is skipped (and reported) instead + // of being merged as empty/garbage and silently degrading detection. + fmt.Fprintf(os.Stderr, "skip %v: %v\n", s, err) + continue + } + if err := mergo.Merge(&resultGlobal, result); err != nil { + fmt.Fprintf(os.Stderr, "merge %v: %v\n", s, err) } - // defer the closing of our jsonFile so that we can parse it later on - defer jsonFile.Close() - - byteValue, _ := ioutil.ReadAll(jsonFile) - - var result map[string]interface{} - - json.Unmarshal([]byte(byteValue), &result) - mergo.Merge(&resultGlobal, result) - } return resultGlobal } + +// loadTechnologyFile reads and parses a single fingerprint shard. The file is +// fully read and closed before returning, so descriptors are released per +// iteration rather than piling up until LoadTechnologiesFiles returns. +func loadTechnologyFile(path string) (map[string]interface{}, error) { + byteValue, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var result map[string]interface{} + if err := json.Unmarshal(byteValue, &result); err != nil { + return nil, err + } + return result, nil +} func DedupTechno(technologies []structure.Technologie) []structure.Technologie { var output []structure.Technologie add := true From 39460bf22afd541926f71388332296d22dc653f9 Mon Sep 17 00:00:00 2001 From: Serizao Date: Mon, 22 Jun 2026 08:38:00 +0200 Subject: [PATCH 02/22] refactor(reliability): Phase 2A - single shared HTTP client, no global mutation Build the HTTP client once in Start() via buildHTTPClient() and share it read-only across all goroutines. Removes three data races present when threads>1: - per-target reassignment of the shared c.HttpClient field in startPortScan - two per-request mutations of the process-global http.DefaultTransport (in startPortScan and getWrapper) Also deletes the dead getClientCtx branch (c.HttpClient == &http.Client{} was a pointer comparison against a fresh allocation, never true). Proxy/TLS config is now applied once from Options instead of being rebuilt identically per target. Verified: go build ./..., go vet ./..., go test -race ./analyze/ --- cmd/root.go | 98 +++++++++++++++++++---------------------------------- 1 file changed, 34 insertions(+), 64 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 007d97d..304d558 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -50,6 +50,12 @@ func (c *Cmd) Start(results chan structure.Data) { c.Dialer = c.InitDialer() defer c.Dialer.Close() + // Build the HTTP client once, up front: it is read-only for the rest of + // the run and shared across all target goroutines. Previously it was + // rebuilt per target on the shared c.HttpClient field (a data race), and + // the process-global http.DefaultTransport was mutated per request. + c.HttpClient = c.buildHTTPClient() + optionsChromeCtx := []chromedp.ExecAllocatorOption{} optionsChromeCtx = append(optionsChromeCtx, chromedp.DefaultExecAllocatorOptions[:]...) optionsChromeCtx = append(optionsChromeCtx, chromedp.Flag("headless", true)) @@ -105,33 +111,6 @@ func (c *Cmd) startPortScan(url string, ip string, results chan structure.Data) swg := sizedwaitgroup.New(*c.Options.ChromeThreads) var CdnName string portTemp := portList - http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true} - - transport := &http.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, - }, - DialContext: c.Dialer.Dial, - DisableKeepAlives: true, - } - if *c.Options.Proxy != "" { - proxyURL, parseErr := URL.Parse(*c.Options.Proxy) - if parseErr == nil { - transport.Proxy = http.ProxyURL(proxyURL) - transport.TLSClientConfig.MinVersion = tls.VersionTLS12 - transport.TLSClientConfig.MaxVersion = tls.VersionTLS12 - } - } - c.HttpClient = &http.Client{ - Timeout: 10 * time.Second, - Transport: transport, - } - if !*c.Options.FollowRedirect { - c.HttpClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { - //data.Infos.Location = fmt.Sprintf("%s", req.URL) - return http.ErrUseLastResponse - } - } if !*c.Options.AmassInput { c.HttpClient.Get("http://" + url) @@ -196,15 +175,7 @@ func (c *Cmd) getWrapper(urlData string, port string, data structure.Data, resul } else { urlDataPort = urlData } - http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true} - if *c.Options.Proxy != "" { - proxyURL, parseErr := URL.Parse(*c.Options.Proxy) - if parseErr == nil { - http.DefaultTransport.(*http.Transport).Proxy = http.ProxyURL(proxyURL) - http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12, MaxVersion: tls.VersionTLS12} - } - } - client := c.getClientCtx() + client := c.HttpClient var TempResp structure.Response //resp, errSSL = client.Get("https://" + urlDataPort) @@ -481,37 +452,36 @@ func (c *Cmd) InitDialer() *fastdialer.Dialer { return dialer } -func (c *Cmd) getClientCtx() *http.Client { - if c.HttpClient == (&http.Client{}) { - transport := &http.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, - }, - DialContext: c.Dialer.Dial, - DisableKeepAlives: true, - } - if *c.Options.Proxy != "" { - proxyURL, parseErr := URL.Parse(*c.Options.Proxy) - if parseErr == nil { - transport.Proxy = http.ProxyURL(proxyURL) - transport.TLSClientConfig.MinVersion = tls.VersionTLS12 - transport.TLSClientConfig.MaxVersion = tls.VersionTLS12 - } - } - client := &http.Client{ - Timeout: 10 * time.Second, - Transport: transport, +// buildHTTPClient constructs the single, shared HTTP client used for every +// probe. It is called once from Start(); the returned client (and its +// transport) is never mutated afterwards, so it is safe to share across all +// target/port goroutines. +func (c *Cmd) buildHTTPClient() *http.Client { + transport := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + DialContext: c.Dialer.Dial, + DisableKeepAlives: true, + } + if *c.Options.Proxy != "" { + proxyURL, parseErr := URL.Parse(*c.Options.Proxy) + if parseErr == nil { + transport.Proxy = http.ProxyURL(proxyURL) + transport.TLSClientConfig.MinVersion = tls.VersionTLS12 + transport.TLSClientConfig.MaxVersion = tls.VersionTLS12 } - if !*c.Options.FollowRedirect { - client.CheckRedirect = func(req *http.Request, via []*http.Request) error { - //data.Infos.Location = fmt.Sprintf("%s", req.URL) - return http.ErrUseLastResponse - } + } + client := &http.Client{ + Timeout: 10 * time.Second, + Transport: transport, + } + if !*c.Options.FollowRedirect { + client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse } - return client - } else { - return c.HttpClient } + return client } func (c *Cmd) DefineBasicMetric(data structure.Data, resp *structure.Response) (structure.Data, structure.Response, error) { From fb4cd41bad8c697a89acc9726be60569e516c80e Mon Sep 17 00:00:00 2001 From: Serizao Date: Mon, 22 Jun 2026 08:39:21 +0200 Subject: [PATCH 03/22] refactor(reliability): Phase 2B - synchronize port-scan caches - portOpen: scanner goroutines now report open ports on a buffered channel drained after swg1.Wait(), instead of appending to a shared slice (race). - PortOpenByIP: reads (CheckIpAlreadyScan) and the append are guarded by a new Cmd.portMu mutex, removing the slice data race. A benign TOCTOU remains (two targets sharing an IP may both scan it once) but it can no longer corrupt the cache. cmd.Cmd now holds a sync.Mutex, so wrapper.configureOptions returns *cmd.Cmd instead of copying it by value. Verified: go build ./..., go vet ./..., go test -race ./analyze/ --- cmd/root.go | 24 +++++++++++++++++------- wrapper/root.go | 4 ++-- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 304d558..72df4ff 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -14,6 +14,7 @@ import ( "os" "strconv" "strings" + "sync" "time" "unicode/utf8" @@ -41,6 +42,7 @@ type Cmd struct { Cdn *cdncheck.Client Options structure.Options PortOpenByIP []structure.PortOpenByIp + portMu sync.Mutex // guards PortOpenByIP HttpClient *http.Client ResultArray []structure.Data Input []string @@ -129,25 +131,33 @@ func (c *Cmd) startPortScan(url string, ip string, results chan structure.Data) CdnName = cdnName } var portOpen []string + c.portMu.Lock() alreadyScanned := lib.CheckIpAlreadyScan(ip, c.PortOpenByIP) + c.portMu.Unlock() if alreadyScanned.IP != "" { portOpen = alreadyScanned.Open_port } else { + // Each scanner goroutine reports its open port on a buffered channel + // instead of appending to a shared slice (which raced). The buffer is + // sized to the port count so a send never blocks. + portChan := make(chan string, len(portTemp)) for _, portEnum := range portTemp { swg1.Add() go func(portEnum string, url string) { defer swg1.Done() - openPort := c.scanPort("tcp", url, portEnum, *c.Options.Porttimeout) - if openPort { - portOpen = append(portOpen, portEnum) + if c.scanPort("tcp", url, portEnum, *c.Options.Porttimeout) { + portChan <- portEnum } }(portEnum, url) } swg1.Wait() - var tempScanned structure.PortOpenByIp - tempScanned.IP = ip - tempScanned.Open_port = portOpen - c.PortOpenByIP = append(c.PortOpenByIP, tempScanned) + close(portChan) + for p := range portChan { + portOpen = append(portOpen, p) + } + c.portMu.Lock() + c.PortOpenByIP = append(c.PortOpenByIP, structure.PortOpenByIp{IP: ip, Open_port: portOpen}) + c.portMu.Unlock() } url = strings.TrimSpace(url) for _, port := range portOpen { diff --git a/wrapper/root.go b/wrapper/root.go index 71f1d46..f3bf229 100644 --- a/wrapper/root.go +++ b/wrapper/root.go @@ -35,7 +35,7 @@ func StartReconSync(input []string, wrapperOptions structure.WrapperOptions) []s return resultGlobal } -func configureOptions(wrapperOptions structure.WrapperOptions) cmd.Cmd { +func configureOptions(wrapperOptions structure.WrapperOptions) *cmd.Cmd { options := structure.Options{} falseBool := false @@ -66,7 +66,7 @@ func configureOptions(wrapperOptions structure.WrapperOptions) cmd.Cmd { } defer os.RemoveAll(folder) - c := cmd.Cmd{} + c := &cmd.Cmd{} c.ResultGlobal = technologies.LoadTechnologiesFiles(folder) c.Options = options From 514a5ec1d3bf9c0682ae5ace41b782eb98d2e033 Mon Sep 17 00:00:00 2001 From: Serizao Date: Mon, 22 Jun 2026 08:40:14 +0200 Subject: [PATCH 04/22] refactor(reliability): Phase 2C - guard XHRUrl against concurrent access The chromedp ListenTarget callback appends XHR response URLs from a separate goroutine while Analyze.Run() reads the slice in the page ActionFunc, a data race. XHRUrl is now guarded by a per-Analyze mutex: writes go through AddXHRUrl(), and analyze_xhr_main() ranges over a snapshot taken under the lock. Verified: go build ./..., go vet ./..., go test -race ./analyze/ --- analyze/analyze_main.go | 2 ++ analyze/analyze_xhr_src.go | 15 ++++++++++++++- cmd/root.go | 2 +- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/analyze/analyze_main.go b/analyze/analyze_main.go index 7d547ce..dcdc8f6 100644 --- a/analyze/analyze_main.go +++ b/analyze/analyze_main.go @@ -3,6 +3,7 @@ package analyze import ( "context" "strings" + "sync" "github.com/EasyRecon/wappaGo/lib" structure "github.com/EasyRecon/wappaGo/structure" "github.com/PuerkitoBio/goquery" @@ -27,6 +28,7 @@ type Analyze struct { CertIssuer string CSSContent []string XHRUrl []string + xhrMu sync.Mutex // guards XHRUrl (written from the CDP listener goroutine) } diff --git a/analyze/analyze_xhr_src.go b/analyze/analyze_xhr_src.go index 41b0ff5..0d79750 100644 --- a/analyze/analyze_xhr_src.go +++ b/analyze/analyze_xhr_src.go @@ -6,8 +6,21 @@ import( ) +// AddXHRUrl records an XHR response URL. It is invoked from the chromedp +// ListenTarget callback, which runs on a separate goroutine concurrently with +// Run(), so writes to XHRUrl must be serialised. +func (a *Analyze) AddXHRUrl(u string) { + a.xhrMu.Lock() + a.XHRUrl = append(a.XHRUrl, u) + a.xhrMu.Unlock() +} + func (a *Analyze)analyze_xhr_main(technoName string,key string){ - for _,url:=range a.XHRUrl { + a.xhrMu.Lock() + urls := make([]string, len(a.XHRUrl)) + copy(urls, a.XHRUrl) + a.xhrMu.Unlock() + for _,url:=range urls { if fmt.Sprintf("%T", a.ResultGlobal[technoName].(map[string]interface{})[key]) == "string" { re, ok := compileCI(a.ResultGlobal[technoName].(map[string]interface{})[key].(string)) if ok && re.MatchString(url) { diff --git a/cmd/root.go b/cmd/root.go index 72df4ff..6d7ceb8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -245,7 +245,7 @@ func (c *Cmd) launchChrome(TempResp structure.Response, data structure.Data, url //log.Println(string(data)) switch typeDoc := ev.(*network.EventResponseReceived).Type; typeDoc { case "XHR": - analyseStruct.XHRUrl = append(analyseStruct.XHRUrl, ev.(*network.EventResponseReceived).Response.URL) + analyseStruct.AddXHRUrl(ev.(*network.EventResponseReceived).Response.URL) case "Stylesheet": //analyseStruct.CSSContent = append(analyseStruct.CSSContent,ev.(*network.EventResponseReceived).Response.URL) From 1cab0f494dafad3760c144f01d0d436ea50a765c Mon Sep 17 00:00:00 2001 From: Serizao Date: Mon, 22 Jun 2026 08:43:26 +0200 Subject: [PATCH 05/22] refactor(reliability): Phase 2D - contain panics from bad input and fingerprints A single malformed target or odd fingerprint entry no longer aborts the whole run: - per-target and per-port goroutines now recover() and log the offending host instead of crashing the process. - Amass input parsing checks the json.Unmarshal error and uses comma-ok for name/addresses/ip; a malformed line is skipped, a missing address leaves ip empty (the host is still scanned by name) rather than panicking. - NewTechno, AddTechno and CheckRequired guard every map/type assertion, so an implies/requires/cpe entry that is missing or non-string is tolerated. Adds TestImpliesMissingTechnoNoPanic covering an implies edge to a technology absent from the DB. Verified: go build ./..., go vet ./..., go test -race ./analyze/ --- analyze/analyze_golden_test.go | 23 +++++++++++++++++++++++ analyze/analyze_main.go | 11 +++++++---- cmd/root.go | 33 ++++++++++++++++++++++++++++++--- technologies/technologies.go | 21 ++++++++++++++------- 4 files changed, 74 insertions(+), 14 deletions(-) diff --git a/analyze/analyze_golden_test.go b/analyze/analyze_golden_test.go index f4b44b1..9e0921d 100644 --- a/analyze/analyze_golden_test.go +++ b/analyze/analyze_golden_test.go @@ -142,6 +142,29 @@ func TestRunGoldenURLAndDNS(t *testing.T) { } } +// TestImpliesMissingTechnoNoPanic pins the crash-containment hardening: an +// "implies" edge pointing at a technology absent from the DB must add that +// technology without panicking (the original AddTechno/NewTechno asserted the +// missing map entry and crashed the whole scan). +func TestImpliesMissingTechnoNoPanic(t *testing.T) { + rg := map[string]interface{}{ + "Thing": map[string]interface{}{ + "html": "thing-marker", + "implies": "GhostTech", // intentionally not present in the DB + }, + } + a := Analyze{ResultGlobal: rg, Body: `thing-marker`} + + got := technologies.DedupTechno(a.Run()) // must not panic + names := technologyVersions(got) + if _, ok := names["Thing"]; !ok { + t.Errorf("Thing not detected (got %v)", technologyNames(got)) + } + if _, ok := names["GhostTech"]; !ok { + t.Errorf("implied GhostTech not added (got %v)", technologyNames(got)) + } +} + // TestCompileCICachesAndToleratesBadPatterns documents the contract the // matchers rely on: valid patterns compile (and are cached), invalid RE2 // patterns return ok=false instead of panicking. diff --git a/analyze/analyze_main.go b/analyze/analyze_main.go index dcdc8f6..e27ab12 100644 --- a/analyze/analyze_main.go +++ b/analyze/analyze_main.go @@ -88,10 +88,13 @@ func (a *Analyze) Run() []structure.Technologie { func (a *Analyze) NewTechno(name string)(structure.Technologie){ - technoTemp := structure.Technologie{} - technoTemp.Name = name - if a.ResultGlobal[name].(map[string]interface{})["cpe"] != nil { - technoTemp.Cpe = a.ResultGlobal[name].(map[string]interface{})["cpe"].(string) + technoTemp := structure.Technologie{Name: name} + // The fingerprint may be absent (e.g. an implied techno missing from the + // DB) or carry a non-string cpe; both are tolerated rather than panicking. + if entry, ok := a.ResultGlobal[name].(map[string]interface{}); ok { + if cpe, ok := entry["cpe"].(string); ok { + technoTemp.Cpe = cpe + } } return technoTemp } \ No newline at end of file diff --git a/cmd/root.go b/cmd/root.go index 6d7ceb8..5b133a8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -89,17 +89,39 @@ func (c *Cmd) Start(results chan structure.Data) { url = "" ip = "" for _, line := range c.Input { + ip = "" if *c.Options.AmassInput { var result map[string]interface{} - json.Unmarshal([]byte(line), &result) - url = result["name"].(string) - ip = result["addresses"].([]interface{})[0].(map[string]interface{})["ip"].(string) + if err := json.Unmarshal([]byte(line), &result); err != nil { + fmt.Fprintf(os.Stderr, "skip amass line: %v\n", err) + continue + } + name, ok := result["name"].(string) + if !ok { + fmt.Fprintf(os.Stderr, "skip amass line: missing name\n") + continue + } + url = name + // Address is best-effort: a missing/odd shape leaves ip empty + // (the host is still scanned by name) instead of panicking. + if addrs, ok := result["addresses"].([]interface{}); ok && len(addrs) > 0 { + if addr0, ok := addrs[0].(map[string]interface{}); ok { + if ipStr, ok := addr0["ip"].(string); ok { + ip = ipStr + } + } + } } else { url = line } swg.Add() go func(url string, ip string) { defer swg.Done() + defer func() { + if r := recover(); r != nil { + fmt.Fprintf(os.Stderr, "recovered while scanning %s: %v\n", url, r) + } + }() c.startPortScan(url, ip, results) }(url, ip) } @@ -164,6 +186,11 @@ func (c *Cmd) startPortScan(url string, ip string, results chan structure.Data) swg.Add() go func(port string, url string, portOpen []string, CdnName string, c *Cmd) { defer swg.Done() + defer func() { + if r := recover(); r != nil { + fmt.Fprintf(os.Stderr, "recovered while probing %s:%s: %v\n", url, port, r) + } + }() data := structure.Data{} data.Infos.CDN = CdnName data.Infos.Data = url diff --git a/technologies/technologies.go b/technologies/technologies.go index 7eacfea..33cdc69 100644 --- a/technologies/technologies.go +++ b/technologies/technologies.go @@ -12,9 +12,13 @@ import ( "github.com/imdario/mergo" ) func CheckRequired(technoName string, technoList map[string]interface{}, tech []structure.Technologie) []structure.Technologie { - for name, _ := range technoList[technoName].(map[string]interface{}) { + entry, ok := technoList[technoName].(map[string]interface{}) + if !ok { + return tech + } + for name, _ := range entry { if name == "requires" { - requires := technoList[technoName].(map[string]interface{})["requires"] + requires := entry["requires"] // Tentative d'assertion du type directement en string if reqString, ok := requires.(string); ok { tech = AddTechno(reqString, tech, technoList) @@ -38,7 +42,7 @@ func CheckRequired(technoName string, technoList map[string]interface{}, tech [] } } if name == "implies" { - implies := technoList[technoName].(map[string]interface{})["implies"] + implies := entry["implies"] switch v := implies.(type) { case string: // Si c'est une chaîne, on ajoute directement la technologie @@ -65,10 +69,13 @@ func CheckRequired(technoName string, technoList map[string]interface{}, tech [] return tech } func AddTechno(name string, tech []structure.Technologie, technoList map[string]interface{}) []structure.Technologie { - technoTemp := structure.Technologie{} - technoTemp.Name = name - if _, ok := technoList[name].(map[string]interface{})["cpe"]; ok { - technoTemp.Cpe = technoList[name].(map[string]interface{})["cpe"].(string) + technoTemp := structure.Technologie{Name: name} + // Guard every step: the implied/required techno may be missing from the DB + // or carry a nil/non-string cpe (the original code panicked on both). + if entry, ok := technoList[name].(map[string]interface{}); ok { + if cpe, ok := entry["cpe"].(string); ok { + technoTemp.Cpe = cpe + } } tech = append(tech, technoTemp) return tech From 9ff9ced303eaa52c0756519a1d86fb9f29496ae1 Mon Sep 17 00:00:00 2001 From: Serizao Date: Mon, 22 Jun 2026 08:44:20 +0200 Subject: [PATCH 06/22] fix(technologies): download fingerprints into the system temp dir DownloadTechnologies created its working directory in the current working directory via a random name, so the 27 downloaded Wappalyzer shards could be left in the repo (and swept into a commit by `git add -A`). Use os.MkdirTemp so the shards live under the OS temp location instead; callers already os.RemoveAll the returned folder. Also ignore wappaGo_report.html. Verified: go build ./..., go vet ./..., go test -race ./analyze/ --- .gitignore | 1 + technologies/technologies.go | 9 +++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index fabfe2c..0fb998a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ targets.txt test.go wappaGo.exe +wappaGo_report.html diff --git a/technologies/technologies.go b/technologies/technologies.go index 33cdc69..2ca2bc3 100644 --- a/technologies/technologies.go +++ b/technologies/technologies.go @@ -83,8 +83,13 @@ func AddTechno(name string, tech []structure.Technologie, technoList map[string] func DownloadTechnologies() (string, error) { files := []string{"_", "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z"} - folder := lib.RandStringBytes(20) - _ = os.Mkdir(folder, 0766) + // Create the working dir under the system temp location, not the current + // working directory, so the downloaded fingerprint shards never land in + // (and get accidentally committed to) the repository. + folder, err := os.MkdirTemp("", "wappago-") + if err != nil { + return "", err + } for _, f := range files { url := fmt.Sprintf("%v/technologies/%v.json", structure.WappazlyerRoot, f) resp, err := http.Get(url) From a8de5e57af0a7fd4a62d3af57278f9e925eb626f Mon Sep 17 00:00:00 2001 From: Serizao Date: Mon, 22 Jun 2026 08:45:06 +0200 Subject: [PATCH 07/22] refactor(reliability): Phase 2E - graceful shutdown on SIGINT/SIGTERM The Chrome ExecAllocator now derives from signal.NotifyContext, so the first Ctrl+C cancels the browser context and lets the deferred cleanup run (Chrome processes, dialer, temp fingerprint dir) instead of orphaning them. A Chrome startup failure now reports the error, closes the results channel and returns (so the stdout consumer exits) instead of panicking and skipping cleanup. Verified: go build ./..., go vet ./..., go test -race ./analyze/ --- cmd/root.go | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 5b133a8..a279010 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -12,9 +12,11 @@ import ( "net/http" URL "net/url" "os" + "os/signal" "strconv" "strings" "sync" + "syscall" "time" "unicode/utf8" @@ -70,8 +72,14 @@ func (c *Cmd) Start(results chan structure.Data) { optionsChromeCtx = append(optionsChromeCtx, chromedp.ProxyServer(*c.Options.Proxy)) } + // Tie the browser lifetime to SIGINT/SIGTERM: the first Ctrl+C cancels this + // context, which tears Chrome down and lets the deferred cleanup (browser + // processes, dialer, temp fingerprint dir) run instead of leaving orphans. + sigCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + //ctxAlloc, cancel := chromedp.NewExecAllocator(context.Background(), append(chromedp.DefaultExecAllocatorOptions[:], chromedp.Flag("headless", false), chromedp.Flag("disable-gpu", true))...) - ctxAlloc, cancel1 := chromedp.NewExecAllocator(context.Background(), optionsChromeCtx...) + ctxAlloc, cancel1 := chromedp.NewExecAllocator(sigCtx, optionsChromeCtx...) defer cancel1() ctxAlloc1, cancel := chromedp.NewContext(ctxAlloc) @@ -79,7 +87,12 @@ func (c *Cmd) Start(results chan structure.Data) { defer cancel() if err := chromedp.Run(c.ChromeCtx); err != nil { - panic(err) + // Startup failure (including a cancel during launch) is reported and the + // results channel is closed so the consumer drains and exits cleanly, + // rather than panicking and skipping cleanup. + fmt.Fprintf(os.Stderr, "could not start Chrome: %v\n", err) + close(results) + return } c.Cdn = cdncheck.New() From 0bc4f3be78c18689fdcbf7ef8725234557c27614 Mon Sep 17 00:00:00 2001 From: Serizao Date: Mon, 22 Jun 2026 08:50:07 +0200 Subject: [PATCH 08/22] refactor(report): Phase 3 - fix stored XSS and O(n^2) report generation - card() now renders through html/template, which contextually escapes the page title, scanned URL, screenshot path and detected technology names. A malicious scanned site can no longer inject script into the operator's report (stored XSS). Adds report/report_test.go covering the escaping. - Report_main is now called once, after swg.Wait(), from the full result set instead of being regenerated after every host (O(n^2) full-file rewrites by concurrent writers). The ResultArray append is guarded by Cmd.reportMu. Verified: go build ./..., go vet ./..., go test -race ./analyze/ ./report/ --- cmd/root.go | 12 ++++-- report/report.go | 93 ++++++++++++++++++++++++++++--------------- report/report_test.go | 37 +++++++++++++++++ 3 files changed, 107 insertions(+), 35 deletions(-) create mode 100644 report/report_test.go diff --git a/cmd/root.go b/cmd/root.go index a279010..57bdfdf 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -47,6 +47,7 @@ type Cmd struct { portMu sync.Mutex // guards PortOpenByIP HttpClient *http.Client ResultArray []structure.Data + reportMu sync.Mutex // guards ResultArray (report mode) Input []string } @@ -139,6 +140,12 @@ func (c *Cmd) Start(results chan structure.Data) { }(url, ip) } swg.Wait() + // Render the HTML report exactly once, from the complete result set, after + // all targets finish. Previously launchChrome regenerated the whole file + // after every host (O(n^2) rewrites with concurrent writers). + if *c.Options.Report { + report.Report_main(c.ResultArray, *c.Options.Screenshot) + } close(results) } @@ -392,13 +399,12 @@ func (c *Cmd) launchChrome(TempResp structure.Response, data structure.Data, url data.Infos.Screenshot = imgTitle + ".png" } if *c.Options.Report { + c.reportMu.Lock() c.ResultArray = append(c.ResultArray, data) + c.reportMu.Unlock() } else { results <- data } - if *c.Options.Report { - report.Report_main(c.ResultArray, *c.Options.Screenshot) - } } func (c *Cmd) scanPort(protocol, hostname string, port string, portTimeout int) bool { diff --git a/report/report.go b/report/report.go index c26f759..4c17a23 100644 --- a/report/report.go +++ b/report/report.go @@ -3,8 +3,9 @@ package report import( "github.com/EasyRecon/wappaGo/structure" -"os" -"strconv" + "html/template" + "os" + "strings" ) @@ -90,45 +91,73 @@ main{ return a } -func card(data structure.Data,screenPath string)(string){ - var technos string - var version string - var status string - for _,techno := range data.Infos.Technologies { - version ="" - if techno.Version != "" { - version=" | "+techno.Version - } - technos = technos+""+techno.Name+version+"" - } - status ="badge-secondary" - switch i := data.Infos.Status_code; { - case i<300: - status ="badge-success" - case i>299 && i<400: - status ="badge-info" - case i>399 && i<500: - status ="badge-warning" - case i>499: - status ="badge-danger" - } - return `
-
`+data.Url+`
+// cardView holds the per-host values rendered into the report. Every field is +// attacker-controlled (page title, scanned URL, detected technology names), +// so the card is rendered through html/template, which contextually escapes +// each value. The previous implementation concatenated these straight into the +// HTML, allowing a malicious page to inject script into the report. +type cardView struct { + Title string + Url string + ScreenshotSrc string + StatusClass string + StatusCode int + Technos []string +} + +var cardTmpl = template.Must(template.New("card").Parse(`<div class="card page-card col-2" style="margin:2px;padding:0px"> + <div title="{{.Title}}" class="card-header text-truncate"> {{.Url}} </div> <div class="page-screenshot-container"> - <img src="`+screenPath+`/`+data.Infos.Screenshot+`" alt="`+data.Infos.Title+`" onerror="this.src='https://upload.wikimedia.org/wikipedia/commons/thumb/a/ac/No_image_available.svg/1024px-No_image_available.svg.png'" class="card-img page-screenshot" style="transform: scale(1); transform-origin: 11.5772% 95.4698%;"> + <img src="{{.ScreenshotSrc}}" alt="{{.Title}}" onerror="this.src='https://upload.wikimedia.org/wikipedia/commons/thumb/a/ac/No_image_available.svg/1024px-No_image_available.svg.png'" class="card-img page-screenshot" style="transform: scale(1); transform-origin: 11.5772% 95.4698%;"> </div> <div class="card-body"> - <h5 class="card-title">`+data.Infos.Title+`</h5> + <h5 class="card-title">{{.Title}}</h5> <p class="card-text"> - <span class="badge badge-pill text-break text-wrap `+status+`">Status: `+strconv.Itoa(data.Infos.Status_code)+`</span> - `+technos+` + <span class="badge badge-pill text-break text-wrap {{.StatusClass}}">Status: {{.StatusCode}}</span> + {{range .Technos}}<span class="badge badge-pill text-break text-wrap badge-info">{{.}}</span>{{end}} </p> </div> <div class="card-footer"> - <a href="`+data.Url+`" target="_blank" class="btn btn-outline-secondary btn-sm card-link float-right">Visit Page</a> + <a href="{{.Url}}" target="_blank" class="btn btn-outline-secondary btn-sm card-link float-right">Visit Page</a> </div> - </div>` + </div>`)) +func card(data structure.Data, screenPath string) string { + status := "badge-secondary" + switch i := data.Infos.Status_code; { + case i < 300: + status = "badge-success" + case i > 299 && i < 400: + status = "badge-info" + case i > 399 && i < 500: + status = "badge-warning" + case i > 499: + status = "badge-danger" + } + + var technos []string + for _, techno := range data.Infos.Technologies { + label := techno.Name + if techno.Version != "" { + label += " | " + techno.Version + } + technos = append(technos, label) + } + + view := cardView{ + Title: data.Infos.Title, + Url: data.Url, + ScreenshotSrc: screenPath + "/" + data.Infos.Screenshot, + StatusClass: status, + StatusCode: data.Infos.Status_code, + Technos: technos, + } + + var sb strings.Builder + if err := cardTmpl.Execute(&sb, view); err != nil { + return "" + } + return sb.String() } diff --git a/report/report_test.go b/report/report_test.go new file mode 100644 index 0000000..0de03ed --- /dev/null +++ b/report/report_test.go @@ -0,0 +1,37 @@ +package report + +import ( + "strings" + "testing" + + "github.com/EasyRecon/wappaGo/structure" +) + +// TestCardEscapesXSS pins the report XSS fix: attacker-controlled fields (page +// title, scanned URL, detected technology names) must be HTML-escaped, so a +// malicious scanned site cannot inject script into the operator's report. +func TestCardEscapesXSS(t *testing.T) { + data := structure.Data{ + Url: "https://evil.test/", + Infos: structure.Host{ + Title: ``, + Status_code: 200, + Screenshot: "evil.png", + Technologies: []structure.Technologie{ + {Name: ``, Version: "1.0"}, + }, + }, + } + + out := card(data, "screens") + + if strings.Contains(out, "") { + t.Errorf("page title injected a raw - - - -` -return a + +// statusBand is one segment of the distribution bar in the summary strip. +type statusBand struct { + class string + label string +} + +var statusBands = []statusBand{ + {"ok", "2xx"}, + {"redir", "3xx"}, + {"client", "4xx"}, + {"server", "5xx"}, + {"unknown", "other"}, +} + +func Report_main(datas []structure.Data, screenPath string) { + counts := map[string]int{} + techs := map[string]bool{} + for _, d := range datas { + counts[statusClass(d.Infos.Status_code)]++ + for _, t := range d.Infos.Technologies { + techs[t.Name] = true + } + } + + var b strings.Builder + b.WriteString(pageHead) + b.WriteString(renderSummary(len(datas), len(techs), counts)) + b.WriteString(`
`) + for _, d := range datas { + b.WriteString(card(d, screenPath)) + } + b.WriteString(pageFoot) + + _ = os.WriteFile("wappaGo_report.html", []byte(b.String()), 0644) } - + +// renderSummary builds the header strip. It only ever interpolates integers and +// fixed band labels, so plain string assembly is safe (no user data). +func renderSummary(hosts, techCount int, counts map[string]int) string { + var bar, legend strings.Builder + for _, band := range statusBands { + c := counts[band.class] + if c == 0 { + continue + } + n := strconv.Itoa(c) + bar.WriteString(``) + legend.WriteString(`
  • ` + band.label + `` + n + `
  • `) + } + + generated := time.Now().Format("2006-01-02 15:04 MST") + return `
    +
    ` + strconv.Itoa(hosts) + `hosts
    +
    + +
      ` + legend.String() + `
    +
    +
    ` + strconv.Itoa(techCount) + `technologies
    +
    ` + generated + `
    +
    ` +} + +const pageHead = ` + + + + +wappaGo · attack surface report + + + +
    +
    + + attack surface report +
    + +
    ` + +const pageFoot = `
    + + + +` diff --git a/report/report_test.go b/report/report_test.go index 0de03ed..bed3c08 100644 --- a/report/report_test.go +++ b/report/report_test.go @@ -1,8 +1,11 @@ package report import ( + "os" + "path/filepath" "strings" "testing" + "time" "github.com/EasyRecon/wappaGo/structure" ) @@ -35,3 +38,91 @@ func TestCardEscapesXSS(t *testing.T) { t.Errorf("expected the script tag to be HTML-escaped, got:\n%s", out) } } + +// TestReportRendersFullPage exercises the whole report with varied hosts and +// writes a sample to the temp dir for manual inspection. +func TestReportRendersFullPage(t *testing.T) { + datas := []structure.Data{ + { + Url: "https://example.com", + Infos: structure.Host{ + Status_code: 200, Scheme: "https", Data: "example.com", + Title: "Example Domain — Home", IP: "93.184.216.34", CDN: "Cloudflare", + Content_type: "text/html", Content_length: 24576, + Response_time: 142 * time.Millisecond, Ports: []string{"80", "443"}, + Screenshot: "abc123.png", + Technologies: []structure.Technologie{ + {Name: "Nginx", Version: "1.21.0", Cpe: "cpe:2.3:a:nginx:nginx", Confidence: "100"}, + {Name: "PHP", Version: "8.1"}, + {Name: "jQuery", Version: "3.6.0"}, + }, + CertVhost: []string{"example.com", "www.example.com", "*.example.com"}, + Cname: []string{"example.com.cdn.cloudflare.net"}, + }, + }, + { + Url: "https://shop.example.com", + Infos: structure.Host{ + Status_code: 301, Scheme: "https", Data: "shop.example.com", + Title: "Redirecting…", IP: "93.184.216.35", + Location: "https://www.example.com/shop", Ports: []string{"443"}, + }, + }, + { + Url: "https://api.example.com", + Infos: structure.Host{ + Status_code: 500, Scheme: "https", Data: "api.example.com", + IP: "10.0.0.7", Content_type: "application/json", Content_length: 312, + Response_time: 2300 * time.Millisecond, Ports: []string{"443", "8443"}, + }, + }, + { + Url: "http://legacy.example.com", + Infos: structure.Host{ + Status_code: 404, Scheme: "http", Data: "legacy.example.com", + Title: ``, IP: "203.0.113.9", + Technologies: []structure.Technologie{{Name: `Evil`}}, + }, + }, + } + + counts := map[string]int{} + techs := map[string]bool{} + for _, d := range datas { + counts[statusClass(d.Infos.Status_code)]++ + for _, te := range d.Infos.Technologies { + techs[te.Name] = true + } + } + var b strings.Builder + b.WriteString(pageHead) + b.WriteString(renderSummary(len(datas), len(techs), counts)) + b.WriteString(`
    `) + for _, d := range datas { + b.WriteString(card(d, "screenshots")) + } + b.WriteString(pageFoot) + page := b.String() + + for _, want := range []string{ + "", "", `id="lightbox"`, `id="filter"`, + "example.com", "93.184.216.34", "Cloudflare", "24.0 KB", "142 ms", "2.30 s", + "cert SANs", "*.example.com", "cname", `class="stamp">301`, `class="stamp">500`, + "https://www.example.com/shop", "1.21.0", + } { + if !strings.Contains(page, want) { + t.Errorf("rendered report is missing %q", want) + } + } + if strings.Contains(page, "") { + t.Errorf("full page leaked an unescaped From 368e8031e6e8e03c65e2f378085589e2a32f3257 Mon Sep 17 00:00:00 2001 From: Serizao Date: Thu, 30 Jul 2026 11:17:42 +0200 Subject: [PATCH 17/22] fix(detection): switch fingerprint source, repair the matchers, pay down debt Fingerprints now come from https://github.com/Serizao/tech (shards at the repo root, not under technologies/). structure.TechnologiesRoot replaces the misspelled WappazlyerRoot, which stays as a deprecated alias. New technologies.Load() downloads, parses, and deletes the on-disk copy before returning: the shards were only ever read once, so holding the temp directory for the whole scan served no purpose and leaked it outright when the download failed early. Both main.go and wrapper go through it, which also removes the drifted second copy of the bootstrap, and a failure is now fatal instead of a logged warning followed by a scan reporting zero technologies everywhere. Detection fixes: - headers: the pattern was looked up with the *response* header name while the comparison used the *fingerprint* key. Resp.Headers is an http.Header clone, so its keys are Go-canonical; 301 of the database's 690 header fingerprints (43%, over 211 technologies) missed, returned a nil interface, and since nil != "" went on to compile the literal "" as a regex. Also inspect every value of a repeated header, not just values[0]. - headers are now taken from the union of the raw probe and what Chrome saw on the final document, Chrome winning on conflicts. Neither alone is enough: the probe stops at the 30x so its headers describe the redirector (a http->https host reported no Server at all), but Chrome consumes Alt-Svc, the only HTTP/3 signal. CDP newline-joins repeated headers, so they are split back apart. - url: matched against the Location header and skipped entirely unless the host redirected, so all 78 url fingerprints were unreachable on a normal response. Now matched against the scanned URL, redirect target included. - meta: only meta[name=] was searched, so every og:/twitter:/fb: fingerprint (published as meta[property=]) could never match. - text vs html: both ran against the raw markup, so a text pattern could fire on an attribute value or a URL invisible to a visitor. - dom: a text/value pattern re-scanned the entire page body once per matched element; it now reads the selected element, as Wappalyzer intends. - js: `.match(/re/gm)[0]` cannot work with \;version:\N because the g flag discards capture groups, threw on a null match, and broke on any pattern containing "/". Rebuilt via new RegExp from a quoted literal, reading the group the marker names. - implies markers are stripped: the database ships PHP\;confidence:75, which was used verbatim as a technology name and also broke the requires gate. - excludes is honoured (43 edges, previously ignored). Mutually exclusive pairs are kept, since deleting both discards evidence rather than resolving it. - DedupTechno no longer depends on map iteration order, which made the reported version change between runs on identical input for 86 technologies. - every fingerprint value is type-asserted with comma-ok, including in Run() itself, and a nil DnsData no longer nil-derefs the dns matcher. Technologie gains an icon field, copied from the fingerprint database and omitted when it declares none. Debt: - Response is trimmed to the four fields anything reads. Raw, RawHeaders, Data, Words, Lines, TLSData, HTTP2 and Pipeline were write-only, and populating Raw meant buffering every probed body whole, twice, with no size cap. - Response.Duration is actually assigned, so response_time is no longer always 0. - Options.ApplyDefaults() runs at the top of cmd.Start, removing the nil-deref surface across fourteen pointer fields, giving library callers the same defaults as the CLI flags, and closing the -threads 0 hole (sizedwaitgroup treats limit<=0 as unbounded). - the per-IP port cache is a map under an RWMutex instead of a slice linearly scanned while holding an exclusive lock, and is skipped when the IP is empty rather than letting every unresolved host share one bogus entry. - InitDialer returns an error instead of a nil *Dialer the caller uses anyway. - the warm-up GET's body is drained and closed. - SIGINT stops the scan instead of only killing Chrome, and a second one aborts. - the JavaScript-dialog handler no longer marshals the shared result struct onto stdout while another goroutine writes it. - dead code removed: lib.RandStringBytes, lib.GetKey, lib.CheckIpAlreadyScan, Analyze.CSSContent, structure.PortOpenByIp, structure.LetterBytes. go.mod moves 1.19 (EOL) to 1.23, which also gives per-iteration loop variables; go mod tidy drops cryptoutil to indirect and httputil entirely. CI now runs on every branch with a gofmt check, vet and go test -race, and reads the Go version from go.mod instead of a second copy of it. wrapper.StartReconSync/StartReconAsync now return an error. Breaking, but ignoring a failed fingerprint download meant silently scanning with an empty database. README updated. 43 tests, including regression guards for each detection fix above. gofmt -l . clean, go vet ./... clean, go test -race ./... green --- .github/workflows/go.yml | 30 ++- README.md | 63 +++++- analyze/analyze_cert.go | 35 ++- analyze/analyze_cookies.go | 4 +- analyze/analyze_dns.go | 67 +++--- analyze/analyze_dom.go | 176 +++++++++------ analyze/analyze_golden_test.go | 347 ++++++++++++++++++++++++++++++ analyze/analyze_headers.go | 72 +++++-- analyze/analyze_html.go | 62 ++++-- analyze/analyze_javascript.go | 138 +++++++++--- analyze/analyze_main.go | 107 ++++----- analyze/analyze_meta.go | 69 ++++-- analyze/analyze_script_src.go | 47 ++-- analyze/analyze_url.go | 64 ++++-- analyze/analyze_xhr_src.go | 103 +++++++-- cmd/headers_test.go | 2 +- cmd/progress.go | 17 +- cmd/redirect_test.go | 38 ++++ cmd/root.go | 295 +++++++++++++++++-------- go.mod | 15 +- go.sum | 76 +++---- lib/lib.go | 28 --- main.go | 17 +- report/report.go | 10 +- report/report_test.go | 35 +++ structure/structure.go | 130 +++++++++-- technologies/technologies.go | 236 ++++++++++++++++---- technologies/technologies_test.go | 217 +++++++++++++++++++ wrapper/root.go | 95 +++++--- 29 files changed, 1999 insertions(+), 596 deletions(-) create mode 100644 cmd/redirect_test.go create mode 100644 technologies/technologies_test.go diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 69407f5..915ce14 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -1,24 +1,42 @@ name: Go +# Runs on every branch, not just master: the previous filter meant a feature +# branch got no CI at all until it opened a PR. on: push: - branches: [ master ] pull_request: - branches: [ master ] jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Go - uses: actions/setup-go@v3 + uses: actions/setup-go@v5 with: - go-version: 1.19 + # Track the version declared in go.mod rather than a second, drifting + # copy of it here. + go-version-file: go.mod + cache: true + + - name: Check formatting + run: | + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "These files are not gofmt'd:" + echo "$unformatted" + exit 1 + fi + + - name: Vet + run: go vet ./... - name: Build run: go build -v ./... - + # The suite was never run in CI, so every detection bug it now guards + # against could have been reintroduced silently. + - name: Test + run: go test -race ./... diff --git a/README.md b/README.md index 22c1606..dfa1ea3 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,10 @@ Usage of wappaGo: Number of chromes threads in each main threads total = option.threads*option.chrome-threads (Default 5) (default 5) -follow-redirect Follow redirect to detect technologie + -jitter int + Max random delay in ms added before each request (0 = none) + -no-progress + Disable the stderr progress bar -port-timeout int Timeout during port scanning in ms (default 2000) -ports string @@ -55,16 +59,31 @@ Usage of wappaGo: -proxy string Use http proxy -report - Generate HTML report + Generate HTML report (in addition to the JSON on stdout) -resolvers string Use specifique resolver separated by comma + -rps float + Max HTTP requests per second per host to stay under rate-based WAF rules (0 = unlimited) -screenshot string path to screenshot if empty no screenshot -threads int Number of threads to start recon in same time (default 5) + -user-agent string + User-Agent sent by both the HTTP probe and Chrome (blank keeps the built-in browser UA) ``` +Results are written to stdout as one JSON object per line, so `-report` can be +combined with a redirect: + +```bash +cat domain.txt | ./wappaGo -report > results.json # JSON on stdout + wappaGo_report.html +``` + +The technology fingerprints are downloaded at startup from +[Serizao/tech](https://github.com/Serizao/tech), parsed into memory, and the +on-disk copy is deleted before the scan begins. + You can either use wappaGo from a file containing a list of domains ```bash cat domain.txt | ./wappaGo @@ -94,16 +113,29 @@ type WrapperOptions struct { ChromeTimeout int ChromeThreads int Proxy string + UserAgent string + Rps float64 + Jitter int } ``` +Any field left at its zero value falls back to the same default as the +corresponding CLI flag (`Ports: "80,443"`, `Threads: 5`, `ChromeThreads: 5`, +`Porttimeout: 2000`), so you only have to set what you want to change. + ## Example +Both entry points return an `error`, which is non-nil when the setup itself +failed — most often because the technology fingerprints could not be downloaded. +Ignoring it means scanning every host with an empty database and reporting zero +technologies everywhere. + ```go package main import ( "fmt" + "log" "github.com/EasyRecon/wappaGo/structure" "github.com/EasyRecon/wappaGo/wrapper" @@ -117,26 +149,35 @@ func main() { Screenshot: "screenshots", } - // Async mode + // Sync mode: blocks, then returns everything. + results, err := wrapper.StartReconSync(input, options) + if err != nil { + log.Fatal(err) + } + for _, result := range results { + fmt.Println(result) + } +} +``` +```go + // Async mode: results are streamed on the channel, which wrapper closes + // when the scan is done. StartReconAsync blocks until then, so run the + // consumer in its own goroutine and wait for it to drain. results := make(chan structure.Data) + done := make(chan struct{}) go func() { + defer close(done) for result := range results { fmt.Println(result) } }() - wrapper.StartReconAsync(input, options, results) - - // Sync mode - - results := wrapper.StartReconSync(input, options) - - for _, result := range results { - fmt.Println(result) + if err := wrapper.StartReconAsync(input, options, results); err != nil { + log.Fatal(err) } -} + <-done ``` For each url, you will receive a structure.Data which contains all the information about the target. diff --git a/analyze/analyze_cert.go b/analyze/analyze_cert.go index ffa04b0..72b4230 100644 --- a/analyze/analyze_cert.go +++ b/analyze/analyze_cert.go @@ -4,10 +4,39 @@ import ( "github.com/EasyRecon/wappaGo/technologies" ) -func (a *Analyze)analyze_cert_main(technoName string,key string){ - if a.CertIssuer == a.ResultGlobal[technoName].(map[string]interface{})[key].(string) { +// analyze_cert_main matches the TLS issuer CN. The fingerprint value used to be +// asserted to string unconditionally, panicking on any technology that ships +// certIssuer as an array. +func (a *Analyze) analyze_cert_main(technoName string, key string) { + entry, ok := a.ResultGlobal[technoName].(map[string]interface{}) + if !ok || a.CertIssuer == "" { + return + } + for _, issuer := range namesOfValue(entry[key]) { + if a.CertIssuer != issuer { + continue + } technoTemp := a.NewTechno(technoName) a.Technos = append(a.Technos, technoTemp) a.Technos = technologies.CheckRequired(technoTemp.Name, a.ResultGlobal, a.Technos) + return + } +} + +// namesOfValue normalises a fingerprint value that may be a bare string or an +// array of strings into a slice. +func namesOfValue(v interface{}) []string { + switch t := v.(type) { + case string: + return []string{t} + case []interface{}: + out := make([]string, 0, len(t)) + for _, item := range t { + if s, ok := item.(string); ok { + out = append(out, s) + } + } + return out } -} \ No newline at end of file + return nil +} diff --git a/analyze/analyze_cookies.go b/analyze/analyze_cookies.go index b2c87ca..6febc3c 100644 --- a/analyze/analyze_cookies.go +++ b/analyze/analyze_cookies.go @@ -4,7 +4,7 @@ import ( "github.com/EasyRecon/wappaGo/technologies" ) -func (a *Analyze) analyze_cookies_main(technoName string,key string){ +func (a *Analyze) analyze_cookies_main(technoName string, key string) { for cookieTechno, _ := range a.ResultGlobal[technoName].(map[string]interface{})[key].(map[string]interface{}) { for _, cookie := range a.CookiesList { if cookieTechno == cookie.Name { @@ -14,4 +14,4 @@ func (a *Analyze) analyze_cookies_main(technoName string,key string){ } } } -} \ No newline at end of file +} diff --git a/analyze/analyze_dns.go b/analyze/analyze_dns.go index 8c84db2..a206ba3 100644 --- a/analyze/analyze_dns.go +++ b/analyze/analyze_dns.go @@ -1,55 +1,58 @@ package analyze - -import( - - "fmt" +import ( "github.com/EasyRecon/wappaGo/technologies" ) -func (a *Analyze) analyze_dns_main(technoName string,key string){ - for key,value := range a.ResultGlobal[technoName].(map[string]interface{})[key].(map[string]interface{}) { + +func (a *Analyze) analyze_dns_main(technoName string, key string) { + // DnsData is nil whenever resolution failed — fastdialer returns (nil, err) + // on NXDOMAIN or a resolver hiccup, and launchChrome assigned it anyway. + // Dereferencing it panicked, and the per-port recover() then dropped the + // whole target from the output without a word. + if a.DnsData == nil { + return + } + entry, ok := a.ResultGlobal[technoName].(map[string]interface{}) + if !ok { + return + } + records, ok := entry[key].(map[string]interface{}) + if !ok { + return + } + for key, value := range records { var resultDNS []string switch key { - case "TXT": - resultDNS = a.DnsData.TXT - case "SOA": - resultDNS = a.DnsData.SOA - case "NS": - resultDNS = a.DnsData.NS - case "CNAME": - resultDNS = a.DnsData.CNAME - case "MX": - resultDNS = a.DnsData.MX + case "TXT": + resultDNS = a.DnsData.TXT + case "SOA": + resultDNS = a.DnsData.SOA + case "NS": + resultDNS = a.DnsData.NS + case "CNAME": + resultDNS = a.DnsData.CNAME + case "MX": + resultDNS = a.DnsData.MX } - if fmt.Sprintf("%T",value) == "string" { - found := a.analyze_dns_regex(value.(string),resultDNS) - if found { + for _, regex := range namesOfValue(value) { + if a.analyze_dns_regex(regex, resultDNS) { technoTemp := a.NewTechno(technoName) a.Technos = append(a.Technos, technoTemp) a.Technos = technologies.CheckRequired(technoTemp.Name, a.ResultGlobal, a.Technos) } - } else { - for _,regex:= range value.([]interface{}) { - found := a.analyze_dns_regex(regex.(string),resultDNS) - if found { - technoTemp := a.NewTechno(technoName) - a.Technos = append(a.Technos, technoTemp) - a.Technos = technologies.CheckRequired(technoTemp.Name, a.ResultGlobal, a.Technos) - } - } } - } + } } -func (a *Analyze) analyze_dns_regex(regex string,resultsDNS []string)(bool){ +func (a *Analyze) analyze_dns_regex(regex string, resultsDNS []string) bool { re, ok := compileCI(regex) if !ok { return false } - for _,resultDNS:= range resultsDNS { + for _, resultDNS := range resultsDNS { if re.MatchString(resultDNS) { return true } } return false -} \ No newline at end of file +} diff --git a/analyze/analyze_dom.go b/analyze/analyze_dom.go index b2faa12..54dbb87 100644 --- a/analyze/analyze_dom.go +++ b/analyze/analyze_dom.go @@ -1,102 +1,148 @@ package analyze - import ( - "fmt" "strings" + "github.com/EasyRecon/wappaGo/technologies" "github.com/PuerkitoBio/goquery" "github.com/chromedp/chromedp" ) +// analyze_dom_main matches the fingerprint's "dom" family. The value is either +// a selector (string), a list of selectors, or a map of selector -> spec where +// the spec describes an attribute, a property, or the element's text. +func (a *Analyze) analyze_dom_main(technoName string, key string, doc *goquery.Document) { + if doc == nil { + return + } + entry, ok := a.ResultGlobal[technoName].(map[string]interface{}) + if !ok { + return + } + + switch spec := entry[key].(type) { + case string: + a.domSelectorPresent(technoName, spec, doc) + case []interface{}: + for _, selector := range spec { + if s, ok := selector.(string); ok { + a.domSelectorPresent(technoName, s, doc) + } + } -func (a *Analyze)analyze_dom_main(technoName string,key string, doc *goquery.Document){ - if fmt.Sprintf("%T", a.ResultGlobal[technoName].(map[string]interface{})[key]) == "string" { - doc.Find(a.ResultGlobal[technoName].(map[string]interface{})[key].(string)).Each(func(i int, s *goquery.Selection) { - technoTemp := a.NewTechno(technoName) - a.Technos = append(a.Technos, technoTemp) - a.Technos = technologies.CheckRequired(technoTemp.Name, a.ResultGlobal, a.Technos) - }) - } else if fmt.Sprintf("%T", a.ResultGlobal[technoName].(map[string]interface{})[key]) == "map[string]interface {}" { - for domKey, domArray := range a.ResultGlobal[technoName].(map[string]interface{})[key].(map[string]interface{}) { - for domKeyElement, domElement := range domArray.(map[string]interface{}) { - if fmt.Sprintf("%T", domElement) == "string" { - doc.Find(domKey).Each(func(i int, s *goquery.Selection) { - a.analyze_dom_valued(technoName,domElement) + case map[string]interface{}: + for domKey, domArray := range spec { + domSpec, ok := domArray.(map[string]interface{}) + if !ok { + continue + } + for domKeyElement, domElement := range domSpec { + switch element := domElement.(type) { + case string: + // Match the selected element's own text. This used to + // re-scan the ENTIRE page body once per matched element, so + // a bare "div" selector turned a single fingerprint into + // thousands of full-document regex scans (~10 s on a normal + // page). + doc.Find(domKey).EachWithBreak(func(i int, s *goquery.Selection) bool { + return !a.analyze_dom_valued(technoName, element, s) }) - } else if fmt.Sprintf("%T", domElement) == "map[string]interface {}" { - for domKeyElement2, domElement2 := range domElement.(map[string]interface{}) { + case map[string]interface{}: + for domKeyElement2, domElement2 := range element { if domKeyElement == "attributes" { - doc.Find(domKey).Each(func(i int, s *goquery.Selection) { - a.analyze_dom_attribute(technoName,domKeyElement2,domElement2,s) + doc.Find(domKey).EachWithBreak(func(i int, s *goquery.Selection) bool { + return !a.analyze_dom_attribute(technoName, domKeyElement2, domElement2, s) }) } else { - a.analyze_dom_exist(technoName,domKeyElement2,domKey) + a.analyze_dom_exist(technoName, domKeyElement2, domKey) } } } } } - } else { - for _, domArray := range a.ResultGlobal[technoName].(map[string]interface{})[key].([]interface{}) { - doc.Find(domArray.(string)).Each(func(i int, s *goquery.Selection) { - technoTemp := a.NewTechno(technoName) - a.Technos = append(a.Technos, technoTemp) - a.Technos = technologies.CheckRequired(technoTemp.Name, a.ResultGlobal, a.Technos) - }) - } - } + } +} + +// domSelectorPresent records the technology when the selector matches anything. +func (a *Analyze) domSelectorPresent(technoName string, selector string, doc *goquery.Document) { + if doc.Find(selector).Length() == 0 { + return + } + technoTemp := a.NewTechno(technoName) + a.Technos = append(a.Technos, technoTemp) + a.Technos = technologies.CheckRequired(technoTemp.Name, a.ResultGlobal, a.Technos) } -func (a *Analyze) analyze_dom_exist(technoName string,domKeyElement2 string,domKey string ){ + +func (a *Analyze) analyze_dom_exist(technoName string, domKeyElement2 string, domKey string) { var res interface{} + if a.Ctx == nil { + return + } chromedp.Evaluate("(()=>{a=false;document.querySelectorAll('"+domKey+"').forEach(element=>{if(element."+domKeyElement2+"!=undefined){a=true}});return a})()", &res).Do(a.Ctx) - //fmt.Println(res, "(()=>{a=false;document.querySelectorAll('"+domKey+"').forEach(element=>{if(element."+domKeyElement2+"!=undefined){a=true}});return a})()") if res == true { - technoTemp := a.NewTechno(technoName) + technoTemp := a.NewTechno(technoName) a.Technos = append(a.Technos, technoTemp) a.Technos = technologies.CheckRequired(technoTemp.Name, a.ResultGlobal, a.Technos) } } - -func (a *Analyze) analyze_dom_attribute(technoName string,domKeyElement2 string,domElement2 interface{},s *goquery.Selection){ - dommAttr, _ := s.Attr(domKeyElement2) - if dommAttr != "" { - if domKeyElement2 != "" { - regex := strings.Split(domElement2.(string), "\\;") - re, ok := compileCI(regex[0]) - if ok && re.MatchString(dommAttr) { - technoTemp := a.NewTechno(technoName) - regexGroup := re.FindAllStringSubmatch(dommAttr, -1) - technoTemp.Version = versionFromMarker(regex, regexGroup) - a.Technos = append(a.Technos, technoTemp) - a.Technos = technologies.CheckRequired(technoTemp.Name, a.ResultGlobal, a.Technos) - } - } else { - technoTemp := a.NewTechno(technoName) - a.Technos = append(a.Technos, technoTemp) - a.Technos = technologies.CheckRequired(technoTemp.Name, a.ResultGlobal, a.Technos) - } +func (a *Analyze) analyze_dom_attribute(technoName string, attrName string, pattern interface{}, s *goquery.Selection) bool { + if attrName == "" { + return false + } + attrValue, exists := s.Attr(attrName) + if !exists || attrValue == "" { + return false } + patternStr, ok := pattern.(string) + if !ok { + return false + } + // An empty pattern means the attribute only has to be present. + if patternStr == "" { + technoTemp := a.NewTechno(technoName) + a.Technos = append(a.Technos, technoTemp) + a.Technos = technologies.CheckRequired(technoTemp.Name, a.ResultGlobal, a.Technos) + return true + } + regex := strings.Split(patternStr, "\\;") + re, ok := compileCI(regex[0]) + if !ok { + return false + } + groups := re.FindStringSubmatch(attrValue) + if groups == nil { + return false + } + technoTemp := a.NewTechno(technoName) + technoTemp.Version = versionFromMarker(regex, [][]string{groups}) + a.Technos = append(a.Technos, technoTemp) + a.Technos = technologies.CheckRequired(technoTemp.Name, a.ResultGlobal, a.Technos) + return true } - -func (a *Analyze) analyze_dom_valued(technoName string,domElement interface{}){ - if domElement == "" { +// analyze_dom_valued matches a dom spec's text/value pattern against the +// selected element. +func (a *Analyze) analyze_dom_valued(technoName string, pattern string, s *goquery.Selection) bool { + if pattern == "" { technoTemp := a.NewTechno(technoName) a.Technos = append(a.Technos, technoTemp) a.Technos = technologies.CheckRequired(technoTemp.Name, a.ResultGlobal, a.Technos) - } else { - regex := strings.Split(domElement.(string), "\\;") - re, ok := compileCI(regex[0]) - if ok && re.MatchString(a.Body) { - //fmt.Println(technoName) - technoTemp := a.NewTechno(technoName) - regexGroup := re.FindAllStringSubmatch(a.Body, -1) - technoTemp.Version = versionFromMarker(regex, regexGroup) - a.Technos = append(a.Technos, technoTemp) - a.Technos = technologies.CheckRequired(technoTemp.Name, a.ResultGlobal, a.Technos) - } + return true + } + regex := strings.Split(pattern, "\\;") + re, ok := compileCI(regex[0]) + if !ok { + return false + } + groups := re.FindStringSubmatch(s.Text()) + if groups == nil { + return false } + technoTemp := a.NewTechno(technoName) + technoTemp.Version = versionFromMarker(regex, [][]string{groups}) + a.Technos = append(a.Technos, technoTemp) + a.Technos = technologies.CheckRequired(technoTemp.Name, a.ResultGlobal, a.Technos) + return true } diff --git a/analyze/analyze_golden_test.go b/analyze/analyze_golden_test.go index c435983..7f83383 100644 --- a/analyze/analyze_golden_test.go +++ b/analyze/analyze_golden_test.go @@ -99,6 +99,47 @@ func TestRunGoldenDetection(t *testing.T) { } } +// TestIconIsCarriedThrough pins the icon field: it is copied from the database +// for detected and implied technologies alike, and omitted (rather than +// invented) for fingerprints that declare none. +func TestIconIsCarriedThrough(t *testing.T) { + rg := map[string]interface{}{ + "Nginx": map[string]interface{}{ + "icon": "Nginx.svg", + "headers": map[string]interface{}{"Server": "nginx"}, + "implies": "Reverse proxy", + }, + "Reverse proxy": map[string]interface{}{"icon": "ReverseProxy.svg"}, + "NoIcon": map[string]interface{}{ + "html": "no-icon-marker", + }, + } + a := Analyze{ + ResultGlobal: rg, + Body: `no-icon-marker`, + Resp: structure.Response{Headers: map[string][]string{"Server": {"nginx"}}}, + } + + icons := map[string]string{} + for _, te := range technologies.DedupTechno(a.Run()) { + icons[te.Name] = te.Icon + } + for name, want := range map[string]string{ + "Nginx": "Nginx.svg", + "Reverse proxy": "ReverseProxy.svg", // reached through implies + "NoIcon": "", + } { + got, ok := icons[name] + if !ok { + t.Errorf("%q not detected (got %v)", name, icons) + continue + } + if got != want { + t.Errorf("%q icon = %q, want %q", name, got, want) + } + } +} + // TestRunGoldenURLAndDNS covers the matchers converted from regexp.MatchString // to the cached compileCI (url, dns) and pins the panic-safety contract: a // PCRE-only pattern that Go's RE2 engine rejects must be skipped silently @@ -209,6 +250,312 @@ func TestRequiresGate(t *testing.T) { } } +// TestHeaderKeyCasing pins the header-lookup fix. Wappalyzer keys its header +// fingerprints however upstream wrote them ("x-powered-by", "server"), while +// a.Resp.Headers is an http.Header clone and is therefore canonical +// ("X-Powered-By", "Server"). Indexing the fingerprint with the response's key +// silently disabled 43% of the header database, so every one of these forms +// must now match. +func TestHeaderKeyCasing(t *testing.T) { + rg := map[string]interface{}{ + // lowercase key + version marker, as Vercel/Ktor ship it + "LowerWithVersion": map[string]interface{}{ + "headers": map[string]interface{}{"x-engine": `Ktor/([\d.]+)\;version:\1`}, + }, + // lowercase key, presence only + "LowerPresence": map[string]interface{}{ + "headers": map[string]interface{}{"x-vercel-cache": ""}, + }, + // mixed case that canonicalises differently ("X-Aspnet-Version") + "MixedCase": map[string]interface{}{ + "headers": map[string]interface{}{"X-AspNet-Version": `([\d.]+)`}, + }, + // already canonical: the one spelling that worked before + "Canonical": map[string]interface{}{ + "headers": map[string]interface{}{"Server": `nginx/([\d.]+)\;version:\1`}, + }, + } + a := Analyze{ + ResultGlobal: rg, + Resp: structure.Response{ + Headers: map[string][]string{ + "X-Engine": {"Ktor/2.3.4"}, + "X-Vercel-Cache": {"HIT"}, + "X-Aspnet-Version": {"4.0.30319"}, + "Server": {"nginx/1.21.0"}, + "X-Unrelated-Header": {"noise"}, + }, + }, + } + + got := technologyVersions(technologies.DedupTechno(a.Run())) + for name, wantVer := range map[string]string{ + "LowerWithVersion": "2.3.4", + "LowerPresence": "", + "MixedCase": "", + "Canonical": "1.21.0", + } { + ver, ok := got[name] + if !ok { + t.Errorf("%q not detected — header lookup missed its fingerprint key (got %v)", name, got) + continue + } + if ver != wantVer { + t.Errorf("%q version = %q, want %q", name, ver, wantVer) + } + } + if len(got) != 4 { + t.Errorf("detected %d technologies, want 4: %v", len(got), got) + } +} + +// TestHeaderRepeatedValues covers the second half of the same fix: a header can +// repeat, and only values[0] used to be examined (which also panicked on an +// empty value slice). +func TestHeaderRepeatedValues(t *testing.T) { + rg := map[string]interface{}{ + "Varnish": map[string]interface{}{ + "headers": map[string]interface{}{"via": `varnish \(Varnish/([\d.]+)\)\;version:\1`}, + }, + } + a := Analyze{ + ResultGlobal: rg, + Resp: structure.Response{ + Headers: map[string][]string{ + "Via": {"1.1 google", "1.1 varnish (Varnish/7.1)"}, + "Empty": {}, + "Server": {"nginx"}, + }, + }, + } + + got := technologyVersions(technologies.DedupTechno(a.Run())) // must not panic + if got["Varnish"] != "7.1" { + t.Errorf("Varnish version = %q, want %q (only the first header value was inspected)", got["Varnish"], "7.1") + } +} + +// TestFinalHeadersPreferredOverProbe pins the redirect fix on the header side: +// with -follow-redirect off the raw probe stops at the 30x, so its headers +// describe the redirector, not the page Chrome renders and analyses. A host +// redirecting http->https therefore reported none of its header-based +// technologies. CDP also newline-joins repeated headers, which must be split +// back apart. +func TestFinalHeadersPreferredOverProbe(t *testing.T) { + rg := map[string]interface{}{ + "Nginx": map[string]interface{}{"headers": map[string]interface{}{"Server": `nginx/([\d.]+)\;version:\1`}}, + "Varnish": map[string]interface{}{"headers": map[string]interface{}{"via": `(\d+(?:\.\d+)?)\s+varnish\;version:\1`}}, + } + + a := Analyze{ + ResultGlobal: rg, + Body: "", + // The probe only saw the redirector: no Server, no Via. + Resp: structure.Response{Headers: map[string][]string{"Location": {"https://example.com/"}}}, + } + // What Chrome reported for the final document, in CDP's shape. + a.SetFinalHeaders("https://example.com/", map[string]interface{}{ + "server": "nginx/1.25.3", + "via": "1.1 google\n1.1 varnish", + }) + + got := technologyVersions(technologies.DedupTechno(a.Run())) + if got["Nginx"] != "1.25.3" { + t.Errorf("Nginx = %q, want %q — the final page's headers must be used", got["Nginx"], "1.25.3") + } + if got["Varnish"] != "1.1" { + t.Errorf("Varnish = %q, want %q — repeated headers must be split on newline", got["Varnish"], "1.1") + } + + // With no Chrome headers at all, the probe's are still used. + b := Analyze{ + ResultGlobal: rg, + Body: "", + Resp: structure.Response{Headers: map[string][]string{"Server": {"nginx/1.21.0"}}}, + } + if got := technologyVersions(technologies.DedupTechno(b.Run())); got["Nginx"] != "1.21.0" { + t.Errorf("fallback to probe headers broken: %v", got) + } +} + +// TestHeadersAreMergedNotReplaced pins the union: Chrome never reports Alt-Svc +// (it consumes it), and that header is the only signal for HTTP/3, so preferring +// Chrome's set outright silently dropped it. Chrome must still win on headers +// present in both, since its view is the page that was actually analysed. +func TestHeadersAreMergedNotReplaced(t *testing.T) { + rg := map[string]interface{}{ + "HTTP/3": map[string]interface{}{"headers": map[string]interface{}{"Alt-Svc": "h3"}}, + "Nginx": map[string]interface{}{"headers": map[string]interface{}{"Server": `nginx/([\d.]+)\;version:\1`}}, + } + a := Analyze{ + ResultGlobal: rg, + Body: "", + Resp: structure.Response{Headers: map[string][]string{ + "Alt-Svc": {`h3=":443"; ma=86400`}, // only the probe sees this + "Server": {"nginx/1.21.0"}, // stale: this was the redirector + }}, + } + a.SetFinalHeaders("https://example.com/", map[string]interface{}{ + "server": "nginx/1.25.3", // the page actually analysed + }) + + got := technologyVersions(technologies.DedupTechno(a.Run())) + if _, ok := got["HTTP/3"]; !ok { + t.Errorf("probe-only header lost: %v", got) + } + if got["Nginx"] != "1.25.3" { + t.Errorf("Nginx = %q, want %q — Chrome must win where both have the header", got["Nginx"], "1.25.3") + } +} + +// TestUrlMatchesScannedURL pins the url-family fix: patterns are matched +// against the URL that was scanned, not against the Location header, and no +// longer require the host to have redirected. +func TestUrlMatchesScannedURL(t *testing.T) { + rg := map[string]interface{}{ + "AdminPanel": map[string]interface{}{"url": `example\.com/admin`}, + "Ionos": map[string]interface{}{"url": `^https?://[^/]+\.ionos\.(space|live)`}, + } + + // No redirect at all: both fingerprints used to be skipped outright. + a := Analyze{ResultGlobal: rg, Url: "https://example.com/admin", Body: ""} + if got := technologyVersions(technologies.DedupTechno(a.Run())); len(got) != 1 || got["AdminPanel"] != "" { + t.Errorf("AdminPanel not detected on a non-redirecting host: %v", got) + } + + b := Analyze{ResultGlobal: rg, Url: "https://foo.ionos.space", Body: ""} + if got := technologyVersions(technologies.DedupTechno(b.Run())); len(got) != 1 || got["Ionos"] != "" { + t.Errorf("Ionos not detected: %v", got) + } + + // The redirect target is still considered. + c := Analyze{ + ResultGlobal: rg, + Url: "https://other.test", + Hote: structure.Host{Location: "https://example.com/admin"}, + Body: "", + } + if got := technologyVersions(technologies.DedupTechno(c.Run())); len(got) != 1 { + t.Errorf("redirect target ignored: %v", got) + } + + // Neither matches -> nothing detected. + d := Analyze{ResultGlobal: rg, Url: "https://unrelated.test/", Body: ""} + if got := technologyVersions(technologies.DedupTechno(d.Run())); len(got) != 0 { + t.Errorf("false positive: %v", got) + } +} + +// TestMetaMatchesPropertyAttribute pins the meta fix: Wappalyzer keys meta +// fingerprints on either attribute, and only meta[name=] used to be searched, so +// every og:/twitter:/fb: fingerprint was unreachable. +func TestMetaMatchesPropertyAttribute(t *testing.T) { + rg := map[string]interface{}{ + "OpenGraph": map[string]interface{}{ + "meta": map[string]interface{}{"og:site_name": ""}, + }, + "Generator": map[string]interface{}{ + "meta": map[string]interface{}{"generator": `Hugo ([\d.]+)\;version:\1`}, + }, + } + a := Analyze{ + ResultGlobal: rg, + Body: `` + + `` + + `` + + ``, + } + + got := technologyVersions(technologies.DedupTechno(a.Run())) + if _, ok := got["OpenGraph"]; !ok { + t.Errorf("meta[property] fingerprint not matched: %v", got) + } + if got["Generator"] != "0.157.0" { + t.Errorf("meta[name] version = %q, want %q", got["Generator"], "0.157.0") + } +} + +// TestTextMatchesRenderedTextOnly pins the html-vs-text split: a "text" pattern +// must match the page's rendered text, not its markup, or it fires on attribute +// values and URLs that are invisible to a visitor. +func TestTextMatchesRenderedTextOnly(t *testing.T) { + rg := map[string]interface{}{ + "MarkupOnly": map[string]interface{}{"text": "data-vendor"}, + "RealText": map[string]interface{}{"text": "Powered by Acme"}, + "HtmlFamily": map[string]interface{}{"html": "data-vendor"}, + } + a := Analyze{ + ResultGlobal: rg, + Body: `

    Powered by Acme

    `, + } + + got := technologyVersions(technologies.DedupTechno(a.Run())) + if _, ok := got["RealText"]; !ok { + t.Errorf("visible text not matched: %v", got) + } + if _, ok := got["HtmlFamily"]; !ok { + t.Errorf("html family must still see the markup: %v", got) + } + if _, ok := got["MarkupOnly"]; ok { + t.Errorf("a text pattern matched an attribute value: %v", got) + } +} + +// TestDomTextUsesElementText pins the dom fix: the pattern is matched against +// the selected element, not against the whole document. +func TestDomTextUsesElementText(t *testing.T) { + rg := map[string]interface{}{ + "Widget": map[string]interface{}{ + "dom": map[string]interface{}{ + ".version": map[string]interface{}{"text": `v([\d.]+)\;version:\1`}, + }, + }, + } + a := Analyze{ + ResultGlobal: rg, + Body: `v9.9.9` + + `v1.2.3`, + } + + got := technologyVersions(technologies.DedupTechno(a.Run())) + if got["Widget"] != "1.2.3" { + t.Errorf("version = %q, want %q — the pattern must read the selected element, not the page", got["Widget"], "1.2.3") + } +} + +// TestBadFingerprintShapesDoNotPanic feeds every matcher a value of the wrong +// JSON type. The database is third-party data fetched at startup, so a shape +// change must degrade detection, not crash the scan. +func TestBadFingerprintShapesDoNotPanic(t *testing.T) { + rg := map[string]interface{}{ + "BadHeaders": map[string]interface{}{"headers": "should-be-an-object"}, + "BadMeta": map[string]interface{}{"meta": []interface{}{"should-be-an-object"}}, + "BadDom": map[string]interface{}{"dom": map[string]interface{}{"div": "should-be-an-object"}}, + "BadCert": map[string]interface{}{"certIssuer": []interface{}{1, 2, 3}}, + "BadDns": map[string]interface{}{"dns": "should-be-an-object"}, + "BadUrl": map[string]interface{}{"url": 42}, + "BadScriptSrc": map[string]interface{}{"scriptSrc": map[string]interface{}{"a": "b"}}, + "BadHtml": map[string]interface{}{"html": map[string]interface{}{"a": "b"}}, + "BadXhr": map[string]interface{}{"xhr": 3.14}, + "NotAnObject": "this technology is not a map at all", + "NilEverything": map[string]interface{}{"headers": nil, "html": nil, "meta": nil}, + } + a := Analyze{ + ResultGlobal: rg, + Body: `
    hello
    `, + Resp: structure.Response{Headers: map[string][]string{"Server": {"nginx"}}}, + SrcList: []string{"https://cdn.test/x.js"}, + Url: "https://example.com", + CertIssuer: "Some CA", + // DnsData deliberately nil: resolution failure must not panic either. + } + + got := technologies.DedupTechno(a.Run()) // must not panic + if len(got) != 0 { + t.Errorf("malformed fingerprints produced detections: %v", technologyNames(got)) + } +} + // TestCompileCICachesAndToleratesBadPatterns documents the contract the // matchers rely on: valid patterns compile (and are cached), invalid RE2 // patterns return ok=false instead of panicking. diff --git a/analyze/analyze_headers.go b/analyze/analyze_headers.go index 64a2d66..b06181e 100644 --- a/analyze/analyze_headers.go +++ b/analyze/analyze_headers.go @@ -3,31 +3,65 @@ package analyze import ( "fmt" "strings" + "github.com/EasyRecon/wappaGo/technologies" ) +// analyze_headers_main matches a fingerprint's "headers" patterns against the +// response headers. +// +// The two maps are keyed differently and must not be confused: the fingerprint +// keeps Wappalyzer's original spelling ("x-powered-by", "server"), while +// a.Resp.Headers comes from http.Header.Clone() and is therefore in Go's +// canonical MIME form ("X-Powered-By", "Server"). The pattern used to be looked +// up with the *response* key, which missed 301 of the database's 690 header +// fingerprints (43%, spread over 211 technologies): the lookup returned a nil +// interface, and since nil != "" the code went on to compile the literal +// "" as a regex, so the regex form and the presence-only form were both +// lost. The pattern now comes straight from the range over the fingerprint map, +// so no lookup can miss. +func (a *Analyze) analyze_headers_main(technoName string, key string) { + entry, ok := a.ResultGlobal[technoName].(map[string]interface{}) + if !ok { + return + } + patterns, ok := entry[key].(map[string]interface{}) + if !ok { + return + } -func (a *Analyze)analyze_headers_main(technoName string,key string){ - for header, _ := range a.ResultGlobal[technoName].(map[string]interface{})[key].(map[string]interface{}) { - for headerName, _ := range a.Resp.Headers { - if strings.ToLower(header) == strings.ToLower(headerName) { - //headerValue := a.ResultGlobal[technoName].(map[string]interface{})[key].(map[string]interface{})[header] - if a.ResultGlobal[technoName].(map[string]interface{})[key].(map[string]interface{})[headerName] != "" { - regex := strings.Split(fmt.Sprintf("%v", a.ResultGlobal[technoName].(map[string]interface{})[key].(map[string]interface{})[headerName]), "\\;") - re, ok := compileCI(regex[0]) - if ok && re.MatchString(a.Resp.Headers[headerName][0]) { - technoTemp := a.NewTechno(technoName) - regexGroup := re.FindAllStringSubmatch(a.Resp.Headers[headerName][0], -1) - technoTemp.Version = versionFromMarker(regex, regexGroup) - a.Technos = append(a.Technos, technoTemp) - a.Technos = technologies.CheckRequired(technoTemp.Name, a.ResultGlobal, a.Technos) - } - } else { - technoTemp := a.NewTechno(technoName) - a.Technos = append(a.Technos, technoTemp) - a.Technos = technologies.CheckRequired(technoTemp.Name, a.ResultGlobal, a.Technos) + for header, pattern := range patterns { + for headerName, values := range a.headers() { + if !strings.EqualFold(header, headerName) { + continue + } + // An empty pattern means "the header only has to be present". + if pattern == "" { + a.addHeaderTechno(technoName, "") + continue + } + regex := strings.Split(fmt.Sprintf("%v", pattern), "\\;") + re, ok := compileCI(regex[0]) + if !ok { + continue + } + // A header may legitimately repeat (Via, Set-Cookie, ...). The old + // code only read values[0], and would have panicked on an empty + // slice. + for _, value := range values { + if !re.MatchString(value) { + continue } + a.addHeaderTechno(technoName, versionFromMarker(regex, re.FindAllStringSubmatch(value, -1))) + break } } } } + +func (a *Analyze) addHeaderTechno(technoName string, version string) { + technoTemp := a.NewTechno(technoName) + technoTemp.Version = version + a.Technos = append(a.Technos, technoTemp) + a.Technos = technologies.CheckRequired(technoTemp.Name, a.ResultGlobal, a.Technos) +} diff --git a/analyze/analyze_html.go b/analyze/analyze_html.go index e34e91c..23a1de9 100644 --- a/analyze/analyze_html.go +++ b/analyze/analyze_html.go @@ -1,29 +1,57 @@ package analyze import ( - "fmt" "strings" + "github.com/EasyRecon/wappaGo/technologies" ) -func (a *Analyze)analyze_html_main(technoName string,key string){ - if fmt.Sprintf("%T", a.ResultGlobal[technoName].(map[string]interface{})[key]) == "string" { - a.analyze_html(technoName, a.ResultGlobal[technoName].(map[string]interface{})[key]) - } else { - for _, htmlRegex := range a.ResultGlobal[technoName].(map[string]interface{})[key].([]interface{}) { - a.analyze_html(technoName,htmlRegex) - } +// analyze_html_main matches the "html" and "text" families. +// +// They are not the same haystack: "html" matches the raw markup, while "text" +// is meant to match the page's rendered text. Both used to run against the full +// markup, so a "text" pattern could match an attribute value, a URL or a +// comment and report a technology that is not visible on the page at all. +func (a *Analyze) analyze_html_main(technoName string, key string) { + entry, ok := a.ResultGlobal[technoName].(map[string]interface{}) + if !ok { + return + } + haystack := a.Body + if key == "text" { + haystack = a.text() + } + if haystack == "" { + return + } + for _, pattern := range namesOfValue(entry[key]) { + a.analyze_html(technoName, pattern, haystack) } } -func (a *Analyze) analyze_html(technoName string,regexStr interface{}) { - regex := strings.Split(fmt.Sprintf("%v", regexStr), "\\;") +// text returns the document's rendered text, computed once per page. +func (a *Analyze) text() string { + if a.textCache == "" && a.Doc != nil { + a.textCache = a.Doc.Text() + } + return a.textCache +} + +func (a *Analyze) analyze_html(technoName string, regexStr string, haystack string) { + regex := strings.Split(regexStr, "\\;") re, ok := compileCI(regex[0]) - if ok && re.MatchString(a.Body) { - technoTemp := a.NewTechno(technoName) - regexGroup := re.FindAllStringSubmatch(a.Body, -1) - technoTemp.Version = versionFromMarker(regex, regexGroup) - a.Technos = append(a.Technos, technoTemp) - a.Technos = technologies.CheckRequired(technoTemp.Name, a.ResultGlobal, a.Technos) + if !ok { + return } -} \ No newline at end of file + // FindStringSubmatch, not FindAllStringSubmatch: only the first match is + // ever consumed, and scanning the whole body for every remaining match was + // pure waste on large pages. + groups := re.FindStringSubmatch(haystack) + if groups == nil { + return + } + technoTemp := a.NewTechno(technoName) + technoTemp.Version = versionFromMarker(regex, [][]string{groups}) + a.Technos = append(a.Technos, technoTemp) + a.Technos = technologies.CheckRequired(technoTemp.Name, a.ResultGlobal, a.Technos) +} diff --git a/analyze/analyze_javascript.go b/analyze/analyze_javascript.go index f3fc115..1120e24 100644 --- a/analyze/analyze_javascript.go +++ b/analyze/analyze_javascript.go @@ -2,54 +2,132 @@ package analyze import ( "fmt" + "strconv" "strings" + "github.com/EasyRecon/wappaGo/technologies" "github.com/chromedp/chromedp" - ) -func (a *Analyze) analyze_js_main(technoName string,key string){ - for js, _ := range a.ResultGlobal[technoName].(map[string]interface{})[key].(map[string]interface{}) { - if a.ResultGlobal[technoName].(map[string]interface{})[key].(map[string]interface{})[js] != "" { // just check if existe & match regex - a.analyze_js_valued(fmt.Sprintf("%v", a.ResultGlobal[technoName].(map[string]interface{})[key].(map[string]interface{})[js]),js,technoName) - } else { // just check if existe - a.analyze_js_exist(js,technoName) +func (a *Analyze) analyze_js_main(technoName string, key string) { + if a.Ctx == nil { + return + } + entry, ok := a.ResultGlobal[technoName].(map[string]interface{}) + if !ok { + return + } + jsSpecs, ok := entry[key].(map[string]interface{}) + if !ok { + return + } + for js, spec := range jsSpecs { + if spec != "" { // the property must exist AND match a regex + a.analyze_js_valued(fmt.Sprintf("%v", spec), js, technoName) + } else { // the property only has to exist + a.analyze_js_exist(js, technoName) } } } -func (a *Analyze) analyze_js_valued(regexStr string, js string, technoName string){ + +func (a *Analyze) analyze_js_valued(regexStr string, js string, technoName string) { regex := strings.Split(regexStr, "\\;") var res interface{} - if regex[0] != "" { - chromedp.Evaluate("(()=>{return "+js+".match(/"+regex[0]+"/gm)[0]})()", &res).Do(a.Ctx) - } else { + + if regex[0] == "" { chromedp.Evaluate("(()=>{return (typeof "+js+" !== 'undefined' ? true : false)})()", &res).Do(a.Ctx) + } else { + // Read the requested capture group inside the page. + // + // The old expression was `.match(//gm)[0]`, wrong three ways: + // the `g` flag makes String.match return whole matches with no capture + // groups, so `\;version:\1` could never work and the reported "version" + // was the entire match ("Meteor.release=1.2" instead of "1.2"); + // indexing [0] throws when match returns null, and that error was + // discarded; and an unescaped "/" in the pattern closed the regex + // literal early, producing a SyntaxError for 31 live patterns. Building + // the RegExp from a properly quoted string literal avoids all three. + group := versionGroupIndex(regex) + expr := "(()=>{try{var m=String(" + js + ").match(new RegExp(" + jsStringLiteral(regex[0]) + ",'m'));" + + "if(!m)return null;var g=m[" + strconv.Itoa(group) + "];return g!==undefined?g:m[0]}catch(e){return null}})()" + chromedp.Evaluate(expr, &res).Do(a.Ctx) } - if res != nil && res != false { - technoTemp := a.NewTechno(technoName) - if (len(regex) > 1 && strings.HasPrefix(regex[1], "confidence")) || (len(regex) > 2 && strings.HasPrefix(regex[2], "confidence")) { - if len(regex) > 1 && strings.HasPrefix(regex[1], "confidence") { - technoTemp.Confidence = strings.Split(regex[1], ":")[1] - } - if len(regex) > 2 && strings.HasPrefix(regex[2], "confidence") { - technoTemp.Confidence = strings.Split(regex[2], ":")[1] - } - } - if (len(regex) > 1 && strings.HasPrefix(regex[1], "version")) || (len(regex) > 2 && strings.HasPrefix(regex[2], "version")) { - //fmt.Println(res,technoName,regex) - if len(regex) > 1 && strings.HasPrefix(regex[1], "version") { - technoTemp.Version = fmt.Sprintf("%v", res) - } - if len(regex) > 2 && strings.HasPrefix(regex[2], "version") { + + if res == nil || res == false { + return + } + + technoTemp := a.NewTechno(technoName) + for _, marker := range regex[1:] { + switch { + case strings.HasPrefix(marker, "confidence:"): + technoTemp.Confidence = strings.TrimPrefix(marker, "confidence:") + case strings.HasPrefix(marker, "version:"): + // res already is the capture group the marker asked for. + if s, ok := res.(string); ok { + technoTemp.Version = s + } else if res != true { technoTemp.Version = fmt.Sprintf("%v", res) } } - a.Technos = append(a.Technos, technoTemp) - a.Technos = technologies.CheckRequired(technoTemp.Name, a.ResultGlobal, a.Technos) } + a.Technos = append(a.Technos, technoTemp) + a.Technos = technologies.CheckRequired(technoTemp.Name, a.ResultGlobal, a.Technos) +} + +// versionGroupIndex returns the capture-group number a "\;version:\N" marker +// refers to, defaulting to 0 (the whole match). +func versionGroupIndex(regex []string) int { + for _, marker := range regex[1:] { + if !strings.HasPrefix(marker, "version:") { + continue + } + spec := strings.TrimPrefix(marker, "version:") + i := strings.Index(spec, "\\") + if i < 0 { + continue + } + digits := spec[i+1:] + end := 0 + for end < len(digits) && digits[end] >= '0' && digits[end] <= '9' { + end++ + } + if n, err := strconv.Atoi(digits[:end]); err == nil { + return n + } + } + return 0 +} + +// jsStringLiteral renders s as a JavaScript string literal so a pattern +// containing quotes, backslashes or newlines cannot break out of the expression +// it is embedded in. +func jsStringLiteral(s string) string { + var b strings.Builder + b.WriteByte('"') + for _, r := range s { + switch r { + case '"': + b.WriteString(`\"`) + case '\\': + b.WriteString(`\\`) + case '\n': + b.WriteString(`\n`) + case '\r': + b.WriteString(`\r`) + case '
': + b.WriteString(`
`) + case '
': + b.WriteString(`
`) + default: + b.WriteRune(r) + } + } + b.WriteByte('"') + return b.String() } -func (a *Analyze) analyze_js_exist(js string,technoName string){ +func (a *Analyze) analyze_js_exist(js string, technoName string) { var res interface{} chromedp.Evaluate("(()=>{ return (typeof "+js+" !== 'undefined' ? true : false)})()", &res).Do(a.Ctx) if res == true { diff --git a/analyze/analyze_main.go b/analyze/analyze_main.go index e27ab12..9679ff3 100644 --- a/analyze/analyze_main.go +++ b/analyze/analyze_main.go @@ -2,79 +2,96 @@ package analyze import ( "context" - "strings" - "sync" "github.com/EasyRecon/wappaGo/lib" structure "github.com/EasyRecon/wappaGo/structure" + "github.com/EasyRecon/wappaGo/technologies" "github.com/PuerkitoBio/goquery" cdp "github.com/chromedp/cdproto/cdp" "github.com/chromedp/cdproto/network" "github.com/projectdiscovery/retryabledns" + "strings" + "sync" ) type Analyze struct { - ResultGlobal map[string]interface{} - Resp structure.Response - SrcList []string - Ctx context.Context - Hote structure.Host - CookiesList []*network.Cookie - Node *cdp.Node - Body string - Doc *goquery.Document - Technos []structure.Technologie - DnsData *retryabledns.DNSData - CertVhost []string - CertIssuer string - CSSContent []string - XHRUrl []string - xhrMu sync.Mutex // guards XHRUrl (written from the CDP listener goroutine) + ResultGlobal map[string]interface{} + Resp structure.Response + SrcList []string + Ctx context.Context + Hote structure.Host + CookiesList []*network.Cookie + Node *cdp.Node + Body string + Doc *goquery.Document + textCache string // rendered document text, computed on first "text" match + Technos []structure.Technologie + // Url is the URL that was scanned. The "url" matchers need it; they used to + // fall back to Hote.Location, which is only ever set on a redirect. + Url string + DnsData *retryabledns.DNSData + CertVhost []string + CertIssuer string + XHRUrl []string + // finalHeaders / finalURL are the main document's response as Chrome saw + // it, after redirects. Written from the CDP listener goroutine, so they are + // guarded by xhrMu like XHRUrl. + finalHeaders map[string][]string + finalURL string + xhrMu sync.Mutex } - func (a *Analyze) Run() []structure.Technologie { // Reuse the document already parsed by the caller (launchChrome) when // available; only parse here as a fallback (e.g. unit tests). - doc := a.Doc - if doc == nil { - doc, _ = goquery.NewDocumentFromReader(strings.NewReader(a.Body)) + if a.Doc == nil { + // Store it back, not just locally: the text() matcher and any later + // caller need the same document. + a.Doc, _ = goquery.NewDocumentFromReader(strings.NewReader(a.Body)) } - //a.Hote := Host{} - for technoName, _ := range a.ResultGlobal { - for key, _ := range a.ResultGlobal[technoName].(map[string]interface{}) { + doc := a.Doc + + for technoName := range a.ResultGlobal { + // The database is third-party JSON fetched at startup. A technology + // whose value is not an object used to panic right here, taking down the + // whole target (and, before the per-target recover(), the process). + entry, ok := a.ResultGlobal[technoName].(map[string]interface{}) + if !ok { + continue + } + for key := range entry { if lib.Contains(structure.InterrestingKey, key) { if key == "js" { - a.analyze_js_main(technoName,key) + a.analyze_js_main(technoName, key) } if key == "headers" { - a.analyze_headers_main(technoName,key) + a.analyze_headers_main(technoName, key) } if key == "dom" { - a.analyze_dom_main(technoName,key,doc) + a.analyze_dom_main(technoName, key, doc) } if key == "cookies" && len(a.CookiesList) > 0 { - a.analyze_cookies_main(technoName,key) + a.analyze_cookies_main(technoName, key) } if key == "scriptSrc" { - a.analyze_scriptSrc_main(technoName,key) + a.analyze_scriptSrc_main(technoName, key) } if key == "url" { - a.analyze_url_main(technoName,key) + a.analyze_url_main(technoName, key) } if key == "html" || key == "text" { - a.analyze_html_main(technoName,key) + a.analyze_html_main(technoName, key) } if key == "meta" { - a.analyze_meta_main(technoName,key,doc) + a.analyze_meta_main(technoName, key, doc) } if key == "dns" { - a.analyze_dns_main(technoName,key) + a.analyze_dns_main(technoName, key) } if key == "certIssuer" { - a.analyze_cert_main(technoName,key) + a.analyze_cert_main(technoName, key) } if key == "xhr" { - a.analyze_xhr_main(technoName,key) + a.analyze_xhr_main(technoName, key) } } @@ -83,18 +100,6 @@ func (a *Analyze) Run() []structure.Technologie { return a.Technos } - - - - -func (a *Analyze) NewTechno(name string)(structure.Technologie){ - technoTemp := structure.Technologie{Name: name} - // The fingerprint may be absent (e.g. an implied techno missing from the - // DB) or carry a non-string cpe; both are tolerated rather than panicking. - if entry, ok := a.ResultGlobal[name].(map[string]interface{}); ok { - if cpe, ok := entry["cpe"].(string); ok { - technoTemp.Cpe = cpe - } - } - return technoTemp -} \ No newline at end of file +func (a *Analyze) NewTechno(name string) structure.Technologie { + return technologies.New(name, a.ResultGlobal) +} diff --git a/analyze/analyze_meta.go b/analyze/analyze_meta.go index b0df03a..c92d555 100644 --- a/analyze/analyze_meta.go +++ b/analyze/analyze_meta.go @@ -1,39 +1,66 @@ package analyze import ( - "fmt" "strings" + "github.com/EasyRecon/wappaGo/technologies" "github.com/PuerkitoBio/goquery" - ) +// analyze_meta_main matches the fingerprint's "meta" patterns against the +// document's tags. +// +// Only meta[name=...] used to be searched. Wappalyzer keys its meta +// fingerprints on either attribute, and every Open Graph / Twitter Card style +// entry (og:*, twitter:*, fb:*) is published as meta[property=...], so those +// fingerprints could never match. +func (a *Analyze) analyze_meta_main(technoName string, key string, doc *goquery.Document) { + if doc == nil { + return + } + entry, ok := a.ResultGlobal[technoName].(map[string]interface{}) + if !ok { + return + } + metas, ok := entry[key].(map[string]interface{}) + if !ok { + return + } -func (a *Analyze)analyze_meta_main(technoName string,key string,doc *goquery.Document){ - - for metaKey, metaProperties := range a.ResultGlobal[technoName].(map[string]interface{})[key].(map[string]interface{}) { - doc.Find("meta[name=\"" + metaKey + "\" i]").Each(func(i int, s *goquery.Selection) { - if fmt.Sprintf("%T", metaProperties) == "string" { - a.analyze_meta(s,metaProperties,technoName) - } else { - for _, metaPropertiess := range metaProperties.([]interface{}) { - a.analyze_meta(s,metaPropertiess,technoName) + for metaKey, metaProperties := range metas { + selector := `meta[name="` + metaKey + `" i], meta[property="` + metaKey + `" i]` + patterns := namesOfValue(metaProperties) + if len(patterns) == 0 { + continue + } + doc.Find(selector).EachWithBreak(func(i int, s *goquery.Selection) bool { + for _, pattern := range patterns { + if a.analyze_meta(s, pattern, technoName) { + return false // matched; stop scanning further meta tags } } + return true }) } } - -func (a *Analyze) analyze_meta(s *goquery.Selection,metaProperties interface{},technoName string){ +func (a *Analyze) analyze_meta(s *goquery.Selection, pattern string, technoName string) bool { metaValue, _ := s.Attr("content") - regex := strings.Split(fmt.Sprintf("%v", metaProperties), "\\;") + if metaValue == "" { + return false + } + regex := strings.Split(pattern, "\\;") re, ok := compileCI(regex[0]) - if ok && re.MatchString(metaValue) { - technoTemp := a.NewTechno(technoName) - regexGroup := re.FindAllStringSubmatch(metaValue, -1) - technoTemp.Version = versionFromMarker(regex, regexGroup) - a.Technos = append(a.Technos, technoTemp) - a.Technos = technologies.CheckRequired(technoTemp.Name, a.ResultGlobal, a.Technos) + if !ok { + return false } -} \ No newline at end of file + groups := re.FindStringSubmatch(metaValue) + if groups == nil { + return false + } + technoTemp := a.NewTechno(technoName) + technoTemp.Version = versionFromMarker(regex, [][]string{groups}) + a.Technos = append(a.Technos, technoTemp) + a.Technos = technologies.CheckRequired(technoTemp.Name, a.ResultGlobal, a.Technos) + return true +} diff --git a/analyze/analyze_script_src.go b/analyze/analyze_script_src.go index 0c2ede1..aa1015e 100644 --- a/analyze/analyze_script_src.go +++ b/analyze/analyze_script_src.go @@ -1,32 +1,45 @@ package analyze import ( - "fmt" "strings" + "github.com/EasyRecon/wappaGo/technologies" ) -func (a *Analyze)analyze_scriptSrc_main(technoName string,key string){ - for _, scriptCrc := range a.SrcList { - if fmt.Sprintf("%T", a.ResultGlobal[technoName].(map[string]interface{})[key]) == "string" { - a.analyze_scriptSrc(technoName,a.ResultGlobal[technoName].(map[string]interface{})[key].(string),scriptCrc) - } else { - for _, scriptSrcArray := range a.ResultGlobal[technoName].(map[string]interface{})[key].([]interface{}) { - finalRegex := strings.ReplaceAll(scriptSrcArray.(string), "/", "\\/") - a.analyze_scriptSrc(technoName,finalRegex,scriptCrc) - } - } +// analyze_scriptSrc_main matches the fingerprint's script-src patterns against +// every ", "https://example.com"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := resolveLocation(tc.probed, tc.location); got != tc.want { + t.Errorf("resolveLocation(%q, %q) = %q, want %q", tc.probed, tc.location, got, tc.want) + } + }) + } +} diff --git a/cmd/root.go b/cmd/root.go index c455e0b..77deea0 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -21,7 +21,6 @@ import ( "unicode/utf8" "github.com/EasyRecon/wappaGo/analyze" - "github.com/EasyRecon/wappaGo/lib" "github.com/EasyRecon/wappaGo/report" "github.com/EasyRecon/wappaGo/structure" "github.com/EasyRecon/wappaGo/technologies" @@ -33,7 +32,6 @@ import ( "github.com/goccy/go-json" "github.com/projectdiscovery/cdncheck" "github.com/projectdiscovery/fastdialer/fastdialer" - pdhttputil "github.com/projectdiscovery/httputil" "github.com/remeh/sizedwaitgroup" ) @@ -43,17 +41,46 @@ type Cmd struct { ResultGlobal map[string]interface{} Cdn *cdncheck.Client Options structure.Options - PortOpenByIP []structure.PortOpenByIp - portMu sync.Mutex // guards PortOpenByIP + // portOpenByIP caches the open ports found for an IP so hosts sharing an + // address are scanned once. It replaces a []PortOpenByIp that was linearly + // scanned while holding portMu — quadratic, and fully serialised across + // every worker (4 s of lock time at 50k hosts). + portOpenByIP map[string][]string + portMu sync.RWMutex // guards portOpenByIP HttpClient *http.Client ResultArray []structure.Data reportMu sync.Mutex // guards ResultArray (report mode) throttle *hostThrottle Input []string + // sigCtx is cancelled on SIGINT/SIGTERM. It gates the host loop and the + // port scanner so Ctrl+C actually stops the scan; previously it only + // parented the Chrome allocator, so the first Ctrl+C killed the browser and + // left the run grinding through the remaining input emitting empty results. + sigCtx context.Context +} + +// interrupted reports whether a shutdown signal has been received. +func (c *Cmd) interrupted() bool { + return c.sigCtx != nil && c.sigCtx.Err() != nil } func (c *Cmd) Start(results chan structure.Data) { - c.Dialer = c.InitDialer() + // Repair anything unset or nonsensical before a single pointer is + // dereferenced. Both entry points go through here, so neither the CLI nor + // the library can reach the scan with a nil field or an unbounded + // worker count. + c.Options.ApplyDefaults() + c.portOpenByIP = make(map[string][]string) + + dialer, err := c.InitDialer() + if err != nil { + // Without a dialer every probe would nil-deref inside an http.Transport + // goroutine, where no recover() can reach it. + fmt.Fprintf(os.Stderr, "could not create the resolver cache: %v\n", err) + close(results) + return + } + c.Dialer = dialer defer c.Dialer.Close() // Build the HTTP client once, up front: it is read-only for the rest of @@ -85,8 +112,31 @@ func (c *Cmd) Start(results chan structure.Data) { // Tie the browser lifetime to SIGINT/SIGTERM: the first Ctrl+C cancels this // context, which tears Chrome down and lets the deferred cleanup (browser // processes, dialer, temp fingerprint dir) run instead of leaving orphans. - sigCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() + // Watch for the signal on an explicit channel rather than via + // signal.NotifyContext: that helper's stop function cancels the context + // itself, so a normal end-of-scan teardown was indistinguishable from a real + // Ctrl+C and printed the interrupt notice on every single run. + sigCtx, cancelScan := context.WithCancel(context.Background()) + defer cancelScan() + c.sigCtx = sigCtx + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(sigCh) + scanDone := make(chan struct{}) + defer close(scanDone) + go func() { + select { + case <-sigCh: + fmt.Fprintln(os.Stderr, "\ninterrupted: finishing in-flight hosts, press Ctrl+C again to abort") + cancelScan() + // Restore the default handler so a second Ctrl+C kills the process. + // Previously the first signal disarmed it for the rest of the run, + // leaving no way to abort at all. + signal.Stop(sigCh) + case <-scanDone: + } + }() //ctxAlloc, cancel := chromedp.NewExecAllocator(context.Background(), append(chromedp.DefaultExecAllocatorOptions[:], chromedp.Flag("headless", false), chromedp.Flag("disable-gpu", true))...) ctxAlloc, cancel1 := chromedp.NewExecAllocator(sigCtx, optionsChromeCtx...) @@ -113,6 +163,10 @@ func (c *Cmd) Start(results chan structure.Data) { ip = "" prog := newProgress(len(c.Input), *c.Options.NoProgress) for _, line := range c.Input { + if c.interrupted() { + fmt.Fprintf(os.Stderr, "aborted: %d/%d hosts not scanned\n", len(c.Input)-int(prog.processed()), len(c.Input)) + break + } ip = "" if *c.Options.AmassInput { var result map[string]interface{} @@ -172,7 +226,14 @@ func (c *Cmd) startPortScan(url string, ip string, results chan structure.Data) if !*c.Options.AmassInput { c.throttle.wait(url) - c.HttpClient.Get("http://" + url) + // This request exists only to populate the dialer history so + // GetDialedIP works. Its body was never read nor closed, which parked a + // connection plus its reader/writer goroutines until the 10 s client + // timeout reaped them — one leaked socket per host. + if warm, err := c.HttpClient.Get("http://" + url); err == nil { + io.Copy(io.Discard, io.LimitReader(warm.Body, 4096)) + warm.Body.Close() + } ip = c.Dialer.GetDialedIP(url) } isCDN, cdnName, _, err := c.Cdn.Check(net.ParseIP(ip)) @@ -188,11 +249,16 @@ func (c *Cmd) startPortScan(url string, ip string, results chan structure.Data) CdnName = cdnName } var portOpen []string - c.portMu.Lock() - alreadyScanned := lib.CheckIpAlreadyScan(ip, c.PortOpenByIP) - c.portMu.Unlock() - if alreadyScanned.IP != "" { - portOpen = alreadyScanned.Open_port + // An empty IP means the address was never resolved; every such host would + // otherwise share one bogus cache entry and inherit another host's ports. + cached, hit := []string(nil), false + if ip != "" { + c.portMu.RLock() + cached, hit = c.portOpenByIP[ip] + c.portMu.RUnlock() + } + if hit { + portOpen = cached } else { // Each scanner goroutine reports its open port on a buffered channel // instead of appending to a shared slice (which raced). The buffer is @@ -212,9 +278,11 @@ func (c *Cmd) startPortScan(url string, ip string, results chan structure.Data) for p := range portChan { portOpen = append(portOpen, p) } - c.portMu.Lock() - c.PortOpenByIP = append(c.PortOpenByIP, structure.PortOpenByIp{IP: ip, Open_port: portOpen}) - c.portMu.Unlock() + if ip != "" { + c.portMu.Lock() + c.portOpenByIP[ip] = portOpen + c.portMu.Unlock() + } } url = strings.TrimSpace(url) for _, port := range portOpen { @@ -294,7 +362,7 @@ func (c *Cmd) getWrapper(urlData string, port string, data structure.Data, resul func (c *Cmd) launchChrome(TempResp structure.Response, data structure.Data, urlData string, port string, results chan structure.Data) { var err error if data.Infos.Location != "" { - urlData = data.Infos.Location + urlData = resolveLocation(urlData, data.Infos.Location) } dnsData, err := c.Dialer.GetDNSData(data.Infos.Data) if dnsData != nil && err == nil { @@ -309,28 +377,28 @@ func (c *Cmd) launchChrome(TempResp structure.Response, data structure.Data, url //data, _ := network.GetResponseBody(ev.(*network.EventResponseReceived).RequestID).Do(cloneCTX) //log.Println(string(data)) - switch typeDoc := ev.(*network.EventResponseReceived).Type; typeDoc { + ev := ev.(*network.EventResponseReceived) + switch ev.Type { case "XHR": - analyseStruct.AddXHRUrl(ev.(*network.EventResponseReceived).Response.URL) - case "Stylesheet": - //analyseStruct.CSSContent = append(analyseStruct.CSSContent,ev.(*network.EventResponseReceived).Response.URL) - - case "Script": - //analyseStruct.CSSContent = append(analyseStruct.CSSContent,ev.(*network.EventResponseReceived).Response.URL) + analyseStruct.AddXHRUrl(ev.Response.URL) + case "Document": + // Headers of the page Chrome actually landed on. The raw probe + // stops at the 30x by default, so its headers describe the + // redirect, not the page that gets analysed — which is why a + // host redirecting http->https reported none of its header-based + // technologies (no Server, no X-Powered-By) even though the DOM + // ones came through. + analyseStruct.SetFinalHeaders(ev.Response.URL, ev.Response.Headers) } } if _, ok := ev.(*page.EventJavascriptDialogOpening); ok { - //fmt.Println("closing alert:", ev.Message) + // Dismiss the dialog and say nothing else. This handler used to + // json.Marshal the shared result struct — racing the goroutine + // writing it — and print it to stdout, injecting a duplicate, + // half-filled record into the JSON stream. go func() { - if err := chromedp.Run(cloneCTX, - page.HandleJavaScriptDialog(true), - ); err != nil { - b, err := json.Marshal(data) - if err != nil { - fmt.Println("Error:", err) - } - fmt.Println(string(b)) - return + if err := chromedp.Run(cloneCTX, page.HandleJavaScriptDialog(true)); err != nil { + fmt.Fprintf(os.Stderr, "dismiss dialog on %s: %v\n", urlData, err) } }() } @@ -376,9 +444,9 @@ func (c *Cmd) launchChrome(TempResp structure.Response, data structure.Data, url var srcList []string doc.Find("script").Each(func(i int, s *goquery.Selection) { srcLink, exist := s.Attr("src") - + if exist { - + //fmt.Println(srcList, srcLink) srcList = append(srcList, srcLink) } @@ -389,7 +457,10 @@ func (c *Cmd) launchChrome(TempResp structure.Response, data structure.Data, url analyseStruct.ResultGlobal = c.ResultGlobal analyseStruct.Resp = TempResp - + // The URL family matches against the page that was scanned, not + // against the Location header. + analyseStruct.Url = urlData + analyseStruct.Ctx = ctx analyseStruct.Hote = data.Infos analyseStruct.CookiesList = cookiesList @@ -403,10 +474,20 @@ func (c *Cmd) launchChrome(TempResp structure.Response, data structure.Data, url }), ) + // chromedp.Run's error used to be assigned and never read, so a failed + // navigation looked exactly like a page that rendered cleanly and happened + // to run no technologies. Record it and say so on stderr. + if err != nil { + data.Infos.Error = err.Error() + fmt.Fprintf(os.Stderr, "render %s: %v\n", urlData, err) + } + data.Infos.Technologies = technologies.DedupTechno(data.Infos.Technologies) // Drop technologies whose "requires" precondition isn't met by the rest of - // the detected set (e.g. a plugin without the platform it needs). + // the detected set (e.g. a plugin without the platform it needs), then drop + // any that a detected technology declares it cannot coexist with. data.Infos.Technologies = technologies.FilterRequired(data.Infos.Technologies, c.ResultGlobal) + data.Infos.Technologies = technologies.FilterExcluded(data.Infos.Technologies, c.ResultGlobal) if *c.Options.Screenshot != "" && len(buf) > 0 { // Name the screenshot by the SHA-1 of its URL. The previous scheme // stripped ':' '/' '.' from the URL, which collapsed distinct URLs to @@ -423,13 +504,44 @@ func (c *Cmd) launchChrome(TempResp structure.Response, data structure.Data, url data.Infos.Screenshot = imgTitle + ".png" } } + // The HTML report is an additional sink, not a replacement for the JSON + // stream. -report used to take the else branch away entirely, so + // `wappaGo -report > out.json` wrote an empty file and the run looked like + // it had detected nothing. if *c.Options.Report { c.reportMu.Lock() c.ResultArray = append(c.ResultArray, data) c.reportMu.Unlock() - } else { - results <- data } + results <- data +} + +// resolveLocation resolves a redirect target against the URL that was actually +// probed. +// +// A Location header is allowed to be relative (RFC 7231 §7.1.2) and very often +// is — "https://stackoverflow.com" answers "302 Location: /questions". Feeding +// that raw value to chromedp.Navigate fails, which aborted the whole action +// chain, so Title, the screenshot and every technology matcher were skipped and +// the host was emitted with an empty title and zero technologies. +// +// Anything that does not resolve to http(s) is refused and the probed URL is +// kept: a scanned host must not be able to steer the operator's browser to +// file:// or javascript:. +func resolveLocation(probed string, location string) string { + base, err := URL.Parse(probed) + if err != nil { + return probed + } + ref, err := URL.Parse(strings.TrimSpace(location)) + if err != nil { + return probed + } + target := base.ResolveReference(ref) + if target.Scheme != "http" && target.Scheme != "https" { + return probed + } + return target.String() } func (c *Cmd) scanPort(protocol, hostname string, port string, portTimeout int) bool { @@ -486,95 +598,90 @@ func chromeMajor(ua string) string { return rest[:j] } +// maxProbeBody caps how much of a probed body is read. Only the metrics below +// use it — technology detection runs on the DOM Chrome renders, not on this. +const maxProbeBody = 4096 + // Do http request func Do(req *http.Request, client *http.Client) (*structure.Response, error) { var gzipRetry bool + started := time.Now() get_response: httpresp, err := client.Do(req) if err != nil { return nil, err } + defer httpresp.Body.Close() var resp structure.Response + // Response.Duration was read into Host.Response_time but never assigned, so + // every result reported response_time: 0 and the report's "time" row was + // always blank. + resp.Duration = time.Since(started) resp.Headers = httpresp.Header.Clone() - // httputil.DumpResponse does not handle websockets - headers, rawResp, err := pdhttputil.DumpResponseHeadersAndRaw(httpresp) - if err != nil { - // Edge case - some servers respond with gzip encoding header but uncompressed body, in this case the standard library configures the reader as gzip, triggering an error when read. - // The bytes slice is not accessible because of abstraction, therefore we need to perform the request again tampering the Accept-Encoding header - if !gzipRetry && strings.Contains(err.Error(), "gzip: invalid header") { - gzipRetry = true - req.Header.Set("Accept-Encoding", "identity") - goto get_response - } - - return nil, err - - } - resp.Raw = string(rawResp) - resp.RawHeaders = string(headers) + // The previous implementation ran pdhttputil.DumpResponseHeadersAndRaw here, + // whose DumpResponse(resp, true) reads the ENTIRE body into memory and then + // copies it again — before the deliberate 4096-byte cap below ever applied. + // The size was dictated purely by the scanned host, so a large or + // heavily-compressed response could exhaust the scanner's memory. Its only + // outputs were Response.Raw and Response.RawHeaders, which nothing read. var respbody []byte // websockets don't have a readable body if httpresp.StatusCode != http.StatusSwitchingProtocols { - var err error - respbody, err = io.ReadAll(io.LimitReader(httpresp.Body, 4096)) + respbody, err = io.ReadAll(io.LimitReader(httpresp.Body, maxProbeBody)) if err != nil { - + // Some servers advertise gzip but send an uncompressed body; the + // stdlib then wires up a gzip reader that fails on the first read. + // Retry once asking for identity. + if !gzipRetry && strings.Contains(err.Error(), "gzip: invalid header") { + gzipRetry = true + req.Header.Set("Accept-Encoding", "identity") + httpresp.Body.Close() + goto get_response + } return nil, err } } - closeErr := httpresp.Body.Close() - if closeErr != nil { - return nil, closeErr - } - - respbodystr := string(respbody) - - // if content length is not defined - if resp.ContentLength <= 0 { - // check if it's in the header and convert to int - if contentLength, ok := resp.Headers["Content-Length"]; ok { - contentLengthInt, _ := strconv.Atoi(strings.Join(contentLength, "")) - resp.ContentLength = contentLengthInt - } + resp.StatusCode = httpresp.StatusCode - // if we have a body, then use the number of bytes in the body if the length is still zero - if resp.ContentLength <= 0 && len(respbodystr) > 0 { - resp.ContentLength = utf8.RuneCountInString(respbodystr) + // Prefer the advertised length; fall back to what was actually read. The + // fallback is capped by maxProbeBody, so it under-reports on a large body + // that omits Content-Length. + if contentLength, ok := resp.Headers["Content-Length"]; ok { + if n, convErr := strconv.Atoi(strings.Join(contentLength, "")); convErr == nil && n >= 0 { + resp.ContentLength = n } } - - resp.Data = respbody - - // fill metrics - resp.StatusCode = httpresp.StatusCode - // number of words - resp.Words = len(strings.Split(respbodystr, " ")) - // number of lines - resp.Lines = len(strings.Split(respbodystr, "\n")) + if resp.ContentLength <= 0 && len(respbody) > 0 { + resp.ContentLength = utf8.RuneCountInString(string(respbody)) + } return &resp, nil } -func (c *Cmd) InitDialer() *fastdialer.Dialer { +// defaultResolvers are used when -resolvers is not set. +const defaultResolvers = "8.8.8.8,1.1.1.1,64.6.64.6,74.82.42.42,1.0.0.1,8.8.4.4,64.6.65.6,77.88.8.8" + +// InitDialer builds the resolving dialer. It used to swallow the error and hand +// back a nil *Dialer, which the caller then used as if it were valid. +func (c *Cmd) InitDialer() (*fastdialer.Dialer, error) { fastdialerOpts := fastdialer.DefaultOptions fastdialerOpts.EnableFallback = true fastdialerOpts.WithDialerHistory = true - if len(*c.Options.Resolvers) == 0 { - *c.Options.Resolvers = "8.8.8.8,1.1.1.1,64.6.64.6,74.82.42.42,1.0.0.1,8.8.4.4,64.6.65.6,77.88.8.8" + resolvers := *c.Options.Resolvers + if len(resolvers) == 0 { + // Do not write through the option pointer: it may alias a caller's + // struct field in library mode. + resolvers = defaultResolvers } - fastdialerOpts.BaseResolvers = strings.Split(*c.Options.Resolvers, ",") + fastdialerOpts.BaseResolvers = strings.Split(resolvers, ",") - dialer, err := fastdialer.NewDialer(fastdialerOpts) - if err != nil { - fmt.Fprintf(os.Stderr, "could not create resolver cache: %s\n", err) - } - return dialer + return fastdialer.NewDialer(fastdialerOpts) } // buildHTTPClient constructs the single, shared HTTP client used for every diff --git a/go.mod b/go.mod index b33db1b..b131678 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/EasyRecon/wappaGo -go 1.19 +go 1.23 require ( github.com/PuerkitoBio/goquery v1.8.1 @@ -9,9 +9,7 @@ require ( github.com/goccy/go-json v0.9.11 github.com/imdario/mergo v0.3.13 github.com/projectdiscovery/cdncheck v1.0.2 - github.com/projectdiscovery/cryptoutil v1.0.0 github.com/projectdiscovery/fastdialer v0.0.16 - github.com/projectdiscovery/httputil v0.0.0-20210913064028-815c8db933ff github.com/projectdiscovery/retryabledns v1.0.24 github.com/remeh/sizedwaitgroup v1.0.0 ) @@ -23,7 +21,6 @@ require ( github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/chromedp/sysutil v1.0.0 // indirect - github.com/corona10/goimagehash v1.1.0 // indirect github.com/dimchansky/utfbom v1.1.1 // indirect github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect @@ -31,22 +28,15 @@ require ( github.com/golang/snappy v0.0.4 // indirect github.com/gorilla/css v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect - github.com/json-iterator/go v1.1.12 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/microcosm-cc/bluemonday v1.0.23 // indirect github.com/miekg/dns v1.1.54 // indirect - github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/projectdiscovery/blackrock v0.0.1 // indirect - github.com/projectdiscovery/fileutil v0.0.0-20220705195237-01becc2a8963 // indirect + github.com/projectdiscovery/cryptoutil v1.0.0 // indirect github.com/projectdiscovery/hmap v0.0.10 // indirect github.com/projectdiscovery/iputil v0.0.2 // indirect - github.com/projectdiscovery/mapcidr v1.1.1 // indirect github.com/projectdiscovery/networkpolicy v0.0.2-0.20221025011504-22b13ce54fff // indirect github.com/projectdiscovery/retryablehttp-go v1.0.16 // indirect - github.com/projectdiscovery/sliceutil v0.0.0-20220625085859-c3a4ecb669f4 // indirect github.com/projectdiscovery/stringsutil v0.0.2 // indirect github.com/projectdiscovery/utils v0.0.28 // indirect github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect @@ -65,7 +55,6 @@ require ( github.com/zmap/rc2 v0.0.0-20131011165748-24b9757f5521 // indirect github.com/zmap/zcrypto v0.0.0-20220803033029-557f3e4940be // indirect go.etcd.io/bbolt v1.3.7 // indirect - go.uber.org/atomic v1.10.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/crypto v0.7.0 // indirect golang.org/x/mod v0.10.0 // indirect diff --git a/go.sum b/go.sum index 9082b84..568ae54 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,5 @@ -github.com/EasyRecon/wappaGo/structure v0.0.0-20220826143555-ecafe47f4d42 h1:qdNuBggGR/nAE1AgMLbGZ21/am9Eyy999I1QCxiatUg= -github.com/EasyRecon/wappaGo/structure v0.0.0-20220826143555-ecafe47f4d42/go.mod h1:0jmjUiREc3j1IuSTG/IwDK8l4ybPsmvDWZx1aOPn2h0= github.com/Mzack9999/go-http-digest-auth-client v0.6.1-0.20220414142836-eb8883508809 h1:ZbFL+BDfBqegi+/Ssh7im5+aQfBRx6it+kHnC7jaDU8= github.com/Mzack9999/go-http-digest-auth-client v0.6.1-0.20220414142836-eb8883508809/go.mod h1:upgc3Zs45jBDnBT4tVRgRcgm26ABpaP7MoTSdgysca4= -github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U= -github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI= github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM= github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ= github.com/akrylysov/pogreb v0.10.0/go.mod h1:pNs6QmpQ1UlTJKDezuRWmaqkgUE2TuU0YTWyqJZ7+lI= @@ -11,12 +7,15 @@ github.com/akrylysov/pogreb v0.10.1 h1:FqlR8VR7uCbJdfUob916tPM+idpKgeESDXOA1K0DK github.com/akrylysov/pogreb v0.10.1/go.mod h1:pNs6QmpQ1UlTJKDezuRWmaqkgUE2TuU0YTWyqJZ7+lI= github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c= github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= -github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d h1:Byv0BzEl3/e6D5CLfI0j/7hiIEtvGVFPCZ7Ei2oq8iQ= github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/bits-and-blooms/bitset v1.3.1 h1:y+qrlmq3XsWi+xZqSaueaE8ry8Y127iMxlMfqcK8p0g= +github.com/bits-and-blooms/bitset v1.3.1/go.mod h1:gIdJ4wp64HaoK2YrL1Q5/N7Y16edYb8uY+O0FJTyyDA= +github.com/bits-and-blooms/bloom/v3 v3.3.1 h1:K2+A19bXT8gJR5mU7y+1yW6hsKfNCjcP2uNfLFKncjQ= +github.com/bits-and-blooms/bloom/v3 v3.3.1/go.mod h1:bhUUknWd5khVbTe4UgMCSiOOVJzr3tMoijSK3WwvW90= github.com/chromedp/cdproto v0.0.0-20220827030233-358ed4af73cf h1:e0oJZmGJidTRZ0FvWXNhdj9OaOMN30Yf+T3K2Tf3L+s= github.com/chromedp/cdproto v0.0.0-20220827030233-358ed4af73cf/go.mod h1:5Y4sD/eXpwrChIuxhSr/G20n9CdbCmoerOHnuAf0Zr0= github.com/chromedp/chromedp v0.8.5 h1:HAVg54yQFcn7sg5reVjXtoI1eQaFxhjAjflHACicUFw= @@ -24,8 +23,6 @@ github.com/chromedp/chromedp v0.8.5/go.mod h1:xal2XY5Di7m/bzlGwtoYpmgIOfDqCakOIV github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic= github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww= github.com/cnf/structhash v0.0.0-20201127153200-e1b16c1ebc08/go.mod h1:pCxVEbcm3AMg7ejXyorUXi6HQCzOIBf7zEDVPtw0/U4= -github.com/corona10/goimagehash v1.1.0 h1:teNMX/1e+Wn/AYSbLHX8mj+mF9r60R1kBeqE9MkoYwI= -github.com/corona10/goimagehash v1.1.0/go.mod h1:VkvE0mLn84L4aF8vCb6mafVajEb6QYMHl2ZJLn0mOGI= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -66,6 +63,8 @@ github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= +github.com/hashicorp/golang-lru/v2 v2.0.1 h1:5pv5N1lT1fjLg2VQ5KWc7kmucp2x/kvFOnxuVTqZ6x4= +github.com/hashicorp/golang-lru/v2 v2.0.1/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk= github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg= @@ -73,69 +72,61 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= -github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/karrick/godirwalk v1.16.1/go.mod h1:j4mkqPuvaLI8mp1DroR3P6ad7cyYd4c1qeJ3RV7ULlk= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= github.com/logrusorgru/aurora v0.0.0-20200102142835-e9ef32dff381/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/microcosm-cc/bluemonday v1.0.18/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM= -github.com/microcosm-cc/bluemonday v1.0.19 h1:OI7hoF5FY4pFz2VA//RN8TfM0YJ2dJcl4P4APrCWy6c= github.com/microcosm-cc/bluemonday v1.0.19/go.mod h1:QNzV2UbLK2/53oIIwTOyLUSABMkjZ4tqiyC1g/DyqxE= github.com/microcosm-cc/bluemonday v1.0.21/go.mod h1:ytNkv4RrDrLJ2pqlsSI46O6IVXmZOBBD4SaJyDwwTkM= github.com/microcosm-cc/bluemonday v1.0.23 h1:SMZe2IGa0NuHvnVNAZ+6B38gsTbi5e4sViiWJyDDqFY= github.com/microcosm-cc/bluemonday v1.0.23/go.mod h1:mN70sk7UkkF8TUr2IGBpNN0jAgStuPzlK76QuruE/z4= -github.com/miekg/dns v1.1.50 h1:DQUfb9uc6smULcREF09Uc+/Gd46YWqJd5DbpPE9xkcA= github.com/miekg/dns v1.1.50/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME= github.com/miekg/dns v1.1.54 h1:5jon9mWcb0sFJGpnI99tOMhCPyJ+RPVz5b63MQG0VWI= github.com/miekg/dns v1.1.54/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= -github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/mreiferson/go-httpclient v0.0.0-20160630210159-31f0106b4474/go.mod h1:OQA4XLvDbMgS8P0CevmM4m9Q3Jq4phKUzcocxuGJ5m8= -github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= -github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= -github.com/onsi/ginkgo v1.15.0 h1:1V1NfVQR87RtWAgp1lv9JZJ5Jap+XFGKPi00andXGi4= github.com/onsi/ginkgo v1.15.0/go.mod h1:hF8qUzuuC8DJGygJH3726JnCZX4MYbRB8yFfISqnKUg= +github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= -github.com/onsi/gomega v1.10.5 h1:7n6FEkpFmfCoo2t+YYqXH0evK+a9ICQz0xcAy9dYcaQ= github.com/onsi/gomega v1.10.5/go.mod h1:gza4q3jKQJijlu05nKWRCW/GavJumGt8aNRxWg7mt48= +github.com/onsi/gomega v1.16.0 h1:6gjqkI8iiRHMvdccRJM8rVKjCWk6ZIm6FTm3ddIe4/c= +github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/projectdiscovery/blackrock v0.0.0-20210415162320-b38689ae3a2e/go.mod h1:/IsapnEYiWG+yEDPXp0e8NWj3npzB9Ccy9lXEUJwMZs= -github.com/projectdiscovery/blackrock v0.0.0-20220628111055-35616c71b2dc h1:jqZK68yPOnNNRmwuXqytl+T9EbwneEUCvMDRjLe0J04= github.com/projectdiscovery/blackrock v0.0.0-20220628111055-35616c71b2dc/go.mod h1:5tNGQP9kOfW+X5+40pZP8aqPYLHs45nJkFaSHLxdeH8= -github.com/projectdiscovery/blackrock v0.0.1/go.mod h1:ANUtjDfaVrqB453bzToU+YB4cUbvBRpLvEwoWIwlTss= -github.com/projectdiscovery/cdncheck v0.0.3 h1:li2/rUJmhVXSqRFyhJMqi6pdBX6ZxMnwzBfE0Kifj/g= -github.com/projectdiscovery/cdncheck v0.0.3/go.mod h1:EevMeCG1ogBoUJYaa0Mv9R1VUboDm/DiynId7DboKy0= github.com/projectdiscovery/cdncheck v1.0.2 h1:8bd7Ed8NgTyH4nyhf0jwRQdGIrTFsnQoPGr2shxR/xM= github.com/projectdiscovery/cdncheck v1.0.2/go.mod h1:6otjXPoRQ42LRbEUqOeTDvFwToOVJptdvlUkiXe/rEE= github.com/projectdiscovery/cryptoutil v0.0.0-20210805184155-b5d2512f9345/go.mod h1:clhQmPnt35ziJW1AhJRKyu8aygXCSoyWj6dtmZBRjjc= @@ -145,48 +136,36 @@ github.com/projectdiscovery/fastdialer v0.0.16 h1:0FcWVAtm6ftVALQpckdlKTeZcJi277 github.com/projectdiscovery/fastdialer v0.0.16/go.mod h1:poZbCGYGRfRcKxU8/1rWH8EVOJ4AZn1a8+CyINHzffQ= github.com/projectdiscovery/fileutil v0.0.0-20210928100737-cab279c5d4b5/go.mod h1:U+QCpQnX8o2N2w0VUGyAzjM3yBAe4BKedVElxiImsx0= github.com/projectdiscovery/fileutil v0.0.0-20220609150212-453ac591c36c/go.mod h1:g8wsrb0S5NtEN0JgVyyPeb3FQdArx+UMESmFX94bcGY= -github.com/projectdiscovery/fileutil v0.0.0-20220705195237-01becc2a8963 h1:4o97N9ftX1J3iKlIRVMPVOVZs4qbCczJvoFF2WA40t4= github.com/projectdiscovery/fileutil v0.0.0-20220705195237-01becc2a8963/go.mod h1:DaY7wmLPMleyHDCD/14YApPCDtrARY4J8Eny2ZGsG/g= github.com/projectdiscovery/goflags v0.0.8/go.mod h1:GDSkWyXa6kfQjpJu10SO64DN8lXuKXVENlBMk8N7H80= github.com/projectdiscovery/gologger v1.0.1/go.mod h1:Ok+axMqK53bWNwDSU1nTNwITLYMXMdZtRc8/y1c7sWE= github.com/projectdiscovery/gologger v1.1.4/go.mod h1:Bhb6Bdx2PV1nMaFLoXNBmHIU85iROS9y1tBuv7T5pMY= github.com/projectdiscovery/hmap v0.0.1/go.mod h1:VDEfgzkKQdq7iGTKz8Ooul0NuYHQ8qiDs6r8bPD1Sb0= -github.com/projectdiscovery/hmap v0.0.2-0.20210917080408-0fd7bd286bfa h1:9sZWFUAshIa/ea0RKjGRuuZiS5PzYXAFjTRUnSbezr0= github.com/projectdiscovery/hmap v0.0.2-0.20210917080408-0fd7bd286bfa/go.mod h1:lV5f/PNPmCCjCN/dR317/chN9s7VG5h/xcbFfXOz8Fo= github.com/projectdiscovery/hmap v0.0.10 h1:O6ALGW3BK+FmknLXW7ENwQevLs+faRJuoRbDtakZZus= github.com/projectdiscovery/hmap v0.0.10/go.mod h1:xdtyejCgl5LJW7yz7nf/ut32tWuV/l7FjUzItiCtJIg= -github.com/projectdiscovery/httputil v0.0.0-20210913064028-815c8db933ff h1:7PH2QB1tyQHyg+Nw8WpRz/POfDl4yvB/MVD4KBiDr/E= -github.com/projectdiscovery/httputil v0.0.0-20210913064028-815c8db933ff/go.mod h1:BueJPSPWAX11IFS6bdAqTkekiIz5Fgco5LVc1kqO9L4= github.com/projectdiscovery/ipranger v0.0.2/go.mod h1:kcAIk/lo5rW+IzUrFkeYyXnFJ+dKwYooEOHGVPP/RWE= github.com/projectdiscovery/iputil v0.0.0-20210414194613-4b4d2517acf0/go.mod h1:PQAqn5h5NXsQTF4ZA00ZTYLRzGCjOtcCq8llAqrsd1A= -github.com/projectdiscovery/iputil v0.0.0-20220712175312-b9406f31cdd8 h1:HRqev12wKvcwK1fe4pSlMfQdPHo9LfTxuFeRN4f3tS4= github.com/projectdiscovery/iputil v0.0.0-20220712175312-b9406f31cdd8/go.mod h1:vHRC+9exsfSbEngMKDl0xiWqkxlLk3lHQZpbS2yFT8U= github.com/projectdiscovery/iputil v0.0.2 h1:f6IGnZF4RImJLysPSPG3D84jyTH34q3lihCFeP+eZzI= github.com/projectdiscovery/iputil v0.0.2/go.mod h1:J3Pcz1q51pi4/JL871mQztg0KOzyWDPxnPLOYJm2pVQ= github.com/projectdiscovery/mapcidr v0.0.4/go.mod h1:ALOIj6ptkWujNoX8RdQwB2mZ+kAmKuLJBq9T5gR5wG0= github.com/projectdiscovery/mapcidr v0.0.6/go.mod h1:ZEBhMmBU3laUl3g9QGTrzJku1VJOzjdFwW01f/zVVzM= -github.com/projectdiscovery/mapcidr v1.0.1 h1:eaLBRrImwlYXv8vbXTwR4sxoQqIxR3Y5k/Sd7HhTIII= github.com/projectdiscovery/mapcidr v1.0.1/go.mod h1:/qxlpxXZQFFjHynSc9u5O0kUPzH46VskECiwLiz7/vw= -github.com/projectdiscovery/mapcidr v1.1.1/go.mod h1:yyp9ghqmmC0+r5DySgDBXE4cf2QW8SBloVESCteWiAg= -github.com/projectdiscovery/networkpolicy v0.0.1 h1:RGRuPlxE8WLFF9tdKSjTsYiTIKHNHW20Kl0nGGiRb1I= github.com/projectdiscovery/networkpolicy v0.0.1/go.mod h1:asvdg5wMy3LPVMGALatebKeOYH5n5fV5RCTv6DbxpIs= github.com/projectdiscovery/networkpolicy v0.0.2-0.20221025011504-22b13ce54fff h1:xq+OYVy41kNjcunbycFxHoKGFXkeDq1v3IRIWh381i8= github.com/projectdiscovery/networkpolicy v0.0.2-0.20221025011504-22b13ce54fff/go.mod h1:NIVv5IoaAs272OVXrjafRAt8/gehnia71EVdeRWl/cE= -github.com/projectdiscovery/retryabledns v1.0.15 h1:3Nn119UwYsfUPC3g0q57ftz0Wb5Zl5ppvw8R0Xu0DEI= github.com/projectdiscovery/retryabledns v1.0.15/go.mod h1:3YbsQVqP7jbQ3CDmarhyVtkJaJ8XcB7S19vMeyMxZxk= github.com/projectdiscovery/retryabledns v1.0.24 h1:CbC0a1EcyRDBcGFHZDGfW5orkWkOCfa0mAMF060XJpI= github.com/projectdiscovery/retryabledns v1.0.24/go.mod h1:bCmv0neiqgemgmFChevfX2BgCxIp8sn5OnbwL1Gov9M= -github.com/projectdiscovery/retryablehttp-go v1.0.2 h1:LV1/KAQU+yeWhNVlvveaYFsjBYRwXlNEq0PvrezMV0U= github.com/projectdiscovery/retryablehttp-go v1.0.2/go.mod h1:dx//aY9V247qHdsRf0vdWHTBZuBQ2vm6Dq5dagxrDYI= github.com/projectdiscovery/retryablehttp-go v1.0.16 h1:xrEK9VVkLf0eN4bYOTc2Pg0qRqz47g8T823vqpCIsl8= github.com/projectdiscovery/retryablehttp-go v1.0.16/go.mod h1:9m76To4lNgBtVfqADzLxZg1wWajv6y/uYMWCOs1Olo8= github.com/projectdiscovery/sliceutil v0.0.0-20220617151003-15892688e1d6/go.mod h1:9YZb6LRjLYAvSOm65v787dwauurixSyjlqXyYa4rTTA= -github.com/projectdiscovery/sliceutil v0.0.0-20220625085859-c3a4ecb669f4 h1:C04j5gVVMXqFyBIetAz92SyPRYCpkFgIwZw0L/pps9Q= github.com/projectdiscovery/sliceutil v0.0.0-20220625085859-c3a4ecb669f4/go.mod h1:RxDaccMjPzIuF7F8XbdGl1yOcqxN4YPiHr9xHpfCkGI= github.com/projectdiscovery/stringsutil v0.0.0-20210804142656-fd3c28dbaafe/go.mod h1:oTRc18WBv9t6BpaN9XBY+QmG28PUpsyDzRht56Qf49I= github.com/projectdiscovery/stringsutil v0.0.0-20220422150559-b54fb5dc6833/go.mod h1:oTRc18WBv9t6BpaN9XBY+QmG28PUpsyDzRht56Qf49I= github.com/projectdiscovery/stringsutil v0.0.0-20220612082425-0037ce9f89f3/go.mod h1:mF5sh4jTghoGWwgUb9qWi5waTFklClDbtrqtJU93awc= -github.com/projectdiscovery/stringsutil v0.0.0-20220731064040-4b67f194751e h1:JBdwX+DJNq5FIJGsZCSKLGV4EhSRiPtNk22Vi+3cTDg= github.com/projectdiscovery/stringsutil v0.0.0-20220731064040-4b67f194751e/go.mod h1:32NYmKyHkKsmisAOAaWrR15lz2ysz2M8x3KMeeoRHoU= github.com/projectdiscovery/stringsutil v0.0.2 h1:uzmw3IVLJSMW1kEg8eCStG/cGbYYZAja8BH3LqqJXMA= github.com/projectdiscovery/stringsutil v0.0.2/go.mod h1:EJ3w6bC5fBYjVou6ryzodQq37D5c6qbAYQpGmAy+DC0= @@ -195,9 +174,9 @@ github.com/projectdiscovery/utils v0.0.28/go.mod h1:RpMyTA/KACEBK+Bo9mk9MgLH0Hfc github.com/remeh/sizedwaitgroup v1.0.0 h1:VNGGFwNo/R5+MJBf6yrsr110p0m4/OX4S3DCy7Kyl5E= github.com/remeh/sizedwaitgroup v1.0.0/go.mod h1:3j2R4OIe/SeS6YDhICBy22RWjJC5eNCJ1V+9+NVNYlo= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= -github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= -github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca h1:NugYot0LIVPxTvN8n+Kvkn6TrbMyxQiuvKdEwFdR9vI= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU= github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7AwssoOcM/tq5JjjG2yYOc8odClEiXA= github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU= @@ -214,11 +193,14 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.7.3/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE= github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ= +github.com/tidwall/assert v0.1.0 h1:aWcKyRBUAdLoVebxo95N7+YZVTFF/ASTr7BN4sLP6XI= +github.com/tidwall/assert v0.1.0/go.mod h1:QLYtGyeqse53vuELQheYl9dngGCJQ+mTtlxcktb+Kj8= github.com/tidwall/btree v1.4.3 h1:Lf5U/66bk0ftNppOBjVoy/AIPBrLMkheBp4NnSNiYOo= github.com/tidwall/btree v1.4.3/go.mod h1:LGm8L/DZjPLmeWGjv5kFrY8dL4uVhMmzmmLYmsObdKE= github.com/tidwall/buntdb v1.2.10 h1:U/ebfkmYPBnyiNZIirUiWFcxA/mgzjbKlyPynFsPtyM= @@ -228,6 +210,8 @@ github.com/tidwall/gjson v1.14.3 h1:9jvXn7olKEHU1S9vwoMGliaT8jq1vJ7IH/n9zD9Dnlw= github.com/tidwall/gjson v1.14.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/grect v0.1.4 h1:dA3oIgNgWdSspFzn1kS4S/RDpZFLrIxAZOdJKjYapOg= github.com/tidwall/grect v0.1.4/go.mod h1:9FBsaYRaR0Tcy4UwefBX/UDcDcDy9V5jUcxHzv2jd5Q= +github.com/tidwall/lotsa v1.0.2 h1:dNVBH5MErdaQ/xd9s769R31/n2dXavsQ0Yf4TMEHHw8= +github.com/tidwall/lotsa v1.0.2/go.mod h1:X6NiU+4yHA3fE3Puvpnn1XMDrFZrE9JO2/w+UMuqgR8= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= @@ -239,7 +223,6 @@ github.com/tidwall/tinyqueue v0.1.1/go.mod h1:O/QNHwrnjqr6IHItYrzoHAKYhBkLI67Q09 github.com/ulule/deepcopier v0.0.0-20200430083143-45decc6639b6 h1:TtyC78WMafNW8QFfv3TeP3yWNDG+uxNkk9vOrnDu6JA= github.com/ulule/deepcopier v0.0.0-20200430083143-45decc6639b6/go.mod h1:h8272+G2omSmi30fBXiZDMkmHuOgonplfKIKjQWzlfs= github.com/weppos/publicsuffix-go v0.12.0/go.mod h1:z3LCPQ38eedDQSwmsSRW4Y7t2L8Ln16JPQ02lHAdn5k= -github.com/weppos/publicsuffix-go v0.15.1-0.20210928183822-5ee35905bd95 h1:DyAZOw3JsVd6LJHqhl4MpKQdYQEmat0C6pPPwom39Ow= github.com/weppos/publicsuffix-go v0.15.1-0.20210928183822-5ee35905bd95/go.mod h1:HYux0V0Zi04bHNwOHy4cXJVz/TQjYonnF6aoYhj+3QE= github.com/weppos/publicsuffix-go v0.15.1-0.20220724114530-e087fba66a37/go.mod h1:5ZC/Uv3fIEUE0eP6o9+Yg4+5+W8V0/BieMi05feGXVA= github.com/weppos/publicsuffix-go v0.30.0 h1:QHPZ2GRu/YE7cvejH9iyavPOkVCB4dNxp2ZvtT+vQLY= @@ -254,20 +237,15 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t github.com/zmap/rc2 v0.0.0-20131011165748-24b9757f5521 h1:kKCF7VX/wTmdg2ZjEaqlq99Bjsoiz7vH6sFniF/vI4M= github.com/zmap/rc2 v0.0.0-20131011165748-24b9757f5521/go.mod h1:3YZ9o3WnatTIZhuOtot4IcUfzoKVjUHqu6WALIyI0nE= github.com/zmap/zcertificate v0.0.0-20180516150559-0e3d58b1bac4/go.mod h1:5iU54tB79AMBcySS0R2XIyZBAVmeHranShAFELYx7is= -github.com/zmap/zcrypto v0.0.0-20211005224000-2d0ffdec8a9b h1:iYQzlljG1dOXBtsJGyzFC/wBK5qUCWs1eLCr/UcJYPA= github.com/zmap/zcrypto v0.0.0-20211005224000-2d0ffdec8a9b/go.mod h1:5nID//bFGkx3/+iHcFIFRHQ54EOPJ0iSj0IGKpMElvw= github.com/zmap/zcrypto v0.0.0-20220803033029-557f3e4940be h1:M5QjuCbUeNZsup53dlJkI/cx6pVdnDOPzyy+XppoowY= github.com/zmap/zcrypto v0.0.0-20220803033029-557f3e4940be/go.mod h1:bRZdjnJaHWVXKEwrfAZMd0gfRjZGNhTbZwzp07s0Abw= go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= -go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU= go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4= go.etcd.io/bbolt v1.3.7 h1:j+zJOnnEjF/kyHlDDgGnVL/AIqIJPq8UoB2GSNfkUfQ= go.etcd.io/bbolt v1.3.7/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= -go.uber.org/multierr v1.8.0 h1:dg6GjLku4EH+249NNmoIciG9N/jURbDG+pFlTkhzIC8= go.uber.org/multierr v1.8.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= @@ -276,13 +254,11 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201124201722-c8d3bf9c5392/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 h1:7I4JAnoQBe7ZtJcBaYHi5UtiO8tQHbUSXxL+pnGRANg= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk= golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -303,7 +279,6 @@ golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220617184016-355a448f1bc9/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.0.0-20220728211354-c7608f3a8462 h1:UreQrH7DbFXSi9ZFox6FNT3WBooWmdANpU+IfkT1T4I= golang.org/x/net v0.0.0-20220728211354-c7608f3a8462/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= @@ -313,8 +288,9 @@ golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 h1:uVc8UZUe6tr40fFVnUP5Oj+veunVezqYl9z7DYw9xzw= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -340,8 +316,6 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220731174439-a90be440212d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64 h1:UiNENfZ8gDvpiWw7IpOMQ27spWmThO1RwwdQVbJahJM= -golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -352,7 +326,6 @@ golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= @@ -361,7 +334,6 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.8.0 h1:vSDcovVPld282ceKgDimkRSC8kpaH1dgyc9UMzlt84Y= golang.org/x/tools v0.8.0/go.mod h1:JxBZ99ISMI5ViVkT1tr6tdNmXeTrcpVSD3vZ1RsRdN4= diff --git a/lib/lib.go b/lib/lib.go index 767670f..8b6da64 100644 --- a/lib/lib.go +++ b/lib/lib.go @@ -2,28 +2,9 @@ package lib import ( "io/fs" - "math/rand" "path/filepath" - - "github.com/EasyRecon/wappaGo/structure" ) -func RandStringBytes(n int) string { - b := make([]byte, n) - for i := range b { - b[i] = structure.LetterBytes[rand.Intn(len(structure.LetterBytes))] - } - return string(b) -} -func CheckIpAlreadyScan(ip string, list []structure.PortOpenByIp) structure.PortOpenByIp { - for _, ipScanned := range list { - if ip == ipScanned.IP { - return ipScanned - } - } - return structure.PortOpenByIp{} -} - func Contains(s []string, e string) bool { for _, a := range s { if a == e { @@ -33,15 +14,6 @@ func Contains(s []string, e string) bool { return false } -func GetKey(array map[string]interface{}) []string { - k := make([]string, len(array)) - i := 0 - for s, _ := range array { - k[i] = s - i++ - } - return k -} func Find(root, ext string) []string { var a []string filepath.WalkDir(root, func(s string, d fs.DirEntry, e error) error { diff --git a/main.go b/main.go index 7bd3dfe..696d91f 100644 --- a/main.go +++ b/main.go @@ -44,11 +44,13 @@ func configure(options structure.Options) { } } } - folder, errDownload := technologies.DownloadTechnologies() + // The fingerprint database is downloaded, parsed and the on-disk copy + // deleted before this returns. Without it every host would report zero + // technologies, so a failure here is fatal rather than a logged warning. + resultGlobal, errDownload := technologies.Load() if errDownload != nil { - log.Println("error during downloading techno file") + log.Fatalf("could not load the technology fingerprints: %v", errDownload) } - defer os.RemoveAll(folder) var input []string var scanner = bufio.NewScanner(bufio.NewReader(os.Stdin)) @@ -57,7 +59,7 @@ func configure(options structure.Options) { } c := cmd.Cmd{} - c.ResultGlobal = technologies.LoadTechnologiesFiles(folder) + c.ResultGlobal = resultGlobal c.Options = options c.Input = input @@ -70,7 +72,9 @@ func configure(options structure.Options) { } results := make(chan structure.Data, resultsBuffer) + done := make(chan struct{}) go func() { + defer close(done) for result := range results { b, err := json.Marshal(result) @@ -82,4 +86,9 @@ func configure(options structure.Options) { }() c.Start(results) + // Start closes the channel, but closing only wakes the consumer — it does + // not wait for it. Returning here would exit the process with up to + // resultsBuffer already-scanned hosts still sitting unprinted in the + // buffer, silently losing them on a perfectly successful run. + <-done } diff --git a/report/report.go b/report/report.go index d4fbb4f..d3ad504 100644 --- a/report/report.go +++ b/report/report.go @@ -161,12 +161,18 @@ func humanizeBytes(n int) string { if n < unit { return strconv.Itoa(n) + " B" } + // The exponent must stay inside units. Content_length is attacker-supplied + // (it is parsed straight from the Content-Length header in cmd.Do), and a + // host advertising >= 1024^5 bytes used to push exp past the end of the old + // 4-character "KMGT" string — an index-out-of-range panic in the report + // writer, which destroyed the whole run's output. + const units = "KMGTPE" div, exp := int64(unit), 0 - for x := int64(n) / unit; x >= unit; x /= unit { + for x := int64(n) / unit; x >= unit && exp < len(units)-1; x /= unit { div *= unit exp++ } - return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGT"[exp]) + return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), units[exp]) } func formatDuration(d time.Duration) string { diff --git a/report/report_test.go b/report/report_test.go index bed3c08..f76e04e 100644 --- a/report/report_test.go +++ b/report/report_test.go @@ -39,6 +39,41 @@ func TestCardEscapesXSS(t *testing.T) { } } +// TestHumanizeBytesDoesNotPanic pins the clamp on the unit exponent. +// Content_length is parsed straight from the attacker-controlled Content-Length +// header, and a host advertising >= 1024^5 bytes used to index past the end of +// the 4-character "KMGT" string, panicking the report writer and destroying the +// whole run's output. +func TestHumanizeBytesDoesNotPanic(t *testing.T) { + const ( + pib = 1 << 50 + eib = 1 << 60 + ) + for _, n := range []int{0, -1, 1, 1023, 1024, 24576, 1 << 30, pib, 100 * pib, eib, 1<<63 - 1} { + func() { + defer func() { + if r := recover(); r != nil { + t.Errorf("humanizeBytes(%d) panicked: %v", n, r) + } + }() + humanizeBytes(n) + }() + } + + for n, want := range map[int]string{ + 0: "", + -1: "", + 512: "512 B", + 24576: "24.0 KB", + pib: "1.0 PB", + eib: "1.0 EB", + } { + if got := humanizeBytes(n); got != want { + t.Errorf("humanizeBytes(%d) = %q, want %q", n, got, want) + } + } +} + // TestReportRendersFullPage exercises the whole report with varied hosts and // writes a sample to the temp dir for manual inspection. func TestReportRendersFullPage(t *testing.T) { diff --git a/structure/structure.go b/structure/structure.go index a26f0c4..d540479 100644 --- a/structure/structure.go +++ b/structure/structure.go @@ -1,20 +1,26 @@ package structure -import ( - "time" - - "github.com/projectdiscovery/cryptoutil" -) +import "time" type Technologie struct { - Name string `json:"name"` - Version string `json:"version,omitempty"` - Cpe string `json:"cpe,omitempty"` + Name string `json:"name"` + Version string `json:"version,omitempty"` + Cpe string `json:"cpe,omitempty"` + // Icon is the fingerprint's icon filename (e.g. "Nginx.svg"), copied + // straight from the database. Omitted for technologies that declare none. + Icon string `json:"icon,omitempty"` Confidence string `json:"confidence,omitempty"` } -const WappazlyerRoot = "https://raw.githubusercontent.com/dochne/wappalyzer/master/src" -const LetterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" +// TechnologiesRoot is the base URL of the fingerprint database. The 27 shards +// (_.json, a.json … z.json) live directly at this root. +const TechnologiesRoot = "https://raw.githubusercontent.com/Serizao/tech/main" + +// WappazlyerRoot is the previous upstream, kept as a deprecated alias so +// library users pinning it still compile. +// +// Deprecated: use TechnologiesRoot. +const WappazlyerRoot = TechnologiesRoot // DefaultUserAgent is presented by both the raw HTTP probe and Chrome so a WAF // sees one consistent, browser-like client instead of "Go-http-client" then @@ -40,6 +46,9 @@ type Host struct { Cname []string `json:"cname,omitempty"` CDN string `json:"cdn,omitempty"` CertVhost []string `json:"certvhost,omitempty"` + // Error carries a failed render. Without it a host whose page never loaded + // is indistinguishable from one that loaded and genuinely runs nothing. + Error string `json:"error,omitempty"` } type Data struct { Url string `json:"url"` @@ -62,6 +71,88 @@ type Options struct { Jitter *int NoProgress *bool } + +// Default values for Options. They are the single source of truth: main.go +// passes them to the flag package, and ApplyDefaults repairs anything that +// reaches cmd.Start unset or nonsensical. +const ( + DefaultPorts = "80,443" + DefaultThreads = 5 + DefaultChromeThreads = 5 + DefaultPortTimeout = 2000 +) + +// ApplyDefaults fills in every unset or invalid field. +// +// Options is a struct of fourteen pointers that cmd dereferences unguarded, so +// any nil is a crash. The library path built this struct by hand and forwarded +// a caller's zero-valued WrapperOptions verbatim, which meant the documented +// README example ran with Threads=0 and Porttimeout=0. A non-positive worker +// count is also actively dangerous: sizedwaitgroup treats limit<=0 as unbounded, +// so `-threads 0` removed the concurrency cap entirely. +func (o *Options) ApplyDefaults() { + str := func(p **string, def string) { + if *p == nil { + v := def + *p = &v + } else if **p == "" { + **p = def + } + } + num := func(p **int, def int) { + if *p == nil { + v := def + *p = &v + } else if **p <= 0 { + **p = def + } + } + flag := func(p **bool) { + if *p == nil { + v := false + *p = &v + } + } + + str(&o.Ports, DefaultPorts) + num(&o.Threads, DefaultThreads) + num(&o.ChromeThreads, DefaultChromeThreads) + num(&o.Porttimeout, DefaultPortTimeout) + + // Empty is meaningful for these: no screenshots, system resolvers, no proxy, + // built-in User-Agent. + for _, p := range []**string{&o.Screenshot, &o.Resolvers, &o.Proxy, &o.UserAgent} { + if *p == nil { + v := "" + *p = &v + } + } + for _, p := range []**bool{&o.AmassInput, &o.FollowRedirect, &o.Report, &o.NoProgress} { + flag(p) + } + if o.ChromeTimeout == nil { + v := 0 + o.ChromeTimeout = &v + } + // 0 means "unlimited" for both, so only a negative value is repaired. + if o.Rps == nil || *o.Rps < 0 { + v := 0.0 + if o.Rps != nil { + *o.Rps = v + } else { + o.Rps = &v + } + } + if o.Jitter == nil || *o.Jitter < 0 { + v := 0 + if o.Jitter != nil { + *o.Jitter = v + } else { + o.Jitter = &v + } + } +} + type WrapperOptions struct { Screenshot string Ports string @@ -76,22 +167,15 @@ type WrapperOptions struct { Rps float64 Jitter int } + +// Response is the raw HTTP probe result. It only carries what is actually +// consumed: Headers feed the header matcher, the rest feed Host's metrics. +// Raw, RawHeaders, Data, Words, Lines, TLSData, HTTP2 and Pipeline were removed +// because nothing ever read them — and populating Raw meant buffering every +// probed body whole, twice. type Response struct { StatusCode int Headers map[string][]string - Data []byte ContentLength int - Raw string - RawHeaders string - Words int - Lines int - TLSData *cryptoutil.TLSData - HTTP2 bool - Pipeline bool Duration time.Duration } - -type PortOpenByIp struct { - IP string - Open_port []string -} diff --git a/technologies/technologies.go b/technologies/technologies.go index fbeec0b..5bd5809 100644 --- a/technologies/technologies.go +++ b/technologies/technologies.go @@ -6,11 +6,14 @@ import ( "io" "net/http" "os" + "strings" + "time" "github.com/EasyRecon/wappaGo/lib" "github.com/EasyRecon/wappaGo/structure" "github.com/imdario/mergo" ) + func CheckRequired(technoName string, technoList map[string]interface{}, tech []structure.Technologie) []structure.Technologie { entry, ok := technoList[technoName].(map[string]interface{}) if !ok { @@ -39,21 +42,98 @@ func CheckRequired(technoName string, technoList map[string]interface{}, tech [] } return tech } -func AddTechno(name string, tech []structure.Technologie, technoList map[string]interface{}) []structure.Technologie { - technoTemp := structure.Technologie{Name: name} - // Guard every step: the implied/required techno may be missing from the DB - // or carry a nil/non-string cpe (the original code panicked on both). - if entry, ok := technoList[name].(map[string]interface{}); ok { - if cpe, ok := entry["cpe"].(string); ok { - technoTemp.Cpe = cpe + +// SplitMarkers separates a Wappalyzer value from its "\;version:" / +// "\;confidence:" markers and returns the bare value plus the two fields. +// +// Markers appear on implies edges too, not just on match patterns: the live +// database ships "PHP\;confidence:75" and "Magento\;version:2". Those used to +// be taken as technology names verbatim, so results carried entries literally +// called `PHP\;confidence:75` — which also broke the requires gate, since no +// requires edge can ever name that string. +func SplitMarkers(value string) (name string, version string, confidence string) { + parts := strings.Split(value, "\\;") + name = parts[0] + for _, marker := range parts[1:] { + switch { + case strings.HasPrefix(marker, "version:"): + version = strings.TrimPrefix(marker, "version:") + case strings.HasPrefix(marker, "confidence:"): + confidence = strings.TrimPrefix(marker, "confidence:") } } + return name, version, confidence +} + +// New builds a Technologie for name, copying the descriptive fields the +// fingerprint database carries for it. +// +// Every step is guarded: the technology may be absent from the database (an +// implied edge can point at one that is not shipped) or carry a non-string +// value, and asserting either used to panic. +func New(name string, technoList map[string]interface{}) structure.Technologie { + techno := structure.Technologie{Name: name} + entry, ok := technoList[name].(map[string]interface{}) + if !ok { + return techno + } + if cpe, ok := entry["cpe"].(string); ok { + techno.Cpe = cpe + } + if icon, ok := entry["icon"].(string); ok { + techno.Icon = icon + } + return techno +} + +func AddTechno(name string, tech []structure.Technologie, technoList map[string]interface{}) []structure.Technologie { + cleanName, version, confidence := SplitMarkers(name) + technoTemp := New(cleanName, technoList) + technoTemp.Version = version + technoTemp.Confidence = confidence tech = append(tech, technoTemp) return tech } +// shards are the 27 files the fingerprint database is split into. +var shards = []string{"_", "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", + "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z"} + +// maxShardBytes caps a single shard. The largest real shard is under 400 KB; +// this only exists so a hostile or broken origin cannot stream forever into +// memory (http.Get has no size limit of its own). +const maxShardBytes = 32 << 20 + +// downloadClient bounds the whole fetch. http.DefaultClient has no timeout at +// all, so a slow-loris origin used to hang the tool before the scan even began. +var downloadClient = &http.Client{Timeout: 60 * time.Second} + +// Load downloads the fingerprint database, parses it into memory, and deletes +// the on-disk copy before returning. +// +// The shards exist on disk only for the few milliseconds between download and +// parse: nothing reads them afterwards, so keeping the temp directory alive for +// the whole scan (as the previous `defer os.RemoveAll(folder)` in main and +// wrapper did) served no purpose and leaked the directory outright whenever the +// download failed early. +func Load() (map[string]interface{}, error) { + folder, err := DownloadTechnologies() + // DownloadTechnologies may return a usable folder alongside an error; clean + // it up either way. + if folder != "" { + defer os.RemoveAll(folder) + } + if err != nil { + return nil, err + } + result := LoadTechnologiesFiles(folder) + if len(result) == 0 { + return nil, fmt.Errorf("fingerprint database is empty (nothing usable downloaded from %s)", structure.TechnologiesRoot) + } + return result, nil +} + func DownloadTechnologies() (string, error) { - files := []string{"_", "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z"} // Create the working dir under the system temp location, not the current // working directory, so the downloaded fingerprint shards never land in // (and get accidentally committed to) the repository. @@ -61,14 +141,15 @@ func DownloadTechnologies() (string, error) { if err != nil { return "", err } - for _, f := range files { - url := fmt.Sprintf("%v/technologies/%v.json", structure.WappazlyerRoot, f) - resp, err := http.Get(url) + for _, f := range shards { + url := fmt.Sprintf("%v/%v.json", structure.TechnologiesRoot, f) + resp, err := downloadClient.Get(url) if err != nil { - return "", err + // Return the folder so the caller can still clean it up. + return folder, err } - body, err := io.ReadAll(resp.Body) + body, err := io.ReadAll(io.LimitReader(resp.Body, maxShardBytes)) resp.Body.Close() if err != nil { fmt.Fprintf(os.Stderr, "skip %v.json: read error: %v\n", f, err) @@ -127,30 +208,108 @@ func loadTechnologyFile(path string) (map[string]interface{}, error) { } return result, nil } + +// DedupTechno collapses repeated detections of the same technology into one +// entry, merging the fields each detection contributed. +// +// The merge must not depend on the order detections arrive in. Run() iterates +// the fingerprint database with a Go map range, so the order in which signal +// families (html, scriptSrc, js, meta, …) fire is randomised on every run. The +// old code kept whichever non-empty Version landed first, so 86 technologies in +// the live database — anything carrying a version marker in two families, e.g. +// Bootstrap or AngularJS — reported a version that changed between runs on +// byte-identical input. Picking the most specific value instead makes the +// output reproducible. func DedupTechno(technologies []structure.Technologie) []structure.Technologie { var output []structure.Technologie - add := true + index := make(map[string]int, len(technologies)) for _, tech := range technologies { - add = true - for i, checkTech := range output { - if checkTech == tech { - add = false - } else { - if checkTech.Name == tech.Name { - if tech.Version != "" && checkTech.Version == "" { - output[i].Version = tech.Version - } - add = false - } - } - } - if add { + i, seen := index[tech.Name] + if !seen { + index[tech.Name] = len(output) output = append(output, tech) + continue + } + output[i].Version = moreSpecific(output[i].Version, tech.Version) + output[i].Confidence = moreSpecific(output[i].Confidence, tech.Confidence) + if output[i].Cpe == "" { + output[i].Cpe = tech.Cpe + } + if output[i].Icon == "" { + output[i].Icon = tech.Icon } } return output } +// moreSpecific picks deterministically between two candidate values: the longer +// one wins ("1.21.0" over "1.21"), ties broken lexicographically so the result +// never depends on argument order. +func moreSpecific(a, b string) string { + switch { + case a == "": + return b + case b == "": + return a + case len(a) != len(b): + if len(a) > len(b) { + return a + } + return b + case a >= b: + return a + default: + return b + } +} + +// FilterExcluded applies Wappalyzer "excludes": when a detected technology +// declares that another cannot coexist with it, the excluded one is dropped. +// +// 43 technologies in the live database carry an excludes edge and none of them +// were honoured, so mutually exclusive pairs (a CMS and the platform it is +// commonly confused with, competing analytics scripts) were both reported. +// Exclusion is evaluated in one pass against the originally detected set, so the +// result never depends on iteration order. +// +// Mutually exclusive pairs are left alone. The database ships three +// (Angular/AngularJS, HTTP/2/SPDY, Lodash/Underscore.js); applying both +// directions would delete both technologies, which throws away evidence instead +// of resolving it. When the data contradicts itself, keeping both and letting the +// operator judge beats silently reporting neither. +func FilterExcluded(techs []structure.Technologie, technoList map[string]interface{}) []structure.Technologie { + if len(techs) == 0 { + return techs + } + excludes := func(name string) []string { + entry, ok := technoList[name].(map[string]interface{}) + if !ok { + return nil + } + return namesOf(entry["excludes"]) + } + + excluded := make(map[string]bool) + for _, t := range techs { + for _, name := range excludes(t.Name) { + if lib.Contains(excludes(name), t.Name) { + continue // mutual: not a resolvable conflict + } + excluded[name] = true + } + } + if len(excluded) == 0 { + return techs + } + kept := techs[:0:0] + for _, t := range techs { + if !excluded[t.Name] { + kept = append(kept, t) + } + } + return kept +} + // FilterRequired enforces Wappalyzer "requires" preconditions: a detected // technology is kept only if every technology it requires is also present in // the result set. Unlike "implies" (which asserts a dependency), "requires" @@ -188,7 +347,7 @@ func requiresSatisfied(name string, technoList map[string]interface{}, detected if !ok { return true } - for _, req := range requiredNames(requires) { + for _, req := range namesOf(requires) { if !detected[req] { return false } @@ -196,24 +355,29 @@ func requiresSatisfied(name string, technoList map[string]interface{}, detected return true } -// requiredNames normalises a "requires" value (string, []interface{} or -// map[string]interface{}) into the list of required technology names. -func requiredNames(requires interface{}) []string { - switch v := requires.(type) { +// namesOf normalises a Wappalyzer cross-reference value ("requires", +// "excludes", "implies") into a list of technology names. The value may be a +// bare string, an array, or an object keyed by name, and entries may carry +// "\;version:"/"\;confidence:" markers that are not part of the name. +func namesOf(value interface{}) []string { + switch v := value.(type) { case string: - return []string{v} + name, _, _ := SplitMarkers(v) + return []string{name} case []interface{}: var out []string for _, item := range v { if s, ok := item.(string); ok { - out = append(out, s) + name, _, _ := SplitMarkers(s) + out = append(out, name) } } return out case map[string]interface{}: var out []string for k := range v { - out = append(out, k) + name, _, _ := SplitMarkers(k) + out = append(out, name) } return out } diff --git a/technologies/technologies_test.go b/technologies/technologies_test.go new file mode 100644 index 0000000..2e3e3f5 --- /dev/null +++ b/technologies/technologies_test.go @@ -0,0 +1,217 @@ +package technologies + +import ( + "reflect" + "testing" + + "github.com/EasyRecon/wappaGo/structure" +) + +func names(techs []structure.Technologie) []string { + out := make([]string, 0, len(techs)) + for _, t := range techs { + out = append(out, t.Name) + } + return out +} + +// TestSplitMarkers pins the marker parsing shared by implies/requires/excludes. +func TestSplitMarkers(t *testing.T) { + cases := []struct { + in string + name, version, confidence string + }{ + {"PHP", "PHP", "", ""}, + {`PHP\;confidence:75`, "PHP", "", "75"}, + {`Magento\;version:2`, "Magento", "2", ""}, + {`Foo\;version:3\;confidence:50`, "Foo", "3", "50"}, + {"", "", "", ""}, + } + for _, tc := range cases { + name, version, confidence := SplitMarkers(tc.in) + if name != tc.name || version != tc.version || confidence != tc.confidence { + t.Errorf("SplitMarkers(%q) = (%q,%q,%q), want (%q,%q,%q)", + tc.in, name, version, confidence, tc.name, tc.version, tc.confidence) + } + } +} + +// TestImpliesStripsMarkers pins the fix for technologies being reported with a +// marker glued to their name. The live database ships `PHP\;confidence:75`, and +// AddTechno used to take that whole string as the technology's name. +func TestImpliesStripsMarkers(t *testing.T) { + db := map[string]interface{}{ + "HHVM": map[string]interface{}{ + "implies": `PHP\;confidence:75`, + }, + "GoMage": map[string]interface{}{ + "implies": []interface{}{`Magento\;version:2`, "PHP"}, + }, + "PHP": map[string]interface{}{"cpe": "cpe:2.3:a:php:php"}, + "Magento": map[string]interface{}{}, + } + + got := CheckRequired("HHVM", db, nil) + if len(got) != 1 { + t.Fatalf("expected 1 implied techno, got %v", names(got)) + } + if got[0].Name != "PHP" { + t.Errorf("implied name = %q, want %q (marker not stripped)", got[0].Name, "PHP") + } + if got[0].Confidence != "75" { + t.Errorf("implied confidence = %q, want %q", got[0].Confidence, "75") + } + if got[0].Cpe == "" { + t.Errorf("cpe not resolved: the lookup must use the cleaned name") + } + + got = CheckRequired("GoMage", db, nil) + if want := []string{"Magento", "PHP"}; !reflect.DeepEqual(names(got), want) { + t.Errorf("implied names = %v, want %v", names(got), want) + } + if got[0].Version != "2" { + t.Errorf("Magento version = %q, want %q", got[0].Version, "2") + } +} + +// TestRequiresGateSeesCleanedImpliesName covers the knock-on effect: a requires +// edge can only ever name the clean technology, so a marker left on an implied +// name silently broke the gate too. +func TestRequiresGateSeesCleanedImpliesName(t *testing.T) { + db := map[string]interface{}{ + "HHVM": map[string]interface{}{"implies": `PHP\;confidence:75`}, + "PHP": map[string]interface{}{}, + "SomeAddon": map[string]interface{}{"requires": "PHP"}, + } + detected := []structure.Technologie{{Name: "HHVM"}, {Name: "SomeAddon"}} + detected = CheckRequired("HHVM", db, detected) + + kept := names(FilterRequired(DedupTechno(detected), db)) + for _, want := range []string{"HHVM", "PHP", "SomeAddon"} { + found := false + for _, k := range kept { + if k == want { + found = true + } + } + if !found { + t.Errorf("%q was dropped; kept = %v", want, kept) + } + } +} + +// TestDedupTechnoIsOrderIndependent pins the determinism fix: Run() iterates the +// database with a Go map range, so detections arrive in a random order on every +// run. Merging must not depend on it. +func TestDedupTechnoIsOrderIndependent(t *testing.T) { + forward := []structure.Technologie{ + {Name: "Bootstrap", Version: "3.4"}, + {Name: "Bootstrap", Version: "3.4.1", Cpe: "cpe:boot"}, + {Name: "Bootstrap", Confidence: "50"}, + } + reverse := []structure.Technologie{ + {Name: "Bootstrap", Confidence: "50"}, + {Name: "Bootstrap", Version: "3.4.1", Cpe: "cpe:boot"}, + {Name: "Bootstrap", Version: "3.4"}, + } + + a, b := DedupTechno(forward), DedupTechno(reverse) + if !reflect.DeepEqual(a, b) { + t.Fatalf("dedup depends on input order:\n forward = %+v\n reverse = %+v", a, b) + } + if len(a) != 1 { + t.Fatalf("expected 1 merged entry, got %+v", a) + } + if a[0].Version != "3.4.1" { + t.Errorf("version = %q, want the most specific %q", a[0].Version, "3.4.1") + } + if a[0].Confidence != "50" { + t.Errorf("confidence = %q, want it merged in", a[0].Confidence) + } + if a[0].Cpe != "cpe:boot" { + t.Errorf("cpe = %q, want it merged in", a[0].Cpe) + } +} + +// TestFilterExcluded pins the newly-honoured "excludes" edge: 43 technologies in +// the live database declare one and none of them were applied, so mutually +// exclusive pairs were both reported. +func TestFilterExcluded(t *testing.T) { + db := map[string]interface{}{ + "Apache HTTP Server": map[string]interface{}{"excludes": "Nginx"}, + "Nginx": map[string]interface{}{}, + "jQuery": map[string]interface{}{}, + "Zepto": map[string]interface{}{"excludes": []interface{}{"jQuery"}}, + } + + got := names(FilterExcluded([]structure.Technologie{ + {Name: "Apache HTTP Server"}, {Name: "Nginx"}, {Name: "jQuery"}, + }, db)) + if want := []string{"Apache HTTP Server", "jQuery"}; !reflect.DeepEqual(got, want) { + t.Errorf("got %v, want %v (Nginx must be excluded by Apache)", got, want) + } + + got = names(FilterExcluded([]structure.Technologie{{Name: "Zepto"}, {Name: "jQuery"}}, db)) + if want := []string{"Zepto"}; !reflect.DeepEqual(got, want) { + t.Errorf("got %v, want %v (array form of excludes)", got, want) + } + + // Nothing excluded -> the slice is returned untouched. + in := []structure.Technologie{{Name: "jQuery"}, {Name: "Nginx"}} + if got := names(FilterExcluded(in, db)); !reflect.DeepEqual(got, []string{"jQuery", "Nginx"}) { + t.Errorf("got %v, want both kept", got) + } + + // An unknown technology must not panic or drop anything. + if got := names(FilterExcluded([]structure.Technologie{{Name: "Ghost"}}, db)); len(got) != 1 { + t.Errorf("unknown technology was dropped: %v", got) + } +} + +// TestFilterExcludedKeepsMutualPairs pins the mutual-exclusion rule. The live +// database ships three pairs that exclude each other (Angular/AngularJS, +// HTTP/2/SPDY, Lodash/Underscore.js); applying both directions would delete +// both, reporting neither instead of resolving the conflict. +func TestFilterExcludedKeepsMutualPairs(t *testing.T) { + db := map[string]interface{}{ + "Angular": map[string]interface{}{"excludes": "AngularJS"}, + "AngularJS": map[string]interface{}{"excludes": "Angular"}, + "Lodash": map[string]interface{}{"excludes": []interface{}{"Underscore.js"}}, + "Underscore.js": map[string]interface{}{"excludes": []interface{}{"Lodash"}}, + "HTTP/3": map[string]interface{}{"excludes": "HTTP/2"}, + "HTTP/2": map[string]interface{}{}, + } + + got := names(FilterExcluded([]structure.Technologie{{Name: "Angular"}, {Name: "AngularJS"}}, db)) + if !reflect.DeepEqual(got, []string{"Angular", "AngularJS"}) { + t.Errorf("mutual pair annihilated: got %v, want both kept", got) + } + + got = names(FilterExcluded([]structure.Technologie{{Name: "Lodash"}, {Name: "Underscore.js"}}, db)) + if len(got) != 2 { + t.Errorf("mutual array-form pair annihilated: got %v", got) + } + + // One-way exclusion still applies. + got = names(FilterExcluded([]structure.Technologie{{Name: "HTTP/3"}, {Name: "HTTP/2"}}, db)) + if !reflect.DeepEqual(got, []string{"HTTP/3"}) { + t.Errorf("one-way exclusion broken: got %v, want [HTTP/3]", got) + } +} + +// TestNamesOfHandlesEveryShape guards the shared normaliser against the three +// JSON shapes the database actually uses. +func TestNamesOfHandlesEveryShape(t *testing.T) { + if got := namesOf("A"); !reflect.DeepEqual(got, []string{"A"}) { + t.Errorf("string form: %v", got) + } + if got := namesOf([]interface{}{"A", `B\;version:1`, 42}); !reflect.DeepEqual(got, []string{"A", "B"}) { + t.Errorf("array form: %v (non-strings must be skipped)", got) + } + if got := namesOf(map[string]interface{}{"A": nil}); !reflect.DeepEqual(got, []string{"A"}) { + t.Errorf("object form: %v", got) + } + if got := namesOf(nil); got != nil { + t.Errorf("nil form: %v", got) + } +} diff --git a/wrapper/root.go b/wrapper/root.go index 32d826f..e1a782d 100644 --- a/wrapper/root.go +++ b/wrapper/root.go @@ -2,7 +2,7 @@ package wrapper import ( "errors" - "log" + "fmt" "os" "github.com/EasyRecon/wappaGo/cmd" @@ -10,70 +10,95 @@ import ( "github.com/EasyRecon/wappaGo/technologies" ) -func StartReconAsync(input []string, wrapperOptions structure.WrapperOptions, results chan structure.Data) { - c := configureOptions(wrapperOptions) +// StartReconAsync scans input and streams every result on results, which it +// closes when the scan is done. It blocks until then. +// +// Setup failures (fingerprint download, screenshot directory) are returned +// instead of being logged and ignored: a failed download used to leave the +// caller with a full scan reporting zero technologies for every host. results +// is left untouched — and unclosed — when a non-nil error is returned. +func StartReconAsync(input []string, wrapperOptions structure.WrapperOptions, results chan structure.Data) error { + c, err := configureOptions(wrapperOptions) + if err != nil { + return err + } c.Input = input c.Start(results) + return nil } -func StartReconSync(input []string, wrapperOptions structure.WrapperOptions) []structure.Data { - c := configureOptions(wrapperOptions) +// StartReconSync scans input and returns every result once the scan is done. +func StartReconSync(input []string, wrapperOptions structure.WrapperOptions) ([]structure.Data, error) { + c, err := configureOptions(wrapperOptions) + if err != nil { + return nil, err + } c.Input = input var resultGlobal []structure.Data results := make(chan structure.Data) + done := make(chan struct{}) go func() { + defer close(done) for result := range results { resultGlobal = append(resultGlobal, result) } }() c.Start(results) + // Waiting on done is what makes the slice safe to read: c.Start closing the + // channel orders nothing with respect to the collector's last append, so + // returning straight away both raced on resultGlobal and dropped whichever + // results the collector had not appended yet. + <-done - return resultGlobal + return resultGlobal, nil } -func configureOptions(wrapperOptions structure.WrapperOptions) *cmd.Cmd { - options := structure.Options{} - falseBool := false - trueBool := true - - options.Screenshot = &wrapperOptions.Screenshot - options.Ports = &wrapperOptions.Ports - options.Threads = &wrapperOptions.Threads - options.Report = &falseBool - options.Porttimeout = &wrapperOptions.Porttimeout - options.ChromeTimeout = &wrapperOptions.ChromeTimeout - options.ChromeThreads = &wrapperOptions.ChromeThreads - options.Resolvers = &wrapperOptions.Resolvers - options.AmassInput = &falseBool - options.FollowRedirect = &wrapperOptions.FollowRedirect - options.Proxy = &wrapperOptions.Proxy - options.UserAgent = &wrapperOptions.UserAgent - options.Rps = &wrapperOptions.Rps - options.Jitter = &wrapperOptions.Jitter - options.NoProgress = &trueBool // library mode: no stderr progress bar +func configureOptions(wrapperOptions structure.WrapperOptions) (*cmd.Cmd, error) { + // Every field gets its own pointer. Sharing one &falseBool across Report and + // AmassInput made two unrelated options alias the same variable. + noReport, noAmass, noProgress := false, false, true + options := structure.Options{ + Screenshot: &wrapperOptions.Screenshot, + Ports: &wrapperOptions.Ports, + Threads: &wrapperOptions.Threads, + Report: &noReport, + Porttimeout: &wrapperOptions.Porttimeout, + ChromeTimeout: &wrapperOptions.ChromeTimeout, + ChromeThreads: &wrapperOptions.ChromeThreads, + Resolvers: &wrapperOptions.Resolvers, + AmassInput: &noAmass, + FollowRedirect: &wrapperOptions.FollowRedirect, + Proxy: &wrapperOptions.Proxy, + UserAgent: &wrapperOptions.UserAgent, + Rps: &wrapperOptions.Rps, + Jitter: &wrapperOptions.Jitter, + NoProgress: &noProgress, // library mode: no stderr progress bar + } + // A zero-valued WrapperOptions used to reach cmd with Threads=0 and + // Porttimeout=0 — including in the README's own example. + options.ApplyDefaults() if *options.Screenshot != "" { if _, err := os.Stat(*options.Screenshot); errors.Is(err, os.ErrNotExist) { - err := os.Mkdir(*options.Screenshot, os.ModePerm) - if err != nil { - log.Println(err) + if err := os.Mkdir(*options.Screenshot, os.ModePerm); err != nil { + return nil, fmt.Errorf("create screenshot dir %q: %w", *options.Screenshot, err) } } } - folder, errDownload := technologies.DownloadTechnologies() - if errDownload != nil { - log.Println("error during downloading techno file") + // Downloads, parses, and deletes the on-disk copy before returning. + resultGlobal, err := technologies.Load() + if err != nil { + return nil, fmt.Errorf("load technology fingerprints: %w", err) } - defer os.RemoveAll(folder) c := &cmd.Cmd{} - c.ResultGlobal = technologies.LoadTechnologiesFiles(folder) + c.ResultGlobal = resultGlobal c.Options = options - return c + return c, nil } From f8461d4e7bd5ab9fd231815103af146012eb0159 Mon Sep 17 00:00:00 2001 From: Serizao Date: Thu, 30 Jul 2026 12:36:56 +0200 Subject: [PATCH 18/22] feat(stealth): one coherent browser identity, and a harness that proves it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser gave itself away in three measurable ways. Each fix below was kept or dropped on the evidence of the new harness, not on reputation. structure.DefaultUserAgent was pinned to Chrome 131 while the installed browser was 150, and --user-agent does not touch the Client Hints Chrome derives from its own build. Measured against a local echo server, every request advertised two different versions at once: User-Agent: ...Chrome/131.0.0.0 Sec-CH-UA: "Google Chrome";v="150" resolveIdentity now reads the real build with Browser.getVersion, rewrites the HeadlessChrome token (new headless still emits it, so the override cannot simply be dropped), and applyIdentity installs it through Emulation.setUserAgentOverride with matching userAgentMetadata. navigator.userAgent, navigator.userAgentData and the Sec-CH-UA-* headers now all come from one source. A hardcoded UA survives only as a fallback when the browser cannot be queried, and -user-agent still wins while the metadata keeps following the real build. The flag list no longer starts from chromedp.DefaultExecAllocatorOptions, whose last entry is Flag("enable-automation", true) — the marker itself. The useful flags are copied in explicitly so each one is a decision. --enable-automation is the only one actually dropped: removing the throughput flags alongside it was measured to cost 47 detections and to time out two hosts, because site isolation gives every third-party iframe its own process, multiplied by the tab count. --disable-webgl is gone. An absent WebGL context is a strong bot signal, and Camoufox spoofs WebGL rather than removing it. Forcing SwiftShader instead was worse still: software rendering pushed 7 of 20 targets past the 60 s budget, returning zero technologies. With no graphics flags at all Chrome reports the real adapter, which is both the most plausible fingerprint and free. screen was 800x600 while the window was 1400x900 — a window larger than its own display. Emulation.setDeviceMetricsOverride now declares a 1920x1080 screen around the viewport. Browser.setWindowBounds was tried for window.outerWidth/outerHeight and removed again: no measurable effect, and it made Page.captureScreenshot fail with -32000, which aborted the action chain and took every matcher down with it. That last failure exposed a standing fragility: the screenshot sat unconditionally ahead of the detection ActionFunc, so any capture error cost the whole host, and it ran even without -screenshot, encoding a PNG per page only to discard it. It is now conditional and non-fatal. Accept-Language was en-US on the probe while Chrome used the host locale (fr-FR here), so the two clients contradicted each other on every host. Both are pinned, and Accept now matches Chrome byte for byte. cmd/browser_stealth_test.go is the measurement, opt-in because it starts a real Chrome (WAPPAGO_STEALTH_CHECK=1). It serves its own page, so verdicts are reproducible, and it reports rather than hides two residuals: canvas, WebGL and audio fingerprints are asserted present and *stable* — deliberately not randomised, since a fingerprint that changes between reads is itself a marker of an anti-fingerprinting tool — and window.outerWidth/outerHeight stay 0, which no CDP call fixes and which the usual injected-script workaround would trade for a broader tell. Detection unchanged: 149/150/150 technologies over the 20-target reference set across three runs, no host at zero. gofmt -l . clean, go vet ./... clean, go test -race ./... green --- README.md | 35 ++ cmd/browser.go | 342 ++++++++++++++++++ cmd/browser_stealth_test.go | 693 ++++++++++++++++++++++++++++++++++++ cmd/root.go | 68 ++-- 4 files changed, 1117 insertions(+), 21 deletions(-) create mode 100644 cmd/browser.go create mode 100644 cmd/browser_stealth_test.go diff --git a/README.md b/README.md index dfa1ea3..61cb4a4 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,41 @@ The technology fingerprints are downloaded at startup from [Serizao/tech](https://github.com/Serizao/tech), parsed into memory, and the on-disk copy is deleted before the scan begins. +## Blending in + +wappaGo presents one consistent browser identity to every host it scans. The +User-Agent is read from the installed Chrome at startup (with the +`HeadlessChrome` token rewritten) and installed together with its matching +Client Hints metadata, so `navigator.userAgent`, `navigator.userAgentData` and +the `Sec-CH-UA-*` headers all report the same build. The raw HTTP probe sends +the same User-Agent, `Accept` and `Accept-Language`. `--enable-automation` is not +passed, and WebGL is left enabled. + +A measurement harness records what the browser actually gives away: + +```bash +WAPPAGO_STEALTH_CHECK=1 go test ./cmd/ -run Stealth -v +``` + +It serves its own page locally, so the verdicts are reproducible. Two known +limits it reports rather than hides: + +- **`window.outerWidth` / `outerHeight` are 0.** Headless Chrome has no real + window and no CDP call changes this. The usual workaround redefines the + accessor from an injected script, which leaves a getter that no longer reports + `[native code]` — a broader tell than the one it fixes, so it is not done. +- **WebGL quality depends on the host.** On a machine with a GPU, Chrome reports + the real adapter, which is the most plausible fingerprint available. On a + GPU-less server or container it falls back to software rendering and the + renderer string says so (`SwiftShader`, `Microsoft Basic Render Driver`, + `llvmpipe`). The harness logs a `NOTE:` when that happens. Running with a GPU, + or with a virtual display, gives a stronger identity. + +Canvas, WebGL and audio fingerprints are deliberately **not** randomised. The +goal is to look ordinary, not unique: a fingerprint that changes between two +reads is itself a signature of an anti-fingerprinting tool. The harness asserts +those surfaces are present and stable instead. + You can either use wappaGo from a file containing a list of domains ```bash cat domain.txt | ./wappaGo diff --git a/cmd/browser.go b/cmd/browser.go new file mode 100644 index 0000000..650dedd --- /dev/null +++ b/cmd/browser.go @@ -0,0 +1,342 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strconv" + "strings" + + "github.com/EasyRecon/wappaGo/structure" + "github.com/chromedp/cdproto/browser" + "github.com/chromedp/cdproto/emulation" + "github.com/chromedp/chromedp" +) + +// chromeAllocatorOptions builds the command line the browser is launched with. +// +// It deliberately does NOT start from chromedp.DefaultExecAllocatorOptions. +// That list ends with Flag("enable-automation", true) — the single most widely +// checked automation marker — so importing it wholesale undid part of the point +// of launching a browser at all. The flags below are the useful subset, copied +// explicitly so every one of them is a decision rather than an inheritance. +// +// Kept separate from Start so the exact production configuration can be +// launched by the stealth harness in browser_stealth_test.go. +func chromeAllocatorOptions(options structure.Options, userAgent string) []chromedp.ExecAllocatorOption { + opts := []chromedp.ExecAllocatorOption{ + // Housekeeping: a scanner must never wait on first-run UI, a default + // browser prompt, a crash reporter or an update check. + chromedp.NoFirstRun, + chromedp.NoDefaultBrowserCheck, + chromedp.Flag("disable-breakpad", true), + chromedp.Flag("safebrowsing-disable-auto-update", true), + chromedp.Flag("metrics-recording-only", true), + chromedp.Flag("disable-sync", true), + chromedp.Flag("password-store", "basic"), + chromedp.Flag("use-mock-keychain", true), + + // Keep background pages and timers running: throttling a backgrounded + // renderer is exactly what stops lazily-loaded scripts from appearing, + // which is what the js/scriptSrc fingerprints look for. + chromedp.Flag("disable-background-timer-throttling", true), + chromedp.Flag("disable-backgrounding-occluded-windows", true), + chromedp.Flag("disable-renderer-backgrounding", true), + chromedp.Flag("disable-ipc-flooding-protection", true), + chromedp.Flag("disable-hang-monitor", true), + + // Containers give /dev/shm 64 MB, which crashes renderers. + chromedp.Flag("disable-dev-shm-usage", true), + + // A scanner must not stop on a prompt. + chromedp.Flag("disable-popup-blocking", true), + chromedp.Flag("disable-prompt-on-repost", true), + + // Throughput, not stealth. Dropping these was measured to cost 47 + // detections over the 20-target reference set and to time out two hosts + // entirely: with site isolation on, every third-party iframe on an + // ad-heavy page gets its own process, multiplied by the concurrent tab + // count. None of them is a known bot signal, so they stay. + chromedp.Flag("disable-features", "site-per-process,Translate,BlinkGenPropertyTrees"), + chromedp.Flag("disable-background-networking", true), + chromedp.Flag("disable-client-side-phishing-detection", true), + chromedp.Flag("disable-extensions", true), + chromedp.Flag("disable-default-apps", true), + chromedp.Flag("enable-features", "NetworkService,NetworkServiceInProcess"), + + // We are deliberately visiting hosts with broken TLS. + chromedp.Flag("ignore-certificate-errors", true), + + // Hides navigator.webdriver. + chromedp.Flag("disable-blink-features", "AutomationControlled"), + + chromedp.Flag("force-color-profile", "srgb"), + chromedp.WindowSize(viewportWidth, viewportHeight), + chromedp.Headless, + } + + // Notable omissions, each one a measured decision: + // + // --enable-automation the marker itself, and the only flag from + // chromedp's defaults that is actually dropped + // --disable-webgl an absent WebGL context is a strong bot signal; + // Camoufox spoofs WebGL rather than removing it + // --disable-gpu would take the real WebGL adapter down with it + // + // Nothing is added for graphics on purpose. Measured on this machine with + // the stealth harness: + // + // no flags NVIDIA T1200 Laptop GPU / Direct3D11 <- kept + // --use-angle=swiftshader SwiftShader (software) + // --disable-gpu Microsoft Basic Render Driver + // --disable-webgl no WebGL context at all <- was here + // + // Letting Chrome choose gives the real adapter, which is the most plausible + // WebGL fingerprint available, and costs nothing. Forcing SwiftShader made + // rendering so much slower that 7 of 20 targets hit the 60 s budget and came + // back with zero technologies. On a GPU-less host Chrome falls back to + // software rendering by itself, so this stays correct on a server too. + + if userAgent != "" { + opts = append(opts, chromedp.UserAgent(userAgent)) + } + if options.Proxy != nil && *options.Proxy != "" { + opts = append(opts, chromedp.ProxyServer(*options.Proxy)) + } + return opts +} + +// On the Runtime.enable CDP leak, deliberately not addressed: +// +// chromedp issues runtime.Enable() on every target (chromedp@v0.8.5:368), and +// that command is the basis of the most widely cited CDP detection technique. +// The usual mitigation — evaluating everything in an isolated world — is not +// available to wappaGo: the js fingerprint family reads page globals +// (jQuery.fn.jquery, Stimulus.Application, ~3155 patterns), so the main world is +// required. The remaining option was to patch chromedp behind a replace +// directive. +// +// Measured before committing to that: run against rebrowser's hosted detector +// with this exact configuration and Chrome 150, runtimeEnableLeak reports "No +// leak detected", as do navigatorWebdriver, viewport, pwInitScripts, bypassCsp +// and useragent. Forking a dependency for no measurable gain is not worth the +// maintenance, so it is left alone. TestStealthRemoteDetector re-checks this, so +// a future Chrome or chromedp that does leak will show up rather than pass +// unnoticed. +// +// One honest caveat: that detector's mainWorldExecution test only fires when the +// page is asked for a specific selector, which the harness does not do. In +// production the js matchers do touch the main world on every page, so that +// particular vector is untested rather than cleared. + +// identity is the single browser identity presented to every scanned host: one +// User-Agent, and the Client Hints metadata derived from the same values. +// +// Splitting those two apart is what the previous approach got wrong. Chrome +// computes Sec-CH-UA from its own build and ignores the --user-agent flag, so +// hardcoding "Chrome/131" while running Chrome 150 made every request advertise +// two different versions — measured on Chrome 150 against a local echo server. +// A real browser derives both from one internal state, so we do too. +type identity struct { + UserAgent string + Major string + Full string + Platform string +} + +// resolveIdentity asks the running browser what it actually is, and turns that +// into an identity that does not admit to being headless. +// +// New headless still writes "HeadlessChrome" into the User-Agent, so the token +// has to be rewritten; everything else is left exactly as Chrome reported it. +func resolveIdentity(ctx context.Context) (identity, error) { + var product, ua string + err := chromedp.Run(ctx, chromedp.ActionFunc(func(ctx context.Context) error { + var e error + _, product, _, ua, _, e = browser.GetVersion().Do(ctx) + return e + })) + if err != nil { + return fallbackIdentity(), err + } + return identityFromUserAgent(ua, product), nil +} + +// identityFromUserAgent derives the presented identity from Chrome's own +// User-Agent and product strings ("HeadlessChrome/150.0.7871.188"). +func identityFromUserAgent(ua, product string) identity { + id := fallbackIdentity() + if ua == "" { + return id + } + // "HeadlessChrome/150.0..." -> "Chrome/150.0..."; a site must not be told + // the browser is headless. + ua = strings.ReplaceAll(ua, "HeadlessChrome/", "Chrome/") + id.UserAgent = ua + + if full := versionAfter(product, "Chrome/"); full != "" { + id.Full = full + } else if full := versionAfter(ua, "Chrome/"); full != "" { + id.Full = full + } + if major := chromeMajor(ua); major != "" { + id.Major = major + } + if strings.Contains(ua, "Windows") { + id.Platform = "Windows" + } else if strings.Contains(ua, "Mac OS X") { + id.Platform = "macOS" + } else if strings.Contains(ua, "Linux") || strings.Contains(ua, "X11") { + id.Platform = "Linux" + } + return id +} + +// fallbackIdentity is used only when the browser cannot be queried. It is a +// last resort, not a default: a hardcoded version is precisely the thing that +// contradicted the Client Hints. +func fallbackIdentity() identity { + ua := structure.DefaultUserAgent + major := chromeMajor(ua) + full := versionAfter(ua, "Chrome/") + if full == "" { + full = major + } + return identity{UserAgent: ua, Major: major, Full: full, Platform: "Windows"} +} + +// Viewport and the screen it is meant to sit on. Headless Chrome reports an +// 800x600 screen regardless of the window size, so a 1400x900 window appeared +// to be larger than the display containing it — impossible on real hardware, and +// the sort of internal inconsistency detectors look for. The screen is declared +// as a common desktop resolution that comfortably contains the viewport. +const ( + viewportWidth = 1400 + viewportHeight = 900 + screenWidth = 1920 + screenHeight = 1080 +) + +// The language both clients advertise. Chrome would otherwise use the host's +// locale (measured: fr-FR on this machine) while the raw probe hardcoded en-US, +// so the two contradicted each other on every host. Pinning it makes them agree +// and makes scans reproducible. +// +// Two spellings are needed: Chrome appends the quality values itself, so handing +// it the full header produced "en-US,en;q=0.9;q=0.9". +const ( + acceptLanguageChrome = "en-US,en" // Emulation.setUserAgentOverride + acceptLanguageHeader = "en-US,en;q=0.9" // the raw probe's own header +) + +// captureScreenshot grabs the viewport into buf when screenshots are enabled. +// +// It is deliberately non-fatal: technology detection is the product, a PNG is +// not, so a renderer that cannot produce one must not abort the action chain +// that carries every matcher. When screenshots are disabled it does nothing at +// all rather than encoding an image that is immediately discarded. +func captureScreenshot(enabled bool, buf *[]byte) chromedp.Action { + return chromedp.ActionFunc(func(ctx context.Context) error { + if !enabled { + return nil + } + if err := chromedp.CaptureScreenshot(buf).Do(ctx); err != nil { + fmt.Fprintf(os.Stderr, "screenshot: %v\n", err) + } + return nil + }) +} + +// applyIdentity installs the identity on a target: the User-Agent together with +// the Client Hints metadata, so navigator.userAgent, navigator.userAgentData and +// the Sec-CH-UA-* headers all agree; a viewport that fits inside a plausible +// screen; and a locale matching the Accept-Language the probe sends. +func applyIdentity(id identity) chromedp.Action { + return chromedp.ActionFunc(func(ctx context.Context) error { + if id.UserAgent != "" { + if err := emulation.SetUserAgentOverride(id.UserAgent). + WithUserAgentMetadata(id.metadata()). + WithAcceptLanguage(acceptLanguageChrome). + Do(ctx); err != nil { + return err + } + } + // Declare the screen the viewport lives on, so screen.width/height stop + // reporting the headless 800x600 default. + if err := emulation.SetDeviceMetricsOverride(viewportWidth, viewportHeight, 1, false). + WithScreenWidth(screenWidth). + WithScreenHeight(screenHeight). + WithPositionX(0). + WithPositionY(0). + Do(ctx); err != nil { + return err + } + // Browser.setWindowBounds was tried here to give + // window.outerWidth/outerHeight a non-zero value. The harness measured no + // effect — headless has no real window — and it made + // Page.captureScreenshot fail with -32000 on heavy pages, which took the + // whole detection pass down with it. Removed: it cost detections and + // bought nothing. The zero outer dimensions are a documented residual. + return nil + }) +} + +// metadata mirrors what a real Chrome of this version reports, including the +// deliberately-bogus "Not)A;Brand" entry Chrome uses to keep parsers honest. +func (id identity) metadata() *emulation.UserAgentMetadata { + brands := []*emulation.UserAgentBrandVersion{ + {Brand: "Not)A;Brand", Version: "99"}, + {Brand: "Chromium", Version: id.Major}, + {Brand: "Google Chrome", Version: id.Major}, + } + full := []*emulation.UserAgentBrandVersion{ + {Brand: "Not)A;Brand", Version: "99.0.0.0"}, + {Brand: "Chromium", Version: id.Full}, + {Brand: "Google Chrome", Version: id.Full}, + } + return &emulation.UserAgentMetadata{ + Brands: brands, + FullVersionList: full, + Platform: id.Platform, + PlatformVersion: platformVersion(id.Platform), + Architecture: "x86", + Bitness: "64", + Model: "", + Mobile: false, + } +} + +// platformVersion is the Sec-CH-UA-Platform-Version value. Chrome reports the +// OS version here; "15.0.0" is what Chrome on Windows 10/11 sends. +func platformVersion(platform string) string { + switch platform { + case "Windows": + return "15.0.0" + case "macOS": + return "14.0.0" + default: + return "" + } +} + +// versionAfter returns the dotted version that follows prefix in s. +func versionAfter(s, prefix string) string { + i := strings.Index(s, prefix) + if i < 0 { + return "" + } + rest := s[i+len(prefix):] + end := 0 + for end < len(rest) && (rest[end] == '.' || (rest[end] >= '0' && rest[end] <= '9')) { + end++ + } + out := strings.TrimSuffix(rest[:end], ".") + if out == "" { + return "" + } + // Reject something that is not a version at all. + if _, err := strconv.Atoi(strings.SplitN(out, ".", 2)[0]); err != nil { + return "" + } + return out +} diff --git a/cmd/browser_stealth_test.go b/cmd/browser_stealth_test.go new file mode 100644 index 0000000..bde507c --- /dev/null +++ b/cmd/browser_stealth_test.go @@ -0,0 +1,693 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "sort" + "strings" + "sync" + "testing" + "time" + + "github.com/EasyRecon/wappaGo/structure" + "github.com/chromedp/cdproto/runtime" + "github.com/chromedp/chromedp" +) + +// This harness measures what the browser we actually launch gives away. It is +// opt-in because it starts a real Chrome: +// +// WAPPAGO_STEALTH_CHECK=1 go test ./cmd/ -run Stealth -v +// +// The probe page is served locally, so the verdicts are deterministic and do +// not depend on a third-party detector staying up or unchanged. Cross-check +// against https://bot-detector.rebrowser.net/ by hand when touching the +// Runtime.enable path, which a local page cannot observe. + +func requireStealthCheck(t *testing.T) { + t.Helper() + if os.Getenv("WAPPAGO_STEALTH_CHECK") == "" { + t.Skip("set WAPPAGO_STEALTH_CHECK=1 to run the stealth harness (launches Chrome)") + } +} + +// stealthProbe is the page the browser is pointed at. Everything it reports is +// read from the page's own main world, i.e. exactly what a detection script +// running on a scanned site would see. +const stealthProbe = ` +probe + +` + +// probeResult mirrors window.__probe. +type probeResult struct { + Webdriver interface{} `json:"webdriver"` + HasWindowChrome bool `json:"hasWindowChrome"` + HasChromeRuntime bool `json:"hasChromeRuntime"` + UserAgent string `json:"userAgent"` + UAData *uaDataInfo `json:"uaData"` + Language string `json:"language"` + PluginCount int `json:"pluginCount"` + MimeTypeCount int `json:"mimeTypeCount"` + HardwareConcurrency int `json:"hardwareConcurrency"` + OuterWidth int `json:"outerWidth"` + OuterHeight int `json:"outerHeight"` + InnerWidth int `json:"innerWidth"` + InnerHeight int `json:"innerHeight"` + ScreenWidth int `json:"screenWidth"` + ScreenHeight int `json:"screenHeight"` + WebGL webglResult `json:"webgl"` + WebGLImageHash2 string `json:"webglImageHash2"` + CanvasHash string `json:"canvasHash"` + CanvasHash2 string `json:"canvasHash2"` + WebdriverIsNative *bool `json:"webdriverIsNative"` + + // Filled from window.__probeAudio, which resolves separately. + Audio audioPair `json:"-"` +} + +type audioPair struct { + First audioResult `json:"first"` + Second audioResult `json:"second"` +} + +type audioResult struct { + SampleRate float64 `json:"sampleRate"` + MaxChannelCount int `json:"maxChannelCount"` + Hash string `json:"hash"` + Error string `json:"error"` +} + +type uaDataInfo struct { + Brands []string `json:"brands"` + Mobile bool `json:"mobile"` + Platform string `json:"platform"` +} + +type webglResult struct { + Available bool `json:"available"` + Vendor string `json:"vendor"` + Renderer string `json:"renderer"` + GLVersion string `json:"glVersion"` + ShadingLanguage string `json:"shadingLanguage"` + ExtensionCount int `json:"extensionCount"` + HasDebugRendererInfo bool `json:"hasDebugRendererInfo"` + MaxTextureSize int `json:"maxTextureSize"` + MaxViewportDims string `json:"maxViewportDims"` + MaxRenderbufferSize int `json:"maxRenderbufferSize"` + Precision string `json:"precision"` + ImageHash string `json:"imageHash"` + Error string `json:"error"` +} + +// runProbe serves the probe page, drives the production browser configuration +// at it, and returns both the in-page findings and the headers the browser +// actually put on the wire. +func runProbe(t *testing.T) (probeResult, http.Header) { + t.Helper() + + var mu sync.Mutex + var got http.Header + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + if got == nil { + got = r.Header.Clone() + } + mu.Unlock() + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write([]byte(stealthProbe)) + })) + defer srv.Close() + + empty := "" + opts := structure.Options{Proxy: &empty} + allocOpts := chromeAllocatorOptions(opts, "") + + allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(), allocOpts...) + defer cancelAlloc() + browserCtx, cancelBrowser := chromedp.NewContext(allocCtx) + defer cancelBrowser() + + ctx, cancel := context.WithTimeout(browserCtx, 90*time.Second) + defer cancel() + + if err := chromedp.Run(ctx); err != nil { + t.Fatalf("could not start Chrome: %v", err) + } + + ident, err := resolveIdentity(ctx) + if err != nil { + t.Logf("resolveIdentity: %v", err) + } + + var raw []byte + err = chromedp.Run(ctx, + applyIdentity(ident), + chromedp.Navigate(srv.URL), + chromedp.ActionFunc(func(ctx context.Context) error { + return chromedp.Evaluate(`JSON.stringify(window.__probe)`, &raw).Do(ctx) + }), + ) + if err != nil { + t.Fatalf("probe run: %v", err) + } + + var unquoted string + if err := json.Unmarshal(raw, &unquoted); err != nil { + t.Fatalf("probe payload was not a JSON string: %v (%s)", err, raw) + } + var res probeResult + if err := json.Unmarshal([]byte(unquoted), &res); err != nil { + t.Fatalf("decode probe: %v (%s)", err, unquoted) + } + + // The audio surface is rendered asynchronously, so it is awaited separately. + // chromedp.Evaluate cannot await a promise, but runtime.Evaluate can. + if err := chromedp.Run(ctx, chromedp.ActionFunc(func(ctx context.Context) error { + out, exc, err := runtime.Evaluate(`window.__probeAudio.then(function(r){return JSON.stringify(r)})`). + WithAwaitPromise(true). + WithReturnByValue(true). + Do(ctx) + if err != nil { + return err + } + if exc != nil { + return fmt.Errorf("%s", exc.Text) + } + var s string + if err := json.Unmarshal(out.Value, &s); err != nil { + return err + } + return json.Unmarshal([]byte(s), &res.Audio) + })); err != nil { + t.Logf("audio probe: %v", err) + } + + mu.Lock() + defer mu.Unlock() + return res, got +} + +// TestStealthReport prints the verdict table. It is the measurement baseline: +// run it before and after any change to chromeAllocatorOptions. +func TestStealthReport(t *testing.T) { + requireStealthCheck(t) + res, hdr := runProbe(t) + + t.Log("=== in-page ===") + t.Logf(" navigator.webdriver %v", res.Webdriver) + t.Logf(" webdriver getter native %v", boolPtr(res.WebdriverIsNative)) + t.Logf(" window.chrome %v (runtime: %v)", res.HasWindowChrome, res.HasChromeRuntime) + t.Logf(" navigator.userAgent %s", res.UserAgent) + if res.UAData != nil { + t.Logf(" userAgentData.brands %s", strings.Join(res.UAData.Brands, ", ")) + t.Logf(" userAgentData.platform %q mobile=%v", res.UAData.Platform, res.UAData.Mobile) + } else { + t.Log(" userAgentData ") + } + t.Logf(" plugins/mimeTypes %d / %d", res.PluginCount, res.MimeTypeCount) + t.Logf(" hardwareConcurrency %d", res.HardwareConcurrency) + t.Logf(" window outer/inner %dx%d / %dx%d", res.OuterWidth, res.OuterHeight, res.InnerWidth, res.InnerHeight) + t.Logf(" screen %dx%d", res.ScreenWidth, res.ScreenHeight) + t.Log("=== fingerprint surfaces ===") + t.Logf(" canvas 2D hash %s (2nd read: %s)", res.CanvasHash, res.CanvasHash2) + t.Logf(" webgl image hash %s (2nd read: %s)", res.WebGL.ImageHash, res.WebGLImageHash2) + t.Logf(" audio sampleRate %.0f, maxChannelCount %d", res.Audio.First.SampleRate, res.Audio.First.MaxChannelCount) + t.Logf(" audio hash %s (2nd read: %s) %s", res.Audio.First.Hash, res.Audio.Second.Hash, res.Audio.First.Error) + if res.WebGL.Available { + t.Logf(" webgl version %s / %s", res.WebGL.GLVersion, res.WebGL.ShadingLanguage) + t.Logf(" webgl extensions %d (debug_renderer_info: %v)", res.WebGL.ExtensionCount, res.WebGL.HasDebugRendererInfo) + t.Logf(" webgl limits maxTexture=%d maxViewport=%s maxRenderbuffer=%d", + res.WebGL.MaxTextureSize, res.WebGL.MaxViewportDims, res.WebGL.MaxRenderbufferSize) + t.Logf(" webgl precision %s", res.WebGL.Precision) + } + + if res.WebGL.Available { + t.Logf(" webgl %s / %s", res.WebGL.Vendor, res.WebGL.Renderer) + } else { + t.Logf(" webgl UNAVAILABLE %s", res.WebGL.Error) + } + + // Known residual, reported rather than hidden: headless Chrome has no real + // window, so window.outerWidth/outerHeight are 0 and neither + // Emulation.setDeviceMetricsOverride nor Browser.setWindowBounds changes + // that. The only known workaround is redefining the accessor from an + // injected script, which leaves a getter that no longer reports + // [native code] — trading a narrow leak for a broader one. Left alone + // deliberately; revisit only with a measurement showing it matters. + if res.OuterWidth == 0 || res.OuterHeight == 0 { + t.Logf(" RESIDUAL: window.outerWidth/outerHeight are 0 (inherent to headless, not patched on purpose)") + } + + t.Log("=== on the wire ===") + var keys []string + for k := range hdr { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + if strings.HasPrefix(strings.ToLower(k), "sec-") || strings.EqualFold(k, "user-agent") || + strings.EqualFold(k, "accept-language") || strings.EqualFold(k, "accept") { + t.Logf(" %-24s %s", k, hdr.Get(k)) + } + } +} + +// TestStealthNoHeadlessToken pins that the identity presented to a site never +// admits to being headless. New headless still writes "HeadlessChrome" into the +// UA, so this only passes because resolveIdentity rewrites it. +func TestStealthNoHeadlessToken(t *testing.T) { + requireStealthCheck(t) + res, hdr := runProbe(t) + + for label, value := range map[string]string{ + "navigator.userAgent": res.UserAgent, + "User-Agent on the wire": hdr.Get("User-Agent"), + } { + if strings.Contains(value, "Headless") { + t.Errorf("%s admits to being headless: %s", label, value) + } + } +} + +// TestStealthUserAgentMatchesClientHints is the regression guard for the leak +// this work started from: the UA claimed Chrome/131 while Sec-CH-UA, which +// Chrome derives from its own build, said 150. A real browser reports one +// version from a single internal state. +func TestStealthUserAgentMatchesClientHints(t *testing.T) { + requireStealthCheck(t) + res, hdr := runProbe(t) + + uaMajor := chromeMajor(hdr.Get("User-Agent")) + if uaMajor == "" { + t.Fatalf("no Chrome version in the User-Agent header: %q", hdr.Get("User-Agent")) + } + + chMajor := majorFromSecChUa(hdr.Get("Sec-Ch-Ua")) + if chMajor == "" { + // A real Chrome always sends Sec-CH-UA over http on localhost too; + // missing headers are themselves a signal. + t.Fatalf("Sec-CH-UA absent (%q) — a real Chrome always sends it", hdr.Get("Sec-Ch-Ua")) + } + if uaMajor != chMajor { + t.Errorf("version mismatch: User-Agent says %s, Sec-CH-UA says %s (headers: UA=%q CH=%q)", + uaMajor, chMajor, hdr.Get("User-Agent"), hdr.Get("Sec-Ch-Ua")) + } + + if navMajor := chromeMajor(res.UserAgent); navMajor != uaMajor { + t.Errorf("navigator.userAgent says %s but the header says %s", navMajor, uaMajor) + } + if res.UAData != nil { + found := false + for _, b := range res.UAData.Brands { + if strings.HasSuffix(b, "/"+uaMajor) { + found = true + } + } + if !found { + t.Errorf("navigator.userAgentData.brands %v carries no %s entry", res.UAData.Brands, uaMajor) + } + } +} + +// TestStealthWebGLAvailable pins the removal of --disable-webgl: a missing WebGL +// context is a strong bot signal, so software rendering must still provide one. +func TestStealthWebGLAvailable(t *testing.T) { + requireStealthCheck(t) + res, _ := runProbe(t) + + if !res.WebGL.Available { + t.Fatalf("no WebGL context: %s", res.WebGL.Error) + } + if res.WebGL.Vendor == "" || res.WebGL.Renderer == "" { + t.Errorf("empty WebGL vendor/renderer: %q / %q", res.WebGL.Vendor, res.WebGL.Renderer) + } +} + +// TestStealthRemoteDetector runs the shipping configuration against +// rebrowser's hosted detector, which checks things a local page cannot observe — +// above all the Runtime.enable CDP leak. +// +// Separate opt-in from the rest of the harness because it needs the network and +// depends on a third party staying up: +// +// WAPPAGO_STEALTH_REMOTE=1 go test ./cmd/ -run RemoteDetector -v +// +// Measured 2026-07-30 against Chrome 150: every test green, runtimeEnableLeak +// included. That is why chromedp is not patched — see the note below. +func TestStealthRemoteDetector(t *testing.T) { + if os.Getenv("WAPPAGO_STEALTH_REMOTE") == "" { + t.Skip("set WAPPAGO_STEALTH_REMOTE=1 to run the hosted detector (needs network)") + } + + empty := "" + allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(), + chromeAllocatorOptions(structure.Options{Proxy: &empty}, "")...) + defer cancelAlloc() + browserCtx, cancelBrowser := chromedp.NewContext(allocCtx) + defer cancelBrowser() + ctx, cancel := context.WithTimeout(browserCtx, 120*time.Second) + defer cancel() + + if err := chromedp.Run(ctx); err != nil { + t.Fatalf("could not start Chrome: %v", err) + } + ident, err := resolveIdentity(ctx) + if err != nil { + t.Logf("resolveIdentity: %v", err) + } + + var raw []byte + if err := chromedp.Run(ctx, + applyIdentity(ident), + chromedp.Navigate("https://bot-detector.rebrowser.net/"), + // The page runs its checks on load; the last one is a network call. + chromedp.Sleep(8*time.Second), + chromedp.Evaluate(`document.body.innerText`, &raw), + ); err != nil { + t.Fatalf("detector run: %v", err) + } + + var text string + if err := json.Unmarshal(raw, &text); err != nil { + t.Fatalf("decode page text: %v", err) + } + + // The page marks each verdict with an emoji: green passed, red failed, white + // means the test was not triggered (which is not a failure). + var failed []string + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + switch { + case strings.HasPrefix(line, "🔴"), strings.HasPrefix(line, "❌"): + failed = append(failed, line) + t.Logf("FAIL %s", line) + case strings.HasPrefix(line, "🟢"), strings.HasPrefix(line, "⚪️"): + t.Logf(" %s", line) + } + } + if len(failed) > 0 { + t.Errorf("%d detector test(s) flagged the browser:\n%s", len(failed), strings.Join(failed, "\n")) + } +} + +// TestStealthFingerprintsAreStable is the counter-intuitive one, and the reason +// wappaGo does not copy Camoufox here. +// +// Camoufox randomises canvas, audio and WebGL because it wants a *different* +// identity per session, to defeat tracking. wappaGo's goal is the opposite: not +// to look unique, but to look ordinary. A fingerprint that changes between two +// reads in the same page is itself a well-known marker of an anti-fingerprinting +// tool — CreepJS reports canvas noise explicitly. A real Chrome returns the same +// bytes every time, so these surfaces must be present and stable, and are +// deliberately left unspoofed. +func TestStealthFingerprintsAreStable(t *testing.T) { + requireStealthCheck(t) + res, _ := runProbe(t) + + if res.CanvasHash == "" || strings.HasPrefix(res.CanvasHash, "error:") { + t.Fatalf("no canvas 2D fingerprint: %q", res.CanvasHash) + } + if res.CanvasHash != res.CanvasHash2 { + t.Errorf("canvas fingerprint changed between two reads (%s vs %s): looks like noise injection", + res.CanvasHash, res.CanvasHash2) + } + + if res.WebGL.Available { + if res.WebGL.ImageHash == "" { + t.Error("WebGL context exists but rendered no readable image") + } + if res.WebGL.ImageHash != res.WebGLImageHash2 { + t.Errorf("WebGL image fingerprint changed between two reads (%s vs %s)", + res.WebGL.ImageHash, res.WebGLImageHash2) + } + } + + if res.Audio.First.Error != "" { + t.Fatalf("no audio fingerprint: %s", res.Audio.First.Error) + } + if res.Audio.First.SampleRate != 44100 { + t.Errorf("OfflineAudioContext sampleRate = %.0f, want the requested 44100", res.Audio.First.SampleRate) + } + if res.Audio.First.MaxChannelCount == 0 { + t.Error("destination.maxChannelCount is 0: no audio output device is being reported") + } + if res.Audio.First.Hash == "" { + t.Error("audio rendered no samples") + } + if res.Audio.First.Hash != res.Audio.Second.Hash { + t.Errorf("audio fingerprint changed between two reads (%s vs %s): looks like noise injection", + res.Audio.First.Hash, res.Audio.Second.Hash) + } +} + +// TestStealthWebGLSurfaceIsCoherent checks the rest of the WebGL surface, which +// detectors cross-reference against the renderer string. A real adapter reports +// plausible limits and a full extension list; a stub or a blocked context does +// not. +func TestStealthWebGLSurfaceIsCoherent(t *testing.T) { + requireStealthCheck(t) + res, _ := runProbe(t) + + if !res.WebGL.Available { + t.Fatalf("no WebGL context: %s", res.WebGL.Error) + } + if !res.WebGL.HasDebugRendererInfo { + t.Error("WEBGL_debug_renderer_info is missing: a real Chrome exposes it, and its absence is itself unusual") + } + // Every desktop GL implementation Chrome ships with clears these easily; the + // point is to catch a stubbed or software-blocked context, not to pin exact + // hardware values. + if res.WebGL.MaxTextureSize < 4096 { + t.Errorf("MAX_TEXTURE_SIZE = %d, implausibly low for a desktop adapter", res.WebGL.MaxTextureSize) + } + if res.WebGL.MaxRenderbufferSize < 4096 { + t.Errorf("MAX_RENDERBUFFER_SIZE = %d, implausibly low", res.WebGL.MaxRenderbufferSize) + } + if res.WebGL.ExtensionCount < 20 { + t.Errorf("only %d WebGL extensions: a real desktop adapter exposes far more", res.WebGL.ExtensionCount) + } + if res.WebGL.MaxViewportDims == "" || res.WebGL.MaxViewportDims == "0x0" { + t.Errorf("MAX_VIEWPORT_DIMS = %q", res.WebGL.MaxViewportDims) + } + if res.WebGL.GLVersion == "" || res.WebGL.ShadingLanguage == "" { + t.Errorf("empty GL version strings: %q / %q", res.WebGL.GLVersion, res.WebGL.ShadingLanguage) + } + + // Software rendering is a legitimate fallback on a GPU-less host, but it is + // visible in the renderer string, so surface it rather than let it pass + // silently as if the fingerprint were as good as a real adapter's. + lower := strings.ToLower(res.WebGL.Renderer) + for _, marker := range []string{"swiftshader", "basic render driver", "llvmpipe"} { + if strings.Contains(lower, marker) { + t.Logf("NOTE: software renderer in use (%q). Plausible on a GPU-less host, but weaker than a real adapter.", res.WebGL.Renderer) + } + } +} + +// TestStealthNavigatorWebdriver pins that navigator.webdriver stays hidden and, +// just as importantly, that hiding it did not leave a non-native accessor +// behind — the exact tell Camoufox's documentation calls out. +func TestStealthNavigatorWebdriver(t *testing.T) { + requireStealthCheck(t) + res, _ := runProbe(t) + + if res.Webdriver == true { + t.Error("navigator.webdriver is true") + } + if res.WebdriverIsNative != nil && !*res.WebdriverIsNative { + t.Error("the navigator.webdriver accessor no longer reports [native code]") + } +} + +// majorFromSecChUa pulls the Google Chrome / Chromium major version out of a +// Sec-CH-UA header value, ignoring the deliberately-bogus "Not;A=Brand" entry. +func majorFromSecChUa(value string) string { + for _, part := range strings.Split(value, ",") { + part = strings.TrimSpace(part) + brand, version, ok := strings.Cut(part, ";v=") + if !ok { + continue + } + brand = strings.Trim(brand, `"`) + if strings.Contains(strings.ToLower(brand), "not") { + continue + } + return strings.Trim(version, `"`) + } + return "" +} + +func boolPtr(b *bool) string { + if b == nil { + return "n/a" + } + return fmt.Sprint(*b) +} diff --git a/cmd/root.go b/cmd/root.go index 77deea0..7ff730c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -52,6 +52,9 @@ type Cmd struct { reportMu sync.Mutex // guards ResultArray (report mode) throttle *hostThrottle Input []string + // identity is the browser identity presented to every scanned host, read + // from the real Chrome build in Start before any worker starts. + identity identity // sigCtx is cancelled on SIGINT/SIGTERM. It gates the host loop and the // port scanner so Ctrl+C actually stops the scan; previously it only // parented the Chrome allocator, so the first Ctrl+C killed the browser and @@ -93,21 +96,12 @@ func (c *Cmd) Start(results chan structure.Data) { // -rps or -jitter is set). c.throttle = newHostThrottle(*c.Options.Rps, *c.Options.Jitter) - optionsChromeCtx := []chromedp.ExecAllocatorOption{} - optionsChromeCtx = append(optionsChromeCtx, chromedp.DefaultExecAllocatorOptions[:]...) - optionsChromeCtx = append(optionsChromeCtx, chromedp.Flag("headless", true)) - optionsChromeCtx = append(optionsChromeCtx, chromedp.Flag("disable-popup-blocking", true)) - optionsChromeCtx = append(optionsChromeCtx, chromedp.DisableGPU) - optionsChromeCtx = append(optionsChromeCtx, chromedp.Flag("disable-webgl", true)) - optionsChromeCtx = append(optionsChromeCtx, chromedp.Flag("ignore-certificate-errors", true)) // RIP shittyproxy.go - optionsChromeCtx = append(optionsChromeCtx, chromedp.WindowSize(1400, 900)) - // Present a real browser UA (drops the "HeadlessChrome" token) and hide the - // automation flag, so Chrome blends in with normal traffic. - optionsChromeCtx = append(optionsChromeCtx, chromedp.UserAgent(c.userAgent())) - optionsChromeCtx = append(optionsChromeCtx, chromedp.Flag("disable-blink-features", "AutomationControlled")) - if *c.Options.Proxy != "" { - optionsChromeCtx = append(optionsChromeCtx, chromedp.ProxyServer(*c.Options.Proxy)) - } + // The User-Agent is left to Chrome at launch: overriding it here with a + // hardcoded version contradicted the Sec-CH-UA headers Chrome derives from + // its own build (measured: UA said Chrome/131 while Sec-CH-UA said 150). + // resolveIdentity below reads the real build and installs a consistent + // identity per target instead. + optionsChromeCtx := chromeAllocatorOptions(c.Options, "") // Tie the browser lifetime to SIGINT/SIGTERM: the first Ctrl+C cancels this // context, which tears Chrome down and lets the deferred cleanup (browser @@ -146,6 +140,10 @@ func (c *Cmd) Start(results chan structure.Data) { c.ChromeCtx = ctxAlloc1 defer cancel() + // Read the real browser build once, up front, and present that same + // identity everywhere: the Chrome targets (User-Agent + Client Hints) and + // the raw HTTP probe's headers. c.identity is written here, before any + // worker exists, and only read afterwards. if err := chromedp.Run(c.ChromeCtx); err != nil { // Startup failure (including a cancel during launch) is reported and the // results channel is closed so the consumer drains and exits cleanly, @@ -155,6 +153,19 @@ func (c *Cmd) Start(results chan structure.Data) { return } + if id, err := resolveIdentity(c.ChromeCtx); err != nil { + fmt.Fprintf(os.Stderr, "could not read the browser build, using the fallback identity: %v\n", err) + c.identity = id + } else { + c.identity = id + } + // An explicit -user-agent still wins, but the Client Hints metadata keeps + // following the real build, so the two cannot contradict each other on the + // version the way a hardcoded UA did. + if c.Options.UserAgent != nil && *c.Options.UserAgent != "" { + c.identity.UserAgent = *c.Options.UserAgent + } + c.Cdn = cdncheck.New() var url string var ip string @@ -410,10 +421,17 @@ func (c *Cmd) launchChrome(TempResp structure.Response, data structure.Data, url c.throttle.wait(data.Infos.Data) err = chromedp.Run(cloneCTX, + // Install the User-Agent and its matching Client Hints before the first + // navigation, so the very first request carries a consistent identity. + applyIdentity(c.identity), chromedp.Navigate(urlData), chromedp.Title(&data.Infos.Title), - //chromedp.FullScreenshot(&buf, 100), - chromedp.CaptureScreenshot(&buf), + // The screenshot is optional and must never be able to cost a detection. + // It used to sit here unconditionally, so on a page where + // Page.captureScreenshot failed (-32000) the action chain aborted and the + // host was reported with zero technologies — and it ran even without + // -screenshot, paying for a full PNG encode per page just to discard it. + captureScreenshot(*c.Options.Screenshot != "", &buf), chromedp.ActionFunc(func(ctx context.Context) error { cookiesList, _ := network.GetCookies().Do(ctx) @@ -554,9 +572,14 @@ func (c *Cmd) scanPort(protocol, hostname string, port string, portTimeout int) return true } -// userAgent returns the configured User-Agent, falling back to the built-in -// browser UA when none is set (e.g. library mode with an empty option). +// userAgent returns the User-Agent presented to scanned hosts. Once Start has +// read the real browser build this is that build's UA; before then (or if the +// browser could not be queried) it is the explicit -user-agent, then the +// built-in fallback. func (c *Cmd) userAgent() string { + if c.identity.UserAgent != "" { + return c.identity.UserAgent + } if c.Options.UserAgent != nil && *c.Options.UserAgent != "" { return *c.Options.UserAgent } @@ -570,8 +593,11 @@ func (c *Cmd) userAgent() string { func setBrowserHeaders(req *http.Request, ua string) { h := req.Header h.Set("User-Agent", ua) - h.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8") - h.Set("Accept-Language", "en-US,en;q=0.9") + // Byte-for-byte what Chrome 1xx sends on a document navigation. The probe + // used to omit the signed-exchange entry and to hardcode a language Chrome + // itself did not use, so the two clients disagreed on every host. + h.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7") + h.Set("Accept-Language", acceptLanguageHeader) h.Set("Upgrade-Insecure-Requests", "1") h.Set("Sec-Fetch-Dest", "document") h.Set("Sec-Fetch-Mode", "navigate") From bf5f7df85d370836bb26bf49af17bc6db4472b15 Mon Sep 17 00:00:00 2001 From: Serizao Date: Thu, 30 Jul 2026 12:38:49 +0200 Subject: [PATCH 19/22] feat(stealth): browser TLS and HTTP/2 fingerprint on the raw probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit gave the browser a coherent identity, but the raw HTTP probe is what a scanned host actually sees first: it runs before Chrome on every target, twice or three times per host. Its handshake was Go's, and no User-Agent can cover that up — JA3/JA4 key on the ClientHello, which is decided before a single header is sent. Measured against a local server, the difference is not subtle: cipher suites first suite ALPN offered crypto/tls 13 0xc02b (none) uTLS Chrome 16 0x5a5a (GREASE) h2, http/1.1 Go was not even offering ALPN, so nothing could negotiate HTTP/2 in the first place. stealthTransport dials https through uTLS with HelloChrome_Auto, then speaks whatever ALPN settled on: h2 goes to x/net/http2 so the SETTINGS/WINDOW_UPDATE fingerprint is a real h2 client's, rather than silently downgrading to HTTP/1.1 and moving the inconsistency instead of removing it. HelloChrome_Auto rather than a pinned profile, since a stale Chrome hello would itself contradict the User-Agent read from the installed browser. Dispatch happens per connection, not per host: ALPN is only known once the handshake completes, and probing a host first to learn its protocol would double the handshake count, working against the pacing -rps already does. The probe runs with DisableKeepAlives, so a connection serves exactly one request anyway and the per-request transports cost an allocation, not a round trip. Two paths deliberately keep the standard transport: plain http, which has no handshake to disguise, and -proxy, where the origin handshake happens inside a CONNECT tunnel we do not drive. The TLS 1.2 MaxVersion pin on the proxy path is dropped while here — capping the tunnel hid every TLS-1.3-only origin behind a probe failure. MinVersion stays. Detection over the 20-target reference set: 148 technologies against 150, no host at zero, no probe errors — within the run-to-run band measured at 149/150/150. gofmt -l . clean, go vet ./... clean, go test -race ./... green --- README.md | 23 ++++++ cmd/root.go | 26 +++---- cmd/transport.go | 126 +++++++++++++++++++++++++++++++ cmd/transport_test.go | 169 ++++++++++++++++++++++++++++++++++++++++++ go.mod | 18 +++-- go.sum | 34 +++++---- 6 files changed, 362 insertions(+), 34 deletions(-) create mode 100644 cmd/transport.go create mode 100644 cmd/transport_test.go diff --git a/README.md b/README.md index 61cb4a4..83f637c 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,29 @@ goal is to look ordinary, not unique: a fingerprint that changes between two reads is itself a signature of an anti-fingerprinting tool. The harness asserts those surfaces are present and stable instead. +The raw HTTP probe also handshakes like a browser. It runs before Chrome on every +target, so its TLS ClientHello is the first thing a host sees, and Go's +`crypto/tls` hello does not resemble one: different cipher list and ordering, and +no ALPN at all — which is what JA3/JA4 hashes key on. The probe now dials https +through [uTLS](https://github.com/refraction-networking/utls) with a Chrome +ClientHello, and carries the request over HTTP/2 when ALPN settles on it, so the +HTTP/2 `SETTINGS`/`WINDOW_UPDATE` fingerprint is a real h2 client's rather than a +downgrade to HTTP/1.1. Measured against a local server: + +| | cipher suites | first suite | ALPN offered | +|---|---|---|---| +| `crypto/tls` | 13 | `0xc02b` | *(none)* | +| uTLS Chrome | 16 | `0x5a5a` (GREASE) | `h2`, `http/1.1` | + +Two cases keep the standard transport on purpose: plain `http`, which has no +handshake to disguise, and `-proxy`, where the origin handshake happens inside a +`CONNECT` tunnel wappaGo does not drive. + +Still outstanding: `chromedp` issues `Runtime.enable` on every target, which is +a reliable CDP detection signal. The usual fix routes evaluation through an +isolated world, but wappaGo's `js` fingerprints read page globals +(`jQuery.fn.jquery`, `Stimulus.Application`) and so require the main world. + You can either use wappaGo from a file containing a list of domains ```bash cat domain.txt | ./wappaGo diff --git a/cmd/root.go b/cmd/root.go index 7ff730c..063c03f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -715,24 +715,24 @@ func (c *Cmd) InitDialer() (*fastdialer.Dialer, error) { // transport) is never mutated afterwards, so it is safe to share across all // target/port goroutines. func (c *Cmd) buildHTTPClient() *http.Client { - transport := &http.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, - }, - DialContext: c.Dialer.Dial, - DisableKeepAlives: true, - } + // A proxy tunnels the origin handshake through CONNECT, so the ClientHello + // is not ours to shape; that path keeps the standard transport. The + // MaxVersion pin that used to be here as well has been dropped: capping the + // tunnel at TLS 1.2 hid every TLS-1.3-only origin behind a probe failure. + var proxyTransport *http.Transport if *c.Options.Proxy != "" { - proxyURL, parseErr := URL.Parse(*c.Options.Proxy) - if parseErr == nil { - transport.Proxy = http.ProxyURL(proxyURL) - transport.TLSClientConfig.MinVersion = tls.VersionTLS12 - transport.TLSClientConfig.MaxVersion = tls.VersionTLS12 + if proxyURL, parseErr := URL.Parse(*c.Options.Proxy); parseErr == nil { + proxyTransport = &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12}, + DialContext: c.Dialer.Dial, + DisableKeepAlives: true, + Proxy: http.ProxyURL(proxyURL), + } } } client := &http.Client{ Timeout: 10 * time.Second, - Transport: transport, + Transport: newHTTPTransport(c.Dialer.Dial, proxyTransport), } if !*c.Options.FollowRedirect { client.CheckRedirect = func(req *http.Request, via []*http.Request) error { diff --git a/cmd/transport.go b/cmd/transport.go new file mode 100644 index 0000000..ffcf2bf --- /dev/null +++ b/cmd/transport.go @@ -0,0 +1,126 @@ +package cmd + +import ( + "context" + "fmt" + "net" + "net/http" + "strings" + + utls "github.com/refraction-networking/utls" + "golang.org/x/net/http2" +) + +// The raw HTTP probe is the first thing a scanned host sees — it runs before +// Chrome on every target — and Go's crypto/tls ClientHello does not resemble a +// browser's at all. Cipher order, extension order, supported groups and +// signature algorithms all differ, which is what JA3/JA4 hashes capture. Sending +// a perfect Chrome User-Agent over a Go TLS handshake advertises the tool on the +// very first packet, before a single header is read. +// +// stealthTransport dials with a Chrome ClientHello via uTLS and then speaks +// whichever protocol ALPN settled on, so the HTTP/2 SETTINGS/WINDOW_UPDATE +// fingerprint comes from x/net/http2 rather than from a downgrade to HTTP/1.1. +// +// chromeHello tracks the newest profile the uTLS build knows about, which is the +// right default: pinning an old Chrome would itself be a mismatch with the +// User-Agent resolved from the installed browser. +var chromeHello = utls.HelloChrome_Auto + +// stealthTransport is a RoundTripper that gives every request a browser-shaped +// TLS handshake. +// +// It dispatches per connection rather than per host because ALPN is only known +// once the handshake completes: probing a host first to learn its protocol would +// double the handshake count, which works against the pacing wappaGo already +// does to stay under rate limits. DisableKeepAlives is on for the probe anyway, +// so a connection serves exactly one request and the one-shot transports below +// cost an allocation, not a round trip. +type stealthTransport struct { + dial func(ctx context.Context, network, addr string) (net.Conn, error) + skipVerify bool +} + +func (t *stealthTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if req.URL.Scheme != "https" { + return nil, fmt.Errorf("stealthTransport handles https only, got %q", req.URL.Scheme) + } + + addr := req.URL.Host + if req.URL.Port() == "" { + addr = net.JoinHostPort(req.URL.Hostname(), "443") + } + + raw, err := t.dial(req.Context(), "tcp", addr) + if err != nil { + return nil, err + } + + conn := utls.UClient(raw, &utls.Config{ + ServerName: req.URL.Hostname(), + InsecureSkipVerify: t.skipVerify, + }, chromeHello) + if err := conn.HandshakeContext(req.Context()); err != nil { + raw.Close() + return nil, err + } + + if conn.ConnectionState().NegotiatedProtocol == http2.NextProtoTLS { + cc, err := (&http2.Transport{}).NewClientConn(conn) + if err != nil { + conn.Close() + return nil, err + } + return cc.RoundTrip(req) + } + + // HTTP/1.1 over the connection just established. Going through + // http.Transport rather than writing the request by hand keeps the + // behaviour the probe relies on — notably Go's transparent gzip, which + // Do() depends on and which the response-reading path in root.go handles + // the failure mode of. + h1 := &http.Transport{ + DisableKeepAlives: true, + DialTLSContext: func(context.Context, string, string) (net.Conn, error) { + return conn, nil + }, + } + resp, err := h1.RoundTrip(req) + if err != nil { + conn.Close() + } + return resp, err +} + +// newHTTPTransport returns the transport the probe should use. +// +// Plain HTTP has no handshake to disguise, and a proxy means the origin +// handshake happens inside a CONNECT tunnel we do not drive, so both fall back +// to the standard transport. Only direct https gets the browser ClientHello. +func newHTTPTransport(dial func(ctx context.Context, network, addr string) (net.Conn, error), proxy *http.Transport) http.RoundTripper { + if proxy != nil { + return proxy + } + plain := &http.Transport{ + DialContext: dial, + DisableKeepAlives: true, + } + return &schemeRouter{ + http: plain, + https: &stealthTransport{dial: dial, skipVerify: true}, + } +} + +// schemeRouter sends https through the uTLS path and everything else through the +// standard transport. +type schemeRouter struct { + http http.RoundTripper + https http.RoundTripper +} + +func (r *schemeRouter) RoundTrip(req *http.Request) (*http.Response, error) { + if strings.EqualFold(req.URL.Scheme, "https") { + return r.https.RoundTrip(req) + } + return r.http.RoundTrip(req) +} diff --git a/cmd/transport_test.go b/cmd/transport_test.go new file mode 100644 index 0000000..568df5a --- /dev/null +++ b/cmd/transport_test.go @@ -0,0 +1,169 @@ +package cmd + +import ( + "context" + "crypto/tls" + "net" + "net/http" + "net/http/httptest" + "testing" +) + +// dialer used by the tests: a plain TCP dial, standing in for fastdialer. +func plainDial(ctx context.Context, network, addr string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, network, addr) +} + +// TestStealthTransportSendsChromeClientHello is the point of the whole file: the +// probe's TLS handshake must look like a browser's, not like Go's. +// +// It captures the ClientHello server-side and compares the cipher suite list +// against what crypto/tls sends. The two differ in count and in leading cipher, +// which is exactly what a JA3/JA4 hash keys on. +func TestStealthTransportSendsChromeClientHello(t *testing.T) { + var got *tls.ClientHelloInfo + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + })) + srv.TLS = &tls.Config{ + GetConfigForClient: func(chi *tls.ClientHelloInfo) (*tls.Config, error) { + got = chi + return nil, nil + }, + } + srv.StartTLS() + defer srv.Close() + + client := &http.Client{Transport: &stealthTransport{dial: plainDial, skipVerify: true}} + resp, err := client.Get(srv.URL) + if err != nil { + t.Fatalf("request through the stealth transport failed: %v", err) + } + resp.Body.Close() + + if got == nil { + t.Fatal("the server never saw a ClientHello") + } + chrome := got + + // Now the same request through crypto/tls, for comparison. + got = nil + std := &http.Client{Transport: &http.Transport{ + DialContext: plainDial, + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + DisableKeepAlives: true, + }} + resp2, err := std.Get(srv.URL) + if err != nil { + t.Fatalf("baseline request failed: %v", err) + } + resp2.Body.Close() + if got == nil { + t.Fatal("the server never saw the baseline ClientHello") + } + goHello := got + + t.Logf("uTLS : %d cipher suites, first 0x%04x, ALPN %v", + len(chrome.CipherSuites), chrome.CipherSuites[0], chrome.SupportedProtos) + t.Logf("crypto/tls: %d cipher suites, first 0x%04x, ALPN %v", + len(goHello.CipherSuites), goHello.CipherSuites[0], goHello.SupportedProtos) + + if len(chrome.CipherSuites) == len(goHello.CipherSuites) && + chrome.CipherSuites[0] == goHello.CipherSuites[0] { + t.Error("the ClientHello is indistinguishable from Go's: uTLS is not being applied") + } + + // A Chrome hello always offers h2 before http/1.1; a JA4 hash includes ALPN, + // so a handshake that only offers http/1.1 would contradict the Chrome + // User-Agent the probe sends. + if len(chrome.SupportedProtos) == 0 || chrome.SupportedProtos[0] != "h2" { + t.Errorf("ALPN = %v, want h2 first as Chrome sends", chrome.SupportedProtos) + } +} + +// TestStealthTransportSpeaksHTTP2 pins the second half of the fix: once ALPN has +// settled on h2 the request must actually be carried over HTTP/2, so the +// SETTINGS/WINDOW_UPDATE fingerprint is a real h2 client's. Falling back to +// HTTP/1.1 here would move the inconsistency rather than remove it. +func TestStealthTransportSpeaksHTTP2(t *testing.T) { + var proto string + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + proto = r.Proto + w.Write([]byte("ok")) + })) + srv.EnableHTTP2 = true + srv.StartTLS() + defer srv.Close() + + client := &http.Client{Transport: &stealthTransport{dial: plainDial, skipVerify: true}} + resp, err := client.Get(srv.URL) + if err != nil { + t.Fatalf("request failed: %v", err) + } + resp.Body.Close() + + if proto != "HTTP/2.0" { + t.Errorf("server saw %s, want HTTP/2.0", proto) + } + if resp.ProtoMajor != 2 { + t.Errorf("client reports HTTP/%d.%d, want 2.x", resp.ProtoMajor, resp.ProtoMinor) + } +} + +// TestStealthTransportFallsBackToHTTP11 covers the other ALPN outcome: a server +// that does not offer h2 must still be scanned. +func TestStealthTransportFallsBackToHTTP11(t *testing.T) { + var proto string + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + proto = r.Proto + w.Write([]byte("ok")) + })) + srv.StartTLS() // no EnableHTTP2 + defer srv.Close() + + client := &http.Client{Transport: &stealthTransport{dial: plainDial, skipVerify: true}} + resp, err := client.Get(srv.URL) + if err != nil { + t.Fatalf("request failed: %v", err) + } + resp.Body.Close() + + if proto != "HTTP/1.1" { + t.Errorf("server saw %s, want HTTP/1.1", proto) + } +} + +// TestSchemeRouterKeepsPlainHTTPOnTheStandardTransport: plain http has no +// handshake to disguise, and routing it through the TLS path would break it. +func TestSchemeRouterKeepsPlainHTTPOnTheStandardTransport(t *testing.T) { + var proto string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + proto = r.Proto + w.Write([]byte("ok")) + })) + defer srv.Close() + + client := &http.Client{Transport: newHTTPTransport(plainDial, nil)} + resp, err := client.Get(srv.URL) + if err != nil { + t.Fatalf("plain http through the router failed: %v", err) + } + resp.Body.Close() + if proto != "HTTP/1.1" { + t.Errorf("server saw %s", proto) + } +} + +// TestNewHTTPTransportHonoursProxy: with a proxy the origin handshake happens +// inside a CONNECT tunnel we do not drive, so the standard transport is used and +// uTLS is deliberately out of the picture. +func TestNewHTTPTransportHonoursProxy(t *testing.T) { + proxy := &http.Transport{DisableKeepAlives: true} + if got := newHTTPTransport(plainDial, proxy); got != http.RoundTripper(proxy) { + t.Errorf("proxy transport was not used: got %T", got) + } + if _, ok := newHTTPTransport(plainDial, nil).(*schemeRouter); !ok { + t.Error("without a proxy the scheme router should be used") + } +} diff --git a/go.mod b/go.mod index b131678..c3312f2 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/EasyRecon/wappaGo -go 1.23 +go 1.24 require ( github.com/PuerkitoBio/goquery v1.8.1 @@ -11,12 +11,15 @@ require ( github.com/projectdiscovery/cdncheck v1.0.2 github.com/projectdiscovery/fastdialer v0.0.16 github.com/projectdiscovery/retryabledns v1.0.24 + github.com/refraction-networking/utls v1.8.2 github.com/remeh/sizedwaitgroup v1.0.0 + golang.org/x/net v0.38.0 ) require ( github.com/Mzack9999/go-http-digest-auth-client v0.6.1-0.20220414142836-eb8883508809 // indirect github.com/akrylysov/pogreb v0.10.1 // indirect + github.com/andybalholm/brotli v1.0.6 // indirect github.com/andybalholm/cascadia v1.3.1 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/aymerick/douceur v0.2.0 // indirect @@ -28,6 +31,7 @@ require ( github.com/golang/snappy v0.0.4 // indirect github.com/gorilla/css v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect + github.com/klauspost/compress v1.17.4 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/microcosm-cc/bluemonday v1.0.23 // indirect github.com/miekg/dns v1.1.54 // indirect @@ -56,11 +60,11 @@ require ( github.com/zmap/zcrypto v0.0.0-20220803033029-557f3e4940be // indirect go.etcd.io/bbolt v1.3.7 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.7.0 // indirect - golang.org/x/mod v0.10.0 // indirect - golang.org/x/net v0.9.0 // indirect - golang.org/x/sys v0.8.0 // indirect - golang.org/x/text v0.9.0 // indirect - golang.org/x/tools v0.8.0 // indirect + golang.org/x/crypto v0.36.0 // indirect + golang.org/x/mod v0.17.0 // indirect + golang.org/x/sync v0.12.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.23.0 // indirect + golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 568ae54..cf04126 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,8 @@ github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJs github.com/akrylysov/pogreb v0.10.0/go.mod h1:pNs6QmpQ1UlTJKDezuRWmaqkgUE2TuU0YTWyqJZ7+lI= github.com/akrylysov/pogreb v0.10.1 h1:FqlR8VR7uCbJdfUob916tPM+idpKgeESDXOA1K0DK4w= github.com/akrylysov/pogreb v0.10.1/go.mod h1:pNs6QmpQ1UlTJKDezuRWmaqkgUE2TuU0YTWyqJZ7+lI= +github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= +github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c= github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= @@ -73,6 +75,8 @@ github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFF github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/karrick/godirwalk v1.16.1/go.mod h1:j4mkqPuvaLI8mp1DroR3P6ad7cyYd4c1qeJ3RV7ULlk= +github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= +github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= @@ -171,6 +175,8 @@ github.com/projectdiscovery/stringsutil v0.0.2 h1:uzmw3IVLJSMW1kEg8eCStG/cGbYYZA github.com/projectdiscovery/stringsutil v0.0.2/go.mod h1:EJ3w6bC5fBYjVou6ryzodQq37D5c6qbAYQpGmAy+DC0= github.com/projectdiscovery/utils v0.0.28 h1:lFTXbW/SZX3vjmCjReCdvbi5Fj46gCU3egdp098tHzo= github.com/projectdiscovery/utils v0.0.28/go.mod h1:RpMyTA/KACEBK+Bo9mk9MgLH0Hfc+BZtSlZ4T9bjLBE= +github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= +github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/remeh/sizedwaitgroup v1.0.0 h1:VNGGFwNo/R5+MJBf6yrsr110p0m4/OX4S3DCy7Kyl5E= github.com/remeh/sizedwaitgroup v1.0.0/go.mod h1:3j2R4OIe/SeS6YDhICBy22RWjJC5eNCJ1V+9+NVNYlo= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= @@ -255,13 +261,13 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201124201722-c8d3bf9c5392/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= -golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk= -golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -282,15 +288,15 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.0.0-20220728211354-c7608f3a8462/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM= -golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -317,8 +323,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220731174439-a90be440212d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -328,15 +334,15 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.8.0 h1:vSDcovVPld282ceKgDimkRSC8kpaH1dgyc9UMzlt84Y= -golang.org/x/tools v0.8.0/go.mod h1:JxBZ99ISMI5ViVkT1tr6tdNmXeTrcpVSD3vZ1RsRdN4= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From b8aea2c254cf142a0d13ec32ac332825854ea814 Mon Sep 17 00:00:00 2001 From: Serizao Date: Thu, 30 Jul 2026 12:42:02 +0200 Subject: [PATCH 20/22] chore(deps): update every direct dependency, and resolve DNS explicitly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bringing the tree up to date, most usefully chromedp v0.8.5 (2022) to v0.16.0 and cdproto from August 2022 to July 2026. Those two pins were why a live scan flooded stderr with "could not unmarshal event: unknown ClientNavigationReason value" and "partitionKey" parse errors — Chrome 13x/150 sends CDP events the 2022 schema cannot decode. Over the 20-target reference set stderr goes from noisy to zero lines, and neither update required a single code change. chromedp v0.8.5 -> v0.16.0 cdproto 2022-08 -> 2026-07 goquery v1.8.1 -> v1.12.0 goccy/go-json v0.9.11 -> v0.10.6 cdncheck v1.0.2 -> v1.2.46 fastdialer v0.0.16 -> v0.5.13 retryabledns v1.0.24 -> v1.0.115 miekg/dns v1.1.62 -> v1.1.72 x/net v0.38.0 -> v0.57.0 github.com/imdario/mergo is deprecated in favour of dario.cat/mergo; migrated to the canonical path at v1.0.2. Nothing covered that merge, so it was verified by measurement instead: the loaded database is byte-for-byte the same shape before and after — 7245 technologies, 7097 icons, 664 cpes. The go directive moves to 1.26 because chromedp v0.16.0 declares it. That raises the floor for anyone building this; CI follows automatically via go-version-file. The update also silently killed the dns fingerprint family, which is why this commit is not only a version bump. OVHcloud stopped being detected on a host that is plainly hosted there, deterministically, 0 of 4 runs. Newer fastdialer only retains address records: GetDNSData("bzhunt.fr") -> A=[149.202.40.56] NS=[] SOA=0 All 80 dns patterns (43 SOA, 27 TXT, 18 NS, 5 CNAME, 4 MX) had gone quiet, and Host.Cname with them. Reverting fastdialer would have papered over the real problem: fastdialer is a dialer, and leaning on its connection cache for detection data was a coupling that only ever worked by accident. cmd/dns.go now asks a resolver directly through retryabledns.QueryMultiple — already a dependency — naming the record types the fingerprints actually match. That turned out to be a gain rather than a repair. The old cache only partly populated those records, so explicit queries take the reference set from 150 to 170 technologies (+20): Amazon SES, Google Workspace, MailChimp, Apple iCloud Mail and Atlassian Statuspage now fire for the first time, and the host that exposed the regression goes from 6 detections to 9. One API break in the whole set: retryabledns.DNSData.SOA changed from []string to a parsed struct. soaStrings maps NS and Mbox, chosen after checking that all 43 SOA patterns in the live database target a hostname (\.cloudflare\.com, \.domaincontrol\.com, \.vercel-dns\.com, tech\.sakura\.ad\.jp). The numeric serial/refresh/TTL fields are left out rather than concatenated into noise a pattern could accidentally hit, and a test fails if one matches the serial. Also folds the resolver list into a single Cmd.resolvers() used by both the dialer and the new resolver, instead of duplicating the default and writing back through the option pointer. Detection: 170 technologies over the reference set, no host at zero, stderr clean. gofmt -l . clean, go vet ./... clean, go test -race ./... green, go mod verify OK --- analyze/analyze_dns.go | 26 +- analyze/analyze_golden_test.go | 55 +++++ cmd/dns.go | 52 ++++ cmd/root.go | 38 ++- go.mod | 82 ++++--- go.sum | 421 ++++++++++++++------------------- technologies/technologies.go | 2 +- 7 files changed, 381 insertions(+), 295 deletions(-) create mode 100644 cmd/dns.go diff --git a/analyze/analyze_dns.go b/analyze/analyze_dns.go index a206ba3..6664e79 100644 --- a/analyze/analyze_dns.go +++ b/analyze/analyze_dns.go @@ -2,6 +2,7 @@ package analyze import ( "github.com/EasyRecon/wappaGo/technologies" + "github.com/projectdiscovery/retryabledns" ) func (a *Analyze) analyze_dns_main(technoName string, key string) { @@ -26,7 +27,7 @@ func (a *Analyze) analyze_dns_main(technoName string, key string) { case "TXT": resultDNS = a.DnsData.TXT case "SOA": - resultDNS = a.DnsData.SOA + resultDNS = soaStrings(a.DnsData.SOA) case "NS": resultDNS = a.DnsData.NS case "CNAME": @@ -44,6 +45,29 @@ func (a *Analyze) analyze_dns_main(technoName string, key string) { } } +// soaStrings flattens SOA records into the strings the fingerprints actually +// match against. +// +// retryabledns used to hand back []string here and now returns a parsed struct, +// so the fields have to be picked deliberately. All 43 SOA patterns in the +// database target a hostname: either the primary nameserver +// (`\.cloudflare\.com`, `\.domaincontrol\.com`, `\.vercel-dns\.com`) or the +// responsible mailbox (`tech\.sakura\.ad\.jp`). The numeric serial/refresh/TTL +// fields are never matched, so they are left out rather than concatenated into +// noise a pattern could accidentally hit. +func soaStrings(records []retryabledns.SOA) []string { + out := make([]string, 0, len(records)*2) + for _, r := range records { + if r.NS != "" { + out = append(out, r.NS) + } + if r.Mbox != "" { + out = append(out, r.Mbox) + } + } + return out +} + func (a *Analyze) analyze_dns_regex(regex string, resultsDNS []string) bool { re, ok := compileCI(regex) if !ok { diff --git a/analyze/analyze_golden_test.go b/analyze/analyze_golden_test.go index 7f83383..a8188ac 100644 --- a/analyze/analyze_golden_test.go +++ b/analyze/analyze_golden_test.go @@ -556,6 +556,61 @@ func TestBadFingerprintShapesDoNotPanic(t *testing.T) { } } +// TestDnsSOAMatchesNameserverAndMailbox pins the SOA mapping after +// retryabledns changed DNSData.SOA from []string to a parsed struct. Every one +// of the 43 SOA fingerprints in the live database targets a hostname — the +// primary nameserver or the responsible mailbox — so both fields must be +// matchable, and the numeric fields must not leak in as noise. +func TestDnsSOAMatchesNameserverAndMailbox(t *testing.T) { + rg := map[string]interface{}{ + "Cloudflare": map[string]interface{}{ + "dns": map[string]interface{}{"SOA": `\.cloudflare\.com`}, + }, + "Sakura Internet": map[string]interface{}{ + "dns": map[string]interface{}{"SOA": `tech\.sakura\.ad\.jp`}, + }, + // Must not match: 2026 appears only in the numeric serial, which is not + // part of the haystack. + "SerialNoise": map[string]interface{}{ + "dns": map[string]interface{}{"SOA": `2026`}, + }, + } + a := Analyze{ + ResultGlobal: rg, + Body: "", + DnsData: &retryabledns.DNSData{ + SOA: []retryabledns.SOA{ + {NS: "ns1.cloudflare.com", Mbox: "dns.cloudflare.com", Serial: 2026073001}, + {NS: "ns1.dns.ne.jp", Mbox: "tech.sakura.ad.jp", Serial: 2026073002}, + }, + }, + } + + got := technologyVersions(technologies.DedupTechno(a.Run())) + for _, want := range []string{"Cloudflare", "Sakura Internet"} { + if _, ok := got[want]; !ok { + t.Errorf("%q not detected from the SOA record (got %v)", want, got) + } + } + if _, ok := got["SerialNoise"]; ok { + t.Error("a pattern matched the SOA serial: numeric fields must stay out of the haystack") + } +} + +// TestDnsNilDataDoesNotPanic keeps the nil guard honest now that SOA goes +// through a helper. +func TestDnsNilDataDoesNotPanic(t *testing.T) { + a := Analyze{ + ResultGlobal: map[string]interface{}{ + "Whatever": map[string]interface{}{"dns": map[string]interface{}{"SOA": "x"}}, + }, + Body: "", + } + if got := technologies.DedupTechno(a.Run()); len(got) != 0 { + t.Errorf("nil DnsData produced detections: %v", technologyNames(got)) + } +} + // TestCompileCICachesAndToleratesBadPatterns documents the contract the // matchers rely on: valid patterns compile (and are cached), invalid RE2 // patterns return ok=false instead of panicking. diff --git a/cmd/dns.go b/cmd/dns.go new file mode 100644 index 0000000..985ffe7 --- /dev/null +++ b/cmd/dns.go @@ -0,0 +1,52 @@ +package cmd + +import ( + "github.com/miekg/dns" + "github.com/projectdiscovery/retryabledns" +) + +// dnsRecordTypes are the record types the fingerprint database actually matches +// against: 43 SOA patterns, 27 TXT, 18 NS, 5 CNAME and 4 MX. A is included +// because it is cheap and the answer is needed anyway. +var dnsRecordTypes = []uint16{ + dns.TypeA, + dns.TypeNS, + dns.TypeSOA, + dns.TypeTXT, + dns.TypeMX, + dns.TypeCNAME, +} + +// dnsResolver answers the queries the dns fingerprint family needs. +// +// This used to piggyback on fastdialer's connection cache via GetDNSData, which +// happened to carry NS/SOA/TXT records as a side effect of dialing. Newer +// fastdialer only keeps the address records, so GetDNSData started returning +// A records and nothing else — silently killing all 80 dns fingerprints and the +// cname field with them. Asking a resolver directly is both explicit about which +// record types matter and no longer at the mercy of a dialer's caching strategy. +type dnsResolver struct { + client *retryabledns.Client +} + +func newDNSResolver(resolvers []string) (*dnsResolver, error) { + client, err := retryabledns.New(resolvers, 2) + if err != nil { + return nil, err + } + return &dnsResolver{client: client}, nil +} + +// resolve returns the records for host, or nil when the lookup fails. A nil +// result is expected (NXDOMAIN, a resolver hiccup) and every caller must handle +// it: the analyzer treats nil as "no dns signals" rather than dereferencing it. +func (r *dnsResolver) resolve(host string) *retryabledns.DNSData { + if r == nil || r.client == nil || host == "" { + return nil + } + data, err := r.client.QueryMultiple(host, dnsRecordTypes) + if err != nil { + return nil + } + return data +} diff --git a/cmd/root.go b/cmd/root.go index 063c03f..f84ac1c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -55,6 +55,9 @@ type Cmd struct { // identity is the browser identity presented to every scanned host, read // from the real Chrome build in Start before any worker starts. identity identity + // dns answers the record queries the dns fingerprint family needs. Built + // once in Start and read-only afterwards. + dns *dnsResolver // sigCtx is cancelled on SIGINT/SIGTERM. It gates the host loop and the // port scanner so Ctrl+C actually stops the scan; previously it only // parented the Chrome allocator, so the first Ctrl+C killed the browser and @@ -86,6 +89,16 @@ func (c *Cmd) Start(results chan structure.Data) { c.Dialer = dialer defer c.Dialer.Close() + // Resolved explicitly rather than scavenged from the dialer's cache: newer + // fastdialer only retains address records, so the dns fingerprints had gone + // silent. A failure here is not fatal — the dns family is one signal source + // among a dozen — but it is worth reporting. + dnsResolver, dnsErr := newDNSResolver(c.resolvers()) + if dnsErr != nil { + fmt.Fprintf(os.Stderr, "could not create the DNS resolver, dns fingerprints disabled: %v\n", dnsErr) + } + c.dns = dnsResolver + // Build the HTTP client once, up front: it is read-only for the rest of // the run and shared across all target goroutines. Previously it was // rebuilt per target on the shared c.HttpClient field (a data race), and @@ -375,8 +388,8 @@ func (c *Cmd) launchChrome(TempResp structure.Response, data structure.Data, url if data.Infos.Location != "" { urlData = resolveLocation(urlData, data.Infos.Location) } - dnsData, err := c.Dialer.GetDNSData(data.Infos.Data) - if dnsData != nil && err == nil { + dnsData := c.dns.resolve(data.Infos.Data) + if dnsData != nil { data.Infos.Cname = dnsData.CNAME } analyseStruct := analyze.Analyze{} @@ -692,20 +705,25 @@ get_response: // defaultResolvers are used when -resolvers is not set. const defaultResolvers = "8.8.8.8,1.1.1.1,64.6.64.6,74.82.42.42,1.0.0.1,8.8.4.4,64.6.65.6,77.88.8.8" +// resolvers returns the resolver list to use, honouring -resolvers. +// +// It does not write through the option pointer: in library mode that pointer may +// alias a caller's struct field. +func (c *Cmd) resolvers() []string { + list := defaultResolvers + if c.Options.Resolvers != nil && *c.Options.Resolvers != "" { + list = *c.Options.Resolvers + } + return strings.Split(list, ",") +} + // InitDialer builds the resolving dialer. It used to swallow the error and hand // back a nil *Dialer, which the caller then used as if it were valid. func (c *Cmd) InitDialer() (*fastdialer.Dialer, error) { fastdialerOpts := fastdialer.DefaultOptions fastdialerOpts.EnableFallback = true fastdialerOpts.WithDialerHistory = true - - resolvers := *c.Options.Resolvers - if len(resolvers) == 0 { - // Do not write through the option pointer: it may alias a caller's - // struct field in library mode. - resolvers = defaultResolvers - } - fastdialerOpts.BaseResolvers = strings.Split(resolvers, ",") + fastdialerOpts.BaseResolvers = c.resolvers() return fastdialer.NewDialer(fastdialerOpts) } diff --git a/go.mod b/go.mod index c3312f2..4a5b65c 100644 --- a/go.mod +++ b/go.mod @@ -1,70 +1,68 @@ module github.com/EasyRecon/wappaGo -go 1.24 +go 1.26 require ( - github.com/PuerkitoBio/goquery v1.8.1 - github.com/chromedp/cdproto v0.0.0-20220827030233-358ed4af73cf - github.com/chromedp/chromedp v0.8.5 - github.com/goccy/go-json v0.9.11 - github.com/imdario/mergo v0.3.13 - github.com/projectdiscovery/cdncheck v1.0.2 - github.com/projectdiscovery/fastdialer v0.0.16 - github.com/projectdiscovery/retryabledns v1.0.24 + dario.cat/mergo v1.0.2 + github.com/PuerkitoBio/goquery v1.12.0 + github.com/chromedp/cdproto v0.0.0-20260719223732-95f6af754cfe + github.com/chromedp/chromedp v0.16.0 + github.com/goccy/go-json v0.10.6 + github.com/miekg/dns v1.1.72 + github.com/projectdiscovery/cdncheck v1.2.46 + github.com/projectdiscovery/fastdialer v0.5.13 + github.com/projectdiscovery/retryabledns v1.0.115 github.com/refraction-networking/utls v1.8.2 github.com/remeh/sizedwaitgroup v1.0.0 - golang.org/x/net v0.38.0 + golang.org/x/net v0.57.0 ) require ( - github.com/Mzack9999/go-http-digest-auth-client v0.6.1-0.20220414142836-eb8883508809 // indirect + github.com/Mzack9999/gcache v0.0.0-20230410081825-519e28eab057 // indirect github.com/akrylysov/pogreb v0.10.1 // indirect - github.com/andybalholm/brotli v1.0.6 // indirect - github.com/andybalholm/cascadia v1.3.1 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/andybalholm/cascadia v1.3.3 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/aymerick/douceur v0.2.0 // indirect - github.com/chromedp/sysutil v1.0.0 // indirect + github.com/chromedp/sysutil v1.1.0 // indirect github.com/dimchansky/utfbom v1.1.1 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/gaissmai/bart v0.28.0 // indirect + github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect - github.com/gobwas/ws v1.1.0 // indirect + github.com/gobwas/ws v1.4.0 // indirect github.com/golang/snappy v0.0.4 // indirect - github.com/gorilla/css v1.0.0 // indirect - github.com/josharian/intern v1.0.0 // indirect - github.com/klauspost/compress v1.17.4 // indirect - github.com/mailru/easyjson v0.7.7 // indirect - github.com/microcosm-cc/bluemonday v1.0.23 // indirect - github.com/miekg/dns v1.1.54 // indirect + github.com/gorilla/css v1.0.1 // indirect + github.com/klauspost/compress v1.18.2 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/projectdiscovery/cryptoutil v1.0.0 // indirect - github.com/projectdiscovery/hmap v0.0.10 // indirect - github.com/projectdiscovery/iputil v0.0.2 // indirect - github.com/projectdiscovery/networkpolicy v0.0.2-0.20221025011504-22b13ce54fff // indirect - github.com/projectdiscovery/retryablehttp-go v1.0.16 // indirect - github.com/projectdiscovery/stringsutil v0.0.2 // indirect - github.com/projectdiscovery/utils v0.0.28 // indirect + github.com/projectdiscovery/blackrock v0.0.1 // indirect + github.com/projectdiscovery/hmap v0.0.101 // indirect + github.com/projectdiscovery/networkpolicy v0.1.42 // indirect + github.com/projectdiscovery/utils v0.11.1 // indirect github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect github.com/syndtr/goleveldb v1.0.0 // indirect github.com/tidwall/btree v1.4.3 // indirect - github.com/tidwall/buntdb v1.2.10 // indirect - github.com/tidwall/gjson v1.14.3 // indirect + github.com/tidwall/buntdb v1.3.0 // indirect + github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/grect v0.1.4 // indirect github.com/tidwall/match v1.1.1 // indirect - github.com/tidwall/pretty v1.2.0 // indirect + github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/rtred v0.1.2 // indirect github.com/tidwall/tinyqueue v0.1.1 // indirect - github.com/ulule/deepcopier v0.0.0-20200430083143-45decc6639b6 // indirect - github.com/weppos/publicsuffix-go v0.30.0 // indirect - github.com/yl2chen/cidranger v1.0.2 // indirect - github.com/zmap/rc2 v0.0.0-20131011165748-24b9757f5521 // indirect - github.com/zmap/zcrypto v0.0.0-20220803033029-557f3e4940be // indirect + github.com/weppos/publicsuffix-go v0.50.3-0.20260104170930-90713dec78f2 // indirect + github.com/zmap/rc2 v0.0.0-20190804163417-abaa70531248 // indirect + github.com/zmap/zcrypto v0.0.0-20240803002437-3a861682ac77 // indirect go.etcd.io/bbolt v1.3.7 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.36.0 // indirect - golang.org/x/mod v0.17.0 // indirect - golang.org/x/sync v0.12.0 // indirect - golang.org/x/sys v0.31.0 // indirect - golang.org/x/text v0.23.0 // indirect - golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/tools v0.47.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index cf04126..2a86507 100644 --- a/go.sum +++ b/go.sum @@ -1,378 +1,317 @@ -github.com/Mzack9999/go-http-digest-auth-client v0.6.1-0.20220414142836-eb8883508809 h1:ZbFL+BDfBqegi+/Ssh7im5+aQfBRx6it+kHnC7jaDU8= -github.com/Mzack9999/go-http-digest-auth-client v0.6.1-0.20220414142836-eb8883508809/go.mod h1:upgc3Zs45jBDnBT4tVRgRcgm26ABpaP7MoTSdgysca4= -github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM= -github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ= -github.com/akrylysov/pogreb v0.10.0/go.mod h1:pNs6QmpQ1UlTJKDezuRWmaqkgUE2TuU0YTWyqJZ7+lI= +cloud.google.com/go/compute/metadata v0.2.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/Mzack9999/gcache v0.0.0-20230410081825-519e28eab057 h1:KFac3SiGbId8ub47e7kd2PLZeACxc1LkiiNoDOFRClE= +github.com/Mzack9999/gcache v0.0.0-20230410081825-519e28eab057/go.mod h1:iLB2pivrPICvLOuROKmlqURtFIEsoJZaMidQfCG1+D4= +github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g= +github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo= +github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ= github.com/akrylysov/pogreb v0.10.1 h1:FqlR8VR7uCbJdfUob916tPM+idpKgeESDXOA1K0DK4w= github.com/akrylysov/pogreb v0.10.1/go.mod h1:pNs6QmpQ1UlTJKDezuRWmaqkgUE2TuU0YTWyqJZ7+lI= -github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= -github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= -github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c= -github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= -github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= +github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= -github.com/bits-and-blooms/bitset v1.3.1 h1:y+qrlmq3XsWi+xZqSaueaE8ry8Y127iMxlMfqcK8p0g= -github.com/bits-and-blooms/bitset v1.3.1/go.mod h1:gIdJ4wp64HaoK2YrL1Q5/N7Y16edYb8uY+O0FJTyyDA= -github.com/bits-and-blooms/bloom/v3 v3.3.1 h1:K2+A19bXT8gJR5mU7y+1yW6hsKfNCjcP2uNfLFKncjQ= -github.com/bits-and-blooms/bloom/v3 v3.3.1/go.mod h1:bhUUknWd5khVbTe4UgMCSiOOVJzr3tMoijSK3WwvW90= -github.com/chromedp/cdproto v0.0.0-20220827030233-358ed4af73cf h1:e0oJZmGJidTRZ0FvWXNhdj9OaOMN30Yf+T3K2Tf3L+s= -github.com/chromedp/cdproto v0.0.0-20220827030233-358ed4af73cf/go.mod h1:5Y4sD/eXpwrChIuxhSr/G20n9CdbCmoerOHnuAf0Zr0= -github.com/chromedp/chromedp v0.8.5 h1:HAVg54yQFcn7sg5reVjXtoI1eQaFxhjAjflHACicUFw= -github.com/chromedp/chromedp v0.8.5/go.mod h1:xal2XY5Di7m/bzlGwtoYpmgIOfDqCakOIVg5OfdkPZ4= -github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic= -github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww= -github.com/cnf/structhash v0.0.0-20201127153200-e1b16c1ebc08/go.mod h1:pCxVEbcm3AMg7ejXyorUXi6HQCzOIBf7zEDVPtw0/U4= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/bits-and-blooms/bitset v1.13.0 h1:bAQ9OPNFYbGHV6Nez0tmNI0RiEu7/hxlYJRUA0wFAVE= +github.com/bits-and-blooms/bitset v1.13.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/bits-and-blooms/bloom/v3 v3.5.0 h1:AKDvi1V3xJCmSR6QhcBfHbCN4Vf8FfxeWkMNQfmAGhY= +github.com/bits-and-blooms/bloom/v3 v3.5.0/go.mod h1:Y8vrn7nk1tPIlmLtW2ZPV+W7StdVMor6bC1xgpjMZFs= +github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= +github.com/chromedp/cdproto v0.0.0-20260719223732-95f6af754cfe h1:PmhRwLZ8qLtldQCBiydwdPFJI8WVQ936ux1cpgHLRb8= +github.com/chromedp/cdproto v0.0.0-20260719223732-95f6af754cfe/go.mod h1:RwFsSODCtFExll+GhHM6R92SARHR3Z3oipaxLHj46C0= +github.com/chromedp/chromedp v0.16.0 h1:rOO4deOm4CbZgBCa8mD9g2rDyIoNs0BkgvNrlbp5ouk= +github.com/chromedp/chromedp v0.16.0/go.mod h1:rbuGKFT1vMcFcFqKfPIO1GpX/N+2s8onm2qMxZLbU5U= +github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= +github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= +github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U= github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= -github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= +github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= +github.com/gaissmai/bart v0.28.0 h1:89yZLo8NmyqD0RYgJ3QO9HhqqGGw+oWhf90cZm69Lko= +github.com/gaissmai/bart v0.28.0/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c= +github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 h1:KZaTBSyshWX3MP5jukJcNSuXDQTO+rNpt0J564dX/eg= +github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= -github.com/gobwas/ws v1.1.0 h1:7RFti/xnNkMJnrK7D1yQ/iCIB5OrrY/54/H930kIbHA= -github.com/gobwas/ws v1.1.0/go.mod h1:nzvNcVha5eUziGrbxFCo6qFIojQHjJV5cLYIbezhfL0= -github.com/goccy/go-json v0.9.11 h1:/pAaQDLHEoCq/5FFmSKBswWmK6H0e8g4159Kc/X/nqk= -github.com/goccy/go-json v0.9.11/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= +github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= -github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= -github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= -github.com/hashicorp/golang-lru/v2 v2.0.1 h1:5pv5N1lT1fjLg2VQ5KWc7kmucp2x/kvFOnxuVTqZ6x4= -github.com/hashicorp/golang-lru/v2 v2.0.1/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-github/v50 v50.2.0/go.mod h1:VBY8FB6yPIjrtKhozXv4FQupxKLS6H4m6xFZlT43q8Q= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= -github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk= -github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/karrick/godirwalk v1.16.1/go.mod h1:j4mkqPuvaLI8mp1DroR3P6ad7cyYd4c1qeJ3RV7ULlk= -github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= -github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= -github.com/logrusorgru/aurora v0.0.0-20200102142835-e9ef32dff381/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= -github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/microcosm-cc/bluemonday v1.0.18/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM= -github.com/microcosm-cc/bluemonday v1.0.19/go.mod h1:QNzV2UbLK2/53oIIwTOyLUSABMkjZ4tqiyC1g/DyqxE= -github.com/microcosm-cc/bluemonday v1.0.21/go.mod h1:ytNkv4RrDrLJ2pqlsSI46O6IVXmZOBBD4SaJyDwwTkM= -github.com/microcosm-cc/bluemonday v1.0.23 h1:SMZe2IGa0NuHvnVNAZ+6B38gsTbi5e4sViiWJyDDqFY= -github.com/microcosm-cc/bluemonday v1.0.23/go.mod h1:mN70sk7UkkF8TUr2IGBpNN0jAgStuPzlK76QuruE/z4= -github.com/miekg/dns v1.1.50/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME= -github.com/miekg/dns v1.1.54 h1:5jon9mWcb0sFJGpnI99tOMhCPyJ+RPVz5b63MQG0VWI= -github.com/miekg/dns v1.1.54/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/mreiferson/go-httpclient v0.0.0-20160630210159-31f0106b4474/go.mod h1:OQA4XLvDbMgS8P0CevmM4m9Q3Jq4phKUzcocxuGJ5m8= -github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= -github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= -github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/mreiferson/go-httpclient v0.0.0-20201222173833-5e475fde3a4d/go.mod h1:OQA4XLvDbMgS8P0CevmM4m9Q3Jq4phKUzcocxuGJ5m8= +github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= +github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= -github.com/onsi/ginkgo v1.15.0/go.mod h1:hF8qUzuuC8DJGygJH3726JnCZX4MYbRB8yFfISqnKUg= github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= -github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= -github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= -github.com/onsi/gomega v1.10.5/go.mod h1:gza4q3jKQJijlu05nKWRCW/GavJumGt8aNRxWg7mt48= -github.com/onsi/gomega v1.16.0 h1:6gjqkI8iiRHMvdccRJM8rVKjCWk6ZIm6FTm3ddIe4/c= -github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE= +github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg= github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/projectdiscovery/blackrock v0.0.0-20210415162320-b38689ae3a2e/go.mod h1:/IsapnEYiWG+yEDPXp0e8NWj3npzB9Ccy9lXEUJwMZs= -github.com/projectdiscovery/blackrock v0.0.0-20220628111055-35616c71b2dc/go.mod h1:5tNGQP9kOfW+X5+40pZP8aqPYLHs45nJkFaSHLxdeH8= -github.com/projectdiscovery/cdncheck v1.0.2 h1:8bd7Ed8NgTyH4nyhf0jwRQdGIrTFsnQoPGr2shxR/xM= -github.com/projectdiscovery/cdncheck v1.0.2/go.mod h1:6otjXPoRQ42LRbEUqOeTDvFwToOVJptdvlUkiXe/rEE= -github.com/projectdiscovery/cryptoutil v0.0.0-20210805184155-b5d2512f9345/go.mod h1:clhQmPnt35ziJW1AhJRKyu8aygXCSoyWj6dtmZBRjjc= -github.com/projectdiscovery/cryptoutil v1.0.0 h1:5rQfnWDthJ5ZFcqze+rmT1N7l1HJQ6EB26MrjaYB7I0= -github.com/projectdiscovery/cryptoutil v1.0.0/go.mod h1:VJvSNE8f8A1MgpjgAL2GPJSQcJa4jbdaeQJstARFrU4= -github.com/projectdiscovery/fastdialer v0.0.16 h1:0FcWVAtm6ftVALQpckdlKTeZcJi277HduvGXsBDO02Q= -github.com/projectdiscovery/fastdialer v0.0.16/go.mod h1:poZbCGYGRfRcKxU8/1rWH8EVOJ4AZn1a8+CyINHzffQ= -github.com/projectdiscovery/fileutil v0.0.0-20210928100737-cab279c5d4b5/go.mod h1:U+QCpQnX8o2N2w0VUGyAzjM3yBAe4BKedVElxiImsx0= -github.com/projectdiscovery/fileutil v0.0.0-20220609150212-453ac591c36c/go.mod h1:g8wsrb0S5NtEN0JgVyyPeb3FQdArx+UMESmFX94bcGY= -github.com/projectdiscovery/fileutil v0.0.0-20220705195237-01becc2a8963/go.mod h1:DaY7wmLPMleyHDCD/14YApPCDtrARY4J8Eny2ZGsG/g= -github.com/projectdiscovery/goflags v0.0.8/go.mod h1:GDSkWyXa6kfQjpJu10SO64DN8lXuKXVENlBMk8N7H80= -github.com/projectdiscovery/gologger v1.0.1/go.mod h1:Ok+axMqK53bWNwDSU1nTNwITLYMXMdZtRc8/y1c7sWE= -github.com/projectdiscovery/gologger v1.1.4/go.mod h1:Bhb6Bdx2PV1nMaFLoXNBmHIU85iROS9y1tBuv7T5pMY= -github.com/projectdiscovery/hmap v0.0.1/go.mod h1:VDEfgzkKQdq7iGTKz8Ooul0NuYHQ8qiDs6r8bPD1Sb0= -github.com/projectdiscovery/hmap v0.0.2-0.20210917080408-0fd7bd286bfa/go.mod h1:lV5f/PNPmCCjCN/dR317/chN9s7VG5h/xcbFfXOz8Fo= -github.com/projectdiscovery/hmap v0.0.10 h1:O6ALGW3BK+FmknLXW7ENwQevLs+faRJuoRbDtakZZus= -github.com/projectdiscovery/hmap v0.0.10/go.mod h1:xdtyejCgl5LJW7yz7nf/ut32tWuV/l7FjUzItiCtJIg= -github.com/projectdiscovery/ipranger v0.0.2/go.mod h1:kcAIk/lo5rW+IzUrFkeYyXnFJ+dKwYooEOHGVPP/RWE= -github.com/projectdiscovery/iputil v0.0.0-20210414194613-4b4d2517acf0/go.mod h1:PQAqn5h5NXsQTF4ZA00ZTYLRzGCjOtcCq8llAqrsd1A= -github.com/projectdiscovery/iputil v0.0.0-20220712175312-b9406f31cdd8/go.mod h1:vHRC+9exsfSbEngMKDl0xiWqkxlLk3lHQZpbS2yFT8U= -github.com/projectdiscovery/iputil v0.0.2 h1:f6IGnZF4RImJLysPSPG3D84jyTH34q3lihCFeP+eZzI= -github.com/projectdiscovery/iputil v0.0.2/go.mod h1:J3Pcz1q51pi4/JL871mQztg0KOzyWDPxnPLOYJm2pVQ= -github.com/projectdiscovery/mapcidr v0.0.4/go.mod h1:ALOIj6ptkWujNoX8RdQwB2mZ+kAmKuLJBq9T5gR5wG0= -github.com/projectdiscovery/mapcidr v0.0.6/go.mod h1:ZEBhMmBU3laUl3g9QGTrzJku1VJOzjdFwW01f/zVVzM= -github.com/projectdiscovery/mapcidr v1.0.1/go.mod h1:/qxlpxXZQFFjHynSc9u5O0kUPzH46VskECiwLiz7/vw= -github.com/projectdiscovery/networkpolicy v0.0.1/go.mod h1:asvdg5wMy3LPVMGALatebKeOYH5n5fV5RCTv6DbxpIs= -github.com/projectdiscovery/networkpolicy v0.0.2-0.20221025011504-22b13ce54fff h1:xq+OYVy41kNjcunbycFxHoKGFXkeDq1v3IRIWh381i8= -github.com/projectdiscovery/networkpolicy v0.0.2-0.20221025011504-22b13ce54fff/go.mod h1:NIVv5IoaAs272OVXrjafRAt8/gehnia71EVdeRWl/cE= -github.com/projectdiscovery/retryabledns v1.0.15/go.mod h1:3YbsQVqP7jbQ3CDmarhyVtkJaJ8XcB7S19vMeyMxZxk= -github.com/projectdiscovery/retryabledns v1.0.24 h1:CbC0a1EcyRDBcGFHZDGfW5orkWkOCfa0mAMF060XJpI= -github.com/projectdiscovery/retryabledns v1.0.24/go.mod h1:bCmv0neiqgemgmFChevfX2BgCxIp8sn5OnbwL1Gov9M= -github.com/projectdiscovery/retryablehttp-go v1.0.2/go.mod h1:dx//aY9V247qHdsRf0vdWHTBZuBQ2vm6Dq5dagxrDYI= -github.com/projectdiscovery/retryablehttp-go v1.0.16 h1:xrEK9VVkLf0eN4bYOTc2Pg0qRqz47g8T823vqpCIsl8= -github.com/projectdiscovery/retryablehttp-go v1.0.16/go.mod h1:9m76To4lNgBtVfqADzLxZg1wWajv6y/uYMWCOs1Olo8= -github.com/projectdiscovery/sliceutil v0.0.0-20220617151003-15892688e1d6/go.mod h1:9YZb6LRjLYAvSOm65v787dwauurixSyjlqXyYa4rTTA= -github.com/projectdiscovery/sliceutil v0.0.0-20220625085859-c3a4ecb669f4/go.mod h1:RxDaccMjPzIuF7F8XbdGl1yOcqxN4YPiHr9xHpfCkGI= -github.com/projectdiscovery/stringsutil v0.0.0-20210804142656-fd3c28dbaafe/go.mod h1:oTRc18WBv9t6BpaN9XBY+QmG28PUpsyDzRht56Qf49I= -github.com/projectdiscovery/stringsutil v0.0.0-20220422150559-b54fb5dc6833/go.mod h1:oTRc18WBv9t6BpaN9XBY+QmG28PUpsyDzRht56Qf49I= -github.com/projectdiscovery/stringsutil v0.0.0-20220612082425-0037ce9f89f3/go.mod h1:mF5sh4jTghoGWwgUb9qWi5waTFklClDbtrqtJU93awc= -github.com/projectdiscovery/stringsutil v0.0.0-20220731064040-4b67f194751e/go.mod h1:32NYmKyHkKsmisAOAaWrR15lz2ysz2M8x3KMeeoRHoU= -github.com/projectdiscovery/stringsutil v0.0.2 h1:uzmw3IVLJSMW1kEg8eCStG/cGbYYZAja8BH3LqqJXMA= -github.com/projectdiscovery/stringsutil v0.0.2/go.mod h1:EJ3w6bC5fBYjVou6ryzodQq37D5c6qbAYQpGmAy+DC0= -github.com/projectdiscovery/utils v0.0.28 h1:lFTXbW/SZX3vjmCjReCdvbi5Fj46gCU3egdp098tHzo= -github.com/projectdiscovery/utils v0.0.28/go.mod h1:RpMyTA/KACEBK+Bo9mk9MgLH0Hfc+BZtSlZ4T9bjLBE= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/projectdiscovery/blackrock v0.0.1 h1:lHQqhaaEFjgf5WkuItbpeCZv2DUIE45k0VbGJyft6LQ= +github.com/projectdiscovery/blackrock v0.0.1/go.mod h1:ANUtjDfaVrqB453bzToU+YB4cUbvBRpLvEwoWIwlTss= +github.com/projectdiscovery/cdncheck v1.2.46 h1:ELtTCaT5dLigd0FGmw6z+JuIpbFAr422hbgAFXMmKvY= +github.com/projectdiscovery/cdncheck v1.2.46/go.mod h1:9oE9KKxCSHNvUf0UaMeqqUwWpC38FkNaTll0ScIBT3w= +github.com/projectdiscovery/fastdialer v0.5.13 h1:Tocdk3yy7WKLmRV1YmWctPI2yU1QfJIlUbMarIzdgGg= +github.com/projectdiscovery/fastdialer v0.5.13/go.mod h1:iSf7DMOttk4LH/YSNAaztliqVCo1cVlmyudR+YXJWW8= +github.com/projectdiscovery/hmap v0.0.101 h1:zXM6YtLmsn8Q0CUUw8QavhqWmiQYwaw+/U679Rr00pc= +github.com/projectdiscovery/hmap v0.0.101/go.mod h1:w6N9/a5H8kvyx53AhtPDUWe5Qq3D6NBDPA23glHpa/Q= +github.com/projectdiscovery/networkpolicy v0.1.42 h1:/d5vriH8fDMoEaFCSD1nuCWs/ki3dft+CDqQnYrKyLg= +github.com/projectdiscovery/networkpolicy v0.1.42/go.mod h1:9ULLaMbdv9UnT0C5rmuK4nIwYs0o776xMnkPUb8TtaE= +github.com/projectdiscovery/retryabledns v1.0.115 h1:RKV63FNIznFHUoawg/1hs53pVH3wqPtFhwstCuxVSoA= +github.com/projectdiscovery/retryabledns v1.0.115/go.mod h1:+fEMWoPigw+M0lGNKY7AZ+g8FIgj+4sONjsinMmeL3k= +github.com/projectdiscovery/utils v0.11.1 h1:PWj1KjIASxt8icxommH72C0TQqNOvGkcSODRkiq0SQw= +github.com/projectdiscovery/utils v0.11.1/go.mod h1:yktGrHGk2CTjNiccXovnvGrLHX9sV2bqz9nSnbA3V8M= github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/remeh/sizedwaitgroup v1.0.0 h1:VNGGFwNo/R5+MJBf6yrsr110p0m4/OX4S3DCy7Kyl5E= github.com/remeh/sizedwaitgroup v1.0.0/go.mod h1:3j2R4OIe/SeS6YDhICBy22RWjJC5eNCJ1V+9+NVNYlo= -github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= -github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU= github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7AwssoOcM/tq5JjjG2yYOc8odClEiXA= github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU= github.com/sirupsen/logrus v1.3.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= -github.com/stretchr/testify v1.7.3/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= -github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE= github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ= github.com/tidwall/assert v0.1.0 h1:aWcKyRBUAdLoVebxo95N7+YZVTFF/ASTr7BN4sLP6XI= github.com/tidwall/assert v0.1.0/go.mod h1:QLYtGyeqse53vuELQheYl9dngGCJQ+mTtlxcktb+Kj8= github.com/tidwall/btree v1.4.3 h1:Lf5U/66bk0ftNppOBjVoy/AIPBrLMkheBp4NnSNiYOo= github.com/tidwall/btree v1.4.3/go.mod h1:LGm8L/DZjPLmeWGjv5kFrY8dL4uVhMmzmmLYmsObdKE= -github.com/tidwall/buntdb v1.2.10 h1:U/ebfkmYPBnyiNZIirUiWFcxA/mgzjbKlyPynFsPtyM= -github.com/tidwall/buntdb v1.2.10/go.mod h1:lZZrZUWzlyDJKlLQ6DKAy53LnG7m5kHyrEHvvcDmBpU= +github.com/tidwall/buntdb v1.3.0 h1:gdhWO+/YwoB2qZMeAU9JcWWsHSYU3OvcieYgFRS0zwA= +github.com/tidwall/buntdb v1.3.0/go.mod h1:lZZrZUWzlyDJKlLQ6DKAy53LnG7m5kHyrEHvvcDmBpU= github.com/tidwall/gjson v1.12.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/gjson v1.14.3 h1:9jvXn7olKEHU1S9vwoMGliaT8jq1vJ7IH/n9zD9Dnlw= -github.com/tidwall/gjson v1.14.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/grect v0.1.4 h1:dA3oIgNgWdSspFzn1kS4S/RDpZFLrIxAZOdJKjYapOg= github.com/tidwall/grect v0.1.4/go.mod h1:9FBsaYRaR0Tcy4UwefBX/UDcDcDy9V5jUcxHzv2jd5Q= github.com/tidwall/lotsa v1.0.2 h1:dNVBH5MErdaQ/xd9s769R31/n2dXavsQ0Yf4TMEHHw8= github.com/tidwall/lotsa v1.0.2/go.mod h1:X6NiU+4yHA3fE3Puvpnn1XMDrFZrE9JO2/w+UMuqgR8= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= -github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/rtred v0.1.2 h1:exmoQtOLvDoO8ud++6LwVsAMTu0KPzLTUrMln8u1yu8= github.com/tidwall/rtred v0.1.2/go.mod h1:hd69WNXQ5RP9vHd7dqekAz+RIdtfBogmglkZSRxCHFQ= github.com/tidwall/tinyqueue v0.1.1 h1:SpNEvEggbpyN5DIReaJ2/1ndroY8iyEGxPYxoSaymYE= github.com/tidwall/tinyqueue v0.1.1/go.mod h1:O/QNHwrnjqr6IHItYrzoHAKYhBkLI67Q096fQP5zMYw= -github.com/ulule/deepcopier v0.0.0-20200430083143-45decc6639b6 h1:TtyC78WMafNW8QFfv3TeP3yWNDG+uxNkk9vOrnDu6JA= -github.com/ulule/deepcopier v0.0.0-20200430083143-45decc6639b6/go.mod h1:h8272+G2omSmi30fBXiZDMkmHuOgonplfKIKjQWzlfs= -github.com/weppos/publicsuffix-go v0.12.0/go.mod h1:z3LCPQ38eedDQSwmsSRW4Y7t2L8Ln16JPQ02lHAdn5k= -github.com/weppos/publicsuffix-go v0.15.1-0.20210928183822-5ee35905bd95/go.mod h1:HYux0V0Zi04bHNwOHy4cXJVz/TQjYonnF6aoYhj+3QE= -github.com/weppos/publicsuffix-go v0.15.1-0.20220724114530-e087fba66a37/go.mod h1:5ZC/Uv3fIEUE0eP6o9+Yg4+5+W8V0/BieMi05feGXVA= -github.com/weppos/publicsuffix-go v0.30.0 h1:QHPZ2GRu/YE7cvejH9iyavPOkVCB4dNxp2ZvtT+vQLY= -github.com/weppos/publicsuffix-go v0.30.0/go.mod h1:kBi8zwYnR0zrbm8RcuN1o9Fzgpnnn+btVN8uWPMyXAY= -github.com/weppos/publicsuffix-go/publicsuffix/generator v0.0.0-20220704091424-e0182326a282/go.mod h1:GHfoeIdZLdZmLjMlzBftbTDntahTttUMWjxZwQJhULE= -github.com/weppos/publicsuffix-go/publicsuffix/generator v0.0.0-20220927085643-dc0d00c92642/go.mod h1:GHfoeIdZLdZmLjMlzBftbTDntahTttUMWjxZwQJhULE= +github.com/weppos/publicsuffix-go v0.13.0/go.mod h1:z3LCPQ38eedDQSwmsSRW4Y7t2L8Ln16JPQ02lHAdn5k= +github.com/weppos/publicsuffix-go v0.40.2/go.mod h1:XsLZnULC3EJ1Gvk9GVjuCTZ8QUu9ufE4TZpOizDShko= +github.com/weppos/publicsuffix-go v0.50.3-0.20260104170930-90713dec78f2 h1:LiQSn5u8Nc6V/GixI+SWxt+YkNIyfKIlkVRULSw2Zt0= +github.com/weppos/publicsuffix-go v0.50.3-0.20260104170930-90713dec78f2/go.mod h1:CbQCKDtXF8UcT7hrxeMa0MDjwhpOI9iYOU7cfq+yo8k= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yl2chen/cidranger v1.0.2 h1:lbOWZVCG1tCRX4u24kuM1Tb4nHqWkDxwLdoS+SevawU= github.com/yl2chen/cidranger v1.0.2/go.mod h1:9U1yz7WPYDwf0vpNWFaeRh0bjwz5RVgRy/9UEQfHl0g= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/zmap/rc2 v0.0.0-20131011165748-24b9757f5521 h1:kKCF7VX/wTmdg2ZjEaqlq99Bjsoiz7vH6sFniF/vI4M= github.com/zmap/rc2 v0.0.0-20131011165748-24b9757f5521/go.mod h1:3YZ9o3WnatTIZhuOtot4IcUfzoKVjUHqu6WALIyI0nE= +github.com/zmap/rc2 v0.0.0-20190804163417-abaa70531248 h1:Nzukz5fNOBIHOsnP+6I79kPx3QhLv8nBy2mfFhBRq30= +github.com/zmap/rc2 v0.0.0-20190804163417-abaa70531248/go.mod h1:3YZ9o3WnatTIZhuOtot4IcUfzoKVjUHqu6WALIyI0nE= github.com/zmap/zcertificate v0.0.0-20180516150559-0e3d58b1bac4/go.mod h1:5iU54tB79AMBcySS0R2XIyZBAVmeHranShAFELYx7is= -github.com/zmap/zcrypto v0.0.0-20211005224000-2d0ffdec8a9b/go.mod h1:5nID//bFGkx3/+iHcFIFRHQ54EOPJ0iSj0IGKpMElvw= -github.com/zmap/zcrypto v0.0.0-20220803033029-557f3e4940be h1:M5QjuCbUeNZsup53dlJkI/cx6pVdnDOPzyy+XppoowY= -github.com/zmap/zcrypto v0.0.0-20220803033029-557f3e4940be/go.mod h1:bRZdjnJaHWVXKEwrfAZMd0gfRjZGNhTbZwzp07s0Abw= -go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= -go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4= +github.com/zmap/zcertificate v0.0.1/go.mod h1:q0dlN54Jm4NVSSuzisusQY0hqDWvu92C+TWveAxiVWk= +github.com/zmap/zcrypto v0.0.0-20201128221613-3719af1573cf/go.mod h1:aPM7r+JOkfL+9qSB4KbYjtoEzJqUK50EXkkJabeNJDQ= +github.com/zmap/zcrypto v0.0.0-20201211161100-e54a5822fb7e/go.mod h1:aPM7r+JOkfL+9qSB4KbYjtoEzJqUK50EXkkJabeNJDQ= +github.com/zmap/zcrypto v0.0.0-20240803002437-3a861682ac77 h1:DCz0McWRVJNICkHdu2XpETqeLvPtZXs315OZyUs1BDk= +github.com/zmap/zcrypto v0.0.0-20240803002437-3a861682ac77/go.mod h1:aSvf+uTU222mUYq/KQj3oiEU7ajhCZe8RRSLHIoM4EM= +github.com/zmap/zlint/v3 v3.0.0/go.mod h1:paGwFySdHIBEMJ61YjoqT4h7Ge+fdYG4sUQhnTb1lJ8= go.etcd.io/bbolt v1.3.7 h1:j+zJOnnEjF/kyHlDDgGnVL/AIqIJPq8UoB2GSNfkUfQ= go.etcd.io/bbolt v1.3.7/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw= -go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/multierr v1.8.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201124201722-c8d3bf9c5392/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/crypto v0.0.0-20201208171446-5f87f3452ae9/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= -golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA= +golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.0.0-20210414194228-064579744ee0/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8= -golang.org/x/net v0.0.0-20210521195947-fe42d452be8f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220617184016-355a448f1bc9/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.0.0-20220728211354-c7608f3a8462/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= -golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= -golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= -golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= -golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201207223542-d4d67f95c62d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210415045647-66c3f260301c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20201126233918-771906719818/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220731174439-a90be440212d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/technologies/technologies.go b/technologies/technologies.go index 5bd5809..cc5aa26 100644 --- a/technologies/technologies.go +++ b/technologies/technologies.go @@ -9,9 +9,9 @@ import ( "strings" "time" + "dario.cat/mergo" "github.com/EasyRecon/wappaGo/lib" "github.com/EasyRecon/wappaGo/structure" - "github.com/imdario/mergo" ) func CheckRequired(technoName string, technoList map[string]interface{}, tech []structure.Technologie) []structure.Technologie { From 955861ad48d875d88413ce446f26889cfecc021c Mon Sep 17 00:00:00 2001 From: Serizao Date: Thu, 30 Jul 2026 13:37:05 +0200 Subject: [PATCH 21/22] fix(stealth): make the TLS handshake and every claimed version agree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two earlier commits each fixed part of the identity and left a seam between them. Probing www.hermes.com — DataDome in front of Cloudflare — surfaced both. setBrowserHeaders still emitted a hardcoded Sec-CH-UA in the Chrome-110 spelling: `"Google Chrome";v="N", "Chromium";v="N", "Not_A Brand";v="24"`, brands in the opposite order to what Chrome now sends, and the wrong bogus entry. So after "one coherent browser identity" the User-Agent finally matched between the probe and the browser while the Client Hints went on contradicting each other — the exact defect that work set out to remove, one header over. Both now derive from the same identity value, platform included, via identity.secChUa(). The second seam is the handshake. chromeHello was HelloChrome_Auto, an alias for whatever the uTLS build considers newest — 133 in v1.8.2 — while the presented User-Agent came from the installed browser, here 150. Measured against that origin, one round-trip per variant, two paced rounds: crypto/tls + UA 150 302 passed uTLS 133 + UA 150 403 blocked, captcha page <- what shipped uTLS 133 + UA 133 302 passed uTLS 131 + UA 150 302 passed uTLS 120 + UA 120 302 passed The only refused combination was the one being shipped: the vendor correlates the TLS fingerprint with the version the request claims. chromeHello is now pinned explicitly rather than tracking an alias a dependency bump can move, and identity.capToHello lowers the presented version to the one the handshake imitates, rewriting the UA string so the header, navigator.userAgent, navigator.userAgentData, Sec-CH-UA and the ClientHello all say 133. The trade-off, since it is a real one: the browser now reports a version older than its own engine, so a site comparing the User-Agent against JS features only present in the real build could notice. That check is rarer and more expensive than the TLS correlation, and claiming an older version is the safe direction — detectors flag claiming *newer* than the latest stable release. Capping only the probe was the alternative and was rejected: it puts the probe and the browser back in disagreement. Honest limit on the above: wappaGo itself was never observed being blocked on that host, before or after — 302 with 15 technologies and the real page title, Angular and TypeScript among them, so Chrome rendered the actual page rather than a challenge. The 403 reproduced 4/4 from an isolated probe carrying the same handshake and UA, and the http warm-up was ruled out as the difference, but the request volume of the investigation itself could not be excluded as a factor. Both fixes are coherence defects that stand on their own regardless. Drops (*Cmd).userAgent, now unused: probeIdentity supersedes it and returns the whole identity instead of one field, so a caller cannot pick up the User-Agent and forget the hints that go with it. Detection: 171 technologies over the 20-target reference set, no host at zero, stderr clean. gofmt -l . clean, go vet ./... clean, go test -race ./... green --- README.md | 17 +++++++++++--- cmd/browser.go | 55 +++++++++++++++++++++++++++++++++++++++++++++ cmd/headers_test.go | 41 ++++++++++++++++++++++++++++----- cmd/root.go | 46 ++++++++++++++++++++++++------------- cmd/transport.go | 23 +++++++++++++++---- 5 files changed, 154 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 83f637c..30b531f 100644 --- a/README.md +++ b/README.md @@ -90,9 +90,20 @@ wappaGo presents one consistent browser identity to every host it scans. The User-Agent is read from the installed Chrome at startup (with the `HeadlessChrome` token rewritten) and installed together with its matching Client Hints metadata, so `navigator.userAgent`, `navigator.userAgentData` and -the `Sec-CH-UA-*` headers all report the same build. The raw HTTP probe sends -the same User-Agent, `Accept` and `Accept-Language`. `--enable-automation` is not -passed, and WebGL is left enabled. +the `Sec-CH-UA-*` headers all report the same build. The raw HTTP probe derives +its `User-Agent`, `Sec-CH-UA-*`, `Accept` and `Accept-Language` from that same +identity. `--enable-automation` is not passed, and WebGL is left enabled. + +That identity is capped to the Chrome version the TLS handshake below can +actually imitate. uTLS ships no profile for the newest Chrome releases, and +claiming a browser newer than the handshake produces is a contradiction +bot-protection vendors correlate: against a DataDome-protected origin, a Chrome +133 handshake paired with a User-Agent claiming Chrome 150 was refused 4/4 with +`403` and a captcha page, while the same handshake with a matching User-Agent +passed 2/2. The cost is that the browser reports an older version than its own +engine, which a site could catch by comparing the User-Agent against JS features +only present in the real build — a rarer and more expensive check than the +TLS/User-Agent correlation, and claiming *older* is the safe direction. A measurement harness records what the browser actually gives away: diff --git a/cmd/browser.go b/cmd/browser.go index 650dedd..6f30327 100644 --- a/cmd/browser.go +++ b/cmd/browser.go @@ -182,6 +182,7 @@ func identityFromUserAgent(ua, product string) identity { if major := chromeMajor(ua); major != "" { id.Major = major } + id.capToHello() if strings.Contains(ua, "Windows") { id.Platform = "Windows" } else if strings.Contains(ua, "Mac OS X") { @@ -192,6 +193,44 @@ func identityFromUserAgent(ua, product string) identity { return id } +// capToHello lowers the presented version to the one the TLS handshake imitates. +// +// The probe's ClientHello comes from a uTLS profile, and uTLS has no profile for +// the newest Chrome releases. Presenting the installed browser's version while +// handshaking as an older one is a contradiction we introduce ourselves, and it +// is the kind bot-protection vendors correlate: against a DataDome origin the +// mismatched pair was refused 4/4 while the matched pair passed 2/2. +// +// The trade-off, stated plainly: the browser then claims to be older than its own +// engine, so a site comparing the User-Agent against JS features only present in +// the real build could notice. That check is far rarer and more expensive than the +// TLS/User-Agent correlation, and claiming an older version is the safe direction +// — claiming a version newer than the latest stable release is what detectors +// actually flag. Capping only the probe was the alternative, and it was rejected +// because it puts the probe and the browser back in disagreement, which is the +// defect this whole line of work started from. +func (id *identity) capToHello() { + if id.Major == "" || helloChromeMajor == "" { + return + } + have, err1 := strconv.Atoi(id.Major) + cap, err2 := strconv.Atoi(helloChromeMajor) + if err1 != nil || err2 != nil || have <= cap { + return + } + id.Major = helloChromeMajor + id.Full = helloChromeMajor + ".0.0.0" + // Rewrite the version inside the UA string so every surface agrees. + if i := strings.Index(id.UserAgent, "Chrome/"); i >= 0 { + rest := id.UserAgent[i+len("Chrome/"):] + end := 0 + for end < len(rest) && (rest[end] == '.' || (rest[end] >= '0' && rest[end] <= '9')) { + end++ + } + id.UserAgent = id.UserAgent[:i] + "Chrome/" + id.Full + rest[end:] + } +} + // fallbackIdentity is used only when the browser cannot be queried. It is a // last resort, not a default: a hardcoded version is precisely the thing that // contradicted the Client Hints. @@ -281,6 +320,22 @@ func applyIdentity(id identity) chromedp.Action { }) } +// secChUa renders the identity as a Sec-CH-UA header value, in the same brand +// order and with the same bogus entry the browser itself sends. +// +// The raw probe used to hardcode `"Google Chrome";v="N", "Chromium";v="N", +// "Not_A Brand";v="24"` — the Chrome-110-era spelling, with the brands in the +// opposite order to what Chrome now emits. So after commit "one coherent browser +// identity" the User-Agent finally agreed between the probe and the browser while +// the Client Hints still contradicted each other. Both now come from here. +func (id identity) secChUa() string { + parts := make([]string, 0, len(id.metadata().Brands)) + for _, b := range id.metadata().Brands { + parts = append(parts, `"`+b.Brand+`";v="`+b.Version+`"`) + } + return strings.Join(parts, ", ") +} + // metadata mirrors what a real Chrome of this version reports, including the // deliberately-bogus "Not)A;Brand" entry Chrome uses to keep parsers honest. func (id identity) metadata() *emulation.UserAgentMetadata { diff --git a/cmd/headers_test.go b/cmd/headers_test.go index fc4666d..d1d7e51 100644 --- a/cmd/headers_test.go +++ b/cmd/headers_test.go @@ -20,16 +20,17 @@ func TestSetBrowserHeadersOnWire(t *testing.T) { })) defer srv.Close() + id := fallbackIdentity() req, _ := http.NewRequest("GET", srv.URL, nil) - setBrowserHeaders(req, structure.DefaultUserAgent) + setBrowserHeaders(req, id) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() - if ua := got.Get("User-Agent"); ua != structure.DefaultUserAgent { - t.Errorf("User-Agent = %q, want %q", ua, structure.DefaultUserAgent) + if ua := got.Get("User-Agent"); ua != id.UserAgent { + t.Errorf("User-Agent = %q, want %q", ua, id.UserAgent) } if strings.Contains(got.Get("User-Agent"), "Go-http-client") { t.Errorf("default Go User-Agent leaked") @@ -39,8 +40,38 @@ func TestSetBrowserHeadersOnWire(t *testing.T) { t.Errorf("missing browser header %q", hdr) } } - if v := got.Get("Sec-Ch-Ua"); !strings.Contains(v, `v="131"`) { - t.Errorf("Sec-CH-UA = %q, want it derived from Chrome 131", v) + if v := got.Get("Sec-Ch-Ua"); !strings.Contains(v, `v="`+id.Major+`"`) { + t.Errorf("Sec-CH-UA = %q, want it derived from the identity's major %q", v, id.Major) + } +} + +// TestProbeSecChUaMatchesTheBrowsers pins the second half of the identity fix. +// The User-Agent was made coherent between probe and browser, but the probe kept +// emitting a hardcoded Chrome-110-era Sec-CH-UA — brands in the opposite order to +// what Chrome sends, and "Not_A Brand";v="24" instead of "Not)A;Brand";v="99". +// A WAF comparing the two clients saw two different browsers. +func TestProbeSecChUaMatchesTheBrowsers(t *testing.T) { + id := identity{UserAgent: structure.DefaultUserAgent, Major: "150", Full: "150.0.0.0", Platform: "Windows"} + + req, _ := http.NewRequest("GET", "https://example.test", nil) + setBrowserHeaders(req, id) + probe := req.Header.Get("Sec-Ch-Ua") + + // Rebuild what Chrome is told to report, from the same metadata. + var browser []string + for _, b := range id.metadata().Brands { + browser = append(browser, `"`+b.Brand+`";v="`+b.Version+`"`) + } + want := strings.Join(browser, ", ") + + if probe != want { + t.Errorf("probe and browser disagree on Sec-CH-UA:\n probe = %s\n browser = %s", probe, want) + } + if strings.Contains(probe, "Not_A Brand") { + t.Errorf("probe still sends the stale Chrome-110 brand spelling: %s", probe) + } + if got := req.Header.Get("Sec-Ch-Ua-Platform"); got != `"Windows"` { + t.Errorf("Sec-CH-UA-Platform = %q, want it from the identity", got) } } diff --git a/cmd/root.go b/cmd/root.go index f84ac1c..f4fe6f9 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -347,7 +347,7 @@ func (c *Cmd) getWrapper(urlData string, port string, data structure.Data, resul if port != "80" { c.throttle.wait(urlData) request, _ := http.NewRequest("GET", "https://"+urlDataPort, nil) - setBrowserHeaders(request, c.userAgent()) + setBrowserHeaders(request, c.probeIdentity()) resp, errSSL = Do(request, client) } if errSSL != nil || port == "80" { @@ -356,7 +356,7 @@ func (c *Cmd) getWrapper(urlData string, port string, data structure.Data, resul } else { c.throttle.wait(urlData) request, _ := http.NewRequest("GET", "http://"+urlDataPort, nil) - setBrowserHeaders(request, c.userAgent()) + setBrowserHeaders(request, c.probeIdentity()) resp, errPlain := Do(request, client) if errPlain != nil || resp == nil { @@ -585,27 +585,39 @@ func (c *Cmd) scanPort(protocol, hostname string, port string, portTimeout int) return true } -// userAgent returns the User-Agent presented to scanned hosts. Once Start has -// read the real browser build this is that build's UA; before then (or if the -// browser could not be queried) it is the explicit -user-agent, then the -// built-in fallback. -func (c *Cmd) userAgent() string { - if c.identity.UserAgent != "" { - return c.identity.UserAgent +// probeIdentity is the identity the raw HTTP probe presents. It is the same one +// the browser gets; when Start has not resolved it yet (library callers, tests) +// it falls back to the built-in one so the header set stays self-consistent +// rather than half-filled. +func (c *Cmd) probeIdentity() identity { + id := c.identity + if id.UserAgent == "" { + id = fallbackIdentity() } if c.Options.UserAgent != nil && *c.Options.UserAgent != "" { - return *c.Options.UserAgent + id.UserAgent = *c.Options.UserAgent + // An operator-supplied UA drives the Client Hints too, so the two cannot + // end up describing different browsers. + if major := chromeMajor(id.UserAgent); major != "" { + id.Major = major + if full := versionAfter(id.UserAgent, "Chrome/"); full != "" { + id.Full = full + } + } + } + if id.Platform == "" { + id.Platform = "Windows" } - return structure.DefaultUserAgent + return id } // setBrowserHeaders makes the raw HTTP probe look like the same Chrome that // drives the rendering pass, so a WAF sees one consistent browser instead of // the default "Go-http-client/1.1". Accept-Encoding is left unset on purpose so // Go keeps decompressing gzip transparently. -func setBrowserHeaders(req *http.Request, ua string) { +func setBrowserHeaders(req *http.Request, id identity) { h := req.Header - h.Set("User-Agent", ua) + h.Set("User-Agent", id.UserAgent) // Byte-for-byte what Chrome 1xx sends on a document navigation. The probe // used to omit the signed-exchange entry and to hardcode a language Chrome // itself did not use, so the two clients disagreed on every host. @@ -616,10 +628,12 @@ func setBrowserHeaders(req *http.Request, ua string) { h.Set("Sec-Fetch-Mode", "navigate") h.Set("Sec-Fetch-Site", "none") h.Set("Sec-Fetch-User", "?1") - if v := chromeMajor(ua); v != "" { - h.Set("Sec-Ch-Ua", `"Google Chrome";v="`+v+`", "Chromium";v="`+v+`", "Not_A Brand";v="24"`) + // Derived from the same identity the browser is given, so the probe and + // Chrome cannot disagree on the brands, their order or the version. + if id.Major != "" { + h.Set("Sec-Ch-Ua", id.secChUa()) h.Set("Sec-Ch-Ua-Mobile", "?0") - h.Set("Sec-Ch-Ua-Platform", `"Windows"`) + h.Set("Sec-Ch-Ua-Platform", `"`+id.Platform+`"`) } } diff --git a/cmd/transport.go b/cmd/transport.go index ffcf2bf..812cc80 100644 --- a/cmd/transport.go +++ b/cmd/transport.go @@ -22,10 +22,25 @@ import ( // whichever protocol ALPN settled on, so the HTTP/2 SETTINGS/WINDOW_UPDATE // fingerprint comes from x/net/http2 rather than from a downgrade to HTTP/1.1. // -// chromeHello tracks the newest profile the uTLS build knows about, which is the -// right default: pinning an old Chrome would itself be a mismatch with the -// User-Agent resolved from the installed browser. -var chromeHello = utls.HelloChrome_Auto +// chromeHello is pinned rather than set to HelloChrome_Auto. +// +// Auto is an alias for whatever the uTLS build considers newest (133 in v1.8.2), +// so a dependency bump silently changes the handshake this tool presents. That +// matters because the handshake and the claimed User-Agent are correlated by +// bot-protection vendors: measured against a DataDome-protected origin, the +// 133 hello paired with a User-Agent claiming Chrome 150 was refused 4/4 with +// HTTP 403 and a captcha page, while the same hello paired with a matching +// Chrome 133 User-Agent passed 2/2. Which profile ships is therefore a decision +// to make deliberately and to keep in step with helloChromeMajor below. +var chromeHello = utls.HelloChrome_133 + +// helloChromeMajor is the Chrome major version chromeHello imitates. +// +// The identity presented to a host is capped to this, because claiming a browser +// newer than the handshake we can actually produce is an inconsistency we create +// ourselves. uTLS has no profile for Chrome 150, so a scanner running on a newer +// Chrome presents this version instead of its own. +const helloChromeMajor = "133" // stealthTransport is a RoundTripper that gives every request a browser-shaped // TLS handshake. From dd2f8f0da2cea184e5e4225f77759622008ca522 Mon Sep 17 00:00:00 2001 From: Serizao Date: Thu, 30 Jul 2026 14:10:39 +0200 Subject: [PATCH 22/22] feat(stealth): close the WebRTC IP leak, drop headless, disable Runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four remaining gaps, each verified by measurement before being acted on. Two of the measurements contradicted claims this file made in earlier commits. WebRTC was the serious one, and it is not fingerprinting but deanonymisation. No WebRTC flag was passed at all, so a scanned page could open an RTCPeerConnection to a public STUN server and read the host's real public address — over UDP, which does not traverse an HTTP proxy, so straight past -proxy. The LAN address was never exposed; Chrome already hides it behind an mDNS name. The obvious remedy does not work. Measured on Chrome 150, reproduced twice: (none) host=1 srflx=1 --force-webrtc-ip-handling-policy=disable_non_proxied_udp host=1 srflx=1 --force-webrtc-ip-handling-policy=default_public_interface_only host=1 srflx=1 --webrtc-ip-handling-policy=default_public_interface_only host=0 srflx=1 --webrtc-ip-handling-policy=disable_non_proxied_udp host=0 srflx=0 Only the older flag name closes it, and it leaves RTCPeerConnection defined, so the leak goes without a "browser with no WebRTC" tell taking its place. Applied when -proxy is set, which is when hiding the address is the point. Headless is no longer the default. It costs an 800x600 screen whatever the window size — a 1400x900 viewport claiming to sit on a smaller display — and it brands its own User-Agent. With a real display both are simply correct (screen=1920x1200, clean UA) and no metrics override is issued at all, one fewer emulated surface. -headless remains for hosts without a display, and a failed launch now points at xvfb-run rather than failing with a bare error. Runtime.enable is closed, and the reason it was deferred was wrong. The previous commit claimed fixing it "imposes patching chromedp (fork or replace)". It does not: chromedp calls runtime.Enable() once at tab attach, and the execution-context IDs it derives are read only by its selector-based actions. wappaGo uses none of them — the complete list is Evaluate, Navigate, Title, CaptureScreenshot, Sleep, ActionFunc, ListenTarget, and Title is itself an EvaluateAsDevTools. Since enable only controls events while Runtime.evaluate is a command that works regardless, runtime.Disable() at the head of the action chain closes the leak and leaves all 3155 js patterns running in the main world, which they require. One line, no fork. window.outerWidth/outerHeight stay 0, and the explanation given for it twice was wrong both times — first "inherent to headless", then "caused by setDeviceMetricsOverride". Measured through chromedp, every combination reports 0: headless or display, override or not, with or without Browser.setWindowBounds. Raw Chrome opening a URL does report the window size, so it comes from the tab chromedp attaches to via Target.createTarget, which has no window. Nothing over CDP changes it; the injected-script workaround would leave a getter that no longer reports [native code], a broader tell than the one it fixes. Documented as the residual it is, with the right reason this time. On software rendering, the earlier reasoning was incomplete. "Do not randomise, because randomisation is itself a signal" is true but insufficient: a stable value that is a *known* value identifies just as well, and the graphics backend does move both fingerprints — real GPU canvas2D d0c568f8 webgl 5796a780 35 exts SwiftShader canvas2D f4d87128 webgl b30b83b0 36 exts --disable-gpu canvas2D 693631e8 webgl b30b83b0 34 exts so canvas 2D is not GPU-independent as assumed. There is no CDP override for WebGL, so masking it means patching JS. Measured against the hosted detector, forcing SwiftShader changed no verdict — the same greens as a real GPU. That is a negative result from one detector, not proof of safety, so nothing is patched and the limitation is documented instead. The harness gains a WebRTC probe that reports its ICE candidates back over HTTP, since gathering is asynchronous and chromedp.Evaluate would only ever catch "pending". It also records the no-proxy case, where the mitigation is deliberately off: 0 candidates with -proxy against 2 (1 server-reflexive) without, which proves the probe can actually observe a leak rather than passing vacuously. Detection: 179 technologies over the 20-target reference set, no host at zero, stderr clean. 60 tests. gofmt -l . clean, go vet ./... clean, go test -race ./... green --- README.md | 67 +++++++++++++--- cmd/browser.go | 100 ++++++++++++++++++----- cmd/browser_stealth_test.go | 155 +++++++++++++++++++++++++++++++++--- cmd/root.go | 28 ++++++- main.go | 1 + structure/structure.go | 11 ++- wrapper/root.go | 1 + 7 files changed, 320 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 30b531f..04b6292 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,9 @@ Usage of wappaGo: Number of chromes threads in each main threads total = option.threads*option.chrome-threads (Default 5) (default 5) -follow-redirect Follow redirect to detect technologie + -headless + Run Chrome without a display. Off by default: headless fakes an 800x600 + screen and brands its own User-Agent. Use xvfb-run on a server instead -jitter int Max random delay in ms added before each request (0 = none) -no-progress @@ -94,6 +97,31 @@ the `Sec-CH-UA-*` headers all report the same build. The raw HTTP probe derives its `User-Agent`, `Sec-CH-UA-*`, `Accept` and `Accept-Language` from that same identity. `--enable-automation` is not passed, and WebGL is left enabled. +**Chrome runs with a display by default.** Headless is available behind +`-headless`, but it costs two things that then have to be papered over: it reports +an 800x600 screen whatever the window size, so a 1400x900 viewport claims to sit +on a smaller display, and it brands its own User-Agent. With a real display both +are simply correct — measured `screen=1920x1200` and a clean UA — and no screen +override is issued at all. On a server, run it under a virtual display: + +```bash +xvfb-run -a --server-args="-screen 0 1920x1080x24" ./wappaGo < domain.txt +``` + +Without a display and without `-headless`, Chrome will not start; wappaGo says so +and points at both options rather than failing with a bare error. + +`RTCPeerConnection` leaks the host's real public address when `-proxy` is in use: +WebRTC negotiates over UDP and does not traverse an HTTP proxy, so a scanned page +can reach a public STUN server and read straight past the proxy. That is +deanonymisation rather than fingerprinting, so `-proxy` also enables +`--webrtc-ip-handling-policy=disable_non_proxied_udp`, which drops both the +server-reflexive and host candidates while leaving `RTCPeerConnection` defined — +closing the leak without substituting a "browser with no WebRTC" tell. Chrome +already hides the LAN address behind an mDNS name by default. The +`--force-webrtc-ip-handling-policy` spelling was measured to have no effect at all +on Chrome 150; only the older flag name works. + That identity is capped to the Chrome version the TLS handshake below can actually imitate. uTLS ships no profile for the newest Chrome releases, and claiming a browser newer than the handshake produces is a contradiction @@ -111,19 +139,33 @@ A measurement harness records what the browser actually gives away: WAPPAGO_STEALTH_CHECK=1 go test ./cmd/ -run Stealth -v ``` -It serves its own page locally, so the verdicts are reproducible. Two known -limits it reports rather than hides: +It serves its own page locally, so the verdicts are reproducible, and it also +drives the hosted detector at `bot-detector.rebrowser.net`: + +```bash +WAPPAGO_STEALTH_REMOTE=1 go test ./cmd/ -run RemoteDetector -v +``` -- **`window.outerWidth` / `outerHeight` are 0.** Headless Chrome has no real - window and no CDP call changes this. The usual workaround redefines the - accessor from an injected script, which leaves a getter that no longer reports - `[native code]` — a broader tell than the one it fixes, so it is not done. -- **WebGL quality depends on the host.** On a machine with a GPU, Chrome reports - the real adapter, which is the most plausible fingerprint available. On a - GPU-less server or container it falls back to software rendering and the - renderer string says so (`SwiftShader`, `Microsoft Basic Render Driver`, - `llvmpipe`). The harness logs a `NOTE:` when that happens. Running with a GPU, - or with a virtual display, gives a stronger identity. +Two known limits it reports rather than hides: + +- **`window.outerWidth` / `outerHeight` are 0.** This comes from the tab chromedp + attaches to through `Target.createTarget`, which has no window of its own — + not from headless, and not from the metrics override. Measured: every + combination of headless/display, override/no override and + `Browser.setWindowBounds` reports 0, while raw Chrome opening a URL itself + reports the window size. Nothing reachable over CDP changes it, and the usual + workaround redefines the accessor from an injected script, which leaves a getter + that no longer reports `[native code]` — a broader tell than the one it fixes. +- **WebGL quality depends on the host.** With a GPU, Chrome reports the real + adapter, the most plausible fingerprint available. On a GPU-less server it falls + back to software rendering and says so (`SwiftShader`, `llvmpipe`, + `Microsoft Basic Render Driver`), and the canvas hash shifts with it — a stable + value that is a *known* value is still identifying. There is no CDP override for + WebGL, so masking it would mean patching JS. Measured against the hosted + detector, forcing SwiftShader changed no verdict at all: the same greens as a + real GPU. That is a negative result from one detector rather than proof of + safety — CreepJS and commercial vendors do fingerprint WebGL — so nothing is + patched for now and the harness logs a `NOTE:` when software rendering is in use. Canvas, WebGL and audio fingerprints are deliberately **not** randomised. The goal is to look ordinary, not unique: a fingerprint that changes between two @@ -185,6 +227,7 @@ type WrapperOptions struct { UserAgent string Rps float64 Jitter int + Headless bool } ``` diff --git a/cmd/browser.go b/cmd/browser.go index 6f30327..ee66548 100644 --- a/cmd/browser.go +++ b/cmd/browser.go @@ -72,7 +72,25 @@ func chromeAllocatorOptions(options structure.Options, userAgent string) []chrom chromedp.Flag("force-color-profile", "srgb"), chromedp.WindowSize(viewportWidth, viewportHeight), - chromedp.Headless, + } + + // Headless is opt-in, not the default. It costs two things that then have to + // be papered over: Chrome reports an 800x600 screen whatever the window size + // (so a 1400x900 viewport claims to sit on a smaller display), and it writes + // "HeadlessChrome" into its own User-Agent. With a real display — Xvfb on a + // server — the screen is genuine and the UA is clean, measured: + // + // chromedp headless, no override screen=800x600 UA contains Headless + // chromedp with a display screen=1920x1200 UA clean + // + // The cost is a display being required; cmd.Start turns the resulting launch + // failure into an actionable message rather than a bare "could not start". + if options.Headless != nil && *options.Headless { + opts = append(opts, chromedp.Headless) + } else { + // Keep the window off-screen so an interactive desktop is not flooded + // with browser windows during a scan. + opts = append(opts, chromedp.Flag("window-position", "-32000,-32000")) } // Notable omissions, each one a measured decision: @@ -102,6 +120,33 @@ func chromeAllocatorOptions(options structure.Options, userAgent string) []chrom } if options.Proxy != nil && *options.Proxy != "" { opts = append(opts, chromedp.ProxyServer(*options.Proxy)) + // Close the WebRTC IP leak, which matters precisely when a proxy is in + // use: WebRTC negotiates over UDP and does not traverse an HTTP proxy, so + // a scanned page could open an RTCPeerConnection to a public STUN server + // and read the host's real public address straight past the proxy. That is + // deanonymisation, not fingerprinting. + // + // The flag name matters and is counter-intuitive. Measured on Chrome 150, + // one page per row, reproduced twice: + // + // (none) host=1 srflx=1 + // --force-webrtc-ip-handling-policy=disable_non_proxied_udp + // host=1 srflx=1 + // --force-webrtc-ip-handling-policy=default_public_interface_only + // host=1 srflx=1 + // --webrtc-ip-handling-policy=default_public_interface_only + // host=0 srflx=1 + // --webrtc-ip-handling-policy=disable_non_proxied_udp + // host=0 srflx=0 + // + // So the modern-looking `--force-` spelling does nothing here, and only + // the older name closes it. Do not "modernise" this without re-measuring. + // + // RTCPeerConnection stays defined either way, so the leak is closed + // without creating a "browser with no WebRTC" tell in its place. Chrome + // already hides the LAN address behind an mDNS name by default, so the + // public address was the only one exposed. + opts = append(opts, chromedp.Flag("webrtc-ip-handling-policy", "disable_non_proxied_udp")) } return opts } @@ -288,9 +333,15 @@ func captureScreenshot(enabled bool, buf *[]byte) chromedp.Action { // applyIdentity installs the identity on a target: the User-Agent together with // the Client Hints metadata, so navigator.userAgent, navigator.userAgentData and -// the Sec-CH-UA-* headers all agree; a viewport that fits inside a plausible -// screen; and a locale matching the Accept-Language the probe sends. -func applyIdentity(id identity) chromedp.Action { +// the Sec-CH-UA-* headers all agree, and a locale matching the Accept-Language +// the probe sends. +// +// headless selects whether the screen has to be faked. With a real display the +// screen is genuine, so no metrics override is issued at all — one fewer CDP call +// and one fewer emulated surface. Under -headless Chrome insists on 800x600 +// regardless of the window, which would put a 1400x900 viewport on a smaller +// display, so the override is applied to compensate. +func applyIdentity(id identity, headless bool) chromedp.Action { return chromedp.ActionFunc(func(ctx context.Context) error { if id.UserAgent != "" { if err := emulation.SetUserAgentOverride(id.UserAgent). @@ -300,22 +351,33 @@ func applyIdentity(id identity) chromedp.Action { return err } } - // Declare the screen the viewport lives on, so screen.width/height stop - // reporting the headless 800x600 default. - if err := emulation.SetDeviceMetricsOverride(viewportWidth, viewportHeight, 1, false). - WithScreenWidth(screenWidth). - WithScreenHeight(screenHeight). - WithPositionX(0). - WithPositionY(0). - Do(ctx); err != nil { - return err + if headless { + if err := emulation.SetDeviceMetricsOverride(viewportWidth, viewportHeight, 1, false). + WithScreenWidth(screenWidth). + WithScreenHeight(screenHeight). + WithPositionX(0). + WithPositionY(0). + Do(ctx); err != nil { + return err + } } - // Browser.setWindowBounds was tried here to give - // window.outerWidth/outerHeight a non-zero value. The harness measured no - // effect — headless has no real window — and it made - // Page.captureScreenshot fail with -32000 on heavy pages, which took the - // whole detection pass down with it. Removed: it cost detections and - // bought nothing. The zero outer dimensions are a documented residual. + // window.outerWidth/outerHeight stay 0. This is a documented residual, and + // the earlier explanations for it in this file were both wrong: it is not + // caused by headless, and not by the metrics override either. Measured + // through chromedp, every combination reports 0: + // + // headless, no override outer=0x0 screen=800x600 + // headless, override outer=0x0 screen=1920x1080 + // headless + setWindowBounds outer=0x0 + // display, no override outer=0x0 screen=1920x1200 + // display + setWindowBounds outer=0x0 + // + // Raw Chrome opening a URL itself does report 1400x900, so the zero comes + // from the tab chromedp attaches to via Target.createTarget, which has no + // window of its own. Nothing reachable over CDP changes it, and the usual + // workaround — redefining the accessor from an injected script — leaves a + // getter that no longer reports [native code], a broader tell than the one + // it fixes. return nil }) } diff --git a/cmd/browser_stealth_test.go b/cmd/browser_stealth_test.go index bde507c..ef6c42b 100644 --- a/cmd/browser_stealth_test.go +++ b/cmd/browser_stealth_test.go @@ -306,7 +306,7 @@ func runProbe(t *testing.T) (probeResult, http.Header) { var raw []byte err = chromedp.Run(ctx, - applyIdentity(ident), + applyIdentity(ident, false), chromedp.Navigate(srv.URL), chromedp.ActionFunc(func(ctx context.Context) error { return chromedp.Evaluate(`JSON.stringify(window.__probe)`, &raw).Do(ctx) @@ -392,15 +392,21 @@ func TestStealthReport(t *testing.T) { t.Logf(" webgl UNAVAILABLE %s", res.WebGL.Error) } - // Known residual, reported rather than hidden: headless Chrome has no real - // window, so window.outerWidth/outerHeight are 0 and neither - // Emulation.setDeviceMetricsOverride nor Browser.setWindowBounds changes - // that. The only known workaround is redefining the accessor from an - // injected script, which leaves a getter that no longer reports - // [native code] — trading a narrow leak for a broader one. Left alone - // deliberately; revisit only with a measurement showing it matters. + // Known residual, reported rather than hidden. It comes from the tab chromedp + // attaches to via Target.createTarget, which has no window of its own — not + // from headless, and not from the metrics override, both of which were blamed + // here before and measured innocent: + // + // headless, no override outer=0x0 display, no override outer=0x0 + // headless, override outer=0x0 display, override outer=0x0 + // headless + windowBounds outer=0x0 display + windowBounds outer=0x0 + // + // Raw Chrome opening a URL itself does report the window size, so nothing + // reachable over CDP fixes this. The usual workaround redefines the accessor + // from an injected script, which leaves a getter that no longer reports + // [native code] — a broader tell than the one it fixes. if res.OuterWidth == 0 || res.OuterHeight == 0 { - t.Logf(" RESIDUAL: window.outerWidth/outerHeight are 0 (inherent to headless, not patched on purpose)") + t.Logf(" RESIDUAL: window.outerWidth/outerHeight are 0 (chromedp target has no window; not patched on purpose)") } t.Log("=== on the wire ===") @@ -488,6 +494,135 @@ func TestStealthWebGLAvailable(t *testing.T) { } } +// webrtcProbe reports its ICE candidates back to the test server rather than +// leaving them in the DOM. +// +// ICE gathering is asynchronous and chromedp.Evaluate returns immediately, so +// reading window state would only ever catch "pending" — the page has to push the +// result once gathering completes. +const webrtcProbe = `` + +type webrtcResult struct { + Tag string `json:"tag"` + Candidates []string `json:"candidates"` +} + +// runWebrtcProbe drives the production configuration at the WebRTC page and +// returns what the page managed to gather. proxy mirrors -proxy, which is what +// gates the mitigation. +func runWebrtcProbe(t *testing.T, proxy string) webrtcResult { + t.Helper() + + var mu sync.Mutex + var got *webrtcResult + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + var res webrtcResult + if err := json.NewDecoder(r.Body).Decode(&res); err == nil { + mu.Lock() + if got == nil { + got = &res + } + mu.Unlock() + } + w.WriteHeader(http.StatusNoContent) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write([]byte(webrtcProbe)) + })) + defer srv.Close() + + opts := structure.Options{Proxy: &proxy} + opts.ApplyDefaults() + allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(), + chromeAllocatorOptions(opts, "")...) + defer cancelAlloc() + browserCtx, cancelBrowser := chromedp.NewContext(allocCtx) + defer cancelBrowser() + ctx, cancel := context.WithTimeout(browserCtx, 90*time.Second) + defer cancel() + + if err := chromedp.Run(ctx, chromedp.Navigate(srv.URL), chromedp.Sleep(10*time.Second)); err != nil { + t.Fatalf("webrtc probe run: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if got == nil { + t.Fatal("the page never reported its ICE candidates") + } + return *got +} + +// TestStealthWebRTCDoesNotLeakTheHostAddress is the regression guard for a leak +// that is not fingerprinting but deanonymisation: WebRTC negotiates over UDP and +// does not traverse an HTTP proxy, so a scanned page could open an +// RTCPeerConnection to a public STUN server and read the scanner's real public +// address straight past -proxy. +// +// The mitigation is gated on -proxy, so that is what this asserts. Note the flag +// name is the older --webrtc-ip-handling-policy; the --force- spelling was +// measured to have no effect at all on Chrome 150. +func TestStealthWebRTCDoesNotLeakTheHostAddress(t *testing.T) { + requireStealthCheck(t) + + res := runWebrtcProbe(t, "http://127.0.0.1:1") + t.Logf("with -proxy: tag=%s, %d candidate(s)", res.Tag, len(res.Candidates)) + for _, c := range res.Candidates { + t.Logf(" %s", c) + } + if res.Tag == "NO_API" { + t.Error("RTCPeerConnection is gone entirely: the leak is closed, but a browser " + + "without WebRTC is itself unusual — the flag should keep the API present") + } + for _, c := range res.Candidates { + if strings.Contains(c, "typ srflx") { + t.Errorf("server-reflexive candidate leaked the host's public address past the proxy: %s", c) + } + if strings.Contains(c, "typ host") { + t.Errorf("host candidate leaked past the proxy: %s", c) + } + } + + // Without -proxy the mitigation is deliberately not applied, on the grounds + // that the scanned host sees the real address anyway. Recording it here keeps + // that decision visible and, just as importantly, proves the probe above is + // capable of observing a leak — a test that can only ever pass is worthless. + bare := runWebrtcProbe(t, "") + leaked := 0 + for _, c := range bare.Candidates { + if strings.Contains(c, "typ srflx") { + leaked++ + } + } + t.Logf("without -proxy (mitigation deliberately off): tag=%s, %d candidate(s), %d server-reflexive", + bare.Tag, len(bare.Candidates), leaked) + if leaked == 0 { + t.Log("NOTE: no leak observed without -proxy either. Either the network blocks STUN or Chrome " + + "changed its default — if the latter, the proxy gating is no longer load-bearing and this " + + "test has stopped proving anything.") + } +} + // TestStealthRemoteDetector runs the shipping configuration against // rebrowser's hosted detector, which checks things a local page cannot observe — // above all the Runtime.enable CDP leak. @@ -523,7 +658,7 @@ func TestStealthRemoteDetector(t *testing.T) { var raw []byte if err := chromedp.Run(ctx, - applyIdentity(ident), + applyIdentity(ident, false), chromedp.Navigate("https://bot-detector.rebrowser.net/"), // The page runs its checks on load; the last one is a network call. chromedp.Sleep(8*time.Second), diff --git a/cmd/root.go b/cmd/root.go index f4fe6f9..9bda55b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -28,6 +28,7 @@ import ( "github.com/chromedp/cdproto/dom" "github.com/chromedp/cdproto/network" "github.com/chromedp/cdproto/page" + "github.com/chromedp/cdproto/runtime" "github.com/chromedp/chromedp" "github.com/goccy/go-json" "github.com/projectdiscovery/cdncheck" @@ -162,6 +163,13 @@ func (c *Cmd) Start(results chan structure.Data) { // results channel is closed so the consumer drains and exits cleanly, // rather than panicking and skipping cleanup. fmt.Fprintf(os.Stderr, "could not start Chrome: %v\n", err) + if !*c.Options.Headless { + // A real display is the default now, so this is the likely cause on a + // server and the bare error would not say so. + fmt.Fprintln(os.Stderr, "hint: Chrome runs with a display by default. On a headless server, "+ + "wrap the command in xvfb-run, or pass -headless (which fakes an 800x600 screen "+ + "and brands the User-Agent).") + } close(results) return } @@ -434,9 +442,27 @@ func (c *Cmd) launchChrome(TempResp structure.Response, data structure.Data, url c.throttle.wait(data.Infos.Data) err = chromedp.Run(cloneCTX, + // Close the Runtime.enable CDP leak before anything loads. + // + // chromedp calls runtime.Enable() once when it attaches to the tab + // (chromedp@v0.16.0/chromedp.go:445), and a page can observe that the + // domain is enabled — the most widely cited CDP detection method. + // Disabling it again costs nothing here: enable only controls *events*, + // while Runtime.evaluate is a command that works either way, so all 3155 + // js patterns keep running in the main world, which they require. + // + // The one thing this gives up is chromedp's execution-context tracking + // (Target.execContexts), read only by its selector-based actions — + // WaitVisible, Nodes, Query, Poll and friends. wappaGo uses none of them: + // the complete list is Evaluate, Navigate, Title, CaptureScreenshot, + // Sleep, ActionFunc and ListenTarget, and Title is itself an + // EvaluateAsDevTools. Adding a selector action later means removing this. + chromedp.ActionFunc(func(ctx context.Context) error { + return runtime.Disable().Do(ctx) + }), // Install the User-Agent and its matching Client Hints before the first // navigation, so the very first request carries a consistent identity. - applyIdentity(c.identity), + applyIdentity(c.identity, *c.Options.Headless), chromedp.Navigate(urlData), chromedp.Title(&data.Infos.Title), // The screenshot is optional and must never be able to cost a detection. diff --git a/main.go b/main.go index 696d91f..705d431 100644 --- a/main.go +++ b/main.go @@ -31,6 +31,7 @@ func main() { options.Rps = flag.Float64("rps", 0, "Max HTTP requests per second per host to stay under rate-based WAF rules (0 = unlimited)") options.Jitter = flag.Int("jitter", 0, "Max random delay in ms added before each request (0 = none)") options.NoProgress = flag.Bool("no-progress", false, "Disable the stderr progress bar") + options.Headless = flag.Bool("headless", false, "Run Chrome without a display. Off by default: headless fakes an 800x600 screen and brands its own User-Agent. Needs a display otherwise (use xvfb-run on a server)") flag.Parse() configure(options) } diff --git a/structure/structure.go b/structure/structure.go index d540479..55fa493 100644 --- a/structure/structure.go +++ b/structure/structure.go @@ -70,6 +70,12 @@ type Options struct { Rps *float64 Jitter *int NoProgress *bool + // Headless runs Chrome without a display. It defaults to false: headless + // reports an 800x600 screen whatever the window size and writes + // "HeadlessChrome" into its own User-Agent, both of which have to be papered + // over. Running with a real display (Xvfb on a server) avoids that entirely. + // Requires a display to be available — see the note in cmd.Start. + Headless *bool } // Default values for Options. They are the single source of truth: main.go @@ -127,7 +133,7 @@ func (o *Options) ApplyDefaults() { *p = &v } } - for _, p := range []**bool{&o.AmassInput, &o.FollowRedirect, &o.Report, &o.NoProgress} { + for _, p := range []**bool{&o.AmassInput, &o.FollowRedirect, &o.Report, &o.NoProgress, &o.Headless} { flag(p) } if o.ChromeTimeout == nil { @@ -166,6 +172,9 @@ type WrapperOptions struct { UserAgent string Rps float64 Jitter int + // Headless runs Chrome without a display. Default false; requires a display + // (Xvfb on a server) when left off. + Headless bool } // Response is the raw HTTP probe result. It only carries what is actually diff --git a/wrapper/root.go b/wrapper/root.go index e1a782d..56aedec 100644 --- a/wrapper/root.go +++ b/wrapper/root.go @@ -77,6 +77,7 @@ func configureOptions(wrapperOptions structure.WrapperOptions) (*cmd.Cmd, error) Rps: &wrapperOptions.Rps, Jitter: &wrapperOptions.Jitter, NoProgress: &noProgress, // library mode: no stderr progress bar + Headless: &wrapperOptions.Headless, } // A zero-valued WrapperOptions used to reach cmd with Threads=0 and // Porttimeout=0 — including in the README's own example.