@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/adapters/index.cjs +3 -0
- package/dist/adapters/index.cjs.map +1 -1
- package/dist/adapters/index.js +3 -0
- package/dist/adapters/index.js.map +1 -1
- package/dist/cli.cjs +459 -6
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +459 -6
- package/dist/cli.js.map +1 -1
- package/dist/core.d.ts +15 -0
- package/dist/corpus/index.cjs +84 -0
- package/dist/corpus/index.cjs.map +1 -1
- package/dist/corpus/index.js +84 -0
- package/dist/corpus/index.js.map +1 -1
- package/dist/corpus/schema.d.ts +8 -0
- package/dist/crawler-ranges.d.ts +31 -0
- package/dist/detectors/id-enumeration.d.ts +31 -0
- package/dist/detectors/index.d.ts +8 -0
- package/dist/detectors/known-bots.d.ts +11 -0
- package/dist/detectors/parameter-sweep.d.ts +39 -0
- package/dist/detectors/probe-volume.d.ts +26 -0
- package/dist/detectors/transport-coherence.d.ts +31 -0
- package/dist/element/index.cjs +4 -1
- package/dist/element/index.cjs.map +1 -1
- package/dist/element/index.js +4 -1
- package/dist/element/index.js.map +1 -1
- package/dist/index.cjs +401 -12
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +396 -12
- package/dist/index.js.map +1 -1
- package/dist/state.d.ts +75 -0
- package/dist/types.d.ts +36 -0
- package/docs/course/05-detectors.md +4 -0
- package/docs/detection/detectors.md +122 -0
- package/docs/detection/signatures.md +9 -1
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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,
|
|
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,
|
|
10163
|
+
function validate(prefixes, limits) {
|
|
9785
10164
|
if (prefixes.length === 0) throw new Error("the list is empty");
|
|
9786
|
-
if (prefixes.length >
|
|
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
|
|
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,
|