mirror of
https://github.com/karust/openserp.git
synced 2026-08-05 16:53:54 +08:00
411 lines
12 KiB
Go
411 lines
12 KiB
Go
//go:build integration
|
|
// +build integration
|
|
|
|
package core
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-rod/rod"
|
|
"github.com/karust/openserp/testutil"
|
|
)
|
|
|
|
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)
|
|
|
|
opts := BrowserOpts{IsHeadless: true, IsLeakless: false}
|
|
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)
|
|
}
|
|
}()
|
|
}
|
|
|
|
func TestFingerprintSannysoft(t *testing.T) {
|
|
testutil.RequireIntegration(t)
|
|
if strings.TrimSpace(os.Getenv(botFingerprintTestsEnv)) != "1" {
|
|
t.Skipf("set %s=1 to run fingerprint tests", botFingerprintTestsEnv)
|
|
}
|
|
|
|
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, ", "))
|
|
}
|
|
|
|
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))
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|