@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.
Files changed (90) hide show
  1. package/CHANGELOG.md +227 -1
  2. package/README.md +18 -10
  3. package/dist/adapters/fastify.d.ts +10 -0
  4. package/dist/adapters/index.cjs +38 -10
  5. package/dist/adapters/index.cjs.map +1 -1
  6. package/dist/adapters/index.js +38 -10
  7. package/dist/adapters/index.js.map +1 -1
  8. package/dist/challenge/index.d.ts +40 -0
  9. package/dist/cli.cjs +2256 -103
  10. package/dist/cli.cjs.map +1 -1
  11. package/dist/cli.js +2256 -103
  12. package/dist/cli.js.map +1 -1
  13. package/dist/config.d.ts +55 -0
  14. package/dist/core.d.ts +48 -21
  15. package/dist/corpus/index.cjs +365 -7
  16. package/dist/corpus/index.cjs.map +1 -1
  17. package/dist/corpus/index.js +365 -7
  18. package/dist/corpus/index.js.map +1 -1
  19. package/dist/corpus/schema.d.ts +33 -0
  20. package/dist/crawler-ranges.d.ts +31 -0
  21. package/dist/dashboard/client/actions.d.ts +1 -1
  22. package/dist/dashboard/client/app.d.ts +9 -2
  23. package/dist/dashboard/client/boot.d.ts +32 -3
  24. package/dist/dashboard/client/query.d.ts +72 -12
  25. package/dist/dashboard/client/registry.d.ts +25 -0
  26. package/dist/dashboard/client/saved.d.ts +29 -0
  27. package/dist/dashboard/client/store.d.ts +16 -2
  28. package/dist/dashboard/client/types.d.ts +2 -0
  29. package/dist/dashboard/client.generated.d.ts +1 -1
  30. package/dist/dashboard/types.d.ts +15 -0
  31. package/dist/detectors/blended-identity.d.ts +34 -0
  32. package/dist/detectors/challenge-integrity.d.ts +26 -0
  33. package/dist/detectors/challenge-reaction.d.ts +39 -0
  34. package/dist/detectors/clearance.d.ts +1 -23
  35. package/dist/detectors/id-enumeration.d.ts +31 -0
  36. package/dist/detectors/index.d.ts +24 -1
  37. package/dist/detectors/known-bots.d.ts +11 -0
  38. package/dist/detectors/marker.d.ts +106 -0
  39. package/dist/detectors/parameter-sweep.d.ts +39 -0
  40. package/dist/detectors/probe-signature.d.ts +27 -0
  41. package/dist/detectors/probe-volume.d.ts +26 -0
  42. package/dist/detectors/site-baseline.d.ts +135 -0
  43. package/dist/detectors/target-integrity.d.ts +16 -0
  44. package/dist/detectors/transport-coherence.d.ts +31 -0
  45. package/dist/detectors/trap.d.ts +10 -3
  46. package/dist/detectors/types.d.ts +17 -0
  47. package/dist/element/index.cjs +730 -80
  48. package/dist/element/index.cjs.map +1 -1
  49. package/dist/element/index.js +730 -80
  50. package/dist/element/index.js.map +1 -1
  51. package/dist/index.cjs +2044 -123
  52. package/dist/index.cjs.map +1 -1
  53. package/dist/index.d.ts +6 -2
  54. package/dist/index.js +2025 -123
  55. package/dist/index.js.map +1 -1
  56. package/dist/internal/async.d.ts +0 -3
  57. package/dist/internal/ip.d.ts +18 -0
  58. package/dist/internal/text.d.ts +28 -0
  59. package/dist/metrics.d.ts +18 -0
  60. package/dist/probe/index.d.ts +153 -0
  61. package/dist/probe/marker.d.ts +119 -0
  62. package/dist/site/index.d.ts +122 -0
  63. package/dist/state.d.ts +205 -0
  64. package/dist/stores/redis.d.ts +24 -1
  65. package/dist/types.d.ts +106 -0
  66. package/docs/course/05-detectors.md +9 -4
  67. package/docs/course/06-identity.md +1 -1
  68. package/docs/course/16-proving-it.md +15 -9
  69. package/docs/course/index.md +1 -1
  70. package/docs/design/decisions.md +1 -1
  71. package/docs/detection/correlation.md +284 -0
  72. package/docs/detection/detectors.md +259 -1
  73. package/docs/detection/index.md +2 -1
  74. package/docs/detection/shadow-mode.md +147 -0
  75. package/docs/detection/signatures.md +10 -2
  76. package/docs/index.md +3 -2
  77. package/docs/integration/client-ip.md +16 -0
  78. package/docs/operations/dashboard.md +40 -1
  79. package/docs/operations/filters.md +143 -0
  80. package/docs/operations/index.md +1 -0
  81. package/docs/operations/metrics.md +18 -0
  82. package/docs/policy/presets.md +1 -1
  83. package/docs/start/choosing-a-policy.md +1 -1
  84. package/docs/start/first-integration.md +1 -1
  85. package/docs/start/installation.md +2 -2
  86. package/docs/testing/cli.md +7 -1
  87. package/docs/testing/corpus.md +12 -8
  88. package/docs/testing/index.md +1 -1
  89. package/docs/testing/try-it.md +1 -1
  90. 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. Enable it
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
@@ -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) — all twenty in detail
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