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.
- jscope/__init__.py +1 -0
- jscope/__main__.py +10 -0
- jscope/ai/__init__.py +0 -0
- jscope/ai/explain.py +180 -0
- jscope/ai/providers.py +71 -0
- jscope/analyzers/__init__.py +0 -0
- jscope/analyzers/auth_analyzer.py +170 -0
- jscope/analyzers/authorization_analyzer.py +196 -0
- jscope/analyzers/base.py +56 -0
- jscope/analyzers/common.py +78 -0
- jscope/analyzers/dataflow_analyzer.py +192 -0
- jscope/analyzers/endpoint_analyzer.py +150 -0
- jscope/analyzers/framework_detector.py +82 -0
- jscope/analyzers/graphql_analyzer.py +198 -0
- jscope/analyzers/graphql_schema_analyzer.py +132 -0
- jscope/analyzers/html_analyzer.py +196 -0
- jscope/analyzers/manager.py +114 -0
- jscope/analyzers/openapi_analyzer.py +278 -0
- jscope/analyzers/router_analyzer.py +502 -0
- jscope/analyzers/secret_analyzer.py +99 -0
- jscope/analyzers/service_worker_analyzer.py +142 -0
- jscope/analyzers/source_map_analyzer.py +119 -0
- jscope/analyzers/storage_analyzer.py +125 -0
- jscope/analyzers/wasm_analyzer.py +139 -0
- jscope/assessment_engine.py +285 -0
- jscope/assessment_reporting.py +71 -0
- jscope/benchmark/__init__.py +1 -0
- jscope/benchmark/fixtures.py +105 -0
- jscope/benchmark/runner.py +155 -0
- jscope/cli.py +1241 -0
- jscope/config.py +72 -0
- jscope/core/__init__.py +0 -0
- jscope/core/artifacts.py +139 -0
- jscope/core/cache.py +198 -0
- jscope/core/classification.py +56 -0
- jscope/core/evidence.py +411 -0
- jscope/core/graph.py +196 -0
- jscope/core/redact.py +67 -0
- jscope/crawler/__init__.py +0 -0
- jscope/crawler/crawler.py +176 -0
- jscope/dataflow/__init__.py +1 -0
- jscope/dataflow/engine.py +1684 -0
- jscope/dataflow/modules.py +205 -0
- jscope/dataflow/rules.py +118 -0
- jscope/downloader/__init__.py +0 -0
- jscope/downloader/downloader.py +116 -0
- jscope/parsing/__init__.py +0 -0
- jscope/parsing/ast_engine.py +322 -0
- jscope/parsing/graphql_schema.py +153 -0
- jscope/parsing/html.py +55 -0
- jscope/parsing/queries.py +385 -0
- jscope/parsing/wasm.py +146 -0
- jscope/passive_discovery.py +63 -0
- jscope/pipeline.py +196 -0
- jscope/plugin/__init__.py +0 -0
- jscope/plugin/_worker.py +199 -0
- jscope/plugin/loader.py +225 -0
- jscope/plugin/sandbox.py +207 -0
- jscope/reporting/__init__.py +0 -0
- jscope/reporting/formats.py +208 -0
- jscope/reporting/human.py +137 -0
- jscope/reporting/json_report.py +35 -0
- jscope/reporting/priority.py +116 -0
- jscope/runtime/__init__.py +0 -0
- jscope/runtime/observe.py +397 -0
- jscope/runtime/prober.py +259 -0
- jscope/runtime/scope.py +288 -0
- jscope/security_transport.py +246 -0
- jscope/verify/__init__.py +1 -0
- jscope/verify/engine.py +290 -0
- jscope/workspace/__init__.py +0 -0
- jscope/workspace/assessment.py +133 -0
- jscope/workspace/common.py +25 -0
- jscope/workspace/diff.py +143 -0
- jscope/workspace/store.py +206 -0
- jscope-0.4.2.dist-info/METADATA +180 -0
- jscope-0.4.2.dist-info/RECORD +81 -0
- jscope-0.4.2.dist-info/WHEEL +5 -0
- jscope-0.4.2.dist-info/entry_points.txt +2 -0
- jscope-0.4.2.dist-info/licenses/LICENSE +21 -0
- jscope-0.4.2.dist-info/top_level.txt +1 -0
jscope/runtime/prober.py
ADDED
|
@@ -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
|
jscope/runtime/scope.py
ADDED
|
@@ -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)
|