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/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
|