@osqd/bothandlerjs 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/CHANGELOG.md +516 -0
  2. package/LICENSE +102 -0
  3. package/README.md +308 -0
  4. package/bin/bothandlerjs.mjs +14 -0
  5. package/dist/actions/index.d.ts +22 -0
  6. package/dist/actions/types.d.ts +59 -0
  7. package/dist/adapters/fastify.d.ts +43 -0
  8. package/dist/adapters/fetch.d.ts +51 -0
  9. package/dist/adapters/index.cjs +723 -0
  10. package/dist/adapters/index.cjs.map +1 -0
  11. package/dist/adapters/index.d.ts +8 -0
  12. package/dist/adapters/index.js +692 -0
  13. package/dist/adapters/index.js.map +1 -0
  14. package/dist/adapters/koa.d.ts +27 -0
  15. package/dist/adapters/node.d.ts +29 -0
  16. package/dist/adapters/shared.d.ts +39 -0
  17. package/dist/audit.d.ts +172 -0
  18. package/dist/challenge/index.d.ts +176 -0
  19. package/dist/challenge/language.d.ts +57 -0
  20. package/dist/challenge/page.d.ts +42 -0
  21. package/dist/challenge/pow.d.ts +33 -0
  22. package/dist/challenge/token.d.ts +72 -0
  23. package/dist/cli.cjs +13388 -0
  24. package/dist/cli.cjs.map +1 -0
  25. package/dist/cli.d.ts +5 -0
  26. package/dist/cli.js +13358 -0
  27. package/dist/cli.js.map +1 -0
  28. package/dist/client/index.cjs +139 -0
  29. package/dist/client/index.cjs.map +1 -0
  30. package/dist/client/index.d.ts +63 -0
  31. package/dist/client/index.js +112 -0
  32. package/dist/client/index.js.map +1 -0
  33. package/dist/config.d.ts +316 -0
  34. package/dist/core.d.ts +443 -0
  35. package/dist/corpus/adversarial.d.ts +2 -0
  36. package/dist/corpus/advertising-email.d.ts +2 -0
  37. package/dist/corpus/ai-crawlers.d.ts +2 -0
  38. package/dist/corpus/benign-bots.d.ts +2 -0
  39. package/dist/corpus/cdn-gateways.d.ts +2 -0
  40. package/dist/corpus/cookies.d.ts +49 -0
  41. package/dist/corpus/crawlers-regional.d.ts +2 -0
  42. package/dist/corpus/crawlers-vertical.d.ts +2 -0
  43. package/dist/corpus/headers.d.ts +403 -0
  44. package/dist/corpus/humans-apps.d.ts +2 -0
  45. package/dist/corpus/humans-browsers.d.ts +2 -0
  46. package/dist/corpus/humans.d.ts +2 -0
  47. package/dist/corpus/index.cjs +4196 -0
  48. package/dist/corpus/index.cjs.map +1 -0
  49. package/dist/corpus/index.d.ts +37 -0
  50. package/dist/corpus/index.js +4132 -0
  51. package/dist/corpus/index.js.map +1 -0
  52. package/dist/corpus/infrastructure.d.ts +17 -0
  53. package/dist/corpus/libraries-extended.d.ts +2 -0
  54. package/dist/corpus/ranges.d.ts +31 -0
  55. package/dist/corpus/reputation.d.ts +21 -0
  56. package/dist/corpus/runner.d.ts +130 -0
  57. package/dist/corpus/schema.d.ts +185 -0
  58. package/dist/corpus/tooling.d.ts +2 -0
  59. package/dist/corpus/unwanted.d.ts +2 -0
  60. package/dist/crawler-ranges.d.ts +113 -0
  61. package/dist/dashboard/client.generated.d.ts +7 -0
  62. package/dist/dashboard/feed.d.ts +186 -0
  63. package/dist/dashboard/index.d.ts +5 -0
  64. package/dist/dashboard/page.d.ts +51 -0
  65. package/dist/dashboard/parse-request.d.ts +28 -0
  66. package/dist/dashboard/preview.d.ts +45 -0
  67. package/dist/dashboard/server.d.ts +70 -0
  68. package/dist/dashboard/types.d.ts +714 -0
  69. package/dist/detectors/accept-signature.d.ts +6 -0
  70. package/dist/detectors/browsing-coherence.d.ts +44 -0
  71. package/dist/detectors/cadence.d.ts +28 -0
  72. package/dist/detectors/clearance.d.ts +25 -0
  73. package/dist/detectors/client-hints.d.ts +19 -0
  74. package/dist/detectors/client-signals.d.ts +27 -0
  75. package/dist/detectors/crawl-breadth.d.ts +28 -0
  76. package/dist/detectors/crawler-verification.d.ts +39 -0
  77. package/dist/detectors/fetch-metadata.d.ts +20 -0
  78. package/dist/detectors/header-integrity.d.ts +26 -0
  79. package/dist/detectors/header-order.d.ts +27 -0
  80. package/dist/detectors/identity-rotation.d.ts +34 -0
  81. package/dist/detectors/index.d.ts +56 -0
  82. package/dist/detectors/ip-intelligence.d.ts +30 -0
  83. package/dist/detectors/known-bots.d.ts +94 -0
  84. package/dist/detectors/probe-signature.d.ts +50 -0
  85. package/dist/detectors/rate-anomaly.d.ts +36 -0
  86. package/dist/detectors/self-identified.d.ts +33 -0
  87. package/dist/detectors/session-integrity.d.ts +24 -0
  88. package/dist/detectors/tls-fingerprint.d.ts +34 -0
  89. package/dist/detectors/trap.d.ts +98 -0
  90. package/dist/detectors/types.d.ts +70 -0
  91. package/dist/detectors/ua-coherence.d.ts +32 -0
  92. package/dist/evidence.d.ts +45 -0
  93. package/dist/facts.d.ts +46 -0
  94. package/dist/index.cjs +9600 -0
  95. package/dist/index.cjs.map +1 -0
  96. package/dist/index.d.ts +63 -0
  97. package/dist/index.js +9444 -0
  98. package/dist/index.js.map +1 -0
  99. package/dist/internal/async.d.ts +16 -0
  100. package/dist/internal/clock.d.ts +17 -0
  101. package/dist/internal/crypto.d.ts +26 -0
  102. package/dist/internal/dns.d.ts +100 -0
  103. package/dist/internal/emitter.d.ts +18 -0
  104. package/dist/internal/http.d.ts +22 -0
  105. package/dist/internal/ip.d.ts +73 -0
  106. package/dist/internal/lru.d.ts +34 -0
  107. package/dist/internal/matcher.d.ts +41 -0
  108. package/dist/internal/pattern.d.ts +14 -0
  109. package/dist/internal/ua.d.ts +92 -0
  110. package/dist/metrics.d.ts +120 -0
  111. package/dist/notify/hub.d.ts +70 -0
  112. package/dist/notify/index.d.ts +7 -0
  113. package/dist/notify/redact.d.ts +48 -0
  114. package/dist/notify/sinks.d.ts +62 -0
  115. package/dist/notify/types.d.ts +56 -0
  116. package/dist/policy/index.d.ts +7 -0
  117. package/dist/policy/match.d.ts +20 -0
  118. package/dist/policy/policy.d.ts +123 -0
  119. package/dist/policy/presets.d.ts +161 -0
  120. package/dist/policy/types.d.ts +155 -0
  121. package/dist/robots.d.ts +83 -0
  122. package/dist/state.d.ts +161 -0
  123. package/dist/stores/index.d.ts +5 -0
  124. package/dist/stores/memory.d.ts +33 -0
  125. package/dist/stores/redis.d.ts +59 -0
  126. package/dist/stores/types.d.ts +37 -0
  127. package/dist/types.d.ts +250 -0
  128. package/docs/challenge/index.md +152 -0
  129. package/docs/challenge/localisation.md +105 -0
  130. package/docs/concepts/actors.md +113 -0
  131. package/docs/concepts/evidence.md +133 -0
  132. package/docs/concepts/the-guard.md +121 -0
  133. package/docs/concepts/threat-model.md +120 -0
  134. package/docs/concepts/verdicts.md +108 -0
  135. package/docs/course/01-first-assessment.md +178 -0
  136. package/docs/course/02-proof-and-suspicion.md +164 -0
  137. package/docs/course/03-verdicts-and-scores.md +160 -0
  138. package/docs/course/04-the-guard.md +200 -0
  139. package/docs/course/05-detectors.md +178 -0
  140. package/docs/course/06-identity.md +161 -0
  141. package/docs/course/07-actors.md +178 -0
  142. package/docs/course/08-traps.md +183 -0
  143. package/docs/course/09-rules.md +224 -0
  144. package/docs/course/10-actions-and-presets.md +216 -0
  145. package/docs/course/11-the-challenge.md +227 -0
  146. package/docs/course/12-going-live.md +211 -0
  147. package/docs/course/13-operating-it.md +238 -0
  148. package/docs/course/14-scaling.md +195 -0
  149. package/docs/course/15-extending.md +259 -0
  150. package/docs/course/16-proving-it.md +266 -0
  151. package/docs/course/index.md +112 -0
  152. package/docs/design/decisions.md +216 -0
  153. package/docs/detection/client-signals.md +96 -0
  154. package/docs/detection/detectors.md +376 -0
  155. package/docs/detection/index.md +134 -0
  156. package/docs/detection/signatures.md +111 -0
  157. package/docs/detection/verification.md +123 -0
  158. package/docs/detection/writing-a-detector.md +145 -0
  159. package/docs/index.md +100 -0
  160. package/docs/integration/adapters.md +138 -0
  161. package/docs/integration/client-ip.md +114 -0
  162. package/docs/integration/index.md +55 -0
  163. package/docs/integration/stores.md +76 -0
  164. package/docs/operations/audit.md +116 -0
  165. package/docs/operations/dashboard.md +725 -0
  166. package/docs/operations/index.md +101 -0
  167. package/docs/operations/metrics.md +74 -0
  168. package/docs/operations/notifications.md +72 -0
  169. package/docs/operations/runtime-changes.md +118 -0
  170. package/docs/policy/actions.md +196 -0
  171. package/docs/policy/index.md +84 -0
  172. package/docs/policy/presets.md +191 -0
  173. package/docs/policy/robots.md +128 -0
  174. package/docs/policy/rules.md +134 -0
  175. package/docs/reference/api.md +126 -0
  176. package/docs/reference/configuration.md +175 -0
  177. package/docs/start/choosing-a-policy.md +127 -0
  178. package/docs/start/first-integration.md +127 -0
  179. package/docs/start/installation.md +63 -0
  180. package/docs/start/upgrading.md +72 -0
  181. package/docs/testing/cli.md +119 -0
  182. package/docs/testing/corpus.md +129 -0
  183. package/docs/testing/index.md +61 -0
  184. package/docs/testing/replay.md +102 -0
  185. package/docs/testing/try-it.md +189 -0
  186. package/package.json +145 -0
@@ -0,0 +1,120 @@
1
+ # Threat model
2
+
3
+ What this library stops, what it raises the cost of, and what it cannot touch.
4
+
5
+ ← [Documentation](../index.md)
6
+
7
+ ---
8
+
9
+ Most bot tooling is vague about this, which is how people end up trusting it for things it
10
+ cannot do. This page is the honest version, and every claim on it is
11
+ [measured](../testing/corpus.md) rather than asserted.
12
+
13
+ ---
14
+
15
+ ## The populations
16
+
17
+ | Who | What they send | What happens |
18
+ | --- | -------------- | ------------ |
19
+ | **Honest automation** | `curl/8.4.0`, `python-requests`, `Googlebot` | proven by [self-declaration](evidence.md); you decide by policy |
20
+ | **Verifiable crawlers** | a named identity backed by DNS or published ranges | [proven either way](../detection/verification.md) — confirmed or refuted |
21
+ | **Careless scrapers** | a copied User-Agent and nothing else | caught by four independent signals |
22
+ | **Competent scrapers** | a copied header set, in the right order, with Client Hints | not caught from one request; caught by [behaviour](../detection/detectors.md) if they are quick |
23
+ | **Patient adversaries** | real Chrome, residential proxies, human pace | **not caught, and this library says so** |
24
+ | **Scanners** | `/.env`, `/.git/config`, JNDI, `TRACE` | [`probe-signature`](../detection/detectors.md), one request at a time |
25
+ | **People who look odd** | Tor, corporate proxies, screen readers, old phones | suspected, never denied — this is what [the guard](the-guard.md) is for |
26
+
27
+ The last two rows are the same problem seen from both ends, and the reason the whole design
28
+ is organised around proof rather than points.
29
+
30
+ ## The evasion ladder
31
+
32
+ Five cases in the corpus, running from crude to undetectable. **The top rungs are expected
33
+ to fail**, and they are kept so that nobody can claim otherwise:
34
+
35
+ | | | |
36
+ |-|-|-|
37
+ | 1 | Copied the User-Agent only | caught, four independent signals |
38
+ | 2 | Copied the header set, not the order | caught, weakly |
39
+ | 3 | Copied the order and the Client Hints | **not caught** from one request |
40
+ | 4 | …at a machine-perfect rhythm | caught by `cadence`, and only by `cadence` |
41
+ | 5 | …paced like a person, a few pages per address | **not caught at all** |
42
+
43
+ At level 5 the difference from a person has stopped being technical. What defeats it is
44
+ **cost** — a [proof of work](../challenge/index.md), or an account — not detection.
45
+
46
+ ---
47
+
48
+ ## What this library cannot do
49
+
50
+ **Stop a determined, well-resourced adversary.** Someone running real Chrome through a
51
+ residential proxy pool, at human pace, with correct headers, solving the proof of work, is
52
+ indistinguishable from a person at the HTTP layer. What this raises is the *cost*.
53
+
54
+ **Prove somebody is human.** No signal here does that and none claims to. Proof of work
55
+ proves CPU. `navigator.webdriver` proves what the client chose to report. The only
56
+ conclusive human signal is [your own application's assertion](../reference/configuration.md).
57
+
58
+ **Replace authentication, authorisation or a WAF.** It classifies traffic. It is not a
59
+ security boundary and nothing about it should be load-bearing for access control. This is
60
+ the most important sentence on the page.
61
+
62
+ **Stop a DDoS.** It runs inside your process, after the connection is accepted. Volume that
63
+ hurts you at the network layer needs handling at the network layer.
64
+
65
+ **Ship IP intelligence.** Address-to-operator mappings go stale within weeks, and a stale
66
+ mapping is a false positive with a long half-life. Bring your own, from a source you refresh
67
+ and can audit.
68
+
69
+ **Be right about a shared address.** Behind CGNAT, "one [actor](actors.md)" is thousands of
70
+ people — which is exactly why the behavioural signals are capped where they are.
71
+
72
+ **Escalate on a wordlist walk.** `probe-signature` reads one request at a time, so a scanner
73
+ working through five hundred paths produces five hundred separate observations rather than a
74
+ mounting case. That is the price of a detector that runs unchanged over a log file;
75
+ enumeration over time is what `rate-anomaly`, `cadence` and `crawl-breadth` are for.
76
+
77
+ ---
78
+
79
+ ## The adversary's view of the library itself
80
+
81
+ Worth thinking about, because a detector that explains itself to the client is a detector
82
+ being tuned against.
83
+
84
+ **Verdict headers are off by default.** An `X-Bot-Score` in the response is a live feedback
85
+ signal: change one header, watch the number fall, iterate. Request-side tagging tells your
86
+ application the same thing and the client nothing.
87
+
88
+ **Metrics and the dashboard describe your detection.** The detector-firing series is exactly
89
+ what somebody tuning a scraper would like to read. Serve them where only you can reach them,
90
+ and use [the dashboard's redaction and sections](../operations/dashboard.md) when more people
91
+ need to watch than need to know.
92
+
93
+ **The challenge is public by design.** Its difficulty and its mechanism are visible to
94
+ everyone. That is fine — it is a cost, not a secret, and a cost that only works while hidden
95
+ is not a cost.
96
+
97
+ **Traps are the one thing to keep quiet about.** A trap works because no person can reach
98
+ it. Publishing the path in a public repository, a `robots.txt` `Allow` line, or a client-side
99
+ comment turns proof back into a guess.
100
+
101
+ ---
102
+
103
+ ## What it is genuinely good at
104
+
105
+ Being clear about the limits above is what makes this list credible:
106
+
107
+ - **Identifying honest automation exactly**, so you can decide about it by policy rather
108
+ than by suspicion — including the [AI crawlers, split by job](../policy/presets.md#decline-ai-training).
109
+ - **Refuting forged identities**, with proof rather than inference.
110
+ - **Making bulk extraction expensive** without touching anybody who is not doing it.
111
+ - **Never denying a person on a guess** — enforced by [the guard](the-guard.md), checked by
112
+ [the corpus](../testing/corpus.md), and visible as `bothandler_downgrades_total`.
113
+ - **Telling you what your policy would do**, [before it does it](../testing/index.md).
114
+
115
+ ## Related
116
+
117
+ - [Evidence](evidence.md) — proof versus suspicion, in detail
118
+ - [The guard](the-guard.md) — the mechanism the last claim rests on
119
+ - [The corpus](../testing/corpus.md) — where the ladder above lives
120
+ - [Detectors](../detection/detectors.md) — what each signal is actually worth
@@ -0,0 +1,108 @@
1
+ # Verdicts, classes and scores
2
+
3
+ What the engine concludes, and which field you should actually be reading.
4
+
5
+ ← [Documentation](../index.md)
6
+
7
+ ---
8
+
9
+ An assessment carries three different summaries of the same request. They answer
10
+ different questions and are not interchangeable.
11
+
12
+ ## `verdict` — what this is
13
+
14
+ ```ts
15
+ type Verdict = "confirmed-bot" | "verified-bot" | "suspected-bot" | "human" | "unknown";
16
+ ```
17
+
18
+ | Verdict | Proven? | Means |
19
+ | ------- | ------- | ----- |
20
+ | `confirmed-bot` | yes | Proven automation. It said so, walked into a trap, or violated a protocol. |
21
+ | `verified-bot` | yes | Proven automation **and** proven to be who it claims — a crawler that checked out. |
22
+ | `suspected-bot` | no | The score crossed `suspectThreshold`. A judgement call. |
23
+ | `human` | no | Human evidence outweighs bot evidence. Also a judgement. |
24
+ | `unknown` | no | Nothing conclusive either way. **This is what ordinary traffic looks like.** |
25
+
26
+ `unknown` being the common case is not a failure. Most requests carry no strong signal in
27
+ either direction, and a library that concluded something about all of them would be
28
+ guessing about most of them.
29
+
30
+ ## `certain` — whether it is proven
31
+
32
+ ```ts
33
+ if (assessment.certain) { /* there is at least one `certain` piece of evidence */ }
34
+ ```
35
+
36
+ **This is the field to read**, not the score. `certain` is what the
37
+ [guard](the-guard.md) consults before allowing a terminal action, and it is the only
38
+ thing that distinguishes "we know" from "we think".
39
+
40
+ A proven **human** sets `certain` too — an operator assertion or a granted clearance is
41
+ proof in the same sense. Check `verdict` alongside it when the direction matters.
42
+
43
+ ## `score` — how suspicious, when it is a judgement
44
+
45
+ An integer. `0–99` for a probabilistic verdict; a flat `100` for anything proven, for the
46
+ benefit of dashboards that chart one.
47
+
48
+ The score exists to be *compared*, not to be trusted absolutely. It is the output of a
49
+ saturating function over weighted evidence, so the difference between 40 and 60 is real
50
+ and the difference between 96 and 98 is noise.
51
+
52
+ ```ts
53
+ suspectThreshold: 60 // the score at which a verdict becomes `suspected-bot`
54
+ ```
55
+
56
+ Before you move that number, look at the score distribution on the
57
+ [dashboard](../operations/dashboard.md) or in `bothandler_score_bucket`: the question is
58
+ not "is 60 right in the abstract" but "how close does *my* ordinary traffic run to it".
59
+
60
+ ## `botClass` — what kind of thing this is
61
+
62
+ ```ts
63
+ type BotClass =
64
+ | "human" | "verified-bot" | "declared-bot" | "automation"
65
+ | "http-client" | "scanner" | "scraper" | "impersonator" | "unknown";
66
+ ```
67
+
68
+ This is the field policies usually want, because it carries intent in a way a verdict
69
+ does not. `verified-bot` and `impersonator` are both proven; one is Googlebot and the
70
+ other is something pretending to be it.
71
+
72
+ | Class | Typical example |
73
+ | ----- | --------------- |
74
+ | `verified-bot` | Googlebot, confirmed by reverse DNS or a published range |
75
+ | `declared-bot` | GPTBot, ClaudeBot — honest, unverified, and a business decision |
76
+ | `http-client` | curl, python-requests, Go-http-client |
77
+ | `automation` | Headless Chrome, Playwright, Selenium |
78
+ | `scraper` | Behaviourally a scraper: breadth, cadence, no session |
79
+ | `scanner` | sqlmap, Nikto, probes for `/wp-admin` and `.env` |
80
+ | `impersonator` | Claimed an identity that was refuted |
81
+ | `human` | Proven or strongly indicated to be a person |
82
+
83
+ ## `confidence`
84
+
85
+ `0–1`, the raw probability before the threshold is applied. Useful if you are charting
86
+ distributions or building your own thresholds; `score` is the same number scaled and
87
+ rounded.
88
+
89
+ ## Putting it together
90
+
91
+ ```ts
92
+ const { assessment, decision } = await detector.handle(facts);
93
+
94
+ // The two questions worth asking, in this order:
95
+ if (assessment.certain && assessment.botClass === "impersonator") {
96
+ // Proven forgery. Safe to refuse.
97
+ }
98
+ if (assessment.verdict === "suspected-bot") {
99
+ // A judgement. Challenge, tag, slow — never refuse on this alone,
100
+ // and the guard will stop you if a rule tries.
101
+ }
102
+ ```
103
+
104
+ ## Related
105
+
106
+ - [Evidence and certainty](evidence.md) — where all of this comes from
107
+ - [The safety guard](the-guard.md) — why `certain` matters more than `score`
108
+ - [Matching requests](../policy/rules.md) — matching rules on any of these fields
@@ -0,0 +1,178 @@
1
+ # Lesson 1 — Your first assessment
2
+
3
+ **Goal:** turn an HTTP request into a verdict, and understand every field that comes back.
4
+
5
+ ← [Course](index.md) · Next: [Proof and suspicion](02-proof-and-suspicion.md)
6
+
7
+ ---
8
+
9
+ ## The two calls
10
+
11
+ Everything in this library is downstream of two functions.
12
+
13
+ `createFacts` normalises a request into a `RequestFacts` — headers lowercased, path decoded
14
+ once and resolved, query into a null-prototype bag, everything length-bounded. `assess`
15
+ reads those facts and returns an `Assessment`.
16
+
17
+ Neither touches a response. That is why you can run `assess` over a log file, and why the
18
+ rest of this course can happen in a plain script with no server.
19
+
20
+ ## Do this
21
+
22
+ `serif/lesson-01.mjs`:
23
+
24
+ ```js
25
+ import { BotHandler, createFacts } from "@osqd/bothandlerjs";
26
+
27
+ const detector = new BotHandler();
28
+
29
+ const request = createFacts({
30
+ method: "GET",
31
+ url: "/books/1",
32
+ headers: { host: "serif.example", "user-agent": "curl/8.4.0", accept: "*/*" },
33
+ ip: "203.0.113.10",
34
+ });
35
+
36
+ const assessment = await detector.assess(request);
37
+
38
+ console.log("verdict ", assessment.verdict);
39
+ console.log("class ", assessment.botClass);
40
+ console.log("score ", assessment.score);
41
+ console.log("certain ", assessment.certain);
42
+ console.log("confidence", assessment.confidence);
43
+ for (const piece of assessment.evidence) {
44
+ console.log(` [${piece.certainty}] ${piece.detector}: ${piece.summary}`);
45
+ }
46
+ ```
47
+
48
+ ```bash
49
+ node lesson-01.mjs
50
+ ```
51
+
52
+ ### Checkpoint
53
+
54
+ ```
55
+ verdict confirmed-bot
56
+ class http-client
57
+ score 100
58
+ certain true
59
+ confidence 1
60
+ [certain] self-identified: User-Agent identifies curl
61
+ ```
62
+
63
+ If you got that, the library is installed and working.
64
+
65
+ ## What each field means
66
+
67
+ | Field | |
68
+ | ----- | - |
69
+ | `verdict` | the conclusion: `confirmed-bot`, `verified-bot`, `suspected-bot`, `human`, `unknown` |
70
+ | `botClass` | *what kind* of client: `http-client`, `scraper`, `scanner`, `impersonator`, `declared-bot`, `verified-bot`, `automation`, `human`, `unknown` |
71
+ | `score` | suspicion, 0–100, from probabilistic signals only |
72
+ | `certain` | whether at least one piece of **proof** fired. **This, not the score, is the important one** |
73
+ | `confidence` | how much to trust the verdict, 0–1; exactly 1 when `certain` |
74
+ | `evidence` | every bot-pointing observation, strongest first |
75
+ | `humanEvidence` | every person-pointing observation; these *subtract* |
76
+ | `actor` | who this client is and what has been seen from them — [lesson 7](07-actors.md) |
77
+ | `requestId` | random per request, safe to log |
78
+ | `durationMs` | time spent in detection |
79
+
80
+ Note what happened above: `curl` did not get a score of 100 because many signals agreed.
81
+ It got `certain: true` because the client **said** it was curl, and the score follows from
82
+ proof rather than the other way round.
83
+
84
+ ## Now try a real browser
85
+
86
+ Replace the headers with a full Chrome set — the Client Hints, the Fetch Metadata, the
87
+ negotiation headers:
88
+
89
+ ```js
90
+ const CHROME = {
91
+ host: "serif.example",
92
+ "user-agent":
93
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
94
+ accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
95
+ "accept-language": "en-GB,en;q=0.9",
96
+ "accept-encoding": "gzip, deflate, br",
97
+ "sec-ch-ua": '"Chromium";v="122", "Not(A:Brand";v="24", "Google Chrome";v="122"',
98
+ "sec-ch-ua-mobile": "?0",
99
+ "sec-ch-ua-platform": '"macOS"',
100
+ "sec-fetch-site": "none",
101
+ "sec-fetch-mode": "navigate",
102
+ "sec-fetch-dest": "document",
103
+ };
104
+ ```
105
+
106
+ Also print the human evidence:
107
+
108
+ ```js
109
+ for (const piece of assessment.humanEvidence) {
110
+ console.log(` (human) [${piece.certainty}] ${piece.detector}: ${piece.summary}`);
111
+ }
112
+ ```
113
+
114
+ ### Checkpoint
115
+
116
+ ```
117
+ verdict unknown
118
+ class unknown
119
+ score 0
120
+ certain false
121
+ confidence 1
122
+ (human) [weak] browsing-coherence: Fetch Metadata, Client Hints and negotiation headers are all present and mutually consistent
123
+ ```
124
+
125
+ **`unknown` is the correct answer for a person**, and it is the resting state of ordinary
126
+ traffic. The library does not claim to have proved a human — it has nothing conclusive, and
127
+ says so. Only your own application can assert that somebody is a person, which you will do
128
+ in [lesson 12](12-going-live.md).
129
+
130
+ ## Exercise
131
+
132
+ Take the Chrome headers and delete everything except `host`, `user-agent` and
133
+ `accept: */*` — a scraper that copied a User-Agent string and nothing else. Assess it.
134
+
135
+ <details>
136
+ <summary>Checkpoint</summary>
137
+
138
+ ```
139
+ verdict unknown
140
+ class unknown
141
+ score 45
142
+ certain false
143
+ confidence 0.553
144
+ [moderate] header-integrity: Client claims to be a browser but sent no Accept-Language header
145
+ [moderate] header-integrity: Client claims to be a browser but sent no Accept-Encoding header
146
+ [weak] accept-signature: Client claiming a browser sent Accept: */* with no Fetch Metadata to explain it
147
+ ```
148
+
149
+ Three signals, a score of 45, and still `unknown` — not enough to call it a bot, and
150
+ nowhere near enough to refuse it. `confidence` dropped to 0.553, which is the library
151
+ saying it is genuinely unsure.
152
+
153
+ Notice what it did **not** do: claim to be certain. A copied User-Agent is suspicious and
154
+ is not proof, and lesson 2 is about why that distinction is the whole design.
155
+ </details>
156
+
157
+ ## Common mistake
158
+
159
+ **Passing a forwarded address as `ip`.** `createFacts` wants the **socket** address; the
160
+ forwarded chain is resolved separately and carefully, because getting it wrong lets clients
161
+ pick their own identity. [Lesson 12](12-going-live.md) covers it, and it is the single
162
+ most consequential setting in the library.
163
+
164
+ ## What you learned
165
+
166
+ - `createFacts` + `assess` is the whole read path, and neither touches a response
167
+ - Five fields describe a client, and `certain` is the one that matters most
168
+ - `unknown` is what ordinary human traffic looks like
169
+ - Proof and suspicion arrive by different routes
170
+
171
+ ## Reference
172
+
173
+ - [How detection works](../detection/index.md) — the pipeline behind `assess`
174
+ - [Verdicts, classes and scores](../concepts/verdicts.md)
175
+ - [Configuration](../reference/configuration.md) — every constructor option
176
+
177
+ Next: [Proof and suspicion](02-proof-and-suspicion.md) — the idea the rest of the course
178
+ depends on.
@@ -0,0 +1,164 @@
1
+ # Lesson 2 — Proof and suspicion
2
+
3
+ **Goal:** understand the distinction the entire library is built on, and why every other
4
+ design decision follows from it.
5
+
6
+ ← [Course](index.md) · Prev: [First assessment](01-first-assessment.md) · Next: [Verdicts and scores](03-verdicts-and-scores.md)
7
+
8
+ ---
9
+
10
+ ## The problem every bot detector has
11
+
12
+ Every signal that catches sophisticated automation is *probabilistic*: header
13
+ consistency, timing regularity, missing cookies, TLS fingerprints. And every one of them
14
+ has a population of real people who trip it.
15
+
16
+ Someone on a privacy-hardened browser. Someone behind a corporate proxy that strips
17
+ headers. Someone using a screen reader. Someone on a hotel network, or a five-year-old
18
+ phone, or a carrier that transcodes pages.
19
+
20
+ The usual answer is to add the signals into a score and block above a threshold. **That is
21
+ the mistake this library exists to avoid.** Points do not compose into proof. Two unrelated
22
+ suspicions about an unusual but entirely real browser reach 100 as readily as two
23
+ well-founded ones — and the people who get caught are disproportionately the ones with the
24
+ strongest reasons for their unusual setup.
25
+
26
+ So evidence here lives in two compartments that never mix.
27
+
28
+ | | Deterministic (`certain`) | Probabilistic (`strong` / `moderate` / `weak`) |
29
+ | --- | --- | --- |
30
+ | Rests on | a declaration, a contradiction, a trap, an external authority | a pattern automation usually shows |
31
+ | Can it be wrong? | only if the client lied about itself | yes, about real people |
32
+ | How it combines | short-circuits to a verdict | noisy-OR into a score of 0–99 |
33
+ | Can it deny service? | **yes** | **no** |
34
+ | What it can still do | anything | tag, log, delay, rate-limit, challenge, alert |
35
+
36
+ ## See it
37
+
38
+ Print the basis of the curl evidence from lesson 1:
39
+
40
+ ```js
41
+ const assessment = await detector.assess(
42
+ createFacts({ method: "GET", url: "/", headers: { host: "serif.example", "user-agent": "curl/8.4.0" }, ip: "203.0.113.10" }),
43
+ );
44
+ console.log(assessment.evidence[0].deterministicBasis);
45
+ ```
46
+
47
+ ### Checkpoint
48
+
49
+ ```
50
+ The product token "curl/" is emitted by an HTTP library or an automation runtime and by
51
+ no browser. Nothing a person does in a browser produces it.
52
+ ```
53
+
54
+ **Every piece of `certain` evidence has to carry one of those sentences**, in writing, and
55
+ the library refuses evidence that claims certainty without one. It is a forcing function:
56
+ if you cannot write the sentence, your evidence is `strong`. When you write your own
57
+ detector in [lesson 15](15-extending.md) you will meet this rule from the other side.
58
+
59
+ ## The five things that earn `certain`
60
+
61
+ 1. **Self-declaration.** The client says it is a bot: `curl/8.4.0`, `python-requests`,
62
+ `Googlebot/2.1`, `HeadlessChrome` in a Client Hints brand list. Not an inference — you
63
+ are believing the client's own statement about itself, and no honest client is ever
64
+ harmed by being believed. If the statement is a lie, the misclassification is the
65
+ client's doing.
66
+ 2. **A refuted third-party identity.** It claimed to be Googlebot and DNS says otherwise.
67
+ Note how narrow that is: a privacy extension rewriting a User-Agent to a *generic*
68
+ browser string never lands here, because it never claims to be a named, verifiable
69
+ third party.
70
+ 3. **A confirmed third-party identity.** The same check passing. Used to *allow*.
71
+ 4. **A trap.** A link hidden from layout and from assistive technology and excluded in
72
+ `robots.txt`. Detection by construction rather than by inference — no sequence of user
73
+ input reaches it. [Lesson 8](08-traps.md).
74
+ 5. **A protocol violation.** Three of them, each a rule a recipient is *required* to
75
+ enforce: a connection-specific header on HTTP/2 ([RFC 9113 §8.2.2](https://www.rfc-editor.org/rfc/rfc9113#section-8.2.2)),
76
+ `Content-Length` beside `Transfer-Encoding` ([RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1)),
77
+ and a repeated `Host` ([RFC 9112 §3.2](https://www.rfc-editor.org/rfc/rfc9112#section-3.2)).
78
+
79
+ Plus one on the human side: **your own application's assertion** that a request belongs to
80
+ a person.
81
+
82
+ ## The rule that catches people out
83
+
84
+ > **No argument from absence may ever be `certain`.**
85
+
86
+ A header missing from your *facts* is not a header missing from the *request*. An HTTP/1.1
87
+ request with no `Host` violates RFC 9112 as plainly as anything above — and it is
88
+ deliberately not proven here, because somebody building facts from a log line, a WAF event
89
+ or a partial adapter would otherwise manufacture proof against every request in the file.
90
+
91
+ You will rely on this in [lesson 16](16-proving-it.md) when you replay your own access
92
+ logs, where most headers genuinely are missing from the record.
93
+
94
+ ## How suspicion adds up
95
+
96
+ Probabilistic evidence combines by **noisy-OR** — `1 − Π(1 − wᵢ)` — not by a sum. The
97
+ weights are `strong` 0.60, `moderate` 0.35, `weak` 0.15.
98
+
99
+ It is bounded without clamping, and it has the right shape: many weak signals do add up,
100
+ but asymptotically. **The probabilistic score is capped at 99**, because 100 means proof.
101
+
102
+ Human evidence subtracts: `score = pBot × (1 − pHuman)`.
103
+
104
+ ## One cause, counted once
105
+
106
+ Noisy-OR is only sound over *independent* signals, and several of these are not. A
107
+ corporate proxy that strips `Sec-Fetch-*` also strips the Client Hints and the
108
+ `Accept-Language` — so three detectors fire at once about one person behind one appliance,
109
+ and the arithmetic reads their agreement as corroboration when it is an echo.
110
+
111
+ Evidence may therefore declare a **`family`**: a shared root cause. Within a family the
112
+ engine takes the strongest observation instead of compounding.
113
+
114
+ You saw this in lesson 1's exercise without noticing. Print the families:
115
+
116
+ ```js
117
+ for (const piece of assessment.evidence) {
118
+ console.log(piece.certainty.padEnd(9), (piece.family ?? "—").padEnd(18), piece.summary);
119
+ }
120
+ ```
121
+
122
+ Run it against the copied-User-Agent request. The two `header-integrity` observations share
123
+ a family, so they count once. That scraper scores 45 rather than the ~60 it would reach if
124
+ its two absences were treated as independent — and the reason is that a real corporate
125
+ network produces exactly the same pair, every day.
126
+
127
+ Families are a scoring correction and nothing more. **They never touch the proven path.**
128
+
129
+ ## Exercise
130
+
131
+ Build a request that is proven a bot *and* carries a lot of suspicion, and one that carries
132
+ suspicion alone. Compare `certain`, `score` and `confidence` on each.
133
+
134
+ Then answer, without running anything: a client scores 97 from six independent
135
+ probabilistic signals. Under the default configuration, can a rule block it?
136
+
137
+ <details>
138
+ <summary>Answer</summary>
139
+
140
+ **No.** Not at 97, not at 99. The score is not what gates a terminal action — `certain` is.
141
+ Six probabilistic signals are six things a real person can trip, and the population that
142
+ trips six of them is disproportionately the population with the strongest reasons for an
143
+ unusual setup.
144
+
145
+ That is [lesson 4](04-the-guard.md), and it is enforced by a mechanism you cannot forget to
146
+ apply.
147
+ </details>
148
+
149
+ ## What you learned
150
+
151
+ - Probabilistic signals all have real people who trip them; that is why points never become
152
+ proof
153
+ - Exactly five things earn `certain`, and each must carry a written basis
154
+ - No argument from absence may ever be certain
155
+ - Noisy-OR bounds suspicion at 99; human evidence subtracts
156
+ - One root cause is counted once, so a stripped-header proxy is not three reasons
157
+
158
+ ## Reference
159
+
160
+ - [Evidence and certainty](../concepts/evidence.md) — the full model
161
+ - [Design decisions](../design/decisions.md) — this choice, and what it costs
162
+ - [Threat model](../concepts/threat-model.md) — who this catches and who it does not
163
+
164
+ Next: [Verdicts, classes and scores](03-verdicts-and-scores.md).