feat(benchmark): add native CDP fingerprint survey runner

This commit is contained in:
ldm0
2026-09-29 16:50:19 +08:00
parent 3c8dbbbfcd
commit e1864258e2
17 changed files with 1178 additions and 6 deletions
@@ -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.
@@ -0,0 +1 @@
"""Observational public-site compatibility benchmark; never an aggregate bot pass gate."""
@@ -0,0 +1,3 @@
from .cli import main
raise SystemExit(main())
@@ -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)
@@ -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]
@@ -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
@@ -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 <strong>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 <p>. 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;
}
@@ -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
@@ -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"))
@@ -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
@@ -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');
}
+30 -6
View File
@@ -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:],
+3
View File
@@ -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"
@@ -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"<div><strong>Test <span>Results:</span></strong><svg></svg><strong>{value}</strong></div>"
def card(label: str, value: str) -> str:
return (f"<section><div><div><h3>{label}</h3></div></div>"
f"<div><div><p>{value}</p></div></div></section>")
HASH = "a" * 32
JA3 = "771,4865-4866,0-23-35,29-23,0"
JA4 = "t13d1516h2_" + "b" * 12 + "_" + "c" * 12
TLS = ("<h3>HTTP/2 Fingerprint</h3>" + 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", "<p>Normal</p>", None, {"valid": False, "verdict": None}
yield "pending", "browserscan-bot", bot("Loading..."), None, {"valid": False, "verdict": None}
yield "hidden", "browserscan-bot", f"<div hidden>{bot('Normal')}</div>", None, {"valid": False}
yield "template", "browserscan-bot", f"<template>{bot('Normal')}</template>", None, {"valid": False}
yield "duplicate", "browserscan-bot", bot("Normal") + bot("Robot"), None, {"valid": False}
yield "duplicate-value", "browserscan-bot", bot("Normal").replace(
"</div>", "<strong>Robot</strong></div>"), None, {"valid": False}
yield "hidden-decoy", "browserscan-bot", bot("Robot") + f"<aside hidden>{bot('Normal')}</aside>", None, {
"valid": True, "verdict": "Robot"}
yield "explanation-decoy", "browserscan-bot", bot("") + "<p>Normal</p>", 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 + """<script>
for (const p of document.querySelectorAll('section p')) {
const ad = document.createElement('div');
ad.textContent = 'Convert Data Files'; p.append(ad);
}
</script>""", None, {"valid": True}
yield "tls-hash-only-in-ad", "browserscan-tls", TLS.replace(HASH, "") + f"""<script>
const ad = document.createElement('div'); ad.textContent = '{HASH}';
document.querySelector('section p').append(ad);
</script>""", None, {"valid": False}
yield "tls-hashes-in-prose", "browserscan-tls", (
"<h3>HTTP/2 Fingerprint</h3>" + card("Akamai Hash", "") + card("JA3", "") + card("JA4", "")
+ f"<p>{HASH} {HASH}</p><script>const example='{HASH}'</script>"), 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"<div aria-hidden=true>{TLS}</div>", None, {"valid": False}
yield "creep-zero", "creepjs", "<p>99% headless</p>", 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", "<p>0% headless</p>", 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()))
@@ -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()
+25
View File
@@ -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()
+124
View File
@@ -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"