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,116 @@
1
+ """Deterministic, explainable prioritization ("where do I start testing?").
2
+
3
+ No ML. Each finding gets an integer priority from a fixed rule table; every
4
+ point is accompanied by a human-readable reason so the ranking can be argued
5
+ with. Rules follow design-brainstorm.md section 4:
6
+ 1. auth/authz-adjacent surfaces rank highest (broken access control is the
7
+ most valuable bug class in bounty programs)
8
+ 2. cross-analyzer agreement outranks single-analyzer findings
9
+ 3. findings reachable from a route with NO client-side protection rank
10
+ above those behind a guard
11
+ 4. higher confidence breaks ties
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+
18
+ from jscope.core.evidence import Confidence, Finding
19
+ from jscope.core.graph import KnowledgeGraph
20
+
21
+ SURFACE_BASE = {
22
+ "authorization": 40, "admin": 38, "auth": 34, "idor_candidate": 34, "internal": 32, "debug": 30,
23
+ "payment": 30, "graphql": 28, "token_storage": 26, "upload": 26, "export": 24, "cookie_handling": 22,
24
+ "source_map": 22, "service_worker": 14,
25
+ # Phase 4
26
+ "undocumented_api": 36, "unauthenticated_write": 30, "deprecated_api": 20, "csrf_candidate": 18,
27
+ "supply_chain": 16, "csp_weak": 12, "wasm": 14,
28
+ # 0.5 data-flow surfaces
29
+ "dom_xss_candidate": 36, "code_injection_candidate": 40, "open_redirect_candidate": 24,
30
+ "sql_injection_candidate": 38, "command_injection_candidate": 40,
31
+ }
32
+ CONF_POINTS = {Confidence.CONFIRMED: 12, Confidence.HIGH: 9, Confidence.MEDIUM: 6, Confidence.LOW: 3,
33
+ Confidence.INFORMATIONAL: 0}
34
+
35
+
36
+ def finding_key(category: str, subject: str) -> str:
37
+ """Stable identity across scans. `JS-0001` ids are order-based and NOT stable; this is."""
38
+ return f"{category}|{subject}"
39
+
40
+
41
+ @dataclass
42
+ class PriorityDelta:
43
+ """What changed since the previous scan (Phase 3). Empty on a first scan: a baseline is not 'new'."""
44
+ new_keys: set[str] = field(default_factory=set)
45
+ raised_keys: set[str] = field(default_factory=set)
46
+ weakened_routes: set[str] = field(default_factory=set) # route node ids that lost protection
47
+
48
+
49
+ NEW_POINTS, RAISED_POINTS, WEAKENED_POINTS = 12, 8, 14
50
+ HIDDEN_POINTS, RUNTIME_POINTS = 10, 10
51
+
52
+
53
+ def prioritize(findings: list[Finding], graph: KnowledgeGraph | None = None,
54
+ delta: PriorityDelta | None = None) -> list[tuple[Finding, int, list[str]]]:
55
+ ranked = []
56
+ for f in findings:
57
+ pts, why = 0, []
58
+ if f.attack_surface:
59
+ base = SURFACE_BASE.get(f.attack_surface, 10)
60
+ pts += base
61
+ why.append(f"+{base} attack surface '{f.attack_surface}'")
62
+ else:
63
+ continue # priority list is about attack surfaces
64
+ c = CONF_POINTS.get(f.confidence, 0)
65
+ pts += c
66
+ why.append(f"+{c} confidence {f.confidence.value}")
67
+ if any(e.type.value == "CROSS_ANALYZER_AGREEMENT" for e in f.evidence):
68
+ pts += 8
69
+ why.append("+8 independent analyzers agree")
70
+ if f.metadata.get("client_referenced") is False:
71
+ unresolved = f.metadata.get("client_unresolved_calls", 0)
72
+ if unresolved:
73
+ pts += HIDDEN_POINTS // 2
74
+ why.append(f"+{HIDDEN_POINTS // 2} documented in a spec/schema but matched by no resolvable client call "
75
+ f"(halved: {unresolved} client request(s) have unresolvable URLs, so a dynamic caller is possible)")
76
+ else:
77
+ pts += HIDDEN_POINTS
78
+ why.append(f"+{HIDDEN_POINTS} documented in a spec/schema but matched by no client call (UI never exercises it)")
79
+ rt = [e for e in f.evidence if e.type.value.startswith("RUNTIME_")]
80
+ if rt:
81
+ if any(e.type.value == "RUNTIME_CONTEXT" for e in rt):
82
+ pts += RUNTIME_POINTS
83
+ why.append(f"+{RUNTIME_POINTS} confirmed to respond on the live origin (opt-in probe)")
84
+ else:
85
+ pts -= RUNTIME_POINTS
86
+ why.append(f"-{RUNTIME_POINTS} probe got a genuine not-found at the tested base URL")
87
+ if f.metadata.get("absence_based"):
88
+ pts -= 6
89
+ why.append("-6 finding is based on the absence of a guard")
90
+ if graph is not None and f.category == "endpoint" and f.attack_surface == "auth":
91
+ why.append("+0 auth endpoints are expected to be reachable without a session")
92
+ elif graph is not None and f.category == "endpoint":
93
+ routes = graph.routes_reaching(f"endpoint:{f.subject}")
94
+ if routes:
95
+ unprot = [r for r in routes if graph.route_protection(r.id)["unguarded"]]
96
+ if unprot:
97
+ pts += 10
98
+ why.append(f"+10 reachable from route with no client-side protection ({unprot[0].label})")
99
+ else:
100
+ why.append("+0 reachable only via client-guarded routes (still verify server-side)")
101
+ if delta is not None:
102
+ key = finding_key(f.category, f.subject)
103
+ if key in delta.new_keys:
104
+ pts += NEW_POINTS
105
+ why.append(f"+{NEW_POINTS} new since previous scan")
106
+ elif key in delta.raised_keys:
107
+ pts += RAISED_POINTS
108
+ why.append(f"+{RAISED_POINTS} confidence rose since previous scan")
109
+ if graph is not None and f.category == "endpoint" and delta.weakened_routes:
110
+ hit = [r for r in graph.routes_reaching(f"endpoint:{f.subject}") if r.id in delta.weakened_routes]
111
+ if hit:
112
+ pts += WEAKENED_POINTS
113
+ why.append(f"+{WEAKENED_POINTS} reachable from route that lost client-side protection ({hit[0].label})")
114
+ ranked.append((f, pts, why))
115
+ ranked.sort(key=lambda t: (-t[1], t[0].id))
116
+ return ranked
File without changes
@@ -0,0 +1,397 @@
1
+ """Runtime observation (FR-03): collect evidence about what the application ACTUALLY does, inside an explicit scope.
2
+
3
+ Two drivers share one trace model:
4
+ * PlaywrightDriver -- a real browser (JavaScript executes, XHR/fetch are seen). Needs `pip install playwright` + a browser.
5
+ * HttpPassiveDriver -- no JavaScript: fetches the page and its script/stylesheet references through the scoped client.
6
+ Honest about its blind spot: requests initiated BY script are not observed.
7
+ Both route every outbound request through ScopePolicy (scheme/host/port/DNS/address class/redirects/rate/stop switch).
8
+
9
+ Evidence discipline (PRD FR-03):
10
+ * a trace separates what was OBSERVED from what static analysis INFERRED; correlation never claims an exact source line
11
+ (exactness is `endpoint-match` or `initiator-file`, never a line number)
12
+ * a blocked request, browser failure or timeout makes the trace `incomplete` and every conclusion that depends on the
13
+ missing part `inconclusive` -- absence of an observation is never reported as safety
14
+ * credentials are not retained: Authorization/Cookie headers are never recorded, cookie VALUES are never stored,
15
+ storage values for sensitive-looking keys are masked, URLs are redacted (jscope.core.redact)
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import re
22
+ import time
23
+ from dataclasses import dataclass, field
24
+ from typing import Any, Callable
25
+ from urllib.parse import urljoin, urlparse
26
+
27
+ from jscope.core.redact import redact_storage_value, redact_url
28
+ from jscope.runtime.scope import (EmergencyStopped, ScopePolicy, ScopeViolation, ScopedClient, pinned_dns)
29
+
30
+ DRIVERS = ("auto", "playwright", "http")
31
+
32
+
33
+ @dataclass
34
+ class TraceEvent:
35
+ kind: str # navigation | redirect | request | response | blocked | storage | console_error | step
36
+ url: str = ""
37
+ method: str = ""
38
+ status: int | None = None
39
+ resource_type: str = "" # document | script | xhr | fetch | stylesheet | image | other
40
+ from_url: str = ""
41
+ initiator: str = "" # script URL that issued the request when the driver can tell (file-level only)
42
+ detail: str = ""
43
+ step: int = 0
44
+ seq: int = 0
45
+
46
+ def to_dict(self) -> dict:
47
+ return {k: v for k, v in self.__dict__.items() if v not in ("", None) or k in ("kind", "seq")}
48
+
49
+
50
+ @dataclass
51
+ class RuntimeTrace:
52
+ target: str
53
+ driver: str
54
+ scenario: str = "default"
55
+ events: list[TraceEvent] = field(default_factory=list)
56
+ status: str = "complete" # complete | incomplete
57
+ incomplete_reasons: list[str] = field(default_factory=list)
58
+ limitations: list[str] = field(default_factory=list)
59
+ storage: dict[str, Any] = field(default_factory=dict)
60
+ scope: dict[str, Any] = field(default_factory=dict)
61
+
62
+ def add(self, **kw) -> TraceEvent:
63
+ ev = TraceEvent(seq=len(self.events), **kw)
64
+ self.events.append(ev)
65
+ return ev
66
+
67
+ def incomplete(self, reason: str) -> None:
68
+ self.status = "incomplete"
69
+ if reason not in self.incomplete_reasons:
70
+ self.incomplete_reasons.append(reason)
71
+
72
+ def requests(self, *types: str) -> list[TraceEvent]:
73
+ return [e for e in self.events if e.kind == "request" and (not types or e.resource_type in types)]
74
+
75
+ def to_dict(self) -> dict:
76
+ return {"schema": "jscope-runtime-trace/1", "target": redact_url(self.target), "driver": self.driver, "scenario": self.scenario,
77
+ "status": self.status, "incomplete_reasons": self.incomplete_reasons, "limitations": self.limitations,
78
+ "events": [e.to_dict() for e in self.events], "storage": self.storage, "scope": self.scope}
79
+
80
+
81
+ # ------------------------------------------------------------------------------------------------ scenarios
82
+ @dataclass
83
+ class Scenario:
84
+ name: str = "default"
85
+ steps: list[dict] = field(default_factory=list) # {"goto": path} {"click": sel} {"fill": [sel, text]} {"wait_ms": n}
86
+
87
+ @staticmethod
88
+ def load(path: str) -> "Scenario":
89
+ data = json.loads(open(path, encoding="utf-8").read())
90
+ steps = data.get("steps", [])
91
+ if not isinstance(steps, list) or not all(isinstance(s, dict) and len(s) == 1 for s in steps):
92
+ raise ValueError("scenario steps must be a list of single-key objects, e.g. {\"goto\": \"/\"}")
93
+ for s in steps:
94
+ if next(iter(s)) not in ("goto", "click", "fill", "wait_ms"):
95
+ raise ValueError(f"unsupported scenario step {next(iter(s))!r}")
96
+ return Scenario(str(data.get("name", "scenario")), steps)
97
+
98
+ @staticmethod
99
+ def default(path: str = "/") -> "Scenario":
100
+ return Scenario("default", [{"goto": path}])
101
+
102
+
103
+ # ------------------------------------------------------------------------------------------------ passive HTTP driver
104
+ class HttpPassiveDriver:
105
+ name = "http-passive"
106
+
107
+ def __init__(self, policy: ScopePolicy, client: ScopedClient | None = None):
108
+ self.policy = policy
109
+ self.client = client or ScopedClient(policy)
110
+
111
+ def run(self, base_url: str, scenario: Scenario, trace: RuntimeTrace) -> None:
112
+ trace.limitations.append("passive HTTP driver: JavaScript is NOT executed, so requests issued by scripts (XHR/fetch), "
113
+ "client-side routing and DOM state are not observed")
114
+ for i, step in enumerate(scenario.steps):
115
+ (kind, arg), = step.items()
116
+ trace.add(kind="step", detail=f"{kind} {redact_url(str(arg)) if kind == 'goto' else '...'}", step=i)
117
+ if kind != "goto":
118
+ trace.incomplete(f"step {kind!r} needs a real browser (use --driver playwright)")
119
+ continue
120
+ try:
121
+ self._goto(urljoin(base_url, str(arg)), trace, i)
122
+ except EmergencyStopped as exc:
123
+ trace.incomplete(f"emergency stop: {exc}")
124
+ return
125
+ except ScopeViolation as exc:
126
+ trace.add(kind="blocked", url=redact_url(urljoin(base_url, str(arg))), detail=str(exc), step=i)
127
+ trace.incomplete(f"request blocked by scope policy: {exc}")
128
+ except Exception as exc: # noqa: BLE001 - network errors are inconclusive, never "safe"
129
+ trace.incomplete(f"request failed: {type(exc).__name__}")
130
+
131
+ def _goto(self, url: str, trace: RuntimeTrace, step: int) -> None:
132
+ r = self.client.request("GET", url, follow=True, body_limit=512 * 1024)
133
+ for frm, st, to in r.redirects:
134
+ trace.add(kind="redirect", url=redact_url(to), from_url=redact_url(frm), status=st, step=step)
135
+ trace.add(kind="request", url=redact_url(r.url), method="GET", resource_type="document", step=step)
136
+ trace.add(kind="response", url=redact_url(r.url), status=r.status, resource_type="document", step=step)
137
+ trace.add(kind="navigation", url=redact_url(r.url), status=r.status, step=step)
138
+ if "html" not in (r.content_type or "").lower():
139
+ return
140
+ from bs4 import BeautifulSoup
141
+ soup = BeautifulSoup(r.body.decode("utf-8", "replace"), "html.parser")
142
+ subs: list[tuple[str, str]] = []
143
+ for el in soup.find_all("script", src=True):
144
+ subs.append((urljoin(r.url, el["src"]), "script"))
145
+ for el in soup.find_all("link", href=True):
146
+ if {"stylesheet", "modulepreload"} & set(el.get("rel", [])):
147
+ subs.append((urljoin(r.url, el["href"]), "stylesheet"))
148
+ for u, rtype in subs[:40]:
149
+ try:
150
+ sr = self.client.request("GET", u, follow=True, body_limit=1024)
151
+ trace.add(kind="request", url=redact_url(sr.url), method="GET", resource_type=rtype, initiator=redact_url(r.url), step=step)
152
+ trace.add(kind="response", url=redact_url(sr.url), status=sr.status, resource_type=rtype, step=step)
153
+ except ScopeViolation as exc:
154
+ trace.add(kind="blocked", url=redact_url(u), detail=str(exc), step=step)
155
+ trace.incomplete(f"subresource blocked by scope policy: {urlparse(u).netloc}")
156
+ except EmergencyStopped:
157
+ raise
158
+ except Exception as exc: # noqa: BLE001
159
+ trace.incomplete(f"subresource failed: {type(exc).__name__}")
160
+
161
+
162
+ # ------------------------------------------------------------------------------------------------ browser driver
163
+ class PlaywrightDriver:
164
+ """Real browser driver. The trace recording is separated from Playwright (`on_request`, `on_response`, ...), so the
165
+ event handling is unit-tested with stand-in objects; the launch/route code needs an installed browser."""
166
+ name = "playwright"
167
+
168
+ def __init__(self, policy: ScopePolicy, headless: bool = True, step_timeout_ms: int = 15000,
169
+ sync_playwright: Callable | None = None):
170
+ self.policy, self.headless, self.step_timeout_ms = policy, headless, step_timeout_ms
171
+ self._sync_playwright = sync_playwright
172
+
173
+ @staticmethod
174
+ def available() -> bool:
175
+ try:
176
+ import playwright.sync_api # noqa: F401
177
+ return True
178
+ except ImportError:
179
+ return False
180
+
181
+ # -- event recording (browser independent) ----------------------------------------------------
182
+ def attach(self, trace: RuntimeTrace) -> "Recorder":
183
+ return Recorder(self.policy, trace)
184
+
185
+ def run(self, base_url: str, scenario: Scenario, trace: RuntimeTrace) -> None:
186
+ sp = self._sync_playwright
187
+ if sp is None:
188
+ try:
189
+ from playwright.sync_api import sync_playwright as sp
190
+ except ImportError:
191
+ trace.incomplete("playwright is not installed (pip install playwright && playwright install chromium)")
192
+ return
193
+ rec = self.attach(trace)
194
+ try:
195
+ host = urlparse(base_url).hostname or ""
196
+ port = urlparse(base_url).port or (443 if base_url.startswith("https") else 80)
197
+ addrs = self.policy.check_url(base_url) # validated BEFORE the browser starts
198
+ args = ["--disable-dev-shm-usage", "--disable-extensions", "--no-first-run", "--disable-background-networking",
199
+ f"--host-resolver-rules=MAP {host} {addrs[0] if addrs else host}, MAP * ~NOTFOUND"] # pins DNS in the browser
200
+ with sp() as p:
201
+ browser = p.chromium.launch(headless=self.headless, args=args)
202
+ ctx = browser.new_context(accept_downloads=False, service_workers="block", permissions=[], ignore_https_errors=False,
203
+ java_script_enabled=True, bypass_csp=False)
204
+ ctx.route("**/*", rec.route_handler)
205
+ page = ctx.new_page()
206
+ page.set_default_timeout(self.step_timeout_ms)
207
+ page.on("request", rec.on_request)
208
+ page.on("response", rec.on_response)
209
+ page.on("framenavigated", lambda fr: rec.on_navigated(fr.url) if fr == page.main_frame else None)
210
+ page.on("console", lambda m: rec.on_console(m.type, m.text))
211
+ page.on("requestfailed", lambda r: rec.on_failed(r.url, getattr(r, "failure", "")))
212
+ for i, step in enumerate(scenario.steps):
213
+ self.policy.stop.check()
214
+ (kind, arg), = step.items()
215
+ rec.step = i
216
+ trace.add(kind="step", detail=f"{kind} {redact_url(str(arg)) if kind == 'goto' else (arg[0] if kind == 'fill' else arg)}", step=i)
217
+ try:
218
+ if kind == "goto":
219
+ page.goto(urljoin(base_url, str(arg)), wait_until="load")
220
+ elif kind == "click":
221
+ page.click(str(arg))
222
+ elif kind == "fill":
223
+ page.fill(str(arg[0]), str(arg[1]))
224
+ elif kind == "wait_ms":
225
+ page.wait_for_timeout(int(arg))
226
+ except EmergencyStopped:
227
+ raise
228
+ except Exception as exc: # noqa: BLE001 - timeouts etc.
229
+ trace.incomplete(f"step {i} ({kind}) did not complete: {type(exc).__name__}")
230
+ self.snapshot_storage(page, ctx, trace)
231
+ browser.close()
232
+ except EmergencyStopped as exc:
233
+ trace.incomplete(f"emergency stop: {exc}")
234
+ except ScopeViolation as exc:
235
+ trace.incomplete(f"target blocked by scope policy: {exc}")
236
+ except Exception as exc: # noqa: BLE001 - browser crash / missing browser binary
237
+ trace.incomplete(f"browser failure: {type(exc).__name__}: {str(exc)[:160]}")
238
+
239
+ def snapshot_storage(self, page, ctx, trace: RuntimeTrace) -> None:
240
+ try:
241
+ raw = page.evaluate("() => ({local: Object.entries(localStorage), session: Object.entries(sessionStorage)})")
242
+ trace.storage = store_snapshot(raw.get("local", []), raw.get("session", []), ctx.cookies())
243
+ except Exception as exc: # noqa: BLE001
244
+ trace.incomplete(f"storage snapshot failed: {type(exc).__name__}")
245
+
246
+
247
+ def store_snapshot(local: list, session: list, cookies: list) -> dict:
248
+ """Keys always; values only for non-sensitive keys, truncated; cookie VALUES never."""
249
+ return {
250
+ "localStorage": [{"key": k, "value": redact_storage_value(k, str(v)), "length": len(str(v))} for k, v in local][:100],
251
+ "sessionStorage": [{"key": k, "value": redact_storage_value(k, str(v)), "length": len(str(v))} for k, v in session][:100],
252
+ "cookies": [{"name": c.get("name"), "domain": c.get("domain"), "httpOnly": bool(c.get("httpOnly")), "secure": bool(c.get("secure")),
253
+ "sameSite": c.get("sameSite")} for c in cookies][:100],
254
+ }
255
+
256
+
257
+ class Recorder:
258
+ """Turns browser callbacks into trace events, enforcing scope in the route handler."""
259
+ TYPES = {"document": "document", "script": "script", "xhr": "xhr", "fetch": "fetch", "stylesheet": "stylesheet", "image": "image"}
260
+
261
+ def __init__(self, policy: ScopePolicy, trace: RuntimeTrace):
262
+ self.policy, self.trace, self.step = policy, trace, 0
263
+
264
+ def route_handler(self, route, request=None) -> None:
265
+ request = request or route.request
266
+ try:
267
+ self.policy.stop.check()
268
+ self.policy.check_url(request.url)
269
+ self.policy.before_send()
270
+ route.continue_()
271
+ except (ScopeViolation, EmergencyStopped) as exc:
272
+ self.trace.add(kind="blocked", url=redact_url(request.url), method=request.method, detail=str(exc), step=self.step)
273
+ self.trace.incomplete(f"request to {urlparse(request.url).netloc} blocked by scope policy")
274
+ route.abort("blockedbyclient")
275
+
276
+ def on_request(self, req) -> None:
277
+ rt = self.TYPES.get(getattr(req, "resource_type", ""), "other")
278
+ init = ""
279
+ frame_url = getattr(getattr(req, "frame", None), "url", "")
280
+ if rt in ("xhr", "fetch"):
281
+ init = redact_url(frame_url) if frame_url else "" # page-level only: NOT a source location
282
+ self.trace.add(kind="request", url=redact_url(req.url), method=req.method, resource_type=rt, initiator=init, step=self.step)
283
+ red = getattr(req, "redirected_from", None)
284
+ if red is not None:
285
+ self.trace.add(kind="redirect", url=redact_url(req.url), from_url=redact_url(red.url), step=self.step)
286
+
287
+ def on_response(self, resp) -> None:
288
+ self.trace.add(kind="response", url=redact_url(resp.url), status=resp.status,
289
+ resource_type=self.TYPES.get(getattr(resp.request, "resource_type", ""), "other"), step=self.step)
290
+
291
+ def on_navigated(self, url: str) -> None:
292
+ self.trace.add(kind="navigation", url=redact_url(url), step=self.step)
293
+
294
+ def on_console(self, kind: str, text: str) -> None:
295
+ if kind == "error":
296
+ from jscope.core.redact import redact_text
297
+ self.trace.add(kind="console_error", detail=(redact_text(text) or "")[:200], step=self.step)
298
+
299
+ def on_failed(self, url: str, failure: str) -> None:
300
+ self.trace.incomplete(f"request failed: {redact_url(url)} ({failure})")
301
+
302
+
303
+ # ------------------------------------------------------------------------------------------------ orchestration
304
+ def observe(base_url: str, policy: ScopePolicy, scenario: Scenario | None = None, driver: str = "auto",
305
+ drivers: dict[str, Any] | None = None) -> RuntimeTrace:
306
+ scenario = scenario or Scenario.default()
307
+ drivers = drivers or {}
308
+ if driver == "auto":
309
+ driver = "playwright" if PlaywrightDriver.available() else "http"
310
+ d = drivers.get(driver) or (PlaywrightDriver(policy) if driver == "playwright" else HttpPassiveDriver(policy))
311
+ trace = RuntimeTrace(target=base_url, driver=d.name, scenario=scenario.name, scope=policy.describe())
312
+ d.run(base_url, scenario, trace)
313
+ trace.scope = policy.describe()
314
+ if policy.blocked:
315
+ trace.incomplete(f"{len(policy.blocked)} request(s) blocked by scope policy")
316
+ trace.limitations.append("an incomplete trace is inconclusive: missing observations are NOT evidence that behaviour is absent or safe")
317
+ return trace
318
+
319
+
320
+ # ------------------------------------------------------------------------------------------------ correlation
321
+ _PARAM = re.compile(r"(:[A-Za-z_]\w*|\{[^}/]*\}|\$\{[^}]*\}|\[[^\]/]+\]|<[^>/]+>|\*)")
322
+
323
+
324
+ def _template_regex(path: str) -> re.Pattern:
325
+ out, last = [], 0
326
+ for m in _PARAM.finditer(path):
327
+ out.append(re.escape(path[last:m.start()]))
328
+ out.append(r"[^/?#]+")
329
+ last = m.end()
330
+ out.append(re.escape(path[last:]))
331
+ return re.compile("^" + "".join(out) + r"/?$")
332
+
333
+
334
+ def correlate(trace: RuntimeTrace, payload: dict) -> dict:
335
+ """Link observed requests to static endpoint findings. Output sections:
336
+ confirmed_by_runtime static endpoint also seen at runtime (exactness: endpoint-match | initiator-file)
337
+ runtime_only observed requests no static finding explains (static analysis missed them)
338
+ not_observed static endpoints that were NOT seen (inconclusive unless the trace is complete AND driver ran JS)
339
+ Never claims a source line: runtime evidence carries at most the initiating page/script URL."""
340
+ endpoints = [f for f in payload.get("findings", []) if f.get("category") == "endpoint"]
341
+ templ = []
342
+ for f in endpoints:
343
+ subj = f["subject"]
344
+ path = urlparse(subj).path if "://" in subj else subj.split("?")[0]
345
+ if path.startswith("/"):
346
+ templ.append((f, _template_regex(path), path))
347
+ static_files = {}
348
+ for f in payload.get("findings", []):
349
+ for e in f.get("evidence", []):
350
+ if e.get("file"):
351
+ static_files.setdefault(e["file"].rsplit("/", 1)[-1], set()).add(f["id"])
352
+ seen: dict[str, dict] = {}
353
+ runtime_only: list[dict] = []
354
+ for ev in trace.requests("xhr", "fetch", "document", "other"):
355
+ path = urlparse(ev.url).path or "/"
356
+ hit = next(((f, p) for f, rx, p in templ if rx.match(path)), None)
357
+ if hit:
358
+ f, p = hit
359
+ ent = seen.setdefault(f["id"], {"finding_id": f["id"], "subject": f["subject"], "observed": [], "exactness": "endpoint-match",
360
+ "basis": "request path matches the static endpoint (template-aware)"})
361
+ ent["observed"].append({"method": ev.method, "url": ev.url, "status": _status_for(trace, ev.url)})
362
+ init_file = (urlparse(ev.initiator).path.rsplit("/", 1)[-1] if ev.initiator else "")
363
+ if init_file in static_files and f["id"] in static_files[init_file]:
364
+ ent["exactness"] = "initiator-file"
365
+ ent["basis"] += f"; initiated from a page/script ({init_file}) that also contains the static call site (file-level only)"
366
+ elif ev.resource_type in ("xhr", "fetch"):
367
+ runtime_only.append({"method": ev.method, "url": ev.url, "status": _status_for(trace, ev.url),
368
+ "note": "observed at runtime but not predicted by static analysis"})
369
+ can_conclude = trace.status == "complete" and trace.driver == "playwright"
370
+ not_obs = [{"finding_id": f["id"], "subject": f["subject"],
371
+ "verdict": "not_observed" if can_conclude else "inconclusive",
372
+ "reason": ("no matching request in a complete browser trace (the scenario may simply not have exercised it)" if can_conclude
373
+ else "trace is incomplete or JavaScript was not executed; absence proves nothing")}
374
+ for f, _, _ in templ if f["id"] not in seen]
375
+ return {"confirmed_by_runtime": list(seen.values()), "runtime_only": runtime_only, "not_observed": not_obs,
376
+ "trace_status": trace.status, "driver": trace.driver, "incomplete_reasons": trace.incomplete_reasons,
377
+ "note": "runtime observations do not prove exploitability; static/runtime links are endpoint-level, not source-line-level"}
378
+
379
+
380
+ def _status_for(trace: RuntimeTrace, url: str) -> int | None:
381
+ for e in trace.events:
382
+ if e.kind == "response" and e.url == url:
383
+ return e.status
384
+ return None
385
+
386
+
387
+ def novel_routes(trace: RuntimeTrace, payload: dict) -> list[str]:
388
+ """Navigation paths seen at runtime that the static route map did not contain."""
389
+ known = {r["route"] for r in payload.get("route_map", []) or []}
390
+ templ = [_template_regex(r) for r in known if r.startswith("/")]
391
+ out = []
392
+ for e in trace.events:
393
+ if e.kind == "navigation":
394
+ p = urlparse(e.url).path or "/"
395
+ if not any(t.match(p) for t in templ) and p not in out:
396
+ out.append(p)
397
+ return out