@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,211 @@
|
|
|
1
|
+
# Lesson 12 — Going live
|
|
2
|
+
|
|
3
|
+
**Goal:** mount Serif behind a real framework, and get the one setting right that is
|
|
4
|
+
dangerous to get wrong.
|
|
5
|
+
|
|
6
|
+
← [Course](index.md) · Prev: [The challenge](11-the-challenge.md) · Next: [Operating it](13-operating-it.md)
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Three lines
|
|
11
|
+
|
|
12
|
+
```js
|
|
13
|
+
import { BotHandler } from "@osqd/bothandlerjs";
|
|
14
|
+
import { botHandler } from "@osqd/bothandlerjs/adapters";
|
|
15
|
+
|
|
16
|
+
const detector = new BotHandler({ preset: "monitor-only" });
|
|
17
|
+
app.use(botHandler(detector));
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Four adapters ship:
|
|
21
|
+
|
|
22
|
+
```js
|
|
23
|
+
import { botHandler, fastifyBotHandler, koaBotHandler, withBotHandler } from "@osqd/bothandlerjs/adapters";
|
|
24
|
+
|
|
25
|
+
app.use(botHandler(detector)); // Express / Connect / node:http
|
|
26
|
+
fastify.addHook("onRequest", fastifyBotHandler(detector));
|
|
27
|
+
app.use(koaBotHandler(detector));
|
|
28
|
+
export default { fetch: withBotHandler(detector, myHandler) }; // Workers / Deno / Bun / Edge
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**Hono and Next.js need no adapter of their own.** Both speak the platform's `Request` and
|
|
32
|
+
`Response`, which is what `createFetchAdapter` takes and returns:
|
|
33
|
+
|
|
34
|
+
```js
|
|
35
|
+
const guard = createFetchAdapter(detector, { ipHeaders: ["cf-connecting-ip"] });
|
|
36
|
+
|
|
37
|
+
// Hono
|
|
38
|
+
app.use(async (c, next) => {
|
|
39
|
+
const decision = await guard(c.req.raw, c.env);
|
|
40
|
+
if (decision.response) return decision.response;
|
|
41
|
+
await next();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Next.js — middleware.ts
|
|
45
|
+
export async function middleware(request) {
|
|
46
|
+
const decision = await guard(request);
|
|
47
|
+
if (decision.response) return decision.response;
|
|
48
|
+
return NextResponse.next({ request: { headers: decision.request.headers } });
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Passing `decision.request.headers` on is the part worth doing: it carries the verdict to
|
|
53
|
+
routes further in, so they need not assess anything themselves.
|
|
54
|
+
|
|
55
|
+
## Why the engine never touches a response
|
|
56
|
+
|
|
57
|
+
`handle` returns an `ActionOutcome` — `continue`, `respond`, or `drop` — and the adapter
|
|
58
|
+
applies it. That is why the same policy behaves identically on Express and on a Worker, why
|
|
59
|
+
`assess` is safe to run over a log file, and why writing your own adapter is about thirty
|
|
60
|
+
lines.
|
|
61
|
+
|
|
62
|
+
It also **fails open**. An unexpected failure inside detection serves the request; the error
|
|
63
|
+
goes to your `onError` and the visitor gets their page. A bot filter that fails closed is an
|
|
64
|
+
outage with extra steps.
|
|
65
|
+
|
|
66
|
+
## Now the important part
|
|
67
|
+
|
|
68
|
+
### The client IP
|
|
69
|
+
|
|
70
|
+
Stop here if Serif sits behind anything — a load balancer, a CDN, nginx, a service mesh.
|
|
71
|
+
|
|
72
|
+
The client address becomes the [actor key](07-actors.md). Every rate limit, every allowlist
|
|
73
|
+
entry, every behavioural signal depends on it.
|
|
74
|
+
|
|
75
|
+
`X-Forwarded-For` is a **client-supplied header**. Trust it without knowing how many proxies
|
|
76
|
+
sit in front of you and anyone can prepend a fake hop and choose the address you rate-limit,
|
|
77
|
+
allowlist and block on. **The failure is silent** — nothing errors, nothing logs, and every
|
|
78
|
+
per-actor mechanism becomes an attacker input.
|
|
79
|
+
|
|
80
|
+
There is deliberately no convenient default.
|
|
81
|
+
|
|
82
|
+
```js
|
|
83
|
+
proxy: { trustProxy: true, trustedProxies: ["10.0.0.0/8"] } // recommended
|
|
84
|
+
proxy: { trustProxy: true, hops: 2 } // correct only while the count is
|
|
85
|
+
proxy: { trustProxy: false } // default — header ignored entirely
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
**Use `trustedProxies`.** The chain is walked from the right, discarding your own
|
|
89
|
+
infrastructure, and the first address outside it is the client — robust against an extra hop
|
|
90
|
+
appearing when somebody adds a CDN and forgets to tell you.
|
|
91
|
+
|
|
92
|
+
`hops` is right until the topology changes, and then it is wrong in the direction that lets
|
|
93
|
+
clients choose their own address.
|
|
94
|
+
|
|
95
|
+
Three details worth knowing:
|
|
96
|
+
|
|
97
|
+
**Addresses are compared as bytes.** `::ffff:127.0.0.1`, `0177.0.0.1` and `127.0.0.001` are
|
|
98
|
+
the same address, and all slip past an allowlist that compares strings. Invalid CIDRs throw
|
|
99
|
+
at construction rather than matching nothing silently.
|
|
100
|
+
|
|
101
|
+
**The connecting peer is checked too.** On a server reachable both through the load balancer
|
|
102
|
+
and directly, a request arriving from outside your trusted ranges did not come through your
|
|
103
|
+
proxies — so its forwarded header is not evidence, and the socket address is used. Without
|
|
104
|
+
that check, anyone who finds the origin address picks their own client IP.
|
|
105
|
+
|
|
106
|
+
**Do not allowlist loopback.** The moment you sit behind nginx or beside a sidecar, every
|
|
107
|
+
request in the world arrives from `127.0.0.1`.
|
|
108
|
+
|
|
109
|
+
### On Fetch runtimes
|
|
110
|
+
|
|
111
|
+
There is no socket, so the address comes from a header. Only `cf-connecting-ip` and
|
|
112
|
+
`x-real-ip` are trusted by default — both single-valued, written by the edge that terminated
|
|
113
|
+
the connection. `x-forwarded-for` is **not**, because a proxy *appends* to it.
|
|
114
|
+
|
|
115
|
+
Better still, ask the platform:
|
|
116
|
+
|
|
117
|
+
```js
|
|
118
|
+
withBotHandler(detector, myHandler, {
|
|
119
|
+
clientIp: (request, env) => env.cf?.connectingIp,
|
|
120
|
+
});
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
If no address can be found, every visitor is tracked under one empty actor key — which
|
|
124
|
+
applies your rate limits to the whole site at once. The adapter warns the first time.
|
|
125
|
+
|
|
126
|
+
On Workers, enable `nodejs_compat`; the engine uses `node:crypto`.
|
|
127
|
+
|
|
128
|
+
## Tell it about your own people
|
|
129
|
+
|
|
130
|
+
The only conclusive human signal in this library is your assertion:
|
|
131
|
+
|
|
132
|
+
```js
|
|
133
|
+
new BotHandler({
|
|
134
|
+
isHuman: (facts) => Boolean(sessions.get(facts.session ?? "")?.authenticated),
|
|
135
|
+
});
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
That produces `certain` human evidence, which is what the `cleared-human-allow` rule at the
|
|
139
|
+
top of most presets is for. Without it that rule never fires, and your signed-in customers
|
|
140
|
+
are judged like strangers.
|
|
141
|
+
|
|
142
|
+
## Serif, mounted
|
|
143
|
+
|
|
144
|
+
```js
|
|
145
|
+
import express from "express";
|
|
146
|
+
import { BotHandler } from "@osqd/bothandlerjs";
|
|
147
|
+
import { botHandler } from "@osqd/bothandlerjs/adapters";
|
|
148
|
+
|
|
149
|
+
const site = new BotHandler({
|
|
150
|
+
preset: "monitor-only", // week one. Enforce later.
|
|
151
|
+
proxy: { trustProxy: true, trustedProxies: ["10.0.0.0/8"] },
|
|
152
|
+
allowlist: ["10.0.0.0/8"], // monitors and CI
|
|
153
|
+
ignorePaths: ["/healthz", "/metrics"],
|
|
154
|
+
isHuman: (facts) => Boolean(sessions.get(facts.session ?? "")?.authenticated),
|
|
155
|
+
challenge: { secrets: [process.env.SERIF_CHALLENGE_SECRET], contactHtml: "<p>…</p>" },
|
|
156
|
+
onWarning: (message) => console.warn("[bot]", message),
|
|
157
|
+
onDowngrade: ({ decision }) => console.warn(`[bot] guard stopped rule ${decision.rule}`),
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const auth = new BotHandler({ preset: "protect-auth", proxy: { trustProxy: true, trustedProxies: ["10.0.0.0/8"] } });
|
|
161
|
+
|
|
162
|
+
const app = express();
|
|
163
|
+
app.use(botHandler(site));
|
|
164
|
+
app.use("/login", botHandler(auth));
|
|
165
|
+
app.use("/checkout", botHandler(auth));
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Note `allowlist` and `ignorePaths` are different things. An **ignored path** is not
|
|
169
|
+
assessed. An **allowlisted address** is not judged *at all* — not judged leniently, not
|
|
170
|
+
judged. It is the strongest setting in the library, and the one most worth alerting on when
|
|
171
|
+
it changes.
|
|
172
|
+
|
|
173
|
+
## Exercise
|
|
174
|
+
|
|
175
|
+
Serif runs on Cloudflare in front of a Node origin. A colleague suggests
|
|
176
|
+
`proxy: { trustProxy: true, hops: 1 }`. What is wrong with it, and what breaks?
|
|
177
|
+
|
|
178
|
+
<details>
|
|
179
|
+
<summary>Answer</summary>
|
|
180
|
+
|
|
181
|
+
`hops: 1` takes the **last** entry in `X-Forwarded-For`, which is what Cloudflare appended —
|
|
182
|
+
so today it is right. It breaks the moment anything is added in front: another CDN, a
|
|
183
|
+
regional load balancer, a WAF. Then the last entry is that hop's, and Cloudflare's entry —
|
|
184
|
+
the real client — is one further left.
|
|
185
|
+
|
|
186
|
+
Worse, the failure is silent and the wrong direction: the address you rate-limit becomes one
|
|
187
|
+
your own infrastructure controls, so all traffic collapses onto one actor.
|
|
188
|
+
|
|
189
|
+
```js
|
|
190
|
+
proxy: { trustProxy: true, trustedProxies: [...cloudflareRanges, "10.0.0.0/8"] }
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Walking from the right and discarding known infrastructure survives an extra hop appearing.
|
|
194
|
+
And on the Cloudflare edge itself, prefer `clientIp: (req, env) => env.cf?.connectingIp` —
|
|
195
|
+
ask the platform, not a header.
|
|
196
|
+
</details>
|
|
197
|
+
|
|
198
|
+
## What you learned
|
|
199
|
+
|
|
200
|
+
- Four adapters, plus Hono and Next.js for free; the engine never touches a response
|
|
201
|
+
- It fails open, on purpose
|
|
202
|
+
- `trustedProxies` over `hops`, always; the failure mode of getting it wrong is silent
|
|
203
|
+
- `isHuman` is the only conclusive human signal that exists
|
|
204
|
+
- Allowlisting stops detection entirely — it is not leniency
|
|
205
|
+
|
|
206
|
+
## Reference
|
|
207
|
+
|
|
208
|
+
- [Adapters](../integration/adapters.md) · [The client IP](../integration/client-ip.md)
|
|
209
|
+
- [Configuration](../reference/configuration.md)
|
|
210
|
+
|
|
211
|
+
Next: [Operating it](13-operating-it.md).
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# Lesson 13 — Operating it
|
|
2
|
+
|
|
3
|
+
**Goal:** see what the policy is doing to real traffic, and know which two numbers to alert
|
|
4
|
+
on.
|
|
5
|
+
|
|
6
|
+
← [Course](index.md) · Prev: [Going live](12-going-live.md) · Next: [Scaling](14-scaling.md)
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## The dashboard
|
|
11
|
+
|
|
12
|
+
Counters tell you *how much*. The dashboard tells you **which requests, and why** — every
|
|
13
|
+
assessment as it lands, and on any row you open, the individual evidence with its certainty
|
|
14
|
+
tier and, for proven ones, the written basis.
|
|
15
|
+
|
|
16
|
+
```js
|
|
17
|
+
const dashboard = await detector.serveDashboard({
|
|
18
|
+
port: 9674,
|
|
19
|
+
title: "serif",
|
|
20
|
+
auth: { username: "ops", password: process.env.DASHBOARD_PASSWORD },
|
|
21
|
+
});
|
|
22
|
+
console.log(dashboard.url);
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
That is the whole integration: it listens on a port of its own, subscribes to the handler
|
|
26
|
+
you called it on, and returns a handle with a `close()`.
|
|
27
|
+
|
|
28
|
+
Four screens — the live feed, actors, statistics, and the policy editor. Open a row and you
|
|
29
|
+
see the evidence that produced the verdict; that is the view that answers "why was this
|
|
30
|
+
customer challenged?".
|
|
31
|
+
|
|
32
|
+
### Before it goes anywhere real
|
|
33
|
+
|
|
34
|
+
**It reports on one process.** Behind a load balancer with eight pods you are looking at an
|
|
35
|
+
eighth of your traffic. The page names the instance, which is the honest amount of help it
|
|
36
|
+
can give.
|
|
37
|
+
|
|
38
|
+
**Its feed is memory-only** and bounded. It is a live view, not a log.
|
|
39
|
+
|
|
40
|
+
**Everything it can *do* is behind a flag**, and each flag is separate:
|
|
41
|
+
|
|
42
|
+
```js
|
|
43
|
+
controls: { reset: true, editPolicy: true, editGuard: true, editRanges: true }
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`editGuard` is separate from `editPolicy` deliberately: a rule that overreaches is stopped
|
|
47
|
+
by the guard, and this changes whether anything stops it. They read like similar powers and
|
|
48
|
+
are not.
|
|
49
|
+
|
|
50
|
+
**What it *shows* is separate again** — `sections` — and enforced on the server, so a
|
|
51
|
+
switched-off section's endpoint answers 403 and its fields never leave the process:
|
|
52
|
+
|
|
53
|
+
```js
|
|
54
|
+
// What an analyst gets: the shape of the traffic, without naming individuals.
|
|
55
|
+
await detector.serveDashboard({
|
|
56
|
+
port: 9684,
|
|
57
|
+
sections: { evidence: false, policy: false },
|
|
58
|
+
redact: { maskIp: true },
|
|
59
|
+
});
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
That combination is worth understanding. The dashboard people watch all day need not be the
|
|
63
|
+
one that names individuals, or the one that explains your detection to whoever is scraping
|
|
64
|
+
you.
|
|
65
|
+
|
|
66
|
+
## Metrics
|
|
67
|
+
|
|
68
|
+
On by default; a handful of integer increments per request.
|
|
69
|
+
|
|
70
|
+
```js
|
|
71
|
+
app.get("/internal/metrics", (_req, res) => res.type("text/plain").send(detector.prometheus()));
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**Serve it where only you can reach it.** The detector-firing series describe how your
|
|
75
|
+
detection behaves, which is exactly what somebody tuning a scraper would like to read.
|
|
76
|
+
|
|
77
|
+
### The two to alert on
|
|
78
|
+
|
|
79
|
+
**`bothandler_downgrades_total`** — rules that asked to deny and were refused for lack of
|
|
80
|
+
proof. You met it in [lesson 4](04-the-guard.md). A rising count means your policy is asking
|
|
81
|
+
for something the evidence does not support.
|
|
82
|
+
|
|
83
|
+
**`bothandler_verdicts_total{verdict="unknown"}`** — ordinary traffic. If this falls, either
|
|
84
|
+
your traffic changed or your detection did, and you want to know which.
|
|
85
|
+
|
|
86
|
+
### The one to read before moving a threshold
|
|
87
|
+
|
|
88
|
+
**`bothandler_score_bucket`** — how suspicion is distributed across everything scored, in
|
|
89
|
+
ten buckets of ten points. Proven assessments are counted separately by
|
|
90
|
+
`bothandler_proven_total`; their score is 100 by definition and would put a meaningless
|
|
91
|
+
spike at the top.
|
|
92
|
+
|
|
93
|
+
Moving `suspectThreshold` or a rule's `minScore` without looking at this is guessing. The
|
|
94
|
+
histogram tells you how many requests sit in the ten points you are about to cross.
|
|
95
|
+
|
|
96
|
+
### Per-detector timing is available and off
|
|
97
|
+
|
|
98
|
+
Timing each detector costs two clock reads per detector per request — forty on a
|
|
99
|
+
twenty-detector set, to measure work counted in microseconds. Worth paying while you tune:
|
|
100
|
+
|
|
101
|
+
```js
|
|
102
|
+
new BotHandler({ metrics: { perDetectorTiming: true } });
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
It is how you find out that one `io` detector costs more than the other nineteen together.
|
|
106
|
+
|
|
107
|
+
## The traffic audit
|
|
108
|
+
|
|
109
|
+
Counters cannot tell you a number is *unusual*, and bot traffic is not a level — it is an
|
|
110
|
+
event. The number that matters is not "12% of requests are bots" but "12% now, 2% for the
|
|
111
|
+
hour before".
|
|
112
|
+
|
|
113
|
+
```js
|
|
114
|
+
new BotHandler({
|
|
115
|
+
audit: {
|
|
116
|
+
windowMs: 5 * 60_000,
|
|
117
|
+
baselineMs: 60 * 60_000,
|
|
118
|
+
minSamples: 50,
|
|
119
|
+
cooldownMs: 15 * 60_000,
|
|
120
|
+
},
|
|
121
|
+
onAnomaly: (anomaly) => pager.send(anomaly.severity, anomaly.summary),
|
|
122
|
+
});
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
**The baseline ends where the window begins.** A baseline containing the window would be
|
|
126
|
+
partly made of the thing being measured, and a large enough spike would raise its own bar
|
|
127
|
+
until it stopped looking like one.
|
|
128
|
+
|
|
129
|
+
| Check | Fires when |
|
|
130
|
+
| ----- | ---------- |
|
|
131
|
+
| `bot-share-spike` | automation is a much larger share than the baseline |
|
|
132
|
+
| `traffic-spike` | volume far above the baseline rate |
|
|
133
|
+
| `denial-spike` | a much larger share is being denied — **read these before assuming they are all bots** |
|
|
134
|
+
| `guard-stop-spike` | the guard is refusing far more rules than usual |
|
|
135
|
+
| `human-share-drop` | traffic reading as human has fallen away |
|
|
136
|
+
| `detector-failures` | detectors erroring or timing out |
|
|
137
|
+
| `challenge-solve-rate` | nearly everything challenged is passing — **high is the bad direction** |
|
|
138
|
+
|
|
139
|
+
That last one is the closest to this library's thesis. Every other check asks whether the
|
|
140
|
+
*traffic* changed shape; this asks whether the mitigation is landing on the right
|
|
141
|
+
population. A proof of work is trivial for a browser and trivial for a competent scraper —
|
|
142
|
+
what it actually costs is a few seconds of somebody's afternoon. So when nearly everything
|
|
143
|
+
challenged goes on to pass, the challenges are not filtering bots out, they are **taxing
|
|
144
|
+
people**. From every other angle a solved challenge looks like a challenge that worked,
|
|
145
|
+
which is why it needs saying out loud.
|
|
146
|
+
|
|
147
|
+
Every check has a **floor** as well as a ratio — a quiet site at 3am produces "800% more
|
|
148
|
+
bots" from four requests, and an alerting system that cries wolf at 3am gets muted — and a
|
|
149
|
+
**cooldown**, because a spike lasting an hour is one event, not sixty.
|
|
150
|
+
|
|
151
|
+
## Events
|
|
152
|
+
|
|
153
|
+
Everything the engine concludes is available as a callback or a subscription:
|
|
154
|
+
|
|
155
|
+
```js
|
|
156
|
+
const stop = detector.on("denial", ({ assessment, decision }) =>
|
|
157
|
+
log.info({ actor: assessment.actor.key, rule: decision.rule }),
|
|
158
|
+
);
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
| Event | Fires when |
|
|
162
|
+
| ----- | ---------- |
|
|
163
|
+
| `assessment` | every assessment. The firehose |
|
|
164
|
+
| `decision` | every decision |
|
|
165
|
+
| `denial` | a request was actually refused |
|
|
166
|
+
| `downgrade` | the guard replaced a terminal action |
|
|
167
|
+
| `challenge` | one was issued, solved or rejected |
|
|
168
|
+
| `detector-failure` | a detector threw or timed out |
|
|
169
|
+
| `policy-change` / `guard-change` / `range-change` / `actor-change` | somebody changed something at runtime |
|
|
170
|
+
| `anomaly` | the audit noticed a change of shape |
|
|
171
|
+
| `warning` / `error` | misconfiguration; a component failed |
|
|
172
|
+
|
|
173
|
+
**None of them can hurt a request.** A handler that throws is caught and reported once
|
|
174
|
+
through `onError`, the rest still run, and none is awaited — returning a promise is fine and
|
|
175
|
+
the response never waits for your webhook.
|
|
176
|
+
|
|
177
|
+
## Notifications
|
|
178
|
+
|
|
179
|
+
```js
|
|
180
|
+
notifications: {
|
|
181
|
+
sinks: [consoleNotifier(), slackNotifier({ url }), webhookNotifier({ url, secret })],
|
|
182
|
+
filter: { types: ["denial", "downgrade", "anomaly"], minScore: 70 },
|
|
183
|
+
redaction: { maskIp: true },
|
|
184
|
+
dedupeWindowMs: 60_000,
|
|
185
|
+
maxPerWindow: 200,
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Two properties decide whether alerting is an asset or a liability under bot load. **It never
|
|
190
|
+
blocks a request** — a wedged webhook slows nothing. **It has a ceiling** — repeats collapse,
|
|
191
|
+
a global cap catches distributed traffic where every event is genuinely distinct, and the
|
|
192
|
+
suppressed count is reported when the window rolls, so a quiet channel is never mistaken for
|
|
193
|
+
quiet traffic.
|
|
194
|
+
|
|
195
|
+
Addresses are masked to a `/24` or `/64` on the way *out*, so detection still sees
|
|
196
|
+
everything.
|
|
197
|
+
|
|
198
|
+
## Exercise
|
|
199
|
+
|
|
200
|
+
Serif has been live on `monitor-only` for a week. What do you look at, in what order, to
|
|
201
|
+
decide whether to enforce?
|
|
202
|
+
|
|
203
|
+
<details>
|
|
204
|
+
<summary>Answer</summary>
|
|
205
|
+
|
|
206
|
+
1. **`bothandler_verdicts_total`** — what is the actual mix? If `unknown` is not the large
|
|
207
|
+
majority, something is misconfigured, most likely the client IP.
|
|
208
|
+
2. **The dashboard's live feed, filtered to anything not `unknown`.** Open rows and read the
|
|
209
|
+
evidence. You are looking for the integration you forgot: a partner's nightly sync, a
|
|
210
|
+
status prober, the marketing team's link checker, your own renderer.
|
|
211
|
+
3. **`bothandler_score_bucket`** — where does suspicion actually sit? That tells you what
|
|
212
|
+
`minScore: 70` would catch on *your* traffic rather than in general.
|
|
213
|
+
4. **`bothandler_downgrades_total`** — on `monitor-only` this should be zero, because
|
|
214
|
+
nothing asks for a terminal action. If it is not, your rules are not what you think.
|
|
215
|
+
5. **`npx @osqd/bothandlerjs replay` over the week's access log** with the policy you are
|
|
216
|
+
considering, and read every request it would have refused.
|
|
217
|
+
|
|
218
|
+
Only then enforce — and allowlist what step 2 found *first*.
|
|
219
|
+
|
|
220
|
+
Step 5 is [lesson 16](16-proving-it.md), and it is the one that catches what the others
|
|
221
|
+
miss.
|
|
222
|
+
</details>
|
|
223
|
+
|
|
224
|
+
## What you learned
|
|
225
|
+
|
|
226
|
+
- The dashboard shows which requests and why; it reports on one process and is bounded
|
|
227
|
+
- `sections` and `controls` are separate powers, enforced server-side
|
|
228
|
+
- Alert on `downgrades_total` and on `unknown`; read `score_bucket` before moving a threshold
|
|
229
|
+
- The audit compares a window against the baseline before it, with floors and cooldowns
|
|
230
|
+
- A high challenge solve rate is the bad direction
|
|
231
|
+
- Nothing in the observation path can slow or break a request
|
|
232
|
+
|
|
233
|
+
## Reference
|
|
234
|
+
|
|
235
|
+
- [The dashboard](../operations/dashboard.md) · [Metrics](../operations/metrics.md)
|
|
236
|
+
- [The audit](../operations/audit.md) · [Notifications](../operations/notifications.md)
|
|
237
|
+
|
|
238
|
+
Next: [Scaling and changing it live](14-scaling.md).
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# Lesson 14 — Scaling, and changing it live
|
|
2
|
+
|
|
3
|
+
**Goal:** run Serif on more than one process without breaking your rate limits, and change
|
|
4
|
+
a rule during an incident without a deploy.
|
|
5
|
+
|
|
6
|
+
← [Course](index.md) · Prev: [Operating it](13-operating-it.md) · Next: [Extending it](15-extending.md)
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## What breaks at two replicas
|
|
11
|
+
|
|
12
|
+
Two things, and both are correctness rather than optimisation.
|
|
13
|
+
|
|
14
|
+
**Single-use challenge nonces.** Without sharing, a scraper retries a solved nonce against
|
|
15
|
+
another replica until one has not seen it.
|
|
16
|
+
|
|
17
|
+
**Rate limits.** A limit of 100/minute enforced independently by four replicas is a limit of
|
|
18
|
+
400/minute.
|
|
19
|
+
|
|
20
|
+
```js
|
|
21
|
+
import { RedisStore } from "@osqd/bothandlerjs";
|
|
22
|
+
new BotHandler({ store: new RedisStore(redis) });
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`RedisStore` takes a client you already have. The package stays dependency-free — it uses
|
|
26
|
+
the handful of methods `ioredis` and `node-redis` share, and imports neither.
|
|
27
|
+
|
|
28
|
+
## What deliberately does not scale, and why
|
|
29
|
+
|
|
30
|
+
Behavioural state — arrival rates, cadence, path breadth, User-Agent history — stays in
|
|
31
|
+
memory per process, on purpose.
|
|
32
|
+
|
|
33
|
+
A round trip per request would buy accuracy for signals that are only ever allowed to
|
|
34
|
+
**raise suspicion**, never to deny anybody: the [guard](04-the-guard.md) sees to that.
|
|
35
|
+
Behind four replicas each sees a quarter of an actor's traffic and is correspondingly less
|
|
36
|
+
sure, which is the right trade for something that cannot close a door on its own.
|
|
37
|
+
|
|
38
|
+
Spending a network round trip on the request path to sharpen a signal that cannot act alone
|
|
39
|
+
is a bad bargain, and the request path is where this library refuses to make bad bargains.
|
|
40
|
+
|
|
41
|
+
## Except proof, which travels
|
|
42
|
+
|
|
43
|
+
A `confirmed-bot` verdict is *proven* — something declared itself, forged an identity, or
|
|
44
|
+
walked into a trap. That is a fact about the client rather than a judgement about it:
|
|
45
|
+
|
|
46
|
+
```js
|
|
47
|
+
new BotHandler({ store: new RedisStore(redis), shareConfirmations: true });
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Without it, a client proven to be a bot on one replica is a stranger to the other seven, and
|
|
51
|
+
a rule reading `minPriorConfirmations: 1` fires about an eighth as often as it reads.
|
|
52
|
+
|
|
53
|
+
**Proof travels; suspicion stays home.** That sentence is the whole design.
|
|
54
|
+
|
|
55
|
+
The cost is one store read the **first time each instance sees an actor** — not one per
|
|
56
|
+
request — and it is never awaited, so nothing joins the request path. A store outage means
|
|
57
|
+
the count falls back to what that process saw itself.
|
|
58
|
+
|
|
59
|
+
## Changing things without a deploy
|
|
60
|
+
|
|
61
|
+
Two things go stale between deploys, and both hurt.
|
|
62
|
+
|
|
63
|
+
**Published crawler ranges.** A stale list turns a verified crawler into an accused
|
|
64
|
+
impersonator.
|
|
65
|
+
|
|
66
|
+
**A rule set.** Sometimes a rule is wrong in a way you want to fix *now* — which is exactly
|
|
67
|
+
when the deploy pipeline feels longest.
|
|
68
|
+
|
|
69
|
+
### Rules
|
|
70
|
+
|
|
71
|
+
```js
|
|
72
|
+
detector.updatePolicy(
|
|
73
|
+
[...detector.policy.rules, { id: "allow-healthz", match: { path: "/healthz" }, action: "allow" }],
|
|
74
|
+
{ by: "you@serif.example" },
|
|
75
|
+
);
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Validated first, swapped **atomically**, announced through `onWarning` and `policy-change`.
|
|
79
|
+
Invalid input throws and leaves the previous set standing.
|
|
80
|
+
|
|
81
|
+
**What it cannot change:** `falsePositivePolicy`, `fallbackAction` and
|
|
82
|
+
`terminalScoreThreshold`. No runtime edit can relax the guard through this door.
|
|
83
|
+
|
|
84
|
+
### The guard
|
|
85
|
+
|
|
86
|
+
Its own method, its own event, its own dashboard flag:
|
|
87
|
+
|
|
88
|
+
```js
|
|
89
|
+
detector.updateGuard({ falsePositivePolicy: "balanced" }, { by: "you@serif.example" });
|
|
90
|
+
// warning: Guard settings changed at runtime by you@serif.example:
|
|
91
|
+
// falsePositivePolicy strict → balanced.
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`onGuardChange` carries the before and the after. **If you page on one thing in this
|
|
95
|
+
lesson, page on this.**
|
|
96
|
+
|
|
97
|
+
### Ranges and actors
|
|
98
|
+
|
|
99
|
+
```js
|
|
100
|
+
detector.updateCrawlerRanges("gptbot", await fetchRanges());
|
|
101
|
+
detector.updateRanges("allowlist", [...current, "198.51.100.0/24"]);
|
|
102
|
+
detector.listRanges(); // [{ name: "crawler:gptbot", size: 42 }, …]
|
|
103
|
+
|
|
104
|
+
detector.forgetActor(key, { by: "you@serif.example" });
|
|
105
|
+
detector.clearActor(key, 60 * 60_000, { by: "you@serif.example" });
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`range-change` is worth alerting on: the allowlist is the one list that stops detection
|
|
109
|
+
*running*.
|
|
110
|
+
|
|
111
|
+
### `by`, and the audit trail
|
|
112
|
+
|
|
113
|
+
Every mutating method takes `{ by }`. The dashboard fills it from its own `auth` — a basic
|
|
114
|
+
credential names itself, and a custom `authorize` can return an identity instead of `true`.
|
|
115
|
+
|
|
116
|
+
The library has no user model and does not want one. It carries the name it was given into
|
|
117
|
+
the warning and the event, so your trail can say *who* rather than only *what*:
|
|
118
|
+
|
|
119
|
+
```js
|
|
120
|
+
detector.on("policy-change", ({ by, rules }) => auditLog.write({ who: by, count: rules.length }));
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Nothing verifies the name. It is exactly as trustworthy as whatever supplied it — which is
|
|
124
|
+
your authentication, the same thing that decides whether the change is allowed at all.
|
|
125
|
+
|
|
126
|
+
## The incident runbook
|
|
127
|
+
|
|
128
|
+
Put this somewhere you can find it at 3am.
|
|
129
|
+
|
|
130
|
+
```js
|
|
131
|
+
// 1. See it. dashboard → live feed, filter to denials
|
|
132
|
+
// 2. Slow it, uniformly.
|
|
133
|
+
detector.updatePolicy([
|
|
134
|
+
{ id: "incident-ceiling", match: {}, action: "rate-limit",
|
|
135
|
+
params: { limit: { max: 30, windowMs: 60_000 } } },
|
|
136
|
+
...detector.policy.rules,
|
|
137
|
+
], { by: "you@serif.example" });
|
|
138
|
+
|
|
139
|
+
// 3. Only if that is not enough — and read the preset's warning first.
|
|
140
|
+
// It refuses proven automation, so allowlist your webhooks BEFORE switching over.
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
A uniform ceiling is the one mitigation that cannot be wrong about who somebody is. Reach
|
|
144
|
+
for it before you reach for anything that singles clients out.
|
|
145
|
+
|
|
146
|
+
And note what is *not* in the runbook: `falsePositivePolicy: "aggressive"`. An incident is
|
|
147
|
+
precisely when people reach for it, and precisely when the population getting caught is at
|
|
148
|
+
its most unusual.
|
|
149
|
+
|
|
150
|
+
## Exercise
|
|
151
|
+
|
|
152
|
+
Serif runs eight pods behind a load balancer. A rule reads
|
|
153
|
+
`{ minPriorConfirmations: 1, action: "block" }`. It fires far less than expected. Why, and
|
|
154
|
+
what are the two fixes?
|
|
155
|
+
|
|
156
|
+
<details>
|
|
157
|
+
<summary>Answer</summary>
|
|
158
|
+
|
|
159
|
+
Each pod only knows about the confirmations *it* saw. A client proven on pod 3 is a stranger
|
|
160
|
+
to the other seven, so with traffic spread evenly the rule fires roughly one time in eight.
|
|
161
|
+
|
|
162
|
+
**Fix one — share the proof:**
|
|
163
|
+
|
|
164
|
+
```js
|
|
165
|
+
new BotHandler({ store: new RedisStore(redis), shareConfirmations: true });
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
This is what that flag is for. Proof is a fact about the client, so it can cross replicas;
|
|
169
|
+
suspicion stays home.
|
|
170
|
+
|
|
171
|
+
**Fix two — give the actors a better key.** If the load balancer has session affinity, or if
|
|
172
|
+
you key on a session id rather than an address, the same client lands where its history is:
|
|
173
|
+
|
|
174
|
+
```js
|
|
175
|
+
actorKey: (facts) => facts.session ?? facts.ip
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Both are worth doing, and the second helps every behavioural detector as well — see
|
|
179
|
+
[lesson 7](07-actors.md).
|
|
180
|
+
</details>
|
|
181
|
+
|
|
182
|
+
## What you learned
|
|
183
|
+
|
|
184
|
+
- Nonces and rate limits need a shared store; that is correctness, not tuning
|
|
185
|
+
- Behavioural state stays process-local because it can never deny anybody alone
|
|
186
|
+
- `shareConfirmations` lets *proof* cross replicas, unawaited and once per actor
|
|
187
|
+
- `updatePolicy` cannot relax the guard; `updateGuard` is a separate, alertable power
|
|
188
|
+
- A uniform ceiling is the safest first move in an incident
|
|
189
|
+
|
|
190
|
+
## Reference
|
|
191
|
+
|
|
192
|
+
- [Stores](../integration/stores.md) · [Runtime changes](../operations/runtime-changes.md)
|
|
193
|
+
- [Presets](../policy/presets.md#under-attack)
|
|
194
|
+
|
|
195
|
+
Next: [Extending it](15-extending.md).
|