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