@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
package/dist/config.d.ts CHANGED
@@ -13,6 +13,8 @@ import type { Decision } from "./policy/types.js";
13
13
  import type { MetricsOptions } from "./metrics.js";
14
14
  import type { NotificationOptions } from "./notify/hub.js";
15
15
  import type { ChallengeOptions } from "./challenge/index.js";
16
+ import type { MarkerProbeOptions } from "./probe/index.js";
17
+ import type { SiteProfileOptions } from "./site/index.js";
16
18
  import type { Assessment, RequestFacts } from "./types.js";
17
19
  /**
18
20
  * How the client address is recovered from behind a proxy.
@@ -61,6 +63,32 @@ export interface BotHandlerConfig {
61
63
  detectors?: readonly Detector[];
62
64
  /** Appended to the built-in set. Ignored when `detectors` is given. */
63
65
  extraDetectors?: readonly Detector[];
66
+ /**
67
+ * Detector ids to run without letting them decide anything.
68
+ *
69
+ * A shadowed detector runs on every request exactly as it otherwise would. Its
70
+ * findings are counted, charted and readable in the dashboard and in every event —
71
+ * and they are kept out of the verdict, the score, the class, the identity and every
72
+ * rule. So the question "what would turning this on do to my traffic?" is answered by
73
+ * a week of your own logs instead of by an argument about thresholds.
74
+ *
75
+ * This is the honest way to introduce a detector, and the correlation sources are why
76
+ * it exists: several of them fire at `moderate` on real people by design — a mobile
77
+ * user roaming between networks trips `marker-fanout`, a crowd arriving on a broken
78
+ * link trips `path-campaign`, somebody toggling "Request desktop site" trips
79
+ * `identity-drift`. Whether the thresholds are right *for your site* is not a thing
80
+ * this library can know, and shadowing is how you find out without anybody being
81
+ * turned away while you do.
82
+ *
83
+ * ```js
84
+ * new BotHandler({ site: {}, shadowDetectors: ["path-novelty", "miss-baseline"] })
85
+ * ```
86
+ *
87
+ * An id that names no installed detector is a warning rather than an error: the usual
88
+ * cause is a typo, and a typo here silently does nothing, which is the one outcome
89
+ * worth being loud about.
90
+ */
91
+ shadowDetectors?: readonly string[];
64
92
  /**
65
93
  * How claimed crawler identities are confirmed or refuted.
66
94
  *
@@ -120,6 +148,28 @@ export interface BotHandlerConfig {
120
148
  isHuman?: (facts: RequestFacts) => boolean;
121
149
  /** Enables the challenge action. Without it, rules asking for one degrade to `tag`. */
122
150
  challenge?: Omit<ChallengeOptions, "store" | "clock">;
151
+ /**
152
+ * Hand each client a signed marker cookie, and read what comes back.
153
+ *
154
+ * Off by default. It is the only part of this library that acts in order to detect,
155
+ * and it is what lets a series of requests be attributed to one *client* rather than
156
+ * to one address — which is the difference between "three people share an office
157
+ * connection" and "one client claimed to be three different browsers".
158
+ *
159
+ * See `docs/detection/correlation.md`. Requires `secrets`; without them it stays off
160
+ * and says so, because a secret invented at startup would mark every marker forged
161
+ * after a restart.
162
+ */
163
+ probe?: Omit<MarkerProbeOptions, "clock">;
164
+ /**
165
+ * Compare each client against the rest of your traffic rather than against a fixed
166
+ * idea of what clients do.
167
+ *
168
+ * Off by default, and silent for the first `warmupRequests` after being switched on.
169
+ * A baseline drawn from a few hundred requests is not a baseline: every path is rare
170
+ * when nothing has been seen. See `docs/detection/correlation.md`.
171
+ */
172
+ site?: Omit<SiteProfileOptions, "clock">;
123
173
  store?: BotHandlerStore;
124
174
  /**
125
175
  * Share `priorConfirmations` between replicas through the store. Default false.
@@ -273,6 +323,11 @@ export interface BotHandlerConfig {
273
323
  }
274
324
  export interface ResolvedConfig {
275
325
  detectors: Detector[];
326
+ /**
327
+ * {@link BotHandlerConfig.shadowDetectors}, as given. Not yet checked against the
328
+ * installed set — see the note at the assignment for why that has to wait.
329
+ */
330
+ shadowDetectors: ReadonlySet<string>;
276
331
  rules: Rule[];
277
332
  ranges: Map<string, IpRangeSet>;
278
333
  signatures: readonly BotSignature[];
package/dist/core.d.ts CHANGED
@@ -4,6 +4,8 @@ import { TrafficAudit } from "./audit.js";
4
4
  import { NotificationHub } from "./notify/hub.js";
5
5
  import { Policy } from "./policy/policy.js";
6
6
  import type { GuardSettings } from "./policy/policy.js";
7
+ import { MarkerProbe } from "./probe/index.js";
8
+ import { SiteProfile } from "./site/index.js";
7
9
  import type { ActionOutcome } from "./actions/types.js";
8
10
  import type { Rule } from "./policy/types.js";
9
11
  import type { Assessment, RequestFacts } from "./types.js";
@@ -177,34 +179,19 @@ export interface DetectorDescription {
177
179
  description: string;
178
180
  cost: "cheap" | "io";
179
181
  stage: "always" | "confirming";
182
+ /** Running, and deciding nothing. See {@link BotHandlerConfig.shadowDetectors}. */
183
+ shadow?: true | undefined;
180
184
  }
181
- /**
182
- * The engine.
183
- *
184
- * Three separable steps, in a fixed order, each usable on its own:
185
- *
186
- * 1. {@link assess} — gather evidence and reach a verdict. Reads the request, touches
187
- * no response, and is safe to call anywhere, including from a log processor
188
- * replaying yesterday's traffic.
189
- * 2. {@link decide} — apply the policy, subject to the safety guard.
190
- * 3. {@link handle} — do both, then turn the decision into an outcome an adapter can
191
- * apply.
192
- *
193
- * Keeping them apart is what makes the library testable and what makes a
194
- * monitor-only deployment a first-class mode rather than a configuration trick: call
195
- * `assess` alone and you have a detector with no opinions about your traffic at all.
196
- *
197
- * Everything is failure-isolated. A detector that throws, a store that will not
198
- * answer, a notification sink that hangs — each degrades exactly itself and is
199
- * reported through `onError`. Nothing in this file can turn a bad day for a
200
- * dependency into a bad day for the site it is protecting.
201
- */
202
185
  export declare class BotHandler {
203
186
  readonly config: ResolvedConfig;
204
187
  readonly registry: ActorRegistry;
205
188
  readonly store: BotHandlerStore;
206
189
  readonly policy: Policy;
207
190
  readonly challenge: ChallengeService | undefined;
191
+ /** The marker-cookie probe, when the operator asked for one. See `probe` in the config. */
192
+ readonly probe: MarkerProbe | undefined;
193
+ /** The site-wide baseline, when the operator asked for one. See `site` in the config. */
194
+ readonly site: SiteProfile | undefined;
208
195
  readonly notifications: NotificationHub;
209
196
  /**
210
197
  * The traffic audit, or `undefined` when it was switched off with `audit: false`.
@@ -220,6 +207,8 @@ export declare class BotHandler {
220
207
  private readonly cheapDetectors;
221
208
  private readonly ioDetectors;
222
209
  private readonly confirmingDetectors;
210
+ /** Hoisted from the resolved config: read once per detector per request. */
211
+ private readonly shadowIds;
223
212
  private readonly events;
224
213
  private readonly ignoreExact;
225
214
  private readonly ignorePatterns;
@@ -311,6 +300,34 @@ export declare class BotHandler {
311
300
  * `isHuman`, and it expires the same way a solved challenge does.
312
301
  */
313
302
  clearActor(key: string, forMs: number, context?: ChangeContext): void;
303
+ /**
304
+ * Tells the engine what the application answered.
305
+ *
306
+ * The one thing detection cannot see for itself. Every verdict here is reached *before*
307
+ * the response exists — that is what makes it useful, since it can shape the response —
308
+ * and so the status is knowledge only the application holds. Handed back, it closes the
309
+ * oldest gap in reading a scanner: an actor whose requests are almost all misses is
310
+ * looking for something rather than reading anything, and no amount of header analysis
311
+ * shows that.
312
+ *
313
+ * Optional, and silent when the actor has already been forgotten. Nothing about
314
+ * detection depends on it being called; supplying it sharpens `probe-volume` and
315
+ * nothing else. The bundled Node adapter wires it up for you.
316
+ */
317
+ recordOutcome(facts: RequestFacts, status: number): void;
318
+ /**
319
+ * Gives an actor a name, or clears it with `undefined`.
320
+ *
321
+ * Detection never reads it — a label cannot make anybody more or less suspicious, and
322
+ * that separation is deliberate: the moment a note changes a verdict, writing notes
323
+ * becomes a way to be wrong about people at scale. It is for the humans reading the
324
+ * dashboard, and it survives exactly as long as the actor does.
325
+ *
326
+ * Available from code so a deployment can label what it already knows — its own
327
+ * monitoring, a partner's feed, the office egress — rather than waiting for somebody to
328
+ * recognise the address twice.
329
+ */
330
+ labelActor(key: string, label: string | undefined, context?: ChangeContext): void;
314
331
  /** Convenience for `updateRanges("crawler:<id>", …)`, matching a signature id. */
315
332
  updateCrawlerRanges(signatureId: string, entries: readonly string[], context?: ChangeContext): void;
316
333
  /**
@@ -413,6 +430,16 @@ export declare class BotHandler {
413
430
  decide(assessment: Assessment): Decision;
414
431
  /** Assess, decide, and turn the decision into an outcome for an adapter to apply. */
415
432
  handle(facts: RequestFacts): Promise<HandleResult>;
433
+ /**
434
+ * The `Set-Cookie` this response should carry, if any.
435
+ *
436
+ * Nothing is issued to a client that already holds a valid marker, because a
437
+ * `Set-Cookie` on every response makes every response uncacheable by shared caches —
438
+ * a detection feature is not worth a site's cache-hit ratio. Nothing is issued to a
439
+ * verified crawler either: Googlebot does not keep cookies, so a marker sent to it is
440
+ * a header that will never come back and an issuance count that means nothing.
441
+ */
442
+ private markerFor;
416
443
  /** True when this request is the challenge verification endpoint. */
417
444
  isChallengeEndpoint(facts: Pick<RequestFacts, "method" | "path">): boolean;
418
445
  /**
@@ -327,18 +327,21 @@ function createFacts(input) {
327
327
  const rawPath = queryStart === -1 ? url : url.slice(0, queryStart);
328
328
  const headers = /* @__PURE__ */ Object.create(null);
329
329
  for (const [name, value] of Object.entries(input.headers)) {
330
- const joined = joinHeaderValue(value);
331
- if (joined !== void 0) headers[name.toLowerCase()] = joined;
330
+ const lower = name.toLowerCase();
331
+ const joined = lower === "cookie" && Array.isArray(value) ? value.join("; ") : joinHeaderValue(value);
332
+ if (joined !== void 0) headers[lower] = joined;
332
333
  }
334
+ const normalized = normalizePath(rawPath);
333
335
  const facts = {
334
336
  method: (input.method ?? "GET").toUpperCase(),
335
- path: normalizePath(rawPath),
337
+ path: normalized,
336
338
  query: parseQuery(queryStart === -1 ? "" : url.slice(queryStart + 1)),
337
339
  headers,
338
340
  headerOrder: extractOrder(input.rawHeaders, headers),
339
341
  ip: normalizeIp(input.ip) ?? input.ip,
340
342
  timestamp: input.timestamp ?? Date.now()
341
343
  };
344
+ if (rawPath !== normalized) facts.rawPath = rawPath.length > MAX_RAW_PATH ? rawPath.slice(0, MAX_RAW_PATH) : rawPath;
342
345
  const cookieHeader = headers["cookie"];
343
346
  if (cookieHeader !== void 0) facts.cookies = parseCookies(cookieHeader);
344
347
  if (input.protocol !== void 0) facts.protocol = input.protocol;
@@ -371,12 +374,23 @@ function parseQuery(search) {
371
374
  const query = /* @__PURE__ */ Object.create(null);
372
375
  if (search.length === 0) return query;
373
376
  let count = 0;
374
- for (const [key, value] of new URLSearchParams(search)) {
377
+ for (const [key, value] of new URLSearchParams(boundedSearch(search))) {
375
378
  if (count++ >= MAX_QUERY_PARAMS) break;
376
379
  query[key] = value.length > 1024 ? value.slice(0, 1024) : value;
377
380
  }
378
381
  return query;
379
382
  }
383
+ function boundedSearch(search) {
384
+ let seen = 0;
385
+ let at = search.charCodeAt(0) === 63 ? 1 : 0;
386
+ while (at < search.length) {
387
+ let end = search.indexOf("&", at);
388
+ if (end === -1) end = search.length;
389
+ if (end !== at && ++seen > MAX_QUERY_PARAMS) return search.slice(0, at - 1);
390
+ at = end + 1;
391
+ }
392
+ return search;
393
+ }
380
394
  function extractOrder(rawHeaders, headers) {
381
395
  if (!rawHeaders || rawHeaders.length === 0) return EMPTY_ORDER;
382
396
  let isNodeStyle = rawHeaders.length % 2 === 0;
@@ -412,13 +426,14 @@ function isHeaderName(value) {
412
426
  }
413
427
  return true;
414
428
  }
415
- var MAX_URL_LENGTH, MAX_QUERY_PARAMS, MAX_ORDERED_HEADERS, EMPTY_ORDER;
429
+ var MAX_URL_LENGTH, MAX_RAW_PATH, MAX_QUERY_PARAMS, MAX_ORDERED_HEADERS, EMPTY_ORDER;
416
430
  var init_facts = __esm({
417
431
  "src/facts.ts"() {
418
432
  "use strict";
419
433
  init_http();
420
434
  init_ip();
421
435
  MAX_URL_LENGTH = 8192;
436
+ MAX_RAW_PATH = 512;
422
437
  MAX_QUERY_PARAMS = 64;
423
438
  MAX_ORDERED_HEADERS = 64;
424
439
  EMPTY_ORDER = Object.freeze([]);
@@ -1207,7 +1222,248 @@ var AI_CRAWLER_CASES = [
1207
1222
 
1208
1223
  // src/corpus/adversarial.ts
1209
1224
  var CHROME_UA = userAgentOf("chromeWindows");
1225
+ var CURL_UA = "curl/8.4.0";
1210
1226
  var ADVERSARIAL_CASES = [
1227
+ bot({
1228
+ id: "two-scanners-one-address",
1229
+ title: "One address arriving as two different security tools",
1230
+ audience: "hostile",
1231
+ category: "scanning",
1232
+ provenance: "The shape of an actual scan: an operator runs more than one tool against a target, and both announce themselves honestly. Each request on its own is a declared bot; the pair is a scan, and that reading does not exist inside either request.",
1233
+ requests: [
1234
+ { headers: [["Host", "shop.example"], ["User-Agent", "sqlmap/1.7.2#stable (http://sqlmap.org)"], ["Accept", "*/*"]], ip: "198.51.100.66", atMs: 0 },
1235
+ { headers: [["Host", "shop.example"], ["User-Agent", "Mozilla/5.00 (Nikto/2.5.0) (Evasions:None) (Test:Port Check)"], ["Accept", "*/*"]], ip: "198.51.100.66", atMs: 1e3 }
1236
+ ],
1237
+ expect: { verdict: "confirmed-bot", certain: true, detectors: ["blended-identity"] },
1238
+ notes: "Holds under the default address-based actor key, which is what separates it from `identity-rotation`. A NAT gateway presents a hundred browsers \u2014 that is exactly why counting User-Agents there is useless \u2014 and it does not present sqlmap and nikto."
1239
+ }),
1240
+ bot({
1241
+ id: "two-crawler-claims-one-address",
1242
+ title: "One address claiming to be both Googlebot and Bingbot",
1243
+ audience: "hostile",
1244
+ category: "impersonation",
1245
+ provenance: "At most one of these can be true of an address: each operator publishes a proof tied to addresses it controls. The contradiction is visible from the claims alone, with no lookup \u2014 which matters when DNS is unreachable and neither claim can be refuted on its own.",
1246
+ requests: [
1247
+ { headers: [["Host", "shop.example"], ["User-Agent", "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"], ["Accept", "*/*"]], ip: "198.51.100.67", atMs: 0 },
1248
+ { headers: [["Host", "shop.example"], ["User-Agent", "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)"], ["Accept", "*/*"]], ip: "198.51.100.67", atMs: 1e3 }
1249
+ ],
1250
+ expect: { detectors: ["blended-identity"] },
1251
+ notes: "Held at `strong` rather than `certain`. Trusting the wrong forwarded header collapses every client onto one address, and then two genuinely different crawlers produce this exact set \u2014 so it may contribute to a denial and may not be the whole of one."
1252
+ }),
1253
+ bot({
1254
+ id: "id-harvest-contiguous",
1255
+ title: "Every profile id in order, with a copied browser header set",
1256
+ audience: "hostile",
1257
+ category: "scraping",
1258
+ provenance: "Harvesting by identifier rather than by link: the shape of an IDOR sweep and of profile collection. Distinct-path breadth reads it as somebody who visited a lot of pages, which is also what it reads when a person works through a documentation site.",
1259
+ requests: repeat({ ...browser("chromeWindows"), ip: "198.51.100.65" }, 40, 800, (index) => `/user/${index + 1}`),
1260
+ expect: {
1261
+ // One `moderate` signal against a flawless header set, like the others here. What
1262
+ // changed is that the walk is now *visible* — before this detector it was scored
1263
+ // identically to a hundred and twenty scattered ids and to ordinary article paths.
1264
+ verdict: "unknown",
1265
+ detectors: ["id-enumeration"]
1266
+ },
1267
+ notes: "What separates this from reading is not which ids were asked for but that they cover a range: people arrive at ids through links, and links do not densely enumerate an integer interval. Held at `moderate` because products in one category often carry consecutive ids, so somebody browsing a catalogue makes a smaller version of this shape."
1268
+ }),
1269
+ bot({
1270
+ id: "wordlist-scan-mostly-misses",
1271
+ title: "A wordlist walked with a copied browser header set, almost all of it missing",
1272
+ audience: "hostile",
1273
+ category: "scanning",
1274
+ provenance: "The oldest tell there is, and the one this library could not see: it decides before the response exists, which is what lets it shape the response and also what hides the status from it. A person browsing does not generate thirty misses in a row; a wordlist does almost nothing else.",
1275
+ requests: repeat({ ...browser("chromeWindows"), ip: "198.51.100.64", status: 404 }, 30, 700, (index) => `/${["admin", "backup", "old", "test", "config", "db"][index % 6]}-${index}`),
1276
+ expect: {
1277
+ // One `moderate` signal against an otherwise flawless header set does not cross the
1278
+ // line, and it should not: a site that has just moved its URLs produces the same
1279
+ // shape from ordinary readers. Raising the ceiling so this case reads better would
1280
+ // be tuning the detector to the test rather than to the traffic.
1281
+ verdict: "unknown",
1282
+ detectors: ["probe-volume"]
1283
+ },
1284
+ notes: "Only counts 404 and 410. A 403 is usually this library's own doing, and counting it would let a rule that challenges an actor manufacture the evidence for having challenged it; a 500 is the site's problem and says nothing about the client. Capped at `moderate` because a site that has just moved its URLs produces this from perfectly ordinary readers."
1285
+ }),
1286
+ bot({
1287
+ id: "browser-claim-over-http-1-0",
1288
+ title: "A perfect Chrome header set, arriving over HTTP/1.0",
1289
+ audience: "hostile",
1290
+ category: "impersonation",
1291
+ provenance: "Most tooling lets you set headers and does not let you choose an HTTP version, so the transport is the half a copied header set does not cover. No shipping browser has offered HTTP/1.0 to a server in well over a decade.",
1292
+ requests: repeat({ ...browser("chromeWindows"), ip: "198.51.100.62", httpVersion: "1.0" }, 30, 900, (index) => `/${["news", "about", "blog", "help", "terms"][index % 5]}`),
1293
+ expect: {
1294
+ // Contributes rather than concludes. On its own, against an otherwise flawless
1295
+ // header set, one `moderate` signal does not reach the threshold — and it should
1296
+ // not, because an intermediary can cause this. Beside anything sharper it does.
1297
+ verdict: "unknown",
1298
+ detectors: ["transport-coherence"]
1299
+ },
1300
+ notes: "Capped at `moderate` because it is not always the client's doing: a few older load balancers speak HTTP/1.0 to the origin, and behind one of those every request looks like this. That is what `transportCoherenceDetector({ legacyHttp: false })` is for, and why this may never deny anybody on its own."
1301
+ }),
1302
+ // ---------------------------------------------------------------------------
1303
+ // The optional sources. None of these can be detected without the operator
1304
+ // switching something on — a marker cookie, or a site-wide baseline — so each
1305
+ // exists to hold that feature to the same standard as everything shipped by
1306
+ // default. See `docs/detection/correlation.md`.
1307
+ // ---------------------------------------------------------------------------
1308
+ bot({
1309
+ id: "marker-held-while-identity-changes",
1310
+ title: "One client presenting a marker it was issued as Chrome, then as curl",
1311
+ audience: "unwanted-bot",
1312
+ category: "evasion",
1313
+ provenance: "Identity rotation, which is invisible without a marker. Correlating by address cannot tell this apart from two people sharing an office connection, so the library declined to guess. A signed cookie removes the ambiguity: both requests carried an HMAC only this server can produce.",
1314
+ requires: ["marker-probe"],
1315
+ requests: [
1316
+ ...repeat({ ...browser("chromeWindows"), ip: "198.51.100.81", headers: [...browser("chromeWindows").headers, ["Cookie", "sid=held"]] }, 4, 1500, (i) => `/products/${i}`),
1317
+ ...repeat({ ...plain(CURL_UA), ip: "198.51.100.81", headers: [...plain(CURL_UA).headers, ["Cookie", "sid=held"]] }, 4, 1500, (i) => `/products/${i + 4}`).map((request) => ({ ...request, atMs: (request.atMs ?? 0) + 6e3 }))
1318
+ ],
1319
+ expect: { verdict: "confirmed-bot", detectors: ["identity-drift"] },
1320
+ notes: "The browser family carries the weight and the platform does not, because a phone with `Request desktop site` changes its platform and is a person. Software does not change what it is."
1321
+ }),
1322
+ bot({
1323
+ id: "marker-never-stored-though-cookies-sent",
1324
+ title: "A client replaying a captured session cookie and storing nothing new",
1325
+ audience: "unwanted-bot",
1326
+ category: "scraping",
1327
+ provenance: "A scraper handed a session header to copy. It sends the one cookie it was configured with on every request and never stores anything the server sets, which a browser with a jar does not do.",
1328
+ requires: ["marker-probe"],
1329
+ keepsCookies: false,
1330
+ requests: repeat(
1331
+ { ...browser("chromeWindows"), ip: "198.51.100.82", headers: [...browser("chromeWindows").headers, ["Cookie", "sid=captured-elsewhere"]] },
1332
+ 9,
1333
+ 1200,
1334
+ (i) => `/products/${i}`
1335
+ ),
1336
+ expect: { verdict: "unknown", detectors: ["marker-persistence"] },
1337
+ notes: "Deliberately narrower than `session-integrity`, which already reports a client sending no cookie at all. Overlapping them double-counted one observation and the population it landed on was people who block cookies."
1338
+ }),
1339
+ bot({
1340
+ id: "marker-edited-by-its-holder",
1341
+ title: "A client that edited the signed cookie it was given",
1342
+ audience: "unwanted-bot",
1343
+ category: "evasion",
1344
+ provenance: "Browsers do not edit their own cookies. A marker failing its HMAC was altered by whoever held it, and the only reason to alter an opaque signed value is to see what the server does with a different one.",
1345
+ requires: ["marker-probe"],
1346
+ keepsCookies: false,
1347
+ requests: repeat(
1348
+ { ...browser("chromeWindows"), ip: "198.51.100.83", headers: [...browser("chromeWindows").headers, ["Cookie", "__bh_m=eyJ2IjoxfQ.not-a-signature-this-server-made"]] },
1349
+ 4,
1350
+ 1500,
1351
+ (i) => `/account/${i}`
1352
+ ),
1353
+ expect: { verdict: "unknown", detectors: ["marker-integrity"] },
1354
+ notes: "Stops at `strong` rather than `certain` because a middlebox or a broken cookie jar can mangle a value in transit. That is rare, it is not the client's fault, and it should cost a challenge rather than a door."
1355
+ }),
1356
+ bot({
1357
+ id: "marker-carried-across-a-proxy-pool",
1358
+ title: "One marker presented from twenty different networks",
1359
+ audience: "unwanted-bot",
1360
+ category: "scraping",
1361
+ provenance: "A scraper on a rotating proxy pool that keeps its cookie jar, which most of them do because discarding it breaks the sites they are taking. The marker comes back only from the client that received it, so this is one client across twenty networks.",
1362
+ requires: ["marker-probe"],
1363
+ requests: Array.from({ length: 20 }, (_, index) => ({
1364
+ ...browser("chromeWindows"),
1365
+ headers: [...browser("chromeWindows").headers, ["Cookie", "sid=pooled"]],
1366
+ ip: `198.51.${140 + index}.9`,
1367
+ path: `/catalogue/${index}`,
1368
+ atMs: index * 2500
1369
+ })),
1370
+ expect: { verdict: "unknown", detectors: ["marker-fanout"] },
1371
+ notes: "Capped at `moderate` and offered no higher: a phone on a carrier using CGNAT can be renumbered across a great many /24s in the twelve hours a marker lives, and so can anyone whose employer egresses through a rotating pool."
1372
+ }),
1373
+ bot({
1374
+ id: "range-walked-across-many-clients",
1375
+ title: "An id range divided between ten clients so none of them walks enough to notice",
1376
+ audience: "unwanted-bot",
1377
+ category: "scraping",
1378
+ provenance: "The threat every per-actor threshold misses by construction. Split a range across enough addresses and each one is unremarkable, `id-enumeration` fires for nobody, and the range is still walked end to end. It is only visible in the union.",
1379
+ requires: ["site-baseline"],
1380
+ requests: Array.from({ length: 200 }, (_, index) => ({
1381
+ ...browser("chromeWindows"),
1382
+ ip: `198.51.${170 + index % 10}.5`,
1383
+ path: `/user/${index + 1}`,
1384
+ atMs: index * 900
1385
+ })),
1386
+ expect: { verdict: "unknown", detectors: ["distributed-walk"] },
1387
+ notes: "Coverage and the revisit ratio must both agree. Many clients on numbered pages is what a catalogue is; what a catalogue also has, and an enumeration does not, is people returning to the same popular items."
1388
+ }),
1389
+ bot({
1390
+ id: "fresh-path-wanted-by-everybody",
1391
+ title: "A path this site never served, requested at once by twenty unrelated clients",
1392
+ audience: "unwanted-bot",
1393
+ category: "recon",
1394
+ provenance: "What a freshly disclosed vulnerability looks like from inside a site: a URL nobody had ever requested is requested by hundreds of unrelated clients within the hour, each making a single request and moving on.",
1395
+ requires: ["site-baseline"],
1396
+ requests: Array.from({ length: 20 }, (_, index) => ({
1397
+ ...plain(CURL_UA),
1398
+ ip: `198.51.${190 + index}.11`,
1399
+ path: "/vendor/proprietary-thing/rce.php",
1400
+ status: 404,
1401
+ atMs: index * 3e3
1402
+ })),
1403
+ expect: { verdict: "unknown", detectors: ["path-campaign"] },
1404
+ notes: "The miss rate is required rather than optional. Many clients arriving at once on a brand-new URL is also exactly what a successful launch looks like; what separates them is whether the site had anything to serve."
1405
+ }),
1406
+ bot({
1407
+ id: "missing-far-more-than-this-site-does",
1408
+ title: 'A client answered "not found" far more often than the site answers it at all',
1409
+ audience: "unwanted-bot",
1410
+ category: "recon",
1411
+ provenance: "A fixed miss threshold is wrong on both kinds of site: on one mid-migration it reports everybody, and on a tidy one it stays silent while a client misses a third of the time. The site's own rate is the only honest comparison.",
1412
+ requires: ["site-baseline"],
1413
+ requests: repeat({ ...plain(CURL_UA), ip: "198.51.210.12", status: 404 }, 26, 1100, (i) => `/backup-${i}.sql`),
1414
+ expect: { verdict: "unknown", detectors: ["miss-baseline"] },
1415
+ notes: "Shares the `misses` family with `probe-volume`, which reads the same misses against a fixed threshold. One cause, so the stronger reading stands rather than the two summing."
1416
+ }),
1417
+ bot({
1418
+ id: "solution-farm-replaying-answers",
1419
+ title: "A client answering challenges with solutions that have already been spent",
1420
+ audience: "unwanted-bot",
1421
+ category: "evasion",
1422
+ provenance: "What a solved-challenge farm looks like from the server. A challenge nonce is random, single-use and signed, so a second valid solution for one is the same answer sent twice or one answer handed around \u2014 neither of which a browser does. One replay is a retried POST on a flaky connection, which is why the threshold is not one.",
1423
+ challengeHistory: { replayedSolutions: 4, implausibleSolves: 2 },
1424
+ requests: repeat({ ...browser("chromeWindows"), ip: "198.51.100.71" }, 6, 1500, () => "/account"),
1425
+ expect: {
1426
+ // A shape worth reporting and not worth concluding from: the client is otherwise
1427
+ // indistinguishable from the browser whose headers it copied.
1428
+ verdict: "unknown",
1429
+ detectors: ["challenge-integrity"]
1430
+ },
1431
+ notes: "The proof-of-work floor is measured on the server between issuing and receiving, so no client clock is involved, and it is set at a SHA-256 rate no browser has ever reached. Both signals stay `moderate`: they say the answers did not come from the page we served, which is a fact about the answering software rather than proof about the traffic it is attached to."
1432
+ }),
1433
+ bot({
1434
+ id: "head-only-visit",
1435
+ title: "A visit made entirely of HEAD, claiming a browser",
1436
+ audience: "unwanted-bot",
1437
+ category: "scraping",
1438
+ provenance: "Checking what exists without reading any of it: link checkers, availability monitors and inventory watchers all do this, and a browser navigating never does.",
1439
+ requests: repeat({ ...browser("chromeWindows"), ip: "198.51.100.63", method: "HEAD" }, 30, 900, (index) => `/${["news", "about", "blog", "help", "terms"][index % 5]}`),
1440
+ expect: {
1441
+ // As above: a shape worth reporting, not worth concluding from alone.
1442
+ verdict: "unknown",
1443
+ detectors: ["transport-coherence"]
1444
+ },
1445
+ notes: "One HEAD is a browser checking a link it is about to follow, or a cache revalidating; the shape only means anything across a visit, which is why it is counted on the actor rather than on the request. A link checker is a real and mostly harmless thing to be, so this stays `moderate`."
1446
+ }),
1447
+ bot({
1448
+ id: "catalogue-sweep-by-page",
1449
+ title: "A catalogue taken a page at a time, with the path never changing",
1450
+ audience: "unwanted-bot",
1451
+ category: "scraping",
1452
+ provenance: "How a catalogue is actually taken. The collector copies a browser's headers exactly and walks ?page=1..N, which leaves the path constant \u2014 so distinct-path breadth reads it as somebody rereading one page rather than as enumeration.",
1453
+ requests: repeat({ ...browser("chromeWindows"), ip: "198.51.100.61" }, 40, 900, (index) => `/products?page=${index}`),
1454
+ expect: {
1455
+ // Not proven, and not even suspected at this pace. Said plainly because it is true:
1456
+ // headers this clean leave only behaviour, behaviour is weak by construction, and a
1457
+ // collector polite enough to space its requests stays under the line. What changed
1458
+ // is that it no longer scores *lower* than the identical crawl expressed as distinct
1459
+ // paths — measured at a faster pace before this detector existed, the two differed by
1460
+ // seven points and only the path version crossed; they now score the same at every
1461
+ // volume tried.
1462
+ verdict: "unknown",
1463
+ detectors: ["parameter-sweep"]
1464
+ },
1465
+ notes: "The counterpart to crawl-breadth rather than a replacement for it: breadth counts paths, this counts what is hung on them. Both stay weak, and both are worth having because a collector picks one shape or the other and nothing says which. Neither is a reason to deny anybody on its own."
1466
+ }),
1211
1467
  // ---------------------------------------------------------------------------
1212
1468
  // Forged identities. The narrow case where a lie is provable.
1213
1469
  // ---------------------------------------------------------------------------
@@ -1550,6 +1806,38 @@ var ADVERSARIAL_CASES = [
1550
1806
  expect: { certain: false, detectors: ["probe-signature"], neverAction: ["block", "drop"] },
1551
1807
  tags: ["scanning"]
1552
1808
  }),
1809
+ bot({
1810
+ id: "traversal-encoded-past-a-filter",
1811
+ title: "A traversal with its dots and slashes written in percent-encoding",
1812
+ audience: "hostile",
1813
+ category: "wordlist-probe",
1814
+ provenance: "The standard first move against a path filter, and the reason this library keeps the raw target: normalisation resolves the dots, so what reaches a wordlist check is `/app/config.yml` \u2014 an ordinary-looking path nobody has, on no list. The spelling is the whole signal, and it is destroyed by the thing that makes rules work.",
1815
+ requests: [{ ...browser("chromeWindows"), path: "/%2e%2e%2f%2e%2e%2fapp/config.yml", status: 404 }],
1816
+ expect: { certain: false, detectors: ["target-integrity"], neverAction: ["block", "drop"] },
1817
+ notes: "`strong`, not proven. A path segment carrying a URL as data is encoded to sit in a path and encoded again by whatever built the link, which produces the same characters honestly \u2014 so this may score, and may not close a door on its own.",
1818
+ tags: ["scanning"]
1819
+ }),
1820
+ bot({
1821
+ id: "traversal-double-encoded",
1822
+ title: "A traversal encoded twice, so one round of decoding leaves it encoded",
1823
+ audience: "hostile",
1824
+ category: "wordlist-probe",
1825
+ provenance: "Aimed at a filter that decodes once and then inspects: after its single pass the target still reads `%2e%2e%2f`, which the filter does not recognise, and the server behind it decodes again.",
1826
+ requests: [{ ...browser("chromeWindows"), path: "/static/%252e%252e%252f%252e%252e%252fetc/passwd", status: 404 }],
1827
+ expect: { certain: false, detectors: ["target-integrity"], neverAction: ["block", "drop"] },
1828
+ tags: ["scanning"]
1829
+ }),
1830
+ bot({
1831
+ id: "absolute-form-proxy-probe",
1832
+ title: "A request target addressed to somewhere else entirely",
1833
+ audience: "hostile",
1834
+ category: "protocol-abuse",
1835
+ provenance: "Absolute-form is the request line a client sends to a *proxy*. Arriving at an origin server it is a question \u2014 will you fetch this for me \u2014 and open-proxy scanning asks it of everything with a port 80 open.",
1836
+ requests: [{ ...plain(CHROME_UA), path: "http://scanner.example/check", status: 404 }],
1837
+ expect: { certain: false, detectors: ["target-integrity"], neverAction: ["block", "drop"] },
1838
+ notes: "RFC 9112 \xA73.2.2 requires servers to accept absolute-form, so this is not malformed and is not proven. No browser has ever sent one to an origin server.",
1839
+ tags: ["scanning"]
1840
+ }),
1553
1841
  bot({
1554
1842
  id: "probe-trace-method",
1555
1843
  title: "A TRACE request",
@@ -3130,6 +3418,46 @@ var HUMAN_CASES = [
3130
3418
  ],
3131
3419
  expect: { verdict: "unknown", maxScore: 0, notDetectors: ["header-order"], action: "allow" }
3132
3420
  }),
3421
+ human({
3422
+ id: "request-desktop-site-mid-visit",
3423
+ title: "Somebody switching their phone to the desktop version of a site",
3424
+ category: "mangled-by-infrastructure",
3425
+ provenance: "`Request desktop site` rewrites the User-Agent to claim a Mac. The browser is the same Safari and the cookie jar is the same jar, so the marker comes back \u2014 which means the library can see, correctly, that one client has now described itself two different ways.",
3426
+ requires: ["marker-probe"],
3427
+ notes: "The reason `identity-drift` weighs a changed *browser family* at `strong` and a changed platform only at `moderate`. Software does not change what it is; a platform changes when a person taps a menu item, and this is that person.",
3428
+ requests: [
3429
+ ...humanPaced({ ...browser("safariIos"), ip: "203.0.115.24", headers: [...browser("safariIos").headers, ["Cookie", "sid=phone-session"]] }, [
3430
+ "/",
3431
+ "/collections/lamps",
3432
+ "/products/brass-desk-lamp",
3433
+ "/products/brass-desk-lamp/reviews"
3434
+ ]),
3435
+ // The same person, same session, having tapped "Request desktop site".
3436
+ ...humanPaced(
3437
+ { ...browser("safariMac"), ip: "203.0.115.24", headers: [...browser("safariMac").headers, ["Cookie", "sid=phone-session"]] },
3438
+ ["/products/brass-desk-lamp", "/delivery", "/products/brass-desk-lamp", "/basket"]
3439
+ ).map((request) => ({ ...request, atMs: (request.atMs ?? 0) + 47e3 }))
3440
+ ],
3441
+ expect: { certain: false, action: ["allow", "tag", "log", "delay", "challenge", "rate-limit"] },
3442
+ tags: ["known-cost"]
3443
+ }),
3444
+ human({
3445
+ id: "broken-link-shared-widely",
3446
+ title: "A crowd of people following one mistyped link",
3447
+ category: "mangled-by-infrastructure",
3448
+ provenance: "Somebody shares a URL with a typo in it and thousands of real people follow it within the hour. From the server this is a path the site has never served, requested by many unrelated clients, and answered `not found` to every one of them \u2014 which is the exact shape `path-campaign` reads.",
3449
+ requires: ["site-baseline"],
3450
+ notes: "The known cost of comparing a client with the rest of the traffic: most of the evidence is about what *other* people did, and a person following a bad link is indistinguishable here from one running a list. It is capped at `moderate` for this case specifically, and the guarantee it must keep is this one \u2014 reported, never refused.",
3451
+ requests: Array.from({ length: 16 }, (_, index) => ({
3452
+ ...browser("chromeWindows", { kind: "cross-site-navigate", referer: "https://social.example/" }),
3453
+ ip: `203.0.114.${index + 1}`,
3454
+ path: "/blog/anouncing-our-new-thing",
3455
+ status: 404,
3456
+ atMs: index * 4e3
3457
+ })),
3458
+ expect: { certain: false, action: ["allow", "tag", "log", "delay", "challenge", "rate-limit"] },
3459
+ tags: ["known-cost"]
3460
+ }),
3133
3461
  human({
3134
3462
  id: "cgnat-shared-address",
3135
3463
  title: "Many people behind one carrier-grade NAT address",
@@ -4006,6 +4334,23 @@ function checkExpectations(item, result, assertActions) {
4006
4334
  }
4007
4335
  return failures;
4008
4336
  }
4337
+ function withExtraCookies(request, extra) {
4338
+ const headers = [];
4339
+ let merged = false;
4340
+ for (const [name, value] of request.headers) {
4341
+ if (!merged && name.toLowerCase() === "cookie") {
4342
+ headers.push([name, [value, ...extra].join("; ")]);
4343
+ merged = true;
4344
+ } else {
4345
+ headers.push([name, value]);
4346
+ }
4347
+ }
4348
+ if (!merged) headers.push(["Cookie", extra.join("; ")]);
4349
+ return { ...request, headers };
4350
+ }
4351
+ function keepsCookies(request) {
4352
+ return request.headers.some(([name]) => name.toLowerCase() === "cookie");
4353
+ }
4009
4354
  async function runCase(handler, clock, item, startedAt, provides, assertActions = true) {
4010
4355
  const missing = (item.requires ?? []).filter((capability) => !provides.has(capability));
4011
4356
  if (missing.length > 0) {
@@ -4028,11 +4373,24 @@ async function runCase(handler, clock, item, startedAt, provides, assertActions
4028
4373
  const seed = toFacts(item.requests[0], fallbackIp, clock.now());
4029
4374
  clearanceCookie = handler.grantClearance(seed, item.clearance)?.split(";")[0];
4030
4375
  }
4376
+ if (item.challengeHistory !== void 0) {
4377
+ clock.set(startedAt);
4378
+ const seed = toFacts(item.requests[0], fallbackIp, clock.now());
4379
+ const key = handler.actorKeyFor(seed);
4380
+ const state = handler.registry.observe(key, seed);
4381
+ for (let i = 0; i < (item.challengeHistory.replayedSolutions ?? 0); i++) state.noteChallengeAnomaly("replay");
4382
+ for (let i = 0; i < (item.challengeHistory.implausibleSolves ?? 0); i++) state.noteChallengeAnomaly("implausible-speed");
4383
+ }
4384
+ let issuedCookies;
4031
4385
  for (const request of item.requests) {
4032
4386
  clock.set(startedAt + (request.atMs ?? 0));
4033
- const withClearance = clearanceCookie === void 0 ? request : { ...request, headers: [...request.headers, ["Cookie", clearanceCookie]] };
4034
- const facts = toFacts(withClearance, fallbackIp, clock.now());
4387
+ const extraCookies = [clearanceCookie, item.keepsCookies ?? keepsCookies(request) ? issuedCookies : void 0].filter((value) => value !== void 0);
4388
+ const withCookies = extraCookies.length === 0 ? request : withExtraCookies(request, extraCookies);
4389
+ const facts = toFacts(withCookies, fallbackIp, clock.now());
4035
4390
  const { assessment, decision, outcome } = await handler.handle(facts);
4391
+ const setCookie = outcome.kind === "continue" ? outcome.responseHeaders?.["set-cookie"] : outcome.kind === "respond" ? outcome.headers["set-cookie"] : void 0;
4392
+ if (setCookie !== void 0) issuedCookies = setCookie.split(";")[0];
4393
+ handler.recordOutcome(facts, request.status ?? 200);
4036
4394
  requests.push({ assessment, decision, outcome });
4037
4395
  }
4038
4396
  const final = requests[requests.length - 1];