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 +9 -0
- otpguard/baselines.py +81 -0
- otpguard/cli.py +192 -0
- otpguard/detectors/__init__.py +15 -0
- otpguard/detectors/base.py +79 -0
- otpguard/detectors/budget.py +57 -0
- otpguard/detectors/history.py +59 -0
- otpguard/detectors/population.py +302 -0
- otpguard/detectors/request.py +62 -0
- otpguard/enrich.py +149 -0
- otpguard/model.py +90 -0
- otpguard/policy.py +217 -0
- otpguard/replay.py +388 -0
- otpguard/scoring.py +51 -0
- otpguard/sentinel.py +240 -0
- otpguard/server.py +86 -0
- otpguard/simulate.py +296 -0
- otpguard/sql.py +154 -0
- otpguard/store/__init__.py +11 -0
- otpguard/store/base.py +62 -0
- otpguard/store/memory.py +145 -0
- otpguard/store/redis_store.py +155 -0
- otpguard-0.1.0.dist-info/METADATA +220 -0
- otpguard-0.1.0.dist-info/RECORD +28 -0
- otpguard-0.1.0.dist-info/WHEEL +5 -0
- otpguard-0.1.0.dist-info/entry_points.txt +2 -0
- otpguard-0.1.0.dist-info/licenses/LICENSE +21 -0
- otpguard-0.1.0.dist-info/top_level.txt +1 -0
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)
|