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/feeds.py ADDED
@@ -0,0 +1,377 @@
1
+ """Known-bad-link feeds: download into a local cache, then look up offline.
2
+
3
+ `update_feeds()` is the only code path that touches the network (`scamgate update-feeds`, and the
4
+ Hermes plugin's refresh in refresh.py). Checks only read the cache. Each feed is stored as its own normalized file, so every entry keeps its source,
5
+ and `meta.json` records when each feed was last fetched successfully.
6
+
7
+ Cache dir: $SCAMGATE_CACHE_DIR, else $XDG_CACHE_HOME/scamgate, else ~/.cache/scamgate.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import csv
13
+ import io
14
+ import json
15
+ import os
16
+ import time
17
+ from dataclasses import dataclass, field
18
+ from datetime import datetime, timezone
19
+ from functools import lru_cache
20
+ from pathlib import Path
21
+ from typing import Callable, Iterable
22
+ from urllib.parse import urlsplit, urlunsplit
23
+
24
+ from .hostnorm import canon_host
25
+
26
+ ENV_CACHE_DIR = "SCAMGATE_CACHE_DIR"
27
+ ENV_URLHAUS_KEY = "SCAMGATE_URLHAUS_AUTH_KEY"
28
+ USER_AGENT = "scamgate/0.3 (+https://github.com/odaiameera/scamgate)"
29
+ STALE_AFTER_HOURS = 24
30
+ REFRESH_HINT = ("Refresh them with the update_feeds tool or `scamgate update-feeds`, or with `/scamgate update` "
31
+ "(`hermes scamgate update-feeds` in a terminal) when scamgate is a Hermes plugin.")
32
+
33
+
34
+ def cache_dir() -> Path:
35
+ if env := os.environ.get(ENV_CACHE_DIR):
36
+ return Path(env).expanduser()
37
+ base = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
38
+ return Path(base) / "scamgate"
39
+
40
+
41
+ # ---------------------------------------------------------------- normalization
42
+
43
+ def normalize_url(url: str) -> str | None:
44
+ """Canonical form for exact matching: lowercase scheme+host, no default port,
45
+ no fragment, no trailing slash on the path. Path and query are kept as-is."""
46
+ raw = url.strip()
47
+ if not raw or raw.startswith("#"):
48
+ return None
49
+ if "://" not in raw:
50
+ raw = "http://" + raw
51
+ try:
52
+ p = urlsplit(raw)
53
+ host = canon_host(p.hostname or "")
54
+ port = p.port
55
+ except ValueError:
56
+ return None
57
+ if not host:
58
+ return None
59
+ scheme = p.scheme.lower()
60
+ netloc = host
61
+ if port and not ((scheme == "http" and port == 80) or (scheme == "https" and port == 443)):
62
+ netloc = f"{host}:{port}"
63
+ path = p.path.rstrip("/")
64
+ # http and https variants of the same URL are treated as the same link.
65
+ if scheme in ("http", "https"):
66
+ scheme = "http"
67
+ return urlunsplit((scheme, netloc, path, p.query, ""))
68
+
69
+
70
+ def normalize_host(value: str) -> str | None:
71
+ v = value.strip().lower()
72
+ if not v or v.startswith("#"):
73
+ return None
74
+ if "://" in v:
75
+ try:
76
+ v = urlsplit(v).hostname or ""
77
+ except ValueError:
78
+ return None
79
+ v = canon_host(v.split("/", 1)[0].split(":", 1)[0])
80
+ if not v or "." not in v or " " in v:
81
+ return None
82
+ return v
83
+
84
+
85
+ def host_of(url: str) -> str | None:
86
+ return normalize_host(url if "://" in url else "http://" + url)
87
+
88
+
89
+ # ---------------------------------------------------------------- parsers
90
+
91
+ def _lines(text: str) -> Iterable[str]:
92
+ for line in text.splitlines():
93
+ line = line.strip()
94
+ if line and not line.startswith("#"):
95
+ yield line
96
+
97
+
98
+ def parse_url_list(text: str) -> tuple[set[str], set[str]]:
99
+ """One URL per line -> (urls, hosts)."""
100
+ urls, hosts = set(), set()
101
+ for line in _lines(text):
102
+ if (u := normalize_url(line)) and (h := host_of(line)):
103
+ urls.add(u)
104
+ hosts.add(h)
105
+ return urls, hosts
106
+
107
+
108
+ def parse_domain_list(text: str) -> tuple[set[str], set[str]]:
109
+ """One domain per line -> (no urls, hosts)."""
110
+ return set(), {h for line in _lines(text) if (h := normalize_host(line))}
111
+
112
+
113
+ def parse_urlhaus_csv(text: str) -> tuple[set[str], set[str]]:
114
+ """URLhaus CSV dump: id,dateadded,url,url_status,... (comment lines start with #)."""
115
+ body = "\n".join(line for line in text.splitlines() if line and not line.startswith("#"))
116
+ urls, hosts = set(), set()
117
+ for row in csv.reader(io.StringIO(body)):
118
+ if len(row) > 2 and (u := normalize_url(row[2])) and (h := host_of(row[2])):
119
+ urls.add(u)
120
+ hosts.add(h)
121
+ return urls, hosts
122
+
123
+
124
+ # ---------------------------------------------------------------- feed registry
125
+
126
+ @dataclass(frozen=True)
127
+ class Feed:
128
+ name: str
129
+ title: str
130
+ parser: Callable[[str], tuple[set[str], set[str]]]
131
+ url: Callable[[], str | None] # None -> feed not configured (e.g. missing key)
132
+ license: str
133
+ default: bool = True
134
+ # Host matches from URL feeds can be shared hosting; only exact host hits count then.
135
+ hosts_are_listed_domains: bool = True
136
+
137
+
138
+ def _urlhaus_url() -> str | None:
139
+ key = (os.environ.get(ENV_URLHAUS_KEY) or "").strip()
140
+ if not key or key.startswith("${"): # unset, or a plugin setting placeholder nobody filled in
141
+ return None
142
+ return f"https://urlhaus-api.abuse.ch/v2/files/exports/{key}/csv_online"
143
+
144
+
145
+ FEEDS: dict[str, Feed] = {f.name: f for f in [
146
+ Feed("openphish", "OpenPhish community feed", parse_url_list,
147
+ lambda: "https://openphish.com/feed.txt",
148
+ "OpenPhish Terms of Use: personal / non-commercial research only, no redistribution",
149
+ hosts_are_listed_domains=False),
150
+ Feed("phishing_database_domains", "Phishing.Database (active domains)", parse_domain_list,
151
+ lambda: "https://raw.githubusercontent.com/Phishing-Database/Phishing.Database/master/phishing-domains-ACTIVE.txt",
152
+ "MIT (Phishing.Database contributors)"),
153
+ Feed("phishing_database_links", "Phishing.Database (active links)", parse_url_list,
154
+ lambda: "https://raw.githubusercontent.com/Phishing-Database/Phishing.Database/master/phishing-links-ACTIVE.txt",
155
+ "MIT (Phishing.Database contributors)", default=False, hosts_are_listed_domains=False),
156
+ Feed("urlhaus", "abuse.ch URLhaus (online malware URLs)", parse_urlhaus_csv, _urlhaus_url,
157
+ "abuse.ch fair-use terms; free Auth-Key required; commercial use may need a paid plan",
158
+ hosts_are_listed_domains=False),
159
+ ]}
160
+
161
+
162
+ # ---------------------------------------------------------------- update
163
+
164
+ @dataclass
165
+ class FeedResult:
166
+ name: str
167
+ ok: bool
168
+ urls: int = 0
169
+ hosts: int = 0
170
+ message: str = ""
171
+
172
+
173
+ MAX_FEED_BYTES = 256 * 1024 * 1024 # GitHub serves files up to 100 MB; far bigger isn't a feed
174
+
175
+
176
+ def _download(url: str, timeout: int = 120) -> str:
177
+ import urllib.request # lazy: the hook never downloads, so it skips this import
178
+
179
+ req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
180
+ with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310
181
+ data = resp.read(MAX_FEED_BYTES + 1)
182
+ if len(data) > MAX_FEED_BYTES:
183
+ raise ValueError(f"feed is larger than {MAX_FEED_BYTES // (1024 * 1024)} MB; kept the old cache")
184
+ return data.decode("utf-8", "replace")
185
+
186
+
187
+ def _meta_path(d: Path) -> Path:
188
+ return d / "meta.json"
189
+
190
+
191
+ def read_meta(d: Path | None = None) -> dict:
192
+ p = _meta_path(d or cache_dir())
193
+ try:
194
+ return json.loads(p.read_text("utf-8"))
195
+ except (OSError, ValueError):
196
+ return {}
197
+
198
+
199
+ def _atomic_write(path: Path, text: str) -> None:
200
+ tmp = path.with_suffix(path.suffix + ".tmp")
201
+ tmp.write_text(text, "utf-8")
202
+ tmp.replace(path)
203
+
204
+
205
+ def update_feeds(names: list[str] | None = None, *, directory: Path | None = None,
206
+ fetch: Callable[[str], str] | None = None) -> list[FeedResult]:
207
+ """Download feeds into the cache. A failed feed keeps its previous cache files."""
208
+ fetch = fetch or _download # looked up per call, so tests and the CI smoke job can serve fixtures
209
+ d = directory or cache_dir()
210
+ d.mkdir(parents=True, exist_ok=True)
211
+ meta = read_meta(d)
212
+ selected = names or [n for n, f in FEEDS.items() if f.default]
213
+ results = []
214
+ for name in selected:
215
+ feed = FEEDS.get(name)
216
+ if feed is None:
217
+ results.append(FeedResult(name, False, message="unknown feed"))
218
+ continue
219
+ url = feed.url()
220
+ if url is None:
221
+ results.append(FeedResult(name, False, message=f"skipped: set {ENV_URLHAUS_KEY} to enable"))
222
+ continue
223
+ try:
224
+ urls, hosts = feed.parser(fetch(url))
225
+ if not urls and not hosts:
226
+ raise ValueError("feed parsed to zero entries")
227
+ except Exception as e: # network, HTTP, parse: keep the old cache
228
+ old = meta.get(name, {}).get("fetched_at")
229
+ results.append(FeedResult(name, False, message=f"{type(e).__name__}: {e}; kept cache from {old or 'never'}"))
230
+ continue
231
+ _atomic_write(d / f"{name}.urls.txt", "\n".join(sorted(urls)) + "\n")
232
+ _atomic_write(d / f"{name}.hosts.txt", "\n".join(sorted(hosts)) + "\n")
233
+ meta[name] = {"fetched_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
234
+ "source_url": url.replace(os.environ.get(ENV_URLHAUS_KEY) or "\0", "<key>"),
235
+ "urls": len(urls), "hosts": len(hosts), "license": feed.license}
236
+ _atomic_write(_meta_path(d), json.dumps(meta, indent=2))
237
+ results.append(FeedResult(name, True, len(urls), len(hosts)))
238
+ load_index.cache_clear()
239
+ return results
240
+
241
+
242
+ # ---------------------------------------------------------------- lookup
243
+
244
+ @dataclass
245
+ class FeedHit:
246
+ kind: str # "url" | "host" | "parent"
247
+ feeds: list[str]
248
+ matched: str
249
+
250
+
251
+ @dataclass
252
+ class FeedIndex:
253
+ urls: dict[str, list[str]] = field(default_factory=dict) # normalized url -> feeds
254
+ hosts: dict[str, list[str]] = field(default_factory=dict) # host -> feeds
255
+ listed_domains: dict[str, list[str]] = field(default_factory=dict) # parent-match ok
256
+ fetched_at: dict[str, str] = field(default_factory=dict)
257
+
258
+ def lookup(self, url: str) -> FeedHit | None:
259
+ if (u := normalize_url(url)) and u in self.urls:
260
+ return FeedHit("url", self.urls[u], u)
261
+ host = host_of(url)
262
+ if not host:
263
+ return None
264
+ if host in self.hosts:
265
+ return FeedHit("host", self.hosts[host], host)
266
+ labels = host.split(".")
267
+ for i in range(1, len(labels) - 1):
268
+ parent = ".".join(labels[i:])
269
+ if parent in self.listed_domains:
270
+ return FeedHit("parent", self.listed_domains[parent], parent)
271
+ return None
272
+
273
+ def stale_feeds(self, hours: float = STALE_AFTER_HOURS, now: float | None = None) -> list[str]:
274
+ now = now or time.time()
275
+ out = []
276
+ for name, ts in self.fetched_at.items():
277
+ try:
278
+ age = now - datetime.fromisoformat(ts).timestamp()
279
+ except ValueError:
280
+ age = float("inf")
281
+ if age > hours * 3600:
282
+ out.append(name)
283
+ return out
284
+
285
+ def __bool__(self) -> bool:
286
+ return bool(self.urls or self.hosts)
287
+
288
+
289
+ def _read_set(p: Path) -> list[str]:
290
+ try:
291
+ return [x for x in p.read_text("utf-8").splitlines() if x]
292
+ except OSError:
293
+ return []
294
+
295
+
296
+ class LazyFeedIndex:
297
+ """Same lookups as FeedIndex, without building dicts: searches the cached files directly.
298
+
299
+ For short-lived processes (the enforcement hook runs once per tool call), loading ~400k
300
+ domains into dicts costs ~0.6s; a substring search over the raw file costs a few ms.
301
+ """
302
+
303
+ def __init__(self, directory: Path | str | None = None):
304
+ self.dir = Path(directory) if directory else cache_dir()
305
+ self.meta = read_meta(self.dir)
306
+ self.fetched_at = {n: i.get("fetched_at", "") for n, i in self.meta.items() if n in FEEDS}
307
+ self._blobs: dict[str, bytes] = {}
308
+
309
+ def _blob(self, name: str, kind: str) -> bytes:
310
+ key = f"{name}.{kind}"
311
+ if key not in self._blobs:
312
+ try:
313
+ data = (self.dir / f"{name}.{kind}.txt").read_bytes().replace(bytes([13]), b"")
314
+ except OSError:
315
+ data = b""
316
+ self._blobs[key] = b"\n" + data + (b"" if data.endswith(b"\n") else b"\n")
317
+ return self._blobs[key]
318
+
319
+ def _has(self, name: str, kind: str, value: str) -> bool:
320
+ return b"\n" + value.encode() + b"\n" in self._blob(name, kind)
321
+
322
+ def lookup(self, url: str) -> FeedHit | None:
323
+ names = list(self.fetched_at)
324
+ if u := normalize_url(url):
325
+ if hit := [n for n in names if self._has(n, "urls", u)]:
326
+ return FeedHit("url", hit, u)
327
+ host = host_of(url)
328
+ if not host:
329
+ return None
330
+ if hit := [n for n in names if self._has(n, "hosts", host)]:
331
+ return FeedHit("host", hit, host)
332
+ listed = [n for n in names if FEEDS[n].hosts_are_listed_domains]
333
+ labels = host.split(".")
334
+ for i in range(1, len(labels) - 1):
335
+ parent = ".".join(labels[i:])
336
+ if hit := [n for n in listed if self._has(n, "hosts", parent)]:
337
+ return FeedHit("parent", hit, parent)
338
+ return None
339
+
340
+ stale_feeds = FeedIndex.stale_feeds
341
+
342
+ def __bool__(self) -> bool:
343
+ return any(len(self._blob(n, k)) > 2 for n in self.fetched_at for k in ("urls", "hosts"))
344
+
345
+
346
+ @lru_cache(maxsize=4)
347
+ def load_index(directory: str | None = None) -> FeedIndex:
348
+ d = Path(directory) if directory else cache_dir()
349
+ idx = FeedIndex()
350
+ for name, info in read_meta(d).items():
351
+ feed = FEEDS.get(name)
352
+ if feed is None:
353
+ continue
354
+ idx.fetched_at[name] = info.get("fetched_at", "")
355
+ for u in _read_set(d / f"{name}.urls.txt"):
356
+ idx.urls.setdefault(u, []).append(name)
357
+ for h in _read_set(d / f"{name}.hosts.txt"):
358
+ if feed.hosts_are_listed_domains:
359
+ idx.listed_domains.setdefault(h, []).append(name)
360
+ idx.hosts.setdefault(h, []).append(name)
361
+ return idx
362
+
363
+
364
+ def current_index() -> FeedIndex:
365
+ """The cached index, reloaded when `update-feeds` has rewritten meta.json
366
+ (so a long-running MCP server picks up refreshed feeds without a restart)."""
367
+ d = cache_dir()
368
+ try:
369
+ mtime = _meta_path(d).stat().st_mtime
370
+ except OSError:
371
+ mtime = 0.0
372
+ return _index_at(str(d), mtime)
373
+
374
+
375
+ @lru_cache(maxsize=2)
376
+ def _index_at(directory: str, mtime: float) -> FeedIndex:
377
+ return load_index.__wrapped__(directory)
scamgate/guard.py ADDED
@@ -0,0 +1,116 @@
1
+ """Who may change scamgate's state, and how its records are signed.
2
+
3
+ Overrides and payee history change what scamgate allows, so only a person may write them:
4
+ - `require_human()` refuses when the caller is non-interactive or runs inside a known agent
5
+ (Claude Code, Hermes, Codex). The CLI calls it, and so do the library functions, so an agent
6
+ importing `scamgate.overrides` directly is refused too.
7
+ - Each record is signed with an HMAC key in the state dir (created 0600). A line an agent appends
8
+ with any text editor fails verification and is ignored.
9
+
10
+ This is tamper-evidence against an agent using its normal tools, not protection against code
11
+ running as you: that code can read the key. See README "Limits".
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import hmac
18
+ import json
19
+ import os
20
+ import secrets
21
+ import sys
22
+ from pathlib import Path
23
+
24
+ from .state import ensure_state_dir, state_dir
25
+
26
+ AGENT_ENV_MARKERS = ("CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "HERMES_SESSION_ID", "HERMES_GATEWAY_SESSION",
27
+ "HERMES_INTERACTIVE_AGENT", "CODEX_SANDBOX", "CODEX_SANDBOX_NETWORK_DISABLED",
28
+ "CURSOR_AGENT", "SCAMGATE_AGENT")
29
+
30
+
31
+ def _interactive() -> bool:
32
+ try:
33
+ return sys.stdin.isatty() and sys.stdout.isatty()
34
+ except (AttributeError, ValueError):
35
+ return False
36
+
37
+
38
+ def agent_marker() -> str | None:
39
+ return next((m for m in AGENT_ENV_MARKERS if os.environ.get(m)), None)
40
+
41
+
42
+ def require_human(action: str, *, interactive=None) -> None:
43
+ if m := agent_marker():
44
+ raise PermissionError(f"{action} must be done by a person, not an agent (running under {m}).")
45
+ if not (interactive or _interactive)():
46
+ raise PermissionError(f"{action} must be run by a person in an interactive terminal.")
47
+
48
+
49
+ def _private_write(p: Path, data: str) -> None:
50
+ ensure_state_dir()
51
+ fd = os.open(p, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
52
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
53
+ f.write(data)
54
+ try:
55
+ os.chmod(p, 0o600)
56
+ except OSError:
57
+ pass
58
+
59
+
60
+ def create_once(p: Path, data: str) -> None:
61
+ """Write a new private file only if none exists. Two processes starting together keep one key:
62
+ the full file is written aside, then hard-linked into place, which fails if another got there first."""
63
+ ensure_state_dir()
64
+ tmp = p.with_name(f".{p.name}.{os.getpid()}.{secrets.token_hex(4)}.tmp")
65
+ _private_write(tmp, data)
66
+ try:
67
+ os.link(tmp, p)
68
+ except FileExistsError:
69
+ pass
70
+ except OSError: # no hard links on this filesystem: exclusive create instead
71
+ try:
72
+ fd = os.open(p, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
73
+ except FileExistsError:
74
+ pass
75
+ else:
76
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
77
+ f.write(data)
78
+ finally:
79
+ tmp.unlink(missing_ok=True)
80
+
81
+
82
+ def _key() -> bytes:
83
+ p = state_dir() / "state.key"
84
+ if not p.exists():
85
+ create_once(p, secrets.token_hex(32))
86
+ return p.read_text("utf-8").strip().encode()
87
+
88
+
89
+ def key_exists() -> bool:
90
+ return (state_dir() / "state.key").exists()
91
+
92
+
93
+ def _canonical(rec: dict) -> bytes:
94
+ return json.dumps({k: v for k, v in rec.items() if k != "sig"}, sort_keys=True, separators=(",", ":")).encode()
95
+
96
+
97
+ def sign(rec: dict) -> dict:
98
+ return {**rec, "sig": hmac.new(_key(), _canonical(rec), hashlib.sha256).hexdigest()}
99
+
100
+
101
+ def verify(rec: dict) -> bool:
102
+ sig = rec.get("sig")
103
+ if not isinstance(sig, str) or not key_exists():
104
+ return False
105
+ return hmac.compare_digest(sig, hmac.new(_key(), _canonical(rec), hashlib.sha256).hexdigest())
106
+
107
+
108
+ def append_private(p: Path, line: str) -> None:
109
+ ensure_state_dir()
110
+ fd = os.open(p, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
111
+ with os.fdopen(fd, "a", encoding="utf-8") as f:
112
+ f.write(line + "\n")
113
+ try:
114
+ os.chmod(p, 0o600)
115
+ except OSError:
116
+ pass