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/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""scamgate: a self-hosted scam check for AI agents."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.3.3"
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def register(ctx):
|
|
7
|
+
"""Hermes plugin entry. Imported lazily so a normal install never loads the plugin."""
|
|
8
|
+
from .plugin import register as _register
|
|
9
|
+
|
|
10
|
+
return _register(ctx)
|
|
11
|
+
|
scamgate/asklog.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Ask log: every gated call scamgate escalated or blocked, plus what happened next.
|
|
2
|
+
|
|
3
|
+
Two append-only JSONL files in the state dir:
|
|
4
|
+
- asks.jsonl: one entry per flagged call (redacted targets, rules, score, agent's stated reason)
|
|
5
|
+
- outcomes.jsonl: whether the call then ran (human approved) or didn't (human refused / blocked)
|
|
6
|
+
|
|
7
|
+
The agent's stated reason is stored for a human to read. It is marked untrusted and is never used
|
|
8
|
+
to change rules automatically: injected page text can write it.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import time
|
|
17
|
+
from collections import defaultdict
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from datetime import datetime, timedelta, timezone
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from .redact import redact_text, redact_url
|
|
23
|
+
from .state import ensure_state_dir, state_dir
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def log_path() -> Path:
|
|
27
|
+
return state_dir() / "asks.jsonl"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def outcomes_path() -> Path:
|
|
31
|
+
return state_dir() / "outcomes.jsonl"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _pending_path(key: str) -> Path:
|
|
35
|
+
return state_dir() / "pending" / key
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _append(path: Path, rec: dict) -> None:
|
|
39
|
+
from .guard import append_private # only you can read which sites were asked about
|
|
40
|
+
|
|
41
|
+
append_private(path, json.dumps(rec, ensure_ascii=False))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def make_key(session: str, call_id: str) -> str:
|
|
45
|
+
return hashlib.sha256(f"{session}\0{call_id}".encode()).hexdigest()[:16]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def log_check(*, tool: str, decision: str, risk_score: int, rule_ids: list[str], targets: list[str],
|
|
49
|
+
hosts: list[str], stated_reason: str | None, session: str, call_id: str,
|
|
50
|
+
agent: str = "hermes") -> str:
|
|
51
|
+
key = make_key(session, call_id or f"{time.time_ns()}")
|
|
52
|
+
_append(log_path(), {
|
|
53
|
+
"ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
54
|
+
"key": key,
|
|
55
|
+
"agent": agent,
|
|
56
|
+
"tool": tool,
|
|
57
|
+
"decision": decision,
|
|
58
|
+
"risk_score": risk_score,
|
|
59
|
+
"rules": sorted(set(rule_ids)),
|
|
60
|
+
"hosts": sorted(set(hosts)),
|
|
61
|
+
"targets": [redact_url(t) for t in targets[:5]],
|
|
62
|
+
"stated_reason": redact_text(stated_reason or "", limit=80),
|
|
63
|
+
"stated_reason_trust": "untrusted",
|
|
64
|
+
})
|
|
65
|
+
if decision in ("approve", "ask"):
|
|
66
|
+
p = _pending_path(key)
|
|
67
|
+
ensure_state_dir()
|
|
68
|
+
p.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
69
|
+
os.close(os.open(p, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600))
|
|
70
|
+
return key
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def record_outcome(key: str, outcome: str) -> bool:
|
|
74
|
+
"""outcome: 'ran' (human approved) or 'not_run' (refused, timed out, or blocked)."""
|
|
75
|
+
p = _pending_path(key)
|
|
76
|
+
if not p.exists():
|
|
77
|
+
return False
|
|
78
|
+
_append(outcomes_path(), {"ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
79
|
+
"key": key, "outcome": outcome})
|
|
80
|
+
p.unlink(missing_ok=True)
|
|
81
|
+
return True
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _read(path: Path) -> list[dict]:
|
|
85
|
+
if not path.exists():
|
|
86
|
+
return []
|
|
87
|
+
out = []
|
|
88
|
+
for line in path.read_text("utf-8").splitlines():
|
|
89
|
+
try:
|
|
90
|
+
out.append(json.loads(line))
|
|
91
|
+
except json.JSONDecodeError:
|
|
92
|
+
continue
|
|
93
|
+
return out
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def read_entries() -> list[dict]:
|
|
97
|
+
return _read(log_path())
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def read_outcomes() -> dict[str, str]:
|
|
101
|
+
return {o["key"]: o["outcome"] for o in _read(outcomes_path()) if "key" in o}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass
|
|
105
|
+
class ReviewRow:
|
|
106
|
+
host: str
|
|
107
|
+
asks: int = 0
|
|
108
|
+
blocks: int = 0
|
|
109
|
+
ran: int = 0
|
|
110
|
+
not_run: int = 0
|
|
111
|
+
pending: int = 0
|
|
112
|
+
rules: dict[str, int] = field(default_factory=dict)
|
|
113
|
+
tools: dict[str, int] = field(default_factory=dict)
|
|
114
|
+
sample_reasons: list[str] = field(default_factory=list)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def summarize(days: int = 7, now: datetime | None = None) -> list[ReviewRow]:
|
|
118
|
+
now = now or datetime.now(timezone.utc)
|
|
119
|
+
cutoff = now - timedelta(days=days)
|
|
120
|
+
outcomes = read_outcomes()
|
|
121
|
+
rows: dict[str, ReviewRow] = {}
|
|
122
|
+
rule_counts: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
|
123
|
+
tool_counts: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
|
124
|
+
for e in read_entries():
|
|
125
|
+
try:
|
|
126
|
+
ts = datetime.fromisoformat(e["ts"])
|
|
127
|
+
except (KeyError, ValueError):
|
|
128
|
+
continue
|
|
129
|
+
if ts < cutoff:
|
|
130
|
+
continue
|
|
131
|
+
for host in e.get("hosts") or ["<no host>"]:
|
|
132
|
+
row = rows.setdefault(host, ReviewRow(host))
|
|
133
|
+
if e.get("decision") == "block":
|
|
134
|
+
row.blocks += 1
|
|
135
|
+
else:
|
|
136
|
+
row.asks += 1
|
|
137
|
+
o = outcomes.get(e.get("key", ""))
|
|
138
|
+
if o == "ran":
|
|
139
|
+
row.ran += 1
|
|
140
|
+
elif o == "not_run":
|
|
141
|
+
row.not_run += 1
|
|
142
|
+
else:
|
|
143
|
+
row.pending += 1
|
|
144
|
+
for r in e.get("rules", []):
|
|
145
|
+
rule_counts[host][r] += 1
|
|
146
|
+
tool_counts[host][e.get("tool", "?")] += 1
|
|
147
|
+
reason = e.get("stated_reason")
|
|
148
|
+
if reason and reason not in row.sample_reasons and len(row.sample_reasons) < 3:
|
|
149
|
+
row.sample_reasons.append(reason)
|
|
150
|
+
for host, row in rows.items():
|
|
151
|
+
row.rules = dict(sorted(rule_counts[host].items(), key=lambda kv: -kv[1]))
|
|
152
|
+
row.tools = dict(sorted(tool_counts[host].items(), key=lambda kv: -kv[1]))
|
|
153
|
+
return sorted(rows.values(), key=lambda r: (-(r.asks + r.blocks), r.host))
|
scamgate/bench.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Run YAML scenarios through the engine and score the results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from . import engine
|
|
11
|
+
from .models import Decision, PaymentCheck, Verdict
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Result:
|
|
16
|
+
scenario_id: str
|
|
17
|
+
name: str
|
|
18
|
+
label: str # scam | legit
|
|
19
|
+
expected: Decision
|
|
20
|
+
verdict: Verdict
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def flagged(self) -> bool:
|
|
24
|
+
return self.verdict.decision is not Decision.allow
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def outcome(self) -> str:
|
|
28
|
+
if self.label == "scam":
|
|
29
|
+
return "caught" if self.flagged else "missed"
|
|
30
|
+
# A legit case may still be *expected* to ask (e.g. over budget); that is not a false positive.
|
|
31
|
+
if self.flagged and self.expected is Decision.allow:
|
|
32
|
+
return "false_positive"
|
|
33
|
+
return "ok"
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def exact(self) -> bool:
|
|
37
|
+
return self.verdict.decision is self.expected
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load_scenarios(directory: Path) -> list[dict]:
|
|
41
|
+
out = []
|
|
42
|
+
for f in sorted(directory.glob("*.yaml")):
|
|
43
|
+
data = yaml.safe_load(f.read_text("utf-8"))
|
|
44
|
+
data.setdefault("id", f.stem)
|
|
45
|
+
out.append(data)
|
|
46
|
+
return out
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def run_scenario(s: dict) -> Result:
|
|
50
|
+
kind = s["kind"]
|
|
51
|
+
inp = s["input"]
|
|
52
|
+
if kind == "url":
|
|
53
|
+
verdict = engine.check_url(inp["url"], inp.get("intent"))
|
|
54
|
+
elif kind == "payment":
|
|
55
|
+
verdict = engine.check_payment(PaymentCheck(**inp), use_history=False) # scenario input is ground truth
|
|
56
|
+
else:
|
|
57
|
+
raise ValueError(f"{s['id']}: unknown kind {kind!r}")
|
|
58
|
+
label = s["label"]
|
|
59
|
+
if label not in ("scam", "legit"):
|
|
60
|
+
raise ValueError(f"{s['id']}: label must be scam or legit")
|
|
61
|
+
return Result(s["id"], s.get("name", s["id"]), label, Decision(s["expected"]), verdict)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def run_all(directory: Path) -> list[Result]:
|
|
65
|
+
return [run_scenario(s) for s in load_scenarios(directory)]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def summarize(results: list[Result]) -> dict[str, int]:
|
|
69
|
+
return {
|
|
70
|
+
"total": len(results),
|
|
71
|
+
"scams": sum(r.label == "scam" for r in results),
|
|
72
|
+
"legit": sum(r.label == "legit" for r in results),
|
|
73
|
+
"caught": sum(r.outcome == "caught" for r in results),
|
|
74
|
+
"missed": sum(r.outcome == "missed" for r in results),
|
|
75
|
+
"false_positives": sum(r.outcome == "false_positive" for r in results),
|
|
76
|
+
"exact_decision_match": sum(r.exact for r in results),
|
|
77
|
+
}
|
scamgate/cli.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""scamgate command line."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from . import bench as bench_mod
|
|
14
|
+
from . import engine
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(help="Self-hosted scam check for AI agents.", no_args_is_help=True)
|
|
17
|
+
console = Console()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.command()
|
|
21
|
+
def serve() -> None:
|
|
22
|
+
"""Run the MCP server over stdio."""
|
|
23
|
+
from .server import run
|
|
24
|
+
|
|
25
|
+
run()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@app.command("check-url")
|
|
29
|
+
def check_url_cmd(url: str, intent: Optional[str] = typer.Option(None, help="What the agent is trying to do")) -> None:
|
|
30
|
+
"""Check one URL and print the verdict as JSON."""
|
|
31
|
+
console.print_json(json.dumps(engine.check_url(url, intent).model_dump(mode="json")))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.command("update-feeds")
|
|
35
|
+
def update_feeds_cmd(
|
|
36
|
+
feed: list[str] = typer.Option(None, "--feed", "-f", help="Feed(s) to fetch; default: all default feeds"),
|
|
37
|
+
all_feeds: bool = typer.Option(False, "--all", help="Also fetch opt-in feeds (large Phishing.Database links list)"),
|
|
38
|
+
cache: Optional[Path] = typer.Option(None, "--cache-dir", help="Cache folder (default $SCAMGATE_CACHE_DIR or ~/.cache/scamgate)"),
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Download known-bad-link feeds into the local cache. Failed feeds keep their old cache."""
|
|
41
|
+
from . import feeds as feeds_mod
|
|
42
|
+
|
|
43
|
+
names = list(feed) if feed else ([*feeds_mod.FEEDS] if all_feeds else None)
|
|
44
|
+
results = feeds_mod.update_feeds(names, directory=cache)
|
|
45
|
+
table = Table(title=f"feeds -> {cache or feeds_mod.cache_dir()}")
|
|
46
|
+
for col in ("feed", "status", "urls", "hosts", "note"):
|
|
47
|
+
table.add_column(col)
|
|
48
|
+
for r in results:
|
|
49
|
+
table.add_row(r.name, "[green]ok[/green]" if r.ok else "[yellow]not updated[/yellow]",
|
|
50
|
+
str(r.urls) if r.ok else "", str(r.hosts) if r.ok else "", r.message)
|
|
51
|
+
console.print(table)
|
|
52
|
+
if not any(r.ok for r in results):
|
|
53
|
+
raise typer.Exit(1)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@app.command("domain-age")
|
|
57
|
+
def domain_age_cmd(host: str = typer.Argument(..., help="Domain or URL, e.g. shop.example")) -> None:
|
|
58
|
+
"""Look up when a domain was registered (RDAP) and cache it. This goes online, to the domain's registry."""
|
|
59
|
+
from . import domainage
|
|
60
|
+
from .rules.url import parse
|
|
61
|
+
|
|
62
|
+
dom = parse(host).registrable
|
|
63
|
+
age = domainage.lookup(dom, live=True)
|
|
64
|
+
if age is None:
|
|
65
|
+
console.print(f"{dom}: registration date not available (the registry didn't say, or isn't on RDAP).")
|
|
66
|
+
raise typer.Exit(1)
|
|
67
|
+
console.print(f"{dom}: registered {age} days ago. Cached for SCAMGATE_RDAP=cache|live.")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@app.command("feeds-status")
|
|
71
|
+
def feeds_status(cache: Optional[Path] = typer.Option(None, "--cache-dir")) -> None:
|
|
72
|
+
"""Show cached feeds, their entry counts and age."""
|
|
73
|
+
from . import feeds as feeds_mod
|
|
74
|
+
|
|
75
|
+
meta = feeds_mod.read_meta(cache)
|
|
76
|
+
if not meta:
|
|
77
|
+
console.print("No feeds cached yet. Run `scamgate update-feeds`.")
|
|
78
|
+
raise typer.Exit(1)
|
|
79
|
+
idx = feeds_mod.FeedIndex(fetched_at={k: v.get("fetched_at", "") for k, v in meta.items()})
|
|
80
|
+
stale = set(idx.stale_feeds())
|
|
81
|
+
for name, m in meta.items():
|
|
82
|
+
flag = " [yellow](stale)[/yellow]" if name in stale else ""
|
|
83
|
+
console.print(f"{name}: {m.get('urls', 0)} urls, {m.get('hosts', 0)} hosts, fetched {m.get('fetched_at')}{flag}")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@app.command()
|
|
87
|
+
def hook() -> None:
|
|
88
|
+
"""Enforcement hook: read a Hermes or Claude Code hook event (JSON) on stdin, print the decision."""
|
|
89
|
+
from . import hook as hook_mod
|
|
90
|
+
|
|
91
|
+
raise typer.Exit(hook_mod.main())
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@app.command()
|
|
95
|
+
def review(days: int = typer.Option(7, help="Look back this many days"),
|
|
96
|
+
limit: int = typer.Option(15, help="Show at most this many hosts")) -> None:
|
|
97
|
+
"""Show which links keep tripping the gate, and what you did with each ask."""
|
|
98
|
+
from . import asklog
|
|
99
|
+
|
|
100
|
+
rows = asklog.summarize(days=days)[:limit]
|
|
101
|
+
if not rows:
|
|
102
|
+
console.print(f"No asks or blocks in the last {days} days.")
|
|
103
|
+
return
|
|
104
|
+
table = Table(title=f"scamgate asks, last {days} days")
|
|
105
|
+
for col in ("host", "asks", "you approved", "you refused", "pending", "blocked", "top rules", "tools"):
|
|
106
|
+
table.add_column(col)
|
|
107
|
+
for r in rows:
|
|
108
|
+
table.add_row(r.host, str(r.asks), str(r.ran), str(r.not_run), str(r.pending), str(r.blocks),
|
|
109
|
+
", ".join(list(r.rules)[:2]), ", ".join(list(r.tools)[:2]))
|
|
110
|
+
console.print(table)
|
|
111
|
+
for r in rows[:5]:
|
|
112
|
+
if r.sample_reasons:
|
|
113
|
+
console.print(f"[dim]{r.host}: agent said (untrusted): "
|
|
114
|
+
+ " | ".join(f"\"{s}\"" for s in r.sample_reasons) + "[/dim]")
|
|
115
|
+
console.print("\nTurn a repeat ask into a rule: [bold]scamgate promote --host HOST --decision deny[/bold], or "
|
|
116
|
+
"[bold]--decision allow --rule RULE_ID[/bold] (allow waives one rule on one exact host).")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@app.command()
|
|
120
|
+
def promote(host: str = typer.Option(..., help="Exact host, e.g. shop.example (no wildcards)"),
|
|
121
|
+
decision: str = typer.Option(..., help="allow or deny"),
|
|
122
|
+
rule: Optional[str] = typer.Option(None, help="For allow: the one rule to waive on this host"),
|
|
123
|
+
note: str = typer.Option("", help="Why (for your own records)")) -> None:
|
|
124
|
+
"""Add a human-approved override. Asks for confirmation; agents are blocked from running this."""
|
|
125
|
+
from . import overrides
|
|
126
|
+
|
|
127
|
+
from . import guard
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
guard.require_human("Adding a scamgate override", interactive=lambda: overrides._interactive())
|
|
131
|
+
except PermissionError as exc:
|
|
132
|
+
console.print(f"[red]{exc}[/red]")
|
|
133
|
+
raise typer.Exit(3)
|
|
134
|
+
if decision == "allow" and not rule:
|
|
135
|
+
console.print("An allow rule must name the one rule it waives: add --rule RULE_ID "
|
|
136
|
+
"(see `scamgate review` for the rules that fired).")
|
|
137
|
+
raise typer.Exit(2)
|
|
138
|
+
if rule and rule in overrides.NEVER_WAIVED:
|
|
139
|
+
console.print(f"{rule} can't be waived: known-bad feeds, your blocklist and seed-phrase checks always apply.")
|
|
140
|
+
raise typer.Exit(2)
|
|
141
|
+
scope = (f"waive '{rule}' for {host} only (not its subdomains)" if decision == "allow"
|
|
142
|
+
else f"deny {host} and all its subdomains")
|
|
143
|
+
console.print(f"This will {scope}.")
|
|
144
|
+
if not typer.confirm("Add this rule?", default=False):
|
|
145
|
+
raise typer.Exit(1)
|
|
146
|
+
try:
|
|
147
|
+
rec = overrides.add(host, decision, rule=rule, note=note)
|
|
148
|
+
except ValueError as exc:
|
|
149
|
+
console.print(f"[red]{exc}[/red]")
|
|
150
|
+
raise typer.Exit(2)
|
|
151
|
+
console.print(f"Added: {rec['decision']} {rec['host']}" + (f" (rule {rec['rule']})" if rec["rule"] else ""))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@app.command("record-payment")
|
|
155
|
+
def record_payment(payee: str = typer.Option(..., help="Payee name"),
|
|
156
|
+
account: str = typer.Option(..., help="IBAN, wallet or merchant id")) -> None:
|
|
157
|
+
"""Record a payee you've actually paid, so future checks know them (stored as hashes only)."""
|
|
158
|
+
from . import payees
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
payees.record(payee, account)
|
|
162
|
+
except PermissionError as exc:
|
|
163
|
+
console.print(f"[red]{exc}[/red]")
|
|
164
|
+
raise typer.Exit(3)
|
|
165
|
+
console.print(f"Recorded '{payee}'. Future payments to this exact account count as a known payee.")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@app.command()
|
|
169
|
+
def bench(
|
|
170
|
+
directory: Path = typer.Option(Path("scenarios"), "--dir", help="Folder of scenario YAML files"),
|
|
171
|
+
strict: bool = typer.Option(False, help="Exit non-zero if any decision differs from expected"),
|
|
172
|
+
verbose: bool = typer.Option(False, "-v", "--verbose", help="Show reasons for each scenario"),
|
|
173
|
+
) -> None:
|
|
174
|
+
"""Run the scenario set and report caught / missed / false positives."""
|
|
175
|
+
if not directory.is_dir():
|
|
176
|
+
console.print(f"[red]No scenario folder at {directory}[/red]")
|
|
177
|
+
raise typer.Exit(2)
|
|
178
|
+
results = bench_mod.run_all(directory)
|
|
179
|
+
|
|
180
|
+
table = Table(title=f"scamgate bench ({directory})")
|
|
181
|
+
for col in ("id", "label", "expected", "got", "score", "outcome"):
|
|
182
|
+
table.add_column(col)
|
|
183
|
+
colours = {"caught": "green", "ok": "green", "missed": "red", "false_positive": "yellow"}
|
|
184
|
+
for r in results:
|
|
185
|
+
got = r.verdict.decision.value
|
|
186
|
+
got_s = got if r.exact else f"[bold]{got}[/bold] (!)"
|
|
187
|
+
table.add_row(r.scenario_id, r.label, r.expected.value, got_s, str(r.verdict.risk_score),
|
|
188
|
+
f"[{colours[r.outcome]}]{r.outcome}[/{colours[r.outcome]}]")
|
|
189
|
+
console.print(table)
|
|
190
|
+
|
|
191
|
+
if verbose:
|
|
192
|
+
for r in results:
|
|
193
|
+
console.print(f"\n[bold]{r.scenario_id}[/bold] {r.name}: {r.verdict.summary}")
|
|
194
|
+
for reason in r.verdict.reasons:
|
|
195
|
+
console.print(f" +{reason.weight:<3} {reason.rule_id}: {reason.explanation}")
|
|
196
|
+
|
|
197
|
+
s = bench_mod.summarize(results)
|
|
198
|
+
console.print(
|
|
199
|
+
f"\nScams: {s['scams']} caught: {s['caught']} missed: {s['missed']}\n"
|
|
200
|
+
f"Legit: {s['legit']} false positives: {s['false_positives']}\n"
|
|
201
|
+
f"Exact decision match: {s['exact_decision_match']}/{s['total']}"
|
|
202
|
+
)
|
|
203
|
+
console.print("[dim]Example scenarios only; these numbers are a regression check, not a real-world detection rate.[/dim]")
|
|
204
|
+
if strict and s["exact_decision_match"] != s["total"]:
|
|
205
|
+
raise typer.Exit(1)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
if __name__ == "__main__":
|
|
209
|
+
app()
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# scamgate rules config.
|
|
2
|
+
# Each rule's weight is added to the risk score when it fires (score is capped at 100).
|
|
3
|
+
# decision: score >= deny -> deny, score >= ask -> ask, otherwise allow.
|
|
4
|
+
thresholds:
|
|
5
|
+
ask: 25
|
|
6
|
+
deny: 70
|
|
7
|
+
|
|
8
|
+
rules:
|
|
9
|
+
# --- URL rules ---
|
|
10
|
+
url.unreadable: { enabled: true, weight: 40 } # can't tell where the link goes: ask, never allow
|
|
11
|
+
url.blocklist: { enabled: true, weight: 100 } # your own list (data/blocklist.txt)
|
|
12
|
+
# Known-bad-link feeds (cached by `scamgate update-feeds`, looked up offline).
|
|
13
|
+
# exact URL listed -> deny; exact host listed -> 75 (deny on its own);
|
|
14
|
+
# parent domain listed (domain feeds only) -> 50.
|
|
15
|
+
url.known_bad_feed: { enabled: true, weight: 100, host_weight: 75, parent_weight: 50 }
|
|
16
|
+
url.lookalike_brand: { enabled: true, weight: 60, exact_token_weight: 45, official_prefix_weight: 80 }
|
|
17
|
+
url.userinfo: { enabled: true, weight: 40, disguise_weight: 75 } # 'paypal.com@evil.example'
|
|
18
|
+
url.ip_host: { enabled: true, weight: 40 }
|
|
19
|
+
url.punycode: { enabled: true, weight: 40 }
|
|
20
|
+
url.suspicious_tld: { enabled: true, weight: 20 }
|
|
21
|
+
url.intent_mismatch: { enabled: true, weight: 45, secret_weight: 80 }
|
|
22
|
+
url.credential_page: { enabled: true, weight: 25 } # login page on an unrecognised site; waivable per host
|
|
23
|
+
url.domain_age: { enabled: true, weight: 30, min_age_days: 30 } # only with SCAMGATE_RDAP=cache|live
|
|
24
|
+
|
|
25
|
+
# --- Payment rules ---
|
|
26
|
+
pay.new_payee_urgency: { enabled: true, weight: 35 }
|
|
27
|
+
pay.bank_details_changed: { enabled: true, weight: 50 }
|
|
28
|
+
pay.irreversible_method: { enabled: true, weight: 35, new_payee_transfer_weight: 25, new_payee_transfer_vop_match_weight: 10 }
|
|
29
|
+
pay.method_restricted: { enabled: true, weight: 30 }
|
|
30
|
+
pay.over_budget: { enabled: true, weight: 30 }
|
|
31
|
+
pay.pressure_phrases: { enabled: true, weight: 35 }
|
|
32
|
+
pay.unbacked_known_claim: { enabled: true, weight: 15 }
|
|
33
|
+
pay.vop_mismatch:
|
|
34
|
+
enabled: true
|
|
35
|
+
weights: { no_match: 60, close_match: 25, unavailable: 10, match: 0 }
|
|
36
|
+
|
|
37
|
+
lists:
|
|
38
|
+
brands:
|
|
39
|
+
paypal: [paypal.com, paypal.me]
|
|
40
|
+
revolut: [revolut.com, revolut.me]
|
|
41
|
+
aib: [aib.ie]
|
|
42
|
+
boi: [bankofireland.com, boi.com]
|
|
43
|
+
stripe: [stripe.com, stripe.network]
|
|
44
|
+
# Not amazonaws.com: its subdomains are customers' buckets and servers (S3, EC2, API Gateway).
|
|
45
|
+
amazon: [amazon.com, amazon.co.uk, amazon.ie, amazon.de, amazon.fr, amazon.es, amazon.it, amazon.nl]
|
|
46
|
+
apple: [apple.com, icloud.com]
|
|
47
|
+
# Brand pack: the names most used in phishing (APWG / vendor reports), plus Irish and UK banks,
|
|
48
|
+
# couriers and services. Exact-name and typo checks only; keep names that are also everyday
|
|
49
|
+
# words out (they'd flag normal sites), or list them under brand_typo_exempt.
|
|
50
|
+
microsoft: [microsoft.com, microsoftonline.com, live.com, outlook.com, office.com, office365.com, azure.com, xbox.com]
|
|
51
|
+
outlook: [outlook.com, live.com, microsoft.com, office.com]
|
|
52
|
+
office365: [office.com, office365.com, microsoft.com]
|
|
53
|
+
google: [google.com, google.ie, google.co.uk, gmail.com, youtube.com]
|
|
54
|
+
gmail: [gmail.com, google.com]
|
|
55
|
+
netflix: [netflix.com]
|
|
56
|
+
facebook: [facebook.com, fb.com, meta.com]
|
|
57
|
+
instagram: [instagram.com]
|
|
58
|
+
whatsapp: [whatsapp.com, whatsapp.net]
|
|
59
|
+
linkedin: [linkedin.com]
|
|
60
|
+
dropbox: [dropbox.com, dropboxusercontent.com]
|
|
61
|
+
docusign: [docusign.com, docusign.net]
|
|
62
|
+
adobe: [adobe.com]
|
|
63
|
+
ebay: [ebay.com, ebay.ie, ebay.co.uk]
|
|
64
|
+
coinbase: [coinbase.com]
|
|
65
|
+
binance: [binance.com]
|
|
66
|
+
metamask: [metamask.io]
|
|
67
|
+
kraken: [kraken.com]
|
|
68
|
+
dhl: [dhl.com, dhl.ie, dhl.co.uk, dhl.de]
|
|
69
|
+
fedex: [fedex.com]
|
|
70
|
+
anpost: [anpost.ie, anpost.com]
|
|
71
|
+
royalmail: [royalmail.com]
|
|
72
|
+
evri: [evri.com]
|
|
73
|
+
usps: [usps.com]
|
|
74
|
+
hmrc: [hmrc.gov.uk, gov.uk]
|
|
75
|
+
ptsb: [ptsb.ie]
|
|
76
|
+
bankofireland: [bankofireland.com, boi.com]
|
|
77
|
+
ulsterbank: [ulsterbank.ie, ulsterbank.co.uk]
|
|
78
|
+
hsbc: [hsbc.com, hsbc.co.uk]
|
|
79
|
+
barclays: [barclays.co.uk, barclays.com]
|
|
80
|
+
natwest: [natwest.com]
|
|
81
|
+
santander: [santander.co.uk, santander.com]
|
|
82
|
+
lloyds: [lloydsbank.com, lloyds.com]
|
|
83
|
+
monzo: [monzo.com]
|
|
84
|
+
starling: [starlingbank.com]
|
|
85
|
+
bunq: [bunq.com]
|
|
86
|
+
n26: [n26.com]
|
|
87
|
+
klarna: [klarna.com]
|
|
88
|
+
wellsfargo: [wellsfargo.com]
|
|
89
|
+
venmo: [venmo.com]
|
|
90
|
+
cashapp: [cash.app, cashapp.com]
|
|
91
|
+
airbnb: [airbnb.com, airbnb.ie, airbnb.co.uk]
|
|
92
|
+
booking: [booking.com]
|
|
93
|
+
ryanair: [ryanair.com]
|
|
94
|
+
aerlingus: [aerlingus.com]
|
|
95
|
+
|
|
96
|
+
# Everyday words one letter away from a brand ('finance' ~ 'binance'). The typo check skips them.
|
|
97
|
+
brand_typo_exempt: [finance, finances, kroken, broken, token, tokens, monza, months, mondo, lloyd, cashap,
|
|
98
|
+
starting, startling, sparling, docusing, adore, adobo, netflux, ebays, fedx, bunk, bung,
|
|
99
|
+
booking, cooking, looking, hooking, rooking, ryanairs, outlooks, metamorph, hsbcs, n25, n27,
|
|
100
|
+
venom, venue, venus, klara, klarne]
|
|
101
|
+
|
|
102
|
+
# Padding scammers put around a brand name ('appleid', 'securepaypal', 'amazonrefund').
|
|
103
|
+
# A brand inside any other word ('applebees', 'revolution', 'pineapple') isn't flagged.
|
|
104
|
+
brand_affixes: [id, login, logon, signin, sign, secure, security, safe, verify, verification, verified, validate,
|
|
105
|
+
support, help, helpdesk, desk, service, services, svc, account, accounts, acct, update, updates,
|
|
106
|
+
billing, bill, pay, payment, payments, my, online, app, apps, web, portal, auth, alert, alerts,
|
|
107
|
+
notice, center, centre, team, care, customer, customers, client, refund, refunds, unlock, unlocks,
|
|
108
|
+
recovery, recover, restore, official, store, shop, gift, card, cards, wallet, connect, check, checks,
|
|
109
|
+
confirm, mail, cloud, user, users, member, members, uk, ie, us, eu, de, global, inc, ltd, get, go,
|
|
110
|
+
www, login, access, hub, info, dept, claim, claims, prize, reward, rewards, bonus, invoice, order,
|
|
111
|
+
orders, delivery, track, tracking, case, review, reviews, limited, limit, suspend, suspended,
|
|
112
|
+
locked, lock, block, blocked, protect, protection, fraud, team, official, net, com, now, here, x]
|
|
113
|
+
|
|
114
|
+
# Shared hosting: each hostname under these is a different customer's or user's site, so it's never an
|
|
115
|
+
# official brand site, and a feed listing counts only for that exact host (never as a parent domain).
|
|
116
|
+
# amazonaws.com S3 buckets, EC2, API Gateway (583 Public Suffix List entries)
|
|
117
|
+
# googleusercontent.com Google's sandbox for untrusted user content (Apps Script web apps, Sites embeds)
|
|
118
|
+
# cloudapp.azure.com Azure VM names customers pick: <label>.<region>.cloudapp.azure.com
|
|
119
|
+
shared_hosting: [amazonaws.com, googleusercontent.com, cloudapp.azure.com]
|
|
120
|
+
|
|
121
|
+
# Big shared platforms (file hosts, form builders, shorteners, identity providers).
|
|
122
|
+
# Unlike shared_hosting, one host serves many users' pages here, so even an exact host listing is ignored.
|
|
123
|
+
# Feeds list single bad pages on these, so a host/domain hit here is ignored; only an
|
|
124
|
+
# exact-URL listing counts. Each entry also covers its subdomains.
|
|
125
|
+
feed_host_ignore:
|
|
126
|
+
- google.com
|
|
127
|
+
- googleapis.com
|
|
128
|
+
- forms.gle
|
|
129
|
+
- goo.gl
|
|
130
|
+
- microsoftonline.com
|
|
131
|
+
- microsoft.com
|
|
132
|
+
- live.com
|
|
133
|
+
- office.com
|
|
134
|
+
- sharepoint.com
|
|
135
|
+
- 1drv.ms
|
|
136
|
+
- discordapp.com
|
|
137
|
+
- discordapp.net
|
|
138
|
+
- github.com
|
|
139
|
+
- githubusercontent.com
|
|
140
|
+
- gitlab.com
|
|
141
|
+
- bitbucket.org
|
|
142
|
+
- dropbox.com
|
|
143
|
+
- dropboxusercontent.com
|
|
144
|
+
- mediafire.com
|
|
145
|
+
- pastebin.com
|
|
146
|
+
- t.me
|
|
147
|
+
- telegra.ph
|
|
148
|
+
- bit.ly
|
|
149
|
+
- tinyurl.com
|
|
150
|
+
- t.co
|
|
151
|
+
- linktr.ee
|
|
152
|
+
- ipfs.io
|
|
153
|
+
- facebook.com
|
|
154
|
+
- linkedin.com
|
|
155
|
+
|
|
156
|
+
suspicious_tlds: [zip, mov, top, xyz, click, country, gq, tk, ml, cf, ga, rest, cam, icu, sbs, cfd, shop, buzz, lol]
|
|
157
|
+
|
|
158
|
+
# URL terms that ask for credentials or secrets.
|
|
159
|
+
credential_terms: [login, log-in, signin, sign-in, verify-account, account-verify, unlock, password, otp, 2fa]
|
|
160
|
+
secret_terms: [seed, seed-phrase, seedphrase, mnemonic, recovery-phrase, private-key, privatekey, connect-wallet, wallet-connect]
|
|
161
|
+
# If the agent's stated intent contains one of these, credential terms are expected.
|
|
162
|
+
credential_intent_terms: [log in, login, sign in, signin, account, password, my bank, banking]
|
|
163
|
+
|
|
164
|
+
urgency_terms:
|
|
165
|
+
- urgent
|
|
166
|
+
- urgently
|
|
167
|
+
- immediately
|
|
168
|
+
- asap
|
|
169
|
+
- right away
|
|
170
|
+
- today
|
|
171
|
+
- within the hour
|
|
172
|
+
- end of day
|
|
173
|
+
- before close of business
|
|
174
|
+
- deadline
|
|
175
|
+
- final notice
|
|
176
|
+
- last chance
|
|
177
|
+
|
|
178
|
+
pressure_phrases:
|
|
179
|
+
- act now
|
|
180
|
+
- keep this confidential
|
|
181
|
+
- keep this between us
|
|
182
|
+
- do not tell the bank
|
|
183
|
+
- don't tell the bank
|
|
184
|
+
- do not tell anyone
|
|
185
|
+
- don't tell anyone
|
|
186
|
+
- guaranteed returns
|
|
187
|
+
- guaranteed return
|
|
188
|
+
- risk-free
|
|
189
|
+
- double your money
|
|
190
|
+
- police will
|
|
191
|
+
- arrest warrant
|
|
192
|
+
- your account will be closed
|
|
193
|
+
- do not call
|
|
194
|
+
- i'm in meetings
|
|
195
|
+
|
|
196
|
+
bank_change_phrases:
|
|
197
|
+
- new bank details
|
|
198
|
+
- changed our bank
|
|
199
|
+
- updated bank details
|
|
200
|
+
- updated account details
|
|
201
|
+
- new account details
|
|
202
|
+
- changed bank account
|
|
203
|
+
- new iban
|
|
204
|
+
|
|
205
|
+
method_restriction_phrases:
|
|
206
|
+
- only accept crypto
|
|
207
|
+
- crypto only
|
|
208
|
+
- only accept usdt
|
|
209
|
+
- only accept bitcoin
|
|
210
|
+
- usdt only
|
|
211
|
+
- bitcoin only
|
|
212
|
+
- gift card
|
|
213
|
+
- gift cards
|
|
214
|
+
- no cards
|
|
215
|
+
- no card payments
|
|
216
|
+
- bank transfer only
|
|
217
|
+
- only accept bank transfer
|
|
218
|
+
- friends and family
|