@osqd/bothandlerjs 0.3.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 +516 -0
- package/LICENSE +102 -0
- package/README.md +308 -0
- package/bin/bothandlerjs.mjs +14 -0
- package/dist/actions/index.d.ts +22 -0
- package/dist/actions/types.d.ts +59 -0
- package/dist/adapters/fastify.d.ts +43 -0
- package/dist/adapters/fetch.d.ts +51 -0
- package/dist/adapters/index.cjs +723 -0
- package/dist/adapters/index.cjs.map +1 -0
- package/dist/adapters/index.d.ts +8 -0
- package/dist/adapters/index.js +692 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/koa.d.ts +27 -0
- package/dist/adapters/node.d.ts +29 -0
- package/dist/adapters/shared.d.ts +39 -0
- package/dist/audit.d.ts +172 -0
- package/dist/challenge/index.d.ts +176 -0
- package/dist/challenge/language.d.ts +57 -0
- package/dist/challenge/page.d.ts +42 -0
- package/dist/challenge/pow.d.ts +33 -0
- package/dist/challenge/token.d.ts +72 -0
- package/dist/cli.cjs +13388 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +13358 -0
- package/dist/cli.js.map +1 -0
- package/dist/client/index.cjs +139 -0
- package/dist/client/index.cjs.map +1 -0
- package/dist/client/index.d.ts +63 -0
- package/dist/client/index.js +112 -0
- package/dist/client/index.js.map +1 -0
- package/dist/config.d.ts +316 -0
- package/dist/core.d.ts +443 -0
- package/dist/corpus/adversarial.d.ts +2 -0
- package/dist/corpus/advertising-email.d.ts +2 -0
- package/dist/corpus/ai-crawlers.d.ts +2 -0
- package/dist/corpus/benign-bots.d.ts +2 -0
- package/dist/corpus/cdn-gateways.d.ts +2 -0
- package/dist/corpus/cookies.d.ts +49 -0
- package/dist/corpus/crawlers-regional.d.ts +2 -0
- package/dist/corpus/crawlers-vertical.d.ts +2 -0
- package/dist/corpus/headers.d.ts +403 -0
- package/dist/corpus/humans-apps.d.ts +2 -0
- package/dist/corpus/humans-browsers.d.ts +2 -0
- package/dist/corpus/humans.d.ts +2 -0
- package/dist/corpus/index.cjs +4196 -0
- package/dist/corpus/index.cjs.map +1 -0
- package/dist/corpus/index.d.ts +37 -0
- package/dist/corpus/index.js +4132 -0
- package/dist/corpus/index.js.map +1 -0
- package/dist/corpus/infrastructure.d.ts +17 -0
- package/dist/corpus/libraries-extended.d.ts +2 -0
- package/dist/corpus/ranges.d.ts +31 -0
- package/dist/corpus/reputation.d.ts +21 -0
- package/dist/corpus/runner.d.ts +130 -0
- package/dist/corpus/schema.d.ts +185 -0
- package/dist/corpus/tooling.d.ts +2 -0
- package/dist/corpus/unwanted.d.ts +2 -0
- package/dist/crawler-ranges.d.ts +113 -0
- package/dist/dashboard/client.generated.d.ts +7 -0
- package/dist/dashboard/feed.d.ts +186 -0
- package/dist/dashboard/index.d.ts +5 -0
- package/dist/dashboard/page.d.ts +51 -0
- package/dist/dashboard/parse-request.d.ts +28 -0
- package/dist/dashboard/preview.d.ts +45 -0
- package/dist/dashboard/server.d.ts +70 -0
- package/dist/dashboard/types.d.ts +714 -0
- package/dist/detectors/accept-signature.d.ts +6 -0
- package/dist/detectors/browsing-coherence.d.ts +44 -0
- package/dist/detectors/cadence.d.ts +28 -0
- package/dist/detectors/clearance.d.ts +25 -0
- package/dist/detectors/client-hints.d.ts +19 -0
- package/dist/detectors/client-signals.d.ts +27 -0
- package/dist/detectors/crawl-breadth.d.ts +28 -0
- package/dist/detectors/crawler-verification.d.ts +39 -0
- package/dist/detectors/fetch-metadata.d.ts +20 -0
- package/dist/detectors/header-integrity.d.ts +26 -0
- package/dist/detectors/header-order.d.ts +27 -0
- package/dist/detectors/identity-rotation.d.ts +34 -0
- package/dist/detectors/index.d.ts +56 -0
- package/dist/detectors/ip-intelligence.d.ts +30 -0
- package/dist/detectors/known-bots.d.ts +94 -0
- package/dist/detectors/probe-signature.d.ts +50 -0
- package/dist/detectors/rate-anomaly.d.ts +36 -0
- package/dist/detectors/self-identified.d.ts +33 -0
- package/dist/detectors/session-integrity.d.ts +24 -0
- package/dist/detectors/tls-fingerprint.d.ts +34 -0
- package/dist/detectors/trap.d.ts +98 -0
- package/dist/detectors/types.d.ts +70 -0
- package/dist/detectors/ua-coherence.d.ts +32 -0
- package/dist/evidence.d.ts +45 -0
- package/dist/facts.d.ts +46 -0
- package/dist/index.cjs +9600 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +63 -0
- package/dist/index.js +9444 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/async.d.ts +16 -0
- package/dist/internal/clock.d.ts +17 -0
- package/dist/internal/crypto.d.ts +26 -0
- package/dist/internal/dns.d.ts +100 -0
- package/dist/internal/emitter.d.ts +18 -0
- package/dist/internal/http.d.ts +22 -0
- package/dist/internal/ip.d.ts +73 -0
- package/dist/internal/lru.d.ts +34 -0
- package/dist/internal/matcher.d.ts +41 -0
- package/dist/internal/pattern.d.ts +14 -0
- package/dist/internal/ua.d.ts +92 -0
- package/dist/metrics.d.ts +120 -0
- package/dist/notify/hub.d.ts +70 -0
- package/dist/notify/index.d.ts +7 -0
- package/dist/notify/redact.d.ts +48 -0
- package/dist/notify/sinks.d.ts +62 -0
- package/dist/notify/types.d.ts +56 -0
- package/dist/policy/index.d.ts +7 -0
- package/dist/policy/match.d.ts +20 -0
- package/dist/policy/policy.d.ts +123 -0
- package/dist/policy/presets.d.ts +161 -0
- package/dist/policy/types.d.ts +155 -0
- package/dist/robots.d.ts +83 -0
- package/dist/state.d.ts +161 -0
- package/dist/stores/index.d.ts +5 -0
- package/dist/stores/memory.d.ts +33 -0
- package/dist/stores/redis.d.ts +59 -0
- package/dist/stores/types.d.ts +37 -0
- package/dist/types.d.ts +250 -0
- package/docs/challenge/index.md +152 -0
- package/docs/challenge/localisation.md +105 -0
- package/docs/concepts/actors.md +113 -0
- package/docs/concepts/evidence.md +133 -0
- package/docs/concepts/the-guard.md +121 -0
- package/docs/concepts/threat-model.md +120 -0
- package/docs/concepts/verdicts.md +108 -0
- package/docs/course/01-first-assessment.md +178 -0
- package/docs/course/02-proof-and-suspicion.md +164 -0
- package/docs/course/03-verdicts-and-scores.md +160 -0
- package/docs/course/04-the-guard.md +200 -0
- package/docs/course/05-detectors.md +178 -0
- package/docs/course/06-identity.md +161 -0
- package/docs/course/07-actors.md +178 -0
- package/docs/course/08-traps.md +183 -0
- package/docs/course/09-rules.md +224 -0
- package/docs/course/10-actions-and-presets.md +216 -0
- package/docs/course/11-the-challenge.md +227 -0
- package/docs/course/12-going-live.md +211 -0
- package/docs/course/13-operating-it.md +238 -0
- package/docs/course/14-scaling.md +195 -0
- package/docs/course/15-extending.md +259 -0
- package/docs/course/16-proving-it.md +266 -0
- package/docs/course/index.md +112 -0
- package/docs/design/decisions.md +216 -0
- package/docs/detection/client-signals.md +96 -0
- package/docs/detection/detectors.md +376 -0
- package/docs/detection/index.md +134 -0
- package/docs/detection/signatures.md +111 -0
- package/docs/detection/verification.md +123 -0
- package/docs/detection/writing-a-detector.md +145 -0
- package/docs/index.md +100 -0
- package/docs/integration/adapters.md +138 -0
- package/docs/integration/client-ip.md +114 -0
- package/docs/integration/index.md +55 -0
- package/docs/integration/stores.md +76 -0
- package/docs/operations/audit.md +116 -0
- package/docs/operations/dashboard.md +725 -0
- package/docs/operations/index.md +101 -0
- package/docs/operations/metrics.md +74 -0
- package/docs/operations/notifications.md +72 -0
- package/docs/operations/runtime-changes.md +118 -0
- package/docs/policy/actions.md +196 -0
- package/docs/policy/index.md +84 -0
- package/docs/policy/presets.md +191 -0
- package/docs/policy/robots.md +128 -0
- package/docs/policy/rules.md +134 -0
- package/docs/reference/api.md +126 -0
- package/docs/reference/configuration.md +175 -0
- package/docs/start/choosing-a-policy.md +127 -0
- package/docs/start/first-integration.md +127 -0
- package/docs/start/installation.md +63 -0
- package/docs/start/upgrading.md +72 -0
- package/docs/testing/cli.md +119 -0
- package/docs/testing/corpus.md +129 -0
- package/docs/testing/index.md +61 -0
- package/docs/testing/replay.md +102 -0
- package/docs/testing/try-it.md +189 -0
- package/package.json +145 -0
package/dist/core.d.ts
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
import { ActorRegistry } from "./state.js";
|
|
2
|
+
import { ChallengeService } from "./challenge/index.js";
|
|
3
|
+
import { TrafficAudit } from "./audit.js";
|
|
4
|
+
import { NotificationHub } from "./notify/hub.js";
|
|
5
|
+
import { Policy } from "./policy/policy.js";
|
|
6
|
+
import type { GuardSettings } from "./policy/policy.js";
|
|
7
|
+
import type { ActionOutcome } from "./actions/types.js";
|
|
8
|
+
import type { Rule } from "./policy/types.js";
|
|
9
|
+
import type { Assessment, RequestFacts } from "./types.js";
|
|
10
|
+
import type { BotHandlerConfig, ResolvedConfig } from "./config.js";
|
|
11
|
+
import type { TrafficAnomaly } from "./audit.js";
|
|
12
|
+
import type { DashboardOptions, DashboardServer } from "./dashboard/types.js";
|
|
13
|
+
import type { MetricsSnapshot, PrometheusOptions } from "./metrics.js";
|
|
14
|
+
import type { BotHandlerStore } from "./stores/types.js";
|
|
15
|
+
import type { Decision } from "./policy/types.js";
|
|
16
|
+
import type { SolutionOutcome } from "./challenge/index.js";
|
|
17
|
+
export interface HandleResult {
|
|
18
|
+
assessment: Assessment;
|
|
19
|
+
decision: Decision;
|
|
20
|
+
outcome: ActionOutcome;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Everything the engine will tell you about, and the whole integration surface.
|
|
24
|
+
*
|
|
25
|
+
* Each of these has a matching `onX` in the config — the same mechanism, registered
|
|
26
|
+
* for you at construction. Subscribe here when you want to add a listener later,
|
|
27
|
+
* add several, or remove one; use the config form when you just want a callback.
|
|
28
|
+
*
|
|
29
|
+
* They are deliberately *derived* rather than raw: `denial` and `downgrade` exist
|
|
30
|
+
* because "a request was refused" and "the guard refused a rule" are the two things
|
|
31
|
+
* an operator wants an alert on, and making every subscriber re-derive them from
|
|
32
|
+
* `decision` is how two integrations end up disagreeing about what a denial is.
|
|
33
|
+
*/
|
|
34
|
+
export interface BotHandlerEvents extends Record<string, unknown> {
|
|
35
|
+
/** Every assessment, including the ones that concluded nothing. */
|
|
36
|
+
assessment: Assessment;
|
|
37
|
+
/** Every decision, with the assessment behind it. */
|
|
38
|
+
decision: {
|
|
39
|
+
assessment: Assessment;
|
|
40
|
+
decision: Decision;
|
|
41
|
+
};
|
|
42
|
+
/** A request that was actually denied: `block`, `drop` or `redirect`. */
|
|
43
|
+
denial: {
|
|
44
|
+
assessment: Assessment;
|
|
45
|
+
decision: Decision;
|
|
46
|
+
};
|
|
47
|
+
/** The guard replaced a terminal action with something recoverable. */
|
|
48
|
+
downgrade: {
|
|
49
|
+
assessment: Assessment;
|
|
50
|
+
decision: Decision;
|
|
51
|
+
};
|
|
52
|
+
/** A challenge was issued, solved or rejected. */
|
|
53
|
+
challenge: {
|
|
54
|
+
phase: "issued" | "solved" | "rejected";
|
|
55
|
+
actorKey?: string | undefined;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* A detector threw or timed out.
|
|
59
|
+
*
|
|
60
|
+
* `requestId` is empty when the failure was noticed before the assessment was
|
|
61
|
+
* assembled — the failure list on the assessment is the per-request record; this
|
|
62
|
+
* event is the operational one, for the alert that says a resolver is down.
|
|
63
|
+
*/
|
|
64
|
+
"detector-failure": {
|
|
65
|
+
detector: string;
|
|
66
|
+
reason: string;
|
|
67
|
+
message: string;
|
|
68
|
+
requestId: string;
|
|
69
|
+
};
|
|
70
|
+
/** The rule set was replaced at runtime. */
|
|
71
|
+
"policy-change": {
|
|
72
|
+
rules: readonly string[];
|
|
73
|
+
warnings: readonly string[];
|
|
74
|
+
by?: string | undefined;
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* The guard settings were changed at runtime.
|
|
78
|
+
*
|
|
79
|
+
* Separate from `policy-change` on purpose. "Which rules exist" and "how far a rule
|
|
80
|
+
* is allowed to go" are different powers with different consequences, and an
|
|
81
|
+
* operator wiring an alert almost always wants the second one and not the first.
|
|
82
|
+
* `before` and `after` both travel so the alert can say what actually moved.
|
|
83
|
+
*/
|
|
84
|
+
"guard-change": {
|
|
85
|
+
before: GuardSettings & {
|
|
86
|
+
suspectThreshold: number;
|
|
87
|
+
};
|
|
88
|
+
after: GuardSettings & {
|
|
89
|
+
suspectThreshold: number;
|
|
90
|
+
};
|
|
91
|
+
by?: string | undefined;
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* A range set was replaced at runtime — an allowlist entry added, a crawler's
|
|
95
|
+
* published ranges refreshed.
|
|
96
|
+
*
|
|
97
|
+
* Worth an event of its own because the allowlist is the one list that stops
|
|
98
|
+
* detection *running*: an address on it is not judged leniently, it is not judged at
|
|
99
|
+
* all. A change to it is a change to what your bot handling can see.
|
|
100
|
+
*/
|
|
101
|
+
"range-change": {
|
|
102
|
+
name: string;
|
|
103
|
+
size: number;
|
|
104
|
+
entries: readonly string[];
|
|
105
|
+
by?: string | undefined;
|
|
106
|
+
};
|
|
107
|
+
/**
|
|
108
|
+
* An actor's behavioural memory was changed by hand — forgotten, or granted human
|
|
109
|
+
* clearance.
|
|
110
|
+
*
|
|
111
|
+
* The remedy for a false positive that has stuck to somebody, and therefore exactly
|
|
112
|
+
* the operation an audit trail wants to have seen.
|
|
113
|
+
*/
|
|
114
|
+
"actor-change": {
|
|
115
|
+
key: string;
|
|
116
|
+
action: "forget" | "clear";
|
|
117
|
+
until?: number | undefined;
|
|
118
|
+
by?: string | undefined;
|
|
119
|
+
};
|
|
120
|
+
/** The audit noticed the traffic change shape. */
|
|
121
|
+
anomaly: TrafficAnomaly;
|
|
122
|
+
warning: string;
|
|
123
|
+
error: {
|
|
124
|
+
error: unknown;
|
|
125
|
+
source: string;
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Who asked for a runtime change.
|
|
130
|
+
*
|
|
131
|
+
* Every mutating method takes one, and every one of them puts it in the warning and in
|
|
132
|
+
* the event. The library has no idea who anybody is — it has no user model and does not
|
|
133
|
+
* want one — so this is a string the caller supplies, and the caller is the thing that
|
|
134
|
+
* *does* know: an authenticating dashboard, a deploy pipeline, an admin CLI. An absent
|
|
135
|
+
* `by` is not a failure; it means whatever made the change could not say who.
|
|
136
|
+
*/
|
|
137
|
+
export interface ChangeContext {
|
|
138
|
+
by?: string | undefined;
|
|
139
|
+
}
|
|
140
|
+
/** Options for a single assessment. */
|
|
141
|
+
export interface AssessOptions {
|
|
142
|
+
/**
|
|
143
|
+
* Whether this request is part of your traffic. Default true.
|
|
144
|
+
*
|
|
145
|
+
* `record: false` is a **dry run**: the detectors all run and the verdict is real,
|
|
146
|
+
* but nothing is written down. No actor state moves, no counter increments, no
|
|
147
|
+
* `assessment` event fires and no notification is sent — so asking "what would this
|
|
148
|
+
* request be judged as?" does not become a row in the answer to "what is my traffic
|
|
149
|
+
* doing?".
|
|
150
|
+
*
|
|
151
|
+
* It is what the dashboard's request tester uses, and what to reach for anywhere else
|
|
152
|
+
* you want the engine's opinion about a request that is not happening: a support
|
|
153
|
+
* ticket, a test, a rule you are drafting. The one thing a dry run cannot see is
|
|
154
|
+
* history — it gets an actor with no past, because the alternative is to record the
|
|
155
|
+
* request against a real one, which is the thing it promised not to do.
|
|
156
|
+
*/
|
|
157
|
+
record?: boolean;
|
|
158
|
+
}
|
|
159
|
+
/** Description of a registered detector, for documentation and diagnostics. */
|
|
160
|
+
export interface DetectorDescription {
|
|
161
|
+
id: string;
|
|
162
|
+
description: string;
|
|
163
|
+
cost: "cheap" | "io";
|
|
164
|
+
stage: "always" | "confirming";
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* The engine.
|
|
168
|
+
*
|
|
169
|
+
* Three separable steps, in a fixed order, each usable on its own:
|
|
170
|
+
*
|
|
171
|
+
* 1. {@link assess} — gather evidence and reach a verdict. Reads the request, touches
|
|
172
|
+
* no response, and is safe to call anywhere, including from a log processor
|
|
173
|
+
* replaying yesterday's traffic.
|
|
174
|
+
* 2. {@link decide} — apply the policy, subject to the safety guard.
|
|
175
|
+
* 3. {@link handle} — do both, then turn the decision into an outcome an adapter can
|
|
176
|
+
* apply.
|
|
177
|
+
*
|
|
178
|
+
* Keeping them apart is what makes the library testable and what makes a
|
|
179
|
+
* monitor-only deployment a first-class mode rather than a configuration trick: call
|
|
180
|
+
* `assess` alone and you have a detector with no opinions about your traffic at all.
|
|
181
|
+
*
|
|
182
|
+
* Everything is failure-isolated. A detector that throws, a store that will not
|
|
183
|
+
* answer, a notification sink that hangs — each degrades exactly itself and is
|
|
184
|
+
* reported through `onError`. Nothing in this file can turn a bad day for a
|
|
185
|
+
* dependency into a bad day for the site it is protecting.
|
|
186
|
+
*/
|
|
187
|
+
export declare class BotHandler {
|
|
188
|
+
readonly config: ResolvedConfig;
|
|
189
|
+
readonly registry: ActorRegistry;
|
|
190
|
+
readonly store: BotHandlerStore;
|
|
191
|
+
readonly policy: Policy;
|
|
192
|
+
readonly challenge: ChallengeService | undefined;
|
|
193
|
+
readonly notifications: NotificationHub;
|
|
194
|
+
/**
|
|
195
|
+
* The traffic audit, or `undefined` when it was switched off with `audit: false`.
|
|
196
|
+
*
|
|
197
|
+
* Exposed so you can ask it questions on your own schedule — `summary()` for the
|
|
198
|
+
* two windows as they stand, `evaluate()` to run the checks now — which is what a
|
|
199
|
+
* health endpoint or a cron job wants rather than waiting for the timer.
|
|
200
|
+
*/
|
|
201
|
+
readonly audit: TrafficAudit | undefined;
|
|
202
|
+
private readonly signatures;
|
|
203
|
+
private readonly resolver;
|
|
204
|
+
private readonly handlers;
|
|
205
|
+
private readonly cheapDetectors;
|
|
206
|
+
private readonly ioDetectors;
|
|
207
|
+
private readonly confirmingDetectors;
|
|
208
|
+
private readonly events;
|
|
209
|
+
private readonly ignoreExact;
|
|
210
|
+
private readonly ignorePatterns;
|
|
211
|
+
private readonly isHuman;
|
|
212
|
+
private readonly meter;
|
|
213
|
+
/** Hoisted out of the metrics object: read once per detector per request. */
|
|
214
|
+
private readonly timing;
|
|
215
|
+
constructor(options?: BotHandlerConfig);
|
|
216
|
+
/**
|
|
217
|
+
* Runs the audit's checks now and emits whatever they found.
|
|
218
|
+
*
|
|
219
|
+
* The timer does this on a schedule; calling it yourself is for the cases a timer
|
|
220
|
+
* cannot serve — a health endpoint that should report the current picture, a cron
|
|
221
|
+
* job, a test with a manual clock.
|
|
222
|
+
*/
|
|
223
|
+
runAudit(): TrafficAnomaly[];
|
|
224
|
+
private raiseAnomaly;
|
|
225
|
+
on<K extends keyof BotHandlerEvents & string>(event: K, listener: (payload: BotHandlerEvents[K]) => void): () => void;
|
|
226
|
+
/**
|
|
227
|
+
* A point-in-time copy of the counters. Cheap enough to call on every scrape.
|
|
228
|
+
*
|
|
229
|
+
* Returns `undefined` when metrics were switched off with `metrics: false`.
|
|
230
|
+
*/
|
|
231
|
+
metrics(): MetricsSnapshot | undefined;
|
|
232
|
+
/** The same counters in Prometheus text exposition format. */
|
|
233
|
+
prometheus(options?: PrometheusOptions): string | undefined;
|
|
234
|
+
/**
|
|
235
|
+
* Starts the operator dashboard on a listener of its own.
|
|
236
|
+
*
|
|
237
|
+
* ```ts
|
|
238
|
+
* const dashboard = await botHandler.serveDashboard({
|
|
239
|
+
* port: 9674,
|
|
240
|
+
* auth: { username: "ops", password: process.env.DASH_PASSWORD! },
|
|
241
|
+
* });
|
|
242
|
+
* console.log(dashboard.url);
|
|
243
|
+
* ```
|
|
244
|
+
*
|
|
245
|
+
* **Its own listener, not a route in your application.** Mounting it inside the app
|
|
246
|
+
* it reports on puts it behind the bot handler, and that arrangement has three
|
|
247
|
+
* separate failure modes: reading the dashboard shows up in the dashboard, a
|
|
248
|
+
* challenge served to your site can lock you out of the tool you are using to read
|
|
249
|
+
* about it, and the page becomes reachable at whatever authentication your public
|
|
250
|
+
* site happens to have. A second port is the cheapest fix for all three.
|
|
251
|
+
*
|
|
252
|
+
* **Loopback by default, and it refuses to bind anywhere else without an explicit
|
|
253
|
+
* `auth`.** This page shows client addresses and, per request, the exact evidence
|
|
254
|
+
* behind the verdict — which is a tuning guide for anyone building a scraper
|
|
255
|
+
* against you. See {@link DashboardOptions.host}.
|
|
256
|
+
*
|
|
257
|
+
* The returned handle carries the URL to open (with the real port, which matters if
|
|
258
|
+
* you passed 0) and a `close()` that stops the listener, drops the event streams and
|
|
259
|
+
* unsubscribes from this handler.
|
|
260
|
+
*/
|
|
261
|
+
serveDashboard(options?: DashboardOptions): Promise<DashboardServer>;
|
|
262
|
+
/**
|
|
263
|
+
* Replaces an IP range set while the process runs.
|
|
264
|
+
*
|
|
265
|
+
* Published crawler ranges are the reason this exists. Operators revise them, a
|
|
266
|
+
* stale list turns a verified crawler into an accused impersonator, and requiring a
|
|
267
|
+
* restart to pick up a new one means the list is refreshed roughly never. Fetch
|
|
268
|
+
* them on whatever schedule you like and hand them here.
|
|
269
|
+
*
|
|
270
|
+
* The swap is atomic — the new set is fully parsed and validated before it replaces
|
|
271
|
+
* the old one — so a request assessed mid-update sees one list or the other, never
|
|
272
|
+
* a half-built one. Invalid input throws and leaves the existing set in place,
|
|
273
|
+
* because a range set that silently matches nothing is worse than a stale one.
|
|
274
|
+
*/
|
|
275
|
+
updateRanges(name: string, entries: readonly string[], context?: ChangeContext): void;
|
|
276
|
+
/** The ranges in a set, as written. `undefined` when no set of that name exists. */
|
|
277
|
+
rangeEntries(name: string): readonly string[] | undefined;
|
|
278
|
+
/**
|
|
279
|
+
* Discards one actor's behavioural memory.
|
|
280
|
+
*
|
|
281
|
+
* The remedy for a false positive that has stuck: a person whose actor key collected
|
|
282
|
+
* a `confirmed-bot` — a shared office address, a phone that reused an IP — carries
|
|
283
|
+
* `priorConfirmations` for the rest of the window, and any rule reading
|
|
284
|
+
* `minPriorConfirmations` keeps matching them. Until this existed the only cure was
|
|
285
|
+
* `registry.clear()`, which throws away every actor's history to fix one.
|
|
286
|
+
*
|
|
287
|
+
* It is not an allowlist: the next request from this actor is assessed exactly as any
|
|
288
|
+
* first request would be.
|
|
289
|
+
*/
|
|
290
|
+
forgetActor(key: string, context?: ChangeContext): void;
|
|
291
|
+
/**
|
|
292
|
+
* Grants an actor human clearance for a while, as though it had solved a challenge.
|
|
293
|
+
*
|
|
294
|
+
* The `clearance` detector reads it, so this is an assertion about a person made by
|
|
295
|
+
* an operator on evidence the request does not carry — the same category of claim as
|
|
296
|
+
* `isHuman`, and it expires the same way a solved challenge does.
|
|
297
|
+
*/
|
|
298
|
+
clearActor(key: string, forMs: number, context?: ChangeContext): void;
|
|
299
|
+
/** Convenience for `updateRanges("crawler:<id>", …)`, matching a signature id. */
|
|
300
|
+
updateCrawlerRanges(signatureId: string, entries: readonly string[], context?: ChangeContext): void;
|
|
301
|
+
/**
|
|
302
|
+
* Replaces the rule list while the process runs.
|
|
303
|
+
*
|
|
304
|
+
* The dashboard's policy editor is the reason this exists, and the shape of it is
|
|
305
|
+
* deliberate: you may change **which rules exist**, and nothing about **how far a
|
|
306
|
+
* rule is allowed to go**. `falsePositivePolicy`, `fallbackAction` and
|
|
307
|
+
* `terminalScoreThreshold` are fixed at construction. A dashboard — or anything
|
|
308
|
+
* else holding a reference to this handler — therefore cannot relax the guard that
|
|
309
|
+
* stops an unproven verdict from denying somebody. Loosening that is a deploy, on
|
|
310
|
+
* purpose, reviewed by whoever reviews deploys.
|
|
311
|
+
*
|
|
312
|
+
* Validation runs first and throws on a rule that could never work; the swap only
|
|
313
|
+
* happens if every rule survives it, so a bad edit leaves the running policy exactly
|
|
314
|
+
* as it was. Warnings — a duplicate id, a rule that can never match — come back to
|
|
315
|
+
* the caller *and* go to `onWarning`, because a policy changed at runtime should
|
|
316
|
+
* leave a trace in the same place a policy loaded at startup does.
|
|
317
|
+
*/
|
|
318
|
+
updatePolicy(rules: readonly Rule[], context?: ChangeContext): {
|
|
319
|
+
warnings: string[];
|
|
320
|
+
};
|
|
321
|
+
/**
|
|
322
|
+
* Changes the guard settings while the process runs.
|
|
323
|
+
*
|
|
324
|
+
* The one runtime change that can start denying people, and it is kept apart from
|
|
325
|
+
* {@link updatePolicy} for exactly that reason: a separate method, a separate
|
|
326
|
+
* dashboard control, a separate event. Fields left out keep their current value.
|
|
327
|
+
*
|
|
328
|
+
* Everything is validated before anything is applied, so a rejected change leaves
|
|
329
|
+
* the running guard exactly as it was — the same contract `updatePolicy` has. What
|
|
330
|
+
* is refused and why is documented on {@link Policy.replaceGuard}; the short version
|
|
331
|
+
* is that a terminal `fallbackAction` would turn every downgrade into the denial the
|
|
332
|
+
* downgrade exists to prevent.
|
|
333
|
+
*
|
|
334
|
+
* It is announced twice on purpose: a `warning`, which lands wherever your startup
|
|
335
|
+
* warnings land and in the dashboard's notices, and a `guard-change` event carrying
|
|
336
|
+
* both the before and the after.
|
|
337
|
+
*/
|
|
338
|
+
updateGuard(settings: Partial<GuardSettings> & {
|
|
339
|
+
suspectThreshold?: number;
|
|
340
|
+
}, context?: ChangeContext): {
|
|
341
|
+
guard: GuardSettings & {
|
|
342
|
+
suspectThreshold: number;
|
|
343
|
+
};
|
|
344
|
+
};
|
|
345
|
+
/**
|
|
346
|
+
* Tells the other replicas that this actor has been proven a bot.
|
|
347
|
+
*
|
|
348
|
+
* Fire and forget, off the request path, and failure is silence: a store that is
|
|
349
|
+
* unavailable costs the *other* instances a fact they would have liked, and costs
|
|
350
|
+
* this request nothing at all. The window is the registry's own, so a shared count
|
|
351
|
+
* ages out at the same rate the local one does rather than accumulating for ever.
|
|
352
|
+
*/
|
|
353
|
+
private publishConfirmation;
|
|
354
|
+
/**
|
|
355
|
+
* Asks the store what other replicas already know about an actor we have just met.
|
|
356
|
+
*
|
|
357
|
+
* Once per actor per instance, and deliberately *not* awaited: making it synchronous
|
|
358
|
+
* would put a network round trip on the request path, which is the one thing the
|
|
359
|
+
* behavioural state is arranged to avoid.
|
|
360
|
+
*
|
|
361
|
+
* So the request that triggers the read is assessed with whatever has arrived by the
|
|
362
|
+
* time the detectors finish — which for a fast store is usually the answer, and for a
|
|
363
|
+
* slow or distant one is nothing. Every request after it has the number. Neither
|
|
364
|
+
* outcome is wrong: an actor nobody here has seen before is an actor with no local
|
|
365
|
+
* history either, so the first request was always going to be judged on the request
|
|
366
|
+
* alone.
|
|
367
|
+
*
|
|
368
|
+
* The read subtracts nothing. This instance has not confirmed anything about a
|
|
369
|
+
* brand-new actor yet, so whatever the store holds was put there by somebody else,
|
|
370
|
+
* and the two counts add up cleanly from here.
|
|
371
|
+
*/
|
|
372
|
+
private loadSharedConfirmations;
|
|
373
|
+
/** Names and sizes of every configured range set. */
|
|
374
|
+
listRanges(): Array<{
|
|
375
|
+
name: string;
|
|
376
|
+
size: number;
|
|
377
|
+
}>;
|
|
378
|
+
/** Every registered detector, for documentation and for checking what is actually installed. */
|
|
379
|
+
describeDetectors(): DetectorDescription[];
|
|
380
|
+
/** Recovers the client address from a socket address and headers, honouring the proxy config. */
|
|
381
|
+
resolveIp(socketAddress: string | undefined, headers: Record<string, string | undefined>): string;
|
|
382
|
+
actorKeyFor(facts: RequestFacts): string;
|
|
383
|
+
isAllowlisted(ip: string): boolean;
|
|
384
|
+
isIgnoredPath(path: string): boolean;
|
|
385
|
+
/**
|
|
386
|
+
* Gathers evidence and reaches a verdict. Never throws, never writes a response.
|
|
387
|
+
*
|
|
388
|
+
* Detectors run in three phases, ordered by what they cost:
|
|
389
|
+
*
|
|
390
|
+
* 1. **Cheap** — pure string and header inspection, sequential, microseconds.
|
|
391
|
+
* 2. **I/O** — concurrent, each under its own timeout, so one slow lookup cannot
|
|
392
|
+
* hold the request open.
|
|
393
|
+
* 3. **Confirming** — only when the request actually claimed an identity worth
|
|
394
|
+
* checking. A request that named no crawler resolves no DNS.
|
|
395
|
+
*/
|
|
396
|
+
assess(facts: RequestFacts, options?: AssessOptions): Promise<Assessment>;
|
|
397
|
+
/** Applies the policy. Pure — no I/O, no side effects beyond the downgrade notification. */
|
|
398
|
+
decide(assessment: Assessment): Decision;
|
|
399
|
+
/** Assess, decide, and turn the decision into an outcome for an adapter to apply. */
|
|
400
|
+
handle(facts: RequestFacts): Promise<HandleResult>;
|
|
401
|
+
/** True when this request is the challenge verification endpoint. */
|
|
402
|
+
isChallengeEndpoint(facts: Pick<RequestFacts, "method" | "path">): boolean;
|
|
403
|
+
/**
|
|
404
|
+
* Verifies a submitted challenge solution.
|
|
405
|
+
*
|
|
406
|
+
* Deliberately its own entry point rather than something `handle` detects: the
|
|
407
|
+
* verification endpoint must not itself be assessed, or a client that has just been
|
|
408
|
+
* challenged would be challenged again for trying to answer.
|
|
409
|
+
*/
|
|
410
|
+
verifyChallenge(facts: RequestFacts, body: unknown): Promise<SolutionOutcome>;
|
|
411
|
+
/**
|
|
412
|
+
* Mints a clearance cookie for an actor, bypassing the challenge.
|
|
413
|
+
*
|
|
414
|
+
* Call it the moment your application knows something the request cannot show — a
|
|
415
|
+
* completed login, a verified payment. `operator` clearance is the only conclusive
|
|
416
|
+
* human signal this library recognises.
|
|
417
|
+
*/
|
|
418
|
+
grantClearance(facts: RequestFacts, level?: "pow" | "interaction" | "operator"): string | undefined;
|
|
419
|
+
/**
|
|
420
|
+
* Runs one detector.
|
|
421
|
+
*
|
|
422
|
+
* Returns `undefined` when the detector completed synchronously — the common case,
|
|
423
|
+
* and the one worth keeping off the microtask queue — or a promise the caller must
|
|
424
|
+
* await. Failures are absorbed here in both paths: a detector can throw, reject or
|
|
425
|
+
* hang, and none of those may reach the request.
|
|
426
|
+
*/
|
|
427
|
+
private run;
|
|
428
|
+
private collect;
|
|
429
|
+
private recordFailure;
|
|
430
|
+
private bypassed;
|
|
431
|
+
/**
|
|
432
|
+
* Raises a warning through the handler's own channel.
|
|
433
|
+
*
|
|
434
|
+
* Public because things outside this class legitimately have something to say: an
|
|
435
|
+
* adapter that has spotted a misconfiguration, the crawler-range refresher reporting
|
|
436
|
+
* a publisher it could not reach. Both used to call `config.onWarning` directly,
|
|
437
|
+
* which reaches the callback and *not* the `warning` event — so a subscriber, and
|
|
438
|
+
* with it the dashboard's notices panel, never heard about either. One channel, one
|
|
439
|
+
* set of listeners.
|
|
440
|
+
*/
|
|
441
|
+
warn(message: string): void;
|
|
442
|
+
private fail;
|
|
443
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Realistic cookie jars.
|
|
3
|
+
*
|
|
4
|
+
* A browser that has been to a commercial site once is carrying a dozen cookies it
|
|
5
|
+
* never asked for, and their *shape* is as characteristic as any header: Google
|
|
6
|
+
* Analytics' `_ga` encodes a client id and a first-seen timestamp, Meta's `_fbp`
|
|
7
|
+
* encodes a version, a subdomain index and a creation time, a TCF consent string is
|
|
8
|
+
* base64 with a version prefix. Automation carries none of this, or carries a single
|
|
9
|
+
* hand-set session cookie and nothing else.
|
|
10
|
+
*
|
|
11
|
+
* The values below are structurally correct and entirely synthetic. They exist so a
|
|
12
|
+
* fixture looks like a request from somebody who has actually used the web, rather
|
|
13
|
+
* than like `Cookie: sid=abc`.
|
|
14
|
+
*/
|
|
15
|
+
export interface CookieJarOptions {
|
|
16
|
+
/** Seed for the synthetic identifiers, so two visitors differ. */
|
|
17
|
+
visitor?: string;
|
|
18
|
+
/** Epoch seconds the visitor was first seen. */
|
|
19
|
+
firstSeen?: number;
|
|
20
|
+
/** Epoch seconds of this session's start. */
|
|
21
|
+
sessionStart?: number;
|
|
22
|
+
/** Include Google Analytics 4 cookies. Present on most of the commercial web. */
|
|
23
|
+
analytics?: boolean;
|
|
24
|
+
/** Include Meta's advertising cookies. */
|
|
25
|
+
advertising?: boolean;
|
|
26
|
+
/** Include a consent-management platform's cookies. */
|
|
27
|
+
consent?: boolean;
|
|
28
|
+
/** Include Cloudflare's bot-management cookies, present on a large slice of sites. */
|
|
29
|
+
cloudflare?: boolean;
|
|
30
|
+
/** Include product-analytics and support-widget cookies. */
|
|
31
|
+
productAnalytics?: boolean;
|
|
32
|
+
/** Application cookies: session, cart, CSRF. */
|
|
33
|
+
application?: boolean;
|
|
34
|
+
/** Extra pairs appended verbatim. */
|
|
35
|
+
extra?: readonly (readonly [string, string])[];
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Builds a `Cookie` header value.
|
|
39
|
+
*
|
|
40
|
+
* Order matters a little: browsers send cookies sorted by path length descending and
|
|
41
|
+
* then by creation time, which in practice means the host-wide analytics cookies set
|
|
42
|
+
* on the first visit come before the application cookies set later. Reproducing that
|
|
43
|
+
* ordering is the kind of detail a hand-written fixture never has.
|
|
44
|
+
*/
|
|
45
|
+
export declare function cookieJar(options?: CookieJarOptions): string;
|
|
46
|
+
/** A first-time visitor: no analytics history, only whatever this page set. */
|
|
47
|
+
export declare function freshVisitorJar(visitor?: string): string;
|
|
48
|
+
/** A returning customer of a commercial site — the fullest jar in ordinary use. */
|
|
49
|
+
export declare function returningCustomerJar(visitor?: string): string;
|