@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,160 @@
1
+ # Lesson 3 — Verdicts, classes and scores
2
+
3
+ **Goal:** know which of the four descriptive fields to act on, and why reading the wrong
4
+ one is the most common integration bug.
5
+
6
+ ← [Course](index.md) · Prev: [Proof and suspicion](02-proof-and-suspicion.md) · Next: [The guard](04-the-guard.md)
7
+
8
+ ---
9
+
10
+ ## Four fields, four questions
11
+
12
+ | Field | The question it answers |
13
+ | ----- | ----------------------- |
14
+ | `verdict` | What did we conclude? |
15
+ | `certain` | Is that conclusion **proof**? |
16
+ | `botClass` | What *kind* of client is it? |
17
+ | `score` | How suspicious is it, if we are only guessing? |
18
+
19
+ They are not four ways of saying the same thing, and rules that mix them up are where
20
+ policies go wrong.
21
+
22
+ ## The verdicts
23
+
24
+ | Verdict | Meaning | Proven? |
25
+ | ------- | ------- | ------- |
26
+ | `confirmed-bot` | Self-declared, self-contradictory, or caught in a trap | yes |
27
+ | `verified-bot` | Crawler identity confirmed against an external authority | yes |
28
+ | `suspected-bot` | Probabilistic signals cleared `suspectThreshold` (default 60) | **no** |
29
+ | `human` | Positive evidence of a person | only if `certain` |
30
+ | `unknown` | Nothing conclusive. The resting state of ordinary traffic | — |
31
+
32
+ The two proven verdicts are different in *direction*, and that is the point:
33
+ `confirmed-bot` is usually a reason to act, `verified-bot` is usually a reason to allow.
34
+ Googlebot is a bot you want.
35
+
36
+ ## The classes
37
+
38
+ `botClass` is orthogonal to the verdict. It answers "what is it?" rather than "how sure are
39
+ we?":
40
+
41
+ `human` · `verified-bot` · `declared-bot` · `automation` · `http-client` · `scanner` ·
42
+ `scraper` · `impersonator` · `unknown`
43
+
44
+ Most real policies key on `botClass`, because it maps to a business decision. "Rate-limit
45
+ scrapers, block scanners, allow verified crawlers, tag declared bots" is a policy about
46
+ classes, not verdicts.
47
+
48
+ ## Do this
49
+
50
+ ```js
51
+ const cases = [
52
+ ["curl", { host: "serif.example", "user-agent": "curl/8.4.0", accept: "*/*" }],
53
+ ["sqlmap", { host: "serif.example", "user-agent": "sqlmap/1.7.2#stable (https://sqlmap.org)" }],
54
+ ["GPTBot", { host: "serif.example", "user-agent": "Mozilla/5.0 (compatible; GPTBot/1.0; +https://openai.com/gptbot)" }],
55
+ ["python", { host: "serif.example", "user-agent": "python-requests/2.32.3", "accept-encoding": "gzip, deflate" }],
56
+ ["Chrome", CHROME],
57
+ ];
58
+
59
+ for (const [label, headers] of cases) {
60
+ const a = await detector.assess(createFacts({ method: "GET", url: "/books", headers, ip: "203.0.113.10" }));
61
+ console.log(
62
+ label.padEnd(8),
63
+ a.verdict.padEnd(14),
64
+ a.botClass.padEnd(14),
65
+ String(a.score).padStart(3),
66
+ a.certain ? "proven" : "guess",
67
+ a.identity ?? "",
68
+ );
69
+ }
70
+ ```
71
+
72
+ Give each case its own `ip` if you want them judged independently — otherwise they are all
73
+ the same [actor](07-actors.md) and later requests carry the earlier ones' history.
74
+
75
+ ### What to notice
76
+
77
+ - `sqlmap` and `curl` are both `confirmed-bot` and both proven, but their **classes**
78
+ differ — `scanner` and `http-client`. A policy that blocks scanners and tolerates HTTP
79
+ clients needs the class, not the verdict.
80
+ - `GPTBot` carries an `identity`. That is what lets you write a rule about one named
81
+ crawler, and what [lesson 6](06-identity.md) is about.
82
+ - Chrome is `unknown`, scoring 0. Ordinary traffic concludes nothing.
83
+
84
+ ## Which field should a rule use?
85
+
86
+ **Prefer `botClass` for business decisions.** "What do I do about scrapers?" is a question
87
+ you can answer once.
88
+
89
+ **Use `certain: true` when you are about to withhold something.** It is the field that
90
+ means proof.
91
+
92
+ **Use `score` only for graded, recoverable responses** — a challenge threshold, a
93
+ rate-limit tier. Never as a licence to deny.
94
+
95
+ **Be careful with a bare `{ certain: true }`.** It matches a proven **human** too, and
96
+ challenging a customer you just vouched for is worse than useless. The shipped
97
+ `protect-data` preset spells this out by matching verdicts explicitly:
98
+
99
+ ```js
100
+ { id: "any-proven-automation-challenge",
101
+ match: { verdict: ["confirmed-bot", "verified-bot"], certain: true },
102
+ action: "challenge" }
103
+ ```
104
+
105
+ ## `confidence`, and what it is for
106
+
107
+ `confidence` is 0–1, and exactly 1 when `certain`. It is not a second score — it is how
108
+ much to trust the verdict. In lesson 1 the copied-User-Agent request scored 45 with
109
+ confidence 0.553: genuinely unsure, and the number says so.
110
+
111
+ Use it for logging and for triage in a dashboard. Do not build rules on it; `score` and
112
+ `certain` are what the policy layer matches on.
113
+
114
+ ## Exercise
115
+
116
+ Write a table of what Serif should do with each class, before you know how to express it.
117
+ Just the decision:
118
+
119
+ | Class | What Serif does |
120
+ | ----- | --------------- |
121
+ | `verified-bot` | ? |
122
+ | `declared-bot` | ? |
123
+ | `scraper` | ? |
124
+ | `scanner` | ? |
125
+ | `impersonator` | ? |
126
+ | `http-client` | ? |
127
+ | `unknown` | ? |
128
+
129
+ <details>
130
+ <summary>A defensible answer</summary>
131
+
132
+ | Class | Serif | Why |
133
+ | ----- | ----- | --- |
134
+ | `verified-bot` | allow | Googlebot brings readers |
135
+ | `declared-bot` | tag | honest automation; let the app decide |
136
+ | `scraper` | rate-limit | bulk extraction, slowed rather than refused |
137
+ | `scanner` | block | proven, and looking for a way in |
138
+ | `impersonator` | block | forged an identity; proven by DNS |
139
+ | `http-client` | challenge | might be somebody's integration — do not refuse outright |
140
+ | `unknown` | allow | this is your readers |
141
+
142
+ Keep this. It is the policy you will write in [lesson 9](09-rules.md), and comparing it
143
+ with the shipped presets in [lesson 10](10-actions-and-presets.md) is the point of that
144
+ lesson.
145
+ </details>
146
+
147
+ ## What you learned
148
+
149
+ - Verdict, class, certainty and score answer four different questions
150
+ - `botClass` is usually the right field for a business decision
151
+ - `certain` is the only field that should gate withholding something
152
+ - `{ certain: true }` alone also matches a proven human
153
+
154
+ ## Reference
155
+
156
+ - [Verdicts, classes and scores](../concepts/verdicts.md)
157
+ - [Matching requests](../policy/rules.md) — every field a rule can read
158
+
159
+ Next: [The safety guard](04-the-guard.md) — what happens when a rule asks for more than its
160
+ evidence supports.
@@ -0,0 +1,200 @@
1
+ # Lesson 4 — The safety guard
2
+
3
+ **Goal:** watch a rule you wrote get overruled, and understand why that is the most
4
+ valuable behaviour in the library.
5
+
6
+ ← [Course](index.md) · Prev: [Verdicts and scores](03-verdicts-and-scores.md) · Next: [The detectors](05-detectors.md)
7
+
8
+ ---
9
+
10
+ ## Assess, then decide
11
+
12
+ Detection and policy are separate calls, on purpose:
13
+
14
+ ```js
15
+ const assessment = await detector.assess(facts); // what is this client?
16
+ const decision = detector.decide(assessment); // what do we do about it?
17
+ ```
18
+
19
+ *What is this client* and *what should we do about it* are different questions with
20
+ different lifetimes. `decide` is synchronous and pure, which is what lets you replay a
21
+ policy over a log file, preview a rule change against real traffic, and test both halves
22
+ separately.
23
+
24
+ The guard lives in `decide`.
25
+
26
+ ## Do this
27
+
28
+ Write a rule that blocks anything suspected — deliberately too aggressive:
29
+
30
+ ```js
31
+ import { BotHandler, createFacts } from "@osqd/bothandlerjs";
32
+
33
+ const detector = new BotHandler({
34
+ rules: [{ id: "block-suspects", match: { verdict: "suspected-bot" }, action: "block" }],
35
+ suspectThreshold: 40,
36
+ });
37
+
38
+ // A scraper that copied a User-Agent and nothing else.
39
+ const spoof = createFacts({
40
+ method: "GET",
41
+ url: "/books",
42
+ ip: "203.0.113.55",
43
+ headers: {
44
+ host: "serif.example",
45
+ "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
46
+ accept: "*/*",
47
+ },
48
+ });
49
+
50
+ const assessment = await detector.assess(spoof);
51
+ const decision = detector.decide(assessment);
52
+
53
+ console.log(`verdict ${assessment.verdict} score ${assessment.score} certain ${assessment.certain}`);
54
+ console.log(`action ${decision.action} rule ${decision.rule} downgradedFrom ${decision.downgradedFrom}`);
55
+ console.log(decision.downgradeReason);
56
+ ```
57
+
58
+ ### Checkpoint
59
+
60
+ ```
61
+ verdict suspected-bot score 45 certain false
62
+ action tag rule block-suspects downgradedFrom block
63
+ Strict mode permits a terminal action only on proven evidence. This request's verdict
64
+ (suspected-bot, score 45) rests on probabilistic signals, any of which a real client can
65
+ trip.
66
+ ```
67
+
68
+ **Your rule said `block`. The request was tagged.**
69
+
70
+ The rule still fired — `decision.rule` names it — and the guard replaced the action,
71
+ recorded what it replaced, and explained itself in a sentence you can put in front of an
72
+ operator. Nothing was silent.
73
+
74
+ ## Why it is a separate pass
75
+
76
+ The guard runs **after** a rule is selected, not inside the rule. Anywhere else it can be
77
+ forgotten.
78
+
79
+ Inside a rule, it would have to be written correctly by every author of every rule,
80
+ including at 3am during an incident, including by somebody who has not read this course.
81
+ After selection, it cannot be forgotten in a rule, worked around by a clever predicate, or
82
+ bypassed by copying an example from a blog post. Relaxing it is one explicit, greppable
83
+ setting.
84
+
85
+ ## Now give it proof
86
+
87
+ Same rule set, a client that declares itself:
88
+
89
+ ```js
90
+ const proven = createFacts({
91
+ method: "GET", url: "/books", ip: "203.0.113.10",
92
+ headers: { host: "serif.example", "user-agent": "curl/8.4.0" },
93
+ });
94
+ const d = detector.decide(await detector.assess(proven));
95
+ console.log(`action ${d.action} rule ${d.rule} downgradedFrom ${d.downgradedFrom ?? "(not downgraded)"}`);
96
+ ```
97
+
98
+ ```
99
+ action allow rule default downgradedFrom (not downgraded)
100
+ ```
101
+
102
+ Surprised? **The rule did not match.** `curl` is `confirmed-bot`, not `suspected-bot`, so
103
+ `block-suspects` never applied and the default action took over. The guard was not
104
+ involved at all.
105
+
106
+ That is a useful accident to hit early: a rule that matches the wrong verdict is a much
107
+ more common bug than a rule the guard overrules.
108
+
109
+ ## The three terminal actions
110
+
111
+ `block`, `drop` and `redirect` withhold the page. Everything else — `allow`, `log`, `tag`,
112
+ `delay`, `rate-limit`, `challenge`, `custom` — leaves the client a way through.
113
+
114
+ Only the terminal three are gated. [Lesson 10](10-actions-and-presets.md) ranks all ten by
115
+ what each costs somebody who turns out to be a person.
116
+
117
+ ## The three modes
118
+
119
+ ```js
120
+ new BotHandler({ falsePositivePolicy: "strict" }) // default
121
+ ```
122
+
123
+ | Mode | A terminal action is allowed when |
124
+ | ---- | --------------------------------- |
125
+ | `strict` | the verdict is **proven**. Nothing else |
126
+ | `balanced` | proven, **or** the score is at or above `terminalScoreThreshold` (default 85) |
127
+ | `aggressive` | any bot verdict, proven or not |
128
+
129
+ Try `balanced` with the spoofed request above and it is still tagged — 45 is under 85. Push
130
+ `suspectThreshold` down and the score does not change; the score is what it is, and the
131
+ threshold only decides what to call it.
132
+
133
+ **`aggressive` will deny real people.** It exists because some operators genuinely need it —
134
+ an internal API where every human is authenticated, say — and because a setting you have to
135
+ write down is better than one you can reach by accident. If you find yourself reaching for
136
+ it during an incident, read [`under-attack`](../policy/presets.md#under-attack) first: it is
137
+ designed for exactly that moment and keeps the guard on.
138
+
139
+ ## The fallback
140
+
141
+ When the guard replaces a terminal action, it substitutes `fallbackAction` — `challenge`
142
+ by default. Above you got `tag`, not `challenge`, because no challenge secret is
143
+ configured; a rule asking for a challenge without one degrades to `tag` and warns. You will
144
+ fix that in [lesson 11](11-the-challenge.md).
145
+
146
+ `fallbackAction` **cannot itself be terminal**, and the library refuses at construction:
147
+
148
+ ```js
149
+ new BotHandler({ fallbackAction: "block" });
150
+ // throws — a terminal fallback would make every downgrade deny the request the
151
+ // downgrade existed to protect, while still recording it as a guard stop
152
+ ```
153
+
154
+ ## Exercise
155
+
156
+ Count the downgrades over a mixed batch, and notice what that number is for.
157
+
158
+ ```js
159
+ const detector = new BotHandler({
160
+ rules: [{ id: "block-suspects", match: { verdict: "suspected-bot" }, action: "block" }],
161
+ suspectThreshold: 40,
162
+ onDowngrade: ({ decision }) => console.log(` guard stopped rule "${decision.rule}"`),
163
+ });
164
+ ```
165
+
166
+ Run several requests through it. Then read the counter:
167
+
168
+ ```js
169
+ console.log(detector.metrics().downgrades);
170
+ ```
171
+
172
+ <details>
173
+ <summary>What that number means</summary>
174
+
175
+ `bothandler_downgrades_total` is the single most informative number this library exposes.
176
+ A rising count means **your policy is asking for something the evidence does not
177
+ support** — and the guard is absorbing the difference.
178
+
179
+ Alert on it. If it climbs after a deploy, you changed a rule; if it climbs on its own, your
180
+ traffic changed. Either way it is telling you the policy and reality have drifted apart,
181
+ and the guard is the only reason nobody has been wrongly refused yet.
182
+
183
+ [Lesson 13](13-operating-it.md) wires it up properly.
184
+ </details>
185
+
186
+ ## What you learned
187
+
188
+ - `assess` and `decide` are separate; the guard lives in `decide`, after rule selection
189
+ - A rule can fire and still not get the action it asked for — visibly, with a reason
190
+ - Only `block`, `drop` and `redirect` are gated
191
+ - `fallbackAction` cannot be terminal, and that is enforced at construction
192
+ - `downgrades` is the number to watch
193
+
194
+ ## Reference
195
+
196
+ - [The safety guard](../concepts/the-guard.md)
197
+ - [Actions](../policy/actions.md) — ordered by what each costs a person
198
+ - [Design decisions](../design/decisions.md) — why the guard is a separate pass
199
+
200
+ Next: [The detectors](05-detectors.md) — where the evidence actually comes from.
@@ -0,0 +1,178 @@
1
+ # Lesson 5 — The detectors
2
+
3
+ **Goal:** know what each of the twenty detectors reads, what it costs, and what it is
4
+ allowed to conclude — then turn one off and watch the score move.
5
+
6
+ ← [Course](index.md) · Prev: [The guard](04-the-guard.md) · Next: [Identity and verification](06-identity.md)
7
+
8
+ ---
9
+
10
+ ## What is installed
11
+
12
+ ```js
13
+ for (const d of detector.describeDetectors()) {
14
+ console.log(`${d.id.padEnd(22)} ${d.cost.padEnd(6)} ${d.stage}`);
15
+ }
16
+ ```
17
+
18
+ ### Checkpoint
19
+
20
+ ```
21
+ self-identified cheap always
22
+ trap cheap always
23
+ ip-intelligence cheap always
24
+ probe-signature cheap always
25
+ header-integrity cheap always
26
+ ua-coherence cheap always
27
+ client-hints cheap always
28
+ fetch-metadata cheap always
29
+ accept-signature cheap always
30
+ header-order cheap always
31
+ rate-anomaly cheap always
32
+ cadence cheap always
33
+ crawl-breadth cheap always
34
+ session-integrity cheap always
35
+ browsing-coherence cheap always
36
+ crawler-verification io confirming
37
+ ```
38
+
39
+ **Sixteen of twenty ship on by default.** The other four —
40
+ `identity-rotation`, `tls-fingerprint`, `clearance`, `client-signals` — each need something
41
+ from you, and you will switch three of them on later in the course.
42
+
43
+ ## Cost and stage
44
+
45
+ Every detector declares two things, and they decide when it runs.
46
+
47
+ **`cost`** is `cheap` or `io`. Cheap detectors are pure string and header work and run
48
+ sequentially in microseconds. `io` detectors touch the network or a shared store, run
49
+ concurrently, and each gets its own timeout — `detectorTimeoutMs`, default 300 ms.
50
+
51
+ **`stage`** is `always` or `confirming`. The confirming stage runs only when an earlier
52
+ detector produced something worth confirming, which is why `crawler-verification` does no
53
+ DNS at all for a request that never claimed an identity.
54
+
55
+ A detector that throws or times out is dropped; **the request is not**. A bot filter that
56
+ fails closed is an outage with extra steps.
57
+
58
+ ## What each one does
59
+
60
+ ### Identity
61
+
62
+ | Detector | Ceiling | Reads |
63
+ | -------- | ------- | ----- |
64
+ | `self-identified` | `certain` | 161 signatures, 389 tokens, in one Aho–Corasick pass. Also catches unrecognised crawlers that name a contact URL, and bare client tokens with no browser preamble |
65
+ | `crawler-verification` | `certain` | Forward-confirmed reverse DNS, or published address ranges. **Confirms and refutes** |
66
+
67
+ ### Single-request consistency
68
+
69
+ | Detector | Ceiling | Reads |
70
+ | -------- | ------- | ----- |
71
+ | `header-integrity` | `certain`* | Header set against the claimed client, plus framing rules a recipient must enforce. `certain` only for the three protocol violations |
72
+ | `ua-coherence` | `strong` | The User-Agent against itself: two engines, two platforms, Chrome on an iPhone, a version its platform never received. Needs no other headers, so it works on a log line |
73
+ | `client-hints` | `certain`* | `Sec-CH-UA` against the User-Agent. `certain` only for a self-declared headless brand |
74
+ | `fetch-metadata` | `strong` | `Sec-Fetch-*` absence on engines that send them, and incoherent combinations. Page JavaScript cannot set these |
75
+ | `accept-signature` | `strong` | `Accept: */*` on a navigation; `Accept-Language` that is not valid grammar |
76
+ | `header-order` | `moderate` | Orderings no mainstream browser produces. HTTP/1.x only |
77
+
78
+ ### Behaviour over time
79
+
80
+ | Detector | Ceiling | Reads |
81
+ | -------- | ------- | ----- |
82
+ | `rate-anomaly` | `moderate` | Arrivals in a short window. Reports; never concludes |
83
+ | `cadence` | `moderate` | Coefficient of variation of the gaps. Catches the polite scraper pacing itself *under* your rate limit |
84
+ | `crawl-breadth` | `weak` | Distinct paths against total requests: reading a site against enumerating it |
85
+ | `session-integrity` | `moderate` | A "browser" that never carries a cookie |
86
+ | `identity-rotation` | `moderate` | One actor, several User-Agents. **Off by default** — under an IP actor key this fires on every corporate NAT |
87
+ | `browsing-coherence` | `moderate` | The only detector arguing *for* the client. Human-pointing, so it discounts |
88
+
89
+ ### Environment and traps
90
+
91
+ | Detector | Ceiling | Reads |
92
+ | -------- | ------- | ----- |
93
+ | `trap` | `certain` | Hidden links, hidden fields, trap headers — [lesson 8](08-traps.md) |
94
+ | `probe-signature` | `strong` | What the request *asks for*: `/.env`, `/.git/config`, a JNDI lookup, a `TRACE` |
95
+ | `ip-intelligence` | `certain`* | Your denylist (`certain` — your decision, not our inference) and datacenter ranges (`moderate`). Ships no data |
96
+ | `tls-fingerprint` | `strong` | JA3/JA4 from your edge. **Off by default** |
97
+ | `clearance` | `certain` | A signed clearance token. `certain` only at `operator` level — [lesson 11](11-the-challenge.md) |
98
+ | `client-signals` | `moderate` | What a browser-side script reported. **Capped at `moderate`, permanently** — [lesson 15](15-extending.md) |
99
+
100
+ ## Do this: turn one off
101
+
102
+ `header-integrity` was doing most of the work on the copied-User-Agent request from lesson
103
+ 1. Remove it and see:
104
+
105
+ ```js
106
+ import { BotHandler, createFacts, defaultDetectors } from "@osqd/bothandlerjs";
107
+
108
+ const without = new BotHandler({
109
+ detectors: defaultDetectors().filter((d) => d.id !== "header-integrity"),
110
+ });
111
+
112
+ const spoof = createFacts({
113
+ method: "GET", url: "/books", ip: "203.0.113.55",
114
+ headers: { host: "serif.example", "user-agent": CHROME["user-agent"], accept: "*/*" },
115
+ });
116
+
117
+ console.log("with ", (await detector.assess(spoof)).score);
118
+ console.log("without", (await without.assess(spoof)).score);
119
+ ```
120
+
121
+ The score falls from 45 to 15 — the two `header-integrity` absences were most of the case.
122
+
123
+ Note `detectors:` **replaces** the set entirely. To add without removing, use
124
+ `extraDetectors`.
125
+
126
+ ## Adjusting one rather than removing it
127
+
128
+ Several detectors take options. The one you are most likely to want is
129
+ `probe-signature`, whose second tier is platform administration paths — `/wp-login.php`,
130
+ `/administrator`, `/phpmyadmin`. Those are a probe on sites that do not run those
131
+ platforms and the *front door* on sites that do:
132
+
133
+ ```js
134
+ import { defaultDetectors, probeSignatureDetector } from "@osqd/bothandlerjs";
135
+
136
+ const detectors = defaultDetectors().map((d) =>
137
+ d.id === "probe-signature" ? probeSignatureDetector({ ignore: ["/wp-login.php", "/wp-admin"] }) : d,
138
+ );
139
+ ```
140
+
141
+ That tier is capped at `moderate` precisely so that forgetting is survivable: an author
142
+ signing in to their own site scores 21 and is served.
143
+
144
+ ## Exercise
145
+
146
+ Serif runs on a Fetch runtime behind Cloudflare. Which detector should you drop, and why?
147
+
148
+ <details>
149
+ <summary>Answer</summary>
150
+
151
+ **`header-order`.** Fetch runtimes normalise header order, so it has nothing real to read
152
+ there and will either return nothing or reason from an ordering the platform invented
153
+ rather than the client.
154
+
155
+ ```js
156
+ detectors: defaultDetectors().filter((d) => d.id !== "header-order")
157
+ ```
158
+
159
+ More generally: a detector reading something your infrastructure rewrites is not neutral,
160
+ it is *misleading*. The same reasoning is why `identity-rotation` is off by default under an
161
+ IP-based actor key.
162
+ </details>
163
+
164
+ ## What you learned
165
+
166
+ - Sixteen of twenty detectors are on by default; the rest need something from you
167
+ - `cost` and `stage` decide when a detector runs, and confirming work is skipped when
168
+ nothing claimed an identity
169
+ - A failing detector is dropped, never the request
170
+ - Each detector has a ceiling it may not exceed
171
+ - `detectors` replaces; `extraDetectors` adds
172
+
173
+ ## Reference
174
+
175
+ - [The detectors](../detection/detectors.md) — all twenty in full
176
+ - [How detection works](../detection/index.md) — the pipeline and its budgets
177
+
178
+ Next: [Identity and verification](06-identity.md).