@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.
- package/CHANGELOG.md +516 -0
- package/LICENSE +102 -0
- package/README.md +308 -0
- package/bin/bothandlerjs.mjs +14 -0
- package/dist/actions/index.d.ts +22 -0
- package/dist/actions/types.d.ts +59 -0
- package/dist/adapters/fastify.d.ts +43 -0
- package/dist/adapters/fetch.d.ts +51 -0
- package/dist/adapters/index.cjs +723 -0
- package/dist/adapters/index.cjs.map +1 -0
- package/dist/adapters/index.d.ts +8 -0
- package/dist/adapters/index.js +692 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/koa.d.ts +27 -0
- package/dist/adapters/node.d.ts +29 -0
- package/dist/adapters/shared.d.ts +39 -0
- package/dist/audit.d.ts +172 -0
- package/dist/challenge/index.d.ts +176 -0
- package/dist/challenge/language.d.ts +57 -0
- package/dist/challenge/page.d.ts +42 -0
- package/dist/challenge/pow.d.ts +33 -0
- package/dist/challenge/token.d.ts +72 -0
- package/dist/cli.cjs +13388 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +13358 -0
- package/dist/cli.js.map +1 -0
- package/dist/client/index.cjs +139 -0
- package/dist/client/index.cjs.map +1 -0
- package/dist/client/index.d.ts +63 -0
- package/dist/client/index.js +112 -0
- package/dist/client/index.js.map +1 -0
- package/dist/config.d.ts +316 -0
- package/dist/core.d.ts +443 -0
- package/dist/corpus/adversarial.d.ts +2 -0
- package/dist/corpus/advertising-email.d.ts +2 -0
- package/dist/corpus/ai-crawlers.d.ts +2 -0
- package/dist/corpus/benign-bots.d.ts +2 -0
- package/dist/corpus/cdn-gateways.d.ts +2 -0
- package/dist/corpus/cookies.d.ts +49 -0
- package/dist/corpus/crawlers-regional.d.ts +2 -0
- package/dist/corpus/crawlers-vertical.d.ts +2 -0
- package/dist/corpus/headers.d.ts +403 -0
- package/dist/corpus/humans-apps.d.ts +2 -0
- package/dist/corpus/humans-browsers.d.ts +2 -0
- package/dist/corpus/humans.d.ts +2 -0
- package/dist/corpus/index.cjs +4196 -0
- package/dist/corpus/index.cjs.map +1 -0
- package/dist/corpus/index.d.ts +37 -0
- package/dist/corpus/index.js +4132 -0
- package/dist/corpus/index.js.map +1 -0
- package/dist/corpus/infrastructure.d.ts +17 -0
- package/dist/corpus/libraries-extended.d.ts +2 -0
- package/dist/corpus/ranges.d.ts +31 -0
- package/dist/corpus/reputation.d.ts +21 -0
- package/dist/corpus/runner.d.ts +130 -0
- package/dist/corpus/schema.d.ts +185 -0
- package/dist/corpus/tooling.d.ts +2 -0
- package/dist/corpus/unwanted.d.ts +2 -0
- package/dist/crawler-ranges.d.ts +113 -0
- package/dist/dashboard/client.generated.d.ts +7 -0
- package/dist/dashboard/feed.d.ts +186 -0
- package/dist/dashboard/index.d.ts +5 -0
- package/dist/dashboard/page.d.ts +51 -0
- package/dist/dashboard/parse-request.d.ts +28 -0
- package/dist/dashboard/preview.d.ts +45 -0
- package/dist/dashboard/server.d.ts +70 -0
- package/dist/dashboard/types.d.ts +714 -0
- package/dist/detectors/accept-signature.d.ts +6 -0
- package/dist/detectors/browsing-coherence.d.ts +44 -0
- package/dist/detectors/cadence.d.ts +28 -0
- package/dist/detectors/clearance.d.ts +25 -0
- package/dist/detectors/client-hints.d.ts +19 -0
- package/dist/detectors/client-signals.d.ts +27 -0
- package/dist/detectors/crawl-breadth.d.ts +28 -0
- package/dist/detectors/crawler-verification.d.ts +39 -0
- package/dist/detectors/fetch-metadata.d.ts +20 -0
- package/dist/detectors/header-integrity.d.ts +26 -0
- package/dist/detectors/header-order.d.ts +27 -0
- package/dist/detectors/identity-rotation.d.ts +34 -0
- package/dist/detectors/index.d.ts +56 -0
- package/dist/detectors/ip-intelligence.d.ts +30 -0
- package/dist/detectors/known-bots.d.ts +94 -0
- package/dist/detectors/probe-signature.d.ts +50 -0
- package/dist/detectors/rate-anomaly.d.ts +36 -0
- package/dist/detectors/self-identified.d.ts +33 -0
- package/dist/detectors/session-integrity.d.ts +24 -0
- package/dist/detectors/tls-fingerprint.d.ts +34 -0
- package/dist/detectors/trap.d.ts +98 -0
- package/dist/detectors/types.d.ts +70 -0
- package/dist/detectors/ua-coherence.d.ts +32 -0
- package/dist/evidence.d.ts +45 -0
- package/dist/facts.d.ts +46 -0
- package/dist/index.cjs +9600 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +63 -0
- package/dist/index.js +9444 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/async.d.ts +16 -0
- package/dist/internal/clock.d.ts +17 -0
- package/dist/internal/crypto.d.ts +26 -0
- package/dist/internal/dns.d.ts +100 -0
- package/dist/internal/emitter.d.ts +18 -0
- package/dist/internal/http.d.ts +22 -0
- package/dist/internal/ip.d.ts +73 -0
- package/dist/internal/lru.d.ts +34 -0
- package/dist/internal/matcher.d.ts +41 -0
- package/dist/internal/pattern.d.ts +14 -0
- package/dist/internal/ua.d.ts +92 -0
- package/dist/metrics.d.ts +120 -0
- package/dist/notify/hub.d.ts +70 -0
- package/dist/notify/index.d.ts +7 -0
- package/dist/notify/redact.d.ts +48 -0
- package/dist/notify/sinks.d.ts +62 -0
- package/dist/notify/types.d.ts +56 -0
- package/dist/policy/index.d.ts +7 -0
- package/dist/policy/match.d.ts +20 -0
- package/dist/policy/policy.d.ts +123 -0
- package/dist/policy/presets.d.ts +161 -0
- package/dist/policy/types.d.ts +155 -0
- package/dist/robots.d.ts +83 -0
- package/dist/state.d.ts +161 -0
- package/dist/stores/index.d.ts +5 -0
- package/dist/stores/memory.d.ts +33 -0
- package/dist/stores/redis.d.ts +59 -0
- package/dist/stores/types.d.ts +37 -0
- package/dist/types.d.ts +250 -0
- package/docs/challenge/index.md +152 -0
- package/docs/challenge/localisation.md +105 -0
- package/docs/concepts/actors.md +113 -0
- package/docs/concepts/evidence.md +133 -0
- package/docs/concepts/the-guard.md +121 -0
- package/docs/concepts/threat-model.md +120 -0
- package/docs/concepts/verdicts.md +108 -0
- package/docs/course/01-first-assessment.md +178 -0
- package/docs/course/02-proof-and-suspicion.md +164 -0
- package/docs/course/03-verdicts-and-scores.md +160 -0
- package/docs/course/04-the-guard.md +200 -0
- package/docs/course/05-detectors.md +178 -0
- package/docs/course/06-identity.md +161 -0
- package/docs/course/07-actors.md +178 -0
- package/docs/course/08-traps.md +183 -0
- package/docs/course/09-rules.md +224 -0
- package/docs/course/10-actions-and-presets.md +216 -0
- package/docs/course/11-the-challenge.md +227 -0
- package/docs/course/12-going-live.md +211 -0
- package/docs/course/13-operating-it.md +238 -0
- package/docs/course/14-scaling.md +195 -0
- package/docs/course/15-extending.md +259 -0
- package/docs/course/16-proving-it.md +266 -0
- package/docs/course/index.md +112 -0
- package/docs/design/decisions.md +216 -0
- package/docs/detection/client-signals.md +96 -0
- package/docs/detection/detectors.md +376 -0
- package/docs/detection/index.md +134 -0
- package/docs/detection/signatures.md +111 -0
- package/docs/detection/verification.md +123 -0
- package/docs/detection/writing-a-detector.md +145 -0
- package/docs/index.md +100 -0
- package/docs/integration/adapters.md +138 -0
- package/docs/integration/client-ip.md +114 -0
- package/docs/integration/index.md +55 -0
- package/docs/integration/stores.md +76 -0
- package/docs/operations/audit.md +116 -0
- package/docs/operations/dashboard.md +725 -0
- package/docs/operations/index.md +101 -0
- package/docs/operations/metrics.md +74 -0
- package/docs/operations/notifications.md +72 -0
- package/docs/operations/runtime-changes.md +118 -0
- package/docs/policy/actions.md +196 -0
- package/docs/policy/index.md +84 -0
- package/docs/policy/presets.md +191 -0
- package/docs/policy/robots.md +128 -0
- package/docs/policy/rules.md +134 -0
- package/docs/reference/api.md +126 -0
- package/docs/reference/configuration.md +175 -0
- package/docs/start/choosing-a-policy.md +127 -0
- package/docs/start/first-integration.md +127 -0
- package/docs/start/installation.md +63 -0
- package/docs/start/upgrading.md +72 -0
- package/docs/testing/cli.md +119 -0
- package/docs/testing/corpus.md +129 -0
- package/docs/testing/index.md +61 -0
- package/docs/testing/replay.md +102 -0
- package/docs/testing/try-it.md +189 -0
- 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).
|