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