@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/index.cjs CHANGED
@@ -144,16 +144,20 @@ function hashString(value) {
144
144
  }
145
145
  return hash >>> 0;
146
146
  }
147
- var TIMESTAMP_RING, PATH_CAP, UA_CAP, MAX_TRACKED_ARRIVALS, MAX_TRACKED_PATHS, MAX_TRACKED_USER_AGENTS, ActorState, ActorRegistry;
147
+ var TIMESTAMP_RING, PATH_CAP, QUERY_CAP, METHOD_CAP, WALK_CAP, UA_CAP, MAX_TRACKED_ARRIVALS, MAX_TRACKED_PATHS, MAX_TRACKED_QUERIES, MAX_TRACKED_USER_AGENTS, ActorState, ActorRegistry;
148
148
  var init_state = __esm({
149
149
  "src/state.ts"() {
150
150
  "use strict";
151
151
  init_lru();
152
152
  TIMESTAMP_RING = 32;
153
153
  PATH_CAP = 64;
154
+ QUERY_CAP = 64;
155
+ METHOD_CAP = 12;
156
+ WALK_CAP = 4;
154
157
  UA_CAP = 4;
155
158
  MAX_TRACKED_ARRIVALS = TIMESTAMP_RING;
156
159
  MAX_TRACKED_PATHS = PATH_CAP;
160
+ MAX_TRACKED_QUERIES = QUERY_CAP;
157
161
  MAX_TRACKED_USER_AGENTS = UA_CAP;
158
162
  ActorState = class {
159
163
  key;
@@ -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),
@@ -1905,7 +2036,7 @@ function compileSignatures(signatures = BOT_SIGNATURES) {
1905
2036
  function indexSignatures(signatures = BOT_SIGNATURES) {
1906
2037
  return new Map(signatures.map((signature) => [signature.id, signature]));
1907
2038
  }
1908
- var BOT_CATEGORIES, SEARCH, AI, SEO, SOCIAL, MONITORING, ARCHIVE, FEED, SECURITY, LIBRARY, HEADLESS, EMBEDDED, ADVERTISING, COMMERCE, ACADEMIC, ACCESSIBILITY, BOT_SIGNATURES, BENIGN_CATEGORIES;
2039
+ var BOT_CATEGORIES, SEARCH, AI, SEO, SOCIAL, MONITORING, ARCHIVE, FEED, SECURITY, LIBRARY, HEADLESS, EMBEDDED, ADVERTISING, COMMERCE, ACADEMIC, EMAIL_SECURITY, ACCESSIBILITY, BOT_SIGNATURES, BENIGN_CATEGORIES;
1909
2040
  var init_known_bots = __esm({
1910
2041
  "src/detectors/known-bots.ts"() {
1911
2042
  "use strict";
@@ -1926,6 +2057,7 @@ var init_known_bots = __esm({
1926
2057
  "commerce",
1927
2058
  "accessibility",
1928
2059
  "academic",
2060
+ "email-security",
1929
2061
  "other"
1930
2062
  ];
1931
2063
  SEARCH = [
@@ -1950,6 +2082,13 @@ var init_known_bots = __esm({
1950
2082
  { id: "yisouspider", name: "Shenma (Yisou) Spider", tokens: ["yisouspider"], category: "search", benign: true, robotsAgent: "YisouSpider", verification: { kind: "none" } },
1951
2083
  { id: "yahoo-slurp", name: "Yahoo! Slurp", tokens: ["yahoo! slurp"], category: "search", benign: true, robotsAgent: "Slurp", verification: { kind: "fcrdns", domains: ["crawl.yahoo.net", "yahoo.com"] } },
1952
2084
  { id: "mail-ru", name: "Mail.Ru bot", tokens: ["mail.ru_bot"], category: "search", benign: true, verification: { kind: "none" } },
2085
+ { id: "brave-search", name: "Brave Search", tokens: ["bravesearchbot"], category: "search", benign: true, robotsAgent: "BraveSearchBot", verification: { kind: "none" } },
2086
+ { id: "ecosia", name: "Ecosia", tokens: ["ecosiabot"], category: "search", benign: true, verification: { kind: "none" } },
2087
+ { id: "startpage", name: "Startpage", tokens: ["startpagebot"], category: "search", benign: true, verification: { kind: "none" } },
2088
+ { id: "daum", name: "Daumoa", tokens: ["daumoa"], category: "search", benign: true, verification: { kind: "none" } },
2089
+ { id: "stract", name: "Stract", tokens: ["stractbot"], category: "search", benign: true, verification: { kind: "none" } },
2090
+ { id: "rightdao", name: "RightDao", tokens: ["rightdaobot"], category: "search", benign: true, verification: { kind: "none" } },
2091
+ { id: "gigablast", name: "Gigablast", tokens: ["gigablastopensource"], category: "search", benign: true, verification: { kind: "none" } },
1953
2092
  { id: "exabot", name: "Exabot (Exalead)", tokens: ["exabot"], category: "search", benign: true, verification: { kind: "none" } },
1954
2093
  { id: "kagibot", name: "Kagi", tokens: ["kagibot"], category: "search", benign: true, robotsAgent: "KagiBot", verification: { kind: "none" } }
1955
2094
  ];
@@ -2020,11 +2159,15 @@ var init_known_bots = __esm({
2020
2159
  { id: "pinterestbot", name: "Pinterestbot", tokens: ["pinterest/", "pinterestbot"], category: "social", benign: true, verification: { kind: "fcrdns", domains: ["pinterest.com"] } },
2021
2160
  { id: "mastodon", name: "Mastodon / Fediverse", tokens: ["mastodon/", "pleroma", "misskey/", "akkoma"], category: "social", benign: true, verification: { kind: "none" } },
2022
2161
  { id: "embedly", name: "Embedly", tokens: ["embedly"], category: "social", benign: true, verification: { kind: "none" } },
2023
- { id: "bluesky", name: "Bluesky card fetcher", tokens: ["bluesky cardyb", "cardyb/"], category: "social", benign: true, verification: { kind: "none" } },
2162
+ { id: "bluesky", name: "Bluesky card fetcher", tokens: ["bluesky cardyb", "cardyb/", "blueskybot"], category: "social", benign: true, verification: { kind: "none" } },
2024
2163
  { id: "iframely", name: "Iframely", tokens: ["iframely"], category: "social", benign: true, verification: { kind: "none" } },
2025
2164
  { id: "skype-preview", name: "Skype URI preview", tokens: ["skypeuripreview"], category: "social", benign: true, verification: { kind: "none" } },
2026
2165
  { id: "vk-share", name: "VK / Odnoklassniki preview", tokens: ["vkshare", "odklbot"], category: "social", benign: true, verification: { kind: "none" } },
2027
2166
  { id: "discourse-onebox", name: "Discourse Onebox", tokens: ["discourse forum onebox"], category: "social", benign: true, verification: { kind: "none" } },
2167
+ { id: "microsoft-preview", name: "Microsoft Teams preview", tokens: ["microsoftpreview"], category: "social", benign: true, verification: { kind: "none" } },
2168
+ { id: "zoom-preview", name: "Zoom link preview", tokens: ["zoombot"], category: "social", benign: true, verification: { kind: "none" } },
2169
+ { id: "signal-preview", name: "Signal link preview", tokens: ["signalbot"], category: "social", benign: true, verification: { kind: "none" } },
2170
+ { id: "matrix-synapse", name: "Matrix (Synapse) preview", tokens: ["synapse/"], category: "social", benign: true, verification: { kind: "none" } },
2028
2171
  { id: "yahoo-preview", name: "Yahoo Link Preview", tokens: ["yahoo link preview"], category: "social", benign: true, verification: { kind: "none" } }
2029
2172
  ];
2030
2173
  MONITORING = [
@@ -2064,6 +2207,10 @@ var init_known_bots = __esm({
2064
2207
  { id: "smartnews", name: "SmartNews", tokens: ["smartnewsbot"], category: "feed", benign: true, verification: { kind: "none" } },
2065
2208
  { id: "flipboard", name: "Flipboard", tokens: ["flipboardproxy"], category: "feed", benign: true, verification: { kind: "none" } },
2066
2209
  { id: "podcast-index", name: "Podcast Index", tokens: ["podcastindexbot"], category: "feed", benign: true, verification: { kind: "none" } },
2210
+ // Spotify's podcast fetcher sends `Spotify/1.0` — and so does the Spotify desktop app,
2211
+ // with a person driving it. There is no token that separates them, so this one is left
2212
+ // unnamed rather than named wrongly: the corpus proved the point immediately by blocking
2213
+ // a human under `protect-auth`, `indexers-only` and `under-attack` at once.
2067
2214
  { id: "freshrss", name: "FreshRSS", tokens: ["freshrss"], category: "feed", benign: true, verification: { kind: "none" } },
2068
2215
  { id: "netnewswire", name: "NetNewsWire", tokens: ["netnewswire"], category: "feed", benign: true, verification: { kind: "none" } },
2069
2216
  { id: "overcast", name: "Overcast", tokens: ["overcast/"], category: "feed", benign: true, verification: { kind: "none" } },
@@ -2190,7 +2337,23 @@ var init_known_bots = __esm({
2190
2337
  ];
2191
2338
  ADVERTISING = [
2192
2339
  { id: "adsbot-google", name: "AdsBot-Google", tokens: ["adsbot-google", "mediapartners-google", "adsbot"], category: "advertising", benign: true, verification: { kind: "fcrdns", domains: ["googlebot.com", "google.com"] } },
2193
- { id: "criteo", name: "Criteo", tokens: ["criteobot"], category: "advertising", benign: true, verification: { kind: "none" } }
2340
+ { id: "criteo", name: "Criteo", tokens: ["criteobot"], category: "advertising", benign: true, verification: { kind: "none" } },
2341
+ // Verification and contextual classification: they read a page to decide whether an ad
2342
+ // may appear beside it, or what the page is about. A publisher usually wants these and a
2343
+ // site with no advertising has no reason to.
2344
+ { id: "doubleverify", name: "DoubleVerify", tokens: ["doubleverifybot"], category: "advertising", benign: true, verification: { kind: "none" } },
2345
+ { id: "ias", name: "Integral Ad Science", tokens: ["ias crawler"], category: "advertising", benign: true, verification: { kind: "none" } },
2346
+ { id: "moat", name: "Moat", tokens: ["moatbot"], category: "advertising", benign: true, verification: { kind: "none" } },
2347
+ { id: "comscore", name: "comScore (Proximic)", tokens: ["proximic"], category: "advertising", benign: true, verification: { kind: "none" } },
2348
+ { id: "grapeshot", name: "Grapeshot", tokens: ["grapeshotcrawler"], category: "advertising", benign: true, verification: { kind: "none" } },
2349
+ { id: "peer39", name: "Peer39", tokens: ["peer39bot"], category: "advertising", benign: true, verification: { kind: "none" } },
2350
+ { id: "taboola", name: "Taboola", tokens: ["taboolabot"], category: "advertising", benign: true, verification: { kind: "none" } },
2351
+ { id: "outbrain", name: "Outbrain", tokens: ["outbrainbot"], category: "advertising", benign: true, verification: { kind: "none" } },
2352
+ { id: "pubmatic", name: "PubMatic", tokens: ["pubmaticbot"], category: "advertising", benign: true, verification: { kind: "none" } },
2353
+ { id: "thetradedesk", name: "The Trade Desk", tokens: ["ttd-content"], category: "advertising", benign: true, verification: { kind: "none" } },
2354
+ // Competitive ad intelligence rather than verification: it collects what everyone else
2355
+ // is running. Named, and left for the operator to decide about.
2356
+ { id: "adbeat", name: "Adbeat", tokens: ["adbeat_bot"], category: "advertising", benign: false, verification: { kind: "none" } }
2194
2357
  ];
2195
2358
  COMMERCE = [
2196
2359
  { id: "idealo", name: "idealo", tokens: ["idealo-bot"], category: "commerce", benign: false, verification: { kind: "none" } },
@@ -2206,6 +2369,12 @@ var init_known_bots = __esm({
2206
2369
  { id: "openalex", name: "OpenAlex", tokens: ["openalexbot"], category: "academic", benign: true, verification: { kind: "none" } },
2207
2370
  { id: "webis", name: "Webis research crawler", tokens: ["webisbot"], category: "academic", benign: true, verification: { kind: "none" } }
2208
2371
  ];
2372
+ EMAIL_SECURITY = [
2373
+ { id: "proofpoint", name: "Proofpoint URL Defense", tokens: ["proofpointurldefensebot"], category: "email-security", benign: true, verification: { kind: "none" } },
2374
+ { id: "mimecast", name: "Mimecast URL Protect", tokens: ["mimecasturlprotectbot"], category: "email-security", benign: true, verification: { kind: "none" } },
2375
+ { id: "barracuda", name: "Barracuda Link Protect", tokens: ["barracudalinkprotectbot"], category: "email-security", benign: true, verification: { kind: "none" } },
2376
+ { id: "cisco-esa", name: "Cisco Secure Email", tokens: ["ciscosecureemailbot"], category: "email-security", benign: true, verification: { kind: "none" } }
2377
+ ];
2209
2378
  ACCESSIBILITY = [
2210
2379
  { id: "siteimprove", name: "Siteimprove", tokens: ["siteimprovebot"], category: "accessibility", benign: true, robotsAgent: "SiteimproveBot", verification: { kind: "none" } }
2211
2380
  ];
@@ -2224,7 +2393,8 @@ var init_known_bots = __esm({
2224
2393
  ...EMBEDDED,
2225
2394
  ...COMMERCE,
2226
2395
  ...ACADEMIC,
2227
- ...ACCESSIBILITY
2396
+ ...ACCESSIBILITY,
2397
+ ...EMAIL_SECURITY
2228
2398
  ]);
2229
2399
  BENIGN_CATEGORIES = /* @__PURE__ */ new Set(["search", "social", "monitoring", "feed", "archive", "advertising"]);
2230
2400
  }
@@ -2808,6 +2978,166 @@ var init_crawl_breadth = __esm({
2808
2978
  }
2809
2979
  });
2810
2980
 
2981
+ // src/detectors/parameter-sweep.ts
2982
+ function parameterSweepDetector(options = {}) {
2983
+ const threshold = options.threshold ?? 25;
2984
+ const variantsPerPath = options.variantsPerPath ?? 8;
2985
+ const minRequests = options.minRequests ?? 20;
2986
+ if (threshold > MAX_TRACKED_QUERIES) {
2987
+ throw new RangeError(
2988
+ `parameterSweepDetector threshold ${threshold} can never be reached: an actor's distinct-query count saturates at ${MAX_TRACKED_QUERIES}. Use ${MAX_TRACKED_QUERIES} or fewer.`
2989
+ );
2990
+ }
2991
+ return {
2992
+ id: "parameter-sweep",
2993
+ description: "Counts distinct query strings against the paths they sit on, to catch enumeration that leaves the path unchanged",
2994
+ cost: "cheap",
2995
+ stage: "always",
2996
+ inspect(ctx) {
2997
+ const { distinctPaths, distinctQueries, queriesSaturated, total } = ctx.state;
2998
+ if (total < minRequests || distinctQueries < threshold) return void 0;
2999
+ const spread = distinctQueries / Math.max(1, distinctPaths);
3000
+ if (spread < variantsPerPath) return void 0;
3001
+ return {
3002
+ detector: "parameter-sweep",
3003
+ 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`,
3004
+ direction: "bot",
3005
+ certainty: "weak",
3006
+ // Saturation means the count stopped being able to grow, so the real spread is
3007
+ // wider than the one reported — the same argument breadth makes for itself.
3008
+ weight: queriesSaturated ? 0.25 : 0.15,
3009
+ botClass: "scraper",
3010
+ metadata: {
3011
+ distinctQueries,
3012
+ distinctPaths,
3013
+ variantsPerPath: Number(spread.toFixed(1)),
3014
+ totalRequests: total,
3015
+ saturated: queriesSaturated
3016
+ }
3017
+ };
3018
+ }
3019
+ };
3020
+ }
3021
+ var init_parameter_sweep = __esm({
3022
+ "src/detectors/parameter-sweep.ts"() {
3023
+ "use strict";
3024
+ init_state();
3025
+ }
3026
+ });
3027
+
3028
+ // src/detectors/transport-coherence.ts
3029
+ function transportCoherenceDetector(options = {}) {
3030
+ const checkLegacyHttp = options.legacyHttp ?? true;
3031
+ const minHeadRequests = options.minHeadRequests ?? 8;
3032
+ return {
3033
+ id: "transport-coherence",
3034
+ description: "Reads the HTTP version and the methods across a visit against the client the request claims to be",
3035
+ cost: "cheap",
3036
+ stage: "always",
3037
+ inspect(ctx) {
3038
+ if (!claimsBrowser(ctx.ua)) return void 0;
3039
+ const results = [];
3040
+ const version = ctx.facts.httpVersion;
3041
+ if (checkLegacyHttp && version !== void 0 && LEGACY_VERSIONS.has(version)) {
3042
+ results.push({
3043
+ detector: "transport-coherence",
3044
+ summary: `Client claims to be a browser but negotiated HTTP/${version}, which no shipping browser has offered in over a decade`,
3045
+ direction: "bot",
3046
+ certainty: "moderate",
3047
+ botClass: "impersonator",
3048
+ // One downgrading proxy in front of the application does this to every request
3049
+ // that passes through it, so this must count once rather than once per reason.
3050
+ family: "legacy-transport",
3051
+ metadata: { httpVersion: version, browser: ctx.ua.browser }
3052
+ });
3053
+ }
3054
+ const methods = ctx.state.methodsSeen;
3055
+ if (ctx.state.total >= minHeadRequests && methods.length > 0 && methods.every((method) => method === "HEAD")) {
3056
+ results.push({
3057
+ detector: "transport-coherence",
3058
+ summary: `Client claims to be a browser but has issued nothing but HEAD across ${ctx.state.total} requests`,
3059
+ direction: "bot",
3060
+ certainty: "moderate",
3061
+ botClass: "scraper",
3062
+ metadata: { requests: ctx.state.total, browser: ctx.ua.browser }
3063
+ });
3064
+ }
3065
+ return results.length > 0 ? results : void 0;
3066
+ }
3067
+ };
3068
+ }
3069
+ var LEGACY_VERSIONS;
3070
+ var init_transport_coherence = __esm({
3071
+ "src/detectors/transport-coherence.ts"() {
3072
+ "use strict";
3073
+ init_ua();
3074
+ LEGACY_VERSIONS = /* @__PURE__ */ new Set(["0.9", "1.0"]);
3075
+ }
3076
+ });
3077
+
3078
+ // src/detectors/probe-volume.ts
3079
+ function probeVolumeDetector(options = {}) {
3080
+ const minResponses = options.minResponses ?? 20;
3081
+ const missRatio = options.missRatio ?? 0.8;
3082
+ return {
3083
+ id: "probe-volume",
3084
+ description: "Reads the share of an actor's requests that were answered 404 or 410, where the application reports them",
3085
+ cost: "cheap",
3086
+ stage: "always",
3087
+ inspect(ctx) {
3088
+ const { responses, misses } = ctx.state;
3089
+ if (responses < minResponses) return void 0;
3090
+ const ratio = misses / responses;
3091
+ if (ratio < missRatio) return void 0;
3092
+ return {
3093
+ detector: "probe-volume",
3094
+ summary: `${misses} of this client's last ${responses} requests were answered "not found" (${(ratio * 100).toFixed(0)}%)`,
3095
+ direction: "bot",
3096
+ certainty: "moderate",
3097
+ botClass: "scanner",
3098
+ metadata: { responses, misses, missRatio: Number(ratio.toFixed(3)) }
3099
+ };
3100
+ }
3101
+ };
3102
+ }
3103
+ var init_probe_volume = __esm({
3104
+ "src/detectors/probe-volume.ts"() {
3105
+ "use strict";
3106
+ }
3107
+ });
3108
+
3109
+ // src/detectors/id-enumeration.ts
3110
+ function idEnumerationDetector(options = {}) {
3111
+ const minRequests = options.minRequests ?? 30;
3112
+ const density = options.density ?? 0.9;
3113
+ return {
3114
+ id: "id-enumeration",
3115
+ description: "Reports an actor covering a contiguous range of numeric identifiers under one path shape",
3116
+ cost: "cheap",
3117
+ stage: "always",
3118
+ inspect(ctx) {
3119
+ const walk = ctx.state.densestWalk();
3120
+ if (walk === void 0 || walk.count < minRequests) return void 0;
3121
+ if (walk.span < minRequests) return void 0;
3122
+ const covered = Math.min(1, walk.count / walk.span);
3123
+ if (covered < density) return void 0;
3124
+ return {
3125
+ detector: "id-enumeration",
3126
+ summary: `${walk.count} requests to ${walk.template} covering ${(covered * 100).toFixed(0)}% of a ${walk.span}-wide range of ids`,
3127
+ direction: "bot",
3128
+ certainty: "moderate",
3129
+ botClass: "scraper",
3130
+ metadata: { template: walk.template, requests: walk.count, span: walk.span, coverage: Number(covered.toFixed(3)) }
3131
+ };
3132
+ }
3133
+ };
3134
+ }
3135
+ var init_id_enumeration = __esm({
3136
+ "src/detectors/id-enumeration.ts"() {
3137
+ "use strict";
3138
+ }
3139
+ });
3140
+
2811
3141
  // src/detectors/crawler-verification.ts
2812
3142
  function crawlerVerificationDetector(options = {}) {
2813
3143
  const missingPtrIsForgery = options.treatMissingPtrAsForgery ?? true;
@@ -4185,11 +4515,15 @@ function defaultDetectors(options = {}) {
4185
4515
  clientHintsDetector(),
4186
4516
  fetchMetadataDetector(),
4187
4517
  acceptSignatureDetector(),
4518
+ transportCoherenceDetector(),
4188
4519
  headerOrderDetector(),
4189
4520
  // Behaviour across requests.
4190
4521
  rateAnomalyDetector(),
4191
4522
  cadenceDetector(),
4192
4523
  crawlBreadthDetector(),
4524
+ parameterSweepDetector(),
4525
+ probeVolumeDetector(),
4526
+ idEnumerationDetector(),
4193
4527
  sessionIntegrityDetector(),
4194
4528
  // The other side of the argument: what a real browsing session looks like.
4195
4529
  browsingCoherenceDetector(),
@@ -4205,6 +4539,10 @@ var init_detectors = __esm({
4205
4539
  init_cadence();
4206
4540
  init_client_hints();
4207
4541
  init_crawl_breadth();
4542
+ init_parameter_sweep();
4543
+ init_transport_coherence();
4544
+ init_probe_volume();
4545
+ init_id_enumeration();
4208
4546
  init_crawler_verification();
4209
4547
  init_fetch_metadata();
4210
4548
  init_header_integrity();
@@ -4227,6 +4565,10 @@ var init_detectors = __esm({
4227
4565
  init_rate_anomaly();
4228
4566
  init_cadence();
4229
4567
  init_crawl_breadth();
4568
+ init_parameter_sweep();
4569
+ init_transport_coherence();
4570
+ init_probe_volume();
4571
+ init_id_enumeration();
4230
4572
  init_session_integrity();
4231
4573
  init_identity_rotation();
4232
4574
  init_trap();
@@ -5054,6 +5396,11 @@ function assessmentFromEntry(entry) {
5054
5396
  key: entry.actor,
5055
5397
  requests: entry.actorStats.requests,
5056
5398
  distinctPaths: entry.actorStats.distinctPaths,
5399
+ distinctQueries: 0,
5400
+ queriesSaturated: false,
5401
+ methodsSeen: ["GET"],
5402
+ responses: 0,
5403
+ misses: 0,
5057
5404
  firstSeen: entry.actorStats.firstSeen,
5058
5405
  lastSeen: entry.at,
5059
5406
  ...entry.actorStats.sinceLastMs !== void 0 ? { sinceLastMs: entry.actorStats.sinceLastMs } : {},
@@ -5322,7 +5669,10 @@ button[disabled] { opacity: .5; cursor: default; }
5322
5669
  .tab[aria-selected="true"] { color: var(--ink); border-bottom-color: var(--s1); }
5323
5670
 
5324
5671
  /* --- layout ------------------------------------------------------------- */
5325
- main { padding: 18px 20px 64px; max-width: 1680px; margin: 0 auto; }
5672
+ /* The top padding is the gap under the sticky header. At 18px the counter row sat almost
5673
+ against the header's border and read as part of it; the tiles carry their own border, so
5674
+ two lines were meeting with nothing between them. */
5675
+ main { padding: 28px 20px 64px; max-width: 1680px; margin: 0 auto; }
5326
5676
  .stack { display: grid; gap: 16px; }
5327
5677
  /* Everything above the feed is drawn by script once the first snapshot arrives, which
5328
5678
  inserts a block of content above what is already laid out. The browser's scroll
@@ -7536,6 +7886,7 @@ __export(index_exports, {
7536
7886
  defineHandler: () => defineHandler,
7537
7887
  evidence: () => evidence,
7538
7888
  executeAction: () => executeAction,
7889
+ fetchAddressList: () => fetchAddressList,
7539
7890
  fetchCrawlerRanges: () => fetchCrawlerRanges,
7540
7891
  fetchMetadataDetector: () => fetchMetadataDetector,
7541
7892
  formatIp: () => formatIp,
@@ -7544,6 +7895,7 @@ __export(index_exports, {
7544
7895
  headerIntegrityDetector: () => headerIntegrityDetector,
7545
7896
  headerOrderDetector: () => headerOrderDetector,
7546
7897
  headerOrderFingerprint: () => headerOrderFingerprint,
7898
+ idEnumerationDetector: () => idEnumerationDetector,
7547
7899
  identityRotationDetector: () => identityRotationDetector,
7548
7900
  independentStrongSignals: () => independentStrongSignals,
7549
7901
  indexSignatures: () => indexSignatures,
@@ -7559,6 +7911,7 @@ __export(index_exports, {
7559
7911
  noisyOr: () => noisyOr,
7560
7912
  normalizeIp: () => normalizeIp,
7561
7913
  notifyJsNotifier: () => notifyJsNotifier,
7914
+ parameterSweepDetector: () => parameterSweepDetector,
7562
7915
  parseAcceptLanguage: () => parseAcceptLanguage,
7563
7916
  parseCidr: () => parseCidr,
7564
7917
  parseCookies: () => parseCookies,
@@ -7568,6 +7921,7 @@ __export(index_exports, {
7568
7921
  pickTranslation: () => pickTranslation,
7569
7922
  probeShapeFor: () => probeShapeFor,
7570
7923
  probeSignatureDetector: () => probeSignatureDetector,
7924
+ probeVolumeDetector: () => probeVolumeDetector,
7571
7925
  protectApi: () => protectApi,
7572
7926
  protectAuth: () => protectAuth,
7573
7927
  protectContent: () => protectContent,
@@ -7596,6 +7950,7 @@ __export(index_exports, {
7596
7950
  systemClock: () => systemClock,
7597
7951
  tlsFingerprintDetector: () => tlsFingerprintDetector,
7598
7952
  toPrometheus: () => toPrometheus,
7953
+ transportCoherenceDetector: () => transportCoherenceDetector,
7599
7954
  trapDetector: () => trapDetector,
7600
7955
  trapRobotsEntries: () => trapRobotsEntries,
7601
7956
  uaCoherenceDetector: () => uaCoherenceDetector,
@@ -9457,6 +9812,24 @@ var BotHandler = class {
9457
9812
  this.warn(`Actor "${key}" was cleared as human at runtime${attribute(context)}, until ${new Date(until).toISOString()}.`);
9458
9813
  this.events.emit("actor-change", { key, action: "clear", until, by: context.by });
9459
9814
  }
9815
+ /**
9816
+ * Tells the engine what the application answered.
9817
+ *
9818
+ * The one thing detection cannot see for itself. Every verdict here is reached *before*
9819
+ * the response exists — that is what makes it useful, since it can shape the response —
9820
+ * and so the status is knowledge only the application holds. Handed back, it closes the
9821
+ * oldest gap in reading a scanner: an actor whose requests are almost all misses is
9822
+ * looking for something rather than reading anything, and no amount of header analysis
9823
+ * shows that.
9824
+ *
9825
+ * Optional, and silent when the actor has already been forgotten. Nothing about
9826
+ * detection depends on it being called; supplying it sharpens `probe-volume` and
9827
+ * nothing else. The bundled Node adapter wires it up for you.
9828
+ */
9829
+ recordOutcome(facts, status) {
9830
+ if (!Number.isFinite(status)) return;
9831
+ this.registry.peek(this.actorKeyFor(facts))?.recordOutcome(status);
9832
+ }
9460
9833
  /** Convenience for `updateRanges("crawler:<id>", …)`, matching a signature id. */
9461
9834
  updateCrawlerRanges(signatureId, entries, context = {}) {
9462
9835
  this.updateRanges(`crawler:${signatureId}`, entries, context);
@@ -9846,6 +10219,11 @@ var BotHandler = class {
9846
10219
  key: actorKey,
9847
10220
  requests: 0,
9848
10221
  distinctPaths: 0,
10222
+ distinctQueries: 0,
10223
+ queriesSaturated: false,
10224
+ methodsSeen: ["GET"],
10225
+ responses: 0,
10226
+ misses: 0,
9849
10227
  firstSeen: facts.timestamp,
9850
10228
  lastSeen: facts.timestamp,
9851
10229
  priorConfirmations: 0,
@@ -9917,6 +10295,12 @@ var MAX_V6_BLOCK = 19;
9917
10295
  var MAX_PREFIXES = 1e4;
9918
10296
  var MAX_BYTES = 4 * 1024 * 1024;
9919
10297
  async function fetchCrawlerRanges(source, options = {}) {
10298
+ return fetchPrefixes(source, options, { maxPrefixes: MAX_PREFIXES, subject: `a crawler's address list`, id: source.id });
10299
+ }
10300
+ async function fetchAddressList(source, options = {}) {
10301
+ return fetchPrefixes(source, options, { maxPrefixes: options.maxPrefixes ?? 1e5, subject: "an address list", id: source.id });
10302
+ }
10303
+ async function fetchPrefixes(source, options, limits) {
9920
10304
  const url = new URL(source.url);
9921
10305
  if (url.protocol !== "https:") throw new ConfigError(`Crawler ranges must be published over HTTPS. "${source.url}" is not.`);
9922
10306
  const fetcher = options.fetch ?? globalThis.fetch;
@@ -9930,7 +10314,7 @@ async function fetchCrawlerRanges(source, options = {}) {
9930
10314
  const text = await response.text();
9931
10315
  if (text.length > MAX_BYTES) throw new Error(`the list is ${Math.round(text.length / 1024)} kB, which is not a list of prefixes`);
9932
10316
  const prefixes = text.trimStart().startsWith("{") ? fromJson(text) : fromLines(text);
9933
- return validate(prefixes, source.id);
10317
+ return validate(prefixes, limits);
9934
10318
  }
9935
10319
  function fromJson(text) {
9936
10320
  const document = JSON.parse(text);
@@ -9943,24 +10327,24 @@ function fromJson(text) {
9943
10327
  return out;
9944
10328
  }
9945
10329
  function fromLines(text) {
9946
- return text.split("\n").map((line) => line.split("#")[0]?.trim() ?? "").filter((line) => line !== "");
10330
+ return text.split("\n").map((line) => (line.split("#")[0] ?? "").split(";")[0]?.trim() ?? "").filter((line) => line !== "");
9947
10331
  }
9948
- function validate(prefixes, id) {
10332
+ function validate(prefixes, limits) {
9949
10333
  if (prefixes.length === 0) throw new Error("the list is empty");
9950
- if (prefixes.length > MAX_PREFIXES) throw new Error(`${prefixes.length} prefixes is not a crawler's address list`);
10334
+ if (prefixes.length > limits.maxPrefixes) throw new Error(`${prefixes.length} prefixes is not ${limits.subject}`);
9951
10335
  const accepted = [];
9952
10336
  for (const prefix of prefixes) {
9953
10337
  const cidr = parseCidr(prefix);
9954
10338
  if (cidr === void 0 || cidr === null) continue;
9955
10339
  const isV4 = cidr.bytes.length === 4;
9956
10340
  if (cidr.prefix < (isV4 ? MAX_V4_BLOCK : MAX_V6_BLOCK)) {
9957
- throw new Error(`"${prefix}" covers more of the internet than any crawler owns \u2014 refusing the whole list rather than verifying strangers`);
10341
+ throw new Error(`"${prefix}" covers more of the internet than any published list should \u2014 refusing the whole list rather than acting on it`);
9958
10342
  }
9959
10343
  accepted.push(prefix);
9960
10344
  }
9961
10345
  if (accepted.length === 0) throw new Error(`nothing in the list parsed as an address or CIDR (first entry: "${prefixes[0]}")`);
9962
10346
  const set = new IpRangeSet(accepted);
9963
- if (set.size === 0) throw new Error(`nothing in the list loaded for ${id}`);
10347
+ if (set.size === 0) throw new Error(`nothing in the list loaded for ${limits.id}`);
9964
10348
  return accepted;
9965
10349
  }
9966
10350
  async function refreshCrawlerRanges(handler, options = {}) {
@@ -10274,6 +10658,7 @@ init_lru();
10274
10658
  defineHandler,
10275
10659
  evidence,
10276
10660
  executeAction,
10661
+ fetchAddressList,
10277
10662
  fetchCrawlerRanges,
10278
10663
  fetchMetadataDetector,
10279
10664
  formatIp,
@@ -10282,6 +10667,7 @@ init_lru();
10282
10667
  headerIntegrityDetector,
10283
10668
  headerOrderDetector,
10284
10669
  headerOrderFingerprint,
10670
+ idEnumerationDetector,
10285
10671
  identityRotationDetector,
10286
10672
  independentStrongSignals,
10287
10673
  indexSignatures,
@@ -10297,6 +10683,7 @@ init_lru();
10297
10683
  noisyOr,
10298
10684
  normalizeIp,
10299
10685
  notifyJsNotifier,
10686
+ parameterSweepDetector,
10300
10687
  parseAcceptLanguage,
10301
10688
  parseCidr,
10302
10689
  parseCookies,
@@ -10306,6 +10693,7 @@ init_lru();
10306
10693
  pickTranslation,
10307
10694
  probeShapeFor,
10308
10695
  probeSignatureDetector,
10696
+ probeVolumeDetector,
10309
10697
  protectApi,
10310
10698
  protectAuth,
10311
10699
  protectContent,
@@ -10334,6 +10722,7 @@ init_lru();
10334
10722
  systemClock,
10335
10723
  tlsFingerprintDetector,
10336
10724
  toPrometheus,
10725
+ transportCoherenceDetector,
10337
10726
  trapDetector,
10338
10727
  trapRobotsEntries,
10339
10728
  uaCoherenceDetector,