@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
@@ -0,0 +1,147 @@
1
+ # Shadow mode
2
+
3
+ Run a detector on every request and let it decide nothing. Then read what it would have
4
+ done, from your own traffic, before it does it.
5
+
6
+ ← [Documentation](../index.md) · [Detection](index.md)
7
+
8
+ ---
9
+
10
+ ## The problem it solves
11
+
12
+ Every detector ships with thresholds somebody chose. They were chosen against a corpus of
13
+ real traffic and they are defensible — and they were not chosen against *your* traffic,
14
+ which is the only traffic that matters when the question is whether a threshold is about
15
+ to start challenging your customers.
16
+
17
+ The [correlation detectors](correlation.md) make this sharp, because several of them fire
18
+ at `moderate` on real people **by design**:
19
+
20
+ - a phone moving between wifi and cellular presents one marker from many networks, which
21
+ is what `marker-fanout` counts;
22
+ - a crowd arriving on a link somebody posted wrong all request the same missing page,
23
+ which is what `path-campaign` counts;
24
+ - tapping "Request desktop site" changes the platform a client claims, which is what
25
+ `identity-drift` counts.
26
+
27
+ None of those is a bug. Each is a real observation with a real innocent explanation, and
28
+ whether the threshold separating them is right *here* is not a thing this library can
29
+ know. Shadow mode is how you find out without anybody being turned away while you do.
30
+
31
+ ```js
32
+ const handler = new BotHandler({
33
+ preset: "protect-content",
34
+ site: {},
35
+ probe: { secrets: [process.env.MARKER_SECRET] },
36
+ shadowDetectors: ["path-novelty", "marker-fanout", "path-campaign"],
37
+ });
38
+ ```
39
+
40
+ Those three now run on every request exactly as they otherwise would. Their findings are
41
+ counted, charted, and readable in the dashboard next to the evidence that did decide.
42
+ And they decided nothing.
43
+
44
+ ## What "decided nothing" means, precisely
45
+
46
+ A shadowed detector cannot affect:
47
+
48
+ | | |
49
+ | --- | --- |
50
+ | the verdict | including via `certain` evidence, which does not go through scoring at all |
51
+ | the score and the confidence | its findings are not in the arithmetic |
52
+ | the bot class and the identity | the class is picked from evidence that counted |
53
+ | any rule | a rule matching `detector: "path-novelty"` does not match a shadowed one |
54
+ | anything downstream of a verdict | the guard, the action, the challenge, `recordOutcome` |
55
+
56
+ This is not a weight of zero, and the difference is the whole design. A weight is
57
+ consulted on the **probabilistic** path; `certain` evidence never reaches that path,
58
+ because it short-circuits scoring and returns `confirmed-bot` on its own — which is what
59
+ makes a terminal action permissible. A shadowed detector emitting `certain` would have
60
+ blocked people with its weight sitting at zero the entire time.
61
+
62
+ So the evidence is kept out of the arithmetic rather than weighted inside it. It lands in
63
+ `assessment.shadowEvidence`, which nothing that decides anything reads:
64
+
65
+ ```js
66
+ const assessment = await handler.assess(facts);
67
+
68
+ assessment.evidence; // what decided this request
69
+ assessment.shadowEvidence; // what the shadowed detectors said, flagged with `shadow: true`
70
+ assessment.shadowVerdict; // what the verdict would have been if they had counted
71
+ ```
72
+
73
+ ## The number that actually answers the question
74
+
75
+ `shadowVerdict` is the point. "It fired 312 times" is not a thing anybody can act on; what
76
+ you need to know is what turning it on would *do*, and the honest form of that is the
77
+ verdict each request would have received.
78
+
79
+ It is computed only when a shadowed detector found something, so a well-behaved one costs
80
+ nothing on the requests it is silent about — and a badly-behaved one is the case you
81
+ wanted to hear about anyway.
82
+
83
+ ```js
84
+ if (assessment.shadowVerdict?.verdict !== assessment.verdict) {
85
+ log.info({ was: assessment.verdict, wouldBe: assessment.shadowVerdict?.verdict }, "shadow");
86
+ }
87
+ ```
88
+
89
+ ## Reading it
90
+
91
+ **In the dashboard.** Shadowed findings appear in the evidence list for each request,
92
+ dimmed and labelled *"shadowed: counted, and part of no decision"*, with a line beneath
93
+ saying what the verdict would have been. The Statistics tab counts them separately from
94
+ real firings and reports how many verdicts they would have moved, by the verdict they
95
+ would have produced.
96
+
97
+ **In Prometheus**, only when something is shadowed:
98
+
99
+ ```
100
+ bothandler_shadow_firings_total{detector="path-novelty"} 312
101
+ bothandler_shadow_verdict_changes_total{verdict="suspected-bot"} 41
102
+ ```
103
+
104
+ Forty-one against a `human` count that did not move is the shape of a detector about to
105
+ start challenging people. That is the reading this exists to make possible.
106
+
107
+ **In `detectors()`**, a shadowed detector is marked, so `describeDetectors()` and the
108
+ dashboard's detector list both say so even before it has fired once.
109
+
110
+ ## How to use it
111
+
112
+ 1. Turn a detector on in shadow, alongside the policy you are already running.
113
+ 2. Leave it a week. A week, not a day: the traffic that catches a threshold out is the
114
+ Monday morning, the campaign, the outage, the release.
115
+ 3. Read `shadow_verdict_changes_total`. If it would have moved requests to
116
+ `suspected-bot` or `confirmed-bot`, find them in the dashboard and read them — one at
117
+ a time, as requests, not as a number.
118
+ 4. Either adjust the threshold and go back to step 2, or take it out of
119
+ `shadowDetectors`.
120
+
121
+ This is the same shape as [`monitor-only`](../policy/presets.md#monitor-only) and
122
+ [`replay`](../testing/replay.md), and it answers the question those two cannot: *what
123
+ happens when I add something new to a policy that is already running*.
124
+
125
+ ## The honest limits
126
+
127
+ **A shadowed detector still consumes its budget.** It runs, it is timed, and if it throws
128
+ or times out that is recorded like any other failure. Shadowing is about influence, not
129
+ about cost.
130
+
131
+ **It still writes to actor state, if it writes to actor state.** Detectors are supposed to
132
+ report rather than record — the recording that matters happens in the pipeline around them
133
+ — but a custom detector that mutates `ctx.state` in `inspect` will do so shadowed or not.
134
+ The bundled ones do not.
135
+
136
+ **Naming a detector that is not installed is a warning, not an error.** The usual cause is
137
+ a typo, and a typo here is otherwise invisible: nothing was going to run, so nothing looks
138
+ any different either way. The warning is checked after the handler has finished installing
139
+ detectors, because the marker, site and challenge detectors arrive with the source they
140
+ read — and those are exactly the ones worth shadowing.
141
+
142
+ ## Related
143
+
144
+ - [The detectors](detectors.md) — what each one reads, and its ceiling
145
+ - [Correlation](correlation.md) — the sources whose thresholds this exists to check
146
+ - [Metrics](../operations/metrics.md) — the counters, in full
147
+ - [Choosing a policy](../start/choosing-a-policy.md) — the same caution, one level up
@@ -6,7 +6,7 @@ How a client is recognised by name, and what a name is worth.
6
6
 
7
7
  ---
8
8
 
9
- 161 signatures, 389 tokens, matched in a single Aho–Corasick pass over the lower-cased
9
+ 205 signatures, 389 tokens, matched in a single Aho–Corasick pass over the lower-cased
10
10
  User-Agent. One pass regardless of how many signatures there are, which is why adding to
11
11
  this costs nothing measurable.
12
12
 
@@ -44,8 +44,16 @@ individual name does not.
44
44
  | `commerce` | idealo, Kelkoo, PriceRunner, Trivago, Skyscanner, Indeedbot | a commercial decision |
45
45
  | `academic` | Crossref, OpenAlex, university web-science crawls | usually allow |
46
46
  | `accessibility` | Siteimprove and other WCAG auditors | allow |
47
+ | `email-security` | Proofpoint, Mimecast, Barracuda, Cisco Secure Email | allow |
47
48
 
48
- The last three are separate from their nearest neighbours because the decision is. A price
49
+ `email-security` is a mail or messaging gateway checking a link somebody was sent, before
50
+ they are allowed to click it — and it is separate because of who pays when it is blocked. A
51
+ social preview that fails costs a card; one of these failing tells a real person, in their
52
+ inbox, that their mail contained a link that could not be verified, and they were never the
53
+ one crawling. They arrive with none of a browser's marks — from a datacentre, once, no
54
+ cookie, no referer — because the request is automation acting on a human's behalf.
55
+
56
+ The other three are separate from their nearest neighbours because the decision is. A price
49
57
  comparator is not an SEO auditor — nothing there is auditing your site for you, and the
50
58
  same crawler is a distribution channel to one retailer and a competitor's research tool to
51
59
  the next, which is why nothing in `commerce` is marked benign. A citation index is not a
package/docs/index.md CHANGED
@@ -38,10 +38,11 @@ Read these once and everything else follows from them. They are short.
38
38
  | | |
39
39
  | --- | --- |
40
40
  | [How detection works](detection/index.md) | The pipeline: stages, budgets, failure, and what an assessment contains. |
41
- | [The detectors](detection/detectors.md) | All twenty, each with what it reads, why it exists, and what it costs. |
41
+ | [The detectors](detection/detectors.md) | All twenty-two, each with what it reads, why it exists, and what it costs. |
42
42
  | [The signature database](detection/signatures.md) | How a client is recognised by name, and what a name is worth. |
43
43
  | [Verifying a crawler](detection/verification.md) | Reverse DNS, published address ranges, and refuting a forgery. |
44
44
  | [Browser signals](detection/client-signals.md) | The optional page script, and the ceiling on anything it reports. |
45
+ | [Shadow mode](detection/shadow-mode.md) | Run a detector without letting it decide anything, and read what it would have done. |
45
46
  | [Writing a detector](detection/writing-a-detector.md) | The contract, and the rules about certainty you have to keep. |
46
47
 
47
48
  ## Policy
@@ -89,7 +90,7 @@ Read these once and everything else follows from them. They are short.
89
90
  | --- | --- |
90
91
  | [Testing overview](testing/index.md) | The three ways to find out what a policy does before it does it. |
91
92
  | [The command line](testing/cli.md) | `replay`, `check`, `explain`, `robots`, `detectors`. |
92
- | [The traffic corpus](testing/corpus.md) | 526 shapes of real traffic, and how to run your own config against them. |
93
+ | [The traffic corpus](testing/corpus.md) | 548 shapes of real traffic, and how to run your own config against them. |
93
94
  | [Replaying your own logs](testing/replay.md) | The most useful thing you can do before deploying anything. |
94
95
  | [Try it locally](testing/try-it.md) | A demo site, a live dashboard and eighteen scripted clients. |
95
96
 
@@ -56,6 +56,22 @@ holds.
56
56
  Invalid CIDRs throw at construction rather than matching silently. A range that matches
57
57
  nothing is a control you believe you have and do not.
58
58
 
59
+ ## Ports in the forwarded header
60
+
61
+ Most proxies write a bare address. Azure's Application Gateway and Front Door write
62
+ `1.2.3.4:5678`, and RFC 7239 spells IPv6 as `[2001:db8::1]:5678`. Both are read correctly:
63
+ the port is removed before the address is parsed.
64
+
65
+ This matters more than it sounds. Every entry in such a chain carries a port, so an
66
+ implementation that cannot read one reads *none* of them — the chain empties, and every
67
+ client behind that proxy resolves to the proxy's own address. They then share one actor,
68
+ one history and one rate-limit bucket, so a single bot is enough to lock out every real
69
+ visitor, and nothing about it looks like a failure.
70
+
71
+ A bare IPv6 address is made of colons, so only two shapes are treated as carrying a port:
72
+ a bracketed host, and a single colon whose left side is an IPv4 address. `2001:db8::1` and
73
+ `::1` are addresses, not hosts with ports.
74
+
59
75
  ## The peer is checked too
60
76
 
61
77
  The connecting peer counts as the first hop and is checked the same way.
@@ -29,6 +29,13 @@ User-Agent, the verdict, the score, the action and the rule that chose it. Filte
29
29
  proven, suspected, human, guard stops, denied, mitigated or served; or type into the
30
30
  search box.
31
31
 
32
+ **The window.** The **From** and **To** boxes above the feed take absolute instants, and
33
+ either may be left empty — which is what makes one control answer all three of the
34
+ questions people actually ask: from the moment an incident started until now, everything up
35
+ to when it stopped, or between two moments. They read and write local time, the same clock
36
+ the rows show. The charts keep showing their own rolling window; this narrows the feed, the
37
+ counts and the export.
38
+
32
39
  The feed pages, fifty requests at a time. The newest page follows the stream; stepping
33
40
  back holds the list still while you read it — a feed that renumbers itself under somebody
34
41
  paging through it cannot be read — and says **held while you read** so the stillness is
@@ -46,6 +53,9 @@ score:>70 -certain probabilistic traffic close to the line
46
53
  "GET /api/v2/orders" a phrase, spaces and all
47
54
  ```
48
55
 
56
+ The whole language, with every field and worked examples, is in [the filter
57
+ reference](filters.md).
58
+
49
59
  Fields: `path` `actor` `ua` `verdict` `action` `rule` `detector` `identity` `method`
50
60
  `class` `id` `bypass` `outcome` `certain` `score`. Every term must match — narrowing
51
61
  means `AND` — and anything that is not a field term is matched against the whole
@@ -114,14 +124,43 @@ regularity (near zero is a metronome, which no person is), prior confirmations,
114
124
  whether they hold clearance. **In feed** sends one to the live feed as an `actor:` filter,
115
125
  which makes it a shareable URL like every other view.
116
126
 
127
+ **Tracked, or shown in the feed.** The toggle above the table chooses which population
128
+ you are looking at. *Tracked* is the registry — the default, and the one that answers "who
129
+ is hitting me hardest". *Shown in the feed* lists only the clients that appear in the feed
130
+ as you have currently filtered it, so once a filter is on it answers the other question:
131
+ who is in *this*. It is derived from rows the page already holds, so it narrows with the
132
+ search, the chips and the window without another request.
133
+
134
+ Three columns go blank in that view rather than being filled in. Per minute, cadence and
135
+ unsolved challenges are properties of a client's whole history as the engine sees it, and
136
+ the feed's ring holds a few hundred requests rather than that history — computing them
137
+ from the slice would put a confident number under a heading that means something else. A
138
+ dash says "ask the registry", which is the other half of the toggle.
139
+
117
140
  This screen pages too, twenty-five at a time, busiest first. The registry holds far more
118
141
  clients than the feed's ring holds requests, and paging is what reaches them: the feed
119
142
  already shows you what is loudest, and the population behind it is the reason this screen
120
143
  exists. Ranking something that is still moving means a client can shift between pages
121
- while you read; the order is a snapshot of a live list, not a stable index.
144
+ while you read; the order is a snapshot of a live list, not a stable index. Paging belongs
145
+ to the registry: the feed-scoped view is built from what is already on screen, so there is
146
+ nothing behind it to page to.
122
147
 
123
148
  The **Actors tracked** counter above the tab strip is the way in: it is a button, so
124
149
  pressing it — or reaching it with the keyboard and pressing Enter — opens this screen.
150
+
151
+ **Labels.** An address is not a memory. Whoever works out that `198.51.100.4` is a
152
+ partner's price feed can write that down with **Label**, and it shows wherever that actor
153
+ appears — with the key still underneath it, because the key is what you search for. Labels
154
+ can also be set from code, which is the better place for what a deployment already knows:
155
+
156
+ ```ts
157
+ handler.labelActor("198.51.100.4", "partner price feed");
158
+ handler.labelActor("198.51.100.4", undefined); // and back again
159
+ ```
160
+
161
+ **Detection never reads a label.** That separation is deliberate: the moment a note can
162
+ change a verdict, writing notes becomes a way to be wrong about people at scale. A label
163
+ lives exactly as long as the actor does.
125
164
  Where the `registry` section is switched off there is no Actors screen to open, and the
126
165
  counter stays an ordinary tile rather than offering to go somewhere that does not exist.
127
166
 
@@ -0,0 +1,143 @@
1
+ # The feed filter
2
+
3
+ The search box above the live feed takes a small query language. It is the same language
4
+ everywhere it appears — the feed, the export, a shared link — so a filter that finds
5
+ something can be sent to somebody else and will find the same thing.
6
+
7
+ An empty query matches everything. Adjacent terms mean `AND`, because that is what
8
+ narrowing means — and `$or`, `$not` and set membership are there for when it is not.
9
+
10
+ ## Terms
11
+
12
+ | Form | Means |
13
+ | --- | --- |
14
+ | `checkout` | the text appears anywhere in the request |
15
+ | `path:/api` | the path contains `/api` |
16
+ | `-path:/health` | the path does **not** contain `/health` |
17
+ | `"GET /api/v2/orders"` | the phrase, spaces and all |
18
+ | `score:>70` | a numeric comparison |
19
+
20
+ Negation is `-` or `!`, and works on any term. Quoting keeps spaces together, which is the
21
+ only way to search for a phrase.
22
+
23
+ ## Operators
24
+
25
+ | Form | Means |
26
+ | --- | --- |
27
+ | `a $and b` | both — the same as writing them next to each other |
28
+ | `a $or b` | either |
29
+ | `$not a` | not — the same as `-a`, spelled out |
30
+ | `path:$in(/health, /metrics)` | the path contains any of these |
31
+ | `action:$notin(allow, tag)` | the action contains none of these |
32
+ | `(a $or b) $and c` | grouping |
33
+
34
+ `$not` binds tightest, then `$and`, then `$or` — the conventional precedence, so
35
+ `a $or b $and c` reads as `a $or (b $and c)`. Brackets are there for the times that is not
36
+ what you meant.
37
+
38
+ **Operators carry a `$` for a reason.** A bare `or` is a word that appears in User-Agents
39
+ and in paths — `header-order` contains one — and a language where an ordinary search word
40
+ silently becomes an operator is a language that lies about what it matched. Typing `or`
41
+ searches for the text `or`.
42
+
43
+ Nothing you can type is an error. The box filters as you type, so half-written input is
44
+ the normal state rather than a mistake: an unclosed bracket, a dangling `$or`, a `$in(`
45
+ with nothing after it yet all parse to the best available reading of what is there.
46
+
47
+ An empty set matches nothing rather than everything, for the same reason — `$in()` is a
48
+ query somebody is halfway through, and a filter that widens while you are still typing it
49
+ is a filter that lies.
50
+
51
+ A colon with an unknown name in front of it is *not* a field term — a path can contain a
52
+ colon and so can a User-Agent — so `foo:bar` searches for the text `foo:bar`.
53
+
54
+ ## Fields
55
+
56
+ | Field | Also | Matches against |
57
+ | --- | --- | --- |
58
+ | `path` | `url` | the request path |
59
+ | `actor` | `ip` | the actor key, usually the address |
60
+ | `ua` | `useragent`, `agent` | the User-Agent string |
61
+ | `verdict` | | `confirmed-bot`, `verified-bot`, `suspected-bot`, `human`, `unknown` |
62
+ | `class` | `botclass` | `scanner`, `scraper`, `impersonator`, `http-client`, `automation`, … |
63
+ | `action` | | what was decided: `allow`, `challenge`, `block`, `tag`, … |
64
+ | `outcome` | | the coarse version: `deny`, `mitigate`, `allow`, `pending` |
65
+ | `rule` | | the rule id that decided it |
66
+ | `detector` | | any detector that produced evidence on this request |
67
+ | `identity` | | the named bot, such as `googlebot` |
68
+ | `method` | | `GET`, `POST`, … |
69
+ | `certain` | | `true` or `false` — whether the verdict rests on proof |
70
+ | `bypass` | | why detection was skipped, when it was |
71
+ | `id` | `request` | the request id |
72
+ | `score` | | 0–100. Takes `>` and `<` as well as `=` |
73
+
74
+ `score` is the only numeric field. `score:>70`, `score:<20` and `score:60` all work;
75
+ everything else matches a substring, case-insensitively.
76
+
77
+ ## Worked examples
78
+
79
+ ```text
80
+ actor:203.0.113.4 -path:/health that address, except its health checks
81
+ rule:no-scrapers action:tag the rule that fired, and what it settled on
82
+ score:>70 -certain probabilistic traffic close to the line
83
+ detector:trap everything that touched a trap
84
+ class:impersonator verdict:confirmed-bot proven forgeries
85
+ outcome:deny -class:scanner denials that were not scanners
86
+ "GET /api/v2/orders" a phrase
87
+ ```
88
+
89
+ ## The chips
90
+
91
+ The row of buttons — **All**, **Proven**, **Suspected**, **Human**, **Guard stops**,
92
+ **Denied**, **Mitigated**, **Served** — is a separate filter that combines with the query
93
+ rather than replacing it. Picking **Denied** and typing `path:/api` gives you denials on
94
+ `/api`.
95
+
96
+ ## Saved filters
97
+
98
+ Anything you can type can be saved by name and recalled from the dropdown beside the box.
99
+ Saved filters live in **your browser**, not on the server: they are yours, they do not need
100
+ a write endpoint, and clearing site data clears them. They record the query *and* the chip,
101
+ because a filter is usually both.
102
+
103
+ ## Hiding traffic
104
+
105
+ There is no separate exclusion list. `$not` does the job:
106
+
107
+ ```text
108
+ $not path:/health everything except health checks
109
+ path:$notin(/health, /metrics) except either of two
110
+ $not (path:/health $or ua:kube-probe) except anything matching either
111
+ ```
112
+
113
+ This used to be a button that kept its own hidden list in your browser. One mechanism is
114
+ better than two, and this one is in the URL like every other narrowing — so a view with
115
+ the noise taken out is a link you can send somebody rather than a setting only you have.
116
+
117
+ Hidden traffic is still assessed, still counted in the metrics, and still acted on. A
118
+ filter hides it from *your screen*; it does not stop the engine seeing it.
119
+
120
+ ## Completion
121
+
122
+ The box completes as you type. Field names come from the parser's own list, so anything it
123
+ offers is something the language accepts — and fields whose values are a closed set
124
+ (`verdict`, `class`, `action`, `outcome`, `certain`, `method`) complete their values too.
125
+ `rule`, `identity` and `path` take anything, so nothing is offered for them: guessing there
126
+ would be inventing options rather than completing them.
127
+
128
+ A token beginning with `$` completes to an operator, and a closed-set field offers `$in(`
129
+ and `$notin(` alongside its values — because a field with a fixed set of values is exactly
130
+ the field somebody wants two of.
131
+
132
+ Arrow keys move, Enter or Tab accepts, Escape closes.
133
+
134
+ ## The window
135
+
136
+ **From** and **To** take absolute instants and either may be left empty, which is what lets
137
+ one control answer "from the incident until now", "everything up to when it stopped" and
138
+ "between these two moments". They use local time, the same clock the rows show.
139
+
140
+ ## Related
141
+
142
+ - [The dashboard](dashboard.md) — the screens, and what each one is for
143
+ - [Embedding it](embedding.md) — the same dashboard inside your own page
@@ -99,4 +99,5 @@ want the raw event, take it here.
99
99
 
100
100
  - [Metrics](metrics.md) · [The audit](audit.md) · [Notifications](notifications.md)
101
101
  - [The dashboard](dashboard.md) — all of the above, rendered
102
+ - [The feed filter](filters.md) — the query language, saved filters and exclusions
102
103
  - [Configuration reference](../reference/configuration.md)
@@ -67,8 +67,26 @@ nineteen together.
67
67
 
68
68
  Switch metrics off entirely with `metrics: false`.
69
69
 
70
+ ## The counters that appear only when something is shadowed
71
+
72
+ A detector named in [`shadowDetectors`](../detection/shadow-mode.md) runs and decides
73
+ nothing, so its firings are counted apart from the ones that decided something. Folding
74
+ them together would put work into a chart of decisions that made none.
75
+
76
+ ```
77
+ bothandler_shadow_firings_total{detector="path-novelty"} 312
78
+ bothandler_shadow_verdict_changes_total{verdict="suspected-bot"} 41
79
+ ```
80
+
81
+ The second is the one to read. It counts the assessments the shadowed detectors *would
82
+ have moved*, keyed by the verdict they would have produced — so forty-one would-be
83
+ `suspected-bot`s against a `human` count that did not move is the shape of a detector
84
+ about to start challenging people. Both are absent from the output entirely while nothing
85
+ is shadowed, rather than sitting at zero.
86
+
70
87
  ## Related
71
88
 
72
89
  - [The audit](audit.md) — because a counter cannot tell you a number is *unusual*
73
90
  - [The guard](../concepts/the-guard.md) — what `bothandler_downgrades_total` is counting
91
+ - [Shadow mode](../detection/shadow-mode.md) — running a detector without letting it decide
74
92
  - [The dashboard](dashboard.md) — these numbers, drawn
@@ -241,4 +241,4 @@ npx @osqd/bothandlerjs check --preset protect-auth
241
241
  - [Rules](rules.md) — the grammar these are written in
242
242
  - [Actions](actions.md) — what each rule can ask for
243
243
  - [Choosing a policy](../start/choosing-a-policy.md) — the decision, start to finish
244
- - [The corpus](../testing/corpus.md) — 526 cases, including the ones these presets get wrong
244
+ - [The corpus](../testing/corpus.md) — 548 cases, including the ones these presets get wrong
@@ -81,7 +81,7 @@ npx @osqd/bothandlerjs check --preset protect-content --audience human
81
81
  npx @osqd/bothandlerjs replay /var/log/nginx/access.log --preset protect-content
82
82
  ```
83
83
 
84
- The first is [526 shapes of real traffic](../testing/corpus.md); the second is *yours*. Both
84
+ The first is [548 shapes of real traffic](../testing/corpus.md); the second is *yours*. Both
85
85
  print every request the policy would have refused, with the evidence.
86
86
 
87
87
  For your own configuration rather than a preset:
@@ -61,7 +61,7 @@ npx @osqd/bothandlerjs check --preset protect-content
61
61
  npx @osqd/bothandlerjs replay /var/log/nginx/access.log --preset protect-content
62
62
  ```
63
63
 
64
- The first runs your policy against [526 shapes of real traffic](../testing/corpus.md); the
64
+ The first runs your policy against [548 shapes of real traffic](../testing/corpus.md); the
65
65
  second runs it against yours. Read the list of would-be-denied requests. If any of them is a
66
66
  person, the policy is wrong — and you found out from a log file.
67
67
 
@@ -16,7 +16,7 @@ The library imports nothing but `node:` builtins, and CI fails if that ever stop
16
16
  true. Nothing here can hand your project a transitive advisory, an install script, or a
17
17
  version conflict with something you already run.
18
18
 
19
- Redis, if you use it, is your client passed in: `RedisStore` describes the five commands it
19
+ Redis, if you use it, is your client passed in: `RedisStore` describes the four commands it
20
20
  needs structurally and imports neither `ioredis` nor `node-redis`.
21
21
 
22
22
  ```ts
@@ -31,7 +31,7 @@ new BotHandler({ store: new RedisStore(redis) });
31
31
  | `@osqd/bothandlerjs` | the engine, detectors, presets, robots, stores, notifiers, challenge, dashboard |
32
32
  | `@osqd/bothandlerjs/adapters` | [Express, Fastify, Koa, Fetch](../integration/adapters.md) |
33
33
  | `@osqd/bothandlerjs/client` | the browser-side [client signals](../detection/client-signals.md) script |
34
- | `@osqd/bothandlerjs/corpus` | [`runCorpus`](../testing/corpus.md) and all 526 cases |
34
+ | `@osqd/bothandlerjs/corpus` | [`runCorpus`](../testing/corpus.md) and all 548 cases |
35
35
  | `@osqd/bothandlerjs/cli` | the [command line](../testing/cli.md) |
36
36
 
37
37
  ## Runtimes
@@ -43,7 +43,7 @@ npx @osqd/bothandlerjs check --preset protect-content
43
43
  ```
44
44
 
45
45
  ```
46
- protect-content against 522 shapes of real traffic
46
+ protect-content against 548 shapes of real traffic
47
47
 
48
48
  human 181 cases 3 tag, 175 allow, 3 challenge
49
49
  benign-bot 144 cases 6 allow, 86 tag, 48 block, 2 challenge, 2 rate-limit
@@ -112,6 +112,12 @@ Lists what a configuration actually installs, with each detector's cost and stag
112
112
  for the question "is `identity-rotation` on?", which has caused more confusion than any
113
113
  other single setting.
114
114
 
115
+ A preset selects *rules*, so it does not change this list — the flag is there because the
116
+ command builds a real handler, not because the answer moves. What does change it is
117
+ `challenge`, `probe` and `site`, each of which installs the detectors that read it. The
118
+ command says so on stderr, so redirecting the list somewhere stays clean. An unknown preset
119
+ is refused rather than quietly answered for the default configuration.
120
+
115
121
  ## Related
116
122
 
117
123
  - [The corpus](corpus.md) · [Log replay](replay.md)
@@ -1,6 +1,6 @@
1
1
  # The traffic corpus
2
2
 
3
- 526 cases, 739 requests, 6,002 header lines, 55 categories of real web traffic — and a
3
+ 548 cases, 1,235 requests, 12,802 header lines, 58 categories of real web traffic — and a
4
4
  harness that runs them against *your* configuration.
5
5
 
6
6
  ← [Documentation](../index.md) · [Testing](index.md)
@@ -21,12 +21,16 @@ npm run corpus -- --audience human --verbose
21
21
  ```
22
22
 
23
23
  ```
24
- monitor-only 526/526 cases pass · 0 false positives
25
- protect-content 526/526 cases pass · 0 false positives
26
- protect-data 525/526 cases pass · 0 false positives
27
- protect-auth 511/526 cases pass · 0 false positives
24
+ monitor-only 539/539 cases pass · 0 false positives
25
+ protect-content 539/539 cases pass · 0 false positives
26
+ protect-data 538/539 cases pass · 0 false positives
27
+ protect-auth 524/539 cases pass · 0 false positives
28
28
  ```
29
29
 
30
+ 539 rather than 548 because nine cases name a source this configuration does not provide —
31
+ a marker probe or a site baseline — and a verdict about a marker from a handler that issues
32
+ none is a verdict about nothing. Those are *skipped*, and named, rather than failed.
33
+
30
34
  The two strict presets do not pass everything, and **the gap is the documentation rather
31
35
  than a defect**: [`protect-auth`](../policy/presets.md#protect-auth) blocks proven
32
36
  automation, and the fifteen cases it fails are your payment webhook, your own server-side
@@ -38,11 +42,11 @@ corpus keeps the receipts.
38
42
 
39
43
  | Audience | Cases | Examples |
40
44
  | -------- | ----: | -------- |
41
- | **human** | 182 | 30 browser profiles across Chromium, Gecko and WebKit · desktop, mobile, tablet, console, television · 40 in-app WebViews (Instagram, TikTok, WeChat, KakaoTalk, LINE, VK, banking and airline apps) · Electron desktop apps · Tor, `resistFingerprinting`, Sec-GPC · Lynx, w3m, screen readers · IE11, Android 4.4, a car's infotainment screen · corporate proxies, carrier transcoders, CGNAT, iCloud Private Relay · an author signing in at `/wp-login.php` and a developer searching a docs site for SQL syntax |
45
+ | **human** | 186 | 30 browser profiles across Chromium, Gecko and WebKit · desktop, mobile, tablet, console, television · 40 in-app WebViews (Instagram, TikTok, WeChat, KakaoTalk, LINE, VK, banking and airline apps) · Electron desktop apps · Tor, `resistFingerprinting`, Sec-GPC · Lynx, w3m, screen readers · IE11, Android 4.4, a car's infotainment screen · corporate proxies, carrier transcoders, CGNAT, iCloud Private Relay · an author signing in at `/wp-login.php` and a developer searching a docs site for SQL syntax |
42
46
  | **benign-bot** | 144 | Googlebot and Bingbot verified by DNS · 30 regional crawlers (Naver, Seznam, Coc Coc, Sogou, 360, Shenma, Qwant, Mojeek) · Google's and Microsoft's specialist fleets · 12 link unfurlers · monitoring · feed and podcast clients · academic and archival crawlers · ad verification · email link scanners |
43
47
  | **declared-bot** | 32 | The AI fleet split by job — training, search, fetch-for-a-user — plus an agentic browser |
44
- | **unwanted-bot** | 106 | SEO and market-intelligence crawlers · **50 HTTP clients in their real header orders**, across Python, Node, JVM, Go, Rust, PHP, Ruby, .NET, Perl · headless runtimes · fabricated User-Agents from a randomiser (Chrome on an iPhone, Windows and macOS at once, Firefox on WebKit) |
45
- | **hostile** | 29 | Forged Googlebot four ways · scanners · credential stuffing · traps · protocol abuse and request-smuggling framing · forwarding-header injection · wordlist probes for `/.env`, `/.git`, JNDI and TRACE |
48
+ | **unwanted-bot** | 116 | SEO and market-intelligence crawlers · **50 HTTP clients in their real header orders**, across Python, Node, JVM, Go, Rust, PHP, Ruby, .NET, Perl · headless runtimes · fabricated User-Agents from a randomiser (Chrome on an iPhone, Windows and macOS at once, Firefox on WebKit) |
49
+ | **hostile** | 37 | Forged Googlebot four ways · scanners · credential stuffing · traps · protocol abuse and request-smuggling framing · forwarding-header injection · wordlist probes for `/.env`, `/.git`, JNDI and TRACE · traversals spelled in percent-encoding and encoded twice over · open-proxy probing |
46
50
  | **infrastructure** | 33 | CDN origin pulls (Cloudflare, Fastly, Akamai, CloudFront) · API gateways and service meshes · k8s and ELB probes · webhooks · browser prefetch |
47
51
 
48
52
  Every request is built the way the client actually builds it — the Client Hints block, the
@@ -13,7 +13,7 @@ that cheap.
13
13
  | Page | Answers |
14
14
  | ---- | ------- |
15
15
  | [The CLI](cli.md) | "what is this one request?", "what does this policy do?" |
16
- | [The corpus](corpus.md) | "who would this configuration hurt?" — 526 shapes of real traffic |
16
+ | [The corpus](corpus.md) | "who would this configuration hurt?" — 548 shapes of real traffic |
17
17
  | [Log replay](replay.md) | "what would this have done to yesterday?" |
18
18
  | [Try it](try-it.md) | "what does it look like running?" — the demo, the simulator, three dashboards |
19
19
 
@@ -77,7 +77,7 @@ detectors read. Run one scenario at a time with `npm run simulate:curl`,
77
77
  ### Replaying the whole corpus over the wire
78
78
 
79
79
  ```bash
80
- npm run simulate:corpus # all 526 cases
80
+ npm run simulate:corpus # all 548 cases
81
81
  npm run simulate:corpus:human # only the people
82
82
  npm run simulate -- --corpus --tag known-cost # only the awkward ones
83
83
  npm run simulate -- --corpus --case browse-chrome-windows --verbose
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@osqd/bothandlerjs",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -80,11 +80,14 @@
80
80
  ],
81
81
  "scripts": {
82
82
  "build": "tsup && tsc --emitDeclarationOnly && tsc -p tsconfig.element.json",
83
+ "check:package": "npm run build && tsx scripts/check-package.ts",
83
84
  "build:watch": "tsup --watch",
84
85
  "test": "vitest run",
85
86
  "test:watch": "vitest",
86
87
  "test:coverage": "vitest run --coverage",
87
88
  "test:browser": "vitest run --config vitest.browser.config.ts",
89
+ "test:browser:firefox": "BROWSER_ENGINE=firefox npm run test:browser",
90
+ "test:browser:webkit": "BROWSER_ENGINE=webkit npm run test:browser",
88
91
  "lint": "biome check .",
89
92
  "docs:check": "node scripts/check-links.mjs",
90
93
  "format": "biome check --write .",