jscope 0.4.2__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.
Files changed (81) hide show
  1. jscope/__init__.py +1 -0
  2. jscope/__main__.py +10 -0
  3. jscope/ai/__init__.py +0 -0
  4. jscope/ai/explain.py +180 -0
  5. jscope/ai/providers.py +71 -0
  6. jscope/analyzers/__init__.py +0 -0
  7. jscope/analyzers/auth_analyzer.py +170 -0
  8. jscope/analyzers/authorization_analyzer.py +196 -0
  9. jscope/analyzers/base.py +56 -0
  10. jscope/analyzers/common.py +78 -0
  11. jscope/analyzers/dataflow_analyzer.py +192 -0
  12. jscope/analyzers/endpoint_analyzer.py +150 -0
  13. jscope/analyzers/framework_detector.py +82 -0
  14. jscope/analyzers/graphql_analyzer.py +198 -0
  15. jscope/analyzers/graphql_schema_analyzer.py +132 -0
  16. jscope/analyzers/html_analyzer.py +196 -0
  17. jscope/analyzers/manager.py +114 -0
  18. jscope/analyzers/openapi_analyzer.py +278 -0
  19. jscope/analyzers/router_analyzer.py +502 -0
  20. jscope/analyzers/secret_analyzer.py +99 -0
  21. jscope/analyzers/service_worker_analyzer.py +142 -0
  22. jscope/analyzers/source_map_analyzer.py +119 -0
  23. jscope/analyzers/storage_analyzer.py +125 -0
  24. jscope/analyzers/wasm_analyzer.py +139 -0
  25. jscope/assessment_engine.py +285 -0
  26. jscope/assessment_reporting.py +71 -0
  27. jscope/benchmark/__init__.py +1 -0
  28. jscope/benchmark/fixtures.py +105 -0
  29. jscope/benchmark/runner.py +155 -0
  30. jscope/cli.py +1241 -0
  31. jscope/config.py +72 -0
  32. jscope/core/__init__.py +0 -0
  33. jscope/core/artifacts.py +139 -0
  34. jscope/core/cache.py +198 -0
  35. jscope/core/classification.py +56 -0
  36. jscope/core/evidence.py +411 -0
  37. jscope/core/graph.py +196 -0
  38. jscope/core/redact.py +67 -0
  39. jscope/crawler/__init__.py +0 -0
  40. jscope/crawler/crawler.py +176 -0
  41. jscope/dataflow/__init__.py +1 -0
  42. jscope/dataflow/engine.py +1684 -0
  43. jscope/dataflow/modules.py +205 -0
  44. jscope/dataflow/rules.py +118 -0
  45. jscope/downloader/__init__.py +0 -0
  46. jscope/downloader/downloader.py +116 -0
  47. jscope/parsing/__init__.py +0 -0
  48. jscope/parsing/ast_engine.py +322 -0
  49. jscope/parsing/graphql_schema.py +153 -0
  50. jscope/parsing/html.py +55 -0
  51. jscope/parsing/queries.py +385 -0
  52. jscope/parsing/wasm.py +146 -0
  53. jscope/passive_discovery.py +63 -0
  54. jscope/pipeline.py +196 -0
  55. jscope/plugin/__init__.py +0 -0
  56. jscope/plugin/_worker.py +199 -0
  57. jscope/plugin/loader.py +225 -0
  58. jscope/plugin/sandbox.py +207 -0
  59. jscope/reporting/__init__.py +0 -0
  60. jscope/reporting/formats.py +208 -0
  61. jscope/reporting/human.py +137 -0
  62. jscope/reporting/json_report.py +35 -0
  63. jscope/reporting/priority.py +116 -0
  64. jscope/runtime/__init__.py +0 -0
  65. jscope/runtime/observe.py +397 -0
  66. jscope/runtime/prober.py +259 -0
  67. jscope/runtime/scope.py +288 -0
  68. jscope/security_transport.py +246 -0
  69. jscope/verify/__init__.py +1 -0
  70. jscope/verify/engine.py +290 -0
  71. jscope/workspace/__init__.py +0 -0
  72. jscope/workspace/assessment.py +133 -0
  73. jscope/workspace/common.py +25 -0
  74. jscope/workspace/diff.py +143 -0
  75. jscope/workspace/store.py +206 -0
  76. jscope-0.4.2.dist-info/METADATA +180 -0
  77. jscope-0.4.2.dist-info/RECORD +81 -0
  78. jscope-0.4.2.dist-info/WHEEL +5 -0
  79. jscope-0.4.2.dist-info/entry_points.txt +2 -0
  80. jscope-0.4.2.dist-info/licenses/LICENSE +21 -0
  81. jscope-0.4.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,259 @@
1
+ """Runtime confirmation (Phase 4): the ONLY part of JScope that talks to a target. Opt-in, narrow by construction.
2
+
3
+ Hard rules (enforced in code, each covered by a test):
4
+ * nothing is sent unless the caller passes an explicit allow-list of hosts AND the base URL's host is in it
5
+ * only GET (for endpoints the client itself calls with GET) and OPTIONS (for everything else). Never POST/PUT/PATCH/DELETE,
6
+ never a request body
7
+ * redirects are NOT followed (a 3xx is evidence in itself and following could leave the allow-list)
8
+ * endpoints with unresolved parameters, absolute URLs on other hosts, and side-effect-sounding paths are skipped, with the reason
9
+ * rate-limited, hard request cap, identifying User-Agent
10
+ * a catch-all baseline (random non-existent path) is fetched first, so an SPA that answers 200/HTML for everything cannot
11
+ "confirm" an endpoint; a response indistinguishable from the baseline is inconclusive, not positive
12
+
13
+ Results become RUNTIME_CONTEXT / RUNTIME_NOT_FOUND evidence applied through `apply_runtime_evidence` -- the engine's own door.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ import secrets
20
+ import time
21
+ from dataclasses import dataclass, field
22
+ from typing import Callable
23
+ from urllib.parse import urlparse
24
+
25
+ from jscope.core.evidence import (RUNTIME_ANALYZER, Evidence, EvidenceType, Finding, apply_runtime_evidence)
26
+ from jscope.runtime.scope import (EmergencyStopped, ScopePolicy, ScopeViolation, pinned_dns, system_resolver)
27
+
28
+ USER_AGENT = "jscope-confirm/0.4 (authorized security testing; safe methods only)"
29
+ _UNRESOLVED = re.compile(r"(\{|\}|\$\{|/:[A-Za-z_]|\[[^\]]+\]|<[^>]+>|\?)")
30
+ _SIDE_EFFECT = re.compile(r"(logout|signout|sign-out|delete|remove|destroy|unsubscribe|revoke|reset|cancel|activate|confirm|"
31
+ r"checkout|purchase|\bpay\b|payment|send|invite|webhook|callback|verify|approve|reject|purge|truncate|"
32
+ r"disable|enable|import|sync|trigger|run|execute)", re.I)
33
+
34
+
35
+ @dataclass
36
+ class HttpResponse:
37
+ status: int
38
+ content_type: str = ""
39
+ length: int = 0
40
+ headers: dict = field(default_factory=dict)
41
+
42
+
43
+ Fetcher = Callable[[str, str, dict, float], HttpResponse] # (method, url, headers, timeout) -> response
44
+
45
+
46
+ def default_fetch(method: str, url: str, headers: dict, timeout: float) -> HttpResponse:
47
+ import requests
48
+ r = requests.request(method, url, headers=headers, timeout=timeout, allow_redirects=False, stream=True)
49
+ try:
50
+ body = r.raw.read(65536, decode_content=True) if method == "GET" else b""
51
+ finally:
52
+ r.close()
53
+ return HttpResponse(r.status_code, r.headers.get("Content-Type", ""), int(r.headers.get("Content-Length") or len(body)),
54
+ {k.lower(): v for k, v in r.headers.items()})
55
+
56
+
57
+ @dataclass
58
+ class PlanItem:
59
+ finding_id: str
60
+ subject: str
61
+ method: str # GET | OPTIONS
62
+ url: str | None
63
+ skip_reason: str | None = None
64
+
65
+
66
+ @dataclass
67
+ class ProbeResult:
68
+ finding_id: str
69
+ subject: str
70
+ method: str
71
+ url: str
72
+ status: int | None
73
+ verdict: str # exists | exists_protected | not_found | inconclusive | error
74
+ reason: str
75
+
76
+
77
+ class ScopeError(ScopeViolation):
78
+ pass
79
+
80
+
81
+ class Prober:
82
+ def __init__(self, base_url: str, allow_hosts: list[str], rate_per_second: float = 2.0, max_requests: int = 100,
83
+ timeout: float = 8.0, headers: dict | None = None, fetch: Fetcher | None = None,
84
+ sleep: Callable[[float], None] = time.sleep, policy: ScopePolicy | None = None,
85
+ allow_private: bool = False):
86
+ u = urlparse(base_url)
87
+ if u.scheme not in ("http", "https") or not u.netloc:
88
+ raise ScopeError("base URL must be an absolute http(s) URL")
89
+ self.allow = {h.lower() for h in allow_hosts if h}
90
+ if not self.allow:
91
+ raise ScopeError("at least one --allow-host is required: JScope never probes a host you have not named")
92
+ if u.netloc.lower() not in self.allow:
93
+ raise ScopeError(f"base URL host {u.netloc.lower()!r} is not in the allow-list {sorted(self.allow)}")
94
+ self.base = f"{u.scheme}://{u.netloc}{u.path.rstrip('/')}"
95
+ self.host = u.netloc.lower()
96
+ self.min_gap = 1.0 / rate_per_second if rate_per_second > 0 else 1.0
97
+ self.max_requests, self.timeout = max_requests, timeout
98
+ self.headers = {"User-Agent": USER_AGENT, "Accept": "*/*", **(headers or {})}
99
+ self._fetch, self._sleep = fetch or default_fetch, sleep
100
+ self._real_network = fetch is None
101
+ self.sent = 0
102
+ self._last = 0.0
103
+ # FR-10: every request is validated by a ScopePolicy (scheme/host/port/DNS/address class) before it is sent. With an
104
+ # injected `fetch` (tests) nothing touches the network, so DNS and address-class checks are skipped for that seam only.
105
+ self.policy = policy or ScopePolicy(
106
+ allow_hosts=set(self.allow), allow_private=allow_private or not self._real_network,
107
+ resolver=system_resolver if self._real_network else (lambda h, p: []),
108
+ rate_per_second=rate_per_second, max_requests=max_requests, timeout=timeout, redirects="none")
109
+ if policy is None and any(":" in h for h in self.allow):
110
+ self.policy.ports = None
111
+
112
+ # ------------------------------------------------------------ planning (sends nothing)
113
+ def plan(self, findings: list[Finding]) -> list[PlanItem]:
114
+ items: list[PlanItem] = []
115
+ for f in findings:
116
+ if f.category != "endpoint":
117
+ continue
118
+ subject = f.subject
119
+ if "://" in subject:
120
+ host = urlparse(subject).netloc.lower()
121
+ if host not in self.allow:
122
+ items.append(PlanItem(f.id, subject, "GET", None, f"host {host!r} is not in the allow-list"))
123
+ continue
124
+ path = urlparse(subject).path
125
+ else:
126
+ path = subject
127
+ if _UNRESOLVED.search(path):
128
+ items.append(PlanItem(f.id, subject, "GET", None, "path has unresolved parameters / a query string; a real value is needed"))
129
+ continue
130
+ if _SIDE_EFFECT.search(path):
131
+ items.append(PlanItem(f.id, subject, "GET", None, "path sounds state-changing; not probed even with a safe method"))
132
+ continue
133
+ method = str(f.metadata.get("method") or "").upper()
134
+ probe_method = "GET" if method in ("", "GET") and f.metadata.get("callee") != "form" else "OPTIONS"
135
+ if not path.startswith("/"):
136
+ items.append(PlanItem(f.id, subject, probe_method, None, "not a path"))
137
+ continue
138
+ url = (f"{urlparse(subject).scheme}://{urlparse(subject).netloc}{path}" if "://" in subject else self.base + path)
139
+ items.append(PlanItem(f.id, subject, probe_method, url))
140
+ return items
141
+
142
+ # ------------------------------------------------------------ execution
143
+ def _send(self, method: str, url: str) -> HttpResponse:
144
+ host = urlparse(url).netloc.lower()
145
+ if host not in self.allow: # defence in depth: re-check at the point of sending
146
+ raise ScopeError(f"refusing to send to {host!r}")
147
+ if method not in ("GET", "OPTIONS"):
148
+ raise ScopeError(f"method {method} is never sent by JScope")
149
+ try:
150
+ self.policy.stop.check()
151
+ addrs = self.policy.check_url(url, resolve=self._real_network) # scheme, port, DNS, private/loopback/metadata ranges
152
+ except EmergencyStopped:
153
+ raise
154
+ except ScopeViolation as exc:
155
+ raise ScopeError(str(exc)) from exc
156
+ if self.sent >= self.max_requests:
157
+ raise ScopeError("request cap reached")
158
+ wait = self.min_gap - (time.monotonic() - self._last)
159
+ if wait > 0:
160
+ self._sleep(wait)
161
+ self.policy.stop.check()
162
+ self._last = time.monotonic()
163
+ self.sent += 1
164
+ if self._real_network and addrs:
165
+ with pinned_dns(urlparse(url).hostname or "", addrs[0]): # connect to the address we validated, never re-resolve
166
+ return self._fetch(method, url, self.headers, self.timeout)
167
+ return self._fetch(method, url, self.headers, self.timeout)
168
+
169
+ def baseline(self) -> HttpResponse | None:
170
+ try:
171
+ return self._send("GET", f"{self.base}/jscope-nonexistent-{secrets.token_hex(6)}")
172
+ except ScopeError:
173
+ raise
174
+ except Exception: # noqa: BLE001
175
+ return None
176
+
177
+ @staticmethod
178
+ def _same_as_baseline(r: HttpResponse, b: HttpResponse | None) -> bool:
179
+ if b is None or r.status != b.status:
180
+ return False
181
+ ct = lambda x: x.content_type.split(";")[0].strip().lower()
182
+ if ct(r) != ct(b):
183
+ return False
184
+ return abs(r.length - b.length) <= max(32, int(0.05 * max(b.length, 1)))
185
+
186
+ def judge(self, item: PlanItem, r: HttpResponse, base: HttpResponse | None) -> tuple[str, str]:
187
+ st, ct = r.status, r.content_type.split(";")[0].strip().lower()
188
+ if st in (404, 410) and item.method == "GET":
189
+ return "not_found", f"{st}: no such route at this base URL (a wrong base URL also looks like this)"
190
+ if self._same_as_baseline(r, base):
191
+ return "inconclusive", f"{st} is indistinguishable from the server's answer for a random non-existent path (catch-all)"
192
+ if item.method == "OPTIONS":
193
+ allow = r.headers.get("allow", "") + r.headers.get("access-control-allow-methods", "")
194
+ if st in (200, 204) and allow:
195
+ return "exists", f"OPTIONS {st} advertises methods: {allow[:60]}"
196
+ return "inconclusive", f"OPTIONS {st} without an Allow header proves nothing"
197
+ if st in (401, 403):
198
+ return "exists_protected", f"{st}: the route exists and demands authorization"
199
+ if st == 405:
200
+ return "exists", "405 Method Not Allowed: the route exists"
201
+ if 200 <= st < 300:
202
+ if ct in ("text/html", "application/xhtml+xml") and (base is None or ct == base.content_type.split(";")[0].strip().lower()):
203
+ return "inconclusive", f"{st} text/html for an API-style path looks like an app-shell fallback"
204
+ return "exists", f"{st} {ct or 'no content-type'}, {r.length} bytes"
205
+ if 300 <= st < 400:
206
+ return "exists", f"{st} redirect to {r.headers.get('location', '?')[:60]} (differs from the catch-all baseline)"
207
+ if st in (404, 410):
208
+ return "not_found", f"{st}: no such route at this base URL (a wrong base URL also looks like this)"
209
+ return "inconclusive", f"{st} is not decisive"
210
+
211
+ def run(self, findings: list[Finding]) -> tuple[list[PlanItem], list[ProbeResult]]:
212
+ plan = self.plan(findings)
213
+ todo = [p for p in plan if p.url]
214
+ results: list[ProbeResult] = []
215
+ if not todo:
216
+ return plan, results
217
+ try:
218
+ base = self.baseline()
219
+ except EmergencyStopped:
220
+ return plan, results
221
+ for item in todo:
222
+ try:
223
+ r = self._send(item.method, item.url)
224
+ except EmergencyStopped as exc:
225
+ results.append(ProbeResult(item.finding_id, item.subject, item.method, item.url, None, "error", f"stopped: {exc}"))
226
+ break
227
+ except ScopeError as exc:
228
+ results.append(ProbeResult(item.finding_id, item.subject, item.method, item.url, None, "error", str(exc)))
229
+ if "cap" in str(exc):
230
+ break
231
+ continue
232
+ except Exception as exc: # noqa: BLE001 network errors are not evidence either way
233
+ results.append(ProbeResult(item.finding_id, item.subject, item.method, item.url, None, "error", f"{type(exc).__name__}: {exc}"[:120]))
234
+ continue
235
+ verdict, reason = self.judge(item, r, base)
236
+ results.append(ProbeResult(item.finding_id, item.subject, item.method, item.url, r.status, verdict, reason))
237
+ return plan, results
238
+
239
+
240
+ def evidence_for(res: ProbeResult) -> list[Evidence]:
241
+ if res.verdict in ("exists", "exists_protected"):
242
+ t = EvidenceType.RUNTIME_CONTEXT
243
+ elif res.verdict == "not_found":
244
+ t = EvidenceType.RUNTIME_NOT_FOUND
245
+ else:
246
+ return [] # inconclusive / error never move a score
247
+ return [Evidence(t, f"{res.method} {res.url} -> {res.reason}", RUNTIME_ANALYZER, None, None,
248
+ f"{res.method} {res.status}", {"verdict": res.verdict, "status": res.status})]
249
+
250
+
251
+ def apply_results(findings: list[Finding], results: list[ProbeResult]) -> list[Finding]:
252
+ by_id: dict[str, list[Evidence]] = {}
253
+ for r in results:
254
+ by_id.setdefault(r.finding_id, []).extend(evidence_for(r))
255
+ out = []
256
+ for f in findings:
257
+ extra = by_id.get(f.id)
258
+ out.append(apply_runtime_evidence(f, extra) if extra else f)
259
+ return out
@@ -0,0 +1,288 @@
1
+ """Scan scope and runtime safety (FR-10).
2
+
3
+ One policy object answers "may JScope talk to this destination?" for EVERY outbound request the runtime layer makes
4
+ (confirm, observe, verify). It checks, in this order:
5
+ scheme -> host allow-list -> port allow-list -> DNS resolution -> every resolved address against the
6
+ loopback/private/link-local/multicast/reserved/metadata deny-list (unless `allow_private` was granted for a controlled
7
+ local test) -> redirect target (same rules, plus `redirects` mode).
8
+
9
+ DNS-rebinding defence: the destination is resolved ONCE, validated, and then the connection is pinned to the validated
10
+ address (`PinnedSession`). A name that resolves to a public address when checked and to 127.0.0.1 when connected to
11
+ cannot happen, because there is no second resolution. TLS still verifies the original hostname.
12
+
13
+ Static analysis never imports this module's network pieces.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import ipaddress
19
+ import os
20
+ import socket
21
+ import threading
22
+ import time
23
+ from dataclasses import dataclass, field
24
+ from typing import Callable
25
+ from urllib.parse import urljoin, urlparse
26
+
27
+ Resolver = Callable[[str, int], list[str]]
28
+
29
+
30
+ def _redact(text: str) -> str:
31
+ from jscope.core.redact import redact_text, redact_url
32
+ return redact_url(text) if text.startswith(("http://", "https://")) else (redact_text(text) or "")
33
+
34
+
35
+ def system_resolver(host: str, port: int) -> list[str]:
36
+ infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
37
+ return sorted({i[4][0] for i in infos})
38
+
39
+
40
+ class ScopeViolation(ValueError):
41
+ """The destination is outside the configured scope. Never a network error: nothing was sent."""
42
+
43
+
44
+ class EmergencyStopped(RuntimeError):
45
+ pass
46
+
47
+
48
+ @dataclass
49
+ class BlockedRequest:
50
+ url: str
51
+ reason: str
52
+ at: float = field(default_factory=time.time)
53
+
54
+
55
+ def _is_prohibited(ip_text: str) -> str | None:
56
+ try:
57
+ ip = ipaddress.ip_address(ip_text.split("%")[0])
58
+ except ValueError:
59
+ return "unparseable address"
60
+ if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped:
61
+ ip = ip.ipv4_mapped
62
+ for name in ("is_loopback", "is_private", "is_link_local", "is_multicast", "is_reserved", "is_unspecified"):
63
+ if getattr(ip, name):
64
+ return name[3:].replace("_", " ")
65
+ if ip_text in ("169.254.169.254", "fd00:ec2::254"):
66
+ return "cloud metadata address"
67
+ if isinstance(ip, ipaddress.IPv4Address) and ip in ipaddress.ip_network("100.64.0.0/10"):
68
+ return "carrier-grade NAT"
69
+ return None
70
+
71
+
72
+ class EmergencyStop:
73
+ """Process-wide kill switch for active tests. Trips via `.trip()`, a sentinel file (`jscope stop`), or SIGINT handlers
74
+ that call trip(). Every request checks it BEFORE sending."""
75
+ DEFAULT_FILE = ".jscope-stop"
76
+
77
+ def __init__(self, stop_file: str | None = None):
78
+ self._ev = threading.Event()
79
+ self.stop_file = stop_file or os.environ.get("JSCOPE_STOP_FILE") or self.DEFAULT_FILE
80
+ self.reason = ""
81
+
82
+ def trip(self, reason: str = "emergency stop requested") -> None:
83
+ self.reason = reason
84
+ self._ev.set()
85
+
86
+ def is_set(self) -> bool:
87
+ if not self._ev.is_set() and self.stop_file and os.path.exists(self.stop_file):
88
+ self.trip(f"stop file {self.stop_file!r} present")
89
+ return self._ev.is_set()
90
+
91
+ def check(self) -> None:
92
+ if self.is_set():
93
+ raise EmergencyStopped(self.reason or "emergency stop")
94
+
95
+ def clear(self) -> None:
96
+ self._ev.clear()
97
+ self.reason = ""
98
+
99
+
100
+ @dataclass
101
+ class ScopePolicy:
102
+ allow_hosts: set[str]
103
+ schemes: tuple[str, ...] = ("http", "https")
104
+ ports: set[int] | None = None # None => default ports of the scheme only
105
+ redirects: str = "same-scope" # "none" | "same-scope"
106
+ max_redirects: int = 3
107
+ allow_private: bool = False # explicit opt-in for a controlled local/lab target
108
+ resolver: Resolver = system_resolver
109
+ blocked: list[BlockedRequest] = field(default_factory=list)
110
+ rate_per_second: float = 2.0
111
+ max_requests: int = 100
112
+ timeout: float = 8.0
113
+ stop: EmergencyStop = field(default_factory=EmergencyStop)
114
+
115
+ def __post_init__(self):
116
+ self.allow_hosts = {h.lower() for h in self.allow_hosts if h}
117
+ self.sent = 0
118
+ self._last = 0.0
119
+
120
+ # -- checks ------------------------------------------------------------------------------------------
121
+ def _host_allowed(self, host: str, port: int | None) -> bool:
122
+ host = host.lower()
123
+ if host in self.allow_hosts:
124
+ return True
125
+ return port is not None and f"{host}:{port}" in self.allow_hosts
126
+
127
+ def check_url(self, url: str, resolve: bool = True) -> list[str]:
128
+ """Return the validated addresses the connection may use, or raise ScopeViolation (and log the block)."""
129
+ try:
130
+ return self._check(url, resolve)
131
+ except ScopeViolation as exc:
132
+ self.blocked.append(BlockedRequest(url, str(exc)))
133
+ raise
134
+
135
+ def _check(self, url: str, resolve: bool) -> list[str]:
136
+ u = urlparse(url)
137
+ if u.scheme not in self.schemes:
138
+ raise ScopeViolation(f"scheme {u.scheme!r} is not allowed (allowed: {', '.join(self.schemes)})")
139
+ host = (u.hostname or "").lower()
140
+ if not host:
141
+ raise ScopeViolation("no host in URL")
142
+ if u.username or u.password:
143
+ raise ScopeViolation("credentials in the URL are not allowed")
144
+ port = u.port or (443 if u.scheme == "https" else 80)
145
+ default_port = u.port is None
146
+ if not self._host_allowed(host, u.port if u.port else None) and not self._host_allowed(host, port):
147
+ raise ScopeViolation(f"host {host!r} is not in the allow-list {sorted(self.allow_hosts)}")
148
+ if self.ports is not None:
149
+ if port not in self.ports:
150
+ raise ScopeViolation(f"port {port} is not allowed (allowed: {sorted(self.ports)})")
151
+ elif not default_port and f"{host}:{port}" not in self.allow_hosts:
152
+ raise ScopeViolation(f"non-default port {port} requires listing '{host}:{port}' in the allow-list or --allow-port")
153
+ # literal IPs are checked directly; names are resolved and EVERY answer is checked
154
+ try:
155
+ ipaddress.ip_address(host)
156
+ addrs = [host]
157
+ except ValueError:
158
+ addrs = self.resolver(host, port) if resolve else []
159
+ if resolve and not addrs:
160
+ raise ScopeViolation(f"{host!r} did not resolve to any address")
161
+ if not self.allow_private:
162
+ for a in addrs:
163
+ why = _is_prohibited(a)
164
+ if why:
165
+ raise ScopeViolation(f"{host!r} resolves to {a} ({why}); local/private destinations need explicit --allow-private")
166
+ return addrs
167
+
168
+ def check_redirect(self, current_url: str, location: str, hops: int) -> str:
169
+ if self.redirects == "none":
170
+ raise ScopeViolation("redirects are disabled by policy")
171
+ if hops >= self.max_redirects:
172
+ raise ScopeViolation(f"more than {self.max_redirects} redirects")
173
+ target = urljoin(current_url, location)
174
+ self.check_url(target) # same checks as any request: scheme, host, port, DNS, address class
175
+ return target
176
+
177
+ # -- pacing --------------------------------------------------------------------------------------------
178
+ def before_send(self, sleep: Callable[[float], None] = time.sleep) -> None:
179
+ self.stop.check()
180
+ if self.sent >= self.max_requests:
181
+ raise ScopeViolation("request cap reached")
182
+ gap = 1.0 / self.rate_per_second if self.rate_per_second > 0 else 0
183
+ wait = gap - (time.monotonic() - self._last)
184
+ if wait > 0:
185
+ sleep(wait)
186
+ self.stop.check()
187
+ self._last = time.monotonic()
188
+ self.sent += 1
189
+
190
+ def describe(self) -> dict:
191
+ return {"allow_hosts": sorted(self.allow_hosts), "schemes": list(self.schemes), "ports": sorted(self.ports) if self.ports else "default",
192
+ "redirects": self.redirects, "allow_private": self.allow_private, "rate_per_second": self.rate_per_second,
193
+ "max_requests": self.max_requests, "blocked": [{"url": _redact(b.url), "reason": _redact(b.reason)} for b in self.blocked]}
194
+
195
+
196
+ # ---------------------------------------------------------------------------------------------- pinned connections
197
+ _pin_lock = threading.RLock()
198
+ _pins: dict[str, str] = {}
199
+ _orig_create_connection = None
200
+
201
+
202
+ def _install_pin_hook():
203
+ global _orig_create_connection
204
+ from urllib3.util import connection as u3conn
205
+ if _orig_create_connection is not None:
206
+ return
207
+ _orig_create_connection = u3conn.create_connection
208
+
209
+ def pinned(address, *a, **kw):
210
+ host, port = address
211
+ ip = _pins.get(str(host).lower())
212
+ return _orig_create_connection((ip, port) if ip else address, *a, **kw)
213
+ u3conn.create_connection = pinned
214
+
215
+
216
+ class pinned_dns:
217
+ """Context manager: connections to `host` go to `ip` (already validated) and nowhere else, whatever DNS says later."""
218
+ def __init__(self, host: str, ip: str | None):
219
+ self.host, self.ip = host.lower(), ip
220
+
221
+ def __enter__(self):
222
+ _pin_lock.acquire()
223
+ _install_pin_hook()
224
+ if self.ip:
225
+ _pins[self.host] = self.ip
226
+ return self
227
+
228
+ def __exit__(self, *exc):
229
+ _pins.pop(self.host, None)
230
+ _pin_lock.release()
231
+
232
+
233
+ @dataclass
234
+ class ScopedResponse:
235
+ status: int
236
+ content_type: str = ""
237
+ length: int = 0
238
+ headers: dict = field(default_factory=dict)
239
+ set_cookies: list = field(default_factory=list)
240
+ body: bytes = b""
241
+ url: str = ""
242
+ redirects: list = field(default_factory=list) # [(from_url, status, to_url)]
243
+
244
+
245
+ class ScopedClient:
246
+ """The only HTTP client the runtime layer uses. Validates, pins, paces, follows redirects manually inside scope."""
247
+
248
+ def __init__(self, policy: ScopePolicy, headers: dict | None = None,
249
+ transport: Callable[[str, str, dict, float, int], ScopedResponse] | None = None,
250
+ sleep: Callable[[float], None] = time.sleep):
251
+ self.policy = policy
252
+ self.headers = {"User-Agent": "jscope/0.5 (authorized security testing)", "Accept": "*/*", **(headers or {})}
253
+ self._transport = transport
254
+ self._sleep = sleep
255
+
256
+ def request(self, method: str, url: str, headers: dict | None = None, follow: bool = False, body_limit: int = 65536) -> ScopedResponse:
257
+ hops, trail = 0, []
258
+ h = {**self.headers, **(headers or {})}
259
+ while True:
260
+ addrs = self.policy.check_url(url, resolve=self._transport is None)
261
+ self.policy.before_send(self._sleep)
262
+ r = self._send(method, url, h, addrs, body_limit)
263
+ r.url = url
264
+ loc = r.headers.get("location")
265
+ if follow and 300 <= r.status < 400 and loc:
266
+ target = self.policy.check_redirect(url, loc, hops)
267
+ trail.append((url, r.status, target))
268
+ hops += 1
269
+ url = target
270
+ continue
271
+ r.redirects = trail
272
+ return r
273
+
274
+ def _send(self, method: str, url: str, headers: dict, addrs: list[str], body_limit: int) -> ScopedResponse:
275
+ if self._transport is not None:
276
+ return self._transport(method, url, headers, self.policy.timeout, body_limit)
277
+ import requests
278
+ host = urlparse(url).hostname or ""
279
+ ip = addrs[0] if addrs else None
280
+ with pinned_dns(host, ip):
281
+ r = requests.request(method, url, headers=headers, timeout=self.policy.timeout, allow_redirects=False, stream=True)
282
+ try:
283
+ body = r.raw.read(body_limit, decode_content=True) if method == "GET" else b""
284
+ cookies = r.raw.headers.getlist("Set-Cookie") if hasattr(r.raw.headers, "getlist") else []
285
+ finally:
286
+ r.close()
287
+ return ScopedResponse(r.status_code, r.headers.get("Content-Type", ""), int(r.headers.get("Content-Length") or len(body)),
288
+ {k.lower(): v for k, v in r.headers.items()}, list(cookies), body)