@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
package/README.md
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
# 🤖 bothandlerjs
|
|
2
|
+
|
|
3
|
+
**Bot traffic detection and handling for TypeScript.** Identify automated traffic,
|
|
4
|
+
prove it where proof is possible, and respond the way you choose — tag it, slow it,
|
|
5
|
+
rate-limit it, challenge it, alert on it, or refuse it.
|
|
6
|
+
|
|
7
|
+
The design rests on one distinction that most bot tooling blurs: **proof versus
|
|
8
|
+
suspicion**. Evidence is tiered, the two tiers combine by different rules, and a guard
|
|
9
|
+
in the policy layer stops a probabilistic verdict from ever reaching a terminal
|
|
10
|
+
action. That is what this library means by *no false positives*, stated precisely:
|
|
11
|
+
|
|
12
|
+
> **Nothing is ever denied service on the strength of a guess.**
|
|
13
|
+
|
|
14
|
+
Guesses still do useful work — they tag, delay, rate-limit, challenge and alert. They
|
|
15
|
+
just cannot shut the door. Only deterministic evidence can, and every piece of
|
|
16
|
+
deterministic evidence has to explain in writing why it admits no benign explanation.
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install @osqd/bothandlerjs
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
No runtime dependencies. The library imports nothing but `node:` builtins, and CI
|
|
23
|
+
fails if that ever stops being true — so nothing here can hand your project a
|
|
24
|
+
transitive advisory, an install script, or a version conflict with something you
|
|
25
|
+
already run. Redis, if you use it, is your client passed in: `RedisStore` describes
|
|
26
|
+
the five commands it needs structurally and imports neither `ioredis` nor
|
|
27
|
+
`node-redis`.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Documentation
|
|
32
|
+
|
|
33
|
+
The README is the argument and the shortest path to a working integration. Everything
|
|
34
|
+
else lives in **[`docs/`](docs/index.md)** — thirty pages, one per question, each
|
|
35
|
+
explaining why a thing exists as well as how to use it.
|
|
36
|
+
|
|
37
|
+
| | |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| **[The course](docs/course/index.md)** | Sixteen lessons that build one integration, from a first assessment to a policy you can defend. Start here if the library is new to you. |
|
|
40
|
+
| **[Start here](docs/index.md)** | [Installation](docs/start/installation.md) · [Your first integration](docs/start/first-integration.md) · [Choosing a policy](docs/start/choosing-a-policy.md) · [Upgrading](docs/start/upgrading.md) |
|
|
41
|
+
| **Concepts** | [Evidence and certainty](docs/concepts/evidence.md) · [Verdicts and scores](docs/concepts/verdicts.md) · [The safety guard](docs/concepts/the-guard.md) · [Actors](docs/concepts/actors.md) · [Threat model](docs/concepts/threat-model.md) |
|
|
42
|
+
| **[Detection](docs/detection/index.md)** | [The 20 detectors](docs/detection/detectors.md) · [Signatures](docs/detection/signatures.md) · [Verification](docs/detection/verification.md) · [Browser signals](docs/detection/client-signals.md) · [Writing a detector](docs/detection/writing-a-detector.md) |
|
|
43
|
+
| **[Policy](docs/policy/index.md)** | [Rules](docs/policy/rules.md) · [Actions](docs/policy/actions.md) · [Presets](docs/policy/presets.md) · [robots.txt](docs/policy/robots.md) · [The challenge](docs/challenge/index.md) |
|
|
44
|
+
| **[Operations](docs/operations/index.md)** | [The dashboard](docs/operations/dashboard.md) · [Metrics](docs/operations/metrics.md) · [The audit](docs/operations/audit.md) · [Notifications](docs/operations/notifications.md) · [Runtime changes](docs/operations/runtime-changes.md) |
|
|
45
|
+
| **[Integration](docs/integration/index.md)** | [Adapters](docs/integration/adapters.md) · [The client IP](docs/integration/client-ip.md) · [Stores](docs/integration/stores.md) |
|
|
46
|
+
| **[Testing](docs/testing/index.md)** | [The CLI](docs/testing/cli.md) · [The corpus](docs/testing/corpus.md) · [Log replay](docs/testing/replay.md) · [Try it locally](docs/testing/try-it.md) |
|
|
47
|
+
| **Reference** | [Configuration](docs/reference/configuration.md) · [API](docs/reference/api.md) · [Design decisions](docs/design/decisions.md) |
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Why this design
|
|
52
|
+
|
|
53
|
+
Every bot detector eventually faces the same problem. The signals that catch
|
|
54
|
+
sophisticated automation — header consistency, TLS fingerprints, timing regularity,
|
|
55
|
+
missing cookies — are all *probabilistic*. Each one has a population of real people
|
|
56
|
+
who trip it: someone on a privacy-hardened browser, behind a corporate proxy, using a
|
|
57
|
+
screen reader, on a hotel network, on a five-year-old phone.
|
|
58
|
+
|
|
59
|
+
The usual answer is to add the signals into a score and block above a threshold. That
|
|
60
|
+
is exactly the mistake. Points do not compose into proof. Two unrelated suspicions
|
|
61
|
+
about an unusual but entirely real browser reach 100 just as readily as two
|
|
62
|
+
well-founded ones, and the people who get caught are disproportionately the ones with
|
|
63
|
+
the strongest reasons for their unusual setup.
|
|
64
|
+
|
|
65
|
+
So this library keeps the two kinds of evidence in separate compartments, all the way
|
|
66
|
+
through:
|
|
67
|
+
|
|
68
|
+
| | Deterministic (`certain`) | Probabilistic (`strong` / `moderate` / `weak`) |
|
|
69
|
+
| -------------------- | ---------------------------------------------------------------- | ----------------------------------------------- |
|
|
70
|
+
| What it rests on | A declaration, a contradiction, a trap, or an external authority | A pattern that automation usually shows |
|
|
71
|
+
| Can it be wrong? | Only if the client lied about itself | Yes, about real people |
|
|
72
|
+
| How it combines | Short-circuits to a verdict | Noisy-OR into a 0–99 score |
|
|
73
|
+
| Can it block? | **Yes** | **No** (under the default policy) |
|
|
74
|
+
| What it can still do | anything | tag, log, delay, rate-limit, challenge, alert |
|
|
75
|
+
|
|
76
|
+
The guard that enforces the last row lives in the policy layer and runs *after* a rule
|
|
77
|
+
has been chosen — so it cannot be forgotten in a rule, worked around by a clever
|
|
78
|
+
predicate, or bypassed by someone who has not read this document. Relaxing it is one
|
|
79
|
+
explicit, greppable setting.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Quick start
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import { BotHandler } from "@osqd/bothandlerjs";
|
|
87
|
+
import { botHandler } from "@osqd/bothandlerjs/adapters";
|
|
88
|
+
|
|
89
|
+
const detector = new BotHandler({
|
|
90
|
+
preset: "protect-content",
|
|
91
|
+
challenge: { secrets: [process.env.BOT_SECRET!] },
|
|
92
|
+
allowlist: ["10.0.0.0/8"], // your monitors and CI
|
|
93
|
+
ignorePaths: ["/healthz", "/metrics"],
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
app.use(botHandler(detector));
|
|
97
|
+
|
|
98
|
+
// Optional: a live view of what it is doing, on a port of its own.
|
|
99
|
+
await detector.serveDashboard({ port: 9674 });
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
That is a working configuration. Against a live server it produces:
|
|
103
|
+
|
|
104
|
+
| Request | Result |
|
|
105
|
+
| ------------------------------------------- | --------------------------------------------- |
|
|
106
|
+
| A real Chrome navigation | `200` — served, untouched |
|
|
107
|
+
| `curl https://yoursite/` | `429` — challenge page (proven `http-client`) |
|
|
108
|
+
| A `Googlebot` UA from an address DNS refutes | `403` — proven `impersonator` |
|
|
109
|
+
| `sqlmap` | `403` — self-identified scanner |
|
|
110
|
+
| A hit on a trap link | `403` — no person can reach it |
|
|
111
|
+
| `GET /healthz` | `200` — never assessed |
|
|
112
|
+
| Real Googlebot, confirmed by DNS | `200` — explicitly allowed |
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## The certainty model
|
|
117
|
+
|
|
118
|
+
### What earns `certain`
|
|
119
|
+
|
|
120
|
+
Only five things, and each is deterministic for a stated reason:
|
|
121
|
+
|
|
122
|
+
1. **Self-declaration.** The client says it is a bot: `curl/8.4.0`,
|
|
123
|
+
`python-requests/2.31.0`, `Googlebot/2.1`, `HeadlessChrome` in a Client Hints brand
|
|
124
|
+
list. We are not inferring — we are believing the client's own statement about
|
|
125
|
+
itself, and no honest client is ever harmed by being believed. If the statement is
|
|
126
|
+
a lie, the misclassification is the client's doing.
|
|
127
|
+
2. **A refuted third-party identity.** A client claimed to be Googlebot and
|
|
128
|
+
forward-confirmed reverse DNS says otherwise. Note how narrow this is: a privacy
|
|
129
|
+
extension rewriting a UA to a generic browser string never lands here, because it
|
|
130
|
+
never claims to be a *named, verifiable* third party.
|
|
131
|
+
3. **A confirmed third-party identity.** The same check passing. Used to *allow*.
|
|
132
|
+
4. **A trap.** A link hidden from layout and from assistive technology, excluded in
|
|
133
|
+
`robots.txt`. Detection by construction rather than by inference — there is no
|
|
134
|
+
sequence of user input that reaches it.
|
|
135
|
+
5. **A protocol violation.** Three of them, each a rule the specification requires a
|
|
136
|
+
recipient to *enforce* rather than merely recommends: a connection-specific header
|
|
137
|
+
on HTTP/2 (RFC 9113 §8.2.2), a message carrying both `Content-Length` and
|
|
138
|
+
`Transfer-Encoding` (RFC 9112 §6.1), and a repeated `Host` or `Content-Length`
|
|
139
|
+
(RFC 9112 §3.2). A client emitting any of them cannot talk to a compliant proxy, so
|
|
140
|
+
no shipping client emits one — and the last two are the ambiguity every
|
|
141
|
+
request-smuggling technique is built on.
|
|
142
|
+
|
|
143
|
+
Plus one on the human side: **your application's own assertion** (`isHuman`, or an
|
|
144
|
+
`operator` clearance token) that a request belongs to a person.
|
|
145
|
+
|
|
146
|
+
One rule holds all of this together, and it is worth stating on its own:
|
|
147
|
+
|
|
148
|
+
> **No argument from absence may ever be `certain`.**
|
|
149
|
+
|
|
150
|
+
A header missing from the facts is not a header missing from the request. An HTTP/1.1
|
|
151
|
+
request with no `Host` violates RFC 9112 as plainly as anything above — and it is
|
|
152
|
+
deliberately *not* proven here, because a caller building facts from a log line, a WAF
|
|
153
|
+
event or a partial adapter would otherwise manufacture proof against every request in
|
|
154
|
+
the file. The protocol checks that did make the list all reason from what is
|
|
155
|
+
**present**.
|
|
156
|
+
|
|
157
|
+
### What does not
|
|
158
|
+
|
|
159
|
+
Everything else, including several signals that look conclusive:
|
|
160
|
+
|
|
161
|
+
- **A User-Agent that contradicts itself.** Chrome on an iPhone, where Apple's rules
|
|
162
|
+
mean Chrome is WebKit and says `CriOS`; Windows and macOS in one string; Firefox
|
|
163
|
+
claiming the WebKit engine. These describe a client that has never shipped — and
|
|
164
|
+
they are also what a person's UA-spoofing extension produces. `strong`.
|
|
165
|
+
- **UA / Client-Hints contradictions.** A genuine self-contradiction — and also what
|
|
166
|
+
every UA-spoofing privacy extension produces for a real person. `strong`.
|
|
167
|
+
- **Missing `Sec-Fetch-*` on a modern browser.** Excellent signal; also what a
|
|
168
|
+
stripping corporate proxy produces. `strong`.
|
|
169
|
+
- **Header order.** A real fingerprint a scraper cannot fix by copying a UA string;
|
|
170
|
+
also reordered by any intermediary, and meaningless over HTTP/2. `weak`.
|
|
171
|
+
- **High request rate.** The signal people trust most and should trust least: a
|
|
172
|
+
corporate NAT, a university, a mobile carrier's CGNAT pool and a VPN exit all
|
|
173
|
+
present hundreds of real people as one address. Capped at `moderate`.
|
|
174
|
+
- **Datacenter IP ranges.** Where scrapers live — and where every consumer VPN, every
|
|
175
|
+
corporate gateway, every Tor exit and iCloud Private Relay live too. `moderate`.
|
|
176
|
+
- **`navigator.webdriver` and friends.** Reported by JavaScript running inside the
|
|
177
|
+
client, which is the one place an adversary has complete control. `moderate`.
|
|
178
|
+
- **A request for `/.env`.** No link points at it and no menu leads to it — but a URL
|
|
179
|
+
is client-supplied text, and the client supplying it might be a security engineer
|
|
180
|
+
testing their own site. `strong`, and a challenge rather than a closed door. A trap
|
|
181
|
+
path is the case that *is* proven, and the difference is construction: a trap is
|
|
182
|
+
unreachable by any sequence of user input, and a wordlist entry is merely unusual.
|
|
183
|
+
|
|
184
|
+
Writing a `deterministicBasis` string is required for `certain` evidence and enforced
|
|
185
|
+
at runtime outside production. It is a useful forcing function: if you cannot write
|
|
186
|
+
one, your evidence is `strong`.
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## How a request flows through
|
|
191
|
+
|
|
192
|
+
```
|
|
193
|
+
request
|
|
194
|
+
│
|
|
195
|
+
├─ ignorePaths / allowlist ─────────────────► bypass, no detection at all
|
|
196
|
+
│
|
|
197
|
+
├─ normalise facts headers lowercased, path decoded once and resolved,
|
|
198
|
+
│ query into a null-prototype bag, everything bounded
|
|
199
|
+
│
|
|
200
|
+
├─ actor state bounded LRU: arrival ring, path hashes, UA count
|
|
201
|
+
│
|
|
202
|
+
├─ DETECT phase 1 cheap pure string/header work, sequential, microseconds
|
|
203
|
+
│ phase 2 io concurrent, each under its own timeout
|
|
204
|
+
│ phase 3 confirm DNS — only if an identity was actually claimed
|
|
205
|
+
│
|
|
206
|
+
├─ COMBINE certain evidence short-circuits ─► confirmed-bot / verified-bot / human
|
|
207
|
+
│ otherwise noisy-OR ─────────────► score 0–99, suspected-bot / unknown
|
|
208
|
+
│
|
|
209
|
+
├─ DECIDE first matching rule wins
|
|
210
|
+
│ └─ SAFETY GUARD: terminal action + no proof ─► downgrade + record
|
|
211
|
+
│
|
|
212
|
+
└─ ACT continue (tagged) │ respond │ drop
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Each stage is separately callable. `assess()` reads the request and touches no
|
|
216
|
+
response, so it is safe to run over a log file. `decide()` is pure. `handle()` does
|
|
217
|
+
all three.
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
## What this library cannot do
|
|
222
|
+
|
|
223
|
+
- **Stop a determined, well-resourced adversary.** Someone running real Chrome through
|
|
224
|
+
a residential proxy pool, at human pace, with correct headers, solving the proof of
|
|
225
|
+
work, is indistinguishable from a person at the HTTP layer — because at that point
|
|
226
|
+
the difference has stopped being technical. What this raises is the *cost*.
|
|
227
|
+
- **Prove somebody is human.** No signal here does that and none claims to. Proof of
|
|
228
|
+
work proves CPU. `navigator.webdriver` proves what the client chose to report. The
|
|
229
|
+
only conclusive human signal is your own application's assertion.
|
|
230
|
+
- **Replace authentication, authorisation or a WAF.** It classifies traffic. It is not
|
|
231
|
+
a security boundary and nothing about it should be load-bearing for access control.
|
|
232
|
+
- **Ship IP intelligence.** Address-to-operator mappings go stale within weeks, and a
|
|
233
|
+
stale mapping is a false positive with a long half-life. Bring your own, from a
|
|
234
|
+
source you refresh and can audit.
|
|
235
|
+
- **Be right about a shared address.** Behind CGNAT, "one actor" is thousands of
|
|
236
|
+
people. That is why the behavioural signals are capped where they are.
|
|
237
|
+
- **Escalate on a wordlist walk.** `probe-signature` reads one request at a time, so a
|
|
238
|
+
scanner working through five hundred paths produces five hundred separate
|
|
239
|
+
observations rather than a mounting case. That is the price of a detector that runs
|
|
240
|
+
unchanged over a log file; enumeration over time is what `rate-anomaly`, `cadence`
|
|
241
|
+
and `crawl-breadth` are for.
|
|
242
|
+
|
|
243
|
+
---
|
|
244
|
+
|
|
245
|
+
## Development
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
npm install
|
|
249
|
+
npm test # 661 tests, including the full traffic corpus
|
|
250
|
+
npm run lint # biome, the same rule set as the sibling projects
|
|
251
|
+
npm run typecheck # src + tests + examples, strict, exactOptionalPropertyTypes
|
|
252
|
+
npm run test:coverage # the same tests, against a coverage ratchet
|
|
253
|
+
npm run build # ESM + CJS + declarations
|
|
254
|
+
npm run demo # protected site :9673 + live dashboard :9674
|
|
255
|
+
npm run demo:roles # the same dashboard behind roles: analyst :9684, operator :9685, admin :9686
|
|
256
|
+
npm run simulate # eighteen curated scenarios against the demo
|
|
257
|
+
npm run simulate:corpus # replay all 526 corpus cases over a real socket
|
|
258
|
+
npm run bench # hot-path benchmark, median of several rounds
|
|
259
|
+
npm run corpus # 526 shapes of real traffic against your policy
|
|
260
|
+
npm run example # a minimal Express integration on :3000
|
|
261
|
+
|
|
262
|
+
npx @osqd/bothandlerjs replay access.log # what your policy would have done
|
|
263
|
+
npx @osqd/bothandlerjs check # your policy against 526 shapes of real traffic
|
|
264
|
+
npx @osqd/bothandlerjs explain "curl/8.4.0" # one request, and the evidence behind the verdict
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Project layout:
|
|
268
|
+
|
|
269
|
+
```
|
|
270
|
+
src/
|
|
271
|
+
core.ts engine: assess -> decide -> handle
|
|
272
|
+
cli.ts replay, check, explain, robots, detectors
|
|
273
|
+
metrics.ts counters and Prometheus rendering
|
|
274
|
+
robots.ts robots.txt generation from a policy
|
|
275
|
+
evidence.ts certainty model and evidence combination
|
|
276
|
+
config.ts validation, defaults, client-IP resolution
|
|
277
|
+
facts.ts request normalisation
|
|
278
|
+
state.ts bounded per-actor behavioural memory
|
|
279
|
+
detectors/ twenty detectors + the signature database
|
|
280
|
+
policy/ rules, matcher, the safety guard, presets
|
|
281
|
+
actions/ decision -> framework-neutral outcome
|
|
282
|
+
challenge/ proof of work, signed tokens, the interstitial
|
|
283
|
+
stores/ memory and Redis
|
|
284
|
+
notify/ hub, sinks, redaction
|
|
285
|
+
dashboard/ the operator dashboard: server, feed, page
|
|
286
|
+
dashboard/client/ its browser code — a real module, bundled into the page
|
|
287
|
+
adapters/ Express/Connect, Fastify, Koa, Fetch
|
|
288
|
+
client/ browser-side signal script
|
|
289
|
+
internal/ IP, crypto, UA, Aho-Corasick, LRU, DNS, HTTP
|
|
290
|
+
corpus/ 526 shapes of real traffic, with provenance, and the harness
|
|
291
|
+
that runs them against your configuration
|
|
292
|
+
demo/ the protected site and the live dashboard
|
|
293
|
+
scripts/simulate.ts the traffic simulator
|
|
294
|
+
examples/ minimal integrations to copy from
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
---
|
|
298
|
+
|
|
299
|
+
## License
|
|
300
|
+
|
|
301
|
+
OSQD Non-Resale License, Version 1.0 — see [LICENSE](LICENSE).
|
|
302
|
+
|
|
303
|
+
---
|
|
304
|
+
|
|
305
|
+
## Security
|
|
306
|
+
|
|
307
|
+
See [SECURITY.md](SECURITY.md) for the threat model, what this library defends
|
|
308
|
+
against, and how to report a vulnerability.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Thin launcher. Everything lives in the build so the CLI and the library share one
|
|
3
|
+
// implementation — a CLI that reimplements the engine is a CLI that drifts from it.
|
|
4
|
+
import { main } from "../dist/cli.js";
|
|
5
|
+
|
|
6
|
+
main(process.argv.slice(2)).then(
|
|
7
|
+
(code) => {
|
|
8
|
+
process.exitCode = code;
|
|
9
|
+
},
|
|
10
|
+
(error) => {
|
|
11
|
+
console.error(error instanceof Error ? error.message : error);
|
|
12
|
+
process.exitCode = 1;
|
|
13
|
+
},
|
|
14
|
+
);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ActionContext, ActionOutcome, CustomHandler } from "./types.js";
|
|
2
|
+
import type { Assessment } from "../types.js";
|
|
3
|
+
export type { ActionContext, ActionOutcome, CustomHandler } from "./types.js";
|
|
4
|
+
/**
|
|
5
|
+
* Verdict headers.
|
|
6
|
+
*
|
|
7
|
+
* Separate `X-Bot-*` fields rather than one JSON blob: individual headers survive
|
|
8
|
+
* proxies, land in access logs with no extra configuration, and can be routed on by a
|
|
9
|
+
* CDN or a load balancer without anything having to parse them.
|
|
10
|
+
*/
|
|
11
|
+
export declare function verdictHeaders(assessment: Assessment): Record<string, string>;
|
|
12
|
+
/**
|
|
13
|
+
* Turns a decision into an outcome.
|
|
14
|
+
*
|
|
15
|
+
* Every branch fails towards serving the request. An unconfigured challenge, a store
|
|
16
|
+
* that will not answer, a custom handler that was never registered: each of those is
|
|
17
|
+
* a misconfiguration on our side, and the visitor should not pay for it. The warning
|
|
18
|
+
* goes to the operator; the client gets its page.
|
|
19
|
+
*/
|
|
20
|
+
export declare function executeAction(context: ActionContext): Promise<ActionOutcome>;
|
|
21
|
+
/** Registers a custom handler, with the types inferred. */
|
|
22
|
+
export declare function defineHandler(handler: CustomHandler): CustomHandler;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Assessment } from "../types.js";
|
|
2
|
+
import type { Decision } from "../policy/types.js";
|
|
3
|
+
import type { BotHandlerStore } from "../stores/types.js";
|
|
4
|
+
import type { ChallengeService } from "../challenge/index.js";
|
|
5
|
+
import type { Clock } from "../internal/clock.js";
|
|
6
|
+
/**
|
|
7
|
+
* What the engine decided should happen, expressed without reference to any
|
|
8
|
+
* framework.
|
|
9
|
+
*
|
|
10
|
+
* The engine never touches a response object. It returns one of these and an adapter
|
|
11
|
+
* applies it. That separation is what lets the same decision logic drive Express,
|
|
12
|
+
* Fastify, a Fetch handler in a Worker, and a test that asserts on a plain object —
|
|
13
|
+
* and it means the core has no way to accidentally write to a socket.
|
|
14
|
+
*/
|
|
15
|
+
export type ActionOutcome = {
|
|
16
|
+
kind: "continue";
|
|
17
|
+
/** Headers to add to the *request* before it reaches your application. */
|
|
18
|
+
requestHeaders?: Record<string, string>;
|
|
19
|
+
/** Headers to add to the eventual response. */
|
|
20
|
+
responseHeaders?: Record<string, string>;
|
|
21
|
+
/** Hold the request this long before passing it on. */
|
|
22
|
+
delayMs?: number;
|
|
23
|
+
} | {
|
|
24
|
+
kind: "respond";
|
|
25
|
+
status: number;
|
|
26
|
+
headers: Record<string, string>;
|
|
27
|
+
body: string;
|
|
28
|
+
}
|
|
29
|
+
/** Close the connection with no response at all. */
|
|
30
|
+
| {
|
|
31
|
+
kind: "drop";
|
|
32
|
+
};
|
|
33
|
+
export interface ActionContext {
|
|
34
|
+
assessment: Assessment;
|
|
35
|
+
decision: Decision;
|
|
36
|
+
store: BotHandlerStore;
|
|
37
|
+
challenge: ChallengeService | undefined;
|
|
38
|
+
clock: Clock;
|
|
39
|
+
/**
|
|
40
|
+
* Whether verdict headers may appear on the *response*.
|
|
41
|
+
*
|
|
42
|
+
* Off by default. Echoing your verdict back to the client tells an operator
|
|
43
|
+
* refining a scraper precisely which change made them invisible, turning your
|
|
44
|
+
* detection into their test suite. Request-side tagging carries the same
|
|
45
|
+
* information to your own application with none of that.
|
|
46
|
+
*/
|
|
47
|
+
exposeVerdictHeaders: boolean;
|
|
48
|
+
/** Custom handlers by id, for the `custom` action. */
|
|
49
|
+
handlers: ReadonlyMap<string, CustomHandler>;
|
|
50
|
+
/** Reports an action that could not run as configured. */
|
|
51
|
+
onWarning: (message: string) => void;
|
|
52
|
+
/** Records challenge lifecycle events for metrics. */
|
|
53
|
+
onChallenge?: ((event: "issued") => void) | undefined;
|
|
54
|
+
}
|
|
55
|
+
export interface CustomHandler {
|
|
56
|
+
id: string;
|
|
57
|
+
description?: string;
|
|
58
|
+
execute(context: ActionContext): ActionOutcome | Promise<ActionOutcome>;
|
|
59
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { BotHandler } from "../core.js";
|
|
2
|
+
import type { RequestFacts } from "../types.js";
|
|
3
|
+
/**
|
|
4
|
+
* The parts of Fastify's request and reply this adapter touches, described
|
|
5
|
+
* structurally so the library takes no dependency on Fastify or its types.
|
|
6
|
+
*/
|
|
7
|
+
export interface FastifyLikeRequest {
|
|
8
|
+
method: string;
|
|
9
|
+
url: string;
|
|
10
|
+
headers: Record<string, string | string[] | undefined>;
|
|
11
|
+
raw: import("node:http").IncomingMessage;
|
|
12
|
+
}
|
|
13
|
+
export interface FastifyLikeReply {
|
|
14
|
+
code(status: number): FastifyLikeReply;
|
|
15
|
+
header(name: string, value: string): FastifyLikeReply;
|
|
16
|
+
send(body: unknown): unknown;
|
|
17
|
+
hijack?(): void;
|
|
18
|
+
}
|
|
19
|
+
export interface FastifyAdapterOptions {
|
|
20
|
+
tlsFingerprintHeader?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Also serve the challenge verification endpoint. Default true.
|
|
23
|
+
*
|
|
24
|
+
* Fastify parses bodies after `onRequest`, so this reads the raw stream itself.
|
|
25
|
+
* That is why it must run here rather than as a route: by the time a route handler
|
|
26
|
+
* sees the request, the body has been consumed by a parser that does not know about
|
|
27
|
+
* this endpoint.
|
|
28
|
+
*/
|
|
29
|
+
mountChallengeEndpoint?: boolean;
|
|
30
|
+
enrich?: (request: FastifyLikeRequest, facts: RequestFacts) => RequestFacts;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* An `onRequest` hook for Fastify.
|
|
34
|
+
*
|
|
35
|
+
* ```ts
|
|
36
|
+
* fastify.addHook("onRequest", fastifyBotHandler(handler));
|
|
37
|
+
* ```
|
|
38
|
+
*
|
|
39
|
+
* `onRequest` rather than `preHandler` on purpose: it is the earliest hook, so a
|
|
40
|
+
* refused request never reaches routing, validation or your body parser — none of
|
|
41
|
+
* which should be doing work for traffic that has already been decided about.
|
|
42
|
+
*/
|
|
43
|
+
export declare function fastifyBotHandler(handler: BotHandler, options?: FastifyAdapterOptions): (request: FastifyLikeRequest, reply: FastifyLikeReply) => Promise<void>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { BotHandler } from "../core.js";
|
|
2
|
+
import type { HandleResult } from "../core.js";
|
|
3
|
+
import type { RequestFacts } from "../types.js";
|
|
4
|
+
export interface FetchAdapterOptions {
|
|
5
|
+
/**
|
|
6
|
+
* Headers to read the client address from, in order of preference.
|
|
7
|
+
*
|
|
8
|
+
* Only headers your edge *overwrites* belong here. One a client can set is one it
|
|
9
|
+
* can use to choose its own identity — and with it the address you rate-limit,
|
|
10
|
+
* allowlist, denylist and block on. `x-forwarded-for` is deliberately **not** in the
|
|
11
|
+
* default list: it is appended to rather than replaced, so its leftmost entry is
|
|
12
|
+
* whatever the client wrote. List it explicitly only if you know your edge replaces
|
|
13
|
+
* the whole header.
|
|
14
|
+
*/
|
|
15
|
+
ipHeaders?: readonly string[];
|
|
16
|
+
/** Reads the address from the platform's own context object, e.g. a Workers `ConnInfo`. */
|
|
17
|
+
clientIp?: (request: Request, context: unknown) => string | undefined;
|
|
18
|
+
/** Header carrying an edge-computed JA3/JA4 fingerprint. */
|
|
19
|
+
tlsFingerprintHeader?: string;
|
|
20
|
+
mountChallengeEndpoint?: boolean;
|
|
21
|
+
enrich?: (request: Request, facts: RequestFacts) => RequestFacts;
|
|
22
|
+
}
|
|
23
|
+
export interface FetchDecision {
|
|
24
|
+
/** Serve this instead of calling your handler. `undefined` means carry on. */
|
|
25
|
+
response?: Response | undefined;
|
|
26
|
+
/** The request to pass on, carrying the verdict headers. */
|
|
27
|
+
request: Request;
|
|
28
|
+
/**
|
|
29
|
+
* What the engine concluded — absent for a challenge verification request, which is
|
|
30
|
+
* answered without being assessed.
|
|
31
|
+
*
|
|
32
|
+
* Reported as missing rather than filled in with a placeholder assessment: "we did
|
|
33
|
+
* not judge this request" and "we judged it and found nothing" must not look the
|
|
34
|
+
* same to whatever is reading this.
|
|
35
|
+
*/
|
|
36
|
+
result?: HandleResult | undefined;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Web-standard adapter, for Cloudflare Workers, Deno, Bun, Vercel Edge and anything
|
|
40
|
+
* else built on `Request`/`Response`.
|
|
41
|
+
*
|
|
42
|
+
* Returns a decision rather than wrapping your handler, so the same primitive fits a
|
|
43
|
+
* router, a middleware chain or a plain `fetch` export. {@link withBotHandler} wraps it
|
|
44
|
+
* for the common case.
|
|
45
|
+
*
|
|
46
|
+
* Note that the engine uses `node:crypto` for HMAC and hashing. On Workers that means
|
|
47
|
+
* enabling `nodejs_compat`; Deno and Bun provide it natively.
|
|
48
|
+
*/
|
|
49
|
+
export declare function createFetchAdapter(handler: BotHandler, options?: FetchAdapterOptions): (request: Request, context?: unknown) => Promise<FetchDecision>;
|
|
50
|
+
/** Wraps a handler. The common case: one call, one line. */
|
|
51
|
+
export declare function withBotHandler(handler: BotHandler, next: (request: Request, context: unknown) => Response | Promise<Response>, options?: FetchAdapterOptions): (request: Request, context?: unknown) => Promise<Response>;
|