@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,134 @@
|
|
|
1
|
+
# How detection works
|
|
2
|
+
|
|
3
|
+
The pipeline a request goes through, what it costs, and what comes out the other end.
|
|
4
|
+
|
|
5
|
+
← [Documentation](../index.md)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
facts → bypass checks → cheap detectors ─┐
|
|
11
|
+
io detectors ────┼→ combine → assessment → policy → decision
|
|
12
|
+
confirming ──────┘
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Three calls, and you can stop after any of them:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
const facts = createFacts({ method, url, headers, ip }); // normalise
|
|
19
|
+
const assessment = await detector.assess(facts); // what is this?
|
|
20
|
+
const decision = detector.decide(assessment); // what do we do?
|
|
21
|
+
const { outcome } = await detector.handle(facts); // all three, plus the action
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`assess` never decides and `decide` never inspects. That separation is what makes the
|
|
25
|
+
[replay](../testing/replay.md), the [corpus](../testing/corpus.md) and the dashboard's
|
|
26
|
+
policy preview possible: `decide` is **pure**, so a candidate policy can be run over
|
|
27
|
+
recorded assessments as many times as you like and nothing about the running system moves.
|
|
28
|
+
|
|
29
|
+
## Before any detector runs
|
|
30
|
+
|
|
31
|
+
Two checks short-circuit everything:
|
|
32
|
+
|
|
33
|
+
| | |
|
|
34
|
+
| --- | --- |
|
|
35
|
+
| `ignorePaths` | Health checks, static assets, your own instrumentation. |
|
|
36
|
+
| `allowlist` | Addresses that are **not judged at all** — see [design decisions](../design/decisions.md). |
|
|
37
|
+
|
|
38
|
+
Either produces an assessment with `bypass` set and no evidence. It is counted (so the
|
|
39
|
+
dashboard can say how much traffic detection actually ran on) and nothing else happens.
|
|
40
|
+
|
|
41
|
+
## The three stages
|
|
42
|
+
|
|
43
|
+
**Cheap detectors** are synchronous by contract and run *without* an `await`. That is not
|
|
44
|
+
micro-optimisation: `await` on a non-promise still yields a microtask turn, so awaiting
|
|
45
|
+
each of a dozen detectors put a dozen scheduler round-trips on every request to your site
|
|
46
|
+
— which dominated the cost of a clean browser request. A detector that returns a promise
|
|
47
|
+
anyway is collected and awaited with the rest, so the contract is enforced by behaviour
|
|
48
|
+
rather than by trust.
|
|
49
|
+
|
|
50
|
+
**`io` detectors** run concurrently under `detectorTimeoutMs` (default 300 ms). Mislabelling
|
|
51
|
+
one as `cheap` would put an unbounded await on the request path, so the engine times out
|
|
52
|
+
any promise a `cheap` detector returns as well.
|
|
53
|
+
|
|
54
|
+
**Confirming detectors** run only when a signature matched. With no claimed identity there
|
|
55
|
+
is nothing to confirm and no lookup to make, which is why a normal browser request never
|
|
56
|
+
touches DNS.
|
|
57
|
+
|
|
58
|
+
## Failure is not the visitor's problem
|
|
59
|
+
|
|
60
|
+
A detector that throws or times out is recorded as a `DetectorFailure`, reported through
|
|
61
|
+
`onDetectorFailure`, and the assessment continues with the evidence it has. A resolver
|
|
62
|
+
being down degrades detection; it must never take down the site the detection protects.
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
assessment.failures; // [{ detector, reason: "timeout" | "error", message }]
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The same rule holds one level up: an unexpected failure inside an
|
|
69
|
+
[adapter](../integration/adapters.md) serves the request. A bot filter that fails closed
|
|
70
|
+
is an outage with extra steps.
|
|
71
|
+
|
|
72
|
+
## What comes out
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
interface Assessment {
|
|
76
|
+
requestId: string; // random per request, safe to log and echo
|
|
77
|
+
verdict: Verdict; // confirmed-bot | verified-bot | suspected-bot | human | unknown
|
|
78
|
+
botClass: BotClass;
|
|
79
|
+
identity?: string; // "googlebot", when something named itself
|
|
80
|
+
score: number; // 0–99, or 100 for proven
|
|
81
|
+
confidence: number; // 0–1, unrounded
|
|
82
|
+
certain: boolean; // ← the field that gates terminal actions
|
|
83
|
+
evidence: Evidence[]; // bot-pointing, strongest first
|
|
84
|
+
humanEvidence: Evidence[];
|
|
85
|
+
actor: ActorSnapshot; // history at the time of this request
|
|
86
|
+
durationMs: number;
|
|
87
|
+
failures: DetectorFailure[];
|
|
88
|
+
facts: RequestFacts;
|
|
89
|
+
bypass?: "allowlist" | "ignored-path";
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
See [verdicts, classes and scores](../concepts/verdicts.md) for which field to read when,
|
|
94
|
+
and [evidence and certainty](../concepts/evidence.md) for how the evidence became a score.
|
|
95
|
+
|
|
96
|
+
## Asking about a request that is not happening
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
const assessment = await detector.assess(facts, { record: false });
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
A **dry run**. Every detector runs and the verdict is real — and nothing is written down:
|
|
103
|
+
no counter moves, no actor state changes, no `assessment` event fires, no notification is
|
|
104
|
+
sent. Asking what the engine thinks of a request does not become part of the answer to
|
|
105
|
+
"what is my traffic doing?".
|
|
106
|
+
|
|
107
|
+
Reach for it wherever you want an opinion about a request nobody made: a support ticket
|
|
108
|
+
("why is this customer being challenged?"), a rule you are drafting, a test. It is what
|
|
109
|
+
`bothandlerjs explain` and the dashboard's request tester run on.
|
|
110
|
+
|
|
111
|
+
The one thing it cannot see is history. It gets an actor with no past, so `cadence`,
|
|
112
|
+
`crawl-breadth` and `rate-anomaly` have nothing to read. What it answers precisely is
|
|
113
|
+
*what would this look like as a first request* — which is what a support ticket is asking
|
|
114
|
+
anyway.
|
|
115
|
+
|
|
116
|
+
## What it costs
|
|
117
|
+
|
|
118
|
+
Measured on the clean-browser path, which is the overwhelmingly common case and the one
|
|
119
|
+
where nothing short-circuits because nothing fires:
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
assess — clean browser ~9 us ~104,000 ops/s
|
|
123
|
+
handle — clean browser ~12 us ~83,000 ops/s
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`npm run bench` reproduces it; `npm run bench:guard` is the CI ratchet that stops it
|
|
127
|
+
quietly getting worse. Turn on `metrics.perDetectorTiming` while tuning to see where the
|
|
128
|
+
time goes, and turn it off afterwards — it is two clock reads per detector per request.
|
|
129
|
+
|
|
130
|
+
## Related
|
|
131
|
+
|
|
132
|
+
- [The detectors](detectors.md) — all twenty in detail
|
|
133
|
+
- [Writing a detector](writing-a-detector.md)
|
|
134
|
+
- [Policy](../policy/index.md) — what happens to an assessment next
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# The signature database
|
|
2
|
+
|
|
3
|
+
How a client is recognised by name, and what a name is worth.
|
|
4
|
+
|
|
5
|
+
← [Documentation](../index.md) · [The detectors](detectors.md)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
161 signatures, 389 tokens, matched in a single Aho–Corasick pass over the lower-cased
|
|
10
|
+
User-Agent. One pass regardless of how many signatures there are, which is why adding to
|
|
11
|
+
this costs nothing measurable.
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
interface BotSignature {
|
|
15
|
+
id: string; // stable; appears in rules, logs and metrics
|
|
16
|
+
name: string; // "Googlebot"
|
|
17
|
+
tokens: readonly string[]; // lower-case literals; any one identifies it
|
|
18
|
+
category: BotCategory;
|
|
19
|
+
benign: boolean;
|
|
20
|
+
robotsAgent?: string; // the name to write in robots.txt
|
|
21
|
+
verification: Verification;
|
|
22
|
+
docs?: string; // the operator's own documentation
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Categories
|
|
27
|
+
|
|
28
|
+
`category` is what most policies actually match on, because it carries intent in a way an
|
|
29
|
+
individual name does not.
|
|
30
|
+
|
|
31
|
+
| Category | Examples | Typical policy |
|
|
32
|
+
| -------- | -------- | -------------- |
|
|
33
|
+
| `search` | Googlebot, Bingbot, DuckDuckBot, Yandex, Baidu, Seznam, Naver | allow |
|
|
34
|
+
| `ai` | GPTBot, ClaudeBot, PerplexityBot, CCBot, Bytespider, Amazonbot | a business decision |
|
|
35
|
+
| `seo` | AhrefsBot, Semrush, Majestic, Moz | usually rate-limit |
|
|
36
|
+
| `social` | facebookexternalhit, Twitterbot, Slackbot, Discord, Bluesky | allow — these are people sharing links |
|
|
37
|
+
| `monitoring` | UptimeRobot, Pingdom, Checkly, Better Uptime | allow |
|
|
38
|
+
| `archive` | ia_archiver, Common Crawl | your call |
|
|
39
|
+
| `feed` | Feedly, podcast clients, RSS readers | allow |
|
|
40
|
+
| `scanner` | sqlmap, Nikto, Nuclei, masscan | block |
|
|
41
|
+
| `library` | curl, wget, python-requests, Go-http-client, okhttp | usually challenge |
|
|
42
|
+
| `headless` | HeadlessChrome, Playwright, Puppeteer, Selenium | usually challenge |
|
|
43
|
+
| `embedded` | Smart TVs, set-top boxes, game consoles | allow |
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
{ id: "no-ai", match: { category: "ai" }, action: "block", reason: "Not for model training." }
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Verification
|
|
50
|
+
|
|
51
|
+
What, if anything, can check the claim:
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
type Verification =
|
|
55
|
+
| { kind: "fcrdns"; domains: readonly string[] } // reverse DNS, forward-confirmed
|
|
56
|
+
| { kind: "ip-ranges"; publishedAt?: string } // an address list the operator publishes
|
|
57
|
+
| { kind: "none" }; // no published mechanism
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`none` is honest rather than lazy: a great many crawlers publish nothing that can confirm
|
|
61
|
+
them, and for those the claim is **unfalsifiable**. The library neither confirms nor
|
|
62
|
+
accuses — it records what the client said and lets the policy decide what a self-declared
|
|
63
|
+
identity is worth. See [verifying a crawler](verification.md).
|
|
64
|
+
|
|
65
|
+
## What a name is worth
|
|
66
|
+
|
|
67
|
+
A matched signature makes `self-identified` produce `certain` evidence, and this is the
|
|
68
|
+
part worth being precise about: **the certainty is about the declaration, not about the
|
|
69
|
+
identity**.
|
|
70
|
+
|
|
71
|
+
`Googlebot/2.1` in a User-Agent proves that something *claimed to be Googlebot*. It is
|
|
72
|
+
`confirmed-bot` — automation, certainly, because no person's browser sends that string —
|
|
73
|
+
and it becomes `verified-bot` only once `crawler-verification` confirms it. A forgery
|
|
74
|
+
that is refuted becomes an `impersonator`, which is the strongest thing the library ever
|
|
75
|
+
concludes about anybody.
|
|
76
|
+
|
|
77
|
+
So a rule matching `identity: ["googlebot"]` alone matches forgeries too. Match on
|
|
78
|
+
proof when it matters:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
{ id: "trust-google", match: { identity: ["googlebot"], verdict: "verified-bot" }, action: "allow" }
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Adding your own
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
new BotHandler({
|
|
88
|
+
extraSignatures: [
|
|
89
|
+
{
|
|
90
|
+
id: "acme-partner",
|
|
91
|
+
name: "Acme partner integration",
|
|
92
|
+
tokens: ["acme-partner-sync"],
|
|
93
|
+
category: "library",
|
|
94
|
+
benign: true,
|
|
95
|
+
verification: { kind: "ip-ranges" },
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// and, if you know where they call from:
|
|
101
|
+
detector.updateCrawlerRanges("acme-partner", ["198.51.100.0/24"]);
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`signatures` replaces the shipped set entirely; `extraSignatures` adds to it. Tokens are
|
|
105
|
+
lower-case literals rather than patterns, which is what keeps the match linear.
|
|
106
|
+
|
|
107
|
+
## Related
|
|
108
|
+
|
|
109
|
+
- [Verifying a crawler](verification.md) — turning a claim into a verdict
|
|
110
|
+
- [robots.txt](../policy/robots.md) — `robotsAgent` is what makes this generatable
|
|
111
|
+
- [The detectors](detectors.md#self-identified)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# Verifying a crawler
|
|
2
|
+
|
|
3
|
+
Turning "I am Googlebot" into a verdict — or into a refutation.
|
|
4
|
+
|
|
5
|
+
← [Documentation](../index.md) · [The detectors](detectors.md)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
A User-Agent is a claim. Two mechanisms can check one, depending on what the operator
|
|
10
|
+
publishes, and both are run by
|
|
11
|
+
[`crawler-verification`](detectors.md#crawler-verification) — which only runs at all when
|
|
12
|
+
a [signature](signatures.md) matched, because with no claim there is nothing to check.
|
|
13
|
+
|
|
14
|
+
## Forward-confirmed reverse DNS
|
|
15
|
+
|
|
16
|
+
The mechanism Google, Bing, Yandex, Baidu and Apple document for their own crawlers:
|
|
17
|
+
|
|
18
|
+
1. `PTR` the client address → `crawl-66-249-66-1.googlebot.com`
|
|
19
|
+
2. Check the name ends in a domain the signature lists.
|
|
20
|
+
3. Resolve that name forward → does it come back to the same address?
|
|
21
|
+
|
|
22
|
+
Step 3 is the one that matters. Reverse DNS alone is controlled by whoever owns the
|
|
23
|
+
address block; forward-confirming it means the *crawler's* DNS has to agree.
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
{ verification: { kind: "fcrdns", domains: ["googlebot.com", "google.com"] } }
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
| Outcome | Evidence |
|
|
30
|
+
| ------- | -------- |
|
|
31
|
+
| Forward-confirmed under a listed domain | `certain` → `verified-bot` |
|
|
32
|
+
| Resolves, but to a name outside the domains | `certain` refutation → `impersonator` |
|
|
33
|
+
| No answer, timeout, resolver error | **nothing** |
|
|
34
|
+
|
|
35
|
+
That last row is the important one. A resolver having a bad afternoon must not look like
|
|
36
|
+
an accusation, so an unhappy lookup produces no evidence at all — not weak evidence, none.
|
|
37
|
+
|
|
38
|
+
DNS results are cached (`cachingResolver`), and the whole thing runs under
|
|
39
|
+
`detectorTimeoutMs`.
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
new BotHandler({
|
|
43
|
+
resolver: cachingResolver(nodeDnsResolver(), { ttlMs: 600_000, maxEntries: 5_000 }),
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### `treatMissingPtrAsForgery`
|
|
48
|
+
|
|
49
|
+
Off by default, and it should stay off unless you know what you are doing. Some legitimate
|
|
50
|
+
crawlers have no `PTR` at all; treating absence as forgery converts a gap in somebody
|
|
51
|
+
else's DNS into an accusation.
|
|
52
|
+
|
|
53
|
+
## Published address ranges
|
|
54
|
+
|
|
55
|
+
Twelve shipped signatures verify by address instead — every AI crawler among them. Where
|
|
56
|
+
it is available this is better than reverse DNS in three ways: a lookup instead of a
|
|
57
|
+
network round trip on the request path, immune to somebody else's DNS having a bad
|
|
58
|
+
afternoon, and it works for the several crawlers that publish ranges and no useful `PTR`.
|
|
59
|
+
|
|
60
|
+
**The library ships no address data**, and that is deliberate: a range baked into a
|
|
61
|
+
release is wrong by the time somebody installs it, and being wrong here means verifying
|
|
62
|
+
whoever has since been handed the address. What it ships is the URL each operator
|
|
63
|
+
publishes.
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
import { startCrawlerRangeRefresh } from "@osqd/bothandlerjs";
|
|
67
|
+
|
|
68
|
+
const stop = startCrawlerRangeRefresh(detector); // twice a day by default
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Opt-in, because it makes outbound requests, and a dependency-free package quietly fetching
|
|
72
|
+
URLs on a timer is not something to inherit by accident.
|
|
73
|
+
|
|
74
|
+
### What it will and will not accept
|
|
75
|
+
|
|
76
|
+
It reads the two formats anybody publishes: a JSON document with a `prefixes` array of
|
|
77
|
+
`{ ipv4Prefix }` / `{ ipv6Prefix }` objects — the shape Google standardised and the AI
|
|
78
|
+
crawlers copied — and a plain-text list of one address or CIDR per line.
|
|
79
|
+
|
|
80
|
+
Two things are refused **whole**, because these ranges do not merely describe a crawler,
|
|
81
|
+
they *verify* one — and an address inside them is a `verified-bot`, which most policies
|
|
82
|
+
allow:
|
|
83
|
+
|
|
84
|
+
- a list containing a block bigger than any crawler owns (`/8` or wider for IPv4);
|
|
85
|
+
- an empty list.
|
|
86
|
+
|
|
87
|
+
A partially-parsed list is refused too. The operation replaces a set, and a set that half
|
|
88
|
+
arrived is worse than the one already installed.
|
|
89
|
+
|
|
90
|
+
### It fails open, per source
|
|
91
|
+
|
|
92
|
+
One publisher being down, having moved its file, or serving something unrecognisable
|
|
93
|
+
leaves every other crawler's ranges as they were — and leaves *that* crawler's ranges as
|
|
94
|
+
they were too, which is the state it was in before you called this. Failures are raised as
|
|
95
|
+
warnings, so they land in the [dashboard's notices](../operations/dashboard.md).
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
const result = await refreshCrawlerRanges(detector, { by: "the nightly job" });
|
|
99
|
+
result.updated; // [{ id: "googlebot", prefixes: 42 }]
|
|
100
|
+
result.failed; // [{ id: "gptbot", reason: "503 Service Unavailable" }]
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Supplying your own
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
await refreshCrawlerRanges(detector, {
|
|
107
|
+
sources: [{ id: "gptbot", url: "https://internal.example/mirrors/gptbot.json" }],
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// or set them directly, from any source you like
|
|
111
|
+
detector.updateCrawlerRanges("gptbot", ["203.0.113.0/24"]);
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Mirroring the lists internally is a reasonable thing to do: it removes an outbound
|
|
115
|
+
dependency from your servers and lets you review a change before it takes effect.
|
|
116
|
+
`PUBLISHED_CRAWLER_RANGES` is the shipped list of pointers, and nothing stops you pinning
|
|
117
|
+
all of them.
|
|
118
|
+
|
|
119
|
+
## Related
|
|
120
|
+
|
|
121
|
+
- [The signature database](signatures.md) — what is being verified
|
|
122
|
+
- [Design decisions](../design/decisions.md) — why no address data ships
|
|
123
|
+
- [Runtime changes](../operations/runtime-changes.md) — ranges are a runtime change like any other
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# Writing a detector
|
|
2
|
+
|
|
3
|
+
The contract, and the one rule you cannot bend.
|
|
4
|
+
|
|
5
|
+
← [Documentation](../index.md) · [The detectors](detectors.md)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
A detector returns **evidence** and never a verdict. It does not know what will be done
|
|
10
|
+
with what it found, and that is deliberate: the same observation is worth a tag on a
|
|
11
|
+
documentation site and a challenge on a checkout.
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import type { Detector, Evidence } from "@osqd/bothandlerjs";
|
|
15
|
+
|
|
16
|
+
export function checkoutVelocity(): Detector {
|
|
17
|
+
return {
|
|
18
|
+
id: "checkout-velocity",
|
|
19
|
+
description: "More checkout attempts in a minute than a person makes",
|
|
20
|
+
cost: "cheap",
|
|
21
|
+
stage: "always",
|
|
22
|
+
|
|
23
|
+
inspect(ctx): Evidence | undefined {
|
|
24
|
+
if (!ctx.facts.path.startsWith("/checkout")) return undefined;
|
|
25
|
+
|
|
26
|
+
const attempts = ctx.state.requestsWithin(60_000, ctx.facts.timestamp);
|
|
27
|
+
if (attempts < 8) return undefined;
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
detector: "checkout-velocity",
|
|
31
|
+
summary: `${attempts} checkout attempts in a minute`,
|
|
32
|
+
direction: "bot",
|
|
33
|
+
certainty: "strong", // not `certain` — a shared address explains it too
|
|
34
|
+
weight: 0.6,
|
|
35
|
+
botClass: "automation",
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
new BotHandler({ extraDetectors: [checkoutVelocity()] });
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## The context
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
interface DetectionContext {
|
|
48
|
+
facts: RequestFacts; // the normalised request
|
|
49
|
+
ua: ParsedUserAgent; // parsed once, shared by every detector
|
|
50
|
+
actor: ActorSnapshot; // history *before* this request
|
|
51
|
+
state: ActorState; // the live object: requestsWithin, intervalStats, …
|
|
52
|
+
clock: Clock;
|
|
53
|
+
signatures: MultiPatternMatcher;
|
|
54
|
+
signatureMatches: BotSignature[];
|
|
55
|
+
resolver: DnsResolver;
|
|
56
|
+
ranges: Map<string, IpRangeSet>;
|
|
57
|
+
shared: Map<string, unknown>; // scratch space for one request
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`shared` exists so two detectors can avoid doing the same expensive thing twice within a
|
|
62
|
+
single assessment. It is discarded afterwards.
|
|
63
|
+
|
|
64
|
+
## The rule about certainty
|
|
65
|
+
|
|
66
|
+
**`certain` requires a written `deterministicBasis`.** Not a strong feeling — an
|
|
67
|
+
explanation of why no legitimate client produces this:
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
{
|
|
71
|
+
certainty: "certain",
|
|
72
|
+
deterministicBasis:
|
|
73
|
+
"HTTP/2 forbids connection-specific headers outright. A compliant client cannot " +
|
|
74
|
+
"send one, so its presence is a protocol violation rather than an unusual choice.",
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`strictEvidence` (on by default) enforces it: evidence marked `certain` with no basis is
|
|
79
|
+
**rejected** and reported through `onWarning`. The mechanism is there because "is this
|
|
80
|
+
really proof?" is a question people answer optimistically at 2am, and a downgrade is a
|
|
81
|
+
much better failure than a false accusation.
|
|
82
|
+
|
|
83
|
+
If you are unsure, you want `strong`. A `strong` signal still tags, delays, rate-limits
|
|
84
|
+
and challenges; the only thing it cannot do is deny somebody service, and if you cannot
|
|
85
|
+
write the sentence then it should not be able to.
|
|
86
|
+
|
|
87
|
+
## Cost and stage
|
|
88
|
+
|
|
89
|
+
| | |
|
|
90
|
+
| --- | --- |
|
|
91
|
+
| `cost: "cheap"` | Synchronous, in-memory. Run without an await. |
|
|
92
|
+
| `cost: "io"` | May touch the network or a store. Run concurrently under a timeout. |
|
|
93
|
+
| `stage: "always"` | Every request. |
|
|
94
|
+
| `stage: "confirming"` | Only when a signature matched — there is a claim to check. |
|
|
95
|
+
|
|
96
|
+
Mislabelling an `io` detector as `cheap` would put an unbounded await on the request path.
|
|
97
|
+
The engine times out any promise a `cheap` detector returns anyway, so the failure mode is
|
|
98
|
+
a timeout rather than a hung request — but label it correctly.
|
|
99
|
+
|
|
100
|
+
## Human-pointing evidence
|
|
101
|
+
|
|
102
|
+
`direction: "human"` puts the evidence on the other side of the scale, where it
|
|
103
|
+
*discounts* suspicion. Use it for positive marks of a real session, and hold it to the
|
|
104
|
+
same standard: a `certain` human signal means somebody's own code asserted it, not that a
|
|
105
|
+
cookie looked plausible.
|
|
106
|
+
|
|
107
|
+
## Families
|
|
108
|
+
|
|
109
|
+
If your detector fires on the same root cause as another, declare a `family`. Within a
|
|
110
|
+
family the engine takes the strongest observation rather than compounding them — see
|
|
111
|
+
[families](../concepts/evidence.md#families).
|
|
112
|
+
|
|
113
|
+
## Replacing a shipped detector
|
|
114
|
+
|
|
115
|
+
`defaultDetectors()` returns the standard set as an array, so swapping one for a
|
|
116
|
+
configured version is a `map`:
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
import { defaultDetectors, probeSignatureDetector } from "@osqd/bothandlerjs";
|
|
120
|
+
|
|
121
|
+
new BotHandler({
|
|
122
|
+
detectors: defaultDetectors().map((d) =>
|
|
123
|
+
d.id === "probe-signature" ? probeSignatureDetector({ ignore: ["/wp-login.php"] }) : d,
|
|
124
|
+
),
|
|
125
|
+
});
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Testing it
|
|
129
|
+
|
|
130
|
+
Put your handler in front of the [corpus](../testing/corpus.md). A new detector that
|
|
131
|
+
scores an extra ten points on every headless browser is doing its job; one that also
|
|
132
|
+
scores three points on every Safari user is not, and only the corpus will tell you which
|
|
133
|
+
you wrote.
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
import { runCorpus } from "@osqd/bothandlerjs/corpus";
|
|
137
|
+
|
|
138
|
+
const scorecard = await runCorpus({
|
|
139
|
+
create: ({ resolver, clock }) => new BotHandler({ extraDetectors: [mine()], resolver, clock }),
|
|
140
|
+
assertActions: false,
|
|
141
|
+
});
|
|
142
|
+
expect(scorecard.falsePositives).toEqual([]);
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
See `examples/custom-detector.ts` for a complete one.
|
package/docs/index.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Documentation
|
|
2
|
+
|
|
3
|
+
**bothandlerjs** — bot traffic detection and handling for TypeScript.
|
|
4
|
+
|
|
5
|
+
← [Back to the README](../README.md)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Start here
|
|
10
|
+
|
|
11
|
+
**New to this?** [**The course**](course/index.md) teaches every capability in sixteen
|
|
12
|
+
lessons, in the order that makes each one make sense, with something to run at every step.
|
|
13
|
+
The pages below are the reference: they answer "how does X work?" rather than "what do I do
|
|
14
|
+
next?".
|
|
15
|
+
|
|
16
|
+
| | |
|
|
17
|
+
| --- | --- |
|
|
18
|
+
| [The course](course/index.md) | Sixteen lessons, one running example, from first assessment to a policy you can defend. |
|
|
19
|
+
| [Installation](start/installation.md) | Install it, and what it needs from your runtime. |
|
|
20
|
+
| [Your first integration](start/first-integration.md) | Ten lines that assess traffic, and what each one does. |
|
|
21
|
+
| [Choosing a policy](start/choosing-a-policy.md) | Pick a preset, prove it against your own traffic, then deploy it. |
|
|
22
|
+
| [Upgrading](start/upgrading.md) | What changed between versions, and what you have to do about it. |
|
|
23
|
+
|
|
24
|
+
## The ideas the library is built on
|
|
25
|
+
|
|
26
|
+
Read these once and everything else follows from them. They are short.
|
|
27
|
+
|
|
28
|
+
| | |
|
|
29
|
+
| --- | --- |
|
|
30
|
+
| [Evidence and certainty](concepts/evidence.md) | The two tiers, why they combine by different rules, and what "proof" means here. |
|
|
31
|
+
| [Verdicts, classes and scores](concepts/verdicts.md) | What the engine concludes, and which field you should actually be reading. |
|
|
32
|
+
| [The safety guard](concepts/the-guard.md) | The thing that stops a guess closing a door. The central mechanism. |
|
|
33
|
+
| [Actors and behavioural memory](concepts/actors.md) | Who "the same client" is, what is remembered about them, and for how long. |
|
|
34
|
+
| [Threat model](concepts/threat-model.md) | What this defends against, what it does not, and what it costs to be wrong. |
|
|
35
|
+
|
|
36
|
+
## Detection
|
|
37
|
+
|
|
38
|
+
| | |
|
|
39
|
+
| --- | --- |
|
|
40
|
+
| [How detection works](detection/index.md) | The pipeline: stages, budgets, failure, and what an assessment contains. |
|
|
41
|
+
| [The detectors](detection/detectors.md) | All twenty, each with what it reads, why it exists, and what it costs. |
|
|
42
|
+
| [The signature database](detection/signatures.md) | How a client is recognised by name, and what a name is worth. |
|
|
43
|
+
| [Verifying a crawler](detection/verification.md) | Reverse DNS, published address ranges, and refuting a forgery. |
|
|
44
|
+
| [Browser signals](detection/client-signals.md) | The optional page script, and the ceiling on anything it reports. |
|
|
45
|
+
| [Writing a detector](detection/writing-a-detector.md) | The contract, and the rules about certainty you have to keep. |
|
|
46
|
+
|
|
47
|
+
## Policy
|
|
48
|
+
|
|
49
|
+
| | |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| [Policy overview](policy/index.md) | Rules, order, and how a decision is reached. |
|
|
52
|
+
| [Matching requests](policy/rules.md) | Every field a rule can match on, with examples. |
|
|
53
|
+
| [Actions](policy/actions.md) | All ten, ordered by what each costs a client that turns out to be a person. |
|
|
54
|
+
| [Presets](policy/presets.md) | The eight shipped policies, and what each is for. |
|
|
55
|
+
| [robots.txt](policy/robots.md) | Generating the file your policy implies. |
|
|
56
|
+
|
|
57
|
+
## The challenge
|
|
58
|
+
|
|
59
|
+
| | |
|
|
60
|
+
| --- | --- |
|
|
61
|
+
| [The challenge](challenge/index.md) | Proof of work, clearance, and exactly what it buys. |
|
|
62
|
+
| [Languages](challenge/localisation.md) | Writing the interstitial in a language the visitor reads. |
|
|
63
|
+
|
|
64
|
+
## Running it
|
|
65
|
+
|
|
66
|
+
| | |
|
|
67
|
+
| --- | --- |
|
|
68
|
+
| [Operations overview](operations/index.md) | What to watch, and what to do when it moves. |
|
|
69
|
+
| [The dashboard](operations/dashboard.md) | What it shows, what it refuses to do, and every option it takes. |
|
|
70
|
+
| [Metrics](operations/metrics.md) | Counters, histograms and the Prometheus exposition. |
|
|
71
|
+
| [The traffic audit](operations/audit.md) | Watching the shape of your traffic rather than any one request. |
|
|
72
|
+
| [Notifications](operations/notifications.md) | Getting told, without being told a thousand times. |
|
|
73
|
+
| [Runtime changes](operations/runtime-changes.md) | Changing rules, the guard, ranges and actors without a deploy. |
|
|
74
|
+
|
|
75
|
+
## Integrating it
|
|
76
|
+
|
|
77
|
+
| | |
|
|
78
|
+
| --- | --- |
|
|
79
|
+
| [Integration overview](integration/index.md) | Where the library sits in a request. |
|
|
80
|
+
| [Adapters](integration/adapters.md) | Express, Fastify, Koa, Fetch, Hono, Next.js — and writing your own. |
|
|
81
|
+
| [Getting the client address right](integration/client-ip.md) | The highest-consequence setting in the library. |
|
|
82
|
+
| [Shared state](integration/stores.md) | What has to be shared between replicas, and what deliberately is not. |
|
|
83
|
+
|
|
84
|
+
## Proving it before it meets anybody
|
|
85
|
+
|
|
86
|
+
| | |
|
|
87
|
+
| --- | --- |
|
|
88
|
+
| [Testing overview](testing/index.md) | The three ways to find out what a policy does before it does it. |
|
|
89
|
+
| [The command line](testing/cli.md) | `replay`, `check`, `explain`, `robots`, `detectors`. |
|
|
90
|
+
| [The traffic corpus](testing/corpus.md) | 526 shapes of real traffic, and how to run your own config against them. |
|
|
91
|
+
| [Replaying your own logs](testing/replay.md) | The most useful thing you can do before deploying anything. |
|
|
92
|
+
| [Try it locally](testing/try-it.md) | A demo site, a live dashboard and eighteen scripted clients. |
|
|
93
|
+
|
|
94
|
+
## Reference
|
|
95
|
+
|
|
96
|
+
| | |
|
|
97
|
+
| --- | --- |
|
|
98
|
+
| [Configuration](reference/configuration.md) | Every option, with its default and its consequence. |
|
|
99
|
+
| [API](reference/api.md) | Every export, grouped by what it is for. |
|
|
100
|
+
| [Design decisions](design/decisions.md) | The things this library refuses to do, and why. |
|