@osqd/bothandlerjs 0.5.0 → 0.6.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/dist/cli.js CHANGED
@@ -123,16 +123,20 @@ function hashString(value) {
123
123
  }
124
124
  return hash >>> 0;
125
125
  }
126
- var TIMESTAMP_RING, PATH_CAP, UA_CAP, MAX_TRACKED_ARRIVALS, MAX_TRACKED_PATHS, ActorState, ActorRegistry;
126
+ var TIMESTAMP_RING, PATH_CAP, QUERY_CAP, METHOD_CAP, WALK_CAP, UA_CAP, MAX_TRACKED_ARRIVALS, MAX_TRACKED_PATHS, MAX_TRACKED_QUERIES, ActorState, ActorRegistry;
127
127
  var init_state = __esm({
128
128
  "src/state.ts"() {
129
129
  "use strict";
130
130
  init_lru();
131
131
  TIMESTAMP_RING = 32;
132
132
  PATH_CAP = 64;
133
+ QUERY_CAP = 64;
134
+ METHOD_CAP = 12;
135
+ WALK_CAP = 4;
133
136
  UA_CAP = 4;
134
137
  MAX_TRACKED_ARRIVALS = TIMESTAMP_RING;
135
138
  MAX_TRACKED_PATHS = PATH_CAP;
139
+ MAX_TRACKED_QUERIES = QUERY_CAP;
136
140
  ActorState = class {
137
141
  key;
138
142
  firstSeen;
@@ -173,6 +177,45 @@ var init_state = __esm({
173
177
  paths = /* @__PURE__ */ new Set();
174
178
  pathsOverflowed = false;
175
179
  pathsSaturatedAtTotal = 0;
180
+ /**
181
+ * Distinct *parameterised* requests: the path together with its query.
182
+ *
183
+ * Counted apart from `paths` because the two answer different questions and a scraper
184
+ * lives in the gap between them. `/products?page=1` through `?page=200` is one path and
185
+ * two hundred requests, so breadth reads it as somebody rereading a single page — which
186
+ * is exactly what enumerating a catalogue looks like from the path alone.
187
+ */
188
+ queries = /* @__PURE__ */ new Set();
189
+ queriesOverflowed = false;
190
+ /**
191
+ * Which HTTP methods this actor has used.
192
+ *
193
+ * A browser navigating issues GET. Something that has issued nothing but HEAD across a
194
+ * long visit is checking what exists rather than reading it, and that is a fact about
195
+ * the actor rather than about any one of its requests — which is why it is kept here.
196
+ */
197
+ methods = /* @__PURE__ */ new Set();
198
+ /**
199
+ * Numeric walks in progress, by path shape: `/user/#` against the ids requested under it.
200
+ *
201
+ * Three numbers per shape, deliberately — a count, a lowest and a highest — rather than
202
+ * the ids themselves. What separates enumeration from reading is not which ids were
203
+ * asked for but whether they *cover a range*: thirty requests spanning thirty
204
+ * consecutive ids is a walk, and thirty scattered across a hundred thousand is somebody
205
+ * following links. Both are answerable from a count and a span, and only the count and
206
+ * the span survive an actor asking for ten thousand of them.
207
+ */
208
+ walks = /* @__PURE__ */ new Map();
209
+ /**
210
+ * What the application answered, for the requests anybody bothered to tell us about.
211
+ *
212
+ * The engine decides *before* the response exists, so this arrives afterwards and only
213
+ * when the adapter reports it. Kept as two counters rather than a list because the one
214
+ * question worth asking is a ratio: an actor whose requests are almost all misses is
215
+ * looking for something rather than reading anything.
216
+ */
217
+ responsesSeen = 0;
218
+ missesSeen = 0;
176
219
  userAgents = /* @__PURE__ */ new Set();
177
220
  constructor(key, now) {
178
221
  this.key = key;
@@ -192,6 +235,15 @@ var init_state = __esm({
192
235
  this.pathsOverflowed = true;
193
236
  this.pathsSaturatedAtTotal = this.total;
194
237
  }
238
+ const keys = Object.keys(facts.query).sort();
239
+ if (keys.length > 0) {
240
+ const signature = `${facts.path}?${keys.map((key) => `${key}=${facts.query[key] ?? ""}`).join("&")}`;
241
+ const queryHash = hashString(signature);
242
+ if (this.queries.size < QUERY_CAP) this.queries.add(queryHash);
243
+ else if (!this.queries.has(queryHash)) this.queriesOverflowed = true;
244
+ }
245
+ if (this.methods.size < METHOD_CAP) this.methods.add(facts.method);
246
+ this.noteWalk(facts.path);
195
247
  const ua = facts.headers["user-agent"];
196
248
  if (ua !== void 0 && this.userAgents.size < UA_CAP) this.userAgents.add(ua);
197
249
  }
@@ -202,6 +254,79 @@ var init_state = __esm({
202
254
  get pathsSaturated() {
203
255
  return this.pathsOverflowed;
204
256
  }
257
+ /** Distinct path-and-query combinations seen. Saturates at {@link QUERY_CAP}. */
258
+ get distinctQueries() {
259
+ return this.queries.size;
260
+ }
261
+ get queriesSaturated() {
262
+ return this.queriesOverflowed;
263
+ }
264
+ /**
265
+ * Records what the application answered. Called after the response, if at all.
266
+ *
267
+ * 404 and 410 only. A 403 is usually this library's own doing and counting it would
268
+ * make the detector that reads this argue with itself; a 500 is the site's problem and
269
+ * says nothing about the client.
270
+ */
271
+ recordOutcome(status) {
272
+ this.responsesSeen++;
273
+ if (status === 404 || status === 410) this.missesSeen++;
274
+ }
275
+ /** Responses reported for this actor. Zero unless something is reporting them. */
276
+ get responses() {
277
+ return this.responsesSeen;
278
+ }
279
+ /** Of those, how many were 404 or 410. */
280
+ get misses() {
281
+ return this.missesSeen;
282
+ }
283
+ /**
284
+ * Files a request under the shape of its path, if that path carries a number.
285
+ *
286
+ * The last numeric segment is the one taken to be the identifier: in `/api/v2/orders/42`
287
+ * the version is part of the shape and the order id is what is being walked.
288
+ */
289
+ noteWalk(path) {
290
+ const segments = path.split("/");
291
+ let value;
292
+ let template = "";
293
+ for (const segment of segments) {
294
+ if (segment !== "" && /^\d+$/.test(segment)) {
295
+ const parsed = Number(segment);
296
+ if (Number.isSafeInteger(parsed) && parsed <= 1e7) value = parsed;
297
+ template += "/#";
298
+ } else if (segment !== "") {
299
+ template += `/${segment}`;
300
+ }
301
+ }
302
+ if (value === void 0) return;
303
+ const existing = this.walks.get(template);
304
+ if (existing !== void 0) {
305
+ existing.count++;
306
+ if (value < existing.min) existing.min = value;
307
+ if (value > existing.max) existing.max = value;
308
+ return;
309
+ }
310
+ if (this.walks.size < WALK_CAP) this.walks.set(template, { count: 1, min: value, max: value });
311
+ }
312
+ /**
313
+ * The path shape this actor has walked hardest, with how far it reached.
314
+ *
315
+ * `span` is inclusive of both ends, so a walk of 1 to 30 spans 30. Comparing the count
316
+ * against it is what separates covering a range from visiting a few points in one.
317
+ */
318
+ densestWalk() {
319
+ let best;
320
+ for (const [template, walk] of this.walks) {
321
+ const span = walk.max - walk.min + 1;
322
+ if (best === void 0 || walk.count > best.count) best = { template, count: walk.count, span };
323
+ }
324
+ return best;
325
+ }
326
+ /** Every HTTP method this actor has used, in first-seen order. */
327
+ get methodsSeen() {
328
+ return [...this.methods];
329
+ }
205
330
  /**
206
331
  * Requests seen when {@link distinctPaths} stopped being able to grow, or 0 if it
207
332
  * still can. Over that many requests the distinct count is exact, so it is the only
@@ -290,6 +415,12 @@ var init_state = __esm({
290
415
  key: this.key,
291
416
  requests: this.total,
292
417
  distinctPaths: this.distinctPaths,
418
+ distinctQueries: this.distinctQueries,
419
+ methodsSeen: this.methodsSeen,
420
+ walk: this.densestWalk(),
421
+ responses: this.responses,
422
+ misses: this.misses,
423
+ queriesSaturated: this.queriesSaturated,
293
424
  firstSeen: this.firstSeen,
294
425
  lastSeen: this.lastSeen,
295
426
  sinceLastMs: this.sinceLast(now),
@@ -3352,7 +3483,7 @@ function compileSignatures(signatures = BOT_SIGNATURES) {
3352
3483
  }
3353
3484
  return new MultiPatternMatcher(entries);
3354
3485
  }
3355
- var BOT_CATEGORIES, SEARCH, AI, SEO, SOCIAL, MONITORING, ARCHIVE, FEED, SECURITY, LIBRARY, HEADLESS, EMBEDDED, ADVERTISING, COMMERCE, ACADEMIC, ACCESSIBILITY, BOT_SIGNATURES;
3486
+ var BOT_CATEGORIES, SEARCH, AI, SEO, SOCIAL, MONITORING, ARCHIVE, FEED, SECURITY, LIBRARY, HEADLESS, EMBEDDED, ADVERTISING, COMMERCE, ACADEMIC, EMAIL_SECURITY, ACCESSIBILITY, BOT_SIGNATURES;
3356
3487
  var init_known_bots = __esm({
3357
3488
  "src/detectors/known-bots.ts"() {
3358
3489
  "use strict";
@@ -3373,6 +3504,7 @@ var init_known_bots = __esm({
3373
3504
  "commerce",
3374
3505
  "accessibility",
3375
3506
  "academic",
3507
+ "email-security",
3376
3508
  "other"
3377
3509
  ];
3378
3510
  SEARCH = [
@@ -3397,6 +3529,13 @@ var init_known_bots = __esm({
3397
3529
  { id: "yisouspider", name: "Shenma (Yisou) Spider", tokens: ["yisouspider"], category: "search", benign: true, robotsAgent: "YisouSpider", verification: { kind: "none" } },
3398
3530
  { id: "yahoo-slurp", name: "Yahoo! Slurp", tokens: ["yahoo! slurp"], category: "search", benign: true, robotsAgent: "Slurp", verification: { kind: "fcrdns", domains: ["crawl.yahoo.net", "yahoo.com"] } },
3399
3531
  { id: "mail-ru", name: "Mail.Ru bot", tokens: ["mail.ru_bot"], category: "search", benign: true, verification: { kind: "none" } },
3532
+ { id: "brave-search", name: "Brave Search", tokens: ["bravesearchbot"], category: "search", benign: true, robotsAgent: "BraveSearchBot", verification: { kind: "none" } },
3533
+ { id: "ecosia", name: "Ecosia", tokens: ["ecosiabot"], category: "search", benign: true, verification: { kind: "none" } },
3534
+ { id: "startpage", name: "Startpage", tokens: ["startpagebot"], category: "search", benign: true, verification: { kind: "none" } },
3535
+ { id: "daum", name: "Daumoa", tokens: ["daumoa"], category: "search", benign: true, verification: { kind: "none" } },
3536
+ { id: "stract", name: "Stract", tokens: ["stractbot"], category: "search", benign: true, verification: { kind: "none" } },
3537
+ { id: "rightdao", name: "RightDao", tokens: ["rightdaobot"], category: "search", benign: true, verification: { kind: "none" } },
3538
+ { id: "gigablast", name: "Gigablast", tokens: ["gigablastopensource"], category: "search", benign: true, verification: { kind: "none" } },
3400
3539
  { id: "exabot", name: "Exabot (Exalead)", tokens: ["exabot"], category: "search", benign: true, verification: { kind: "none" } },
3401
3540
  { id: "kagibot", name: "Kagi", tokens: ["kagibot"], category: "search", benign: true, robotsAgent: "KagiBot", verification: { kind: "none" } }
3402
3541
  ];
@@ -3467,11 +3606,15 @@ var init_known_bots = __esm({
3467
3606
  { id: "pinterestbot", name: "Pinterestbot", tokens: ["pinterest/", "pinterestbot"], category: "social", benign: true, verification: { kind: "fcrdns", domains: ["pinterest.com"] } },
3468
3607
  { id: "mastodon", name: "Mastodon / Fediverse", tokens: ["mastodon/", "pleroma", "misskey/", "akkoma"], category: "social", benign: true, verification: { kind: "none" } },
3469
3608
  { id: "embedly", name: "Embedly", tokens: ["embedly"], category: "social", benign: true, verification: { kind: "none" } },
3470
- { id: "bluesky", name: "Bluesky card fetcher", tokens: ["bluesky cardyb", "cardyb/"], category: "social", benign: true, verification: { kind: "none" } },
3609
+ { id: "bluesky", name: "Bluesky card fetcher", tokens: ["bluesky cardyb", "cardyb/", "blueskybot"], category: "social", benign: true, verification: { kind: "none" } },
3471
3610
  { id: "iframely", name: "Iframely", tokens: ["iframely"], category: "social", benign: true, verification: { kind: "none" } },
3472
3611
  { id: "skype-preview", name: "Skype URI preview", tokens: ["skypeuripreview"], category: "social", benign: true, verification: { kind: "none" } },
3473
3612
  { id: "vk-share", name: "VK / Odnoklassniki preview", tokens: ["vkshare", "odklbot"], category: "social", benign: true, verification: { kind: "none" } },
3474
3613
  { id: "discourse-onebox", name: "Discourse Onebox", tokens: ["discourse forum onebox"], category: "social", benign: true, verification: { kind: "none" } },
3614
+ { id: "microsoft-preview", name: "Microsoft Teams preview", tokens: ["microsoftpreview"], category: "social", benign: true, verification: { kind: "none" } },
3615
+ { id: "zoom-preview", name: "Zoom link preview", tokens: ["zoombot"], category: "social", benign: true, verification: { kind: "none" } },
3616
+ { id: "signal-preview", name: "Signal link preview", tokens: ["signalbot"], category: "social", benign: true, verification: { kind: "none" } },
3617
+ { id: "matrix-synapse", name: "Matrix (Synapse) preview", tokens: ["synapse/"], category: "social", benign: true, verification: { kind: "none" } },
3475
3618
  { id: "yahoo-preview", name: "Yahoo Link Preview", tokens: ["yahoo link preview"], category: "social", benign: true, verification: { kind: "none" } }
3476
3619
  ];
3477
3620
  MONITORING = [
@@ -3511,6 +3654,10 @@ var init_known_bots = __esm({
3511
3654
  { id: "smartnews", name: "SmartNews", tokens: ["smartnewsbot"], category: "feed", benign: true, verification: { kind: "none" } },
3512
3655
  { id: "flipboard", name: "Flipboard", tokens: ["flipboardproxy"], category: "feed", benign: true, verification: { kind: "none" } },
3513
3656
  { id: "podcast-index", name: "Podcast Index", tokens: ["podcastindexbot"], category: "feed", benign: true, verification: { kind: "none" } },
3657
+ // Spotify's podcast fetcher sends `Spotify/1.0` — and so does the Spotify desktop app,
3658
+ // with a person driving it. There is no token that separates them, so this one is left
3659
+ // unnamed rather than named wrongly: the corpus proved the point immediately by blocking
3660
+ // a human under `protect-auth`, `indexers-only` and `under-attack` at once.
3514
3661
  { id: "freshrss", name: "FreshRSS", tokens: ["freshrss"], category: "feed", benign: true, verification: { kind: "none" } },
3515
3662
  { id: "netnewswire", name: "NetNewsWire", tokens: ["netnewswire"], category: "feed", benign: true, verification: { kind: "none" } },
3516
3663
  { id: "overcast", name: "Overcast", tokens: ["overcast/"], category: "feed", benign: true, verification: { kind: "none" } },
@@ -3637,7 +3784,23 @@ var init_known_bots = __esm({
3637
3784
  ];
3638
3785
  ADVERTISING = [
3639
3786
  { id: "adsbot-google", name: "AdsBot-Google", tokens: ["adsbot-google", "mediapartners-google", "adsbot"], category: "advertising", benign: true, verification: { kind: "fcrdns", domains: ["googlebot.com", "google.com"] } },
3640
- { id: "criteo", name: "Criteo", tokens: ["criteobot"], category: "advertising", benign: true, verification: { kind: "none" } }
3787
+ { id: "criteo", name: "Criteo", tokens: ["criteobot"], category: "advertising", benign: true, verification: { kind: "none" } },
3788
+ // Verification and contextual classification: they read a page to decide whether an ad
3789
+ // may appear beside it, or what the page is about. A publisher usually wants these and a
3790
+ // site with no advertising has no reason to.
3791
+ { id: "doubleverify", name: "DoubleVerify", tokens: ["doubleverifybot"], category: "advertising", benign: true, verification: { kind: "none" } },
3792
+ { id: "ias", name: "Integral Ad Science", tokens: ["ias crawler"], category: "advertising", benign: true, verification: { kind: "none" } },
3793
+ { id: "moat", name: "Moat", tokens: ["moatbot"], category: "advertising", benign: true, verification: { kind: "none" } },
3794
+ { id: "comscore", name: "comScore (Proximic)", tokens: ["proximic"], category: "advertising", benign: true, verification: { kind: "none" } },
3795
+ { id: "grapeshot", name: "Grapeshot", tokens: ["grapeshotcrawler"], category: "advertising", benign: true, verification: { kind: "none" } },
3796
+ { id: "peer39", name: "Peer39", tokens: ["peer39bot"], category: "advertising", benign: true, verification: { kind: "none" } },
3797
+ { id: "taboola", name: "Taboola", tokens: ["taboolabot"], category: "advertising", benign: true, verification: { kind: "none" } },
3798
+ { id: "outbrain", name: "Outbrain", tokens: ["outbrainbot"], category: "advertising", benign: true, verification: { kind: "none" } },
3799
+ { id: "pubmatic", name: "PubMatic", tokens: ["pubmaticbot"], category: "advertising", benign: true, verification: { kind: "none" } },
3800
+ { id: "thetradedesk", name: "The Trade Desk", tokens: ["ttd-content"], category: "advertising", benign: true, verification: { kind: "none" } },
3801
+ // Competitive ad intelligence rather than verification: it collects what everyone else
3802
+ // is running. Named, and left for the operator to decide about.
3803
+ { id: "adbeat", name: "Adbeat", tokens: ["adbeat_bot"], category: "advertising", benign: false, verification: { kind: "none" } }
3641
3804
  ];
3642
3805
  COMMERCE = [
3643
3806
  { id: "idealo", name: "idealo", tokens: ["idealo-bot"], category: "commerce", benign: false, verification: { kind: "none" } },
@@ -3653,6 +3816,12 @@ var init_known_bots = __esm({
3653
3816
  { id: "openalex", name: "OpenAlex", tokens: ["openalexbot"], category: "academic", benign: true, verification: { kind: "none" } },
3654
3817
  { id: "webis", name: "Webis research crawler", tokens: ["webisbot"], category: "academic", benign: true, verification: { kind: "none" } }
3655
3818
  ];
3819
+ EMAIL_SECURITY = [
3820
+ { id: "proofpoint", name: "Proofpoint URL Defense", tokens: ["proofpointurldefensebot"], category: "email-security", benign: true, verification: { kind: "none" } },
3821
+ { id: "mimecast", name: "Mimecast URL Protect", tokens: ["mimecasturlprotectbot"], category: "email-security", benign: true, verification: { kind: "none" } },
3822
+ { id: "barracuda", name: "Barracuda Link Protect", tokens: ["barracudalinkprotectbot"], category: "email-security", benign: true, verification: { kind: "none" } },
3823
+ { id: "cisco-esa", name: "Cisco Secure Email", tokens: ["ciscosecureemailbot"], category: "email-security", benign: true, verification: { kind: "none" } }
3824
+ ];
3656
3825
  ACCESSIBILITY = [
3657
3826
  { id: "siteimprove", name: "Siteimprove", tokens: ["siteimprovebot"], category: "accessibility", benign: true, robotsAgent: "SiteimproveBot", verification: { kind: "none" } }
3658
3827
  ];
@@ -3671,7 +3840,8 @@ var init_known_bots = __esm({
3671
3840
  ...EMBEDDED,
3672
3841
  ...COMMERCE,
3673
3842
  ...ACADEMIC,
3674
- ...ACCESSIBILITY
3843
+ ...ACCESSIBILITY,
3844
+ ...EMAIL_SECURITY
3675
3845
  ]);
3676
3846
  }
3677
3847
  });
@@ -4423,6 +4593,166 @@ var init_crawl_breadth = __esm({
4423
4593
  }
4424
4594
  });
4425
4595
 
4596
+ // src/detectors/parameter-sweep.ts
4597
+ function parameterSweepDetector(options = {}) {
4598
+ const threshold = options.threshold ?? 25;
4599
+ const variantsPerPath = options.variantsPerPath ?? 8;
4600
+ const minRequests = options.minRequests ?? 20;
4601
+ if (threshold > MAX_TRACKED_QUERIES) {
4602
+ throw new RangeError(
4603
+ `parameterSweepDetector threshold ${threshold} can never be reached: an actor's distinct-query count saturates at ${MAX_TRACKED_QUERIES}. Use ${MAX_TRACKED_QUERIES} or fewer.`
4604
+ );
4605
+ }
4606
+ return {
4607
+ id: "parameter-sweep",
4608
+ description: "Counts distinct query strings against the paths they sit on, to catch enumeration that leaves the path unchanged",
4609
+ cost: "cheap",
4610
+ stage: "always",
4611
+ inspect(ctx) {
4612
+ const { distinctPaths, distinctQueries, queriesSaturated, total } = ctx.state;
4613
+ if (total < minRequests || distinctQueries < threshold) return void 0;
4614
+ const spread = distinctQueries / Math.max(1, distinctPaths);
4615
+ if (spread < variantsPerPath) return void 0;
4616
+ return {
4617
+ detector: "parameter-sweep",
4618
+ summary: queriesSaturated ? `at least ${distinctQueries} distinct query strings across only ${distinctPaths} path(s)` : `${distinctQueries} distinct query strings across only ${distinctPaths} path(s) in ${total} requests`,
4619
+ direction: "bot",
4620
+ certainty: "weak",
4621
+ // Saturation means the count stopped being able to grow, so the real spread is
4622
+ // wider than the one reported — the same argument breadth makes for itself.
4623
+ weight: queriesSaturated ? 0.25 : 0.15,
4624
+ botClass: "scraper",
4625
+ metadata: {
4626
+ distinctQueries,
4627
+ distinctPaths,
4628
+ variantsPerPath: Number(spread.toFixed(1)),
4629
+ totalRequests: total,
4630
+ saturated: queriesSaturated
4631
+ }
4632
+ };
4633
+ }
4634
+ };
4635
+ }
4636
+ var init_parameter_sweep = __esm({
4637
+ "src/detectors/parameter-sweep.ts"() {
4638
+ "use strict";
4639
+ init_state();
4640
+ }
4641
+ });
4642
+
4643
+ // src/detectors/transport-coherence.ts
4644
+ function transportCoherenceDetector(options = {}) {
4645
+ const checkLegacyHttp = options.legacyHttp ?? true;
4646
+ const minHeadRequests = options.minHeadRequests ?? 8;
4647
+ return {
4648
+ id: "transport-coherence",
4649
+ description: "Reads the HTTP version and the methods across a visit against the client the request claims to be",
4650
+ cost: "cheap",
4651
+ stage: "always",
4652
+ inspect(ctx) {
4653
+ if (!claimsBrowser(ctx.ua)) return void 0;
4654
+ const results = [];
4655
+ const version = ctx.facts.httpVersion;
4656
+ if (checkLegacyHttp && version !== void 0 && LEGACY_VERSIONS.has(version)) {
4657
+ results.push({
4658
+ detector: "transport-coherence",
4659
+ summary: `Client claims to be a browser but negotiated HTTP/${version}, which no shipping browser has offered in over a decade`,
4660
+ direction: "bot",
4661
+ certainty: "moderate",
4662
+ botClass: "impersonator",
4663
+ // One downgrading proxy in front of the application does this to every request
4664
+ // that passes through it, so this must count once rather than once per reason.
4665
+ family: "legacy-transport",
4666
+ metadata: { httpVersion: version, browser: ctx.ua.browser }
4667
+ });
4668
+ }
4669
+ const methods = ctx.state.methodsSeen;
4670
+ if (ctx.state.total >= minHeadRequests && methods.length > 0 && methods.every((method) => method === "HEAD")) {
4671
+ results.push({
4672
+ detector: "transport-coherence",
4673
+ summary: `Client claims to be a browser but has issued nothing but HEAD across ${ctx.state.total} requests`,
4674
+ direction: "bot",
4675
+ certainty: "moderate",
4676
+ botClass: "scraper",
4677
+ metadata: { requests: ctx.state.total, browser: ctx.ua.browser }
4678
+ });
4679
+ }
4680
+ return results.length > 0 ? results : void 0;
4681
+ }
4682
+ };
4683
+ }
4684
+ var LEGACY_VERSIONS;
4685
+ var init_transport_coherence = __esm({
4686
+ "src/detectors/transport-coherence.ts"() {
4687
+ "use strict";
4688
+ init_ua();
4689
+ LEGACY_VERSIONS = /* @__PURE__ */ new Set(["0.9", "1.0"]);
4690
+ }
4691
+ });
4692
+
4693
+ // src/detectors/probe-volume.ts
4694
+ function probeVolumeDetector(options = {}) {
4695
+ const minResponses = options.minResponses ?? 20;
4696
+ const missRatio = options.missRatio ?? 0.8;
4697
+ return {
4698
+ id: "probe-volume",
4699
+ description: "Reads the share of an actor's requests that were answered 404 or 410, where the application reports them",
4700
+ cost: "cheap",
4701
+ stage: "always",
4702
+ inspect(ctx) {
4703
+ const { responses, misses } = ctx.state;
4704
+ if (responses < minResponses) return void 0;
4705
+ const ratio = misses / responses;
4706
+ if (ratio < missRatio) return void 0;
4707
+ return {
4708
+ detector: "probe-volume",
4709
+ summary: `${misses} of this client's last ${responses} requests were answered "not found" (${(ratio * 100).toFixed(0)}%)`,
4710
+ direction: "bot",
4711
+ certainty: "moderate",
4712
+ botClass: "scanner",
4713
+ metadata: { responses, misses, missRatio: Number(ratio.toFixed(3)) }
4714
+ };
4715
+ }
4716
+ };
4717
+ }
4718
+ var init_probe_volume = __esm({
4719
+ "src/detectors/probe-volume.ts"() {
4720
+ "use strict";
4721
+ }
4722
+ });
4723
+
4724
+ // src/detectors/id-enumeration.ts
4725
+ function idEnumerationDetector(options = {}) {
4726
+ const minRequests = options.minRequests ?? 30;
4727
+ const density = options.density ?? 0.9;
4728
+ return {
4729
+ id: "id-enumeration",
4730
+ description: "Reports an actor covering a contiguous range of numeric identifiers under one path shape",
4731
+ cost: "cheap",
4732
+ stage: "always",
4733
+ inspect(ctx) {
4734
+ const walk = ctx.state.densestWalk();
4735
+ if (walk === void 0 || walk.count < minRequests) return void 0;
4736
+ if (walk.span < minRequests) return void 0;
4737
+ const covered = Math.min(1, walk.count / walk.span);
4738
+ if (covered < density) return void 0;
4739
+ return {
4740
+ detector: "id-enumeration",
4741
+ summary: `${walk.count} requests to ${walk.template} covering ${(covered * 100).toFixed(0)}% of a ${walk.span}-wide range of ids`,
4742
+ direction: "bot",
4743
+ certainty: "moderate",
4744
+ botClass: "scraper",
4745
+ metadata: { template: walk.template, requests: walk.count, span: walk.span, coverage: Number(covered.toFixed(3)) }
4746
+ };
4747
+ }
4748
+ };
4749
+ }
4750
+ var init_id_enumeration = __esm({
4751
+ "src/detectors/id-enumeration.ts"() {
4752
+ "use strict";
4753
+ }
4754
+ });
4755
+
4426
4756
  // src/detectors/crawler-verification.ts
4427
4757
  function crawlerVerificationDetector(options = {}) {
4428
4758
  const missingPtrIsForgery = options.treatMissingPtrAsForgery ?? true;
@@ -5600,11 +5930,15 @@ function defaultDetectors(options = {}) {
5600
5930
  clientHintsDetector(),
5601
5931
  fetchMetadataDetector(),
5602
5932
  acceptSignatureDetector(),
5933
+ transportCoherenceDetector(),
5603
5934
  headerOrderDetector(),
5604
5935
  // Behaviour across requests.
5605
5936
  rateAnomalyDetector(),
5606
5937
  cadenceDetector(),
5607
5938
  crawlBreadthDetector(),
5939
+ parameterSweepDetector(),
5940
+ probeVolumeDetector(),
5941
+ idEnumerationDetector(),
5608
5942
  sessionIntegrityDetector(),
5609
5943
  // The other side of the argument: what a real browsing session looks like.
5610
5944
  browsingCoherenceDetector(),
@@ -5620,6 +5954,10 @@ var init_detectors = __esm({
5620
5954
  init_cadence();
5621
5955
  init_client_hints();
5622
5956
  init_crawl_breadth();
5957
+ init_parameter_sweep();
5958
+ init_transport_coherence();
5959
+ init_probe_volume();
5960
+ init_id_enumeration();
5623
5961
  init_crawler_verification();
5624
5962
  init_fetch_metadata();
5625
5963
  init_header_integrity();
@@ -6471,6 +6809,11 @@ function assessmentFromEntry(entry) {
6471
6809
  key: entry.actor,
6472
6810
  requests: entry.actorStats.requests,
6473
6811
  distinctPaths: entry.actorStats.distinctPaths,
6812
+ distinctQueries: 0,
6813
+ queriesSaturated: false,
6814
+ methodsSeen: ["GET"],
6815
+ responses: 0,
6816
+ misses: 0,
6474
6817
  firstSeen: entry.actorStats.firstSeen,
6475
6818
  lastSeen: entry.at,
6476
6819
  ...entry.actorStats.sinceLastMs !== void 0 ? { sinceLastMs: entry.actorStats.sinceLastMs } : {},
@@ -6739,7 +7082,10 @@ button[disabled] { opacity: .5; cursor: default; }
6739
7082
  .tab[aria-selected="true"] { color: var(--ink); border-bottom-color: var(--s1); }
6740
7083
 
6741
7084
  /* --- layout ------------------------------------------------------------- */
6742
- main { padding: 18px 20px 64px; max-width: 1680px; margin: 0 auto; }
7085
+ /* The top padding is the gap under the sticky header. At 18px the counter row sat almost
7086
+ against the header's border and read as part of it; the tiles carry their own border, so
7087
+ two lines were meeting with nothing between them. */
7088
+ main { padding: 28px 20px 64px; max-width: 1680px; margin: 0 auto; }
6743
7089
  .stack { display: grid; gap: 16px; }
6744
7090
  /* Everything above the feed is drawn by script once the first snapshot arrives, which
6745
7091
  inserts a block of content above what is already laid out. The browser's scroll
@@ -9147,6 +9493,24 @@ var init_core = __esm({
9147
9493
  this.warn(`Actor "${key}" was cleared as human at runtime${attribute(context)}, until ${new Date(until).toISOString()}.`);
9148
9494
  this.events.emit("actor-change", { key, action: "clear", until, by: context.by });
9149
9495
  }
9496
+ /**
9497
+ * Tells the engine what the application answered.
9498
+ *
9499
+ * The one thing detection cannot see for itself. Every verdict here is reached *before*
9500
+ * the response exists — that is what makes it useful, since it can shape the response —
9501
+ * and so the status is knowledge only the application holds. Handed back, it closes the
9502
+ * oldest gap in reading a scanner: an actor whose requests are almost all misses is
9503
+ * looking for something rather than reading anything, and no amount of header analysis
9504
+ * shows that.
9505
+ *
9506
+ * Optional, and silent when the actor has already been forgotten. Nothing about
9507
+ * detection depends on it being called; supplying it sharpens `probe-volume` and
9508
+ * nothing else. The bundled Node adapter wires it up for you.
9509
+ */
9510
+ recordOutcome(facts, status) {
9511
+ if (!Number.isFinite(status)) return;
9512
+ this.registry.peek(this.actorKeyFor(facts))?.recordOutcome(status);
9513
+ }
9150
9514
  /** Convenience for `updateRanges("crawler:<id>", …)`, matching a signature id. */
9151
9515
  updateCrawlerRanges(signatureId, entries, context = {}) {
9152
9516
  this.updateRanges(`crawler:${signatureId}`, entries, context);
@@ -9536,6 +9900,11 @@ var init_core = __esm({
9536
9900
  key: actorKey,
9537
9901
  requests: 0,
9538
9902
  distinctPaths: 0,
9903
+ distinctQueries: 0,
9904
+ queriesSaturated: false,
9905
+ methodsSeen: ["GET"],
9906
+ responses: 0,
9907
+ misses: 0,
9539
9908
  firstSeen: facts.timestamp,
9540
9909
  lastSeen: facts.timestamp,
9541
9910
  priorConfirmations: 0,
@@ -10346,6 +10715,89 @@ var init_adversarial = __esm({
10346
10715
  init_ranges();
10347
10716
  CHROME_UA = userAgentOf("chromeWindows");
10348
10717
  ADVERSARIAL_CASES = [
10718
+ bot({
10719
+ id: "id-harvest-contiguous",
10720
+ title: "Every profile id in order, with a copied browser header set",
10721
+ audience: "hostile",
10722
+ category: "scraping",
10723
+ 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.",
10724
+ requests: repeat({ ...browser("chromeWindows"), ip: "198.51.100.65" }, 40, 800, (index) => `/user/${index + 1}`),
10725
+ expect: {
10726
+ // One `moderate` signal against a flawless header set, like the others here. What
10727
+ // changed is that the walk is now *visible* — before this detector it was scored
10728
+ // identically to a hundred and twenty scattered ids and to ordinary article paths.
10729
+ verdict: "unknown",
10730
+ detectors: ["id-enumeration"]
10731
+ },
10732
+ 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."
10733
+ }),
10734
+ bot({
10735
+ id: "wordlist-scan-mostly-misses",
10736
+ title: "A wordlist walked with a copied browser header set, almost all of it missing",
10737
+ audience: "hostile",
10738
+ category: "scanning",
10739
+ 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.",
10740
+ requests: repeat({ ...browser("chromeWindows"), ip: "198.51.100.64", status: 404 }, 30, 700, (index) => `/${["admin", "backup", "old", "test", "config", "db"][index % 6]}-${index}`),
10741
+ expect: {
10742
+ // One `moderate` signal against an otherwise flawless header set does not cross the
10743
+ // line, and it should not: a site that has just moved its URLs produces the same
10744
+ // shape from ordinary readers. Raising the ceiling so this case reads better would
10745
+ // be tuning the detector to the test rather than to the traffic.
10746
+ verdict: "unknown",
10747
+ detectors: ["probe-volume"]
10748
+ },
10749
+ 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."
10750
+ }),
10751
+ bot({
10752
+ id: "browser-claim-over-http-1-0",
10753
+ title: "A perfect Chrome header set, arriving over HTTP/1.0",
10754
+ audience: "hostile",
10755
+ category: "impersonation",
10756
+ 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.",
10757
+ requests: repeat({ ...browser("chromeWindows"), ip: "198.51.100.62", httpVersion: "1.0" }, 30, 900, (index) => `/${["news", "about", "blog", "help", "terms"][index % 5]}`),
10758
+ expect: {
10759
+ // Contributes rather than concludes. On its own, against an otherwise flawless
10760
+ // header set, one `moderate` signal does not reach the threshold — and it should
10761
+ // not, because an intermediary can cause this. Beside anything sharper it does.
10762
+ verdict: "unknown",
10763
+ detectors: ["transport-coherence"]
10764
+ },
10765
+ 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."
10766
+ }),
10767
+ bot({
10768
+ id: "head-only-visit",
10769
+ title: "A visit made entirely of HEAD, claiming a browser",
10770
+ audience: "unwanted-bot",
10771
+ category: "scraping",
10772
+ 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.",
10773
+ requests: repeat({ ...browser("chromeWindows"), ip: "198.51.100.63", method: "HEAD" }, 30, 900, (index) => `/${["news", "about", "blog", "help", "terms"][index % 5]}`),
10774
+ expect: {
10775
+ // As above: a shape worth reporting, not worth concluding from alone.
10776
+ verdict: "unknown",
10777
+ detectors: ["transport-coherence"]
10778
+ },
10779
+ 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`."
10780
+ }),
10781
+ bot({
10782
+ id: "catalogue-sweep-by-page",
10783
+ title: "A catalogue taken a page at a time, with the path never changing",
10784
+ audience: "unwanted-bot",
10785
+ category: "scraping",
10786
+ 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.",
10787
+ requests: repeat({ ...browser("chromeWindows"), ip: "198.51.100.61" }, 40, 900, (index) => `/products?page=${index}`),
10788
+ expect: {
10789
+ // Not proven, and not even suspected at this pace. Said plainly because it is true:
10790
+ // headers this clean leave only behaviour, behaviour is weak by construction, and a
10791
+ // collector polite enough to space its requests stays under the line. What changed
10792
+ // is that it no longer scores *lower* than the identical crawl expressed as distinct
10793
+ // paths — measured at a faster pace before this detector existed, the two differed by
10794
+ // seven points and only the path version crossed; they now score the same at every
10795
+ // volume tried.
10796
+ verdict: "unknown",
10797
+ detectors: ["parameter-sweep"]
10798
+ },
10799
+ 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."
10800
+ }),
10349
10801
  // ---------------------------------------------------------------------------
10350
10802
  // Forged identities. The narrow case where a lie is provable.
10351
10803
  // ---------------------------------------------------------------------------
@@ -13288,6 +13740,7 @@ async function runCase(handler, clock, item, startedAt, provides, assertActions
13288
13740
  const withClearance = clearanceCookie === void 0 ? request : { ...request, headers: [...request.headers, ["Cookie", clearanceCookie]] };
13289
13741
  const facts = toFacts(withClearance, fallbackIp, clock.now());
13290
13742
  const { assessment, decision, outcome } = await handler.handle(facts);
13743
+ if (request.status !== void 0) handler.recordOutcome(facts, request.status);
13291
13744
  requests.push({ assessment, decision, outcome });
13292
13745
  }
13293
13746
  const final = requests[requests.length - 1];