cerberus-keys 0.1.0__tar.gz
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.
- cerberus_keys-0.1.0/PKG-INFO +250 -0
- cerberus_keys-0.1.0/README.md +239 -0
- cerberus_keys-0.1.0/pyproject.toml +37 -0
- cerberus_keys-0.1.0/setup.cfg +4 -0
- cerberus_keys-0.1.0/src/cerberus_keys/__init__.py +10 -0
- cerberus_keys-0.1.0/src/cerberus_keys/client.py +412 -0
- cerberus_keys-0.1.0/src/cerberus_keys/fingerprint.py +117 -0
- cerberus_keys-0.1.0/src/cerberus_keys/litellm.py +241 -0
- cerberus_keys-0.1.0/src/cerberus_keys/py.typed +0 -0
- cerberus_keys-0.1.0/src/cerberus_keys/version.py +33 -0
- cerberus_keys-0.1.0/src/cerberus_keys.egg-info/PKG-INFO +250 -0
- cerberus_keys-0.1.0/src/cerberus_keys.egg-info/SOURCES.txt +17 -0
- cerberus_keys-0.1.0/src/cerberus_keys.egg-info/dependency_links.txt +1 -0
- cerberus_keys-0.1.0/src/cerberus_keys.egg-info/requires.txt +5 -0
- cerberus_keys-0.1.0/src/cerberus_keys.egg-info/top_level.txt +1 -0
- cerberus_keys-0.1.0/tests/test_client.py +111 -0
- cerberus_keys-0.1.0/tests/test_fingerprint.py +146 -0
- cerberus_keys-0.1.0/tests/test_litellm_callback.py +372 -0
- cerberus_keys-0.1.0/tests/test_never_raises.py +256 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cerberus-keys
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Client library for Cerberus — per-key API abuse detection
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Provides-Extra: dev
|
|
8
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
9
|
+
Requires-Dist: build>=1.0; extra == "dev"
|
|
10
|
+
Requires-Dist: twine>=5.0; extra == "dev"
|
|
11
|
+
|
|
12
|
+
# Cerberus
|
|
13
|
+
|
|
14
|
+
Watches API-key usage metadata and tells you when one of your customers' keys
|
|
15
|
+
starts behaving unlike its own history — a shared, stolen, or scraped
|
|
16
|
+
credential — before the bill arrives.
|
|
17
|
+
|
|
18
|
+
Not bot detection. Specifically: *this paying customer's key is doing
|
|
19
|
+
something it has never done, and it's costing you money.*
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## What Cerberus receives
|
|
24
|
+
|
|
25
|
+
**Twelve fields. Nothing else.** The ingest endpoint rejects any unknown field
|
|
26
|
+
by name, so this list is enforced in code rather than promised in prose.
|
|
27
|
+
|
|
28
|
+
| Field | Meaning |
|
|
29
|
+
|---|---|
|
|
30
|
+
| `ts` | ISO 8601 UTC timestamp |
|
|
31
|
+
| `key_fp` | Fingerprint of your API key ID — never the key |
|
|
32
|
+
| `endpoint` | Route **template** (see below) |
|
|
33
|
+
| `tokens_in` / `tokens_out` | Token counts |
|
|
34
|
+
| `latency_ms` | Request latency |
|
|
35
|
+
| `status` | HTTP status code |
|
|
36
|
+
| `ip_fp` | Fingerprint of the client IP — never the address |
|
|
37
|
+
| `ip_net_fp` / `ip_block_fp` | Fingerprints of the surrounding network |
|
|
38
|
+
| `ip_family` | `v4` or `v6` |
|
|
39
|
+
| `cost` | Optional, in USD |
|
|
40
|
+
|
|
41
|
+
**No content, ever.** No prompts, no responses — not hashed, not truncated,
|
|
42
|
+
not optional.
|
|
43
|
+
|
|
44
|
+
**Thirty-day rolling retention.**
|
|
45
|
+
|
|
46
|
+
## Why we cannot see your keys or your users' IP addresses
|
|
47
|
+
|
|
48
|
+
Fingerprints are HMAC-SHA256 under a secret salt **you generate and we never
|
|
49
|
+
receive**, truncated to 128 bits.
|
|
50
|
+
|
|
51
|
+
This matters because a plain hash is not anonymisation: the IPv4 space is
|
|
52
|
+
2^32, so a GPU builds the whole rainbow table in minutes. With a salt we never
|
|
53
|
+
hold, we cannot reverse a fingerprint even in principle — and because the salt
|
|
54
|
+
is per-tenant, the same IP hitting two of our customers produces two unrelated
|
|
55
|
+
fingerprints. We could not build a cross-customer profile if we wanted to.
|
|
56
|
+
|
|
57
|
+
### Salt handling
|
|
58
|
+
|
|
59
|
+
- Store it wherever your other secrets live, **not in a config file**.
|
|
60
|
+
- Use the **same salt on every instance**, or one key looks like several.
|
|
61
|
+
- **Do not rotate it.** Every historical fingerprint becomes uncorrelatable
|
|
62
|
+
and all baselines reset.
|
|
63
|
+
- If you lose it, baselines reset. Nothing else breaks.
|
|
64
|
+
|
|
65
|
+
### Looking up a fingerprint at 2am
|
|
66
|
+
|
|
67
|
+
An alert names a key by fingerprint, because that is all we have. You can
|
|
68
|
+
always map it back — you hold both the salt and your key list:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from cerberus_keys import fingerprint
|
|
72
|
+
{fingerprint(k, salt): k for k in my_api_keys}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
We cannot do this lookup. That is the point. Build the index once during
|
|
76
|
+
onboarding and keep it.
|
|
77
|
+
|
|
78
|
+
## Installing
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
pip install cerberus-keys
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Zero runtime dependencies, standard library only.
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from cerberus_keys import Cerberus
|
|
88
|
+
|
|
89
|
+
client = Cerberus(ingest_token, salt, endpoint_url="https://.../v1/events")
|
|
90
|
+
client.record(api_key=key, ip=client_ip, endpoint="/v1/chat",
|
|
91
|
+
tokens_in=n_in, tokens_out=n_out, latency_ms=ms, status=200)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`record()` **never blocks, never raises, and never adds latency** to your
|
|
95
|
+
request path. It enqueues and returns; a full buffer drops rather than
|
|
96
|
+
applying backpressure; every exception is swallowed at the boundary. A
|
|
97
|
+
monitoring library that can stall the thing it monitors is not worth running.
|
|
98
|
+
Dropped events are visible on `client.dropped` and are never an error.
|
|
99
|
+
|
|
100
|
+
## You will know within the hour that it works
|
|
101
|
+
|
|
102
|
+
Once your first events reach us, Cerberus posts a one-time confirmation to your
|
|
103
|
+
Slack webhook. It arrives on the next hourly pass — so within the hour, not
|
|
104
|
+
instantly — and it tells you:
|
|
105
|
+
|
|
106
|
+
- how many events we received, and how many distinct API keys they came from
|
|
107
|
+
- how many distinct client IPs we can see
|
|
108
|
+
- when detection starts, since we need about a week of history per key first
|
|
109
|
+
|
|
110
|
+
**If it does not arrive, something is wrong, and that is the point.** Cerberus
|
|
111
|
+
can fail in a way that looks exactly like working: this SDK drops failed sends
|
|
112
|
+
silently, on purpose, so it can never add latency or raise into your request
|
|
113
|
+
path. A Cerberus that is receiving nothing looks identical to a Cerberus that
|
|
114
|
+
is watching quietly. The confirmation exists so that silence means something
|
|
115
|
+
specific instead of nothing.
|
|
116
|
+
|
|
117
|
+
If an hour passes and no message arrives, check in this order:
|
|
118
|
+
|
|
119
|
+
1. **Is a Slack webhook configured on your account?** No webhook, no message —
|
|
120
|
+
and no alerts either.
|
|
121
|
+
2. **Is the ingest token right, and not revoked?** A bad token gets a 401,
|
|
122
|
+
which the SDK drops.
|
|
123
|
+
3. **Is `endpoint_url` correct?** Anything non-2xx is dropped the same way.
|
|
124
|
+
4. **Is your process alive long enough to flush?** The client batches. A script
|
|
125
|
+
that exits immediately should call `.close()`.
|
|
126
|
+
|
|
127
|
+
`Cerberus(...).dropped` counts events the client discarded, and is the fastest
|
|
128
|
+
local check that something is being sent at all.
|
|
129
|
+
|
|
130
|
+
### `endpoint` must be a route template
|
|
131
|
+
|
|
132
|
+
Send `/v1/orgs/{org_id}/chat`, **not** `/v1/orgs/acme-corp/chat`. Live paths
|
|
133
|
+
routinely carry identifiers, and this field would carry them to us.
|
|
134
|
+
|
|
135
|
+
The ingest endpoint rejects paths containing `@`, UUIDs, or long digit and hex
|
|
136
|
+
runs — but that is defence in depth, not a guarantee. `/v1/orgs/acme-corp/chat`
|
|
137
|
+
defeats every one of those checks. **The template requirement is the
|
|
138
|
+
mechanism; we cannot detect every violation of it.**
|
|
139
|
+
|
|
140
|
+
## If you run LiteLLM
|
|
141
|
+
|
|
142
|
+
This is the cheapest integration on offer: LiteLLM's callback payload already
|
|
143
|
+
carries eight of the twelve fields, so there is no instrumentation to write.
|
|
144
|
+
It is **two config blocks, and both are required.**
|
|
145
|
+
|
|
146
|
+
First, a file the proxy can import. Put it next to your config:
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
# cerberus_callback.py
|
|
150
|
+
import os
|
|
151
|
+
from cerberus_keys.litellm import CerberusLogger
|
|
152
|
+
|
|
153
|
+
cerberus = CerberusLogger(
|
|
154
|
+
ingest_token=os.environ["CERBERUS_INGEST_TOKEN"],
|
|
155
|
+
salt=bytes.fromhex(os.environ["CERBERUS_SALT"]),
|
|
156
|
+
endpoint_url="https://cerberushq.dev/v1/events",
|
|
157
|
+
)
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
**Block one** registers it:
|
|
161
|
+
|
|
162
|
+
```yaml
|
|
163
|
+
litellm_settings:
|
|
164
|
+
callbacks: cerberus_callback.cerberus
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
**Block two** is the one that matters, and it is in a *different* section:
|
|
168
|
+
|
|
169
|
+
```yaml
|
|
170
|
+
general_settings:
|
|
171
|
+
use_x_forwarded_for: true # REQUIRED
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Without it, LiteLLM reports `request.client.host` — which behind any load
|
|
175
|
+
balancer, ingress, or CDN is *the load balancer's address on every request*.
|
|
176
|
+
Distinct-IP counts collapse to one permanently and fan-out detection silently
|
|
177
|
+
cannot fire, while looking exactly like a clean bill of health. It is the only
|
|
178
|
+
way to install Cerberus, have it appear to work, and get nothing from it.
|
|
179
|
+
|
|
180
|
+
Cerberus checks for this shape and tells you if it sees it. Don't rely on
|
|
181
|
+
that; set the flag.
|
|
182
|
+
|
|
183
|
+
### Two things that are different on LiteLLM
|
|
184
|
+
|
|
185
|
+
**Resolving a fingerprint takes the hash, not the key.** LiteLLM never hands a
|
|
186
|
+
callback your raw key — it passes `user_api_key_hash`, which is
|
|
187
|
+
`sha256(key).hexdigest()`. So the fingerprint in an alert is
|
|
188
|
+
`HMAC(salt, sha256hex(key))`. Calling `resolve()` with raw keys matches
|
|
189
|
+
nothing, silently, and reads exactly like "that key isn't ours" at the worst
|
|
190
|
+
possible moment. Map them first:
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
from cerberus_keys import resolve
|
|
194
|
+
from cerberus_keys.litellm import key_fingerprint_input
|
|
195
|
+
|
|
196
|
+
resolve(fp, [key_fingerprint_input(k) for k in your_keys], salt)
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
**Streaming latency is time-to-first-token.** LiteLLM's `response_time` is
|
|
200
|
+
`completion_start_time - start_time` when `stream=True`, not the full
|
|
201
|
+
duration. Latency is a digest signal rather than a fan-out condition, so this
|
|
202
|
+
does not affect what pages you — but a streaming-heavy proxy will show lower
|
|
203
|
+
latencies than its users experience, and that is a property of the source, not
|
|
204
|
+
of Cerberus.
|
|
205
|
+
|
|
206
|
+
The proxy path only. `requester_ip_address` is populated in LiteLLM's
|
|
207
|
+
proxy-side request handling, so the LiteLLM **SDK used as a library** does not
|
|
208
|
+
carry it — take the normal `Cerberus(...).record(...)` path there.
|
|
209
|
+
|
|
210
|
+
## What it detects
|
|
211
|
+
|
|
212
|
+
**One real-time signal: key fan-out.** Not "many IPs" — *many IPs each doing
|
|
213
|
+
very little*, which is the shape of a shared credential and what separates it
|
|
214
|
+
from your infrastructure scaling up. Scaling three containers to forty raises
|
|
215
|
+
volume in proportion; a leaked key inverts that. Five conditions must all
|
|
216
|
+
hold, sustained across consecutive hours, before anything pages you.
|
|
217
|
+
|
|
218
|
+
Everything else — cost and volume anomalies — goes in a daily digest. A
|
|
219
|
+
customer who just launched and 10x'd their usage looks identical to abuse, and
|
|
220
|
+
that customer is the best thing that happened to you this quarter.
|
|
221
|
+
|
|
222
|
+
## Known limits, stated plainly
|
|
223
|
+
|
|
224
|
+
- **Keys used from fewer than 3 distinct IPs are not protected at all.** A
|
|
225
|
+
key that lives on one or two servers never builds a baseline the rule can
|
|
226
|
+
compare against, so a leak of that key -- however dispersed, however
|
|
227
|
+
sustained -- does not fire. This is the single biggest gap, it covers the
|
|
228
|
+
most common key shape, and `GET /v1/status` names the affected keys as
|
|
229
|
+
`below_detection_floor` rather than pretending they are still warming up.
|
|
230
|
+
The floor is a calibration threshold; it will move only on replay evidence,
|
|
231
|
+
not by guess.
|
|
232
|
+
- **Keys already spread across many IPs are under-protected.** The rule is
|
|
233
|
+
relative to each key's own history, so a key that normally lives on 200 IPs
|
|
234
|
+
needs a far larger jump to trip. Conservative by design; you should know it.
|
|
235
|
+
- **A leak that ramps slowly enough is never caught.** The baseline tracks it
|
|
236
|
+
upward and nothing fires. Detecting that needs a long-horizon reference we
|
|
237
|
+
don't yet have.
|
|
238
|
+
- **Concentrated datacenter scrapers don't page.** A scraper rotating inside
|
|
239
|
+
one provider's range looks concentrated, and the rule requires dispersion.
|
|
240
|
+
Deliberate: a missed scraper is cheaper than a false page.
|
|
241
|
+
- **A serverless migration can page you.** If you move a key from a few
|
|
242
|
+
servers to many small workers (a serverless or autoscaling rollout) at
|
|
243
|
+
roughly constant traffic, and those workers are spread across many network
|
|
244
|
+
blocks — which cloud egress usually is — that has the same shape as a
|
|
245
|
+
leaked key being used from many places, and the rule can fire. It is the one
|
|
246
|
+
false alarm the design cannot rule out from the data it holds: it cannot
|
|
247
|
+
tell your cloud's address ranges apart from a stranger's. If it happens,
|
|
248
|
+
acknowledge the alert (it offers a "this key is legitimately distributed"
|
|
249
|
+
link) and it stops. Tell us if you have a rollout planned and we can quiet
|
|
250
|
+
the key ahead of time.
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# Cerberus
|
|
2
|
+
|
|
3
|
+
Watches API-key usage metadata and tells you when one of your customers' keys
|
|
4
|
+
starts behaving unlike its own history — a shared, stolen, or scraped
|
|
5
|
+
credential — before the bill arrives.
|
|
6
|
+
|
|
7
|
+
Not bot detection. Specifically: *this paying customer's key is doing
|
|
8
|
+
something it has never done, and it's costing you money.*
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## What Cerberus receives
|
|
13
|
+
|
|
14
|
+
**Twelve fields. Nothing else.** The ingest endpoint rejects any unknown field
|
|
15
|
+
by name, so this list is enforced in code rather than promised in prose.
|
|
16
|
+
|
|
17
|
+
| Field | Meaning |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `ts` | ISO 8601 UTC timestamp |
|
|
20
|
+
| `key_fp` | Fingerprint of your API key ID — never the key |
|
|
21
|
+
| `endpoint` | Route **template** (see below) |
|
|
22
|
+
| `tokens_in` / `tokens_out` | Token counts |
|
|
23
|
+
| `latency_ms` | Request latency |
|
|
24
|
+
| `status` | HTTP status code |
|
|
25
|
+
| `ip_fp` | Fingerprint of the client IP — never the address |
|
|
26
|
+
| `ip_net_fp` / `ip_block_fp` | Fingerprints of the surrounding network |
|
|
27
|
+
| `ip_family` | `v4` or `v6` |
|
|
28
|
+
| `cost` | Optional, in USD |
|
|
29
|
+
|
|
30
|
+
**No content, ever.** No prompts, no responses — not hashed, not truncated,
|
|
31
|
+
not optional.
|
|
32
|
+
|
|
33
|
+
**Thirty-day rolling retention.**
|
|
34
|
+
|
|
35
|
+
## Why we cannot see your keys or your users' IP addresses
|
|
36
|
+
|
|
37
|
+
Fingerprints are HMAC-SHA256 under a secret salt **you generate and we never
|
|
38
|
+
receive**, truncated to 128 bits.
|
|
39
|
+
|
|
40
|
+
This matters because a plain hash is not anonymisation: the IPv4 space is
|
|
41
|
+
2^32, so a GPU builds the whole rainbow table in minutes. With a salt we never
|
|
42
|
+
hold, we cannot reverse a fingerprint even in principle — and because the salt
|
|
43
|
+
is per-tenant, the same IP hitting two of our customers produces two unrelated
|
|
44
|
+
fingerprints. We could not build a cross-customer profile if we wanted to.
|
|
45
|
+
|
|
46
|
+
### Salt handling
|
|
47
|
+
|
|
48
|
+
- Store it wherever your other secrets live, **not in a config file**.
|
|
49
|
+
- Use the **same salt on every instance**, or one key looks like several.
|
|
50
|
+
- **Do not rotate it.** Every historical fingerprint becomes uncorrelatable
|
|
51
|
+
and all baselines reset.
|
|
52
|
+
- If you lose it, baselines reset. Nothing else breaks.
|
|
53
|
+
|
|
54
|
+
### Looking up a fingerprint at 2am
|
|
55
|
+
|
|
56
|
+
An alert names a key by fingerprint, because that is all we have. You can
|
|
57
|
+
always map it back — you hold both the salt and your key list:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from cerberus_keys import fingerprint
|
|
61
|
+
{fingerprint(k, salt): k for k in my_api_keys}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
We cannot do this lookup. That is the point. Build the index once during
|
|
65
|
+
onboarding and keep it.
|
|
66
|
+
|
|
67
|
+
## Installing
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pip install cerberus-keys
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Zero runtime dependencies, standard library only.
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from cerberus_keys import Cerberus
|
|
77
|
+
|
|
78
|
+
client = Cerberus(ingest_token, salt, endpoint_url="https://.../v1/events")
|
|
79
|
+
client.record(api_key=key, ip=client_ip, endpoint="/v1/chat",
|
|
80
|
+
tokens_in=n_in, tokens_out=n_out, latency_ms=ms, status=200)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`record()` **never blocks, never raises, and never adds latency** to your
|
|
84
|
+
request path. It enqueues and returns; a full buffer drops rather than
|
|
85
|
+
applying backpressure; every exception is swallowed at the boundary. A
|
|
86
|
+
monitoring library that can stall the thing it monitors is not worth running.
|
|
87
|
+
Dropped events are visible on `client.dropped` and are never an error.
|
|
88
|
+
|
|
89
|
+
## You will know within the hour that it works
|
|
90
|
+
|
|
91
|
+
Once your first events reach us, Cerberus posts a one-time confirmation to your
|
|
92
|
+
Slack webhook. It arrives on the next hourly pass — so within the hour, not
|
|
93
|
+
instantly — and it tells you:
|
|
94
|
+
|
|
95
|
+
- how many events we received, and how many distinct API keys they came from
|
|
96
|
+
- how many distinct client IPs we can see
|
|
97
|
+
- when detection starts, since we need about a week of history per key first
|
|
98
|
+
|
|
99
|
+
**If it does not arrive, something is wrong, and that is the point.** Cerberus
|
|
100
|
+
can fail in a way that looks exactly like working: this SDK drops failed sends
|
|
101
|
+
silently, on purpose, so it can never add latency or raise into your request
|
|
102
|
+
path. A Cerberus that is receiving nothing looks identical to a Cerberus that
|
|
103
|
+
is watching quietly. The confirmation exists so that silence means something
|
|
104
|
+
specific instead of nothing.
|
|
105
|
+
|
|
106
|
+
If an hour passes and no message arrives, check in this order:
|
|
107
|
+
|
|
108
|
+
1. **Is a Slack webhook configured on your account?** No webhook, no message —
|
|
109
|
+
and no alerts either.
|
|
110
|
+
2. **Is the ingest token right, and not revoked?** A bad token gets a 401,
|
|
111
|
+
which the SDK drops.
|
|
112
|
+
3. **Is `endpoint_url` correct?** Anything non-2xx is dropped the same way.
|
|
113
|
+
4. **Is your process alive long enough to flush?** The client batches. A script
|
|
114
|
+
that exits immediately should call `.close()`.
|
|
115
|
+
|
|
116
|
+
`Cerberus(...).dropped` counts events the client discarded, and is the fastest
|
|
117
|
+
local check that something is being sent at all.
|
|
118
|
+
|
|
119
|
+
### `endpoint` must be a route template
|
|
120
|
+
|
|
121
|
+
Send `/v1/orgs/{org_id}/chat`, **not** `/v1/orgs/acme-corp/chat`. Live paths
|
|
122
|
+
routinely carry identifiers, and this field would carry them to us.
|
|
123
|
+
|
|
124
|
+
The ingest endpoint rejects paths containing `@`, UUIDs, or long digit and hex
|
|
125
|
+
runs — but that is defence in depth, not a guarantee. `/v1/orgs/acme-corp/chat`
|
|
126
|
+
defeats every one of those checks. **The template requirement is the
|
|
127
|
+
mechanism; we cannot detect every violation of it.**
|
|
128
|
+
|
|
129
|
+
## If you run LiteLLM
|
|
130
|
+
|
|
131
|
+
This is the cheapest integration on offer: LiteLLM's callback payload already
|
|
132
|
+
carries eight of the twelve fields, so there is no instrumentation to write.
|
|
133
|
+
It is **two config blocks, and both are required.**
|
|
134
|
+
|
|
135
|
+
First, a file the proxy can import. Put it next to your config:
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
# cerberus_callback.py
|
|
139
|
+
import os
|
|
140
|
+
from cerberus_keys.litellm import CerberusLogger
|
|
141
|
+
|
|
142
|
+
cerberus = CerberusLogger(
|
|
143
|
+
ingest_token=os.environ["CERBERUS_INGEST_TOKEN"],
|
|
144
|
+
salt=bytes.fromhex(os.environ["CERBERUS_SALT"]),
|
|
145
|
+
endpoint_url="https://cerberushq.dev/v1/events",
|
|
146
|
+
)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
**Block one** registers it:
|
|
150
|
+
|
|
151
|
+
```yaml
|
|
152
|
+
litellm_settings:
|
|
153
|
+
callbacks: cerberus_callback.cerberus
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
**Block two** is the one that matters, and it is in a *different* section:
|
|
157
|
+
|
|
158
|
+
```yaml
|
|
159
|
+
general_settings:
|
|
160
|
+
use_x_forwarded_for: true # REQUIRED
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Without it, LiteLLM reports `request.client.host` — which behind any load
|
|
164
|
+
balancer, ingress, or CDN is *the load balancer's address on every request*.
|
|
165
|
+
Distinct-IP counts collapse to one permanently and fan-out detection silently
|
|
166
|
+
cannot fire, while looking exactly like a clean bill of health. It is the only
|
|
167
|
+
way to install Cerberus, have it appear to work, and get nothing from it.
|
|
168
|
+
|
|
169
|
+
Cerberus checks for this shape and tells you if it sees it. Don't rely on
|
|
170
|
+
that; set the flag.
|
|
171
|
+
|
|
172
|
+
### Two things that are different on LiteLLM
|
|
173
|
+
|
|
174
|
+
**Resolving a fingerprint takes the hash, not the key.** LiteLLM never hands a
|
|
175
|
+
callback your raw key — it passes `user_api_key_hash`, which is
|
|
176
|
+
`sha256(key).hexdigest()`. So the fingerprint in an alert is
|
|
177
|
+
`HMAC(salt, sha256hex(key))`. Calling `resolve()` with raw keys matches
|
|
178
|
+
nothing, silently, and reads exactly like "that key isn't ours" at the worst
|
|
179
|
+
possible moment. Map them first:
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
from cerberus_keys import resolve
|
|
183
|
+
from cerberus_keys.litellm import key_fingerprint_input
|
|
184
|
+
|
|
185
|
+
resolve(fp, [key_fingerprint_input(k) for k in your_keys], salt)
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
**Streaming latency is time-to-first-token.** LiteLLM's `response_time` is
|
|
189
|
+
`completion_start_time - start_time` when `stream=True`, not the full
|
|
190
|
+
duration. Latency is a digest signal rather than a fan-out condition, so this
|
|
191
|
+
does not affect what pages you — but a streaming-heavy proxy will show lower
|
|
192
|
+
latencies than its users experience, and that is a property of the source, not
|
|
193
|
+
of Cerberus.
|
|
194
|
+
|
|
195
|
+
The proxy path only. `requester_ip_address` is populated in LiteLLM's
|
|
196
|
+
proxy-side request handling, so the LiteLLM **SDK used as a library** does not
|
|
197
|
+
carry it — take the normal `Cerberus(...).record(...)` path there.
|
|
198
|
+
|
|
199
|
+
## What it detects
|
|
200
|
+
|
|
201
|
+
**One real-time signal: key fan-out.** Not "many IPs" — *many IPs each doing
|
|
202
|
+
very little*, which is the shape of a shared credential and what separates it
|
|
203
|
+
from your infrastructure scaling up. Scaling three containers to forty raises
|
|
204
|
+
volume in proportion; a leaked key inverts that. Five conditions must all
|
|
205
|
+
hold, sustained across consecutive hours, before anything pages you.
|
|
206
|
+
|
|
207
|
+
Everything else — cost and volume anomalies — goes in a daily digest. A
|
|
208
|
+
customer who just launched and 10x'd their usage looks identical to abuse, and
|
|
209
|
+
that customer is the best thing that happened to you this quarter.
|
|
210
|
+
|
|
211
|
+
## Known limits, stated plainly
|
|
212
|
+
|
|
213
|
+
- **Keys used from fewer than 3 distinct IPs are not protected at all.** A
|
|
214
|
+
key that lives on one or two servers never builds a baseline the rule can
|
|
215
|
+
compare against, so a leak of that key -- however dispersed, however
|
|
216
|
+
sustained -- does not fire. This is the single biggest gap, it covers the
|
|
217
|
+
most common key shape, and `GET /v1/status` names the affected keys as
|
|
218
|
+
`below_detection_floor` rather than pretending they are still warming up.
|
|
219
|
+
The floor is a calibration threshold; it will move only on replay evidence,
|
|
220
|
+
not by guess.
|
|
221
|
+
- **Keys already spread across many IPs are under-protected.** The rule is
|
|
222
|
+
relative to each key's own history, so a key that normally lives on 200 IPs
|
|
223
|
+
needs a far larger jump to trip. Conservative by design; you should know it.
|
|
224
|
+
- **A leak that ramps slowly enough is never caught.** The baseline tracks it
|
|
225
|
+
upward and nothing fires. Detecting that needs a long-horizon reference we
|
|
226
|
+
don't yet have.
|
|
227
|
+
- **Concentrated datacenter scrapers don't page.** A scraper rotating inside
|
|
228
|
+
one provider's range looks concentrated, and the rule requires dispersion.
|
|
229
|
+
Deliberate: a missed scraper is cheaper than a false page.
|
|
230
|
+
- **A serverless migration can page you.** If you move a key from a few
|
|
231
|
+
servers to many small workers (a serverless or autoscaling rollout) at
|
|
232
|
+
roughly constant traffic, and those workers are spread across many network
|
|
233
|
+
blocks — which cloud egress usually is — that has the same shape as a
|
|
234
|
+
leaked key being used from many places, and the rule can fire. It is the one
|
|
235
|
+
false alarm the design cannot rule out from the data it holds: it cannot
|
|
236
|
+
tell your cloud's address ranges apart from a stranger's. If it happens,
|
|
237
|
+
acknowledge the alert (it offers a "this key is legitimately distributed"
|
|
238
|
+
link) and it stops. Tell us if you have a rollout planned and we can quiet
|
|
239
|
+
the key ahead of time.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "cerberus-keys"
|
|
3
|
+
# DERIVED, never restated: see src/cerberus_keys/version.py. A version
|
|
4
|
+
# written in two files is a version that will be edited in one.
|
|
5
|
+
dynamic = ["version"]
|
|
6
|
+
description = "Client library for Cerberus — per-key API abuse detection"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
requires-python = ">=3.11"
|
|
9
|
+
dependencies = [] # zero runtime dependencies, deliberately
|
|
10
|
+
|
|
11
|
+
[project.optional-dependencies]
|
|
12
|
+
# `build` and `twine` are the release path, declared so it is
|
|
13
|
+
# reproducible rather than remembered. See docs/RUNBOOK-sdk-release.md.
|
|
14
|
+
dev = ["pytest>=8.0", "build>=1.0", "twine>=5.0"]
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["setuptools>=68"]
|
|
18
|
+
build-backend = "setuptools.build_meta"
|
|
19
|
+
|
|
20
|
+
[tool.setuptools.dynamic]
|
|
21
|
+
version = {attr = "cerberus_keys.version.__version__"}
|
|
22
|
+
|
|
23
|
+
[tool.setuptools.packages.find]
|
|
24
|
+
where = ["src"]
|
|
25
|
+
|
|
26
|
+
# py.typed must reach the wheel, or every customer's type checker silently
|
|
27
|
+
# treats this package as untyped -- no error anywhere, just no checking.
|
|
28
|
+
#
|
|
29
|
+
# MEASURED REDUNDANT with the setuptools in use: removing this stanza and
|
|
30
|
+
# building from a clean tree still ships the file. It is kept as an explicit
|
|
31
|
+
# declaration rather than depending on that behaviour continuing, but it is
|
|
32
|
+
# NOT what guarantees the outcome -- tests/test_python_package.py is, by
|
|
33
|
+
# building a wheel and looking inside it. The server's pricing.json had a
|
|
34
|
+
# correct-looking package-data declaration and shipped nothing, which is the
|
|
35
|
+
# reason nothing here is trusted on the strength of reading it.
|
|
36
|
+
[tool.setuptools.package-data]
|
|
37
|
+
cerberus_keys = ["py.typed"]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from .client import Cerberus
|
|
2
|
+
from .version import CLIENT_ID, __version__
|
|
3
|
+
from .fingerprint import (
|
|
4
|
+
IpFingerprints, client_ip_from_forwarded_for, fingerprint, ip_fingerprints,
|
|
5
|
+
resolve,
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
__all__ = ["Cerberus", "fingerprint", "ip_fingerprints", "IpFingerprints",
|
|
9
|
+
"client_ip_from_forwarded_for", "resolve", "CLIENT_ID",
|
|
10
|
+
"__version__"]
|