otpguard 0.1.0__py3-none-any.whl

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/__init__.py 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__"]
otpguard/baselines.py ADDED
@@ -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
otpguard/cli.py ADDED
@@ -0,0 +1,192 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+
7
+ from . import __version__
8
+ from . import simulate as sim
9
+ from .model import OtpRequest
10
+ from .policy import Policy, _ts
11
+
12
+
13
+ def _policy(path: str | None) -> Policy:
14
+ return Policy.load(path) if path else Policy()
15
+
16
+
17
+ def cmd_check(a) -> int:
18
+ from .sentinel import Sentinel
19
+ g = Sentinel(_policy(a.policy))
20
+ d = g.check(OtpRequest(phone=a.phone, purpose=a.purpose, ip=a.ip))
21
+ print(json.dumps(d.to_dict(), indent=2))
22
+ return 0
23
+
24
+
25
+ def cmd_validate(a) -> int:
26
+ p = Policy.load(a.policy)
27
+ print(f"ok: version {p.version}, mode {p.mode}, {len(p.purposes)} purposes, "
28
+ f"{len(p.countries)} country rules, {len(p.campaigns)} campaigns")
29
+ return 0
30
+
31
+
32
+ def cmd_replay(a) -> int:
33
+ from .replay import Mapping, load_incidents, replay, write_report
34
+ mapping = Mapping.load(a.mapping)
35
+ rep = replay(a.log, _policy(a.policy), mapping,
36
+ load_incidents(a.incidents, mapping.tz) if a.incidents else None,
37
+ as_enforced=not a.shadow, decisions_out=a.decisions,
38
+ progress_every=a.progress)
39
+ j, m = write_report(rep, a.out)
40
+ print(m.read_text(encoding="utf-8"))
41
+ print(f"wrote {j} and {m}")
42
+ return 0
43
+
44
+
45
+ def cmd_sim_baseline(a) -> int:
46
+ surge = (_ts(a.surge[0]), _ts(a.surge[1], end=True), float(a.surge[2])) if a.surge else None
47
+ n = sim.write(sim.baseline(a.days, _ts(a.start), a.rate, a.seed, surge), a.out)
48
+ print(f"wrote {n:,} rows to {a.out}")
49
+ return 0
50
+
51
+
52
+ def cmd_sim_inject(a) -> int:
53
+ d = sim.scenario_defaults(a.scenario)
54
+ hours = a.hours if a.hours is not None else d["hours"]
55
+ rate = a.rate if a.rate is not None else d["rate"]
56
+ otp_types = a.otp_types.split(",") if a.otp_types else None
57
+ if a.volume is not None:
58
+ rate = a.volume * sim.otp_rate_per_hour(a.base, otp_types)
59
+ ctypes = a.customer_types.split(",") if a.customer_types else None
60
+ existing = sim.customers_in(a.base, customer_types=ctypes) if a.scenario == "A5" else None
61
+ attack = sim.attack(a.scenario, _ts(a.start), hours, rate, a.seed, existing)
62
+ n = sim.write(sim.merge(sim.read(a.base), attack), a.out)
63
+ print(f"wrote {n:,} rows to {a.out} ({a.scenario}: {sim.SCENARIOS[a.scenario]}, "
64
+ f"{rate:.0f} attack requests/h)")
65
+ return 0
66
+
67
+
68
+ def cmd_sim_assign_ips(a) -> int:
69
+ mix = None
70
+ if a.mix:
71
+ import yaml
72
+ mix = [(m["asn"], float(m["share"]), int(m["networks"])) for m in yaml.safe_load(open(a.mix))]
73
+ n = sim.write(sim.assign_ips(sim.read(a.base), mix, a.seed), a.out)
74
+ print(f"wrote {n:,} rows to {a.out}")
75
+ return 0
76
+
77
+
78
+ def cmd_sim_import(a) -> int:
79
+ import yaml
80
+ rows, companion = sim.import_log(a.log, a.mapping)
81
+ n = sim.write(rows, a.out)
82
+ mpath = a.mapping_out or str(a.out).rsplit(".", 1)[0] + ".mapping.yaml"
83
+ with open(mpath, "w", encoding="utf-8") as fh:
84
+ yaml.safe_dump(companion, fh, sort_keys=False)
85
+ print(f"wrote {n:,} rows to {a.out} and its replay mapping to {mpath}")
86
+ return 0
87
+
88
+
89
+ def cmd_prune(a) -> int:
90
+ from .sql import SqlAudit
91
+ n = SqlAudit(a.sql, create=False).prune(a.days)
92
+ print(f"deleted {n['otp_events']:,} decisions and {n['alerts']:,} alerts older than {a.days} days")
93
+ return 0
94
+
95
+
96
+ def cmd_serve(a) -> int:
97
+ import uvicorn
98
+
99
+ from .sentinel import Sentinel
100
+ from .server import create_app
101
+ g = Sentinel.from_config(a.policy, redis_url=a.redis, sql_url=a.sql, asn_tsv=a.asn) if a.policy \
102
+ else Sentinel(Policy())
103
+ uvicorn.run(create_app(g, show_reasons=a.show_reasons), host=a.host, port=a.port)
104
+ return 0
105
+
106
+
107
+ def main(argv: list[str] | None = None) -> int:
108
+ ap = argparse.ArgumentParser(prog="otpguard")
109
+ ap.add_argument("--version", action="version", version=f"otpguard {__version__}")
110
+ sub = ap.add_subparsers(dest="cmd", required=True)
111
+
112
+ c = sub.add_parser("check", help="run one request through a policy")
113
+ c.add_argument("--policy")
114
+ c.add_argument("--phone", required=True)
115
+ c.add_argument("--purpose", default="login")
116
+ c.add_argument("--ip")
117
+ c.set_defaults(fn=cmd_check)
118
+
119
+ v = sub.add_parser("validate", help="load a policy file and report problems")
120
+ v.add_argument("policy")
121
+ v.set_defaults(fn=cmd_validate)
122
+
123
+ r = sub.add_parser("replay", help="replay a historical log")
124
+ r.add_argument("log")
125
+ r.add_argument("--mapping", required=True)
126
+ r.add_argument("--policy")
127
+ r.add_argument("--incidents")
128
+ r.add_argument("--out", default="replay-report")
129
+ r.add_argument("--decisions", help="also write one line per decision to this CSV")
130
+ r.add_argument("--shadow", action="store_true",
131
+ help="count every request as sent (default: blocked ones are not sent)")
132
+ r.add_argument("--progress", type=int, default=0, metavar="N")
133
+ r.set_defaults(fn=cmd_replay)
134
+
135
+ s = sub.add_parser("simulate", help="synthetic traffic and attacks")
136
+ ss = s.add_subparsers(dest="what", required=True)
137
+ b = ss.add_parser("baseline")
138
+ b.add_argument("--days", type=int, default=28)
139
+ b.add_argument("--start", default="2026-01-05")
140
+ b.add_argument("--rate", type=float, default=400, help="average OTPs per hour")
141
+ b.add_argument("--surge", nargs=3, metavar=("START", "END", "FACTOR"))
142
+ b.add_argument("--seed", type=int, default=1)
143
+ b.add_argument("--out", required=True)
144
+ b.set_defaults(fn=cmd_sim_baseline)
145
+ i = ss.add_parser("inject")
146
+ i.add_argument("--base", required=True)
147
+ i.add_argument("--scenario", required=True, choices=sorted(sim.SCENARIOS))
148
+ i.add_argument("--start", required=True)
149
+ i.add_argument("--hours", type=float)
150
+ i.add_argument("--rate", type=float, help="attack requests per hour")
151
+ i.add_argument("--volume", type=float,
152
+ help="attack rate as a fraction of the log's mean OTP rate (overrides --rate)")
153
+ i.add_argument("--otp-types", help="comma-separated types that are OTPs in --base (default otp:*)")
154
+ i.add_argument("--customer-types",
155
+ help="comma-separated types that mark customers, for A5 (default event:*)")
156
+ i.add_argument("--seed", type=int, default=7)
157
+ i.add_argument("--out", required=True)
158
+ i.set_defaults(fn=cmd_sim_inject)
159
+ im = ss.add_parser("import", help="convert a real log to the simulator schema, to inject attacks into it")
160
+ im.add_argument("log")
161
+ im.add_argument("--mapping", required=True)
162
+ im.add_argument("--out", required=True)
163
+ im.add_argument("--mapping-out", help="where to write the replay mapping (default: next to --out)")
164
+ im.set_defaults(fn=cmd_sim_import)
165
+ ai = ss.add_parser("assign-ips", help="give a log without addresses synthetic ones (for A2 / D8 / D9)")
166
+ ai.add_argument("--base", required=True)
167
+ ai.add_argument("--mix", help="YAML list of {asn, share, networks}")
168
+ ai.add_argument("--seed", type=int, default=11)
169
+ ai.add_argument("--out", required=True)
170
+ ai.set_defaults(fn=cmd_sim_assign_ips)
171
+
172
+ pr = sub.add_parser("prune", help="delete audit rows older than the retention period")
173
+ pr.add_argument("--sql", required=True)
174
+ pr.add_argument("--days", type=int, default=90)
175
+ pr.set_defaults(fn=cmd_prune)
176
+
177
+ sv = sub.add_parser("serve", help="run the HTTP service")
178
+ sv.add_argument("--policy")
179
+ sv.add_argument("--redis")
180
+ sv.add_argument("--sql")
181
+ sv.add_argument("--asn", help="iptoasn.com TSV file")
182
+ sv.add_argument("--host", default="127.0.0.1")
183
+ sv.add_argument("--port", type=int, default=8080)
184
+ sv.add_argument("--show-reasons", action="store_true")
185
+ sv.set_defaults(fn=cmd_serve)
186
+
187
+ a = ap.parse_args(argv)
188
+ return a.fn(a)
189
+
190
+
191
+ if __name__ == "__main__":
192
+ sys.exit(main())
@@ -0,0 +1,15 @@
1
+ from .base import Context, Detector
2
+ from .budget import SpendBudget
3
+ from .history import ResendWithoutVerify, VerificationRatio
4
+ from .population import (ClientIntegrity, ConversionMismatch, CountryShare, IpRotation,
5
+ NetworkConcentration, Pacing, PrefixBurst)
6
+ from .request import DestinationRisk, PurposeCap
7
+
8
+ ALL = [PurposeCap, DestinationRisk, ResendWithoutVerify, VerificationRatio, CountryShare,
9
+ PrefixBurst, Pacing, NetworkConcentration, IpRotation, ClientIntegrity,
10
+ ConversionMismatch, SpendBudget]
11
+
12
+ # detectors a solved CAPTCHA says something about; destination detectors keep their weight
13
+ CLIENT_SIDE = {"D7", "D8", "D9", "D10"}
14
+
15
+ __all__ = ["Context", "Detector", "ALL", "CLIENT_SIDE"] + [d.__name__ for d in ALL]
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import TYPE_CHECKING
5
+
6
+ from ..enrich import Network, Phone
7
+ from ..model import OtpRequest, Signal
8
+
9
+ if TYPE_CHECKING:
10
+ from ..baselines import Baselines
11
+ from ..policy import Policy
12
+ from ..store.base import Store
13
+
14
+
15
+ @dataclass
16
+ class Context:
17
+ req: OtpRequest
18
+ ts: float
19
+ hour_index: int
20
+ phone: Phone
21
+ net: Network | None
22
+ policy: "Policy"
23
+ store: "Store"
24
+ baselines: "Baselines"
25
+ is_new_number: bool = False
26
+ is_new_ip: bool = False
27
+ in_campaign: bool = False
28
+ notes: dict = field(default_factory=dict)
29
+
30
+ @property
31
+ def purpose(self) -> str:
32
+ return self.req.purpose
33
+
34
+ @property
35
+ def cc(self) -> str:
36
+ return self.phone.region
37
+
38
+
39
+ class Detector:
40
+ id = "D0"
41
+ layer = "L0"
42
+ name = "detector"
43
+ needs_verification = False
44
+ needs_events = False
45
+ needs_ip = False
46
+
47
+ def __init__(self, policy: "Policy"):
48
+ self.cfg = policy.det(self.id)
49
+
50
+ def active(self, ctx: Context) -> bool:
51
+ p = ctx.policy
52
+ if not p.enabled(self.id):
53
+ return False
54
+ if self.needs_verification and not p.verification_feedback:
55
+ return False
56
+ if self.needs_events and not p.event_feedback:
57
+ return False
58
+ if self.needs_ip and ctx.net is None:
59
+ return False
60
+ return True
61
+
62
+ def record(self, ctx: Context) -> None:
63
+ """Update state for this request before any detector evaluates it."""
64
+
65
+ def evaluate(self, ctx: Context) -> Signal | None:
66
+ raise NotImplementedError
67
+
68
+ def sent(self, ctx: Context) -> None:
69
+ """Called when the OTP is going out (allowed, or shadow mode)."""
70
+
71
+ def not_sent(self, ctx: Context) -> None:
72
+ """Called when the OTP is not going out, so reservations can be given back."""
73
+
74
+ def signal(self, score: float, detail: str, scope: str = "", **kw) -> Signal:
75
+ return Signal(detector=self.id, score=max(0.0, min(1.0, score)), detail=detail, scope=scope, **kw)
76
+
77
+
78
+ def clamp(x: float, lo: float = 0.0, hi: float = 1.0) -> float:
79
+ return max(lo, min(hi, x))
@@ -0,0 +1,57 @@
1
+ """Layer 4: money. The last line of defence when nothing else fires."""
2
+ from __future__ import annotations
3
+
4
+ from ..model import Action, Signal
5
+ from .base import Context, Detector
6
+
7
+ _HOUR, _DAY = 60, 1440
8
+
9
+
10
+ class SpendBudget(Detector):
11
+ """D12. Spend per country and in total, per hour and per day.
12
+
13
+ Each budget has a soft level (challenge above it) and a hard level
14
+ (``block_factor`` × budget, block above it). The hard levels are taken atomically
15
+ with ``Store.reserve`` so concurrent requests cannot overspend; the soft level is
16
+ read from the sums the reservation returns."""
17
+
18
+ id, layer, name = "D12", "L4", "spend budget"
19
+
20
+ def evaluate(self, ctx: Context) -> Signal | None:
21
+ p = ctx.policy
22
+ rule = p.country(ctx.cc)
23
+ price = float(rule.price)
24
+ if price <= 0:
25
+ return None
26
+ bf = float(self.cfg["block_factor"])
27
+ scopes = [(f"spend:{ctx.cc}", f"{ctx.cc} spend", rule.budget_per_hour, rule.budget_per_day),
28
+ ("spend:all", "total spend", p.budget_per_hour, p.budget_per_day)]
29
+ reserved: list[str] = []
30
+ worst: Signal | None = None
31
+ for key, label, per_hour, per_day in scopes:
32
+ windows = [(w, float(b) * bf) for w, b in ((_HOUR, per_hour), (_DAY, per_day)) if b]
33
+ if not windows:
34
+ continue
35
+ ok, sums = ctx.store.reserve(key, ctx.ts, windows, price)
36
+ if not ok:
37
+ for k in reserved: # give back what we took so far
38
+ ctx.store.incr(k, ctx.ts, -price)
39
+ w, limit, s = next((w, b, s) for (w, b), s in zip(windows, sums) if s + price > b)
40
+ per = "hourly" if w == _HOUR else "daily"
41
+ return self.signal(1.0, f"{label} {s:.2f} is far over the {per} budget {limit / bf:.2f}",
42
+ scope=f"country:{ctx.cc}" if key != "spend:all" else "budget:all",
43
+ force=Action.BLOCK, retry_after=3600 if w == _HOUR else 6 * 3600)
44
+ reserved.append(key)
45
+ for (w, limit), s in zip(windows, sums):
46
+ if s + price > limit / bf and worst is None:
47
+ per = "hourly" if w == _HOUR else "daily"
48
+ worst = self.signal(0.6, f"{label} {s:.2f} is over the {per} budget {limit / bf:.2f}",
49
+ scope=f"country:{ctx.cc}" if key != "spend:all" else "budget:all",
50
+ force=Action.CHALLENGE, retry_after=600)
51
+ ctx.notes["D12_reserved"] = (reserved, price)
52
+ return worst
53
+
54
+ def not_sent(self, ctx: Context) -> None:
55
+ keys, price = ctx.notes.pop("D12_reserved", ([], 0.0))
56
+ for k in keys:
57
+ ctx.store.incr(k, ctx.ts, -price)
@@ -0,0 +1,59 @@
1
+ """Layer 2: what happened to earlier codes for the same number or country."""
2
+ from __future__ import annotations
3
+
4
+ from ..model import Signal
5
+ from .base import Context, Detector, clamp
6
+
7
+
8
+ class ResendWithoutVerify(Detector):
9
+ """D3. Several codes to the same number and none of them entered."""
10
+
11
+ id, layer, name = "D3", "L2", "resend without verify"
12
+ needs_verification = True
13
+
14
+ def evaluate(self, ctx: Context) -> Signal | None:
15
+ w, k = int(self.cfg["window_min"]), int(self.cfg["k"])
16
+ h = ctx.phone.hash
17
+ sends = ctx.store.window_sum(f"snd:{h}", ctx.ts, w)
18
+ if sends < k:
19
+ return None
20
+ if ctx.store.window_sum(f"ver:{h}", ctx.ts, w) > 0:
21
+ return None
22
+ return self.signal(clamp((sends - k + 1) / k + 0.3),
23
+ f"{int(sends)} codes to this number in {w} min, none entered",
24
+ scope=f"phone:{h}")
25
+
26
+ def sent(self, ctx: Context) -> None:
27
+ ctx.store.incr(f"snd:{ctx.phone.hash}", ctx.ts)
28
+
29
+
30
+ class VerificationRatio(Detector):
31
+ """D4. Share of codes that get entered, per country and purpose."""
32
+
33
+ id, layer, name = "D4", "L2", "verification ratio"
34
+ needs_verification = True
35
+ LAG_S = 300 # codes sent in the last few minutes may not be typed in yet
36
+
37
+ def evaluate(self, ctx: Context) -> Signal | None:
38
+ c = self.cfg
39
+ w = int(c["window_min"])
40
+ key = f"{ctx.purpose}:cc:{ctx.cc}"
41
+ sent = ctx.store.window_sum(f"snd:p:{key}", ctx.ts - self.LAG_S, max(1, w - 5))
42
+ if sent < c["min_sent"]:
43
+ return None
44
+ ver = ctx.store.window_sum(f"ver:p:{key}", ctx.ts, w)
45
+ base = ctx.baselines.get("vr", key, ctx.hour_index)
46
+ r0 = base.mean if base and base.n >= 6 else float(c["prior_ratio"])
47
+ r0 = max(r0, 0.05)
48
+ k = float(c["prior_strength"])
49
+ mean = (ver + r0 * k) / (sent + k)
50
+ deficit = 1 - mean / r0
51
+ drop = float(c["drop"])
52
+ if deficit <= drop:
53
+ return None
54
+ return self.signal(0.4 + 0.6 * (deficit - drop) / (1 - drop),
55
+ f"only {ver:.0f} of {sent:.0f} {ctx.purpose} codes to {ctx.cc} entered "
56
+ f"(usually {r0:.0%})", scope=f"country:{ctx.cc}")
57
+
58
+ def sent(self, ctx: Context) -> None:
59
+ ctx.store.incr(f"snd:p:{ctx.purpose}:cc:{ctx.cc}", ctx.ts)