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.
@@ -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.3
4
- Summary: Maskbreak — real-time fraud, VPN, proxy, and bot detection API. Free tier, sub-40ms response.
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) — a real-time fraud detection API that flags VPNs, residential proxies, antidetect browsers (Kameleo, GoLogin, Multilogin), Tor exit nodes, and AI bots in under 40 ms.
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
  [![PyPI](https://img.shields.io/pypi/v/sentinelsup.svg)](https://pypi.org/project/sentinelsup/)
35
39
  [![Python versions](https://img.shields.io/pypi/pyversions/sentinelsup.svg)](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 (fastest)
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. My API key
48
- > is sk_live_YOUR_KEY; put it in a SENTINEL_KEY env var, never in
49
- > client-side code. Then show me how to test it.
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["SENTINEL_KEY"]) # or omit — reads the env var itself
72
+ s = Sentinel(api_key=os.environ["MASKBREAK_API_KEY"]) # or omit — reads the env var itself
69
73
 
70
- result = s.evaluate(token=request.json["sentinelToken"]) # token from the frontend SDK
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 if decision != 'allow'
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) never fire. For
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 — block VPN/proxy signups
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 SENTINEL_KEY (or SENTINEL_API_KEY) from env
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 SENTINEL_KEY (or SENTINEL_API_KEY) from env
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
- try:
197
- result = sentinel.evaluate(token=token)
198
- if result.is_blocked:
199
- return JsonResponse({"error": "blocked"}, status=403)
200
- except Exception:
201
- pass # fail open
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` | `$SENTINEL_KEY` (falls back to `$SENTINEL_API_KEY`) | Your key starting with `sk_live_` |
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 test tokens exercise your allow / review / block handling end-to-end — no browser needed. Test calls are authenticated and rate-limited like real ones but never billed, stored, or webhooked, and the response carries `test=True`:
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") # CI/staging — nothing billed, nothing stored
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
- Every account also has a personal `sk_test_...` key (Settings → API Key) that runs the *complete* live pipeline — real tokens, your rules and exception pins included — while events stay flagged as test and never count toward usage or webhooks.
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
- All failures raise `SentinelError`. The exception carries `.status` (HTTP code) and `.body` (parsed error body) when available.
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 # transient — retry once or fail open
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. Upgrade at [maskbreak.com](https://maskbreak.com) when you need more.
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) — a real-time fraud detection API that flags VPNs, residential proxies, antidetect browsers (Kameleo, GoLogin, Multilogin), Tor exit nodes, and AI bots in under 40 ms.
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
  [![PyPI](https://img.shields.io/pypi/v/sentinelsup.svg)](https://pypi.org/project/sentinelsup/)
6
10
  [![Python versions](https://img.shields.io/pypi/pyversions/sentinelsup.svg)](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 (fastest)
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. My API key
19
- > is sk_live_YOUR_KEY; put it in a SENTINEL_KEY env var, never in
20
- > client-side code. Then show me how to test it.
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["SENTINEL_KEY"]) # or omit — reads the env var itself
43
+ s = Sentinel(api_key=os.environ["MASKBREAK_API_KEY"]) # or omit — reads the env var itself
40
44
 
41
- result = s.evaluate(token=request.json["sentinelToken"]) # token from the frontend SDK
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 if decision != 'allow'
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) never fire. For
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 — block VPN/proxy signups
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 SENTINEL_KEY (or SENTINEL_API_KEY) from env
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 SENTINEL_KEY (or SENTINEL_API_KEY) from env
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
- try:
168
- result = sentinel.evaluate(token=token)
169
- if result.is_blocked:
170
- return JsonResponse({"error": "blocked"}, status=403)
171
- except Exception:
172
- pass # fail open
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` | `$SENTINEL_KEY` (falls back to `$SENTINEL_API_KEY`) | Your key starting with `sk_live_` |
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 test tokens exercise your allow / review / block handling end-to-end — no browser needed. Test calls are authenticated and rate-limited like real ones but never billed, stored, or webhooked, and the response carries `test=True`:
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") # CI/staging — nothing billed, nothing stored
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
- Every account also has a personal `sk_test_...` key (Settings → API Key) that runs the *complete* live pipeline — real tokens, your rules and exception pins included — while events stay flagged as test and never count toward usage or webhooks.
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
- All failures raise `SentinelError`. The exception carries `.status` (HTTP code) and `.body` (parsed error body) when available.
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 # transient — retry once or fail open
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. Upgrade at [maskbreak.com](https://maskbreak.com) when you need more.
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 (set after the SDK injects it on the client).
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 SENTINEL_API_KEY
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
- # Fail open on infra problems; switch to fail-closed for finance flows.
38
- return self.get_response(request)
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 — block VPN/proxy signups using Sentinel.
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 SENTINEL_API_KEY=sk_live_...
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 SENTINEL_API_KEY
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
- # Fail open if Sentinel is down — log and continue.
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.3"
12
- description = "Maskbreak — real-time fraud, VPN, proxy, and bot detection API. Free tier, sub-40ms response."
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 SENTINEL_KEY or SENTINEL_API_KEY from the env
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.3"
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
- # SENTINEL_KEY is the name every doc surface uses; SENTINEL_API_KEY
75
- # is kept for existing installs that already adopted it.
76
- api_key = api_key or os.environ.get("SENTINEL_KEY") or os.environ.get("SENTINEL_API_KEY")
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 SENTINEL_KEY. "
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.urlopen(req, timeout=self.timeout) as resp:
107
- raw = resp.read().decode("utf-8")
108
- return json.loads(raw) if raw else {}
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 isinstance(ip, str):
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