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,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)."""
@@ -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