scamgate 0.3.3__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.
- scamgate/__init__.py +11 -0
- scamgate/asklog.py +153 -0
- scamgate/bench.py +77 -0
- scamgate/cli.py +209 -0
- scamgate/config/rules.yaml +218 -0
- scamgate/config.py +62 -0
- scamgate/data/blocklist.txt +9 -0
- scamgate/domainage.py +153 -0
- scamgate/engine.py +145 -0
- scamgate/entry.py +19 -0
- scamgate/feeds.py +377 -0
- scamgate/guard.py +116 -0
- scamgate/harden.py +393 -0
- scamgate/hook.py +1055 -0
- scamgate/hook_entry.py +15 -0
- scamgate/hostnorm.py +60 -0
- scamgate/integrity.py +85 -0
- scamgate/models.py +77 -0
- scamgate/overrides.py +90 -0
- scamgate/payees.py +141 -0
- scamgate/paygate.py +228 -0
- scamgate/plugin.py +222 -0
- scamgate/plugin.yaml +24 -0
- scamgate/redact.py +93 -0
- scamgate/refresh.py +233 -0
- scamgate/registry.py +66 -0
- scamgate/rules/__init__.py +1 -0
- scamgate/rules/payment.py +127 -0
- scamgate/rules/url.py +446 -0
- scamgate/server.py +75 -0
- scamgate/state.py +32 -0
- scamgate/tools.py +94 -0
- scamgate-0.3.3.dist-info/METADATA +544 -0
- scamgate-0.3.3.dist-info/RECORD +37 -0
- scamgate-0.3.3.dist-info/WHEEL +4 -0
- scamgate-0.3.3.dist-info/entry_points.txt +3 -0
- scamgate-0.3.3.dist-info/licenses/LICENSE +21 -0
scamgate/hook_entry.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Fast entry point for the enforcement hook (skips typer/rich startup).
|
|
2
|
+
|
|
3
|
+
`scamgate-hook` runs once per tool call, so startup time matters. `scamgate hook` does the
|
|
4
|
+
same thing through the full CLI.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main() -> None:
|
|
13
|
+
from .hook import main as run
|
|
14
|
+
|
|
15
|
+
sys.exit(run())
|
scamgate/hostnorm.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Host normalisation shared by the rules and the feed lookups.
|
|
2
|
+
|
|
3
|
+
Browsers and curl accept hosts that look nothing like the domain they reach:
|
|
4
|
+
percent-encoded letters (`evilsh%6fp.com`), integer IPv4 (`3405803781`), hex or octal parts
|
|
5
|
+
(`0xcb007105`, `0313.0.0.1`). Normalise them so every check sees the real destination.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ipaddress
|
|
11
|
+
import re
|
|
12
|
+
import unicodedata
|
|
13
|
+
from urllib.parse import unquote
|
|
14
|
+
|
|
15
|
+
_NUM_PART = re.compile(r"^(?:0x[0-9a-f]+|0[0-7]*|[1-9][0-9]*)$", re.I)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _num(part: str) -> int:
|
|
19
|
+
p = part.lower()
|
|
20
|
+
if p.startswith("0x"):
|
|
21
|
+
return int(p, 16)
|
|
22
|
+
if len(p) > 1 and p.startswith("0"):
|
|
23
|
+
return int(p, 8)
|
|
24
|
+
return int(p)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def numeric_ipv4(host: str) -> str | None:
|
|
28
|
+
"""'3405803781', '0xcb007105', '0313.0.0.1', '203.0x0.113.5' -> dotted quad, else None.
|
|
29
|
+
|
|
30
|
+
Follows inet_aton: 1-4 parts, the last part fills the remaining bytes.
|
|
31
|
+
"""
|
|
32
|
+
h = host.strip("[]").rstrip(".")
|
|
33
|
+
parts = h.split(".")
|
|
34
|
+
if not 1 <= len(parts) <= 4 or not all(_NUM_PART.match(p) for p in parts):
|
|
35
|
+
return None
|
|
36
|
+
try:
|
|
37
|
+
nums = [_num(p) for p in parts]
|
|
38
|
+
except ValueError:
|
|
39
|
+
return None
|
|
40
|
+
*head, last = nums
|
|
41
|
+
if any(n > 255 for n in head) or last >= 256 ** (4 - len(head)):
|
|
42
|
+
return None
|
|
43
|
+
value = 0
|
|
44
|
+
for n in head:
|
|
45
|
+
value = value * 256 + n
|
|
46
|
+
value = value * 256 ** (4 - len(head)) + last
|
|
47
|
+
return str(ipaddress.IPv4Address(value))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# Ideographic and halfwidth full stops: browsers read them as '.' in a host name.
|
|
51
|
+
_DOTS = str.maketrans({"。": ".", ".": ".", "。": "."})
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def canon_host(host: str) -> str:
|
|
55
|
+
"""Lowercase, percent-decoded, no trailing dot, numeric IPv4 in dotted form."""
|
|
56
|
+
h = unicodedata.normalize("NFKC", unquote(host or "")).translate(_DOTS).strip().lower().rstrip(".")
|
|
57
|
+
if h and not h.startswith("["):
|
|
58
|
+
if (ip := numeric_ipv4(h)) is not None and h != ip:
|
|
59
|
+
return ip
|
|
60
|
+
return h
|
scamgate/integrity.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Check that the installed scamgate code hasn't been modified.
|
|
2
|
+
|
|
3
|
+
A wheel install has a RECORD file with a SHA-256 for every file. The hook verifies scamgate's
|
|
4
|
+
own Python and config files against it at startup and fails closed if anything changed.
|
|
5
|
+
An editable/dev install (source checkout) has nothing to verify against, so it reports "dev"
|
|
6
|
+
and passes: run the hook from a pinned install (`uv tool install`) to get this protection.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import hashlib
|
|
13
|
+
from importlib import metadata
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _digest(path: Path) -> str:
|
|
18
|
+
return base64.urlsafe_b64encode(hashlib.sha256(path.read_bytes()).digest()).rstrip(b"=").decode()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def verify(entries: list[tuple[Path, str, str]]) -> tuple[bool, str]:
|
|
22
|
+
problems = []
|
|
23
|
+
for path, algo, expected in entries:
|
|
24
|
+
if algo != "sha256":
|
|
25
|
+
continue
|
|
26
|
+
if not path.is_file():
|
|
27
|
+
problems.append(f"missing: {path.name}")
|
|
28
|
+
elif _digest(path) != expected:
|
|
29
|
+
problems.append(f"modified: {path.name}")
|
|
30
|
+
if problems:
|
|
31
|
+
return False, "; ".join(problems[:5])
|
|
32
|
+
return True, f"{len(entries)} files verified"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _is_editable(dist: metadata.Distribution) -> bool:
|
|
36
|
+
import json
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
direct = json.loads(dist.read_text("direct_url.json") or "{}")
|
|
40
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
41
|
+
return False
|
|
42
|
+
return bool((direct.get("dir_info") or {}).get("editable"))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
_KNOWN_PTH = {"distutils-precedence.pth", "_virtualenv.pth"}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def unexpected_pth(dirs: list[Path]) -> list[str]:
|
|
49
|
+
""".pth files no installed package owns. Python runs `import` lines in them at every start."""
|
|
50
|
+
out = []
|
|
51
|
+
for d in dirs:
|
|
52
|
+
d = Path(d)
|
|
53
|
+
if not d.is_dir():
|
|
54
|
+
continue
|
|
55
|
+
owned = set()
|
|
56
|
+
for rec in d.glob("*.dist-info/RECORD"):
|
|
57
|
+
for line in rec.read_text("utf-8", errors="replace").splitlines():
|
|
58
|
+
name = line.split(",", 1)[0].replace("\\", "/")
|
|
59
|
+
if name.endswith(".pth") and "/" not in name:
|
|
60
|
+
owned.add(name)
|
|
61
|
+
for p in sorted(d.glob("*.pth")):
|
|
62
|
+
if p.name not in owned and p.name not in _KNOWN_PTH and not p.name.startswith("__editable__"):
|
|
63
|
+
out.append(p.name)
|
|
64
|
+
return out
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def check() -> tuple[bool, str]:
|
|
68
|
+
try:
|
|
69
|
+
dist = metadata.distribution("scamgate")
|
|
70
|
+
except metadata.PackageNotFoundError:
|
|
71
|
+
return True, "dev (not installed as a package)"
|
|
72
|
+
if _is_editable(dist):
|
|
73
|
+
return True, "dev (editable install, nothing to verify)"
|
|
74
|
+
entries = []
|
|
75
|
+
for f in dist.files or []:
|
|
76
|
+
s = str(f).replace("\\", "/")
|
|
77
|
+
if not s.startswith("scamgate/") or not f.hash or not s.endswith((".py", ".yaml", ".txt")):
|
|
78
|
+
continue
|
|
79
|
+
entries.append((Path(f.locate()), f.hash.mode, f.hash.value))
|
|
80
|
+
if not entries:
|
|
81
|
+
return True, "dev (no RECORD hashes)"
|
|
82
|
+
ok, why = verify(entries)
|
|
83
|
+
if ok and (extra := unexpected_pth([Path(dist.locate_file(""))])):
|
|
84
|
+
return False, f"unknown startup file(s) next to scamgate: {', '.join(extra[:3])}"
|
|
85
|
+
return ok, why
|
scamgate/models.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Shared input and verdict models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Decision(str, Enum):
|
|
11
|
+
allow = "allow"
|
|
12
|
+
ask = "ask" # stop and ask the human
|
|
13
|
+
deny = "deny"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PaymentMethod(str, Enum):
|
|
17
|
+
card = "card"
|
|
18
|
+
bank_transfer = "bank_transfer"
|
|
19
|
+
crypto = "crypto"
|
|
20
|
+
gift_card = "gift_card"
|
|
21
|
+
other = "other"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class VopResult(str, Enum):
|
|
25
|
+
"""Verification of Payee (name vs account) result, if the caller has one."""
|
|
26
|
+
|
|
27
|
+
match = "match"
|
|
28
|
+
close_match = "close_match"
|
|
29
|
+
no_match = "no_match"
|
|
30
|
+
unavailable = "unavailable"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Reason(BaseModel):
|
|
34
|
+
rule_id: str
|
|
35
|
+
explanation: str = Field(description="Plain-English reason a human can read")
|
|
36
|
+
weight: int = Field(ge=0, le=100)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Verdict(BaseModel):
|
|
40
|
+
decision: Decision
|
|
41
|
+
risk_score: int = Field(ge=0, le=100)
|
|
42
|
+
reasons: list[Reason] = Field(default_factory=list)
|
|
43
|
+
summary: str
|
|
44
|
+
checked_host: str | None = Field(
|
|
45
|
+
default=None,
|
|
46
|
+
description="For links: the host scamgate judged, the one a browser would open. If it isn't where you "
|
|
47
|
+
"expected the link to go, treat that as a warning. Empty for payments and unreadable links.",
|
|
48
|
+
)
|
|
49
|
+
warnings: list[str] = Field(default_factory=list, description="Notes about the check itself, e.g. a stale feed cache")
|
|
50
|
+
advisory: str = (
|
|
51
|
+
"scamgate is advisory. It can miss scams and flag legitimate actions; "
|
|
52
|
+
"a human should make the final call on anything flagged."
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class UrlCheck(BaseModel):
|
|
57
|
+
url: str
|
|
58
|
+
intent: str | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class PaymentCheck(BaseModel):
|
|
62
|
+
payee_name: str
|
|
63
|
+
payee_account: str = Field(description="IBAN, wallet address, card merchant id, etc.")
|
|
64
|
+
amount: float = Field(ge=0, allow_inf_nan=False)
|
|
65
|
+
currency: str
|
|
66
|
+
method: PaymentMethod
|
|
67
|
+
context_text: str | None = Field(
|
|
68
|
+
default=None, description="Messages or page text that led to this payment"
|
|
69
|
+
)
|
|
70
|
+
is_new_payee: bool | None = None
|
|
71
|
+
agent_claim_unbacked: bool = Field(
|
|
72
|
+
default=False, exclude=True,
|
|
73
|
+
description="Set by scamgate, not the caller: the agent called a payee known with no record of it",
|
|
74
|
+
)
|
|
75
|
+
bank_details_changed: bool | None = None
|
|
76
|
+
budget: float | None = Field(default=None, ge=0, allow_inf_nan=False)
|
|
77
|
+
vop_result: VopResult | None = None
|
scamgate/overrides.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Human-approved overrides, promoted from the ask log with `scamgate promote`.
|
|
2
|
+
|
|
3
|
+
Rules for overrides, so the gate can't be trained by an attacker:
|
|
4
|
+
- Only a person can add them: `add()` refuses agent environments and non-interactive callers,
|
|
5
|
+
whether it's called from the CLI or imported directly.
|
|
6
|
+
- Every line is signed (see guard.py). Lines written any other way are ignored.
|
|
7
|
+
- `deny` covers an exact host and its subdomains.
|
|
8
|
+
- `allow` is narrow: one exact host (no subdomains) AND one named rule. It waives only that rule's
|
|
9
|
+
points for that host. It never waives known-bad feeds, the blocklist, intent checks or your own deny.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from . import guard
|
|
20
|
+
from .state import state_dir
|
|
21
|
+
|
|
22
|
+
NEVER_WAIVED = {"url.known_bad_feed", "url.blocklist", "url.override", "url.intent_mismatch"}
|
|
23
|
+
_HOST = re.compile(r"^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9-]{2,63}$")
|
|
24
|
+
_interactive = guard._interactive
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def overrides_path() -> Path:
|
|
28
|
+
return state_dir() / "overrides.jsonl"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _check_host(host: str) -> str:
|
|
32
|
+
h = (host or "").strip().lower().rstrip(".")
|
|
33
|
+
if not _HOST.match(h):
|
|
34
|
+
raise ValueError(f"not an exact host name: {host!r} (no wildcards, schemes or paths)")
|
|
35
|
+
return h
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def add(host: str, decision: str, *, rule: str | None = None, note: str = "") -> dict:
|
|
39
|
+
h = _check_host(host)
|
|
40
|
+
if decision not in ("allow", "deny"):
|
|
41
|
+
raise ValueError("decision must be 'allow' or 'deny'")
|
|
42
|
+
if decision == "allow" and not rule:
|
|
43
|
+
raise ValueError("an allow override needs --rule: it waives one rule on one host")
|
|
44
|
+
if decision == "allow" and rule in NEVER_WAIVED:
|
|
45
|
+
raise ValueError(f"{rule} can't be waived: known-bad lists, your blocklist and intent checks always apply")
|
|
46
|
+
guard.require_human("Adding a scamgate override", interactive=lambda: _interactive())
|
|
47
|
+
rec = guard.sign({"host": h, "decision": decision, "rule": rule if decision == "allow" else None,
|
|
48
|
+
"note": note[:200], "added_by": "human-cli",
|
|
49
|
+
"added_at": datetime.now(timezone.utc).isoformat(timespec="seconds")})
|
|
50
|
+
guard.append_private(overrides_path(), json.dumps(rec))
|
|
51
|
+
return rec
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load() -> list[dict]:
|
|
55
|
+
return load_checked()[0]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load_checked() -> tuple[list[dict], int]:
|
|
59
|
+
"""(rules that verify, number of lines that don't). A missing or changed key fails every line,
|
|
60
|
+
and the engine says so rather than dropping your rules silently."""
|
|
61
|
+
p = overrides_path()
|
|
62
|
+
if not p.exists():
|
|
63
|
+
return [], 0
|
|
64
|
+
out, bad = [], 0
|
|
65
|
+
for line in p.read_text("utf-8").splitlines():
|
|
66
|
+
if not line.strip():
|
|
67
|
+
continue
|
|
68
|
+
try:
|
|
69
|
+
rec = json.loads(line)
|
|
70
|
+
except json.JSONDecodeError:
|
|
71
|
+
bad += 1
|
|
72
|
+
continue
|
|
73
|
+
if (isinstance(rec, dict) and rec.get("host") and rec.get("decision") in ("allow", "deny")
|
|
74
|
+
and guard.verify(rec)):
|
|
75
|
+
out.append(rec)
|
|
76
|
+
else:
|
|
77
|
+
bad += 1
|
|
78
|
+
return out, bad
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def deny_match(host: str, items: list[dict]) -> dict | None:
|
|
82
|
+
for o in items:
|
|
83
|
+
if o["decision"] == "deny" and (host == o["host"] or host.endswith("." + o["host"])):
|
|
84
|
+
return o
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def waived_rules(host: str, items: list[dict]) -> set[str]:
|
|
89
|
+
return {o["rule"] for o in items
|
|
90
|
+
if o["decision"] == "allow" and o.get("rule") and host == o["host"]} - NEVER_WAIVED
|
scamgate/payees.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Local payee history, so payment checks don't rely on what the agent claims.
|
|
2
|
+
|
|
3
|
+
Stores salted hashes of the normalized payee name and account, never the values, plus hashes of
|
|
4
|
+
one-letter-deletion variants of the name's first word, so near-names ("Acme Trading Ltd",
|
|
5
|
+
"Acmee Ltd", Cyrillic "Acmе") still match a payee you've paid before without storing the name.
|
|
6
|
+
Only a person can add a payee (`scamgate record-payment`), and every line is signed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import secrets
|
|
15
|
+
import unicodedata
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from . import guard
|
|
21
|
+
from .state import state_dir
|
|
22
|
+
|
|
23
|
+
_SUFFIXES = r"\b(ltd|limited|llc|inc|plc|gmbh|teoranta|dac|co|company|the|and|of)\b"
|
|
24
|
+
_GENERIC = {"bank", "the", "mr", "mrs", "ms", "dr", "st", "saint", "group", "services", "payments", "account"}
|
|
25
|
+
# Letters that look Latin in most fonts.
|
|
26
|
+
_CONFUSABLE = str.maketrans({
|
|
27
|
+
"а": "a", "е": "e", "о": "o", "р": "p", "с": "c", "у": "y", "х": "x", "і": "i", "ј": "j", "ѕ": "s",
|
|
28
|
+
"ԁ": "d", "ɡ": "g", "һ": "h", "ӏ": "l", "ο": "o", "α": "a", "ε": "e", "ι": "i", "κ": "k", "ν": "v",
|
|
29
|
+
"ρ": "p", "τ": "t", "υ": "u", "χ": "x", "ϲ": "c", "0": "o", "1": "l",
|
|
30
|
+
})
|
|
31
|
+
_interactive = guard._interactive
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def history_path() -> Path:
|
|
35
|
+
return state_dir() / "payees.jsonl"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _salt_path() -> Path:
|
|
39
|
+
return state_dir() / "payees.salt"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _salt(create: bool = False) -> bytes | None:
|
|
43
|
+
p = _salt_path()
|
|
44
|
+
if not p.exists():
|
|
45
|
+
if not create:
|
|
46
|
+
return None
|
|
47
|
+
guard.create_once(p, secrets.token_hex(16))
|
|
48
|
+
return p.read_text("utf-8").strip().encode()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _fold(name: str) -> str:
|
|
52
|
+
n = unicodedata.normalize("NFKC", name or "").lower().translate(_CONFUSABLE)
|
|
53
|
+
n = "".join(c for c in unicodedata.normalize("NFKD", n) if not unicodedata.combining(c))
|
|
54
|
+
return re.sub(_SUFFIXES, " ", n)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def norm_name(name: str) -> str:
|
|
58
|
+
return re.sub(r"[^a-z0-9]", "", _fold(name))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def core_word(name: str) -> str:
|
|
62
|
+
words = [w for w in re.split(r"[^a-z0-9]+", _fold(name)) if len(w) >= 3 and w not in _GENERIC]
|
|
63
|
+
return words[0] if words else norm_name(name)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _variants(word: str) -> set[str]:
|
|
67
|
+
return {word} | {word[:i] + word[i + 1:] for i in range(len(word))} if len(word) >= 4 else {word}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _salt_id(salt: bytes) -> str:
|
|
71
|
+
return hashlib.sha256(b"scamgate-salt-id\0" + salt).hexdigest()[:16]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def norm_account(account: str) -> str:
|
|
75
|
+
return re.sub(r"[^A-Z0-9]", "", (account or "").upper())
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _h(kind: str, value: str, salt: bytes | None = None) -> str:
|
|
79
|
+
s = salt if salt is not None else (_salt() or b"")
|
|
80
|
+
return hashlib.sha256(s + kind.encode() + b"\0" + value.encode()).hexdigest()[:32]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class PayeeFacts:
|
|
85
|
+
has_history: bool
|
|
86
|
+
known_name: bool
|
|
87
|
+
known_account: bool
|
|
88
|
+
name_seen_with_other_account: bool
|
|
89
|
+
near_name: bool = False
|
|
90
|
+
account_under_other_name: bool = False
|
|
91
|
+
history_broken: bool = False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _rows() -> list[dict]:
|
|
95
|
+
p = history_path()
|
|
96
|
+
if not p.exists():
|
|
97
|
+
return []
|
|
98
|
+
out = []
|
|
99
|
+
for line in p.read_text("utf-8").splitlines():
|
|
100
|
+
try:
|
|
101
|
+
rec = json.loads(line)
|
|
102
|
+
except json.JSONDecodeError:
|
|
103
|
+
continue
|
|
104
|
+
if isinstance(rec, dict) and guard.verify(rec):
|
|
105
|
+
out.append(rec)
|
|
106
|
+
return out
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def lookup(name: str, account: str) -> PayeeFacts:
|
|
110
|
+
p = history_path()
|
|
111
|
+
if p.exists() and p.stat().st_size and (_salt() is None or not guard.key_exists()):
|
|
112
|
+
return PayeeFacts(True, False, False, False, history_broken=True)
|
|
113
|
+
rows = _rows()
|
|
114
|
+
if not rows:
|
|
115
|
+
return PayeeFacts(False, False, False, False)
|
|
116
|
+
sid = _salt_id(_salt() or b"")
|
|
117
|
+
if any(r.get("salt_id") not in (None, sid) for r in rows):
|
|
118
|
+
return PayeeFacts(True, False, False, False, history_broken=True)
|
|
119
|
+
nh, ah = _h("name", norm_name(name)), _h("acct", norm_account(account))
|
|
120
|
+
near = {_h("core", v) for v in _variants(core_word(name))}
|
|
121
|
+
exact_name = [r for r in rows if r.get("name") == nh]
|
|
122
|
+
near_name = [r for r in rows if r.get("name") != nh and near & set(r.get("core") or [])]
|
|
123
|
+
same_acct = [r for r in rows if r.get("account") == ah]
|
|
124
|
+
known_account = any(r in exact_name or r in near_name for r in same_acct)
|
|
125
|
+
return PayeeFacts(
|
|
126
|
+
has_history=True,
|
|
127
|
+
known_name=bool(exact_name),
|
|
128
|
+
known_account=known_account,
|
|
129
|
+
name_seen_with_other_account=bool(exact_name) and not known_account,
|
|
130
|
+
near_name=bool(near_name) and not exact_name and not known_account,
|
|
131
|
+
account_under_other_name=bool(same_acct) and not known_account,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def record(name: str, account: str) -> None:
|
|
136
|
+
guard.require_human("Recording a payee", interactive=lambda: _interactive())
|
|
137
|
+
salt = _salt(create=True)
|
|
138
|
+
rec = guard.sign({"v": 2, "salt_id": _salt_id(salt), "name": _h("name", norm_name(name), salt), "account": _h("acct", norm_account(account), salt),
|
|
139
|
+
"core": sorted(_h("core", v, salt) for v in _variants(core_word(name))),
|
|
140
|
+
"ts": datetime.now(timezone.utc).isoformat(timespec="seconds")})
|
|
141
|
+
guard.append_private(history_path(), json.dumps(rec))
|