@osqd/bothandlerjs 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/CHANGELOG.md +227 -1
  2. package/README.md +18 -10
  3. package/dist/adapters/fastify.d.ts +10 -0
  4. package/dist/adapters/index.cjs +38 -10
  5. package/dist/adapters/index.cjs.map +1 -1
  6. package/dist/adapters/index.js +38 -10
  7. package/dist/adapters/index.js.map +1 -1
  8. package/dist/challenge/index.d.ts +40 -0
  9. package/dist/cli.cjs +2256 -103
  10. package/dist/cli.cjs.map +1 -1
  11. package/dist/cli.js +2256 -103
  12. package/dist/cli.js.map +1 -1
  13. package/dist/config.d.ts +55 -0
  14. package/dist/core.d.ts +48 -21
  15. package/dist/corpus/index.cjs +365 -7
  16. package/dist/corpus/index.cjs.map +1 -1
  17. package/dist/corpus/index.js +365 -7
  18. package/dist/corpus/index.js.map +1 -1
  19. package/dist/corpus/schema.d.ts +33 -0
  20. package/dist/crawler-ranges.d.ts +31 -0
  21. package/dist/dashboard/client/actions.d.ts +1 -1
  22. package/dist/dashboard/client/app.d.ts +9 -2
  23. package/dist/dashboard/client/boot.d.ts +32 -3
  24. package/dist/dashboard/client/query.d.ts +72 -12
  25. package/dist/dashboard/client/registry.d.ts +25 -0
  26. package/dist/dashboard/client/saved.d.ts +29 -0
  27. package/dist/dashboard/client/store.d.ts +16 -2
  28. package/dist/dashboard/client/types.d.ts +2 -0
  29. package/dist/dashboard/client.generated.d.ts +1 -1
  30. package/dist/dashboard/types.d.ts +15 -0
  31. package/dist/detectors/blended-identity.d.ts +34 -0
  32. package/dist/detectors/challenge-integrity.d.ts +26 -0
  33. package/dist/detectors/challenge-reaction.d.ts +39 -0
  34. package/dist/detectors/clearance.d.ts +1 -23
  35. package/dist/detectors/id-enumeration.d.ts +31 -0
  36. package/dist/detectors/index.d.ts +24 -1
  37. package/dist/detectors/known-bots.d.ts +11 -0
  38. package/dist/detectors/marker.d.ts +106 -0
  39. package/dist/detectors/parameter-sweep.d.ts +39 -0
  40. package/dist/detectors/probe-signature.d.ts +27 -0
  41. package/dist/detectors/probe-volume.d.ts +26 -0
  42. package/dist/detectors/site-baseline.d.ts +135 -0
  43. package/dist/detectors/target-integrity.d.ts +16 -0
  44. package/dist/detectors/transport-coherence.d.ts +31 -0
  45. package/dist/detectors/trap.d.ts +10 -3
  46. package/dist/detectors/types.d.ts +17 -0
  47. package/dist/element/index.cjs +730 -80
  48. package/dist/element/index.cjs.map +1 -1
  49. package/dist/element/index.js +730 -80
  50. package/dist/element/index.js.map +1 -1
  51. package/dist/index.cjs +2044 -123
  52. package/dist/index.cjs.map +1 -1
  53. package/dist/index.d.ts +6 -2
  54. package/dist/index.js +2025 -123
  55. package/dist/index.js.map +1 -1
  56. package/dist/internal/async.d.ts +0 -3
  57. package/dist/internal/ip.d.ts +18 -0
  58. package/dist/internal/text.d.ts +28 -0
  59. package/dist/metrics.d.ts +18 -0
  60. package/dist/probe/index.d.ts +153 -0
  61. package/dist/probe/marker.d.ts +119 -0
  62. package/dist/site/index.d.ts +122 -0
  63. package/dist/state.d.ts +205 -0
  64. package/dist/stores/redis.d.ts +24 -1
  65. package/dist/types.d.ts +106 -0
  66. package/docs/course/05-detectors.md +9 -4
  67. package/docs/course/06-identity.md +1 -1
  68. package/docs/course/16-proving-it.md +15 -9
  69. package/docs/course/index.md +1 -1
  70. package/docs/design/decisions.md +1 -1
  71. package/docs/detection/correlation.md +284 -0
  72. package/docs/detection/detectors.md +259 -1
  73. package/docs/detection/index.md +2 -1
  74. package/docs/detection/shadow-mode.md +147 -0
  75. package/docs/detection/signatures.md +10 -2
  76. package/docs/index.md +3 -2
  77. package/docs/integration/client-ip.md +16 -0
  78. package/docs/operations/dashboard.md +40 -1
  79. package/docs/operations/filters.md +143 -0
  80. package/docs/operations/index.md +1 -0
  81. package/docs/operations/metrics.md +18 -0
  82. package/docs/policy/presets.md +1 -1
  83. package/docs/start/choosing-a-policy.md +1 -1
  84. package/docs/start/first-integration.md +1 -1
  85. package/docs/start/installation.md +2 -2
  86. package/docs/testing/cli.md +7 -1
  87. package/docs/testing/corpus.md +12 -8
  88. package/docs/testing/index.md +1 -1
  89. package/docs/testing/try-it.md +1 -1
  90. package/package.json +4 -1
@@ -61,6 +61,14 @@ export interface CaseRequest {
61
61
  tlsFingerprint?: string;
62
62
  /** Milliseconds after the case's start time. Drives the behavioural detectors. */
63
63
  atMs?: number;
64
+ /**
65
+ * What the application answered, if the case is about that.
66
+ *
67
+ * The engine decides before a response exists, so this is reported back afterwards the
68
+ * way an adapter reports it. Only cases about response shape need it — a scan that is
69
+ * almost all misses being the one that matters.
70
+ */
71
+ status?: number;
64
72
  /** The source could not supply the full header set. See `RequestFacts.partialHeaders`. */
65
73
  partialHeaders?: boolean;
66
74
  }
@@ -133,6 +141,31 @@ export interface TrafficCase {
133
141
  * from the request.
134
142
  */
135
143
  clearance?: "pow" | "interaction" | "operator";
144
+ /**
145
+ * Answers this actor has already given to challenges, established before the case runs.
146
+ *
147
+ * Declared rather than performed, for the same reason `clearance` is: the corpus
148
+ * replays *requests*, and these are outcomes of the challenge verification endpoint,
149
+ * which is deliberately not part of the request path. A case cannot submit a solution
150
+ * any more than it can sign its own clearance token, so it says what has already
151
+ * happened and the runner tells the handler.
152
+ */
153
+ /**
154
+ * Whether this client stores the cookies it is given. Default: inferred.
155
+ *
156
+ * The runner returns cookies the handler set to any case that sends a cookie of its
157
+ * own, because that is what having a jar means and modelling it otherwise made every
158
+ * browsing session look like a client that threw its marker away. A case sets this
159
+ * explicitly to say something the requests cannot: `false` models a client that sends
160
+ * one cookie it was configured with and stores nothing new.
161
+ */
162
+ keepsCookies?: boolean;
163
+ challengeHistory?: {
164
+ /** Solutions submitted for challenges that had already been solved. */
165
+ replayedSolutions?: number;
166
+ /** Solutions returned faster than the proof of work can be computed in a browser. */
167
+ implausibleSolves?: number;
168
+ };
136
169
  /**
137
170
  * A person whose *client software* declares itself automated, and why.
138
171
  *
@@ -81,6 +81,37 @@ export interface RefreshResult {
81
81
  * an error rather than a guess.
82
82
  */
83
83
  export declare function fetchCrawlerRanges(source: PublishedRangeSource, options?: RefreshOptions): Promise<string[]>;
84
+ /** Where a reputation or hosting-provider address list is published. */
85
+ export interface AddressListSource {
86
+ /** The range set to install it as. `denylist` blocks; `datacenter` corroborates. */
87
+ id: "denylist" | "datacenter" | "allowlist";
88
+ url: string;
89
+ }
90
+ export interface AddressListOptions extends RefreshOptions {
91
+ /** Entries to accept before refusing the list. Default 100,000. */
92
+ maxPrefixes?: number;
93
+ }
94
+ /**
95
+ * Fetches a published address list — a reputation feed, a hosting provider's own ranges.
96
+ *
97
+ * The same hardened path as {@link fetchCrawlerRanges}: HTTPS only, a size cap, comments
98
+ * stripped, JSON `prefixes` documents or one prefix per line, and a list that is empty,
99
+ * oversized or contains a block big enough to matter is refused **whole** rather than in
100
+ * part. It is a separate entry point because the limits differ — a reputation feed is
101
+ * thousands of entries where a crawler's is hundreds, and calling one a crawler's list in
102
+ * an error message helps nobody.
103
+ *
104
+ * It does not install anything. Hand the result to `updateRanges("denylist", …)` when you
105
+ * are ready, which is the same two-step the crawler path takes and for the same reason:
106
+ * fetching is the part that can fail, and installing is the part that changes behaviour.
107
+ *
108
+ * **A denylist entry is `certain`.** It does not corroborate anything — it decides, and it
109
+ * blocks people. A feed is somebody else's judgement about an address, refreshed on
110
+ * somebody else's schedule, and an address that was a bot last month may be a customer's
111
+ * home connection this month. Read what you are subscribing to, and prefer `datacenter`
112
+ * for anything you have not decided about yourself.
113
+ */
114
+ export declare function fetchAddressList(source: AddressListSource, options?: AddressListOptions): Promise<string[]>;
84
115
  /**
85
116
  * Fetches every published list and installs it.
86
117
  *
@@ -14,4 +14,4 @@
14
14
  */
15
15
  export type AfterAction = () => void;
16
16
  export declare function isConfirming(): boolean;
17
- export declare function actorActions(key: string, after: AfterAction): HTMLElement[];
17
+ export declare function actorActions(key: string, after: AfterAction, current?: string): HTMLElement[];
@@ -19,8 +19,15 @@ export interface App {
19
19
  replace?: boolean;
20
20
  push?: boolean;
21
21
  }) => void;
22
- /** Puts the feed's filter state into the URL, so a view can be sent to somebody. */
23
- syncUrl: () => void;
22
+ /**
23
+ * Puts the feed's filter state and the Actors scope into the URL, so a view can be
24
+ * sent to somebody. `replace: false` for a discrete choice a click made, which the
25
+ * back button should undo; the default replaces, because a history entry per keystroke
26
+ * of the search box is not history.
27
+ */
28
+ syncUrl: (options?: {
29
+ replace?: boolean;
30
+ }) => void;
24
31
  }
25
32
  export declare const app: App;
26
33
  /**
@@ -1,5 +1,34 @@
1
1
  import type { Boot } from "./types.js";
2
- export declare const BOOT: Boot;
2
+ /**
3
+ * Reassignable, and that is the fix for a real bug rather than a style choice.
4
+ *
5
+ * On the served page this module evaluates after the nonced script has stamped the
6
+ * global, so reading it once was right. Embedded, the element sets the global and *then*
7
+ * imports the client — correct, and not something it can actually guarantee, because any
8
+ * other code path that reaches a client module first evaluates this one first.
9
+ *
10
+ * One did. `disconnectedCallback` imports `stream.js` to close the stream, `stream.js`
11
+ * imports this, and a host page that mounts, unmounts and remounts the element before the
12
+ * first bootstrap fetch returns — React 18's development double-mount, exactly — ran that
13
+ * import while the global was still undefined. `base` then froze at `""` for the life of
14
+ * the page, so every request the dashboard made went to the host application's own origin
15
+ * root instead of to `src`: no dashboard, and a stray `/api/stream` arriving at somebody
16
+ * else's router.
17
+ *
18
+ * These are `let` and {@link applyBoot} replaces them, so being evaluated early costs
19
+ * nothing. ES module bindings are live and esbuild keeps them so — importers read the
20
+ * variable, not a copy of it — which is what makes this work without every consumer
21
+ * changing to a getter.
22
+ */
23
+ export declare let BOOT: Boot;
3
24
  /** Every request the page makes is relative to the mount path. */
4
- export declare const API: string;
5
- export declare const SECTIONS: Required<import("./types.js").DashboardSections>;
25
+ export declare let API: string;
26
+ export declare let SECTIONS: Required<import("./types.js").DashboardSections>;
27
+ /**
28
+ * Points this module at the payload the element fetched.
29
+ *
30
+ * Called before the client is imported. Ordering still matters for *drawing* — the client
31
+ * builds its tab strip from these — but no longer for correctness of the mount path,
32
+ * which is the part that was silently wrong.
33
+ */
34
+ export declare function applyBoot(next: Boot): void;
@@ -7,19 +7,32 @@ import type { DashboardEntry } from "./types.js";
7
7
  * question in the two cases somebody actually reaches for the box: an address that
8
8
  * also appears inside a User-Agent, and a path that is a prefix of ten others.
9
9
  *
10
- * So terms can name a field, and can be negated:
10
+ * So terms name a field, and combine:
11
11
  *
12
12
  * ```text
13
- * actor:203.0.113.4 -path:/health that address, except its health checks
14
- * rule:no-scrapers action:tag the rule that fired, and what it settled on
15
- * score:>70 -certain probabilistic traffic close to the line
16
- * "GET /api/v2/orders" a phrase, spaces and all
13
+ * actor:203.0.113.4 -path:/health that address, except its health checks
14
+ * verdict:human $or verdict:unknown either verdict
15
+ * $not path:/health the same as -path:/health, spelled out
16
+ * action:$in(block, drop) one of a set
17
+ * action:$notin(allow, tag) none of a set
18
+ * (verdict:human $or score:<20) $and $not path:/health
19
+ * "GET /api/v2/orders" a phrase, spaces and all
17
20
  * ```
18
21
  *
19
- * Every term must match — this is an `AND`, because that is what narrowing means and
20
- * a filter box that quietly `OR`s is a filter box that lies. Anything that does not
21
- * parse as a field term is matched against the whole request, so a plain word still
22
- * behaves exactly as it did.
22
+ * **Adjacent terms still mean AND**, which is what narrowing means and what every
23
+ * existing query and saved filter relies on. `$and` is available for people who would
24
+ * rather write it than rely on juxtaposition; it parses to the same thing. `$or` binds
25
+ * more loosely than `$and`, and `$not` more tightly than either, so
26
+ * `a $or b $and c` reads as `a $or (b $and c)` — the conventional precedence, and the
27
+ * reason parentheses exist for the times it is not what you meant.
28
+ *
29
+ * Operator names are prefixed with `$` for one reason: a bare `or` is a word that
30
+ * appears in User-Agents and paths, and a language where an ordinary search word
31
+ * silently becomes an operator is a language that lies about what it matched.
32
+ *
33
+ * **Nothing here throws.** It backs a live search box, so half-typed input is the
34
+ * normal state rather than an error: an unclosed parenthesis, a dangling `$or`, a
35
+ * `$in(` with nothing after it all parse to the best reading available.
23
36
  *
24
37
  * Kept free of the DOM so it can be unit-tested, which is the point: this is the piece
25
38
  * of the page most likely to be wrong in a way nobody notices.
@@ -31,12 +44,59 @@ export interface Term {
31
44
  negated: boolean;
32
45
  /** For numeric fields: how `value` should be compared. */
33
46
  compare?: "<" | ">" | "=" | undefined;
47
+ /** For `$in` and `$notin`: the set. `value` holds the first, so a term always has one. */
48
+ values?: readonly string[] | undefined;
34
49
  }
35
- export declare function parseQuery(input: string): Term[];
50
+ /**
51
+ * A parsed query.
52
+ *
53
+ * A tree rather than a list, because `$or` cannot be expressed in a list — which is
54
+ * what the flat version of this quietly told people by ignoring it.
55
+ */
56
+ export type Filter = {
57
+ kind: "all";
58
+ } | {
59
+ kind: "term";
60
+ term: Term;
61
+ } | {
62
+ kind: "not";
63
+ of: Filter;
64
+ } | {
65
+ kind: "and";
66
+ parts: readonly Filter[];
67
+ } | {
68
+ kind: "or";
69
+ parts: readonly Filter[];
70
+ };
71
+ /** Every field name the language accepts, including the aliases. For suggestions. */
72
+ export declare const FIELD_NAMES: readonly string[];
73
+ /**
74
+ * Values worth offering for the fields that have a fixed set of them.
75
+ *
76
+ * Only where the set really is closed. `rule`, `identity` and `path` take anything, and
77
+ * offering a guess there would be inventing options rather than completing them.
78
+ */
79
+ export declare const FIELD_VALUES: Readonly<Record<string, readonly string[]>>;
80
+ /**
81
+ * What to offer for the token the caret is sitting in.
82
+ *
83
+ * Returns the completions and the span they would replace, so the caller can put one in
84
+ * without disturbing the rest of the query. A token that already names a field with a
85
+ * closed value set completes the value; anything else completes the field name.
86
+ */
87
+ export declare function suggestFor(input: string, caret: number): {
88
+ options: readonly string[];
89
+ from: number;
90
+ to: number;
91
+ };
92
+ /** The operator words the language understands. Offered by the completions too. */
93
+ export declare const OPERATORS: readonly string[];
94
+ /** Parses a query. An empty or unparseable one matches everything. */
95
+ export declare function parseFilter(input: string): Filter;
36
96
  /** Everything about a request, as one lower-case string. What a free term is matched against. */
37
97
  export declare function searchableText(entry: DashboardEntry): string;
38
- /** True when every term is satisfied. An empty query matches everything. */
39
- export declare function matchesQuery(terms: readonly Term[], entry: DashboardEntry, haystack: string): boolean;
98
+ /** True when the query is satisfied. An empty one matches everything. */
99
+ export declare function matches(filter: Filter, entry: DashboardEntry, haystack: string): boolean;
40
100
  /** The named filter buttons, which are a second, independent narrowing. */
41
101
  export type FilterName = "all" | "proven" | "suspected" | "human" | "guard" | "deny" | "mitigate" | "allow";
42
102
  export declare function matchesFilter(filter: FilterName, entry: DashboardEntry): boolean;
@@ -1,4 +1,29 @@
1
+ import type { ActorRow } from "./types.js";
1
2
  export declare function loadActors(): Promise<void>;
2
3
  /** Polls while the screen is showing, and stops the moment it is not. */
3
4
  export declare function trackActors(): void;
5
+ /**
6
+ * The actors visible in the feed you are looking at.
7
+ *
8
+ * The registry answers "who is hitting me hardest", which is a different question from
9
+ * "who is in *this*" — and once a filter is on, the second one is usually what somebody
10
+ * has in mind. Derived from the rows the feed is already showing, so it narrows with the
11
+ * filter, the timeframe and the search without another request.
12
+ *
13
+ * Three columns are left blank on purpose. `Per min`, `Cadence` and `Unsolved` are
14
+ * properties of the whole actor as the engine sees it, and the feed's ring holds a few
15
+ * hundred requests rather than a client's history — computing them from that slice would
16
+ * put a confident number under a heading that means something else. A dash says "ask the
17
+ * registry", which is the button next to it.
18
+ */
19
+ export declare function feedActors(): ActorRow[];
20
+ /**
21
+ * Moves the toggle to `scope` and redraws, without touching the URL.
22
+ *
23
+ * Separate from the click handler because the URL is also an input: opening a link and
24
+ * pressing Back both arrive here, and neither should write the address they just read.
25
+ */
26
+ export declare function applyActorScope(scope: "tracked" | "feed"): void;
27
+ /** Wires the tracked/shown toggle. Called once. */
28
+ export declare function initActorScope(): void;
4
29
  export declare function drawActors(): void;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Filters somebody saved.
3
+ *
4
+ * They live in this browser rather than on the handler, and that is a decision worth
5
+ * stating. A saved filter is a personal working note — the view one operator returns to
6
+ * during an incident — not configuration the whole team shares. Keeping it here means it
7
+ * needs no write endpoint, no auth decision about who may change whose, and no migration
8
+ * when the handler restarts. The cost is that it does not follow you to another machine,
9
+ * which is the right trade for a bookmark.
10
+ *
11
+ * Hidden traffic used to live here too, as a second list. It does not any more: `$not`
12
+ * in the query says the same thing and, unlike a list in one person's browser, travels
13
+ * in the URL — so a view with the noise taken out is something you can send somebody.
14
+ *
15
+ * Every read and write is wrapped: a private window, cleared site data or a browser set to
16
+ * refuse storage all throw on access rather than returning nothing, and a dashboard that
17
+ * cannot save a bookmark must still show the traffic.
18
+ */
19
+ export interface SavedFilter {
20
+ name: string;
21
+ /** The query text, exactly as typed. */
22
+ query: string;
23
+ /** The chip it was saved with, because a filter is usually both. */
24
+ filter: string;
25
+ }
26
+ export declare function savedFilters(): SavedFilter[];
27
+ /** Saves under a name, replacing any filter already using it. */
28
+ export declare function saveFilter(entry: SavedFilter): SavedFilter[];
29
+ export declare function deleteFilter(name: string): SavedFilter[];
@@ -1,5 +1,5 @@
1
1
  import type { EditorRule } from "./draft.js";
2
- import type { FilterName, Term } from "./query.js";
2
+ import type { Filter, FilterName } from "./query.js";
3
3
  import type { ActorRow, DashboardEntry, Policy, Row, Snapshot, TabName } from "./types.js";
4
4
  /** Rows built into the table at once. Beyond this a feed is scrolled past, not read. */
5
5
  export declare const FEED_LIMIT = 300;
@@ -11,10 +11,13 @@ export interface State {
11
11
  /** The Actors screen's list, fetched rather than streamed. See `registry.ts`. */
12
12
  actors: ActorRow[];
13
13
  actorsTracked: number;
14
+ /** Which population the Actors screen is listing. See `registry.feedActors`. */
15
+ actorScope: "tracked" | "feed";
14
16
  paused: boolean;
15
17
  filter: FilterName;
16
18
  search: string;
17
- terms: Term[];
19
+ /** The parsed search. A tree, so `$or` means something. */
20
+ query: Filter;
18
21
  tab: TabName;
19
22
  open: Set<string>;
20
23
  actor: string | undefined;
@@ -49,6 +52,15 @@ export interface State {
49
52
  /** How many rows a page of each table holds. Chosen in the page, not configured. */
50
53
  feedPageSize: number;
51
54
  actorsPageSize: number;
55
+ /**
56
+ * The window somebody asked to look at, as absolute instants.
57
+ *
58
+ * Either end may be left open, which is what makes one control answer all three of the
59
+ * questions people actually ask: from an incident until now, from the beginning until
60
+ * something stopped, or between two moments.
61
+ */
62
+ fromMs: number | undefined;
63
+ toMs: number | undefined;
52
64
  /**
53
65
  * How many skipped entries have already been fetched back and merged.
54
66
  *
@@ -70,6 +82,8 @@ export declare const state: State;
70
82
  export declare function ingest(entry: DashboardEntry): void;
71
83
  export declare function clearFeed(): void;
72
84
  export declare function setSearch(value: string): void;
85
+ /** Sets the window somebody is looking at. Either end may be left open. */
86
+ export declare function setTimeframe(from: number | undefined, to: number | undefined): void;
73
87
  /** Back to the live first page, thawed. Called whenever what matches changes. */
74
88
  export declare function resetPaging(): void;
75
89
  /**
@@ -55,6 +55,8 @@ export interface Snapshot extends DashboardSnapshot {
55
55
  * of the store came to fail on `HTMLInputElement`.
56
56
  */
57
57
  export interface ActorRow {
58
+ /** A name somebody gave this actor. Never read by detection. */
59
+ label?: string | undefined;
58
60
  key: string;
59
61
  requests: number;
60
62
  recentRate: number;