@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,161 @@
1
+ # Lesson 6 — Identity and verification
2
+
3
+ **Goal:** recognise a crawler by name, then prove or refute the claim — and understand why
4
+ a name alone is worth so little.
5
+
6
+ ← [Course](index.md) · Prev: [The detectors](05-detectors.md) · Next: [Actors and behaviour](07-actors.md)
7
+
8
+ ---
9
+
10
+ ## A name is a claim, not a fact
11
+
12
+ `self-identified` matches 161 signatures across search, AI, SEO, social, monitoring,
13
+ feeds, archives, security tooling, HTTP libraries and headless runtimes. When it fires you
14
+ get an `identity` — `"googlebot"`, `"gptbot"` — and a `category`.
15
+
16
+ For a client that has nothing to gain by lying, that is enough: `curl` saying it is `curl`
17
+ is proof, because no honest client is harmed by being believed.
18
+
19
+ **But `Googlebot` is worth impersonating.** A name that buys privileged treatment cannot be
20
+ taken on trust, which is what verification is for.
21
+
22
+ ## Do this
23
+
24
+ ```js
25
+ const forged = await detector.assess(
26
+ createFacts({
27
+ method: "GET", url: "/", ip: "203.0.113.200",
28
+ headers: { host: "serif.example", "user-agent": "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" },
29
+ }),
30
+ );
31
+
32
+ console.log(`verdict=${forged.verdict} class=${forged.botClass} identity=${forged.identity} certain=${forged.certain}`);
33
+ for (const e of forged.evidence) console.log(` [${e.certainty}] ${e.detector}: ${e.summary}`);
34
+ ```
35
+
36
+ > This one makes a real DNS lookup. It is the only example in the course that touches the
37
+ > network.
38
+
39
+ ### Checkpoint
40
+
41
+ ```
42
+ verdict=confirmed-bot class=impersonator identity=googlebot certain=true
43
+ [certain] self-identified: User-Agent identifies Googlebot
44
+ [certain] crawler-verification: Client claims to be Googlebot, but DNS refutes it:
45
+ address has no PTR record, which every operator of a verifiable crawler publishes
46
+ ```
47
+
48
+ Two pieces of proof, pointing in opposite directions about the same client — it *is*
49
+ declared automation, and it is *not* what it declared. The class is `impersonator`, and
50
+ that is a verdict you can act on with confidence, because it rests on an external authority
51
+ rather than on a pattern.
52
+
53
+ ## How forward-confirmed reverse DNS works
54
+
55
+ Three steps, and the third is the one people skip:
56
+
57
+ 1. **Reverse.** Look up the `PTR` record for the client's address → `crawl-66-249-66-1.googlebot.com`
58
+ 2. **Check the domain.** Does it end in a domain the operator publishes? → `googlebot.com` ✓
59
+ 3. **Forward.** Resolve that hostname back to an address. Does it match the one you started
60
+ with?
61
+
62
+ Without step 3, anyone who controls reverse DNS for their own address can claim any name
63
+ they like. With it, the claim can only be made by somebody who controls the operator's
64
+ forward DNS too.
65
+
66
+ | Outcome | Verdict |
67
+ | ------- | ------- |
68
+ | forward-confirmed, domain matches | `verified-bot` — proof, used to **allow** |
69
+ | resolves, domain does not match | `impersonator` — proof, used to **refuse** |
70
+ | no `PTR`, or forward does not match | `impersonator` |
71
+ | resolver error, timeout, no answer | **silence** — no evidence either way |
72
+
73
+ That last row matters. A resolver having a bad afternoon must never look like an
74
+ accusation, so a lookup that fails produces nothing rather than a refutation.
75
+
76
+ ## Verifying by published address ranges
77
+
78
+ Twelve shipped signatures — every AI crawler among them — verify by address rather than by
79
+ DNS. It is better where available: a lookup instead of a round trip on the request path,
80
+ immune to somebody else's DNS, and it works for crawlers that publish ranges and no useful
81
+ `PTR` record.
82
+
83
+ **The library ships no address data**, deliberately. A range baked into a release is wrong
84
+ by the time you install it, and being wrong here means verifying whoever has since been
85
+ handed the address. What it ships is the URL each operator publishes:
86
+
87
+ ```js
88
+ import { startCrawlerRangeRefresh } from "@osqd/bothandlerjs";
89
+
90
+ const stop = startCrawlerRangeRefresh(detector); // twice a day
91
+ ```
92
+
93
+ Opt-in, because it makes outbound requests and a dependency-free package quietly fetching
94
+ URLs on a timer is not something to inherit by accident. It fails open per source: one
95
+ publisher being down leaves every other crawler's ranges as they were.
96
+
97
+ Two things it refuses outright, because these ranges do not merely *describe* a crawler,
98
+ they **verify** one: a list containing a block wider than any crawler owns, and an empty
99
+ list. Either would hand verified status — which most policies allow — to whatever it
100
+ covered.
101
+
102
+ Supplying them yourself, for a mirror you control:
103
+
104
+ ```js
105
+ detector.updateCrawlerRanges("gptbot", ["203.0.113.0/24"]);
106
+ ```
107
+
108
+ ## Categories, and why they are the useful handle
109
+
110
+ Every signature has a category: `search`, `ai`, `seo`, `social`, `monitoring`, `feed`,
111
+ `archive`, `security`, `library`, `headless`, `advertising`.
112
+
113
+ Categories are how a policy expresses a *business* decision without naming thirty crawlers:
114
+
115
+ ```js
116
+ { id: "ai-decline", match: { category: "ai", certain: true }, action: "block" }
117
+ ```
118
+
119
+ You will use this in [lesson 10](10-actions-and-presets.md), where the
120
+ `decline-ai-training` preset splits the AI fleet by job — training crawlers declined,
121
+ fetch-because-a-person-asked served.
122
+
123
+ ## Exercise
124
+
125
+ Serif wants Googlebot and Bingbot allowed, and anything forging them refused. Write the two
126
+ rules — you have not learned rule syntax yet, so write them as sentences and check your
127
+ reasoning.
128
+
129
+ <details>
130
+ <summary>Answer</summary>
131
+
132
+ ```js
133
+ { id: "verified-crawler-allow", match: { verdict: "verified-bot" }, action: "allow" }
134
+ { id: "impersonator-block", match: { botClass: "impersonator", certain: true }, action: "block" }
135
+ ```
136
+
137
+ Two things worth noticing.
138
+
139
+ **The allow rule does not name Googlebot.** `verified-bot` already means "an external
140
+ authority confirmed this", so naming crawlers individually only creates a list to maintain.
141
+
142
+ **The block rule keeps `certain: true`** even though `impersonator` is only ever reached
143
+ through proof. It costs nothing, and it means the rule still says what it depends on if
144
+ somebody later adds a probabilistic route to that class.
145
+ </details>
146
+
147
+ ## What you learned
148
+
149
+ - A name is a claim; for clients with nothing to gain by lying, that is enough
150
+ - FCrDNS is three steps, and the forward step is what makes it proof
151
+ - Verification confirms *and* refutes, and stays silent when DNS says nothing
152
+ - The library ships no address data on purpose; refreshing is opt-in and fails open
153
+ - Categories are the handle for business decisions about crawlers
154
+
155
+ ## Reference
156
+
157
+ - [The signature database](../detection/signatures.md)
158
+ - [Verifying a crawler](../detection/verification.md)
159
+ - [Runtime changes](../operations/runtime-changes.md) — keeping ranges fresh
160
+
161
+ Next: [Actors and behaviour](07-actors.md).
@@ -0,0 +1,178 @@
1
+ # Lesson 7 — Actors and behaviour over time
2
+
3
+ **Goal:** watch suspicion accumulate across requests, and understand why the identity you
4
+ choose decides how good every behavioural signal is.
5
+
6
+ ← [Course](index.md) · Prev: [Identity and verification](06-identity.md) · Next: [Traps](08-traps.md)
7
+
8
+ ---
9
+
10
+ ## One request tells you less than six
11
+
12
+ Everything so far judged a single request. Four detectors need more than that:
13
+ `rate-anomaly`, `cadence`, `crawl-breadth` and `session-integrity` all read an **actor** —
14
+ the library's word for "the same client, seen again".
15
+
16
+ ## Do this
17
+
18
+ Forty requests from one address, exactly 250 ms apart — a metronome:
19
+
20
+ ```js
21
+ import { BotHandler, createFacts } from "@osqd/bothandlerjs";
22
+
23
+ const detector = new BotHandler();
24
+ const UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36";
25
+
26
+ for (let i = 0; i < 40; i++) {
27
+ const a = await detector.assess(
28
+ createFacts({
29
+ method: "GET",
30
+ url: `/books/${i}`,
31
+ ip: "203.0.113.91",
32
+ headers: { host: "serif.example", "user-agent": UA, accept: "text/html,application/xhtml+xml", "accept-language": "en-GB,en;q=0.9", "accept-encoding": "gzip, deflate, br" },
33
+ timestamp: 1_700_000_000_000 + i * 250,
34
+ }),
35
+ );
36
+
37
+ if ([9, 19, 39].includes(i)) {
38
+ console.log(`after ${i + 1}: score=${a.score} verdict=${a.verdict} requests=${a.actor.requests} paths=${a.actor.distinctPaths}`);
39
+ for (const e of a.evidence) console.log(` [${e.certainty}] ${e.detector}: ${e.summary}`);
40
+ }
41
+ }
42
+ ```
43
+
44
+ Passing an explicit `timestamp` is what makes this reproducible — you are simulating time
45
+ rather than waiting for it.
46
+
47
+ ### Checkpoint
48
+
49
+ ```
50
+ after 10: score=45 verdict=unknown requests=10 paths=10
51
+ [moderate] cadence: Arrivals are machine-regular: 9 gaps averaging 250ms with a coefficient of variation of 0.000
52
+
53
+ after 20: score=69 verdict=suspected-bot requests=20 paths=20
54
+ [moderate] cadence: Arrivals are machine-regular: 19 gaps averaging 250ms with a coefficient of variation of 0.000
55
+ [moderate] session-integrity: 20 requests from this actor, none carrying any cookie
56
+ [weak] rate-anomaly: 20 requests in 10s (2/s) from this actor
57
+
58
+ after 40: score=80 verdict=suspected-bot requests=40 paths=40
59
+ [moderate] cadence: Arrivals are machine-regular: 31 gaps averaging 250ms with a coefficient of variation of 0.000
60
+ [moderate] rate-anomaly: 32 requests in 10s (3.2/s) from this actor
61
+ [moderate] session-integrity: 40 requests from this actor, none carrying any cookie
62
+ [weak] crawl-breadth: 40 distinct paths across 40 requests (100% never revisited)
63
+ ```
64
+
65
+ **The headers never changed.** Every request looked like a perfectly ordinary browser on
66
+ its own; the case was built entirely out of the relationship between them.
67
+
68
+ Note `cadence` — a coefficient of variation of exactly 0.000. That is the signal that
69
+ catches the *polite* scraper: one pacing itself deliberately under your rate limit is
70
+ invisible to rate counting and obvious here, because people are irregular and loops are
71
+ not.
72
+
73
+ ## The saturation you can see
74
+
75
+ At 40 requests, `cadence` reports "31 gaps" and `rate-anomaly` "32 requests". Not a bug —
76
+ the arrival ring holds **32 timestamps**, deliberately.
77
+
78
+ Per-actor state is a fixed budget: 32 arrival timestamps, 64 path *hashes* (not strings),
79
+ up to 4 User-Agents, and at most 20,000 actors in a bounded LRU. Every structure keyed by
80
+ something a client controls has a ceiling, because an unbounded map keyed by IP is a remote
81
+ OOM.
82
+
83
+ The trade-off is stated rather than hidden: `requestsWithin` saturates at 32 and reports
84
+ `undercounted: true` rather than a true rate. That series exists to *describe* an actor
85
+ cheaply; exact counting belongs to the `rate-limit` action, which uses a store.
86
+
87
+ ## The actor key is the most valuable thing you can change
88
+
89
+ By default an actor is the client address. An address is a poor identity: shared by a whole
90
+ office, changed by a phone every few minutes, and behind CGNAT it is thousands of people.
91
+
92
+ **Every behavioural detector is only as good as this function.**
93
+
94
+ ```js
95
+ new BotHandler({
96
+ actorKey: (facts) => facts.session ?? facts.ip,
97
+ });
98
+ ```
99
+
100
+ A session id, an authenticated user id, or an address plus a TLS fingerprint all make the
101
+ same detectors sharper — sharp enough that `identity-rotation` becomes worth enabling,
102
+ which under an IP key would fire on every corporate NAT.
103
+
104
+ This is also why the behavioural signals are *capped* where they are. Under an address key
105
+ "one actor" may be a university, so nothing here may exceed `moderate` and none of it can
106
+ deny anybody on its own. The [guard](04-the-guard.md) guarantees that structurally.
107
+
108
+ ## What an actor remembers
109
+
110
+ ```js
111
+ console.log(a.actor);
112
+ ```
113
+
114
+ | Field | |
115
+ | ----- | - |
116
+ | `key` | what it is tracked under |
117
+ | `requests`, `distinctPaths` | inside the behavioural window |
118
+ | `firstSeen`, `lastSeen`, `sinceLastMs` | |
119
+ | `priorConfirmations` | assessments in the window that concluded `confirmed-bot` |
120
+ | `unsolvedChallenges` | outstanding, not cumulative — solving one clears it |
121
+
122
+ `unsolvedChallenges` is deliberately **not evidence**. One abandoned challenge is a person;
123
+ what repeated abandonment means is a judgement about your traffic that only you can make,
124
+ so it is exposed as something a *rule* can read. You will use it in
125
+ [lesson 11](11-the-challenge.md).
126
+
127
+ Idle actors are forgotten after `actorWindowMs` (default 15 minutes), and past `maxActors`
128
+ the least recently seen is evicted — never one still sending traffic.
129
+
130
+ ## Forgetting one, by hand
131
+
132
+ The support-ticket path. Somebody is being challenged, you have looked at their requests,
133
+ and you are satisfied:
134
+
135
+ ```js
136
+ detector.forgetActor("203.0.113.91", { by: "you@serif.example" });
137
+ detector.clearActor("203.0.113.91", 60 * 60_000, { by: "you@serif.example" });
138
+ ```
139
+
140
+ `clearActor` exempts them for a stated number of milliseconds, so the exemption expires on
141
+ its own rather than becoming a permanent hole nobody remembers opening.
142
+
143
+ ## Exercise
144
+
145
+ Re-run the forty requests with a *human* rhythm — random gaps between 400 ms and 6 s — and
146
+ have each request revisit one of five paths rather than a new one each time.
147
+
148
+ <details>
149
+ <summary>What you should see, and what it means</summary>
150
+
151
+ `cadence` stops firing, because the coefficient of variation is no longer near zero.
152
+ `crawl-breadth` stops firing, because 5 distinct paths across 40 requests is reading rather
153
+ than enumerating. `session-integrity` still fires — no cookie is still no cookie — and the
154
+ score settles far below the threshold.
155
+
156
+ That is the honest limit of behavioural detection, and it is the top of the evasion ladder
157
+ in the [threat model](../concepts/threat-model.md): a scraper paced like a person, taking a
158
+ few pages per address, is **not caught at all**. What defeats that is cost — a challenge,
159
+ or an account — not detection.
160
+
161
+ Knowing precisely where your detection stops is more useful than believing it does not.
162
+ </details>
163
+
164
+ ## What you learned
165
+
166
+ - Four detectors read an actor rather than a request, and build a case across time
167
+ - `cadence` catches the polite scraper that rate counting cannot
168
+ - Per-actor state is a fixed, bounded budget, and it saturates visibly rather than lying
169
+ - `actorKey` is the highest-value thing you can replace
170
+ - Behavioural signals are capped at `moderate` because an address is a poor identity
171
+
172
+ ## Reference
173
+
174
+ - [Actors and behavioural memory](../concepts/actors.md)
175
+ - [The detectors](../detection/detectors.md) — the behavioural four in detail
176
+ - [Threat model](../concepts/threat-model.md) — where this stops working
177
+
178
+ Next: [Traps](08-traps.md) — the one detector that needs no statistics at all.
@@ -0,0 +1,183 @@
1
+ # Lesson 8 — Traps
2
+
3
+ **Goal:** lay a honeypot properly — including the POST-body case that silently does
4
+ nothing if you skip it.
5
+
6
+ ← [Course](index.md) · Prev: [Actors and behaviour](07-actors.md) · Next: [Rules](09-rules.md)
7
+
8
+ ---
9
+
10
+ ## Why a trap is proof
11
+
12
+ Every other probabilistic detector asks *"does this look like automation?"*. A trap asks
13
+ *"did you fetch a thing that no person can reach?"* — and that is detection by
14
+ **construction** rather than by inference.
15
+
16
+ A trap link is positioned off-screen, marked `aria-hidden` and `tabindex="-1"` so assistive
17
+ technology skips it, `rel="nofollow noindex"`, and disallowed in `robots.txt`. There is no
18
+ sequence of user input that reaches it. A client that fetched it either ignored every one
19
+ of those signals or never rendered the page at all.
20
+
21
+ That is why `trap` is one of the five things that earn `certain`, and it is the cheapest
22
+ proof in the library — no statistics, no DNS, no history.
23
+
24
+ ## Do this
25
+
26
+ ```js
27
+ import { BotHandler, createFacts, renderTrapLink, DEFAULT_TRAP_PATHS } from "@osqd/bothandlerjs";
28
+
29
+ console.log(DEFAULT_TRAP_PATHS);
30
+ console.log(renderTrapLink("/internal/export.csv"));
31
+
32
+ const detector = new BotHandler();
33
+ const caught = await detector.assess(
34
+ createFacts({
35
+ method: "GET",
36
+ url: "/internal/export.csv",
37
+ ip: "203.0.113.150",
38
+ headers: { host: "serif.example", "user-agent": CHROME["user-agent"], accept: "text/html" },
39
+ }),
40
+ );
41
+ console.log(`verdict=${caught.verdict} certain=${caught.certain} score=${caught.score}`);
42
+ for (const e of caught.evidence) console.log(` [${e.certainty}] ${e.detector}: ${e.summary}`);
43
+ console.log("basis:", caught.evidence[0].deterministicBasis);
44
+ ```
45
+
46
+ Notice the request carries a **complete, believable Chrome header set**. It is still
47
+ proven, because the trap does not care what you claim to be — only where you went.
48
+
49
+ ## Putting one in a page
50
+
51
+ ```js
52
+ app.get("/", (req, res) => {
53
+ res.send(`
54
+ <main>…your page…</main>
55
+ ${renderTrapLink("/internal/export.csv", { label: "Archive index" })}
56
+ `);
57
+ });
58
+ ```
59
+
60
+ And tell the well-behaved crawlers to stay away, so that only the ones ignoring
61
+ `robots.txt` are ever caught:
62
+
63
+ ```js
64
+ import { trapRobotsEntries } from "@osqd/bothandlerjs";
65
+ console.log(trapRobotsEntries());
66
+ // User-agent: *
67
+ // Disallow: /internal/export.csv
68
+ // Disallow: /api/v1/all-users
69
+ // Disallow: /sitemap-index-full.xml
70
+ ```
71
+
72
+ This is not a courtesy. **Publishing the disallow is what makes the trap proof.** Without
73
+ it, Googlebot follows the link — it has no way to know it should not — and you have proven
74
+ something false about a crawler you wanted.
75
+
76
+ ## The form-field trap, and the mistake everyone makes
77
+
78
+ `renderTrapField` gives you a hidden input to drop into a form:
79
+
80
+ ```js
81
+ import { renderTrapField } from "@osqd/bothandlerjs";
82
+ res.send(`<form method="post" action="/signup">${renderTrapField("company_url")}…</form>`);
83
+ ```
84
+
85
+ A bot filling every input it finds fills this one. A person never sees it.
86
+
87
+ **But the engine reads no request body.** Doing so would consume the stream before your own
88
+ parser saw it. So a hidden field on a `method="post"` form arrives somewhere this library
89
+ cannot see — and the forms worth protecting are POSTs.
90
+
91
+ Skip the next step and the field is rendered, filled by a bot, and **silently ignored**:
92
+
93
+ ```js
94
+ import { BotHandler, TRAP_FIELD_SOURCE, defaultDetectors, trapDetector } from "@osqd/bothandlerjs";
95
+ import { botHandler } from "@osqd/bothandlerjs/adapters";
96
+
97
+ const detector = new BotHandler({
98
+ // Register the field name — the detector cannot recognise a honeypot it was never told about.
99
+ detectors: defaultDetectors().map((d) =>
100
+ d.id === "trap" ? trapDetector({ formFields: ["company_url"] }) : d,
101
+ ),
102
+ });
103
+
104
+ app.use(express.urlencoded({ extended: false })); // your parser runs first
105
+ app.use(
106
+ botHandler(detector, {
107
+ enrich: (request, facts) => ({ ...facts, extra: { [TRAP_FIELD_SOURCE]: request.body } }),
108
+ }),
109
+ );
110
+ ```
111
+
112
+ Two things have to line up: the detector must be **told the field name**, and the parsed
113
+ body must be **handed over** with `enrich`. A field arriving in the query string is read
114
+ without any of this.
115
+
116
+ ## Choosing paths
117
+
118
+ The defaults are `/internal/export.csv`, `/api/v1/all-users` and
119
+ `/sitemap-index-full.xml` — chosen to look like something worth fetching. Replace them with
120
+ paths that fit your site:
121
+
122
+ ```js
123
+ trapDetector({ paths: ["/admin/backup.sql", "/customers/export"] })
124
+ ```
125
+
126
+ Two rules:
127
+
128
+ **Do not use a path you might one day build.** A trap that becomes a real endpoint proves
129
+ things about your own users.
130
+
131
+ **Keep them out of public view.** A trap works because no person can reach it — publishing
132
+ the path in a public repository or a client-side comment turns proof back into a guess.
133
+ This is the one part of your configuration worth treating as a secret.
134
+
135
+ ## Exercise
136
+
137
+ Serif has a signup form being hit by a registration bot. Lay a trap that catches it, and
138
+ list everything that has to be true for it to work.
139
+
140
+ <details>
141
+ <summary>Answer</summary>
142
+
143
+ ```js
144
+ // 1. Tell the detector the field name.
145
+ const detector = new BotHandler({
146
+ detectors: defaultDetectors().map((d) =>
147
+ d.id === "trap" ? trapDetector({ formFields: ["serif_referral_code"] }) : d,
148
+ ),
149
+ rules: [{ id: "trap-block", match: { detector: "trap", certain: true }, action: "block" }],
150
+ });
151
+
152
+ // 2. Render it in the form.
153
+ `<form method="post" action="/signup">${renderTrapField("serif_referral_code")}…</form>`
154
+
155
+ // 3. Parse the body first, then hand it over.
156
+ app.use(express.urlencoded({ extended: false }));
157
+ app.use(botHandler(detector, {
158
+ enrich: (request, facts) => ({ ...facts, extra: { [TRAP_FIELD_SOURCE]: request.body } }),
159
+ }));
160
+ ```
161
+
162
+ Four things must all hold: the field is **rendered**, its name is **registered** with the
163
+ detector, your parser runs **before** the middleware, and `enrich` **hands the body over**.
164
+ Miss any one and the trap fails open silently — no error, no warning, just a honeypot that
165
+ never catches anything.
166
+
167
+ That silence is why this lesson exists.
168
+ </details>
169
+
170
+ ## What you learned
171
+
172
+ - A trap is proof by construction, and needs no statistics at all
173
+ - Publishing the disallow in `robots.txt` is what makes it proof rather than a mistake
174
+ - A POST field trap needs the name registered *and* the body handed over with `enrich`
175
+ - Trap paths are the one part of your config to keep quiet about
176
+
177
+ ## Reference
178
+
179
+ - [The detectors](../detection/detectors.md) — `trap` in full
180
+ - [Adapters](../integration/adapters.md) — `enrich`
181
+ - [robots.txt](../policy/robots.md)
182
+
183
+ Next: [Rules](09-rules.md) — writing the policy.