liarjs.dev
Open source · MIT

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 liarjs

Why 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.3

A 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:

idwhat it catches−pts
webdrivernavigator.webdriver is set40
native-integritycore APIs are not genuine [native code], spoofing done in JS35
headless-uaHeadlessChrome token in the User-Agent30
gpu-triadWebGL GPU ↔ WebGPU adapter.info identity22
canvas-roundtriptoDataURL and getImageData disagree on the same canvas20
webgl-readbackreadPixels and toDataURL disagree on the same frame20
webgpu-archGPUAdapter.info.architecture names a different GPU family than WebGL20
canvas-lieidentical canvas draws read back differently (per-call noise)18
font-metric-clonea proprietary family and its free metric clone measure identically18
audio-lietwo identical OfflineAudioContext renders produce different samples18
worker-consistencya Worker reports different UA / languages / hardware / timezone / GPU20
uach-verUA-CH fullVersionList ≠ the version in the UA string15
cpu-perf-tiernavigator.cpuPerformance exists on a Chrome that never shipped it15
plugins-verplugin / mimeType face vs the claimed Chrome version15
perm-notifNotification.permission ≠ permissions.query() state15
tz-offsetIntl timezone implies a different offset than getTimezoneOffset()15
domrect-liegetBoundingClientRect unstable across reads12
langs-emptynavigator.languages is empty10
gpu-ageGPU too old to be real for a current Chrome (MAX_TEXTURE_SIZE)10
webgpu-emptyWebGPU supported but adapter.info is blank10
heap-platformperformance.memory heap ceiling ≠ the claimed OS reports8
voice-localespeech-synthesis voice language ≠ locale (leaks host OS language)8
cjk-fontsCJK fonts installed on a non-CJK locale (leaks host region)6
colordepthscreen.colorDepth not 24 or 306
lang-baseChromium: a region tag with no bare primary subtag6
storage-quotaStorageManager quota below 1 GB4
conn-rttnavigator.connection.rtt === 03

Nine compare the JavaScript story against what the wire actually carried, and these are the ones a browser cannot answer about itself:

idwhat it catches−pts
ua-http-jsUser-Agent header ≠ navigator.userAgent25
cf-botthe edge already classifies the connection as a known bot25
platformSec-CH-UA-Platform ≠ navigator.platform15
uach-ua-stringSec-CH-UA-Platform ≠ the OS token in the UA string15
tzIP-derived timezone ≠ browser timezone12
webrtc-ipWebRTC-exposed public IP ≠ the IP the connection came from10
langAccept-Language ≠ navigator.languages[0]8
http-protoa current browser that negotiated HTTP/1.16
tls-vera current browser that negotiated TLS < 1.36

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 all

2 · 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:9222

3 · 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 scanning

Launching 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

packagewhat it is
liarjsthe CLI and the driver: launch or attach, scan, render, exit code, diff
@liarjs/collectthe 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/checksthe 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

v0.1.1 · MIT · npm · source & issues