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/refresh.py ADDED
@@ -0,0 +1,233 @@
1
+ """Refreshing the known-bad-link feeds without extra packages.
2
+
3
+ The Hermes plugin's `/scamgate update`, its `scamgate_update_feeds` tool, `hermes scamgate
4
+ update-feeds` and the background refresh all come through `refresh()`. None of them takes a feed
5
+ name, a URL or a folder from the caller: it always downloads the default feeds from their fixed
6
+ sources into the configured cache. An agent can ask for a refresh, but can't choose what lands in
7
+ the cache.
8
+
9
+ A lock file in the cache folder makes sure only one refresh runs at a time, however many Hermes
10
+ processes (gateway, chats, cron jobs) have the plugin loaded.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import os
17
+ import threading
18
+ import time
19
+ from contextlib import contextmanager
20
+ from dataclasses import dataclass, field
21
+ from datetime import datetime
22
+ from typing import Callable, Iterator
23
+
24
+ from . import feeds
25
+
26
+ AUTO_REFRESH_HOURS = 6.0 # the background refresh downloads again once the lists are this old
27
+ TOOL_MIN_GAP_HOURS = 1.0 # the agent tool doesn't download again within this time
28
+ LOCK_NAME = ".update.lock"
29
+ LOCK_STALE_SECONDS = 30 * 60 # a lock older than this was left by a refresh that died
30
+ OFF_VALUES = ("0", "false", "off", "no")
31
+ ON_VALUES = ("1", "true", "on", "yes")
32
+ _log = logging.getLogger("scamgate")
33
+
34
+
35
+ def env_flag(name: str, default: bool) -> bool:
36
+ """A yes/no environment setting. Unset, empty or unrecognised values (such as an unfilled
37
+ `${user_config.KEY}` placeholder) mean `default`."""
38
+ value = (os.environ.get(name) or "").strip().lower()
39
+ if value in OFF_VALUES:
40
+ return False
41
+ if value in ON_VALUES:
42
+ return True
43
+ return default
44
+
45
+
46
+ @dataclass
47
+ class Outcome:
48
+ updated: list[str] = field(default_factory=list)
49
+ failed: dict[str, str] = field(default_factory=dict)
50
+ not_configured: dict[str, str] = field(default_factory=dict)
51
+ counts: dict[str, dict[str, int]] = field(default_factory=dict)
52
+ skipped: str = "" # "fresh" | "busy" when nothing was downloaded on purpose
53
+
54
+ @property
55
+ def ok(self) -> bool:
56
+ return bool(self.updated) or self.skipped in ("fresh", "busy")
57
+
58
+ def as_dict(self) -> dict:
59
+ return {"updated": self.updated, "failed": self.failed, "not_configured": self.not_configured,
60
+ "counts": self.counts, "skipped": self.skipped, "cache": str(feeds.cache_dir())}
61
+
62
+ def text(self) -> str:
63
+ if self.skipped == "fresh":
64
+ return f"scamgate: the phishing lists were refreshed less than {TOOL_MIN_GAP_HOURS:g} hour ago."
65
+ if self.skipped == "busy":
66
+ return "scamgate: another refresh is already running; the lists will update when it finishes."
67
+ lines = []
68
+ for name in self.updated:
69
+ c = self.counts.get(name, {})
70
+ lines.append(f"{name}: updated ({c.get('urls', 0)} links, {c.get('hosts', 0)} hosts)")
71
+ for name, why in self.failed.items():
72
+ lines.append(f"{name}: not updated, kept the old list ({why})")
73
+ for name, why in self.not_configured.items():
74
+ lines.append(f"{name}: {why}")
75
+ head = "scamgate: phishing lists refreshed." if self.updated else "scamgate: no list could be refreshed."
76
+ return head + "\n" + "\n".join(lines)
77
+
78
+
79
+ def _configured_defaults() -> list[str]:
80
+ """Default feeds that can be downloaded here (URLhaus only counts once its key is set)."""
81
+ return [n for n, f in feeds.FEEDS.items() if f.default and f.url() is not None]
82
+
83
+
84
+ def oldest_age_hours(now: float | None = None) -> float:
85
+ """Age of the oldest default feed in hours; infinity when one was never downloaded."""
86
+ now = now or time.time()
87
+ meta = feeds.read_meta()
88
+ oldest = 0.0
89
+ for name in _configured_defaults():
90
+ ts = meta.get(name, {}).get("fetched_at")
91
+ try:
92
+ age = (now - datetime.fromisoformat(ts).timestamp()) / 3600 if ts else float("inf")
93
+ except ValueError:
94
+ age = float("inf")
95
+ oldest = max(oldest, age)
96
+ return oldest
97
+
98
+
99
+ @contextmanager
100
+ def _lock() -> Iterator[bool]:
101
+ d = feeds.cache_dir()
102
+ d.mkdir(parents=True, exist_ok=True)
103
+ path = d / LOCK_NAME
104
+ try:
105
+ if time.time() - path.stat().st_mtime > LOCK_STALE_SECONDS:
106
+ path.unlink(missing_ok=True)
107
+ except OSError:
108
+ pass
109
+ try:
110
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
111
+ except FileExistsError:
112
+ yield False
113
+ return
114
+ with os.fdopen(fd, "w") as f:
115
+ f.write(str(os.getpid()))
116
+ try:
117
+ yield True
118
+ finally:
119
+ path.unlink(missing_ok=True)
120
+
121
+
122
+ def refresh(*, if_older_than_hours: float | None = None) -> Outcome:
123
+ """Download the default feeds into the cache. Never raises for network or parse errors."""
124
+ if if_older_than_hours is not None and oldest_age_hours() < if_older_than_hours:
125
+ return Outcome(skipped="fresh")
126
+ with _lock() as got:
127
+ if not got:
128
+ return Outcome(skipped="busy")
129
+ results = feeds.update_feeds()
130
+ out = Outcome()
131
+ for r in results:
132
+ if r.ok:
133
+ out.updated.append(r.name)
134
+ out.counts[r.name] = {"urls": r.urls, "hosts": r.hosts}
135
+ elif r.message.startswith("skipped"):
136
+ out.not_configured[r.name] = r.message
137
+ else:
138
+ out.failed[r.name] = r.message
139
+ return out
140
+
141
+
142
+ def auto_pass() -> Outcome:
143
+ """One pass of the background refresh: download only when the lists are 6 hours old."""
144
+ return refresh(if_older_than_hours=AUTO_REFRESH_HOURS)
145
+
146
+
147
+ # ---------------------------------------------------------------- background refresh thread
148
+
149
+ _BG_LOCK = threading.Lock()
150
+ _BG: tuple[threading.Thread, threading.Event] | None = None
151
+
152
+
153
+ def _background_loop(stop: threading.Event, first_delay: float, every: float) -> None:
154
+ if stop.wait(first_delay):
155
+ return
156
+ while True:
157
+ try:
158
+ out = auto_pass()
159
+ if out.failed:
160
+ _log.warning("scamgate: background feed refresh failed for %s", ", ".join(out.failed))
161
+ except Exception:
162
+ _log.warning("scamgate: background feed refresh failed", exc_info=True)
163
+ if stop.wait(every):
164
+ return
165
+
166
+
167
+ def start_background(*, first_delay: float, every: float,
168
+ on_unload: Callable[[Callable[[], None]], object] | None = None) -> threading.Thread:
169
+ """One daemon refresh thread per process (the Hermes plugin, or `scamgate serve` with
170
+ SCAMGATE_AUTO_UPDATE on). Processes share the lock file, so only one downloads at a time."""
171
+ global _BG
172
+ with _BG_LOCK:
173
+ if _BG is not None and _BG[0].is_alive() and not _BG[1].is_set():
174
+ return _BG[0]
175
+ stop = threading.Event()
176
+ thread = threading.Thread(target=_background_loop, args=(stop, first_delay, every),
177
+ name="scamgate-feed-refresh", daemon=True)
178
+ _BG = (thread, stop)
179
+ thread.start()
180
+ if callable(on_unload):
181
+ try:
182
+ on_unload(stop.set)
183
+ except Exception:
184
+ pass
185
+ return thread
186
+
187
+
188
+ def status() -> tuple[bool, list[str]]:
189
+ """(any feeds cached, one line per cached feed)."""
190
+ meta = {k: v for k, v in feeds.read_meta().items() if k in feeds.FEEDS}
191
+ if not meta:
192
+ return False, []
193
+ idx = feeds.FeedIndex(fetched_at={k: v.get("fetched_at", "") for k, v in meta.items()})
194
+ stale = set(idx.stale_feeds())
195
+ lines = [f"{name}: {m.get('urls', 0)} links, {m.get('hosts', 0)} hosts, fetched {m.get('fetched_at')}"
196
+ + (" (stale)" if name in stale else "") for name, m in meta.items()]
197
+ return True, lines
198
+
199
+
200
+ # ---------------------------------------------------------------- `hermes scamgate ...`
201
+
202
+ def setup_cli(parser) -> None:
203
+ """Builds `hermes scamgate ...`. Hermes sets `func=cli_main` on this parser."""
204
+ sub = parser.add_subparsers(dest="scamgate_command", metavar="COMMAND")
205
+ sub.required = True
206
+ up = sub.add_parser("update-feeds", help="Download the default phishing lists into the cache now")
207
+ up.set_defaults(scamgate_run=_cli_update)
208
+ st = sub.add_parser("feeds-status", help="Show the cached phishing lists and their age")
209
+ st.set_defaults(scamgate_run=_cli_status)
210
+
211
+
212
+ def _cli_update(args) -> int:
213
+ out = refresh()
214
+ print(out.text())
215
+ print(f"cache: {feeds.cache_dir()}")
216
+ return 0 if out.ok else 1
217
+
218
+
219
+ def _cli_status(args) -> int:
220
+ any_cached, lines = status()
221
+ if not any_cached:
222
+ print("No phishing lists cached yet. Run `hermes scamgate update-feeds`, or `/scamgate update` in a chat.")
223
+ return 1
224
+ print("\n".join(lines))
225
+ return 0
226
+
227
+
228
+ def cli_main(args) -> int:
229
+ run = getattr(args, "scamgate_run", None)
230
+ if run is None:
231
+ print("Usage: hermes scamgate update-feeds | feeds-status")
232
+ return 2
233
+ return run(args)
scamgate/registry.py ADDED
@@ -0,0 +1,66 @@
1
+ """A tiny rule registry.
2
+
3
+ Add a rule by decorating a function:
4
+
5
+ @register("pay.my_rule", kind="payment")
6
+ def my_rule(check: PaymentCheck, ctx: RuleContext) -> Hit | None:
7
+ ...
8
+
9
+ Then give it an entry under `rules:` in config/rules.yaml (enabled + weight).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field
15
+ from typing import Any, Callable, Literal
16
+
17
+ Kind = Literal["url", "payment"]
18
+
19
+
20
+ @dataclass
21
+ class Hit:
22
+ explanation: str
23
+ weight: int | None = None # None -> use the rule's configured weight
24
+
25
+
26
+ @dataclass
27
+ class RuleContext:
28
+ config: dict[str, Any]
29
+ rule_config: dict[str, Any]
30
+ blocklist: frozenset[str] = field(default_factory=frozenset)
31
+ feeds: Any = None # scamgate.feeds.FeedIndex | None
32
+
33
+
34
+ RuleFn = Callable[[Any, RuleContext], "Hit | list[Hit] | None"]
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Rule:
39
+ rule_id: str
40
+ kind: Kind
41
+ fn: RuleFn
42
+ description: str
43
+
44
+
45
+ _REGISTRY: dict[str, Rule] = {}
46
+
47
+
48
+ def register(rule_id: str, kind: Kind) -> Callable[[RuleFn], RuleFn]:
49
+ def deco(fn: RuleFn) -> RuleFn:
50
+ if rule_id in _REGISTRY:
51
+ raise ValueError(f"duplicate rule id: {rule_id}")
52
+ _REGISTRY[rule_id] = Rule(rule_id, kind, fn, (fn.__doc__ or "").strip())
53
+ return fn
54
+
55
+ return deco
56
+
57
+
58
+ def rules_for(kind: Kind) -> list[Rule]:
59
+ # Importing the rule modules populates the registry.
60
+ from .rules import payment, url # noqa: F401
61
+
62
+ return [r for r in _REGISTRY.values() if r.kind == kind]
63
+
64
+
65
+ def all_rules() -> list[Rule]:
66
+ return rules_for("url") + rules_for("payment")
@@ -0,0 +1 @@
1
+ """Rule modules. Each module registers its rules on import."""
@@ -0,0 +1,127 @@
1
+ """Payment rules, based on common push-payment and purchase-scam typologies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from ..config import lst
8
+ from ..models import PaymentCheck, PaymentMethod, VopResult
9
+ from ..registry import Hit, RuleContext, register
10
+
11
+
12
+ def _text(check: PaymentCheck) -> str:
13
+ return re.sub(r"\s+", " ", (check.context_text or "").lower().replace("’", "'"))
14
+
15
+
16
+ def _find(text: str, phrases: list[str]) -> list[str]:
17
+ return [p for p in phrases if re.search(rf"(?<![a-z]){re.escape(p)}(?![a-z])", text)]
18
+
19
+
20
+ def _quote(found: list[str], n: int = 3) -> str:
21
+ return ", ".join(f"'{f}'" for f in found[:n])
22
+
23
+
24
+ @register("pay.new_payee_urgency", kind="payment")
25
+ def new_payee_urgency(check: PaymentCheck, ctx: RuleContext) -> Hit | None:
26
+ """First payment to this payee, and the request is urgent."""
27
+ if not check.is_new_payee:
28
+ return None
29
+ found = _find(_text(check), lst(ctx.config, "urgency_terms"))
30
+ if found:
31
+ return Hit(
32
+ f"This is a new payee and the request is urgent ({_quote(found)}). "
33
+ "Scammers rush people so they skip checks."
34
+ )
35
+ return None
36
+
37
+
38
+ @register("pay.bank_details_changed", kind="payment")
39
+ def bank_details_changed(check: PaymentCheck, ctx: RuleContext) -> Hit | None:
40
+ """Payee's bank details changed recently (invoice-redirection pattern)."""
41
+ found = _find(_text(check), lst(ctx.config, "bank_change_phrases"))
42
+ if check.bank_details_changed or found:
43
+ why = f" (message says {_quote(found, 1)})" if found else ""
44
+ return Hit(
45
+ f"The payee's bank details have changed{why}. Invoice-redirection fraud works "
46
+ "exactly like this; confirm the new account by phone using a number you already had."
47
+ )
48
+ return None
49
+
50
+
51
+ @register("pay.irreversible_method", kind="payment")
52
+ def irreversible_method(check: PaymentCheck, ctx: RuleContext) -> Hit | None:
53
+ """Payment method is hard or impossible to reverse."""
54
+ if check.method in (PaymentMethod.crypto, PaymentMethod.gift_card):
55
+ label = "crypto" if check.method is PaymentMethod.crypto else "gift cards"
56
+ return Hit(f"Paying by {label} is effectively irreversible, and it's the method scammers ask for most.")
57
+ if check.method is PaymentMethod.bank_transfer and check.is_new_payee:
58
+ if check.vop_result is not None and check.vop_result.value == "match":
59
+ return Hit("A bank transfer to a new payee is hard to recall once sent. Your bank confirmed the "
60
+ "name matches the account, which helps.",
61
+ weight=int(ctx.rule_config.get("new_payee_transfer_vop_match_weight", 10)))
62
+ return Hit(
63
+ "A bank transfer to a new payee is hard to recall once sent. Check the name matches the account "
64
+ "with your bank's Verification of Payee, or confirm the details by phone, before sending.",
65
+ weight=int(ctx.rule_config.get("new_payee_transfer_weight", 25)),
66
+ )
67
+ return None
68
+
69
+
70
+ @register("pay.method_restricted", kind="payment")
71
+ def method_restricted(check: PaymentCheck, ctx: RuleContext) -> Hit | None:
72
+ """Seller insists on a specific irreversible payment method."""
73
+ found = _find(_text(check), lst(ctx.config, "method_restriction_phrases"))
74
+ if found:
75
+ return Hit(
76
+ f"The seller restricts how you can pay ({_quote(found)}). Legit sellers rarely "
77
+ "refuse protected methods like cards."
78
+ )
79
+ return None
80
+
81
+
82
+ @register("pay.over_budget", kind="payment")
83
+ def over_budget(check: PaymentCheck, ctx: RuleContext) -> Hit | None:
84
+ """Amount exceeds the budget the human gave the agent."""
85
+ if check.budget is not None and check.amount > check.budget:
86
+ return Hit(
87
+ f"{check.amount:,.2f} {check.currency} is over the budget of "
88
+ f"{check.budget:,.2f} {check.currency}."
89
+ )
90
+ return None
91
+
92
+
93
+ @register("pay.pressure_phrases", kind="payment")
94
+ def pressure_phrases(check: PaymentCheck, ctx: RuleContext) -> Hit | None:
95
+ """Messages use pressure, secrecy, or too-good-to-be-true language."""
96
+ found = _find(_text(check), lst(ctx.config, "pressure_phrases"))
97
+ if found:
98
+ return Hit(
99
+ f"The messages use pressure or secrecy language ({_quote(found)}), "
100
+ "typical of CEO-fraud, impersonation and investment scams."
101
+ )
102
+ return None
103
+
104
+
105
+ @register("pay.vop_mismatch", kind="payment")
106
+ def vop_mismatch(check: PaymentCheck, ctx: RuleContext) -> Hit | None:
107
+ """Verification of Payee says the name doesn't match the account."""
108
+ if check.vop_result is None:
109
+ return None
110
+ weights = ctx.rule_config.get("weights", {})
111
+ w = int(weights.get(check.vop_result.value, 0))
112
+ msg = {
113
+ VopResult.no_match: f"Verification of Payee says '{check.payee_name}' does NOT match the account holder.",
114
+ VopResult.close_match: f"Verification of Payee only partly matches '{check.payee_name}'; check the exact name.",
115
+ VopResult.unavailable: "Verification of Payee wasn't available, so the payee name is unconfirmed.",
116
+ VopResult.match: "Verification of Payee matched.",
117
+ }[check.vop_result]
118
+ return Hit(msg, weight=w) if w > 0 else None
119
+
120
+
121
+ @register("pay.unbacked_known_claim", kind="payment")
122
+ def unbacked_known_claim(check: PaymentCheck, ctx: RuleContext) -> Hit | None:
123
+ """The agent said this is a known payee, but there's no record of paying them."""
124
+ if check.agent_claim_unbacked:
125
+ return Hit("The agent described this as a payee you know, but you've never paid them from here. "
126
+ "Scam messages often claim familiarity to skip checks.")
127
+ return None