sentinelsup 0.2.3__tar.gz → 0.2.5__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.
- sentinelsup-0.2.5/.github/workflows/ci.yml +31 -0
- {sentinelsup-0.2.3 → sentinelsup-0.2.5}/PKG-INFO +93 -32
- {sentinelsup-0.2.3 → sentinelsup-0.2.5}/README.md +91 -30
- {sentinelsup-0.2.3 → sentinelsup-0.2.5}/examples/django_middleware.py +14 -5
- {sentinelsup-0.2.3 → sentinelsup-0.2.5}/examples/flask_signup_guard.py +7 -5
- {sentinelsup-0.2.3 → sentinelsup-0.2.5}/pyproject.toml +2 -2
- {sentinelsup-0.2.3 → sentinelsup-0.2.5}/sentinel/__init__.py +33 -10
- sentinelsup-0.2.5/tests/test_client.py +165 -0
- {sentinelsup-0.2.3 → sentinelsup-0.2.5}/.gitignore +0 -0
- {sentinelsup-0.2.3 → sentinelsup-0.2.5}/LICENSE +0 -0
- {sentinelsup-0.2.3 → sentinelsup-0.2.5}/sentinel/py.typed +0 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
name: Python SDK
|
|
2
|
+
on: [push, pull_request]
|
|
3
|
+
|
|
4
|
+
permissions:
|
|
5
|
+
contents: read
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
test:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
timeout-minutes: 10
|
|
11
|
+
strategy:
|
|
12
|
+
fail-fast: false
|
|
13
|
+
matrix:
|
|
14
|
+
python: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14']
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v7
|
|
17
|
+
with:
|
|
18
|
+
persist-credentials: false
|
|
19
|
+
- uses: actions/setup-python@v7
|
|
20
|
+
with:
|
|
21
|
+
python-version: ${{ matrix.python }}
|
|
22
|
+
- run: python -m unittest discover -s tests -v
|
|
23
|
+
- name: Build and check distributions
|
|
24
|
+
if: matrix.python == '3.14'
|
|
25
|
+
run: |
|
|
26
|
+
python -m pip install build twine
|
|
27
|
+
python -m build
|
|
28
|
+
python -m twine check dist/*
|
|
29
|
+
python -m pip install --no-deps dist/*.whl
|
|
30
|
+
cd "$RUNNER_TEMP"
|
|
31
|
+
python -c "from sentinel import Sentinel, SentinelError; assert Sentinel('sk_test_fixture').api_key == 'sk_test_fixture'"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sentinelsup
|
|
3
|
-
Version: 0.2.
|
|
4
|
-
Summary: Maskbreak
|
|
3
|
+
Version: 0.2.5
|
|
4
|
+
Summary: Official Maskbreak SDK for browser-backed fraud checks and cloud/Tor IP lookups.
|
|
5
5
|
Project-URL: Homepage, https://maskbreak.com
|
|
6
6
|
Project-URL: Documentation, https://maskbreak.com/api
|
|
7
7
|
Project-URL: Repository, https://github.com/sentinelsup/maskbreak-python
|
|
@@ -29,7 +29,11 @@ Description-Content-Type: text/markdown
|
|
|
29
29
|
|
|
30
30
|
# sentinelsup — Maskbreak Python SDK
|
|
31
31
|
|
|
32
|
-
Official Python SDK for [Maskbreak](https://maskbreak.com)
|
|
32
|
+
Official Python SDK for [Maskbreak](https://maskbreak.com). Evaluate SDK-backed
|
|
33
|
+
visits for network and browser risk signals, or look up a public IP for cloud
|
|
34
|
+
range and Tor signals. These are different evidence sources, not interchangeable
|
|
35
|
+
checks. VPN/proxy service names are returned when known; a VPN alone routes to
|
|
36
|
+
review under the default policy, not automatic blocking.
|
|
33
37
|
|
|
34
38
|
[](https://pypi.org/project/sentinelsup/)
|
|
35
39
|
[](https://pypi.org/project/sentinelsup/)
|
|
@@ -37,16 +41,16 @@ Official Python SDK for [Maskbreak](https://maskbreak.com) — a real-time fraud
|
|
|
37
41
|
|
|
38
42
|
Zero dependencies — just the standard library. Works with Flask, Django, FastAPI, or bare `urllib`.
|
|
39
43
|
|
|
40
|
-
## Set up with AI
|
|
44
|
+
## Set up with an AI assistant
|
|
41
45
|
|
|
42
46
|
Using Claude Code, Cursor, Copilot, or any AI coding assistant? Paste this one
|
|
43
47
|
prompt and it wires the whole integration — frontend script, backend check,
|
|
44
48
|
env var, and a test:
|
|
45
49
|
|
|
46
50
|
> Fetch https://maskbreak.com/integrate.md and follow it to add Maskbreak fraud
|
|
47
|
-
> protection to this app — protect signup, login, and checkout.
|
|
48
|
-
>
|
|
49
|
-
> client-side code.
|
|
51
|
+
> protection to this app — protect signup, login, and checkout. Read the API key
|
|
52
|
+
> from the server-only MASKBREAK_API_KEY environment variable; I will configure the
|
|
53
|
+
> secret separately. Never put it in client-side code. Show me how to test it.
|
|
50
54
|
|
|
51
55
|
[`integrate.md`](https://maskbreak.com/integrate.md) is the canonical
|
|
52
56
|
machine-readable integration guide, kept in sync with the live API.
|
|
@@ -65,9 +69,12 @@ Python 3.8+. Get a free API key (no credit card) at [maskbreak.com/signup](https
|
|
|
65
69
|
import os
|
|
66
70
|
from sentinel import Sentinel
|
|
67
71
|
|
|
68
|
-
s = Sentinel(api_key=os.environ["
|
|
72
|
+
s = Sentinel(api_key=os.environ["MASKBREAK_API_KEY"]) # or omit — reads the env var itself
|
|
69
73
|
|
|
70
|
-
result = s.evaluate(
|
|
74
|
+
result = s.evaluate(
|
|
75
|
+
token=request.json["sentinelToken"],
|
|
76
|
+
fingerprint_event_id=request.json.get("fingerprintEventId"),
|
|
77
|
+
)
|
|
71
78
|
|
|
72
79
|
if result.is_blocked: # decision == 'block'
|
|
73
80
|
abort(403)
|
|
@@ -78,6 +85,14 @@ print(result.network) # {'vpn': True, 'proxy': False, 'datacenter': True
|
|
|
78
85
|
print(result.reasons) # ['vpn_detected', 'datacenter_asn', ...]
|
|
79
86
|
```
|
|
80
87
|
|
|
88
|
+
This is a handler fragment, not a complete signup implementation. Route `review`
|
|
89
|
+
to your verification/review flow; only `allow` is an approval. Keep API keys on
|
|
90
|
+
the server. An unavailable device layer or `raw["degraded"]` is not proof of a
|
|
91
|
+
clean visit; `degraded` describes the network layer only.
|
|
92
|
+
|
|
93
|
+
Since v0.2.5, `Sentinel()` without a key reads `MASKBREAK_API_KEY`; the older
|
|
94
|
+
`SENTINEL_KEY` and `SENTINEL_API_KEY` names are still read as fallbacks.
|
|
95
|
+
|
|
81
96
|
Check the signup email against the disposable-domain feed (checked
|
|
82
97
|
transiently, never stored), or look up an arbitrary IP with no browser
|
|
83
98
|
token at all:
|
|
@@ -94,7 +109,8 @@ print(info["signals"]) # {'vpn': ..., 'proxied': ..., 'tor': ..., '
|
|
|
94
109
|
|
|
95
110
|
## What you get back
|
|
96
111
|
|
|
97
|
-
`evaluate()` returns an `EvaluateResult` dataclass
|
|
112
|
+
`evaluate()` returns an `EvaluateResult` dataclass. The type sketch below uses
|
|
113
|
+
Python 3.10+ annotation syntax for readability; the package minimum stays 3.8:
|
|
98
114
|
|
|
99
115
|
```python
|
|
100
116
|
@dataclass
|
|
@@ -112,7 +128,7 @@ class EvaluateResult:
|
|
|
112
128
|
test: bool # True for test-token / test-key calls
|
|
113
129
|
raw: dict # full upstream response
|
|
114
130
|
|
|
115
|
-
is_suspicious: bool # True
|
|
131
|
+
is_suspicious: bool # True for a non-null decision other than 'allow'
|
|
116
132
|
is_blocked: bool # True if decision == 'block'
|
|
117
133
|
```
|
|
118
134
|
|
|
@@ -142,7 +158,7 @@ network (VPN/proxy/datacenter) and device (antidetect/bot/tampering):
|
|
|
142
158
|
|
|
143
159
|
Forward both fields to your backend with the form submission and pass them to
|
|
144
160
|
`evaluate()` as `token` and `fingerprint_event_id` — without the second one,
|
|
145
|
-
the device-layer signals (antidetect, automation, emulator)
|
|
161
|
+
the device-layer signals (antidetect, automation, emulator) are unavailable. For
|
|
146
162
|
fetch/XHR submissions, collect them explicitly:
|
|
147
163
|
|
|
148
164
|
```js
|
|
@@ -151,20 +167,21 @@ const { token, fingerprintEventId } = await window.Sentinel.collect();
|
|
|
151
167
|
|
|
152
168
|
## Examples
|
|
153
169
|
|
|
154
|
-
### Flask —
|
|
170
|
+
### Flask — route signup decisions
|
|
155
171
|
|
|
156
172
|
```python
|
|
157
173
|
from flask import Flask, request, abort, jsonify
|
|
158
174
|
from sentinel import Sentinel, SentinelError
|
|
159
175
|
|
|
160
176
|
app = Flask(__name__)
|
|
161
|
-
sentinel = Sentinel() # reads
|
|
177
|
+
sentinel = Sentinel() # reads MASKBREAK_API_KEY from env
|
|
162
178
|
|
|
163
179
|
@app.route("/signup", methods=["POST"])
|
|
164
180
|
def signup():
|
|
165
181
|
data = request.get_json()
|
|
166
182
|
try:
|
|
167
|
-
result = sentinel.evaluate(token=data["sentinelToken"]
|
|
183
|
+
result = sentinel.evaluate(token=data["sentinelToken"],
|
|
184
|
+
fingerprint_event_id=data.get("fingerprintEventId"))
|
|
168
185
|
except SentinelError as e:
|
|
169
186
|
# Fail open OR fail closed — your call. Logged either way.
|
|
170
187
|
app.logger.warning("Sentinel error: %s", e)
|
|
@@ -173,6 +190,11 @@ def signup():
|
|
|
173
190
|
if result and result.is_blocked:
|
|
174
191
|
abort(403, "Signup blocked")
|
|
175
192
|
|
|
193
|
+
if result and result.decision == "review":
|
|
194
|
+
return jsonify({"needs_verification": True}), 202
|
|
195
|
+
|
|
196
|
+
# This example explicitly fails open on SDK errors. Choose an endpoint-
|
|
197
|
+
# specific fallback; do not reuse this policy for transfers or withdrawals.
|
|
176
198
|
# ... your normal signup flow
|
|
177
199
|
return jsonify({"ok": True})
|
|
178
200
|
```
|
|
@@ -181,9 +203,9 @@ def signup():
|
|
|
181
203
|
|
|
182
204
|
```python
|
|
183
205
|
from django.http import JsonResponse
|
|
184
|
-
from sentinel import Sentinel
|
|
206
|
+
from sentinel import Sentinel, SentinelError
|
|
185
207
|
|
|
186
|
-
sentinel = Sentinel() # reads
|
|
208
|
+
sentinel = Sentinel() # reads MASKBREAK_API_KEY from env
|
|
187
209
|
|
|
188
210
|
class FraudCheckMiddleware:
|
|
189
211
|
def __init__(self, get_response):
|
|
@@ -192,13 +214,17 @@ class FraudCheckMiddleware:
|
|
|
192
214
|
def __call__(self, request):
|
|
193
215
|
if request.path.startswith("/api/checkout"):
|
|
194
216
|
token = request.META.get("HTTP_X_SENTINEL_TOKEN")
|
|
195
|
-
if token:
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
217
|
+
if not token:
|
|
218
|
+
return JsonResponse({"error": "verification required"}, status=400)
|
|
219
|
+
try:
|
|
220
|
+
result = sentinel.evaluate(token=token,
|
|
221
|
+
fingerprint_event_id=request.META.get("HTTP_X_SENTINEL_FINGERPRINT_EVENT_ID"))
|
|
222
|
+
except SentinelError:
|
|
223
|
+
return JsonResponse({"error": "verification unavailable"}, status=503)
|
|
224
|
+
if result.is_blocked:
|
|
225
|
+
return JsonResponse({"error": "blocked"}, status=403)
|
|
226
|
+
if result.decision == "review":
|
|
227
|
+
return JsonResponse({"error": "additional verification required"}, status=409)
|
|
202
228
|
return self.get_response(request)
|
|
203
229
|
```
|
|
204
230
|
|
|
@@ -210,7 +236,7 @@ Runnable versions live in [`examples/`](./examples/).
|
|
|
210
236
|
|
|
211
237
|
| Option | Default | Description |
|
|
212
238
|
|--------|---------|-------------|
|
|
213
|
-
| `api_key` | `$
|
|
239
|
+
| `api_key` | `$MASKBREAK_API_KEY` (falls back to `$SENTINEL_KEY`, then `$SENTINEL_API_KEY`) | Your key starting with `sk_live_` |
|
|
214
240
|
| `endpoint` | `https://maskbreak.com` | Override base URL (for testing) |
|
|
215
241
|
| `timeout` | `5.0` | Per-request timeout in seconds |
|
|
216
242
|
|
|
@@ -222,31 +248,66 @@ Returns `EvaluateResult`. Raises `SentinelError` on network/API failure.
|
|
|
222
248
|
- `account_id` — your own user id for this session; enables multi-accounting detection (`device.linked_accounts` / `device.multi_account`).
|
|
223
249
|
- `email` — adds `email.disposable` to the raw response; burner domains escalate `allow` to `review`.
|
|
224
250
|
|
|
251
|
+
This synchronous SDK forwards only these named inputs. It does not expose a
|
|
252
|
+
timezone input, automatic retries, a circuit breaker, or every REST endpoint.
|
|
253
|
+
Device availability and `degraded` remain accessible through `raw`; missing
|
|
254
|
+
device evidence must not be treated as a clean device result. Account linking
|
|
255
|
+
(`linked_accounts`) is customer-scoped; device `first_seen`/`times_seen` history
|
|
256
|
+
is not customer-scoped.
|
|
257
|
+
|
|
225
258
|
### `sentinel.lookup(ip)`
|
|
226
259
|
|
|
227
260
|
Returns the raw response dict for any public IPv4/IPv6 address (wraps `GET /v1/lookup/{ip}`): `verdict` (`allow`/`review`/`block`), `risk_score` (0–100), `known`, `signals` (`{vpn, proxied, tor, dch, anon}` or `None`), `network` (`{asn, org, country, city}`), `latency_ms`. Shares the per-key hourly quota with `evaluate()`. `known: False` means our feeds hold no data — it is **not** a clean guarantee.
|
|
228
261
|
|
|
262
|
+
Production bare-IP lookup checks cloud ranges and Tor exits, not live-visit
|
|
263
|
+
VPN/proxy evidence. Legacy `vpn`/`proxied` keys in the shape do not imply those
|
|
264
|
+
checks ran. Use `evaluate()` with browser evidence for VPN/proxy checks. When
|
|
265
|
+
obtaining an IP behind a proxy, trust forwarded headers only from configured
|
|
266
|
+
trusted proxies; never blindly take the first client-supplied value.
|
|
267
|
+
|
|
229
268
|
## Testing
|
|
230
269
|
|
|
231
|
-
Deterministic
|
|
270
|
+
Deterministic `test_*` tokens exercise response handling, not detection quality.
|
|
271
|
+
SDK fixture calls use authentication and quota but do not increment billable
|
|
272
|
+
usage or trigger webhooks; console-originated live-key fixtures can be stored
|
|
273
|
+
as test events. Personal rules and exception pins can change fixture decisions.
|
|
232
274
|
|
|
233
275
|
```python
|
|
234
276
|
result = s.evaluate(token="test_vpn") # also: test_clean, test_proxy, test_datacenter, test_tor
|
|
235
|
-
assert result.decision == "review"
|
|
277
|
+
assert result.decision == "review" # default policy, no overriding rules/pins
|
|
236
278
|
assert result.test
|
|
237
279
|
```
|
|
238
280
|
|
|
239
281
|
No account yet? The public sandbox key accepts the same test tokens:
|
|
240
282
|
|
|
241
283
|
```python
|
|
242
|
-
s = Sentinel(api_key="sk_test_sandbox") #
|
|
284
|
+
s = Sentinel(api_key="sk_test_sandbox") # deterministic fixtures only, no live detection
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
The public sandbox is separately rate-limited, accepts only supported fixture
|
|
288
|
+
tokens, and does not store events or run live detection. It is not a production
|
|
289
|
+
allowance. A personal `sk_test_...` key runs the live pipeline with real browser
|
|
290
|
+
evidence and your policy; resulting events can be stored with `is_test` set,
|
|
291
|
+
without incrementing usage or firing webhooks. Test keys still have rate limits.
|
|
292
|
+
|
|
293
|
+
Local checks require no API credentials:
|
|
294
|
+
|
|
295
|
+
```bash
|
|
296
|
+
python -m unittest discover -s tests -v
|
|
297
|
+
python -m pip install build twine
|
|
298
|
+
python -m build
|
|
299
|
+
python -m twine check dist/*
|
|
243
300
|
```
|
|
244
301
|
|
|
245
|
-
|
|
302
|
+
The CI matrix targets Python 3.8–3.14 without raising the 3.8 minimum. A configured
|
|
303
|
+
matrix is not a claim that every interpreter was tested locally; inspect its run.
|
|
246
304
|
|
|
247
305
|
## Errors
|
|
248
306
|
|
|
249
|
-
|
|
307
|
+
Transport/API failures and unusable success responses raise `SentinelError`.
|
|
308
|
+
The exception carries `.status` (HTTP code) and `.body` (parsed error body) when
|
|
309
|
+
available. Redirects are rejected to avoid forwarding credentials. Configure the
|
|
310
|
+
final API base URL; the client does not retry automatically.
|
|
250
311
|
|
|
251
312
|
```python
|
|
252
313
|
from sentinel import Sentinel, SentinelError
|
|
@@ -259,13 +320,13 @@ except SentinelError as e:
|
|
|
259
320
|
elif e.status and 400 <= e.status < 500:
|
|
260
321
|
pass # bad input, won't recover by retrying
|
|
261
322
|
else:
|
|
262
|
-
pass #
|
|
323
|
+
pass # unknown outcome — use the endpoint's explicit fallback policy
|
|
263
324
|
```
|
|
264
325
|
|
|
265
326
|
## Rate limits
|
|
266
327
|
|
|
267
328
|
Free tier: **1,000 requests/hour** per API key. No monthly cap, no credit
|
|
268
|
-
card.
|
|
329
|
+
card.
|
|
269
330
|
|
|
270
331
|
## What Maskbreak detects
|
|
271
332
|
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# sentinelsup — Maskbreak Python SDK
|
|
2
2
|
|
|
3
|
-
Official Python SDK for [Maskbreak](https://maskbreak.com)
|
|
3
|
+
Official Python SDK for [Maskbreak](https://maskbreak.com). Evaluate SDK-backed
|
|
4
|
+
visits for network and browser risk signals, or look up a public IP for cloud
|
|
5
|
+
range and Tor signals. These are different evidence sources, not interchangeable
|
|
6
|
+
checks. VPN/proxy service names are returned when known; a VPN alone routes to
|
|
7
|
+
review under the default policy, not automatic blocking.
|
|
4
8
|
|
|
5
9
|
[](https://pypi.org/project/sentinelsup/)
|
|
6
10
|
[](https://pypi.org/project/sentinelsup/)
|
|
@@ -8,16 +12,16 @@ Official Python SDK for [Maskbreak](https://maskbreak.com) — a real-time fraud
|
|
|
8
12
|
|
|
9
13
|
Zero dependencies — just the standard library. Works with Flask, Django, FastAPI, or bare `urllib`.
|
|
10
14
|
|
|
11
|
-
## Set up with AI
|
|
15
|
+
## Set up with an AI assistant
|
|
12
16
|
|
|
13
17
|
Using Claude Code, Cursor, Copilot, or any AI coding assistant? Paste this one
|
|
14
18
|
prompt and it wires the whole integration — frontend script, backend check,
|
|
15
19
|
env var, and a test:
|
|
16
20
|
|
|
17
21
|
> Fetch https://maskbreak.com/integrate.md and follow it to add Maskbreak fraud
|
|
18
|
-
> protection to this app — protect signup, login, and checkout.
|
|
19
|
-
>
|
|
20
|
-
> client-side code.
|
|
22
|
+
> protection to this app — protect signup, login, and checkout. Read the API key
|
|
23
|
+
> from the server-only MASKBREAK_API_KEY environment variable; I will configure the
|
|
24
|
+
> secret separately. Never put it in client-side code. Show me how to test it.
|
|
21
25
|
|
|
22
26
|
[`integrate.md`](https://maskbreak.com/integrate.md) is the canonical
|
|
23
27
|
machine-readable integration guide, kept in sync with the live API.
|
|
@@ -36,9 +40,12 @@ Python 3.8+. Get a free API key (no credit card) at [maskbreak.com/signup](https
|
|
|
36
40
|
import os
|
|
37
41
|
from sentinel import Sentinel
|
|
38
42
|
|
|
39
|
-
s = Sentinel(api_key=os.environ["
|
|
43
|
+
s = Sentinel(api_key=os.environ["MASKBREAK_API_KEY"]) # or omit — reads the env var itself
|
|
40
44
|
|
|
41
|
-
result = s.evaluate(
|
|
45
|
+
result = s.evaluate(
|
|
46
|
+
token=request.json["sentinelToken"],
|
|
47
|
+
fingerprint_event_id=request.json.get("fingerprintEventId"),
|
|
48
|
+
)
|
|
42
49
|
|
|
43
50
|
if result.is_blocked: # decision == 'block'
|
|
44
51
|
abort(403)
|
|
@@ -49,6 +56,14 @@ print(result.network) # {'vpn': True, 'proxy': False, 'datacenter': True
|
|
|
49
56
|
print(result.reasons) # ['vpn_detected', 'datacenter_asn', ...]
|
|
50
57
|
```
|
|
51
58
|
|
|
59
|
+
This is a handler fragment, not a complete signup implementation. Route `review`
|
|
60
|
+
to your verification/review flow; only `allow` is an approval. Keep API keys on
|
|
61
|
+
the server. An unavailable device layer or `raw["degraded"]` is not proof of a
|
|
62
|
+
clean visit; `degraded` describes the network layer only.
|
|
63
|
+
|
|
64
|
+
Since v0.2.5, `Sentinel()` without a key reads `MASKBREAK_API_KEY`; the older
|
|
65
|
+
`SENTINEL_KEY` and `SENTINEL_API_KEY` names are still read as fallbacks.
|
|
66
|
+
|
|
52
67
|
Check the signup email against the disposable-domain feed (checked
|
|
53
68
|
transiently, never stored), or look up an arbitrary IP with no browser
|
|
54
69
|
token at all:
|
|
@@ -65,7 +80,8 @@ print(info["signals"]) # {'vpn': ..., 'proxied': ..., 'tor': ..., '
|
|
|
65
80
|
|
|
66
81
|
## What you get back
|
|
67
82
|
|
|
68
|
-
`evaluate()` returns an `EvaluateResult` dataclass
|
|
83
|
+
`evaluate()` returns an `EvaluateResult` dataclass. The type sketch below uses
|
|
84
|
+
Python 3.10+ annotation syntax for readability; the package minimum stays 3.8:
|
|
69
85
|
|
|
70
86
|
```python
|
|
71
87
|
@dataclass
|
|
@@ -83,7 +99,7 @@ class EvaluateResult:
|
|
|
83
99
|
test: bool # True for test-token / test-key calls
|
|
84
100
|
raw: dict # full upstream response
|
|
85
101
|
|
|
86
|
-
is_suspicious: bool # True
|
|
102
|
+
is_suspicious: bool # True for a non-null decision other than 'allow'
|
|
87
103
|
is_blocked: bool # True if decision == 'block'
|
|
88
104
|
```
|
|
89
105
|
|
|
@@ -113,7 +129,7 @@ network (VPN/proxy/datacenter) and device (antidetect/bot/tampering):
|
|
|
113
129
|
|
|
114
130
|
Forward both fields to your backend with the form submission and pass them to
|
|
115
131
|
`evaluate()` as `token` and `fingerprint_event_id` — without the second one,
|
|
116
|
-
the device-layer signals (antidetect, automation, emulator)
|
|
132
|
+
the device-layer signals (antidetect, automation, emulator) are unavailable. For
|
|
117
133
|
fetch/XHR submissions, collect them explicitly:
|
|
118
134
|
|
|
119
135
|
```js
|
|
@@ -122,20 +138,21 @@ const { token, fingerprintEventId } = await window.Sentinel.collect();
|
|
|
122
138
|
|
|
123
139
|
## Examples
|
|
124
140
|
|
|
125
|
-
### Flask —
|
|
141
|
+
### Flask — route signup decisions
|
|
126
142
|
|
|
127
143
|
```python
|
|
128
144
|
from flask import Flask, request, abort, jsonify
|
|
129
145
|
from sentinel import Sentinel, SentinelError
|
|
130
146
|
|
|
131
147
|
app = Flask(__name__)
|
|
132
|
-
sentinel = Sentinel() # reads
|
|
148
|
+
sentinel = Sentinel() # reads MASKBREAK_API_KEY from env
|
|
133
149
|
|
|
134
150
|
@app.route("/signup", methods=["POST"])
|
|
135
151
|
def signup():
|
|
136
152
|
data = request.get_json()
|
|
137
153
|
try:
|
|
138
|
-
result = sentinel.evaluate(token=data["sentinelToken"]
|
|
154
|
+
result = sentinel.evaluate(token=data["sentinelToken"],
|
|
155
|
+
fingerprint_event_id=data.get("fingerprintEventId"))
|
|
139
156
|
except SentinelError as e:
|
|
140
157
|
# Fail open OR fail closed — your call. Logged either way.
|
|
141
158
|
app.logger.warning("Sentinel error: %s", e)
|
|
@@ -144,6 +161,11 @@ def signup():
|
|
|
144
161
|
if result and result.is_blocked:
|
|
145
162
|
abort(403, "Signup blocked")
|
|
146
163
|
|
|
164
|
+
if result and result.decision == "review":
|
|
165
|
+
return jsonify({"needs_verification": True}), 202
|
|
166
|
+
|
|
167
|
+
# This example explicitly fails open on SDK errors. Choose an endpoint-
|
|
168
|
+
# specific fallback; do not reuse this policy for transfers or withdrawals.
|
|
147
169
|
# ... your normal signup flow
|
|
148
170
|
return jsonify({"ok": True})
|
|
149
171
|
```
|
|
@@ -152,9 +174,9 @@ def signup():
|
|
|
152
174
|
|
|
153
175
|
```python
|
|
154
176
|
from django.http import JsonResponse
|
|
155
|
-
from sentinel import Sentinel
|
|
177
|
+
from sentinel import Sentinel, SentinelError
|
|
156
178
|
|
|
157
|
-
sentinel = Sentinel() # reads
|
|
179
|
+
sentinel = Sentinel() # reads MASKBREAK_API_KEY from env
|
|
158
180
|
|
|
159
181
|
class FraudCheckMiddleware:
|
|
160
182
|
def __init__(self, get_response):
|
|
@@ -163,13 +185,17 @@ class FraudCheckMiddleware:
|
|
|
163
185
|
def __call__(self, request):
|
|
164
186
|
if request.path.startswith("/api/checkout"):
|
|
165
187
|
token = request.META.get("HTTP_X_SENTINEL_TOKEN")
|
|
166
|
-
if token:
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
188
|
+
if not token:
|
|
189
|
+
return JsonResponse({"error": "verification required"}, status=400)
|
|
190
|
+
try:
|
|
191
|
+
result = sentinel.evaluate(token=token,
|
|
192
|
+
fingerprint_event_id=request.META.get("HTTP_X_SENTINEL_FINGERPRINT_EVENT_ID"))
|
|
193
|
+
except SentinelError:
|
|
194
|
+
return JsonResponse({"error": "verification unavailable"}, status=503)
|
|
195
|
+
if result.is_blocked:
|
|
196
|
+
return JsonResponse({"error": "blocked"}, status=403)
|
|
197
|
+
if result.decision == "review":
|
|
198
|
+
return JsonResponse({"error": "additional verification required"}, status=409)
|
|
173
199
|
return self.get_response(request)
|
|
174
200
|
```
|
|
175
201
|
|
|
@@ -181,7 +207,7 @@ Runnable versions live in [`examples/`](./examples/).
|
|
|
181
207
|
|
|
182
208
|
| Option | Default | Description |
|
|
183
209
|
|--------|---------|-------------|
|
|
184
|
-
| `api_key` | `$
|
|
210
|
+
| `api_key` | `$MASKBREAK_API_KEY` (falls back to `$SENTINEL_KEY`, then `$SENTINEL_API_KEY`) | Your key starting with `sk_live_` |
|
|
185
211
|
| `endpoint` | `https://maskbreak.com` | Override base URL (for testing) |
|
|
186
212
|
| `timeout` | `5.0` | Per-request timeout in seconds |
|
|
187
213
|
|
|
@@ -193,31 +219,66 @@ Returns `EvaluateResult`. Raises `SentinelError` on network/API failure.
|
|
|
193
219
|
- `account_id` — your own user id for this session; enables multi-accounting detection (`device.linked_accounts` / `device.multi_account`).
|
|
194
220
|
- `email` — adds `email.disposable` to the raw response; burner domains escalate `allow` to `review`.
|
|
195
221
|
|
|
222
|
+
This synchronous SDK forwards only these named inputs. It does not expose a
|
|
223
|
+
timezone input, automatic retries, a circuit breaker, or every REST endpoint.
|
|
224
|
+
Device availability and `degraded` remain accessible through `raw`; missing
|
|
225
|
+
device evidence must not be treated as a clean device result. Account linking
|
|
226
|
+
(`linked_accounts`) is customer-scoped; device `first_seen`/`times_seen` history
|
|
227
|
+
is not customer-scoped.
|
|
228
|
+
|
|
196
229
|
### `sentinel.lookup(ip)`
|
|
197
230
|
|
|
198
231
|
Returns the raw response dict for any public IPv4/IPv6 address (wraps `GET /v1/lookup/{ip}`): `verdict` (`allow`/`review`/`block`), `risk_score` (0–100), `known`, `signals` (`{vpn, proxied, tor, dch, anon}` or `None`), `network` (`{asn, org, country, city}`), `latency_ms`. Shares the per-key hourly quota with `evaluate()`. `known: False` means our feeds hold no data — it is **not** a clean guarantee.
|
|
199
232
|
|
|
233
|
+
Production bare-IP lookup checks cloud ranges and Tor exits, not live-visit
|
|
234
|
+
VPN/proxy evidence. Legacy `vpn`/`proxied` keys in the shape do not imply those
|
|
235
|
+
checks ran. Use `evaluate()` with browser evidence for VPN/proxy checks. When
|
|
236
|
+
obtaining an IP behind a proxy, trust forwarded headers only from configured
|
|
237
|
+
trusted proxies; never blindly take the first client-supplied value.
|
|
238
|
+
|
|
200
239
|
## Testing
|
|
201
240
|
|
|
202
|
-
Deterministic
|
|
241
|
+
Deterministic `test_*` tokens exercise response handling, not detection quality.
|
|
242
|
+
SDK fixture calls use authentication and quota but do not increment billable
|
|
243
|
+
usage or trigger webhooks; console-originated live-key fixtures can be stored
|
|
244
|
+
as test events. Personal rules and exception pins can change fixture decisions.
|
|
203
245
|
|
|
204
246
|
```python
|
|
205
247
|
result = s.evaluate(token="test_vpn") # also: test_clean, test_proxy, test_datacenter, test_tor
|
|
206
|
-
assert result.decision == "review"
|
|
248
|
+
assert result.decision == "review" # default policy, no overriding rules/pins
|
|
207
249
|
assert result.test
|
|
208
250
|
```
|
|
209
251
|
|
|
210
252
|
No account yet? The public sandbox key accepts the same test tokens:
|
|
211
253
|
|
|
212
254
|
```python
|
|
213
|
-
s = Sentinel(api_key="sk_test_sandbox") #
|
|
255
|
+
s = Sentinel(api_key="sk_test_sandbox") # deterministic fixtures only, no live detection
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
The public sandbox is separately rate-limited, accepts only supported fixture
|
|
259
|
+
tokens, and does not store events or run live detection. It is not a production
|
|
260
|
+
allowance. A personal `sk_test_...` key runs the live pipeline with real browser
|
|
261
|
+
evidence and your policy; resulting events can be stored with `is_test` set,
|
|
262
|
+
without incrementing usage or firing webhooks. Test keys still have rate limits.
|
|
263
|
+
|
|
264
|
+
Local checks require no API credentials:
|
|
265
|
+
|
|
266
|
+
```bash
|
|
267
|
+
python -m unittest discover -s tests -v
|
|
268
|
+
python -m pip install build twine
|
|
269
|
+
python -m build
|
|
270
|
+
python -m twine check dist/*
|
|
214
271
|
```
|
|
215
272
|
|
|
216
|
-
|
|
273
|
+
The CI matrix targets Python 3.8–3.14 without raising the 3.8 minimum. A configured
|
|
274
|
+
matrix is not a claim that every interpreter was tested locally; inspect its run.
|
|
217
275
|
|
|
218
276
|
## Errors
|
|
219
277
|
|
|
220
|
-
|
|
278
|
+
Transport/API failures and unusable success responses raise `SentinelError`.
|
|
279
|
+
The exception carries `.status` (HTTP code) and `.body` (parsed error body) when
|
|
280
|
+
available. Redirects are rejected to avoid forwarding credentials. Configure the
|
|
281
|
+
final API base URL; the client does not retry automatically.
|
|
221
282
|
|
|
222
283
|
```python
|
|
223
284
|
from sentinel import Sentinel, SentinelError
|
|
@@ -230,13 +291,13 @@ except SentinelError as e:
|
|
|
230
291
|
elif e.status and 400 <= e.status < 500:
|
|
231
292
|
pass # bad input, won't recover by retrying
|
|
232
293
|
else:
|
|
233
|
-
pass #
|
|
294
|
+
pass # unknown outcome — use the endpoint's explicit fallback policy
|
|
234
295
|
```
|
|
235
296
|
|
|
236
297
|
## Rate limits
|
|
237
298
|
|
|
238
299
|
Free tier: **1,000 requests/hour** per API key. No monthly cap, no credit
|
|
239
|
-
card.
|
|
300
|
+
card.
|
|
240
301
|
|
|
241
302
|
## What Maskbreak detects
|
|
242
303
|
|
|
@@ -4,7 +4,11 @@ Add to settings.py MIDDLEWARE:
|
|
|
4
4
|
"yourapp.middleware.SentinelMiddleware",
|
|
5
5
|
|
|
6
6
|
Then ensure your frontend forwards the Sentinel token via the
|
|
7
|
-
X-Sentinel-Token header
|
|
7
|
+
X-Sentinel-Token header, and the device event ID via
|
|
8
|
+
X-Sentinel-Fingerprint-Event-Id. Only a live, non-degraded allow decision reaches
|
|
9
|
+
the guarded view; test/sample/sandbox results and review require a separate
|
|
10
|
+
verification flow. This is a middleware example,
|
|
11
|
+
not that verification flow's implementation.
|
|
8
12
|
"""
|
|
9
13
|
|
|
10
14
|
import logging
|
|
@@ -20,7 +24,7 @@ _GUARDED_PATHS = ("/api/checkout", "/api/withdraw", "/api/transfer")
|
|
|
20
24
|
class SentinelMiddleware:
|
|
21
25
|
def __init__(self, get_response):
|
|
22
26
|
self.get_response = get_response
|
|
23
|
-
self.sentinel = Sentinel() # reads
|
|
27
|
+
self.sentinel = Sentinel() # reads MASKBREAK_API_KEY, older names as fallback
|
|
24
28
|
|
|
25
29
|
def __call__(self, request):
|
|
26
30
|
if not request.path.startswith(_GUARDED_PATHS):
|
|
@@ -31,11 +35,12 @@ class SentinelMiddleware:
|
|
|
31
35
|
return JsonResponse({"error": "missing X-Sentinel-Token"}, status=400)
|
|
32
36
|
|
|
33
37
|
try:
|
|
34
|
-
result = self.sentinel.evaluate(token=token
|
|
38
|
+
result = self.sentinel.evaluate(token=token,
|
|
39
|
+
fingerprint_event_id=request.META.get("HTTP_X_SENTINEL_FINGERPRINT_EVENT_ID"))
|
|
35
40
|
except SentinelError as e:
|
|
36
41
|
log.warning("Sentinel error: %s", e)
|
|
37
|
-
#
|
|
38
|
-
return
|
|
42
|
+
# Unknown state must not authorize checkout, withdrawal or transfer.
|
|
43
|
+
return JsonResponse({"error": "verification unavailable"}, status=503)
|
|
39
44
|
|
|
40
45
|
if result.is_blocked:
|
|
41
46
|
return JsonResponse(
|
|
@@ -43,6 +48,10 @@ class SentinelMiddleware:
|
|
|
43
48
|
status=403,
|
|
44
49
|
)
|
|
45
50
|
|
|
51
|
+
if (result.decision != "allow" or result.test or
|
|
52
|
+
any(result.raw.get(flag) for flag in ("test", "sample", "sandbox", "degraded"))):
|
|
53
|
+
return JsonResponse({"error": "additional verification required"}, status=409)
|
|
54
|
+
|
|
46
55
|
# Stash on request so the view can read decision/score
|
|
47
56
|
request.sentinel = result
|
|
48
57
|
return self.get_response(request)
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
"""Flask example —
|
|
1
|
+
"""Flask example — route signup risk; a VPN alone means review, not block.
|
|
2
2
|
|
|
3
3
|
Run:
|
|
4
4
|
pip install flask sentinelsup
|
|
5
|
-
export
|
|
5
|
+
export MASKBREAK_API_KEY=sk_live_...
|
|
6
6
|
python flask_signup_guard.py
|
|
7
7
|
"""
|
|
8
8
|
|
|
@@ -12,7 +12,7 @@ from flask import Flask, abort, jsonify, request
|
|
|
12
12
|
from sentinel import Sentinel, SentinelError
|
|
13
13
|
|
|
14
14
|
app = Flask(__name__)
|
|
15
|
-
sentinel = Sentinel() # reads
|
|
15
|
+
sentinel = Sentinel() # reads MASKBREAK_API_KEY (older SENTINEL_KEY still works)
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
@app.route("/signup", methods=["POST"])
|
|
@@ -25,9 +25,11 @@ def signup() -> object:
|
|
|
25
25
|
abort(400, "missing email or sentinelToken")
|
|
26
26
|
|
|
27
27
|
try:
|
|
28
|
-
result = sentinel.evaluate(token=token
|
|
28
|
+
result = sentinel.evaluate(token=token,
|
|
29
|
+
fingerprint_event_id=payload.get("fingerprintEventId"),
|
|
30
|
+
email=email)
|
|
29
31
|
except SentinelError as e:
|
|
30
|
-
#
|
|
32
|
+
# Explicit fail-open demo policy; do not reuse for sensitive mutations.
|
|
31
33
|
app.logger.warning("Sentinel unavailable: %s", e)
|
|
32
34
|
result = None
|
|
33
35
|
|
|
@@ -8,8 +8,8 @@ build-backend = "hatchling.build"
|
|
|
8
8
|
# would install a stranger's code. "sentinelsup" matches the npm scope
|
|
9
9
|
# (@sentinelsup/sdk).
|
|
10
10
|
name = "sentinelsup"
|
|
11
|
-
version = "0.2.
|
|
12
|
-
description = "Maskbreak
|
|
11
|
+
version = "0.2.5"
|
|
12
|
+
description = "Official Maskbreak SDK for browser-backed fraud checks and cloud/Tor IP lookups."
|
|
13
13
|
readme = "README.md"
|
|
14
14
|
requires-python = ">=3.8"
|
|
15
15
|
license = { text = "MIT" }
|
|
@@ -4,7 +4,7 @@ fraud detection API at https://maskbreak.com/v1/evaluate.
|
|
|
4
4
|
|
|
5
5
|
Usage:
|
|
6
6
|
from sentinel import Sentinel
|
|
7
|
-
s = Sentinel() # reads
|
|
7
|
+
s = Sentinel() # reads MASKBREAK_API_KEY from the env
|
|
8
8
|
result = s.evaluate(token=request.json["sentinelToken"])
|
|
9
9
|
if result.is_blocked:
|
|
10
10
|
abort(403)
|
|
@@ -18,7 +18,14 @@ from urllib import error, parse, request
|
|
|
18
18
|
|
|
19
19
|
DEFAULT_ENDPOINT = "https://maskbreak.com"
|
|
20
20
|
DEFAULT_TIMEOUT = 5.0
|
|
21
|
-
__version__ = "0.2.
|
|
21
|
+
__version__ = "0.2.5"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _NoRedirect(request.HTTPRedirectHandler):
|
|
25
|
+
"""Do not forward API credentials or replay calls at a redirect target."""
|
|
26
|
+
|
|
27
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
28
|
+
return None
|
|
22
29
|
|
|
23
30
|
|
|
24
31
|
class SentinelError(Exception):
|
|
@@ -71,13 +78,20 @@ class Sentinel:
|
|
|
71
78
|
endpoint: str = DEFAULT_ENDPOINT,
|
|
72
79
|
timeout: float = DEFAULT_TIMEOUT,
|
|
73
80
|
) -> None:
|
|
74
|
-
#
|
|
75
|
-
#
|
|
76
|
-
|
|
81
|
+
# MASKBREAK_API_KEY is the name every doc surface uses. SENTINEL_KEY
|
|
82
|
+
# and SENTINEL_API_KEY are older names, still read so existing installs
|
|
83
|
+
# keep working; the first non-empty one wins.
|
|
84
|
+
api_key = (
|
|
85
|
+
api_key
|
|
86
|
+
or os.environ.get("MASKBREAK_API_KEY")
|
|
87
|
+
or os.environ.get("SENTINEL_KEY")
|
|
88
|
+
or os.environ.get("SENTINEL_API_KEY")
|
|
89
|
+
)
|
|
77
90
|
if not api_key or not isinstance(api_key, str):
|
|
78
91
|
raise SentinelError(
|
|
79
92
|
"Sentinel: api_key is required. "
|
|
80
|
-
"Pass it explicitly or set
|
|
93
|
+
"Pass it explicitly or set MASKBREAK_API_KEY "
|
|
94
|
+
"(the older SENTINEL_KEY still works). "
|
|
81
95
|
"Get one free at https://maskbreak.com/signup"
|
|
82
96
|
)
|
|
83
97
|
self.api_key = api_key
|
|
@@ -103,9 +117,16 @@ class Sentinel:
|
|
|
103
117
|
)
|
|
104
118
|
|
|
105
119
|
try:
|
|
106
|
-
with request.
|
|
107
|
-
|
|
108
|
-
|
|
120
|
+
with request.build_opener(_NoRedirect()).open(req, timeout=self.timeout) as resp:
|
|
121
|
+
try:
|
|
122
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
123
|
+
except (ValueError, UnicodeError):
|
|
124
|
+
raise SentinelError("Sentinel: invalid JSON response", status=resp.status) from None
|
|
125
|
+
if not isinstance(data, dict):
|
|
126
|
+
raise SentinelError("Sentinel: expected a JSON object", status=resp.status)
|
|
127
|
+
return data
|
|
128
|
+
except SentinelError:
|
|
129
|
+
raise
|
|
109
130
|
except error.HTTPError as e:
|
|
110
131
|
try:
|
|
111
132
|
err_body = json.loads(e.read().decode("utf-8"))
|
|
@@ -161,6 +182,8 @@ class Sentinel:
|
|
|
161
182
|
payload["email"] = email
|
|
162
183
|
|
|
163
184
|
data = self._request("/v1/evaluate", payload)
|
|
185
|
+
if data.get("decision") not in ("allow", "review", "block"):
|
|
186
|
+
raise SentinelError("Sentinel: invalid or missing evaluation decision", body=data)
|
|
164
187
|
|
|
165
188
|
return EvaluateResult(
|
|
166
189
|
decision=data.get("decision"),
|
|
@@ -198,7 +221,7 @@ class Sentinel:
|
|
|
198
221
|
Raises:
|
|
199
222
|
SentinelError: on network failure, timeout, or non-2xx response.
|
|
200
223
|
"""
|
|
201
|
-
if not ip or not
|
|
224
|
+
if not isinstance(ip, str) or not ip.strip():
|
|
202
225
|
raise SentinelError(
|
|
203
226
|
"Sentinel.lookup: ip (public IPv4 or IPv6 address) is required"
|
|
204
227
|
)
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Standard-library regression tests; all HTTP traffic stays on loopback."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import runpy
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
import unittest
|
|
9
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from types import ModuleType, SimpleNamespace
|
|
12
|
+
from unittest.mock import Mock, patch
|
|
13
|
+
|
|
14
|
+
from sentinel import EvaluateResult, Sentinel, SentinelError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FixtureHandler(BaseHTTPRequestHandler):
|
|
18
|
+
def log_message(self, *args):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
def do_GET(self):
|
|
22
|
+
body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
|
|
23
|
+
self.server.calls.append((self.command, self.path, dict(self.headers), body))
|
|
24
|
+
status, response, headers = self.server.reply
|
|
25
|
+
if self.path == "/redirect-target":
|
|
26
|
+
status, response, headers = 200, b'{"decision":"allow"}', {}
|
|
27
|
+
self.send_response(status)
|
|
28
|
+
for key, value in headers.items():
|
|
29
|
+
self.send_header(key, value)
|
|
30
|
+
self.send_header("Content-Length", str(len(response)))
|
|
31
|
+
self.end_headers()
|
|
32
|
+
self.wfile.write(response)
|
|
33
|
+
|
|
34
|
+
do_POST = do_GET
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ClientTests(unittest.TestCase):
|
|
38
|
+
@classmethod
|
|
39
|
+
def setUpClass(cls):
|
|
40
|
+
cls.server = HTTPServer(("127.0.0.1", 0), FixtureHandler)
|
|
41
|
+
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
|
|
42
|
+
cls.thread.start()
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def tearDownClass(cls):
|
|
46
|
+
cls.server.shutdown()
|
|
47
|
+
cls.server.server_close()
|
|
48
|
+
cls.thread.join()
|
|
49
|
+
|
|
50
|
+
def setUp(self):
|
|
51
|
+
self.server.calls = []
|
|
52
|
+
self.server.reply = (200, b'{"decision":"review","future_field":true}', {})
|
|
53
|
+
self.client = Sentinel("sk_test_fixture", "http://127.0.0.1:%d" % self.server.server_port)
|
|
54
|
+
|
|
55
|
+
def test_evaluate_forwards_fields_and_preserves_additive_response(self):
|
|
56
|
+
result = self.client.evaluate("fixture", "device-event", "account", "a@example.test")
|
|
57
|
+
method, path, headers, body = self.server.calls[0]
|
|
58
|
+
self.assertEqual((method, path), ("POST", "/v1/evaluate"))
|
|
59
|
+
self.assertEqual(headers["Authorization"], "Bearer sk_test_fixture")
|
|
60
|
+
self.assertEqual(json.loads(body), {"token": "fixture", "fingerprintEventId": "device-event",
|
|
61
|
+
"accountId": "account", "email": "a@example.test"})
|
|
62
|
+
self.assertEqual(result.decision, "review")
|
|
63
|
+
self.assertFalse(result.is_blocked)
|
|
64
|
+
self.assertTrue(result.is_suspicious)
|
|
65
|
+
self.assertTrue(result.raw["future_field"])
|
|
66
|
+
|
|
67
|
+
def test_lookup_trims_and_encodes_ipv6_without_body(self):
|
|
68
|
+
self.server.reply = (200, b'{"verdict":"allow","known":false}', {})
|
|
69
|
+
self.assertFalse(self.client.lookup(" 2001:db8::1 ")["known"])
|
|
70
|
+
method, path, _, body = self.server.calls[0]
|
|
71
|
+
self.assertEqual((method, path, body), ("GET", "/v1/lookup/2001%3Adb8%3A%3A1", b""))
|
|
72
|
+
|
|
73
|
+
def test_blank_lookup_and_missing_token_do_not_send(self):
|
|
74
|
+
with self.assertRaises(SentinelError):
|
|
75
|
+
self.client.lookup(" ")
|
|
76
|
+
with self.assertRaises(SentinelError):
|
|
77
|
+
self.client.evaluate("")
|
|
78
|
+
self.assertEqual(self.server.calls, [])
|
|
79
|
+
|
|
80
|
+
def test_invalid_success_bodies_raise_sdk_error(self):
|
|
81
|
+
for body in (b"", b"<html>gateway</html>", b"null", b"[]", b'"text"', b"42"):
|
|
82
|
+
with self.subTest(body=body):
|
|
83
|
+
self.server.reply = (200, body, {})
|
|
84
|
+
with self.assertRaises(SentinelError) as caught:
|
|
85
|
+
self.client.evaluate("fixture")
|
|
86
|
+
self.assertEqual(caught.exception.status, 200)
|
|
87
|
+
|
|
88
|
+
def test_missing_or_invalid_decisions_are_not_success(self):
|
|
89
|
+
for data in ({}, {"decision": None}, {"decision": "unexpected"}, {"decision": []}):
|
|
90
|
+
with self.subTest(data=data):
|
|
91
|
+
self.server.reply = (200, json.dumps(data).encode(), {})
|
|
92
|
+
with self.assertRaisesRegex(SentinelError, "evaluation decision"):
|
|
93
|
+
self.client.evaluate("fixture")
|
|
94
|
+
|
|
95
|
+
def test_api_errors_preserve_status_and_object_body(self):
|
|
96
|
+
self.server.reply = (429, b'{"error":"Try later"}', {})
|
|
97
|
+
with self.assertRaises(SentinelError) as caught:
|
|
98
|
+
self.client.evaluate("fixture")
|
|
99
|
+
self.assertEqual(caught.exception.status, 429)
|
|
100
|
+
self.assertEqual(caught.exception.body, {"error": "Try later"})
|
|
101
|
+
|
|
102
|
+
def test_redirect_does_not_forward_key_or_replay_request(self):
|
|
103
|
+
target = "http://localhost:%d/redirect-target" % self.server.server_port
|
|
104
|
+
self.server.reply = (302, b'{}', {"Location": target})
|
|
105
|
+
with self.assertRaises(SentinelError) as caught:
|
|
106
|
+
self.client.lookup("203.0.113.1")
|
|
107
|
+
self.assertEqual(caught.exception.status, 302)
|
|
108
|
+
self.assertEqual(len(self.server.calls), 1, "redirect target must not receive the API key")
|
|
109
|
+
|
|
110
|
+
def test_environment_key_precedence(self):
|
|
111
|
+
env = {"MASKBREAK_API_KEY": "current", "SENTINEL_KEY": "primary", "SENTINEL_API_KEY": "legacy"}
|
|
112
|
+
with patch.dict(os.environ, env):
|
|
113
|
+
self.assertEqual(Sentinel().api_key, "current")
|
|
114
|
+
self.assertEqual(Sentinel("explicit").api_key, "explicit")
|
|
115
|
+
os.environ.pop("MASKBREAK_API_KEY")
|
|
116
|
+
self.assertEqual(Sentinel().api_key, "primary")
|
|
117
|
+
os.environ.pop("SENTINEL_KEY")
|
|
118
|
+
self.assertEqual(Sentinel().api_key, "legacy")
|
|
119
|
+
|
|
120
|
+
def test_environment_key_new_name_alone(self):
|
|
121
|
+
with patch.dict(os.environ, {"MASKBREAK_API_KEY": "current"}):
|
|
122
|
+
for old in ("SENTINEL_KEY", "SENTINEL_API_KEY"):
|
|
123
|
+
os.environ.pop(old, None)
|
|
124
|
+
self.assertEqual(Sentinel().api_key, "current")
|
|
125
|
+
|
|
126
|
+
def test_missing_key_names_the_current_variable(self):
|
|
127
|
+
with patch.dict(os.environ, {}):
|
|
128
|
+
for name in ("MASKBREAK_API_KEY", "SENTINEL_KEY", "SENTINEL_API_KEY"):
|
|
129
|
+
os.environ.pop(name, None)
|
|
130
|
+
with self.assertRaises(SentinelError) as caught:
|
|
131
|
+
Sentinel()
|
|
132
|
+
self.assertIn("MASKBREAK_API_KEY", str(caught.exception))
|
|
133
|
+
|
|
134
|
+
def test_result_helpers_keep_review_distinct_from_block(self):
|
|
135
|
+
for decision in ("allow", "review", "block"):
|
|
136
|
+
result = EvaluateResult(decision=decision)
|
|
137
|
+
self.assertEqual(result.is_blocked, decision == "block")
|
|
138
|
+
self.assertEqual(result.is_suspicious, decision != "allow")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class DjangoGuardTests(unittest.TestCase):
|
|
142
|
+
def test_financial_guard_requires_live_complete_allow_before_view(self):
|
|
143
|
+
http = ModuleType("django.http")
|
|
144
|
+
http.JsonResponse = lambda body, status: SimpleNamespace(status_code=status)
|
|
145
|
+
with patch.dict(sys.modules, {"django": ModuleType("django"), "django.http": http}):
|
|
146
|
+
example = runpy.run_path(str(Path(__file__).resolve().parents[1] / "examples" / "django_middleware.py"))
|
|
147
|
+
for decision, flag, status in [("allow", None, 200), ("review", None, 409), ("block", None, 403)] + [
|
|
148
|
+
("allow", flag, 409) for flag in ("test", "sample", "sandbox", "degraded")
|
|
149
|
+
]:
|
|
150
|
+
with self.subTest(decision=decision, flag=flag):
|
|
151
|
+
view = Mock(return_value=SimpleNamespace(status_code=200))
|
|
152
|
+
with patch.dict(os.environ, {"MASKBREAK_API_KEY": "sk_test_fixture"}):
|
|
153
|
+
middleware = example["SentinelMiddleware"](view)
|
|
154
|
+
middleware.sentinel = Mock()
|
|
155
|
+
middleware.sentinel.evaluate.return_value = EvaluateResult(
|
|
156
|
+
decision=decision, test=flag == "test", raw={flag: True} if flag else {})
|
|
157
|
+
request = SimpleNamespace(path="/api/transfer", META={
|
|
158
|
+
"HTTP_X_SENTINEL_TOKEN": "fixture", "HTTP_X_SENTINEL_FINGERPRINT_EVENT_ID": "device-event"})
|
|
159
|
+
self.assertEqual(middleware(request).status_code, status)
|
|
160
|
+
self.assertEqual(view.call_count, 1 if status == 200 else 0)
|
|
161
|
+
middleware.sentinel.evaluate.assert_called_once_with(token="fixture", fingerprint_event_id="device-event")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
if __name__ == "__main__":
|
|
165
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|