@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.d.ts CHANGED
@@ -26,8 +26,8 @@ export type { BotHandlerConfig, ResolvedConfig, ProxyConfig } from "./config.js"
26
26
  export { combineEvidence, noisyOr, sortEvidence, weightOf } from "./evidence.js";
27
27
  export type { CombineOptions, CombinedEvidence } from "./evidence.js";
28
28
  export { generateRobotsTxt, robotsFromRules, agentFor } from "./robots.js";
29
- export { fetchCrawlerRanges, refreshCrawlerRanges, startCrawlerRangeRefresh, PUBLISHED_CRAWLER_RANGES } from "./crawler-ranges.js";
30
- export type { PublishedRangeSource, RefreshOptions, RefreshResult, ScheduleOptions } from "./crawler-ranges.js";
29
+ export { PUBLISHED_CRAWLER_RANGES, fetchAddressList, fetchCrawlerRanges, refreshCrawlerRanges, startCrawlerRangeRefresh } from "./crawler-ranges.js";
30
+ export type { AddressListOptions, AddressListSource, PublishedRangeSource, RefreshOptions, RefreshResult, ScheduleOptions } from "./crawler-ranges.js";
31
31
  export type { RobotsOptions, RobotsFromRulesResult } from "./robots.js";
32
32
  export { TrafficAudit, DEFAULT_CHECKS } from "./audit.js";
33
33
  export type { AnomalySeverity, AuditCheck, AuditContext, AuditOptions, AuditWindow, TrafficAnomaly } from "./audit.js";
package/dist/index.js CHANGED
@@ -122,16 +122,20 @@ function hashString(value) {
122
122
  }
123
123
  return hash >>> 0;
124
124
  }
125
- var TIMESTAMP_RING, PATH_CAP, UA_CAP, MAX_TRACKED_ARRIVALS, MAX_TRACKED_PATHS, MAX_TRACKED_USER_AGENTS, ActorState, ActorRegistry;
125
+ 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;
126
126
  var init_state = __esm({
127
127
  "src/state.ts"() {
128
128
  "use strict";
129
129
  init_lru();
130
130
  TIMESTAMP_RING = 32;
131
131
  PATH_CAP = 64;
132
+ QUERY_CAP = 64;
133
+ METHOD_CAP = 12;
134
+ WALK_CAP = 4;
132
135
  UA_CAP = 4;
133
136
  MAX_TRACKED_ARRIVALS = TIMESTAMP_RING;
134
137
  MAX_TRACKED_PATHS = PATH_CAP;
138
+ MAX_TRACKED_QUERIES = QUERY_CAP;
135
139
  MAX_TRACKED_USER_AGENTS = UA_CAP;
136
140
  ActorState = class {
137
141
  key;
@@ -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),
@@ -1883,7 +2014,7 @@ function compileSignatures(signatures = BOT_SIGNATURES) {
1883
2014
  function indexSignatures(signatures = BOT_SIGNATURES) {
1884
2015
  return new Map(signatures.map((signature) => [signature.id, signature]));
1885
2016
  }
1886
- var BOT_CATEGORIES, SEARCH, AI, SEO, SOCIAL, MONITORING, ARCHIVE, FEED, SECURITY, LIBRARY, HEADLESS, EMBEDDED, ADVERTISING, COMMERCE, ACADEMIC, ACCESSIBILITY, BOT_SIGNATURES, BENIGN_CATEGORIES;
2017
+ var BOT_CATEGORIES, SEARCH, AI, SEO, SOCIAL, MONITORING, ARCHIVE, FEED, SECURITY, LIBRARY, HEADLESS, EMBEDDED, ADVERTISING, COMMERCE, ACADEMIC, EMAIL_SECURITY, ACCESSIBILITY, BOT_SIGNATURES, BENIGN_CATEGORIES;
1887
2018
  var init_known_bots = __esm({
1888
2019
  "src/detectors/known-bots.ts"() {
1889
2020
  "use strict";
@@ -1904,6 +2035,7 @@ var init_known_bots = __esm({
1904
2035
  "commerce",
1905
2036
  "accessibility",
1906
2037
  "academic",
2038
+ "email-security",
1907
2039
  "other"
1908
2040
  ];
1909
2041
  SEARCH = [
@@ -1928,6 +2060,13 @@ var init_known_bots = __esm({
1928
2060
  { id: "yisouspider", name: "Shenma (Yisou) Spider", tokens: ["yisouspider"], category: "search", benign: true, robotsAgent: "YisouSpider", verification: { kind: "none" } },
1929
2061
  { id: "yahoo-slurp", name: "Yahoo! Slurp", tokens: ["yahoo! slurp"], category: "search", benign: true, robotsAgent: "Slurp", verification: { kind: "fcrdns", domains: ["crawl.yahoo.net", "yahoo.com"] } },
1930
2062
  { id: "mail-ru", name: "Mail.Ru bot", tokens: ["mail.ru_bot"], category: "search", benign: true, verification: { kind: "none" } },
2063
+ { id: "brave-search", name: "Brave Search", tokens: ["bravesearchbot"], category: "search", benign: true, robotsAgent: "BraveSearchBot", verification: { kind: "none" } },
2064
+ { id: "ecosia", name: "Ecosia", tokens: ["ecosiabot"], category: "search", benign: true, verification: { kind: "none" } },
2065
+ { id: "startpage", name: "Startpage", tokens: ["startpagebot"], category: "search", benign: true, verification: { kind: "none" } },
2066
+ { id: "daum", name: "Daumoa", tokens: ["daumoa"], category: "search", benign: true, verification: { kind: "none" } },
2067
+ { id: "stract", name: "Stract", tokens: ["stractbot"], category: "search", benign: true, verification: { kind: "none" } },
2068
+ { id: "rightdao", name: "RightDao", tokens: ["rightdaobot"], category: "search", benign: true, verification: { kind: "none" } },
2069
+ { id: "gigablast", name: "Gigablast", tokens: ["gigablastopensource"], category: "search", benign: true, verification: { kind: "none" } },
1931
2070
  { id: "exabot", name: "Exabot (Exalead)", tokens: ["exabot"], category: "search", benign: true, verification: { kind: "none" } },
1932
2071
  { id: "kagibot", name: "Kagi", tokens: ["kagibot"], category: "search", benign: true, robotsAgent: "KagiBot", verification: { kind: "none" } }
1933
2072
  ];
@@ -1998,11 +2137,15 @@ var init_known_bots = __esm({
1998
2137
  { id: "pinterestbot", name: "Pinterestbot", tokens: ["pinterest/", "pinterestbot"], category: "social", benign: true, verification: { kind: "fcrdns", domains: ["pinterest.com"] } },
1999
2138
  { id: "mastodon", name: "Mastodon / Fediverse", tokens: ["mastodon/", "pleroma", "misskey/", "akkoma"], category: "social", benign: true, verification: { kind: "none" } },
2000
2139
  { id: "embedly", name: "Embedly", tokens: ["embedly"], category: "social", benign: true, verification: { kind: "none" } },
2001
- { id: "bluesky", name: "Bluesky card fetcher", tokens: ["bluesky cardyb", "cardyb/"], category: "social", benign: true, verification: { kind: "none" } },
2140
+ { id: "bluesky", name: "Bluesky card fetcher", tokens: ["bluesky cardyb", "cardyb/", "blueskybot"], category: "social", benign: true, verification: { kind: "none" } },
2002
2141
  { id: "iframely", name: "Iframely", tokens: ["iframely"], category: "social", benign: true, verification: { kind: "none" } },
2003
2142
  { id: "skype-preview", name: "Skype URI preview", tokens: ["skypeuripreview"], category: "social", benign: true, verification: { kind: "none" } },
2004
2143
  { id: "vk-share", name: "VK / Odnoklassniki preview", tokens: ["vkshare", "odklbot"], category: "social", benign: true, verification: { kind: "none" } },
2005
2144
  { id: "discourse-onebox", name: "Discourse Onebox", tokens: ["discourse forum onebox"], category: "social", benign: true, verification: { kind: "none" } },
2145
+ { id: "microsoft-preview", name: "Microsoft Teams preview", tokens: ["microsoftpreview"], category: "social", benign: true, verification: { kind: "none" } },
2146
+ { id: "zoom-preview", name: "Zoom link preview", tokens: ["zoombot"], category: "social", benign: true, verification: { kind: "none" } },
2147
+ { id: "signal-preview", name: "Signal link preview", tokens: ["signalbot"], category: "social", benign: true, verification: { kind: "none" } },
2148
+ { id: "matrix-synapse", name: "Matrix (Synapse) preview", tokens: ["synapse/"], category: "social", benign: true, verification: { kind: "none" } },
2006
2149
  { id: "yahoo-preview", name: "Yahoo Link Preview", tokens: ["yahoo link preview"], category: "social", benign: true, verification: { kind: "none" } }
2007
2150
  ];
2008
2151
  MONITORING = [
@@ -2042,6 +2185,10 @@ var init_known_bots = __esm({
2042
2185
  { id: "smartnews", name: "SmartNews", tokens: ["smartnewsbot"], category: "feed", benign: true, verification: { kind: "none" } },
2043
2186
  { id: "flipboard", name: "Flipboard", tokens: ["flipboardproxy"], category: "feed", benign: true, verification: { kind: "none" } },
2044
2187
  { id: "podcast-index", name: "Podcast Index", tokens: ["podcastindexbot"], category: "feed", benign: true, verification: { kind: "none" } },
2188
+ // Spotify's podcast fetcher sends `Spotify/1.0` — and so does the Spotify desktop app,
2189
+ // with a person driving it. There is no token that separates them, so this one is left
2190
+ // unnamed rather than named wrongly: the corpus proved the point immediately by blocking
2191
+ // a human under `protect-auth`, `indexers-only` and `under-attack` at once.
2045
2192
  { id: "freshrss", name: "FreshRSS", tokens: ["freshrss"], category: "feed", benign: true, verification: { kind: "none" } },
2046
2193
  { id: "netnewswire", name: "NetNewsWire", tokens: ["netnewswire"], category: "feed", benign: true, verification: { kind: "none" } },
2047
2194
  { id: "overcast", name: "Overcast", tokens: ["overcast/"], category: "feed", benign: true, verification: { kind: "none" } },
@@ -2168,7 +2315,23 @@ var init_known_bots = __esm({
2168
2315
  ];
2169
2316
  ADVERTISING = [
2170
2317
  { id: "adsbot-google", name: "AdsBot-Google", tokens: ["adsbot-google", "mediapartners-google", "adsbot"], category: "advertising", benign: true, verification: { kind: "fcrdns", domains: ["googlebot.com", "google.com"] } },
2171
- { id: "criteo", name: "Criteo", tokens: ["criteobot"], category: "advertising", benign: true, verification: { kind: "none" } }
2318
+ { id: "criteo", name: "Criteo", tokens: ["criteobot"], category: "advertising", benign: true, verification: { kind: "none" } },
2319
+ // Verification and contextual classification: they read a page to decide whether an ad
2320
+ // may appear beside it, or what the page is about. A publisher usually wants these and a
2321
+ // site with no advertising has no reason to.
2322
+ { id: "doubleverify", name: "DoubleVerify", tokens: ["doubleverifybot"], category: "advertising", benign: true, verification: { kind: "none" } },
2323
+ { id: "ias", name: "Integral Ad Science", tokens: ["ias crawler"], category: "advertising", benign: true, verification: { kind: "none" } },
2324
+ { id: "moat", name: "Moat", tokens: ["moatbot"], category: "advertising", benign: true, verification: { kind: "none" } },
2325
+ { id: "comscore", name: "comScore (Proximic)", tokens: ["proximic"], category: "advertising", benign: true, verification: { kind: "none" } },
2326
+ { id: "grapeshot", name: "Grapeshot", tokens: ["grapeshotcrawler"], category: "advertising", benign: true, verification: { kind: "none" } },
2327
+ { id: "peer39", name: "Peer39", tokens: ["peer39bot"], category: "advertising", benign: true, verification: { kind: "none" } },
2328
+ { id: "taboola", name: "Taboola", tokens: ["taboolabot"], category: "advertising", benign: true, verification: { kind: "none" } },
2329
+ { id: "outbrain", name: "Outbrain", tokens: ["outbrainbot"], category: "advertising", benign: true, verification: { kind: "none" } },
2330
+ { id: "pubmatic", name: "PubMatic", tokens: ["pubmaticbot"], category: "advertising", benign: true, verification: { kind: "none" } },
2331
+ { id: "thetradedesk", name: "The Trade Desk", tokens: ["ttd-content"], category: "advertising", benign: true, verification: { kind: "none" } },
2332
+ // Competitive ad intelligence rather than verification: it collects what everyone else
2333
+ // is running. Named, and left for the operator to decide about.
2334
+ { id: "adbeat", name: "Adbeat", tokens: ["adbeat_bot"], category: "advertising", benign: false, verification: { kind: "none" } }
2172
2335
  ];
2173
2336
  COMMERCE = [
2174
2337
  { id: "idealo", name: "idealo", tokens: ["idealo-bot"], category: "commerce", benign: false, verification: { kind: "none" } },
@@ -2184,6 +2347,12 @@ var init_known_bots = __esm({
2184
2347
  { id: "openalex", name: "OpenAlex", tokens: ["openalexbot"], category: "academic", benign: true, verification: { kind: "none" } },
2185
2348
  { id: "webis", name: "Webis research crawler", tokens: ["webisbot"], category: "academic", benign: true, verification: { kind: "none" } }
2186
2349
  ];
2350
+ EMAIL_SECURITY = [
2351
+ { id: "proofpoint", name: "Proofpoint URL Defense", tokens: ["proofpointurldefensebot"], category: "email-security", benign: true, verification: { kind: "none" } },
2352
+ { id: "mimecast", name: "Mimecast URL Protect", tokens: ["mimecasturlprotectbot"], category: "email-security", benign: true, verification: { kind: "none" } },
2353
+ { id: "barracuda", name: "Barracuda Link Protect", tokens: ["barracudalinkprotectbot"], category: "email-security", benign: true, verification: { kind: "none" } },
2354
+ { id: "cisco-esa", name: "Cisco Secure Email", tokens: ["ciscosecureemailbot"], category: "email-security", benign: true, verification: { kind: "none" } }
2355
+ ];
2187
2356
  ACCESSIBILITY = [
2188
2357
  { id: "siteimprove", name: "Siteimprove", tokens: ["siteimprovebot"], category: "accessibility", benign: true, robotsAgent: "SiteimproveBot", verification: { kind: "none" } }
2189
2358
  ];
@@ -2202,7 +2371,8 @@ var init_known_bots = __esm({
2202
2371
  ...EMBEDDED,
2203
2372
  ...COMMERCE,
2204
2373
  ...ACADEMIC,
2205
- ...ACCESSIBILITY
2374
+ ...ACCESSIBILITY,
2375
+ ...EMAIL_SECURITY
2206
2376
  ]);
2207
2377
  BENIGN_CATEGORIES = /* @__PURE__ */ new Set(["search", "social", "monitoring", "feed", "archive", "advertising"]);
2208
2378
  }
@@ -2786,6 +2956,166 @@ var init_crawl_breadth = __esm({
2786
2956
  }
2787
2957
  });
2788
2958
 
2959
+ // src/detectors/parameter-sweep.ts
2960
+ function parameterSweepDetector(options = {}) {
2961
+ const threshold = options.threshold ?? 25;
2962
+ const variantsPerPath = options.variantsPerPath ?? 8;
2963
+ const minRequests = options.minRequests ?? 20;
2964
+ if (threshold > MAX_TRACKED_QUERIES) {
2965
+ throw new RangeError(
2966
+ `parameterSweepDetector threshold ${threshold} can never be reached: an actor's distinct-query count saturates at ${MAX_TRACKED_QUERIES}. Use ${MAX_TRACKED_QUERIES} or fewer.`
2967
+ );
2968
+ }
2969
+ return {
2970
+ id: "parameter-sweep",
2971
+ description: "Counts distinct query strings against the paths they sit on, to catch enumeration that leaves the path unchanged",
2972
+ cost: "cheap",
2973
+ stage: "always",
2974
+ inspect(ctx) {
2975
+ const { distinctPaths, distinctQueries, queriesSaturated, total } = ctx.state;
2976
+ if (total < minRequests || distinctQueries < threshold) return void 0;
2977
+ const spread = distinctQueries / Math.max(1, distinctPaths);
2978
+ if (spread < variantsPerPath) return void 0;
2979
+ return {
2980
+ detector: "parameter-sweep",
2981
+ 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`,
2982
+ direction: "bot",
2983
+ certainty: "weak",
2984
+ // Saturation means the count stopped being able to grow, so the real spread is
2985
+ // wider than the one reported — the same argument breadth makes for itself.
2986
+ weight: queriesSaturated ? 0.25 : 0.15,
2987
+ botClass: "scraper",
2988
+ metadata: {
2989
+ distinctQueries,
2990
+ distinctPaths,
2991
+ variantsPerPath: Number(spread.toFixed(1)),
2992
+ totalRequests: total,
2993
+ saturated: queriesSaturated
2994
+ }
2995
+ };
2996
+ }
2997
+ };
2998
+ }
2999
+ var init_parameter_sweep = __esm({
3000
+ "src/detectors/parameter-sweep.ts"() {
3001
+ "use strict";
3002
+ init_state();
3003
+ }
3004
+ });
3005
+
3006
+ // src/detectors/transport-coherence.ts
3007
+ function transportCoherenceDetector(options = {}) {
3008
+ const checkLegacyHttp = options.legacyHttp ?? true;
3009
+ const minHeadRequests = options.minHeadRequests ?? 8;
3010
+ return {
3011
+ id: "transport-coherence",
3012
+ description: "Reads the HTTP version and the methods across a visit against the client the request claims to be",
3013
+ cost: "cheap",
3014
+ stage: "always",
3015
+ inspect(ctx) {
3016
+ if (!claimsBrowser(ctx.ua)) return void 0;
3017
+ const results = [];
3018
+ const version = ctx.facts.httpVersion;
3019
+ if (checkLegacyHttp && version !== void 0 && LEGACY_VERSIONS.has(version)) {
3020
+ results.push({
3021
+ detector: "transport-coherence",
3022
+ summary: `Client claims to be a browser but negotiated HTTP/${version}, which no shipping browser has offered in over a decade`,
3023
+ direction: "bot",
3024
+ certainty: "moderate",
3025
+ botClass: "impersonator",
3026
+ // One downgrading proxy in front of the application does this to every request
3027
+ // that passes through it, so this must count once rather than once per reason.
3028
+ family: "legacy-transport",
3029
+ metadata: { httpVersion: version, browser: ctx.ua.browser }
3030
+ });
3031
+ }
3032
+ const methods = ctx.state.methodsSeen;
3033
+ if (ctx.state.total >= minHeadRequests && methods.length > 0 && methods.every((method) => method === "HEAD")) {
3034
+ results.push({
3035
+ detector: "transport-coherence",
3036
+ summary: `Client claims to be a browser but has issued nothing but HEAD across ${ctx.state.total} requests`,
3037
+ direction: "bot",
3038
+ certainty: "moderate",
3039
+ botClass: "scraper",
3040
+ metadata: { requests: ctx.state.total, browser: ctx.ua.browser }
3041
+ });
3042
+ }
3043
+ return results.length > 0 ? results : void 0;
3044
+ }
3045
+ };
3046
+ }
3047
+ var LEGACY_VERSIONS;
3048
+ var init_transport_coherence = __esm({
3049
+ "src/detectors/transport-coherence.ts"() {
3050
+ "use strict";
3051
+ init_ua();
3052
+ LEGACY_VERSIONS = /* @__PURE__ */ new Set(["0.9", "1.0"]);
3053
+ }
3054
+ });
3055
+
3056
+ // src/detectors/probe-volume.ts
3057
+ function probeVolumeDetector(options = {}) {
3058
+ const minResponses = options.minResponses ?? 20;
3059
+ const missRatio = options.missRatio ?? 0.8;
3060
+ return {
3061
+ id: "probe-volume",
3062
+ description: "Reads the share of an actor's requests that were answered 404 or 410, where the application reports them",
3063
+ cost: "cheap",
3064
+ stage: "always",
3065
+ inspect(ctx) {
3066
+ const { responses, misses } = ctx.state;
3067
+ if (responses < minResponses) return void 0;
3068
+ const ratio = misses / responses;
3069
+ if (ratio < missRatio) return void 0;
3070
+ return {
3071
+ detector: "probe-volume",
3072
+ summary: `${misses} of this client's last ${responses} requests were answered "not found" (${(ratio * 100).toFixed(0)}%)`,
3073
+ direction: "bot",
3074
+ certainty: "moderate",
3075
+ botClass: "scanner",
3076
+ metadata: { responses, misses, missRatio: Number(ratio.toFixed(3)) }
3077
+ };
3078
+ }
3079
+ };
3080
+ }
3081
+ var init_probe_volume = __esm({
3082
+ "src/detectors/probe-volume.ts"() {
3083
+ "use strict";
3084
+ }
3085
+ });
3086
+
3087
+ // src/detectors/id-enumeration.ts
3088
+ function idEnumerationDetector(options = {}) {
3089
+ const minRequests = options.minRequests ?? 30;
3090
+ const density = options.density ?? 0.9;
3091
+ return {
3092
+ id: "id-enumeration",
3093
+ description: "Reports an actor covering a contiguous range of numeric identifiers under one path shape",
3094
+ cost: "cheap",
3095
+ stage: "always",
3096
+ inspect(ctx) {
3097
+ const walk = ctx.state.densestWalk();
3098
+ if (walk === void 0 || walk.count < minRequests) return void 0;
3099
+ if (walk.span < minRequests) return void 0;
3100
+ const covered = Math.min(1, walk.count / walk.span);
3101
+ if (covered < density) return void 0;
3102
+ return {
3103
+ detector: "id-enumeration",
3104
+ summary: `${walk.count} requests to ${walk.template} covering ${(covered * 100).toFixed(0)}% of a ${walk.span}-wide range of ids`,
3105
+ direction: "bot",
3106
+ certainty: "moderate",
3107
+ botClass: "scraper",
3108
+ metadata: { template: walk.template, requests: walk.count, span: walk.span, coverage: Number(covered.toFixed(3)) }
3109
+ };
3110
+ }
3111
+ };
3112
+ }
3113
+ var init_id_enumeration = __esm({
3114
+ "src/detectors/id-enumeration.ts"() {
3115
+ "use strict";
3116
+ }
3117
+ });
3118
+
2789
3119
  // src/detectors/crawler-verification.ts
2790
3120
  function crawlerVerificationDetector(options = {}) {
2791
3121
  const missingPtrIsForgery = options.treatMissingPtrAsForgery ?? true;
@@ -4163,11 +4493,15 @@ function defaultDetectors(options = {}) {
4163
4493
  clientHintsDetector(),
4164
4494
  fetchMetadataDetector(),
4165
4495
  acceptSignatureDetector(),
4496
+ transportCoherenceDetector(),
4166
4497
  headerOrderDetector(),
4167
4498
  // Behaviour across requests.
4168
4499
  rateAnomalyDetector(),
4169
4500
  cadenceDetector(),
4170
4501
  crawlBreadthDetector(),
4502
+ parameterSweepDetector(),
4503
+ probeVolumeDetector(),
4504
+ idEnumerationDetector(),
4171
4505
  sessionIntegrityDetector(),
4172
4506
  // The other side of the argument: what a real browsing session looks like.
4173
4507
  browsingCoherenceDetector(),
@@ -4183,6 +4517,10 @@ var init_detectors = __esm({
4183
4517
  init_cadence();
4184
4518
  init_client_hints();
4185
4519
  init_crawl_breadth();
4520
+ init_parameter_sweep();
4521
+ init_transport_coherence();
4522
+ init_probe_volume();
4523
+ init_id_enumeration();
4186
4524
  init_crawler_verification();
4187
4525
  init_fetch_metadata();
4188
4526
  init_header_integrity();
@@ -4205,6 +4543,10 @@ var init_detectors = __esm({
4205
4543
  init_rate_anomaly();
4206
4544
  init_cadence();
4207
4545
  init_crawl_breadth();
4546
+ init_parameter_sweep();
4547
+ init_transport_coherence();
4548
+ init_probe_volume();
4549
+ init_id_enumeration();
4208
4550
  init_session_integrity();
4209
4551
  init_identity_rotation();
4210
4552
  init_trap();
@@ -5032,6 +5374,11 @@ function assessmentFromEntry(entry) {
5032
5374
  key: entry.actor,
5033
5375
  requests: entry.actorStats.requests,
5034
5376
  distinctPaths: entry.actorStats.distinctPaths,
5377
+ distinctQueries: 0,
5378
+ queriesSaturated: false,
5379
+ methodsSeen: ["GET"],
5380
+ responses: 0,
5381
+ misses: 0,
5035
5382
  firstSeen: entry.actorStats.firstSeen,
5036
5383
  lastSeen: entry.at,
5037
5384
  ...entry.actorStats.sinceLastMs !== void 0 ? { sinceLastMs: entry.actorStats.sinceLastMs } : {},
@@ -5300,7 +5647,10 @@ button[disabled] { opacity: .5; cursor: default; }
5300
5647
  .tab[aria-selected="true"] { color: var(--ink); border-bottom-color: var(--s1); }
5301
5648
 
5302
5649
  /* --- layout ------------------------------------------------------------- */
5303
- main { padding: 18px 20px 64px; max-width: 1680px; margin: 0 auto; }
5650
+ /* The top padding is the gap under the sticky header. At 18px the counter row sat almost
5651
+ against the header's border and read as part of it; the tiles carry their own border, so
5652
+ two lines were meeting with nothing between them. */
5653
+ main { padding: 28px 20px 64px; max-width: 1680px; margin: 0 auto; }
5304
5654
  .stack { display: grid; gap: 16px; }
5305
5655
  /* Everything above the feed is drawn by script once the first snapshot arrives, which
5306
5656
  inserts a block of content above what is already laid out. The browser's scroll
@@ -9293,6 +9643,24 @@ var BotHandler = class {
9293
9643
  this.warn(`Actor "${key}" was cleared as human at runtime${attribute(context)}, until ${new Date(until).toISOString()}.`);
9294
9644
  this.events.emit("actor-change", { key, action: "clear", until, by: context.by });
9295
9645
  }
9646
+ /**
9647
+ * Tells the engine what the application answered.
9648
+ *
9649
+ * The one thing detection cannot see for itself. Every verdict here is reached *before*
9650
+ * the response exists — that is what makes it useful, since it can shape the response —
9651
+ * and so the status is knowledge only the application holds. Handed back, it closes the
9652
+ * oldest gap in reading a scanner: an actor whose requests are almost all misses is
9653
+ * looking for something rather than reading anything, and no amount of header analysis
9654
+ * shows that.
9655
+ *
9656
+ * Optional, and silent when the actor has already been forgotten. Nothing about
9657
+ * detection depends on it being called; supplying it sharpens `probe-volume` and
9658
+ * nothing else. The bundled Node adapter wires it up for you.
9659
+ */
9660
+ recordOutcome(facts, status) {
9661
+ if (!Number.isFinite(status)) return;
9662
+ this.registry.peek(this.actorKeyFor(facts))?.recordOutcome(status);
9663
+ }
9296
9664
  /** Convenience for `updateRanges("crawler:<id>", …)`, matching a signature id. */
9297
9665
  updateCrawlerRanges(signatureId, entries, context = {}) {
9298
9666
  this.updateRanges(`crawler:${signatureId}`, entries, context);
@@ -9682,6 +10050,11 @@ var BotHandler = class {
9682
10050
  key: actorKey,
9683
10051
  requests: 0,
9684
10052
  distinctPaths: 0,
10053
+ distinctQueries: 0,
10054
+ queriesSaturated: false,
10055
+ methodsSeen: ["GET"],
10056
+ responses: 0,
10057
+ misses: 0,
9685
10058
  firstSeen: facts.timestamp,
9686
10059
  lastSeen: facts.timestamp,
9687
10060
  priorConfirmations: 0,
@@ -9753,6 +10126,12 @@ var MAX_V6_BLOCK = 19;
9753
10126
  var MAX_PREFIXES = 1e4;
9754
10127
  var MAX_BYTES = 4 * 1024 * 1024;
9755
10128
  async function fetchCrawlerRanges(source, options = {}) {
10129
+ return fetchPrefixes(source, options, { maxPrefixes: MAX_PREFIXES, subject: `a crawler's address list`, id: source.id });
10130
+ }
10131
+ async function fetchAddressList(source, options = {}) {
10132
+ return fetchPrefixes(source, options, { maxPrefixes: options.maxPrefixes ?? 1e5, subject: "an address list", id: source.id });
10133
+ }
10134
+ async function fetchPrefixes(source, options, limits) {
9756
10135
  const url = new URL(source.url);
9757
10136
  if (url.protocol !== "https:") throw new ConfigError(`Crawler ranges must be published over HTTPS. "${source.url}" is not.`);
9758
10137
  const fetcher = options.fetch ?? globalThis.fetch;
@@ -9766,7 +10145,7 @@ async function fetchCrawlerRanges(source, options = {}) {
9766
10145
  const text = await response.text();
9767
10146
  if (text.length > MAX_BYTES) throw new Error(`the list is ${Math.round(text.length / 1024)} kB, which is not a list of prefixes`);
9768
10147
  const prefixes = text.trimStart().startsWith("{") ? fromJson(text) : fromLines(text);
9769
- return validate(prefixes, source.id);
10148
+ return validate(prefixes, limits);
9770
10149
  }
9771
10150
  function fromJson(text) {
9772
10151
  const document = JSON.parse(text);
@@ -9779,24 +10158,24 @@ function fromJson(text) {
9779
10158
  return out;
9780
10159
  }
9781
10160
  function fromLines(text) {
9782
- return text.split("\n").map((line) => line.split("#")[0]?.trim() ?? "").filter((line) => line !== "");
10161
+ return text.split("\n").map((line) => (line.split("#")[0] ?? "").split(";")[0]?.trim() ?? "").filter((line) => line !== "");
9783
10162
  }
9784
- function validate(prefixes, id) {
10163
+ function validate(prefixes, limits) {
9785
10164
  if (prefixes.length === 0) throw new Error("the list is empty");
9786
- if (prefixes.length > MAX_PREFIXES) throw new Error(`${prefixes.length} prefixes is not a crawler's address list`);
10165
+ if (prefixes.length > limits.maxPrefixes) throw new Error(`${prefixes.length} prefixes is not ${limits.subject}`);
9787
10166
  const accepted = [];
9788
10167
  for (const prefix of prefixes) {
9789
10168
  const cidr = parseCidr(prefix);
9790
10169
  if (cidr === void 0 || cidr === null) continue;
9791
10170
  const isV4 = cidr.bytes.length === 4;
9792
10171
  if (cidr.prefix < (isV4 ? MAX_V4_BLOCK : MAX_V6_BLOCK)) {
9793
- throw new Error(`"${prefix}" covers more of the internet than any crawler owns \u2014 refusing the whole list rather than verifying strangers`);
10172
+ throw new Error(`"${prefix}" covers more of the internet than any published list should \u2014 refusing the whole list rather than acting on it`);
9794
10173
  }
9795
10174
  accepted.push(prefix);
9796
10175
  }
9797
10176
  if (accepted.length === 0) throw new Error(`nothing in the list parsed as an address or CIDR (first entry: "${prefixes[0]}")`);
9798
10177
  const set = new IpRangeSet(accepted);
9799
- if (set.size === 0) throw new Error(`nothing in the list loaded for ${id}`);
10178
+ if (set.size === 0) throw new Error(`nothing in the list loaded for ${limits.id}`);
9800
10179
  return accepted;
9801
10180
  }
9802
10181
  async function refreshCrawlerRanges(handler, options = {}) {
@@ -10109,6 +10488,7 @@ export {
10109
10488
  defineHandler,
10110
10489
  evidence,
10111
10490
  executeAction,
10491
+ fetchAddressList,
10112
10492
  fetchCrawlerRanges,
10113
10493
  fetchMetadataDetector,
10114
10494
  formatIp,
@@ -10117,6 +10497,7 @@ export {
10117
10497
  headerIntegrityDetector,
10118
10498
  headerOrderDetector,
10119
10499
  headerOrderFingerprint,
10500
+ idEnumerationDetector,
10120
10501
  identityRotationDetector,
10121
10502
  independentStrongSignals,
10122
10503
  indexSignatures,
@@ -10132,6 +10513,7 @@ export {
10132
10513
  noisyOr,
10133
10514
  normalizeIp,
10134
10515
  notifyJsNotifier,
10516
+ parameterSweepDetector,
10135
10517
  parseAcceptLanguage,
10136
10518
  parseCidr,
10137
10519
  parseCookies,
@@ -10141,6 +10523,7 @@ export {
10141
10523
  pickTranslation,
10142
10524
  probeShapeFor,
10143
10525
  probeSignatureDetector,
10526
+ probeVolumeDetector,
10144
10527
  protectApi,
10145
10528
  protectAuth,
10146
10529
  protectContent,
@@ -10169,6 +10552,7 @@ export {
10169
10552
  systemClock,
10170
10553
  tlsFingerprintDetector,
10171
10554
  toPrometheus,
10555
+ transportCoherenceDetector,
10172
10556
  trapDetector,
10173
10557
  trapRobotsEntries,
10174
10558
  uaCoherenceDetector,