diff --git a/moli-benchmark/moli_benchmark/fingerprint/README.md b/moli-benchmark/moli_benchmark/fingerprint/README.md new file mode 100644 index 0000000000..f0c9bd4c2f --- /dev/null +++ b/moli-benchmark/moli_benchmark/fingerprint/README.md @@ -0,0 +1,65 @@ +# Native CDP fingerprint observations + +This observational suite visits twelve public fingerprint/bot-test sites. It is +not a performance benchmark or a bot-evasion guarantee. `complete` means the +collector obtained its required fields, **not** that the site judged the browser +normal. Missing, partial, timed-out, network-failed and crashed samples stay in +the report; repetitions are declared before running, never retried to green. + +Install the optional client and run from the repository root: + +```sh +uv sync --project moli-benchmark --locked --extra fingerprint +xvfb-run -a uv run --project moli-benchmark --locked --extra fingerprint \ + python -m moli_benchmark.fingerprint \ + --moli-bin target/release/moli --chrome-bin /usr/lib/chromium/chromium \ + --output-dir target/benchmark/fingerprint-example +``` + +Use the native Chromium executable and a real display (or Xvfb), not headless +Chromium. This suite owns fresh loopback CDP servers and browser profiles. It +does not override identity, disable TLS verification or launch Chromium with +`--disable-gpu`. Proxy settings are disabled consistently. Moli uses `serve +--layout --resource`. Both engines enable Runtime, Page and Network via CDP. +Cases run serially with alternating engine order; no resource sampler runs +beside the browser. Default Moli identity remains unchanged. + +`--site ID` and `--engine moli|chromium` can repeat to select a subset. `--runs N` +declares 1..10 repetitions; the default is one. Existing output directories are +rejected. Results contain binary, client and collector provenance plus selected +DOM/API values. API bodies, tokens, visitor IDs, IP addresses, console strings +and page HTML are not stored. Script auditing retains only host, hash and size. + +## Sampling contract + +Each case waits its declared number of seconds after DOMContentLoaded. The +interaction demo types fixed dummy credentials and submits its public demo form; +Pixelscan clicks the public scan link. Both have an explicit post-action window. +These are scripted actions, not human-behavior simulations. The manifest records +all waits and limits; results from different workloads are not interchangeable. + +Collectors read scoped DOM `textContent` or selected existing result objects. +They never take a screenshot or force a rendering checkpoint to make a result +appear. Network evidence is frozen before the DOM snapshot: only result bodies +completed by that cutoff qualify. Subsequent audit work cannot fill a missing +sample with a late response. Snapshot start/end and response timing are recorded. + +Incolumitas Proxy currently redirects to a host with a certificate mismatch in +the tested environment. No complete result schema has been verified there. Its +collector reports labels/errors as **partial**, never as a successful proxy +check. BrowserLeaks pages are capability reports, not binary bot verdicts. +Pixelscan's summary can complete while individual sections are still collecting; +their separate states remain visible. CreepJS ratings can be incomplete even +when lie/error totals are available. Fingerprint Pro requires a finished, +unambiguous API result; its empty HTML shell does not qualify. + +Local real-DOM fixtures, independent of the public services: + +```sh +uv run --project moli-benchmark --locked --extra fingerprint python \ + moli-benchmark/tests/check_fingerprint_dom.py --engine moli \ + --binary target/release/moli --output target/benchmark/fingerprint-dom-moli +``` + +These fixtures originated in the survey's CDP-smoke diagnostics. They test +collector correctness, not new CDP product functionality. diff --git a/moli-benchmark/moli_benchmark/fingerprint/__init__.py b/moli-benchmark/moli_benchmark/fingerprint/__init__.py new file mode 100644 index 0000000000..61a33ae6f7 --- /dev/null +++ b/moli-benchmark/moli_benchmark/fingerprint/__init__.py @@ -0,0 +1 @@ +"""Observational public-site compatibility benchmark; never an aggregate bot pass gate.""" diff --git a/moli-benchmark/moli_benchmark/fingerprint/__main__.py b/moli-benchmark/moli_benchmark/fingerprint/__main__.py new file mode 100644 index 0000000000..eb53e2f31b --- /dev/null +++ b/moli-benchmark/moli_benchmark/fingerprint/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +raise SystemExit(main()) diff --git a/moli-benchmark/moli_benchmark/fingerprint/browser.py b/moli-benchmark/moli_benchmark/fingerprint/browser.py new file mode 100644 index 0000000000..2514c709f5 --- /dev/null +++ b/moli-benchmark/moli_benchmark/fingerprint/browser.py @@ -0,0 +1,57 @@ +"""Native launch profile using the benchmark's existing owned-process manager.""" +from __future__ import annotations + +import asyncio +from contextlib import asynccontextmanager +from pathlib import Path +import socket + +from ..artifacts import write_json +from ..raw_cdp import discover_websocket_url +from ..target_serve import start_target_serve, stop_target_serve + + +def endpoint_closed(endpoint: str) -> bool: + port = int(endpoint.rsplit(":", 1)[1]) + with socket.socket() as sock: + sock.settimeout(1) + return sock.connect_ex(("127.0.0.1", port)) != 0 + + +@asynccontextmanager +async def browser_session(binary: Path, engine: str, output: Path): + from playwright.async_api import async_playwright + + output.mkdir(parents=True, exist_ok=False) + handle = None + browser = None + metadata = {"engine": engine, "native": True, "identity_override": False, + "tls_verification": True, "process_cleaned_up": False} + try: + # Startup is bounded by the existing manager. Do not cancel a to_thread + # launch: its still-running thread could otherwise leave an unowned process. + target = "moli-full-cdp" if engine == "moli" else "chrome-cdp" + handle = start_target_serve(target, binary, 15, native=True) + websocket = await discover_websocket_url(handle.endpoint) + async with async_playwright() as pw: + browser = await pw.chromium.connect_over_cdp(websocket, timeout=15000) + try: + context = await asyncio.wait_for(browser.new_context(no_viewport=True), 15) + page = await asyncio.wait_for(context.new_page(), 15) + cdp = await asyncio.wait_for(context.new_cdp_session(page), 10) + metadata["browser"] = await asyncio.wait_for(cdp.send("Browser.getVersion"), 10) + for domain in ["Runtime", "Page", "Network"]: + await asyncio.wait_for(cdp.send(domain + ".enable"), 10) + yield page, cdp + finally: + if browser is not None: + try: + await asyncio.wait_for(browser.close(), 10) + except Exception as error: + metadata["close_error_type"] = type(error).__name__ + finally: + if handle is not None: + stopped = stop_target_serve(handle) + metadata.update(returncode=stopped["returncode"], endpoint_closed=endpoint_closed(handle.endpoint)) + metadata["process_cleaned_up"] = stopped["process_exited"] and metadata["endpoint_closed"] + write_json(output / "environment.json", metadata) diff --git a/moli-benchmark/moli_benchmark/fingerprint/cases.py b/moli-benchmark/moli_benchmark/fingerprint/cases.py new file mode 100644 index 0000000000..e899518996 --- /dev/null +++ b/moli-benchmark/moli_benchmark/fingerprint/cases.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass + + +@dataclass(frozen=True) +class Case: + id: str + url: str + wait_seconds: int + kind: str + action: str | None = None + action_wait_seconds: int = 0 + + +# Keep the original twelve-site workload and fixed observation windows. Changing +# a URL, action, window or projector changes the workload hash in every report. +CASES = ( + Case("fingerprint-pro", "https://demo.fingerprint.com/playground", 12, "commercial-fingerprint"), + Case("browserscan-bot", "https://www.browserscan.net/bot-detection", 10, "bot-signals"), + Case("incolumitas-bot", "https://bot.incolumitas.com/", 16, "bot-and-behavior"), + Case("incolumitas-proxy", "https://bot.incolumitas.com/proxy_detect.html", 15, "network-consistency"), + Case("device-browser-static", "https://deviceandbrowserinfo.com/are_you_a_bot", 10, "bot-signals"), + Case("device-browser-behavior", "https://deviceandbrowserinfo.com/are_you_a_bot_interactions", 20, + "behavior", "demo_login", 20), + Case("sannysoft", "https://bot.sannysoft.com/", 8, "legacy-headless"), + Case("creepjs", "https://abrahamjuliot.github.io/creepjs/", 20, "fingerprint-consistency"), + Case("pixelscan", "https://pixelscan.net/", 15, "fingerprint-consistency", "start_scan", 15), + Case("browserleaks-js", "https://browserleaks.com/javascript", 8, "javascript-fingerprint"), + Case("browserleaks-webgl", "https://browserleaks.com/webgl", 8, "graphics-fingerprint"), + Case("browserscan-tls", "https://www.browserscan.net/tls", 10, "network-fingerprint"), +) + + +def select_cases(ids: list[str] | None = None) -> tuple[Case, ...]: + known = {case.id for case in CASES} + if ids is not None and (not ids or len(ids) != len(set(ids)) or set(ids) - known): + raise ValueError("case selection must contain unique, known site IDs") + return tuple(case for case in CASES if ids is None or case.id in ids) + + +def manifest(cases: tuple[Case, ...]) -> list[dict]: + return [asdict(case) for case in cases] diff --git a/moli-benchmark/moli_benchmark/fingerprint/cli.py b/moli-benchmark/moli_benchmark/fingerprint/cli.py new file mode 100644 index 0000000000..8988652df1 --- /dev/null +++ b/moli-benchmark/moli_benchmark/fingerprint/cli.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import argparse +import asyncio +from datetime import datetime, timezone +from pathlib import Path + +from ..config import RESULTS_ROOT, moli_binary, optional_binary +from .cases import CASES, select_cases +from .runner import run_suite + + +def add_arguments(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--site", action="append", choices=[case.id for case in CASES]) + parser.add_argument("--engine", action="append", choices=["moli", "chromium"]) + parser.add_argument("--moli-bin") + parser.add_argument("--chrome-bin") + parser.add_argument("--runs", type=int, default=1, help="predeclared repetitions, never retries; 1..10") + parser.add_argument("--output-dir", type=Path, default=None) + + +def command(args) -> int: + engines = args.engine or ["moli", "chromium"] + if len(set(engines)) != len(engines): + raise ValueError("engine selection must be unique") + cases = select_cases(args.site) + binaries = {} + for engine in engines: + binary = moli_binary(args.moli_bin) if engine == "moli" else optional_binary( + "CHROME_BIN", ("chromium", "chromium-browser", "google-chrome"), args.chrome_bin) + if binary is None: + raise ValueError("native Chromium is required; supply --chrome-bin (no bundled browser is launched)") + binaries[engine] = binary + output = args.output_dir or RESULTS_ROOT / ("fingerprint-" + datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S.%fZ")) + summary = asyncio.run(run_suite(output, binaries, cases, runs=args.runs)) + print(output) + # A site's bot verdict never determines this process exit code. An incomplete + # experiment or a broken collector still cannot silently return success. + harness_errors = {"collector_error", "browser_crash", "cleanup_error"} + return int(not summary["matrix_complete"] or not summary["inputs_unchanged"] + or any(row["status"] in harness_errors for row in summary["suites"][0]["samples"])) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Native CDP twelve-site observations; availability is not bot pass rate") + add_arguments(parser) + try: + return command(parser.parse_args()) + except (RuntimeError, ValueError, FileExistsError, ImportError) as error: + parser.exit(1, f"fingerprint benchmark: {error}\n") + except KeyboardInterrupt: + return 130 diff --git a/moli-benchmark/moli_benchmark/fingerprint/dom_results.js b/moli-benchmark/moli_benchmark/fingerprint/dom_results.js new file mode 100644 index 0000000000..9d2b309453 --- /dev/null +++ b/moli-benchmark/moli_benchmark/fingerprint/dom_results.js @@ -0,0 +1,92 @@ +// Passive projections of result containers, independent of layout and paint. +// Return null ONLY for an unsupported site; missing results never select a fallback. +({site}) => { + const supported = ['browserscan-bot', 'browserscan-tls', 'creepjs']; + if (!supported.includes(site)) return null; + + const report = {readyState: document.readyState, valid: false, collector: 'dom-results-v1'}; + const text = node => (node?.textContent || '').trim().replace(/\s+/g, ' '); + // Exclude explicitly inactive markup without consulting computed style or geometry. + const active = node => !node.closest('script, style, template, [hidden], [aria-hidden="true"]'); + const labelled = (selector, label) => Array.from(document.querySelectorAll(selector)) + .filter(node => active(node) && text(node) === label); + + if (site === 'browserscan-bot') { + const labels = labelled('strong', 'Test Results:'); + report.verdict = null; + report.state = labels.length === 0 ? 'missing' : labels.length > 1 ? 'ambiguous' : 'pending'; + if (labels.length === 1) { + // The label and verdict are sibling s; do not scan explanatory prose. + const values = Array.from(labels[0].parentElement.querySelectorAll(':scope > strong')) + .filter(node => node !== labels[0] && active(node)); + if (values.length === 1 && ['Normal', 'Robot'].includes(text(values[0]))) { + report.verdict = text(values[0]); + report.state = 'complete'; + report.valid = true; // Complete is not the same as a passing verdict. + } else if (values.length > 1) { + report.state = 'ambiguous'; + } + } + return report; + } + + if (site === 'browserscan-tls') { + const fields = { + akamaiHash: ['Akamai Hash', /^[a-f0-9]{32}$/i], + ja3: ['JA3', /^\d+,(?:\d+(?:-\d+)*)?,(?:\d+(?:-\d+)*)?,(?:\d+(?:-\d+)*)?,(?:\d+(?:-\d+)*)?$/], + ja4: ['JA4', /^[tqd][a-z0-9]{9}_[a-f0-9]{12}_[a-f0-9]{12}$/i], + }; + report.kind = 'report-only'; + report.reportLabels = ['HTTP/2 Fingerprint', 'Akamai Hash', 'JA3', 'JA4'] + .filter(label => labelled('h3', label).length === 1); + report.fields = Object.fromEntries(Object.entries(fields).map(([key, [label, pattern]]) => { + const labels = labelled('h3', label); + if (labels.length !== 1) return [key, labels.length ? 'ambiguous' : 'missing']; + // A report card has a heading wrapper followed by a value wrapper. Avoid + // hashed CSS classes and never accept hashes from unrelated cards/examples. + const heading = labels[0].parentElement?.parentElement; + const body = heading?.nextElementSibling; + const values = body ? Array.from(body.querySelectorAll(':scope > div > p')).filter(active) : []; + if (values.length > 1) return [key, 'ambiguous']; + // The scalar is direct text in

. Ad annotations can append child DOM + // (e.g. "Convert Data Files"); those are not part of the fingerprint. + const value = Array.from(values[0]?.childNodes || []).filter(node => node.nodeType === Node.TEXT_NODE) + .map(node => node.textContent).join('').trim(); + const state = !value || /^(?:loading[.\u2026]*|pending|-+)$/i.test(value) ? 'pending' + : pattern.test(value) ? 'populated' : 'unrecognized'; + return [key, state]; // Do not retain the fingerprint or network identity. + })); + report.valid = report.reportLabels.length === 4 + && Object.values(report.fields).every(state => state === 'populated'); + return report; + } + + // CreepJS publishes its completed measurements here. Rating fields are the + // source for the DOM percentages; neither a layout snapshot nor modal text is needed. + const fingerprint = window.Fingerprint; + const count = value => Number.isInteger(value) && value >= 0 ? value : null; + const boolean = value => typeof value === 'boolean' ? value : null; + const percent = value => typeof value === 'number' && Number.isFinite(value) + && value >= 0 && value <= 100 ? value : null; + report.metrics = { + totalLies: count(fingerprint?.lies?.totalLies), + capturedErrorCount: Array.isArray(fingerprint?.capturedErrors?.data) + ? fingerprint.capturedErrors.data.length : null, + audioLied: boolean(fingerprint?.offlineAudioContext?.lied), + svgLied: boolean(fingerprint?.svg?.lied), + }; + const names = {likeHeadless: 'like headless', headless: 'headless', stealth: 'stealth'}; + report.ratings = Object.fromEntries(Object.keys(names) + .map(key => [key, percent(fingerprint?.headless?.[key + 'Rating'])])); + report.percentages = Object.entries(report.ratings) + .filter(([, value]) => value !== null).map(([key, value]) => `${value}% ${names[key]}`); + report.ratingsComplete = Object.values(report.ratings).every(value => value !== null); + report.headlessSignals = Object.fromEntries(Object.keys(names).map(key => { + const signals = fingerprint?.headless?.[key]; + return [key, signals && typeof signals === 'object' ? Object.fromEntries(Object.entries(signals) + .filter(([name, value]) => /^[A-Za-z0-9_]{1,80}$/.test(name) && typeof value === 'boolean')) : null]; + })); + // Retain the survey's core-result criterion, but expose partial ratings separately. + report.valid = report.metrics.totalLies !== null && report.metrics.capturedErrorCount !== null; + return report; +} diff --git a/moli-benchmark/moli_benchmark/fingerprint/observations.py b/moli-benchmark/moli_benchmark/fingerprint/observations.py new file mode 100644 index 0000000000..add39431c6 --- /dev/null +++ b/moli-benchmark/moli_benchmark/fingerprint/observations.py @@ -0,0 +1,133 @@ +"""Bounded, allowlisted observations. Never save raw CDP messages or API bodies.""" +from __future__ import annotations + +import asyncio +import base64 +from copy import deepcopy +import hashlib +import json +import math +import re +from urllib.parse import urlsplit + + +def is_fp_result(url: str) -> bool: + parsed = urlsplit(url) + return parsed.scheme == "https" and parsed.hostname == "demo.fingerprint.com" and parsed.path.startswith("/api/event/") + + +def select_fp_result(value: object) -> dict: + if not isinstance(value, dict): + return {} + result = {key: value[key] for key in ["tampering", "virtual_machine", "developer_tools", "incognito"] + if type(value.get(key)) is bool} + if value.get("bot") in ("not_detected", "good", "bad"): + result["bot"] = value["bot"] + for key in ["suspect_score", "tampering_ml_score", "virtual_machine_ml_score"]: + score = value.get(key) + maximum = 100 if key == "suspect_score" else 1 + if type(score) in {int, float} and math.isfinite(score) and 0 <= score <= maximum: + result[key] = score + return result + + +def complete_fp_result(value: dict) -> bool: + return all(key in value for key in ["bot", "tampering", "suspect_score", "virtual_machine"]) + + +def error_record(phase: str, error: BaseException) -> dict: + return {"phase": phase, "type": type(error).__name__, + "network_codes": sorted(set(re.findall(r"(?:net::)?ERR_[A-Z_]+", str(error))))} + + +class Observer: + def __init__(self, cdp, clock): + self.cdp, self.clock = cdp, clock + self.requests: dict[str, dict] = {} + self.exception_count = 0 + self.network_failures: dict[str, int] = {} + self.script_limit = 40 + self.script_count = 0 + self.script_limit_reached = False + self.result_count = 0 + self.result_limit_reached = False + cdp.on("Network.requestWillBeSent", self.requested) + cdp.on("Network.responseReceived", self.received) + cdp.on("Network.loadingFinished", self.finished) + cdp.on("Network.loadingFailed", self.failed) + cdp.on("Runtime.exceptionThrown", self.exception) + + def requested(self, params): + url = params.get("request", {}).get("url", "") + kind = "result" if is_fp_result(url) else "script" if params.get("type") == "Script" else None + # Redirects reuse a request id; the final destination must still qualify. + self.requests.pop(params["requestId"], None) + if kind == "script": + self.script_count += 1 + if self.script_count > self.script_limit: + self.script_limit_reached = True + return + if kind == "result": + self.result_count += 1 + if self.result_count > 20: + self.result_limit_reached = True + return + if kind is not None: + self.requests[params["requestId"]] = {"kind": kind, "host": urlsplit(url).hostname, + "sent_at": self.clock()} + + def received(self, params): + if (record := self.requests.get(params["requestId"])) is not None: + record.update(status=params["response"]["status"], headers_at=self.clock()) + + def finished(self, params): + if (record := self.requests.get(params["requestId"])) is not None: + record["finished_at"] = self.clock() + + def failed(self, params): + for code in set(re.findall(r"(?:net::)?ERR_[A-Z_]+", params.get("errorText", ""))): + self.network_failures[code] = self.network_failures.get(code, 0) + 1 + if (record := self.requests.get(params["requestId"])) is not None: + record["failed_at"] = self.clock() + + def exception(self, _params): + self.exception_count += 1 + + def snapshot(self, cutoff: float) -> dict: + return {"cutoff": cutoff, "requests": deepcopy(self.requests), "exception_count": self.exception_count, + "network_failures": dict(self.network_failures), "script_limit_reached": self.script_limit_reached, + "result_limit_reached": self.result_limit_reached} + + async def resolve(self, frozen: dict) -> dict: + result = {"responses": [], "scripts": [], "exception_count": frozen["exception_count"], + "network_failures": frozen["network_failures"], "script_limit_reached": frozen["script_limit_reached"], + "result_limit_reached": frozen["result_limit_reached"]} + # Read only bodies that were complete at the cutoff; later responses can + # never fill earlier samples. Result bodies have priority over script hashes. + requests = sorted(frozen["requests"].items(), key=lambda item: item[1]["kind"] != "result") + try: + async with asyncio.timeout(15): + for request_id, record in requests: + complete = (record.get("status") == 200 and "failed_at" not in record + and record.get("finished_at", math.inf) <= frozen["cutoff"]) + projected = {**record, "within_window": complete} + if record["kind"] == "result": + result["responses"].append(projected) + elif not complete: + continue + else: + result["scripts"].append(projected) + if not complete: + continue + try: + response = await asyncio.wait_for(self.cdp.send("Network.getResponseBody", {"requestId": request_id}), 3) + body = base64.b64decode(response["body"]) if response.get("base64Encoded") else response["body"].encode() + if record["kind"] == "result": + projected["result"] = select_fp_result(json.loads(body)) + else: + projected.update(sha256=hashlib.sha256(body).hexdigest(), bytes=len(body)) + except Exception as error: + projected["read_error"] = type(error).__name__ + except TimeoutError: + result["body_audit_incomplete"] = True + return result diff --git a/moli-benchmark/moli_benchmark/fingerprint/probe.py b/moli-benchmark/moli_benchmark/fingerprint/probe.py new file mode 100644 index 0000000000..09f35504d1 --- /dev/null +++ b/moli-benchmark/moli_benchmark/fingerprint/probe.py @@ -0,0 +1,12 @@ +from pathlib import Path + +ROOT = Path(__file__).parent + + +def dom_probe(fallback: str | None = None) -> str: + source = (ROOT / "dom_results.js").read_text(encoding="utf-8") + return source if fallback is None else f"arg => (\n{source}\n)(arg) ?? (\n{fallback}\n)(arg)" + + +def site_probe() -> str: + return dom_probe((ROOT / "site_results.js").read_text(encoding="utf-8")) diff --git a/moli-benchmark/moli_benchmark/fingerprint/runner.py b/moli-benchmark/moli_benchmark/fingerprint/runner.py new file mode 100644 index 0000000000..2d94ca2f7b --- /dev/null +++ b/moli-benchmark/moli_benchmark/fingerprint/runner.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +import asyncio +from copy import deepcopy +from dataclasses import asdict +from datetime import datetime, timezone +import hashlib +import importlib.metadata +import json +from pathlib import Path +import platform +import subprocess +import time + +from ..artifacts import write_json +from ..config import REPO_ROOT +from .browser import browser_session +from .cases import Case, manifest +from .observations import Observer, complete_fp_result, error_record +from .probe import site_probe + +SCHEMA_VERSION = 1 + + +def digest(path: Path) -> str: + with path.open("rb") as stream: + return hashlib.file_digest(stream, "sha256").hexdigest() + + +def collector_hashes() -> dict[str, str]: + root = Path(__file__).parent + paths = [*root.glob("*.py"), *root.glob("*.js"), root.parent / "target_serve.py"] + return {str(path.relative_to(root.parent)): digest(path) for path in sorted(paths)} + + +def workload(cases: tuple[Case, ...]) -> dict: + return {"cases": manifest(cases), "mode": "native-cdp", "runtime_enabled": True, + "layout": "on-demand", "resources": "all", "identity_override": False, + "tls_verification": True, "proxy": "disabled", "parallelism": 1, + "navigation_timeout_seconds": 45, "sample_timeout_seconds": 150, + "typing_delay_ms": 80, "dummy_credentials": True, "screenshot": False, + "playwright_version": importlib.metadata.version("playwright"), + "collector": collector_hashes()} + + +def workload_hash(value: dict) -> str: + return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + + +def classify(row: dict) -> str: + if not row.get("process_cleaned_up"): + return "cleanup_error" + if row.get("browser_crashed"): + return "browser_crash" + errors = row.get("errors", []) + if errors: + if any(error.get("network_codes") for error in errors): + return "network_error" + if any("Timeout" in error["type"] for error in errors): + return "timeout" + return "collector_error" + if (row.get("navigation_status") or 0) >= 400: + return "network_error" + report = row.get("report") or {} + if report.get("valid") is True: + return "complete" + evidence = [value for key, value in report.items() if key not in {"valid", "readyState", "collector", "kind", "source"}] + return "partial" if any(value not in (None, False, "", {}, []) for value in evidence) else "missing" + + +async def act(page, case: Case): + if case.action == "demo_login": + await page.locator('input[type=email]').first.click(timeout=10000) + await page.keyboard.type("moli-cdp-demo@example.com", delay=80) + await page.locator('input[type=password]').first.click(timeout=10000) + await page.keyboard.type("Moli-Demo-Only-2026!", delay=80) + await page.get_by_role("button", name="Login", exact=True).click(timeout=10000) + elif case.action == "start_scan": + link = page.locator('a[href="/fingerprint-check"]').filter(has_text="Scan My Browser Now") + if await link.count() != 1: + raise ValueError("public scan link is absent or ambiguous") + await link.click(timeout=10000) + elif case.action is not None: + raise ValueError("unknown site action") + + +async def capture(case: Case, engine: str, binary: Path, output: Path, *, probe: str | None = None, run: int = 1) -> dict: + row = {"site": case.id, "engine": engine, "run": run, "case": asdict(case), "errors": [], + "started_at": datetime.now(timezone.utc).isoformat(), "report": None, + "browser_crashed": False} + stage = "session" + try: + async with browser_session(binary, engine, output) as (page, cdp): + def crashed(_page): + row["browser_crashed"] = True + page.on("crash", crashed) + started = time.monotonic() + clock = lambda: time.monotonic() - started + observer = Observer(cdp, clock) + try: + async with asyncio.timeout(150): + stage = "navigation" + response = await page.goto(case.url, wait_until="domcontentloaded", timeout=45000) + row["navigation_status"] = response.status if response else None + row["domcontentloaded_at"] = clock() + await asyncio.sleep(case.wait_seconds) + if case.action: + stage = case.action + await act(page, case) + row["action_completed_at"] = clock() + await asyncio.sleep(case.action_wait_seconds) + stage = "projection" + cutoff = clock() + frozen = observer.snapshot(cutoff) + row["cutoff"] = cutoff + report = await asyncio.wait_for(page.evaluate(probe or site_probe(), {"site": case.id}), 15) + if not isinstance(report, dict) or type(report.get("valid")) is not bool: + raise ValueError("projector returned an invalid report envelope") + row["report"] = report + row["snapshot_completed_at"] = clock() + stage = "network_evidence" + row["observations"] = await observer.resolve(frozen) + if case.id == "fingerprint-pro": + completed = [entry["result"] for entry in row["observations"]["responses"] + if entry["within_window"] and complete_fp_result(entry.get("result", {}))] + # Multiple contradictory replies are not a license to select the best score. + unique = {json.dumps(value, sort_keys=True) for value in completed} + row["report"] = {"valid": len(unique) == 1 and not row["observations"]["result_limit_reached"], "results": completed, + "ambiguous": len(unique) > 1} + row["elapsed_seconds"] = clock() + except Exception as error: + row["errors"].append(error_record(stage, error)) + except Exception as error: + row["errors"].append(error_record(stage, error)) + metadata_path = output / "environment.json" + metadata = json.loads(metadata_path.read_text()) if metadata_path.exists() else {} + row["process_cleaned_up"] = metadata.get("process_cleaned_up", False) + row["browser"] = metadata.get("browser", {}) + row["status"] = classify(row) + row["finished_at"] = datetime.now(timezone.utc).isoformat() + write_json(output / "result.json", row) + return row + + +def build_summary(rows: list[dict], cases: tuple[Case, ...], engines: list[str], *, runs: int = 1) -> dict: + states = ("complete", "partial", "missing", "timeout", "network_error", "collector_error", "browser_crash", "cleanup_error") + coverage = {engine: {"planned": len(cases) * runs, "attempted": sum(r["engine"] == engine for r in rows), + **{state: sum(r["engine"] == engine and r["status"] == state for r in rows) for state in states}} + for engine in engines} + return {"suite": "fingerprint", "observational": True, "availability_is_not_pass_count": True, + "cases": [case.id for case in cases], "runs": runs, "coverage": coverage, "samples": deepcopy(rows)} + + +async def run_suite(output: Path, binaries: dict[str, Path], cases: tuple[Case, ...], *, runs: int = 1) -> dict: + if not cases or not binaries or set(binaries) - {"moli", "chromium"} or not 1 <= runs <= 10: + raise ValueError("supply cases, native engines and 1..10 predeclared runs") + binaries = {engine: path.resolve(strict=True) for engine, path in binaries.items()} + config = workload(cases) + provenance = {"schema_version": SCHEMA_VERSION, "suite": "fingerprint", "workload": config, + "workload_hash": workload_hash(config), "started_at": datetime.now(timezone.utc).isoformat(), + "environment": {"platform": platform.platform(), "machine": platform.machine(), + "python": platform.python_version()}, + "binaries": {engine: {"path": str(path), "sha256": digest(path)} for engine, path in binaries.items()}} + revision = subprocess.run(["git", "rev-parse", "HEAD"], cwd=REPO_ROOT, text=True, capture_output=True, check=True) + provenance["revision"] = revision.stdout.strip() + output.mkdir(parents=True, exist_ok=False) + write_json(output / "manifest.json", provenance) + rows: list[dict] = [] + summary = {"schema_version": SCHEMA_VERSION, "manifest": provenance, "suites": []} + try: + for run in range(1, runs + 1): + for index, case in enumerate(cases): + engines = list(binaries) + if (index + run - 1) % 2: + engines.reverse() + for engine in engines: + print(f"fingerprint {run}/{runs} {engine} {case.id}", flush=True) + row = await capture(case, engine, binaries[engine], output / "samples" / str(run) / engine / case.id, run=run) + rows.append(row) + summary["suites"] = [build_summary(rows, cases, list(binaries), runs=runs)] + write_json(output / "summary.json", summary) + if not row["process_cleaned_up"]: + raise RuntimeError("browser cleanup failed; refusing to start another public sample") + finally: + summary["suites"] = [build_summary(rows, cases, list(binaries), runs=runs)] + summary["inputs_unchanged"] = collector_hashes() == config["collector"] and all( + digest(path) == provenance["binaries"][engine]["sha256"] for engine, path in binaries.items()) + summary["finished_at"] = datetime.now(timezone.utc).isoformat() + summary["matrix_complete"] = len(rows) == len(cases) * len(binaries) * runs + write_json(output / "summary.json", summary) + return summary diff --git a/moli-benchmark/moli_benchmark/fingerprint/site_results.js b/moli-benchmark/moli_benchmark/fingerprint/site_results.js new file mode 100644 index 0000000000..53653087c3 --- /dev/null +++ b/moli-benchmark/moli_benchmark/fingerprint/site_results.js @@ -0,0 +1,116 @@ +// Allowlisted, passive DOM projections for the other nine sites. Only selected +// results cross CDP; no page HTML, API tokens, IP addresses or console text. +({site}) => { + const text = node => (node?.textContent || '').trim(); + const parse = id => { try { return JSON.parse(text(document.getElementById(id))); } catch { return null; } }; + const bool = value => typeof value === 'boolean' ? value : null; + const states = value => value && typeof value === 'object' ? Object.fromEntries(Object.entries(value) + .filter(([key, v]) => /^[A-Za-z_]+$/.test(key) && ['OK', 'FAIL', 'WARN'].includes(v))) : null; + const score = (value, maximum) => { + if (typeof value === 'string' && /^\d+(?:\.\d+)?$/.test(value.trim())) value = Number(value); + return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= maximum ? value : null; + }; + const report = {readyState: document.readyState, valid: false}; + if (site === 'fingerprint-pro') return {...report, source: 'network-result-required'}; + if (site === 'device-browser-static' || site === 'device-browser-behavior') { + const value = parse('jsonResult'); + report.isBot = bool(value?.isBot); + report.details = value?.details && typeof value.details === 'object' ? Object.fromEntries( + Object.entries(value.details).filter(([k, v]) => /^(?:is|has|suspicious)[A-Za-z]+$/.test(k) + && typeof v === 'boolean')) : null; + report.valid = report.isBot !== null && report.details !== null && Object.keys(report.details).length > 0; + return report; + } + if (site === 'incolumitas-bot') { + const newer = parse('new-tests'), detection = parse('detection-tests'); + report.newTests = states(newer); + report.intoli = states(detection?.intoli); + report.fpscanner = states(detection?.fpscanner); + report.valid = [report.newTests, report.intoli, report.fpscanner].every(v => v && Object.keys(v).length > 0); + report.behavior = {jsonScore: score(newer?.behavioralClassificationScore, 1), + domScore: score(text(document.getElementById('behavioralScore')), 1)}; + return report; + } + if (site === 'incolumitas-proxy') { + // This page mixes several independent remote probes. Labels alone do not + // establish a completed network report. Keep this explicitly partial until + // the site exposes verifiable result values; never invent a pass from prose. + const body = text(document.body); + report.kind = 'report-only'; + report.certificateErrors = [...new Set(body.match(/(?:net::)?ERR_CERT_[A-Z_]+/g) || [])]; + report.reportLabels = ['WebRTC', 'TCP/IP', 'DNS', 'Timezone', 'Proxy'].filter(label => body.includes(label)); + report.completion = 'unverified'; + return report; + } + if (site === 'sannysoft') { + const ids = ['user-agent-result', 'webdriver-result', 'advanced-webdriver-result', 'chrome-result', + 'permissions-result', 'plugins-length-result', 'plugins-type-result', 'languages-result', + 'webgl-vendor', 'webgl-renderer', 'broken-image-dimensions']; + report.tests = Object.fromEntries(ids.map(id => { + const node = document.getElementById(id); + return [id, node ? {state: Array.from(node.classList).find(s => ['passed', 'failed', 'warn'].includes(s)) || null, + value: text(node).slice(0, 250)} : null]; + })); + report.fpscanner = Object.fromEntries(Array.from(document.querySelectorAll('tr'), row => + Array.from(row.querySelectorAll(':scope > th, :scope > td'), text)).filter(row => + /^[A-Z_]+$/.test(row[0] || '') && /^(ok|fail|warn)$/i.test(row[1] || '')).map(row => [row[0], row[1]])); + report.valid = Object.values(report.tests).every(v => v?.state !== null && v?.state !== undefined); + return report; + } + if (site === 'pixelscan') { + const cards = Array.from(document.querySelectorAll('[checkervalue]'), text); + const lower = cards.map(t => t.toLowerCase()); + report.masking = lower.includes('masking detected') ? true : lower.includes('no masking detected') ? false : null; + report.automated = lower.includes('automated behavior detected') ? true + : lower.includes('no automated behavior detected') ? false : null; + report.browser = cards.find(t => /^(Chrome|Chromium|Firefox|Safari|Edge)\b/.test(t))?.slice(0, 100) || null; + report.valid = location.pathname === '/fingerprint-check' && report.masking !== null && report.automated !== null; + const selectors = {browser:'pxlscn-browser-integrity', fingerprint:'pxlscn-fingerprint-masking', + automation:'pxlscn-bot-detection', location:'pxlscn-location-masking', proxy:'pxlscn-proxy', + hardware:'pxlscn-hardware-fingerprints', navigator:'pxlscn-navigator', fonts:'pxlscn-fonts', + timeLanguage:'pxlscn-time-language', connections:'pxlscn-connections'}; + const known = ['Masking detected', 'No masking detected', 'Automated behavior detected', + 'No automated behavior detected', 'Proxy detected', 'No proxy detected', 'Proxy check error', + 'Timezone spoofed', 'Location not detected']; + report.sections = Object.fromEntries(Object.entries(selectors).map(([key, selector]) => { + const node = document.querySelector(selector); + if (!node) return [key, {present:false}]; + const cell = node.querySelector('[checkervalue]'), value = text(cell); + const status = !cell ? 'absent' : !value ? 'empty' : /^Collecting Data/.test(value) ? 'collecting' + : known.includes(value) ? value : 'populated'; + const details = Array.from(node.querySelectorAll('.checker-details-value, .checker-details-value--small')); + return [key, {present:true, status, collecting:text(node).includes('Collecting Data'), + loaders:node.querySelectorAll('pxlscn-loader').length, detailCells:details.length, + populatedDetailCells:details.filter(e => text(e)).length}]; + })); + return report; + } + if (site === 'browserleaks-js') { + report.kind = 'report-only'; + report.fields = Object.fromEntries(['userAgent', 'platform', 'hardwareConcurrency', 'webdriver'] + .map(key => [key, text(document.getElementById('js-' + key)).slice(0, 250) || null])); + report.valid = Object.values(report.fields).every(v => v !== null); + return report; + } + if (site === 'browserleaks-webgl') { + report.kind = 'report-only'; + const rows = Array.from(document.querySelectorAll('tr'), row => + Array.from(row.querySelectorAll(':scope > th, :scope > td'), text)); + const selected = label => { + const row = rows.find(row => row[0] === label); + return row?.length > 1 ? row.slice(1).join(' ').slice(0, 250) || null : null; + }; + report.fields = Object.fromEntries(['WebGL Report Hash', 'WebGL Image Hash', 'WebGL Image', + 'Unmasked Vendor', 'Unmasked Renderer', 'Max Texture Size', 'Max Combined Texture Image Units', + 'Max Viewport Dimensions', 'Aliased Point Size Range'].map(key => [key, selected(key)])); + report.unavailable = Array.from(document.querySelectorAll('p, .warning, .notice'), text) + .some(t => /^(?:WebGL (?:is |seems to be )?(?:disabled or unavailable|not supported)|Your browser (?:does not support|doesn't support) WebGL)\b/i.test(t)); + report.valid = report.unavailable || report.fields['WebGL Report Hash'] !== null; + const canvas = document.getElementById('gl-image-src')?.querySelector('canvas'); + const hash = text(document.getElementById('gl-image-hash')); + report.imageProbe = {canvasInserted:!!canvas, dimensions:canvas ? [canvas.width, canvas.height] : null, + hash:/^[a-f0-9]{32}$/i.test(hash) ? hash : null}; + return report; + } + throw new Error('Unknown fingerprint site'); +} diff --git a/moli-benchmark/moli_benchmark/target_serve.py b/moli-benchmark/moli_benchmark/target_serve.py index 0986704d01..2dbe3c5b1b 100644 --- a/moli-benchmark/moli_benchmark/target_serve.py +++ b/moli-benchmark/moli_benchmark/target_serve.py @@ -2,6 +2,7 @@ from __future__ import annotations import os import shutil +import signal import subprocess import tempfile import threading @@ -12,6 +13,7 @@ from typing import Any from .config import REPO_ROOT, ReservedPort, clear_proxy_env, reserve_port from .sampling import ResourceSampler +from .process import _kill_process_group from .serve import probe_url from .synthetic_compare import target_enables_all_resource_fetch, target_metadata @@ -35,11 +37,12 @@ class TargetServeHandle: endpoint: str command: list[str] logs: list[str] - sampler: ResourceSampler + sampler: ResourceSampler | None log_threads: list[threading.Thread] port_lease: ReservedPort temp_dir: Path | None = None ready_ms: float | None = None + native: bool = False def _append_log(logs: list[str], line: str) -> None: @@ -89,6 +92,8 @@ def _serve_command( port: int, temp_dir: Path | None, extra_args: tuple[str, ...] = (), + *, + native: bool = False, ) -> list[str]: engine = target_metadata(target)["engine"] if engine == "moli" or engine == "lightpanda": @@ -112,6 +117,15 @@ def _serve_command( if engine == "chrome": if temp_dir is None: raise RuntimeError("chrome target requires a temporary profile directory") + if native: + if extra_args: + raise ValueError("native browser launches do not accept identity or feature overrides") + return [ + str(binary), "--no-first-run", "--no-default-browser-check", + "--no-proxy-server", "--password-store=basic", "--window-size=1920,1080", + "--remote-debugging-address=127.0.0.1", + f"--user-data-dir={temp_dir}", f"--remote-debugging-port={port}", "about:blank", + ] return [ str(binary), "--headless=new", @@ -134,7 +148,10 @@ def start_target_serve( extra_args: tuple[str, ...] = (), *, sample_interval_seconds: float | None = None, + native: bool = False, ) -> TargetServeHandle: + if native and (target not in {"moli-full-cdp", "chrome-cdp"} or extra_args): + raise ValueError("native mode requires moli-full-cdp or chrome-cdp without extra flags") engine = target_metadata(target)["engine"] temp_dir = Path(tempfile.mkdtemp(prefix=f"moli-benchmark-{target}-")) if engine == "chrome" else None logs: list[str] = [] @@ -142,7 +159,7 @@ def start_target_serve( try: port = reserved_port.port endpoint = f"http://127.0.0.1:{port}" - command = _serve_command(target, binary, port, temp_dir, extra_args) + command = _serve_command(target, binary, port, temp_dir, extra_args, native=native) reserved_port.release_socket() process = subprocess.Popen( command, @@ -163,12 +180,13 @@ def start_target_serve( ] for thread in log_threads: thread.start() - sampler = ( + sampler = None if native else ( ResourceSampler(process.pid) if sample_interval_seconds is None else ResourceSampler(process.pid, interval_seconds=sample_interval_seconds) ) - sampler.start() + if sampler is not None: + sampler.start() handle = TargetServeHandle( target=target, process=process, @@ -179,6 +197,7 @@ def start_target_serve( log_threads=log_threads, port_lease=reserved_port, temp_dir=temp_dir, + native=native, ) started = time.perf_counter() deadline = started + timeout_seconds @@ -212,19 +231,24 @@ def stop_target_serve( resources: dict[str, Any] = {} try: process_exited = _terminate_process(handle.process) + if handle.native and os.name == "posix": + # Native sessions own a fresh process group. Reap residual children + # even if Chromium's root crashed or Browser.close already exited it. + _kill_process_group(handle.process.pid, signal.SIGKILL) if process_exited: for thread in handle.log_threads: thread.join(timeout=0.2) else: _append_log(handle.logs, "process did not exit after SIGKILL; skipped pipe drain") - resources = handle.sampler.stop() - if include_resource_samples: + resources = handle.sampler.stop() if handle.sampler is not None else {} + if include_resource_samples and handle.sampler is not None: resources["samples"] = list(handle.sampler.samples) finally: handle.port_lease.close() if handle.temp_dir is not None: shutil.rmtree(handle.temp_dir, ignore_errors=True) return { + "process_exited": process_exited, "returncode": handle.process.returncode, "resources": resources, "log_tail": handle.logs[-40:], diff --git a/moli-benchmark/pyproject.toml b/moli-benchmark/pyproject.toml index ae681b7236..2e61e6d09b 100644 --- a/moli-benchmark/pyproject.toml +++ b/moli-benchmark/pyproject.toml @@ -8,6 +8,9 @@ dependencies = [ "websockets>=13,<16", ] +[project.optional-dependencies] +fingerprint = ["playwright>=1.48,<2"] + [project.scripts] moli-benchmark = "moli_benchmark.cli:main" moli-stress = "moli_benchmark.stress:main" diff --git a/moli-benchmark/tests/check_fingerprint_dom.py b/moli-benchmark/tests/check_fingerprint_dom.py new file mode 100644 index 0000000000..fa2c9cdc08 --- /dev/null +++ b/moli-benchmark/tests/check_fingerprint_dom.py @@ -0,0 +1,129 @@ +"""Run deterministic DOM-reader fixtures on a real CDP browser, without screenshots. + +Not part of offline unittest discovery: pass an explicit owned browser binary. +""" +from __future__ import annotations + +import argparse +import asyncio +from copy import deepcopy +from pathlib import Path +import unittest + +from moli_benchmark.artifacts import write_json as save +from moli_benchmark.fingerprint.browser import browser_session +from moli_benchmark.fingerprint.probe import dom_probe as survey_probe + + +def bot(value: str) -> str: + return f"

Test Results:{value}
" + + +def card(label: str, value: str) -> str: + return (f"

{label}

" + f"

{value}

") + + +HASH = "a" * 32 +JA3 = "771,4865-4866,0-23-35,29-23,0" +JA4 = "t13d1516h2_" + "b" * 12 + "_" + "c" * 12 +TLS = ("

HTTP/2 Fingerprint

" + card("Akamai Hash", HASH) + + card("JA3", JA3) + card("JA4", JA4)) +CREEP = {"lies": {"totalLies": 0}, "capturedErrors": {"data": []}, + "offlineAudioContext": {"lied": False}, "svg": {"lied": False}, + "headless": {"likeHeadlessRating": 38, "headlessRating": 0, "stealthRating": 0, + "headless": {"webDriverIsOn": False, "opaqueToken": "not retained"}}} + + +def fixtures(): + for verdict in ["Normal", "Robot"]: + yield verdict, "browserscan-bot", bot(verdict), None, {"valid": True, "verdict": verdict} + yield "missing", "browserscan-bot", "

Normal

", None, {"valid": False, "verdict": None} + yield "pending", "browserscan-bot", bot("Loading..."), None, {"valid": False, "verdict": None} + yield "hidden", "browserscan-bot", f"", None, {"valid": False} + yield "template", "browserscan-bot", f"", None, {"valid": False} + yield "duplicate", "browserscan-bot", bot("Normal") + bot("Robot"), None, {"valid": False} + yield "duplicate-value", "browserscan-bot", bot("Normal").replace( + "", "Robot"), None, {"valid": False} + yield "hidden-decoy", "browserscan-bot", bot("Robot") + f"", None, { + "valid": True, "verdict": "Robot"} + yield "explanation-decoy", "browserscan-bot", bot("") + "

Normal

", None, {"valid": False} + yield "tls-complete", "browserscan-tls", TLS, None, {"valid": True, "fields": { + "akamaiHash": "populated", "ja3": "populated", "ja4": "populated"}} + # Real ad scripts append a div inside p. Putting that in HTML source instead + # would implicitly close p during parsing, exercising a different DOM. + yield "tls-ad-annotation", "browserscan-tls", TLS + """""", None, {"valid": True} + yield "tls-hash-only-in-ad", "browserscan-tls", TLS.replace(HASH, "") + f"""""", None, {"valid": False} + yield "tls-hashes-in-prose", "browserscan-tls", ( + "

HTTP/2 Fingerprint

" + card("Akamai Hash", "") + card("JA3", "") + card("JA4", "") + + f"

{HASH} {HASH}

"), None, {"valid": False} + yield "tls-no-ja4", "browserscan-tls", TLS.replace(JA4, "Loading..."), None, {"valid": False} + yield "tls-malformed", "browserscan-tls", TLS.replace(JA3, HASH), None, {"valid": False} + yield "tls-duplicate", "browserscan-tls", TLS + card("JA3", JA3), None, {"valid": False} + yield "tls-hidden", "browserscan-tls", f"", None, {"valid": False} + yield "creep-zero", "creepjs", "

99% headless

", CREEP, { + "valid": True, "ratingsComplete": True, + "percentages": ["38% like headless", "0% headless", "0% stealth"], + "metrics": {"totalLies": 0, "capturedErrorCount": 0, "audioLied": False, "svgLied": False}, + "headlessSignals": {"headless": {"webDriverIsOn": False}, "likeHeadless": None, "stealth": None}} + yield "creep-absent", "creepjs", "

0% headless

", None, { + "valid": False, "ratingsComplete": False, "percentages": []} + partial = deepcopy(CREEP) + del partial["headless"]["headlessRating"] + yield "creep-partial", "creepjs", "", partial, {"valid": True, "ratingsComplete": False, + "ratings": {"likeHeadless": 38, "headless": None, "stealth": 0}} + invalid = deepcopy(CREEP) + invalid["lies"]["totalLies"] = "0" + invalid["headless"].update(likeHeadlessRating=101, headlessRating=False, stealthRating="0") + yield "creep-invalid-types", "creepjs", "", invalid, { + "valid": False, "ratingsComplete": False, "percentages": []} + + +async def check(args): + assertions = unittest.TestCase() + results = [] + # Even a supported-but-missing report must not execute the rendered-text fallback. + probe = survey_probe("() => { throw new Error('unexpected fallback'); }") + async with browser_session(args.binary, args.engine, args.output) as (page, _): + for name, site, html, fingerprint, expected in fixtures(): + record = {"fixture": name, "passed": False} + try: + await page.set_content(html, wait_until="domcontentloaded", timeout=10000) + await page.evaluate("""fingerprint => { + window.Fingerprint = fingerprint; + const forbidden = () => { throw new Error('layout-dependent read'); }; + Object.defineProperty(HTMLElement.prototype, 'innerText', {get: forbidden, configurable: true}); + Element.prototype.getBoundingClientRect = forbidden; + window.getComputedStyle = forbidden; + }""", fingerprint) + actual = await asyncio.wait_for(page.evaluate(probe, {"site": site}), 10) + for key, value in expected.items(): + assertions.assertEqual(actual[key], value, f"{name}: {key}") + record["passed"] = True + except Exception as error: + record.update(error_type=type(error).__name__, detail=str(error)) + results.append(record) + # Only unrelated sites may use the supplied legacy collector. + actual = await page.evaluate(survey_probe("arg => ({fallback: arg.site})"), {"site": "other"}) + assertions.assertEqual(actual, {"fallback": "other"}) + save(args.output / "fixtures.json", results) + failures = [item for item in results if not item["passed"]] + print(f"{args.engine}: {len(results) - len(failures)}/{len(results)} DOM fixtures passed; fallback dispatch passed") + if failures: + raise SystemExit(f"Fixture failures: {failures}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--binary", type=Path, required=True) + parser.add_argument("--engine", choices=["moli", "chromium"], required=True) + parser.add_argument("--output", type=Path, required=True) + asyncio.run(check(parser.parse_args())) diff --git a/moli-benchmark/tests/test_fingerprint_runner.py b/moli-benchmark/tests/test_fingerprint_runner.py new file mode 100644 index 0000000000..944a5755c6 --- /dev/null +++ b/moli-benchmark/tests/test_fingerprint_runner.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import asyncio +import json +import unittest + +from moli_benchmark.fingerprint.cases import CASES, select_cases +from moli_benchmark.fingerprint.observations import Observer, complete_fp_result, is_fp_result, select_fp_result +from moli_benchmark.fingerprint.runner import build_summary, classify + + +class FingerprintContractTests(unittest.TestCase): + def test_canonical_matrix_and_selection(self): + self.assertEqual(len(CASES), 12) + self.assertEqual(len({case.id for case in CASES}), 12) + self.assertEqual(select_cases(["browserscan-bot"])[0].wait_seconds, 10) + for ids in [[], ["unknown"], ["creepjs", "creepjs"]]: + with self.assertRaises(ValueError): + select_cases(ids) + + def test_fp_requires_all_fields_and_retains_zero_and_false(self): + raw = {"bot": "not_detected", "tampering": False, "virtual_machine": False, "suspect_score": 0, + "visitorId": "secret", "requestId": "secret", "ip": "secret", "raw_device_attributes": {"ip": "secret"}} + value = select_fp_result(raw) + self.assertEqual(len(value), 4) + self.assertTrue(complete_fp_result(value)) + for field in value: + self.assertFalse(complete_fp_result({key: v for key, v in value.items() if key != field})) + self.assertEqual(select_fp_result({"bot": {}, "suspect_score": True, "tampering": "false"}), {}) + self.assertNotIn("suspect_score", select_fp_result({"suspect_score": float("nan")})) + + def test_result_origin_is_exact(self): + self.assertTrue(is_fp_result("https://demo.fingerprint.com/api/event/id?token=secret")) + self.assertFalse(is_fp_result("https://demo.fingerprint.com.attacker.test/api/event/id")) + self.assertFalse(is_fp_result("http://demo.fingerprint.com/api/event/id")) + + def test_negative_verdict_is_complete_not_a_collection_error(self): + row = {"engine": "moli", "report": {"valid": True, "verdict": "Robot"}, "process_cleaned_up": True} + row["status"] = classify(row) + self.assertEqual(row["status"], "complete") + summary = build_summary([row], select_cases(["browserscan-bot"]), ["moli", "chromium"]) + self.assertEqual(summary["coverage"]["moli"]["complete"], 1) + self.assertEqual(summary["coverage"]["chromium"]["attempted"], 0) + self.assertNotIn("pass_count", summary) + + def test_missing_does_not_become_pass_and_errors_do_not_disappear(self): + row = {"report": {"valid": False}, "process_cleaned_up": True} + self.assertEqual(classify(row), "missing") + row["report"]["fields"] = {"value": None} + self.assertEqual(classify(row), "partial") + row["errors"] = [{"type": "TimeoutError"}] + self.assertEqual(classify(row), "timeout") + row["errors"] = [{"type": "Error", "network_codes": ["net::ERR_CERT_AUTHORITY_INVALID"]}] + self.assertEqual(classify(row), "network_error") + row["process_cleaned_up"] = False + self.assertEqual(classify(row), "cleanup_error") + + +class FakeCDP: + def __init__(self): + self.calls = [] + + def on(self, *_args): + pass + + async def send(self, method, params): + self.calls.append((method, params)) + return {"body": json.dumps({"bot": "not_detected", "tampering": False, + "virtual_machine": False, "suspect_score": 6, "ip": "private"})} + + +class ObservationWindowTests(unittest.IsolatedAsyncioTestCase): + async def test_headers_before_but_body_after_cutoff_does_not_fill_the_sample(self): + cdp = FakeCDP() + now = [1] + observer = Observer(cdp, lambda: now[0]) + observer.requested({"requestId": "r", "request": {"url": "https://demo.fingerprint.com/api/event/secret"}}) + observer.received({"requestId": "r", "response": {"status": 200}}) + frozen = observer.snapshot(2) + now[0] = 3 + observer.finished({"requestId": "r"}) + result = await observer.resolve(frozen) + self.assertFalse(result["responses"][0]["within_window"]) + self.assertNotIn("result", result["responses"][0]) + self.assertEqual(cdp.calls, []) + completed = await observer.resolve(observer.snapshot(4)) + self.assertTrue(complete_fp_result(completed["responses"][0]["result"])) + encoded = json.dumps(completed) + for forbidden in ["private", "secret", "requestId"]: + self.assertNotIn(forbidden, encoded) + + async def test_redirect_outside_result_origin_cannot_supply_a_verdict(self): + observer = Observer(FakeCDP(), lambda: 1) + for url in ["https://demo.fingerprint.com/api/event/id", "https://example.com/redirected"]: + observer.requested({"requestId": "r", "request": {"url": url}}) + observer.received({"requestId": "r", "response": {"status": 200}}) + observer.finished({"requestId": "r"}) + self.assertEqual((await observer.resolve(observer.snapshot(2)))["responses"], []) + + +if __name__ == "__main__": + unittest.main() diff --git a/moli-benchmark/tests/test_target_serve.py b/moli-benchmark/tests/test_target_serve.py index 21ff1fd7e1..b80fa23044 100644 --- a/moli-benchmark/tests/test_target_serve.py +++ b/moli-benchmark/tests/test_target_serve.py @@ -1,6 +1,7 @@ from __future__ import annotations import io +import os import unittest from pathlib import Path from unittest.mock import patch @@ -128,6 +129,30 @@ class TargetServeTests(unittest.TestCase): [{"elapsed_ms": 1.0, "rss_bytes": 42}], ) + def test_native_chromium_preserves_identity_and_does_not_sample_resources(self) -> None: + with ( + patch("moli_benchmark.target_serve.subprocess.Popen", return_value=_FakeProcess()), + patch("moli_benchmark.target_serve.ResourceSampler") as sampler, + patch("moli_benchmark.target_serve.probe_url", return_value=True), + patch("moli_benchmark.target_serve._kill_process_group") as kill, + ): + handle = start_target_serve("chrome-cdp", Path("/bin/chromium"), 1, native=True) + self.assertIn("--remote-debugging-address=127.0.0.1", handle.command) + self.assertIn("--no-proxy-server", handle.command) + for forbidden in ["--headless", "--disable-gpu", "--no-sandbox", "--user-agent"]: + self.assertFalse(any(flag.startswith(forbidden) for flag in handle.command)) + sampler.assert_not_called() + stopped = stop_target_serve(handle) + self.assertTrue(stopped["process_exited"]) + self.assertEqual(stopped["resources"], {}) + if os.name == "posix": + kill.assert_called_once() + + def test_native_profile_rejects_custom_flags_and_unrelated_engines(self) -> None: + for target, flags in [("lightpanda-cdp", ()), ("chrome-cdp", ("--user-agent=Fake",))]: + with self.assertRaises(ValueError): + start_target_serve(target, Path("/unused"), 1, flags, native=True) + if __name__ == "__main__": unittest.main() diff --git a/moli-benchmark/uv.lock b/moli-benchmark/uv.lock index 67c9e6fa9d..f52000a2b9 100644 --- a/moli-benchmark/uv.lock +++ b/moli-benchmark/uv.lock @@ -2,6 +2,83 @@ version = 1 revision = 3 requires-python = ">=3.11" +[[package]] +name = "greenlet" +version = "3.5.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/6e/0091f175ccd02b02bc8811bbcbcc6ac2e980be116e3b2f7a736ca322bf84/greenlet-3.5.6.tar.gz", hash = "sha256:8e67c43bdfc88d5fee6db0d3e40175b362fc95fb85f0412d233b9b203c53a575", size = 207653, upload-time = "2026-09-14T15:42:51.806Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d7/41511ee2696f14be4200b524d9553dc4295e2bdeb20aa8962c3cb25e71c6/greenlet-3.5.6-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a6a4b98a9132e0f45c9fc245a63894cfd8c45fb7a0d6bffc5eab3ec327cf7324", size = 294075, upload-time = "2026-09-14T14:25:16.922Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7b/b509624970909294cd064ff7346148ca9941c21bec9026d7873dd254e9fa/greenlet-3.5.6-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45bfd2b51e38aaa5f9849f114d9c7c1d75f69187c849b3549cd64c465283abfa", size = 613429, upload-time = "2026-09-14T15:12:00.454Z" }, + { url = "https://files.pythonhosted.org/packages/2b/5c/d2eb503067f9ba20875ef8c87681f29a64f53bbbbe4059a5d7c53179d442/greenlet-3.5.6-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3c6dede9133e1da41d561bc3fb14e92b47e2ce39ae60edefaad145658ea7c5e2", size = 625405, upload-time = "2026-09-14T15:20:41.053Z" }, + { url = "https://files.pythonhosted.org/packages/1b/24/9b071d11c8bb9f5f38cccacc38fcc234d91997a4c395cc2bf43ecae89642/greenlet-3.5.6-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4fb8e59f68845d56c23c031dcd79c329f345e4a9d2ffac91c3d1ab366bdc457b", size = 633270, upload-time = "2026-09-14T15:25:04.864Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d3/63d4477ce31dff2fd802a9a20240f6606aac85977e0fb18443aae33de3f6/greenlet-3.5.6-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c20ea32a73d17b9b60e3371240e17b0068120c98a5ec01a224a7dd8c89733ba", size = 624428, upload-time = "2026-09-14T14:35:56.895Z" }, + { url = "https://files.pythonhosted.org/packages/88/17/ac11883ecc9da19c681c8b763ee39e6f7dca2aa81874eb11a075d3cbeb00/greenlet-3.5.6-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:d701eab36200c36224833d07dbdb709adb7fd4253429548ddb5e547b8ed40586", size = 428068, upload-time = "2026-09-14T15:28:35.872Z" }, + { url = "https://files.pythonhosted.org/packages/ad/aa/9cde4e00688eaa2a03b91d12e4681439a87e6aad860399e0847af6a014ca/greenlet-3.5.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5a0b2791239c99992a86c1b635b787fe2a877d9eaaa26f8891ce943832b585ae", size = 1588385, upload-time = "2026-09-14T15:10:05.386Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/24632b5ec186b64e21e07a8f53ce5e15a7e9cb33eddee99a5fe16379afa5/greenlet-3.5.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:188bf333769b7145e2b0b4a7f09615ec550ed44d3a2a8395fb7b36f0e9901e13", size = 1653119, upload-time = "2026-09-14T14:35:48.275Z" }, + { url = "https://files.pythonhosted.org/packages/ce/6c/019d2ef898f4b9ac845167f1c6f73229e9a4e2439362a5e2ce50205a19b0/greenlet-3.5.6-cp311-cp311-win_amd64.whl", hash = "sha256:a6b4ff33f7e011bbaa148238d131c4fd4f8afbab3c104ddfbdb2b12b74ff7016", size = 323317, upload-time = "2026-09-14T14:22:38.836Z" }, + { url = "https://files.pythonhosted.org/packages/5a/7a/439df999455e3bdf02b1c68f3848d4020385ef0a01f89f706b07bf148a65/greenlet-3.5.6-cp311-cp311-win_arm64.whl", hash = "sha256:59deccd347735a7774223b05a93773fddbb298aba3cea21be4337fb4752dbe32", size = 307739, upload-time = "2026-09-14T14:23:40.469Z" }, + { url = "https://files.pythonhosted.org/packages/72/18/3fc6d951466ae9a2a688edcddde3b2e388da0a8244e0caf7117bbeb0eb95/greenlet-3.5.6-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:a5876d0a60355af98d535c47f6cd6eb0f8a432396dab26845d380b92f8412422", size = 295668, upload-time = "2026-09-14T14:22:33.241Z" }, + { url = "https://files.pythonhosted.org/packages/27/89/366d2af5061eeefa5012f510d95a99c8620dcc457609838db4d538820318/greenlet-3.5.6-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e85880b538e59a59f55117b81f208a6660ad5ac328aad9305f812d9b8bc67a0f", size = 611700, upload-time = "2026-09-14T15:12:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/54/1c/07f133f865fd58ae593dd2bbec3144acaee9b04ffe2eb48c6e121747ceef/greenlet-3.5.6-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f0ba7c2a329d650628f4c8572fd1db29f0a59dd70a3e3e0710dcf18a35cce9d8", size = 624223, upload-time = "2026-09-14T15:20:42.459Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f2/844dc823ff2752ad049caa6b59d57e4572f9c445934b02d3518f4c67197c/greenlet-3.5.6-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee7d9da3bf493909cf811a3f038840cb34fab5ae2956b8a263919f6e289ab188", size = 629529, upload-time = "2026-09-14T15:25:06.354Z" }, + { url = "https://files.pythonhosted.org/packages/66/6a/1594f3869c57c149abdb380492529e04d4c0229b5e4d79572c5bd0aaa673/greenlet-3.5.6-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:975736b002ed080d124cf81a79cb7e05cb26d6b3f5c7a7b651c0fcce70353aa1", size = 621404, upload-time = "2026-09-14T14:35:59.027Z" }, + { url = "https://files.pythonhosted.org/packages/c0/42/b1f8dbc89a53b9e77859fc1ad1627d106fc361daa3ea4bdf43a91ebb4338/greenlet-3.5.6-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:71890d5247020c25c21a6b65202782bfc281d4e6e244842419d30e3492bb6dcc", size = 432385, upload-time = "2026-09-14T15:28:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f5/33e5c9e48178b9259fd000f8f45caa4a65036f65d3d0c06a602f570f025d/greenlet-3.5.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0616b8f878098c5681fd8f0dc92d887551717402342a70f0abcbfea5f5ad8a44", size = 1584998, upload-time = "2026-09-14T15:10:06.653Z" }, + { url = "https://files.pythonhosted.org/packages/ef/31/9b4e140bc24d0ad7927ebd651f5608b0acc2334d061748c3b6ad19085cfa/greenlet-3.5.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3dbb4596a6a4e5d47121a33ff20533a81e60f302d9e67b69909a8bc21a43f0a7", size = 1647568, upload-time = "2026-09-14T14:35:49.787Z" }, + { url = "https://files.pythonhosted.org/packages/c3/71/d79f1791f824f8ff15c2978746640467ae932a2365e0201069f7f272395f/greenlet-3.5.6-cp312-cp312-win_amd64.whl", hash = "sha256:7ac4abb3877c43af320392c664774eef6fa2cc063c79a55fc02d844a3cbe7395", size = 324203, upload-time = "2026-09-14T14:22:54.504Z" }, + { url = "https://files.pythonhosted.org/packages/63/af/42aca4d56e8cb321912203069d8d34734cb288222f10ad2ae102718cc577/greenlet-3.5.6-cp312-cp312-win_arm64.whl", hash = "sha256:301102a49120b095e72a7838792b41233975fc1c155daec6d98f81c00c9280e0", size = 308310, upload-time = "2026-09-14T14:24:03.008Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a1/e720a38852366c589e1a46cf570b886507ad2cf591050c203365638baab0/greenlet-3.5.6-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:f96f0e30b5a95c7631b12bfe214cbc90ec8fe8cfa36920596c10514a65743519", size = 294627, upload-time = "2026-09-14T14:24:40.102Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c3/58187858df41354a11e6a55b421e7af9059798abdab3a384cc51b8567c38/greenlet-3.5.6-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c75116c9de79949de23006e2d9b35ee82874c594fcf5c0311b439acaa14b8441", size = 614356, upload-time = "2026-09-14T15:12:03.399Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b9/3a7e67d5f05c9760b1ad411fa52264bd69cc08e22a2ebfb4018b90628ced/greenlet-3.5.6-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cad5782f93f7f738b62c6527b6f32a60694d924029f299a8b524758cfa53d815", size = 626756, upload-time = "2026-09-14T15:20:44.269Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7c/40400455f5b5a65bb83e94fde66d1be9e5ec518638113f8083ace746c309/greenlet-3.5.6-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a93ee7c6e8fd0f8a83525a51bd777be57ee17787e91d805bd8d6faf9dcada18e", size = 632632, upload-time = "2026-09-14T15:25:07.813Z" }, + { url = "https://files.pythonhosted.org/packages/85/cb/ab0c123c514ed4e94c0dc9ee2e86362633e6b998cfc05de7fc9ac2eb9690/greenlet-3.5.6-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f98e8215e172f567ce80eeaed9107fb4d32b6c44f26983d9b8334658136a205a", size = 623779, upload-time = "2026-09-14T14:36:01.104Z" }, + { url = "https://files.pythonhosted.org/packages/f9/67/1f35cff30a6c51c3f23b63d4afcc7313ab4f97490ba3676fa78178984b27/greenlet-3.5.6-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7f731ebac68ea06d628658295cb2d217b10186329fcf9a3b6a149045059bf92e", size = 434933, upload-time = "2026-09-14T15:28:38.858Z" }, + { url = "https://files.pythonhosted.org/packages/a5/26/fda8a5a06e7073333ccb038133c5893b9e0c4fe29d5992a17e83c241bc6e/greenlet-3.5.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df19e2d0b1620039af5102563fbd96e8938c7f5c3f5828528d641d9fc585525e", size = 1584930, upload-time = "2026-09-14T15:10:08.234Z" }, + { url = "https://files.pythonhosted.org/packages/2f/37/50f8813163148d6234e08b23dcad6a9e37f01d148c8ec976e4c44ea2d918/greenlet-3.5.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:06c0e933290fba8ffe53ead4ae1b8044b0e9754b75cebf381aa2bc3e50d82fac", size = 1647590, upload-time = "2026-09-14T14:35:51.173Z" }, + { url = "https://files.pythonhosted.org/packages/86/da/b7669b09586365654083a62bd0724cf06cb74bd5085a15cdd161271f992f/greenlet-3.5.6-cp313-cp313-win_amd64.whl", hash = "sha256:5b602b4201b965a8354d74e232364a66ff243dd142e350d035f46169bb36e13d", size = 324086, upload-time = "2026-09-14T14:23:48.428Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5d/c9663cfe84a2a9e0aa96f066f5b0594c227ea4c647511e087e2e11d4ac0a/greenlet-3.5.6-cp313-cp313-win_arm64.whl", hash = "sha256:876077e7ebb8c84ed068e2b23d4c62ebb010d60df84b9591af1be2f39010ffb2", size = 308211, upload-time = "2026-09-14T14:28:01.634Z" }, + { url = "https://files.pythonhosted.org/packages/66/c0/d254544ae2b8bdd311aef000fafc02828c2771b17d994b3075620ea7cc6e/greenlet-3.5.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8cddea1b8339451c2fb3388e138347b6126744f33b611bdb55b7357361cfef46", size = 295221, upload-time = "2026-09-14T14:25:11.583Z" }, + { url = "https://files.pythonhosted.org/packages/18/18/eb54be16b9cc3971e09ca5b73334e1b8c804a4630d9addaaf218a4fe300f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c59acfa8eb73a1e0d484392dc002bdf001fd4ce73394e0132df3d1ab6093d7cb", size = 660992, upload-time = "2026-09-14T15:12:04.876Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b4/e193efe65671dcf294bc51fcc59efb52d154adf8612c4ea016da0d2c486c/greenlet-3.5.6-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3b4a01c6da07ef9f80d4fe8933b994bc99747bcea3eab0330a9c34d3c12655b", size = 673428, upload-time = "2026-09-14T15:20:45.756Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/631bb45fafde1dca782152377c0676d182ec924820064047f533a3627b28/greenlet-3.5.6-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dd0b83bed3405b586a3133629f1d1a5bc7bfd64822a3b7ab342bdc68e6dbc61b", size = 677688, upload-time = "2026-09-14T15:25:09.279Z" }, + { url = "https://files.pythonhosted.org/packages/45/ac/28fa7a9e50f2859466214c4ac584d776db52c1604ad4dd158960a5af2a1f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a09d59bef1db94f384b5bcc2d523694d338f3df6b757aeeaf7baca5d0c0be88", size = 670773, upload-time = "2026-09-14T14:36:02.577Z" }, + { url = "https://files.pythonhosted.org/packages/40/30/2b0a73e68e1e18e30b601d0d183cfdfc2beca4de5a6843c630f0fc9fb90c/greenlet-3.5.6-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:fdacf26402389bdd89857ad3c045a26fe8f3314f9a8b28226f82f88463a65b77", size = 480475, upload-time = "2026-09-14T15:28:40.741Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cd/fb7d6cdd86ff3427c1494854f0e35437eba05142be91f530f6da75e09e19/greenlet-3.5.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8b7c73d1cef3d9ae963e9ff03f6222df43efbb9054ffd2f1969c935b7fc84c02", size = 1631900, upload-time = "2026-09-14T15:10:09.745Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/143bdbb20a516628cb15074ae52ed17d850b450292609c7a6fccac6dbece/greenlet-3.5.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8b27df301f56e3b3d2298095c8f7d6b68f2521f6b1693e901fa039bdbae34424", size = 1693740, upload-time = "2026-09-14T14:35:52.959Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9e/019642432e6ae283301df1361227d47610709d2dc69a38f95edef266d713/greenlet-3.5.6-cp314-cp314-win_amd64.whl", hash = "sha256:f8f0bd690e1a41294ac87905e8121c81a3761ec2583c768f13467428606c8c7a", size = 327473, upload-time = "2026-09-14T14:28:12.948Z" }, + { url = "https://files.pythonhosted.org/packages/e9/7f/8aafc7bf70c948786dba7221d0dc0838e5329bebc6d434ef2208b4f0e760/greenlet-3.5.6-cp314-cp314-win_arm64.whl", hash = "sha256:8cda13494d86a4f12429641117cb6ac4bbbc9c30a33f711f7d3a2e5fbe4b0b7e", size = 311095, upload-time = "2026-09-14T14:28:00.7Z" }, + { url = "https://files.pythonhosted.org/packages/14/7e/7a205688a5b3074933b18a906608d46d106e9a79d776bdab5a4abf4b4feb/greenlet-3.5.6-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:97c5a53e8c1754df58e73f047a99e287d4da1bdfe64b0072fb25c87000897951", size = 305352, upload-time = "2026-09-14T14:21:31.962Z" }, + { url = "https://files.pythonhosted.org/packages/78/cb/9c4a57a9d9dd0256e20b8f7f4f06554c2c92badebf0ab73ce344321b78b9/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fea4427d1ffdb3b523d7daa6712038428a4c16c450b9777bdd1221cfee0eab49", size = 672671, upload-time = "2026-09-14T15:12:06.347Z" }, + { url = "https://files.pythonhosted.org/packages/97/52/c6729681ebbd298f4decd28746815acc8a0b0a0fde21d2df33776fd4d042/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73a29b5ba642e35433166a03a3e02935e7238c4b3467fbd77523b99edea23e5b", size = 679489, upload-time = "2026-09-14T15:20:47.291Z" }, + { url = "https://files.pythonhosted.org/packages/71/76/3c11c21e0716b1f1dc7c1a4b3d690abb1d3b448c69a9d32049fecb64010a/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:61a61b4a95a4f97922c3a6f5606d3e360851584bd47e500a5161373c53810e3d", size = 681303, upload-time = "2026-09-14T15:25:11.088Z" }, + { url = "https://files.pythonhosted.org/packages/58/c5/2b6c721ba8b8963da42d5a0f57f25b8aaeb1fe9bdd156875e57f3be648a2/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:460e70b033aba8ed47e2ac9b5d0d2157b05a34fbfa30a241400aef4118902cdc", size = 676608, upload-time = "2026-09-14T14:36:03.959Z" }, + { url = "https://files.pythonhosted.org/packages/3f/26/3ae402202452cd5941bbbd483e5a74297e2397e7aa3182c2a5e3ab7d5666/greenlet-3.5.6-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:fe3170a69fe039b18ad18171e66faa9a75f6fe9d78f968fd9b54e09fbd714d81", size = 510112, upload-time = "2026-09-14T15:28:42.112Z" }, + { url = "https://files.pythonhosted.org/packages/b2/04/0d018e0d05bcdde19a0fcb907834155f1fc853a9bedd3f3f5e6acadcae19/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca80a49b53ed1d22f7282da7255f7bb2fd1935fd0f623d8613fda38745f18961", size = 1641479, upload-time = "2026-09-14T15:10:11.216Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/f02ef9073919158f6403fe3701d4ed4403d646720e7201dfc6e9d264bac3/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:916f92f2a8db10508f739d0b5e00b83defe5d1115a997c54532a6d7cf8c95404", size = 1698758, upload-time = "2026-09-14T14:35:54.336Z" }, + { url = "https://files.pythonhosted.org/packages/08/a5/1f48fe647473a2dcccfd1839b2ff2c78eb57009be776b4da071e901c9bff/greenlet-3.5.6-cp314-cp314t-win_amd64.whl", hash = "sha256:886bcf1870af74c32bc310fd00a6b803445e17e51b7d5a107c7b35c0f362cc16", size = 331574, upload-time = "2026-09-14T14:27:18.451Z" }, + { url = "https://files.pythonhosted.org/packages/cd/72/3882855a75838faeb54a58aeef4fd77d20b2a86d4bad570c70d41b565dcf/greenlet-3.5.6-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:3ac3494c381dab876cad7d0b22f3a722f3e0c8deb3a65b9e7f35ad7f58b8fcb3", size = 295926, upload-time = "2026-09-14T14:27:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/10/1f/be4d957d8a9b90bcbe8db206548a42134d96222d43e5ed3fc4708fb6e24b/greenlet-3.5.6-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:602024dae6d77e161f4b89491b62ca1d4f19949d79d47b2db057e476d21179d6", size = 666910, upload-time = "2026-09-14T15:12:07.901Z" }, + { url = "https://files.pythonhosted.org/packages/a1/af/60d62571a7d6de961e4ce7625d6c2faf359345659fc782d2cdf517c34577/greenlet-3.5.6-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8e63209c3e1e828ee6a457529b4a6d8b05d050fe0ae03a7ae49e967c5d312e0", size = 677415, upload-time = "2026-09-14T15:20:48.817Z" }, + { url = "https://files.pythonhosted.org/packages/f5/41/b3114c97c10e796010f00a30f51c81470072bca4b53e396ccca87484fcf7/greenlet-3.5.6-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9133d68624b1f2e89ec2f554d56aea8a5b0d7168cd9320200ba58d4d794845a4", size = 681337, upload-time = "2026-09-14T15:25:12.812Z" }, + { url = "https://files.pythonhosted.org/packages/fb/16/ac9e547b611539aaed1870eb1d6ddc57abdd5924b3a99bb9b5f0b44176b8/greenlet-3.5.6-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccadce0130fd813ec86ebfe969a6c58b42acc1d0fe55a47525375b740e07b605", size = 675927, upload-time = "2026-09-14T14:36:05.34Z" }, + { url = "https://files.pythonhosted.org/packages/48/1b/d41861c2fa00968e39e467a495ca8db9ce9b6310a5d9b57561b3d0dc48fa/greenlet-3.5.6-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:5adcbbfe78bdc242c71740a02e0991cc1b2f34d33c8bb15ca45eee8fd1140942", size = 487339, upload-time = "2026-09-14T15:28:43.497Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b1/b7ba08d6431121741f1d30be0d5d292e76873325179a63586cd9217b62f6/greenlet-3.5.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9297fb9c39b9a2c039dbcd306c410bd6906b95244dec3bba4318d36c718c164c", size = 1637236, upload-time = "2026-09-14T15:10:12.442Z" }, + { url = "https://files.pythonhosted.org/packages/af/c5/3b1cbc68f0c082022fc8717f7fe4b8b13b8d583c52352be37f4e9f55bcd2/greenlet-3.5.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b374e79ffa7511afc11773aef40a4ccea6191fba1c856ea2f9c56738dca69d7a", size = 1698526, upload-time = "2026-09-14T14:35:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/de/56/12941ed2711400451c89d544e10f831800a2770f19dd55eac8f0f7f2003b/greenlet-3.5.6-cp315-cp315-win_amd64.whl", hash = "sha256:7969bffa322c097bd46ae595ada6a931cefda613f18ba64587e9cff4cb320756", size = 327739, upload-time = "2026-09-14T14:23:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3b/576b9ed5ac929252e340cf60b4bcb6a8515350dc20797064b1922dc4ea75/greenlet-3.5.6-cp315-cp315-win_arm64.whl", hash = "sha256:8dba0129b93e7091dfefaf4cf7000172741bff7f47bf6326fcf17f32fbb54d6b", size = 311715, upload-time = "2026-09-14T14:28:25.154Z" }, + { url = "https://files.pythonhosted.org/packages/16/c2/86cfc5555a98e12b86966ddbd24fd39af32f71f2f785c6595b7feb2db156/greenlet-3.5.6-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:de3de000d459402cda015068fd135aa50c0bf6f2477a80d4da1e646f123b4e78", size = 306244, upload-time = "2026-09-14T14:27:57.565Z" }, + { url = "https://files.pythonhosted.org/packages/14/6d/83ffc9d05a75a80ab3a7595dbb1d9604e5d4fc2996d73a8ae2dbd1284900/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45663c01a4de48b9a64a2ee1509d92d1dfd3afb02b2ccfc9333029d11aef996a", size = 676578, upload-time = "2026-09-14T15:12:09.468Z" }, + { url = "https://files.pythonhosted.org/packages/5d/d6/c2cf684810e5caded075970aaadea654ecb58b8382b9aecf1d231b936894/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3deccbb57a481e3a408fe61cdfd5c13e0678fc0a30fdd09597917ca87b4be877", size = 684203, upload-time = "2026-09-14T15:20:50.261Z" }, + { url = "https://files.pythonhosted.org/packages/f2/d1/039c353d5593a97a89699e989324c9bc86af499e6c6152fe0180f5742204/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:63aff70fe5aac59c72215f42ec39fcb59ff46774fa966e717f8ecb6ee2273577", size = 686023, upload-time = "2026-09-14T15:25:14.528Z" }, + { url = "https://files.pythonhosted.org/packages/62/19/00e1bee5d2af890dc8f400b54d0b0f9b489965f92bc12b407ff72cc6f469/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:311018b46472fb26ee85870847fb89eb64cc8aaddb617400789d87076f7cfeec", size = 681253, upload-time = "2026-09-14T14:36:06.742Z" }, + { url = "https://files.pythonhosted.org/packages/8a/62/97ceb8e0b2ea96046cdf8e95b042715020ebb12d83ea0690db80a8f03d23/greenlet-3.5.6-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:520648db8fb92eef7b3e6013f5a6f901cdf0d6685f639c2f7a245879f865bef7", size = 517058, upload-time = "2026-09-14T15:28:44.924Z" }, + { url = "https://files.pythonhosted.org/packages/89/58/c9275fd0ca195d1d3402931bcce8cfcc74726ff76efb1883d229e6e1a3d7/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7f924a5a9d5890649566f2f6682e0d8ad8ca23028bacffbbac36dbd7fd680176", size = 1645988, upload-time = "2026-09-14T15:10:13.758Z" }, + { url = "https://files.pythonhosted.org/packages/e0/36/b35747582fa4f1a5453f8f3002405dbac788e450cec7674dc2d204b6ccb5/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:de9923832f2d8c1a5ecd8d7260465a6ca5a86888a0d129e3bd5cf0406d2fc5bf", size = 1702383, upload-time = "2026-09-14T14:35:58.143Z" }, + { url = "https://files.pythonhosted.org/packages/ed/69/6ec22ac9351e474d2a134d0ff9400dc80362d1c20f0721088ffffdfc205b/greenlet-3.5.6-cp315-cp315t-win_amd64.whl", hash = "sha256:2ab5f42ac6c238eb71770715e6e909ad9a1a92b6c681ccb64cd5a0f07edb953f", size = 331845, upload-time = "2026-09-14T14:27:41.723Z" }, + { url = "https://files.pythonhosted.org/packages/30/cf/697c051fd534e223461fb8b523890e21a24eeca229cd50624cff6f02fabd/greenlet-3.5.6-cp315-cp315t-win_arm64.whl", hash = "sha256:f9fe868463ec7e1363733af77e38a5fda3e9b63940337048c945d69e0c80ff24", size = 315070, upload-time = "2026-09-14T14:22:21.476Z" }, +] + [[package]] name = "moli-benchmark" version = "0.1.0" @@ -11,11 +88,18 @@ dependencies = [ { name = "websockets" }, ] +[package.optional-dependencies] +fingerprint = [ + { name = "playwright" }, +] + [package.metadata] requires-dist = [ { name = "pillow", specifier = ">=10,<13" }, + { name = "playwright", marker = "extra == 'fingerprint'", specifier = ">=1.48,<2" }, { name = "websockets", specifier = ">=13,<16" }, ] +provides-extras = ["fingerprint"] [[package]] name = "pillow" @@ -102,6 +186,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, ] +[[package]] +name = "playwright" +version = "1.63.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/dd/fbb3d34228ad753bc14464d5f2252585b73367539775a88d0a3949cf5a45/playwright-1.63.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:84c540759e8e7f01e690e197e04060f48899d37cea322299f255843273d3385d", size = 44278407, upload-time = "2026-09-15T16:49:06.045Z" }, + { url = "https://files.pythonhosted.org/packages/f5/9a/948b930b1a8c4ee869e5a139a2b7747caa06aab56a3f09a2f0abdcbda221/playwright-1.63.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fd1aa00631d44d55e56e0975bf3f3f285fac4a9fd2813183f0c12a488f1a1b24", size = 42944679, upload-time = "2026-09-15T16:49:10.039Z" }, + { url = "https://files.pythonhosted.org/packages/94/11/dc5c13fa1602371603acd461be47529c1b3513815d3a0dc98f642c291a10/playwright-1.63.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:c89fc4736502a1f0fac2c8ca5d10c0cbc1c669f1f4774a2d8507a43140e4d53f", size = 44278410, upload-time = "2026-09-15T16:49:13.861Z" }, + { url = "https://files.pythonhosted.org/packages/27/9c/103a5037789062bdab27c7dca53f3ca6b075b572ab2cd96eec825b3aec4e/playwright-1.63.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:ad21bc07516b187965a7521c5cf0df0bd657b17482eaad74335272d35a2b07de", size = 48217159, upload-time = "2026-09-15T16:49:17.404Z" }, + { url = "https://files.pythonhosted.org/packages/f3/82/3d85505284c5a210f2da6c07b8f757524e79d1fba9cfdafe1eafb766ae59/playwright-1.63.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:354e15b29503565fc598b89f16fbe070459343bef9d7498a93e304864000c6a7", size = 47902708, upload-time = "2026-09-15T16:49:21.055Z" }, + { url = "https://files.pythonhosted.org/packages/69/8d/f74ff6b52751859f4b69caf66aa7d4a3c8d6c1f0c7dc9920da103612ee39/playwright-1.63.0-py3-none-win32.whl", hash = "sha256:660c00c62639e31b16700ba5456b351ddba55bb766b7ce8261223aa34928e482", size = 38606075, upload-time = "2026-09-15T16:49:29.546Z" }, + { url = "https://files.pythonhosted.org/packages/76/eb/d6b8d92658038e260dbc7dd69fb3fdbe245aac283953de8b50b02bfe5f61/playwright-1.63.0-py3-none-win_amd64.whl", hash = "sha256:2f9a707a6c6c91157ed77bff2b8caeb04b3c8d46e70d585fc134298cbe4b5cc6", size = 38606082, upload-time = "2026-09-15T16:49:32.838Z" }, + { url = "https://files.pythonhosted.org/packages/be/75/2432b3e3c7c62103b72d4c4cc8b16a56383ada372bbb0d1278591e988f71/playwright-1.63.0-py3-none-win_arm64.whl", hash = "sha256:1e4a3a838ce22fb68ad17193fcd142a19610d9d70fb9966d2239f5dddc0cc05b", size = 34523554, upload-time = "2026-09-15T16:49:36.455Z" }, +] + +[[package]] +name = "pyee" +version = "13.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + [[package]] name = "websockets" version = "15.0.1"