@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.
Files changed (186) hide show
  1. package/CHANGELOG.md +516 -0
  2. package/LICENSE +102 -0
  3. package/README.md +308 -0
  4. package/bin/bothandlerjs.mjs +14 -0
  5. package/dist/actions/index.d.ts +22 -0
  6. package/dist/actions/types.d.ts +59 -0
  7. package/dist/adapters/fastify.d.ts +43 -0
  8. package/dist/adapters/fetch.d.ts +51 -0
  9. package/dist/adapters/index.cjs +723 -0
  10. package/dist/adapters/index.cjs.map +1 -0
  11. package/dist/adapters/index.d.ts +8 -0
  12. package/dist/adapters/index.js +692 -0
  13. package/dist/adapters/index.js.map +1 -0
  14. package/dist/adapters/koa.d.ts +27 -0
  15. package/dist/adapters/node.d.ts +29 -0
  16. package/dist/adapters/shared.d.ts +39 -0
  17. package/dist/audit.d.ts +172 -0
  18. package/dist/challenge/index.d.ts +176 -0
  19. package/dist/challenge/language.d.ts +57 -0
  20. package/dist/challenge/page.d.ts +42 -0
  21. package/dist/challenge/pow.d.ts +33 -0
  22. package/dist/challenge/token.d.ts +72 -0
  23. package/dist/cli.cjs +13388 -0
  24. package/dist/cli.cjs.map +1 -0
  25. package/dist/cli.d.ts +5 -0
  26. package/dist/cli.js +13358 -0
  27. package/dist/cli.js.map +1 -0
  28. package/dist/client/index.cjs +139 -0
  29. package/dist/client/index.cjs.map +1 -0
  30. package/dist/client/index.d.ts +63 -0
  31. package/dist/client/index.js +112 -0
  32. package/dist/client/index.js.map +1 -0
  33. package/dist/config.d.ts +316 -0
  34. package/dist/core.d.ts +443 -0
  35. package/dist/corpus/adversarial.d.ts +2 -0
  36. package/dist/corpus/advertising-email.d.ts +2 -0
  37. package/dist/corpus/ai-crawlers.d.ts +2 -0
  38. package/dist/corpus/benign-bots.d.ts +2 -0
  39. package/dist/corpus/cdn-gateways.d.ts +2 -0
  40. package/dist/corpus/cookies.d.ts +49 -0
  41. package/dist/corpus/crawlers-regional.d.ts +2 -0
  42. package/dist/corpus/crawlers-vertical.d.ts +2 -0
  43. package/dist/corpus/headers.d.ts +403 -0
  44. package/dist/corpus/humans-apps.d.ts +2 -0
  45. package/dist/corpus/humans-browsers.d.ts +2 -0
  46. package/dist/corpus/humans.d.ts +2 -0
  47. package/dist/corpus/index.cjs +4196 -0
  48. package/dist/corpus/index.cjs.map +1 -0
  49. package/dist/corpus/index.d.ts +37 -0
  50. package/dist/corpus/index.js +4132 -0
  51. package/dist/corpus/index.js.map +1 -0
  52. package/dist/corpus/infrastructure.d.ts +17 -0
  53. package/dist/corpus/libraries-extended.d.ts +2 -0
  54. package/dist/corpus/ranges.d.ts +31 -0
  55. package/dist/corpus/reputation.d.ts +21 -0
  56. package/dist/corpus/runner.d.ts +130 -0
  57. package/dist/corpus/schema.d.ts +185 -0
  58. package/dist/corpus/tooling.d.ts +2 -0
  59. package/dist/corpus/unwanted.d.ts +2 -0
  60. package/dist/crawler-ranges.d.ts +113 -0
  61. package/dist/dashboard/client.generated.d.ts +7 -0
  62. package/dist/dashboard/feed.d.ts +186 -0
  63. package/dist/dashboard/index.d.ts +5 -0
  64. package/dist/dashboard/page.d.ts +51 -0
  65. package/dist/dashboard/parse-request.d.ts +28 -0
  66. package/dist/dashboard/preview.d.ts +45 -0
  67. package/dist/dashboard/server.d.ts +70 -0
  68. package/dist/dashboard/types.d.ts +714 -0
  69. package/dist/detectors/accept-signature.d.ts +6 -0
  70. package/dist/detectors/browsing-coherence.d.ts +44 -0
  71. package/dist/detectors/cadence.d.ts +28 -0
  72. package/dist/detectors/clearance.d.ts +25 -0
  73. package/dist/detectors/client-hints.d.ts +19 -0
  74. package/dist/detectors/client-signals.d.ts +27 -0
  75. package/dist/detectors/crawl-breadth.d.ts +28 -0
  76. package/dist/detectors/crawler-verification.d.ts +39 -0
  77. package/dist/detectors/fetch-metadata.d.ts +20 -0
  78. package/dist/detectors/header-integrity.d.ts +26 -0
  79. package/dist/detectors/header-order.d.ts +27 -0
  80. package/dist/detectors/identity-rotation.d.ts +34 -0
  81. package/dist/detectors/index.d.ts +56 -0
  82. package/dist/detectors/ip-intelligence.d.ts +30 -0
  83. package/dist/detectors/known-bots.d.ts +94 -0
  84. package/dist/detectors/probe-signature.d.ts +50 -0
  85. package/dist/detectors/rate-anomaly.d.ts +36 -0
  86. package/dist/detectors/self-identified.d.ts +33 -0
  87. package/dist/detectors/session-integrity.d.ts +24 -0
  88. package/dist/detectors/tls-fingerprint.d.ts +34 -0
  89. package/dist/detectors/trap.d.ts +98 -0
  90. package/dist/detectors/types.d.ts +70 -0
  91. package/dist/detectors/ua-coherence.d.ts +32 -0
  92. package/dist/evidence.d.ts +45 -0
  93. package/dist/facts.d.ts +46 -0
  94. package/dist/index.cjs +9600 -0
  95. package/dist/index.cjs.map +1 -0
  96. package/dist/index.d.ts +63 -0
  97. package/dist/index.js +9444 -0
  98. package/dist/index.js.map +1 -0
  99. package/dist/internal/async.d.ts +16 -0
  100. package/dist/internal/clock.d.ts +17 -0
  101. package/dist/internal/crypto.d.ts +26 -0
  102. package/dist/internal/dns.d.ts +100 -0
  103. package/dist/internal/emitter.d.ts +18 -0
  104. package/dist/internal/http.d.ts +22 -0
  105. package/dist/internal/ip.d.ts +73 -0
  106. package/dist/internal/lru.d.ts +34 -0
  107. package/dist/internal/matcher.d.ts +41 -0
  108. package/dist/internal/pattern.d.ts +14 -0
  109. package/dist/internal/ua.d.ts +92 -0
  110. package/dist/metrics.d.ts +120 -0
  111. package/dist/notify/hub.d.ts +70 -0
  112. package/dist/notify/index.d.ts +7 -0
  113. package/dist/notify/redact.d.ts +48 -0
  114. package/dist/notify/sinks.d.ts +62 -0
  115. package/dist/notify/types.d.ts +56 -0
  116. package/dist/policy/index.d.ts +7 -0
  117. package/dist/policy/match.d.ts +20 -0
  118. package/dist/policy/policy.d.ts +123 -0
  119. package/dist/policy/presets.d.ts +161 -0
  120. package/dist/policy/types.d.ts +155 -0
  121. package/dist/robots.d.ts +83 -0
  122. package/dist/state.d.ts +161 -0
  123. package/dist/stores/index.d.ts +5 -0
  124. package/dist/stores/memory.d.ts +33 -0
  125. package/dist/stores/redis.d.ts +59 -0
  126. package/dist/stores/types.d.ts +37 -0
  127. package/dist/types.d.ts +250 -0
  128. package/docs/challenge/index.md +152 -0
  129. package/docs/challenge/localisation.md +105 -0
  130. package/docs/concepts/actors.md +113 -0
  131. package/docs/concepts/evidence.md +133 -0
  132. package/docs/concepts/the-guard.md +121 -0
  133. package/docs/concepts/threat-model.md +120 -0
  134. package/docs/concepts/verdicts.md +108 -0
  135. package/docs/course/01-first-assessment.md +178 -0
  136. package/docs/course/02-proof-and-suspicion.md +164 -0
  137. package/docs/course/03-verdicts-and-scores.md +160 -0
  138. package/docs/course/04-the-guard.md +200 -0
  139. package/docs/course/05-detectors.md +178 -0
  140. package/docs/course/06-identity.md +161 -0
  141. package/docs/course/07-actors.md +178 -0
  142. package/docs/course/08-traps.md +183 -0
  143. package/docs/course/09-rules.md +224 -0
  144. package/docs/course/10-actions-and-presets.md +216 -0
  145. package/docs/course/11-the-challenge.md +227 -0
  146. package/docs/course/12-going-live.md +211 -0
  147. package/docs/course/13-operating-it.md +238 -0
  148. package/docs/course/14-scaling.md +195 -0
  149. package/docs/course/15-extending.md +259 -0
  150. package/docs/course/16-proving-it.md +266 -0
  151. package/docs/course/index.md +112 -0
  152. package/docs/design/decisions.md +216 -0
  153. package/docs/detection/client-signals.md +96 -0
  154. package/docs/detection/detectors.md +376 -0
  155. package/docs/detection/index.md +134 -0
  156. package/docs/detection/signatures.md +111 -0
  157. package/docs/detection/verification.md +123 -0
  158. package/docs/detection/writing-a-detector.md +145 -0
  159. package/docs/index.md +100 -0
  160. package/docs/integration/adapters.md +138 -0
  161. package/docs/integration/client-ip.md +114 -0
  162. package/docs/integration/index.md +55 -0
  163. package/docs/integration/stores.md +76 -0
  164. package/docs/operations/audit.md +116 -0
  165. package/docs/operations/dashboard.md +725 -0
  166. package/docs/operations/index.md +101 -0
  167. package/docs/operations/metrics.md +74 -0
  168. package/docs/operations/notifications.md +72 -0
  169. package/docs/operations/runtime-changes.md +118 -0
  170. package/docs/policy/actions.md +196 -0
  171. package/docs/policy/index.md +84 -0
  172. package/docs/policy/presets.md +191 -0
  173. package/docs/policy/robots.md +128 -0
  174. package/docs/policy/rules.md +134 -0
  175. package/docs/reference/api.md +126 -0
  176. package/docs/reference/configuration.md +175 -0
  177. package/docs/start/choosing-a-policy.md +127 -0
  178. package/docs/start/first-integration.md +127 -0
  179. package/docs/start/installation.md +63 -0
  180. package/docs/start/upgrading.md +72 -0
  181. package/docs/testing/cli.md +119 -0
  182. package/docs/testing/corpus.md +129 -0
  183. package/docs/testing/index.md +61 -0
  184. package/docs/testing/replay.md +102 -0
  185. package/docs/testing/try-it.md +189 -0
  186. package/package.json +145 -0
@@ -0,0 +1,24 @@
1
+ import type { Detector } from "./types.js";
2
+ export interface SessionIntegrityOptions {
3
+ /** Requests from one actor before a total absence of cookies is worth reporting. Default 12. */
4
+ minRequests?: number;
5
+ /** Also report navigations that carry no Referer. Default true. */
6
+ checkReferer?: boolean;
7
+ }
8
+ /**
9
+ * Does this client behave like something that holds a session?
10
+ *
11
+ * A browser accumulates state. Once your server has set anything at all — a session
12
+ * cookie, a consent flag, an A/B bucket — a real browser sends it back on every
13
+ * subsequent request, forever. A stateless HTTP client sends nothing back no matter
14
+ * how many times it visits, because it discards the response headers it does not
15
+ * care about.
16
+ *
17
+ * A dozen requests from one actor with not a single cookie is therefore a decent
18
+ * tell. It is only `moderate`, though, and the reason is worth internalising: the
19
+ * population that blocks all cookies is real people who have gone out of their way
20
+ * to protect their privacy. Escalating on this signal alone punishes precisely the
21
+ * users least deserving of it, which is the argument this library's whole design
22
+ * exists to make.
23
+ */
24
+ export declare function sessionIntegrityDetector(options?: SessionIntegrityOptions): Detector;
@@ -0,0 +1,34 @@
1
+ import type { Detector } from "./types.js";
2
+ /** What a given TLS fingerprint is known to belong to. */
3
+ export interface FingerprintProfile {
4
+ /** Human-readable client name, e.g. `"Chrome 122 (macOS)"`. */
5
+ label: string;
6
+ /** Rendering engine this fingerprint belongs to, matched against the parsed UA. */
7
+ engine?: "blink" | "gecko" | "webkit" | undefined;
8
+ /** True when the fingerprint belongs to a tool rather than a browser. */
9
+ automated?: boolean | undefined;
10
+ }
11
+ export interface TlsFingerprintOptions {
12
+ /** Fingerprint (JA3/JA4) to profile. Supply your own; none ships with this library. */
13
+ profiles?: ReadonlyMap<string, FingerprintProfile>;
14
+ }
15
+ /**
16
+ * Compares the TLS handshake fingerprint against the client the User-Agent claims.
17
+ *
18
+ * This is the signal that survives a scraper copying your browser's headers
19
+ * perfectly, because the fingerprint is produced by the TLS library before a single
20
+ * HTTP byte is sent. A Go program presenting Chrome's User-Agent still has Go's
21
+ * ClientHello, and the mismatch is visible.
22
+ *
23
+ * Node cannot compute this — the ClientHello is consumed by the TLS layer before any
24
+ * JavaScript runs — so the fingerprint must come from your edge: nginx with a JA3
25
+ * module, HAProxy, Cloudflare's `cf-ja3-hash`, or an ALB with the right attributes
26
+ * forwarded. Point `tlsFingerprintHeader` at whichever header carries it.
27
+ *
28
+ * It stops at `strong` for two reasons. Fingerprints collide across clients that
29
+ * share a TLS library, so a match is weaker evidence than it looks. And they churn
30
+ * with every browser release, so a profile table is out of date the moment you stop
31
+ * updating it — a stale table produces mismatches for people running a browser newer
32
+ * than your data, which is the worst possible failure mode.
33
+ */
34
+ export declare function tlsFingerprintDetector(options?: TlsFingerprintOptions): Detector;
@@ -0,0 +1,98 @@
1
+ import type { Detector } from "./types.js";
2
+ export interface TrapOptions {
3
+ /**
4
+ * Paths that exist only to be found by something that reads markup rather than
5
+ * renders it. Matched exactly, or as a prefix when the entry ends in `/`.
6
+ */
7
+ paths?: readonly string[];
8
+ /**
9
+ * Names of form fields that are present in your HTML, hidden from people, and must
10
+ * therefore arrive empty. Anything that fills one filled it by parsing the form.
11
+ *
12
+ * **You have to hand the submitted values over.** The engine reads no request body,
13
+ * deliberately — doing so would mean consuming the stream before your own parser
14
+ * sees it. A hidden field in a `method="post"` form therefore arrives in the body,
15
+ * where nothing here can see it, so a query-string-only check would silently never
16
+ * fire for the forms honeypots are actually put on: sign-up, contact, comment,
17
+ * login. Put the parsed fields in `facts.extra.formFields` — see
18
+ * {@link TRAP_FIELD_SOURCE} — and this reads them alongside the query string.
19
+ */
20
+ formFields?: readonly string[];
21
+ /** Header a trap link may carry, if you prefer marking traps out-of-band. */
22
+ headerName?: string;
23
+ }
24
+ /**
25
+ * Where {@link trapDetector} looks for submitted form fields, inside `facts.extra`.
26
+ *
27
+ * ```ts
28
+ * botHandler(handler, {
29
+ * enrich: (request, facts) => ({ ...facts, extra: { [TRAP_FIELD_SOURCE]: request.body } }),
30
+ * });
31
+ * ```
32
+ *
33
+ * Values are read as strings; anything else is ignored rather than coerced, because
34
+ * this feeds a `certain` verdict and `String(someObject)` is not evidence of anything.
35
+ */
36
+ export declare const TRAP_FIELD_SOURCE = "formFields";
37
+ /** Trap paths installed unless you replace them. Chosen to look like something worth fetching. */
38
+ export declare const DEFAULT_TRAP_PATHS: readonly string[];
39
+ /**
40
+ * Bait. The one detector whose evidence needs no statistics at all.
41
+ *
42
+ * A trap is a link or a form field that exists in your HTML but is unreachable by a
43
+ * person: hidden from layout, hidden from assistive technology, and excluded in
44
+ * `robots.txt`. There is no sequence of clicks, keystrokes or gestures that gets a
45
+ * human to it. Something that requests it read your markup and followed every href
46
+ * it found, which is the definition of automation.
47
+ *
48
+ * This is `certain` for a reason that no header check can match: it does not model
49
+ * what bots look like, it constructs a situation only a bot can be in. Detection by
50
+ * construction rather than by inference, and consequently the one signal whose
51
+ * false-positive rate does not depend on how well the internet is behaving today.
52
+ *
53
+ * Three things must all be true for that guarantee to hold, and they are your
54
+ * responsibility, not the library's:
55
+ *
56
+ * 1. The trap must be **invisible and unfocusable** — use {@link renderTrapLink},
57
+ * which handles `aria-hidden`, `tabindex="-1"` and `rel="nofollow"` together.
58
+ * 2. The path must be **disallowed in robots.txt** — see {@link trapRobotsEntries} —
59
+ * so a well-behaved crawler you *want* is not punished for being thorough.
60
+ * 3. The path must **serve nothing real**, now or ever. A trap that later becomes a
61
+ * working endpoint turns into a source of false positives that will be very hard
62
+ * to diagnose.
63
+ */
64
+ export declare function trapDetector(options?: TrapOptions): Detector;
65
+ export interface TrapLinkOptions {
66
+ /** Visible-to-nobody label. Something plausible to a parser, e.g. "Full export". */
67
+ label?: string;
68
+ }
69
+ /**
70
+ * Renders a trap link as HTML, with every attribute needed for the guarantee above.
71
+ *
72
+ * `aria-hidden` and `tabindex="-1"` together remove it from both the visual and the
73
+ * accessibility tree, so screen-reader and keyboard-only users — who are otherwise
74
+ * the group most at risk from clever traps — can never reach it. `rel="nofollow"`
75
+ * asks search engines not to follow it, and belt-and-braces with `robots.txt`.
76
+ *
77
+ * The output is static and contains no interpolated user input, so it is safe to
78
+ * inline. Emit it once, near the end of `<body>`.
79
+ */
80
+ export declare function renderTrapLink(path: string, options?: TrapLinkOptions): string;
81
+ /**
82
+ * Renders a hidden honeypot form field. Give it a name a form-filler will want to
83
+ * complete — `email_confirm`, `website` — and register that name in `formFields`.
84
+ *
85
+ * If the form is a POST — and the forms worth protecting are — the value arrives in
86
+ * the body, which this library never reads. Pass your parsed body through
87
+ * {@link TRAP_FIELD_SOURCE}, or the field will be rendered, filled, and ignored.
88
+ */
89
+ export declare function renderTrapField(name: string): string;
90
+ /**
91
+ * `robots.txt` lines that exclude your traps.
92
+ *
93
+ * Publish these. A crawler that obeys `robots.txt` is exactly the kind you want to
94
+ * keep, and it is unfair — and bad for your search ranking — to catch it in a net it
95
+ * had no way to see. The bots this detector is for ignore `robots.txt` entirely,
96
+ * which is the point.
97
+ */
98
+ export declare function trapRobotsEntries(paths?: readonly string[]): string;
@@ -0,0 +1,70 @@
1
+ import type { ActorSnapshot, Evidence, RequestFacts } from "../types.js";
2
+ import type { ParsedUserAgent } from "../internal/ua.js";
3
+ import type { MultiPatternMatcher } from "../internal/matcher.js";
4
+ import type { BotSignature } from "./known-bots.js";
5
+ import type { ActorState } from "../state.js";
6
+ import type { Clock } from "../internal/clock.js";
7
+ import type { IpRangeSet } from "../internal/ip.js";
8
+ import type { DnsResolver } from "../internal/dns.js";
9
+ /**
10
+ * Everything a detector is allowed to see.
11
+ *
12
+ * Detectors get a read-only view of the request plus the small number of services
13
+ * they legitimately need. They deliberately do **not** get the config object, the
14
+ * policy, or the response — a detector that can see the policy is a detector that
15
+ * will eventually be written to game it, and a detector that can touch the response
16
+ * has stopped being a detector.
17
+ */
18
+ export interface DetectionContext {
19
+ readonly facts: RequestFacts;
20
+ /** The User-Agent, parsed once per request and shared by every detector. */
21
+ readonly ua: ParsedUserAgent;
22
+ /** Accumulated history for this actor. Read-only here; the engine owns mutation. */
23
+ readonly actor: ActorSnapshot;
24
+ /** The mutable state record behind the snapshot, for detectors that need the raw series. */
25
+ readonly state: ActorState;
26
+ readonly clock: Clock;
27
+ /** Compiled known-bot signatures, matched in a single pass. */
28
+ readonly signatures: MultiPatternMatcher<BotSignature>;
29
+ /** Signature matches for this request's UA, computed once and cached here. */
30
+ readonly signatureMatches: readonly BotSignature[];
31
+ /** DNS access for identity verification. Already timeout-bounded by the engine. */
32
+ readonly resolver: DnsResolver;
33
+ /** Named IP range sets from config: `allowlist`, `denylist`, `datacenter`, and per-crawler ranges. */
34
+ readonly ranges: ReadonlyMap<string, IpRangeSet>;
35
+ /** Scratch space shared between detectors within one request. Cleared afterwards. */
36
+ readonly shared: Map<string, unknown>;
37
+ }
38
+ /** Detectors return evidence, never verdicts. Combining evidence is the engine's job. */
39
+ export type DetectorResult = Evidence | readonly Evidence[] | undefined | null;
40
+ export interface Detector {
41
+ /** Stable id. Appears in evidence, rules, logs and metrics — treat it as public API. */
42
+ id: string;
43
+ /** One line, shown in `describeDetectors()` and in generated documentation. */
44
+ description: string;
45
+ /**
46
+ * `cheap` detectors are pure and synchronous: string and header inspection only.
47
+ * `io` detectors may touch DNS or a shared store, so the engine wraps them in a
48
+ * timeout and runs them concurrently. Mislabelling a blocking detector as `cheap`
49
+ * puts an unbounded await on the request path — the one thing this pipeline must
50
+ * never do.
51
+ */
52
+ cost?: "cheap" | "io";
53
+ /**
54
+ * `always` runs on every request. `confirming` runs only once a primary detector
55
+ * has produced something worth checking — used by reverse-DNS verification, which
56
+ * has no reason to resolve anything for a request that made no identity claim.
57
+ */
58
+ stage?: "always" | "confirming";
59
+ inspect(context: DetectionContext): DetectorResult | Promise<DetectorResult>;
60
+ }
61
+ /** Convenience for the common case of returning a single piece of evidence. */
62
+ export declare function evidence(input: Evidence): Evidence;
63
+ /**
64
+ * Whether a *missing* header may be treated as evidence.
65
+ *
66
+ * False when the source told us its header set is incomplete. Any check phrased as
67
+ * "the client did not send X" has to consult this first; a check phrased as "the
68
+ * client sent X and it contradicts Y" does not, because presence is still presence.
69
+ */
70
+ export declare function absenceIsMeaningful(context: DetectionContext): boolean;
@@ -0,0 +1,32 @@
1
+ import type { Detector } from "./types.js";
2
+ /**
3
+ * Does this User-Agent contradict *itself*?
4
+ *
5
+ * Every other single-request detector compares the User-Agent against something else
6
+ * — the Client Hints, the header set, the header order. This one needs nothing but
7
+ * the string, which makes it the only consistency check that still works on a source
8
+ * with no headers at all: an nginx access line, a CDN log, a WAF event. That is not a
9
+ * small population, and until now every one of those requests reached the engine with
10
+ * exactly one usable detector.
11
+ *
12
+ * What it looks for is a string describing a client that has never existed. Two
13
+ * rendering engines at once. Two operating systems at once. Chrome on an iPhone,
14
+ * where Apple's rules mean Chrome is WebKit and says `CriOS`. A browser preamble no
15
+ * browser has emitted since 2009. These are not "unusual" — they are impossible, and
16
+ * they are what a User-Agent *randomiser* produces, because randomisers assemble a
17
+ * string from independent lists of browsers, versions and platforms and never check
18
+ * that the combination is one that ships.
19
+ *
20
+ * **Why none of it is `certain`.** The same reason `client-hints` is not: the client
21
+ * controls this string end to end, and the population that rewrites it badly includes
22
+ * privacy extensions, enterprise UA policies, developer emulation, and a long tail of
23
+ * embedded devices whose vendor concatenated two templates. A self-contradiction is
24
+ * excellent evidence that the string is fabricated; it is not evidence about who
25
+ * fabricated it, and a person with a UA-spoofing extension has fabricated one too.
26
+ *
27
+ * The version-versus-platform checks are deliberately the weakest thing here, because
28
+ * the "impossible" combination is only impossible on the vendor's own builds. Chrome
29
+ * ended Windows 7 support at 109, and Supermium and Thorium then shipped Chrome 120+
30
+ * on Windows 7 to a real, if small, population. Reported as `moderate`; never more.
31
+ */
32
+ export declare function uaCoherenceDetector(): Detector;
@@ -0,0 +1,45 @@
1
+ import type { BotClass, Evidence, Verdict } from "./types.js";
2
+ export interface CombineOptions {
3
+ /**
4
+ * Score at or above which an unproven request is called `suspected-bot`. Default 60.
5
+ * Raising it makes the library quieter, never safer — nothing here blocks.
6
+ */
7
+ suspectThreshold: number;
8
+ /**
9
+ * Rejects `certain` evidence that carries no `deterministicBasis`. On by default
10
+ * outside production: it is the guard that stops the certainty tier from quietly
11
+ * eroding into "signals we feel strongly about", which is how a
12
+ * no-false-positive promise dies.
13
+ */
14
+ strictEvidence: boolean;
15
+ /** Called instead of throwing when `strictEvidence` finds a problem. */
16
+ onEvidenceViolation?: ((message: string, evidence: Evidence) => void) | undefined;
17
+ }
18
+ export interface CombinedEvidence {
19
+ verdict: Verdict;
20
+ botClass: BotClass;
21
+ identity?: string | undefined;
22
+ score: number;
23
+ confidence: number;
24
+ certain: boolean;
25
+ botEvidence: Evidence[];
26
+ humanEvidence: Evidence[];
27
+ }
28
+ /** Effective weight of a piece of evidence: its own, clamped, or its tier's default. */
29
+ export declare function weightOf(item: Evidence): number;
30
+ /** Strongest first: certainty tier, then weight. Stable, so detector order breaks ties. */
31
+ export declare function sortEvidence(items: Evidence[]): Evidence[];
32
+ /**
33
+ * Noisy-OR: the probability that *at least one* of these independent signals is
34
+ * telling the truth. Chosen over a sum because it is bounded at 1 without clamping
35
+ * and because it has the right shape — many weak signals genuinely do add up to
36
+ * something, but they approach certainty asymptotically and never reach it.
37
+ *
38
+ * The independence assumption is a simplification, and where it is most obviously
39
+ * wrong — several detectors reporting one stripping proxy — it is corrected before
40
+ * the weights get here: see {@link Evidence.family} and `independentWeights`. What
41
+ * remains is mild optimism about correlations nobody has named, which is tolerable
42
+ * precisely because this number cannot get anyone blocked.
43
+ */
44
+ export declare function noisyOr(weights: readonly number[]): number;
45
+ export declare function combineEvidence(items: readonly Evidence[], options: CombineOptions): CombinedEvidence;
@@ -0,0 +1,46 @@
1
+ import type { RequestFacts } from "./types.js";
2
+ export interface FactsInput {
3
+ method?: string | undefined;
4
+ /** Request target, path plus optional query — what `req.url` gives you. */
5
+ url?: string | undefined;
6
+ /** Raw header map. Values may be arrays; names may be any case. */
7
+ headers: Record<string, string | string[] | undefined>;
8
+ /**
9
+ * Header names in wire order. Node exposes them via `req.rawHeaders` (alternating
10
+ * name/value) — pass that array directly, or a name-only list.
11
+ */
12
+ rawHeaders?: readonly string[] | undefined;
13
+ /** Socket address. Pass the *socket's* address; forwarding is resolved separately. */
14
+ ip: string;
15
+ timestamp?: number | undefined;
16
+ protocol?: "http" | "https" | undefined;
17
+ /**
18
+ * The HTTP version of the connection **this process accepted** — Node's
19
+ * `request.httpVersion`, not the version the client negotiated with your edge.
20
+ *
21
+ * The distinction has teeth. HTTP/2 forbids connection-specific headers, and
22
+ * `header-integrity` treats one as a deterministic protocol violation. If you set
23
+ * this from a forwarded header while the request itself arrived over HTTP/1.1 from
24
+ * a proxy — which adds `Connection: keep-alive` — you will manufacture that
25
+ * violation for every real browser behind that proxy, and it is a `certain` verdict,
26
+ * so it can block. Report the connection you actually have, or leave it unset.
27
+ */
28
+ httpVersion?: string | undefined;
29
+ tlsFingerprint?: string | undefined;
30
+ /** See {@link RequestFacts.partialHeaders}. Set it when the source cannot supply every header. */
31
+ partialHeaders?: boolean | undefined;
32
+ extra?: Record<string, unknown> | undefined;
33
+ }
34
+ /**
35
+ * Reduces a request to the facts detectors are allowed to see.
36
+ *
37
+ * Normalisation happens exactly once, here, and every detector reads the result. That
38
+ * is partly performance — lowercasing a header map per detector would be absurd — but
39
+ * mostly correctness: if one detector reads `req.headers['User-Agent']` and another
40
+ * reads `req.headers['user-agent']`, they will eventually disagree about the same
41
+ * request, and the bug will be invisible.
42
+ *
43
+ * Every field is bounded. Each one is attacker-controlled, and this runs on every
44
+ * request to your site.
45
+ */
46
+ export declare function createFacts(input: FactsInput): RequestFacts;