@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.
- package/CHANGELOG.md +227 -1
- package/README.md +18 -10
- package/dist/adapters/fastify.d.ts +10 -0
- package/dist/adapters/index.cjs +38 -10
- package/dist/adapters/index.cjs.map +1 -1
- package/dist/adapters/index.js +38 -10
- package/dist/adapters/index.js.map +1 -1
- package/dist/challenge/index.d.ts +40 -0
- package/dist/cli.cjs +2256 -103
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +2256 -103
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +55 -0
- package/dist/core.d.ts +48 -21
- package/dist/corpus/index.cjs +365 -7
- package/dist/corpus/index.cjs.map +1 -1
- package/dist/corpus/index.js +365 -7
- package/dist/corpus/index.js.map +1 -1
- package/dist/corpus/schema.d.ts +33 -0
- package/dist/crawler-ranges.d.ts +31 -0
- package/dist/dashboard/client/actions.d.ts +1 -1
- package/dist/dashboard/client/app.d.ts +9 -2
- package/dist/dashboard/client/boot.d.ts +32 -3
- package/dist/dashboard/client/query.d.ts +72 -12
- package/dist/dashboard/client/registry.d.ts +25 -0
- package/dist/dashboard/client/saved.d.ts +29 -0
- package/dist/dashboard/client/store.d.ts +16 -2
- package/dist/dashboard/client/types.d.ts +2 -0
- package/dist/dashboard/client.generated.d.ts +1 -1
- package/dist/dashboard/types.d.ts +15 -0
- package/dist/detectors/blended-identity.d.ts +34 -0
- package/dist/detectors/challenge-integrity.d.ts +26 -0
- package/dist/detectors/challenge-reaction.d.ts +39 -0
- package/dist/detectors/clearance.d.ts +1 -23
- package/dist/detectors/id-enumeration.d.ts +31 -0
- package/dist/detectors/index.d.ts +24 -1
- package/dist/detectors/known-bots.d.ts +11 -0
- package/dist/detectors/marker.d.ts +106 -0
- package/dist/detectors/parameter-sweep.d.ts +39 -0
- package/dist/detectors/probe-signature.d.ts +27 -0
- package/dist/detectors/probe-volume.d.ts +26 -0
- package/dist/detectors/site-baseline.d.ts +135 -0
- package/dist/detectors/target-integrity.d.ts +16 -0
- package/dist/detectors/transport-coherence.d.ts +31 -0
- package/dist/detectors/trap.d.ts +10 -3
- package/dist/detectors/types.d.ts +17 -0
- package/dist/element/index.cjs +730 -80
- package/dist/element/index.cjs.map +1 -1
- package/dist/element/index.js +730 -80
- package/dist/element/index.js.map +1 -1
- package/dist/index.cjs +2044 -123
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.js +2025 -123
- package/dist/index.js.map +1 -1
- package/dist/internal/async.d.ts +0 -3
- package/dist/internal/ip.d.ts +18 -0
- package/dist/internal/text.d.ts +28 -0
- package/dist/metrics.d.ts +18 -0
- package/dist/probe/index.d.ts +153 -0
- package/dist/probe/marker.d.ts +119 -0
- package/dist/site/index.d.ts +122 -0
- package/dist/state.d.ts +205 -0
- package/dist/stores/redis.d.ts +24 -1
- package/dist/types.d.ts +106 -0
- package/docs/course/05-detectors.md +9 -4
- package/docs/course/06-identity.md +1 -1
- package/docs/course/16-proving-it.md +15 -9
- package/docs/course/index.md +1 -1
- package/docs/design/decisions.md +1 -1
- package/docs/detection/correlation.md +284 -0
- package/docs/detection/detectors.md +259 -1
- package/docs/detection/index.md +2 -1
- package/docs/detection/shadow-mode.md +147 -0
- package/docs/detection/signatures.md +10 -2
- package/docs/index.md +3 -2
- package/docs/integration/client-ip.md +16 -0
- package/docs/operations/dashboard.md +40 -1
- package/docs/operations/filters.md +143 -0
- package/docs/operations/index.md +1 -0
- package/docs/operations/metrics.md +18 -0
- package/docs/policy/presets.md +1 -1
- package/docs/start/choosing-a-policy.md +1 -1
- package/docs/start/first-integration.md +1 -1
- package/docs/start/installation.md +2 -2
- package/docs/testing/cli.md +7 -1
- package/docs/testing/corpus.md +12 -8
- package/docs/testing/index.md +1 -1
- package/docs/testing/try-it.md +1 -1
- 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
|
-
|
|
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
|
-
|
|
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) |
|
|
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
|
package/docs/operations/index.md
CHANGED
|
@@ -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
|
package/docs/policy/presets.md
CHANGED
|
@@ -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) —
|
|
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 [
|
|
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 [
|
|
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
|
|
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
|
|
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
|
package/docs/testing/cli.md
CHANGED
|
@@ -43,7 +43,7 @@ npx @osqd/bothandlerjs check --preset protect-content
|
|
|
43
43
|
```
|
|
44
44
|
|
|
45
45
|
```
|
|
46
|
-
protect-content against
|
|
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)
|
package/docs/testing/corpus.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# The traffic corpus
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
25
|
-
protect-content
|
|
26
|
-
protect-data
|
|
27
|
-
protect-auth
|
|
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** |
|
|
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** |
|
|
45
|
-
| **hostile** |
|
|
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
|
package/docs/testing/index.md
CHANGED
|
@@ -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?" —
|
|
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
|
|
package/docs/testing/try-it.md
CHANGED
|
@@ -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
|
|
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.
|
|
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 .",
|