@osqd/bothandlerjs 0.5.0 → 0.7.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 +227 -1
- package/README.md +18 -10
- package/dist/adapters/fastify.d.ts +10 -0
- package/dist/adapters/index.cjs +38 -10
- package/dist/adapters/index.cjs.map +1 -1
- package/dist/adapters/index.js +38 -10
- package/dist/adapters/index.js.map +1 -1
- package/dist/challenge/index.d.ts +40 -0
- package/dist/cli.cjs +2256 -103
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +2256 -103
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +55 -0
- package/dist/core.d.ts +48 -21
- package/dist/corpus/index.cjs +365 -7
- package/dist/corpus/index.cjs.map +1 -1
- package/dist/corpus/index.js +365 -7
- package/dist/corpus/index.js.map +1 -1
- package/dist/corpus/schema.d.ts +33 -0
- package/dist/crawler-ranges.d.ts +31 -0
- package/dist/dashboard/client/actions.d.ts +1 -1
- package/dist/dashboard/client/app.d.ts +9 -2
- package/dist/dashboard/client/boot.d.ts +32 -3
- package/dist/dashboard/client/query.d.ts +72 -12
- package/dist/dashboard/client/registry.d.ts +25 -0
- package/dist/dashboard/client/saved.d.ts +29 -0
- package/dist/dashboard/client/store.d.ts +16 -2
- package/dist/dashboard/client/types.d.ts +2 -0
- package/dist/dashboard/client.generated.d.ts +1 -1
- package/dist/dashboard/types.d.ts +15 -0
- package/dist/detectors/blended-identity.d.ts +34 -0
- package/dist/detectors/challenge-integrity.d.ts +26 -0
- package/dist/detectors/challenge-reaction.d.ts +39 -0
- package/dist/detectors/clearance.d.ts +1 -23
- package/dist/detectors/id-enumeration.d.ts +31 -0
- package/dist/detectors/index.d.ts +24 -1
- package/dist/detectors/known-bots.d.ts +11 -0
- package/dist/detectors/marker.d.ts +106 -0
- package/dist/detectors/parameter-sweep.d.ts +39 -0
- package/dist/detectors/probe-signature.d.ts +27 -0
- package/dist/detectors/probe-volume.d.ts +26 -0
- package/dist/detectors/site-baseline.d.ts +135 -0
- package/dist/detectors/target-integrity.d.ts +16 -0
- package/dist/detectors/transport-coherence.d.ts +31 -0
- package/dist/detectors/trap.d.ts +10 -3
- package/dist/detectors/types.d.ts +17 -0
- package/dist/element/index.cjs +730 -80
- package/dist/element/index.cjs.map +1 -1
- package/dist/element/index.js +730 -80
- package/dist/element/index.js.map +1 -1
- package/dist/index.cjs +2044 -123
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.js +2025 -123
- package/dist/index.js.map +1 -1
- package/dist/internal/async.d.ts +0 -3
- package/dist/internal/ip.d.ts +18 -0
- package/dist/internal/text.d.ts +28 -0
- package/dist/metrics.d.ts +18 -0
- package/dist/probe/index.d.ts +153 -0
- package/dist/probe/marker.d.ts +119 -0
- package/dist/site/index.d.ts +122 -0
- package/dist/state.d.ts +205 -0
- package/dist/stores/redis.d.ts +24 -1
- package/dist/types.d.ts +106 -0
- package/docs/course/05-detectors.md +9 -4
- package/docs/course/06-identity.md +1 -1
- package/docs/course/16-proving-it.md +15 -9
- package/docs/course/index.md +1 -1
- package/docs/design/decisions.md +1 -1
- package/docs/detection/correlation.md +284 -0
- package/docs/detection/detectors.md +259 -1
- package/docs/detection/index.md +2 -1
- package/docs/detection/shadow-mode.md +147 -0
- package/docs/detection/signatures.md +10 -2
- package/docs/index.md +3 -2
- package/docs/integration/client-ip.md +16 -0
- package/docs/operations/dashboard.md +40 -1
- package/docs/operations/filters.md +143 -0
- package/docs/operations/index.md +1 -0
- package/docs/operations/metrics.md +18 -0
- package/docs/policy/presets.md +1 -1
- package/docs/start/choosing-a-policy.md +1 -1
- package/docs/start/first-integration.md +1 -1
- package/docs/start/installation.md +2 -2
- package/docs/testing/cli.md +7 -1
- package/docs/testing/corpus.md +12 -8
- package/docs/testing/index.md +1 -1
- package/docs/testing/try-it.md +1 -1
- package/package.json +4 -1
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
# Correlating a client's own requests
|
|
2
|
+
|
|
3
|
+
Most of this library reads one request. A smaller and more valuable part reads a
|
|
4
|
+
*series* — what an actor has done across many requests — because the tells that matter
|
|
5
|
+
most are not visible in any single one. A wordlist scan is a hundred ordinary-looking
|
|
6
|
+
404s. A scrape is a thousand ordinary-looking page loads. Nothing in any one of those
|
|
7
|
+
requests is remarkable; the shape of all of them together is.
|
|
8
|
+
|
|
9
|
+
This page is about the join: what makes two requests "the same client", and what each
|
|
10
|
+
kind of join is worth.
|
|
11
|
+
|
|
12
|
+
## The problem with joining on an address
|
|
13
|
+
|
|
14
|
+
Every cross-request detector needs to decide which requests belong together, and until
|
|
15
|
+
recently there was only one way to decide it — the **actor key**, derived from the
|
|
16
|
+
client address. It is available on every request, it costs nothing, and it is wrong in
|
|
17
|
+
both directions:
|
|
18
|
+
|
|
19
|
+
- **It merges people who are unrelated.** An office, a school, a household and a mobile
|
|
20
|
+
carrier all put many people behind one address. Anything inferred about "the actor" is
|
|
21
|
+
really about a crowd.
|
|
22
|
+
- **It splits a client that is one thing.** A scraper on a rotating proxy pool is a new
|
|
23
|
+
actor every few requests, and a phone changing networks is a new actor several times a
|
|
24
|
+
day.
|
|
25
|
+
|
|
26
|
+
Both errors have teeth. The first is how a library ends up denying somebody for a
|
|
27
|
+
stranger's behaviour. The second is how a scraper walks straight past every per-actor
|
|
28
|
+
threshold by changing address more often than the threshold counts.
|
|
29
|
+
|
|
30
|
+
One detector was left unwritten for exactly this reason. `identityRotationDetector` —
|
|
31
|
+
the client that arrives as Chrome, then as curl, then as Googlebot — fires on any
|
|
32
|
+
address fronting several browsers, which describes every corporate network on the
|
|
33
|
+
internet. It ships, but it is not installed by default, and the note in
|
|
34
|
+
`defaultDetectors()` says why: it is only safe once your actor key is narrower than an
|
|
35
|
+
address.
|
|
36
|
+
|
|
37
|
+
## The marker
|
|
38
|
+
|
|
39
|
+
A marker is a signed cookie this server issues and reads back. It is off by default.
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
new BotHandler({
|
|
43
|
+
probe: { secrets: [process.env.MARKER_SECRET] },
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Two requests carrying the same marker came from the same client. Not the same address,
|
|
48
|
+
not the same network — the same browser profile, because the marker holds an HMAC only
|
|
49
|
+
this server can produce and only that client received. That single fact is what makes
|
|
50
|
+
the rest of this page possible, and it is what turns "a client at this address claimed
|
|
51
|
+
two identities" into "this client claimed two identities".
|
|
52
|
+
|
|
53
|
+
It contains a random id, a validity window, and three short hashes standing for the
|
|
54
|
+
identity claimed when it was issued. It carries no identifier of a person, is
|
|
55
|
+
first-party, is `HttpOnly`, and expires on its own. Like every token here it is **signed
|
|
56
|
+
and not encrypted**, so nothing secret may go in one.
|
|
57
|
+
|
|
58
|
+
### What it costs
|
|
59
|
+
|
|
60
|
+
Verifying a marker is an HMAC, and a session presents the same cookie on every request,
|
|
61
|
+
so successful verifications are cached — with expiry re-checked on each hit, and failures
|
|
62
|
+
never cached, since caching those would let anyone fill the cache with unique junk. The
|
|
63
|
+
probe costs roughly 5% of an assessment with a marker held, and nothing at all when the
|
|
64
|
+
client holds none.
|
|
65
|
+
|
|
66
|
+
Network fan-out is sketched into 128 bits per marker rather than remembered as a set of
|
|
67
|
+
addresses, which measured at 55.6 MB with both caps full. The estimate carries a few
|
|
68
|
+
percent of error either way — 16 real networks read as 17 — so `marker-fanout`'s threshold
|
|
69
|
+
is a soft boundary. With everything full the probe holds well under a megabyte.
|
|
70
|
+
|
|
71
|
+
A `Set-Cookie` makes a response uncacheable by most shared caches, so the probe issues a
|
|
72
|
+
marker **only when the client is not already holding a valid one** — for an ordinary
|
|
73
|
+
visitor, the first request of a session and no other. That is also why the marker is not
|
|
74
|
+
reissued to refresh it, and why verified crawlers are never issued one at all: Googlebot
|
|
75
|
+
keeps no cookies, so a marker sent to it is a header that never comes back.
|
|
76
|
+
|
|
77
|
+
### Secrets
|
|
78
|
+
|
|
79
|
+
`secrets` is required, and deliberately has no default. A secret generated at startup
|
|
80
|
+
would read every marker minted by another replica — or by this one before a restart — as
|
|
81
|
+
*forged*, turning the strongest signal here into a machine for accusing ordinary
|
|
82
|
+
visitors. The first secret signs and all of them verify, so rotation is a prepend
|
|
83
|
+
followed by a removal one marker lifetime later.
|
|
84
|
+
|
|
85
|
+
Rotation does not disturb anything. The identity hashes inside a marker are derived under
|
|
86
|
+
a fixed salt rather than under the signing secret, precisely so that prepending a key
|
|
87
|
+
does not silently re-describe every visitor as a different browser.
|
|
88
|
+
|
|
89
|
+
## What the marker makes visible
|
|
90
|
+
|
|
91
|
+
| Detector | Ceiling | Reads |
|
|
92
|
+
| --- | --- | --- |
|
|
93
|
+
| `identity-drift` | `strong` | The identity claimed now against the one claimed when the marker was issued |
|
|
94
|
+
| `marker-integrity` | `strong` | A marker presented with a signature this server could not have produced |
|
|
95
|
+
| `marker-fanout` | `moderate` | Distinct networks one marker has been presented from |
|
|
96
|
+
| `marker-persistence` | `moderate` | A client that sends cookies but never returns the one this server set |
|
|
97
|
+
| `challenge-reaction` | `strong` | What a client did in the seconds after it was challenged |
|
|
98
|
+
| `challenge-integrity` | `moderate` | Solutions replayed, or returned faster than the puzzle allows |
|
|
99
|
+
|
|
100
|
+
All of them are installed automatically when `probe` is configured and are absent
|
|
101
|
+
otherwise, because a marker nobody issued is a marker nobody can fail to return.
|
|
102
|
+
|
|
103
|
+
### Identity drift, and why the parts are weighed separately
|
|
104
|
+
|
|
105
|
+
A **browser family** that changes — Chrome to curl, Firefox to Googlebot — has no benign
|
|
106
|
+
reading. Software does not change what it is, so one of the two claims is false and the
|
|
107
|
+
evidence is `strong`.
|
|
108
|
+
|
|
109
|
+
A **platform** that changes does have a benign reading, and a common one: "Request
|
|
110
|
+
desktop site" on a phone rewrites the User-Agent to claim a desktop, and the person doing
|
|
111
|
+
it is a person. A language changes when somebody changes their language. Those are
|
|
112
|
+
reported at `moderate` and named as the soft case in the summary, or turned off:
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
identityDriftDetector({ reportSoftDrift: false });
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Not returning the marker, and who that describes
|
|
119
|
+
|
|
120
|
+
`marker-persistence` deliberately says nothing about a client that sends **no** cookies at
|
|
121
|
+
all. That client is `session-integrity`'s business, and it already reports it at a weight
|
|
122
|
+
chosen for the people who produce it — people who block cookies. Having both speak is one
|
|
123
|
+
observation counted twice, landing on exactly that population: measured on the corpus, the
|
|
124
|
+
overlapping version took the `cookies-blocked` case from 21 to 38 and put +24 on five
|
|
125
|
+
ordinary browsing sessions.
|
|
126
|
+
|
|
127
|
+
So this asks the narrower question only a marker can answer — the client is demonstrably
|
|
128
|
+
keeping cookies, and ours is not among them — and the two share an evidence `family`, so
|
|
129
|
+
even where both apply the stronger stands rather than the two summing.
|
|
130
|
+
|
|
131
|
+
### Reaction beats observation
|
|
132
|
+
|
|
133
|
+
`challenge-reaction` is the strongest idea here, and the reason is structural. Every
|
|
134
|
+
other detector reads traffic that would have happened anyway and argues backwards from
|
|
135
|
+
it. This one reads a response to a stimulus **we chose**: we decided when the challenge
|
|
136
|
+
went out, so a client that changes what it claims to be within seconds of receiving one
|
|
137
|
+
is reacting to it. There was no reason to look at that moment except that we created it.
|
|
138
|
+
|
|
139
|
+
It reports two things — a changed identity, and never answering at all across repeated
|
|
140
|
+
asks. The first is `strong` when a marker ties the two requests together and `moderate`
|
|
141
|
+
when only the address does, because that is genuinely how much less an address-based join
|
|
142
|
+
is worth. The second is capped at `moderate` forever: a person with JavaScript disabled
|
|
143
|
+
produces it every time, and they are a person.
|
|
144
|
+
|
|
145
|
+
## Comparing a client with everybody else
|
|
146
|
+
|
|
147
|
+
The marker answers "is this the same client". A different set of questions needs the
|
|
148
|
+
opposite comparison — not this client against itself, but this client against the rest of
|
|
149
|
+
your traffic. It is also off by default:
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
new BotHandler({
|
|
153
|
+
site: { warmupRequests: 5000 },
|
|
154
|
+
});
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
| Detector | Ceiling | Reads |
|
|
158
|
+
| --- | --- | --- |
|
|
159
|
+
| `distributed-walk` | `moderate` | A numeric range walked across many clients, none of which walks enough of it alone |
|
|
160
|
+
| `path-novelty` | `moderate` | A client whose requests are almost all for paths nobody else has asked for |
|
|
161
|
+
| `miss-baseline` | `moderate` | This client's miss rate against the site's own |
|
|
162
|
+
| `path-campaign` | `moderate` | A path the site never served that many unrelated clients suddenly want |
|
|
163
|
+
|
|
164
|
+
### Warmup is the whole safety story
|
|
165
|
+
|
|
166
|
+
Nothing is reported until `warmupRequests` have been observed, and that number is the most
|
|
167
|
+
important setting here. A baseline is a claim about what is normal, and a claim drawn from
|
|
168
|
+
four hundred requests is not one: on a quiet site at three in the morning *every* path is
|
|
169
|
+
one nobody else has asked for, because nothing has been asked for. A profile consulted
|
|
170
|
+
early does not merely fail — it fails confidently, about everybody. The failure mode of the
|
|
171
|
+
whole module is silence, which is the correct direction for something whose mistakes land
|
|
172
|
+
on all your visitors at once.
|
|
173
|
+
|
|
174
|
+
The same reasoning caps every detector here at `moderate`. A baseline is wrong exactly when
|
|
175
|
+
a site is most unusual: the day of a redesign, the hour a campaign lands, the migration
|
|
176
|
+
that leaves half the URLs missing.
|
|
177
|
+
|
|
178
|
+
### What each one is really for
|
|
179
|
+
|
|
180
|
+
`distributed-walk` addresses the one threat per-actor thresholds miss **by construction**.
|
|
181
|
+
Split an id range across five hundred addresses at one request a minute each and every
|
|
182
|
+
actor is unremarkable, `id-enumeration` never fires for anybody, and the range is still
|
|
183
|
+
walked end to end. It is only visible in the union. What separates it from a busy shop is
|
|
184
|
+
that enumeration *covers* a contiguous range and visits each id about once, while real
|
|
185
|
+
readers cluster on popular items and return to them — so coverage and the revisit ratio
|
|
186
|
+
must both agree, and either alone would report an ordinary catalogue.
|
|
187
|
+
|
|
188
|
+
`path-novelty` is a self-maintaining wordlist. A wordlist is a list of paths that exist on
|
|
189
|
+
*some* sites; on yours most of them do not exist and nobody has ever asked for them. It
|
|
190
|
+
catches the scanner whose list is newer than the one this library ships.
|
|
191
|
+
|
|
192
|
+
`path-campaign` is its inverse, and catches what it misses. A freshly disclosed
|
|
193
|
+
vulnerability looks like one URL nobody had ever requested being requested by hundreds of
|
|
194
|
+
unrelated clients within the hour — each of them making a single request, which is nothing
|
|
195
|
+
at all on its own. The miss rate is **required** rather than optional here, because many
|
|
196
|
+
clients arriving at once on a brand-new URL is also exactly what a successful launch looks
|
|
197
|
+
like. What separates them is whether the site had anything to serve.
|
|
198
|
+
|
|
199
|
+
**The known cost of `path-campaign`** is a broken link. Somebody shares a URL with a typo
|
|
200
|
+
and thousands of real people follow it within the hour, which from the server is a path
|
|
201
|
+
the site has never served, requested by many unrelated clients, answered `not found` every
|
|
202
|
+
time — the firing shape exactly. Those people are reported at `moderate` and never
|
|
203
|
+
refused; the corpus carries the case (`broken-link-shared-widely`) and holds it to the
|
|
204
|
+
never-deny guarantee under every shipped preset.
|
|
205
|
+
|
|
206
|
+
**`distributed-walk` needs both of its bounds.** The revisit ratio is checked from above
|
|
207
|
+
*and* below, and the lower bound is what makes it usable on a real site. Ids spread across
|
|
208
|
+
a six-figure catalogue coarsen the bitmap until one bucket stands for hundreds of ids;
|
|
209
|
+
ordinary browsing then touches nearly every bucket, so coverage reads 1.0 and the
|
|
210
|
+
estimated id count runs far ahead of the requests that were actually made. Requiring the
|
|
211
|
+
visits to account for the ids claimed is what rejects an estimate that has left the
|
|
212
|
+
evidence behind — measured on a simulated shop, sixty long-tail shoppers were reported
|
|
213
|
+
before that bound existed and none after.
|
|
214
|
+
|
|
215
|
+
`miss-baseline` is `probe-volume` done relative. A fixed 80% threshold reports everybody on
|
|
216
|
+
a site mid-migration and stays silent on a tidy one where a client missing a third of the
|
|
217
|
+
time is remarkable.
|
|
218
|
+
|
|
219
|
+
### What it costs
|
|
220
|
+
|
|
221
|
+
Everything is bounded, and the bounds are the interesting part, because a client picks its
|
|
222
|
+
own paths and therefore picks how much there is to remember. Walk ids are held as a
|
|
223
|
+
**1024-bit map over the range rather than as a set of numbers**: a set measured at 45 MB
|
|
224
|
+
for a table anybody could fill on purpose by requesting `/anything/1`, while the bitmap is
|
|
225
|
+
128 bytes however wide the range grows, coarsening rather than growing. With every table at
|
|
226
|
+
its cap the profile holds about 17 MB, nearly all of it the path table, and costs roughly
|
|
227
|
+
3% of an assessment.
|
|
228
|
+
|
|
229
|
+
The state is kept in process like the rest of the behavioural series, so across replicas
|
|
230
|
+
each sees its own share of the traffic. That understates every count here, and understating
|
|
231
|
+
costs a missed detection rather than an accusation.
|
|
232
|
+
|
|
233
|
+
### What the assessment carries
|
|
234
|
+
|
|
235
|
+
An `Assessment` exposes `marker`: the reading, the drift, and the identity shape. It is
|
|
236
|
+
there because the action path needs it to decide whether a response should carry a new
|
|
237
|
+
marker, and because it is genuinely useful to an operator looking at one request.
|
|
238
|
+
|
|
239
|
+
What leaves the machine is less than that. Notification sinks receive a **redacted** copy:
|
|
240
|
+
`redactEvent` runs on the way out and is on by default, and it drops `facts.cookies`,
|
|
241
|
+
strips the credential headers — `cookie`, `authorization`, `x-api-key` among them — masks
|
|
242
|
+
the address and the actor key to a `/24`, and masks query values. A marker is reduced the
|
|
243
|
+
same way: what a sink sees is whether one was presented, whether it verified, whether the
|
|
244
|
+
identity moved, and how many networks it has come from. The claims inside it, the marker id
|
|
245
|
+
included, do not go. They are the decoded contents of a cookie, and the rule about cookies
|
|
246
|
+
already covered them.
|
|
247
|
+
|
|
248
|
+
The unredacted assessment is what your own process holds — `handle()` returns it, and the
|
|
249
|
+
dashboard renders it for an operator who is already inside your perimeter. Turning
|
|
250
|
+
redaction off (`notifications: { redaction: false }`) is the one way to send more than the
|
|
251
|
+
above, and it is worth knowing what you are choosing when you do.
|
|
252
|
+
|
|
253
|
+
## What this deliberately does not do
|
|
254
|
+
|
|
255
|
+
**It does not link two clients by how alike they look.** Joining strangers on a shared
|
|
256
|
+
fingerprint and letting one's verdict raise the other's is guilt by association, and when
|
|
257
|
+
the link is wrong it denies a person for a stranger's behaviour. Every join here is the
|
|
258
|
+
same actor, or the same marker — something this server issued — and never a statistical
|
|
259
|
+
resemblance between two clients.
|
|
260
|
+
|
|
261
|
+
The site detectors are the one place evidence about *other* traffic reaches a verdict, and
|
|
262
|
+
they are shaped by that. Each one describes a pattern the client in front of you genuinely
|
|
263
|
+
took part in — it requested that path, it walked that range — rather than importing
|
|
264
|
+
somebody else's verdict, and each is capped where it cannot deny anybody alone.
|
|
265
|
+
|
|
266
|
+
**It does not build a profile.** The marker holds no identifier of a person, is not
|
|
267
|
+
readable across sites, and expires. Nothing here is retained to describe a visitor; it is
|
|
268
|
+
retained to describe a *series of requests*, and it ages out with the series.
|
|
269
|
+
|
|
270
|
+
**It does not reach `certain`.** Nothing on this page can. `certain` means no benign
|
|
271
|
+
explanation exists, and every signal here joins two requests — with the join itself being
|
|
272
|
+
the thing that could be wrong. A tampered marker comes closest and still stops at
|
|
273
|
+
`strong`, because a middlebox can mangle a cookie in transit and that is not the client's
|
|
274
|
+
fault.
|
|
275
|
+
|
|
276
|
+
## Related
|
|
277
|
+
|
|
278
|
+
- [Shadow mode](shadow-mode.md) — run any of these against your own traffic for a week
|
|
279
|
+
before it is allowed to decide anything. Several of them fire at `moderate` on real
|
|
280
|
+
people by design, and whether the thresholds are right *here* is not a thing this
|
|
281
|
+
library can know.
|
|
282
|
+
- [Detectors](detectors.md) — the full catalogue and what each one is worth.
|
|
283
|
+
- [The client IP](../integration/client-ip.md) — why the actor key is what it is.
|
|
284
|
+
- [Writing a detector](writing-a-detector.md) — including what a detector may see.
|
|
@@ -165,6 +165,55 @@ query string.
|
|
|
165
165
|
probeSignatureDetector({ extraPaths: ["/internal/admin"] })
|
|
166
166
|
```
|
|
167
167
|
|
|
168
|
+
### `target-integrity`
|
|
169
|
+
|
|
170
|
+
**cheap · always · ceiling `strong`**
|
|
171
|
+
|
|
172
|
+
How was this target *spelled*?
|
|
173
|
+
|
|
174
|
+
`probe-signature` above reads what a request asked for. This one reads how it asked, and it
|
|
175
|
+
exists because the two are not the same question — and because the answer to the second is
|
|
176
|
+
normally destroyed before any detector sees it.
|
|
177
|
+
|
|
178
|
+
Every detector reads `facts.path`, which is normalised: decoded once, backslashes and
|
|
179
|
+
doubled slashes collapsed, `.` and `..` resolved. That is not optional — a rule scoped to
|
|
180
|
+
`/admin` has to hold against `/%61dmin` and `/./admin` or it is not a rule. It is also
|
|
181
|
+
exactly what makes an evasive target arrive looking ordinary:
|
|
182
|
+
|
|
183
|
+
```
|
|
184
|
+
GET /%2e%2e%2f%2e%2e%2fapp/config.yml → facts.path = "/app/config.yml"
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Which is a page nobody has, on no wordlist, indistinguishable from a broken link. So the
|
|
188
|
+
target as it arrived is kept in `facts.rawPath` — and only when it differs from the
|
|
189
|
+
normalised form, which on ordinary traffic it does not — and this reads it.
|
|
190
|
+
|
|
191
|
+
| Spelling | Tier |
|
|
192
|
+
| --- | ---: |
|
|
193
|
+
| Encoded its own encoding, so one decoding pass leaves it still encoded | `strong` |
|
|
194
|
+
| Wrote a traversal with its dots and slashes percent-encoded | `strong` |
|
|
195
|
+
| Carried a control character, raw or encoded | `strong` |
|
|
196
|
+
| Addressed the target to a proxy — `GET http://elsewhere/` at an origin server | `strong` |
|
|
197
|
+
| Hid a path separator inside a segment | `moderate` |
|
|
198
|
+
| Walked up out of the site root, written plainly | `moderate` |
|
|
199
|
+
|
|
200
|
+
**Nothing here is `certain`, and the closest call says why.** A path segment that carries a
|
|
201
|
+
URL as *data* — `/redirect/https%3A%2F%2Fexample.com%2Fa` — is encoded once to sit in a
|
|
202
|
+
path and encoded again by whatever built the link around it. That is a real pattern on real
|
|
203
|
+
sites and it produces `%252e` honestly. A deliberate act with no ordinary cause is not the
|
|
204
|
+
same thing as one admitting no benign explanation, and only the second may close a door.
|
|
205
|
+
|
|
206
|
+
The plain-traversal row is the one a *broken* client produces as readily as a hostile one,
|
|
207
|
+
so it has a switch:
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
targetIntegrityDetector({ reportPlainTraversal: false })
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
All of these share the family `evasive-target`. One target usually trips several — a
|
|
214
|
+
traversal is normally encoded and an encoded traversal is often double-encoded — and that
|
|
215
|
+
is one act seen three ways, not three reasons.
|
|
216
|
+
|
|
168
217
|
### `tls-fingerprint`
|
|
169
218
|
|
|
170
219
|
**cheap · always · ceiling `strong`**
|
|
@@ -211,6 +260,27 @@ new BotHandler({
|
|
|
211
260
|
The library ships **no address data** and refuses to guess any; see
|
|
212
261
|
[design decisions](../design/decisions.md).
|
|
213
262
|
|
|
263
|
+
**Loading a feed.** `fetchAddressList` reads a published list — a reputation feed, a hosting
|
|
264
|
+
provider's own ranges — over the same hardened path the crawler ranges use: HTTPS only, a
|
|
265
|
+
size cap, `#` and `;` comments stripped, JSON `prefixes` documents or one prefix per line,
|
|
266
|
+
and a list that is empty, oversized or contains a block big enough to matter is refused
|
|
267
|
+
**whole** rather than in part.
|
|
268
|
+
|
|
269
|
+
```ts
|
|
270
|
+
import { fetchAddressList } from "@osqd/bothandlerjs";
|
|
271
|
+
|
|
272
|
+
const prefixes = await fetchAddressList({ id: "denylist", url: "https://example.org/drop.txt" });
|
|
273
|
+
detector.updateRanges("denylist", prefixes);
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
Two steps, on purpose: fetching is the part that can fail, and installing is the part that
|
|
277
|
+
changes what happens to somebody. Nothing is fetched on a schedule unless you schedule it.
|
|
278
|
+
|
|
279
|
+
And think hard before pointing that at `denylist` rather than `datacenter`. A denylist entry
|
|
280
|
+
does not corroborate anything — it decides, and it blocks people. A feed is somebody else's
|
|
281
|
+
judgement about an address, refreshed on somebody else's schedule, and an address that was a
|
|
282
|
+
bot last month may be a customer's home connection this month.
|
|
283
|
+
|
|
214
284
|
### `trap`
|
|
215
285
|
|
|
216
286
|
**cheap · always · ceiling `certain`**
|
|
@@ -222,6 +292,9 @@ would echo. Reaching one requires reading the page as data rather than as a page
|
|
|
222
292
|
import { renderTrapLink, trapRobotsEntries, DEFAULT_TRAP_PATHS } from "@osqd/bothandlerjs";
|
|
223
293
|
|
|
224
294
|
app.get("/", (_req, res) => res.send(page + renderTrapLink()));
|
|
295
|
+
|
|
296
|
+
// And publish the same paths, so a crawler that obeys robots.txt never sees them.
|
|
297
|
+
app.get("/robots.txt", (_req, res) => res.type("text/plain").send(trapRobotsEntries(DEFAULT_TRAP_PATHS)));
|
|
225
298
|
```
|
|
226
299
|
|
|
227
300
|
The trap paths belong in your `robots.txt` as `Disallow`, which is what makes the evidence
|
|
@@ -276,6 +349,151 @@ walking a sitemap almost never revisits, so its ratio sits near one.
|
|
|
276
349
|
`weak`, because a *welcome* crawler produces exactly this shape and so does a person on a
|
|
277
350
|
first visit to a documentation site.
|
|
278
351
|
|
|
352
|
+
### `blended-identity`
|
|
353
|
+
|
|
354
|
+
**cheap · always · ceiling `strong`**
|
|
355
|
+
|
|
356
|
+
What a *series* of claims says, as opposed to what one claim says.
|
|
357
|
+
|
|
358
|
+
Every other identity check here reads a single request: this User-Agent names this bot, and
|
|
359
|
+
that claim is either confirmable or it is not. The set of identities an actor has claimed
|
|
360
|
+
over time is a different object, and some sets are self-contradictory in a way no member of
|
|
361
|
+
them is.
|
|
362
|
+
|
|
363
|
+
Three readings:
|
|
364
|
+
|
|
365
|
+
- **Several security tools.** One address arriving as two or more named scanners is a scan,
|
|
366
|
+
not a coincidence.
|
|
367
|
+
- **Several verifiable crawlers.** At most one of Googlebot, Bingbot and Yandex can be true
|
|
368
|
+
of an address, because each publishes a proof tied to addresses it controls. The
|
|
369
|
+
contradiction is visible from the claims alone — which matters most when DNS is
|
|
370
|
+
unreachable and neither claim can be refuted on its own.
|
|
371
|
+
- **A crawler that also probes.** An actor that sent a scanner payload *and* claimed to be
|
|
372
|
+
a search crawler has told you which of the two is the lie.
|
|
373
|
+
|
|
374
|
+
**These hold under the default address-based actor key**, which is why they are on by
|
|
375
|
+
default and [`identity-rotation`](#identity-rotation) is not. A NAT gateway presents many
|
|
376
|
+
browsers — that is exactly what makes counting User-Agents useless there — but it does not
|
|
377
|
+
present sqlmap *and* nikto, and it does not claim to be Googlebot *and* Bingbot. The
|
|
378
|
+
innocent explanation for a hundred browsers behind one address is an office; there is no
|
|
379
|
+
corresponding one for these.
|
|
380
|
+
|
|
381
|
+
`strong`, not `certain`. Trusting the wrong forwarded header collapses every client onto
|
|
382
|
+
one address, and then two genuinely different crawlers produce this exact set — so it may
|
|
383
|
+
contribute to a denial and may not be the whole of one.
|
|
384
|
+
|
|
385
|
+
### `id-enumeration`
|
|
386
|
+
|
|
387
|
+
**cheap · always · ceiling `moderate`**
|
|
388
|
+
|
|
389
|
+
Somebody working through the identifiers rather than following the links.
|
|
390
|
+
|
|
391
|
+
`crawl-breadth` sees this as "many distinct paths" — which is also what it sees when a
|
|
392
|
+
person reads a documentation site, so it stays `weak` and nothing separates the two.
|
|
393
|
+
Measured before this existed: `/user/1` through `/user/120` in order scored 57, a hundred
|
|
394
|
+
and twenty scattered ids scored 57, and ordinary article paths scored 57.
|
|
395
|
+
|
|
396
|
+
What separates them is not *which* ids were asked for but whether they **cover a range**.
|
|
397
|
+
People arrive at ids through links, and links do not densely enumerate an integer interval;
|
|
398
|
+
a harvester does nothing else. Thirty requests reaching from id 1 to id 33 is a walk; thirty
|
|
399
|
+
scattered across a hundred thousand is somebody reading.
|
|
400
|
+
|
|
401
|
+
It costs three numbers per path shape — a count, a lowest and a highest — rather than a
|
|
402
|
+
list of every id seen, which is what makes it affordable for an actor that asks for ten
|
|
403
|
+
thousand of them. The last numeric segment is taken as the identifier, so in
|
|
404
|
+
`/api/v2/orders/42` the version is part of the shape and the order id is the walk. Numbers
|
|
405
|
+
too large to be a counter are ignored: nobody walks epoch seconds.
|
|
406
|
+
|
|
407
|
+
`moderate`, with the bar set high on purpose. Products in one category often carry
|
|
408
|
+
consecutive ids, so somebody browsing a catalogue produces a smaller version of this.
|
|
409
|
+
|
|
410
|
+
### `probe-volume`
|
|
411
|
+
|
|
412
|
+
**cheap · always · ceiling `moderate`**
|
|
413
|
+
|
|
414
|
+
An actor that is looking for something rather than reading anything.
|
|
415
|
+
|
|
416
|
+
The oldest tell there is for a scanner, and the one this library could not see. Every
|
|
417
|
+
verdict here is reached *before* the response exists — that is what lets it shape the
|
|
418
|
+
response, and it is also what hides the status code from it. So the application reports it
|
|
419
|
+
back:
|
|
420
|
+
|
|
421
|
+
```ts
|
|
422
|
+
const { outcome } = await handler.handle(facts);
|
|
423
|
+
// …your application answers…
|
|
424
|
+
handler.recordOutcome(facts, response.statusCode);
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
Every bundled adapter does this for you — Node and Express on the response's `finish`,
|
|
428
|
+
Fastify on the reply's, Koa from the status the middleware chain settled on, and the Fetch
|
|
429
|
+
wrapper from the `Response` it returns. You only need the call above if you drive the
|
|
430
|
+
engine yourself. Nothing else depends on it: every other detector works unchanged if you
|
|
431
|
+
never call it, and this one is simply absent.
|
|
432
|
+
|
|
433
|
+
Counts **404 and 410 only**. A 403 is usually this library's own doing, and counting it
|
|
434
|
+
would let a rule that challenges an actor manufacture the evidence for having challenged
|
|
435
|
+
it. A 500 is the site's problem and says nothing about the client.
|
|
436
|
+
|
|
437
|
+
`moderate`, because a site that has just moved its URLs produces exactly this shape from
|
|
438
|
+
perfectly ordinary readers, and so does a feed reader working through removed articles.
|
|
439
|
+
Eighty per cent of at least twenty reported responses, by default.
|
|
440
|
+
|
|
441
|
+
### `transport-coherence`
|
|
442
|
+
|
|
443
|
+
**cheap · always · ceiling `moderate`**
|
|
444
|
+
|
|
445
|
+
How a claimed browser *moves*, rather than what it says.
|
|
446
|
+
|
|
447
|
+
The header checks read one request against the client it claims to be. This reads the
|
|
448
|
+
transport underneath and the verbs across a visit — harder to copy, because neither is in
|
|
449
|
+
the part of a request most tooling lets you set.
|
|
450
|
+
|
|
451
|
+
Two things. A claimed browser that negotiated **HTTP/1.0**, which no shipping browser has
|
|
452
|
+
offered in over a decade. And a visit made **entirely of HEAD**: one HEAD is a browser
|
|
453
|
+
checking a link it is about to follow or a cache revalidating, but a whole visit of them is
|
|
454
|
+
something checking what exists without reading any of it.
|
|
455
|
+
|
|
456
|
+
Both were measured as blind spots before this existed — a client claiming Chrome 120 over
|
|
457
|
+
HTTP/1.0, and one whose whole visit was HEAD, each scored exactly what the honest control
|
|
458
|
+
scored.
|
|
459
|
+
|
|
460
|
+
Capped at `moderate`, for different reasons each. HTTP/1.0 is not always the client's
|
|
461
|
+
doing: a few older load balancers speak it to the origin, and behind one of those every
|
|
462
|
+
request looks like this — which is what `transportCoherenceDetector({ legacyHttp: false })`
|
|
463
|
+
is for. An all-HEAD visit is a stronger shape, but a link checker is a real and mostly
|
|
464
|
+
harmless thing to be.
|
|
465
|
+
|
|
466
|
+
**It cannot simply be made NAT-safe, and it is worth saying why.** A rotator changes the
|
|
467
|
+
User-Agent and nothing else, so "several User-Agents behind a single header shape" looks
|
|
468
|
+
like a clean way to separate it from a gateway. Measured, it is not: ten real browser
|
|
469
|
+
profiles collapse to six stable header shapes, and Chrome, Edge, Opera and Chromium on
|
|
470
|
+
Linux share one — they are the same engine sending the same headers in the same order. An
|
|
471
|
+
office running Chrome and Edge produces that signature exactly. The narrower actor key is
|
|
472
|
+
the fix; there is no header trick that substitutes for it.
|
|
473
|
+
|
|
474
|
+
### `parameter-sweep`
|
|
475
|
+
|
|
476
|
+
**cheap · always · ceiling `weak`**
|
|
477
|
+
|
|
478
|
+
The collection `crawl-breadth` cannot see.
|
|
479
|
+
|
|
480
|
+
Breadth counts distinct *paths*, and a path carries no query string — so the shape it reads
|
|
481
|
+
as "somebody rereading one page" is also the shape of enumerating a catalogue.
|
|
482
|
+
`/products?page=1` through `?page=200` is one path and two hundred requests. Measured on
|
|
483
|
+
the same two hundred requests expressed both ways: as distinct paths they scored 62 and
|
|
484
|
+
were called `suspected-bot`; as `?page=N` they scored 55 and passed as `unknown`. Paginated
|
|
485
|
+
collection is not an exotic case — it is how catalogues, search results and APIs are
|
|
486
|
+
actually taken.
|
|
487
|
+
|
|
488
|
+
So this counts the other thing: distinct parameterisations, and how many of them stack onto
|
|
489
|
+
a single path. Both halves matter. A high variant count on its own is ordinary — a shop's
|
|
490
|
+
own visitors filter and sort — and it is the *concentration* that separates a person
|
|
491
|
+
changing their mind from a machine walking an index.
|
|
492
|
+
|
|
493
|
+
`weak`, for the same reason as breadth: a person paging through search results produces a
|
|
494
|
+
smaller version of exactly this. Its value is as a second signal beside an actor that has
|
|
495
|
+
already failed something sharper.
|
|
496
|
+
|
|
279
497
|
### `identity-rotation`
|
|
280
498
|
|
|
281
499
|
**cheap · always · ceiling `moderate` · off by default**
|
|
@@ -285,7 +503,9 @@ mid-session; something that does is cycling through a spoofing list.
|
|
|
285
503
|
|
|
286
504
|
**Off by default, and think before enabling it.** With the default address-based actor
|
|
287
505
|
key, a corporate NAT presents a hundred people's browsers as one actor with a hundred
|
|
288
|
-
User-Agents — which is this detector's exact signature and is entirely innocent.
|
|
506
|
+
User-Agents — which is this detector's exact signature and is entirely innocent. Switching
|
|
507
|
+
it on without also narrowing `actorKey` raises a startup warning, because it is a decision
|
|
508
|
+
whose consequences are invisible until real visitors are being challenged. Enable it
|
|
289
509
|
when your `actorKey` identifies a session rather than a network.
|
|
290
510
|
|
|
291
511
|
### `session-integrity`
|
|
@@ -369,8 +589,46 @@ A resolver that is merely *unhappy* must reach a different verdict from one that
|
|
|
369
589
|
|
|
370
590
|
---
|
|
371
591
|
|
|
592
|
+
## Detectors that arrive with something else
|
|
593
|
+
|
|
594
|
+
Ten more exist and are not in the catalogue above, because none of them is installed by
|
|
595
|
+
default: each reads a source that has to be configured before it exists at all, and a
|
|
596
|
+
detector with nothing to read is a permanently silent entry in `describeDetectors()`.
|
|
597
|
+
They are documented together in
|
|
598
|
+
[correlating a client's own requests](correlation.md), which is also where the reasoning
|
|
599
|
+
about what each is worth lives.
|
|
600
|
+
|
|
601
|
+
**With `probe`** — a signed marker cookie this server issues and reads back, which
|
|
602
|
+
identifies a *client* across requests rather than an address:
|
|
603
|
+
|
|
604
|
+
| Detector | Ceiling |
|
|
605
|
+
| --- | --- |
|
|
606
|
+
| `identity-drift` | `strong` |
|
|
607
|
+
| `marker-integrity` | `strong` |
|
|
608
|
+
| `marker-fanout` | `moderate` |
|
|
609
|
+
| `marker-persistence` | `moderate` |
|
|
610
|
+
|
|
611
|
+
**With `challenge`** — reactions to a question this server chose to ask:
|
|
612
|
+
|
|
613
|
+
| Detector | Ceiling |
|
|
614
|
+
| --- | --- |
|
|
615
|
+
| `challenge-reaction` | `strong` |
|
|
616
|
+
| `challenge-integrity` | `moderate` |
|
|
617
|
+
|
|
618
|
+
**With `site`** — a warmed-up baseline of what the rest of your traffic looks like:
|
|
619
|
+
|
|
620
|
+
| Detector | Ceiling |
|
|
621
|
+
| --- | --- |
|
|
622
|
+
| `distributed-walk` | `moderate` |
|
|
623
|
+
| `path-novelty` | `moderate` |
|
|
624
|
+
| `miss-baseline` | `moderate` |
|
|
625
|
+
| `path-campaign` | `moderate` |
|
|
626
|
+
|
|
627
|
+
---
|
|
628
|
+
|
|
372
629
|
## Related
|
|
373
630
|
|
|
374
631
|
- [How detection works](index.md) — the pipeline these run in
|
|
632
|
+
- [Correlating a client's own requests](correlation.md) — the ten above, in detail
|
|
375
633
|
- [Writing a detector](writing-a-detector.md) — the contract, and the rules on certainty
|
|
376
634
|
- [Evidence and certainty](../concepts/evidence.md) — what the tiers mean
|
package/docs/detection/index.md
CHANGED
|
@@ -129,6 +129,7 @@ time goes, and turn it off afterwards — it is two clock reads per detector per
|
|
|
129
129
|
|
|
130
130
|
## Related
|
|
131
131
|
|
|
132
|
-
- [The detectors](detectors.md) —
|
|
132
|
+
- [The detectors](detectors.md) — the default set, in detail
|
|
133
|
+
- [Correlating a client's own requests](correlation.md) — the marker cookie, and reading a series rather than a request
|
|
133
134
|
- [Writing a detector](writing-a-detector.md)
|
|
134
135
|
- [Policy](../policy/index.md) — what happens to an assessment next
|