@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.js
CHANGED
|
@@ -123,16 +123,20 @@ function hashString(value) {
|
|
|
123
123
|
}
|
|
124
124
|
return hash >>> 0;
|
|
125
125
|
}
|
|
126
|
-
var TIMESTAMP_RING, PATH_CAP, UA_CAP, MAX_TRACKED_ARRIVALS, MAX_TRACKED_PATHS, ActorState, ActorRegistry;
|
|
126
|
+
var TIMESTAMP_RING, PATH_CAP, QUERY_CAP, METHOD_CAP, WALK_CAP, UA_CAP, MAX_TRACKED_ARRIVALS, MAX_TRACKED_PATHS, MAX_TRACKED_QUERIES, ActorState, ActorRegistry;
|
|
127
127
|
var init_state = __esm({
|
|
128
128
|
"src/state.ts"() {
|
|
129
129
|
"use strict";
|
|
130
130
|
init_lru();
|
|
131
131
|
TIMESTAMP_RING = 32;
|
|
132
132
|
PATH_CAP = 64;
|
|
133
|
+
QUERY_CAP = 64;
|
|
134
|
+
METHOD_CAP = 12;
|
|
135
|
+
WALK_CAP = 4;
|
|
133
136
|
UA_CAP = 4;
|
|
134
137
|
MAX_TRACKED_ARRIVALS = TIMESTAMP_RING;
|
|
135
138
|
MAX_TRACKED_PATHS = PATH_CAP;
|
|
139
|
+
MAX_TRACKED_QUERIES = QUERY_CAP;
|
|
136
140
|
ActorState = class {
|
|
137
141
|
key;
|
|
138
142
|
firstSeen;
|
|
@@ -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),
|
|
@@ -3352,7 +3483,7 @@ function compileSignatures(signatures = BOT_SIGNATURES) {
|
|
|
3352
3483
|
}
|
|
3353
3484
|
return new MultiPatternMatcher(entries);
|
|
3354
3485
|
}
|
|
3355
|
-
var BOT_CATEGORIES, SEARCH, AI, SEO, SOCIAL, MONITORING, ARCHIVE, FEED, SECURITY, LIBRARY, HEADLESS, EMBEDDED, ADVERTISING, COMMERCE, ACADEMIC, ACCESSIBILITY, BOT_SIGNATURES;
|
|
3486
|
+
var BOT_CATEGORIES, SEARCH, AI, SEO, SOCIAL, MONITORING, ARCHIVE, FEED, SECURITY, LIBRARY, HEADLESS, EMBEDDED, ADVERTISING, COMMERCE, ACADEMIC, EMAIL_SECURITY, ACCESSIBILITY, BOT_SIGNATURES;
|
|
3356
3487
|
var init_known_bots = __esm({
|
|
3357
3488
|
"src/detectors/known-bots.ts"() {
|
|
3358
3489
|
"use strict";
|
|
@@ -3373,6 +3504,7 @@ var init_known_bots = __esm({
|
|
|
3373
3504
|
"commerce",
|
|
3374
3505
|
"accessibility",
|
|
3375
3506
|
"academic",
|
|
3507
|
+
"email-security",
|
|
3376
3508
|
"other"
|
|
3377
3509
|
];
|
|
3378
3510
|
SEARCH = [
|
|
@@ -3397,6 +3529,13 @@ var init_known_bots = __esm({
|
|
|
3397
3529
|
{ id: "yisouspider", name: "Shenma (Yisou) Spider", tokens: ["yisouspider"], category: "search", benign: true, robotsAgent: "YisouSpider", verification: { kind: "none" } },
|
|
3398
3530
|
{ id: "yahoo-slurp", name: "Yahoo! Slurp", tokens: ["yahoo! slurp"], category: "search", benign: true, robotsAgent: "Slurp", verification: { kind: "fcrdns", domains: ["crawl.yahoo.net", "yahoo.com"] } },
|
|
3399
3531
|
{ id: "mail-ru", name: "Mail.Ru bot", tokens: ["mail.ru_bot"], category: "search", benign: true, verification: { kind: "none" } },
|
|
3532
|
+
{ id: "brave-search", name: "Brave Search", tokens: ["bravesearchbot"], category: "search", benign: true, robotsAgent: "BraveSearchBot", verification: { kind: "none" } },
|
|
3533
|
+
{ id: "ecosia", name: "Ecosia", tokens: ["ecosiabot"], category: "search", benign: true, verification: { kind: "none" } },
|
|
3534
|
+
{ id: "startpage", name: "Startpage", tokens: ["startpagebot"], category: "search", benign: true, verification: { kind: "none" } },
|
|
3535
|
+
{ id: "daum", name: "Daumoa", tokens: ["daumoa"], category: "search", benign: true, verification: { kind: "none" } },
|
|
3536
|
+
{ id: "stract", name: "Stract", tokens: ["stractbot"], category: "search", benign: true, verification: { kind: "none" } },
|
|
3537
|
+
{ id: "rightdao", name: "RightDao", tokens: ["rightdaobot"], category: "search", benign: true, verification: { kind: "none" } },
|
|
3538
|
+
{ id: "gigablast", name: "Gigablast", tokens: ["gigablastopensource"], category: "search", benign: true, verification: { kind: "none" } },
|
|
3400
3539
|
{ id: "exabot", name: "Exabot (Exalead)", tokens: ["exabot"], category: "search", benign: true, verification: { kind: "none" } },
|
|
3401
3540
|
{ id: "kagibot", name: "Kagi", tokens: ["kagibot"], category: "search", benign: true, robotsAgent: "KagiBot", verification: { kind: "none" } }
|
|
3402
3541
|
];
|
|
@@ -3467,11 +3606,15 @@ var init_known_bots = __esm({
|
|
|
3467
3606
|
{ id: "pinterestbot", name: "Pinterestbot", tokens: ["pinterest/", "pinterestbot"], category: "social", benign: true, verification: { kind: "fcrdns", domains: ["pinterest.com"] } },
|
|
3468
3607
|
{ id: "mastodon", name: "Mastodon / Fediverse", tokens: ["mastodon/", "pleroma", "misskey/", "akkoma"], category: "social", benign: true, verification: { kind: "none" } },
|
|
3469
3608
|
{ id: "embedly", name: "Embedly", tokens: ["embedly"], category: "social", benign: true, verification: { kind: "none" } },
|
|
3470
|
-
{ id: "bluesky", name: "Bluesky card fetcher", tokens: ["bluesky cardyb", "cardyb/"], category: "social", benign: true, verification: { kind: "none" } },
|
|
3609
|
+
{ id: "bluesky", name: "Bluesky card fetcher", tokens: ["bluesky cardyb", "cardyb/", "blueskybot"], category: "social", benign: true, verification: { kind: "none" } },
|
|
3471
3610
|
{ id: "iframely", name: "Iframely", tokens: ["iframely"], category: "social", benign: true, verification: { kind: "none" } },
|
|
3472
3611
|
{ id: "skype-preview", name: "Skype URI preview", tokens: ["skypeuripreview"], category: "social", benign: true, verification: { kind: "none" } },
|
|
3473
3612
|
{ id: "vk-share", name: "VK / Odnoklassniki preview", tokens: ["vkshare", "odklbot"], category: "social", benign: true, verification: { kind: "none" } },
|
|
3474
3613
|
{ id: "discourse-onebox", name: "Discourse Onebox", tokens: ["discourse forum onebox"], category: "social", benign: true, verification: { kind: "none" } },
|
|
3614
|
+
{ id: "microsoft-preview", name: "Microsoft Teams preview", tokens: ["microsoftpreview"], category: "social", benign: true, verification: { kind: "none" } },
|
|
3615
|
+
{ id: "zoom-preview", name: "Zoom link preview", tokens: ["zoombot"], category: "social", benign: true, verification: { kind: "none" } },
|
|
3616
|
+
{ id: "signal-preview", name: "Signal link preview", tokens: ["signalbot"], category: "social", benign: true, verification: { kind: "none" } },
|
|
3617
|
+
{ id: "matrix-synapse", name: "Matrix (Synapse) preview", tokens: ["synapse/"], category: "social", benign: true, verification: { kind: "none" } },
|
|
3475
3618
|
{ id: "yahoo-preview", name: "Yahoo Link Preview", tokens: ["yahoo link preview"], category: "social", benign: true, verification: { kind: "none" } }
|
|
3476
3619
|
];
|
|
3477
3620
|
MONITORING = [
|
|
@@ -3511,6 +3654,10 @@ var init_known_bots = __esm({
|
|
|
3511
3654
|
{ id: "smartnews", name: "SmartNews", tokens: ["smartnewsbot"], category: "feed", benign: true, verification: { kind: "none" } },
|
|
3512
3655
|
{ id: "flipboard", name: "Flipboard", tokens: ["flipboardproxy"], category: "feed", benign: true, verification: { kind: "none" } },
|
|
3513
3656
|
{ id: "podcast-index", name: "Podcast Index", tokens: ["podcastindexbot"], category: "feed", benign: true, verification: { kind: "none" } },
|
|
3657
|
+
// Spotify's podcast fetcher sends `Spotify/1.0` — and so does the Spotify desktop app,
|
|
3658
|
+
// with a person driving it. There is no token that separates them, so this one is left
|
|
3659
|
+
// unnamed rather than named wrongly: the corpus proved the point immediately by blocking
|
|
3660
|
+
// a human under `protect-auth`, `indexers-only` and `under-attack` at once.
|
|
3514
3661
|
{ id: "freshrss", name: "FreshRSS", tokens: ["freshrss"], category: "feed", benign: true, verification: { kind: "none" } },
|
|
3515
3662
|
{ id: "netnewswire", name: "NetNewsWire", tokens: ["netnewswire"], category: "feed", benign: true, verification: { kind: "none" } },
|
|
3516
3663
|
{ id: "overcast", name: "Overcast", tokens: ["overcast/"], category: "feed", benign: true, verification: { kind: "none" } },
|
|
@@ -3637,7 +3784,23 @@ var init_known_bots = __esm({
|
|
|
3637
3784
|
];
|
|
3638
3785
|
ADVERTISING = [
|
|
3639
3786
|
{ id: "adsbot-google", name: "AdsBot-Google", tokens: ["adsbot-google", "mediapartners-google", "adsbot"], category: "advertising", benign: true, verification: { kind: "fcrdns", domains: ["googlebot.com", "google.com"] } },
|
|
3640
|
-
{ id: "criteo", name: "Criteo", tokens: ["criteobot"], category: "advertising", benign: true, verification: { kind: "none" } }
|
|
3787
|
+
{ id: "criteo", name: "Criteo", tokens: ["criteobot"], category: "advertising", benign: true, verification: { kind: "none" } },
|
|
3788
|
+
// Verification and contextual classification: they read a page to decide whether an ad
|
|
3789
|
+
// may appear beside it, or what the page is about. A publisher usually wants these and a
|
|
3790
|
+
// site with no advertising has no reason to.
|
|
3791
|
+
{ id: "doubleverify", name: "DoubleVerify", tokens: ["doubleverifybot"], category: "advertising", benign: true, verification: { kind: "none" } },
|
|
3792
|
+
{ id: "ias", name: "Integral Ad Science", tokens: ["ias crawler"], category: "advertising", benign: true, verification: { kind: "none" } },
|
|
3793
|
+
{ id: "moat", name: "Moat", tokens: ["moatbot"], category: "advertising", benign: true, verification: { kind: "none" } },
|
|
3794
|
+
{ id: "comscore", name: "comScore (Proximic)", tokens: ["proximic"], category: "advertising", benign: true, verification: { kind: "none" } },
|
|
3795
|
+
{ id: "grapeshot", name: "Grapeshot", tokens: ["grapeshotcrawler"], category: "advertising", benign: true, verification: { kind: "none" } },
|
|
3796
|
+
{ id: "peer39", name: "Peer39", tokens: ["peer39bot"], category: "advertising", benign: true, verification: { kind: "none" } },
|
|
3797
|
+
{ id: "taboola", name: "Taboola", tokens: ["taboolabot"], category: "advertising", benign: true, verification: { kind: "none" } },
|
|
3798
|
+
{ id: "outbrain", name: "Outbrain", tokens: ["outbrainbot"], category: "advertising", benign: true, verification: { kind: "none" } },
|
|
3799
|
+
{ id: "pubmatic", name: "PubMatic", tokens: ["pubmaticbot"], category: "advertising", benign: true, verification: { kind: "none" } },
|
|
3800
|
+
{ id: "thetradedesk", name: "The Trade Desk", tokens: ["ttd-content"], category: "advertising", benign: true, verification: { kind: "none" } },
|
|
3801
|
+
// Competitive ad intelligence rather than verification: it collects what everyone else
|
|
3802
|
+
// is running. Named, and left for the operator to decide about.
|
|
3803
|
+
{ id: "adbeat", name: "Adbeat", tokens: ["adbeat_bot"], category: "advertising", benign: false, verification: { kind: "none" } }
|
|
3641
3804
|
];
|
|
3642
3805
|
COMMERCE = [
|
|
3643
3806
|
{ id: "idealo", name: "idealo", tokens: ["idealo-bot"], category: "commerce", benign: false, verification: { kind: "none" } },
|
|
@@ -3653,6 +3816,12 @@ var init_known_bots = __esm({
|
|
|
3653
3816
|
{ id: "openalex", name: "OpenAlex", tokens: ["openalexbot"], category: "academic", benign: true, verification: { kind: "none" } },
|
|
3654
3817
|
{ id: "webis", name: "Webis research crawler", tokens: ["webisbot"], category: "academic", benign: true, verification: { kind: "none" } }
|
|
3655
3818
|
];
|
|
3819
|
+
EMAIL_SECURITY = [
|
|
3820
|
+
{ id: "proofpoint", name: "Proofpoint URL Defense", tokens: ["proofpointurldefensebot"], category: "email-security", benign: true, verification: { kind: "none" } },
|
|
3821
|
+
{ id: "mimecast", name: "Mimecast URL Protect", tokens: ["mimecasturlprotectbot"], category: "email-security", benign: true, verification: { kind: "none" } },
|
|
3822
|
+
{ id: "barracuda", name: "Barracuda Link Protect", tokens: ["barracudalinkprotectbot"], category: "email-security", benign: true, verification: { kind: "none" } },
|
|
3823
|
+
{ id: "cisco-esa", name: "Cisco Secure Email", tokens: ["ciscosecureemailbot"], category: "email-security", benign: true, verification: { kind: "none" } }
|
|
3824
|
+
];
|
|
3656
3825
|
ACCESSIBILITY = [
|
|
3657
3826
|
{ id: "siteimprove", name: "Siteimprove", tokens: ["siteimprovebot"], category: "accessibility", benign: true, robotsAgent: "SiteimproveBot", verification: { kind: "none" } }
|
|
3658
3827
|
];
|
|
@@ -3671,7 +3840,8 @@ var init_known_bots = __esm({
|
|
|
3671
3840
|
...EMBEDDED,
|
|
3672
3841
|
...COMMERCE,
|
|
3673
3842
|
...ACADEMIC,
|
|
3674
|
-
...ACCESSIBILITY
|
|
3843
|
+
...ACCESSIBILITY,
|
|
3844
|
+
...EMAIL_SECURITY
|
|
3675
3845
|
]);
|
|
3676
3846
|
}
|
|
3677
3847
|
});
|
|
@@ -4423,6 +4593,166 @@ var init_crawl_breadth = __esm({
|
|
|
4423
4593
|
}
|
|
4424
4594
|
});
|
|
4425
4595
|
|
|
4596
|
+
// src/detectors/parameter-sweep.ts
|
|
4597
|
+
function parameterSweepDetector(options = {}) {
|
|
4598
|
+
const threshold = options.threshold ?? 25;
|
|
4599
|
+
const variantsPerPath = options.variantsPerPath ?? 8;
|
|
4600
|
+
const minRequests = options.minRequests ?? 20;
|
|
4601
|
+
if (threshold > MAX_TRACKED_QUERIES) {
|
|
4602
|
+
throw new RangeError(
|
|
4603
|
+
`parameterSweepDetector threshold ${threshold} can never be reached: an actor's distinct-query count saturates at ${MAX_TRACKED_QUERIES}. Use ${MAX_TRACKED_QUERIES} or fewer.`
|
|
4604
|
+
);
|
|
4605
|
+
}
|
|
4606
|
+
return {
|
|
4607
|
+
id: "parameter-sweep",
|
|
4608
|
+
description: "Counts distinct query strings against the paths they sit on, to catch enumeration that leaves the path unchanged",
|
|
4609
|
+
cost: "cheap",
|
|
4610
|
+
stage: "always",
|
|
4611
|
+
inspect(ctx) {
|
|
4612
|
+
const { distinctPaths, distinctQueries, queriesSaturated, total } = ctx.state;
|
|
4613
|
+
if (total < minRequests || distinctQueries < threshold) return void 0;
|
|
4614
|
+
const spread = distinctQueries / Math.max(1, distinctPaths);
|
|
4615
|
+
if (spread < variantsPerPath) return void 0;
|
|
4616
|
+
return {
|
|
4617
|
+
detector: "parameter-sweep",
|
|
4618
|
+
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`,
|
|
4619
|
+
direction: "bot",
|
|
4620
|
+
certainty: "weak",
|
|
4621
|
+
// Saturation means the count stopped being able to grow, so the real spread is
|
|
4622
|
+
// wider than the one reported — the same argument breadth makes for itself.
|
|
4623
|
+
weight: queriesSaturated ? 0.25 : 0.15,
|
|
4624
|
+
botClass: "scraper",
|
|
4625
|
+
metadata: {
|
|
4626
|
+
distinctQueries,
|
|
4627
|
+
distinctPaths,
|
|
4628
|
+
variantsPerPath: Number(spread.toFixed(1)),
|
|
4629
|
+
totalRequests: total,
|
|
4630
|
+
saturated: queriesSaturated
|
|
4631
|
+
}
|
|
4632
|
+
};
|
|
4633
|
+
}
|
|
4634
|
+
};
|
|
4635
|
+
}
|
|
4636
|
+
var init_parameter_sweep = __esm({
|
|
4637
|
+
"src/detectors/parameter-sweep.ts"() {
|
|
4638
|
+
"use strict";
|
|
4639
|
+
init_state();
|
|
4640
|
+
}
|
|
4641
|
+
});
|
|
4642
|
+
|
|
4643
|
+
// src/detectors/transport-coherence.ts
|
|
4644
|
+
function transportCoherenceDetector(options = {}) {
|
|
4645
|
+
const checkLegacyHttp = options.legacyHttp ?? true;
|
|
4646
|
+
const minHeadRequests = options.minHeadRequests ?? 8;
|
|
4647
|
+
return {
|
|
4648
|
+
id: "transport-coherence",
|
|
4649
|
+
description: "Reads the HTTP version and the methods across a visit against the client the request claims to be",
|
|
4650
|
+
cost: "cheap",
|
|
4651
|
+
stage: "always",
|
|
4652
|
+
inspect(ctx) {
|
|
4653
|
+
if (!claimsBrowser(ctx.ua)) return void 0;
|
|
4654
|
+
const results = [];
|
|
4655
|
+
const version = ctx.facts.httpVersion;
|
|
4656
|
+
if (checkLegacyHttp && version !== void 0 && LEGACY_VERSIONS.has(version)) {
|
|
4657
|
+
results.push({
|
|
4658
|
+
detector: "transport-coherence",
|
|
4659
|
+
summary: `Client claims to be a browser but negotiated HTTP/${version}, which no shipping browser has offered in over a decade`,
|
|
4660
|
+
direction: "bot",
|
|
4661
|
+
certainty: "moderate",
|
|
4662
|
+
botClass: "impersonator",
|
|
4663
|
+
// One downgrading proxy in front of the application does this to every request
|
|
4664
|
+
// that passes through it, so this must count once rather than once per reason.
|
|
4665
|
+
family: "legacy-transport",
|
|
4666
|
+
metadata: { httpVersion: version, browser: ctx.ua.browser }
|
|
4667
|
+
});
|
|
4668
|
+
}
|
|
4669
|
+
const methods = ctx.state.methodsSeen;
|
|
4670
|
+
if (ctx.state.total >= minHeadRequests && methods.length > 0 && methods.every((method) => method === "HEAD")) {
|
|
4671
|
+
results.push({
|
|
4672
|
+
detector: "transport-coherence",
|
|
4673
|
+
summary: `Client claims to be a browser but has issued nothing but HEAD across ${ctx.state.total} requests`,
|
|
4674
|
+
direction: "bot",
|
|
4675
|
+
certainty: "moderate",
|
|
4676
|
+
botClass: "scraper",
|
|
4677
|
+
metadata: { requests: ctx.state.total, browser: ctx.ua.browser }
|
|
4678
|
+
});
|
|
4679
|
+
}
|
|
4680
|
+
return results.length > 0 ? results : void 0;
|
|
4681
|
+
}
|
|
4682
|
+
};
|
|
4683
|
+
}
|
|
4684
|
+
var LEGACY_VERSIONS;
|
|
4685
|
+
var init_transport_coherence = __esm({
|
|
4686
|
+
"src/detectors/transport-coherence.ts"() {
|
|
4687
|
+
"use strict";
|
|
4688
|
+
init_ua();
|
|
4689
|
+
LEGACY_VERSIONS = /* @__PURE__ */ new Set(["0.9", "1.0"]);
|
|
4690
|
+
}
|
|
4691
|
+
});
|
|
4692
|
+
|
|
4693
|
+
// src/detectors/probe-volume.ts
|
|
4694
|
+
function probeVolumeDetector(options = {}) {
|
|
4695
|
+
const minResponses = options.minResponses ?? 20;
|
|
4696
|
+
const missRatio = options.missRatio ?? 0.8;
|
|
4697
|
+
return {
|
|
4698
|
+
id: "probe-volume",
|
|
4699
|
+
description: "Reads the share of an actor's requests that were answered 404 or 410, where the application reports them",
|
|
4700
|
+
cost: "cheap",
|
|
4701
|
+
stage: "always",
|
|
4702
|
+
inspect(ctx) {
|
|
4703
|
+
const { responses, misses } = ctx.state;
|
|
4704
|
+
if (responses < minResponses) return void 0;
|
|
4705
|
+
const ratio = misses / responses;
|
|
4706
|
+
if (ratio < missRatio) return void 0;
|
|
4707
|
+
return {
|
|
4708
|
+
detector: "probe-volume",
|
|
4709
|
+
summary: `${misses} of this client's last ${responses} requests were answered "not found" (${(ratio * 100).toFixed(0)}%)`,
|
|
4710
|
+
direction: "bot",
|
|
4711
|
+
certainty: "moderate",
|
|
4712
|
+
botClass: "scanner",
|
|
4713
|
+
metadata: { responses, misses, missRatio: Number(ratio.toFixed(3)) }
|
|
4714
|
+
};
|
|
4715
|
+
}
|
|
4716
|
+
};
|
|
4717
|
+
}
|
|
4718
|
+
var init_probe_volume = __esm({
|
|
4719
|
+
"src/detectors/probe-volume.ts"() {
|
|
4720
|
+
"use strict";
|
|
4721
|
+
}
|
|
4722
|
+
});
|
|
4723
|
+
|
|
4724
|
+
// src/detectors/id-enumeration.ts
|
|
4725
|
+
function idEnumerationDetector(options = {}) {
|
|
4726
|
+
const minRequests = options.minRequests ?? 30;
|
|
4727
|
+
const density = options.density ?? 0.9;
|
|
4728
|
+
return {
|
|
4729
|
+
id: "id-enumeration",
|
|
4730
|
+
description: "Reports an actor covering a contiguous range of numeric identifiers under one path shape",
|
|
4731
|
+
cost: "cheap",
|
|
4732
|
+
stage: "always",
|
|
4733
|
+
inspect(ctx) {
|
|
4734
|
+
const walk = ctx.state.densestWalk();
|
|
4735
|
+
if (walk === void 0 || walk.count < minRequests) return void 0;
|
|
4736
|
+
if (walk.span < minRequests) return void 0;
|
|
4737
|
+
const covered = Math.min(1, walk.count / walk.span);
|
|
4738
|
+
if (covered < density) return void 0;
|
|
4739
|
+
return {
|
|
4740
|
+
detector: "id-enumeration",
|
|
4741
|
+
summary: `${walk.count} requests to ${walk.template} covering ${(covered * 100).toFixed(0)}% of a ${walk.span}-wide range of ids`,
|
|
4742
|
+
direction: "bot",
|
|
4743
|
+
certainty: "moderate",
|
|
4744
|
+
botClass: "scraper",
|
|
4745
|
+
metadata: { template: walk.template, requests: walk.count, span: walk.span, coverage: Number(covered.toFixed(3)) }
|
|
4746
|
+
};
|
|
4747
|
+
}
|
|
4748
|
+
};
|
|
4749
|
+
}
|
|
4750
|
+
var init_id_enumeration = __esm({
|
|
4751
|
+
"src/detectors/id-enumeration.ts"() {
|
|
4752
|
+
"use strict";
|
|
4753
|
+
}
|
|
4754
|
+
});
|
|
4755
|
+
|
|
4426
4756
|
// src/detectors/crawler-verification.ts
|
|
4427
4757
|
function crawlerVerificationDetector(options = {}) {
|
|
4428
4758
|
const missingPtrIsForgery = options.treatMissingPtrAsForgery ?? true;
|
|
@@ -5600,11 +5930,15 @@ function defaultDetectors(options = {}) {
|
|
|
5600
5930
|
clientHintsDetector(),
|
|
5601
5931
|
fetchMetadataDetector(),
|
|
5602
5932
|
acceptSignatureDetector(),
|
|
5933
|
+
transportCoherenceDetector(),
|
|
5603
5934
|
headerOrderDetector(),
|
|
5604
5935
|
// Behaviour across requests.
|
|
5605
5936
|
rateAnomalyDetector(),
|
|
5606
5937
|
cadenceDetector(),
|
|
5607
5938
|
crawlBreadthDetector(),
|
|
5939
|
+
parameterSweepDetector(),
|
|
5940
|
+
probeVolumeDetector(),
|
|
5941
|
+
idEnumerationDetector(),
|
|
5608
5942
|
sessionIntegrityDetector(),
|
|
5609
5943
|
// The other side of the argument: what a real browsing session looks like.
|
|
5610
5944
|
browsingCoherenceDetector(),
|
|
@@ -5620,6 +5954,10 @@ var init_detectors = __esm({
|
|
|
5620
5954
|
init_cadence();
|
|
5621
5955
|
init_client_hints();
|
|
5622
5956
|
init_crawl_breadth();
|
|
5957
|
+
init_parameter_sweep();
|
|
5958
|
+
init_transport_coherence();
|
|
5959
|
+
init_probe_volume();
|
|
5960
|
+
init_id_enumeration();
|
|
5623
5961
|
init_crawler_verification();
|
|
5624
5962
|
init_fetch_metadata();
|
|
5625
5963
|
init_header_integrity();
|
|
@@ -6471,6 +6809,11 @@ function assessmentFromEntry(entry) {
|
|
|
6471
6809
|
key: entry.actor,
|
|
6472
6810
|
requests: entry.actorStats.requests,
|
|
6473
6811
|
distinctPaths: entry.actorStats.distinctPaths,
|
|
6812
|
+
distinctQueries: 0,
|
|
6813
|
+
queriesSaturated: false,
|
|
6814
|
+
methodsSeen: ["GET"],
|
|
6815
|
+
responses: 0,
|
|
6816
|
+
misses: 0,
|
|
6474
6817
|
firstSeen: entry.actorStats.firstSeen,
|
|
6475
6818
|
lastSeen: entry.at,
|
|
6476
6819
|
...entry.actorStats.sinceLastMs !== void 0 ? { sinceLastMs: entry.actorStats.sinceLastMs } : {},
|
|
@@ -6739,7 +7082,10 @@ button[disabled] { opacity: .5; cursor: default; }
|
|
|
6739
7082
|
.tab[aria-selected="true"] { color: var(--ink); border-bottom-color: var(--s1); }
|
|
6740
7083
|
|
|
6741
7084
|
/* --- layout ------------------------------------------------------------- */
|
|
6742
|
-
|
|
7085
|
+
/* The top padding is the gap under the sticky header. At 18px the counter row sat almost
|
|
7086
|
+
against the header's border and read as part of it; the tiles carry their own border, so
|
|
7087
|
+
two lines were meeting with nothing between them. */
|
|
7088
|
+
main { padding: 28px 20px 64px; max-width: 1680px; margin: 0 auto; }
|
|
6743
7089
|
.stack { display: grid; gap: 16px; }
|
|
6744
7090
|
/* Everything above the feed is drawn by script once the first snapshot arrives, which
|
|
6745
7091
|
inserts a block of content above what is already laid out. The browser's scroll
|
|
@@ -9147,6 +9493,24 @@ var init_core = __esm({
|
|
|
9147
9493
|
this.warn(`Actor "${key}" was cleared as human at runtime${attribute(context)}, until ${new Date(until).toISOString()}.`);
|
|
9148
9494
|
this.events.emit("actor-change", { key, action: "clear", until, by: context.by });
|
|
9149
9495
|
}
|
|
9496
|
+
/**
|
|
9497
|
+
* Tells the engine what the application answered.
|
|
9498
|
+
*
|
|
9499
|
+
* The one thing detection cannot see for itself. Every verdict here is reached *before*
|
|
9500
|
+
* the response exists — that is what makes it useful, since it can shape the response —
|
|
9501
|
+
* and so the status is knowledge only the application holds. Handed back, it closes the
|
|
9502
|
+
* oldest gap in reading a scanner: an actor whose requests are almost all misses is
|
|
9503
|
+
* looking for something rather than reading anything, and no amount of header analysis
|
|
9504
|
+
* shows that.
|
|
9505
|
+
*
|
|
9506
|
+
* Optional, and silent when the actor has already been forgotten. Nothing about
|
|
9507
|
+
* detection depends on it being called; supplying it sharpens `probe-volume` and
|
|
9508
|
+
* nothing else. The bundled Node adapter wires it up for you.
|
|
9509
|
+
*/
|
|
9510
|
+
recordOutcome(facts, status) {
|
|
9511
|
+
if (!Number.isFinite(status)) return;
|
|
9512
|
+
this.registry.peek(this.actorKeyFor(facts))?.recordOutcome(status);
|
|
9513
|
+
}
|
|
9150
9514
|
/** Convenience for `updateRanges("crawler:<id>", …)`, matching a signature id. */
|
|
9151
9515
|
updateCrawlerRanges(signatureId, entries, context = {}) {
|
|
9152
9516
|
this.updateRanges(`crawler:${signatureId}`, entries, context);
|
|
@@ -9536,6 +9900,11 @@ var init_core = __esm({
|
|
|
9536
9900
|
key: actorKey,
|
|
9537
9901
|
requests: 0,
|
|
9538
9902
|
distinctPaths: 0,
|
|
9903
|
+
distinctQueries: 0,
|
|
9904
|
+
queriesSaturated: false,
|
|
9905
|
+
methodsSeen: ["GET"],
|
|
9906
|
+
responses: 0,
|
|
9907
|
+
misses: 0,
|
|
9539
9908
|
firstSeen: facts.timestamp,
|
|
9540
9909
|
lastSeen: facts.timestamp,
|
|
9541
9910
|
priorConfirmations: 0,
|
|
@@ -10346,6 +10715,89 @@ var init_adversarial = __esm({
|
|
|
10346
10715
|
init_ranges();
|
|
10347
10716
|
CHROME_UA = userAgentOf("chromeWindows");
|
|
10348
10717
|
ADVERSARIAL_CASES = [
|
|
10718
|
+
bot({
|
|
10719
|
+
id: "id-harvest-contiguous",
|
|
10720
|
+
title: "Every profile id in order, with a copied browser header set",
|
|
10721
|
+
audience: "hostile",
|
|
10722
|
+
category: "scraping",
|
|
10723
|
+
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.",
|
|
10724
|
+
requests: repeat({ ...browser("chromeWindows"), ip: "198.51.100.65" }, 40, 800, (index) => `/user/${index + 1}`),
|
|
10725
|
+
expect: {
|
|
10726
|
+
// One `moderate` signal against a flawless header set, like the others here. What
|
|
10727
|
+
// changed is that the walk is now *visible* — before this detector it was scored
|
|
10728
|
+
// identically to a hundred and twenty scattered ids and to ordinary article paths.
|
|
10729
|
+
verdict: "unknown",
|
|
10730
|
+
detectors: ["id-enumeration"]
|
|
10731
|
+
},
|
|
10732
|
+
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."
|
|
10733
|
+
}),
|
|
10734
|
+
bot({
|
|
10735
|
+
id: "wordlist-scan-mostly-misses",
|
|
10736
|
+
title: "A wordlist walked with a copied browser header set, almost all of it missing",
|
|
10737
|
+
audience: "hostile",
|
|
10738
|
+
category: "scanning",
|
|
10739
|
+
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.",
|
|
10740
|
+
requests: repeat({ ...browser("chromeWindows"), ip: "198.51.100.64", status: 404 }, 30, 700, (index) => `/${["admin", "backup", "old", "test", "config", "db"][index % 6]}-${index}`),
|
|
10741
|
+
expect: {
|
|
10742
|
+
// One `moderate` signal against an otherwise flawless header set does not cross the
|
|
10743
|
+
// line, and it should not: a site that has just moved its URLs produces the same
|
|
10744
|
+
// shape from ordinary readers. Raising the ceiling so this case reads better would
|
|
10745
|
+
// be tuning the detector to the test rather than to the traffic.
|
|
10746
|
+
verdict: "unknown",
|
|
10747
|
+
detectors: ["probe-volume"]
|
|
10748
|
+
},
|
|
10749
|
+
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."
|
|
10750
|
+
}),
|
|
10751
|
+
bot({
|
|
10752
|
+
id: "browser-claim-over-http-1-0",
|
|
10753
|
+
title: "A perfect Chrome header set, arriving over HTTP/1.0",
|
|
10754
|
+
audience: "hostile",
|
|
10755
|
+
category: "impersonation",
|
|
10756
|
+
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.",
|
|
10757
|
+
requests: repeat({ ...browser("chromeWindows"), ip: "198.51.100.62", httpVersion: "1.0" }, 30, 900, (index) => `/${["news", "about", "blog", "help", "terms"][index % 5]}`),
|
|
10758
|
+
expect: {
|
|
10759
|
+
// Contributes rather than concludes. On its own, against an otherwise flawless
|
|
10760
|
+
// header set, one `moderate` signal does not reach the threshold — and it should
|
|
10761
|
+
// not, because an intermediary can cause this. Beside anything sharper it does.
|
|
10762
|
+
verdict: "unknown",
|
|
10763
|
+
detectors: ["transport-coherence"]
|
|
10764
|
+
},
|
|
10765
|
+
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."
|
|
10766
|
+
}),
|
|
10767
|
+
bot({
|
|
10768
|
+
id: "head-only-visit",
|
|
10769
|
+
title: "A visit made entirely of HEAD, claiming a browser",
|
|
10770
|
+
audience: "unwanted-bot",
|
|
10771
|
+
category: "scraping",
|
|
10772
|
+
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.",
|
|
10773
|
+
requests: repeat({ ...browser("chromeWindows"), ip: "198.51.100.63", method: "HEAD" }, 30, 900, (index) => `/${["news", "about", "blog", "help", "terms"][index % 5]}`),
|
|
10774
|
+
expect: {
|
|
10775
|
+
// As above: a shape worth reporting, not worth concluding from alone.
|
|
10776
|
+
verdict: "unknown",
|
|
10777
|
+
detectors: ["transport-coherence"]
|
|
10778
|
+
},
|
|
10779
|
+
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`."
|
|
10780
|
+
}),
|
|
10781
|
+
bot({
|
|
10782
|
+
id: "catalogue-sweep-by-page",
|
|
10783
|
+
title: "A catalogue taken a page at a time, with the path never changing",
|
|
10784
|
+
audience: "unwanted-bot",
|
|
10785
|
+
category: "scraping",
|
|
10786
|
+
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.",
|
|
10787
|
+
requests: repeat({ ...browser("chromeWindows"), ip: "198.51.100.61" }, 40, 900, (index) => `/products?page=${index}`),
|
|
10788
|
+
expect: {
|
|
10789
|
+
// Not proven, and not even suspected at this pace. Said plainly because it is true:
|
|
10790
|
+
// headers this clean leave only behaviour, behaviour is weak by construction, and a
|
|
10791
|
+
// collector polite enough to space its requests stays under the line. What changed
|
|
10792
|
+
// is that it no longer scores *lower* than the identical crawl expressed as distinct
|
|
10793
|
+
// paths — measured at a faster pace before this detector existed, the two differed by
|
|
10794
|
+
// seven points and only the path version crossed; they now score the same at every
|
|
10795
|
+
// volume tried.
|
|
10796
|
+
verdict: "unknown",
|
|
10797
|
+
detectors: ["parameter-sweep"]
|
|
10798
|
+
},
|
|
10799
|
+
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."
|
|
10800
|
+
}),
|
|
10349
10801
|
// ---------------------------------------------------------------------------
|
|
10350
10802
|
// Forged identities. The narrow case where a lie is provable.
|
|
10351
10803
|
// ---------------------------------------------------------------------------
|
|
@@ -13288,6 +13740,7 @@ async function runCase(handler, clock, item, startedAt, provides, assertActions
|
|
|
13288
13740
|
const withClearance = clearanceCookie === void 0 ? request : { ...request, headers: [...request.headers, ["Cookie", clearanceCookie]] };
|
|
13289
13741
|
const facts = toFacts(withClearance, fallbackIp, clock.now());
|
|
13290
13742
|
const { assessment, decision, outcome } = await handler.handle(facts);
|
|
13743
|
+
if (request.status !== void 0) handler.recordOutcome(facts, request.status);
|
|
13291
13744
|
requests.push({ assessment, decision, outcome });
|
|
13292
13745
|
}
|
|
13293
13746
|
const final = requests[requests.length - 1];
|