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,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
|