diff --git a/.github/workflows/fix-d-stealth-cells.yml b/.github/workflows/fix-d-stealth-cells.yml new file mode 100644 index 0000000..d0a7f63 --- /dev/null +++ b/.github/workflows/fix-d-stealth-cells.yml @@ -0,0 +1,82 @@ +name: fix-d-stealth-cells + +# TEMPORARY — plan_RELEASE_FIX_D scaffolding, delete when this stack lands. +# +# FIX-D must report the F-770 User-Agent leak-vector measurement on all three +# qualified cells (Linux/X64, Windows/X64, macOS/ARM64) and then prove the fix on +# the same three. The three-cell `offline-stealth` edge lives in W2's reusable +# `release-gate.yml`, which is NOT on this branch's ancestry (FIX-D is stacked on +# `audit/release-4-w4`, whose CI is the ubuntu-only `test.yml`). This job is the +# stopgap that gives FIX-D that evidence — it is NOT a second gate: it runs the +# SAME selector as W2's edge (`-m "stealth and not online"`) and is superseded by +# it the moment `release-gate.yml` is in this branch's history. Delete it then. +# +# Trigger is scoped to PRs whose base is the W4 branch, so it cannot fire on any +# other PR in the repo. + +on: + pull_request: + branches: ['audit/release-4-w4'] + workflow_dispatch: + +permissions: + contents: read + +jobs: + offline-stealth: + name: offline-stealth (${{ matrix.runner_os }}/${{ matrix.runner_arch }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - { os: ubuntu-latest, runner_os: Linux, runner_arch: X64 } + - { os: windows-latest, runner_os: Windows, runner_arch: X64 } + - { os: macos-latest, runner_os: macOS, runner_arch: ARM64 } + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Install uv + uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1 + + - name: Set up Python 3.12 + run: uv python install 3.12 + + - name: Install dependencies + run: uv sync --extra test --extra sentry + + - name: Record cell + Chrome identity + shell: bash + run: | + echo "runner: ${{ runner.os }}/${{ runner.arch }} (expected ${{ matrix.runner_os }}/${{ matrix.runner_arch }})" + uv run python -c "from stealth_chrome_devtools_mcp.embedded.platform_utils import check_browser_executable as c; print('chrome:', c())" + + - name: Start Xvfb (Linux only — mirrors W2's integration cell) + if: runner.os == 'Linux' + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq xvfb + Xvfb :99 -screen 0 1920x1080x24 -ac +extension GLX +render -noreset & + sleep 3 + + - name: Run the offline stealth lane (F-770 vector measurement) + # -s so the D0 per-vector table reaches the log on a PASSING run; the + # selector is W2's `offline-stealth` selector verbatim. + shell: bash + run: uv run pytest -m "stealth and not online" -v -s --tb=short --timeout=300 + env: + DISPLAY: ${{ runner.os == 'Linux' && ':99' || '' }} + STEALTH_MCP_BROWSER_SESSION_ROOT: ${{ runner.temp }}/stealth-mcp-session-root + STEALTH_PROBE_ARTIFACT_DIR: ${{ runner.temp }}/stealth-artifact + + - name: Print the F-770 vector artifact + if: always() + shell: bash + run: cat "${{ runner.temp }}/stealth-artifact/stealth_probe_result_v1.json" || echo "no artifact" + + - name: Upload the F-770 vector artifact + if: always() + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + with: + name: f770-vectors-${{ matrix.runner_os }}-${{ matrix.runner_arch }} + path: ${{ runner.temp }}/stealth-artifact diff --git a/audit/stage2/finding_F774_ua_client_hints_high_entropy_blanked.md b/audit/stage2/finding_F774_ua_client_hints_high_entropy_blanked.md new file mode 100644 index 0000000..845bd75 --- /dev/null +++ b/audit/stage2/finding_F774_ua_client_hints_high_entropy_blanked.md @@ -0,0 +1,64 @@ +# F-774 — a `--user-agent` override blanks Chrome's high-entropy UA client hints + +**Status: OPEN.** Opened by RELEASE-FIX-D (the F-770 fix), measured not inferred, +pinned as a strict `xfail` in `tests/test_stealth.py` (`XFAIL_SIGNALS`, +`test_product_ua_client_hints_high_entropy_pinned_gap`). +**Severity: LOW** — strictly smaller and strictly more expensive to exploit than +the `HeadlessChrome` token it replaced, but it is a new tell and is recorded as one. + +--- + +## The finding + +RELEASE-FIX-D closes F-770 by supplying a masked default `--user-agent=` launch +flag. Whenever that override is active, Chrome **blanks the high-entropy UA +client hints it cannot derive from the override string**. + +Measured on Chrome 150.0.7871.186, headless, same binary, with and without the mask: + +| `getHighEntropyValues()` field | unmasked | masked | +|---|---|---| +| `architecture` | `"x86"` | `""` | +| `bitness` | `"64"` | `""` | +| `platformVersion` | `"19.0.0"` | `""` | +| `uaFullVersion` | `"150.0.7871.186"` | `""` | +| `fullVersionList` | 3 brands | `[]` | +| `brands` | 3 brands | **unchanged** | +| `mobile` | `false` | **unchanged** | +| `platform` | `"Windows"` | **unchanged** | + +## What is and is not affected + +- **The wire is unaffected.** The hints Chrome actually sends by default — + `sec-ch-ua`, `sec-ch-ua-mobile`, `sec-ch-ua-platform` — are the low-entropy set, + and all three remain populated and coherent with the masked User-Agent (the + gating `ua_major_matches_client_hints` signal asserts that coherence). +- **Only JS matters.** The blanked fields are reachable solely through + `navigator.userAgentData.getHighEntropyValues()`, which a site must call + explicitly. Compare with what it replaced: `HeadlessChrome` in the User-Agent is + one server-side substring test, run before a byte of JavaScript executes. + +## Why FIX-D did not fix it + +The only mechanism that restores these values is +`Emulation.setUserAgentOverride`'s `userAgentMetadata`, which is **per-target**: +every path that creates a tab would have to apply it, and a tab the page itself +opens (`window.open`, `target=_blank`) would not be covered — the exact +second-way/maintenance hazard the launch-flag mechanism was chosen to avoid, and +the failure mode that made RELEASE-FIX-C's `create_hook` re-arm necessary. It is a +different mechanism with its own design decision, deliberately out of FIX-D's scope. + +Any fix must also solve where the metadata comes from: once `--user-agent` is set +at launch, the real values are *already* blanked, so they cannot simply be read +back and re-applied — they would have to be captured before the override exists, +or constructed, which is the guessing FIX-D's coherence rule forbids. + +## Routing + +- Recorded as a strict `xfail`, so a future fix (or a Chrome behaviour change) + XPASSes and turns the suite red, forcing a review. It is never resolved by + relaxing `strict`. +- **W5's `RELEASE_CONTRACT.md` must qualify the stealth claim accordingly:** + headless no longer advertises `HeadlessChrome` on any vector that reaches a + site, and the low-entropy client hints are coherent; the high-entropy client + hints are blank under the mask. diff --git a/audit/stage2/plan_RELEASE_FIX_D.md b/audit/stage2/plan_RELEASE_FIX_D.md new file mode 100644 index 0000000..69c6eb7 --- /dev/null +++ b/audit/stage2/plan_RELEASE_FIX_D.md @@ -0,0 +1,250 @@ +# plan_RELEASE_FIX_D — F-770: the headless User-Agent still advertises `HeadlessChrome` + +**Status: EXECUTED.** Human authorized the FIX-plan route 2026-07-25. Branch +`audit/release-fix-d` stacked on `audit/release-4-w4` (PR #47). Merge gate: human — +**the executor never merges.** + +> ## D0's measurement (the part that decided the mechanism) +> +> Measured under `headless=True` on all three qualified cells, Chrome 150: +> +> | Vector | Linux/X64 | Windows/X64 | macOS/ARM64 | +> |---|---|---|---| +> | V1 `navigator.userAgent` | **LEAK** | **LEAK** | **LEAK** | +> | V2 HTTP `User-Agent` header the server received | **LEAK** | **LEAK** | **LEAK** | +> | V3 `userAgentData` brands / high entropy / `sec-ch-ua` | clean | clean | clean | +> | V4 `Browser.getVersion().userAgent` | **LEAK** | **LEAK** | **LEAK** | +> +> Two of §2/§3's stated assumptions were wrong, which is exactly what D0 exists +> to catch: +> +> * **V3 does not leak.** The brands are identical headless and headed on every +> cell, so the UA-CH path (M-B) was never required. +> * **V4 *is* covered by `--user-agent=`.** §3 lists it as not covered; the flag +> is process-wide and `Browser.getVersion` follows it. +> +> So **M-A alone covers every leaking vector** — chosen, and M-B not implemented. +> `--user-agent-product=` was also measured and is inert in release Chrome. +> +> The frozen reduced-UA platform tokens the mask is built from were confirmed on +> the runners themselves: `Windows NT 10.0; Win64; x64`, +> `Macintosh; Intel Mac OS X 10_15_7` (frozen even on ARM64), `X11; Linux x86_64`. +> +> ## What the fix opened: F-774 +> +> A `--user-agent` override makes Chrome blank the high-entropy UA client hints +> (`architecture`, `bitness`, `platformVersion`, `uaFullVersion`, +> `fullVersionList`); `brands`/`mobile`/`platform` — and therefore every +> `sec-ch-ua*` header on the wire — stay correct. Recorded honestly as a strict +> `xfail` and routed: see +> [finding_F774_ua_client_hints_high_entropy_blanked.md](./finding_F774_ua_client_hints_high_entropy_blanked.md). +> §6's scope-limit clause applies: partial fix + honest claim. + +**Found by:** plan_RELEASE W4's offline stealth probe (`audit/release-4-w4`, PR #45), +pinned as a strict `xfail` in `tests/test_stealth.py`. +**Severity:** the single most commercially significant open finding. It directly +contradicts the product's headline claim. + +--- + +## 1. The finding (F-770) + +The package advertises "undetectable browser automation" and +`Development Status :: 5 - Production/Stable`. Under `headless=True` — a first-class, +documented option — the product's User-Agent still contains the literal token +`HeadlessChrome`: + +``` +Mozilla/5.0 (...) HeadlessChrome/.0.0.0 Safari/537.36 +``` + +This is the **cheapest, most universally deployed bot check in existence**: one +substring test, server-side, before a single byte of JavaScript runs. A product whose +entire value proposition is evading detection fails the first check anyone writes. + +nodriver does not mask the UA token, and the product does not either. The spawn path +(`browser_manager.py:84 _append_user_agent_arg`) applies `--user-agent=` **only when the +caller explicitly supplies one**. The default headless user gets the leak. + +### Why the current pin is not a fix + +`tests/test_stealth.py` records this honestly and deliberately: + +- the signal lives in `XFAIL_SIGNALS` with `finding_id="F-770"`, not in the gating table; +- `test_product_ua_headless_token_pinned_gap` is `@pytest.mark.xfail(strict=True)`. + +An xfailed invariant **does not satisfy a release claim** (plan_RELEASE §0.2). W4's +green therefore does not currently support the stealth headline for headless. Either +this plan closes it, or W5's `RELEASE_CONTRACT.md` must qualify the stealth claim to +exclude headless — those are the only two honest outcomes. + +--- + +## 2. Learn from FIX-C: measure before you fix + +RELEASE-FIX-C shipped on a hypothesis that was strongly evidenced and **wrong** +(see `plan_RELEASE_FIX_C.md`, correction block). The cost was a CI round-trip and a +plan doc that had to be corrected in place. FIX-D therefore front-loads measurement, +and **D1's mechanism is not chosen until D0 has reported.** + +### D0 — characterize the ACTUAL leak surface (tests only, no `src/` edit) + +"The UA leaks" is not precise enough to fix. `--user-agent=` and +`Emulation.setUserAgentOverride` cover **different** subsets of the surface, so measure +all four vectors, on each of Linux/X64, Windows/X64, macOS/ARM64, under `headless=True`: + +| # | Vector | How to observe | Covered by `--user-agent=`? | +|---|---|---|---| +| V1 | `navigator.userAgent` | probe page | yes | +| V2 | The **HTTP `User-Agent` request header actually sent** | the W1/W4 fixture server records request headers — assert on what the server received, not on what the page claims | yes | +| V3 | `navigator.userAgentData.brands` + `getHighEntropyValues()` | probe page | **no** — UA client-hint brands are built from Chrome's version info and are not affected by the flag | +| V4 | `Browser.getVersion` → `userAgent` (the CDP-level value) | CDP | no | + +**Report which vectors actually contain `Headless`.** Do not assume; Chrome's +new-headless behavior has changed across majors and the runner image version is what +matters, not any published summary. If V3 leaks, `--user-agent=` alone is insufficient +and D1 must take the UA-CH path. + +D0 lands as a **characterization test** with an F-770 docstring, recording current +behavior per vector. It is committed and pushed so CI reports the measurement on all +three OS cells before D1 is written. + +--- + +## 3. D1 — the fix + +**Principle: a default headless spawn must not advertise headless.** Universal +default, applied for every user, with **no new config knob** — a knob that must be +found and enabled is not a fix for a headline claim. + +### The two candidate mechanisms + +Pick based on D0's measurement; do not implement both. + +**M-A — pre-launch `--user-agent=` flag.** Derive the masked UA from the resolved +`browser_executable` and pass it as a launch arg when the caller supplied no explicit +`user_agent`. + +- *Pros:* one place; covers V1 and V2 (the HTTP header) for **every** tab, worker, and + subresource for the whole browser process; survives `new_tab`; needs no per-target + bookkeeping. Verified: `--user-agent` is **not** on `platform_utils._stealth_blocked_args()`, + so unlike `--use-mock-keychain` it will actually reach Chrome. Confirm that still holds. +- *Cons:* does not touch V3 (UA-CH brands). Needs the Chrome version **before** launch — + obtain it from the already-resolved executable (bounded subprocess, cached per + executable path; a launch-path subprocess on every spawn is a perf regression and + must not be added naively). + +**M-B — `Emulation.setUserAgentOverride` with `userAgentMetadata`.** Apply post-launch +per target. + +- *Pros:* the only mechanism that can fix V3, because it sets client-hint brands + explicitly. +- *Cons:* per-target. Every path that creates a tab must apply it or the override + leaks on the next tab — that is a second-way hazard and a maintenance trap. If you + take this path, it gets **one home** that every tab-creating path calls, and a pin + proving a *newly created* tab is covered (the exact failure mode that made FIX-C's + `create_hook` re-arm necessary). + +**A combination is permitted only if D0 proves it necessary** (e.g. M-A for V1/V2 plus +M-B for V3). If so, state plainly in the completion report that this is two mechanisms +serving two vectors, not two ways to do one thing. + +### Hard constraints + +- **Do not weaken any probe.** The fix makes the assertion pass; it never edits the + predicate to accept the leak. `_p_ua_no_headless` is the contract. +- **Do not break the caller's explicit `user_agent`.** An explicit value always wins. + Pin this. +- **Consistency is itself a tell.** A UA that claims `Chrome/141` while + `navigator.userAgentData` reports a different major, or while the platform token + disagrees with the real OS, is a *worse* signal than the honest headless UA — it is a + mismatch no real browser produces. Whatever you mask, mask coherently, and pin the + coherence. +- No new runtime dependencies. No `embedded/` module imports `server`. M6-pinned error + bytes preserved. `tools/check_file_budgets.py` green with **no cap padded**. + +--- + +## 4. D2 — the sensitivity control must survive the fix (read this carefully) + +W4's stealth suite is only valid because it proves a **vanilla control is still +detected**. `_collect_probe(base_url, control=True)` builds that control by +monkeypatching: + +```python +_bm_mod.merge_browser_args = lambda args: (list(args or []), []) +``` + +That neutralizes `platform_utils.merge_browser_args` — and **nothing else**. + +So: if D1's masking is applied anywhere *outside* `merge_browser_args` (for example in +`_append_user_agent_arg`, in the spawn path, or via a post-launch CDP call), the +**control browser will also receive the masked UA**. The control then stops being +detectable, `vanilla_detected` goes false, and the entire stealth suite silently becomes +vacuous — it would assert that a stealthy browser is stealthy and that a "vanilla" +browser is also stealthy, proving nothing. + +This is a test-invalidation trap of exactly the kind §8.1 warns about, and it will not +announce itself. D2 must therefore, in the same commit as D1: + +1. Route the masking through a seam the control genuinely disables, **or** extend the + control to disable the new seam explicitly. +2. Assert `control_outcomes["vanilla_detected"] is True` **still holds after the fix**, + and that the control's UA **does** contain `Headless` while the product's does not. + That differential is the proof the fix is real and the test is still sensitive. + +If you cannot make the control fail while the product passes, **STOP and report** — do +not ship a fix whose test cannot tell the two apart. + +--- + +## 5. D3 — flip the pin honestly + +- Move `ua_no_headless_token` from `XFAIL_SIGNALS` into the gating `SIGNALS` table. +- **Delete** `test_product_ua_headless_token_pinned_gap`. Do not leave it as a + non-strict xfail. (Its `strict=True` is self-policing: once the fix lands it XPASSes + and turns the suite red, which is the intended tripwire — resolve it by deleting the + now-redundant test, never by relaxing `strict`.) +- Update the F-770 comment block above `XFAIL_SIGNALS` to record the closure, or remove + the block if it becomes empty. +- Any doc that describes headless stealth as a known gap gets updated in the same commit. + +--- + +## 6. Acceptance — what "fixed" means + +All of the following, or the plan is not done: + +1. On **all three** W2 cells (Linux/X64, Windows/X64, macOS/ARM64), under + `headless=True`, every vector D0 found leaking now passes, asserted as a **gating** + signal — no xfail, no skip, no characterization. +2. The V2 assertion is made against **what the fixture server actually received**, not + only what the page reports. A page-only assertion can be satisfied by a page-level + override while the real HTTP header still leaks. +3. `vanilla_detected is True` still holds; the control's UA still contains `Headless`. +4. An explicit caller-supplied `user_agent` still wins, pinned. +5. A **newly created tab** (`new_tab`, not just the spawn tab) is covered — pinned. +6. Full local gate green: ruff format+check; `ty check --exit-zero-on-warning + src/stealth_chrome_devtools_mcp/` at the **76-diagnostic baseline** (a bare `ty check` + reports 172 because it takes the wrong scope — that is the wrong command, not a + regression); vulture; file budgets; suppression owners; unit suite (~703-705 on + `-m "not integration"`); integration locally. +7. `--no-verify` never used. PR opened, **never merged**. + +### Scope limits — state these, do not paper over them + +- This closes the **UA vector** for headless. It does not make the product + "undetectable"; W4's other signals and the §8 residual wall stand unchanged. +- Headed mode is unaffected (it never leaked this token). +- If D0 shows V3 (UA-CH) leaking and M-B proves out of budget, shipping M-A alone is + **permitted only if** the UA-CH residue is recorded as a new finding, routed, and W5's + contract qualifies the claim. Partial fix + honest claim is acceptable; partial fix + presented as complete is not. + +--- + +## 7. Gates + +Same as FIX-A/B/C. Branch `audit/release-fix-d` stacked on `audit/release-4-w4`; +PR opened against that base and held at the human merge gate. Commit messages end with +`Co-Authored-By: Claude Opus 4.8 `. diff --git a/src/stealth_chrome_devtools_mcp/embedded/platform_utils.py b/src/stealth_chrome_devtools_mcp/embedded/platform_utils.py index 6a1d88a..ff797e6 100644 --- a/src/stealth_chrome_devtools_mcp/embedded/platform_utils.py +++ b/src/stealth_chrome_devtools_mcp/embedded/platform_utils.py @@ -3,8 +3,11 @@ import ctypes import os import platform +import re import shutil +import subprocess import sys +from functools import lru_cache from pathlib import Path from stealth_chrome_devtools_mcp.embedded.debug_logger import debug_logger @@ -183,10 +186,133 @@ def filter_stealth_args(user_args: list[str]) -> tuple: return clean, warnings +# ── The masked default User-Agent (F-770) ─────────────────────────────────── +# Headless Chrome advertises itself in its own User-Agent: +# Mozilla/5.0 (...) HeadlessChrome/.0.0.0 Safari/537.36 +# That single substring is the cheapest bot check in existence — one server-side +# test, before a byte of JavaScript runs — so a default headless spawn must not +# ship it. +# +# The mask is CONSTRUCTED rather than guessed because Chrome froze its +# User-Agent in the reduced-UA rollout: the platform token, the WebKit build and +# the Safari token are constants, and the only varying part is the browser's +# MAJOR version, always rendered ``.0.0.0``. The string built here is +# therefore byte-identical to what the same binary emits headed — i.e. exactly +# Chrome's own User-Agent with ``HeadlessChrome`` replaced by ``Chrome``. That +# equality is pinned as a product-vs-control differential in tests/test_stealth.py: +# consistency is itself a tell, and a UA that disagrees with ``sec-ch-ua`` or with +# the real OS would be a WORSE signal than the honest headless one. +_REDUCED_UA_PLATFORM_TOKEN = { + "Windows": "Windows NT 10.0; Win64; x64", + "Darwin": "Macintosh; Intel Mac OS X 10_15_7", + "Linux": "X11; Linux x86_64", +} + +_USER_AGENT_ARG_PREFIX = "--user-agent=" +_BROWSER_VERSION_RE = re.compile(r"(\d+)\.\d+\.\d+\.\d+") +_VERSION_PROBE_TIMEOUT_SECONDS = 10.0 + + +@lru_cache(maxsize=8) +def resolve_browser_major_version(executable: str) -> str | None: + """Return the major version of a Chromium-family executable, or ``None``. + + Cached per executable path: this runs on the spawn path, so an uncached + subprocess per spawn would be a real performance regression. + + Windows deliberately does NOT shell out — ``chrome.exe --version`` hands the + argument to an already-running Chrome ("Opening in existing browser + session.") instead of printing anything, so the version is read from the + version-named directory every Chromium install keeps beside its binary. + Elsewhere `` --version`` prints e.g. ``Google Chrome 150.0.7871.186``. + """ + if platform.system() == "Windows": + try: + names = [ + entry.name + for entry in Path(executable).parent.iterdir() + if entry.is_dir() + ] + except OSError as error: + debug_logger.log_debug( + "platform_utils", "resolve_browser_major_version", str(error) + ) + return None + majors = [m.group(1) for m in map(_BROWSER_VERSION_RE.fullmatch, names) if m] + return max(majors, key=int) if majors else None + + try: + completed = subprocess.run( # noqa: S603 RELEASE-FIX-D (F-770) + [executable, "--version"], + capture_output=True, + text=True, + timeout=_VERSION_PROBE_TIMEOUT_SECONDS, + check=False, + ) + except (OSError, subprocess.SubprocessError) as error: + debug_logger.log_debug( + "platform_utils", "resolve_browser_major_version", str(error) + ) + return None + match = _BROWSER_VERSION_RE.search(completed.stdout or "") + return match.group(1) if match else None + + +def build_reduced_user_agent(executable: str) -> str | None: + """Build this executable's own reduced User-Agent, without the headless token. + + Returns ``None`` (meaning "do not mask") on an unrecognized platform or when + the version cannot be resolved — masking with a wrong version would be worse + than not masking at all. + """ + platform_token = _REDUCED_UA_PLATFORM_TOKEN.get(platform.system()) + if not platform_token: + return None + major = resolve_browser_major_version(executable) + if not major: + return None + agent = ( + f"Mozilla/5.0 ({platform_token}) AppleWebKit/537.36 (KHTML, like Gecko) " + f"Chrome/{major}.0.0.0 Safari/537.36" + ) + if "edge" in Path(executable).name.lower(): + # Edge appends its own token after Safari/537.36. Dropping it while + # sec-ch-ua still advertises the "Microsoft Edge" brand would be a + # sharper tell than the headless token this mask removes. + agent += f" Edg/{major}.0.0.0" + return agent + + +def _apply_default_user_agent(args: list[str]) -> list[str]: + """Append the masked default ``--user-agent=`` unless the caller supplied one. + + An explicit caller ``user_agent`` reaches here already rendered as a + ``--user-agent=`` arg, so the presence check is what makes an explicit value + win. The flag is process-wide: unlike a per-target CDP override it covers + every tab, worker and subresource of the launched browser — including tabs + the page itself opens — and it reaches the real HTTP request header, which is + the vector a server-side bot check reads first. + """ + if any(arg.lower().startswith(_USER_AGENT_ARG_PREFIX) for arg in args): + return args + executable = check_browser_executable() + agent = build_reduced_user_agent(executable) if executable else None + if not agent: + debug_logger.log_warning( + "platform_utils", + "default_user_agent", + f"could not derive a masked User-Agent for {executable!r}; a headless " + "launch will advertise HeadlessChrome (F-770)", + ) + return args + return [*args, f"{_USER_AGENT_ARG_PREFIX}{agent}"] + + def merge_browser_args(user_args: list[str] | None = None) -> tuple: """ Merge user-provided browser arguments with platform-specific required arguments. - Strips any args that would compromise stealth detection. + Strips any args that would compromise stealth detection, and supplies the + masked default User-Agent (F-770) when the caller did not choose one. Args: user_args: User-provided browser arguments @@ -204,7 +330,7 @@ def merge_browser_args(user_args: list[str] | None = None) -> tuple: if arg not in combined_args: combined_args.append(arg) - return combined_args, stealth_warnings + return _apply_default_user_agent(combined_args), stealth_warnings def get_platform_info() -> dict: diff --git a/tests/fixture_app/stealth_probe.html b/tests/fixture_app/stealth_probe.html index ad3e00c..c0a73d1 100644 --- a/tests/fixture_app/stealth_probe.html +++ b/tests/fixture_app/stealth_probe.html @@ -117,6 +117,39 @@

stealth-probe (armed)

obs.ua_client_hints_mobile = uad ? !!uad.mobile : null; obs.ua_client_hints_platform = uad ? (uad.platform || "") : ""; + // F-770 vector V3 -- the UA client-hint high-entropy values. These are built + // from Chrome's own version info and are NOT affected by --user-agent=, so + // they are measured separately from navigator.userAgent (V1). + obs.ua_client_hints_high_entropy = null; + if (uad && typeof uad.getHighEntropyValues === "function") { + try { + obs.ua_client_hints_high_entropy = await uad.getHighEntropyValues([ + "brands", "fullVersionList", "uaFullVersion", "platform", + "platformVersion", "architecture", "bitness", "model" + ]); + } catch (e) { + obs.ua_client_hints_high_entropy = "ERROR:" + String(e); + } + } + + // F-770 vector V2 -- the HTTP User-Agent request header the SERVER actually + // received. A page cannot read its own request headers, so the probe issues + // one same-origin POST to the fixture app's /api/echo route, which reflects + // back the headers it read off the wire. This is the vector a server-side bot + // check reads first, and a page-level assertion alone can never prove it. + obs.http_request_headers = null; + obs.http_user_agent = null; + try { + var echoResp = await fetch("/api/echo", { method: "POST", body: "stealth-probe" }); + var echoed = await echoResp.json(); + obs.http_request_headers = (echoed && echoed.headers) ? echoed.headers : null; + obs.http_user_agent = obs.http_request_headers + ? (obs.http_request_headers["user-agent"] || "") : null; + } catch (e) { + obs.http_request_headers = "ERROR:" + String(e); + obs.http_user_agent = "ERROR:" + String(e); + } + // Patched-builtin detection: native functions must serialize as native code, // and Function.prototype.toString itself must be native (a common patch hides // the patching by re-patching toString). diff --git a/tests/test_platform_utils.py b/tests/test_platform_utils.py index a9ca258..9194d0a 100644 --- a/tests/test_platform_utils.py +++ b/tests/test_platform_utils.py @@ -8,15 +8,33 @@ deterministic across CI environments. """ +import subprocess + +import pytest + from stealth_chrome_devtools_mcp.embedded import platform_utils from stealth_chrome_devtools_mcp.embedded.platform_utils import ( + build_reduced_user_agent, filter_stealth_args, get_platform_info, get_required_sandbox_args, merge_browser_args, + resolve_browser_major_version, ) +@pytest.fixture(autouse=True) +def _no_default_user_agent(monkeypatch): + """Neutralize the F-770 masked-UA default for the pre-existing arg tests. + + ``merge_browser_args`` now also supplies a default ``--user-agent=`` derived + from the resolved browser, which would make these assertions depend on + whether the machine running them happens to have Chrome installed. The + F-770 behaviour has its own tests below, which opt back in explicitly. + """ + monkeypatch.setattr(platform_utils, "check_browser_executable", lambda: None) + + class TestFilterStealthArgs: def test_known_automation_flags_are_stripped(self): clean, warnings = filter_stealth_args( @@ -91,3 +109,140 @@ def test_reports_expected_keys(self): info = get_platform_info() for key in ("system", "is_root", "is_container", "required_sandbox_args"): assert key in info + + +# --------------------------------------------------------------------------- +# F-770 — the masked default User-Agent (plan_RELEASE_FIX_D D1). +# +# Headless Chrome advertises `HeadlessChrome/.0.0.0` in its own UA, which +# is the cheapest server-side bot check there is. The product now supplies a +# masked default `--user-agent=` built from Chrome's frozen reduced-UA form. The +# three platform tokens below are the exact strings measured on the three +# qualified runners (D0), so a table typo cannot pass silently: a wrong token +# would make the masked UA disagree with the real OS, which is a WORSE tell than +# the headless token it replaces. +# --------------------------------------------------------------------------- +_REDUCED_UA_CELLS = [ + ("Windows", "Windows NT 10.0; Win64; x64"), + ("Darwin", "Macintosh; Intel Mac OS X 10_15_7"), + ("Linux", "X11; Linux x86_64"), +] + + +@pytest.fixture(autouse=True) +def _clear_version_cache(): + """``resolve_browser_major_version`` is lru_cached (it runs on the spawn + path); clear it around every test so a fake never bleeds between them.""" + resolve_browser_major_version.cache_clear() + yield + resolve_browser_major_version.cache_clear() + + +class TestReducedUserAgent: + @pytest.mark.parametrize(("system", "token"), _REDUCED_UA_CELLS) + def test_masked_ua_matches_the_measured_platform_token( + self, monkeypatch, system, token + ): + monkeypatch.setattr(platform_utils.platform, "system", lambda: system) + monkeypatch.setattr( + platform_utils, "resolve_browser_major_version", lambda _exe: "150" + ) + agent = build_reduced_user_agent("/opt/google/chrome/chrome") + assert agent == ( + f"Mozilla/5.0 ({token}) AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/150.0.0.0 Safari/537.36" + ) + assert "Headless" not in agent + + def test_edge_keeps_its_own_token(self, monkeypatch): + # Dropping Edg/ while sec-ch-ua still says "Microsoft Edge" would be a + # sharper tell than the headless token being masked. + monkeypatch.setattr(platform_utils.platform, "system", lambda: "Linux") + monkeypatch.setattr( + platform_utils, "resolve_browser_major_version", lambda _exe: "150" + ) + agent = build_reduced_user_agent("/usr/bin/microsoft-edge-stable") + assert agent.endswith("Chrome/150.0.0.0 Safari/537.36 Edg/150.0.0.0") + + def test_unknown_platform_declines_to_mask(self, monkeypatch): + monkeypatch.setattr(platform_utils.platform, "system", lambda: "FreeBSD") + assert build_reduced_user_agent("/usr/local/bin/chrome") is None + + def test_unresolvable_version_declines_to_mask(self, monkeypatch): + # Masking with a wrong version is worse than not masking: the UA would + # contradict sec-ch-ua. + monkeypatch.setattr(platform_utils.platform, "system", lambda: "Linux") + monkeypatch.setattr( + platform_utils, "resolve_browser_major_version", lambda _exe: None + ) + assert build_reduced_user_agent("/usr/bin/google-chrome") is None + + +class TestResolveBrowserMajorVersion: + def test_windows_reads_the_version_named_sibling_directory( + self, monkeypatch, tmp_path + ): + # Windows must NOT shell out: `chrome.exe --version` hands the flag to a + # running Chrome instead of printing anything. + monkeypatch.setattr(platform_utils.platform, "system", lambda: "Windows") + + def _explode(*_args, **_kwargs): + raise AssertionError("must not run a subprocess on Windows") + + monkeypatch.setattr(platform_utils.subprocess, "run", _explode) + (tmp_path / "150.0.7871.186").mkdir() + (tmp_path / "149.0.7000.1").mkdir() + (tmp_path / "SetupMetrics").mkdir() + assert resolve_browser_major_version(str(tmp_path / "chrome.exe")) == "150" + + def test_posix_parses_the_version_subprocess(self, monkeypatch): + monkeypatch.setattr(platform_utils.platform, "system", lambda: "Linux") + monkeypatch.setattr( + platform_utils.subprocess, + "run", + lambda *_a, **_k: subprocess.CompletedProcess( + args=[], returncode=0, stdout="Google Chrome 150.0.7871.186 \n" + ), + ) + assert resolve_browser_major_version("/usr/bin/google-chrome") == "150" + + def test_posix_probe_failure_is_not_fatal(self, monkeypatch): + monkeypatch.setattr(platform_utils.platform, "system", lambda: "Linux") + + def _boom(*_a, **_k): + raise subprocess.TimeoutExpired(cmd="chrome", timeout=10) + + monkeypatch.setattr(platform_utils.subprocess, "run", _boom) + assert resolve_browser_major_version("/usr/bin/google-chrome") is None + + +class TestDefaultUserAgentInMergeBrowserArgs: + def _arm(self, monkeypatch): + monkeypatch.setattr(platform_utils, "is_running_as_root", lambda: False) + monkeypatch.setattr(platform_utils, "is_running_in_container", lambda: False) + monkeypatch.setattr(platform_utils.platform, "system", lambda: "Linux") + monkeypatch.setattr( + platform_utils, "check_browser_executable", lambda: "/usr/bin/google-chrome" + ) + monkeypatch.setattr( + platform_utils, "resolve_browser_major_version", lambda _exe: "150" + ) + + def test_default_spawn_gets_a_masked_user_agent(self, monkeypatch): + self._arm(monkeypatch) + combined, _ = merge_browser_args([]) + assert combined == [ + "--user-agent=Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36" + ] + + def test_explicit_user_agent_always_wins(self, monkeypatch): + self._arm(monkeypatch) + combined, _ = merge_browser_args(["--user-agent=CallerChose/1.0"]) + assert combined == ["--user-agent=CallerChose/1.0"] + + def test_no_browser_means_no_masking(self, monkeypatch): + self._arm(monkeypatch) + monkeypatch.setattr(platform_utils, "check_browser_executable", lambda: None) + combined, _ = merge_browser_args(["--lang=en-US"]) + assert combined == ["--lang=en-US"] diff --git a/tests/test_stealth.py b/tests/test_stealth.py index 3fd2101..5f68941 100644 --- a/tests/test_stealth.py +++ b/tests/test_stealth.py @@ -23,14 +23,23 @@ gate (``not online``). Drives CreepJS and bot.incolumitas, asserts only the hard invariants, logs the rest, and tolerates network flakiness by design. -Design honesty (release-claim integrity): one product predicate — the headless UA -still advertising ``HeadlessChrome`` — does NOT pass. It is pinned as a strict -``xfail`` (F-770), NOT hidden by weakening a probe. An xfailed invariant cannot -satisfy the stealth release claim; see :data:`XFAIL_SIGNALS` and the test docstring. - -No ``src/`` edits: both browsers spawn through the project's own -``spawn_browser`` tool; the ordered transcript uses the project's own nodriver -``tab.send(uc.cdp.*)`` CDP seam; the fixture and env-isolation reuse W1's homes. +Design honesty (release-claim integrity): a known gap is pinned as a strict +``xfail`` with a finding id, never hidden by weakening a probe — an xfailed +invariant does NOT satisfy a release claim. + +* **F-770 — CLOSED by RELEASE-FIX-D.** The headless User-Agent advertised + ``HeadlessChrome`` on every vector that reaches a site. ``src/`` now supplies a + masked default ``--user-agent`` from ``platform_utils.merge_browser_args``, so + the signal moved into the GATING table and the xfail was deleted rather than + relaxed. The four measured vectors are documented at :data:`F770_VECTORS`. +* **F-774 — OPEN, opened by that fix.** A ``--user-agent`` override makes Chrome + blank the high-entropy UA client hints; the low-entropy ``sec-ch-ua*`` headers + on the wire stay correct. Recorded in :data:`XFAIL_SIGNALS` with the measured + before/after, not papered over. + +Both browsers spawn through the project's own ``spawn_browser`` tool; the ordered +transcript uses the project's own nodriver ``tab.send(uc.cdp.*)`` CDP seam; the +fixture and env-isolation reuse W1's homes. """ from __future__ import annotations @@ -53,6 +62,7 @@ navigate_and_settle, sandbox_kwargs, server_mod, + warmup_once, ) if TYPE_CHECKING: @@ -189,6 +199,58 @@ def _p_ua_no_headless(obs: dict, _os: str) -> bool: return "headlesschrome" not in str(obs.get("user_agent") or "").lower() +def _p_http_ua_no_headless(obs: dict, _os: str) -> bool: + # F-770 V2 — the header the fixture server actually READ off the wire. A + # page-level override can satisfy _p_ua_no_headless while this still leaks. + header = obs.get("http_user_agent") + if not isinstance(header, str) or not header or header.startswith("ERROR:"): + return False + return "headlesschrome" not in header.lower() + + +def _p_ua_matches_http_header(obs: dict, _os: str) -> bool: + # Coherence: a page UA that disagrees with the wire UA is a mismatch no real + # browser produces — a sharper tell than the honest headless token. + page = obs.get("user_agent") + header = obs.get("http_user_agent") + return bool(page) and isinstance(header, str) and header == page + + +def _ua_major(user_agent: object) -> str | None: + m = re.search(r"(?:Headless)?Chrome/(\d+)\.", str(user_agent or "")) + return m.group(1) if m else None + + +def _p_ua_client_hints_high_entropy_populated(obs: dict, _os: str) -> bool: + # F-774: a real Chrome fills every high-entropy hint. Chrome blanks them + # whenever a --user-agent override is active, which is how the F-770 mask + # works — see XFAIL_SIGNALS. + high_entropy = obs.get("ua_client_hints_high_entropy") + if not isinstance(high_entropy, dict): + return False + return all( + bool(high_entropy.get(key)) + for key in ("architecture", "bitness", "uaFullVersion", "fullVersionList") + ) + + +def _p_ua_major_matches_client_hints(obs: dict, _os: str) -> bool: + # Coherence: the UA's major version must be one the UA client hints agree + # with. Masking the token while claiming a version sec-ch-ua contradicts + # would replace one tell with a worse one. + major = _ua_major(obs.get("user_agent")) + high_entropy = obs.get("ua_client_hints_high_entropy") + if not major or not isinstance(high_entropy, dict): + return False + majors = { + str(brand.get("version", "")).split(".")[0] + for brand in (high_entropy.get("brands") or []) + if isinstance(brand, dict) + } + majors.discard("") + return bool(majors) and major in majors + + # ── Gating table: the product MUST pass every applicable row ──────────────── GATE_SIGNALS: tuple[Signal, ...] = ( Signal( @@ -265,21 +327,89 @@ def _p_ua_no_headless(obs: dict, _os: str) -> bool: {"outer_width": 0, "outer_height": 0}, "window.outerWidth/outerHeight are non-zero (0x0 is a headless tell).", ), -) - -# ── xfail table: a KNOWN product stealth gap, pinned honestly (never weakened) ─ -# F-770: under headless (the configuration the offline gate runs), the product's -# default User-Agent still advertises "HeadlessChrome". The product ships nodriver -# and does NOT mask the UA token, so this basic bot tell leaks. Pinned as a strict -# xfail — an xfailed invariant does NOT satisfy the stealth release claim. -XFAIL_SIGNALS: tuple[Signal, ...] = ( + # ── F-770, closed by RELEASE-FIX-D. These four rows were a strict xfail until + # the product supplied a masked default --user-agent; they are GATING now, so + # a regression that reintroduces the headless token fails the release gate. + # An xfailed invariant never satisfied the stealth claim; these do. Signal( "ua_no_headless_token", ("user_agent",), _p_ua_no_headless, {"user_agent": "Mozilla/5.0 ... HeadlessChrome/150.0.0.0 Safari/537.36"}, - "navigator.userAgent does not advertise HeadlessChrome.", - finding_id="F-770", + "navigator.userAgent does not advertise HeadlessChrome (F-770 V1).", + ), + Signal( + "http_ua_no_headless_token", + ("http_user_agent",), + _p_http_ua_no_headless, + {"http_user_agent": "Mozilla/5.0 ... HeadlessChrome/150.0.0.0 Safari/537.36"}, + "The User-Agent header the SERVER received does not advertise " + "HeadlessChrome (F-770 V2 — the vector a bot check reads first).", + ), + Signal( + "ua_matches_http_header", + ("user_agent", "http_user_agent"), + _p_ua_matches_http_header, + {"http_user_agent": "Mozilla/5.0 ... HeadlessChrome/150.0.0.0 Safari/537.36"}, + "The page User-Agent and the wire User-Agent are the same string " + "(a page-only override that leaves the header leaking is a worse tell).", + ), + Signal( + "ua_major_matches_client_hints", + ("user_agent", "ua_client_hints_high_entropy"), + _p_ua_major_matches_client_hints, + { + "user_agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36" + ) + }, + "The masked User-Agent's major version is one the UA client hints agree " + "with (masking coherently, not swapping one tell for a worse one).", + ), +) + +# ── xfail table: a KNOWN product stealth gap, pinned honestly (never weakened) ─ +# F-774 (opened by RELEASE-FIX-D, measured not assumed): whenever a --user-agent +# override is active — which is exactly how the F-770 mask works — Chrome BLANKS +# the high-entropy UA client hints it cannot derive from the override string. +# Measured on Chrome 150, headless, with and without the mask: +# +# architecture "x86" -> "" +# bitness "64" -> "" +# platformVersion "19.0.0" -> "" +# uaFullVersion "150.0.7871.186" -> "" +# fullVersionList [3 brands] -> [] +# brands / mobile / platform UNCHANGED and still correct +# +# So the LOW-entropy hints — the `sec-ch-ua`, `sec-ch-ua-mobile` and +# `sec-ch-ua-platform` headers Chrome actually puts on the wire by default — +# stay coherent with the masked UA; only the JS-only high-entropy set, which a +# site must ask for explicitly, comes back empty. That is a strictly smaller and +# strictly more expensive tell than the `HeadlessChrome` token it replaces (one +# server-side substring test, before any JavaScript runs), but it IS a new tell +# and is recorded here rather than papered over. Fixing it needs +# Emulation.setUserAgentOverride's userAgentMetadata, which is per-target and +# therefore a different mechanism with its own hazards — deliberately out of +# FIX-D's scope. Strict xfail: if it is ever fixed, or Chrome changes, this +# XPASSes and turns the suite red, forcing the review. +XFAIL_SIGNALS: tuple[Signal, ...] = ( + Signal( + "ua_client_hints_high_entropy_populated", + ("ua_client_hints_high_entropy",), + _p_ua_client_hints_high_entropy_populated, + { + "ua_client_hints_high_entropy": { + "brands": [{"brand": "Google Chrome", "version": "150"}], + "architecture": "", + "bitness": "", + "uaFullVersion": "", + "fullVersionList": [], + } + }, + "getHighEntropyValues() returns populated architecture/bitness/" + "uaFullVersion/fullVersionList, as an unmasked Chrome does.", + finding_id="F-774", ), ) @@ -378,6 +508,13 @@ async def _collect_probe(base_url: str, *, control: bool) -> dict: check_browser_executable, ) + # The gate lane (``-m "stealth and not online"``) selects THIS module alone, so + # unlike the full integration lane no earlier E2E module has already paid for + # Chrome's cold start. nodriver gives the debug port only a few seconds, and a + # first launch on a cold Linux/macOS runner overruns it ("Failed to connect to + # browser"). Reuse the shared idempotent warmup rather than growing a timeout. + await warmup_once() + spawn = get_fn("spawn_browser") close = get_fn("close_instance") bm = server_mod.browser_manager @@ -455,6 +592,11 @@ async def _collect_probe(base_url: str, *, control: bool) -> dict: cmdline = [] binary = check_browser_executable() + # F-770 vector V4 -- the CDP-level User-Agent (Browser.getVersion). Not a + # page observation, so it is collected here alongside the process evidence. + version = await tab.send(uc.cdp.browser.get_version()) + cdp_user_agent = version[3] if version and len(version) > 3 else None + return { "result": result, "observations": result.get("observations", {}), @@ -463,6 +605,7 @@ async def _collect_probe(base_url: str, *, control: bool) -> dict: "event_count": event_count, "cmdline": cmdline, "binary": binary, + "cdp_user_agent": cdp_user_agent, "headless": True, } finally: @@ -495,6 +638,97 @@ def control_probe(fixture_app_server) -> dict: return _collect_sync(fixture_app_server, control=True) +# ── F-770 coverage facts: a LATER tab, and an explicit caller User-Agent ────── +# The masked default is a launch flag, so it is process-wide by construction — +# but "by construction" is exactly the reasoning FIX-C's re-arm defect punished, +# so both properties are pinned against a real browser instead of argued. +_EXPLICIT_UA = ( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/123.0.0.0 Safari/537.36 fix-d-explicit" +) + + +async def _read_tab_ua(tab) -> str: + raw = await tab.send( + uc.cdp.runtime.evaluate(expression="navigator.userAgent", return_by_value=True) + ) + return raw[0].value if (raw and raw[0]) else "" + + +async def _read_tab_http_ua(tab, base_url: str) -> str: + raw = await tab.send( + uc.cdp.runtime.evaluate( + expression=( + f"fetch('{base_url}/api/echo', {{method: 'POST', body: 'tab-probe'}})" + ".then(r => r.json()).then(j => j.headers['user-agent'])" + ), + return_by_value=True, + await_promise=True, + ) + ) + return raw[0].value if (raw and raw[0]) else "" + + +async def _collect_ua_facts(base_url: str, *, user_agent: str | None) -> dict: + """Spawn once, then read the User-Agent from the spawn tab AND from a tab + created afterwards through the product's own ``new_tab`` tool.""" + await warmup_once() + spawn = get_fn("spawn_browser") + close = get_fn("close_instance") + open_tab = get_fn("new_tab") + bm = server_mod.browser_manager + + explicit = {"user_agent": user_agent} if user_agent else {} + url = f"{base_url}/stealth_probe.html" + iid = None + try: + spawned = await spawn(headless=True, **explicit, **sandbox_kwargs()) + iid = spawned["instance_id"] + await navigate_and_settle(iid, url) + spawn_tab = await bm.get_tab(iid) + created = await open_tab(instance_id=iid, url=url) + browser = await bm.get_browser(iid) + later_tab = next( + (t for t in browser.tabs if str(t.target.target_id) == created["tab_id"]), + None, + ) + assert later_tab is not None, ( + created["tab_id"], + [str(t.target.target_id) for t in browser.tabs], + ) + return { + "spawn_tab_ua": await _read_tab_ua(spawn_tab), + "new_tab_ua": await _read_tab_ua(later_tab), + "new_tab_http_ua": await _read_tab_http_ua(later_tab, base_url), + } + finally: + if iid is not None: + try: + await close(instance_id=iid) + except Exception: # teardown best-effort + pass + + +def _collect_ua_facts_sync(base_url: str, *, user_agent: str | None) -> dict: + import asyncio + + return asyncio.run(_collect_ua_facts(base_url, user_agent=user_agent)) + + +@pytest.fixture(scope="module") +def ua_default_facts(fixture_app_server) -> dict: + if not CAN_RUN: + pytest.skip("Chrome not available or server failed to load") + return _collect_ua_facts_sync(fixture_app_server, user_agent=None) + + +@pytest.fixture(scope="module") +def ua_explicit_facts(fixture_app_server) -> dict: + if not CAN_RUN: + pytest.skip("Chrome not available or server failed to load") + return _collect_ua_facts_sync(fixture_app_server, user_agent=_EXPLICIT_UA) + + # =========================================================================== # Deterministic, browser-free sensitivity controls (run everywhere, gate-safe). # For each collector family: the predicate PASSES a good baseline and FAILS when @@ -518,9 +752,36 @@ def _good_baseline() -> dict: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36" ), + "http_user_agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36" + ), + "http_request_headers": { + "user-agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36" + ), + "sec-ch-ua": '"Not;A=Brand";v="8", "Chromium";v="150", ' + '"Google Chrome";v="150"', + }, "platform": platform_value, "ua_client_hints_present": True, "ua_client_hints_brands": ["Chromium", "Google Chrome", "Not;A=Brand"], + "ua_client_hints_high_entropy": { + "brands": [ + {"brand": "Not;A=Brand", "version": "8"}, + {"brand": "Chromium", "version": "150"}, + {"brand": "Google Chrome", "version": "150"}, + ], + "fullVersionList": [ + {"brand": "Google Chrome", "version": "150.0.7871.186"} + ], + "uaFullVersion": "150.0.7871.186", + "architecture": "x86", + "bitness": "64", + "platform": "Windows", + "platformVersion": "19.0.0", + }, "fn_tostring_alert": "function alert() { [native code] }", "fn_tostring_meta": "function toString() { [native code] }", "automation_globals_window": [], @@ -531,10 +792,18 @@ def _good_baseline() -> dict: def test_signal_table_is_reviewed_and_nonempty(): - """The predicate table exists, is versioned, and has unique signal names.""" + """The predicate table exists, is versioned, and has unique signal names. + + Every row is GATING: RELEASE-FIX-D closed F-770, the last xfailed signal, so + the table no longer carries an xfail tier. A future known gap re-adds one + deliberately (with a finding id) — it is never created by weakening a row. + """ names = [s.name for s in (*GATE_SIGNALS, *XFAIL_SIGNALS)] assert len(names) == len(set(names)), "duplicate signal names" assert len(GATE_SIGNALS) >= 8, "expected a broad gating table" + assert {"ua_no_headless_token", "http_ua_no_headless_token"} <= set(names), ( + "F-770's page AND wire User-Agent signals must both gate" + ) for s in XFAIL_SIGNALS: assert s.finding_id, f"xfail signal {s.name} must carry a finding id" @@ -600,6 +869,67 @@ def _chrome_version_from_ua(ua: str) -> str | None: return m.group(1) if m else None +# =========================================================================== +# F-770 — the four User-Agent leak vectors (plan_RELEASE_FIX_D D0). +# +# "the UA leaks" is not precise enough to fix: a pre-launch ``--user-agent=`` +# flag and a post-launch ``Emulation.setUserAgentOverride`` cover DIFFERENT +# subsets of the surface, so each vector is measured separately on every OS cell +# BEFORE a masking mechanism is chosen. (RELEASE-FIX-C shipped on a strongly +# evidenced but unmeasured hypothesis and was wrong; D0 exists so FIX-D cannot +# repeat that.) +# +# V1 navigator.userAgent page +# V2 the HTTP ``User-Agent`` request header wire — what the fixture server +# the fixture server actually received READ, not what the page says +# V3 navigator.userAgentData brands + page — built from Chrome's own +# getHighEntropyValues() version info +# V4 Browser.getVersion() -> userAgent CDP +# +# The table is a MEASUREMENT, not a judgement: :func:`f770_vector_readings` +# records each vector's raw value and whether it contains the ``HeadlessChrome`` +# token. The gating verdict lives in :data:`GATE_SIGNALS`. +# =========================================================================== +F770_VECTORS: tuple[str, ...] = ( + "V1_navigator_user_agent", + "V2_http_request_header", + "V3_ua_client_hints", + "V4_cdp_browser_get_version", +) + +_HEADLESS_TOKEN = "headlesschrome" + + +def _leaks_headless(value: object) -> bool: + """True when the serialized reading contains the ``HeadlessChrome`` token.""" + return _HEADLESS_TOKEN in json.dumps(value, default=str).lower() + + +def f770_vector_readings(probe: dict) -> dict[str, dict]: + """Measure the four F-770 UA vectors on one collected probe. + + Returns ``{vector: {"value": , "leaks": bool}}``. Pure — it + judges nothing beyond "does this reading contain the token". + """ + obs = probe["observations"] + readings: dict[str, object] = { + "V1_navigator_user_agent": obs.get("user_agent"), + "V2_http_request_header": obs.get("http_user_agent"), + "V3_ua_client_hints": { + "brands": obs.get("ua_client_hints_brands"), + "high_entropy": obs.get("ua_client_hints_high_entropy"), + "sec_ch_ua_header": (obs.get("http_request_headers") or {}).get("sec-ch-ua") + if isinstance(obs.get("http_request_headers"), dict) + else None, + }, + "V4_cdp_browser_get_version": probe.get("cdp_user_agent"), + } + return { + name: {"value": value, "leaks": _leaks_headless(value)} + for name, value in readings.items() + } + + def _write_artifact(product: dict, control: dict, predicate_outcomes: dict, path): """Write the redacted result artifact: schema version, OS/arch, exact Chrome identity, raw observations, predicate outcomes, and control outcomes. No @@ -624,6 +954,13 @@ def _write_artifact(product: dict, control: dict, predicate_outcomes: dict, path ) is False, }, + # F-770 D0: the per-vector UA measurement for BOTH browsers. The control's + # readings are what an unmasked headless Chrome emits on this exact cell, + # so the pair is also the differential that proves the fix is real. + "f770_ua_vectors": { + "product": f770_vector_readings(product), + "control": f770_vector_readings(control), + }, "cdp_transcript": [e["method"] for e in product["transcript"]], } json.dumps(artifact) # must be serializable @@ -670,6 +1007,12 @@ def test_product_offline_stealth_gate(product_probe, control_probe, tmp_path): for forbidden_flag in ("--enable-automation", "--test-type"): assert not any(a == forbidden_flag for a in cmdline), (forbidden_flag, cmdline) + # F-770 vector V4 — the CDP-level User-Agent. Not a page observation, so it is + # asserted here rather than through the predicate table. The masked + # --user-agent= flag is process-wide, so it reaches this value too. + cdp_ua = product["cdp_user_agent"] + assert isinstance(cdp_ua, str) and "headlesschrome" not in cdp_ua.lower(), cdp_ua + # Vanilla-control sensitivity: the deliberately non-stealth spawn (same product # path, stealth arg-filter neutralized) MUST be detected. If it is NOT detected, # the probe is worthless — fail. Config identity: same binary, same headless @@ -695,7 +1038,12 @@ def test_product_offline_stealth_gate(product_probe, control_probe, tmp_path): assert all(e.get("ok") for e in control["transcript"]), control["transcript"] # Redacted result artifact (validates on re-read; contains no secrets/profile). - artifact_dir = os.environ.get("STEALTH_MCP_STEALTH_ARTIFACT_DIR") + # NOT a ``STEALTH_MCP_*`` name on purpose: ``settings._reject_unknown_prefixed_env`` + # fails ``get_settings()`` for any unknown key in that namespace, so setting + # ``STEALTH_MCP_STEALTH_ARTIFACT_DIR`` (this knob's original W4 name) would + # detonate the whole backend the moment CI exported it. This is a test-only + # artifact path and deliberately lives outside the product's env namespace. + artifact_dir = os.environ.get("STEALTH_PROBE_ARTIFACT_DIR") out_dir = tmp_path if not artifact_dir else Path(artifact_dir) out_dir.mkdir(parents=True, exist_ok=True) artifact_path = out_dir / "stealth_probe_result_v1.json" @@ -709,19 +1057,136 @@ def test_product_offline_stealth_gate(product_probe, control_probe, tmp_path): assert "user-data-dir" not in blob and "user_data_dir" not in blob +def _format_vector_table(label: str, readings: dict[str, dict]) -> str: + rows = [f"[F-770:{label}] {_os_family()}/{_platform.machine()}"] + for name in F770_VECTORS: + entry = readings[name] + verdict = "LEAK" if entry["leaks"] else "clean" + rows.append(f" {name:<28} {verdict:<5} {json.dumps(entry['value'])[:220]}") + return "\n".join(rows) + + +def test_f770_ua_vectors_are_measured(product_probe, control_probe): + """F-770 D0 (plan_RELEASE_FIX_D §2) — MEASURE all four UA vectors, judge none. + + This test's contract is that every vector is actually OBSERVABLE on this cell: + a vector that silently stops being collected would make the F-770 gating + signals vacuous without turning anything red. It deliberately asserts nothing + about whether a vector leaks — that verdict belongs to :data:`GATE_SIGNALS` + (and, before RELEASE-FIX-D, to the strict xfail). The measured table is + printed and written into the release artifact for every OS cell. + """ + for label, probe in (("product", product_probe), ("control", control_probe)): + readings = f770_vector_readings(probe) + print(_format_vector_table(label, readings)) + + v1 = readings["V1_navigator_user_agent"]["value"] + assert isinstance(v1, str) and "Mozilla/5.0" in v1, v1 + + v2 = readings["V2_http_request_header"]["value"] + assert isinstance(v2, str) and not v2.startswith("ERROR:"), ( + f"{label}: the fixture server never reported a User-Agent header " + f"(V2 unmeasured, so a wire-level leak could not be detected): {v2!r}" + ) + assert "Mozilla/5.0" in v2, v2 + + v3 = readings["V3_ua_client_hints"]["value"] + assert v3["brands"], f"{label}: userAgentData.brands unmeasured: {v3!r}" + assert isinstance(v3["high_entropy"], dict), ( + f"{label}: getHighEntropyValues unmeasured: {v3['high_entropy']!r}" + ) + # ``brands`` is the part the V3 coherence gate reads and it is populated + # on both browsers. Whether the rest of the high-entropy set is populated + # is F-774's subject, not a measurability question — asserting it here + # would be judging, which this test deliberately does not do. + assert v3["high_entropy"].get("brands"), v3["high_entropy"] + assert v3["sec_ch_ua_header"], ( + f"{label}: the fixture server saw no sec-ch-ua header: {v3!r}" + ) + + v4 = readings["V4_cdp_browser_get_version"]["value"] + assert isinstance(v4, str) and "Mozilla/5.0" in v4, v4 + + @pytest.mark.xfail( strict=True, - reason="F-770: headless product UA advertises HeadlessChrome; nodriver's default " - "stealth does not mask the UA token. Pinned honestly — an xfailed invariant does " - "NOT satisfy the stealth release claim (the headless UA vector remains detectable).", + reason="F-774: Chrome blanks the high-entropy UA client hints (architecture, " + "bitness, platformVersion, uaFullVersion, fullVersionList) whenever a " + "--user-agent override is active, which is how the F-770 mask works. The " + "low-entropy sec-ch-ua* headers on the wire stay correct. Recorded honestly, " + "not papered over: fixing it needs Emulation.setUserAgentOverride's " + "userAgentMetadata, a per-target mechanism outside RELEASE-FIX-D's scope.", ) -def test_product_ua_headless_token_pinned_gap(product_probe): - """PINNED GAP (F-770). The headless product still leaks 'HeadlessChrome' in its - User-Agent. This assertion is what a fully-stealthy product WOULD satisfy; it - fails today, so the strict xfail passes and records the gap without weakening - any probe.""" +def test_product_ua_client_hints_high_entropy_pinned_gap(product_probe): + """PINNED GAP (F-774). This assertion is what a fully-coherent masked browser + WOULD satisfy. It fails today, so the strict xfail passes and records the + gap — no probe is weakened, and a future fix turns the suite red.""" obs = product_probe["observations"] - assert _p_ua_no_headless(obs, _os_family()) is True, obs.get("user_agent") + assert _p_ua_client_hints_high_entropy_populated(obs, _os_family()) is True, ( + obs.get("ua_client_hints_high_entropy") + ) + + +def test_f770_a_later_tab_is_covered(ua_default_facts): + """F-770: a tab created AFTER spawn is masked too, page and wire. + + A per-target CDP override would satisfy the spawn tab and leak on the next + one (the failure mode that made FIX-C's ``create_hook`` re-arm necessary). + This is what makes the launch-flag mechanism the right one, so it is pinned. + """ + facts = ua_default_facts + assert "HeadlessChrome" not in facts["spawn_tab_ua"], facts["spawn_tab_ua"] + assert "HeadlessChrome" not in facts["new_tab_ua"], facts["new_tab_ua"] + assert facts["new_tab_ua"] == facts["spawn_tab_ua"], facts + assert "HeadlessChrome" not in facts["new_tab_http_ua"], facts["new_tab_http_ua"] + assert facts["new_tab_http_ua"] == facts["new_tab_ua"], facts + + +def test_f770_explicit_user_agent_still_wins(ua_explicit_facts): + """F-770: the masked default NEVER overrides a caller-supplied user_agent.""" + facts = ua_explicit_facts + assert facts["spawn_tab_ua"] == _EXPLICIT_UA, facts["spawn_tab_ua"] + assert facts["new_tab_ua"] == _EXPLICIT_UA, facts["new_tab_ua"] + assert facts["new_tab_http_ua"] == _EXPLICIT_UA, facts["new_tab_http_ua"] + + +def test_f770_product_masks_ua_while_control_still_leaks(product_probe, control_probe): + """F-770 D2 — the fix is real AND the suite that judges it is still sensitive. + + W4's stealth suite is only worth anything because a deliberately non-stealth + control still FAILS the probes. The control is built by neutralizing + ``platform_utils.merge_browser_args``, which is exactly where FIX-D's masked + default ``--user-agent`` is applied — so the control genuinely does not get + it, and this differential proves that rather than assuming it: + + * the control's User-Agent STILL advertises HeadlessChrome (so + ``vanilla_detected`` is not quietly going false, and the whole suite has + not become vacuous); + * the product's User-Agent is the control's with exactly the + ``HeadlessChrome`` token replaced by ``Chrome`` — byte-identical + otherwise. That equality is the coherence contract: the mask is Chrome's + own reduced User-Agent for this binary, not a plausible-looking string + that a platform-token or version mismatch would betray. + """ + control_ua = control_probe["observations"]["user_agent"] + product_ua = product_probe["observations"]["user_agent"] + + assert "HeadlessChrome" in control_ua, ( + "the vanilla control no longer leaks the headless token — the masking was " + f"applied outside the seam the control neutralizes: {control_ua!r}" + ) + assert control_ua.replace("HeadlessChrome", "Chrome") == product_ua, ( + f"masked UA is not the browser's own UA minus the headless token\n" + f" control: {control_ua!r}\n product: {product_ua!r}" + ) + # ...and the same differential holds on the wire, not just in the page. + assert "HeadlessChrome" in control_probe["observations"]["http_user_agent"] + assert ( + control_probe["observations"]["http_user_agent"].replace( + "HeadlessChrome", "Chrome" + ) + == product_probe["observations"]["http_user_agent"] + ) # =========================================================================== diff --git a/tests/test_stealth_args.py b/tests/test_stealth_args.py index 521d654..91bfbc3 100644 --- a/tests/test_stealth_args.py +++ b/tests/test_stealth_args.py @@ -6,11 +6,28 @@ No browser or network required — pure function tests. """ +import pytest + +from stealth_chrome_devtools_mcp.embedded import platform_utils from stealth_chrome_devtools_mcp.embedded.platform_utils import ( filter_stealth_args, merge_browser_args, ) + +@pytest.fixture(autouse=True) +def _no_default_user_agent(monkeypatch): + """Keep these arg-filter tests hermetic. + + ``merge_browser_args`` also supplies the F-770 masked default + ``--user-agent=``, which resolves a real browser (and, off Windows, probes + its version through a subprocess). Neither belongs in a "no browser or + network required" unit file; the masked-default behaviour is covered in + tests/test_platform_utils.py. + """ + monkeypatch.setattr(platform_utils, "check_browser_executable", lambda: None) + + # --------------------------------------------------------------------------- # filter_stealth_args # ---------------------------------------------------------------------------