@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.cjs CHANGED
@@ -145,16 +145,20 @@ function hashString(value) {
145
145
  }
146
146
  return hash >>> 0;
147
147
  }
148
- var TIMESTAMP_RING, PATH_CAP, UA_CAP, MAX_TRACKED_ARRIVALS, MAX_TRACKED_PATHS, ActorState, ActorRegistry;
148
+ var TIMESTAMP_RING, PATH_CAP, QUERY_CAP, METHOD_CAP, WALK_CAP, UA_CAP, MAX_TRACKED_ARRIVALS, MAX_TRACKED_PATHS, MAX_TRACKED_QUERIES, ActorState, ActorRegistry;
149
149
  var init_state = __esm({
150
150
  "src/state.ts"() {
151
151
  "use strict";
152
152
  init_lru();
153
153
  TIMESTAMP_RING = 32;
154
154
  PATH_CAP = 64;
155
+ QUERY_CAP = 64;
156
+ METHOD_CAP = 12;
157
+ WALK_CAP = 4;
155
158
  UA_CAP = 4;
156
159
  MAX_TRACKED_ARRIVALS = TIMESTAMP_RING;
157
160
  MAX_TRACKED_PATHS = PATH_CAP;
161
+ MAX_TRACKED_QUERIES = QUERY_CAP;
158
162
  ActorState = class {
159
163
  key;
160
164
  firstSeen;
@@ -195,6 +199,45 @@ var init_state = __esm({
195
199
  paths = /* @__PURE__ */ new Set();
196
200
  pathsOverflowed = false;
197
201
  pathsSaturatedAtTotal = 0;
202
+ /**
203
+ * Distinct *parameterised* requests: the path together with its query.
204
+ *
205
+ * Counted apart from `paths` because the two answer different questions and a scraper
206
+ * lives in the gap between them. `/products?page=1` through `?page=200` is one path and
207
+ * two hundred requests, so breadth reads it as somebody rereading a single page — which
208
+ * is exactly what enumerating a catalogue looks like from the path alone.
209
+ */
210
+ queries = /* @__PURE__ */ new Set();
211
+ queriesOverflowed = false;
212
+ /**
213
+ * Which HTTP methods this actor has used.
214
+ *
215
+ * A browser navigating issues GET. Something that has issued nothing but HEAD across a
216
+ * long visit is checking what exists rather than reading it, and that is a fact about
217
+ * the actor rather than about any one of its requests — which is why it is kept here.
218
+ */
219
+ methods = /* @__PURE__ */ new Set();
220
+ /**
221
+ * Numeric walks in progress, by path shape: `/user/#` against the ids requested under it.
222
+ *
223
+ * Three numbers per shape, deliberately — a count, a lowest and a highest — rather than
224
+ * the ids themselves. What separates enumeration from reading is not which ids were
225
+ * asked for but whether they *cover a range*: thirty requests spanning thirty
226
+ * consecutive ids is a walk, and thirty scattered across a hundred thousand is somebody
227
+ * following links. Both are answerable from a count and a span, and only the count and
228
+ * the span survive an actor asking for ten thousand of them.
229
+ */
230
+ walks = /* @__PURE__ */ new Map();
231
+ /**
232
+ * What the application answered, for the requests anybody bothered to tell us about.
233
+ *
234
+ * The engine decides *before* the response exists, so this arrives afterwards and only
235
+ * when the adapter reports it. Kept as two counters rather than a list because the one
236
+ * question worth asking is a ratio: an actor whose requests are almost all misses is
237
+ * looking for something rather than reading anything.
238
+ */
239
+ responsesSeen = 0;
240
+ missesSeen = 0;
198
241
  userAgents = /* @__PURE__ */ new Set();
199
242
  constructor(key, now) {
200
243
  this.key = key;
@@ -214,6 +257,15 @@ var init_state = __esm({
214
257
  this.pathsOverflowed = true;
215
258
  this.pathsSaturatedAtTotal = this.total;
216
259
  }
260
+ const keys = Object.keys(facts.query).sort();
261
+ if (keys.length > 0) {
262
+ const signature = `${facts.path}?${keys.map((key) => `${key}=${facts.query[key] ?? ""}`).join("&")}`;
263
+ const queryHash = hashString(signature);
264
+ if (this.queries.size < QUERY_CAP) this.queries.add(queryHash);
265
+ else if (!this.queries.has(queryHash)) this.queriesOverflowed = true;
266
+ }
267
+ if (this.methods.size < METHOD_CAP) this.methods.add(facts.method);
268
+ this.noteWalk(facts.path);
217
269
  const ua = facts.headers["user-agent"];
218
270
  if (ua !== void 0 && this.userAgents.size < UA_CAP) this.userAgents.add(ua);
219
271
  }
@@ -224,6 +276,79 @@ var init_state = __esm({
224
276
  get pathsSaturated() {
225
277
  return this.pathsOverflowed;
226
278
  }
279
+ /** Distinct path-and-query combinations seen. Saturates at {@link QUERY_CAP}. */
280
+ get distinctQueries() {
281
+ return this.queries.size;
282
+ }
283
+ get queriesSaturated() {
284
+ return this.queriesOverflowed;
285
+ }
286
+ /**
287
+ * Records what the application answered. Called after the response, if at all.
288
+ *
289
+ * 404 and 410 only. A 403 is usually this library's own doing and counting it would
290
+ * make the detector that reads this argue with itself; a 500 is the site's problem and
291
+ * says nothing about the client.
292
+ */
293
+ recordOutcome(status) {
294
+ this.responsesSeen++;
295
+ if (status === 404 || status === 410) this.missesSeen++;
296
+ }
297
+ /** Responses reported for this actor. Zero unless something is reporting them. */
298
+ get responses() {
299
+ return this.responsesSeen;
300
+ }
301
+ /** Of those, how many were 404 or 410. */
302
+ get misses() {
303
+ return this.missesSeen;
304
+ }
305
+ /**
306
+ * Files a request under the shape of its path, if that path carries a number.
307
+ *
308
+ * The last numeric segment is the one taken to be the identifier: in `/api/v2/orders/42`
309
+ * the version is part of the shape and the order id is what is being walked.
310
+ */
311
+ noteWalk(path) {
312
+ const segments = path.split("/");
313
+ let value;
314
+ let template = "";
315
+ for (const segment of segments) {
316
+ if (segment !== "" && /^\d+$/.test(segment)) {
317
+ const parsed = Number(segment);
318
+ if (Number.isSafeInteger(parsed) && parsed <= 1e7) value = parsed;
319
+ template += "/#";
320
+ } else if (segment !== "") {
321
+ template += `/${segment}`;
322
+ }
323
+ }
324
+ if (value === void 0) return;
325
+ const existing = this.walks.get(template);
326
+ if (existing !== void 0) {
327
+ existing.count++;
328
+ if (value < existing.min) existing.min = value;
329
+ if (value > existing.max) existing.max = value;
330
+ return;
331
+ }
332
+ if (this.walks.size < WALK_CAP) this.walks.set(template, { count: 1, min: value, max: value });
333
+ }
334
+ /**
335
+ * The path shape this actor has walked hardest, with how far it reached.
336
+ *
337
+ * `span` is inclusive of both ends, so a walk of 1 to 30 spans 30. Comparing the count
338
+ * against it is what separates covering a range from visiting a few points in one.
339
+ */
340
+ densestWalk() {
341
+ let best;
342
+ for (const [template, walk] of this.walks) {
343
+ const span = walk.max - walk.min + 1;
344
+ if (best === void 0 || walk.count > best.count) best = { template, count: walk.count, span };
345
+ }
346
+ return best;
347
+ }
348
+ /** Every HTTP method this actor has used, in first-seen order. */
349
+ get methodsSeen() {
350
+ return [...this.methods];
351
+ }
227
352
  /**
228
353
  * Requests seen when {@link distinctPaths} stopped being able to grow, or 0 if it
229
354
  * still can. Over that many requests the distinct count is exact, so it is the only
@@ -312,6 +437,12 @@ var init_state = __esm({
312
437
  key: this.key,
313
438
  requests: this.total,
314
439
  distinctPaths: this.distinctPaths,
440
+ distinctQueries: this.distinctQueries,
441
+ methodsSeen: this.methodsSeen,
442
+ walk: this.densestWalk(),
443
+ responses: this.responses,
444
+ misses: this.misses,
445
+ queriesSaturated: this.queriesSaturated,
315
446
  firstSeen: this.firstSeen,
316
447
  lastSeen: this.lastSeen,
317
448
  sinceLastMs: this.sinceLast(now),
@@ -3374,7 +3505,7 @@ function compileSignatures(signatures = BOT_SIGNATURES) {
3374
3505
  }
3375
3506
  return new MultiPatternMatcher(entries);
3376
3507
  }
3377
- var BOT_CATEGORIES, SEARCH, AI, SEO, SOCIAL, MONITORING, ARCHIVE, FEED, SECURITY, LIBRARY, HEADLESS, EMBEDDED, ADVERTISING, COMMERCE, ACADEMIC, ACCESSIBILITY, BOT_SIGNATURES;
3508
+ var BOT_CATEGORIES, SEARCH, AI, SEO, SOCIAL, MONITORING, ARCHIVE, FEED, SECURITY, LIBRARY, HEADLESS, EMBEDDED, ADVERTISING, COMMERCE, ACADEMIC, EMAIL_SECURITY, ACCESSIBILITY, BOT_SIGNATURES;
3378
3509
  var init_known_bots = __esm({
3379
3510
  "src/detectors/known-bots.ts"() {
3380
3511
  "use strict";
@@ -3395,6 +3526,7 @@ var init_known_bots = __esm({
3395
3526
  "commerce",
3396
3527
  "accessibility",
3397
3528
  "academic",
3529
+ "email-security",
3398
3530
  "other"
3399
3531
  ];
3400
3532
  SEARCH = [
@@ -3419,6 +3551,13 @@ var init_known_bots = __esm({
3419
3551
  { id: "yisouspider", name: "Shenma (Yisou) Spider", tokens: ["yisouspider"], category: "search", benign: true, robotsAgent: "YisouSpider", verification: { kind: "none" } },
3420
3552
  { id: "yahoo-slurp", name: "Yahoo! Slurp", tokens: ["yahoo! slurp"], category: "search", benign: true, robotsAgent: "Slurp", verification: { kind: "fcrdns", domains: ["crawl.yahoo.net", "yahoo.com"] } },
3421
3553
  { id: "mail-ru", name: "Mail.Ru bot", tokens: ["mail.ru_bot"], category: "search", benign: true, verification: { kind: "none" } },
3554
+ { id: "brave-search", name: "Brave Search", tokens: ["bravesearchbot"], category: "search", benign: true, robotsAgent: "BraveSearchBot", verification: { kind: "none" } },
3555
+ { id: "ecosia", name: "Ecosia", tokens: ["ecosiabot"], category: "search", benign: true, verification: { kind: "none" } },
3556
+ { id: "startpage", name: "Startpage", tokens: ["startpagebot"], category: "search", benign: true, verification: { kind: "none" } },
3557
+ { id: "daum", name: "Daumoa", tokens: ["daumoa"], category: "search", benign: true, verification: { kind: "none" } },
3558
+ { id: "stract", name: "Stract", tokens: ["stractbot"], category: "search", benign: true, verification: { kind: "none" } },
3559
+ { id: "rightdao", name: "RightDao", tokens: ["rightdaobot"], category: "search", benign: true, verification: { kind: "none" } },
3560
+ { id: "gigablast", name: "Gigablast", tokens: ["gigablastopensource"], category: "search", benign: true, verification: { kind: "none" } },
3422
3561
  { id: "exabot", name: "Exabot (Exalead)", tokens: ["exabot"], category: "search", benign: true, verification: { kind: "none" } },
3423
3562
  { id: "kagibot", name: "Kagi", tokens: ["kagibot"], category: "search", benign: true, robotsAgent: "KagiBot", verification: { kind: "none" } }
3424
3563
  ];
@@ -3489,11 +3628,15 @@ var init_known_bots = __esm({
3489
3628
  { id: "pinterestbot", name: "Pinterestbot", tokens: ["pinterest/", "pinterestbot"], category: "social", benign: true, verification: { kind: "fcrdns", domains: ["pinterest.com"] } },
3490
3629
  { id: "mastodon", name: "Mastodon / Fediverse", tokens: ["mastodon/", "pleroma", "misskey/", "akkoma"], category: "social", benign: true, verification: { kind: "none" } },
3491
3630
  { id: "embedly", name: "Embedly", tokens: ["embedly"], category: "social", benign: true, verification: { kind: "none" } },
3492
- { id: "bluesky", name: "Bluesky card fetcher", tokens: ["bluesky cardyb", "cardyb/"], category: "social", benign: true, verification: { kind: "none" } },
3631
+ { id: "bluesky", name: "Bluesky card fetcher", tokens: ["bluesky cardyb", "cardyb/", "blueskybot"], category: "social", benign: true, verification: { kind: "none" } },
3493
3632
  { id: "iframely", name: "Iframely", tokens: ["iframely"], category: "social", benign: true, verification: { kind: "none" } },
3494
3633
  { id: "skype-preview", name: "Skype URI preview", tokens: ["skypeuripreview"], category: "social", benign: true, verification: { kind: "none" } },
3495
3634
  { id: "vk-share", name: "VK / Odnoklassniki preview", tokens: ["vkshare", "odklbot"], category: "social", benign: true, verification: { kind: "none" } },
3496
3635
  { id: "discourse-onebox", name: "Discourse Onebox", tokens: ["discourse forum onebox"], category: "social", benign: true, verification: { kind: "none" } },
3636
+ { id: "microsoft-preview", name: "Microsoft Teams preview", tokens: ["microsoftpreview"], category: "social", benign: true, verification: { kind: "none" } },
3637
+ { id: "zoom-preview", name: "Zoom link preview", tokens: ["zoombot"], category: "social", benign: true, verification: { kind: "none" } },
3638
+ { id: "signal-preview", name: "Signal link preview", tokens: ["signalbot"], category: "social", benign: true, verification: { kind: "none" } },
3639
+ { id: "matrix-synapse", name: "Matrix (Synapse) preview", tokens: ["synapse/"], category: "social", benign: true, verification: { kind: "none" } },
3497
3640
  { id: "yahoo-preview", name: "Yahoo Link Preview", tokens: ["yahoo link preview"], category: "social", benign: true, verification: { kind: "none" } }
3498
3641
  ];
3499
3642
  MONITORING = [
@@ -3533,6 +3676,10 @@ var init_known_bots = __esm({
3533
3676
  { id: "smartnews", name: "SmartNews", tokens: ["smartnewsbot"], category: "feed", benign: true, verification: { kind: "none" } },
3534
3677
  { id: "flipboard", name: "Flipboard", tokens: ["flipboardproxy"], category: "feed", benign: true, verification: { kind: "none" } },
3535
3678
  { id: "podcast-index", name: "Podcast Index", tokens: ["podcastindexbot"], category: "feed", benign: true, verification: { kind: "none" } },
3679
+ // Spotify's podcast fetcher sends `Spotify/1.0` — and so does the Spotify desktop app,
3680
+ // with a person driving it. There is no token that separates them, so this one is left
3681
+ // unnamed rather than named wrongly: the corpus proved the point immediately by blocking
3682
+ // a human under `protect-auth`, `indexers-only` and `under-attack` at once.
3536
3683
  { id: "freshrss", name: "FreshRSS", tokens: ["freshrss"], category: "feed", benign: true, verification: { kind: "none" } },
3537
3684
  { id: "netnewswire", name: "NetNewsWire", tokens: ["netnewswire"], category: "feed", benign: true, verification: { kind: "none" } },
3538
3685
  { id: "overcast", name: "Overcast", tokens: ["overcast/"], category: "feed", benign: true, verification: { kind: "none" } },
@@ -3659,7 +3806,23 @@ var init_known_bots = __esm({
3659
3806
  ];
3660
3807
  ADVERTISING = [
3661
3808
  { id: "adsbot-google", name: "AdsBot-Google", tokens: ["adsbot-google", "mediapartners-google", "adsbot"], category: "advertising", benign: true, verification: { kind: "fcrdns", domains: ["googlebot.com", "google.com"] } },
3662
- { id: "criteo", name: "Criteo", tokens: ["criteobot"], category: "advertising", benign: true, verification: { kind: "none" } }
3809
+ { id: "criteo", name: "Criteo", tokens: ["criteobot"], category: "advertising", benign: true, verification: { kind: "none" } },
3810
+ // Verification and contextual classification: they read a page to decide whether an ad
3811
+ // may appear beside it, or what the page is about. A publisher usually wants these and a
3812
+ // site with no advertising has no reason to.
3813
+ { id: "doubleverify", name: "DoubleVerify", tokens: ["doubleverifybot"], category: "advertising", benign: true, verification: { kind: "none" } },
3814
+ { id: "ias", name: "Integral Ad Science", tokens: ["ias crawler"], category: "advertising", benign: true, verification: { kind: "none" } },
3815
+ { id: "moat", name: "Moat", tokens: ["moatbot"], category: "advertising", benign: true, verification: { kind: "none" } },
3816
+ { id: "comscore", name: "comScore (Proximic)", tokens: ["proximic"], category: "advertising", benign: true, verification: { kind: "none" } },
3817
+ { id: "grapeshot", name: "Grapeshot", tokens: ["grapeshotcrawler"], category: "advertising", benign: true, verification: { kind: "none" } },
3818
+ { id: "peer39", name: "Peer39", tokens: ["peer39bot"], category: "advertising", benign: true, verification: { kind: "none" } },
3819
+ { id: "taboola", name: "Taboola", tokens: ["taboolabot"], category: "advertising", benign: true, verification: { kind: "none" } },
3820
+ { id: "outbrain", name: "Outbrain", tokens: ["outbrainbot"], category: "advertising", benign: true, verification: { kind: "none" } },
3821
+ { id: "pubmatic", name: "PubMatic", tokens: ["pubmaticbot"], category: "advertising", benign: true, verification: { kind: "none" } },
3822
+ { id: "thetradedesk", name: "The Trade Desk", tokens: ["ttd-content"], category: "advertising", benign: true, verification: { kind: "none" } },
3823
+ // Competitive ad intelligence rather than verification: it collects what everyone else
3824
+ // is running. Named, and left for the operator to decide about.
3825
+ { id: "adbeat", name: "Adbeat", tokens: ["adbeat_bot"], category: "advertising", benign: false, verification: { kind: "none" } }
3663
3826
  ];
3664
3827
  COMMERCE = [
3665
3828
  { id: "idealo", name: "idealo", tokens: ["idealo-bot"], category: "commerce", benign: false, verification: { kind: "none" } },
@@ -3675,6 +3838,12 @@ var init_known_bots = __esm({
3675
3838
  { id: "openalex", name: "OpenAlex", tokens: ["openalexbot"], category: "academic", benign: true, verification: { kind: "none" } },
3676
3839
  { id: "webis", name: "Webis research crawler", tokens: ["webisbot"], category: "academic", benign: true, verification: { kind: "none" } }
3677
3840
  ];
3841
+ EMAIL_SECURITY = [
3842
+ { id: "proofpoint", name: "Proofpoint URL Defense", tokens: ["proofpointurldefensebot"], category: "email-security", benign: true, verification: { kind: "none" } },
3843
+ { id: "mimecast", name: "Mimecast URL Protect", tokens: ["mimecasturlprotectbot"], category: "email-security", benign: true, verification: { kind: "none" } },
3844
+ { id: "barracuda", name: "Barracuda Link Protect", tokens: ["barracudalinkprotectbot"], category: "email-security", benign: true, verification: { kind: "none" } },
3845
+ { id: "cisco-esa", name: "Cisco Secure Email", tokens: ["ciscosecureemailbot"], category: "email-security", benign: true, verification: { kind: "none" } }
3846
+ ];
3678
3847
  ACCESSIBILITY = [
3679
3848
  { id: "siteimprove", name: "Siteimprove", tokens: ["siteimprovebot"], category: "accessibility", benign: true, robotsAgent: "SiteimproveBot", verification: { kind: "none" } }
3680
3849
  ];
@@ -3693,7 +3862,8 @@ var init_known_bots = __esm({
3693
3862
  ...EMBEDDED,
3694
3863
  ...COMMERCE,
3695
3864
  ...ACADEMIC,
3696
- ...ACCESSIBILITY
3865
+ ...ACCESSIBILITY,
3866
+ ...EMAIL_SECURITY
3697
3867
  ]);
3698
3868
  }
3699
3869
  });
@@ -4445,6 +4615,166 @@ var init_crawl_breadth = __esm({
4445
4615
  }
4446
4616
  });
4447
4617
 
4618
+ // src/detectors/parameter-sweep.ts
4619
+ function parameterSweepDetector(options = {}) {
4620
+ const threshold = options.threshold ?? 25;
4621
+ const variantsPerPath = options.variantsPerPath ?? 8;
4622
+ const minRequests = options.minRequests ?? 20;
4623
+ if (threshold > MAX_TRACKED_QUERIES) {
4624
+ throw new RangeError(
4625
+ `parameterSweepDetector threshold ${threshold} can never be reached: an actor's distinct-query count saturates at ${MAX_TRACKED_QUERIES}. Use ${MAX_TRACKED_QUERIES} or fewer.`
4626
+ );
4627
+ }
4628
+ return {
4629
+ id: "parameter-sweep",
4630
+ description: "Counts distinct query strings against the paths they sit on, to catch enumeration that leaves the path unchanged",
4631
+ cost: "cheap",
4632
+ stage: "always",
4633
+ inspect(ctx) {
4634
+ const { distinctPaths, distinctQueries, queriesSaturated, total } = ctx.state;
4635
+ if (total < minRequests || distinctQueries < threshold) return void 0;
4636
+ const spread = distinctQueries / Math.max(1, distinctPaths);
4637
+ if (spread < variantsPerPath) return void 0;
4638
+ return {
4639
+ detector: "parameter-sweep",
4640
+ 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`,
4641
+ direction: "bot",
4642
+ certainty: "weak",
4643
+ // Saturation means the count stopped being able to grow, so the real spread is
4644
+ // wider than the one reported — the same argument breadth makes for itself.
4645
+ weight: queriesSaturated ? 0.25 : 0.15,
4646
+ botClass: "scraper",
4647
+ metadata: {
4648
+ distinctQueries,
4649
+ distinctPaths,
4650
+ variantsPerPath: Number(spread.toFixed(1)),
4651
+ totalRequests: total,
4652
+ saturated: queriesSaturated
4653
+ }
4654
+ };
4655
+ }
4656
+ };
4657
+ }
4658
+ var init_parameter_sweep = __esm({
4659
+ "src/detectors/parameter-sweep.ts"() {
4660
+ "use strict";
4661
+ init_state();
4662
+ }
4663
+ });
4664
+
4665
+ // src/detectors/transport-coherence.ts
4666
+ function transportCoherenceDetector(options = {}) {
4667
+ const checkLegacyHttp = options.legacyHttp ?? true;
4668
+ const minHeadRequests = options.minHeadRequests ?? 8;
4669
+ return {
4670
+ id: "transport-coherence",
4671
+ description: "Reads the HTTP version and the methods across a visit against the client the request claims to be",
4672
+ cost: "cheap",
4673
+ stage: "always",
4674
+ inspect(ctx) {
4675
+ if (!claimsBrowser(ctx.ua)) return void 0;
4676
+ const results = [];
4677
+ const version = ctx.facts.httpVersion;
4678
+ if (checkLegacyHttp && version !== void 0 && LEGACY_VERSIONS.has(version)) {
4679
+ results.push({
4680
+ detector: "transport-coherence",
4681
+ summary: `Client claims to be a browser but negotiated HTTP/${version}, which no shipping browser has offered in over a decade`,
4682
+ direction: "bot",
4683
+ certainty: "moderate",
4684
+ botClass: "impersonator",
4685
+ // One downgrading proxy in front of the application does this to every request
4686
+ // that passes through it, so this must count once rather than once per reason.
4687
+ family: "legacy-transport",
4688
+ metadata: { httpVersion: version, browser: ctx.ua.browser }
4689
+ });
4690
+ }
4691
+ const methods = ctx.state.methodsSeen;
4692
+ if (ctx.state.total >= minHeadRequests && methods.length > 0 && methods.every((method) => method === "HEAD")) {
4693
+ results.push({
4694
+ detector: "transport-coherence",
4695
+ summary: `Client claims to be a browser but has issued nothing but HEAD across ${ctx.state.total} requests`,
4696
+ direction: "bot",
4697
+ certainty: "moderate",
4698
+ botClass: "scraper",
4699
+ metadata: { requests: ctx.state.total, browser: ctx.ua.browser }
4700
+ });
4701
+ }
4702
+ return results.length > 0 ? results : void 0;
4703
+ }
4704
+ };
4705
+ }
4706
+ var LEGACY_VERSIONS;
4707
+ var init_transport_coherence = __esm({
4708
+ "src/detectors/transport-coherence.ts"() {
4709
+ "use strict";
4710
+ init_ua();
4711
+ LEGACY_VERSIONS = /* @__PURE__ */ new Set(["0.9", "1.0"]);
4712
+ }
4713
+ });
4714
+
4715
+ // src/detectors/probe-volume.ts
4716
+ function probeVolumeDetector(options = {}) {
4717
+ const minResponses = options.minResponses ?? 20;
4718
+ const missRatio = options.missRatio ?? 0.8;
4719
+ return {
4720
+ id: "probe-volume",
4721
+ description: "Reads the share of an actor's requests that were answered 404 or 410, where the application reports them",
4722
+ cost: "cheap",
4723
+ stage: "always",
4724
+ inspect(ctx) {
4725
+ const { responses, misses } = ctx.state;
4726
+ if (responses < minResponses) return void 0;
4727
+ const ratio = misses / responses;
4728
+ if (ratio < missRatio) return void 0;
4729
+ return {
4730
+ detector: "probe-volume",
4731
+ summary: `${misses} of this client's last ${responses} requests were answered "not found" (${(ratio * 100).toFixed(0)}%)`,
4732
+ direction: "bot",
4733
+ certainty: "moderate",
4734
+ botClass: "scanner",
4735
+ metadata: { responses, misses, missRatio: Number(ratio.toFixed(3)) }
4736
+ };
4737
+ }
4738
+ };
4739
+ }
4740
+ var init_probe_volume = __esm({
4741
+ "src/detectors/probe-volume.ts"() {
4742
+ "use strict";
4743
+ }
4744
+ });
4745
+
4746
+ // src/detectors/id-enumeration.ts
4747
+ function idEnumerationDetector(options = {}) {
4748
+ const minRequests = options.minRequests ?? 30;
4749
+ const density = options.density ?? 0.9;
4750
+ return {
4751
+ id: "id-enumeration",
4752
+ description: "Reports an actor covering a contiguous range of numeric identifiers under one path shape",
4753
+ cost: "cheap",
4754
+ stage: "always",
4755
+ inspect(ctx) {
4756
+ const walk = ctx.state.densestWalk();
4757
+ if (walk === void 0 || walk.count < minRequests) return void 0;
4758
+ if (walk.span < minRequests) return void 0;
4759
+ const covered = Math.min(1, walk.count / walk.span);
4760
+ if (covered < density) return void 0;
4761
+ return {
4762
+ detector: "id-enumeration",
4763
+ summary: `${walk.count} requests to ${walk.template} covering ${(covered * 100).toFixed(0)}% of a ${walk.span}-wide range of ids`,
4764
+ direction: "bot",
4765
+ certainty: "moderate",
4766
+ botClass: "scraper",
4767
+ metadata: { template: walk.template, requests: walk.count, span: walk.span, coverage: Number(covered.toFixed(3)) }
4768
+ };
4769
+ }
4770
+ };
4771
+ }
4772
+ var init_id_enumeration = __esm({
4773
+ "src/detectors/id-enumeration.ts"() {
4774
+ "use strict";
4775
+ }
4776
+ });
4777
+
4448
4778
  // src/detectors/crawler-verification.ts
4449
4779
  function crawlerVerificationDetector(options = {}) {
4450
4780
  const missingPtrIsForgery = options.treatMissingPtrAsForgery ?? true;
@@ -5622,11 +5952,15 @@ function defaultDetectors(options = {}) {
5622
5952
  clientHintsDetector(),
5623
5953
  fetchMetadataDetector(),
5624
5954
  acceptSignatureDetector(),
5955
+ transportCoherenceDetector(),
5625
5956
  headerOrderDetector(),
5626
5957
  // Behaviour across requests.
5627
5958
  rateAnomalyDetector(),
5628
5959
  cadenceDetector(),
5629
5960
  crawlBreadthDetector(),
5961
+ parameterSweepDetector(),
5962
+ probeVolumeDetector(),
5963
+ idEnumerationDetector(),
5630
5964
  sessionIntegrityDetector(),
5631
5965
  // The other side of the argument: what a real browsing session looks like.
5632
5966
  browsingCoherenceDetector(),
@@ -5642,6 +5976,10 @@ var init_detectors = __esm({
5642
5976
  init_cadence();
5643
5977
  init_client_hints();
5644
5978
  init_crawl_breadth();
5979
+ init_parameter_sweep();
5980
+ init_transport_coherence();
5981
+ init_probe_volume();
5982
+ init_id_enumeration();
5645
5983
  init_crawler_verification();
5646
5984
  init_fetch_metadata();
5647
5985
  init_header_integrity();
@@ -6493,6 +6831,11 @@ function assessmentFromEntry(entry) {
6493
6831
  key: entry.actor,
6494
6832
  requests: entry.actorStats.requests,
6495
6833
  distinctPaths: entry.actorStats.distinctPaths,
6834
+ distinctQueries: 0,
6835
+ queriesSaturated: false,
6836
+ methodsSeen: ["GET"],
6837
+ responses: 0,
6838
+ misses: 0,
6496
6839
  firstSeen: entry.actorStats.firstSeen,
6497
6840
  lastSeen: entry.at,
6498
6841
  ...entry.actorStats.sinceLastMs !== void 0 ? { sinceLastMs: entry.actorStats.sinceLastMs } : {},
@@ -6761,7 +7104,10 @@ button[disabled] { opacity: .5; cursor: default; }
6761
7104
  .tab[aria-selected="true"] { color: var(--ink); border-bottom-color: var(--s1); }
6762
7105
 
6763
7106
  /* --- layout ------------------------------------------------------------- */
6764
- main { padding: 18px 20px 64px; max-width: 1680px; margin: 0 auto; }
7107
+ /* The top padding is the gap under the sticky header. At 18px the counter row sat almost
7108
+ against the header's border and read as part of it; the tiles carry their own border, so
7109
+ two lines were meeting with nothing between them. */
7110
+ main { padding: 28px 20px 64px; max-width: 1680px; margin: 0 auto; }
6765
7111
  .stack { display: grid; gap: 16px; }
6766
7112
  /* Everything above the feed is drawn by script once the first snapshot arrives, which
6767
7113
  inserts a block of content above what is already laid out. The browser's scroll
@@ -9169,6 +9515,24 @@ var init_core = __esm({
9169
9515
  this.warn(`Actor "${key}" was cleared as human at runtime${attribute(context)}, until ${new Date(until).toISOString()}.`);
9170
9516
  this.events.emit("actor-change", { key, action: "clear", until, by: context.by });
9171
9517
  }
9518
+ /**
9519
+ * Tells the engine what the application answered.
9520
+ *
9521
+ * The one thing detection cannot see for itself. Every verdict here is reached *before*
9522
+ * the response exists — that is what makes it useful, since it can shape the response —
9523
+ * and so the status is knowledge only the application holds. Handed back, it closes the
9524
+ * oldest gap in reading a scanner: an actor whose requests are almost all misses is
9525
+ * looking for something rather than reading anything, and no amount of header analysis
9526
+ * shows that.
9527
+ *
9528
+ * Optional, and silent when the actor has already been forgotten. Nothing about
9529
+ * detection depends on it being called; supplying it sharpens `probe-volume` and
9530
+ * nothing else. The bundled Node adapter wires it up for you.
9531
+ */
9532
+ recordOutcome(facts, status) {
9533
+ if (!Number.isFinite(status)) return;
9534
+ this.registry.peek(this.actorKeyFor(facts))?.recordOutcome(status);
9535
+ }
9172
9536
  /** Convenience for `updateRanges("crawler:<id>", …)`, matching a signature id. */
9173
9537
  updateCrawlerRanges(signatureId, entries, context = {}) {
9174
9538
  this.updateRanges(`crawler:${signatureId}`, entries, context);
@@ -9558,6 +9922,11 @@ var init_core = __esm({
9558
9922
  key: actorKey,
9559
9923
  requests: 0,
9560
9924
  distinctPaths: 0,
9925
+ distinctQueries: 0,
9926
+ queriesSaturated: false,
9927
+ methodsSeen: ["GET"],
9928
+ responses: 0,
9929
+ misses: 0,
9561
9930
  firstSeen: facts.timestamp,
9562
9931
  lastSeen: facts.timestamp,
9563
9932
  priorConfirmations: 0,
@@ -10368,6 +10737,89 @@ var init_adversarial = __esm({
10368
10737
  init_ranges();
10369
10738
  CHROME_UA = userAgentOf("chromeWindows");
10370
10739
  ADVERSARIAL_CASES = [
10740
+ bot({
10741
+ id: "id-harvest-contiguous",
10742
+ title: "Every profile id in order, with a copied browser header set",
10743
+ audience: "hostile",
10744
+ category: "scraping",
10745
+ 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.",
10746
+ requests: repeat({ ...browser("chromeWindows"), ip: "198.51.100.65" }, 40, 800, (index) => `/user/${index + 1}`),
10747
+ expect: {
10748
+ // One `moderate` signal against a flawless header set, like the others here. What
10749
+ // changed is that the walk is now *visible* — before this detector it was scored
10750
+ // identically to a hundred and twenty scattered ids and to ordinary article paths.
10751
+ verdict: "unknown",
10752
+ detectors: ["id-enumeration"]
10753
+ },
10754
+ 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."
10755
+ }),
10756
+ bot({
10757
+ id: "wordlist-scan-mostly-misses",
10758
+ title: "A wordlist walked with a copied browser header set, almost all of it missing",
10759
+ audience: "hostile",
10760
+ category: "scanning",
10761
+ 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.",
10762
+ requests: repeat({ ...browser("chromeWindows"), ip: "198.51.100.64", status: 404 }, 30, 700, (index) => `/${["admin", "backup", "old", "test", "config", "db"][index % 6]}-${index}`),
10763
+ expect: {
10764
+ // One `moderate` signal against an otherwise flawless header set does not cross the
10765
+ // line, and it should not: a site that has just moved its URLs produces the same
10766
+ // shape from ordinary readers. Raising the ceiling so this case reads better would
10767
+ // be tuning the detector to the test rather than to the traffic.
10768
+ verdict: "unknown",
10769
+ detectors: ["probe-volume"]
10770
+ },
10771
+ 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."
10772
+ }),
10773
+ bot({
10774
+ id: "browser-claim-over-http-1-0",
10775
+ title: "A perfect Chrome header set, arriving over HTTP/1.0",
10776
+ audience: "hostile",
10777
+ category: "impersonation",
10778
+ 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.",
10779
+ requests: repeat({ ...browser("chromeWindows"), ip: "198.51.100.62", httpVersion: "1.0" }, 30, 900, (index) => `/${["news", "about", "blog", "help", "terms"][index % 5]}`),
10780
+ expect: {
10781
+ // Contributes rather than concludes. On its own, against an otherwise flawless
10782
+ // header set, one `moderate` signal does not reach the threshold — and it should
10783
+ // not, because an intermediary can cause this. Beside anything sharper it does.
10784
+ verdict: "unknown",
10785
+ detectors: ["transport-coherence"]
10786
+ },
10787
+ 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."
10788
+ }),
10789
+ bot({
10790
+ id: "head-only-visit",
10791
+ title: "A visit made entirely of HEAD, claiming a browser",
10792
+ audience: "unwanted-bot",
10793
+ category: "scraping",
10794
+ 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.",
10795
+ requests: repeat({ ...browser("chromeWindows"), ip: "198.51.100.63", method: "HEAD" }, 30, 900, (index) => `/${["news", "about", "blog", "help", "terms"][index % 5]}`),
10796
+ expect: {
10797
+ // As above: a shape worth reporting, not worth concluding from alone.
10798
+ verdict: "unknown",
10799
+ detectors: ["transport-coherence"]
10800
+ },
10801
+ 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`."
10802
+ }),
10803
+ bot({
10804
+ id: "catalogue-sweep-by-page",
10805
+ title: "A catalogue taken a page at a time, with the path never changing",
10806
+ audience: "unwanted-bot",
10807
+ category: "scraping",
10808
+ 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.",
10809
+ requests: repeat({ ...browser("chromeWindows"), ip: "198.51.100.61" }, 40, 900, (index) => `/products?page=${index}`),
10810
+ expect: {
10811
+ // Not proven, and not even suspected at this pace. Said plainly because it is true:
10812
+ // headers this clean leave only behaviour, behaviour is weak by construction, and a
10813
+ // collector polite enough to space its requests stays under the line. What changed
10814
+ // is that it no longer scores *lower* than the identical crawl expressed as distinct
10815
+ // paths — measured at a faster pace before this detector existed, the two differed by
10816
+ // seven points and only the path version crossed; they now score the same at every
10817
+ // volume tried.
10818
+ verdict: "unknown",
10819
+ detectors: ["parameter-sweep"]
10820
+ },
10821
+ 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."
10822
+ }),
10371
10823
  // ---------------------------------------------------------------------------
10372
10824
  // Forged identities. The narrow case where a lie is provable.
10373
10825
  // ---------------------------------------------------------------------------
@@ -13310,6 +13762,7 @@ async function runCase(handler, clock, item, startedAt, provides, assertActions
13310
13762
  const withClearance = clearanceCookie === void 0 ? request : { ...request, headers: [...request.headers, ["Cookie", clearanceCookie]] };
13311
13763
  const facts = toFacts(withClearance, fallbackIp, clock.now());
13312
13764
  const { assessment, decision, outcome } = await handler.handle(facts);
13765
+ if (request.status !== void 0) handler.recordOutcome(facts, request.status);
13313
13766
  requests.push({ assessment, decision, outcome });
13314
13767
  }
13315
13768
  const final = requests[requests.length - 1];