From 3af8612ce5427e5abe3a43713b3d68b3e4329777 Mon Sep 17 00:00:00 2001 From: Rustem Kamalov Date: Wed, 15 Apr 2026 02:36:55 +0300 Subject: [PATCH] Fingerprint checks during browser integration tests --- .gitignore | 8 + core/browser_test.go | 399 ++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 388 insertions(+), 19 deletions(-) diff --git a/.gitignore b/.gitignore index 87393cb..efc2398 100644 --- a/.gitignore +++ b/.gitignore @@ -23,8 +23,16 @@ dev.md logrus.txt logs.txt .release +!.release/ +.release/* +!.release/site/ +!.release/site/** core/test/ .aider* .gocache/ openserp .gomodcache/ +AGENTS.md +.claude +sanitize_html_fixtures.py +core/testdata/* diff --git a/core/browser_test.go b/core/browser_test.go index 4cfe8fd..a5a9cc8 100644 --- a/core/browser_test.go +++ b/core/browser_test.go @@ -4,46 +4,407 @@ package core import ( + "encoding/json" + "fmt" "os" + "path/filepath" + "slices" + "sort" "strings" "testing" + "time" + "github.com/go-rod/rod" "github.com/karust/openserp/testutil" ) -var browser *Browser +const botFingerprintTestsEnv = "OPENSERP_BOT_TESTS" +const botFingerprintArtifactDir = "testdata" + +var criticalSannysoftChecks = []string{"webdriver"} + +const sannysoftURL = "https://bot.sannysoft.com" func TestCreateBrowser(t *testing.T) { testutil.RequireIntegration(t) - var err error opts := BrowserOpts{IsHeadless: true, IsLeakless: false} - browser, err = NewBrowser(opts) + browser, err := NewBrowser(opts) if err != nil { t.Fatalf("Error failed initializing browser: %s", err) } + + page, err := browser.Navigate("about:blank") + if err != nil { + t.Fatalf("navigate about:blank: %v", err) + } + defer closeTestBrowser(t, browser) + defer func() { + if err := page.Close(); err != nil { + t.Logf("close page: %v", err) + } + }() } -// Manually observe test results for now. -func TestBot(t *testing.T) { +func TestFingerprintSannysoft(t *testing.T) { testutil.RequireIntegration(t) - if strings.TrimSpace(os.Getenv("OPENSERP_BOT_TESTS")) != "1" { - t.Skip("set OPENSERP_BOT_TESTS=1 to run manual anti-bot screenshot integration test") + if strings.TrimSpace(os.Getenv(botFingerprintTestsEnv)) != "1" { + t.Skipf("set %s=1 to run fingerprint tests", botFingerprintTestsEnv) } - var err error - opts := BrowserOpts{IsHeadless: false, IsLeakless: false, LeavePageOpen: true} - browser, err = NewBrowser(opts) - if err != nil { - t.Fatalf("Error failed initializing browser: %s", err) + stealthOn := runSannysoftFingerprint(t, true) + stealthOff := runSannysoftFingerprint(t, false) + + improved, regressed, stillDetected := compareSannysoftRuns(stealthOff, stealthOn) + t.Logf("Stealth comparison (OFF -> ON): improved=%d regressed=%d still_detected=%d", len(improved), len(regressed), len(stillDetected)) + if len(improved) > 0 { + t.Logf("Stealth fixed: %s", strings.Join(improved, ", ")) + } + if len(regressed) > 0 { + t.Logf("WARN: stealth regressions: %s", strings.Join(regressed, ", ")) + } + if len(stillDetected) > 0 { + t.Logf("WARN: checks still detected with stealth: %s", strings.Join(stillDetected, ", ")) } - page, _ := browser.Navigate("https://bot.sannysoft.com") - page.MustScreenshotFullPage("./test/screenshot_bot.png") + reportPath := filepath.Join(botFingerprintArtifactDir, "fingerprint_sannysoft_report.json") + report := sannysoftReport{ + GeneratedAtUTC: time.Now().UTC().Format(time.RFC3339), + URL: sannysoftURL, + Runs: []sannysoftRunSummary{stealthOff, stealthOn}, + Comparison: sannysoftComparison{ + Baseline: stealthModeLabel(false), + Candidate: stealthModeLabel(true), + Improved: improved, + Regressed: regressed, + StillDetected: stillDetected, + }, + } + if err := writeSannysoftReport(reportPath, report); err != nil { + t.Fatalf("write sannysoft report: %v", err) + } + absReportPath, err := filepath.Abs(reportPath) + if err == nil { + t.Logf("Sannysoft report artifact: %s", absReportPath) + } else { + t.Logf("Sannysoft report artifact: core/%s", filepath.ToSlash(reportPath)) + } - page, _ = browser.Navigate("https://www.whatismybrowser.com/") - page.MustScreenshotFullPage("./test/screenshot_browser.png") - - page, _ = browser.Navigate("https://abrahamjuliot.github.io/creepjs/") - page.MustScreenshotFullPage("./test/screenshot_creep.png") + for _, run := range []sannysoftRunSummary{stealthOff, stealthOn} { + if len(run.CriticalFailures) > 0 { + t.Errorf("CRITICAL fingerprint failures (%s): %v", stealthModeLabel(run.UseStealth), run.CriticalFailures) + } + } +} + +type sannysoftCheck struct { + Name string `json:"name"` + Status string `json:"status"` +} + +type sannysoftRunSummary struct { + UseStealth bool `json:"use_stealth"` + ScreenshotPath string `json:"screenshot_path"` + Checks []sannysoftCheck `json:"checks"` + Passed int `json:"passed"` + Failed int `json:"failed"` + Unknown int `json:"unknown"` + FailedChecks []string `json:"failed_checks"` + CriticalFailures []string `json:"critical_failures"` + ChecksByName map[string]sannysoftCheck `json:"-"` +} + +func runSannysoftFingerprint(t *testing.T, useStealth bool) sannysoftRunSummary { + t.Helper() + + label := stealthModeLabel(useStealth) + + opts := BrowserOpts{ + IsHeadless: true, + IsLeakless: false, + Timeout: 15 * time.Second, + UseStealth: useStealth, + } + browser, err := NewBrowser(opts) + if err != nil { + t.Fatalf("create browser (%s): %v", label, err) + } + defer closeTestBrowser(t, browser) + + artifactPath := filepath.Join(botFingerprintArtifactDir, fmt.Sprintf("fingerprint_sannysoft_%s.png", label)) + + page, err := browser.Navigate(sannysoftURL) + if err != nil { + t.Fatalf("navigate to sannysoft (%s): %v", label, err) + } + defer func() { + if err := page.Close(); err != nil { + t.Logf("close page (%s): %v", label, err) + } + }() + // Keep screenshot defer after page close defer so it runs first (LIFO). + defer saveSannysoftScreenshot(t, page, artifactPath, label) + + if err := waitForSannysoftResults(page, 20*time.Second); err != nil { + t.Fatalf("waiting for sannysoft results (%s): %v", label, err) + } + + checks, err := extractSannysoftResults(page) + if err != nil { + t.Fatalf("extracting sannysoft results (%s): %v", label, err) + } + if len(checks) == 0 { + t.Fatalf("sannysoft did not return any fingerprint check rows (%s)", label) + } + + summary := sannysoftRunSummary{ + UseStealth: useStealth, + ScreenshotPath: filepath.ToSlash(filepath.Join("core", artifactPath)), + ChecksByName: make(map[string]sannysoftCheck, len(checks)), + Checks: make([]sannysoftCheck, 0, len(checks)), + } + + for _, check := range checks { + summary.Checks = append(summary.Checks, check) + + switch check.Status { + case "pass": + summary.Passed++ + case "fail": + summary.Failed++ + t.Logf("WARN (%s): detected by: %s", label, check.Name) + summary.FailedChecks = append(summary.FailedChecks, check.Name) + if isCriticalFingerprintFailure(check.Name) { + summary.CriticalFailures = append(summary.CriticalFailures, check.Name) + } + default: + summary.Unknown++ + } + + summary.ChecksByName[normalizeFingerprintCheckName(check.Name)] = check + } + + sort.Slice(summary.Checks, func(i, j int) bool { + return normalizeFingerprintCheckName(summary.Checks[i].Name) < normalizeFingerprintCheckName(summary.Checks[j].Name) + }) + sort.Strings(summary.FailedChecks) + sort.Strings(summary.CriticalFailures) + + total := len(checks) + t.Logf("Sannysoft (%s): %d/%d checks passed (%d failed, %d unknown)", label, summary.Passed, total, summary.Failed, summary.Unknown) + return summary +} + +type sannysoftComparison struct { + Baseline string `json:"baseline"` + Candidate string `json:"candidate"` + Improved []string `json:"improved"` + Regressed []string `json:"regressed"` + StillDetected []string `json:"still_detected"` +} + +type sannysoftReport struct { + GeneratedAtUTC string `json:"generated_at_utc"` + URL string `json:"url"` + Runs []sannysoftRunSummary `json:"runs"` + Comparison sannysoftComparison `json:"comparison"` +} + +func writeSannysoftReport(path string, report sannysoftReport) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create artifact directory: %w", err) + } + + data, err := json.MarshalIndent(report, "", " ") + if err != nil { + return fmt.Errorf("marshal report: %w", err) + } + data = append(data, '\n') + + if err := os.WriteFile(path, data, 0o644); err != nil { + return fmt.Errorf("write report file %s: %w", path, err) + } + return nil +} + +func saveSannysoftScreenshot(t *testing.T, page *rod.Page, path, label string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Logf("WARN: create screenshot directory (%s): %v", label, err) + return + } + + bytes, err := page.Screenshot(true, nil) + if err != nil { + t.Logf("WARN: capture screenshot (%s): %v", label, err) + return + } + if err := os.WriteFile(path, bytes, 0o644); err != nil { + t.Logf("WARN: write screenshot (%s): %v", label, err) + return + } + + absPath, err := filepath.Abs(path) + if err == nil { + t.Logf("Sannysoft screenshot artifact (%s): %s", label, absPath) + return + } + t.Logf("Sannysoft screenshot artifact (%s): core/%s", label, filepath.ToSlash(path)) +} + +func stealthModeLabel(useStealth bool) string { + if useStealth { + return "stealth-on" + } + return "stealth-off" +} + +func compareSannysoftRuns(baseline, candidate sannysoftRunSummary) (improved, regressed, stillDetected []string) { + names := make(map[string]struct{}, len(baseline.ChecksByName)+len(candidate.ChecksByName)) + for key := range baseline.ChecksByName { + names[key] = struct{}{} + } + for key := range candidate.ChecksByName { + names[key] = struct{}{} + } + + for key := range names { + before, hasBefore := baseline.ChecksByName[key] + after, hasAfter := candidate.ChecksByName[key] + if !hasBefore || !hasAfter { + continue + } + + switch { + case before.Status == "fail" && after.Status == "pass": + improved = append(improved, after.Name) + case before.Status == "pass" && after.Status == "fail": + regressed = append(regressed, after.Name) + case before.Status == "fail" && after.Status == "fail": + stillDetected = append(stillDetected, after.Name) + } + } + + sort.Strings(improved) + sort.Strings(regressed) + sort.Strings(stillDetected) + return improved, regressed, stillDetected +} + +func normalizeFingerprintCheckName(name string) string { + return strings.ToLower(strings.TrimSpace(name)) +} + +func waitForSannysoftResults(page *rod.Page, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + hasRows, _, err := page.Has("table tr") + if err != nil { + return fmt.Errorf("table probe failed: %w", err) + } + + if hasRows { + checks, err := extractSannysoftResults(page) + if err == nil && len(checks) >= 5 { + return nil + } + } + + time.Sleep(250 * time.Millisecond) + } + + return fmt.Errorf("results table not ready after %s", timeout) +} + +func extractSannysoftResults(page *rod.Page) ([]sannysoftCheck, error) { + res, err := page.Eval(`() => { + const parseRGB = (value) => { + const match = (value || "").match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i); + if (!match) return null; + return [parseInt(match[1], 10), parseInt(match[2], 10), parseInt(match[3], 10)]; + }; + + const classify = (text, className, bgColor) => { + const normalizedText = (text || "").toLowerCase(); + const normalizedClass = (className || "").toLowerCase(); + const rgb = parseRGB(bgColor); + + if (/\b(fail(?:ed)?|detected|bot)\b/.test(normalizedText)) return "fail"; + if (/\b(pass(?:ed)?|ok|success)\b/.test(normalizedText)) return "pass"; + if (/\b(fail(?:ed)?|error|danger|bad|red)\b/.test(normalizedClass)) return "fail"; + if (/\b(pass(?:ed)?|success|ok|good|green)\b/.test(normalizedClass)) return "pass"; + + if (rgb) { + const [r, g, b] = rgb; + if (r > g + 35 && r > b + 35) return "fail"; + if (g > r + 20 && g > b + 20) return "pass"; + } + + return "unknown"; + }; + + const rows = Array.from(document.querySelectorAll("table tr")); + const seen = new Set(); + const checks = []; + + for (const row of rows) { + const cells = Array.from(row.querySelectorAll("th, td")); + if (cells.length < 2) continue; + + const nameCell = cells[0]; + const name = (nameCell.innerText || nameCell.textContent || "").replace(/\s+/g, " ").trim(); + if (!name) continue; + if (/^(test(\s+name)?|property|status|result)$/i.test(name)) continue; + + const resultCells = cells.slice(1); + const statusCell = + resultCells.find((cell) => /\b(result|pass|fail|success|ok)\b/i.test(cell.className || "")) || + resultCells.find((cell) => { + const bg = window.getComputedStyle(cell).backgroundColor || ""; + return bg !== "" && bg !== "transparent" && bg !== "rgba(0, 0, 0, 0)"; + }) || + resultCells[0]; + if (!statusCell) continue; + + const statusText = (statusCell.innerText || statusCell.textContent || "").replace(/\s+/g, " ").trim(); + const className = (row.className || "") + " " + (statusCell.className || ""); + const bgColor = window.getComputedStyle(statusCell).backgroundColor || ""; + const dedupeKey = name.toLowerCase(); + if (seen.has(dedupeKey)) continue; + seen.add(dedupeKey); + + checks.push({ + name, + status: classify(statusText, className, bgColor), + }); + } + + return checks; + }`) + if err != nil { + return nil, err + } + + var checks []sannysoftCheck + if err := res.Value.Unmarshal(&checks); err != nil { + return nil, fmt.Errorf("decode sannysoft results: %w", err) + } + return checks, nil +} + +func isCriticalFingerprintFailure(checkName string) bool { + name := strings.ToLower(strings.TrimSpace(checkName)) + return slices.ContainsFunc(criticalSannysoftChecks, func(critical string) bool { + return strings.Contains(name, critical) + }) +} + +func closeTestBrowser(t *testing.T, browser *Browser) { + t.Helper() + if browser == nil || browser.browser == nil { + return + } + if err := browser.Close(); err != nil { + t.Logf("close browser: %v", err) + } }