@osqd/bothandlerjs 0.6.0 → 0.7.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/CHANGELOG.md +227 -1
- package/README.md +18 -10
- package/dist/adapters/fastify.d.ts +10 -0
- package/dist/adapters/index.cjs +38 -13
- package/dist/adapters/index.cjs.map +1 -1
- package/dist/adapters/index.js +38 -13
- package/dist/adapters/index.js.map +1 -1
- package/dist/challenge/index.d.ts +40 -0
- package/dist/cli.cjs +1783 -83
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +1783 -83
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +55 -0
- package/dist/core.d.ts +33 -21
- package/dist/corpus/index.cjs +282 -8
- package/dist/corpus/index.cjs.map +1 -1
- package/dist/corpus/index.js +282 -8
- package/dist/corpus/index.js.map +1 -1
- package/dist/corpus/schema.d.ts +25 -0
- package/dist/dashboard/client/actions.d.ts +1 -1
- package/dist/dashboard/client/app.d.ts +9 -2
- package/dist/dashboard/client/boot.d.ts +32 -3
- package/dist/dashboard/client/query.d.ts +72 -12
- package/dist/dashboard/client/registry.d.ts +25 -0
- package/dist/dashboard/client/saved.d.ts +29 -0
- package/dist/dashboard/client/store.d.ts +16 -2
- package/dist/dashboard/client/types.d.ts +2 -0
- package/dist/dashboard/client.generated.d.ts +1 -1
- package/dist/dashboard/types.d.ts +15 -0
- package/dist/detectors/blended-identity.d.ts +34 -0
- package/dist/detectors/challenge-integrity.d.ts +26 -0
- package/dist/detectors/challenge-reaction.d.ts +39 -0
- package/dist/detectors/clearance.d.ts +1 -23
- package/dist/detectors/index.d.ts +16 -1
- package/dist/detectors/marker.d.ts +106 -0
- package/dist/detectors/probe-signature.d.ts +27 -0
- package/dist/detectors/site-baseline.d.ts +135 -0
- package/dist/detectors/target-integrity.d.ts +16 -0
- package/dist/detectors/trap.d.ts +10 -3
- package/dist/detectors/types.d.ts +17 -0
- package/dist/element/index.cjs +726 -79
- package/dist/element/index.cjs.map +1 -1
- package/dist/element/index.js +726 -79
- package/dist/element/index.js.map +1 -1
- package/dist/index.cjs +1657 -125
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +1643 -125
- package/dist/index.js.map +1 -1
- package/dist/internal/async.d.ts +0 -3
- package/dist/internal/ip.d.ts +18 -0
- package/dist/internal/text.d.ts +28 -0
- package/dist/metrics.d.ts +18 -0
- package/dist/probe/index.d.ts +153 -0
- package/dist/probe/marker.d.ts +119 -0
- package/dist/site/index.d.ts +122 -0
- package/dist/state.d.ts +130 -0
- package/dist/stores/redis.d.ts +24 -1
- package/dist/types.d.ts +70 -0
- package/docs/course/05-detectors.md +6 -5
- package/docs/course/06-identity.md +1 -1
- package/docs/course/16-proving-it.md +15 -9
- package/docs/course/index.md +1 -1
- package/docs/design/decisions.md +1 -1
- package/docs/detection/correlation.md +284 -0
- package/docs/detection/detectors.md +139 -3
- package/docs/detection/index.md +2 -1
- package/docs/detection/shadow-mode.md +147 -0
- package/docs/detection/signatures.md +1 -1
- package/docs/index.md +3 -2
- package/docs/integration/client-ip.md +16 -0
- package/docs/operations/dashboard.md +40 -1
- package/docs/operations/filters.md +143 -0
- package/docs/operations/index.md +1 -0
- package/docs/operations/metrics.md +18 -0
- package/docs/policy/presets.md +1 -1
- package/docs/start/choosing-a-policy.md +1 -1
- package/docs/start/first-integration.md +1 -1
- package/docs/start/installation.md +2 -2
- package/docs/testing/cli.md +7 -1
- package/docs/testing/corpus.md +12 -8
- package/docs/testing/index.md +1 -1
- package/docs/testing/try-it.md +1 -1
- package/package.json +4 -1
package/dist/config.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ import type { Decision } from "./policy/types.js";
|
|
|
13
13
|
import type { MetricsOptions } from "./metrics.js";
|
|
14
14
|
import type { NotificationOptions } from "./notify/hub.js";
|
|
15
15
|
import type { ChallengeOptions } from "./challenge/index.js";
|
|
16
|
+
import type { MarkerProbeOptions } from "./probe/index.js";
|
|
17
|
+
import type { SiteProfileOptions } from "./site/index.js";
|
|
16
18
|
import type { Assessment, RequestFacts } from "./types.js";
|
|
17
19
|
/**
|
|
18
20
|
* How the client address is recovered from behind a proxy.
|
|
@@ -61,6 +63,32 @@ export interface BotHandlerConfig {
|
|
|
61
63
|
detectors?: readonly Detector[];
|
|
62
64
|
/** Appended to the built-in set. Ignored when `detectors` is given. */
|
|
63
65
|
extraDetectors?: readonly Detector[];
|
|
66
|
+
/**
|
|
67
|
+
* Detector ids to run without letting them decide anything.
|
|
68
|
+
*
|
|
69
|
+
* A shadowed detector runs on every request exactly as it otherwise would. Its
|
|
70
|
+
* findings are counted, charted and readable in the dashboard and in every event —
|
|
71
|
+
* and they are kept out of the verdict, the score, the class, the identity and every
|
|
72
|
+
* rule. So the question "what would turning this on do to my traffic?" is answered by
|
|
73
|
+
* a week of your own logs instead of by an argument about thresholds.
|
|
74
|
+
*
|
|
75
|
+
* This is the honest way to introduce a detector, and the correlation sources are why
|
|
76
|
+
* it exists: several of them fire at `moderate` on real people by design — a mobile
|
|
77
|
+
* user roaming between networks trips `marker-fanout`, a crowd arriving on a broken
|
|
78
|
+
* link trips `path-campaign`, somebody toggling "Request desktop site" trips
|
|
79
|
+
* `identity-drift`. Whether the thresholds are right *for your site* is not a thing
|
|
80
|
+
* this library can know, and shadowing is how you find out without anybody being
|
|
81
|
+
* turned away while you do.
|
|
82
|
+
*
|
|
83
|
+
* ```js
|
|
84
|
+
* new BotHandler({ site: {}, shadowDetectors: ["path-novelty", "miss-baseline"] })
|
|
85
|
+
* ```
|
|
86
|
+
*
|
|
87
|
+
* An id that names no installed detector is a warning rather than an error: the usual
|
|
88
|
+
* cause is a typo, and a typo here silently does nothing, which is the one outcome
|
|
89
|
+
* worth being loud about.
|
|
90
|
+
*/
|
|
91
|
+
shadowDetectors?: readonly string[];
|
|
64
92
|
/**
|
|
65
93
|
* How claimed crawler identities are confirmed or refuted.
|
|
66
94
|
*
|
|
@@ -120,6 +148,28 @@ export interface BotHandlerConfig {
|
|
|
120
148
|
isHuman?: (facts: RequestFacts) => boolean;
|
|
121
149
|
/** Enables the challenge action. Without it, rules asking for one degrade to `tag`. */
|
|
122
150
|
challenge?: Omit<ChallengeOptions, "store" | "clock">;
|
|
151
|
+
/**
|
|
152
|
+
* Hand each client a signed marker cookie, and read what comes back.
|
|
153
|
+
*
|
|
154
|
+
* Off by default. It is the only part of this library that acts in order to detect,
|
|
155
|
+
* and it is what lets a series of requests be attributed to one *client* rather than
|
|
156
|
+
* to one address — which is the difference between "three people share an office
|
|
157
|
+
* connection" and "one client claimed to be three different browsers".
|
|
158
|
+
*
|
|
159
|
+
* See `docs/detection/correlation.md`. Requires `secrets`; without them it stays off
|
|
160
|
+
* and says so, because a secret invented at startup would mark every marker forged
|
|
161
|
+
* after a restart.
|
|
162
|
+
*/
|
|
163
|
+
probe?: Omit<MarkerProbeOptions, "clock">;
|
|
164
|
+
/**
|
|
165
|
+
* Compare each client against the rest of your traffic rather than against a fixed
|
|
166
|
+
* idea of what clients do.
|
|
167
|
+
*
|
|
168
|
+
* Off by default, and silent for the first `warmupRequests` after being switched on.
|
|
169
|
+
* A baseline drawn from a few hundred requests is not a baseline: every path is rare
|
|
170
|
+
* when nothing has been seen. See `docs/detection/correlation.md`.
|
|
171
|
+
*/
|
|
172
|
+
site?: Omit<SiteProfileOptions, "clock">;
|
|
123
173
|
store?: BotHandlerStore;
|
|
124
174
|
/**
|
|
125
175
|
* Share `priorConfirmations` between replicas through the store. Default false.
|
|
@@ -273,6 +323,11 @@ export interface BotHandlerConfig {
|
|
|
273
323
|
}
|
|
274
324
|
export interface ResolvedConfig {
|
|
275
325
|
detectors: Detector[];
|
|
326
|
+
/**
|
|
327
|
+
* {@link BotHandlerConfig.shadowDetectors}, as given. Not yet checked against the
|
|
328
|
+
* installed set — see the note at the assignment for why that has to wait.
|
|
329
|
+
*/
|
|
330
|
+
shadowDetectors: ReadonlySet<string>;
|
|
276
331
|
rules: Rule[];
|
|
277
332
|
ranges: Map<string, IpRangeSet>;
|
|
278
333
|
signatures: readonly BotSignature[];
|
package/dist/core.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { TrafficAudit } from "./audit.js";
|
|
|
4
4
|
import { NotificationHub } from "./notify/hub.js";
|
|
5
5
|
import { Policy } from "./policy/policy.js";
|
|
6
6
|
import type { GuardSettings } from "./policy/policy.js";
|
|
7
|
+
import { MarkerProbe } from "./probe/index.js";
|
|
8
|
+
import { SiteProfile } from "./site/index.js";
|
|
7
9
|
import type { ActionOutcome } from "./actions/types.js";
|
|
8
10
|
import type { Rule } from "./policy/types.js";
|
|
9
11
|
import type { Assessment, RequestFacts } from "./types.js";
|
|
@@ -177,34 +179,19 @@ export interface DetectorDescription {
|
|
|
177
179
|
description: string;
|
|
178
180
|
cost: "cheap" | "io";
|
|
179
181
|
stage: "always" | "confirming";
|
|
182
|
+
/** Running, and deciding nothing. See {@link BotHandlerConfig.shadowDetectors}. */
|
|
183
|
+
shadow?: true | undefined;
|
|
180
184
|
}
|
|
181
|
-
/**
|
|
182
|
-
* The engine.
|
|
183
|
-
*
|
|
184
|
-
* Three separable steps, in a fixed order, each usable on its own:
|
|
185
|
-
*
|
|
186
|
-
* 1. {@link assess} — gather evidence and reach a verdict. Reads the request, touches
|
|
187
|
-
* no response, and is safe to call anywhere, including from a log processor
|
|
188
|
-
* replaying yesterday's traffic.
|
|
189
|
-
* 2. {@link decide} — apply the policy, subject to the safety guard.
|
|
190
|
-
* 3. {@link handle} — do both, then turn the decision into an outcome an adapter can
|
|
191
|
-
* apply.
|
|
192
|
-
*
|
|
193
|
-
* Keeping them apart is what makes the library testable and what makes a
|
|
194
|
-
* monitor-only deployment a first-class mode rather than a configuration trick: call
|
|
195
|
-
* `assess` alone and you have a detector with no opinions about your traffic at all.
|
|
196
|
-
*
|
|
197
|
-
* Everything is failure-isolated. A detector that throws, a store that will not
|
|
198
|
-
* answer, a notification sink that hangs — each degrades exactly itself and is
|
|
199
|
-
* reported through `onError`. Nothing in this file can turn a bad day for a
|
|
200
|
-
* dependency into a bad day for the site it is protecting.
|
|
201
|
-
*/
|
|
202
185
|
export declare class BotHandler {
|
|
203
186
|
readonly config: ResolvedConfig;
|
|
204
187
|
readonly registry: ActorRegistry;
|
|
205
188
|
readonly store: BotHandlerStore;
|
|
206
189
|
readonly policy: Policy;
|
|
207
190
|
readonly challenge: ChallengeService | undefined;
|
|
191
|
+
/** The marker-cookie probe, when the operator asked for one. See `probe` in the config. */
|
|
192
|
+
readonly probe: MarkerProbe | undefined;
|
|
193
|
+
/** The site-wide baseline, when the operator asked for one. See `site` in the config. */
|
|
194
|
+
readonly site: SiteProfile | undefined;
|
|
208
195
|
readonly notifications: NotificationHub;
|
|
209
196
|
/**
|
|
210
197
|
* The traffic audit, or `undefined` when it was switched off with `audit: false`.
|
|
@@ -220,6 +207,8 @@ export declare class BotHandler {
|
|
|
220
207
|
private readonly cheapDetectors;
|
|
221
208
|
private readonly ioDetectors;
|
|
222
209
|
private readonly confirmingDetectors;
|
|
210
|
+
/** Hoisted from the resolved config: read once per detector per request. */
|
|
211
|
+
private readonly shadowIds;
|
|
223
212
|
private readonly events;
|
|
224
213
|
private readonly ignoreExact;
|
|
225
214
|
private readonly ignorePatterns;
|
|
@@ -326,6 +315,19 @@ export declare class BotHandler {
|
|
|
326
315
|
* nothing else. The bundled Node adapter wires it up for you.
|
|
327
316
|
*/
|
|
328
317
|
recordOutcome(facts: RequestFacts, status: number): void;
|
|
318
|
+
/**
|
|
319
|
+
* Gives an actor a name, or clears it with `undefined`.
|
|
320
|
+
*
|
|
321
|
+
* Detection never reads it — a label cannot make anybody more or less suspicious, and
|
|
322
|
+
* that separation is deliberate: the moment a note changes a verdict, writing notes
|
|
323
|
+
* becomes a way to be wrong about people at scale. It is for the humans reading the
|
|
324
|
+
* dashboard, and it survives exactly as long as the actor does.
|
|
325
|
+
*
|
|
326
|
+
* Available from code so a deployment can label what it already knows — its own
|
|
327
|
+
* monitoring, a partner's feed, the office egress — rather than waiting for somebody to
|
|
328
|
+
* recognise the address twice.
|
|
329
|
+
*/
|
|
330
|
+
labelActor(key: string, label: string | undefined, context?: ChangeContext): void;
|
|
329
331
|
/** Convenience for `updateRanges("crawler:<id>", …)`, matching a signature id. */
|
|
330
332
|
updateCrawlerRanges(signatureId: string, entries: readonly string[], context?: ChangeContext): void;
|
|
331
333
|
/**
|
|
@@ -428,6 +430,16 @@ export declare class BotHandler {
|
|
|
428
430
|
decide(assessment: Assessment): Decision;
|
|
429
431
|
/** Assess, decide, and turn the decision into an outcome for an adapter to apply. */
|
|
430
432
|
handle(facts: RequestFacts): Promise<HandleResult>;
|
|
433
|
+
/**
|
|
434
|
+
* The `Set-Cookie` this response should carry, if any.
|
|
435
|
+
*
|
|
436
|
+
* Nothing is issued to a client that already holds a valid marker, because a
|
|
437
|
+
* `Set-Cookie` on every response makes every response uncacheable by shared caches —
|
|
438
|
+
* a detection feature is not worth a site's cache-hit ratio. Nothing is issued to a
|
|
439
|
+
* verified crawler either: Googlebot does not keep cookies, so a marker sent to it is
|
|
440
|
+
* a header that will never come back and an issuance count that means nothing.
|
|
441
|
+
*/
|
|
442
|
+
private markerFor;
|
|
431
443
|
/** True when this request is the challenge verification endpoint. */
|
|
432
444
|
isChallengeEndpoint(facts: Pick<RequestFacts, "method" | "path">): boolean;
|
|
433
445
|
/**
|
package/dist/corpus/index.cjs
CHANGED
|
@@ -327,18 +327,21 @@ function createFacts(input) {
|
|
|
327
327
|
const rawPath = queryStart === -1 ? url : url.slice(0, queryStart);
|
|
328
328
|
const headers = /* @__PURE__ */ Object.create(null);
|
|
329
329
|
for (const [name, value] of Object.entries(input.headers)) {
|
|
330
|
-
const
|
|
331
|
-
|
|
330
|
+
const lower = name.toLowerCase();
|
|
331
|
+
const joined = lower === "cookie" && Array.isArray(value) ? value.join("; ") : joinHeaderValue(value);
|
|
332
|
+
if (joined !== void 0) headers[lower] = joined;
|
|
332
333
|
}
|
|
334
|
+
const normalized = normalizePath(rawPath);
|
|
333
335
|
const facts = {
|
|
334
336
|
method: (input.method ?? "GET").toUpperCase(),
|
|
335
|
-
path:
|
|
337
|
+
path: normalized,
|
|
336
338
|
query: parseQuery(queryStart === -1 ? "" : url.slice(queryStart + 1)),
|
|
337
339
|
headers,
|
|
338
340
|
headerOrder: extractOrder(input.rawHeaders, headers),
|
|
339
341
|
ip: normalizeIp(input.ip) ?? input.ip,
|
|
340
342
|
timestamp: input.timestamp ?? Date.now()
|
|
341
343
|
};
|
|
344
|
+
if (rawPath !== normalized) facts.rawPath = rawPath.length > MAX_RAW_PATH ? rawPath.slice(0, MAX_RAW_PATH) : rawPath;
|
|
342
345
|
const cookieHeader = headers["cookie"];
|
|
343
346
|
if (cookieHeader !== void 0) facts.cookies = parseCookies(cookieHeader);
|
|
344
347
|
if (input.protocol !== void 0) facts.protocol = input.protocol;
|
|
@@ -371,12 +374,23 @@ function parseQuery(search) {
|
|
|
371
374
|
const query = /* @__PURE__ */ Object.create(null);
|
|
372
375
|
if (search.length === 0) return query;
|
|
373
376
|
let count = 0;
|
|
374
|
-
for (const [key, value] of new URLSearchParams(search)) {
|
|
377
|
+
for (const [key, value] of new URLSearchParams(boundedSearch(search))) {
|
|
375
378
|
if (count++ >= MAX_QUERY_PARAMS) break;
|
|
376
379
|
query[key] = value.length > 1024 ? value.slice(0, 1024) : value;
|
|
377
380
|
}
|
|
378
381
|
return query;
|
|
379
382
|
}
|
|
383
|
+
function boundedSearch(search) {
|
|
384
|
+
let seen = 0;
|
|
385
|
+
let at = search.charCodeAt(0) === 63 ? 1 : 0;
|
|
386
|
+
while (at < search.length) {
|
|
387
|
+
let end = search.indexOf("&", at);
|
|
388
|
+
if (end === -1) end = search.length;
|
|
389
|
+
if (end !== at && ++seen > MAX_QUERY_PARAMS) return search.slice(0, at - 1);
|
|
390
|
+
at = end + 1;
|
|
391
|
+
}
|
|
392
|
+
return search;
|
|
393
|
+
}
|
|
380
394
|
function extractOrder(rawHeaders, headers) {
|
|
381
395
|
if (!rawHeaders || rawHeaders.length === 0) return EMPTY_ORDER;
|
|
382
396
|
let isNodeStyle = rawHeaders.length % 2 === 0;
|
|
@@ -412,13 +426,14 @@ function isHeaderName(value) {
|
|
|
412
426
|
}
|
|
413
427
|
return true;
|
|
414
428
|
}
|
|
415
|
-
var MAX_URL_LENGTH, MAX_QUERY_PARAMS, MAX_ORDERED_HEADERS, EMPTY_ORDER;
|
|
429
|
+
var MAX_URL_LENGTH, MAX_RAW_PATH, MAX_QUERY_PARAMS, MAX_ORDERED_HEADERS, EMPTY_ORDER;
|
|
416
430
|
var init_facts = __esm({
|
|
417
431
|
"src/facts.ts"() {
|
|
418
432
|
"use strict";
|
|
419
433
|
init_http();
|
|
420
434
|
init_ip();
|
|
421
435
|
MAX_URL_LENGTH = 8192;
|
|
436
|
+
MAX_RAW_PATH = 512;
|
|
422
437
|
MAX_QUERY_PARAMS = 64;
|
|
423
438
|
MAX_ORDERED_HEADERS = 64;
|
|
424
439
|
EMPTY_ORDER = Object.freeze([]);
|
|
@@ -1207,7 +1222,34 @@ var AI_CRAWLER_CASES = [
|
|
|
1207
1222
|
|
|
1208
1223
|
// src/corpus/adversarial.ts
|
|
1209
1224
|
var CHROME_UA = userAgentOf("chromeWindows");
|
|
1225
|
+
var CURL_UA = "curl/8.4.0";
|
|
1210
1226
|
var ADVERSARIAL_CASES = [
|
|
1227
|
+
bot({
|
|
1228
|
+
id: "two-scanners-one-address",
|
|
1229
|
+
title: "One address arriving as two different security tools",
|
|
1230
|
+
audience: "hostile",
|
|
1231
|
+
category: "scanning",
|
|
1232
|
+
provenance: "The shape of an actual scan: an operator runs more than one tool against a target, and both announce themselves honestly. Each request on its own is a declared bot; the pair is a scan, and that reading does not exist inside either request.",
|
|
1233
|
+
requests: [
|
|
1234
|
+
{ headers: [["Host", "shop.example"], ["User-Agent", "sqlmap/1.7.2#stable (http://sqlmap.org)"], ["Accept", "*/*"]], ip: "198.51.100.66", atMs: 0 },
|
|
1235
|
+
{ headers: [["Host", "shop.example"], ["User-Agent", "Mozilla/5.00 (Nikto/2.5.0) (Evasions:None) (Test:Port Check)"], ["Accept", "*/*"]], ip: "198.51.100.66", atMs: 1e3 }
|
|
1236
|
+
],
|
|
1237
|
+
expect: { verdict: "confirmed-bot", certain: true, detectors: ["blended-identity"] },
|
|
1238
|
+
notes: "Holds under the default address-based actor key, which is what separates it from `identity-rotation`. A NAT gateway presents a hundred browsers \u2014 that is exactly why counting User-Agents there is useless \u2014 and it does not present sqlmap and nikto."
|
|
1239
|
+
}),
|
|
1240
|
+
bot({
|
|
1241
|
+
id: "two-crawler-claims-one-address",
|
|
1242
|
+
title: "One address claiming to be both Googlebot and Bingbot",
|
|
1243
|
+
audience: "hostile",
|
|
1244
|
+
category: "impersonation",
|
|
1245
|
+
provenance: "At most one of these can be true of an address: each operator publishes a proof tied to addresses it controls. The contradiction is visible from the claims alone, with no lookup \u2014 which matters when DNS is unreachable and neither claim can be refuted on its own.",
|
|
1246
|
+
requests: [
|
|
1247
|
+
{ headers: [["Host", "shop.example"], ["User-Agent", "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"], ["Accept", "*/*"]], ip: "198.51.100.67", atMs: 0 },
|
|
1248
|
+
{ headers: [["Host", "shop.example"], ["User-Agent", "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)"], ["Accept", "*/*"]], ip: "198.51.100.67", atMs: 1e3 }
|
|
1249
|
+
],
|
|
1250
|
+
expect: { detectors: ["blended-identity"] },
|
|
1251
|
+
notes: "Held at `strong` rather than `certain`. Trusting the wrong forwarded header collapses every client onto one address, and then two genuinely different crawlers produce this exact set \u2014 so it may contribute to a denial and may not be the whole of one."
|
|
1252
|
+
}),
|
|
1211
1253
|
bot({
|
|
1212
1254
|
id: "id-harvest-contiguous",
|
|
1213
1255
|
title: "Every profile id in order, with a copied browser header set",
|
|
@@ -1257,6 +1299,137 @@ var ADVERSARIAL_CASES = [
|
|
|
1257
1299
|
},
|
|
1258
1300
|
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."
|
|
1259
1301
|
}),
|
|
1302
|
+
// ---------------------------------------------------------------------------
|
|
1303
|
+
// The optional sources. None of these can be detected without the operator
|
|
1304
|
+
// switching something on — a marker cookie, or a site-wide baseline — so each
|
|
1305
|
+
// exists to hold that feature to the same standard as everything shipped by
|
|
1306
|
+
// default. See `docs/detection/correlation.md`.
|
|
1307
|
+
// ---------------------------------------------------------------------------
|
|
1308
|
+
bot({
|
|
1309
|
+
id: "marker-held-while-identity-changes",
|
|
1310
|
+
title: "One client presenting a marker it was issued as Chrome, then as curl",
|
|
1311
|
+
audience: "unwanted-bot",
|
|
1312
|
+
category: "evasion",
|
|
1313
|
+
provenance: "Identity rotation, which is invisible without a marker. Correlating by address cannot tell this apart from two people sharing an office connection, so the library declined to guess. A signed cookie removes the ambiguity: both requests carried an HMAC only this server can produce.",
|
|
1314
|
+
requires: ["marker-probe"],
|
|
1315
|
+
requests: [
|
|
1316
|
+
...repeat({ ...browser("chromeWindows"), ip: "198.51.100.81", headers: [...browser("chromeWindows").headers, ["Cookie", "sid=held"]] }, 4, 1500, (i) => `/products/${i}`),
|
|
1317
|
+
...repeat({ ...plain(CURL_UA), ip: "198.51.100.81", headers: [...plain(CURL_UA).headers, ["Cookie", "sid=held"]] }, 4, 1500, (i) => `/products/${i + 4}`).map((request) => ({ ...request, atMs: (request.atMs ?? 0) + 6e3 }))
|
|
1318
|
+
],
|
|
1319
|
+
expect: { verdict: "confirmed-bot", detectors: ["identity-drift"] },
|
|
1320
|
+
notes: "The browser family carries the weight and the platform does not, because a phone with `Request desktop site` changes its platform and is a person. Software does not change what it is."
|
|
1321
|
+
}),
|
|
1322
|
+
bot({
|
|
1323
|
+
id: "marker-never-stored-though-cookies-sent",
|
|
1324
|
+
title: "A client replaying a captured session cookie and storing nothing new",
|
|
1325
|
+
audience: "unwanted-bot",
|
|
1326
|
+
category: "scraping",
|
|
1327
|
+
provenance: "A scraper handed a session header to copy. It sends the one cookie it was configured with on every request and never stores anything the server sets, which a browser with a jar does not do.",
|
|
1328
|
+
requires: ["marker-probe"],
|
|
1329
|
+
keepsCookies: false,
|
|
1330
|
+
requests: repeat(
|
|
1331
|
+
{ ...browser("chromeWindows"), ip: "198.51.100.82", headers: [...browser("chromeWindows").headers, ["Cookie", "sid=captured-elsewhere"]] },
|
|
1332
|
+
9,
|
|
1333
|
+
1200,
|
|
1334
|
+
(i) => `/products/${i}`
|
|
1335
|
+
),
|
|
1336
|
+
expect: { verdict: "unknown", detectors: ["marker-persistence"] },
|
|
1337
|
+
notes: "Deliberately narrower than `session-integrity`, which already reports a client sending no cookie at all. Overlapping them double-counted one observation and the population it landed on was people who block cookies."
|
|
1338
|
+
}),
|
|
1339
|
+
bot({
|
|
1340
|
+
id: "marker-edited-by-its-holder",
|
|
1341
|
+
title: "A client that edited the signed cookie it was given",
|
|
1342
|
+
audience: "unwanted-bot",
|
|
1343
|
+
category: "evasion",
|
|
1344
|
+
provenance: "Browsers do not edit their own cookies. A marker failing its HMAC was altered by whoever held it, and the only reason to alter an opaque signed value is to see what the server does with a different one.",
|
|
1345
|
+
requires: ["marker-probe"],
|
|
1346
|
+
keepsCookies: false,
|
|
1347
|
+
requests: repeat(
|
|
1348
|
+
{ ...browser("chromeWindows"), ip: "198.51.100.83", headers: [...browser("chromeWindows").headers, ["Cookie", "__bh_m=eyJ2IjoxfQ.not-a-signature-this-server-made"]] },
|
|
1349
|
+
4,
|
|
1350
|
+
1500,
|
|
1351
|
+
(i) => `/account/${i}`
|
|
1352
|
+
),
|
|
1353
|
+
expect: { verdict: "unknown", detectors: ["marker-integrity"] },
|
|
1354
|
+
notes: "Stops at `strong` rather than `certain` because a middlebox or a broken cookie jar can mangle a value in transit. That is rare, it is not the client's fault, and it should cost a challenge rather than a door."
|
|
1355
|
+
}),
|
|
1356
|
+
bot({
|
|
1357
|
+
id: "marker-carried-across-a-proxy-pool",
|
|
1358
|
+
title: "One marker presented from twenty different networks",
|
|
1359
|
+
audience: "unwanted-bot",
|
|
1360
|
+
category: "scraping",
|
|
1361
|
+
provenance: "A scraper on a rotating proxy pool that keeps its cookie jar, which most of them do because discarding it breaks the sites they are taking. The marker comes back only from the client that received it, so this is one client across twenty networks.",
|
|
1362
|
+
requires: ["marker-probe"],
|
|
1363
|
+
requests: Array.from({ length: 20 }, (_, index) => ({
|
|
1364
|
+
...browser("chromeWindows"),
|
|
1365
|
+
headers: [...browser("chromeWindows").headers, ["Cookie", "sid=pooled"]],
|
|
1366
|
+
ip: `198.51.${140 + index}.9`,
|
|
1367
|
+
path: `/catalogue/${index}`,
|
|
1368
|
+
atMs: index * 2500
|
|
1369
|
+
})),
|
|
1370
|
+
expect: { verdict: "unknown", detectors: ["marker-fanout"] },
|
|
1371
|
+
notes: "Capped at `moderate` and offered no higher: a phone on a carrier using CGNAT can be renumbered across a great many /24s in the twelve hours a marker lives, and so can anyone whose employer egresses through a rotating pool."
|
|
1372
|
+
}),
|
|
1373
|
+
bot({
|
|
1374
|
+
id: "range-walked-across-many-clients",
|
|
1375
|
+
title: "An id range divided between ten clients so none of them walks enough to notice",
|
|
1376
|
+
audience: "unwanted-bot",
|
|
1377
|
+
category: "scraping",
|
|
1378
|
+
provenance: "The threat every per-actor threshold misses by construction. Split a range across enough addresses and each one is unremarkable, `id-enumeration` fires for nobody, and the range is still walked end to end. It is only visible in the union.",
|
|
1379
|
+
requires: ["site-baseline"],
|
|
1380
|
+
requests: Array.from({ length: 200 }, (_, index) => ({
|
|
1381
|
+
...browser("chromeWindows"),
|
|
1382
|
+
ip: `198.51.${170 + index % 10}.5`,
|
|
1383
|
+
path: `/user/${index + 1}`,
|
|
1384
|
+
atMs: index * 900
|
|
1385
|
+
})),
|
|
1386
|
+
expect: { verdict: "unknown", detectors: ["distributed-walk"] },
|
|
1387
|
+
notes: "Coverage and the revisit ratio must both agree. Many clients on numbered pages is what a catalogue is; what a catalogue also has, and an enumeration does not, is people returning to the same popular items."
|
|
1388
|
+
}),
|
|
1389
|
+
bot({
|
|
1390
|
+
id: "fresh-path-wanted-by-everybody",
|
|
1391
|
+
title: "A path this site never served, requested at once by twenty unrelated clients",
|
|
1392
|
+
audience: "unwanted-bot",
|
|
1393
|
+
category: "recon",
|
|
1394
|
+
provenance: "What a freshly disclosed vulnerability looks like from inside a site: a URL nobody had ever requested is requested by hundreds of unrelated clients within the hour, each making a single request and moving on.",
|
|
1395
|
+
requires: ["site-baseline"],
|
|
1396
|
+
requests: Array.from({ length: 20 }, (_, index) => ({
|
|
1397
|
+
...plain(CURL_UA),
|
|
1398
|
+
ip: `198.51.${190 + index}.11`,
|
|
1399
|
+
path: "/vendor/proprietary-thing/rce.php",
|
|
1400
|
+
status: 404,
|
|
1401
|
+
atMs: index * 3e3
|
|
1402
|
+
})),
|
|
1403
|
+
expect: { verdict: "unknown", detectors: ["path-campaign"] },
|
|
1404
|
+
notes: "The miss rate is required rather than optional. Many clients arriving at once on a brand-new URL is also exactly what a successful launch looks like; what separates them is whether the site had anything to serve."
|
|
1405
|
+
}),
|
|
1406
|
+
bot({
|
|
1407
|
+
id: "missing-far-more-than-this-site-does",
|
|
1408
|
+
title: 'A client answered "not found" far more often than the site answers it at all',
|
|
1409
|
+
audience: "unwanted-bot",
|
|
1410
|
+
category: "recon",
|
|
1411
|
+
provenance: "A fixed miss threshold is wrong on both kinds of site: on one mid-migration it reports everybody, and on a tidy one it stays silent while a client misses a third of the time. The site's own rate is the only honest comparison.",
|
|
1412
|
+
requires: ["site-baseline"],
|
|
1413
|
+
requests: repeat({ ...plain(CURL_UA), ip: "198.51.210.12", status: 404 }, 26, 1100, (i) => `/backup-${i}.sql`),
|
|
1414
|
+
expect: { verdict: "unknown", detectors: ["miss-baseline"] },
|
|
1415
|
+
notes: "Shares the `misses` family with `probe-volume`, which reads the same misses against a fixed threshold. One cause, so the stronger reading stands rather than the two summing."
|
|
1416
|
+
}),
|
|
1417
|
+
bot({
|
|
1418
|
+
id: "solution-farm-replaying-answers",
|
|
1419
|
+
title: "A client answering challenges with solutions that have already been spent",
|
|
1420
|
+
audience: "unwanted-bot",
|
|
1421
|
+
category: "evasion",
|
|
1422
|
+
provenance: "What a solved-challenge farm looks like from the server. A challenge nonce is random, single-use and signed, so a second valid solution for one is the same answer sent twice or one answer handed around \u2014 neither of which a browser does. One replay is a retried POST on a flaky connection, which is why the threshold is not one.",
|
|
1423
|
+
challengeHistory: { replayedSolutions: 4, implausibleSolves: 2 },
|
|
1424
|
+
requests: repeat({ ...browser("chromeWindows"), ip: "198.51.100.71" }, 6, 1500, () => "/account"),
|
|
1425
|
+
expect: {
|
|
1426
|
+
// A shape worth reporting and not worth concluding from: the client is otherwise
|
|
1427
|
+
// indistinguishable from the browser whose headers it copied.
|
|
1428
|
+
verdict: "unknown",
|
|
1429
|
+
detectors: ["challenge-integrity"]
|
|
1430
|
+
},
|
|
1431
|
+
notes: "The proof-of-work floor is measured on the server between issuing and receiving, so no client clock is involved, and it is set at a SHA-256 rate no browser has ever reached. Both signals stay `moderate`: they say the answers did not come from the page we served, which is a fact about the answering software rather than proof about the traffic it is attached to."
|
|
1432
|
+
}),
|
|
1260
1433
|
bot({
|
|
1261
1434
|
id: "head-only-visit",
|
|
1262
1435
|
title: "A visit made entirely of HEAD, claiming a browser",
|
|
@@ -1633,6 +1806,38 @@ var ADVERSARIAL_CASES = [
|
|
|
1633
1806
|
expect: { certain: false, detectors: ["probe-signature"], neverAction: ["block", "drop"] },
|
|
1634
1807
|
tags: ["scanning"]
|
|
1635
1808
|
}),
|
|
1809
|
+
bot({
|
|
1810
|
+
id: "traversal-encoded-past-a-filter",
|
|
1811
|
+
title: "A traversal with its dots and slashes written in percent-encoding",
|
|
1812
|
+
audience: "hostile",
|
|
1813
|
+
category: "wordlist-probe",
|
|
1814
|
+
provenance: "The standard first move against a path filter, and the reason this library keeps the raw target: normalisation resolves the dots, so what reaches a wordlist check is `/app/config.yml` \u2014 an ordinary-looking path nobody has, on no list. The spelling is the whole signal, and it is destroyed by the thing that makes rules work.",
|
|
1815
|
+
requests: [{ ...browser("chromeWindows"), path: "/%2e%2e%2f%2e%2e%2fapp/config.yml", status: 404 }],
|
|
1816
|
+
expect: { certain: false, detectors: ["target-integrity"], neverAction: ["block", "drop"] },
|
|
1817
|
+
notes: "`strong`, not proven. A path segment carrying a URL as data is encoded to sit in a path and encoded again by whatever built the link, which produces the same characters honestly \u2014 so this may score, and may not close a door on its own.",
|
|
1818
|
+
tags: ["scanning"]
|
|
1819
|
+
}),
|
|
1820
|
+
bot({
|
|
1821
|
+
id: "traversal-double-encoded",
|
|
1822
|
+
title: "A traversal encoded twice, so one round of decoding leaves it encoded",
|
|
1823
|
+
audience: "hostile",
|
|
1824
|
+
category: "wordlist-probe",
|
|
1825
|
+
provenance: "Aimed at a filter that decodes once and then inspects: after its single pass the target still reads `%2e%2e%2f`, which the filter does not recognise, and the server behind it decodes again.",
|
|
1826
|
+
requests: [{ ...browser("chromeWindows"), path: "/static/%252e%252e%252f%252e%252e%252fetc/passwd", status: 404 }],
|
|
1827
|
+
expect: { certain: false, detectors: ["target-integrity"], neverAction: ["block", "drop"] },
|
|
1828
|
+
tags: ["scanning"]
|
|
1829
|
+
}),
|
|
1830
|
+
bot({
|
|
1831
|
+
id: "absolute-form-proxy-probe",
|
|
1832
|
+
title: "A request target addressed to somewhere else entirely",
|
|
1833
|
+
audience: "hostile",
|
|
1834
|
+
category: "protocol-abuse",
|
|
1835
|
+
provenance: "Absolute-form is the request line a client sends to a *proxy*. Arriving at an origin server it is a question \u2014 will you fetch this for me \u2014 and open-proxy scanning asks it of everything with a port 80 open.",
|
|
1836
|
+
requests: [{ ...plain(CHROME_UA), path: "http://scanner.example/check", status: 404 }],
|
|
1837
|
+
expect: { certain: false, detectors: ["target-integrity"], neverAction: ["block", "drop"] },
|
|
1838
|
+
notes: "RFC 9112 \xA73.2.2 requires servers to accept absolute-form, so this is not malformed and is not proven. No browser has ever sent one to an origin server.",
|
|
1839
|
+
tags: ["scanning"]
|
|
1840
|
+
}),
|
|
1636
1841
|
bot({
|
|
1637
1842
|
id: "probe-trace-method",
|
|
1638
1843
|
title: "A TRACE request",
|
|
@@ -3213,6 +3418,46 @@ var HUMAN_CASES = [
|
|
|
3213
3418
|
],
|
|
3214
3419
|
expect: { verdict: "unknown", maxScore: 0, notDetectors: ["header-order"], action: "allow" }
|
|
3215
3420
|
}),
|
|
3421
|
+
human({
|
|
3422
|
+
id: "request-desktop-site-mid-visit",
|
|
3423
|
+
title: "Somebody switching their phone to the desktop version of a site",
|
|
3424
|
+
category: "mangled-by-infrastructure",
|
|
3425
|
+
provenance: "`Request desktop site` rewrites the User-Agent to claim a Mac. The browser is the same Safari and the cookie jar is the same jar, so the marker comes back \u2014 which means the library can see, correctly, that one client has now described itself two different ways.",
|
|
3426
|
+
requires: ["marker-probe"],
|
|
3427
|
+
notes: "The reason `identity-drift` weighs a changed *browser family* at `strong` and a changed platform only at `moderate`. Software does not change what it is; a platform changes when a person taps a menu item, and this is that person.",
|
|
3428
|
+
requests: [
|
|
3429
|
+
...humanPaced({ ...browser("safariIos"), ip: "203.0.115.24", headers: [...browser("safariIos").headers, ["Cookie", "sid=phone-session"]] }, [
|
|
3430
|
+
"/",
|
|
3431
|
+
"/collections/lamps",
|
|
3432
|
+
"/products/brass-desk-lamp",
|
|
3433
|
+
"/products/brass-desk-lamp/reviews"
|
|
3434
|
+
]),
|
|
3435
|
+
// The same person, same session, having tapped "Request desktop site".
|
|
3436
|
+
...humanPaced(
|
|
3437
|
+
{ ...browser("safariMac"), ip: "203.0.115.24", headers: [...browser("safariMac").headers, ["Cookie", "sid=phone-session"]] },
|
|
3438
|
+
["/products/brass-desk-lamp", "/delivery", "/products/brass-desk-lamp", "/basket"]
|
|
3439
|
+
).map((request) => ({ ...request, atMs: (request.atMs ?? 0) + 47e3 }))
|
|
3440
|
+
],
|
|
3441
|
+
expect: { certain: false, action: ["allow", "tag", "log", "delay", "challenge", "rate-limit"] },
|
|
3442
|
+
tags: ["known-cost"]
|
|
3443
|
+
}),
|
|
3444
|
+
human({
|
|
3445
|
+
id: "broken-link-shared-widely",
|
|
3446
|
+
title: "A crowd of people following one mistyped link",
|
|
3447
|
+
category: "mangled-by-infrastructure",
|
|
3448
|
+
provenance: "Somebody shares a URL with a typo in it and thousands of real people follow it within the hour. From the server this is a path the site has never served, requested by many unrelated clients, and answered `not found` to every one of them \u2014 which is the exact shape `path-campaign` reads.",
|
|
3449
|
+
requires: ["site-baseline"],
|
|
3450
|
+
notes: "The known cost of comparing a client with the rest of the traffic: most of the evidence is about what *other* people did, and a person following a bad link is indistinguishable here from one running a list. It is capped at `moderate` for this case specifically, and the guarantee it must keep is this one \u2014 reported, never refused.",
|
|
3451
|
+
requests: Array.from({ length: 16 }, (_, index) => ({
|
|
3452
|
+
...browser("chromeWindows", { kind: "cross-site-navigate", referer: "https://social.example/" }),
|
|
3453
|
+
ip: `203.0.114.${index + 1}`,
|
|
3454
|
+
path: "/blog/anouncing-our-new-thing",
|
|
3455
|
+
status: 404,
|
|
3456
|
+
atMs: index * 4e3
|
|
3457
|
+
})),
|
|
3458
|
+
expect: { certain: false, action: ["allow", "tag", "log", "delay", "challenge", "rate-limit"] },
|
|
3459
|
+
tags: ["known-cost"]
|
|
3460
|
+
}),
|
|
3216
3461
|
human({
|
|
3217
3462
|
id: "cgnat-shared-address",
|
|
3218
3463
|
title: "Many people behind one carrier-grade NAT address",
|
|
@@ -4089,6 +4334,23 @@ function checkExpectations(item, result, assertActions) {
|
|
|
4089
4334
|
}
|
|
4090
4335
|
return failures;
|
|
4091
4336
|
}
|
|
4337
|
+
function withExtraCookies(request, extra) {
|
|
4338
|
+
const headers = [];
|
|
4339
|
+
let merged = false;
|
|
4340
|
+
for (const [name, value] of request.headers) {
|
|
4341
|
+
if (!merged && name.toLowerCase() === "cookie") {
|
|
4342
|
+
headers.push([name, [value, ...extra].join("; ")]);
|
|
4343
|
+
merged = true;
|
|
4344
|
+
} else {
|
|
4345
|
+
headers.push([name, value]);
|
|
4346
|
+
}
|
|
4347
|
+
}
|
|
4348
|
+
if (!merged) headers.push(["Cookie", extra.join("; ")]);
|
|
4349
|
+
return { ...request, headers };
|
|
4350
|
+
}
|
|
4351
|
+
function keepsCookies(request) {
|
|
4352
|
+
return request.headers.some(([name]) => name.toLowerCase() === "cookie");
|
|
4353
|
+
}
|
|
4092
4354
|
async function runCase(handler, clock, item, startedAt, provides, assertActions = true) {
|
|
4093
4355
|
const missing = (item.requires ?? []).filter((capability) => !provides.has(capability));
|
|
4094
4356
|
if (missing.length > 0) {
|
|
@@ -4111,12 +4373,24 @@ async function runCase(handler, clock, item, startedAt, provides, assertActions
|
|
|
4111
4373
|
const seed = toFacts(item.requests[0], fallbackIp, clock.now());
|
|
4112
4374
|
clearanceCookie = handler.grantClearance(seed, item.clearance)?.split(";")[0];
|
|
4113
4375
|
}
|
|
4376
|
+
if (item.challengeHistory !== void 0) {
|
|
4377
|
+
clock.set(startedAt);
|
|
4378
|
+
const seed = toFacts(item.requests[0], fallbackIp, clock.now());
|
|
4379
|
+
const key = handler.actorKeyFor(seed);
|
|
4380
|
+
const state = handler.registry.observe(key, seed);
|
|
4381
|
+
for (let i = 0; i < (item.challengeHistory.replayedSolutions ?? 0); i++) state.noteChallengeAnomaly("replay");
|
|
4382
|
+
for (let i = 0; i < (item.challengeHistory.implausibleSolves ?? 0); i++) state.noteChallengeAnomaly("implausible-speed");
|
|
4383
|
+
}
|
|
4384
|
+
let issuedCookies;
|
|
4114
4385
|
for (const request of item.requests) {
|
|
4115
4386
|
clock.set(startedAt + (request.atMs ?? 0));
|
|
4116
|
-
const
|
|
4117
|
-
const
|
|
4387
|
+
const extraCookies = [clearanceCookie, item.keepsCookies ?? keepsCookies(request) ? issuedCookies : void 0].filter((value) => value !== void 0);
|
|
4388
|
+
const withCookies = extraCookies.length === 0 ? request : withExtraCookies(request, extraCookies);
|
|
4389
|
+
const facts = toFacts(withCookies, fallbackIp, clock.now());
|
|
4118
4390
|
const { assessment, decision, outcome } = await handler.handle(facts);
|
|
4119
|
-
|
|
4391
|
+
const setCookie = outcome.kind === "continue" ? outcome.responseHeaders?.["set-cookie"] : outcome.kind === "respond" ? outcome.headers["set-cookie"] : void 0;
|
|
4392
|
+
if (setCookie !== void 0) issuedCookies = setCookie.split(";")[0];
|
|
4393
|
+
handler.recordOutcome(facts, request.status ?? 200);
|
|
4120
4394
|
requests.push({ assessment, decision, outcome });
|
|
4121
4395
|
}
|
|
4122
4396
|
const final = requests[requests.length - 1];
|