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/config.py ADDED
@@ -0,0 +1,62 @@
1
+ """Load rules config (weights, thresholds, word lists) and the local blocklist."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from functools import lru_cache
7
+ from importlib import resources
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import yaml
12
+
13
+ ENV_CONFIG = "SCAMGATE_CONFIG"
14
+ ENV_BLOCKLIST = "SCAMGATE_BLOCKLIST"
15
+
16
+
17
+ def _default_config_text() -> str:
18
+ return resources.files(__package__).joinpath("config/rules.yaml").read_text("utf-8")
19
+
20
+
21
+ @lru_cache(maxsize=8)
22
+ def load_config(path: str | None = None) -> dict[str, Any]:
23
+ path = path or os.environ.get(ENV_CONFIG)
24
+ text = Path(path).read_text("utf-8") if path else _default_config_text()
25
+ return yaml.safe_load(text)
26
+
27
+
28
+ def _blocklist_candidates() -> list[Path]:
29
+ out: list[Path] = []
30
+ if env := os.environ.get(ENV_BLOCKLIST):
31
+ out.append(Path(env))
32
+ # Bundled example list, inside the package (wheel, source checkout and Hermes plugin alike)
33
+ out.append(Path(__file__).resolve().parent / "data" / "blocklist.txt")
34
+ return out
35
+
36
+
37
+ def _parse_domains(text: str) -> set[str]:
38
+ domains = set()
39
+ for line in text.splitlines():
40
+ line = line.split("#", 1)[0].strip().lower().rstrip(".")
41
+ if line:
42
+ domains.add(line)
43
+ return domains
44
+
45
+
46
+ @lru_cache(maxsize=8)
47
+ def load_blocklist(path: str | None = None) -> frozenset[str]:
48
+ domains: set[str] = set()
49
+ candidates = [Path(path)] if path else _blocklist_candidates()
50
+ for p in candidates:
51
+ if p.is_file():
52
+ domains |= _parse_domains(p.read_text("utf-8"))
53
+ break
54
+ return frozenset(domains)
55
+
56
+
57
+ def rule_cfg(config: dict[str, Any], rule_id: str) -> dict[str, Any]:
58
+ return config.get("rules", {}).get(rule_id, {}) or {}
59
+
60
+
61
+ def lst(config: dict[str, Any], name: str) -> Any:
62
+ return config.get("lists", {}).get(name, [])
@@ -0,0 +1,9 @@
1
+ # scamgate local blocklist: one domain per line, '#' for comments.
2
+ # A listed domain also blocks its subdomains.
3
+ # These are made-up EXAMPLE entries on the reserved .example TLD, for tests and demos.
4
+ # Pull real community feeds into data/feeds/ with scripts/fetch_feeds.py (not bundled).
5
+ free-iphone-giveaway.example
6
+ revolut-unlock-account.example
7
+ aib-secure-verify.example
8
+ crypto-doubler.example
9
+ parcel-redelivery-fee.example
scamgate/domainage.py ADDED
@@ -0,0 +1,153 @@
1
+ """Domain age from RDAP, for `url.domain_age`. Off by default; opt in with SCAMGATE_RDAP.
2
+
3
+ New domains are a strong scam-shop signal, but looking one up means asking the domain's registry,
4
+ which tells the registry which site you're about to visit. So it's your call:
5
+
6
+ - `SCAMGATE_RDAP=off` (default): no lookups, the rule never fires.
7
+ - `SCAMGATE_RDAP=cache`: use ages already in the cache (from `scamgate domain-age HOST` or an
8
+ earlier live lookup); never go online.
9
+ - `SCAMGATE_RDAP=live`: look up unknown domains (3 s timeout) and cache the result.
10
+
11
+ The registry list comes from IANA's RDAP bootstrap file (data.iana.org/rdap/dns.json), cached for
12
+ 30 days. Ages are cached for a year; failed or unknown lookups for 7 days, so a domain that won't
13
+ answer doesn't slow every call. Any error means "no answer", never a verdict.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import re
21
+ from datetime import datetime, timedelta, timezone
22
+ from pathlib import Path
23
+ from typing import Callable
24
+
25
+ ENV_RDAP = "SCAMGATE_RDAP"
26
+ _HOSTNAME = re.compile(r"^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$")
27
+ BOOTSTRAP_URL = "https://data.iana.org/rdap/dns.json"
28
+ TIMEOUT_S = 3
29
+ BOOTSTRAP_TTL = timedelta(days=30)
30
+ KNOWN_TTL = timedelta(days=365)
31
+ UNKNOWN_TTL = timedelta(days=7)
32
+
33
+
34
+ def mode() -> str:
35
+ m = (os.environ.get(ENV_RDAP) or "off").strip().lower()
36
+ return m if m in ("off", "cache", "live") else "off"
37
+
38
+
39
+ def _cache_path() -> Path:
40
+ from .feeds import cache_dir
41
+
42
+ return cache_dir() / "rdap.json"
43
+
44
+
45
+ def _now() -> datetime:
46
+ return datetime.now(timezone.utc)
47
+
48
+
49
+ def _load() -> dict:
50
+ try:
51
+ data = json.loads(_cache_path().read_text("utf-8"))
52
+ return data if isinstance(data, dict) else {}
53
+ except (OSError, ValueError):
54
+ return {}
55
+
56
+
57
+ def _save(data: dict) -> None:
58
+ p = _cache_path()
59
+ try:
60
+ p.parent.mkdir(parents=True, exist_ok=True)
61
+ tmp = p.with_suffix(".json.tmp")
62
+ tmp.write_text(json.dumps(data, indent=1, sort_keys=True), "utf-8")
63
+ tmp.replace(p)
64
+ except OSError:
65
+ pass # a read-only cache just means no caching
66
+
67
+
68
+ def _http_get(url: str) -> str:
69
+ import urllib.request
70
+
71
+ from .feeds import USER_AGENT
72
+
73
+ req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "application/rdap+json"})
74
+ with urllib.request.urlopen(req, timeout=TIMEOUT_S) as resp: # noqa: S310
75
+ return resp.read(2_000_000).decode("utf-8", "replace")
76
+
77
+
78
+ def _parse_ts(s: str) -> datetime | None:
79
+ try:
80
+ d = datetime.fromisoformat(s.replace("Z", "+00:00"))
81
+ return d if d.tzinfo else d.replace(tzinfo=timezone.utc)
82
+ except (TypeError, ValueError):
83
+ return None
84
+
85
+
86
+ def _rdap_base(tld: str, data: dict, fetch: Callable[[str], str]) -> str | None:
87
+ boot = data.get("_bootstrap") or {}
88
+ fetched = _parse_ts(boot.get("fetched_at", ""))
89
+ if not boot.get("services") or not fetched or _now() - fetched > BOOTSTRAP_TTL:
90
+ try:
91
+ services = json.loads(fetch(BOOTSTRAP_URL)).get("services", [])
92
+ boot = {"fetched_at": _now().isoformat(timespec="seconds"), "services": services}
93
+ data["_bootstrap"] = boot
94
+ except Exception:
95
+ if not boot.get("services"):
96
+ return None
97
+ for tlds, urls in boot.get("services", []):
98
+ if tld in tlds and urls:
99
+ https = [u for u in urls if u.startswith("https://")]
100
+ return (https or urls)[0].rstrip("/") + "/"
101
+ return None
102
+
103
+
104
+ def registration_date(rdap_json: str) -> datetime | None:
105
+ try:
106
+ doc = json.loads(rdap_json)
107
+ except ValueError:
108
+ return None
109
+ for ev in doc.get("events", []) if isinstance(doc, dict) else []:
110
+ if isinstance(ev, dict) and ev.get("eventAction") == "registration":
111
+ return _parse_ts(ev.get("eventDate", ""))
112
+ return None
113
+
114
+
115
+ def lookup(domain: str, *, live: bool | None = None, fetch: Callable[[str], str] | None = None) -> int | None:
116
+ """Age of `domain` in days, or None if unknown. `live` overrides SCAMGATE_RDAP."""
117
+ domain = (domain or "").strip().lower().rstrip(".")
118
+ if not domain.isascii():
119
+ try:
120
+ ascii_name = domain.encode("idna").decode("ascii") # registries take the xn-- form
121
+ if ascii_name.encode("ascii").decode("idna") != domain:
122
+ return None # Python's IDNA 2003 rules would ask about another name ('faß' -> 'fass')
123
+ except UnicodeError:
124
+ return None
125
+ domain = ascii_name
126
+ if not _HOSTNAME.match(domain):
127
+ return None # only a plain host name goes into the registry URL
128
+ m = mode()
129
+ allow_net = m == "live" if live is None else live
130
+ if m == "off" and live is None:
131
+ return None
132
+ data = _load()
133
+ hit = data.get(domain)
134
+ if isinstance(hit, dict):
135
+ checked = _parse_ts(hit.get("checked_at", ""))
136
+ created = _parse_ts(hit.get("created") or "")
137
+ ttl = KNOWN_TTL if created else UNKNOWN_TTL
138
+ if checked and _now() - checked < ttl:
139
+ return (_now() - created).days if created else None
140
+ if not allow_net:
141
+ return None
142
+ fetch = fetch or _http_get
143
+ created = None
144
+ base = _rdap_base(domain.rsplit(".", 1)[1], data, fetch)
145
+ if base:
146
+ try:
147
+ created = registration_date(fetch(base + "domain/" + domain))
148
+ except Exception:
149
+ created = None
150
+ data[domain] = {"created": created.isoformat(timespec="seconds") if created else None,
151
+ "checked_at": _now().isoformat(timespec="seconds")}
152
+ _save(data)
153
+ return (_now() - created).days if created else None
scamgate/engine.py ADDED
@@ -0,0 +1,145 @@
1
+ """Run registered rules and turn hits into a verdict."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from . import feeds as feeds_mod
8
+ from . import overrides as overrides_mod
9
+ from . import payees as payees_mod
10
+ from .config import load_blocklist, load_config, rule_cfg
11
+ from .models import Decision, PaymentCheck, Reason, UrlCheck, Verdict
12
+ from .registry import Hit, Kind, RuleContext, rules_for
13
+
14
+
15
+ def _apply_overrides(url: str, reasons: list[Reason], items: list[dict], warnings: list[str]) -> list[Reason]:
16
+ from .rules.url import parse
17
+
18
+ host = parse(url).host
19
+ if not host:
20
+ return reasons
21
+ if o := overrides_mod.deny_match(host, items):
22
+ reasons.append(Reason(rule_id="url.override", weight=100,
23
+ explanation=f"You added a deny rule for {o['host']} on {o['added_at'][:10]}."))
24
+ waived = overrides_mod.waived_rules(host, items)
25
+ kept = [r for r in reasons if r.rule_id not in waived]
26
+ if len(kept) != len(reasons):
27
+ names = sorted({r.rule_id for r in reasons} & waived)
28
+ warnings.append(f"Your allow rule for {host} waived: {', '.join(names)}.")
29
+ return kept
30
+
31
+
32
+ def _run(kind: Kind, check: Any, config: dict[str, Any] | None,
33
+ blocklist: frozenset[str] | None, feeds: Any = None,
34
+ extra_warnings: list[str] | None = None) -> Verdict:
35
+ config = config or load_config()
36
+ blocklist = load_blocklist() if blocklist is None else blocklist
37
+ warnings: list[str] = []
38
+ if kind == "url":
39
+ feeds = feeds_mod.current_index() if feeds is None else feeds
40
+ if not feeds:
41
+ warnings.append("No known-bad-link feeds are cached. " + feeds_mod.REFRESH_HINT)
42
+ elif stale := feeds.stale_feeds():
43
+ warnings.append(f"Feed cache older than {feeds_mod.STALE_AFTER_HOURS}h for: "
44
+ f"{', '.join(sorted(stale))}. " + feeds_mod.REFRESH_HINT)
45
+ unreadable, checked_host = False, None
46
+ if kind == "url":
47
+ from .rules.url import parse
48
+
49
+ parsed = parse(check.url)
50
+ # Nothing else can judge a link scamgate can't read, and its text must not reach a reason.
51
+ unreadable = bool(parsed.unreadable)
52
+ checked_host = parsed.host or None
53
+ reasons: list[Reason] = []
54
+ for rule in rules_for(kind):
55
+ if unreadable and rule.rule_id != "url.unreadable":
56
+ continue
57
+ rc = rule_cfg(config, rule.rule_id)
58
+ if not rc.get("enabled", False):
59
+ continue
60
+ out = rule.fn(check, RuleContext(config=config, rule_config=rc, blocklist=blocklist, feeds=feeds))
61
+ hits: list[Hit] = [] if out is None else (out if isinstance(out, list) else [out])
62
+ for h in hits:
63
+ w = h.weight if h.weight is not None else int(rc.get("weight", 0))
64
+ if w <= 0:
65
+ continue
66
+ reasons.append(Reason(rule_id=rule.rule_id, explanation=h.explanation, weight=min(w, 100)))
67
+
68
+ if kind == "url":
69
+ items, bad = overrides_mod.load_checked()
70
+ if bad:
71
+ warnings.append(f"{bad} of your scamgate override rules can't be verified (its signing key is missing or "
72
+ "was changed, or the line was edited by hand), so they're ignored. Check the scamgate "
73
+ "state dir.")
74
+ if items and not unreadable:
75
+ reasons = _apply_overrides(check.url, reasons, items, warnings)
76
+ warnings += extra_warnings or []
77
+
78
+ reasons.sort(key=lambda r: r.weight, reverse=True)
79
+ score = min(100, sum(r.weight for r in reasons))
80
+ th = config.get("thresholds", {})
81
+ if score >= int(th.get("deny", 70)):
82
+ decision = Decision.deny
83
+ elif score >= int(th.get("ask", 25)):
84
+ decision = Decision.ask
85
+ else:
86
+ decision = Decision.allow
87
+ return Verdict(decision=decision, risk_score=score, reasons=reasons,
88
+ summary=_summary(kind, decision, reasons), checked_host=checked_host, warnings=warnings)
89
+
90
+
91
+ def _summary(kind: Kind, decision: Decision, reasons: list[Reason]) -> str:
92
+ thing = "link" if kind == "url" else "payment"
93
+ if decision is Decision.allow and not reasons:
94
+ return f"No scam signals found for this {thing}."
95
+ top = reasons[0].explanation if reasons else ""
96
+ if decision is Decision.allow:
97
+ return f"Low risk {thing}, but note: {top}"
98
+ if decision is Decision.ask:
99
+ return f"Check with the human before this {thing}. Main concern: {top}"
100
+ return f"Do not proceed with this {thing}. Main concern: {top}"
101
+
102
+
103
+ def check_url(url: str, intent: str | None = None, *, config: dict[str, Any] | None = None,
104
+ blocklist: frozenset[str] | None = None, feeds: Any = None) -> Verdict:
105
+ """feeds: a FeedIndex; defaults to the local cache (pass FeedIndex() for none)."""
106
+ return _run("url", UrlCheck(url=url, intent=intent), config, blocklist, feeds)
107
+
108
+
109
+ def check_payment(check: PaymentCheck, *, config: dict[str, Any] | None = None,
110
+ use_history: bool = True) -> Verdict:
111
+ """Payee facts come from local history (`scamgate record-payment`), not the agent's claims."""
112
+ warnings: list[str] = []
113
+ if use_history:
114
+ check, warnings = _with_history(check)
115
+ return _run("payment", check, config, frozenset(), extra_warnings=warnings)
116
+
117
+
118
+ def _with_history(check: PaymentCheck) -> tuple[PaymentCheck, list[str]]:
119
+ facts = payees_mod.lookup(check.payee_name, check.payee_account)
120
+ warnings: list[str] = []
121
+ claimed_known = False
122
+ if facts.history_broken:
123
+ new, changed = True, True
124
+ warnings.append("Your payee history can't be verified (its key or salt is missing or was changed), so "
125
+ "every payee is treated as new with changed details. Check the scamgate state dir.")
126
+ elif facts.known_account:
127
+ new, changed = False, bool(check.bank_details_changed)
128
+ elif facts.name_seen_with_other_account:
129
+ new, changed = False, True
130
+ warnings.append(f"You've paid '{check.payee_name}' before, but never to this account.")
131
+ elif facts.near_name:
132
+ new, changed = True, True
133
+ warnings.append(f"'{check.payee_name}' looks like a payee you've paid before, but this name and account "
134
+ "are new. Lookalike payee names are a common invoice-redirection trick.")
135
+ else:
136
+ new, changed = True, bool(check.bank_details_changed)
137
+ if check.is_new_payee is False:
138
+ claimed_known = True
139
+ warnings.append(f"The agent said '{check.payee_name}' is a known payee, but you've never paid them "
140
+ "from here (no record). Treating them as new.")
141
+ if facts.account_under_other_name:
142
+ changed = True
143
+ warnings.append("You've paid this account before under a different name. Confirm who really owns it.")
144
+ upd = {"is_new_payee": new, "bank_details_changed": changed, "agent_claim_unbacked": claimed_known}
145
+ return check.model_copy(update=upd), warnings
scamgate/entry.py ADDED
@@ -0,0 +1,19 @@
1
+ """The `scamgate` console script.
2
+
3
+ `scamgate hook` runs once per agent tool call (the Claude Code plugin calls `uvx scamgate==X hook`), so it
4
+ skips the typer/rich command line and goes straight to the hook. Every other command loads the full CLI.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+
11
+
12
+ def main() -> None:
13
+ if sys.argv[1:] == ["hook"]:
14
+ from .hook import main as run_hook
15
+
16
+ sys.exit(run_hook())
17
+ from .cli import app
18
+
19
+ app()