diff --git a/docs/app.js b/docs/app.js index e0e7551..91fc74d 100644 --- a/docs/app.js +++ b/docs/app.js @@ -5,32 +5,58 @@ import { } from "./browser.js"; const INSTANT_LABELS = { - isWebDriver: "navigator.webdriver", - isPhantomJS: "PhantomJS globals", - isNightmare: "Nightmare.js marker", - isSelenium: "Selenium document markers", - isDomAutomation: "DOM automation controller", - isHeadless: "Headless / webdriver", - isSuspiciousResolution: "Tiny screen resolution", - isUserAgentValid: "Valid Mozilla UA prefix", - isWebGLSupported: "WebGL available", - isModern: "Modern browser version", - isMissingChromeObject: "Missing chrome object (Chromium)", - isSoftwareRenderer: "Software WebGL renderer", - isSuspiciousWindowDimensions: "Suspicious window metrics", - isEmptyPlugins: "Empty plugins array", - isAutomationArtifacts: "Automation artifacts", - isSuspiciousWebDriverDescriptor: "Suspicious webdriver descriptor", + isWebDriver: "navigator.webdriver is true", + isPhantomJS: "PhantomJS globals on window", + isNightmare: "Nightmare.js marker present", + isSelenium: "Selenium markers on document", + isDomAutomation: "DOM automation controller globals", + isHeadless: "HeadlessChrome or webdriver in UA", + isSuspiciousResolution: "Screen smaller than 136×170", + isUserAgentValid: "User-Agent starts with Mozilla/5.0", + isWebGLSupported: "WebGL context available", + isModern: "Chrome 121+ / Firefox 128+ / Safari 16.4+", + isMissingChromeObject: "Chromium without chrome.runtime", + isSoftwareRenderer: "SwiftShader or llvmpipe renderer", + isSuspiciousWindowDimensions: "No browser chrome at screen origin", + isEmptyPlugins: "navigator.plugins length is zero", + isAutomationArtifacts: "Playwright / ChromeDriver artifacts", + isSuspiciousWebDriverDescriptor: "Patched webdriver property", isChromium: "Chromium-based browser", - isShaderF16Supported: "WebGPU shader-f16 (async)", - isLegitClient: "Overall: legit client", + isShaderF16Supported: "WebGPU shader-f16 support (async only)", + isLegitClient: "All instant checks passed", }; +const INSTANT_ORDER = [ + "isLegitClient", + "isWebDriver", + "isHeadless", + "isAutomationArtifacts", + "isSelenium", + "isPhantomJS", + "isNightmare", + "isDomAutomation", + "isSuspiciousWebDriverDescriptor", + "isMissingChromeObject", + "isEmptyPlugins", + "isSoftwareRenderer", + "isSuspiciousResolution", + "isSuspiciousWindowDimensions", + "isUserAgentValid", + "isWebGLSupported", + "isModern", + "isChromium", + "isShaderF16Supported", +]; + +let instantFilter = "all"; +let lastInstantResult = null; + const instantVerdict = document.getElementById("instant-verdict"); const instantLabel = document.getElementById("instant-label"); const instantStatus = document.getElementById("instant-status"); const instantBadge = document.getElementById("instant-badge"); const instantTable = document.getElementById("instant-table"); +const instantCount = document.getElementById("instant-count"); const behavioralVerdict = document.getElementById("behavioral-verdict"); const behavioralLabel = document.getElementById("behavioral-label"); @@ -40,92 +66,211 @@ const behavioralSignals = document.getElementById("behavioral-signals"); const scoreFill = document.getElementById("score-fill"); const scoreText = document.getElementById("score-text"); -function setVerdict(container, labelEl, badgeEl, isLegit, label) { - container.classList.toggle("legit", isLegit); - container.classList.toggle("suspicious", !isLegit); +const summaryInstantValue = document.getElementById("summary-instant-value"); +const summaryInstantMeta = document.getElementById("summary-instant-meta"); +const summaryBehavioralValue = document.getElementById("summary-behavioral-value"); +const summaryBehavioralMeta = document.getElementById("summary-behavioral-meta"); + +function isTriggeredFlag(key, value) { + if (key === "isLegitClient") { + return !value; + } + if (key === "isShaderF16Supported") { + return value === false; + } + return Boolean(value); +} + +function statusForFlag(key, value) { + if (key === "isLegitClient") { + return { label: value ? "Pass" : "Fail", tone: value ? "ok" : "bad" }; + } + if (key === "isShaderF16Supported") { + if (value === null) { + return { label: "N/A", tone: "neutral" }; + } + return { label: value ? "Supported" : "Missing", tone: value ? "ok" : "bad" }; + } + return value + ? { label: "Triggered", tone: "bad" } + : { label: "Clear", tone: "ok" }; +} + +function setVerdict(container, iconEl, labelEl, badgeEl, isLegit, label, pending = false) { + container.classList.remove("verdict--legit", "verdict--suspicious", "verdict--pending"); + if (pending) { + container.classList.add("verdict--pending"); + iconEl.textContent = "◌"; + badgeEl.textContent = "Pending"; + badgeEl.className = "pill pill--neutral"; + return; + } + + container.classList.add(isLegit ? "verdict--legit" : "verdict--suspicious"); + iconEl.textContent = isLegit ? "✓" : "!"; labelEl.textContent = label; badgeEl.textContent = isLegit ? "Legit" : "Suspicious"; - badgeEl.className = `badge ${isLegit ? "ok" : "bad"}`; + badgeEl.className = `pill ${isLegit ? "pill--ok" : "pill--bad"}`; +} + +function countInstantFlags(result) { + let triggered = 0; + let total = 0; + for (const key of INSTANT_ORDER) { + if (key === "isLegitClient" || !(key in result)) { + continue; + } + total += 1; + if (isTriggeredFlag(key, result[key])) { + triggered += 1; + } + } + return { triggered, total, clear: total - triggered }; } function renderInstantRows(result) { + lastInstantResult = result; instantTable.replaceChildren(); - for (const [key, value] of Object.entries(result)) { - if (!(key in INSTANT_LABELS)) { + + const counts = countInstantFlags(result); + instantCount.textContent = `${counts.triggered} triggered · ${counts.clear} clear · ${counts.total} checks`; + + let visibleRows = 0; + + for (const key of INSTANT_ORDER) { + if (!(key in result) || !(key in INSTANT_LABELS)) { continue; } - const row = document.createElement("tr"); - const name = document.createElement("td"); - const state = document.createElement("td"); - - name.innerHTML = `${key}
${INSTANT_LABELS[key]}`; - - const badge = document.createElement("span"); - if (key === "isLegitClient") { - badge.className = `badge ${value ? "ok" : "bad"}`; - badge.textContent = value ? "pass" : "fail"; - } else if (key === "isShaderF16Supported") { - badge.className = `badge ${value === null ? "neutral" : value ? "ok" : "bad"}`; - badge.textContent = value === null ? "n/a" : value ? "yes" : "no"; - } else { - badge.className = `badge ${value ? "bad" : "ok"}`; - badge.textContent = value ? "triggered" : "clear"; + const value = result[key]; + const triggered = isTriggeredFlag(key, value); + const show = + instantFilter === "all" || + (instantFilter === "triggered" && triggered) || + (instantFilter === "clear" && !triggered); + + if (!show) { + continue; } - state.appendChild(badge); - row.append(name, state); + visibleRows += 1; + + const row = document.createElement("tr"); + row.className = key === "isLegitClient" ? "is-summary" : triggered ? "is-triggered" : "is-clear"; + + const nameCell = document.createElement("td"); + nameCell.innerHTML = `${key}`; + + const descCell = document.createElement("td"); + descCell.innerHTML = `${INSTANT_LABELS[key]}`; + + const stateCell = document.createElement("td"); + const status = statusForFlag(key, value); + stateCell.innerHTML = `${status.label}`; + + row.append(nameCell, descCell, stateCell); + instantTable.appendChild(row); + } + + if (visibleRows === 0) { + const row = document.createElement("tr"); + row.className = "is-empty"; + row.innerHTML = + 'No flags match this filter. Try All or switch filters.'; instantTable.appendChild(row); } } +function updateInstantSummary(result) { + const counts = countInstantFlags(result); + summaryInstantValue.textContent = result.isLegitClient ? "Legit client" : "Suspicious"; + summaryInstantValue.style.color = result.isLegitClient ? "var(--ok)" : "var(--bad)"; + summaryInstantMeta.textContent = result.isLegitClient + ? "No instant flags triggered" + : `${counts.triggered} of ${counts.total} checks triggered`; +} + async function runInstant() { instantStatus.textContent = "Running detectInstantClient…"; const result = detectInstantClient(window); renderInstantRows(result); + updateInstantSummary(result); setVerdict( instantVerdict, + instantVerdict.querySelector(".verdict__icon"), instantLabel, instantBadge, result.isLegitClient, - result.isLegitClient ? "Looks like a legit browser" : "Instant checks flagged this client", + result.isLegitClient + ? "This browser passes instant checks" + : "One or more instant checks failed", ); - instantStatus.textContent = `UA: ${navigator.userAgent}`; + instantStatus.textContent = navigator.userAgent; } async function runAsync() { - instantStatus.textContent = "Running detectInstantClientAsync…"; + instantStatus.textContent = "Running detectInstantClientAsync (includes WebGPU)…"; const result = await detectInstantClientAsync(window); renderInstantRows(result); + updateInstantSummary(result); setVerdict( instantVerdict, + instantVerdict.querySelector(".verdict__icon"), instantLabel, instantBadge, result.isLegitClient, - result.isLegitClient ? "Looks like a legit browser" : "Instant checks flagged this client", + result.isLegitClient + ? "This browser passes instant + WebGPU checks" + : "Instant or WebGPU checks failed", ); - instantStatus.textContent = `shader-f16: ${result.isShaderF16Supported}`; + const shader = + result.isShaderF16Supported === null + ? "shader-f16: not checked in sync path" + : `shader-f16: ${result.isShaderF16Supported ? "supported" : "not supported"}`; + instantStatus.textContent = shader; } function renderBehavioral(result) { const percent = Math.round(result.suspicionScore * 100); scoreFill.style.width = `${percent}%`; - scoreText.textContent = `Score: ${result.suspicionScore.toFixed(3)} (threshold 0.55)`; + scoreText.textContent = result.suspicionScore.toFixed(3); setVerdict( behavioralVerdict, + behavioralVerdict.querySelector(".verdict__icon"), behavioralLabel, behavioralBadge, result.isLegitClient, - result.isLegitClient ? "Behavior looks human" : "Behavior looks automated", + result.isLegitClient ? "Behavior looks human-like" : "Behavior looks automated", ); - behavioralStatus.textContent = `Observed ${result.observationMs}ms · ${result.sampleCounts.mouseMoves} mouse moves`; + + const triggered = result.signals.filter((signal) => signal.triggered).length; + behavioralStatus.textContent = `Observed ${result.observationMs}ms · ${result.sampleCounts.mouseMoves} mouse moves · ${result.sampleCounts.clicks} clicks · ${triggered} signals triggered`; + + summaryBehavioralValue.textContent = result.isLegitClient ? "Legit" : "Suspicious"; + summaryBehavioralValue.style.color = result.isLegitClient ? "var(--ok)" : "var(--bad)"; + summaryBehavioralMeta.textContent = `Score ${result.suspicionScore.toFixed(3)} (threshold 0.55)`; behavioralSignals.replaceChildren(); + if (result.signals.length === 0) { + const empty = document.createElement("li"); + empty.className = "signal-card"; + empty.innerHTML = + '
No signals yetStart observation after interacting in the zone above.
Run observe'; + behavioralSignals.appendChild(empty); + return; + } + for (const signal of result.signals) { const item = document.createElement("li"); - item.className = signal.triggered ? "triggered" : ""; - item.innerHTML = `${signal.id}${signal.triggered ? "triggered" : "clear"}`; + item.className = `signal-card${signal.triggered ? " is-triggered" : ""}`; + item.innerHTML = ` +
+ ${signal.id} + ${signal.description} +
+ ${signal.triggered ? "Triggered" : "Clear"} + `; behavioralSignals.appendChild(item); } } @@ -133,7 +278,16 @@ function renderBehavioral(result) { async function startBehavioral() { const button = document.getElementById("start-behavioral"); button.disabled = true; - behavioralStatus.textContent = "Observing interaction for 3 seconds…"; + behavioralStatus.textContent = "Observing your interaction for 3 seconds…"; + setVerdict( + behavioralVerdict, + behavioralVerdict.querySelector(".verdict__icon"), + behavioralLabel, + behavioralBadge, + false, + "Observing…", + true, + ); try { const detector = createBehavioralClientDetector({ @@ -162,14 +316,47 @@ document.getElementById("inject-webdriver").addEventListener("click", () => { get: () => true, configurable: true, }); - instantStatus.textContent = "Injected navigator.webdriver = true"; + instantStatus.textContent = "Injected navigator.webdriver = true — re-running checks"; void runInstant(); }); document.getElementById("inject-playwright").addEventListener("click", () => { window.__playwright = { version: "demo" }; - instantStatus.textContent = "Injected window.__playwright"; + instantStatus.textContent = "Injected window.__playwright — re-running checks"; void runInstant(); }); +for (const chip of document.querySelectorAll(".chip")) { + chip.addEventListener("click", () => { + for (const other of document.querySelectorAll(".chip")) { + other.classList.remove("chip--active"); + } + chip.classList.add("chip--active"); + instantFilter = chip.dataset.filter ?? "all"; + if (lastInstantResult) { + renderInstantRows(lastInstantResult); + } + }); +} + +const installSnippet = document.querySelector(".install-snippet"); +const copyInstall = document.getElementById("copy-install"); +if (copyInstall && installSnippet) { + copyInstall.addEventListener("click", async () => { + const text = installSnippet.textContent?.trim() ?? ""; + try { + await navigator.clipboard.writeText(text); + copyInstall.textContent = "Copied!"; + setTimeout(() => { + copyInstall.textContent = "Copy"; + }, 1500); + } catch { + copyInstall.textContent = "Failed"; + setTimeout(() => { + copyInstall.textContent = "Copy"; + }, 1500); + } + }); +} + void runInstant(); diff --git a/docs/index.html b/docs/index.html index 68aa061..0beb39b 100644 --- a/docs/index.html +++ b/docs/index.html @@ -11,83 +11,165 @@ -
-
Live browser demo
-

detect-bot-client

-

- Run instant and behavioral client checks in your current browser. Move your mouse, - scroll, and type in the interaction zone to see how behavioral scoring reacts. -

+ + + -
-
-

Instant detection

-

Synchronous checks against window and navigator.

+
+
+
+ Instant + + Run on load +
+
+ Behavioral + + Not observed yet +
+
+ Install +
+ npm install detect-bot-client + +
+
+
-
+
+
+

Instant detection

+

+ Synchronous checks on window and navigator. Any triggered + flag fails isLegitClient. +

+
+
+ + +
+
+ +
+ +
Analyzing… -
Waiting for bundle
+

Loading detection bundle

- + Pending
-
- - +
+ Show + + + + 0 flags
- - - - - - - - -
FlagResult
+
+ + + + + + + + + +
FlagWhat it checksStatus
+
-
-

Behavioral detection

-

- Observes mouse, scroll, typing, and click patterns for ~3 seconds after you start. -

- -
+
+
+

Behavioral detection

+

+ Watches mouse, scroll, typing, and clicks for ~3 seconds. Robotic patterns raise the + suspicion score above the 0.55 threshold. +

+
+
+ +
+ +
Not started -
Interact below, then start observation
+

+ Interact in the zone below, then start observation +

- + Pending
- -
Score: —
+
+
+ Suspicion score + +
+ +

+ 0 = human-like + 0.55 = threshold + 1 = highly automated +

+
-

Move your mouse here, scroll this page, type below, and click the button.

- - +

Try it yourself

+
    +
  1. Move your mouse around this box
  2. +
  3. Scroll the page a little
  4. +
  5. Type in the field below
  6. +
  7. Click the button
  8. +
+ +
- - - + + +
-
    +

    Behavioral signals

    +
      -