The scan, as a command
This page scores the browser you're reading it in. The package scores a browser you're building (a patched Chromium, a stealth automation stack, an anti-detect profile) from your terminal, or from CI, before a silent regression turns into a ban rate.
npx liarjsWhy a package at all
A hosted scanner answers one question well: is the browser in front of me coherent right now? That is not the question that costs people money. The expensive one is regression: a rebase onto a new Chromium, a Chrome major that changes the plugin face, a patch that fixes canvas and quietly desyncs WebGPU from WebGL. Nobody notices by looking. They notice weeks later, in the ban rate.
So the checks are a package: a command with an exit code, a JSON payload, and a diff between two runs. Something you can put in front of a merge instead of in front of a human.
It is also the same code this site runs. The site imports the published packages like anyone else would, so a score here and a score in your pipeline mean the same thing: no “the website says 96 but the tool says 71”.
What one run does
It drives a browser over the Chrome DevTools Protocol (one it launches, or one you point it at), collects the fingerprint inside that browser, and has that same browser fetch the network-layer endpoint so the edge sees its real TLS handshake, its real ASN, its real HTTP version. Then it scores the contradictions.
liarjs · browser consistency scan
Mozilla/5.0 (Windows NT 10.0; Win64; x64) … HeadlessChrome/…
10 / 100 Unverified · driver unchecked
fingerprint −0 · network −12 · automation −78
✗ navigator.webdriver −40
webdriver=true, the automation flag is set.
id: webdriver
✗ Headless token in UA −30
User-Agent contains "HeadlessChrome".
id: headless-ua
! IP timezone ↔ browser timezone −12
IP resolves to America/Los_Angeles but the browser reports Asia/Shanghai
(proxy/VPN or spoofed timezone).
id: tz
! Driver challenge −8
Not taken yet. Everything else here describes the machine; none of it proves a hand
is on the pointer. The behavioural challenge on liarjs.dev answers that directly
- passing it returns these 8 points and is the only route to the top tier. Taking it
and moving too little costs nothing: the score just stays provisional.
id: driver-unverified
17 checks · 2 critical · 2 warnings · 12 clean
The behavioural layer (is a hand driving this browser?) runs only on liarjs.dev.
That is why this score is provisional: it says the environment is coherent, not that a person is here.
edge: 203.0.113.7 · AS4058 CITIC Telecom … · LAS · HTTP/2 · TLSv1.3A stock headless Chrome, scored honestly. Losing 70 points to webdriver and the headless token is the correct answer, not a bug. The driver-unverified row is there on every run, clean browsers included: the CLI cannot ask a hand to move, so those 8 points stay unearned and no terminal run reaches 100. The three axes are printed separately because they fail separately: this browser's fingerprint is fine, its network story is off by a timezone, and it is openly automated.
The 66 checks
Every check carries a weight; the score is 100 minus what fired, split across the three axes. The rating word is not a function of the score alone: the top one, You didn't fool me, has to be earned by passing the behavioural challenge, and that challenge runs only on liarjs.dev. So a CLI run always prints Unverified · driver unchecked, however clean the browser is. On the site the word becomes You didn't fool me at 85 and up, Half a face from 60 to 84, and Caught you below that or whenever the pointer input turns out to be generated. The unanswered question costs points too: driver-unverified holds back 8 until the challenge is passed, which is why even a spotless browser sits at 92 until it moves a pointer. Failing to produce enough movement costs nothing beyond that - the score simply stays provisional. Fifty-seven checks need nothing but the browser; a cross-section of them, heaviest first:
| id | what it catches | −pts |
|---|---|---|
webdriver | navigator.webdriver is set | 40 |
native-integrity | core APIs are not genuine [native code], spoofing done in JS | 35 |
headless-ua | HeadlessChrome token in the User-Agent | 30 |
gpu-triad | WebGL GPU ↔ WebGPU adapter.info identity | 22 |
canvas-roundtrip | toDataURL and getImageData disagree on the same canvas | 20 |
webgl-readback | readPixels and toDataURL disagree on the same frame | 20 |
webgpu-arch | GPUAdapter.info.architecture names a different GPU family than WebGL | 20 |
canvas-lie | identical canvas draws read back differently (per-call noise) | 18 |
font-metric-clone | a proprietary family and its free metric clone measure identically | 18 |
audio-lie | two identical OfflineAudioContext renders produce different samples | 18 |
worker-consistency | a Worker reports different UA / languages / hardware / timezone / GPU | 20 |
uach-ver | UA-CH fullVersionList ≠ the version in the UA string | 15 |
cpu-perf-tier | navigator.cpuPerformance exists on a Chrome that never shipped it | 15 |
plugins-ver | plugin / mimeType face vs the claimed Chrome version | 15 |
perm-notif | Notification.permission ≠ permissions.query() state | 15 |
tz-offset | Intl timezone implies a different offset than getTimezoneOffset() | 15 |
domrect-lie | getBoundingClientRect unstable across reads | 12 |
langs-empty | navigator.languages is empty | 10 |
gpu-age | GPU too old to be real for a current Chrome (MAX_TEXTURE_SIZE) | 10 |
webgpu-empty | WebGPU supported but adapter.info is blank | 10 |
heap-platform | performance.memory heap ceiling ≠ the claimed OS reports | 8 |
voice-locale | speech-synthesis voice language ≠ locale (leaks host OS language) | 8 |
cjk-fonts | CJK fonts installed on a non-CJK locale (leaks host region) | 6 |
colordepth | screen.colorDepth not 24 or 30 | 6 |
lang-base | Chromium: a region tag with no bare primary subtag | 6 |
storage-quota | StorageManager quota below 1 GB | 4 |
conn-rtt | navigator.connection.rtt === 0 | 3 |
Nine compare the JavaScript story against what the wire actually carried, and these are the ones a browser cannot answer about itself:
| id | what it catches | −pts |
|---|---|---|
ua-http-js | User-Agent header ≠ navigator.userAgent | 25 |
cf-bot | the edge already classifies the connection as a known bot | 25 |
platform | Sec-CH-UA-Platform ≠ navigator.platform | 15 |
uach-ua-string | Sec-CH-UA-Platform ≠ the OS token in the UA string | 15 |
tz | IP-derived timezone ≠ browser timezone | 12 |
webrtc-ip | WebRTC-exposed public IP ≠ the IP the connection came from | 10 |
lang | Accept-Language ≠ navigator.languages[0] | 8 |
http-proto | a current browser that negotiated HTTP/1.1 | 6 |
tls-ver | a current browser that negotiated TLS < 1.3 | 6 |
With --offline those nine are skipped, not failed. You get an honest JS-layer score and the report says which checks sat out, no nine free false positives to explain away.
Four ways to run it
1 · The browser on this machine
npx liarjs # finds your Chrome, opens a tab, scores it
npx liarjs --all # also list the checks that passed
npx liarjs --offline # JS layer only, no outbound request at all2 · A browser you didn't launch
Anything exposing a CDP endpoint works, which includes most anti-detect browsers (they open a debug port per profile) and any Chromium fork you built yourself.
# a profile started by an anti-detect browser's local API
npx liarjs --cdp http://127.0.0.1:54321
# your own patched build
./chrome --remote-debugging-port=9222 &
npx liarjs --cdp http://127.0.0.1:92223 · Inside your test suite
Works with any object exposing evaluate(expression). Playwright and Puppeteer pages both qualify.
import { checkPage } from 'liarjs';
const result = await checkPage(page);
expect(result.score).toBeGreaterThanOrEqual(85);
const critical = result.checks.filter((c) => c.status === 'bad');
expect(critical, JSON.stringify(critical, null, 2)).toHaveLength(0);4 · As a gate, not a report
npx liarjs --headless --json scan.json --min-score 60
npx liarjs diff baseline.json scan.json--min-score exits 1 below the floor. diff prints only the checks whose status moved between two runs, so “the rebase broke the GPU triad” is one line in the build log instead of a mystery three weeks later. In environments where some checks can never hold (a datacenter IP will always trip tz), compare against a baseline rather than an absolute floor.
Options
--cdp <endpoint> attach to a running browser (http://127.0.0.1:9222 or ws://…)
--page <url> run the probes on this page instead of about:blank
--endpoint <url> network-layer endpoint (default https://liarjs.dev/api/net.json)
--offline JS-layer checks only, no outbound request
--headless launch headless (headless tells will be reported)
--json [file] write the full result as JSON (stdout when no file given)
--min-score <n> exit 1 when the score is below n
--all also print the checks that passed
--timeout <ms> per-command timeout, default 30000
--keep-open leave the tab open after scanningLaunching picks the first Chrome / Chromium / Edge it finds; override with LIARJS_CHROME=/path/to/binary, and pass extra browser flags with LIARJS_CHROME_ARGS (containers and CI usually want --no-sandbox --disable-dev-shm-usage). Node 22 or newer, and no runtime dependencies: no Puppeteer, no Playwright, not even a WebSocket library.
Three packages
| package | what it is |
|---|---|
liarjs | the CLI and the driver: launch or attach, scan, render, exit code, diff |
@liarjs/collect | the browser-side probes on their own: one call, one JSON fingerprint, zero dependencies. Also ships as an injectable bundle for pages you drive yourself |
@liarjs/checks | the rules as pure functions: computeVerdict(client, server?). Bring your own collector or your own edge and keep the scoring |
The network half, and your data
--offline makes no outbound request. Otherwise the browser under test fetches /api/net.json on this domain, an ordinary request, answered with what Cloudflare saw about that request: IP, ASN, edge colo, HTTP protocol, TLS version and ClientHello shape, and the headers as they arrived. Scans are not stored (see the privacy policy).
If you'd rather not talk to this domain at all, point --endpoint at your own deployment: the endpoint is a small Cloudflare Worker and the whole site is open source.
What it is not
- Not a ban predictor. It measures internal coherence. Real sites also weigh proxy reputation, account age and behaviour, none of which any fingerprint tool can see. A perfect score on a burned IP still gets you blocked.
- Not a JA3/JA4 fingerprint. Cloudflare doesn't expose a raw JA3 on non-Enterprise plans, so the TLS checks use ClientHello length plus extension and cipher hashes, enough to catch an implausible handshake, not a full signature.
- Not frozen. Plugin faces, UA-CH shapes and GPU expectations move with Chrome. Rules are versioned with the package and reviewed per Chrome major; pin a version if you need a stable baseline.
- Not an evasion kit. No profiles, no proxies, no bypasses: it tells you what you look like, and the reasoning behind every check is written up in the field notes.
Get it
npx liarjs # no install
npm i -D liarjs # in a project
npm i @liarjs/collect @liarjs/checks # just the pieces