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.
Files changed (36) hide show
  1. otpguard-0.1.0/LICENSE +21 -0
  2. otpguard-0.1.0/PKG-INFO +220 -0
  3. otpguard-0.1.0/README.md +186 -0
  4. otpguard-0.1.0/pyproject.toml +39 -0
  5. otpguard-0.1.0/setup.cfg +4 -0
  6. otpguard-0.1.0/src/otpguard/__init__.py +9 -0
  7. otpguard-0.1.0/src/otpguard/baselines.py +81 -0
  8. otpguard-0.1.0/src/otpguard/cli.py +192 -0
  9. otpguard-0.1.0/src/otpguard/detectors/__init__.py +15 -0
  10. otpguard-0.1.0/src/otpguard/detectors/base.py +79 -0
  11. otpguard-0.1.0/src/otpguard/detectors/budget.py +57 -0
  12. otpguard-0.1.0/src/otpguard/detectors/history.py +59 -0
  13. otpguard-0.1.0/src/otpguard/detectors/population.py +302 -0
  14. otpguard-0.1.0/src/otpguard/detectors/request.py +62 -0
  15. otpguard-0.1.0/src/otpguard/enrich.py +149 -0
  16. otpguard-0.1.0/src/otpguard/model.py +90 -0
  17. otpguard-0.1.0/src/otpguard/policy.py +217 -0
  18. otpguard-0.1.0/src/otpguard/replay.py +388 -0
  19. otpguard-0.1.0/src/otpguard/scoring.py +51 -0
  20. otpguard-0.1.0/src/otpguard/sentinel.py +240 -0
  21. otpguard-0.1.0/src/otpguard/server.py +86 -0
  22. otpguard-0.1.0/src/otpguard/simulate.py +296 -0
  23. otpguard-0.1.0/src/otpguard/sql.py +154 -0
  24. otpguard-0.1.0/src/otpguard/store/__init__.py +11 -0
  25. otpguard-0.1.0/src/otpguard/store/base.py +62 -0
  26. otpguard-0.1.0/src/otpguard/store/memory.py +145 -0
  27. otpguard-0.1.0/src/otpguard/store/redis_store.py +155 -0
  28. otpguard-0.1.0/src/otpguard.egg-info/PKG-INFO +220 -0
  29. otpguard-0.1.0/src/otpguard.egg-info/SOURCES.txt +34 -0
  30. otpguard-0.1.0/src/otpguard.egg-info/dependency_links.txt +1 -0
  31. otpguard-0.1.0/src/otpguard.egg-info/entry_points.txt +2 -0
  32. otpguard-0.1.0/src/otpguard.egg-info/requires.txt +23 -0
  33. otpguard-0.1.0/src/otpguard.egg-info/top_level.txt +1 -0
  34. otpguard-0.1.0/tests/test_core.py +169 -0
  35. otpguard-0.1.0/tests/test_detectors.py +232 -0
  36. 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.
@@ -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
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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