sentinelsup 0.2.3__tar.gz → 0.2.4__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.4/.github/workflows/ci.yml +31 -0
- {sentinelsup-0.2.3 → sentinelsup-0.2.4}/PKG-INFO +86 -28
- {sentinelsup-0.2.3 → sentinelsup-0.2.4}/README.md +84 -26
- {sentinelsup-0.2.3 → sentinelsup-0.2.4}/examples/django_middleware.py +14 -5
- {sentinelsup-0.2.3 → sentinelsup-0.2.4}/examples/flask_signup_guard.py +7 -5
- {sentinelsup-0.2.3 → sentinelsup-0.2.4}/pyproject.toml +2 -2
- {sentinelsup-0.2.3 → sentinelsup-0.2.4}/sentinel/__init__.py +21 -5
- sentinelsup-0.2.4/tests/test_client.py +148 -0
- {sentinelsup-0.2.3 → sentinelsup-0.2.4}/.gitignore +0 -0
- {sentinelsup-0.2.3 → sentinelsup-0.2.4}/LICENSE +0 -0
- {sentinelsup-0.2.3 → sentinelsup-0.2.4}/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.4
|
|
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 SENTINEL_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.
|
|
@@ -67,7 +71,10 @@ from sentinel import Sentinel
|
|
|
67
71
|
|
|
68
72
|
s = Sentinel(api_key=os.environ["SENTINEL_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,11 @@ 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
|
+
|
|
81
93
|
Check the signup email against the disposable-domain feed (checked
|
|
82
94
|
transiently, never stored), or look up an arbitrary IP with no browser
|
|
83
95
|
token at all:
|
|
@@ -94,7 +106,8 @@ print(info["signals"]) # {'vpn': ..., 'proxied': ..., 'tor': ..., '
|
|
|
94
106
|
|
|
95
107
|
## What you get back
|
|
96
108
|
|
|
97
|
-
`evaluate()` returns an `EvaluateResult` dataclass
|
|
109
|
+
`evaluate()` returns an `EvaluateResult` dataclass. The type sketch below uses
|
|
110
|
+
Python 3.10+ annotation syntax for readability; the package minimum stays 3.8:
|
|
98
111
|
|
|
99
112
|
```python
|
|
100
113
|
@dataclass
|
|
@@ -112,7 +125,7 @@ class EvaluateResult:
|
|
|
112
125
|
test: bool # True for test-token / test-key calls
|
|
113
126
|
raw: dict # full upstream response
|
|
114
127
|
|
|
115
|
-
is_suspicious: bool # True
|
|
128
|
+
is_suspicious: bool # True for a non-null decision other than 'allow'
|
|
116
129
|
is_blocked: bool # True if decision == 'block'
|
|
117
130
|
```
|
|
118
131
|
|
|
@@ -142,7 +155,7 @@ network (VPN/proxy/datacenter) and device (antidetect/bot/tampering):
|
|
|
142
155
|
|
|
143
156
|
Forward both fields to your backend with the form submission and pass them to
|
|
144
157
|
`evaluate()` as `token` and `fingerprint_event_id` — without the second one,
|
|
145
|
-
the device-layer signals (antidetect, automation, emulator)
|
|
158
|
+
the device-layer signals (antidetect, automation, emulator) are unavailable. For
|
|
146
159
|
fetch/XHR submissions, collect them explicitly:
|
|
147
160
|
|
|
148
161
|
```js
|
|
@@ -151,7 +164,7 @@ const { token, fingerprintEventId } = await window.Sentinel.collect();
|
|
|
151
164
|
|
|
152
165
|
## Examples
|
|
153
166
|
|
|
154
|
-
### Flask —
|
|
167
|
+
### Flask — route signup decisions
|
|
155
168
|
|
|
156
169
|
```python
|
|
157
170
|
from flask import Flask, request, abort, jsonify
|
|
@@ -164,7 +177,8 @@ sentinel = Sentinel() # reads SENTINEL_KEY (or SENTINEL_API_KEY) from env
|
|
|
164
177
|
def signup():
|
|
165
178
|
data = request.get_json()
|
|
166
179
|
try:
|
|
167
|
-
result = sentinel.evaluate(token=data["sentinelToken"]
|
|
180
|
+
result = sentinel.evaluate(token=data["sentinelToken"],
|
|
181
|
+
fingerprint_event_id=data.get("fingerprintEventId"))
|
|
168
182
|
except SentinelError as e:
|
|
169
183
|
# Fail open OR fail closed — your call. Logged either way.
|
|
170
184
|
app.logger.warning("Sentinel error: %s", e)
|
|
@@ -173,6 +187,11 @@ def signup():
|
|
|
173
187
|
if result and result.is_blocked:
|
|
174
188
|
abort(403, "Signup blocked")
|
|
175
189
|
|
|
190
|
+
if result and result.decision == "review":
|
|
191
|
+
return jsonify({"needs_verification": True}), 202
|
|
192
|
+
|
|
193
|
+
# This example explicitly fails open on SDK errors. Choose an endpoint-
|
|
194
|
+
# specific fallback; do not reuse this policy for transfers or withdrawals.
|
|
176
195
|
# ... your normal signup flow
|
|
177
196
|
return jsonify({"ok": True})
|
|
178
197
|
```
|
|
@@ -181,7 +200,7 @@ def signup():
|
|
|
181
200
|
|
|
182
201
|
```python
|
|
183
202
|
from django.http import JsonResponse
|
|
184
|
-
from sentinel import Sentinel
|
|
203
|
+
from sentinel import Sentinel, SentinelError
|
|
185
204
|
|
|
186
205
|
sentinel = Sentinel() # reads SENTINEL_KEY (or SENTINEL_API_KEY) from env
|
|
187
206
|
|
|
@@ -192,13 +211,17 @@ class FraudCheckMiddleware:
|
|
|
192
211
|
def __call__(self, request):
|
|
193
212
|
if request.path.startswith("/api/checkout"):
|
|
194
213
|
token = request.META.get("HTTP_X_SENTINEL_TOKEN")
|
|
195
|
-
if token:
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
214
|
+
if not token:
|
|
215
|
+
return JsonResponse({"error": "verification required"}, status=400)
|
|
216
|
+
try:
|
|
217
|
+
result = sentinel.evaluate(token=token,
|
|
218
|
+
fingerprint_event_id=request.META.get("HTTP_X_SENTINEL_FINGERPRINT_EVENT_ID"))
|
|
219
|
+
except SentinelError:
|
|
220
|
+
return JsonResponse({"error": "verification unavailable"}, status=503)
|
|
221
|
+
if result.is_blocked:
|
|
222
|
+
return JsonResponse({"error": "blocked"}, status=403)
|
|
223
|
+
if result.decision == "review":
|
|
224
|
+
return JsonResponse({"error": "additional verification required"}, status=409)
|
|
202
225
|
return self.get_response(request)
|
|
203
226
|
```
|
|
204
227
|
|
|
@@ -222,31 +245,66 @@ Returns `EvaluateResult`. Raises `SentinelError` on network/API failure.
|
|
|
222
245
|
- `account_id` — your own user id for this session; enables multi-accounting detection (`device.linked_accounts` / `device.multi_account`).
|
|
223
246
|
- `email` — adds `email.disposable` to the raw response; burner domains escalate `allow` to `review`.
|
|
224
247
|
|
|
248
|
+
This synchronous SDK forwards only these named inputs. It does not expose a
|
|
249
|
+
timezone input, automatic retries, a circuit breaker, or every REST endpoint.
|
|
250
|
+
Device availability and `degraded` remain accessible through `raw`; missing
|
|
251
|
+
device evidence must not be treated as a clean device result. Account linking
|
|
252
|
+
(`linked_accounts`) is customer-scoped; device `first_seen`/`times_seen` history
|
|
253
|
+
is not customer-scoped.
|
|
254
|
+
|
|
225
255
|
### `sentinel.lookup(ip)`
|
|
226
256
|
|
|
227
257
|
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
258
|
|
|
259
|
+
Production bare-IP lookup checks cloud ranges and Tor exits, not live-visit
|
|
260
|
+
VPN/proxy evidence. Legacy `vpn`/`proxied` keys in the shape do not imply those
|
|
261
|
+
checks ran. Use `evaluate()` with browser evidence for VPN/proxy checks. When
|
|
262
|
+
obtaining an IP behind a proxy, trust forwarded headers only from configured
|
|
263
|
+
trusted proxies; never blindly take the first client-supplied value.
|
|
264
|
+
|
|
229
265
|
## Testing
|
|
230
266
|
|
|
231
|
-
Deterministic
|
|
267
|
+
Deterministic `test_*` tokens exercise response handling, not detection quality.
|
|
268
|
+
SDK fixture calls use authentication and quota but do not increment billable
|
|
269
|
+
usage or trigger webhooks; console-originated live-key fixtures can be stored
|
|
270
|
+
as test events. Personal rules and exception pins can change fixture decisions.
|
|
232
271
|
|
|
233
272
|
```python
|
|
234
273
|
result = s.evaluate(token="test_vpn") # also: test_clean, test_proxy, test_datacenter, test_tor
|
|
235
|
-
assert result.decision == "review"
|
|
274
|
+
assert result.decision == "review" # default policy, no overriding rules/pins
|
|
236
275
|
assert result.test
|
|
237
276
|
```
|
|
238
277
|
|
|
239
278
|
No account yet? The public sandbox key accepts the same test tokens:
|
|
240
279
|
|
|
241
280
|
```python
|
|
242
|
-
s = Sentinel(api_key="sk_test_sandbox") #
|
|
281
|
+
s = Sentinel(api_key="sk_test_sandbox") # deterministic fixtures only, no live detection
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
The public sandbox is separately rate-limited, accepts only supported fixture
|
|
285
|
+
tokens, and does not store events or run live detection. It is not a production
|
|
286
|
+
allowance. A personal `sk_test_...` key runs the live pipeline with real browser
|
|
287
|
+
evidence and your policy; resulting events can be stored with `is_test` set,
|
|
288
|
+
without incrementing usage or firing webhooks. Test keys still have rate limits.
|
|
289
|
+
|
|
290
|
+
Local checks require no API credentials:
|
|
291
|
+
|
|
292
|
+
```bash
|
|
293
|
+
python -m unittest discover -s tests -v
|
|
294
|
+
python -m pip install build twine
|
|
295
|
+
python -m build
|
|
296
|
+
python -m twine check dist/*
|
|
243
297
|
```
|
|
244
298
|
|
|
245
|
-
|
|
299
|
+
The CI matrix targets Python 3.8–3.14 without raising the 3.8 minimum. A configured
|
|
300
|
+
matrix is not a claim that every interpreter was tested locally; inspect its run.
|
|
246
301
|
|
|
247
302
|
## Errors
|
|
248
303
|
|
|
249
|
-
|
|
304
|
+
Transport/API failures and unusable success responses raise `SentinelError`.
|
|
305
|
+
The exception carries `.status` (HTTP code) and `.body` (parsed error body) when
|
|
306
|
+
available. Redirects are rejected to avoid forwarding credentials. Configure the
|
|
307
|
+
final API base URL; the client does not retry automatically.
|
|
250
308
|
|
|
251
309
|
```python
|
|
252
310
|
from sentinel import Sentinel, SentinelError
|
|
@@ -259,13 +317,13 @@ except SentinelError as e:
|
|
|
259
317
|
elif e.status and 400 <= e.status < 500:
|
|
260
318
|
pass # bad input, won't recover by retrying
|
|
261
319
|
else:
|
|
262
|
-
pass #
|
|
320
|
+
pass # unknown outcome — use the endpoint's explicit fallback policy
|
|
263
321
|
```
|
|
264
322
|
|
|
265
323
|
## Rate limits
|
|
266
324
|
|
|
267
325
|
Free tier: **1,000 requests/hour** per API key. No monthly cap, no credit
|
|
268
|
-
card.
|
|
326
|
+
card.
|
|
269
327
|
|
|
270
328
|
## What Maskbreak detects
|
|
271
329
|
|
|
@@ -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 SENTINEL_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.
|
|
@@ -38,7 +42,10 @@ from sentinel import Sentinel
|
|
|
38
42
|
|
|
39
43
|
s = Sentinel(api_key=os.environ["SENTINEL_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,11 @@ 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
|
+
|
|
52
64
|
Check the signup email against the disposable-domain feed (checked
|
|
53
65
|
transiently, never stored), or look up an arbitrary IP with no browser
|
|
54
66
|
token at all:
|
|
@@ -65,7 +77,8 @@ print(info["signals"]) # {'vpn': ..., 'proxied': ..., 'tor': ..., '
|
|
|
65
77
|
|
|
66
78
|
## What you get back
|
|
67
79
|
|
|
68
|
-
`evaluate()` returns an `EvaluateResult` dataclass
|
|
80
|
+
`evaluate()` returns an `EvaluateResult` dataclass. The type sketch below uses
|
|
81
|
+
Python 3.10+ annotation syntax for readability; the package minimum stays 3.8:
|
|
69
82
|
|
|
70
83
|
```python
|
|
71
84
|
@dataclass
|
|
@@ -83,7 +96,7 @@ class EvaluateResult:
|
|
|
83
96
|
test: bool # True for test-token / test-key calls
|
|
84
97
|
raw: dict # full upstream response
|
|
85
98
|
|
|
86
|
-
is_suspicious: bool # True
|
|
99
|
+
is_suspicious: bool # True for a non-null decision other than 'allow'
|
|
87
100
|
is_blocked: bool # True if decision == 'block'
|
|
88
101
|
```
|
|
89
102
|
|
|
@@ -113,7 +126,7 @@ network (VPN/proxy/datacenter) and device (antidetect/bot/tampering):
|
|
|
113
126
|
|
|
114
127
|
Forward both fields to your backend with the form submission and pass them to
|
|
115
128
|
`evaluate()` as `token` and `fingerprint_event_id` — without the second one,
|
|
116
|
-
the device-layer signals (antidetect, automation, emulator)
|
|
129
|
+
the device-layer signals (antidetect, automation, emulator) are unavailable. For
|
|
117
130
|
fetch/XHR submissions, collect them explicitly:
|
|
118
131
|
|
|
119
132
|
```js
|
|
@@ -122,7 +135,7 @@ const { token, fingerprintEventId } = await window.Sentinel.collect();
|
|
|
122
135
|
|
|
123
136
|
## Examples
|
|
124
137
|
|
|
125
|
-
### Flask —
|
|
138
|
+
### Flask — route signup decisions
|
|
126
139
|
|
|
127
140
|
```python
|
|
128
141
|
from flask import Flask, request, abort, jsonify
|
|
@@ -135,7 +148,8 @@ sentinel = Sentinel() # reads SENTINEL_KEY (or SENTINEL_API_KEY) from env
|
|
|
135
148
|
def signup():
|
|
136
149
|
data = request.get_json()
|
|
137
150
|
try:
|
|
138
|
-
result = sentinel.evaluate(token=data["sentinelToken"]
|
|
151
|
+
result = sentinel.evaluate(token=data["sentinelToken"],
|
|
152
|
+
fingerprint_event_id=data.get("fingerprintEventId"))
|
|
139
153
|
except SentinelError as e:
|
|
140
154
|
# Fail open OR fail closed — your call. Logged either way.
|
|
141
155
|
app.logger.warning("Sentinel error: %s", e)
|
|
@@ -144,6 +158,11 @@ def signup():
|
|
|
144
158
|
if result and result.is_blocked:
|
|
145
159
|
abort(403, "Signup blocked")
|
|
146
160
|
|
|
161
|
+
if result and result.decision == "review":
|
|
162
|
+
return jsonify({"needs_verification": True}), 202
|
|
163
|
+
|
|
164
|
+
# This example explicitly fails open on SDK errors. Choose an endpoint-
|
|
165
|
+
# specific fallback; do not reuse this policy for transfers or withdrawals.
|
|
147
166
|
# ... your normal signup flow
|
|
148
167
|
return jsonify({"ok": True})
|
|
149
168
|
```
|
|
@@ -152,7 +171,7 @@ def signup():
|
|
|
152
171
|
|
|
153
172
|
```python
|
|
154
173
|
from django.http import JsonResponse
|
|
155
|
-
from sentinel import Sentinel
|
|
174
|
+
from sentinel import Sentinel, SentinelError
|
|
156
175
|
|
|
157
176
|
sentinel = Sentinel() # reads SENTINEL_KEY (or SENTINEL_API_KEY) from env
|
|
158
177
|
|
|
@@ -163,13 +182,17 @@ class FraudCheckMiddleware:
|
|
|
163
182
|
def __call__(self, request):
|
|
164
183
|
if request.path.startswith("/api/checkout"):
|
|
165
184
|
token = request.META.get("HTTP_X_SENTINEL_TOKEN")
|
|
166
|
-
if token:
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
185
|
+
if not token:
|
|
186
|
+
return JsonResponse({"error": "verification required"}, status=400)
|
|
187
|
+
try:
|
|
188
|
+
result = sentinel.evaluate(token=token,
|
|
189
|
+
fingerprint_event_id=request.META.get("HTTP_X_SENTINEL_FINGERPRINT_EVENT_ID"))
|
|
190
|
+
except SentinelError:
|
|
191
|
+
return JsonResponse({"error": "verification unavailable"}, status=503)
|
|
192
|
+
if result.is_blocked:
|
|
193
|
+
return JsonResponse({"error": "blocked"}, status=403)
|
|
194
|
+
if result.decision == "review":
|
|
195
|
+
return JsonResponse({"error": "additional verification required"}, status=409)
|
|
173
196
|
return self.get_response(request)
|
|
174
197
|
```
|
|
175
198
|
|
|
@@ -193,31 +216,66 @@ Returns `EvaluateResult`. Raises `SentinelError` on network/API failure.
|
|
|
193
216
|
- `account_id` — your own user id for this session; enables multi-accounting detection (`device.linked_accounts` / `device.multi_account`).
|
|
194
217
|
- `email` — adds `email.disposable` to the raw response; burner domains escalate `allow` to `review`.
|
|
195
218
|
|
|
219
|
+
This synchronous SDK forwards only these named inputs. It does not expose a
|
|
220
|
+
timezone input, automatic retries, a circuit breaker, or every REST endpoint.
|
|
221
|
+
Device availability and `degraded` remain accessible through `raw`; missing
|
|
222
|
+
device evidence must not be treated as a clean device result. Account linking
|
|
223
|
+
(`linked_accounts`) is customer-scoped; device `first_seen`/`times_seen` history
|
|
224
|
+
is not customer-scoped.
|
|
225
|
+
|
|
196
226
|
### `sentinel.lookup(ip)`
|
|
197
227
|
|
|
198
228
|
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
229
|
|
|
230
|
+
Production bare-IP lookup checks cloud ranges and Tor exits, not live-visit
|
|
231
|
+
VPN/proxy evidence. Legacy `vpn`/`proxied` keys in the shape do not imply those
|
|
232
|
+
checks ran. Use `evaluate()` with browser evidence for VPN/proxy checks. When
|
|
233
|
+
obtaining an IP behind a proxy, trust forwarded headers only from configured
|
|
234
|
+
trusted proxies; never blindly take the first client-supplied value.
|
|
235
|
+
|
|
200
236
|
## Testing
|
|
201
237
|
|
|
202
|
-
Deterministic
|
|
238
|
+
Deterministic `test_*` tokens exercise response handling, not detection quality.
|
|
239
|
+
SDK fixture calls use authentication and quota but do not increment billable
|
|
240
|
+
usage or trigger webhooks; console-originated live-key fixtures can be stored
|
|
241
|
+
as test events. Personal rules and exception pins can change fixture decisions.
|
|
203
242
|
|
|
204
243
|
```python
|
|
205
244
|
result = s.evaluate(token="test_vpn") # also: test_clean, test_proxy, test_datacenter, test_tor
|
|
206
|
-
assert result.decision == "review"
|
|
245
|
+
assert result.decision == "review" # default policy, no overriding rules/pins
|
|
207
246
|
assert result.test
|
|
208
247
|
```
|
|
209
248
|
|
|
210
249
|
No account yet? The public sandbox key accepts the same test tokens:
|
|
211
250
|
|
|
212
251
|
```python
|
|
213
|
-
s = Sentinel(api_key="sk_test_sandbox") #
|
|
252
|
+
s = Sentinel(api_key="sk_test_sandbox") # deterministic fixtures only, no live detection
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
The public sandbox is separately rate-limited, accepts only supported fixture
|
|
256
|
+
tokens, and does not store events or run live detection. It is not a production
|
|
257
|
+
allowance. A personal `sk_test_...` key runs the live pipeline with real browser
|
|
258
|
+
evidence and your policy; resulting events can be stored with `is_test` set,
|
|
259
|
+
without incrementing usage or firing webhooks. Test keys still have rate limits.
|
|
260
|
+
|
|
261
|
+
Local checks require no API credentials:
|
|
262
|
+
|
|
263
|
+
```bash
|
|
264
|
+
python -m unittest discover -s tests -v
|
|
265
|
+
python -m pip install build twine
|
|
266
|
+
python -m build
|
|
267
|
+
python -m twine check dist/*
|
|
214
268
|
```
|
|
215
269
|
|
|
216
|
-
|
|
270
|
+
The CI matrix targets Python 3.8–3.14 without raising the 3.8 minimum. A configured
|
|
271
|
+
matrix is not a claim that every interpreter was tested locally; inspect its run.
|
|
217
272
|
|
|
218
273
|
## Errors
|
|
219
274
|
|
|
220
|
-
|
|
275
|
+
Transport/API failures and unusable success responses raise `SentinelError`.
|
|
276
|
+
The exception carries `.status` (HTTP code) and `.body` (parsed error body) when
|
|
277
|
+
available. Redirects are rejected to avoid forwarding credentials. Configure the
|
|
278
|
+
final API base URL; the client does not retry automatically.
|
|
221
279
|
|
|
222
280
|
```python
|
|
223
281
|
from sentinel import Sentinel, SentinelError
|
|
@@ -230,13 +288,13 @@ except SentinelError as e:
|
|
|
230
288
|
elif e.status and 400 <= e.status < 500:
|
|
231
289
|
pass # bad input, won't recover by retrying
|
|
232
290
|
else:
|
|
233
|
-
pass #
|
|
291
|
+
pass # unknown outcome — use the endpoint's explicit fallback policy
|
|
234
292
|
```
|
|
235
293
|
|
|
236
294
|
## Rate limits
|
|
237
295
|
|
|
238
296
|
Free tier: **1,000 requests/hour** per API key. No monthly cap, no credit
|
|
239
|
-
card.
|
|
297
|
+
card.
|
|
240
298
|
|
|
241
299
|
## What Maskbreak detects
|
|
242
300
|
|
|
@@ -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 SENTINEL_KEY, with legacy 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 SENTINEL_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 SENTINEL_API_KEY
|
|
15
|
+
sentinel = Sentinel() # reads SENTINEL_KEY, with SENTINEL_API_KEY fallback
|
|
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.4"
|
|
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" }
|
|
@@ -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.4"
|
|
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):
|
|
@@ -103,9 +110,16 @@ class Sentinel:
|
|
|
103
110
|
)
|
|
104
111
|
|
|
105
112
|
try:
|
|
106
|
-
with request.
|
|
107
|
-
|
|
108
|
-
|
|
113
|
+
with request.build_opener(_NoRedirect()).open(req, timeout=self.timeout) as resp:
|
|
114
|
+
try:
|
|
115
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
116
|
+
except (ValueError, UnicodeError):
|
|
117
|
+
raise SentinelError("Sentinel: invalid JSON response", status=resp.status) from None
|
|
118
|
+
if not isinstance(data, dict):
|
|
119
|
+
raise SentinelError("Sentinel: expected a JSON object", status=resp.status)
|
|
120
|
+
return data
|
|
121
|
+
except SentinelError:
|
|
122
|
+
raise
|
|
109
123
|
except error.HTTPError as e:
|
|
110
124
|
try:
|
|
111
125
|
err_body = json.loads(e.read().decode("utf-8"))
|
|
@@ -161,6 +175,8 @@ class Sentinel:
|
|
|
161
175
|
payload["email"] = email
|
|
162
176
|
|
|
163
177
|
data = self._request("/v1/evaluate", payload)
|
|
178
|
+
if data.get("decision") not in ("allow", "review", "block"):
|
|
179
|
+
raise SentinelError("Sentinel: invalid or missing evaluation decision", body=data)
|
|
164
180
|
|
|
165
181
|
return EvaluateResult(
|
|
166
182
|
decision=data.get("decision"),
|
|
@@ -198,7 +214,7 @@ class Sentinel:
|
|
|
198
214
|
Raises:
|
|
199
215
|
SentinelError: on network failure, timeout, or non-2xx response.
|
|
200
216
|
"""
|
|
201
|
-
if not ip or not
|
|
217
|
+
if not isinstance(ip, str) or not ip.strip():
|
|
202
218
|
raise SentinelError(
|
|
203
219
|
"Sentinel.lookup: ip (public IPv4 or IPv6 address) is required"
|
|
204
220
|
)
|
|
@@ -0,0 +1,148 @@
|
|
|
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
|
+
with patch.dict(os.environ, {"SENTINEL_KEY": "primary", "SENTINEL_API_KEY": "legacy"}):
|
|
112
|
+
self.assertEqual(Sentinel().api_key, "primary")
|
|
113
|
+
self.assertEqual(Sentinel("explicit").api_key, "explicit")
|
|
114
|
+
os.environ.pop("SENTINEL_KEY")
|
|
115
|
+
self.assertEqual(Sentinel().api_key, "legacy")
|
|
116
|
+
|
|
117
|
+
def test_result_helpers_keep_review_distinct_from_block(self):
|
|
118
|
+
for decision in ("allow", "review", "block"):
|
|
119
|
+
result = EvaluateResult(decision=decision)
|
|
120
|
+
self.assertEqual(result.is_blocked, decision == "block")
|
|
121
|
+
self.assertEqual(result.is_suspicious, decision != "allow")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class DjangoGuardTests(unittest.TestCase):
|
|
125
|
+
def test_financial_guard_requires_live_complete_allow_before_view(self):
|
|
126
|
+
http = ModuleType("django.http")
|
|
127
|
+
http.JsonResponse = lambda body, status: SimpleNamespace(status_code=status)
|
|
128
|
+
with patch.dict(sys.modules, {"django": ModuleType("django"), "django.http": http}):
|
|
129
|
+
example = runpy.run_path(str(Path(__file__).resolve().parents[1] / "examples" / "django_middleware.py"))
|
|
130
|
+
for decision, flag, status in [("allow", None, 200), ("review", None, 409), ("block", None, 403)] + [
|
|
131
|
+
("allow", flag, 409) for flag in ("test", "sample", "sandbox", "degraded")
|
|
132
|
+
]:
|
|
133
|
+
with self.subTest(decision=decision, flag=flag):
|
|
134
|
+
view = Mock(return_value=SimpleNamespace(status_code=200))
|
|
135
|
+
with patch.dict(os.environ, {"SENTINEL_KEY": "sk_test_fixture"}):
|
|
136
|
+
middleware = example["SentinelMiddleware"](view)
|
|
137
|
+
middleware.sentinel = Mock()
|
|
138
|
+
middleware.sentinel.evaluate.return_value = EvaluateResult(
|
|
139
|
+
decision=decision, test=flag == "test", raw={flag: True} if flag else {})
|
|
140
|
+
request = SimpleNamespace(path="/api/transfer", META={
|
|
141
|
+
"HTTP_X_SENTINEL_TOKEN": "fixture", "HTTP_X_SENTINEL_FINGERPRINT_EVENT_ID": "device-event"})
|
|
142
|
+
self.assertEqual(middleware(request).status_code, status)
|
|
143
|
+
self.assertEqual(view.call_count, 1 if status == 200 else 0)
|
|
144
|
+
middleware.sentinel.evaluate.assert_called_once_with(token="fixture", fingerprint_event_id="device-event")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
if __name__ == "__main__":
|
|
148
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|