@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,120 @@
|
|
|
1
|
+
# Threat model
|
|
2
|
+
|
|
3
|
+
What this library stops, what it raises the cost of, and what it cannot touch.
|
|
4
|
+
|
|
5
|
+
← [Documentation](../index.md)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
Most bot tooling is vague about this, which is how people end up trusting it for things it
|
|
10
|
+
cannot do. This page is the honest version, and every claim on it is
|
|
11
|
+
[measured](../testing/corpus.md) rather than asserted.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## The populations
|
|
16
|
+
|
|
17
|
+
| Who | What they send | What happens |
|
|
18
|
+
| --- | -------------- | ------------ |
|
|
19
|
+
| **Honest automation** | `curl/8.4.0`, `python-requests`, `Googlebot` | proven by [self-declaration](evidence.md); you decide by policy |
|
|
20
|
+
| **Verifiable crawlers** | a named identity backed by DNS or published ranges | [proven either way](../detection/verification.md) — confirmed or refuted |
|
|
21
|
+
| **Careless scrapers** | a copied User-Agent and nothing else | caught by four independent signals |
|
|
22
|
+
| **Competent scrapers** | a copied header set, in the right order, with Client Hints | not caught from one request; caught by [behaviour](../detection/detectors.md) if they are quick |
|
|
23
|
+
| **Patient adversaries** | real Chrome, residential proxies, human pace | **not caught, and this library says so** |
|
|
24
|
+
| **Scanners** | `/.env`, `/.git/config`, JNDI, `TRACE` | [`probe-signature`](../detection/detectors.md), one request at a time |
|
|
25
|
+
| **People who look odd** | Tor, corporate proxies, screen readers, old phones | suspected, never denied — this is what [the guard](the-guard.md) is for |
|
|
26
|
+
|
|
27
|
+
The last two rows are the same problem seen from both ends, and the reason the whole design
|
|
28
|
+
is organised around proof rather than points.
|
|
29
|
+
|
|
30
|
+
## The evasion ladder
|
|
31
|
+
|
|
32
|
+
Five cases in the corpus, running from crude to undetectable. **The top rungs are expected
|
|
33
|
+
to fail**, and they are kept so that nobody can claim otherwise:
|
|
34
|
+
|
|
35
|
+
| | | |
|
|
36
|
+
|-|-|-|
|
|
37
|
+
| 1 | Copied the User-Agent only | caught, four independent signals |
|
|
38
|
+
| 2 | Copied the header set, not the order | caught, weakly |
|
|
39
|
+
| 3 | Copied the order and the Client Hints | **not caught** from one request |
|
|
40
|
+
| 4 | …at a machine-perfect rhythm | caught by `cadence`, and only by `cadence` |
|
|
41
|
+
| 5 | …paced like a person, a few pages per address | **not caught at all** |
|
|
42
|
+
|
|
43
|
+
At level 5 the difference from a person has stopped being technical. What defeats it is
|
|
44
|
+
**cost** — a [proof of work](../challenge/index.md), or an account — not detection.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## What this library cannot do
|
|
49
|
+
|
|
50
|
+
**Stop a determined, well-resourced adversary.** Someone running real Chrome through a
|
|
51
|
+
residential proxy pool, at human pace, with correct headers, solving the proof of work, is
|
|
52
|
+
indistinguishable from a person at the HTTP layer. What this raises is the *cost*.
|
|
53
|
+
|
|
54
|
+
**Prove somebody is human.** No signal here does that and none claims to. Proof of work
|
|
55
|
+
proves CPU. `navigator.webdriver` proves what the client chose to report. The only
|
|
56
|
+
conclusive human signal is [your own application's assertion](../reference/configuration.md).
|
|
57
|
+
|
|
58
|
+
**Replace authentication, authorisation or a WAF.** It classifies traffic. It is not a
|
|
59
|
+
security boundary and nothing about it should be load-bearing for access control. This is
|
|
60
|
+
the most important sentence on the page.
|
|
61
|
+
|
|
62
|
+
**Stop a DDoS.** It runs inside your process, after the connection is accepted. Volume that
|
|
63
|
+
hurts you at the network layer needs handling at the network layer.
|
|
64
|
+
|
|
65
|
+
**Ship IP intelligence.** Address-to-operator mappings go stale within weeks, and a stale
|
|
66
|
+
mapping is a false positive with a long half-life. Bring your own, from a source you refresh
|
|
67
|
+
and can audit.
|
|
68
|
+
|
|
69
|
+
**Be right about a shared address.** Behind CGNAT, "one [actor](actors.md)" is thousands of
|
|
70
|
+
people — which is exactly why the behavioural signals are capped where they are.
|
|
71
|
+
|
|
72
|
+
**Escalate on a wordlist walk.** `probe-signature` reads one request at a time, so a scanner
|
|
73
|
+
working through five hundred paths produces five hundred separate observations rather than a
|
|
74
|
+
mounting case. That is the price of a detector that runs unchanged over a log file;
|
|
75
|
+
enumeration over time is what `rate-anomaly`, `cadence` and `crawl-breadth` are for.
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## The adversary's view of the library itself
|
|
80
|
+
|
|
81
|
+
Worth thinking about, because a detector that explains itself to the client is a detector
|
|
82
|
+
being tuned against.
|
|
83
|
+
|
|
84
|
+
**Verdict headers are off by default.** An `X-Bot-Score` in the response is a live feedback
|
|
85
|
+
signal: change one header, watch the number fall, iterate. Request-side tagging tells your
|
|
86
|
+
application the same thing and the client nothing.
|
|
87
|
+
|
|
88
|
+
**Metrics and the dashboard describe your detection.** The detector-firing series is exactly
|
|
89
|
+
what somebody tuning a scraper would like to read. Serve them where only you can reach them,
|
|
90
|
+
and use [the dashboard's redaction and sections](../operations/dashboard.md) when more people
|
|
91
|
+
need to watch than need to know.
|
|
92
|
+
|
|
93
|
+
**The challenge is public by design.** Its difficulty and its mechanism are visible to
|
|
94
|
+
everyone. That is fine — it is a cost, not a secret, and a cost that only works while hidden
|
|
95
|
+
is not a cost.
|
|
96
|
+
|
|
97
|
+
**Traps are the one thing to keep quiet about.** A trap works because no person can reach
|
|
98
|
+
it. Publishing the path in a public repository, a `robots.txt` `Allow` line, or a client-side
|
|
99
|
+
comment turns proof back into a guess.
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## What it is genuinely good at
|
|
104
|
+
|
|
105
|
+
Being clear about the limits above is what makes this list credible:
|
|
106
|
+
|
|
107
|
+
- **Identifying honest automation exactly**, so you can decide about it by policy rather
|
|
108
|
+
than by suspicion — including the [AI crawlers, split by job](../policy/presets.md#decline-ai-training).
|
|
109
|
+
- **Refuting forged identities**, with proof rather than inference.
|
|
110
|
+
- **Making bulk extraction expensive** without touching anybody who is not doing it.
|
|
111
|
+
- **Never denying a person on a guess** — enforced by [the guard](the-guard.md), checked by
|
|
112
|
+
[the corpus](../testing/corpus.md), and visible as `bothandler_downgrades_total`.
|
|
113
|
+
- **Telling you what your policy would do**, [before it does it](../testing/index.md).
|
|
114
|
+
|
|
115
|
+
## Related
|
|
116
|
+
|
|
117
|
+
- [Evidence](evidence.md) — proof versus suspicion, in detail
|
|
118
|
+
- [The guard](the-guard.md) — the mechanism the last claim rests on
|
|
119
|
+
- [The corpus](../testing/corpus.md) — where the ladder above lives
|
|
120
|
+
- [Detectors](../detection/detectors.md) — what each signal is actually worth
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# Verdicts, classes and scores
|
|
2
|
+
|
|
3
|
+
What the engine concludes, and which field you should actually be reading.
|
|
4
|
+
|
|
5
|
+
← [Documentation](../index.md)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
An assessment carries three different summaries of the same request. They answer
|
|
10
|
+
different questions and are not interchangeable.
|
|
11
|
+
|
|
12
|
+
## `verdict` — what this is
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
type Verdict = "confirmed-bot" | "verified-bot" | "suspected-bot" | "human" | "unknown";
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
| Verdict | Proven? | Means |
|
|
19
|
+
| ------- | ------- | ----- |
|
|
20
|
+
| `confirmed-bot` | yes | Proven automation. It said so, walked into a trap, or violated a protocol. |
|
|
21
|
+
| `verified-bot` | yes | Proven automation **and** proven to be who it claims — a crawler that checked out. |
|
|
22
|
+
| `suspected-bot` | no | The score crossed `suspectThreshold`. A judgement call. |
|
|
23
|
+
| `human` | no | Human evidence outweighs bot evidence. Also a judgement. |
|
|
24
|
+
| `unknown` | no | Nothing conclusive either way. **This is what ordinary traffic looks like.** |
|
|
25
|
+
|
|
26
|
+
`unknown` being the common case is not a failure. Most requests carry no strong signal in
|
|
27
|
+
either direction, and a library that concluded something about all of them would be
|
|
28
|
+
guessing about most of them.
|
|
29
|
+
|
|
30
|
+
## `certain` — whether it is proven
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
if (assessment.certain) { /* there is at least one `certain` piece of evidence */ }
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**This is the field to read**, not the score. `certain` is what the
|
|
37
|
+
[guard](the-guard.md) consults before allowing a terminal action, and it is the only
|
|
38
|
+
thing that distinguishes "we know" from "we think".
|
|
39
|
+
|
|
40
|
+
A proven **human** sets `certain` too — an operator assertion or a granted clearance is
|
|
41
|
+
proof in the same sense. Check `verdict` alongside it when the direction matters.
|
|
42
|
+
|
|
43
|
+
## `score` — how suspicious, when it is a judgement
|
|
44
|
+
|
|
45
|
+
An integer. `0–99` for a probabilistic verdict; a flat `100` for anything proven, for the
|
|
46
|
+
benefit of dashboards that chart one.
|
|
47
|
+
|
|
48
|
+
The score exists to be *compared*, not to be trusted absolutely. It is the output of a
|
|
49
|
+
saturating function over weighted evidence, so the difference between 40 and 60 is real
|
|
50
|
+
and the difference between 96 and 98 is noise.
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
suspectThreshold: 60 // the score at which a verdict becomes `suspected-bot`
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Before you move that number, look at the score distribution on the
|
|
57
|
+
[dashboard](../operations/dashboard.md) or in `bothandler_score_bucket`: the question is
|
|
58
|
+
not "is 60 right in the abstract" but "how close does *my* ordinary traffic run to it".
|
|
59
|
+
|
|
60
|
+
## `botClass` — what kind of thing this is
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
type BotClass =
|
|
64
|
+
| "human" | "verified-bot" | "declared-bot" | "automation"
|
|
65
|
+
| "http-client" | "scanner" | "scraper" | "impersonator" | "unknown";
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
This is the field policies usually want, because it carries intent in a way a verdict
|
|
69
|
+
does not. `verified-bot` and `impersonator` are both proven; one is Googlebot and the
|
|
70
|
+
other is something pretending to be it.
|
|
71
|
+
|
|
72
|
+
| Class | Typical example |
|
|
73
|
+
| ----- | --------------- |
|
|
74
|
+
| `verified-bot` | Googlebot, confirmed by reverse DNS or a published range |
|
|
75
|
+
| `declared-bot` | GPTBot, ClaudeBot — honest, unverified, and a business decision |
|
|
76
|
+
| `http-client` | curl, python-requests, Go-http-client |
|
|
77
|
+
| `automation` | Headless Chrome, Playwright, Selenium |
|
|
78
|
+
| `scraper` | Behaviourally a scraper: breadth, cadence, no session |
|
|
79
|
+
| `scanner` | sqlmap, Nikto, probes for `/wp-admin` and `.env` |
|
|
80
|
+
| `impersonator` | Claimed an identity that was refuted |
|
|
81
|
+
| `human` | Proven or strongly indicated to be a person |
|
|
82
|
+
|
|
83
|
+
## `confidence`
|
|
84
|
+
|
|
85
|
+
`0–1`, the raw probability before the threshold is applied. Useful if you are charting
|
|
86
|
+
distributions or building your own thresholds; `score` is the same number scaled and
|
|
87
|
+
rounded.
|
|
88
|
+
|
|
89
|
+
## Putting it together
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
const { assessment, decision } = await detector.handle(facts);
|
|
93
|
+
|
|
94
|
+
// The two questions worth asking, in this order:
|
|
95
|
+
if (assessment.certain && assessment.botClass === "impersonator") {
|
|
96
|
+
// Proven forgery. Safe to refuse.
|
|
97
|
+
}
|
|
98
|
+
if (assessment.verdict === "suspected-bot") {
|
|
99
|
+
// A judgement. Challenge, tag, slow — never refuse on this alone,
|
|
100
|
+
// and the guard will stop you if a rule tries.
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Related
|
|
105
|
+
|
|
106
|
+
- [Evidence and certainty](evidence.md) — where all of this comes from
|
|
107
|
+
- [The safety guard](the-guard.md) — why `certain` matters more than `score`
|
|
108
|
+
- [Matching requests](../policy/rules.md) — matching rules on any of these fields
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# Lesson 1 — Your first assessment
|
|
2
|
+
|
|
3
|
+
**Goal:** turn an HTTP request into a verdict, and understand every field that comes back.
|
|
4
|
+
|
|
5
|
+
← [Course](index.md) · Next: [Proof and suspicion](02-proof-and-suspicion.md)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## The two calls
|
|
10
|
+
|
|
11
|
+
Everything in this library is downstream of two functions.
|
|
12
|
+
|
|
13
|
+
`createFacts` normalises a request into a `RequestFacts` — headers lowercased, path decoded
|
|
14
|
+
once and resolved, query into a null-prototype bag, everything length-bounded. `assess`
|
|
15
|
+
reads those facts and returns an `Assessment`.
|
|
16
|
+
|
|
17
|
+
Neither touches a response. That is why you can run `assess` over a log file, and why the
|
|
18
|
+
rest of this course can happen in a plain script with no server.
|
|
19
|
+
|
|
20
|
+
## Do this
|
|
21
|
+
|
|
22
|
+
`serif/lesson-01.mjs`:
|
|
23
|
+
|
|
24
|
+
```js
|
|
25
|
+
import { BotHandler, createFacts } from "@osqd/bothandlerjs";
|
|
26
|
+
|
|
27
|
+
const detector = new BotHandler();
|
|
28
|
+
|
|
29
|
+
const request = createFacts({
|
|
30
|
+
method: "GET",
|
|
31
|
+
url: "/books/1",
|
|
32
|
+
headers: { host: "serif.example", "user-agent": "curl/8.4.0", accept: "*/*" },
|
|
33
|
+
ip: "203.0.113.10",
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const assessment = await detector.assess(request);
|
|
37
|
+
|
|
38
|
+
console.log("verdict ", assessment.verdict);
|
|
39
|
+
console.log("class ", assessment.botClass);
|
|
40
|
+
console.log("score ", assessment.score);
|
|
41
|
+
console.log("certain ", assessment.certain);
|
|
42
|
+
console.log("confidence", assessment.confidence);
|
|
43
|
+
for (const piece of assessment.evidence) {
|
|
44
|
+
console.log(` [${piece.certainty}] ${piece.detector}: ${piece.summary}`);
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
node lesson-01.mjs
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Checkpoint
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
verdict confirmed-bot
|
|
56
|
+
class http-client
|
|
57
|
+
score 100
|
|
58
|
+
certain true
|
|
59
|
+
confidence 1
|
|
60
|
+
[certain] self-identified: User-Agent identifies curl
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
If you got that, the library is installed and working.
|
|
64
|
+
|
|
65
|
+
## What each field means
|
|
66
|
+
|
|
67
|
+
| Field | |
|
|
68
|
+
| ----- | - |
|
|
69
|
+
| `verdict` | the conclusion: `confirmed-bot`, `verified-bot`, `suspected-bot`, `human`, `unknown` |
|
|
70
|
+
| `botClass` | *what kind* of client: `http-client`, `scraper`, `scanner`, `impersonator`, `declared-bot`, `verified-bot`, `automation`, `human`, `unknown` |
|
|
71
|
+
| `score` | suspicion, 0–100, from probabilistic signals only |
|
|
72
|
+
| `certain` | whether at least one piece of **proof** fired. **This, not the score, is the important one** |
|
|
73
|
+
| `confidence` | how much to trust the verdict, 0–1; exactly 1 when `certain` |
|
|
74
|
+
| `evidence` | every bot-pointing observation, strongest first |
|
|
75
|
+
| `humanEvidence` | every person-pointing observation; these *subtract* |
|
|
76
|
+
| `actor` | who this client is and what has been seen from them — [lesson 7](07-actors.md) |
|
|
77
|
+
| `requestId` | random per request, safe to log |
|
|
78
|
+
| `durationMs` | time spent in detection |
|
|
79
|
+
|
|
80
|
+
Note what happened above: `curl` did not get a score of 100 because many signals agreed.
|
|
81
|
+
It got `certain: true` because the client **said** it was curl, and the score follows from
|
|
82
|
+
proof rather than the other way round.
|
|
83
|
+
|
|
84
|
+
## Now try a real browser
|
|
85
|
+
|
|
86
|
+
Replace the headers with a full Chrome set — the Client Hints, the Fetch Metadata, the
|
|
87
|
+
negotiation headers:
|
|
88
|
+
|
|
89
|
+
```js
|
|
90
|
+
const CHROME = {
|
|
91
|
+
host: "serif.example",
|
|
92
|
+
"user-agent":
|
|
93
|
+
"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",
|
|
94
|
+
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
|
95
|
+
"accept-language": "en-GB,en;q=0.9",
|
|
96
|
+
"accept-encoding": "gzip, deflate, br",
|
|
97
|
+
"sec-ch-ua": '"Chromium";v="122", "Not(A:Brand";v="24", "Google Chrome";v="122"',
|
|
98
|
+
"sec-ch-ua-mobile": "?0",
|
|
99
|
+
"sec-ch-ua-platform": '"macOS"',
|
|
100
|
+
"sec-fetch-site": "none",
|
|
101
|
+
"sec-fetch-mode": "navigate",
|
|
102
|
+
"sec-fetch-dest": "document",
|
|
103
|
+
};
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Also print the human evidence:
|
|
107
|
+
|
|
108
|
+
```js
|
|
109
|
+
for (const piece of assessment.humanEvidence) {
|
|
110
|
+
console.log(` (human) [${piece.certainty}] ${piece.detector}: ${piece.summary}`);
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Checkpoint
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
verdict unknown
|
|
118
|
+
class unknown
|
|
119
|
+
score 0
|
|
120
|
+
certain false
|
|
121
|
+
confidence 1
|
|
122
|
+
(human) [weak] browsing-coherence: Fetch Metadata, Client Hints and negotiation headers are all present and mutually consistent
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
**`unknown` is the correct answer for a person**, and it is the resting state of ordinary
|
|
126
|
+
traffic. The library does not claim to have proved a human — it has nothing conclusive, and
|
|
127
|
+
says so. Only your own application can assert that somebody is a person, which you will do
|
|
128
|
+
in [lesson 12](12-going-live.md).
|
|
129
|
+
|
|
130
|
+
## Exercise
|
|
131
|
+
|
|
132
|
+
Take the Chrome headers and delete everything except `host`, `user-agent` and
|
|
133
|
+
`accept: */*` — a scraper that copied a User-Agent string and nothing else. Assess it.
|
|
134
|
+
|
|
135
|
+
<details>
|
|
136
|
+
<summary>Checkpoint</summary>
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
verdict unknown
|
|
140
|
+
class unknown
|
|
141
|
+
score 45
|
|
142
|
+
certain false
|
|
143
|
+
confidence 0.553
|
|
144
|
+
[moderate] header-integrity: Client claims to be a browser but sent no Accept-Language header
|
|
145
|
+
[moderate] header-integrity: Client claims to be a browser but sent no Accept-Encoding header
|
|
146
|
+
[weak] accept-signature: Client claiming a browser sent Accept: */* with no Fetch Metadata to explain it
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Three signals, a score of 45, and still `unknown` — not enough to call it a bot, and
|
|
150
|
+
nowhere near enough to refuse it. `confidence` dropped to 0.553, which is the library
|
|
151
|
+
saying it is genuinely unsure.
|
|
152
|
+
|
|
153
|
+
Notice what it did **not** do: claim to be certain. A copied User-Agent is suspicious and
|
|
154
|
+
is not proof, and lesson 2 is about why that distinction is the whole design.
|
|
155
|
+
</details>
|
|
156
|
+
|
|
157
|
+
## Common mistake
|
|
158
|
+
|
|
159
|
+
**Passing a forwarded address as `ip`.** `createFacts` wants the **socket** address; the
|
|
160
|
+
forwarded chain is resolved separately and carefully, because getting it wrong lets clients
|
|
161
|
+
pick their own identity. [Lesson 12](12-going-live.md) covers it, and it is the single
|
|
162
|
+
most consequential setting in the library.
|
|
163
|
+
|
|
164
|
+
## What you learned
|
|
165
|
+
|
|
166
|
+
- `createFacts` + `assess` is the whole read path, and neither touches a response
|
|
167
|
+
- Five fields describe a client, and `certain` is the one that matters most
|
|
168
|
+
- `unknown` is what ordinary human traffic looks like
|
|
169
|
+
- Proof and suspicion arrive by different routes
|
|
170
|
+
|
|
171
|
+
## Reference
|
|
172
|
+
|
|
173
|
+
- [How detection works](../detection/index.md) — the pipeline behind `assess`
|
|
174
|
+
- [Verdicts, classes and scores](../concepts/verdicts.md)
|
|
175
|
+
- [Configuration](../reference/configuration.md) — every constructor option
|
|
176
|
+
|
|
177
|
+
Next: [Proof and suspicion](02-proof-and-suspicion.md) — the idea the rest of the course
|
|
178
|
+
depends on.
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# Lesson 2 — Proof and suspicion
|
|
2
|
+
|
|
3
|
+
**Goal:** understand the distinction the entire library is built on, and why every other
|
|
4
|
+
design decision follows from it.
|
|
5
|
+
|
|
6
|
+
← [Course](index.md) · Prev: [First assessment](01-first-assessment.md) · Next: [Verdicts and scores](03-verdicts-and-scores.md)
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## The problem every bot detector has
|
|
11
|
+
|
|
12
|
+
Every signal that catches sophisticated automation is *probabilistic*: header
|
|
13
|
+
consistency, timing regularity, missing cookies, TLS fingerprints. And every one of them
|
|
14
|
+
has a population of real people who trip it.
|
|
15
|
+
|
|
16
|
+
Someone on a privacy-hardened browser. Someone behind a corporate proxy that strips
|
|
17
|
+
headers. Someone using a screen reader. Someone on a hotel network, or a five-year-old
|
|
18
|
+
phone, or a carrier that transcodes pages.
|
|
19
|
+
|
|
20
|
+
The usual answer is to add the signals into a score and block above a threshold. **That is
|
|
21
|
+
the mistake this library exists to avoid.** Points do not compose into proof. Two unrelated
|
|
22
|
+
suspicions about an unusual but entirely real browser reach 100 as readily as two
|
|
23
|
+
well-founded ones — and the people who get caught are disproportionately the ones with the
|
|
24
|
+
strongest reasons for their unusual setup.
|
|
25
|
+
|
|
26
|
+
So evidence here lives in two compartments that never mix.
|
|
27
|
+
|
|
28
|
+
| | Deterministic (`certain`) | Probabilistic (`strong` / `moderate` / `weak`) |
|
|
29
|
+
| --- | --- | --- |
|
|
30
|
+
| Rests on | a declaration, a contradiction, a trap, an external authority | a pattern automation usually shows |
|
|
31
|
+
| Can it be wrong? | only if the client lied about itself | yes, about real people |
|
|
32
|
+
| How it combines | short-circuits to a verdict | noisy-OR into a score of 0–99 |
|
|
33
|
+
| Can it deny service? | **yes** | **no** |
|
|
34
|
+
| What it can still do | anything | tag, log, delay, rate-limit, challenge, alert |
|
|
35
|
+
|
|
36
|
+
## See it
|
|
37
|
+
|
|
38
|
+
Print the basis of the curl evidence from lesson 1:
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
const assessment = await detector.assess(
|
|
42
|
+
createFacts({ method: "GET", url: "/", headers: { host: "serif.example", "user-agent": "curl/8.4.0" }, ip: "203.0.113.10" }),
|
|
43
|
+
);
|
|
44
|
+
console.log(assessment.evidence[0].deterministicBasis);
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Checkpoint
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
The product token "curl/" is emitted by an HTTP library or an automation runtime and by
|
|
51
|
+
no browser. Nothing a person does in a browser produces it.
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**Every piece of `certain` evidence has to carry one of those sentences**, in writing, and
|
|
55
|
+
the library refuses evidence that claims certainty without one. It is a forcing function:
|
|
56
|
+
if you cannot write the sentence, your evidence is `strong`. When you write your own
|
|
57
|
+
detector in [lesson 15](15-extending.md) you will meet this rule from the other side.
|
|
58
|
+
|
|
59
|
+
## The five things that earn `certain`
|
|
60
|
+
|
|
61
|
+
1. **Self-declaration.** The client says it is a bot: `curl/8.4.0`, `python-requests`,
|
|
62
|
+
`Googlebot/2.1`, `HeadlessChrome` in a Client Hints brand list. Not an inference — you
|
|
63
|
+
are believing the client's own statement about itself, and no honest client is ever
|
|
64
|
+
harmed by being believed. If the statement is a lie, the misclassification is the
|
|
65
|
+
client's doing.
|
|
66
|
+
2. **A refuted third-party identity.** It claimed to be Googlebot and DNS says otherwise.
|
|
67
|
+
Note how narrow that is: a privacy extension rewriting a User-Agent to a *generic*
|
|
68
|
+
browser string never lands here, because it never claims to be a named, verifiable
|
|
69
|
+
third party.
|
|
70
|
+
3. **A confirmed third-party identity.** The same check passing. Used to *allow*.
|
|
71
|
+
4. **A trap.** A link hidden from layout and from assistive technology and excluded in
|
|
72
|
+
`robots.txt`. Detection by construction rather than by inference — no sequence of user
|
|
73
|
+
input reaches it. [Lesson 8](08-traps.md).
|
|
74
|
+
5. **A protocol violation.** Three of them, each a rule a recipient is *required* to
|
|
75
|
+
enforce: a connection-specific header on HTTP/2 ([RFC 9113 §8.2.2](https://www.rfc-editor.org/rfc/rfc9113#section-8.2.2)),
|
|
76
|
+
`Content-Length` beside `Transfer-Encoding` ([RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1)),
|
|
77
|
+
and a repeated `Host` ([RFC 9112 §3.2](https://www.rfc-editor.org/rfc/rfc9112#section-3.2)).
|
|
78
|
+
|
|
79
|
+
Plus one on the human side: **your own application's assertion** that a request belongs to
|
|
80
|
+
a person.
|
|
81
|
+
|
|
82
|
+
## The rule that catches people out
|
|
83
|
+
|
|
84
|
+
> **No argument from absence may ever be `certain`.**
|
|
85
|
+
|
|
86
|
+
A header missing from your *facts* is not a header missing from the *request*. An HTTP/1.1
|
|
87
|
+
request with no `Host` violates RFC 9112 as plainly as anything above — and it is
|
|
88
|
+
deliberately not proven here, because somebody building facts from a log line, a WAF event
|
|
89
|
+
or a partial adapter would otherwise manufacture proof against every request in the file.
|
|
90
|
+
|
|
91
|
+
You will rely on this in [lesson 16](16-proving-it.md) when you replay your own access
|
|
92
|
+
logs, where most headers genuinely are missing from the record.
|
|
93
|
+
|
|
94
|
+
## How suspicion adds up
|
|
95
|
+
|
|
96
|
+
Probabilistic evidence combines by **noisy-OR** — `1 − Π(1 − wᵢ)` — not by a sum. The
|
|
97
|
+
weights are `strong` 0.60, `moderate` 0.35, `weak` 0.15.
|
|
98
|
+
|
|
99
|
+
It is bounded without clamping, and it has the right shape: many weak signals do add up,
|
|
100
|
+
but asymptotically. **The probabilistic score is capped at 99**, because 100 means proof.
|
|
101
|
+
|
|
102
|
+
Human evidence subtracts: `score = pBot × (1 − pHuman)`.
|
|
103
|
+
|
|
104
|
+
## One cause, counted once
|
|
105
|
+
|
|
106
|
+
Noisy-OR is only sound over *independent* signals, and several of these are not. A
|
|
107
|
+
corporate proxy that strips `Sec-Fetch-*` also strips the Client Hints and the
|
|
108
|
+
`Accept-Language` — so three detectors fire at once about one person behind one appliance,
|
|
109
|
+
and the arithmetic reads their agreement as corroboration when it is an echo.
|
|
110
|
+
|
|
111
|
+
Evidence may therefore declare a **`family`**: a shared root cause. Within a family the
|
|
112
|
+
engine takes the strongest observation instead of compounding.
|
|
113
|
+
|
|
114
|
+
You saw this in lesson 1's exercise without noticing. Print the families:
|
|
115
|
+
|
|
116
|
+
```js
|
|
117
|
+
for (const piece of assessment.evidence) {
|
|
118
|
+
console.log(piece.certainty.padEnd(9), (piece.family ?? "—").padEnd(18), piece.summary);
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Run it against the copied-User-Agent request. The two `header-integrity` observations share
|
|
123
|
+
a family, so they count once. That scraper scores 45 rather than the ~60 it would reach if
|
|
124
|
+
its two absences were treated as independent — and the reason is that a real corporate
|
|
125
|
+
network produces exactly the same pair, every day.
|
|
126
|
+
|
|
127
|
+
Families are a scoring correction and nothing more. **They never touch the proven path.**
|
|
128
|
+
|
|
129
|
+
## Exercise
|
|
130
|
+
|
|
131
|
+
Build a request that is proven a bot *and* carries a lot of suspicion, and one that carries
|
|
132
|
+
suspicion alone. Compare `certain`, `score` and `confidence` on each.
|
|
133
|
+
|
|
134
|
+
Then answer, without running anything: a client scores 97 from six independent
|
|
135
|
+
probabilistic signals. Under the default configuration, can a rule block it?
|
|
136
|
+
|
|
137
|
+
<details>
|
|
138
|
+
<summary>Answer</summary>
|
|
139
|
+
|
|
140
|
+
**No.** Not at 97, not at 99. The score is not what gates a terminal action — `certain` is.
|
|
141
|
+
Six probabilistic signals are six things a real person can trip, and the population that
|
|
142
|
+
trips six of them is disproportionately the population with the strongest reasons for an
|
|
143
|
+
unusual setup.
|
|
144
|
+
|
|
145
|
+
That is [lesson 4](04-the-guard.md), and it is enforced by a mechanism you cannot forget to
|
|
146
|
+
apply.
|
|
147
|
+
</details>
|
|
148
|
+
|
|
149
|
+
## What you learned
|
|
150
|
+
|
|
151
|
+
- Probabilistic signals all have real people who trip them; that is why points never become
|
|
152
|
+
proof
|
|
153
|
+
- Exactly five things earn `certain`, and each must carry a written basis
|
|
154
|
+
- No argument from absence may ever be certain
|
|
155
|
+
- Noisy-OR bounds suspicion at 99; human evidence subtracts
|
|
156
|
+
- One root cause is counted once, so a stripped-header proxy is not three reasons
|
|
157
|
+
|
|
158
|
+
## Reference
|
|
159
|
+
|
|
160
|
+
- [Evidence and certainty](../concepts/evidence.md) — the full model
|
|
161
|
+
- [Design decisions](../design/decisions.md) — this choice, and what it costs
|
|
162
|
+
- [Threat model](../concepts/threat-model.md) — who this catches and who it does not
|
|
163
|
+
|
|
164
|
+
Next: [Verdicts, classes and scores](03-verdicts-and-scores.md).
|