@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
package/README.md ADDED
@@ -0,0 +1,308 @@
1
+ # 🤖 bothandlerjs
2
+
3
+ **Bot traffic detection and handling for TypeScript.** Identify automated traffic,
4
+ prove it where proof is possible, and respond the way you choose — tag it, slow it,
5
+ rate-limit it, challenge it, alert on it, or refuse it.
6
+
7
+ The design rests on one distinction that most bot tooling blurs: **proof versus
8
+ suspicion**. Evidence is tiered, the two tiers combine by different rules, and a guard
9
+ in the policy layer stops a probabilistic verdict from ever reaching a terminal
10
+ action. That is what this library means by *no false positives*, stated precisely:
11
+
12
+ > **Nothing is ever denied service on the strength of a guess.**
13
+
14
+ Guesses still do useful work — they tag, delay, rate-limit, challenge and alert. They
15
+ just cannot shut the door. Only deterministic evidence can, and every piece of
16
+ deterministic evidence has to explain in writing why it admits no benign explanation.
17
+
18
+ ```bash
19
+ npm install @osqd/bothandlerjs
20
+ ```
21
+
22
+ No runtime dependencies. The library imports nothing but `node:` builtins, and CI
23
+ fails if that ever stops being true — so nothing here can hand your project a
24
+ transitive advisory, an install script, or a version conflict with something you
25
+ already run. Redis, if you use it, is your client passed in: `RedisStore` describes
26
+ the five commands it needs structurally and imports neither `ioredis` nor
27
+ `node-redis`.
28
+
29
+ ---
30
+
31
+ ## Documentation
32
+
33
+ The README is the argument and the shortest path to a working integration. Everything
34
+ else lives in **[`docs/`](docs/index.md)** — thirty pages, one per question, each
35
+ explaining why a thing exists as well as how to use it.
36
+
37
+ | | |
38
+ | --- | --- |
39
+ | **[The course](docs/course/index.md)** | Sixteen lessons that build one integration, from a first assessment to a policy you can defend. Start here if the library is new to you. |
40
+ | **[Start here](docs/index.md)** | [Installation](docs/start/installation.md) · [Your first integration](docs/start/first-integration.md) · [Choosing a policy](docs/start/choosing-a-policy.md) · [Upgrading](docs/start/upgrading.md) |
41
+ | **Concepts** | [Evidence and certainty](docs/concepts/evidence.md) · [Verdicts and scores](docs/concepts/verdicts.md) · [The safety guard](docs/concepts/the-guard.md) · [Actors](docs/concepts/actors.md) · [Threat model](docs/concepts/threat-model.md) |
42
+ | **[Detection](docs/detection/index.md)** | [The 20 detectors](docs/detection/detectors.md) · [Signatures](docs/detection/signatures.md) · [Verification](docs/detection/verification.md) · [Browser signals](docs/detection/client-signals.md) · [Writing a detector](docs/detection/writing-a-detector.md) |
43
+ | **[Policy](docs/policy/index.md)** | [Rules](docs/policy/rules.md) · [Actions](docs/policy/actions.md) · [Presets](docs/policy/presets.md) · [robots.txt](docs/policy/robots.md) · [The challenge](docs/challenge/index.md) |
44
+ | **[Operations](docs/operations/index.md)** | [The dashboard](docs/operations/dashboard.md) · [Metrics](docs/operations/metrics.md) · [The audit](docs/operations/audit.md) · [Notifications](docs/operations/notifications.md) · [Runtime changes](docs/operations/runtime-changes.md) |
45
+ | **[Integration](docs/integration/index.md)** | [Adapters](docs/integration/adapters.md) · [The client IP](docs/integration/client-ip.md) · [Stores](docs/integration/stores.md) |
46
+ | **[Testing](docs/testing/index.md)** | [The CLI](docs/testing/cli.md) · [The corpus](docs/testing/corpus.md) · [Log replay](docs/testing/replay.md) · [Try it locally](docs/testing/try-it.md) |
47
+ | **Reference** | [Configuration](docs/reference/configuration.md) · [API](docs/reference/api.md) · [Design decisions](docs/design/decisions.md) |
48
+
49
+ ---
50
+
51
+ ## Why this design
52
+
53
+ Every bot detector eventually faces the same problem. The signals that catch
54
+ sophisticated automation — header consistency, TLS fingerprints, timing regularity,
55
+ missing cookies — are all *probabilistic*. Each one has a population of real people
56
+ who trip it: someone on a privacy-hardened browser, behind a corporate proxy, using a
57
+ screen reader, on a hotel network, on a five-year-old phone.
58
+
59
+ The usual answer is to add the signals into a score and block above a threshold. That
60
+ is exactly the mistake. Points do not compose into proof. Two unrelated suspicions
61
+ about an unusual but entirely real browser reach 100 just as readily as two
62
+ well-founded ones, and the people who get caught are disproportionately the ones with
63
+ the strongest reasons for their unusual setup.
64
+
65
+ So this library keeps the two kinds of evidence in separate compartments, all the way
66
+ through:
67
+
68
+ | | Deterministic (`certain`) | Probabilistic (`strong` / `moderate` / `weak`) |
69
+ | -------------------- | ---------------------------------------------------------------- | ----------------------------------------------- |
70
+ | What it rests on | A declaration, a contradiction, a trap, or an external authority | A pattern that automation usually shows |
71
+ | Can it be wrong? | Only if the client lied about itself | Yes, about real people |
72
+ | How it combines | Short-circuits to a verdict | Noisy-OR into a 0–99 score |
73
+ | Can it block? | **Yes** | **No** (under the default policy) |
74
+ | What it can still do | anything | tag, log, delay, rate-limit, challenge, alert |
75
+
76
+ The guard that enforces the last row lives in the policy layer and runs *after* a rule
77
+ has been chosen — so it cannot be forgotten in a rule, worked around by a clever
78
+ predicate, or bypassed by someone who has not read this document. Relaxing it is one
79
+ explicit, greppable setting.
80
+
81
+ ---
82
+
83
+ ## Quick start
84
+
85
+ ```ts
86
+ import { BotHandler } from "@osqd/bothandlerjs";
87
+ import { botHandler } from "@osqd/bothandlerjs/adapters";
88
+
89
+ const detector = new BotHandler({
90
+ preset: "protect-content",
91
+ challenge: { secrets: [process.env.BOT_SECRET!] },
92
+ allowlist: ["10.0.0.0/8"], // your monitors and CI
93
+ ignorePaths: ["/healthz", "/metrics"],
94
+ });
95
+
96
+ app.use(botHandler(detector));
97
+
98
+ // Optional: a live view of what it is doing, on a port of its own.
99
+ await detector.serveDashboard({ port: 9674 });
100
+ ```
101
+
102
+ That is a working configuration. Against a live server it produces:
103
+
104
+ | Request | Result |
105
+ | ------------------------------------------- | --------------------------------------------- |
106
+ | A real Chrome navigation | `200` — served, untouched |
107
+ | `curl https://yoursite/` | `429` — challenge page (proven `http-client`) |
108
+ | A `Googlebot` UA from an address DNS refutes | `403` — proven `impersonator` |
109
+ | `sqlmap` | `403` — self-identified scanner |
110
+ | A hit on a trap link | `403` — no person can reach it |
111
+ | `GET /healthz` | `200` — never assessed |
112
+ | Real Googlebot, confirmed by DNS | `200` — explicitly allowed |
113
+
114
+ ---
115
+
116
+ ## The certainty model
117
+
118
+ ### What earns `certain`
119
+
120
+ Only five things, and each is deterministic for a stated reason:
121
+
122
+ 1. **Self-declaration.** The client says it is a bot: `curl/8.4.0`,
123
+ `python-requests/2.31.0`, `Googlebot/2.1`, `HeadlessChrome` in a Client Hints brand
124
+ list. We are not inferring — we are believing the client's own statement about
125
+ itself, and no honest client is ever harmed by being believed. If the statement is
126
+ a lie, the misclassification is the client's doing.
127
+ 2. **A refuted third-party identity.** A client claimed to be Googlebot and
128
+ forward-confirmed reverse DNS says otherwise. Note how narrow this is: a privacy
129
+ extension rewriting a UA to a generic browser string never lands here, because it
130
+ never claims to be a *named, verifiable* third party.
131
+ 3. **A confirmed third-party identity.** The same check passing. Used to *allow*.
132
+ 4. **A trap.** A link hidden from layout and from assistive technology, excluded in
133
+ `robots.txt`. Detection by construction rather than by inference — there is no
134
+ sequence of user input that reaches it.
135
+ 5. **A protocol violation.** Three of them, each a rule the specification requires a
136
+ recipient to *enforce* rather than merely recommends: a connection-specific header
137
+ on HTTP/2 (RFC 9113 §8.2.2), a message carrying both `Content-Length` and
138
+ `Transfer-Encoding` (RFC 9112 §6.1), and a repeated `Host` or `Content-Length`
139
+ (RFC 9112 §3.2). A client emitting any of them cannot talk to a compliant proxy, so
140
+ no shipping client emits one — and the last two are the ambiguity every
141
+ request-smuggling technique is built on.
142
+
143
+ Plus one on the human side: **your application's own assertion** (`isHuman`, or an
144
+ `operator` clearance token) that a request belongs to a person.
145
+
146
+ One rule holds all of this together, and it is worth stating on its own:
147
+
148
+ > **No argument from absence may ever be `certain`.**
149
+
150
+ A header missing from the facts is not a header missing from the request. An HTTP/1.1
151
+ request with no `Host` violates RFC 9112 as plainly as anything above — and it is
152
+ deliberately *not* proven here, because a caller building facts from a log line, a WAF
153
+ event or a partial adapter would otherwise manufacture proof against every request in
154
+ the file. The protocol checks that did make the list all reason from what is
155
+ **present**.
156
+
157
+ ### What does not
158
+
159
+ Everything else, including several signals that look conclusive:
160
+
161
+ - **A User-Agent that contradicts itself.** Chrome on an iPhone, where Apple's rules
162
+ mean Chrome is WebKit and says `CriOS`; Windows and macOS in one string; Firefox
163
+ claiming the WebKit engine. These describe a client that has never shipped — and
164
+ they are also what a person's UA-spoofing extension produces. `strong`.
165
+ - **UA / Client-Hints contradictions.** A genuine self-contradiction — and also what
166
+ every UA-spoofing privacy extension produces for a real person. `strong`.
167
+ - **Missing `Sec-Fetch-*` on a modern browser.** Excellent signal; also what a
168
+ stripping corporate proxy produces. `strong`.
169
+ - **Header order.** A real fingerprint a scraper cannot fix by copying a UA string;
170
+ also reordered by any intermediary, and meaningless over HTTP/2. `weak`.
171
+ - **High request rate.** The signal people trust most and should trust least: a
172
+ corporate NAT, a university, a mobile carrier's CGNAT pool and a VPN exit all
173
+ present hundreds of real people as one address. Capped at `moderate`.
174
+ - **Datacenter IP ranges.** Where scrapers live — and where every consumer VPN, every
175
+ corporate gateway, every Tor exit and iCloud Private Relay live too. `moderate`.
176
+ - **`navigator.webdriver` and friends.** Reported by JavaScript running inside the
177
+ client, which is the one place an adversary has complete control. `moderate`.
178
+ - **A request for `/.env`.** No link points at it and no menu leads to it — but a URL
179
+ is client-supplied text, and the client supplying it might be a security engineer
180
+ testing their own site. `strong`, and a challenge rather than a closed door. A trap
181
+ path is the case that *is* proven, and the difference is construction: a trap is
182
+ unreachable by any sequence of user input, and a wordlist entry is merely unusual.
183
+
184
+ Writing a `deterministicBasis` string is required for `certain` evidence and enforced
185
+ at runtime outside production. It is a useful forcing function: if you cannot write
186
+ one, your evidence is `strong`.
187
+
188
+ ---
189
+
190
+ ## How a request flows through
191
+
192
+ ```
193
+ request
194
+
195
+ ├─ ignorePaths / allowlist ─────────────────► bypass, no detection at all
196
+
197
+ ├─ normalise facts headers lowercased, path decoded once and resolved,
198
+ │ query into a null-prototype bag, everything bounded
199
+
200
+ ├─ actor state bounded LRU: arrival ring, path hashes, UA count
201
+
202
+ ├─ DETECT phase 1 cheap pure string/header work, sequential, microseconds
203
+ │ phase 2 io concurrent, each under its own timeout
204
+ │ phase 3 confirm DNS — only if an identity was actually claimed
205
+
206
+ ├─ COMBINE certain evidence short-circuits ─► confirmed-bot / verified-bot / human
207
+ │ otherwise noisy-OR ─────────────► score 0–99, suspected-bot / unknown
208
+
209
+ ├─ DECIDE first matching rule wins
210
+ │ └─ SAFETY GUARD: terminal action + no proof ─► downgrade + record
211
+
212
+ └─ ACT continue (tagged) │ respond │ drop
213
+ ```
214
+
215
+ Each stage is separately callable. `assess()` reads the request and touches no
216
+ response, so it is safe to run over a log file. `decide()` is pure. `handle()` does
217
+ all three.
218
+
219
+ ---
220
+
221
+ ## What this library cannot do
222
+
223
+ - **Stop a determined, well-resourced adversary.** Someone running real Chrome through
224
+ a residential proxy pool, at human pace, with correct headers, solving the proof of
225
+ work, is indistinguishable from a person at the HTTP layer — because at that point
226
+ the difference has stopped being technical. What this raises is the *cost*.
227
+ - **Prove somebody is human.** No signal here does that and none claims to. Proof of
228
+ work proves CPU. `navigator.webdriver` proves what the client chose to report. The
229
+ only conclusive human signal is your own application's assertion.
230
+ - **Replace authentication, authorisation or a WAF.** It classifies traffic. It is not
231
+ a security boundary and nothing about it should be load-bearing for access control.
232
+ - **Ship IP intelligence.** Address-to-operator mappings go stale within weeks, and a
233
+ stale mapping is a false positive with a long half-life. Bring your own, from a
234
+ source you refresh and can audit.
235
+ - **Be right about a shared address.** Behind CGNAT, "one actor" is thousands of
236
+ people. That is why the behavioural signals are capped where they are.
237
+ - **Escalate on a wordlist walk.** `probe-signature` reads one request at a time, so a
238
+ scanner working through five hundred paths produces five hundred separate
239
+ observations rather than a mounting case. That is the price of a detector that runs
240
+ unchanged over a log file; enumeration over time is what `rate-anomaly`, `cadence`
241
+ and `crawl-breadth` are for.
242
+
243
+ ---
244
+
245
+ ## Development
246
+
247
+ ```bash
248
+ npm install
249
+ npm test # 661 tests, including the full traffic corpus
250
+ npm run lint # biome, the same rule set as the sibling projects
251
+ npm run typecheck # src + tests + examples, strict, exactOptionalPropertyTypes
252
+ npm run test:coverage # the same tests, against a coverage ratchet
253
+ npm run build # ESM + CJS + declarations
254
+ npm run demo # protected site :9673 + live dashboard :9674
255
+ npm run demo:roles # the same dashboard behind roles: analyst :9684, operator :9685, admin :9686
256
+ npm run simulate # eighteen curated scenarios against the demo
257
+ npm run simulate:corpus # replay all 526 corpus cases over a real socket
258
+ npm run bench # hot-path benchmark, median of several rounds
259
+ npm run corpus # 526 shapes of real traffic against your policy
260
+ npm run example # a minimal Express integration on :3000
261
+
262
+ npx @osqd/bothandlerjs replay access.log # what your policy would have done
263
+ npx @osqd/bothandlerjs check # your policy against 526 shapes of real traffic
264
+ npx @osqd/bothandlerjs explain "curl/8.4.0" # one request, and the evidence behind the verdict
265
+ ```
266
+
267
+ Project layout:
268
+
269
+ ```
270
+ src/
271
+ core.ts engine: assess -> decide -> handle
272
+ cli.ts replay, check, explain, robots, detectors
273
+ metrics.ts counters and Prometheus rendering
274
+ robots.ts robots.txt generation from a policy
275
+ evidence.ts certainty model and evidence combination
276
+ config.ts validation, defaults, client-IP resolution
277
+ facts.ts request normalisation
278
+ state.ts bounded per-actor behavioural memory
279
+ detectors/ twenty detectors + the signature database
280
+ policy/ rules, matcher, the safety guard, presets
281
+ actions/ decision -> framework-neutral outcome
282
+ challenge/ proof of work, signed tokens, the interstitial
283
+ stores/ memory and Redis
284
+ notify/ hub, sinks, redaction
285
+ dashboard/ the operator dashboard: server, feed, page
286
+ dashboard/client/ its browser code — a real module, bundled into the page
287
+ adapters/ Express/Connect, Fastify, Koa, Fetch
288
+ client/ browser-side signal script
289
+ internal/ IP, crypto, UA, Aho-Corasick, LRU, DNS, HTTP
290
+ corpus/ 526 shapes of real traffic, with provenance, and the harness
291
+ that runs them against your configuration
292
+ demo/ the protected site and the live dashboard
293
+ scripts/simulate.ts the traffic simulator
294
+ examples/ minimal integrations to copy from
295
+ ```
296
+
297
+ ---
298
+
299
+ ## License
300
+
301
+ OSQD Non-Resale License, Version 1.0 — see [LICENSE](LICENSE).
302
+
303
+ ---
304
+
305
+ ## Security
306
+
307
+ See [SECURITY.md](SECURITY.md) for the threat model, what this library defends
308
+ against, and how to report a vulnerability.
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+ // Thin launcher. Everything lives in the build so the CLI and the library share one
3
+ // implementation — a CLI that reimplements the engine is a CLI that drifts from it.
4
+ import { main } from "../dist/cli.js";
5
+
6
+ main(process.argv.slice(2)).then(
7
+ (code) => {
8
+ process.exitCode = code;
9
+ },
10
+ (error) => {
11
+ console.error(error instanceof Error ? error.message : error);
12
+ process.exitCode = 1;
13
+ },
14
+ );
@@ -0,0 +1,22 @@
1
+ import type { ActionContext, ActionOutcome, CustomHandler } from "./types.js";
2
+ import type { Assessment } from "../types.js";
3
+ export type { ActionContext, ActionOutcome, CustomHandler } from "./types.js";
4
+ /**
5
+ * Verdict headers.
6
+ *
7
+ * Separate `X-Bot-*` fields rather than one JSON blob: individual headers survive
8
+ * proxies, land in access logs with no extra configuration, and can be routed on by a
9
+ * CDN or a load balancer without anything having to parse them.
10
+ */
11
+ export declare function verdictHeaders(assessment: Assessment): Record<string, string>;
12
+ /**
13
+ * Turns a decision into an outcome.
14
+ *
15
+ * Every branch fails towards serving the request. An unconfigured challenge, a store
16
+ * that will not answer, a custom handler that was never registered: each of those is
17
+ * a misconfiguration on our side, and the visitor should not pay for it. The warning
18
+ * goes to the operator; the client gets its page.
19
+ */
20
+ export declare function executeAction(context: ActionContext): Promise<ActionOutcome>;
21
+ /** Registers a custom handler, with the types inferred. */
22
+ export declare function defineHandler(handler: CustomHandler): CustomHandler;
@@ -0,0 +1,59 @@
1
+ import type { Assessment } from "../types.js";
2
+ import type { Decision } from "../policy/types.js";
3
+ import type { BotHandlerStore } from "../stores/types.js";
4
+ import type { ChallengeService } from "../challenge/index.js";
5
+ import type { Clock } from "../internal/clock.js";
6
+ /**
7
+ * What the engine decided should happen, expressed without reference to any
8
+ * framework.
9
+ *
10
+ * The engine never touches a response object. It returns one of these and an adapter
11
+ * applies it. That separation is what lets the same decision logic drive Express,
12
+ * Fastify, a Fetch handler in a Worker, and a test that asserts on a plain object —
13
+ * and it means the core has no way to accidentally write to a socket.
14
+ */
15
+ export type ActionOutcome = {
16
+ kind: "continue";
17
+ /** Headers to add to the *request* before it reaches your application. */
18
+ requestHeaders?: Record<string, string>;
19
+ /** Headers to add to the eventual response. */
20
+ responseHeaders?: Record<string, string>;
21
+ /** Hold the request this long before passing it on. */
22
+ delayMs?: number;
23
+ } | {
24
+ kind: "respond";
25
+ status: number;
26
+ headers: Record<string, string>;
27
+ body: string;
28
+ }
29
+ /** Close the connection with no response at all. */
30
+ | {
31
+ kind: "drop";
32
+ };
33
+ export interface ActionContext {
34
+ assessment: Assessment;
35
+ decision: Decision;
36
+ store: BotHandlerStore;
37
+ challenge: ChallengeService | undefined;
38
+ clock: Clock;
39
+ /**
40
+ * Whether verdict headers may appear on the *response*.
41
+ *
42
+ * Off by default. Echoing your verdict back to the client tells an operator
43
+ * refining a scraper precisely which change made them invisible, turning your
44
+ * detection into their test suite. Request-side tagging carries the same
45
+ * information to your own application with none of that.
46
+ */
47
+ exposeVerdictHeaders: boolean;
48
+ /** Custom handlers by id, for the `custom` action. */
49
+ handlers: ReadonlyMap<string, CustomHandler>;
50
+ /** Reports an action that could not run as configured. */
51
+ onWarning: (message: string) => void;
52
+ /** Records challenge lifecycle events for metrics. */
53
+ onChallenge?: ((event: "issued") => void) | undefined;
54
+ }
55
+ export interface CustomHandler {
56
+ id: string;
57
+ description?: string;
58
+ execute(context: ActionContext): ActionOutcome | Promise<ActionOutcome>;
59
+ }
@@ -0,0 +1,43 @@
1
+ import type { BotHandler } from "../core.js";
2
+ import type { RequestFacts } from "../types.js";
3
+ /**
4
+ * The parts of Fastify's request and reply this adapter touches, described
5
+ * structurally so the library takes no dependency on Fastify or its types.
6
+ */
7
+ export interface FastifyLikeRequest {
8
+ method: string;
9
+ url: string;
10
+ headers: Record<string, string | string[] | undefined>;
11
+ raw: import("node:http").IncomingMessage;
12
+ }
13
+ export interface FastifyLikeReply {
14
+ code(status: number): FastifyLikeReply;
15
+ header(name: string, value: string): FastifyLikeReply;
16
+ send(body: unknown): unknown;
17
+ hijack?(): void;
18
+ }
19
+ export interface FastifyAdapterOptions {
20
+ tlsFingerprintHeader?: string;
21
+ /**
22
+ * Also serve the challenge verification endpoint. Default true.
23
+ *
24
+ * Fastify parses bodies after `onRequest`, so this reads the raw stream itself.
25
+ * That is why it must run here rather than as a route: by the time a route handler
26
+ * sees the request, the body has been consumed by a parser that does not know about
27
+ * this endpoint.
28
+ */
29
+ mountChallengeEndpoint?: boolean;
30
+ enrich?: (request: FastifyLikeRequest, facts: RequestFacts) => RequestFacts;
31
+ }
32
+ /**
33
+ * An `onRequest` hook for Fastify.
34
+ *
35
+ * ```ts
36
+ * fastify.addHook("onRequest", fastifyBotHandler(handler));
37
+ * ```
38
+ *
39
+ * `onRequest` rather than `preHandler` on purpose: it is the earliest hook, so a
40
+ * refused request never reaches routing, validation or your body parser — none of
41
+ * which should be doing work for traffic that has already been decided about.
42
+ */
43
+ export declare function fastifyBotHandler(handler: BotHandler, options?: FastifyAdapterOptions): (request: FastifyLikeRequest, reply: FastifyLikeReply) => Promise<void>;
@@ -0,0 +1,51 @@
1
+ import type { BotHandler } from "../core.js";
2
+ import type { HandleResult } from "../core.js";
3
+ import type { RequestFacts } from "../types.js";
4
+ export interface FetchAdapterOptions {
5
+ /**
6
+ * Headers to read the client address from, in order of preference.
7
+ *
8
+ * Only headers your edge *overwrites* belong here. One a client can set is one it
9
+ * can use to choose its own identity — and with it the address you rate-limit,
10
+ * allowlist, denylist and block on. `x-forwarded-for` is deliberately **not** in the
11
+ * default list: it is appended to rather than replaced, so its leftmost entry is
12
+ * whatever the client wrote. List it explicitly only if you know your edge replaces
13
+ * the whole header.
14
+ */
15
+ ipHeaders?: readonly string[];
16
+ /** Reads the address from the platform's own context object, e.g. a Workers `ConnInfo`. */
17
+ clientIp?: (request: Request, context: unknown) => string | undefined;
18
+ /** Header carrying an edge-computed JA3/JA4 fingerprint. */
19
+ tlsFingerprintHeader?: string;
20
+ mountChallengeEndpoint?: boolean;
21
+ enrich?: (request: Request, facts: RequestFacts) => RequestFacts;
22
+ }
23
+ export interface FetchDecision {
24
+ /** Serve this instead of calling your handler. `undefined` means carry on. */
25
+ response?: Response | undefined;
26
+ /** The request to pass on, carrying the verdict headers. */
27
+ request: Request;
28
+ /**
29
+ * What the engine concluded — absent for a challenge verification request, which is
30
+ * answered without being assessed.
31
+ *
32
+ * Reported as missing rather than filled in with a placeholder assessment: "we did
33
+ * not judge this request" and "we judged it and found nothing" must not look the
34
+ * same to whatever is reading this.
35
+ */
36
+ result?: HandleResult | undefined;
37
+ }
38
+ /**
39
+ * Web-standard adapter, for Cloudflare Workers, Deno, Bun, Vercel Edge and anything
40
+ * else built on `Request`/`Response`.
41
+ *
42
+ * Returns a decision rather than wrapping your handler, so the same primitive fits a
43
+ * router, a middleware chain or a plain `fetch` export. {@link withBotHandler} wraps it
44
+ * for the common case.
45
+ *
46
+ * Note that the engine uses `node:crypto` for HMAC and hashing. On Workers that means
47
+ * enabling `nodejs_compat`; Deno and Bun provide it natively.
48
+ */
49
+ export declare function createFetchAdapter(handler: BotHandler, options?: FetchAdapterOptions): (request: Request, context?: unknown) => Promise<FetchDecision>;
50
+ /** Wraps a handler. The common case: one call, one line. */
51
+ export declare function withBotHandler(handler: BotHandler, next: (request: Request, context: unknown) => Response | Promise<Response>, options?: FetchAdapterOptions): (request: Request, context?: unknown) => Promise<Response>;