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
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Assessment-bound URL validation and guarded Requests transport.
|
|
2
|
+
|
|
3
|
+
This is an enforcement adapter for requests-based HTTP paths. Callers must supply
|
|
4
|
+
an explicit root/approved host set; redirects are handled manually and revalidated.
|
|
5
|
+
DNS checks reduce SSRF risk but requests' standard resolver is not connection-pinned,
|
|
6
|
+
so DNS rebinding/TOCTOU protection is not guaranteed by this adapter.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
import hashlib, ipaddress, json, socket, time, threading
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from urllib.parse import urljoin, urlsplit, urlunsplit
|
|
13
|
+
from urllib.request import url2pathname
|
|
14
|
+
import requests
|
|
15
|
+
|
|
16
|
+
class PolicyError(ValueError):
|
|
17
|
+
def __init__(self, reason: str, url: str = ""):
|
|
18
|
+
self.reason, self.url = reason, url
|
|
19
|
+
super().__init__(f"{reason}: {redact_url(url)}" if url else reason)
|
|
20
|
+
|
|
21
|
+
def normalize_host(host: str) -> str:
|
|
22
|
+
if not isinstance(host, str) or not host or '%' in host:
|
|
23
|
+
raise PolicyError('invalid-host')
|
|
24
|
+
host = host.rstrip('.').lower()
|
|
25
|
+
try:
|
|
26
|
+
return ipaddress.ip_address(host.strip('[]')).compressed
|
|
27
|
+
except ValueError:
|
|
28
|
+
try:
|
|
29
|
+
import idna
|
|
30
|
+
host = idna.encode(host, uts46=True, std3_rules=True).decode('ascii').lower()
|
|
31
|
+
except ImportError:
|
|
32
|
+
try: host = host.encode('idna').decode('ascii').lower()
|
|
33
|
+
except UnicodeError as e: raise PolicyError('invalid-idn') from e
|
|
34
|
+
if len(host) > 253 or not host or any(not x or len(x)>63 for x in host.split('.')):
|
|
35
|
+
raise PolicyError('invalid-host')
|
|
36
|
+
if any(not __import__('re').fullmatch(r'[a-z0-9](?:[a-z0-9-]*[a-z0-9])?', x) for x in host.split('.')):
|
|
37
|
+
raise PolicyError('invalid-host')
|
|
38
|
+
return host
|
|
39
|
+
|
|
40
|
+
def redact_url(url: str) -> str:
|
|
41
|
+
try:
|
|
42
|
+
p=urlsplit(url)
|
|
43
|
+
host=p.hostname or ''
|
|
44
|
+
return urlunsplit((p.scheme, host + ((':'+str(p.port)) if p.port else ''), p.path, '[redacted]' if p.query else '', ''))
|
|
45
|
+
except Exception: return '[invalid-url]'
|
|
46
|
+
|
|
47
|
+
def validate_url(url: str, hosts: set[str], schemes=('http','https'), ports=None, allow_subdomains=True) -> str:
|
|
48
|
+
if not isinstance(url,str) or any(ord(c)<32 for c in url): raise PolicyError('malformed-url', str(url))
|
|
49
|
+
try:
|
|
50
|
+
p=urlsplit(url)
|
|
51
|
+
if p.scheme.lower() not in schemes: raise PolicyError('scheme-not-allowed',url)
|
|
52
|
+
if not p.netloc or not p.hostname: raise PolicyError('missing-host',url)
|
|
53
|
+
if p.username is not None or p.password is not None or '@' in p.netloc: raise PolicyError('userinfo-not-allowed',url)
|
|
54
|
+
host=normalize_host(p.hostname)
|
|
55
|
+
port=p.port
|
|
56
|
+
if port is not None and not 1 <= port <= 65535: raise PolicyError('invalid-port',url)
|
|
57
|
+
effective=port or (443 if p.scheme.lower()=='https' else 80)
|
|
58
|
+
if ports is not None and effective not in ports: raise PolicyError('port-not-allowed',url)
|
|
59
|
+
normalized={normalize_host(h) for h in hosts}
|
|
60
|
+
if not any(host == h or (allow_subdomains and host.endswith('.'+h)) for h in normalized): raise PolicyError('host-out-of-scope',url)
|
|
61
|
+
# reject ambiguous authority and malformed percent escapes in authority
|
|
62
|
+
if '%' in p.netloc: raise PolicyError('malformed-authority',url)
|
|
63
|
+
return urlunsplit((p.scheme.lower(), (f'[{host}]' if ':' in host else host) + (f':{port}' if port else ''), p.path or '/', p.query, ''))
|
|
64
|
+
except PolicyError: raise
|
|
65
|
+
except (ValueError, UnicodeError) as e: raise PolicyError('malformed-url',url) from e
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class TransportPolicy:
|
|
69
|
+
hosts: set[str]
|
|
70
|
+
assessment_id: str
|
|
71
|
+
allow_private: bool = False
|
|
72
|
+
allow_loopback: bool = False
|
|
73
|
+
allow_link_local: bool = False
|
|
74
|
+
schemes: tuple[str,...] = ('http','https')
|
|
75
|
+
ports: set[int] | None = None
|
|
76
|
+
timeout: float = 10.0
|
|
77
|
+
max_redirects: int = 5
|
|
78
|
+
max_response_bytes: int = 8*1024*1024
|
|
79
|
+
requests_per_second: float = 2.0
|
|
80
|
+
total_budget: int = 100
|
|
81
|
+
retries: int = 0
|
|
82
|
+
methods: set[str] = field(default_factory=lambda: {'GET','HEAD'})
|
|
83
|
+
journal_path: str | None = None
|
|
84
|
+
per_host_requests_per_second: float | None = None
|
|
85
|
+
max_concurrency: int = 4
|
|
86
|
+
_count: int = 0
|
|
87
|
+
_last: float = 0.0
|
|
88
|
+
_host_last: dict[str, float] = field(default_factory=dict, repr=False)
|
|
89
|
+
_lock: object = field(default_factory=threading.Lock, repr=False)
|
|
90
|
+
_semaphore: object = field(default_factory=lambda: threading.BoundedSemaphore(4), repr=False)
|
|
91
|
+
|
|
92
|
+
def __post_init__(self):
|
|
93
|
+
if not self.assessment_id or not self.hosts: raise PolicyError('missing-assessment-or-scope')
|
|
94
|
+
self.hosts={normalize_host(h) for h in self.hosts}
|
|
95
|
+
if (self.timeout <= 0 or self.max_redirects < 0 or self.total_budget < 1
|
|
96
|
+
or self.requests_per_second <= 0 or self.max_response_bytes < 1
|
|
97
|
+
or self.max_concurrency < 1
|
|
98
|
+
or (self.per_host_requests_per_second is not None and self.per_host_requests_per_second <= 0)):
|
|
99
|
+
raise PolicyError('invalid-policy-limits')
|
|
100
|
+
self._semaphore = threading.BoundedSemaphore(self.max_concurrency)
|
|
101
|
+
|
|
102
|
+
def event(self, kind, url='', outcome='info', reason=''):
|
|
103
|
+
if not self.journal_path: return
|
|
104
|
+
p=Path(self.journal_path); p.parent.mkdir(parents=True,exist_ok=True)
|
|
105
|
+
record={'timestamp':time.time(),'assessment_id':self.assessment_id,'event':kind,'url':redact_url(url),'outcome':outcome,'reason_code':reason}
|
|
106
|
+
line=json.dumps(record,sort_keys=True)
|
|
107
|
+
# append-only event record with a hash of this record; not an authenticity guarantee
|
|
108
|
+
record['integrity_sha256']=hashlib.sha256(line.encode()).hexdigest()
|
|
109
|
+
with p.open('a',encoding='utf-8') as f: f.write(json.dumps(record,sort_keys=True)+'\n')
|
|
110
|
+
|
|
111
|
+
def check_dns(self, url):
|
|
112
|
+
p=urlsplit(url); host=normalize_host(p.hostname or '')
|
|
113
|
+
try: ip=ipaddress.ip_address(host)
|
|
114
|
+
except ValueError:
|
|
115
|
+
try: rows=socket.getaddrinfo(host,p.port or (443 if p.scheme=='https' else 80),type=socket.SOCK_STREAM)
|
|
116
|
+
except OSError as e: raise PolicyError('dns-failure',url) from e
|
|
117
|
+
ips={ipaddress.ip_address(row[4][0].split('%')[0]) for row in rows}
|
|
118
|
+
if not ips: raise PolicyError('dns-empty',url)
|
|
119
|
+
else: ips={ip}
|
|
120
|
+
for ip in ips:
|
|
121
|
+
if ip.is_loopback and not self.allow_loopback: raise PolicyError('loopback-address-blocked',url)
|
|
122
|
+
if ip.is_link_local and not self.allow_link_local: raise PolicyError('link-local-address-blocked',url)
|
|
123
|
+
if (ip.is_private or ip.is_reserved or ip.is_multicast or ip.is_unspecified) and not self.allow_private: raise PolicyError('restricted-address-blocked',url)
|
|
124
|
+
|
|
125
|
+
class GuardedSession:
|
|
126
|
+
"""Requests adapter that validates every hop and caps decoded response bytes.
|
|
127
|
+
|
|
128
|
+
DNS is checked immediately before each request, but Requests' normal adapter
|
|
129
|
+
resolves again at connect time. This implementation therefore does NOT claim
|
|
130
|
+
connection-pinned DNS-rebinding protection.
|
|
131
|
+
"""
|
|
132
|
+
_SENSITIVE_HEADERS = {"authorization", "proxy-authorization", "cookie", "cookie2"}
|
|
133
|
+
|
|
134
|
+
def __init__(self, policy: TransportPolicy, session=None):
|
|
135
|
+
self.policy = policy
|
|
136
|
+
self.session = session or requests.Session()
|
|
137
|
+
self.session.verify = True
|
|
138
|
+
|
|
139
|
+
def request(self, method, url, **kwargs):
|
|
140
|
+
method = method.upper()
|
|
141
|
+
if method not in self.policy.methods:
|
|
142
|
+
raise PolicyError('method-not-allowed', url)
|
|
143
|
+
current = url
|
|
144
|
+
original_origin = None
|
|
145
|
+
headers = dict(kwargs.pop("headers", {}) or {})
|
|
146
|
+
timeout = kwargs.pop("timeout", self.policy.timeout)
|
|
147
|
+
if isinstance(timeout, (int, float)):
|
|
148
|
+
timeout = (timeout, timeout)
|
|
149
|
+
elif not isinstance(timeout, tuple) or len(timeout) != 2 or min(timeout) <= 0:
|
|
150
|
+
raise PolicyError("invalid-timeout", url)
|
|
151
|
+
# The adapter owns redirects, streaming, TLS verification and timeouts.
|
|
152
|
+
kwargs.pop("allow_redirects", None)
|
|
153
|
+
kwargs.pop("stream", None)
|
|
154
|
+
kwargs["stream"] = True
|
|
155
|
+
kwargs["verify"] = True
|
|
156
|
+
for hop in range(self.policy.max_redirects + 1):
|
|
157
|
+
current = validate_url(current, self.policy.hosts, self.policy.schemes,
|
|
158
|
+
self.policy.ports, allow_subdomains=False)
|
|
159
|
+
self.policy.check_dns(current)
|
|
160
|
+
p = urlsplit(current)
|
|
161
|
+
origin = (p.scheme, normalize_host(p.hostname or ""), p.port or (443 if p.scheme == "https" else 80))
|
|
162
|
+
if original_origin is None:
|
|
163
|
+
original_origin = origin
|
|
164
|
+
elif origin != original_origin:
|
|
165
|
+
# Never forward caller- or session-level credentials across an origin change.
|
|
166
|
+
headers = {k: v for k, v in headers.items() if k.lower() not in self._SENSITIVE_HEADERS}
|
|
167
|
+
for key in list(getattr(self.session, "headers", {}).keys()):
|
|
168
|
+
if key.lower() in self._SENSITIVE_HEADERS:
|
|
169
|
+
self.session.headers.pop(key, None)
|
|
170
|
+
cookie_jar = getattr(self.session, "cookies", None)
|
|
171
|
+
if cookie_jar is not None:
|
|
172
|
+
cookie_jar.clear()
|
|
173
|
+
kwargs.pop("auth", None)
|
|
174
|
+
with self.policy._lock:
|
|
175
|
+
if self.policy._count >= self.policy.total_budget:
|
|
176
|
+
self.policy.event('request-blocked', current, 'blocked', 'request-budget-exhausted')
|
|
177
|
+
raise PolicyError('request-budget-exhausted', current)
|
|
178
|
+
now = time.monotonic()
|
|
179
|
+
delay = 1 / self.policy.requests_per_second - (now - self.policy._last)
|
|
180
|
+
host_rate = self.policy.per_host_requests_per_second or self.policy.requests_per_second
|
|
181
|
+
hdelay = 1 / host_rate - (now - self.policy._host_last.get(origin[1], 0.0))
|
|
182
|
+
delay = max(delay, hdelay)
|
|
183
|
+
if delay > 0:
|
|
184
|
+
time.sleep(delay)
|
|
185
|
+
now = time.monotonic()
|
|
186
|
+
self.policy._last = now
|
|
187
|
+
self.policy._host_last[origin[1]] = now
|
|
188
|
+
self.policy._count += 1
|
|
189
|
+
self.policy.event('request-attempted', current, 'allowed', 'scope-and-destination-validated')
|
|
190
|
+
try:
|
|
191
|
+
with self.policy._semaphore:
|
|
192
|
+
resp = self.session.request(method, current, timeout=timeout,
|
|
193
|
+
allow_redirects=False, headers=headers, **kwargs)
|
|
194
|
+
# Count decoded bytes while streaming. iter_content applies requests' decoding.
|
|
195
|
+
body = bytearray()
|
|
196
|
+
try:
|
|
197
|
+
iterator = resp.iter_content(chunk_size=65536) if hasattr(resp, "iter_content") else [getattr(resp, "content", b"")]
|
|
198
|
+
for chunk in iterator:
|
|
199
|
+
if not chunk:
|
|
200
|
+
continue
|
|
201
|
+
if len(body) + len(chunk) > self.policy.max_response_bytes:
|
|
202
|
+
resp.close()
|
|
203
|
+
self.policy.event('request-blocked', current, 'blocked', 'response-size-limit')
|
|
204
|
+
raise PolicyError('response-size-limit', current)
|
|
205
|
+
body.extend(chunk)
|
|
206
|
+
# Keep normal requests.Response semantics for downstream callers.
|
|
207
|
+
if hasattr(resp, "_content"):
|
|
208
|
+
resp._content = bytes(body)
|
|
209
|
+
resp._content_consumed = True
|
|
210
|
+
except Exception:
|
|
211
|
+
resp.close()
|
|
212
|
+
raise
|
|
213
|
+
except requests.RequestException as e:
|
|
214
|
+
self.policy.event('transport-error', current, 'error', type(e).__name__)
|
|
215
|
+
raise
|
|
216
|
+
if getattr(resp, "is_redirect", False) or getattr(resp, "is_permanent_redirect", False):
|
|
217
|
+
loc = resp.headers.get('Location')
|
|
218
|
+
status = resp.status_code
|
|
219
|
+
resp.close()
|
|
220
|
+
if not loc:
|
|
221
|
+
raise PolicyError('redirect-without-location', current)
|
|
222
|
+
if hop >= self.policy.max_redirects:
|
|
223
|
+
self.policy.event('redirect-blocked', current, 'blocked', 'redirect-limit')
|
|
224
|
+
raise PolicyError('redirect-limit', current)
|
|
225
|
+
nxt = urljoin(current, loc)
|
|
226
|
+
# Validate before the next send; next iteration rechecks DNS and scope.
|
|
227
|
+
validate_url(nxt, self.policy.hosts, self.policy.schemes, self.policy.ports, allow_subdomains=False)
|
|
228
|
+
self.policy.event('redirect-allowed', nxt, 'allowed', 'destination-in-scope')
|
|
229
|
+
current = nxt
|
|
230
|
+
if status == 303 or (status in (301, 302) and method == 'POST'):
|
|
231
|
+
method = 'GET'
|
|
232
|
+
kwargs.pop('data', None)
|
|
233
|
+
kwargs.pop('json', None)
|
|
234
|
+
continue
|
|
235
|
+
self.policy.event('request-completed', current, 'allowed', f'http-{resp.status_code}')
|
|
236
|
+
return resp
|
|
237
|
+
raise PolicyError('redirect-limit', current)
|
|
238
|
+
|
|
239
|
+
def get(self, url, **kwargs):
|
|
240
|
+
return self.request('GET', url, **kwargs)
|
|
241
|
+
|
|
242
|
+
def head(self, url, **kwargs):
|
|
243
|
+
return self.request('HEAD', url, **kwargs)
|
|
244
|
+
|
|
245
|
+
def close(self):
|
|
246
|
+
self.session.close()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Evidence-based vulnerability verification (FR-04)."""
|
jscope/verify/engine.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Verification engine (FR-04): attempts to establish whether a candidate finding is genuinely reproducible.
|
|
2
|
+
|
|
3
|
+
Every procedure returns a VerificationResult with one of five statuses (confirmed, probable, unverified, inconclusive,
|
|
4
|
+
rejected) and NEVER upgrades a finding on its own authority: `jscope.core.classification.classify` is the only place a
|
|
5
|
+
status becomes `confirmed_vulnerability`, and only when `procedure` is also set (so a bare status without a recorded,
|
|
6
|
+
reproducible procedure can never read as confirmed). A rejected verification does NOT delete or silently clean a finding
|
|
7
|
+
-- the original static evidence stays, with the verification attached (FR-04 acceptance criteria).
|
|
8
|
+
|
|
9
|
+
Supported classes in this version: dom_xss / reflected_xss, open_redirect, cors_misconfiguration, security_headers,
|
|
10
|
+
idor / broken_access_control (requires configured test identities). Each `generate_test` call derives a SAFE test
|
|
11
|
+
(read-only / harmless marker) from the finding; nothing here ever performs a destructive request.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
import time
|
|
18
|
+
import uuid
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import Any
|
|
21
|
+
from urllib.parse import urlencode, urljoin, urlparse
|
|
22
|
+
|
|
23
|
+
from jscope.core.redact import redact_obj, redact_url
|
|
24
|
+
from jscope.runtime.observe import PlaywrightDriver, Scenario, Recorder, RuntimeTrace
|
|
25
|
+
from jscope.runtime.scope import EmergencyStopped, ScopePolicy, ScopeViolation, ScopedClient, ScopedResponse
|
|
26
|
+
|
|
27
|
+
STATUSES = ("confirmed", "probable", "unverified", "inconclusive", "rejected")
|
|
28
|
+
SUPPORTED_CLASSES = ("dom_xss_candidate", "reflected_xss", "open_redirect_candidate", "cors_misconfiguration",
|
|
29
|
+
"security_headers", "idor", "broken_access_control", "session_handling")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class TestIdentity:
|
|
34
|
+
__test__ = False # not a pytest test class: a verification-engine data type that happens to start with "Test"
|
|
35
|
+
name: str
|
|
36
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
37
|
+
cookies: dict[str, str] = field(default_factory=dict)
|
|
38
|
+
role: str = ""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class TestCase:
|
|
43
|
+
vuln_class: str
|
|
44
|
+
finding_id: str
|
|
45
|
+
description: str
|
|
46
|
+
preconditions: list[str] = field(default_factory=list)
|
|
47
|
+
requests_planned: int = 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class VerificationResult:
|
|
52
|
+
finding_id: str
|
|
53
|
+
vuln_class: str
|
|
54
|
+
status: str
|
|
55
|
+
procedure: str
|
|
56
|
+
preconditions: list[str] = field(default_factory=list)
|
|
57
|
+
actions: list[dict] = field(default_factory=list) # redacted request/response evidence
|
|
58
|
+
observations: list[str] = field(default_factory=list)
|
|
59
|
+
limitations: list[str] = field(default_factory=list)
|
|
60
|
+
behavior_reproduced: bool = False
|
|
61
|
+
at: float = field(default_factory=time.time)
|
|
62
|
+
error: str | None = None
|
|
63
|
+
|
|
64
|
+
def to_metadata(self) -> dict[str, Any]:
|
|
65
|
+
return {"status": self.status, "procedure": self.procedure, "preconditions": self.preconditions,
|
|
66
|
+
"actions": self.actions, "observations": self.observations, "limitations": self.limitations,
|
|
67
|
+
"behavior_reproduced": self.behavior_reproduced, "at": self.at, "error": self.error, "class": self.vuln_class}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _rejected(fid: str, cls: str, procedure: str, reason: str) -> VerificationResult:
|
|
71
|
+
return VerificationResult(fid, cls, "rejected", procedure, observations=[reason], limitations=[reason])
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _inconclusive(fid: str, cls: str, procedure: str, reason: str, actions: list | None = None) -> VerificationResult:
|
|
75
|
+
return VerificationResult(fid, cls, "inconclusive", procedure, observations=[reason], limitations=[reason], actions=actions or [])
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class VerificationEngine:
|
|
79
|
+
def __init__(self, policy: ScopePolicy, base_url: str, identities: list[TestIdentity] | None = None,
|
|
80
|
+
playwright_driver: PlaywrightDriver | None = None, authorized: bool = False):
|
|
81
|
+
self.policy = policy
|
|
82
|
+
self.base = base_url.rstrip("/")
|
|
83
|
+
self.identities = identities or []
|
|
84
|
+
self.client = ScopedClient(policy)
|
|
85
|
+
self.pw = playwright_driver
|
|
86
|
+
self.authorized = authorized # FR-10: active verification requires explicit authorization
|
|
87
|
+
|
|
88
|
+
# ------------------------------------------------------------------------------------------ dispatch
|
|
89
|
+
def verify(self, finding: dict) -> VerificationResult:
|
|
90
|
+
cls = finding.get("metadata", {}).get("sink_class") or _infer_class(finding)
|
|
91
|
+
proc = {"html": self.verify_xss, "navigation": self.verify_open_redirect}.get(cls)
|
|
92
|
+
category = finding.get("category")
|
|
93
|
+
if category == "endpoint" and finding.get("metadata", {}).get("verify_as") == "cors":
|
|
94
|
+
proc = self.verify_cors
|
|
95
|
+
if category in ("authorization", "idor") or finding.get("attack_surface") == "idor_candidate":
|
|
96
|
+
proc = self.verify_authorization
|
|
97
|
+
if proc is None:
|
|
98
|
+
return VerificationResult(finding["id"], cls or category or "unknown", "unverified", "no-procedure",
|
|
99
|
+
observations=["no verification procedure is defined for this finding's class"])
|
|
100
|
+
if not self.authorized:
|
|
101
|
+
return _rejected(finding["id"], cls or category, "authorization-check",
|
|
102
|
+
"active verification requires explicit authorization (--authorize-active-tests)")
|
|
103
|
+
try:
|
|
104
|
+
self.policy.stop.check()
|
|
105
|
+
return proc(finding)
|
|
106
|
+
except EmergencyStopped as exc:
|
|
107
|
+
return _inconclusive(finding["id"], cls or category, "stopped", f"emergency stop: {exc}")
|
|
108
|
+
except ScopeViolation as exc:
|
|
109
|
+
return _rejected(finding["id"], cls or category, "scope-check", f"target is out of scope: {exc}")
|
|
110
|
+
|
|
111
|
+
# ------------------------------------------------------------------------------------------ XSS (reflected / DOM)
|
|
112
|
+
def verify_xss(self, finding: dict) -> VerificationResult:
|
|
113
|
+
flow = finding.get("metadata", {}).get("flow", {})
|
|
114
|
+
src_kind = flow.get("source", {}).get("kind", "")
|
|
115
|
+
url = self._url_for_source(finding, flow)
|
|
116
|
+
if url is None:
|
|
117
|
+
return _inconclusive(finding["id"], "dom_xss_candidate", "xss-marker",
|
|
118
|
+
"could not derive a concrete, safe test URL from this static source")
|
|
119
|
+
marker = f"jscopex{uuid.uuid4().hex[:8]}"
|
|
120
|
+
probe = f'<i data-jscope="{marker}">{marker}</i>'
|
|
121
|
+
test_url = self._inject(url, src_kind, probe)
|
|
122
|
+
if test_url is None:
|
|
123
|
+
return _inconclusive(finding["id"], "dom_xss_candidate", "xss-marker",
|
|
124
|
+
f"do not know how to safely inject a marker into source kind {src_kind!r}")
|
|
125
|
+
if self.pw is None or not PlaywrightDriver.available():
|
|
126
|
+
return self._xss_http_fallback(finding, test_url, marker, probe)
|
|
127
|
+
return self._xss_browser(finding, test_url, marker)
|
|
128
|
+
|
|
129
|
+
def _xss_browser(self, finding: dict, test_url: str, marker: str) -> VerificationResult:
|
|
130
|
+
trace = RuntimeTrace(test_url, "playwright")
|
|
131
|
+
try:
|
|
132
|
+
from playwright.sync_api import sync_playwright
|
|
133
|
+
except ImportError:
|
|
134
|
+
return self._xss_http_fallback(finding, test_url, marker, marker)
|
|
135
|
+
actions = [{"request": {"method": "GET", "url": redact_url(test_url)}}]
|
|
136
|
+
try:
|
|
137
|
+
self.policy.check_url(test_url)
|
|
138
|
+
with sync_playwright() as p:
|
|
139
|
+
browser = p.chromium.launch(headless=True, args=["--disable-dev-shm-usage"])
|
|
140
|
+
ctx = browser.new_context(service_workers="block", permissions=[])
|
|
141
|
+
page = ctx.new_page()
|
|
142
|
+
rec = Recorder(self.policy, trace)
|
|
143
|
+
ctx.route("**/*", rec.route_handler)
|
|
144
|
+
page.goto(test_url, wait_until="load", timeout=15000)
|
|
145
|
+
self.policy.stop.check()
|
|
146
|
+
found = page.evaluate(f"() => !!document.querySelector('[data-jscope=\"{marker}\"]')")
|
|
147
|
+
browser.close()
|
|
148
|
+
except EmergencyStopped:
|
|
149
|
+
raise
|
|
150
|
+
except ScopeViolation as exc:
|
|
151
|
+
return _rejected(finding["id"], "dom_xss_candidate", "xss-browser-marker", f"target out of scope: {exc}")
|
|
152
|
+
except Exception as exc: # noqa: BLE001
|
|
153
|
+
return _inconclusive(finding["id"], "dom_xss_candidate", "xss-browser-marker",
|
|
154
|
+
f"browser verification did not complete: {type(exc).__name__}", actions)
|
|
155
|
+
actions.append({"observation": "marker element present in the DOM" if found else "marker element not found"})
|
|
156
|
+
if found:
|
|
157
|
+
return VerificationResult(finding["id"], "dom_xss_candidate", "confirmed", "xss-browser-marker",
|
|
158
|
+
actions=actions, observations=[f"a harmless marker element ({marker}) injected via the "
|
|
159
|
+
"documented source was parsed into the live DOM, confirming the sink renders it as markup"],
|
|
160
|
+
behavior_reproduced=True)
|
|
161
|
+
return VerificationResult(finding["id"], "dom_xss_candidate", "rejected", "xss-browser-marker", actions=actions,
|
|
162
|
+
observations=["the injected marker was NOT found as a DOM element; the sink likely encodes or strips it"])
|
|
163
|
+
|
|
164
|
+
def _xss_http_fallback(self, finding: dict, test_url: str, marker: str, probe: str) -> VerificationResult:
|
|
165
|
+
try:
|
|
166
|
+
r = self.client.request("GET", test_url, follow=True)
|
|
167
|
+
except ScopeViolation as exc:
|
|
168
|
+
return _rejected(finding["id"], "dom_xss_candidate", "xss-http-reflection", f"target out of scope: {exc}")
|
|
169
|
+
body = r.body.decode("utf-8", "replace")
|
|
170
|
+
actions = [{"request": {"method": "GET", "url": redact_url(test_url)}, "response": {"status": r.status}}]
|
|
171
|
+
if probe in body:
|
|
172
|
+
return VerificationResult(finding["id"], "reflected_xss", "probable", "xss-http-reflection", actions=actions,
|
|
173
|
+
observations=[f"marker reflected unescaped in the response body (static check only; "
|
|
174
|
+
"DOM parsing / script execution was not observed -- install playwright to confirm)"],
|
|
175
|
+
limitations=["no browser available: this is reflection evidence, not confirmed DOM execution"])
|
|
176
|
+
if marker in body:
|
|
177
|
+
return VerificationResult(finding["id"], "reflected_xss", "unverified", "xss-http-reflection", actions=actions,
|
|
178
|
+
observations=["marker reflected but appears escaped/encoded"], behavior_reproduced=False)
|
|
179
|
+
return VerificationResult(finding["id"], "reflected_xss", "rejected", "xss-http-reflection", actions=actions,
|
|
180
|
+
observations=["marker not found in the response; could not reproduce"])
|
|
181
|
+
|
|
182
|
+
def _url_for_source(self, finding: dict, flow: dict) -> str | None:
|
|
183
|
+
meta = finding.get("metadata", {})
|
|
184
|
+
if meta.get("test_url"):
|
|
185
|
+
return urljoin(self.base + "/", meta["test_url"])
|
|
186
|
+
return self.base + "/"
|
|
187
|
+
|
|
188
|
+
@staticmethod
|
|
189
|
+
def _inject(url: str, src_kind: str, probe: str) -> str | None:
|
|
190
|
+
u = urlparse(url)
|
|
191
|
+
if src_kind == "url_hash":
|
|
192
|
+
return url.split("#")[0] + "#" + probe
|
|
193
|
+
if src_kind in ("url_query", "url"):
|
|
194
|
+
return url.split("?")[0] + "?" + urlencode({"q": probe})
|
|
195
|
+
if src_kind == "referrer":
|
|
196
|
+
return None # cannot forge the Referer header from the target URL alone
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
# ------------------------------------------------------------------------------------------ open redirect
|
|
200
|
+
def verify_open_redirect(self, finding: dict) -> VerificationResult:
|
|
201
|
+
flow = finding.get("metadata", {}).get("flow", {})
|
|
202
|
+
url = self._url_for_source(finding, flow)
|
|
203
|
+
marker_host = "jscope-verify.invalid"
|
|
204
|
+
test_url = self._inject(url, flow.get("source", {}).get("kind", ""), f"https://{marker_host}/")
|
|
205
|
+
if test_url is None:
|
|
206
|
+
return _inconclusive(finding["id"], "open_redirect_candidate", "redirect-marker",
|
|
207
|
+
"could not derive a concrete test URL for this source kind")
|
|
208
|
+
try:
|
|
209
|
+
r = self.client.request("GET", test_url, follow=False)
|
|
210
|
+
except ScopeViolation as exc:
|
|
211
|
+
return _rejected(finding["id"], "open_redirect_candidate", "redirect-marker", f"target out of scope: {exc}")
|
|
212
|
+
loc = r.headers.get("location", "")
|
|
213
|
+
actions = [{"request": {"method": "GET", "url": redact_url(test_url)}, "response": {"status": r.status, "location": redact_url(loc)}}]
|
|
214
|
+
if 300 <= r.status < 400 and marker_host in loc:
|
|
215
|
+
return VerificationResult(finding["id"], "open_redirect_candidate", "confirmed", "redirect-marker", actions=actions,
|
|
216
|
+
observations=[f"server issued a {r.status} redirect to the attacker-controlled host from the test URL"],
|
|
217
|
+
behavior_reproduced=True)
|
|
218
|
+
return VerificationResult(finding["id"], "open_redirect_candidate", "rejected", "redirect-marker", actions=actions,
|
|
219
|
+
observations=["no redirect to the attacker-controlled host was observed"])
|
|
220
|
+
|
|
221
|
+
# ------------------------------------------------------------------------------------------ CORS / headers
|
|
222
|
+
def verify_cors(self, finding: dict) -> VerificationResult:
|
|
223
|
+
path = finding.get("subject", "/")
|
|
224
|
+
url = self.base + (path if path.startswith("/") else "/" + path)
|
|
225
|
+
origin = "https://jscope-verify.invalid"
|
|
226
|
+
try:
|
|
227
|
+
r = self.client.request("GET", url, headers={"Origin": origin})
|
|
228
|
+
except ScopeViolation as exc:
|
|
229
|
+
return _rejected(finding["id"], "cors_misconfiguration", "cors-probe", f"target out of scope: {exc}")
|
|
230
|
+
aco, acc = r.headers.get("access-control-allow-origin", ""), r.headers.get("access-control-allow-credentials", "")
|
|
231
|
+
actions = [{"request": {"method": "GET", "url": redact_url(url), "headers": {"Origin": origin}},
|
|
232
|
+
"response": {"status": r.status, "headers": {"access-control-allow-origin": aco, "access-control-allow-credentials": acc}}}]
|
|
233
|
+
if aco == origin and acc.lower() == "true":
|
|
234
|
+
return VerificationResult(finding["id"], "cors_misconfiguration", "confirmed", "cors-probe", actions=actions,
|
|
235
|
+
observations=["server reflected an arbitrary Origin in Access-Control-Allow-Origin "
|
|
236
|
+
"together with Access-Control-Allow-Credentials: true -- any site can read authenticated responses"],
|
|
237
|
+
behavior_reproduced=True)
|
|
238
|
+
if aco == "*":
|
|
239
|
+
return VerificationResult(finding["id"], "cors_misconfiguration", "probable", "cors-probe", actions=actions,
|
|
240
|
+
observations=["Access-Control-Allow-Origin: * (credentials are not allowed with a wildcard, "
|
|
241
|
+
"so impact depends on whether the endpoint needs authentication)"])
|
|
242
|
+
return VerificationResult(finding["id"], "cors_misconfiguration", "rejected", "cors-probe", actions=actions,
|
|
243
|
+
observations=["server did not reflect the test Origin"])
|
|
244
|
+
|
|
245
|
+
# ------------------------------------------------------------------------------------------ authorization / IDOR
|
|
246
|
+
def verify_authorization(self, finding: dict) -> VerificationResult:
|
|
247
|
+
if len(self.identities) < 2:
|
|
248
|
+
return _inconclusive(finding["id"], "broken_access_control", "authz-diff",
|
|
249
|
+
"needs at least two configured test identities (--identity NAME=... at least twice); "
|
|
250
|
+
"none/too few configured")
|
|
251
|
+
path = finding.get("subject", "")
|
|
252
|
+
if not path.startswith("/") or re.search(r"[:{<*]", path):
|
|
253
|
+
return _inconclusive(finding["id"], "broken_access_control", "authz-diff",
|
|
254
|
+
"endpoint path still has an unresolved parameter; supply a concrete --test-url")
|
|
255
|
+
url = self.base + path
|
|
256
|
+
responses: dict[str, ScopedResponse] = {}
|
|
257
|
+
actions = []
|
|
258
|
+
try:
|
|
259
|
+
for ident in self.identities:
|
|
260
|
+
r = self.client.request("GET", url, headers=ident.headers, follow=True)
|
|
261
|
+
responses[ident.name] = r
|
|
262
|
+
actions.append({"identity": ident.name, "role": ident.role,
|
|
263
|
+
"request": {"method": "GET", "url": redact_url(url)}, "response": {"status": r.status, "length": r.length}})
|
|
264
|
+
except ScopeViolation as exc:
|
|
265
|
+
return _rejected(finding["id"], "broken_access_control", "authz-diff", f"target out of scope: {exc}")
|
|
266
|
+
low_priv = [i for i in self.identities if not i.role or i.role in ("user", "low", "unauth", "guest")]
|
|
267
|
+
high_priv = [i for i in self.identities if i not in low_priv] or self.identities[1:]
|
|
268
|
+
if not low_priv:
|
|
269
|
+
low_priv = [self.identities[0]]
|
|
270
|
+
lo, hi = responses[low_priv[0].name], responses[high_priv[0].name]
|
|
271
|
+
if lo.status == 200 and hi.status == 200 and lo.length and hi.length and abs(lo.length - hi.length) / max(lo.length, hi.length) < 0.05:
|
|
272
|
+
return VerificationResult(finding["id"], "broken_access_control", "confirmed", "authz-diff", actions=actions,
|
|
273
|
+
observations=[f"identity '{low_priv[0].name}' (role={low_priv[0].role or 'unspecified'}) received "
|
|
274
|
+
f"a 200 response of similar size/shape to '{high_priv[0].name}' -- access does not appear to be "
|
|
275
|
+
"differentiated by role for this endpoint"], behavior_reproduced=True,
|
|
276
|
+
limitations=["response-shape comparison is a strong signal, not proof the SAME record/data was returned; "
|
|
277
|
+
"manually diff the response bodies"])
|
|
278
|
+
if lo.status in (401, 403) and hi.status == 200:
|
|
279
|
+
return VerificationResult(finding["id"], "broken_access_control", "rejected", "authz-diff", actions=actions,
|
|
280
|
+
observations=[f"low-privilege identity got {lo.status}; access control appears to be enforced"])
|
|
281
|
+
return VerificationResult(finding["id"], "broken_access_control", "inconclusive", "authz-diff", actions=actions,
|
|
282
|
+
observations=[f"responses differ in a way JScope cannot interpret automatically "
|
|
283
|
+
f"({low_priv[0].name}={lo.status}/{lo.length}b, {high_priv[0].name}={hi.status}/{hi.length}b)"])
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _infer_class(finding: dict) -> str | None:
|
|
287
|
+
cat = finding.get("category")
|
|
288
|
+
if cat == "secret":
|
|
289
|
+
return None
|
|
290
|
+
return cat
|
|
File without changes
|