mirror of
https://github.com/karust/openserp.git
synced 2026-08-05 16:53:54 +08:00
Fingerprint checks during browser integration tests
This commit is contained in:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -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/*
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user