@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,152 @@
1
+ # The challenge
2
+
3
+ A proof-of-work interstitial: what it buys, what it does not, and how to wire it up.
4
+
5
+ ← [Documentation](../index.md)
6
+
7
+ ---
8
+
9
+ ## What it actually buys, stated plainly
10
+
11
+ The client must find a counter such that `SHA-256(nonce + ":" + counter)` begins with
12
+ `difficulty` zero bits. Verification is one hash; solving takes on average `2^difficulty`
13
+ of them.
14
+
15
+ **It does not identify anyone and it does not prove a human is present.** A headless
16
+ Chrome solves it as readily as a person's phone, just paying for the CPU.
17
+
18
+ What it does is convert a scrape from free into merely cheap, and change the *shape* of the
19
+ attack: a stateless scraper pulling a million pages must now run a JavaScript engine and
20
+ burn CPU on every one. That is often enough to make bulk extraction not worth doing, and it
21
+ costs a real visitor a fraction of a second, once.
22
+
23
+ This is why the challenge sits where it does in the [action ladder](../policy/actions.md):
24
+ it is the heaviest thing the [guard](../concepts/the-guard.md) will let a *probabilistic*
25
+ verdict ask for, precisely because a client that is wrongly suspected can pass it on its
26
+ own and carry on.
27
+
28
+ ---
29
+
30
+ ## Turning it on
31
+
32
+ One thing is required and has no default:
33
+
34
+ ```ts
35
+ import { BotHandler } from "@osqd/bothandlerjs";
36
+
37
+ const detector = new BotHandler({
38
+ preset: "protect-content",
39
+ challenge: {
40
+ secrets: [process.env.BOT_CHALLENGE_SECRET!],
41
+ contactHtml: '<p>Locked out? Email <a href="mailto:support@example.com">support@example.com</a>.</p>',
42
+ },
43
+ });
44
+ ```
45
+
46
+ A library-supplied fallback secret is a library-supplied forgery key, and it would end up
47
+ in production somewhere. Without `secrets`, a rule asking for a challenge degrades to a
48
+ `tag` and says so through [`onWarning`](../operations/notifications.md).
49
+
50
+ The [adapters](../integration/adapters.md) serve the verification endpoint for you. Nothing
51
+ else to mount.
52
+
53
+ ### Rotating secrets
54
+
55
+ The first secret signs; all of them verify. Prepend a new one and keep the old for a token
56
+ lifetime, and nobody is logged out:
57
+
58
+ ```ts
59
+ challenge: { secrets: [NEW_SECRET, PREVIOUS_SECRET] }
60
+ ```
61
+
62
+ ---
63
+
64
+ ## Every option
65
+
66
+ | Option | Default | Notes |
67
+ | ------ | ------- | ----- |
68
+ | `secrets` | — | required; first signs, all verify |
69
+ | `difficulty` | `16` | leading zero bits; see below |
70
+ | `challengeTtlMs` | `120_000` | how long a challenge may be solved for |
71
+ | `clearanceTtlMs` | `3_600_000` | how long a granted clearance lasts |
72
+ | `verifyPath` | `/__bothandler/verify` | where the solution is POSTed |
73
+ | `cookieName` | `__bh_clearance` | |
74
+ | `cookieSecure` | `true` | set `false` only for local plaintext development |
75
+ | `cookieSameSite` | `"Lax"` | |
76
+ | `title`, `message` | English defaults | page copy |
77
+ | `contactHtml` | — | **supply something real** |
78
+ | `translations` | — | see [localisation](localisation.md) |
79
+
80
+ ### Difficulty
81
+
82
+ In *bits*, so each step doubles the work. The default 16 is around 65k hashes — tens of
83
+ milliseconds in any modern browser. It is refused above 24.
84
+
85
+ Past about 20 you are charging real people a visible delay, and the oldest and slowest
86
+ devices — which disproportionately belong to the users least able to replace them — pay
87
+ the most. Raise it during an incident, not as a posture.
88
+
89
+ ### `contactHtml`
90
+
91
+ Everyone who sees the no-JavaScript fallback is a person your site just turned away: no
92
+ JavaScript, no WebCrypto, or a device too slow to finish. Put a support address, a phone
93
+ number or a link to a form there. This is the single highest-value line of configuration on
94
+ this page.
95
+
96
+ ---
97
+
98
+ ## What a clearance proves
99
+
100
+ Solving grants a signed cookie, which the `clearance` [detector](../detection/detectors.md)
101
+ reads on subsequent requests. It carries a **level**, and the level is what a rule should
102
+ key on:
103
+
104
+ | Level | What was demonstrated | Certainty |
105
+ | ----- | --------------------- | --------- |
106
+ | `pow` | ran JavaScript, has WebCrypto, spent measurable CPU | not conclusive |
107
+ | `interaction` | a trusted input event was observed | stronger, still forgeable by a driven browser |
108
+ | `operator` | **your application** asserted this is a person | `certain` |
109
+
110
+ Only `operator` is treated as conclusive, because that assertion comes from you rather than
111
+ from the client. See [`isHuman`](../reference/configuration.md) for how to make one.
112
+
113
+ ---
114
+
115
+ ## The lifecycle, and the one piece of state
116
+
117
+ Deliberately stateless up to the moment of success. A challenge is a signed blob the client
118
+ carries; the server stores nothing while it is being solved, so a flood of unsolved
119
+ challenges costs nothing but the bytes to send them.
120
+
121
+ Exactly one thing is written, at the one moment it is indispensable: the solved challenge's
122
+ nonce is **claimed atomically**, so a solution cannot be replayed.
123
+
124
+ That claim lives in the [store](../integration/stores.md). With the default in-memory store
125
+ and several replicas, a scraper retries a solved nonce against other instances until one
126
+ has not seen it. Pass a `RedisStore` if you run more than one process.
127
+
128
+ ## Two refusals worth knowing
129
+
130
+ **Challenging an actor that already holds valid clearance** is refused and warned about.
131
+ Passing a challenge cannot change a proven verdict, so re-issuing would loop for ever.
132
+
133
+ **Challenging on an API** is a mistake the [`protect-api` preset](../policy/presets.md)
134
+ deliberately avoids: an API client is not a browser, so a challenge breaks your customers'
135
+ integrations while an attacker solves it once in headless Chrome. Rate-limit instead.
136
+
137
+ ## Unsolved challenges as evidence
138
+
139
+ Every issued-and-never-solved challenge is counted on the [actor](../concepts/actors.md).
140
+ A client that has been asked five times and never once finished has told you something,
141
+ and rules can read it:
142
+
143
+ ```ts
144
+ { id: "persistent-refusal", match: { minUnsolvedChallenges: 5 }, action: "block" }
145
+ ```
146
+
147
+ ## Related
148
+
149
+ - [Localisation](localisation.md) — showing the page in a language the visitor reads
150
+ - [Actions](../policy/actions.md) — where `challenge` sits on the ladder
151
+ - [The guard](../concepts/the-guard.md) — why it is the ceiling for unproven verdicts
152
+ - [Stores](../integration/stores.md) — why replay protection needs a shared one
@@ -0,0 +1,105 @@
1
+ # Challenge localisation
2
+
3
+ Showing the one public-facing page in a language the visitor can read.
4
+
5
+ ← [Documentation](../index.md) · [The challenge](index.md)
6
+
7
+ ---
8
+
9
+ ## Why this exists
10
+
11
+ The interstitial is the only page this library shows to a member of the public, and it is
12
+ shown because a *probabilistic* verdict went against them.
13
+
14
+ Serving "Checking your browser" in English to somebody whose browser has been asking for
15
+ Japanese since the first request is the same unfairness [the guard](../concepts/the-guard.md)
16
+ exists to prevent, applied to the one screen where it is most visible: **a person who
17
+ cannot read the page cannot find the contact link on it either.** That turns a check into a
18
+ wall.
19
+
20
+ ## The library ships no translations, and will not
21
+
22
+ A machine-translated apology on a page that just turned somebody away is worse than an
23
+ honest English one — and only you know which languages your audience actually reads. What
24
+ this does is pick between the translations *you* supply.
25
+
26
+ ```ts
27
+ new BotHandler({
28
+ challenge: {
29
+ secrets: [process.env.BOT_CHALLENGE_SECRET!],
30
+ contactHtml: '<p>Locked out? <a href="/support">Contact us</a>.</p>',
31
+ translations: {
32
+ ja: {
33
+ title: "ブラウザーを確認しています",
34
+ message: "数秒で完了します。",
35
+ contactHtml: '<p>お困りですか?<a href="/support">サポート</a>へご連絡ください。</p>',
36
+ },
37
+ de: { title: "Browser wird überprüft" },
38
+ "pt-BR": { title: "Verificando seu navegador" },
39
+ },
40
+ },
41
+ });
42
+ ```
43
+
44
+ Anything omitted from a translation falls back to the default text, so a `title`-only entry
45
+ is a perfectly reasonable first step.
46
+
47
+ ## How a language is chosen
48
+
49
+ `Accept-Language` is parsed, `q` honoured, malformed entries dropped rather than fatal —
50
+ this is a client-supplied header and the page it decides is one somebody is already having
51
+ a bad time with. `*` is dropped too: it means "anything", which is what the default is for.
52
+ Ordering is stable within a `q` value, so a header listing two equally-weighted languages
53
+ means the first one.
54
+
55
+ Then, in order:
56
+
57
+ 1. **Exact tag.** `pt-BR` matches a `pt-BR` key.
58
+ 2. **Primary subtag.** `pt-BR` matches a `pt` key — European Portuguese is far better than
59
+ English for a Brazilian visitor.
60
+ 3. **Nothing.** The default English copy.
61
+
62
+ ## Why `pt` never silently becomes `pt-BR`
63
+
64
+ Matching stops after the primary subtag. A visitor asking for `pt-PT` will **not** be handed
65
+ `pt-BR`.
66
+
67
+ That looks unhelpful until you consider the case it protects: serving Simplified Chinese to
68
+ somebody who asked for Traditional is a worse failure than serving English, and no rule can
69
+ tell the two situations apart from the header alone. Whether one regional variant stands in
70
+ for another is a judgement about *your* audience, so it is made by which keys you write
71
+ rather than by a heuristic here.
72
+
73
+ **Key by the primary tag** — `pt`, `zh`, `de` — unless you genuinely have separate regional
74
+ copy.
75
+
76
+ ## The `lang` attribute
77
+
78
+ Each translation may set `lang`, defaulting to the key it is filed under.
79
+
80
+ It matters more than it looks. A screen reader picks its voice and its pronunciation rules
81
+ from this attribute, so Japanese text announced as `lang="en"` is read aloud by an English
82
+ voice and is unintelligible. Getting the copy right and the attribute wrong helps nobody.
83
+
84
+ ```ts
85
+ translations: {
86
+ "zh-Hant": { lang: "zh-Hant", title: "正在檢查您的瀏覽器" },
87
+ }
88
+ ```
89
+
90
+ ## Testing it
91
+
92
+ `parseAcceptLanguage` and `pickTranslation` are pure and exported, so the choice can be
93
+ tested by calling it:
94
+
95
+ ```ts
96
+ import { parseAcceptLanguage, pickTranslation } from "@osqd/bothandlerjs";
97
+
98
+ pickTranslation(translations, parseAcceptLanguage("pt-BR,pt;q=0.9,en;q=0.5"));
99
+ // → { tag: "pt-BR", copy: { title: "Verificando seu navegador" } }
100
+ ```
101
+
102
+ ## Related
103
+
104
+ - [The challenge](index.md) — what the page is for
105
+ - [Configuration](../reference/configuration.md) — the rest of `challenge`
@@ -0,0 +1,113 @@
1
+ # Actors and behavioural memory
2
+
3
+ Who "the same client" is, what is remembered about them, and for how long.
4
+
5
+ ← [Documentation](../index.md)
6
+
7
+ ---
8
+
9
+ Several detectors reason about behaviour over time — rate, rhythm, breadth, rotation —
10
+ and all of them need an answer to one question first: *are these two requests the same
11
+ client?*
12
+
13
+ ## The actor key
14
+
15
+ ```ts
16
+ actorKey: (facts) => facts.ip // the default
17
+ ```
18
+
19
+ Every behavioural signal in this library is only as good as this function. The default is
20
+ the client address, which is the only thing always available and is also the weakest: a
21
+ corporate NAT, a mobile carrier's CGNAT pool, a university and a VPN exit all present
22
+ hundreds of real people as one actor.
23
+
24
+ If you can do better, do:
25
+
26
+ ```ts
27
+ actorKey: (facts) => sessionIdFrom(facts) ?? facts.ip,
28
+ ```
29
+
30
+ A session id, an authenticated user id, or an address plus a TLS fingerprint all make the
31
+ same detectors sharper — sharp enough that
32
+ [`identity-rotation`](../detection/detectors.md#identity-rotation) becomes worth enabling,
33
+ which it is not on a shared address.
34
+
35
+ ## What is remembered
36
+
37
+ ```ts
38
+ const snapshot = assessment.actor;
39
+ ```
40
+
41
+ | Field | |
42
+ | ----- | --- |
43
+ | `key` | Whatever `actorKey` returned |
44
+ | `requests` | Requests in the window |
45
+ | `distinctPaths` | Breadth of the crawl |
46
+ | `firstSeen` / `lastSeen` | Epoch ms |
47
+ | `sinceLastMs` | Gap since the previous request |
48
+ | `priorConfirmations` | Times this actor was **proven** a bot before now |
49
+ | `unsolvedChallenges` | Challenges issued that no solution came back for |
50
+ | `cleared` | Holds a currently-valid human clearance |
51
+
52
+ `priorConfirmations` is a snapshot taken *before* this request's own outcome is recorded,
53
+ so a rule reading `minPriorConfirmations: 1` does not match on an actor's very first
54
+ request.
55
+
56
+ ## Everything here is bounded
57
+
58
+ Every key in this subsystem is attacker-chosen, so nothing may grow without limit:
59
+
60
+ - An actor's arrival history is a **fixed-size ring** of timestamps.
61
+ - Its path set is **capped**, and the cap is visible (`pathsSaturated`) so a detector can
62
+ tell "wide" from "we stopped counting".
63
+ - The registry itself is a **bounded LRU** with a TTL: `maxActors` (default 20,000) and
64
+ `actorWindowMs` (default 15 minutes).
65
+
66
+ ```ts
67
+ actorWindowMs: 900_000,
68
+ maxActors: 20_000,
69
+ ```
70
+
71
+ An actor still sending traffic is never the one evicted to make room, and eviction is
72
+ plain forgetting: the next request from that client is assessed as a first request.
73
+
74
+ ## It is process-local, deliberately
75
+
76
+ Rates, cadence and path breadth live in memory rather than in a shared store. A round trip
77
+ per request would buy accuracy for signals that are only ever allowed to *raise suspicion*
78
+ — never to deny anybody — and would put a network dependency on the hot path of every
79
+ request to your site.
80
+
81
+ Behind four replicas each one sees a quarter of an actor's traffic and is correspondingly
82
+ less sure. That is the right trade for something that cannot close a door on its own.
83
+
84
+ **A confirmation is not one of those**, and it can travel:
85
+
86
+ ```ts
87
+ new BotHandler({ store: new RedisStore(redis), shareConfirmations: true });
88
+ ```
89
+
90
+ `confirmed-bot` is a *proven* verdict — a fact about the client rather than a judgement
91
+ about it — and without sharing it a client proven to be a bot on one replica is a stranger
92
+ to the other seven. Proof travels; suspicion stays home. See
93
+ [Shared state](../integration/stores.md).
94
+
95
+ ## Acting on one actor by hand
96
+
97
+ ```ts
98
+ detector.forgetActor("203.0.113.9"); // discard its history
99
+ detector.clearActor("203.0.113.9", 3_600_000); // grant human clearance for an hour
100
+ detector.registry.top(50, Date.now()); // who is here, busiest first
101
+ ```
102
+
103
+ `forgetActor` is the cure for a false positive that has stuck: a person whose actor key
104
+ collected a `confirmed-bot` carries `priorConfirmations` for the rest of the window, and
105
+ before this existed the only remedy was clearing every actor's history to fix one.
106
+
107
+ These are also on the [dashboard](../operations/dashboard.md), behind a control flag.
108
+
109
+ ## Related
110
+
111
+ - [The detectors](../detection/detectors.md) — which ones read this state
112
+ - [Shared state](../integration/stores.md) — what crosses replicas
113
+ - [Getting the client address right](../integration/client-ip.md) — the input to all of it
@@ -0,0 +1,133 @@
1
+ # Evidence and certainty
2
+
3
+ The distinction the whole library rests on: **proof versus suspicion**.
4
+
5
+ ← [Documentation](../index.md)
6
+
7
+ ---
8
+
9
+ Every detector produces *evidence*, never a verdict. A piece of evidence carries a
10
+ `certainty` tier, and there are two kinds of tier with two different sets of rules.
11
+
12
+ | Tier | Weight | What it means |
13
+ | ---- | ------ | ------------- |
14
+ | `certain` | — | **Proof.** There is no benign explanation, and the detector has to write down why. |
15
+ | `strong` | 0.60 | A signal with a real population of exceptions. |
16
+ | `moderate` | 0.35 | Suggestive. |
17
+ | `weak` | 0.15 | Worth a point, not worth a sentence. |
18
+
19
+ `certain` is not "very strong". It is a different kind of claim, and it is the only kind
20
+ that can cost somebody their access.
21
+
22
+ ## What makes something certain
23
+
24
+ A `certain` piece of evidence must carry a `deterministicBasis` — a written explanation
25
+ of why no legitimate client produces this. Not "no legitimate client we know of": none,
26
+ by construction.
27
+
28
+ ```ts
29
+ {
30
+ detector: "self-identified",
31
+ certainty: "certain",
32
+ summary: 'User-Agent identifies curl',
33
+ deterministicBasis:
34
+ 'The product token "curl/" is emitted by an HTTP library or an automation ' +
35
+ "runtime and by no browser. Nothing a person does in a browser produces it.",
36
+ }
37
+ ```
38
+
39
+ There are only a few genuine sources of proof, and they are all of the same shape —
40
+ **the client told us, or the client did something only automation does**:
41
+
42
+ - **A self-declaration.** `python-requests/2.31.0` in a User-Agent is not an inference.
43
+ If it is a lie, the misclassification belongs to whoever lied.
44
+ - **A refuted identity.** Something claimed to be Googlebot; the operator's own DNS says
45
+ the address is not Google's. The claim is disproven by the party entitled to answer.
46
+ - **A trap.** A path no link points at, a form field no rendered browser displays.
47
+ Reaching it requires reading the page as data rather than as a page.
48
+ - **A protocol violation.** Two `Host` headers. `Connection: keep-alive` on HTTP/2.
49
+ Something a compliant client cannot emit.
50
+ - **An operator assertion.** Your code said this is a person. We believe you.
51
+
52
+ Everything else is a judgement. A missing `Accept-Language` is *suspicious* and belongs
53
+ to a real population: privacy browsers, corporate proxies, screen readers, old phones.
54
+
55
+ ## How the two tiers combine
56
+
57
+ Differently, on purpose.
58
+
59
+ **Proof does not accumulate.** One `certain` piece is enough, and a second adds nothing:
60
+ the verdict is already `confirmed-bot` or `verified-bot`, the score is reported as 100
61
+ for anything that charts one, and no amount of probabilistic evidence can produce the
62
+ same outcome. Certainty short-circuits scoring entirely.
63
+
64
+ **Suspicion accumulates by noisy-OR**, not by addition:
65
+
66
+ ```
67
+ pBot = 1 − Π (1 − weight)
68
+ ```
69
+
70
+ Three `moderate` signals reach 0.72, not 1.05. The function saturates, which is the
71
+ point: adding a fourth weak signal to three strong ones should barely move a number that
72
+ is already near certainty, and adding twenty weak ones must never *reach* it.
73
+
74
+ ```ts
75
+ import { combineEvidence, noisyOr } from "@osqd/bothandlerjs";
76
+
77
+ noisyOr([0.6, 0.35, 0.35]); // 0.831 — not 1.3
78
+ ```
79
+
80
+ ### Families
81
+
82
+ Signals that are one signal wearing different hats are counted once. A client missing
83
+ `Accept-Language`, `Accept-Encoding` and `Referer` has one property — it is not a
84
+ browser — reported three times, and letting each contribute independently would triple a
85
+ single observation.
86
+
87
+ ```ts
88
+ { detector: "header-integrity", family: "absent-browser-headers", certainty: "moderate" }
89
+ ```
90
+
91
+ Within a family only the strongest counts. It is a scoring correction and nothing more:
92
+ families never touch the proven path.
93
+
94
+ ### Human evidence subtracts
95
+
96
+ Almost every detector argues in one direction, which is a problem the scoring model has
97
+ to make visible. A person reading forty pages of documentation from a university's shared
98
+ address accumulates `rate-anomaly`, `cadence`, `crawl-breadth` and `ip-intelligence`
99
+ without a single thing being wrong with their request.
100
+
101
+ So evidence has a `direction`, and the engine discounts the bot score by whatever human
102
+ evidence it holds:
103
+
104
+ ```
105
+ score = pBot × (1 − pHuman)
106
+ ```
107
+
108
+ [`browsing-coherence`](../detection/detectors.md#browsing-coherence) exists entirely to
109
+ produce that counterweight — a cache validator, a cookie jar, a same-site navigation are
110
+ the marks of a browsing session, and a scraper has none of them.
111
+
112
+ ## Reading it back
113
+
114
+ ```ts
115
+ const assessment = await detector.assess(facts);
116
+
117
+ assessment.certain; // true ← this, not the score, gates terminal actions
118
+ assessment.verdict; // "confirmed-bot"
119
+ assessment.score; // 100 for proven; 0–99 for a judgement
120
+ assessment.evidence; // strongest first
121
+ assessment.humanEvidence; // what argued the other way
122
+ assessment.evidence[0].deterministicBasis; // why it cannot be wrong
123
+ ```
124
+
125
+ `strictEvidence` (on by default) makes the rule mechanical rather than cultural: a
126
+ detector returning `certain` with no `deterministicBasis` is rejected and reported
127
+ through `onWarning`. See [Writing a detector](../detection/writing-a-detector.md).
128
+
129
+ ## Related
130
+
131
+ - [Verdicts, classes and scores](verdicts.md) — what the engine does with all this
132
+ - [The safety guard](the-guard.md) — what stops a score from denying anybody
133
+ - [The detectors](../detection/detectors.md) — which tier each one can reach
@@ -0,0 +1,121 @@
1
+ # The safety guard
2
+
3
+ The mechanism that stops a guess from closing a door. If you read one page, read this one.
4
+
5
+ ← [Documentation](../index.md)
6
+
7
+ ---
8
+
9
+ Every bot detector eventually faces the same problem. The signals that catch sophisticated
10
+ automation — header consistency, TLS fingerprints, timing regularity, missing cookies —
11
+ are all *probabilistic*. Each one has a population of real people who trip it: somebody on
12
+ a privacy-hardened browser, behind a corporate proxy, using a screen reader, on a hotel
13
+ network, on a five-year-old phone.
14
+
15
+ The usual answer is to add the signals into a score and block above a threshold. That is
16
+ exactly the mistake. Points do not compose into proof. Two unrelated suspicions about an
17
+ unusual but entirely real browser reach 100 just as readily as two well-founded ones, and
18
+ the people who get caught are disproportionately the ones with the strongest reasons for
19
+ their unusual setup.
20
+
21
+ ## What the guard does
22
+
23
+ It runs **after** a rule has been selected and before its action is applied:
24
+
25
+ ```
26
+ rule matches → guard → decision
27
+ ```
28
+
29
+ Under the default `strict` mode there is exactly one question — *is there proof?* — and
30
+ everything else is a downgrade:
31
+
32
+ ```ts
33
+ if (!TERMINAL_ACTIONS.has(action)) return decision; // not terminal, nothing to check
34
+ if (mode === "aggressive") return decision; // the guard is off
35
+ if (assessment.certain) return decision; // proven; the rule stands
36
+ return downgrade(decision); // a guess. Not this.
37
+ ```
38
+
39
+ Terminal actions are `block`, `drop` and `redirect` — the three that end the request
40
+ without a way through. A downgraded decision becomes `fallbackAction` and records what it
41
+ was:
42
+
43
+ ```ts
44
+ {
45
+ action: "challenge",
46
+ downgradedFrom: "block",
47
+ downgradeReason:
48
+ "Strict mode permits a terminal action only on proven evidence. This request's " +
49
+ "verdict (suspected-bot, score 78) rests on probabilistic signals, any of which " +
50
+ "a real client can trip.",
51
+ }
52
+ ```
53
+
54
+ ## Why it lives here and not in your rules
55
+
56
+ It cannot be forgotten in a rule, cannot be bypassed by a cleverly-worded predicate, and
57
+ does not depend on whoever wrote the rules understanding the certainty model. A rule that
58
+ says `action: "block"` on a probabilistic match is not a bug to be caught in review — it
59
+ is a thing the engine will simply decline to do.
60
+
61
+ Turning that off is a single, explicit, greppable line.
62
+
63
+ ## The three modes
64
+
65
+ ```ts
66
+ falsePositivePolicy: "strict" // default
67
+ ```
68
+
69
+ | Mode | A terminal action survives when |
70
+ | ---- | ------------------------------- |
71
+ | `strict` | There is `certain` evidence. Nothing else. |
72
+ | `balanced` | There is proof, **or** the score clears `terminalScoreThreshold` with at least two *independent strong* signals. |
73
+ | `aggressive` | Always. The guard is off. |
74
+
75
+ `balanced` requires independence deliberately: three signals from the same family are one
76
+ observation, and `independentStrongSignals` counts families rather than pieces. Even so,
77
+ real people do trip two independent strong signals — a hardened browser behind a corporate
78
+ proxy is the usual pair — so `balanced` will eventually deny somebody who should have been
79
+ served. That is the trade it is; make it knowingly.
80
+
81
+ `aggressive` means every rule does exactly what it says, on proof or on suspicion alike.
82
+ The people it turns away first are the ones with the most unusual and most legitimate
83
+ setups.
84
+
85
+ ## The fallback
86
+
87
+ ```ts
88
+ fallbackAction: "challenge" // default when a challenge is configured, otherwise "tag"
89
+ ```
90
+
91
+ What a stopped rule becomes. **It cannot be terminal**, and that is enforced rather than
92
+ advised: a `block` fallback would make every downgrade deny the request the downgrade
93
+ existed to protect — and the decision would still be *recorded* as a guard stop, so the
94
+ metric that exists to catch this would report success.
95
+
96
+ ## Watching it
97
+
98
+ `bothandler_downgrades_total` is one of the two series worth alerting on. A rising count
99
+ means your rules are asking to deny requests the evidence does not support — the guard
100
+ working, and your policy needing attention.
101
+
102
+ The [dashboard](../operations/dashboard.md) breaks guard stops down by the rule that
103
+ overreached, which is usually enough to find the one at fault in a few seconds.
104
+
105
+ ## Changing it
106
+
107
+ The guard is fixed at construction unless something opts in:
108
+
109
+ ```ts
110
+ detector.updateGuard({ falsePositivePolicy: "balanced" }, { by: "ada@example.com" });
111
+ ```
112
+
113
+ See [Runtime changes](../operations/runtime-changes.md). The dashboard exposes this behind
114
+ a control flag separate from its rule editor, because "which rules exist" and "how far a
115
+ rule may go" are different powers.
116
+
117
+ ## Related
118
+
119
+ - [Evidence and certainty](evidence.md) — what "proof" means, precisely
120
+ - [Actions](../policy/actions.md) — which are terminal and which are not
121
+ - [Presets](../policy/presets.md) — every shipped policy keeps `strict`