From 7e4cd75a0bddb7e7faaa6b86f97d0c0c35b49b97 Mon Sep 17 00:00:00 2001 From: LaLi <213186321+codeisalifestyle@users.noreply.github.com> Date: Sat, 20 Jun 2026 02:36:04 +0100 Subject: [PATCH] feat(stealth-diagnostic): bundle a stealth diagnostic with the engine Add `mithwire stealth-diagnostic` (and `mithwire.stealth_diagnostic()` / `run_stealth_diagnostic()`): a diagnostic that drives a bare `mithwire.start()` browser across public bot-detection sites (sannysoft, deviceandbrowserinfo, CreepJS, ipapi) plus a deterministic WebRTC leak probe, then grades the result PASS/WARN/FAIL with short, factual hints for well-established tells (exposed webdriver, headless UA, browser-vs-egress timezone mismatch, WebRTC real-IP leak, datacenter IP). It reveals how an install looks and supports an identify -> adjust -> re-verify loop (re-run with --browser-arg to confirm a fix); it does not auto-fix. - mithwire/stealth_diagnostic/probes.py: readiness-gated JS probes (single source of truth; ported from the mithwire-mcp baseline harness) + SITES - mithwire/stealth_diagnostic/report.py: signal flattening, verdict + hints - mithwire/stealth_diagnostic/runner.py: browser-driving runner + entrypoints - mithwire/__main__.py + [project.scripts]: `mithwire stealth-diagnostic` CLI - tests/test_stealth_diagnostic_report.py: browser-free grading-logic tests Co-authored-by: Cursor --- mithwire/__init__.py | 3 + mithwire/__main__.py | 62 ++++++ mithwire/stealth_diagnostic/__init__.py | 38 ++++ mithwire/stealth_diagnostic/probes.py | 256 +++++++++++++++++++++ mithwire/stealth_diagnostic/report.py | 284 ++++++++++++++++++++++++ mithwire/stealth_diagnostic/runner.py | 107 +++++++++ pyproject.toml | 3 + tests/test_stealth_diagnostic_report.py | 144 ++++++++++++ 8 files changed, 897 insertions(+) create mode 100644 mithwire/__main__.py create mode 100644 mithwire/stealth_diagnostic/__init__.py create mode 100644 mithwire/stealth_diagnostic/probes.py create mode 100644 mithwire/stealth_diagnostic/report.py create mode 100644 mithwire/stealth_diagnostic/runner.py create mode 100644 tests/test_stealth_diagnostic_report.py diff --git a/mithwire/__init__.py b/mithwire/__init__.py index 48886c5..6e96ca0 100644 --- a/mithwire/__init__.py +++ b/mithwire/__init__.py @@ -16,6 +16,7 @@ from mithwire.core.tab import Tab from mithwire.core.util import loop, start from mithwire.stealth import FingerprintConfig, Stealth, compute_launch_args +from mithwire.stealth_diagnostic import run_stealth_diagnostic, stealth_diagnostic __all__ = [ "loop", @@ -31,6 +32,8 @@ "FingerprintConfig", "Stealth", "compute_launch_args", + "stealth_diagnostic", + "run_stealth_diagnostic", ] __version__ = "0.50.5" \ No newline at end of file diff --git a/mithwire/__main__.py b/mithwire/__main__.py new file mode 100644 index 0000000..4824d40 --- /dev/null +++ b/mithwire/__main__.py @@ -0,0 +1,62 @@ +"""``python -m mithwire`` / ``mithwire`` command-line entry point.""" +from __future__ import annotations + +import argparse +import json +import sys + +from mithwire.stealth_diagnostic import format_report, run_stealth_diagnostic +from mithwire.core.util import loop as _loop +from mithwire.stealth_diagnostic.probes import SITES + + +def _cmd_stealth_diagnostic(args: argparse.Namespace) -> int: + sites = args.sites.split(",") if args.sites else None + report = _loop().run_until_complete( + run_stealth_diagnostic( + headless=args.headless, + sites=sites, + proxy=args.proxy, + browser_args=(args.browser_arg or None), + browser_executable_path=args.browser_path, + sandbox=not args.no_sandbox, + include_webrtc=not args.no_webrtc, + ) + ) + if args.json: + print(json.dumps(report.to_dict(), indent=2)) + else: + print(format_report(report, color=not args.no_color)) + # Exit non-zero only on a hard FAIL, so the diagnostic is CI-friendly. + return 1 if not report.ok else 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="mithwire", description="mithwire CLI") + sub = parser.add_subparsers(dest="command", required=True) + + st = sub.add_parser( + "stealth-diagnostic", + help="run the stealth diagnostic against public bot-detection sites", + ) + st.add_argument("--headless", action="store_true", help="run headless (leaks a HeadlessChrome UA; headful is recommended)") + st.add_argument("--sites", default=None, help=f"comma-separated subset of: {','.join(s[0] for s in SITES)}") + st.add_argument("--proxy", default=None, help="proxy as host:port or scheme://host:port (no auth in the bare engine)") + st.add_argument("--browser-arg", action="append", default=[], help="extra Chromium flag (repeatable) — apply a fix and re-run to verify") + st.add_argument("--browser-path", default=None, help="path to the Chrome/Chromium executable") + st.add_argument("--no-sandbox", action="store_true", help="launch with the sandbox disabled (needed as root / in containers)") + st.add_argument("--no-webrtc", action="store_true", help="skip the WebRTC leak probe") + st.add_argument("--json", action="store_true", help="emit the full report as JSON") + st.add_argument("--no-color", action="store_true", help="disable ANSI colors") + st.set_defaults(func=_cmd_stealth_diagnostic) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/mithwire/stealth_diagnostic/__init__.py b/mithwire/stealth_diagnostic/__init__.py new file mode 100644 index 0000000..b866cf2 --- /dev/null +++ b/mithwire/stealth_diagnostic/__init__.py @@ -0,0 +1,38 @@ +"""mithwire stealth diagnostic. + +A diagnostic that runs a bare ``mithwire.start()`` browser against a set of +public bot-detection sites and reports how it looks, so anyone who installs +mithwire can check their machine, adjust their client, and re-run to verify. + + from mithwire import stealth_diagnostic + report = stealth_diagnostic() # headful, all sites + print(report.verdict) # PASS / WARN / FAIL + +Or from the shell:: + + mithwire stealth-diagnostic +""" +from __future__ import annotations + +from .report import ( + FAIL, + PASS, + WARN, + Finding, + StealthDiagnosticReport, + build_report, + format_report, +) +from .runner import run_stealth_diagnostic, stealth_diagnostic + +__all__ = [ + "stealth_diagnostic", + "run_stealth_diagnostic", + "build_report", + "format_report", + "StealthDiagnosticReport", + "Finding", + "PASS", + "WARN", + "FAIL", +] diff --git a/mithwire/stealth_diagnostic/probes.py b/mithwire/stealth_diagnostic/probes.py new file mode 100644 index 0000000..ef80ebb --- /dev/null +++ b/mithwire/stealth_diagnostic/probes.py @@ -0,0 +1,256 @@ +"""Detection-site probes for the mithwire stealth diagnostic. + +Each probe is a self-contained JS expression that runs in the page and returns +a plain (JSON-serializable) value. Probes are **readiness-gated**: they poll the +page for their authoritative result to exist (and stabilize) rather than relying +on a fixed sleep, so they behave correctly on slow links and never sample a +half-rendered page. See ``SITE_PARSING`` notes inline for why each parse is the +robust one. + +This module is the single source of truth for the shared probes: the engine +stealth diagnostic (:mod:`mithwire.stealth_diagnostic`) and the mithwire-mcp +baseline harness both import these definitions so the JS never drifts. +""" +from __future__ import annotations + +import json +from typing import Any + +__all__ = [ + "NAV_PROBE", + "SANNYSOFT_PROBE", + "DEVICEANDBROWSER_PROBE", + "CREEPJS_PROBE", + "IPAPI_PROBE", + "WEBRTC_PROBE", + "SITES", + "wrap", + "parse", +] + +# --- navigator / core fingerprint ----------------------------------------- +# Captured on the first secure-context site so userAgentData & deviceMemory are +# present. These are the values most automation tells key on. +NAV_PROBE = r""" +(() => { + const r = {}; + const safe = (f, d=null) => { try { return f(); } catch(e) { return d; } }; + r.userAgent = safe(() => navigator.userAgent); + r.webdriverType = typeof navigator.webdriver; + r.webdriverValue = safe(() => String(navigator.webdriver)); + r.languages = safe(() => navigator.languages); + r.platform = safe(() => navigator.platform); + r.vendor = safe(() => navigator.vendor); + r.hardwareConcurrency = safe(() => navigator.hardwareConcurrency); + r.deviceMemory = safe(() => navigator.deviceMemory); + r.timezone = safe(() => Intl.DateTimeFormat().resolvedOptions().timeZone); + r.screen = safe(() => ({ w: screen.width, h: screen.height, depth: screen.colorDepth })); + r.dpr = safe(() => devicePixelRatio); + r.hasChrome = safe(() => !!window.chrome); + r.hasChromeRuntime = safe(() => !!(window.chrome && window.chrome.runtime)); + r.uaData = safe(() => navigator.userAgentData ? { + mobile: navigator.userAgentData.mobile, + platform: navigator.userAgentData.platform, + brands: (navigator.userAgentData.brands || []).map(b => b.brand + ' ' + b.version) + } : null); + r.webgl = safe(() => { + const c = document.createElement('canvas'); + const gl = c.getContext('webgl') || c.getContext('experimental-webgl'); + const dbg = gl.getExtension('WEBGL_debug_renderer_info'); + return { vendor: gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL), renderer: gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) }; + }, { err: true }); + // `Function.prototype.toString` / patched-native tells: a spoof that replaces + // a native fn with a JS one (without masking) shows up here as non-native. + r.nativeToString = safe(() => ({ + getParameter: ('' + WebGLRenderingContext.prototype.getParameter).includes('[native code]'), + permissionsQuery: ('' + navigator.permissions.query).includes('[native code]'), + fnToString: ('' + Function.prototype.toString).includes('[native code]') + })); + return r; +})() +""" + +# deviceandbrowserinfo computes its verdict SERVER-SIDE and renders the returned +# JSON into a
 block. Anchor on that element
+# (textContent flattens Prism's spans to clean JSON); self-poll until it parses.
+DEVICEANDBROWSER_PROBE = r"""
+(async () => {
+  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+  const read = () => {
+    const el = document.querySelector('code.language-json')
+      || document.querySelector('pre code');
+    if (el && el.textContent && el.textContent.trim().charAt(0) === '{') {
+      try { return JSON.parse(el.textContent); } catch (e) { return null; }
+    }
+    return null;
+  };
+  const deadline = Date.now() + 25000;
+  let v = read();
+  while (!v && Date.now() < deadline) { await sleep(250); v = read(); }
+  if (!v) return { ready: false, error: 'no-verdict' };
+  return { ready: true, isBot: v.isBot, details: v.details || {} };
+})()
+"""
+
+# sannysoft's real verdicts are the 8 `td.result` cells (each with a stable id).
+# Plain `.passed` cells are the fp2 data rows (always green) -- NOT tests -- so
+# key strictly off `td.result`. Readiness = every cell has text AND the
+# (id,verdict) signature held stable for one extra cycle (the page hard-codes
+# `failed` at parse time and swaps to `passed` async, so polling on 'unknown'
+# false-passes the red state).
+SANNYSOFT_PROBE = r"""
+(async () => {
+  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+  const verdict = (cls) => /failed/.test(cls) ? 'failed'
+    : /warn/.test(cls) ? 'warn' : /passed/.test(cls) ? 'passed' : 'unknown';
+  const name = (td) => {
+    const tr = td.closest('tr');
+    return tr && tr.cells[0]
+      ? tr.cells[0].innerText.replace(/\s+/g, ' ').trim() : (td.id || '');
+  };
+  const collect = () => [...document.querySelectorAll('td.result')].map((td) => ({
+    id: td.id, name: name(td), verdict: verdict(td.className),
+    value: (td.innerText || '').trim(),
+  }));
+  const allFilled = (rows) => rows.length > 0 && rows.every((r) => r.value.length > 0);
+  const signature = (rows) => rows.map((r) => r.id + ':' + r.verdict).join(',');
+  const deadline = Date.now() + 12000;
+  let rows = collect(), lastSig = '', stableSince = -1;
+  while (Date.now() < deadline) {
+    if (allFilled(rows)) {
+      const sig = signature(rows);
+      if (sig === lastSig) {
+        if (stableSince < 0) stableSince = Date.now();
+        if (Date.now() - stableSince >= 400) break;
+      } else { lastSig = sig; stableSince = -1; }
+    }
+    await sleep(200);
+    rows = collect();
+  }
+  return {
+    total: rows.length,
+    passed: rows.filter((r) => r.verdict === 'passed').length,
+    failed: rows.filter((r) => r.verdict === 'failed').map((r) => r.id || r.name),
+    warn: rows.filter((r) => r.verdict === 'warn').map((r) => r.id || r.name),
+    rows: rows.map((r) => ({ ...r, value: r.value.slice(0, 40) })),
+  };
+})()
+"""
+
+# CreepJS renders progressively; there is NO plain-text trust score in this
+# build. Stable signal = the `.lies` count (spoofing inconsistencies it caught;
+# 0 on a clean browser). Gate readiness on the fuzzy hash being populated with a
+# non-zero hex char (it renders a 16-zero placeholder before computing).
+CREEPJS_PROBE = r"""
+(async () => {
+  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+  const safe = (f, d=null) => { try { return f(); } catch (e) { return d; } };
+  const fuzzyHex = () => safe(() => {
+    const e = document.querySelector('.fuzzy-fp');
+    return e ? (e.innerText || '').replace(/fuzzy:?/i, '').replace(/[^0-9a-f]/gi, '') : '';
+  }, '') || '';
+  const fuzzyReady = () => { const h = fuzzyHex(); return h.length >= 16 && /[1-9a-f]/.test(h); };
+  const deadline = Date.now() + 24000;
+  while (Date.now() < deadline && !fuzzyReady()) { await sleep(300); }
+  const txt = safe(() => document.body.innerText, '') || '';
+  const lies = safe(() => [...document.querySelectorAll('.lies')], []) || [];
+  const categories = lies.map((e) => {
+    const row = e.closest('div');
+    return (row ? (row.innerText || '') : (e.textContent || '')).replace(/\s+/g, ' ').trim().slice(0, 40);
+  });
+  const fpId = safe(() => { const m = txt.match(/FP ID:\s*([0-9a-f]{16,})/i); return m ? m[1] : null; });
+  return {
+    ready: fuzzyReady(),
+    lieNodes: lies.length,
+    lieCategories: categories,
+    fpId: fpId,
+    fuzzyHash: fuzzyHex().slice(0, 16) || null,
+  };
+})()
+"""
+
+# IP / geo ground truth. The body is raw JSON; parse directly. Used to compare
+# the egress IP's timezone against the browser's Intl timezone (a mismatch is a
+# classic tell) and to surface datacenter/proxy/vpn flags on the exit IP.
+IPAPI_PROBE = r"""
+(() => {
+  const safe = (f, d=null) => { try { return f(); } catch (e) { return d; } };
+  const raw = safe(() => document.body.innerText, '') || '';
+  let j = null;
+  try { j = JSON.parse(raw); } catch (e) { return { ready: false, error: String(e) }; }
+  const loc = j.location || {};
+  return {
+    ready: true,
+    ip: j.ip, country: loc.country, timezone: loc.timezone,
+    is_proxy: j.is_proxy, is_vpn: j.is_vpn, is_datacenter: j.is_datacenter,
+    is_tor: j.is_tor, is_abuser: j.is_abuser, is_crawler: j.is_crawler, is_mobile: j.is_mobile,
+    asn: (j.asn || {}).descr || (j.asn || {}).org || null,
+  };
+})()
+"""
+
+# Deterministic WebRTC leak probe: drive our own RTCPeerConnection against a
+# public STUN server and WAIT for ICE gathering to complete (9s cap) before
+# reporting every candidate. A public srflx address that isn't the egress IP is
+# a real-IP leak. Runs on a secure (https) page.
+WEBRTC_PROBE = r"""
+(async () => {
+  if (typeof RTCPeerConnection === 'undefined') return { ready: false, error: 'no-rtc' };
+  const cands = [];
+  const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
+  try {
+    pc.createDataChannel('probe');
+    await pc.setLocalDescription(await pc.createOffer());
+  } catch (e) {
+    try { pc.close(); } catch (_) {}
+    return { ready: false, error: 'offer-failed:' + String(e) };
+  }
+  let complete = false;
+  await new Promise((resolve) => {
+    pc.onicegatheringstatechange = () => {
+      if (pc.iceGatheringState === 'complete') { complete = true; resolve(); }
+    };
+    pc.onicecandidate = (e) => {
+      if (!e.candidate) { complete = true; resolve(); return; }
+      const c = e.candidate.candidate || '';
+      const parts = c.split(' ');
+      cands.push({ addr: parts[4] || '', typ: (c.match(/ typ (\S+)/) || [])[1] || '' });
+    };
+    setTimeout(resolve, 9000);
+  });
+  try { pc.close(); } catch (_) {}
+  return { ready: true, gatheringComplete: complete, candidates: cands };
+})()
+"""
+
+# (key, url, nav_wait_s, probe_js, probe_timeout_s). Self-polling probes gate on
+# readiness internally; probe_timeout MUST exceed the in-JS deadline so a timely
+# -but-late page isn't cut off by the outer guard. The first entry must be a
+# secure-context site (the navigator probe runs there).
+SITES: list[tuple[str, str, float, str, float]] = [
+    ("deviceandbrowserinfo", "https://deviceandbrowserinfo.com/are_you_a_bot", 2.0, DEVICEANDBROWSER_PROBE, 32.0),
+    ("sannysoft", "https://bot.sannysoft.com/", 1.5, SANNYSOFT_PROBE, 18.0),
+    ("creepjs", "https://abrahamjuliot.github.io/creepjs/", 2.0, CREEPJS_PROBE, 32.0),
+    ("ipapi", "https://api.ipapi.is/", 1.5, IPAPI_PROBE, 12.0),
+]
+
+
+def wrap(expr: str) -> str:
+    """Force a probe to resolve to a JSON string.
+
+    ``Tab.evaluate(return_by_value=True)`` hands back a RemoteObject for nested
+    objects; serializing to a string in-page and ``json.loads``-ing it in Python
+    yields a uniform plain value across sync and async probes. ``Promise.resolve``
+    lets a probe be a plain IIFE *or* an async (readiness-polling) one.
+    """
+    return f"Promise.resolve(({expr})).then((v) => JSON.stringify(v))"
+
+
+def parse(value: Any) -> Any:
+    """Decode the JSON string a wrapped probe returns (tolerant of failures)."""
+    if isinstance(value, str):
+        try:
+            return json.loads(value)
+        except Exception:  # noqa: BLE001
+            return {"__unparsed__": value[:500]}
+    return value
diff --git a/mithwire/stealth_diagnostic/report.py b/mithwire/stealth_diagnostic/report.py
new file mode 100644
index 0000000..9cb7937
--- /dev/null
+++ b/mithwire/stealth_diagnostic/report.py
@@ -0,0 +1,284 @@
+"""Turn raw stealth-diagnostic probe output into a verdict + actionable findings.
+
+The stealth diagnostic is exactly that: it reveals how a freshly-installed mithwire
+browser looks to common detectors on *this* machine, so the operator can adjust
+their own client (flags, headful vs headless, timezone, proxy, …) and re-run to
+confirm. It does not auto-fix anything — it reports clearly and, where a signal
+is a well-established tell, attaches a short factual hint.
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Any
+
+# Verdict / severity levels, ordered.
+PASS = "PASS"
+WARN = "WARN"
+FAIL = "FAIL"
+_RANK = {PASS: 0, WARN: 1, FAIL: 2}
+
+# deviceandbrowserinfo flags that indicate concrete automation (hard fail) vs
+# soft heuristics (warn). Kept deliberately small; unknown true flags warn.
+_DAB_HARD_FLAGS = {
+    "isHeadlessChrome",
+    "hasWebdriverTrue",
+    "isAutomatedWithCDP",
+    "isWebGLInconsistent",
+    "hasInconsistentClientHints",
+}
+
+
+@dataclass
+class Finding:
+    site: str
+    signal: str
+    severity: str
+    detail: str
+    hint: str | None = None
+
+
+@dataclass
+class StealthDiagnosticReport:
+    headless: bool
+    verdict: str = PASS
+    findings: list[Finding] = field(default_factory=list)
+    signals: dict[str, Any] = field(default_factory=dict)
+    raw: dict[str, Any] = field(default_factory=dict)
+
+    @property
+    def ok(self) -> bool:
+        return self.verdict != FAIL
+
+    def _add(self, finding: Finding) -> None:
+        self.findings.append(finding)
+        if _RANK[finding.severity] > _RANK[self.verdict]:
+            self.verdict = finding.severity
+
+    def to_dict(self) -> dict[str, Any]:
+        return {
+            "verdict": self.verdict,
+            "headless": self.headless,
+            "signals": self.signals,
+            "findings": [vars(f) for f in self.findings],
+        }
+
+
+def _is_errorish(v: Any) -> bool:
+    return isinstance(v, dict) and any(
+        k in v for k in ("__timeout__", "__error__", "__unparsed__", "__eval_error__")
+    )
+
+
+def build_report(raw: dict[str, Any], *, headless: bool) -> StealthDiagnosticReport:
+    """Flatten raw probe output into derived signals + a graded findings list."""
+    rep = StealthDiagnosticReport(headless=headless, raw=raw)
+    probes = raw.get("probes", {}) if isinstance(raw, dict) else {}
+    sig = rep.signals
+
+    # --- navigator core ---------------------------------------------------
+    nav = probes.get("navigator") or {}
+    if isinstance(nav, dict) and not _is_errorish(nav):
+        wd_val = nav.get("webdriverValue")
+        sig["webdriver"] = f"{nav.get('webdriverType')}={wd_val}"
+        exposed = wd_val not in (None, "false", "undefined", "null", "")
+        if exposed:
+            rep._add(Finding(
+                "navigator", "webdriver", FAIL,
+                f"navigator.webdriver is exposed ({wd_val}).",
+                "Launch the browser via mithwire.start() — it removes the "
+                "webdriver flag. A plain Chrome/CDP launch will be flagged.",
+            ))
+
+        ua = nav.get("userAgent") or ""
+        sig["userAgent"] = ua
+        if "Headless" in ua:
+            rep._add(Finding(
+                "navigator", "user-agent", WARN,
+                "User-Agent contains a 'HeadlessChrome' token.",
+                "Run headful (under Xvfb on a headless server) so the UA and "
+                "client-hints match a real browser.",
+            ))
+
+        sig["timezone"] = nav.get("timezone")
+        wgl = nav.get("webgl")
+        if isinstance(wgl, dict) and wgl.get("err"):
+            sig["webgl"] = "unavailable"
+            rep._add(Finding(
+                "navigator", "webgl", WARN,
+                "No WebGL renderer is available.",
+                "Common in headless/no-GPU containers; some fingerprinters "
+                "expect a renderer. Provide one (e.g. software rendering) if "
+                "your targets check it.",
+            ))
+        elif isinstance(wgl, dict):
+            sig["webgl"] = f"{wgl.get('vendor')} / {wgl.get('renderer')}"
+
+        nts = nav.get("nativeToString") or {}
+        if isinstance(nts, dict) and (nts.get("getParameter") is False or nts.get("fnToString") is False):
+            rep._add(Finding(
+                "navigator", "native-functions", WARN,
+                "A patched function no longer reports '[native code]'.",
+                "If you override native methods, mask their toString so the "
+                "tamper isn't itself detectable.",
+            ))
+
+    # --- bot.sannysoft ----------------------------------------------------
+    sanny = probes.get("sannysoft") or {}
+    if isinstance(sanny, dict) and not _is_errorish(sanny) and sanny.get("total"):
+        failed = sanny.get("failed") or []
+        sig["sannysoft"] = f"{sanny.get('passed')}/{sanny.get('total')} passed"
+        if failed:
+            rep._add(Finding(
+                "sannysoft", "checks", FAIL,
+                f"Failed checks: {', '.join(failed)}.",
+                "These are concrete automation tells; inspect each failing "
+                "row's value to see what leaked.",
+            ))
+
+    # --- deviceandbrowserinfo --------------------------------------------
+    dab = probes.get("deviceandbrowserinfo") or {}
+    if isinstance(dab, dict) and dab.get("ready"):
+        details = dab.get("details") or {}
+        true_flags = sorted(k for k, v in details.items() if v is True)
+        sig["dab_isBot"] = dab.get("isBot")
+        sig["dab_flags"] = true_flags or "none"
+        hard = [f for f in true_flags if f in _DAB_HARD_FLAGS]
+        if hard:
+            rep._add(Finding(
+                "deviceandbrowserinfo", "flags", FAIL,
+                f"Concrete automation flags set: {', '.join(hard)}.",
+                "Each maps to a specific tell (headless UA, webdriver, CDP "
+                "automation, inconsistent fingerprint). Fix the underlying leak.",
+            ))
+        elif dab.get("isBot"):
+            rep._add(Finding(
+                "deviceandbrowserinfo", "flags", WARN,
+                f"isBot=true via soft heuristics: {', '.join(true_flags) or 'unknown'}.",
+                "No hard automation flag tripped. Often environmental "
+                "(datacenter IP, timezone/locale inconsistency); see the "
+                "timezone finding if present.",
+            ))
+
+    # --- CreepJS ----------------------------------------------------------
+    creep = probes.get("creepjs") or {}
+    if isinstance(creep, dict) and creep.get("ready"):
+        lies = creep.get("lieNodes") or 0
+        sig["creep_lies"] = lies
+        if lies:
+            cats = creep.get("lieCategories") or []
+            rep._add(Finding(
+                "creepjs", "lies", WARN,
+                f"{lies} spoofing inconsistency(ies): {', '.join(cats) or 'see report'}.",
+                "CreepJS cross-checks the main thread against Worker scopes. A "
+                "single headless Navigator lie is a known engine-only gap; "
+                "more usually means a spoof disagrees across scopes.",
+            ))
+
+    # --- ip / timezone alignment -----------------------------------------
+    ip = probes.get("ipapi") or {}
+    if isinstance(ip, dict) and ip.get("ready"):
+        sig["egress_ip"] = ip.get("ip")
+        sig["egress_timezone"] = ip.get("timezone")
+        flags = sorted(
+            k.replace("is_", "")
+            for k in ("is_proxy", "is_vpn", "is_datacenter", "is_tor", "is_abuser", "is_crawler")
+            if ip.get(k) is True
+        )
+        sig["ip_flags"] = flags or "none"
+        browser_tz = nav.get("timezone") if isinstance(nav, dict) else None
+        egress_tz = ip.get("timezone")
+        if browser_tz and egress_tz:
+            if browser_tz == egress_tz:
+                sig["tz_match"] = "MATCH"
+            else:
+                sig["tz_match"] = f"MISMATCH ({browser_tz} vs {egress_tz})"
+                rep._add(Finding(
+                    "ipapi", "timezone", WARN,
+                    f"Browser timezone {browser_tz} != egress IP timezone {egress_tz}.",
+                    "A browser-TZ vs IP-TZ gap is a classic tell. Align the "
+                    "browser to the egress zone (e.g. CDP "
+                    "Emulation.setTimezoneOverride) or run in that region; pair "
+                    "geo spoofing with a same-region proxy.",
+                ))
+        if "datacenter" in flags:
+            rep._add(Finding(
+                "ipapi", "ip-reputation", WARN,
+                "Egress IP is flagged as datacenter.",
+                "Datacenter IPs are higher-suspicion for many detectors; use a "
+                "residential/mobile proxy for sensitive targets.",
+            ))
+
+    # --- WebRTC leak ------------------------------------------------------
+    wrtc = probes.get("webrtc") or {}
+    if isinstance(wrtc, dict) and wrtc.get("ready"):
+        egress = ip.get("ip") if isinstance(ip, dict) else None
+        publics: list[str] = []
+        for c in wrtc.get("candidates") or []:
+            addr = (c.get("addr") or "") if isinstance(c, dict) else ""
+            if _classify_addr(addr) == "public" and addr not in publics:
+                publics.append(addr)
+        leaks = [a for a in publics if a != egress]
+        if leaks:
+            sig["webrtc"] = f"REAL-IP-LEAK {leaks}"
+            rep._add(Finding(
+                "webrtc", "ice-candidates", FAIL,
+                f"WebRTC exposed a public IP that isn't the egress: {leaks}.",
+                "Behind a proxy this de-anonymizes you. Filter public ICE "
+                "candidates / force --force-webrtc-ip-handling-policy, or "
+                "disable RTCPeerConnection if WebRTC isn't needed.",
+            ))
+        elif publics:
+            sig["webrtc"] = "egress-only (ok)"
+        else:
+            sig["webrtc"] = "no-public (ok)"
+
+    return rep
+
+
+def _classify_addr(addr: str) -> str:
+    import re
+
+    a = (addr or "").strip().lower()
+    if not a:
+        return "empty"
+    if a.endswith(".local") or "mdns" in a:
+        return "mdns"
+    if ":" in a:
+        return "private" if a.startswith(("fe80", "fc", "fd")) else "public"
+    if re.match(r"^(10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)", a):
+        return "private"
+    if re.match(r"^\d{1,3}(\.\d{1,3}){3}$", a):
+        return "public"
+    return "other"
+
+
+_VERDICT_ICON = {PASS: "PASS", WARN: "WARN", FAIL: "FAIL"}
+
+
+def format_report(rep: StealthDiagnosticReport, *, color: bool = True) -> str:
+    """Human-readable CLI rendering of a report."""
+    def c(code: str, s: str) -> str:
+        return f"\033[{code}m{s}\033[0m" if color else s
+
+    vcolor = {PASS: "32", WARN: "33", FAIL: "31"}[rep.verdict]
+    lines = []
+    lines.append(c("1", "mithwire stealth diagnostic"))
+    mode = "headless" if rep.headless else "headful"
+    lines.append(f"  mode: {mode}")
+    lines.append(f"  verdict: {c(vcolor, c('1', rep.verdict))}")
+    lines.append("")
+    lines.append(c("1", "Signals"))
+    for k, v in rep.signals.items():
+        lines.append(f"  {k:<18} {v}")
+    if rep.findings:
+        lines.append("")
+        lines.append(c("1", "Findings"))
+        for f in rep.findings:
+            fc = {PASS: "32", WARN: "33", FAIL: "31"}[f.severity]
+            lines.append(f"  {c(fc, f.severity)} [{f.site}/{f.signal}] {f.detail}")
+            if f.hint:
+                lines.append(f"       -> {f.hint}")
+    else:
+        lines.append("")
+        lines.append(c("32", "  No issues detected — this browser looks clean to the bundled detectors."))
+    return "\n".join(lines)
diff --git a/mithwire/stealth_diagnostic/runner.py b/mithwire/stealth_diagnostic/runner.py
new file mode 100644
index 0000000..70675ee
--- /dev/null
+++ b/mithwire/stealth_diagnostic/runner.py
@@ -0,0 +1,107 @@
+"""Drive a real mithwire browser across the detection sites and grade it.
+
+This is the engine-owned equivalent of the mithwire-mcp baseline harness's
+``mithwire`` column: a bare ``mithwire.start(...)`` browser (the engine's
+always-on stealth, no MCP layers) run against the bundled detectors so an
+installer can see exactly how their machine looks and adjust their client.
+"""
+from __future__ import annotations
+
+import asyncio
+from typing import Any, Iterable
+
+from mithwire.core.util import loop as _loop
+from mithwire.core.util import start as _start
+
+from .probes import NAV_PROBE, SITES, WEBRTC_PROBE, parse, wrap
+from .report import StealthDiagnosticReport, build_report
+
+__all__ = ["run_stealth_diagnostic", "stealth_diagnostic"]
+
+
+async def _guard(coro, timeout: float, name: str) -> Any:
+    try:
+        return await asyncio.wait_for(coro, timeout=timeout)
+    except asyncio.TimeoutError:
+        return {"__timeout__": name, "after_s": timeout}
+    except Exception as exc:  # noqa: BLE001
+        return {"__error__": f"{type(exc).__name__}: {exc}"}
+
+
+async def _eval(tab: Any, probe: str, timeout: float, name: str) -> Any:
+    async def _run() -> Any:
+        result = await tab.evaluate(wrap(probe), await_promise=True, return_by_value=True)
+        if not isinstance(result, str):
+            # Non-string -> an ExceptionDetails / RemoteObject, i.e. the probe
+            # threw or didn't serialize. Surface it rather than crash the run.
+            return {"__eval_error__": str(result)[:300]}
+        return parse(result)
+
+    return await _guard(_run(), timeout, name)
+
+
+async def run_stealth_diagnostic(
+    *,
+    headless: bool = False,
+    sites: Iterable[str] | None = None,
+    proxy: str | None = None,
+    browser_args: list[str] | None = None,
+    browser_executable_path: str | None = None,
+    sandbox: bool = True,
+    include_webrtc: bool = True,
+) -> StealthDiagnosticReport:
+    """Launch a mithwire browser, probe the detection sites, return a report.
+
+    :param headless: run headless (leaks a HeadlessChrome UA — see the report's
+        warning); default is headful, the recommended stealth mode.
+    :param sites: optional subset of site keys (see ``probes.SITES``); default
+        runs them all.
+    :param proxy: optional ``host:port`` (or scheme://host:port) proxy. The bare
+        engine sets ``--proxy-server`` only; an authenticated proxy will
+        407-challenge (proxy auth is an mithwire-mcp layer).
+    :param browser_args: extra Chromium flags to launch with (this is exactly
+        where a user applies a fix and re-runs to verify it).
+    :param browser_executable_path: override Chrome/Chromium autodetection.
+    """
+    selected = list(SITES)
+    if sites is not None:
+        wanted = set(sites)
+        selected = [s for s in SITES if s[0] in wanted]
+        if not selected:
+            raise ValueError(f"no known sites in {sorted(wanted)}; valid: {[s[0] for s in SITES]}")
+
+    args = list(browser_args or [])
+    if proxy:
+        scheme_host = proxy if "://" in proxy else f"http://{proxy}"
+        args.append(f"--proxy-server={scheme_host}")
+
+    raw: dict[str, Any] = {"headless": headless, "proxy": bool(proxy), "probes": {}}
+
+    browser = await _start(
+        headless=headless,
+        browser_args=args or None,
+        browser_executable_path=browser_executable_path,
+        sandbox=sandbox,
+    )
+    try:
+        tab = browser.main_tab
+        for i, (key, url, wait, probe, probe_to) in enumerate(selected):
+            await _guard(tab.get(url), 40, f"nav {key}")
+            if wait:
+                await asyncio.sleep(wait)
+            if i == 0:
+                raw["probes"]["navigator"] = await _eval(tab, NAV_PROBE, 15, "navigator")
+            raw["probes"][key] = await _eval(tab, probe, probe_to, key)
+        # WebRTC leak check on the current secure (https) page after ICE
+        # gathering completes — independent of any site's own snapshot.
+        if include_webrtc:
+            raw["probes"]["webrtc"] = await _eval(tab, WEBRTC_PROBE, 15, "webrtc")
+    finally:
+        await _guard(browser.stop(), 20, "stop")
+
+    return build_report(raw, headless=headless)
+
+
+def stealth_diagnostic(**kwargs: Any) -> StealthDiagnosticReport:
+    """Synchronous wrapper around :func:`run_stealth_diagnostic` (engine loop)."""
+    return _loop().run_until_complete(run_stealth_diagnostic(**kwargs))
diff --git a/pyproject.toml b/pyproject.toml
index 582f96c..6969570 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -41,6 +41,9 @@ dependencies = [
       "deprecated"
 ]
 
+[project.scripts]
+mithwire = "mithwire.__main__:main"
+
 #
 [project.urls]
 "Homepage" = "https://github.com/codeisalifestyle/mithwire"
diff --git a/tests/test_stealth_diagnostic_report.py b/tests/test_stealth_diagnostic_report.py
new file mode 100644
index 0000000..553d1fd
--- /dev/null
+++ b/tests/test_stealth_diagnostic_report.py
@@ -0,0 +1,144 @@
+"""Browser-free tests for the stealth-diagnostic grading logic.
+
+These pin the verdict/finding rules (:mod:`mithwire.stealth_diagnostic.report`)
+without launching Chrome, by feeding synthetic probe payloads shaped exactly
+like the real probes emit. The browser-driving runner is covered by the live
+``mithwire stealth-diagnostic`` command, not here.
+"""
+from __future__ import annotations
+
+import unittest
+
+from mithwire.stealth_diagnostic.report import FAIL, PASS, WARN, build_report
+
+
+def _finding(rep, site, signal):
+    return next((f for f in rep.findings if f.site == site and f.signal == signal), None)
+
+
+CLEAN_NAV = {
+    "userAgent": "Mozilla/5.0 (X11; Linux x86_64) Chrome/120.0.0.0 Safari/537.36",
+    "webdriverType": "boolean",
+    "webdriverValue": "false",
+    "timezone": "Europe/London",
+    "webgl": {"vendor": "Google Inc.", "renderer": "ANGLE (SwiftShader)"},
+    "nativeToString": {"getParameter": True, "permissionsQuery": True, "fnToString": True},
+}
+
+
+def _clean_raw():
+    return {
+        "headless": False,
+        "probes": {
+            "navigator": dict(CLEAN_NAV),
+            "sannysoft": {"total": 8, "passed": 8, "failed": [], "warn": [], "rows": []},
+            "deviceandbrowserinfo": {"ready": True, "isBot": False, "details": {}},
+            "creepjs": {"ready": True, "lieNodes": 0, "lieCategories": []},
+            "ipapi": {"ready": True, "ip": "1.2.3.4", "timezone": "Europe/London"},
+            "webrtc": {"ready": True, "candidates": [{"addr": "1.2.3.4", "typ": "srflx"}]},
+        },
+    }
+
+
+class CleanBrowserTests(unittest.TestCase):
+    def test_clean_browser_passes_with_no_findings(self):
+        rep = build_report(_clean_raw(), headless=False)
+        self.assertEqual(rep.verdict, PASS)
+        self.assertEqual(rep.findings, [])
+        self.assertEqual(rep.signals["tz_match"], "MATCH")
+        self.assertEqual(rep.signals["webrtc"], "egress-only (ok)")
+
+
+class HardFailTests(unittest.TestCase):
+    def test_exposed_webdriver_is_fail(self):
+        raw = _clean_raw()
+        raw["probes"]["navigator"]["webdriverValue"] = "true"
+        rep = build_report(raw, headless=False)
+        self.assertEqual(rep.verdict, FAIL)
+        self.assertIsNotNone(_finding(rep, "navigator", "webdriver"))
+
+    def test_sannysoft_failure_is_fail(self):
+        raw = _clean_raw()
+        raw["probes"]["sannysoft"] = {"total": 8, "passed": 7, "failed": ["webdriver"], "warn": [], "rows": []}
+        rep = build_report(raw, headless=False)
+        self.assertEqual(rep.verdict, FAIL)
+
+    def test_dab_hard_flag_is_fail_soft_is_warn(self):
+        hard = _clean_raw()
+        hard["probes"]["deviceandbrowserinfo"] = {
+            "ready": True, "isBot": True, "details": {"isHeadlessChrome": True},
+        }
+        self.assertEqual(build_report(hard, headless=False).verdict, FAIL)
+
+        soft = _clean_raw()
+        soft["probes"]["deviceandbrowserinfo"] = {
+            "ready": True, "isBot": True, "details": {"hasSuspiciousWeakSignals": True},
+        }
+        rep = build_report(soft, headless=False)
+        self.assertEqual(rep.verdict, WARN)
+        self.assertIsNotNone(_finding(rep, "deviceandbrowserinfo", "flags"))
+
+    def test_webrtc_real_ip_leak_is_fail(self):
+        raw = _clean_raw()
+        raw["probes"]["webrtc"] = {
+            "ready": True,
+            "candidates": [
+                {"addr": "1.2.3.4", "typ": "srflx"},
+                {"addr": "9.9.9.9", "typ": "srflx"},  # not the egress IP -> leak
+            ],
+        }
+        rep = build_report(raw, headless=False)
+        self.assertEqual(rep.verdict, FAIL)
+        leak = _finding(rep, "webrtc", "ice-candidates")
+        self.assertIsNotNone(leak)
+        self.assertIn("9.9.9.9", leak.detail)
+
+
+class SoftWarnTests(unittest.TestCase):
+    def test_timezone_mismatch_warns_with_hint(self):
+        raw = _clean_raw()
+        raw["probes"]["ipapi"]["timezone"] = "America/New_York"
+        rep = build_report(raw, headless=False)
+        self.assertEqual(rep.verdict, WARN)
+        tz = _finding(rep, "ipapi", "timezone")
+        self.assertIsNotNone(tz)
+        self.assertIn("Emulation.setTimezoneOverride", tz.hint)
+
+    def test_headless_ua_warns(self):
+        raw = _clean_raw()
+        raw["probes"]["navigator"]["userAgent"] = "Mozilla/5.0 HeadlessChrome/120.0.0.0"
+        rep = build_report(raw, headless=True)
+        self.assertEqual(rep.verdict, WARN)
+        self.assertIsNotNone(_finding(rep, "navigator", "user-agent"))
+
+    def test_creepjs_lies_warn(self):
+        raw = _clean_raw()
+        raw["probes"]["creepjs"] = {"ready": True, "lieNodes": 2, "lieCategories": ["navigator", "worker"]}
+        rep = build_report(raw, headless=False)
+        self.assertEqual(rep.verdict, WARN)
+
+    def test_datacenter_ip_warns(self):
+        raw = _clean_raw()
+        raw["probes"]["ipapi"]["is_datacenter"] = True
+        rep = build_report(raw, headless=False)
+        self.assertEqual(rep.verdict, WARN)
+        self.assertEqual(rep.signals["ip_flags"], ["datacenter"])
+
+
+class ResilienceTests(unittest.TestCase):
+    def test_errorish_and_missing_probes_do_not_crash(self):
+        raw = {
+            "headless": True,
+            "probes": {
+                "navigator": {"__timeout__": "navigator"},
+                "sannysoft": {"__error__": "boom"},
+                # other probes absent entirely
+            },
+        }
+        rep = build_report(raw, headless=True)
+        # Nothing assertable tripped, so it stays PASS rather than raising.
+        self.assertEqual(rep.verdict, PASS)
+
+
+if __name__ == "__main__":
+    unittest.main()