@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
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs `work` and resolves to `fallback` if it takes longer than `ms`.
|
|
3
|
+
*
|
|
4
|
+
* Detection runs inline on the request path, so an unbounded await is an
|
|
5
|
+
* availability bug: a slow reverse-DNS lookup or a stalled Redis round-trip would
|
|
6
|
+
* hold the response open. Every I/O-bound detector is wrapped in this, and a
|
|
7
|
+
* timeout degrades that one detector rather than the request.
|
|
8
|
+
*
|
|
9
|
+
* The underlying promise is not cancelled (JS has no such mechanism) but its result
|
|
10
|
+
* is discarded and its rejection swallowed, so a late failure cannot surface as an
|
|
11
|
+
* unhandled rejection.
|
|
12
|
+
*/
|
|
13
|
+
export declare function withTimeout<T>(work: Promise<T>, ms: number, fallback: T): Promise<T>;
|
|
14
|
+
/** `Promise.allSettled` semantics without allocating a settled-result wrapper per item. */
|
|
15
|
+
export declare function settleAll(work: Iterable<Promise<void>>): Promise<void>;
|
|
16
|
+
export declare function isPromise(value: unknown): value is Promise<unknown>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Injectable time. Every stateful detector reads `now()` from here rather than
|
|
3
|
+
* calling `Date.now()`, which is what makes rate, cadence and expiry logic testable
|
|
4
|
+
* without sleeping and without flaky wall-clock assumptions.
|
|
5
|
+
*/
|
|
6
|
+
export interface Clock {
|
|
7
|
+
now(): number;
|
|
8
|
+
}
|
|
9
|
+
export declare const systemClock: Clock;
|
|
10
|
+
/** Test double: time only advances when you say so. */
|
|
11
|
+
export declare class ManualClock implements Clock {
|
|
12
|
+
private current;
|
|
13
|
+
constructor(current?: number);
|
|
14
|
+
now(): number;
|
|
15
|
+
advance(ms: number): void;
|
|
16
|
+
set(ms: number): void;
|
|
17
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signing and comparison primitives for clearance tokens.
|
|
3
|
+
*
|
|
4
|
+
* Two rules govern everything in this file. Tokens are signed, never encrypted —
|
|
5
|
+
* their contents are readable by the client and must contain nothing secret. And
|
|
6
|
+
* every comparison of a secret-derived value goes through {@link constantTimeEqual},
|
|
7
|
+
* because a token verifier is exactly the kind of oracle a timing attack likes.
|
|
8
|
+
*/
|
|
9
|
+
export declare function base64UrlEncode(input: Uint8Array | string): string;
|
|
10
|
+
export declare function base64UrlDecode(input: string): Buffer;
|
|
11
|
+
/** HMAC-SHA256, returned as base64url. */
|
|
12
|
+
export declare function sign(payload: string, secret: string): string;
|
|
13
|
+
/**
|
|
14
|
+
* Constant-time string comparison. Falls back to a `false` return on
|
|
15
|
+
* length mismatch — the length of a signature is not a secret, so leaking it is
|
|
16
|
+
* fine, and `timingSafeEqual` throws on unequal lengths.
|
|
17
|
+
*/
|
|
18
|
+
export declare function constantTimeEqual(a: string, b: string): boolean;
|
|
19
|
+
/** URL-safe random id. 16 bytes is 128 bits — collision-free for request ids and nonces. */
|
|
20
|
+
export declare function randomId(bytes?: number): string;
|
|
21
|
+
/**
|
|
22
|
+
* SHA-256 truncated to 128 bits, base64url. Used to derive stable, non-reversible
|
|
23
|
+
* keys from values we do not want to store in the clear (IP-based actor keys when
|
|
24
|
+
* `hashActorKeys` is on, header-order fingerprints).
|
|
25
|
+
*/
|
|
26
|
+
export declare function shortHash(value: string, secret?: string): string;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DNS access for crawler identity verification.
|
|
3
|
+
*
|
|
4
|
+
* The distinction this module exists to preserve is between *disproof* and *no
|
|
5
|
+
* answer*, and it is the load-bearing detail of the whole no-false-positive design.
|
|
6
|
+
*
|
|
7
|
+
* A reverse lookup that succeeds and returns `crawl-66-249-66-1.googlebot.com` for a
|
|
8
|
+
* client claiming to be Googlebot **confirms** the claim. One that succeeds and
|
|
9
|
+
* returns `some-vps.example.net` **disproves** it. One that times out, hits SERVFAIL,
|
|
10
|
+
* or finds no resolver at all proves nothing whatsoever — and must never be allowed
|
|
11
|
+
* to look like disproof, because "our resolver was briefly unhappy" would otherwise
|
|
12
|
+
* become "we blocked Googlebot".
|
|
13
|
+
*/
|
|
14
|
+
/** The subset of `node:dns/promises` this library uses. Inject your own for tests or a custom resolver. */
|
|
15
|
+
export interface DnsResolver {
|
|
16
|
+
/** PTR names for an address. Rejects on NXDOMAIN. */
|
|
17
|
+
reverse(ip: string): Promise<string[]>;
|
|
18
|
+
/** A and AAAA records for a name. Rejects on NXDOMAIN. */
|
|
19
|
+
resolveAddresses(hostname: string): Promise<string[]>;
|
|
20
|
+
}
|
|
21
|
+
export type VerificationOutcome =
|
|
22
|
+
/** DNS confirmed the claimed identity. Deterministic. */
|
|
23
|
+
{
|
|
24
|
+
status: "verified";
|
|
25
|
+
hostname: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* DNS answered, and the answer contradicts the claim. Deterministic.
|
|
29
|
+
*
|
|
30
|
+
* `cause` is what callers should branch on. One of them offers an option to stop
|
|
31
|
+
* treating a missing PTR as forgery, and it used to implement that by searching
|
|
32
|
+
* `reason` for the words "no PTR record" — so rewording a human-readable sentence
|
|
33
|
+
* silently turned an operator's explicit opt-out into a no-op.
|
|
34
|
+
*/
|
|
35
|
+
| {
|
|
36
|
+
status: "contradicted";
|
|
37
|
+
cause: "no-ptr" | "wrong-domain" | "no-forward-record" | "address-mismatch";
|
|
38
|
+
reason: string;
|
|
39
|
+
hostname?: string | undefined;
|
|
40
|
+
}
|
|
41
|
+
/** No usable answer. Proves nothing; produces no evidence in either direction. */
|
|
42
|
+
| {
|
|
43
|
+
status: "indeterminate";
|
|
44
|
+
reason: string;
|
|
45
|
+
};
|
|
46
|
+
/** Node-backed resolver. Imported lazily so the library stays loadable on edge runtimes. */
|
|
47
|
+
export declare function nodeDnsResolver(timeoutMs?: number): DnsResolver;
|
|
48
|
+
export declare class DnsTimeoutError extends Error {
|
|
49
|
+
readonly name = "DnsTimeoutError";
|
|
50
|
+
/**
|
|
51
|
+
* Carried so the timeout reads as a timeout everywhere a DNS error's code is
|
|
52
|
+
* inspected — the cache's replay, and the reason string an operator sees when a
|
|
53
|
+
* crawler could not be verified. Deliberately not one of {@link DEFINITIVE_ABSENCE}:
|
|
54
|
+
* a lookup that ran out of time has said nothing about whether the name exists.
|
|
55
|
+
*/
|
|
56
|
+
readonly code = "ETIMEDOUT";
|
|
57
|
+
constructor();
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Classifies a DNS rejection. `true` means the negative answer is authoritative and
|
|
61
|
+
* may be treated as a contradiction; `false` means we simply did not get an answer.
|
|
62
|
+
*/
|
|
63
|
+
export declare function isDefinitiveAbsence(error: unknown): boolean;
|
|
64
|
+
/** True when `hostname` is `domain` itself or a subdomain of it. Never a substring match. */
|
|
65
|
+
export declare function isUnderDomain(hostname: string, domain: string): boolean;
|
|
66
|
+
/**
|
|
67
|
+
* Forward-confirmed reverse DNS.
|
|
68
|
+
*
|
|
69
|
+
* 1. PTR the client IP.
|
|
70
|
+
* 2. Require at least one name under one of `domains` — a *suffix* match on label
|
|
71
|
+
* boundaries, so `googlebot.com.evil.net` does not pass.
|
|
72
|
+
* 3. Forward-resolve that name and require the original IP back, which is what stops
|
|
73
|
+
* anyone who controls a PTR record for their own address from claiming to be
|
|
74
|
+
* Googlebot.
|
|
75
|
+
*/
|
|
76
|
+
export declare function forwardConfirmedReverseDns(resolver: DnsResolver, ip: string, domains: readonly string[]): Promise<VerificationOutcome>;
|
|
77
|
+
export interface CachingResolverOptions {
|
|
78
|
+
/** How long a successful answer is reused, ms. Default 3600000 (1h). */
|
|
79
|
+
ttlMs?: number;
|
|
80
|
+
/**
|
|
81
|
+
* How long a failure is remembered, ms. Default 60000.
|
|
82
|
+
*
|
|
83
|
+
* Much shorter than the success TTL on purpose. Caching a failure for an hour would
|
|
84
|
+
* turn a one-minute resolver blip into an hour of unverified Googlebot, and the
|
|
85
|
+
* cost of retrying is one lookup.
|
|
86
|
+
*/
|
|
87
|
+
errorTtlMs?: number;
|
|
88
|
+
/** Maximum names and addresses cached. Default 10000. */
|
|
89
|
+
max?: number;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Memoises lookups.
|
|
93
|
+
*
|
|
94
|
+
* Verification would otherwise resolve DNS twice for *every* request from a crawler,
|
|
95
|
+
* which for a site Googlebot likes is thousands of lookups an hour for an answer that
|
|
96
|
+
* changes approximately never. Caching here rather than inside the detector keeps it
|
|
97
|
+
* at the layer where the data is genuinely cacheable, and lets you swap the whole
|
|
98
|
+
* resolver out in tests without losing it.
|
|
99
|
+
*/
|
|
100
|
+
export declare function cachingResolver(inner: DnsResolver, options?: CachingResolverOptions): DnsResolver;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal typed event emitter.
|
|
3
|
+
*
|
|
4
|
+
* Not `node:events`: listeners here are isolated so that one badly-written
|
|
5
|
+
* subscriber cannot take down request handling. A throwing listener is reported to
|
|
6
|
+
* `onError` and the remaining listeners still run — the same isolation guarantee the
|
|
7
|
+
* detector pipeline gives.
|
|
8
|
+
*/
|
|
9
|
+
export type Listener<T> = (payload: T) => void;
|
|
10
|
+
export declare class Emitter<Events extends Record<string, unknown>> {
|
|
11
|
+
private readonly onError;
|
|
12
|
+
private readonly listeners;
|
|
13
|
+
constructor(onError?: (error: unknown, event: string) => void);
|
|
14
|
+
on<K extends keyof Events & string>(event: K, listener: Listener<Events[K]>): () => void;
|
|
15
|
+
off<K extends keyof Events & string>(event: K, listener: Listener<Events[K]>): void;
|
|
16
|
+
emit<K extends keyof Events & string>(event: K, payload: Events[K]): void;
|
|
17
|
+
removeAll(): void;
|
|
18
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Cookie parsing and serialisation, plus small header helpers. No dependencies. */
|
|
2
|
+
/**
|
|
3
|
+
* Parses a `Cookie` header into a null-prototype bag.
|
|
4
|
+
*
|
|
5
|
+
* Null-prototype matters: a request carrying `__proto__=x` would otherwise hit the
|
|
6
|
+
* prototype setter on a plain object and vanish, and any later `cookies.constructor`
|
|
7
|
+
* lookup would find `Object`'s rather than `undefined`. Neither is a vulnerability by
|
|
8
|
+
* itself; both are the kind of surprise that becomes one.
|
|
9
|
+
*/
|
|
10
|
+
export declare function parseCookies(header: string | undefined): Record<string, string>;
|
|
11
|
+
export interface CookieOptions {
|
|
12
|
+
maxAgeMs?: number;
|
|
13
|
+
path?: string;
|
|
14
|
+
domain?: string;
|
|
15
|
+
secure?: boolean;
|
|
16
|
+
httpOnly?: boolean;
|
|
17
|
+
sameSite?: "Lax" | "Strict" | "None";
|
|
18
|
+
}
|
|
19
|
+
/** Builds a `Set-Cookie` value. Rejects names and values that would let a caller inject attributes. */
|
|
20
|
+
export declare function serializeCookie(name: string, value: string, options?: CookieOptions): string;
|
|
21
|
+
/** Joins multi-value headers the way the rest of the library expects to see them. */
|
|
22
|
+
export declare function joinHeaderValue(value: string | string[] | undefined): string | undefined;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IP parsing, normalisation and CIDR matching, with no dependencies.
|
|
3
|
+
*
|
|
4
|
+
* Everything works on raw byte arrays rather than strings, because string
|
|
5
|
+
* comparison of IPs is a classic source of bypasses: `::ffff:127.0.0.1`,
|
|
6
|
+
* `0177.0.0.1` and `127.0.0.001` are all the same host to the network stack but
|
|
7
|
+
* three different strings to a naive allowlist.
|
|
8
|
+
*/
|
|
9
|
+
/** An IP as bytes: 4 for IPv4, 16 for IPv6. IPv4-mapped IPv6 is folded to 4. */
|
|
10
|
+
export type IpBytes = Uint8Array;
|
|
11
|
+
/**
|
|
12
|
+
* Parses an IPv4 or IPv6 literal into bytes, or `null` if it is not a valid
|
|
13
|
+
* address. Deliberately strict: no octal, no hex, no shorthand octets, no zone
|
|
14
|
+
* ids. Anything ambiguous is rejected rather than guessed at.
|
|
15
|
+
*/
|
|
16
|
+
export declare function parseIp(value: string): IpBytes | null;
|
|
17
|
+
/** Canonical string form, so the same host always produces the same actor key. */
|
|
18
|
+
export declare function formatIp(bytes: IpBytes): string;
|
|
19
|
+
/** Normalises any accepted spelling of an address to its canonical form. Returns `null` if unparseable. */
|
|
20
|
+
export declare function normalizeIp(value: string): string | null;
|
|
21
|
+
export interface Cidr {
|
|
22
|
+
readonly bytes: IpBytes;
|
|
23
|
+
readonly prefix: number;
|
|
24
|
+
readonly source: string;
|
|
25
|
+
}
|
|
26
|
+
/** Parses `"10.0.0.0/8"`, `"2001:db8::/32"`, or a bare address (treated as a /32 or /128). */
|
|
27
|
+
export declare function parseCidr(value: string): Cidr | null;
|
|
28
|
+
/** True when `ip` falls inside `cidr`. Address families never match across each other. */
|
|
29
|
+
export declare function cidrContains(cidr: Cidr, ip: IpBytes): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* A compiled set of CIDR ranges. Building it once and reusing it keeps the hot
|
|
32
|
+
* path free of string parsing — matching is a handful of byte comparisons, and stays
|
|
33
|
+
* that way as the list grows into the thousands.
|
|
34
|
+
*/
|
|
35
|
+
export declare class IpRangeSet {
|
|
36
|
+
private readonly v4;
|
|
37
|
+
private readonly v6;
|
|
38
|
+
private count;
|
|
39
|
+
/**
|
|
40
|
+
* Every range that parsed, in the order it was added.
|
|
41
|
+
*
|
|
42
|
+
* Kept as plain text beside the index rather than reconstructed from it. The index
|
|
43
|
+
* is shaped for lookups — bucketed by leading byte, prefix-split — and walking it
|
|
44
|
+
* back into a list would be both slower and, for anything that wants to *show* the
|
|
45
|
+
* set, wrong: what an operator recognises is the string they wrote, not a normalised
|
|
46
|
+
* form of it. These sets are configuration-sized, so the array costs nothing.
|
|
47
|
+
*/
|
|
48
|
+
private readonly sources;
|
|
49
|
+
/** Ranges that failed to parse, surfaced so a typo in config is loud rather than silent. */
|
|
50
|
+
readonly invalid: string[];
|
|
51
|
+
constructor(ranges?: Iterable<string>);
|
|
52
|
+
add(range: string): void;
|
|
53
|
+
get size(): number;
|
|
54
|
+
/** The ranges in this set, as written, oldest first. What a reader can act on. */
|
|
55
|
+
entries(): readonly string[];
|
|
56
|
+
/**
|
|
57
|
+
* Returns the matching range's original text, or `undefined`. Useful for explaining
|
|
58
|
+
* a decision. When several ranges match, the one added first wins, so the
|
|
59
|
+
* explanation does not depend on the index's internal layout.
|
|
60
|
+
*/
|
|
61
|
+
match(ip: string | IpBytes): string | undefined;
|
|
62
|
+
contains(ip: string | IpBytes): boolean;
|
|
63
|
+
}
|
|
64
|
+
/** IANA special-purpose ranges: loopback, private, link-local, CGNAT, documentation. */
|
|
65
|
+
export declare const SPECIAL_USE_RANGES: readonly ["0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16", "172.16.0.0/12", "192.0.0.0/24", "192.0.2.0/24", "192.168.0.0/16", "198.18.0.0/15", "198.51.100.0/24", "203.0.113.0/24", "224.0.0.0/4", "240.0.0.0/4", "::1/128", "fc00::/7", "fe80::/10", "2001:db8::/32"];
|
|
66
|
+
/** True for loopback/private/link-local/documentation addresses — never public clients. */
|
|
67
|
+
export declare function isSpecialUse(ip: string): boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Masks an address to a coarse network for rate accounting: /24 for IPv4, /64 for
|
|
70
|
+
* IPv6. IPv6 clients routinely get a whole /64 to themselves and rotate the host
|
|
71
|
+
* bits freely, so per-address counting is trivially defeated there.
|
|
72
|
+
*/
|
|
73
|
+
export declare function networkKey(ip: string): string;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Clock } from "./clock.js";
|
|
2
|
+
/**
|
|
3
|
+
* Bounded map with per-entry TTL and least-recently-used eviction.
|
|
4
|
+
*
|
|
5
|
+
* The bound is a security property, not a nicety. Every per-actor structure in this
|
|
6
|
+
* library is keyed by something an attacker controls (an IP, a fingerprint), so an
|
|
7
|
+
* unbounded map is a remote OOM waiting to happen. Capacity is enforced on every
|
|
8
|
+
* insert; expiry is checked lazily on read plus a bounded sweep on insert, so a
|
|
9
|
+
* quiet process never keeps a timer alive.
|
|
10
|
+
*/
|
|
11
|
+
export declare class TtlLru<V> {
|
|
12
|
+
private readonly capacity;
|
|
13
|
+
private readonly ttlMs;
|
|
14
|
+
private readonly clock;
|
|
15
|
+
private readonly entries;
|
|
16
|
+
constructor(capacity: number, ttlMs: number, clock?: Clock);
|
|
17
|
+
get size(): number;
|
|
18
|
+
/**
|
|
19
|
+
* Every live value, most recently used last — the Map's own insertion order, which
|
|
20
|
+
* `get` and `set` maintain.
|
|
21
|
+
*
|
|
22
|
+
* Expired entries are skipped rather than deleted, because this is a read: a caller
|
|
23
|
+
* listing what is in the cache should not be the thing that evicts from it, and the
|
|
24
|
+
* next `get` or `set` on that key will clear it anyway.
|
|
25
|
+
*/
|
|
26
|
+
values(): V[];
|
|
27
|
+
get(key: string): V | undefined;
|
|
28
|
+
set(key: string, value: V): void;
|
|
29
|
+
/** Reads, or creates via `factory` and stores. The common read-modify-write path. */
|
|
30
|
+
getOrCreate(key: string, factory: () => V): V;
|
|
31
|
+
delete(key: string): void;
|
|
32
|
+
clear(): void;
|
|
33
|
+
private evict;
|
|
34
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export declare class MultiPatternMatcher<T> {
|
|
2
|
+
/** Transition tables, one Map per node, indexed by character code. */
|
|
3
|
+
private readonly transitions;
|
|
4
|
+
private readonly fail;
|
|
5
|
+
/** Payloads that end at each node, plus everything reachable via fail links. */
|
|
6
|
+
private readonly outputs;
|
|
7
|
+
/** Longest pattern registered — lets callers skip inputs that cannot match. */
|
|
8
|
+
readonly maxPatternLength: number;
|
|
9
|
+
readonly patternCount: number;
|
|
10
|
+
/**
|
|
11
|
+
* Flat transition table for the root node over 7-bit ASCII.
|
|
12
|
+
*
|
|
13
|
+
* A scan of text that matches nothing — every request from a real browser — sits at
|
|
14
|
+
* the root for its entire length, so the root's transition lookup happens once per
|
|
15
|
+
* character and dominates everything else. A `Map.get` there costs a hash and a
|
|
16
|
+
* bucket probe; an array index costs neither. 512 bytes, built once.
|
|
17
|
+
*
|
|
18
|
+
* Patterns are overwhelmingly lowercase ASCII. The rare one that is not — a crawler
|
|
19
|
+
* that names itself in Cyrillic or Han — gets a root transition above 127, which the
|
|
20
|
+
* flat table cannot hold; those live in {@link rootWide}, left undefined when there
|
|
21
|
+
* are none so the common case pays one `undefined` check.
|
|
22
|
+
*/
|
|
23
|
+
private readonly rootAscii;
|
|
24
|
+
private readonly rootWide;
|
|
25
|
+
constructor(patterns: Iterable<readonly [pattern: string, payload: T]>);
|
|
26
|
+
/** Root-node transition. Kept separate so the hot loop reads an array, not a Map. */
|
|
27
|
+
private fromRoot;
|
|
28
|
+
private buildFailureLinks;
|
|
29
|
+
/**
|
|
30
|
+
* Every distinct payload whose pattern occurs in `haystack`. The input must
|
|
31
|
+
* already be lowercased — casing is normalised by the caller, once, rather than
|
|
32
|
+
* per pattern.
|
|
33
|
+
*
|
|
34
|
+
* The no-match case allocates nothing and returns a shared frozen array. That is
|
|
35
|
+
* the case for every request from a person, so it is the one worth making free.
|
|
36
|
+
*/
|
|
37
|
+
matchAll(haystack: string): readonly T[];
|
|
38
|
+
/** Cheap existence check that stops at the first hit. */
|
|
39
|
+
matchFirst(haystack: string): T | undefined;
|
|
40
|
+
has(haystack: string): boolean;
|
|
41
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path patterns supplied by an operator, made safe to reuse.
|
|
3
|
+
*
|
|
4
|
+
* `RegExp.test` is stateful when the pattern carries `g` or `y`: it resumes from
|
|
5
|
+
* `lastIndex` and resets only on a failed match, so the same pattern tested against
|
|
6
|
+
* the same string answers `true`, `false`, `true`, `false`. Nothing here matches a
|
|
7
|
+
* path more than once, so neither flag can express anything anyone wanted — but both
|
|
8
|
+
* turn a rule into a coin flip, and the symptom (a bot blocked, served, blocked,
|
|
9
|
+
* served) reads as a bug anywhere except in the flag that caused it.
|
|
10
|
+
*/
|
|
11
|
+
/** The pattern with `g` and `y` removed. Returns strings and stateless regexes unchanged. */
|
|
12
|
+
export declare function statelessPattern<T extends string | RegExp>(pattern: T): T;
|
|
13
|
+
/** True when `path` matches: strings are a prefix test, regexes are tested as written. */
|
|
14
|
+
export declare function pathMatches(patterns: readonly (string | RegExp)[], path: string): boolean;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-Agent shape analysis.
|
|
3
|
+
*
|
|
4
|
+
* This is deliberately *not* a full UA-parsing library. Naming the exact browser and
|
|
5
|
+
* version is a losing game — the string is client-controlled, frozen by vendors, and
|
|
6
|
+
* lies constantly. What we extract instead is **structure**: does this string have
|
|
7
|
+
* the shape a real browser emits, and do its parts agree with each other?
|
|
8
|
+
*
|
|
9
|
+
* Structure is far harder to fake convincingly than a product token, and — more
|
|
10
|
+
* importantly — a structural contradiction is *deterministic* evidence in a way that
|
|
11
|
+
* "the string says curl" never is.
|
|
12
|
+
*/
|
|
13
|
+
export type UaShape = "browser-like" | "declared-bot" | "library" | "empty" | "malformed" | "other";
|
|
14
|
+
export interface ParsedUserAgent {
|
|
15
|
+
raw: string;
|
|
16
|
+
lower: string;
|
|
17
|
+
shape: UaShape;
|
|
18
|
+
/** Product token we believe identifies the client, e.g. `"chrome"`, `"safari"`. */
|
|
19
|
+
browser?: string | undefined;
|
|
20
|
+
/** Major version as an integer, when one is present and plausible. */
|
|
21
|
+
majorVersion?: number | undefined;
|
|
22
|
+
/** Rendering engine token: `"blink"`, `"gecko"`, `"webkit"`. */
|
|
23
|
+
engine?: string | undefined;
|
|
24
|
+
/** Normalised OS family: `"windows"`, `"macos"`, `"linux"`, `"android"`, `"ios"`. */
|
|
25
|
+
os?: string | undefined;
|
|
26
|
+
/**
|
|
27
|
+
* Every `Product/Version` pair, in order.
|
|
28
|
+
*
|
|
29
|
+
* Computed on first access and cached. Tokenising the whole string costs more than
|
|
30
|
+
* every other part of parsing put together, and a mainstream browser User-Agent
|
|
31
|
+
* never needs it — only unrecognised and library-shaped strings do. Reading this
|
|
32
|
+
* property is cheap; reading it on a hot path for a browser is not.
|
|
33
|
+
*/
|
|
34
|
+
readonly products: ReadonlyArray<UserAgentProduct>;
|
|
35
|
+
/** True when the string names a URL or an email address anywhere. */
|
|
36
|
+
declaresContact: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* True when the string contains a word like `bot`, `crawler` or `spider`.
|
|
39
|
+
*
|
|
40
|
+
* Kept apart from {@link declaresContact} because the two together are a
|
|
41
|
+
* declaration and either alone is not, and conflating them let a UA reach the
|
|
42
|
+
* `certain` tier on the strength of a URL by itself.
|
|
43
|
+
*/
|
|
44
|
+
declaresAutomation: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* True when contact is published using the crawler convention — a `+` before the
|
|
47
|
+
* URL or address, as in `+http://www.google.com/bot.html`.
|
|
48
|
+
*
|
|
49
|
+
* A bare URL in a User-Agent means very little: plenty of applications put their
|
|
50
|
+
* own homepage or support address in one, and the people behind those are people.
|
|
51
|
+
* The `+` prefix is the long-standing convention specifically for automation
|
|
52
|
+
* announcing its operator, so it carries the intent that a bare URL does not.
|
|
53
|
+
*/
|
|
54
|
+
usesContactConvention: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* A Chromium fork or embedded build that does not reliably implement the modern
|
|
57
|
+
* header set, named by its product token.
|
|
58
|
+
*
|
|
59
|
+
* This distinction is not pedantry, it is a very large population. "Chromium 89 and
|
|
60
|
+
* later sends `Sec-CH-UA`" is true of Google Chrome and of the mainstream forks
|
|
61
|
+
* that track it closely — Edge, Opera, Vivaldi, Brave, Samsung Internet — and false
|
|
62
|
+
* of UC Browser, MIUI Browser, Huawei Browser, QQ Browser, Amazon Silk, television
|
|
63
|
+
* and console builds, and many in-app WebViews, all of which are pinned to an older
|
|
64
|
+
* Chromium or strip the headers. Between them those are hundreds of millions of
|
|
65
|
+
* people, concentrated in South and East Asia.
|
|
66
|
+
*
|
|
67
|
+
* Detectors reasoning from the *absence* of Client Hints or Fetch Metadata must
|
|
68
|
+
* stand down for these; detectors reading what is present carry on unchanged.
|
|
69
|
+
*/
|
|
70
|
+
chromiumFork?: string | undefined;
|
|
71
|
+
}
|
|
72
|
+
export interface UserAgentProduct {
|
|
73
|
+
name: string;
|
|
74
|
+
version?: string | undefined;
|
|
75
|
+
}
|
|
76
|
+
/** Longest UA we will process. Beyond this the string is not a UA, it is a payload. */
|
|
77
|
+
export declare const MAX_USER_AGENT_LENGTH = 512;
|
|
78
|
+
export declare function parseUserAgent(raw: string | undefined): ParsedUserAgent;
|
|
79
|
+
/**
|
|
80
|
+
* True when the string has the *shape* of a mainstream browser UA. Says nothing
|
|
81
|
+
* about whether the claim is honest — that is what the consistency detectors are
|
|
82
|
+
* for. Its job is to identify strings whose claims are worth cross-checking.
|
|
83
|
+
*/
|
|
84
|
+
export declare function claimsBrowser(parsed: ParsedUserAgent): boolean;
|
|
85
|
+
/**
|
|
86
|
+
* True when this client can be expected to implement Client Hints and Fetch Metadata.
|
|
87
|
+
*
|
|
88
|
+
* Only mainstream Chromium and Gecko qualify. Checks that reason from those headers
|
|
89
|
+
* being *missing* must consult this first, or they report a strong signal on every
|
|
90
|
+
* regional fork, television and in-app WebView on the internet.
|
|
91
|
+
*/
|
|
92
|
+
export declare function sendsModernHeaders(parsed: ParsedUserAgent): boolean;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { ActionName, Decision } from "./policy/types.js";
|
|
2
|
+
import type { Assessment, BotClass, BypassReason, Verdict } from "./types.js";
|
|
3
|
+
/** Upper bounds in milliseconds. The last bucket is unbounded. */
|
|
4
|
+
export declare const DURATION_BUCKETS_MS: readonly number[];
|
|
5
|
+
/**
|
|
6
|
+
* Upper bounds for the probabilistic score histogram.
|
|
7
|
+
*
|
|
8
|
+
* Ten buckets of ten points, which is the whole range: `combine()` caps a
|
|
9
|
+
* probabilistic score at 99 and gives every proven assessment a flat 100, so nothing
|
|
10
|
+
* ever lands past the last bound and there is no `+Inf` overflow to think about.
|
|
11
|
+
* Proven requests are counted separately in {@link MetricsSnapshot.proven} rather than
|
|
12
|
+
* piled into the top bucket — their score plays no part in any decision, and including
|
|
13
|
+
* them would put a spike at the right-hand edge that means nothing.
|
|
14
|
+
*/
|
|
15
|
+
export declare const SCORE_BUCKETS: readonly number[];
|
|
16
|
+
export interface MetricsSnapshot {
|
|
17
|
+
/** Requests assessed, including those that bypassed detection. */
|
|
18
|
+
requests: number;
|
|
19
|
+
/** Requests that skipped detection, by reason. */
|
|
20
|
+
bypassed: Record<BypassReason, number>;
|
|
21
|
+
verdicts: Record<Verdict, number>;
|
|
22
|
+
botClasses: Record<BotClass, number>;
|
|
23
|
+
actions: Record<ActionName, number>;
|
|
24
|
+
/** Terminal actions the safety guard replaced with something recoverable. */
|
|
25
|
+
downgrades: number;
|
|
26
|
+
/** Assessments resting on at least one piece of proven evidence. */
|
|
27
|
+
proven: number;
|
|
28
|
+
/** How often each detector produced evidence. */
|
|
29
|
+
detectorFirings: Record<string, number>;
|
|
30
|
+
/** How often each detector threw or timed out. */
|
|
31
|
+
detectorFailures: Record<string, number>;
|
|
32
|
+
/**
|
|
33
|
+
* Time spent inside each detector, when `metrics.perDetectorTiming` is on.
|
|
34
|
+
*
|
|
35
|
+
* Empty otherwise, and empty is the default: timing every detector means two clock
|
|
36
|
+
* reads per detector per request, which on a twenty-detector set is forty syscalls
|
|
37
|
+
* or so on the hot path to measure work that is usually a few microseconds. Worth
|
|
38
|
+
* paying while you tune; not worth paying forever.
|
|
39
|
+
*/
|
|
40
|
+
detectorTimings: Record<string, {
|
|
41
|
+
count: number;
|
|
42
|
+
totalMs: number;
|
|
43
|
+
maxMs: number;
|
|
44
|
+
}>;
|
|
45
|
+
challenges: {
|
|
46
|
+
issued: number;
|
|
47
|
+
solved: number;
|
|
48
|
+
rejected: number;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* How suspicion is distributed across the traffic that was scored.
|
|
52
|
+
*
|
|
53
|
+
* The counters behind the one chart that answers "how close does ordinary traffic
|
|
54
|
+
* run to the line?" — asked of the whole run rather than of the few hundred
|
|
55
|
+
* requests a dashboard happens to still be holding. Certain assessments are
|
|
56
|
+
* excluded; see {@link SCORE_BUCKETS}.
|
|
57
|
+
*/
|
|
58
|
+
scores: {
|
|
59
|
+
count: number;
|
|
60
|
+
totalScore: number;
|
|
61
|
+
/** Cumulative counts, aligned with {@link SCORE_BUCKETS}. Prometheus wants them that way. */
|
|
62
|
+
buckets: number[];
|
|
63
|
+
};
|
|
64
|
+
duration: {
|
|
65
|
+
count: number;
|
|
66
|
+
totalMs: number;
|
|
67
|
+
maxMs: number;
|
|
68
|
+
/** Cumulative counts, aligned with {@link DURATION_BUCKETS_MS} plus a final `+Inf`. */
|
|
69
|
+
buckets: number[];
|
|
70
|
+
};
|
|
71
|
+
/** Actors currently held in the registry. A gauge, not a counter. */
|
|
72
|
+
actorsTracked: number;
|
|
73
|
+
}
|
|
74
|
+
export interface MetricsOptions {
|
|
75
|
+
/** Record per-detector durations. Off by default; see {@link MetricsSnapshot.detectorTimings}. */
|
|
76
|
+
perDetectorTiming?: boolean;
|
|
77
|
+
}
|
|
78
|
+
export declare class Metrics {
|
|
79
|
+
/** Whether the engine should bother timing individual detectors. Read on the hot path. */
|
|
80
|
+
readonly perDetectorTiming: boolean;
|
|
81
|
+
private requests;
|
|
82
|
+
private readonly bypassed;
|
|
83
|
+
private readonly verdicts;
|
|
84
|
+
private readonly botClasses;
|
|
85
|
+
private readonly actions;
|
|
86
|
+
private downgrades;
|
|
87
|
+
private proven;
|
|
88
|
+
private readonly detectorFirings;
|
|
89
|
+
private readonly detectorFailures;
|
|
90
|
+
private readonly detectorTimings;
|
|
91
|
+
private challengesIssued;
|
|
92
|
+
private challengesSolved;
|
|
93
|
+
private challengesRejected;
|
|
94
|
+
private scoreCount;
|
|
95
|
+
private scoreTotal;
|
|
96
|
+
private readonly scoreBuckets;
|
|
97
|
+
private durationCount;
|
|
98
|
+
private durationTotal;
|
|
99
|
+
private durationMax;
|
|
100
|
+
private readonly durationBuckets;
|
|
101
|
+
constructor(options?: MetricsOptions);
|
|
102
|
+
recordAssessment(assessment: Assessment): void;
|
|
103
|
+
/** One detector's duration, in milliseconds. Called only when {@link perDetectorTiming} is on. */
|
|
104
|
+
recordDetectorTiming(detector: string, ms: number): void;
|
|
105
|
+
recordDecision(decision: Decision): void;
|
|
106
|
+
recordChallenge(event: "issued" | "solved" | "rejected"): void;
|
|
107
|
+
snapshot(actorsTracked: number): MetricsSnapshot;
|
|
108
|
+
}
|
|
109
|
+
export interface PrometheusOptions {
|
|
110
|
+
/** Metric name prefix. Default `"bothandler"`. */
|
|
111
|
+
prefix?: string;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Renders a snapshot in the Prometheus text exposition format.
|
|
115
|
+
*
|
|
116
|
+
* Serve it from an endpoint your scraper can reach and nobody else can — the
|
|
117
|
+
* detector-firing series describe how detection behaves, which is exactly what
|
|
118
|
+
* someone tuning a scraper against you would like to read.
|
|
119
|
+
*/
|
|
120
|
+
export declare function toPrometheus(snapshot: MetricsSnapshot, options?: PrometheusOptions): string;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Clock } from "../internal/clock.js";
|
|
2
|
+
import type { RedactionOptions } from "./redact.js";
|
|
3
|
+
import type { BotEvent, NotifyFilter, Notifier } from "./types.js";
|
|
4
|
+
export interface NotificationOptions {
|
|
5
|
+
/** Where events go. Empty means notifications are off. */
|
|
6
|
+
sinks?: readonly Notifier[];
|
|
7
|
+
filter?: NotifyFilter;
|
|
8
|
+
/** Redaction applied before an event leaves the process. `false` disables it. */
|
|
9
|
+
redaction?: RedactionOptions | false;
|
|
10
|
+
/**
|
|
11
|
+
* Suppress repeats of the same actor and verdict inside this window, ms. Default
|
|
12
|
+
* 60000. A scraper generates thousands of identical events a minute; without this
|
|
13
|
+
* the first thing your bot detection breaks is your alerting.
|
|
14
|
+
*/
|
|
15
|
+
dedupeWindowMs?: number;
|
|
16
|
+
/**
|
|
17
|
+
* Hard ceiling on events delivered per dedupe window across all actors. Default
|
|
18
|
+
* 200. The backstop for the case dedupe cannot help with — a distributed scrape
|
|
19
|
+
* from ten thousand addresses, where every event is genuinely distinct.
|
|
20
|
+
*/
|
|
21
|
+
maxPerWindow?: number;
|
|
22
|
+
/** Called when a sink throws. Wire it to your logs. */
|
|
23
|
+
onError?: (error: unknown, sinkId: string) => void;
|
|
24
|
+
clock?: Clock;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Fans events out to sinks, with the two properties that decide whether a
|
|
28
|
+
* notification system is an asset or a liability under load.
|
|
29
|
+
*
|
|
30
|
+
* **It never blocks a request.** `emit` returns immediately; delivery happens on its
|
|
31
|
+
* own. A wedged Slack webhook slows down nothing.
|
|
32
|
+
*
|
|
33
|
+
* **It has a ceiling.** Bot traffic arrives in volumes that ordinary alerting was
|
|
34
|
+
* not built for, and an unbounded notifier turns a scrape into an outage of your own
|
|
35
|
+
* paging system. Repeats of the same actor and verdict collapse into one event per
|
|
36
|
+
* window, and a global cap catches distributed traffic where every event is distinct.
|
|
37
|
+
* Suppressed counts are reported when the window rolls, so a quiet channel is never
|
|
38
|
+
* mistaken for quiet traffic.
|
|
39
|
+
*/
|
|
40
|
+
export declare class NotificationHub {
|
|
41
|
+
private readonly sinks;
|
|
42
|
+
private readonly types;
|
|
43
|
+
private readonly minScore;
|
|
44
|
+
private readonly certainOnly;
|
|
45
|
+
private readonly redaction;
|
|
46
|
+
private readonly dedupe;
|
|
47
|
+
private readonly dedupeWindowMs;
|
|
48
|
+
private readonly maxPerWindow;
|
|
49
|
+
private readonly clock;
|
|
50
|
+
private readonly onError;
|
|
51
|
+
private windowStart;
|
|
52
|
+
private windowCount;
|
|
53
|
+
private suppressed;
|
|
54
|
+
constructor(options?: NotificationOptions);
|
|
55
|
+
get enabled(): boolean;
|
|
56
|
+
/** Queues an event. Returns immediately; never throws. */
|
|
57
|
+
emit(event: BotEvent): void;
|
|
58
|
+
/**
|
|
59
|
+
* Redacts and fans out. The only way an event reaches a sink.
|
|
60
|
+
*
|
|
61
|
+
* Made the single path because it was not one: the suppression summary built its own
|
|
62
|
+
* fan-out loop and sent `sample.assessment` straight through, so every time a window
|
|
63
|
+
* rolled under load — precisely when bot traffic is heaviest — one request's raw
|
|
64
|
+
* address, cookies and query string went to the sink in the clear, beside the
|
|
65
|
+
* properly masked events it was summarising.
|
|
66
|
+
*/
|
|
67
|
+
private dispatch;
|
|
68
|
+
private shouldDeliver;
|
|
69
|
+
private deliverSummary;
|
|
70
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { NotificationHub } from "./hub.js";
|
|
2
|
+
export type { NotificationOptions } from "./hub.js";
|
|
3
|
+
export { consoleNotifier, webhookNotifier, slackNotifier, notifyJsNotifier } from "./sinks.js";
|
|
4
|
+
export type { ConsoleNotifierOptions, WebhookNotifierOptions, SlackNotifierOptions, NotifyJsOptions } from "./sinks.js";
|
|
5
|
+
export { redactEvent, CREDENTIAL_HEADERS } from "./redact.js";
|
|
6
|
+
export type { RedactionOptions } from "./redact.js";
|
|
7
|
+
export type { BotEvent, BotEventType, Notifier, NotifyFilter } from "./types.js";
|