@osqd/bothandlerjs 0.6.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 (84) 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 -13
  5. package/dist/adapters/index.cjs.map +1 -1
  6. package/dist/adapters/index.js +38 -13
  7. package/dist/adapters/index.js.map +1 -1
  8. package/dist/challenge/index.d.ts +40 -0
  9. package/dist/cli.cjs +1783 -83
  10. package/dist/cli.cjs.map +1 -1
  11. package/dist/cli.js +1783 -83
  12. package/dist/cli.js.map +1 -1
  13. package/dist/config.d.ts +55 -0
  14. package/dist/core.d.ts +33 -21
  15. package/dist/corpus/index.cjs +282 -8
  16. package/dist/corpus/index.cjs.map +1 -1
  17. package/dist/corpus/index.js +282 -8
  18. package/dist/corpus/index.js.map +1 -1
  19. package/dist/corpus/schema.d.ts +25 -0
  20. package/dist/dashboard/client/actions.d.ts +1 -1
  21. package/dist/dashboard/client/app.d.ts +9 -2
  22. package/dist/dashboard/client/boot.d.ts +32 -3
  23. package/dist/dashboard/client/query.d.ts +72 -12
  24. package/dist/dashboard/client/registry.d.ts +25 -0
  25. package/dist/dashboard/client/saved.d.ts +29 -0
  26. package/dist/dashboard/client/store.d.ts +16 -2
  27. package/dist/dashboard/client/types.d.ts +2 -0
  28. package/dist/dashboard/client.generated.d.ts +1 -1
  29. package/dist/dashboard/types.d.ts +15 -0
  30. package/dist/detectors/blended-identity.d.ts +34 -0
  31. package/dist/detectors/challenge-integrity.d.ts +26 -0
  32. package/dist/detectors/challenge-reaction.d.ts +39 -0
  33. package/dist/detectors/clearance.d.ts +1 -23
  34. package/dist/detectors/index.d.ts +16 -1
  35. package/dist/detectors/marker.d.ts +106 -0
  36. package/dist/detectors/probe-signature.d.ts +27 -0
  37. package/dist/detectors/site-baseline.d.ts +135 -0
  38. package/dist/detectors/target-integrity.d.ts +16 -0
  39. package/dist/detectors/trap.d.ts +10 -3
  40. package/dist/detectors/types.d.ts +17 -0
  41. package/dist/element/index.cjs +726 -79
  42. package/dist/element/index.cjs.map +1 -1
  43. package/dist/element/index.js +726 -79
  44. package/dist/element/index.js.map +1 -1
  45. package/dist/index.cjs +1657 -125
  46. package/dist/index.cjs.map +1 -1
  47. package/dist/index.d.ts +4 -0
  48. package/dist/index.js +1643 -125
  49. package/dist/index.js.map +1 -1
  50. package/dist/internal/async.d.ts +0 -3
  51. package/dist/internal/ip.d.ts +18 -0
  52. package/dist/internal/text.d.ts +28 -0
  53. package/dist/metrics.d.ts +18 -0
  54. package/dist/probe/index.d.ts +153 -0
  55. package/dist/probe/marker.d.ts +119 -0
  56. package/dist/site/index.d.ts +122 -0
  57. package/dist/state.d.ts +130 -0
  58. package/dist/stores/redis.d.ts +24 -1
  59. package/dist/types.d.ts +70 -0
  60. package/docs/course/05-detectors.md +6 -5
  61. package/docs/course/06-identity.md +1 -1
  62. package/docs/course/16-proving-it.md +15 -9
  63. package/docs/course/index.md +1 -1
  64. package/docs/design/decisions.md +1 -1
  65. package/docs/detection/correlation.md +284 -0
  66. package/docs/detection/detectors.md +139 -3
  67. package/docs/detection/index.md +2 -1
  68. package/docs/detection/shadow-mode.md +147 -0
  69. package/docs/detection/signatures.md +1 -1
  70. package/docs/index.md +3 -2
  71. package/docs/integration/client-ip.md +16 -0
  72. package/docs/operations/dashboard.md +40 -1
  73. package/docs/operations/filters.md +143 -0
  74. package/docs/operations/index.md +1 -0
  75. package/docs/operations/metrics.md +18 -0
  76. package/docs/policy/presets.md +1 -1
  77. package/docs/start/choosing-a-policy.md +1 -1
  78. package/docs/start/first-integration.md +1 -1
  79. package/docs/start/installation.md +2 -2
  80. package/docs/testing/cli.md +7 -1
  81. package/docs/testing/corpus.md +12 -8
  82. package/docs/testing/index.md +1 -1
  83. package/docs/testing/try-it.md +1 -1
  84. package/package.json +4 -1
@@ -165,6 +165,55 @@ query string.
165
165
  probeSignatureDetector({ extraPaths: ["/internal/admin"] })
166
166
  ```
167
167
 
168
+ ### `target-integrity`
169
+
170
+ **cheap · always · ceiling `strong`**
171
+
172
+ How was this target *spelled*?
173
+
174
+ `probe-signature` above reads what a request asked for. This one reads how it asked, and it
175
+ exists because the two are not the same question — and because the answer to the second is
176
+ normally destroyed before any detector sees it.
177
+
178
+ Every detector reads `facts.path`, which is normalised: decoded once, backslashes and
179
+ doubled slashes collapsed, `.` and `..` resolved. That is not optional — a rule scoped to
180
+ `/admin` has to hold against `/%61dmin` and `/./admin` or it is not a rule. It is also
181
+ exactly what makes an evasive target arrive looking ordinary:
182
+
183
+ ```
184
+ GET /%2e%2e%2f%2e%2e%2fapp/config.yml → facts.path = "/app/config.yml"
185
+ ```
186
+
187
+ Which is a page nobody has, on no wordlist, indistinguishable from a broken link. So the
188
+ target as it arrived is kept in `facts.rawPath` — and only when it differs from the
189
+ normalised form, which on ordinary traffic it does not — and this reads it.
190
+
191
+ | Spelling | Tier |
192
+ | --- | ---: |
193
+ | Encoded its own encoding, so one decoding pass leaves it still encoded | `strong` |
194
+ | Wrote a traversal with its dots and slashes percent-encoded | `strong` |
195
+ | Carried a control character, raw or encoded | `strong` |
196
+ | Addressed the target to a proxy — `GET http://elsewhere/` at an origin server | `strong` |
197
+ | Hid a path separator inside a segment | `moderate` |
198
+ | Walked up out of the site root, written plainly | `moderate` |
199
+
200
+ **Nothing here is `certain`, and the closest call says why.** A path segment that carries a
201
+ URL as *data* — `/redirect/https%3A%2F%2Fexample.com%2Fa` — is encoded once to sit in a
202
+ path and encoded again by whatever built the link around it. That is a real pattern on real
203
+ sites and it produces `%252e` honestly. A deliberate act with no ordinary cause is not the
204
+ same thing as one admitting no benign explanation, and only the second may close a door.
205
+
206
+ The plain-traversal row is the one a *broken* client produces as readily as a hostile one,
207
+ so it has a switch:
208
+
209
+ ```ts
210
+ targetIntegrityDetector({ reportPlainTraversal: false })
211
+ ```
212
+
213
+ All of these share the family `evasive-target`. One target usually trips several — a
214
+ traversal is normally encoded and an encoded traversal is often double-encoded — and that
215
+ is one act seen three ways, not three reasons.
216
+
168
217
  ### `tls-fingerprint`
169
218
 
170
219
  **cheap · always · ceiling `strong`**
@@ -243,6 +292,9 @@ would echo. Reaching one requires reading the page as data rather than as a page
243
292
  import { renderTrapLink, trapRobotsEntries, DEFAULT_TRAP_PATHS } from "@osqd/bothandlerjs";
244
293
 
245
294
  app.get("/", (_req, res) => res.send(page + renderTrapLink()));
295
+
296
+ // And publish the same paths, so a crawler that obeys robots.txt never sees them.
297
+ app.get("/robots.txt", (_req, res) => res.type("text/plain").send(trapRobotsEntries(DEFAULT_TRAP_PATHS)));
246
298
  ```
247
299
 
248
300
  The trap paths belong in your `robots.txt` as `Disallow`, which is what makes the evidence
@@ -297,6 +349,39 @@ walking a sitemap almost never revisits, so its ratio sits near one.
297
349
  `weak`, because a *welcome* crawler produces exactly this shape and so does a person on a
298
350
  first visit to a documentation site.
299
351
 
352
+ ### `blended-identity`
353
+
354
+ **cheap · always · ceiling `strong`**
355
+
356
+ What a *series* of claims says, as opposed to what one claim says.
357
+
358
+ Every other identity check here reads a single request: this User-Agent names this bot, and
359
+ that claim is either confirmable or it is not. The set of identities an actor has claimed
360
+ over time is a different object, and some sets are self-contradictory in a way no member of
361
+ them is.
362
+
363
+ Three readings:
364
+
365
+ - **Several security tools.** One address arriving as two or more named scanners is a scan,
366
+ not a coincidence.
367
+ - **Several verifiable crawlers.** At most one of Googlebot, Bingbot and Yandex can be true
368
+ of an address, because each publishes a proof tied to addresses it controls. The
369
+ contradiction is visible from the claims alone — which matters most when DNS is
370
+ unreachable and neither claim can be refuted on its own.
371
+ - **A crawler that also probes.** An actor that sent a scanner payload *and* claimed to be
372
+ a search crawler has told you which of the two is the lie.
373
+
374
+ **These hold under the default address-based actor key**, which is why they are on by
375
+ default and [`identity-rotation`](#identity-rotation) is not. A NAT gateway presents many
376
+ browsers — that is exactly what makes counting User-Agents useless there — but it does not
377
+ present sqlmap *and* nikto, and it does not claim to be Googlebot *and* Bingbot. The
378
+ innocent explanation for a hundred browsers behind one address is an office; there is no
379
+ corresponding one for these.
380
+
381
+ `strong`, not `certain`. Trusting the wrong forwarded header collapses every client onto
382
+ one address, and then two genuinely different crawlers produce this exact set — so it may
383
+ contribute to a denial and may not be the whole of one.
384
+
300
385
  ### `id-enumeration`
301
386
 
302
387
  **cheap · always · ceiling `moderate`**
@@ -339,8 +424,11 @@ const { outcome } = await handler.handle(facts);
339
424
  handler.recordOutcome(facts, response.statusCode);
340
425
  ```
341
426
 
342
- The bundled Node adapter does this for you. Nothing else depends on it: every other
343
- detector works unchanged if you never call it, and this one is simply absent.
427
+ Every bundled adapter does this for you Node and Express on the response's `finish`,
428
+ Fastify on the reply's, Koa from the status the middleware chain settled on, and the Fetch
429
+ wrapper from the `Response` it returns. You only need the call above if you drive the
430
+ engine yourself. Nothing else depends on it: every other detector works unchanged if you
431
+ never call it, and this one is simply absent.
344
432
 
345
433
  Counts **404 and 410 only**. A 403 is usually this library's own doing, and counting it
346
434
  would let a rule that challenges an actor manufacture the evidence for having challenged
@@ -375,6 +463,14 @@ request looks like this — which is what `transportCoherenceDetector({ legacyHt
375
463
  is for. An all-HEAD visit is a stronger shape, but a link checker is a real and mostly
376
464
  harmless thing to be.
377
465
 
466
+ **It cannot simply be made NAT-safe, and it is worth saying why.** A rotator changes the
467
+ User-Agent and nothing else, so "several User-Agents behind a single header shape" looks
468
+ like a clean way to separate it from a gateway. Measured, it is not: ten real browser
469
+ profiles collapse to six stable header shapes, and Chrome, Edge, Opera and Chromium on
470
+ Linux share one — they are the same engine sending the same headers in the same order. An
471
+ office running Chrome and Edge produces that signature exactly. The narrower actor key is
472
+ the fix; there is no header trick that substitutes for it.
473
+
378
474
  ### `parameter-sweep`
379
475
 
380
476
  **cheap · always · ceiling `weak`**
@@ -407,7 +503,9 @@ mid-session; something that does is cycling through a spoofing list.
407
503
 
408
504
  **Off by default, and think before enabling it.** With the default address-based actor
409
505
  key, a corporate NAT presents a hundred people's browsers as one actor with a hundred
410
- User-Agents — which is this detector's exact signature and is entirely innocent. Enable it
506
+ User-Agents — which is this detector's exact signature and is entirely innocent. Switching
507
+ it on without also narrowing `actorKey` raises a startup warning, because it is a decision
508
+ whose consequences are invisible until real visitors are being challenged. Enable it
411
509
  when your `actorKey` identifies a session rather than a network.
412
510
 
413
511
  ### `session-integrity`
@@ -491,8 +589,46 @@ A resolver that is merely *unhappy* must reach a different verdict from one that
491
589
 
492
590
  ---
493
591
 
592
+ ## Detectors that arrive with something else
593
+
594
+ Ten more exist and are not in the catalogue above, because none of them is installed by
595
+ default: each reads a source that has to be configured before it exists at all, and a
596
+ detector with nothing to read is a permanently silent entry in `describeDetectors()`.
597
+ They are documented together in
598
+ [correlating a client's own requests](correlation.md), which is also where the reasoning
599
+ about what each is worth lives.
600
+
601
+ **With `probe`** — a signed marker cookie this server issues and reads back, which
602
+ identifies a *client* across requests rather than an address:
603
+
604
+ | Detector | Ceiling |
605
+ | --- | --- |
606
+ | `identity-drift` | `strong` |
607
+ | `marker-integrity` | `strong` |
608
+ | `marker-fanout` | `moderate` |
609
+ | `marker-persistence` | `moderate` |
610
+
611
+ **With `challenge`** — reactions to a question this server chose to ask:
612
+
613
+ | Detector | Ceiling |
614
+ | --- | --- |
615
+ | `challenge-reaction` | `strong` |
616
+ | `challenge-integrity` | `moderate` |
617
+
618
+ **With `site`** — a warmed-up baseline of what the rest of your traffic looks like:
619
+
620
+ | Detector | Ceiling |
621
+ | --- | --- |
622
+ | `distributed-walk` | `moderate` |
623
+ | `path-novelty` | `moderate` |
624
+ | `miss-baseline` | `moderate` |
625
+ | `path-campaign` | `moderate` |
626
+
627
+ ---
628
+
494
629
  ## Related
495
630
 
496
631
  - [How detection works](index.md) — the pipeline these run in
632
+ - [Correlating a client's own requests](correlation.md) — the ten above, in detail
497
633
  - [Writing a detector](writing-a-detector.md) — the contract, and the rules on certainty
498
634
  - [Evidence and certainty](../concepts/evidence.md) — what the tiers mean
@@ -129,6 +129,7 @@ time goes, and turn it off afterwards — it is two clock reads per detector per
129
129
 
130
130
  ## Related
131
131
 
132
- - [The detectors](detectors.md) — all twenty in detail
132
+ - [The detectors](detectors.md) — the default set, in detail
133
+ - [Correlating a client's own requests](correlation.md) — the marker cookie, and reading a series rather than a request
133
134
  - [Writing a detector](writing-a-detector.md)
134
135
  - [Policy](../policy/index.md) — what happens to an assessment next
@@ -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
 
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