otpguard 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- otpguard-0.1.0/LICENSE +21 -0
- otpguard-0.1.0/PKG-INFO +220 -0
- otpguard-0.1.0/README.md +186 -0
- otpguard-0.1.0/pyproject.toml +39 -0
- otpguard-0.1.0/setup.cfg +4 -0
- otpguard-0.1.0/src/otpguard/__init__.py +9 -0
- otpguard-0.1.0/src/otpguard/baselines.py +81 -0
- otpguard-0.1.0/src/otpguard/cli.py +192 -0
- otpguard-0.1.0/src/otpguard/detectors/__init__.py +15 -0
- otpguard-0.1.0/src/otpguard/detectors/base.py +79 -0
- otpguard-0.1.0/src/otpguard/detectors/budget.py +57 -0
- otpguard-0.1.0/src/otpguard/detectors/history.py +59 -0
- otpguard-0.1.0/src/otpguard/detectors/population.py +302 -0
- otpguard-0.1.0/src/otpguard/detectors/request.py +62 -0
- otpguard-0.1.0/src/otpguard/enrich.py +149 -0
- otpguard-0.1.0/src/otpguard/model.py +90 -0
- otpguard-0.1.0/src/otpguard/policy.py +217 -0
- otpguard-0.1.0/src/otpguard/replay.py +388 -0
- otpguard-0.1.0/src/otpguard/scoring.py +51 -0
- otpguard-0.1.0/src/otpguard/sentinel.py +240 -0
- otpguard-0.1.0/src/otpguard/server.py +86 -0
- otpguard-0.1.0/src/otpguard/simulate.py +296 -0
- otpguard-0.1.0/src/otpguard/sql.py +154 -0
- otpguard-0.1.0/src/otpguard/store/__init__.py +11 -0
- otpguard-0.1.0/src/otpguard/store/base.py +62 -0
- otpguard-0.1.0/src/otpguard/store/memory.py +145 -0
- otpguard-0.1.0/src/otpguard/store/redis_store.py +155 -0
- otpguard-0.1.0/src/otpguard.egg-info/PKG-INFO +220 -0
- otpguard-0.1.0/src/otpguard.egg-info/SOURCES.txt +34 -0
- otpguard-0.1.0/src/otpguard.egg-info/dependency_links.txt +1 -0
- otpguard-0.1.0/src/otpguard.egg-info/entry_points.txt +2 -0
- otpguard-0.1.0/src/otpguard.egg-info/requires.txt +23 -0
- otpguard-0.1.0/src/otpguard.egg-info/top_level.txt +1 -0
- otpguard-0.1.0/tests/test_core.py +169 -0
- otpguard-0.1.0/tests/test_detectors.py +232 -0
- otpguard-0.1.0/tests/test_stores_and_io.py +146 -0
otpguard-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Muhammad Tanzil
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
otpguard-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: otpguard
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Decide whether an OTP SMS should be sent: SMS pumping, bombing and enumeration checks for any provider
|
|
5
|
+
Author-email: Muhammad Tanzil <mtanzil666@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/mtanzil6/otpguard
|
|
8
|
+
Project-URL: Issues, https://github.com/mtanzil6/otpguard/issues
|
|
9
|
+
Keywords: otp,sms,sms pumping,fraud,rate limiting,security
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Topic :: Security
|
|
12
|
+
Classifier: Topic :: Communications :: Telephony
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: phonenumbers>=8.13
|
|
17
|
+
Requires-Dist: PyYAML>=6
|
|
18
|
+
Requires-Dist: tzdata; platform_system == "Windows"
|
|
19
|
+
Provides-Extra: redis
|
|
20
|
+
Requires-Dist: redis>=5; extra == "redis"
|
|
21
|
+
Provides-Extra: sql
|
|
22
|
+
Requires-Dist: SQLAlchemy>=2.0; extra == "sql"
|
|
23
|
+
Provides-Extra: server
|
|
24
|
+
Requires-Dist: fastapi>=0.110; extra == "server"
|
|
25
|
+
Requires-Dist: uvicorn>=0.29; extra == "server"
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
28
|
+
Requires-Dist: fakeredis[lua]>=2.20; extra == "dev"
|
|
29
|
+
Requires-Dist: redis>=5; extra == "dev"
|
|
30
|
+
Requires-Dist: SQLAlchemy>=2.0; extra == "dev"
|
|
31
|
+
Requires-Dist: fastapi>=0.110; extra == "dev"
|
|
32
|
+
Requires-Dist: httpx>=0.27; extra == "dev"
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
# otpguard
|
|
36
|
+
|
|
37
|
+
Decide whether an OTP SMS should go out before you pay for it.
|
|
38
|
+
|
|
39
|
+
`otpguard` sits between your app and your SMS provider. You call `check()` before
|
|
40
|
+
sending a one-time password; it answers **allow**, **challenge** or **block**, with the
|
|
41
|
+
reasons. It is aimed at SMS pumping (artificially inflated traffic), SMS bombing and
|
|
42
|
+
account enumeration through the password-reset flow.
|
|
43
|
+
|
|
44
|
+
It works with any SMS provider and any framework (there is an HTTP service for
|
|
45
|
+
non-Python apps), and it does not rely on the client IP: most checks look at where the
|
|
46
|
+
messages are going and at the shape of the traffic, which attackers can't rotate as
|
|
47
|
+
cheaply as addresses.
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install git+https://github.com/mtanzil6/otpguard.git # core, in-memory store
|
|
53
|
+
pip install "otpguard[redis,sql] @ git+https://github.com/mtanzil6/otpguard.git" # shared state in Redis, audit trail in SQL
|
|
54
|
+
pip install "otpguard[server] @ git+https://github.com/mtanzil6/otpguard.git" # HTTP service
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Not yet on PyPI — install straight from GitHub. Python 3.10+.
|
|
58
|
+
|
|
59
|
+
## Use
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from otpguard import OtpRequest, Sentinel
|
|
63
|
+
|
|
64
|
+
guard = Sentinel.from_config("policy.yaml",
|
|
65
|
+
redis_url="redis://localhost:6379/0",
|
|
66
|
+
sql_url="postgresql://otp@db/otp")
|
|
67
|
+
|
|
68
|
+
d = guard.check(OtpRequest(phone="+966512345678", purpose="register",
|
|
69
|
+
ip=client_ip, session_id=sid, session_age_ms=age))
|
|
70
|
+
if d.allowed:
|
|
71
|
+
provider.send(phone, code)
|
|
72
|
+
elif d.action == "challenge":
|
|
73
|
+
... # CAPTCHA, then check again with challenge_passed=True
|
|
74
|
+
else:
|
|
75
|
+
... # generic error, wait d.retry_after seconds
|
|
76
|
+
|
|
77
|
+
# later, when the user types the right code
|
|
78
|
+
guard.report_verified(d.request_id)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`d.allowed` is always `True` in shadow mode, so you can run it in production and
|
|
82
|
+
watch what it would have done before you let it block anything.
|
|
83
|
+
|
|
84
|
+
Feedback makes it much better. `report_verified()` feeds the verification-ratio and
|
|
85
|
+
resend checks; `report_event("order", ts)` (or logins, or whatever should follow an
|
|
86
|
+
OTP) feeds the conversion check.
|
|
87
|
+
|
|
88
|
+
For asyncio apps, `AsyncSentinel.from_config(...)` has the same methods as coroutines.
|
|
89
|
+
|
|
90
|
+
### HTTP
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
otpguard serve --policy policy.yaml --redis redis://localhost:6379/0 --port 8080
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
POST /v1/check {"phone": "...", "purpose": "login", "ip": "..."} -> {"action": "allow", "allowed": true, "request_id": "...", ...}
|
|
98
|
+
POST /v1/sent {"request_id": "...", "cost": 0.04}
|
|
99
|
+
POST /v1/verified {"request_id": "..."}
|
|
100
|
+
POST /v1/event {"kind": "order", "phone": "..."}
|
|
101
|
+
GET /health
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Reasons are not returned to the client unless you start the server with
|
|
105
|
+
`--show-reasons`; they go to the SQL audit log.
|
|
106
|
+
|
|
107
|
+
## What it checks
|
|
108
|
+
|
|
109
|
+
| | detector | looks at |
|
|
110
|
+
|---|---|---|
|
|
111
|
+
| D1 | purpose cap | codes per number per purpose, 1 h and 24 h (hard block) |
|
|
112
|
+
| D2 | destination risk | country / number-range rules from the policy |
|
|
113
|
+
| D3 | resend without verify | several codes to one number, none entered |
|
|
114
|
+
| D4 | verification ratio | share of codes entered, per country and purpose |
|
|
115
|
+
| D5 | country share | a country's share of traffic against its usual share for that hour of the week |
|
|
116
|
+
| D6 | prefix burst | never-seen numbers piling up in one number range |
|
|
117
|
+
| D7 | pacing | requests arriving at a steady, machine-like pace |
|
|
118
|
+
| D8 | network concentration | requests per IP, /24 (/48), ASN; hosting networks |
|
|
119
|
+
| D9 | IP rotation | far more never-seen IPs than usual in a country/purpose bucket |
|
|
120
|
+
| D10 | client integrity | session token age and reuse, device attestation |
|
|
121
|
+
| D11 | conversion mismatch | e.g. password resets rising while logins don't |
|
|
122
|
+
| D12 | spend budget | spend per country and in total, per hour and per day (challenge, then block) |
|
|
123
|
+
|
|
124
|
+
Each detector returns a score between 0 and 1. Scores are combined with a weighted
|
|
125
|
+
noisy-OR (`risk = 1 - Π(1 - wᵢ·sᵢ)`); hard rules (caps, deny lists, exhausted budgets)
|
|
126
|
+
set a floor. Thresholds, weights, caps, country rules and budgets all live in the
|
|
127
|
+
policy file, see [`examples/policy.yaml`](examples/policy.yaml).
|
|
128
|
+
|
|
129
|
+
D8–D10 only run when you pass an IP or session data. Nothing breaks without them.
|
|
130
|
+
|
|
131
|
+
## Storage
|
|
132
|
+
|
|
133
|
+
- **Redis** for live counters (1-minute and 1-hour buckets, HyperLogLog for distinct
|
|
134
|
+
IPs). Shared by all app servers. The hard rules (per-number caps, spend budgets) are
|
|
135
|
+
checked and taken in one Lua script, so concurrent requests on different servers
|
|
136
|
+
cannot pass a cap together; the other detectors read counters with plain MGETs.
|
|
137
|
+
- **SQL** (anything SQLAlchemy supports) for the audit trail: one row per decision,
|
|
138
|
+
outcomes, policy versions and grouped alerts. `otpguard prune --sql URL --days 90`
|
|
139
|
+
deletes old rows (run it from cron).
|
|
140
|
+
- **Memory** for tests, replay and single-process setups.
|
|
141
|
+
|
|
142
|
+
Phone numbers are stored only as an HMAC under your secret (`OTPGUARD_SECRET`);
|
|
143
|
+
only the country code and a short prefix are kept in clear. IPs are kept as their
|
|
144
|
+
network.
|
|
145
|
+
|
|
146
|
+
If Redis is unreachable, `fail_mode: open` keeps serving with per-process limits;
|
|
147
|
+
`closed` blocks.
|
|
148
|
+
|
|
149
|
+
## Replay your own logs
|
|
150
|
+
|
|
151
|
+
Most teams already have an SMS log with a timestamp, a number and a message type.
|
|
152
|
+
That is enough to see what a policy would have done:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
otpguard replay sms_log.csv --mapping mapping.yaml --policy policy.yaml \
|
|
156
|
+
--incidents incidents.yaml --out report/
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
The log can be CSV or JSON Lines, optionally gzipped. The mapping file says which
|
|
160
|
+
columns to read, the time zone of the timestamps, which message types are OTPs (and for
|
|
161
|
+
which purpose), which are follow-up events for D11 (`events`) and which mark a real
|
|
162
|
+
customer for the collateral figure (`customer_events`; keep these different from
|
|
163
|
+
`events`, or D11 is graded on its own input). See
|
|
164
|
+
[`examples/mapping-case-study.yaml`](examples/mapping-case-study.yaml) and
|
|
165
|
+
[`examples/incidents-case-study.yaml`](examples/incidents-case-study.yaml). The report lists
|
|
166
|
+
what would have been challenged or blocked, per day and per detector, how many likely
|
|
167
|
+
customers (numbers that got an order or delivery message within a week) were caught,
|
|
168
|
+
and, for windows you label in the incidents file, how quickly and how much was caught.
|
|
169
|
+
|
|
170
|
+
## Simulate attacks
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
otpguard simulate baseline --days 28 --out base.csv
|
|
174
|
+
otpguard simulate inject --base base.csv --scenario A2 --start 2026-01-26T10:00 --out mixed.csv
|
|
175
|
+
otpguard replay mixed.csv --mapping examples/mapping-synthetic.yaml --policy examples/policy.yaml
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
To inject attacks into your own log instead of synthetic traffic:
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
otpguard simulate import sms_log.csv --mapping mapping.yaml --out base.csv # also writes base.mapping.yaml
|
|
182
|
+
otpguard simulate assign-ips --base base.csv --mix examples/asn-mix.yaml --out base_ip.csv # optional, for A2/D8/D9
|
|
183
|
+
otpguard simulate inject --base base_ip.csv --scenario A2 --start 2022-05-10T10:00 --volume 0.05 --out mixed.csv
|
|
184
|
+
otpguard replay mixed.csv --mapping base.mapping.yaml --policy policy.yaml
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
`--volume` sets the attack rate as a fraction of the log's average OTP rate.
|
|
188
|
+
|
|
189
|
+
Scenarios: A1 pumping to one country from a few IPs, A2 the same with a fresh IP per
|
|
190
|
+
request, A3 sequential numbers from one prefix, A4 low and slow, A5 one reset to each of
|
|
191
|
+
many existing customers, A6 resend loops, A7 A1 during a sale.
|
|
192
|
+
`python benchmarks/run_scenarios.py` runs all of them; add `--seeds 10`, `--volumes 0.01
|
|
193
|
+
0.05 0.1 0.2`, `--designs full limiter without-L1 without-L3 without-L4` (a conventional
|
|
194
|
+
per-number + per-IP limiter and layer ablations) and `--base`/`--mapping` for your own
|
|
195
|
+
imported log. `python benchmarks/latency.py --redis redis://localhost:6379/1` measures
|
|
196
|
+
decision latency. Results from the current version are in
|
|
197
|
+
[`benchmarks/results`](benchmarks/results/scenarios.md). They come from synthetic
|
|
198
|
+
traffic, so treat them as a sanity check rather than a promise.
|
|
199
|
+
|
|
200
|
+
## Limits
|
|
201
|
+
|
|
202
|
+
- A decision makes several Redis round trips (one per counter family); only the hard
|
|
203
|
+
rules are atomic. Soft scores can be off by a request or two under heavy concurrency.
|
|
204
|
+
- Baselines need a few days of traffic. Until then the caps, country rules and
|
|
205
|
+
budgets do most of the work.
|
|
206
|
+
- An attacker who spreads traffic thinly over many countries, each below its usual
|
|
207
|
+
share, is mostly stopped by the budgets, not by detection.
|
|
208
|
+
- Set `timezone` in the mapping and the policy to the zone your log is written in;
|
|
209
|
+
naive timestamps and campaign dates are read in that zone.
|
|
210
|
+
|
|
211
|
+
## Development
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
pip install -e ".[dev]"
|
|
215
|
+
pytest
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
## License
|
|
219
|
+
|
|
220
|
+
MIT
|
otpguard-0.1.0/README.md
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# otpguard
|
|
2
|
+
|
|
3
|
+
Decide whether an OTP SMS should go out before you pay for it.
|
|
4
|
+
|
|
5
|
+
`otpguard` sits between your app and your SMS provider. You call `check()` before
|
|
6
|
+
sending a one-time password; it answers **allow**, **challenge** or **block**, with the
|
|
7
|
+
reasons. It is aimed at SMS pumping (artificially inflated traffic), SMS bombing and
|
|
8
|
+
account enumeration through the password-reset flow.
|
|
9
|
+
|
|
10
|
+
It works with any SMS provider and any framework (there is an HTTP service for
|
|
11
|
+
non-Python apps), and it does not rely on the client IP: most checks look at where the
|
|
12
|
+
messages are going and at the shape of the traffic, which attackers can't rotate as
|
|
13
|
+
cheaply as addresses.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install git+https://github.com/mtanzil6/otpguard.git # core, in-memory store
|
|
19
|
+
pip install "otpguard[redis,sql] @ git+https://github.com/mtanzil6/otpguard.git" # shared state in Redis, audit trail in SQL
|
|
20
|
+
pip install "otpguard[server] @ git+https://github.com/mtanzil6/otpguard.git" # HTTP service
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Not yet on PyPI — install straight from GitHub. Python 3.10+.
|
|
24
|
+
|
|
25
|
+
## Use
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from otpguard import OtpRequest, Sentinel
|
|
29
|
+
|
|
30
|
+
guard = Sentinel.from_config("policy.yaml",
|
|
31
|
+
redis_url="redis://localhost:6379/0",
|
|
32
|
+
sql_url="postgresql://otp@db/otp")
|
|
33
|
+
|
|
34
|
+
d = guard.check(OtpRequest(phone="+966512345678", purpose="register",
|
|
35
|
+
ip=client_ip, session_id=sid, session_age_ms=age))
|
|
36
|
+
if d.allowed:
|
|
37
|
+
provider.send(phone, code)
|
|
38
|
+
elif d.action == "challenge":
|
|
39
|
+
... # CAPTCHA, then check again with challenge_passed=True
|
|
40
|
+
else:
|
|
41
|
+
... # generic error, wait d.retry_after seconds
|
|
42
|
+
|
|
43
|
+
# later, when the user types the right code
|
|
44
|
+
guard.report_verified(d.request_id)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`d.allowed` is always `True` in shadow mode, so you can run it in production and
|
|
48
|
+
watch what it would have done before you let it block anything.
|
|
49
|
+
|
|
50
|
+
Feedback makes it much better. `report_verified()` feeds the verification-ratio and
|
|
51
|
+
resend checks; `report_event("order", ts)` (or logins, or whatever should follow an
|
|
52
|
+
OTP) feeds the conversion check.
|
|
53
|
+
|
|
54
|
+
For asyncio apps, `AsyncSentinel.from_config(...)` has the same methods as coroutines.
|
|
55
|
+
|
|
56
|
+
### HTTP
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
otpguard serve --policy policy.yaml --redis redis://localhost:6379/0 --port 8080
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
POST /v1/check {"phone": "...", "purpose": "login", "ip": "..."} -> {"action": "allow", "allowed": true, "request_id": "...", ...}
|
|
64
|
+
POST /v1/sent {"request_id": "...", "cost": 0.04}
|
|
65
|
+
POST /v1/verified {"request_id": "..."}
|
|
66
|
+
POST /v1/event {"kind": "order", "phone": "..."}
|
|
67
|
+
GET /health
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Reasons are not returned to the client unless you start the server with
|
|
71
|
+
`--show-reasons`; they go to the SQL audit log.
|
|
72
|
+
|
|
73
|
+
## What it checks
|
|
74
|
+
|
|
75
|
+
| | detector | looks at |
|
|
76
|
+
|---|---|---|
|
|
77
|
+
| D1 | purpose cap | codes per number per purpose, 1 h and 24 h (hard block) |
|
|
78
|
+
| D2 | destination risk | country / number-range rules from the policy |
|
|
79
|
+
| D3 | resend without verify | several codes to one number, none entered |
|
|
80
|
+
| D4 | verification ratio | share of codes entered, per country and purpose |
|
|
81
|
+
| D5 | country share | a country's share of traffic against its usual share for that hour of the week |
|
|
82
|
+
| D6 | prefix burst | never-seen numbers piling up in one number range |
|
|
83
|
+
| D7 | pacing | requests arriving at a steady, machine-like pace |
|
|
84
|
+
| D8 | network concentration | requests per IP, /24 (/48), ASN; hosting networks |
|
|
85
|
+
| D9 | IP rotation | far more never-seen IPs than usual in a country/purpose bucket |
|
|
86
|
+
| D10 | client integrity | session token age and reuse, device attestation |
|
|
87
|
+
| D11 | conversion mismatch | e.g. password resets rising while logins don't |
|
|
88
|
+
| D12 | spend budget | spend per country and in total, per hour and per day (challenge, then block) |
|
|
89
|
+
|
|
90
|
+
Each detector returns a score between 0 and 1. Scores are combined with a weighted
|
|
91
|
+
noisy-OR (`risk = 1 - Π(1 - wᵢ·sᵢ)`); hard rules (caps, deny lists, exhausted budgets)
|
|
92
|
+
set a floor. Thresholds, weights, caps, country rules and budgets all live in the
|
|
93
|
+
policy file, see [`examples/policy.yaml`](examples/policy.yaml).
|
|
94
|
+
|
|
95
|
+
D8–D10 only run when you pass an IP or session data. Nothing breaks without them.
|
|
96
|
+
|
|
97
|
+
## Storage
|
|
98
|
+
|
|
99
|
+
- **Redis** for live counters (1-minute and 1-hour buckets, HyperLogLog for distinct
|
|
100
|
+
IPs). Shared by all app servers. The hard rules (per-number caps, spend budgets) are
|
|
101
|
+
checked and taken in one Lua script, so concurrent requests on different servers
|
|
102
|
+
cannot pass a cap together; the other detectors read counters with plain MGETs.
|
|
103
|
+
- **SQL** (anything SQLAlchemy supports) for the audit trail: one row per decision,
|
|
104
|
+
outcomes, policy versions and grouped alerts. `otpguard prune --sql URL --days 90`
|
|
105
|
+
deletes old rows (run it from cron).
|
|
106
|
+
- **Memory** for tests, replay and single-process setups.
|
|
107
|
+
|
|
108
|
+
Phone numbers are stored only as an HMAC under your secret (`OTPGUARD_SECRET`);
|
|
109
|
+
only the country code and a short prefix are kept in clear. IPs are kept as their
|
|
110
|
+
network.
|
|
111
|
+
|
|
112
|
+
If Redis is unreachable, `fail_mode: open` keeps serving with per-process limits;
|
|
113
|
+
`closed` blocks.
|
|
114
|
+
|
|
115
|
+
## Replay your own logs
|
|
116
|
+
|
|
117
|
+
Most teams already have an SMS log with a timestamp, a number and a message type.
|
|
118
|
+
That is enough to see what a policy would have done:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
otpguard replay sms_log.csv --mapping mapping.yaml --policy policy.yaml \
|
|
122
|
+
--incidents incidents.yaml --out report/
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The log can be CSV or JSON Lines, optionally gzipped. The mapping file says which
|
|
126
|
+
columns to read, the time zone of the timestamps, which message types are OTPs (and for
|
|
127
|
+
which purpose), which are follow-up events for D11 (`events`) and which mark a real
|
|
128
|
+
customer for the collateral figure (`customer_events`; keep these different from
|
|
129
|
+
`events`, or D11 is graded on its own input). See
|
|
130
|
+
[`examples/mapping-case-study.yaml`](examples/mapping-case-study.yaml) and
|
|
131
|
+
[`examples/incidents-case-study.yaml`](examples/incidents-case-study.yaml). The report lists
|
|
132
|
+
what would have been challenged or blocked, per day and per detector, how many likely
|
|
133
|
+
customers (numbers that got an order or delivery message within a week) were caught,
|
|
134
|
+
and, for windows you label in the incidents file, how quickly and how much was caught.
|
|
135
|
+
|
|
136
|
+
## Simulate attacks
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
otpguard simulate baseline --days 28 --out base.csv
|
|
140
|
+
otpguard simulate inject --base base.csv --scenario A2 --start 2026-01-26T10:00 --out mixed.csv
|
|
141
|
+
otpguard replay mixed.csv --mapping examples/mapping-synthetic.yaml --policy examples/policy.yaml
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
To inject attacks into your own log instead of synthetic traffic:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
otpguard simulate import sms_log.csv --mapping mapping.yaml --out base.csv # also writes base.mapping.yaml
|
|
148
|
+
otpguard simulate assign-ips --base base.csv --mix examples/asn-mix.yaml --out base_ip.csv # optional, for A2/D8/D9
|
|
149
|
+
otpguard simulate inject --base base_ip.csv --scenario A2 --start 2022-05-10T10:00 --volume 0.05 --out mixed.csv
|
|
150
|
+
otpguard replay mixed.csv --mapping base.mapping.yaml --policy policy.yaml
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
`--volume` sets the attack rate as a fraction of the log's average OTP rate.
|
|
154
|
+
|
|
155
|
+
Scenarios: A1 pumping to one country from a few IPs, A2 the same with a fresh IP per
|
|
156
|
+
request, A3 sequential numbers from one prefix, A4 low and slow, A5 one reset to each of
|
|
157
|
+
many existing customers, A6 resend loops, A7 A1 during a sale.
|
|
158
|
+
`python benchmarks/run_scenarios.py` runs all of them; add `--seeds 10`, `--volumes 0.01
|
|
159
|
+
0.05 0.1 0.2`, `--designs full limiter without-L1 without-L3 without-L4` (a conventional
|
|
160
|
+
per-number + per-IP limiter and layer ablations) and `--base`/`--mapping` for your own
|
|
161
|
+
imported log. `python benchmarks/latency.py --redis redis://localhost:6379/1` measures
|
|
162
|
+
decision latency. Results from the current version are in
|
|
163
|
+
[`benchmarks/results`](benchmarks/results/scenarios.md). They come from synthetic
|
|
164
|
+
traffic, so treat them as a sanity check rather than a promise.
|
|
165
|
+
|
|
166
|
+
## Limits
|
|
167
|
+
|
|
168
|
+
- A decision makes several Redis round trips (one per counter family); only the hard
|
|
169
|
+
rules are atomic. Soft scores can be off by a request or two under heavy concurrency.
|
|
170
|
+
- Baselines need a few days of traffic. Until then the caps, country rules and
|
|
171
|
+
budgets do most of the work.
|
|
172
|
+
- An attacker who spreads traffic thinly over many countries, each below its usual
|
|
173
|
+
share, is mostly stopped by the budgets, not by detection.
|
|
174
|
+
- Set `timezone` in the mapping and the policy to the zone your log is written in;
|
|
175
|
+
naive timestamps and campaign dates are read in that zone.
|
|
176
|
+
|
|
177
|
+
## Development
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
pip install -e ".[dev]"
|
|
181
|
+
pytest
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## License
|
|
185
|
+
|
|
186
|
+
MIT
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "otpguard"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Decide whether an OTP SMS should be sent: SMS pumping, bombing and enumeration checks for any provider"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Muhammad Tanzil", email = "mtanzil666@gmail.com" }]
|
|
14
|
+
keywords = ["otp", "sms", "sms pumping", "fraud", "rate limiting", "security"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Topic :: Security",
|
|
18
|
+
"Topic :: Communications :: Telephony",
|
|
19
|
+
]
|
|
20
|
+
dependencies = ["phonenumbers>=8.13", "PyYAML>=6", "tzdata; platform_system == \"Windows\""]
|
|
21
|
+
|
|
22
|
+
[project.optional-dependencies]
|
|
23
|
+
redis = ["redis>=5"]
|
|
24
|
+
sql = ["SQLAlchemy>=2.0"]
|
|
25
|
+
server = ["fastapi>=0.110", "uvicorn>=0.29"]
|
|
26
|
+
dev = ["pytest>=7", "fakeredis[lua]>=2.20", "redis>=5", "SQLAlchemy>=2.0", "fastapi>=0.110", "httpx>=0.27"]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://github.com/mtanzil6/otpguard"
|
|
30
|
+
Issues = "https://github.com/mtanzil6/otpguard/issues"
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
otpguard = "otpguard.cli:main"
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.packages.find]
|
|
36
|
+
where = ["src"]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
testpaths = ["tests"]
|
otpguard-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""otpguard: decide whether an OTP SMS should be sent, before you pay for it."""
|
|
2
|
+
from .model import Action, Decision, OtpRequest, Signal
|
|
3
|
+
from .policy import Policy
|
|
4
|
+
from .sentinel import AsyncSentinel, Sentinel
|
|
5
|
+
from .store import MemoryStore, StoreError
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
__all__ = ["Action", "Decision", "OtpRequest", "Signal", "Policy", "Sentinel", "AsyncSentinel", "MemoryStore",
|
|
9
|
+
"StoreError", "__version__"]
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Hourly baselines (EWMA mean and variance) that the population detectors compare against.
|
|
2
|
+
|
|
3
|
+
Each key keeps an overall estimate and, once there is enough history, one per hour of
|
|
4
|
+
the week. Hours with no traffic count as zeros; for sparse keys such as prefixes the
|
|
5
|
+
zeros are applied lazily when the key is next touched."""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import math
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .store.base import Store
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def hour_of_week(hour_index: int) -> int:
|
|
16
|
+
# hour_index is hours since the epoch; 1970-01-01 was a Thursday
|
|
17
|
+
return (hour_index + 3 * 24) % 168
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Estimate:
|
|
22
|
+
mean: float
|
|
23
|
+
std: float
|
|
24
|
+
n: int # hours of history behind the key
|
|
25
|
+
weekly: bool = False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Baselines:
|
|
29
|
+
def __init__(self, store: Store, alpha: float = 0.05, how_min_samples: int = 3):
|
|
30
|
+
self.store = store
|
|
31
|
+
self.alpha = alpha
|
|
32
|
+
self.how_min = how_min_samples
|
|
33
|
+
|
|
34
|
+
def _key(self, dim: str, key: str) -> str:
|
|
35
|
+
return f"base:{dim}:{key}"
|
|
36
|
+
|
|
37
|
+
def update(self, dim: str, key: str, hour_index: int, value: float, alpha: float | None = None,
|
|
38
|
+
by_hour_of_week: bool = True) -> None:
|
|
39
|
+
a = alpha or self.alpha
|
|
40
|
+
k = self._key(dim, key)
|
|
41
|
+
rec: dict[str, Any] = self.store.get_json(k) or {"m": None, "v": 0.0, "n": 0, "h": None, "w": {}}
|
|
42
|
+
# decay for the hours we did not see (sparse keys)
|
|
43
|
+
if rec["m"] is not None and rec["h"] is not None and hour_index - rec["h"] > 1:
|
|
44
|
+
gap = hour_index - rec["h"] - 1
|
|
45
|
+
f = (1 - a) ** gap
|
|
46
|
+
rec["v"] = rec["v"] * f + (1 - f) * rec["m"] ** 2 * a # rough: zeros add spread
|
|
47
|
+
rec["m"] *= f
|
|
48
|
+
rec["n"] += gap
|
|
49
|
+
rec["m"], rec["v"] = _ewma(rec["m"], rec["v"], value, a)
|
|
50
|
+
rec["n"] += 1
|
|
51
|
+
rec["h"] = hour_index
|
|
52
|
+
if by_hour_of_week:
|
|
53
|
+
how = str(hour_of_week(hour_index))
|
|
54
|
+
w = rec["w"].get(how) or [None, 0.0, 0]
|
|
55
|
+
# weekly series are short, so they adapt faster
|
|
56
|
+
w[0], w[1] = _ewma(w[0], w[1], value, min(0.5, a * 6))
|
|
57
|
+
w[2] += 1
|
|
58
|
+
rec["w"][how] = w
|
|
59
|
+
self.store.set_json(k, rec, ttl_s=60 * 86400)
|
|
60
|
+
|
|
61
|
+
def get(self, dim: str, key: str, hour_index: int) -> Estimate | None:
|
|
62
|
+
rec = self.store.get_json(self._key(dim, key))
|
|
63
|
+
if not rec or rec["m"] is None:
|
|
64
|
+
return None
|
|
65
|
+
w = rec["w"].get(str(hour_of_week(hour_index)))
|
|
66
|
+
if w and w[2] >= self.how_min and w[0] is not None:
|
|
67
|
+
return Estimate(w[0], math.sqrt(max(w[1], 0.0)), rec["n"], weekly=True)
|
|
68
|
+
m, v = rec["m"], rec["v"]
|
|
69
|
+
if rec["h"] is not None and hour_index - rec["h"] > 1:
|
|
70
|
+
f = (1 - self.alpha) ** (hour_index - rec["h"] - 1)
|
|
71
|
+
m *= f
|
|
72
|
+
return Estimate(m, math.sqrt(max(v, 0.0)), rec["n"])
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _ewma(mean: float | None, var: float, x: float, a: float) -> tuple[float, float]:
|
|
76
|
+
if mean is None:
|
|
77
|
+
return x, 0.0
|
|
78
|
+
d = x - mean
|
|
79
|
+
mean = mean + a * d
|
|
80
|
+
var = (1 - a) * (var + a * d * d)
|
|
81
|
+
return mean, var
|