Skip to content

Latest commit

 

History

History

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 

README.md

liarjs

Score a browser against itself. A browser controls its own JavaScript. It does not control the network it connects over. liarjs collects the JS fingerprint inside the browser, collects the TLS/HTTP/ASN view from the edge that served the request, and reports every place the two stories disagree.

npx liarjs                                 # your local Chrome
npx liarjs --headless --min-score 60       # CI gate
npx liarjs --cdp http://127.0.0.1:9222     # any browser with a debug port

Hosted version, no install: liarjs.dev.


Why

Fingerprint test pages tell you what you look like. They don't tell you whether you're coherent. A profile can present a pristine Chrome 150 in JavaScript and still negotiate a handshake no Chrome would send, from an IP whose timezone is twelve hours off. Patch one layer, the other gives you up.

liarjs is that second question, as a command you can run in CI:

  • 53 checks, 45 of which need nothing but the browser (--offline), 8 of which compare the JS story against what the wire actually carried.
  • 20 probes behind them: canvas, WebGL + WebGL2, WebGPU, audio, 230 fonts over three detection paths plus a fourth that measures widths instead of names, WebRTC, a cross-thread Web Worker comparison, [native code] verification of 26 APIs, and a census of which interfaces the build was compiled with.
  • Attaches to anything speaking CDP. Your Chrome, a Chromium fork you patched, a commercial anti-detect browser's debug port, a Playwright/Puppeteer page you already have.
  • Zero runtime dependencies. No puppeteer, no playwright, no ws, just Node ≥ 22 and its built-in WebSocket.
  • Machine-readable. --json to export, --post to ship results to your own backend, diff so a fingerprint regression shows up in a pull request instead of in your ban rate.

Example

  liarjs · browser consistency scan
  Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/…

   20 / 100  Unverified · driver unchecked
  fingerprint −20  ·  network −12  ·  automation −48

  ✗ navigator.webdriver −40
    webdriver=true, the automation flag is set.
    id: webdriver

  ✗ Worker ↔ main-thread consistency −20
    A Web Worker reported different values than the main thread for userAgent, canvasHash.
    Detectors re-read identity inside workers precisely because partial overrides only patch
    the main thread.
    id: worker-consistency

  ! 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

  23 checks · 2 critical · 2 warnings · 18 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 International CPC · LAS · HTTP/2 · TLSv1.3

Only the failures print by default; add --all for the full list.

What it checks

The score starts at 100 and each failing check deducts its weight, split across three axes (fingerprint, network, automation). The rating word needs one more thing: the top label You didn't fool me is only reachable after a behavioural challenge says a hand is on the pointer, and that challenge runs on liarjs.dev, not here. A CLI run therefore always prints Unverified · driver unchecked, however clean the browser is.

The same reason costs points, not just the word: every scan carries one pending item, driver-unverified, a warn worth 8 on the automation axis. It is not evidence of anything - it is the question this package cannot ask. So a spotless browser scores 92 here, never 100, and --min-score 92 is the strictest gate that can pass. Only the challenge on liarjs.dev returns those 8 points.

JS layer (runs anywhere, including --offline)

id check max −pts
webdriver navigator.webdriver is set 40
native-integrity core APIs are not genuine [native code] (JS-level spoofing) 35
headless-ua HeadlessChrome token in the UA 30
gpu-triad WebGL GPU ↔ WebGPU adapter.info identity 22
worker-consistency a Web Worker reports different identity than the main thread 20
canvas-lie identical canvas draws read back differently, or OffscreenCanvas disagrees 18
webgl-lie the same WebGL scene rendered twice reads back different pixels 18
webgl-pair WebGL and WebGL2 name different GPUs on one machine 15
uach-ver UA-CH fullVersionList ≠ the version in the UA string 15
plugins-ver plugin/mimeType face doesn't match the claimed Chrome version 15
perm-notif Notification.permissionpermissions.query() state 15
tz-offset Intl timezone implies a different offset than getTimezoneOffset() 15
os-fonts installed fonts describe a different OS than the UA claims 14
ua-mobile mobile hints contradict the UA or maxTouchPoints 12
domrect-lie getBoundingClientRect unstable across reads 12
chrome-object the UA claims Chrome but window.chrome is missing 12
window-noise outer is inner plus a few pixels of noise: a UI height no real title bar has 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 returned an adapter but adapter.info is blank 10
headless-viewport outerHeight === innerHeight in both directions: the window reports no browser UI at all (fullscreen, kiosk, old headless) 15
font-methods the measureText and layout font-detection paths disagree 10
audio-params DynamicsCompressor factory defaults are off spec 8
voice-locale speech-synthesis voice language ≠ locale (leaks host OS language) 8
touch-pointer maxTouchPoints contradicts (any-pointer: coarse) 8
codecs claims Chrome but cannot play H.264 (a plain Chromium build) 6
cjk-fonts CJK fonts installed on a non-CJK locale (leaks host region) 6
colordepth screen.colorDepth is neither 24 (8 bpc) nor 30 (10-bit HDR) 6
lang-base Chromium only: a region-qualified tag whose bare primary subtag is missing (zh-CN without zh) 6
tz-dst the reported January/July offsets don't match the zone's DST rule 6
storage-quota StorageManager quota below 1 GB 4
webrtc-mdns host ICE candidates expose raw local addresses instead of .local 4
conn-rtt navigator.connection.rtt === 0 3

Cross-layer (needs the network endpoint)

id check max −pts
ua-http-js User-Agent header ≠ navigator.userAgent 25
cf-bot the edge already classifies you as a known bot 25
platform Sec-CH-UA-Platformnavigator.platform 15
tz IP-derived timezone ≠ browser timezone 12
webrtc-ip WebRTC-exposed public IP ≠ the IP the connection came from 10
lang Accept-Languagenavigator.languages[0] 8
http-proto modern Chrome that negotiated HTTP/1.1 6
tls-ver modern Chrome that negotiated TLS < 1.3 6

With --offline these eight are skipped rather than failed, and the report says so explicitly.

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]        export the full result as JSON (stdout when no file given)
--post <url>         send the full result to your own backend (POST, JSON body)
--token <token>      auth token for --post, sent as X-Liarjs-Token
--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 uses the first Chrome/Chromium/Edge it finds; override with LIARJS_CHROME=/path/to/binary. Extra browser flags come from LIARJS_CHROME_ARGS; containers and CI generally need LIARJS_CHROME_ARGS="--no-sandbox --disable-dev-shm-usage".

Export mode and send mode

--json writes the result where you point it; --post ships the same object to an endpoint you own. They compose:

npx liarjs --json scan.json --post https://api.example.com/scans --token "$LIARJS_TOKEN"

The posted body is the full ScanResult: score, label, every check, the raw fingerprint and the raw edge view.

Run the probes on a real page

about:blank is not a secure context, so UA-CH, StorageManager and most Permissions names are unavailable there no matter which browser you point at. The report says so when it happens. For the complete picture, and whenever your endpoint has no CORS headers:

npx liarjs --page https://example.com

Point it at a browser you didn't launch

Anything that exposes a CDP endpoint works, including most anti-detect browsers, which expose a local debug port per profile:

# a profile started by an anti-detect browser's local API
npx liarjs --cdp http://127.0.0.1:54321

# a stealth-patched Chromium you built yourself
./chrome --remote-debugging-port=9222 &
npx liarjs --cdp http://127.0.0.1:9222

Use it as a library

Works with any object exposing evaluate(expression: string). 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);

Or drive the browser yourself:

import { scan, postResult } from 'liarjs';

const result = await scan({ cdp: 'http://127.0.0.1:9222', endpoint: 'https://my-worker.example/api/net.json' });
console.log(result.score, result.checks.filter((c) => c.status !== 'ok').map((c) => c.id));
await postResult('https://api.example.com/scans', result, { token: process.env.LIARJS_TOKEN });

ScanResult is { score, label, checks[], client, server, meta }, where client is the raw fingerprint, server the raw edge view and meta.schema the payload version (currently 4).

In CI

- run: npx liarjs --headless --json scan.json --min-score 60
- run: npx liarjs diff baseline.json scan.json     # what changed since the last known-good run

diff prints only the checks whose status moved, so a rebase that silently breaks WebGPU↔WebGL alignment is one line in the log instead of a mystery ban three weeks later.

Ignore checks that can't hold in your environment (datacenter IPs will always trip tz) by filtering result.checks in the library API, or by comparing against a baseline instead of an absolute floor.

The network half, and your data

--offline makes no outbound request at all.

Otherwise the browser under test fetches https://liarjs.dev/api/net.json, a normal request answered with what Cloudflare saw about that request (IP, ASN, colo, HTTP protocol, TLS version and ClientHello shape, headers). liarjs.dev does not store scans. If you'd rather not talk to it, point --endpoint at your own deployment: the endpoint is a ~60-line Cloudflare Worker, and the site that hosts it is open in the repo.

Packages

package what it is
liarjs this CLI + the driver/library API
@liarjs/collect the 19 browser-side probes, standalone, injectable, or embeddable on your own site
@liarjs/checks the 53 consistency rules as pure functions

The hosted scan at liarjs.dev runs the exact same packages, so a score there and a score here mean the same thing.

Limitations

  • A score is not a ban prediction. It measures internal coherence. Real sites also weigh proxy reputation, account age and behaviour, none of which this can see.
  • Cloudflare does not expose a raw JA3/JA4 on non-Enterprise plans, so the TLS checks use ClientHello length + extension/cipher hashes, not a full fingerprint string.
  • Checks drift with Chrome. Plugin faces, UA-CH shapes and GPU expectations change; rules are versioned with the package and reviewed per Chrome major.
  • Headless is detected on purpose. --headless will lose points on a stock browser. That's the correct answer, not a bug.

Positioning

This is a measurement and diagnostics tool: understand what a modern detector can see, and check your own footprint. It doesn't ship evasions, profiles or proxies.

MIT © liarjs.dev. Field notes on how each of these checks works: liarjs.dev/blog