@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,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.
|