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.
@@ -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.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) — 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 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(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,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 if decision != 'allow'
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) never fire. For
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 — block VPN/proxy signups
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
- 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
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 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`:
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") # CI/staging — nothing billed, nothing stored
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
- 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.
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
- All failures raise `SentinelError`. The exception carries `.status` (HTTP code) and `.body` (parsed error body) when available.
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 # transient — retry once or fail open
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. Upgrade at [maskbreak.com](https://maskbreak.com) when you need more.
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) — 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 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(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,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 if decision != 'allow'
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) never fire. For
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 — block VPN/proxy signups
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
- 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
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 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`:
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") # CI/staging — nothing billed, nothing stored
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
- 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.
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
- All failures raise `SentinelError`. The exception carries `.status` (HTTP code) and `.body` (parsed error body) when available.
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 # transient — retry once or fail open
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. Upgrade at [maskbreak.com](https://maskbreak.com) when you need more.
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 (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 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
- # 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 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
- # 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.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.3"
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.urlopen(req, timeout=self.timeout) as resp:
107
- raw = resp.read().decode("utf-8")
108
- return json.loads(raw) if raw else {}
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 isinstance(ip, str):
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