Deep in-browser detector for Chrome DevTools Protocol traces and browser-automation artifacts.
31 probes across nine families — CDP protocol side-channels, input-event forensics, behavioural biometrics, framework globals, stack signatures, cross-realm consistency, anti-detect tamper forensics, headless environment tells and cross-signal consistency.
A zero-dependency library that answers two questions about the browser it is running in, and keeps them separate because they need different evidence:
- Is this browser being driven by something? → a 0–100 score and a verdict.
- By what? → a ranked attribution across Puppeteer, Playwright, Selenium/ChromeDriver, Pyppeteer, Cypress, Electron, an open DevTools window, a generic CDP client, an anti-detect patch layer, or a synthetic-input agent.
Every finding comes back as a signal with its raw evidence attached, so a report is auditable rather than a number you have to trust.
score 99.5 / 100
verdict automated
attribution Puppeteer 100% · CDP client 93% · Synthetic input 79%
signals 6 (2 definitive)
probes 31/31 ran in 415 ms
Most published automation-detection code is a copy of a 2024 blog post, and a surprising amount of it silently stopped working. Two examples that this library handles explicitly rather than pretending otherwise:
The classic CDP oracle is dead — and something else still works.
The famous Runtime.enable check planted a getter on error.stack and logged the
error. Two V8 commits in May 2025 stopped the inspector invoking user getters during
error preview, and the signal went quiet around Chrome 138 without much notice.
MickeyJS probes both accessors and reports which fired. Verified against Chrome 151:
stack reads 0, Error.prototype.name reads 14. The name channel is not covered
by the guard and still detects Runtime.enable today.
The CDP input coordinate leak was fixed in Chrome 142.
pageX === screenX (crbug#1477537) was the single best injected-input tell for years.
chromium-review 6917162
fixed it, and started emitting coalesced events for injected input at the same time.
MickeyJS version-gates both affected probes and says so in the evidence, instead of
reporting a clean result as if it meant something.
The general principle: a probe that cannot run says so. It is never silently counted as a pass.
npm install mickeyjsor drop in the bundle — no build step, no dependencies:
<script src="https://cdn.jsdelivr.net/npm/mickeyjs/dist/mickeyjs.min.js"></script>import { detect } from 'mickeyjs';
const report = await detect();
console.log(report.verdict, report.score, report.attribution);Passive collectors want to be live before the visitor interacts, and CDP clients frequently attach after page load:
import { Detector } from 'mickeyjs';
const detector = new Detector();
detector.start(); // install passive collectors + hooks (synchronous, cheap)
// …later, e.g. at form submit or checkout
const report = await detector.scan();
if (report.score > 70) flagSession(report);detector.watch((report) => {
console.log(report.verdict, report.score);
}, 2000);The IIFE bundle exposes the whole namespace as window.MickeyJS:
<script src="./dist/mickeyjs.min.js"></script>
<script>
MickeyJS.detect().then((r) => console.log(r.verdict, r.score));
</script>new Detector({
hooks: true, // patch DOM entry points for the stack-signature probes (default true)
behavior: true, // record input events for the input/behaviour families (default true)
exclude: [], // probe ids to skip
only: null, // restrict the run to these probe ids
});detector.stop() reverts every hook and removes every listener.
| Family | Probes | What it looks at |
|---|---|---|
cdp |
4 | Runtime.enable console-serialisation side effects and cost; debugger pause timing in a Worker |
input |
1 | Provenance artifacts of injected pointer/touch events |
behavior |
1 | Mouse-event rate, click dwell variance, teleport clicks, path linearity, typing rhythm |
framework |
3 | Driver globals, $cdc_ patterns, <html> attributes, exposeFunction bridges, Node runtime |
stack |
3 | pptr: / UtilityScript. / Proxy-trap frames; ChromeDriver's injected bundle; main-world canary |
realm |
2 | Does navigator agree across window, Worker and a fresh iframe |
tamper |
3 | Native-function integrity, cross-realm toString, Proxy failure modes, accessor timing |
env |
12 | Automation flag forensics, headless build identity, surface geometry, GPU, codecs, CSP enforcement |
consistency |
2 | OS / timezone / locale / touch agreement across independent sources |
Full detail — every signal, its tier, its actors and its references — is in SIGNALS.md.
The score is a noisy-OR combination of independent evidence:
P(automated) = 1 − Π(1 − wᵢ)
This is deliberate. Additive scoring lets a pile of weak environment tells — small
screen, few fonts, no GPU, unusual core count — manufacture a confident verdict out of
nothing, which is how real anti-bot systems end up blocking people on old laptops in
VMs. Under noisy-OR, four weak signals reach 76, not 120.
Three further rules keep the model honest:
- A
definitivesignal pins the score to 100.__pwInitScriptsdoes not appear in a browser that Playwright is not driving. No amount of contrary evidence dilutes that. - Repeat observations count once. Seeing the same artifact on five scans is one piece of evidence, not five.
- Family diversity corroborates but cannot convict. Signals spread across three or more families get a small bump, capped at 95, and zero signals stay at zero.
Attribution is scored separately, and a signal naming several possible actors splits its weight between them — so "could be Puppeteer or Playwright" never reads as strong evidence for both.
Please read this section before deploying anything that blocks people.
- A clean result is weak evidence. Patched drivers (
rebrowser-patches, Patchright, nodriver, Camoufox) exist specifically to defeat these checks, and they largely succeed against the cheap families. A silentframeworkfamily plus a livecdpfamily is itself the fingerprint of a patched setup. - Isolated worlds hide from hooks. Every
stackprobe depends on driver code running in the main world. Patched drivers evaluate in an isolated world and are invisible to them by construction. - DevTools and CDP drivers share most signals. The serialisation channels cannot
tell a developer with DevTools open from Puppeteer.
cdp.debuggerPauseis the disambiguator; the attribution list shows both with their separate confidences. - Behavioural probes need interaction. They stay silent otherwise, by design.
envsignals are corroboration, not evidence. Software rendering, sparse fonts and empty device lists are all genuine on real machines — VMs, remote desktops, hardened Linux desktops, GPU blocklists. That is why they are weighted the way they are.- This is an arms race with a maintenance bill. Two of the strongest signals in the field changed status between 2025 and 2026. Anything here can be patched out by the next Chrome release or the next stealth build.
- It cannot see the network layer. TLS/JA4 fingerprints, HTTP/2 SETTINGS ordering,
header ordering and
sec-ch-uaconsistency are strong, complementary signals that JavaScript simply cannot reach. Serious deployments should combine both.
- No browser-crashing payloads. Known crash-the-driver techniques exist; crashing a visitor's browser is not a detector's job.
- No network requests. Nothing is uploaded, no beacon, no third-party call. The entire scan is local.
- No device fingerprinting for identity. Canvas/audio/WebGL hashing to identify which device this is, rather than whether it is automated, is a different product with different consent implications.
The CSP-bypass probe intentionally attempts one policy violation. On a page with a
strict CSP you will see one blocked-resource error in the console — that error is
the probe reporting that enforcement is intact. Disable it with
new Detector({ exclude: ['env.cspBypass'] }).
npm install
npm run build # dist/mickeyjs.js, .min.js, .esm.js
npm test # engine, scoring, registry and catalogue-completeness tests
npm run serve # build + serve the demo on http://localhost:8099The test suite verifies structural correctness rather than browser behaviour: every probe is well-formed, the scoring model behaves under adversarial inputs, a throwing probe cannot break a scan, a hanging probe is timed out, and SIGNALS.md documents exactly the set of signals the probes emit — in both directions, so the catalogue cannot silently drift.
Verified end-to-end against a real Chrome 151 driven over CDP, where it correctly
returns automated, attributes Puppeteer from a pptr: stack frame, and runs all 31
probes in ~415 ms.
This builds directly on published research, and each probe carries a ref to its
source:
- rebrowser/rebrowser-bot-detector
- kaliiiiiiiiii/brotector
- Kaliiiiiiiiii-Vinyzu/CDP-Patches
- Castle — "Why a classic CDP bot detection signal suddenly stopped working"
- arXiv:2607.26935 — minimal feature sets for behavioural agent detection
MIT © MickeyAlton33
{ "version": "1.0.0", "score": 99.5, "verdict": "automated", // automated | likely-automated | suspicious | inconclusive | clean "summary": "Browser is under automation control.", "definitive": ["framework.playwright.globals"], "attribution": [ { "actor": "playwright", "label": "Playwright", "confidence": 100, "signals": [...] } ], "signals": [ { "id": "cdp.errorPreview", "family": "cdp", "label": "Error preview read Error.prototype.name (post-guard CDP channel)", "confidence": "strong", "weight": 0.85, "actors": ["cdp", "devtools"], "evidence": { "stackGetterReads": 0, "nameGetterReads": 14, "chromeMajor": 151, "channel": "name" }, "note": "An inspector serialised a logged Error and read its `name` accessor…", "ref": "V8 commits 2025-05-07 / 2025-05-09", "at": 128.4, "observations": 3 } ], "families": { "cdp": {...}, "input": {...}, ... }, "probes": [ { "id": "realm.worker", "status": "ok", "ms": 42, "fired": 0 } ], "environment": { "userAgent": "...", "chromeMajor": 151, ... }, "interaction": { "moveEvents": 240, "clicks": 3, "rawUpdates": 180 } }