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,142 @@
|
|
|
1
|
+
"""Service Worker Analyzer.
|
|
2
|
+
|
|
3
|
+
Two views of the same feature, from opposite sides:
|
|
4
|
+
* registration (page side): navigator.serviceWorker.register('/sw.js')
|
|
5
|
+
* capabilities (worker side): fetch interception, push, background sync,
|
|
6
|
+
offline caching, message channel, immediate takeover, importScripts,
|
|
7
|
+
Workbox precache manifests.
|
|
8
|
+
When the registered script is also present in the project the registration
|
|
9
|
+
gains ARTIFACT_RESOLVED evidence. Precache manifests are useful recon:
|
|
10
|
+
they enumerate shipped assets, including ones no page links to.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
from jscope.analyzers.base import Analyzer, ProjectContext
|
|
18
|
+
from jscope.analyzers.common import is_test_path
|
|
19
|
+
from jscope.core.evidence import Candidate, Evidence, EvidenceType as ET
|
|
20
|
+
from jscope.parsing import queries as Q
|
|
21
|
+
from jscope.parsing.ast_engine import ParsedFile
|
|
22
|
+
|
|
23
|
+
_SW_EVENTS = {"install", "activate", "fetch", "push", "sync", "periodicsync", "notificationclick",
|
|
24
|
+
"notificationclose", "message", "backgroundfetchsuccess", "pushsubscriptionchange"}
|
|
25
|
+
_CAPABILITY = {
|
|
26
|
+
"fetch": ("capability:fetch_interception",
|
|
27
|
+
"Worker intercepts every in-scope request. Test cache poisoning, whether authenticated responses get cached "
|
|
28
|
+
"and served to the next user, and whether the scope is broader than intended."),
|
|
29
|
+
"push": ("capability:push_notifications",
|
|
30
|
+
"Check the push-subscription registration endpoint for authorization, and what the payload handler does with attacker-influenced data."),
|
|
31
|
+
"notificationclick": ("capability:push_notifications",
|
|
32
|
+
"Check how notification click data selects the opened URL (open redirect / javascript: handling)."),
|
|
33
|
+
"sync": ("capability:background_sync",
|
|
34
|
+
"Queued requests replay later, possibly with stale credentials. Test replay authorization and idempotency."),
|
|
35
|
+
"periodicsync": ("capability:background_sync",
|
|
36
|
+
"Periodic background requests run without a visible page. Test their authorization."),
|
|
37
|
+
"message": ("capability:message_channel",
|
|
38
|
+
"Check the message handler validates event.source/origin: any page in scope may be able to drive the worker."),
|
|
39
|
+
}
|
|
40
|
+
_WORKBOX = ("precacheAndRoute", "registerRoute", "workbox", "precache", "StaleWhileRevalidate", "NetworkFirst")
|
|
41
|
+
_SW_NAME = re.compile(r"(^|[-_./])(sw|service-?worker|workbox[-\w]*)(\.[\w.-]+)?\.js$", re.I)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ServiceWorkerAnalyzer(Analyzer):
|
|
45
|
+
name = "service_worker_analyzer"
|
|
46
|
+
depends_on: list[str] = []
|
|
47
|
+
produces_categories = ["service_worker"]
|
|
48
|
+
|
|
49
|
+
def analyze_project(self, ctx: ProjectContext) -> list[Candidate]:
|
|
50
|
+
index = Q.FileIndex(ctx.files)
|
|
51
|
+
cands: dict[str, Candidate] = {}
|
|
52
|
+
|
|
53
|
+
def add(subject: str, ev: list[Evidence], test: str | None, meta: dict | None = None):
|
|
54
|
+
c = cands.get(subject)
|
|
55
|
+
if c is None:
|
|
56
|
+
c = cands[subject] = Candidate(category="service_worker", subject=subject, analyzer=self.name,
|
|
57
|
+
evidence=[], attack_surface="service_worker", suggested_test=test,
|
|
58
|
+
metadata=dict(meta or {}))
|
|
59
|
+
c.evidence += ev
|
|
60
|
+
|
|
61
|
+
for pf in ctx.files:
|
|
62
|
+
if pf.skipped_reason or pf.tree is None:
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
def ev(node, desc, typ=ET.AST_PATTERN_MATCH):
|
|
66
|
+
out = [Evidence(typ, desc, self.name, pf.path, pf.line_of(node), pf.node_text(node)[:100].replace("\n", " "))]
|
|
67
|
+
if ctx.ast.is_in_dead_branch(pf, node):
|
|
68
|
+
out.append(Evidence(ET.DEAD_CODE, "sits in a statically dead branch", self.name))
|
|
69
|
+
if is_test_path(pf.path):
|
|
70
|
+
out.append(Evidence(ET.TEST_FIXTURE_CONTEXT, "file looks like a test/mock", self.name))
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
calls = Q.iter_calls(ctx.ast, pf)
|
|
74
|
+
sw_events = []
|
|
75
|
+
for s in calls:
|
|
76
|
+
if s.callee.endswith("addEventListener") and s.args_resolved and s.args_resolved[0].value in _SW_EVENTS:
|
|
77
|
+
recv = s.callee.rsplit(".", 1)[0] if "." in s.callee else ""
|
|
78
|
+
if recv in ("self", "this", "globalThis", "") or recv.endswith("self"):
|
|
79
|
+
sw_events.append(s)
|
|
80
|
+
is_sw = bool(sw_events) or any(s.callee in ("self.skipWaiting", "self.clients.claim", "importScripts")
|
|
81
|
+
for s in calls) \
|
|
82
|
+
or any(m in pf.text for m in ("ServiceWorkerGlobalScope", "self.__WB_MANIFEST")) \
|
|
83
|
+
or (bool(_SW_NAME.search(pf.path.replace("\\", "/"))) and any(w in pf.text for w in _WORKBOX))
|
|
84
|
+
# -- page side: registration
|
|
85
|
+
for s in calls:
|
|
86
|
+
if s.callee.endswith("serviceWorker.register") and s.args_resolved and s.args_resolved[0].value:
|
|
87
|
+
script = s.args_resolved[0].value
|
|
88
|
+
e = ev(s.node, f"serviceWorker.register('{script}')")
|
|
89
|
+
target = index.by_basename(script)
|
|
90
|
+
if target is not None:
|
|
91
|
+
e.append(Evidence(ET.ARTIFACT_RESOLVED, f"registered worker script found in project: {target.path}", self.name, target.path))
|
|
92
|
+
scope = None
|
|
93
|
+
if len(s.args_resolved) > 1 and "scope" in s.args_raw[1]:
|
|
94
|
+
scope = s.args_raw[1]
|
|
95
|
+
add(f"registration:{script}", e,
|
|
96
|
+
"Fetch the worker script from the live origin; check its scope header (Service-Worker-Allowed) and "
|
|
97
|
+
"whether it caches authenticated pages.", {"script": script, "options": scope})
|
|
98
|
+
if not is_sw:
|
|
99
|
+
continue
|
|
100
|
+
# -- worker side
|
|
101
|
+
for s in sw_events:
|
|
102
|
+
evname = s.args_resolved[0].value
|
|
103
|
+
if evname in _CAPABILITY:
|
|
104
|
+
subj, test = _CAPABILITY[evname]
|
|
105
|
+
add(subj, ev(s.node, f"worker handles '{evname}' event"), test, {"events": [evname]})
|
|
106
|
+
elif evname in ("install", "activate"):
|
|
107
|
+
pass
|
|
108
|
+
for s in calls:
|
|
109
|
+
if s.callee in ("caches.open", "self.caches.open") and s.args_resolved and s.args_resolved[0].value:
|
|
110
|
+
add("capability:offline_cache", ev(s.node, f"opens cache '{s.args_resolved[0].value}'"),
|
|
111
|
+
"Check what gets cached: authenticated API responses or HTML in a shared cache can leak across sessions.",
|
|
112
|
+
{"caches": [s.args_resolved[0].value]})
|
|
113
|
+
elif s.callee in ("self.skipWaiting", "self.clients.claim"):
|
|
114
|
+
add("behavior:immediate_takeover", ev(s.node, f"{s.callee}() -- new worker takes control immediately"),
|
|
115
|
+
"A malicious or buggy worker version activates without waiting for page close; relevant if the worker script URL is attacker-influenced.")
|
|
116
|
+
elif s.callee == "importScripts" and s.args_resolved:
|
|
117
|
+
for r, raw in zip(s.args_resolved, s.args_raw):
|
|
118
|
+
if r.value:
|
|
119
|
+
add(f"dependency:importScripts:{r.value}", ev(s.node, f"importScripts('{r.value}')"),
|
|
120
|
+
"Third-party script runs with worker privileges over every in-scope request; check origin, integrity and takeover risk." if r.value.startswith("http")
|
|
121
|
+
else "First-party script loaded into the worker; include it in the analysis.")
|
|
122
|
+
elif Q.last_segment(s.callee) in ("precacheAndRoute", "precache") and s.node is not None:
|
|
123
|
+
args = Q.call_args(s.node)
|
|
124
|
+
for item in Q.array_items(args[0]) if args and args[0].type == "array" else []:
|
|
125
|
+
url = None
|
|
126
|
+
if item.type == "object":
|
|
127
|
+
u = Q.object_props(pf, item).get("url")
|
|
128
|
+
if u is not None:
|
|
129
|
+
rv = ctx.ast.resolve_value(pf, u, Q.bindings_for(ctx.ast, pf))
|
|
130
|
+
url = rv.value
|
|
131
|
+
elif item.type == "string":
|
|
132
|
+
url = pf.node_text(item).strip("'\"")
|
|
133
|
+
if url and len(cands) < 400:
|
|
134
|
+
add(f"precache:{url}", ev(item, f"Workbox precache entry {url}"),
|
|
135
|
+
"Precached assets reveal shipped files: fetch them, especially ones no page links to (old bundles, admin pages, source maps).")
|
|
136
|
+
if "self.__WB_MANIFEST" in pf.text:
|
|
137
|
+
node = pf.tree.root_node
|
|
138
|
+
add("precache:__WB_MANIFEST_placeholder",
|
|
139
|
+
[Evidence(ET.MOCK_OR_PLACEHOLDER, "self.__WB_MANIFEST placeholder was never replaced by the build", self.name, pf.path)]
|
|
140
|
+
+ [Evidence(ET.LITERAL_STRING_MATCH, "Workbox manifest injection marker present", self.name, pf.path)] * 2,
|
|
141
|
+
"Build-time placeholder only; the real precache list lives in the built worker.")
|
|
142
|
+
return list(cands.values())
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Source Map Analyzer.
|
|
2
|
+
|
|
3
|
+
Finds `//# sourceMappingURL=` references and, OFFLINE, tries to resolve them:
|
|
4
|
+
inline data: URIs, or a .map sitting next to the bundle on disk. A resolved
|
|
5
|
+
map with sourcesContent means the application's ORIGINAL source (names,
|
|
6
|
+
comments, un-minified logic) is recoverable -- the single biggest accelerant
|
|
7
|
+
for every other analyzer. `expand_source_maps()` (used by the pipeline when
|
|
8
|
+
`source_maps.expand: true`) feeds recovered app sources back in as virtual
|
|
9
|
+
files so the other analyzers run on readable code.
|
|
10
|
+
|
|
11
|
+
Never touches the network: if the .map isn't on disk the finding is only
|
|
12
|
+
"a map is referenced", and the suggested test is to fetch it from the origin.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import base64
|
|
18
|
+
import json
|
|
19
|
+
import re
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from jscope.analyzers.base import Analyzer, ProjectContext
|
|
23
|
+
from jscope.analyzers.common import is_test_path
|
|
24
|
+
from jscope.core.evidence import Candidate, Evidence, EvidenceType as ET
|
|
25
|
+
from jscope.parsing.ast_engine import ASTEngine, ParsedFile
|
|
26
|
+
|
|
27
|
+
_MAP_RE = re.compile(r"//[#@]\s*sourceMappingURL=(\S+)")
|
|
28
|
+
_SENSITIVE_SRC = re.compile(r"(\.env|secret|credential|private|/admin|internal|config/prod|\.pem$|\.key$)", re.I)
|
|
29
|
+
MAX_MAP_BYTES = 50 * 1024 * 1024
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_map(pf: ParsedFile) -> tuple[str, dict | None, str]:
|
|
33
|
+
"""-> (reference, parsed map or None, how it was resolved)."""
|
|
34
|
+
matches = _MAP_RE.findall(pf.text)
|
|
35
|
+
if not matches:
|
|
36
|
+
return "", None, "none"
|
|
37
|
+
ref = matches[-1]
|
|
38
|
+
try:
|
|
39
|
+
if ref.startswith("data:"):
|
|
40
|
+
payload = ref.split(",", 1)[1]
|
|
41
|
+
raw = base64.b64decode(payload) if ";base64" in ref.split(",", 1)[0] else payload.encode()
|
|
42
|
+
return ref[:60] + "...", json.loads(raw), "inline"
|
|
43
|
+
if "://" in ref or pf.origin != "disk":
|
|
44
|
+
return ref, None, "remote"
|
|
45
|
+
candidate = (Path(pf.path).parent / ref).resolve()
|
|
46
|
+
if candidate.is_file() and candidate.stat().st_size <= MAX_MAP_BYTES:
|
|
47
|
+
return ref, json.loads(candidate.read_text(encoding="utf-8", errors="replace")), "disk"
|
|
48
|
+
except (OSError, ValueError, json.JSONDecodeError, IndexError):
|
|
49
|
+
return ref, None, "unparseable"
|
|
50
|
+
return ref, None, "not_on_disk"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def app_sources(smap: dict) -> list[tuple[str, str]]:
|
|
54
|
+
"""(source path, content) for non-vendor sources that embed content."""
|
|
55
|
+
out = []
|
|
56
|
+
contents = smap.get("sourcesContent") or []
|
|
57
|
+
for name, content in zip(smap.get("sources", []), contents):
|
|
58
|
+
if not content or "node_modules" in name or name.startswith(("webpack/bootstrap", "(webpack)")):
|
|
59
|
+
continue
|
|
60
|
+
out.append((name, content))
|
|
61
|
+
return out
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class SourceMapAnalyzer(Analyzer):
|
|
65
|
+
name = "source_map_analyzer"
|
|
66
|
+
depends_on: list[str] = []
|
|
67
|
+
produces_categories = ["source_map"]
|
|
68
|
+
|
|
69
|
+
def analyze_project(self, ctx: ProjectContext) -> list[Candidate]:
|
|
70
|
+
out: list[Candidate] = []
|
|
71
|
+
for pf in ctx.files:
|
|
72
|
+
if pf.skipped_reason or pf.origin != "disk":
|
|
73
|
+
continue
|
|
74
|
+
ref, smap, how = load_map(pf)
|
|
75
|
+
if how == "none":
|
|
76
|
+
continue
|
|
77
|
+
ev = [Evidence(ET.LITERAL_STRING_MATCH, f"sourceMappingURL comment references '{ref}'", self.name, pf.path)]
|
|
78
|
+
meta: dict = {"bundle": pf.path, "reference": ref, "resolved": how}
|
|
79
|
+
test = ("Request the .map next to this bundle on the live origin (same path + '.map'). "
|
|
80
|
+
"If it is served, the original source is recoverable.")
|
|
81
|
+
if is_test_path(pf.path):
|
|
82
|
+
ev.append(Evidence(ET.TEST_FIXTURE_CONTEXT, "bundle lives in a test/mock location", self.name))
|
|
83
|
+
if smap is not None:
|
|
84
|
+
srcs = smap.get("sources", [])
|
|
85
|
+
app = app_sources(smap)
|
|
86
|
+
meta.update({"sources_total": len(srcs), "app_sources_with_content": len(app),
|
|
87
|
+
"app_sources_sample": [n for n, _ in app[:25]]})
|
|
88
|
+
ev.append(Evidence(ET.ARTIFACT_RESOLVED,
|
|
89
|
+
f"map resolved ({how}) and parsed: {len(srcs)} sources, {len(app)} application sources with embedded content",
|
|
90
|
+
self.name, pf.path))
|
|
91
|
+
if app:
|
|
92
|
+
ev.append(Evidence(ET.ARTIFACT_RESOLVED, "original application source is recoverable from sourcesContent", self.name, pf.path))
|
|
93
|
+
hits = [n for n in srcs if _SENSITIVE_SRC.search(n)]
|
|
94
|
+
if hits:
|
|
95
|
+
ev.append(Evidence(ET.NAMING_CONVENTION, f"map lists sensitive-looking sources: {', '.join(hits[:3])}", self.name, pf.path))
|
|
96
|
+
meta["sensitive_sources"] = hits[:10]
|
|
97
|
+
test = ("Original source is recoverable. Run `jscope analyze --expand-source-maps` to analyze it, "
|
|
98
|
+
"and confirm whether the .map is publicly served on the origin.")
|
|
99
|
+
out.append(Candidate(category="source_map", subject=f"sourcemap:{Path(pf.path).name}", analyzer=self.name,
|
|
100
|
+
evidence=ev, attack_surface="source_map", suggested_test=test, metadata=meta))
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def expand_source_maps(files: list[ParsedFile], ast: ASTEngine) -> tuple[list[ParsedFile], int]:
|
|
105
|
+
"""Return extra virtual ParsedFiles for recovered JS application sources."""
|
|
106
|
+
extra: list[ParsedFile] = []
|
|
107
|
+
for pf in files:
|
|
108
|
+
if pf.origin != "disk" or pf.skipped_reason:
|
|
109
|
+
continue
|
|
110
|
+
_, smap, how = load_map(pf)
|
|
111
|
+
if smap is None:
|
|
112
|
+
continue
|
|
113
|
+
for name, content in app_sources(smap):
|
|
114
|
+
if not name.lower().split("?")[0].endswith((".js", ".jsx", ".mjs", ".cjs")):
|
|
115
|
+
continue # TS/Vue/Svelte sources can't be parsed by the JS grammar
|
|
116
|
+
vpf = ast.parse_bytes(content.encode("utf-8", errors="replace"), path=f"{pf.path}!/{name}")
|
|
117
|
+
vpf.origin = "sourcemap"
|
|
118
|
+
extra.append(vpf)
|
|
119
|
+
return extra, len(extra)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Storage Analyzer: localStorage / sessionStorage / IndexedDB / cookies.
|
|
2
|
+
|
|
3
|
+
Findings are per (store, key), aggregated across read/write sites. Keys
|
|
4
|
+
that look credential-bearing in script-readable storage are flagged as
|
|
5
|
+
token-storage attack surface (any XSS can exfiltrate them) -- that is a
|
|
6
|
+
risk *observation*, not a vulnerability claim, and is scored accordingly.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
from jscope.analyzers.base import Analyzer, ProjectContext
|
|
14
|
+
from jscope.analyzers.common import SENSITIVE_KEY_RE, is_test_path
|
|
15
|
+
from jscope.core.evidence import Candidate, Evidence, EvidenceType as ET
|
|
16
|
+
from jscope.core.graph import EdgeKind, NodeKind
|
|
17
|
+
from jscope.parsing import queries as Q
|
|
18
|
+
from jscope.parsing.ast_engine import ParsedFile
|
|
19
|
+
|
|
20
|
+
_STORES = {"localStorage", "sessionStorage"}
|
|
21
|
+
_OPS = {"setItem": "write", "getItem": "read", "removeItem": "delete"}
|
|
22
|
+
_COOKIE_NAME_RE = re.compile(r"^\s*([^=;\s]+)\s*=")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class StorageAnalyzer(Analyzer):
|
|
26
|
+
name = "storage_analyzer"
|
|
27
|
+
depends_on: list[str] = []
|
|
28
|
+
produces_categories = ["storage"]
|
|
29
|
+
|
|
30
|
+
def analyze_project(self, ctx: ProjectContext) -> list[Candidate]:
|
|
31
|
+
cands: dict[str, Candidate] = {}
|
|
32
|
+
ops: dict[str, set[str]] = {}
|
|
33
|
+
|
|
34
|
+
def add(subject: str, ev: list[Evidence], op: str, surface: str | None, test: str | None, meta: dict):
|
|
35
|
+
c = cands.get(subject)
|
|
36
|
+
if c is None:
|
|
37
|
+
c = cands[subject] = Candidate(category="storage", subject=subject, analyzer=self.name,
|
|
38
|
+
evidence=[], attack_surface=surface, suggested_test=test,
|
|
39
|
+
metadata=dict(meta))
|
|
40
|
+
c.evidence += ev
|
|
41
|
+
ops.setdefault(subject, set()).add(op)
|
|
42
|
+
|
|
43
|
+
for pf in ctx.files:
|
|
44
|
+
if pf.skipped_reason or pf.tree is None:
|
|
45
|
+
continue
|
|
46
|
+
g = ctx.graph
|
|
47
|
+
for site in Q.iter_calls(ctx.ast, pf):
|
|
48
|
+
parts = site.callee.split(".")
|
|
49
|
+
kind = key = store = None
|
|
50
|
+
if len(parts) >= 2 and parts[-1] in _OPS and parts[-2] in _STORES:
|
|
51
|
+
store, kind = parts[-2], _OPS[parts[-1]]
|
|
52
|
+
key = site.args_resolved[0] if site.args_resolved else None
|
|
53
|
+
elif site.callee.endswith("indexedDB.open"):
|
|
54
|
+
store, kind = "indexedDB", "open"
|
|
55
|
+
key = site.args_resolved[0] if site.args_resolved else None
|
|
56
|
+
elif parts[0] in ("Cookies", "cookies", "Cookie") and parts[-1] in ("set", "get", "remove"):
|
|
57
|
+
store, kind = "cookie", {"set": "write", "get": "read", "remove": "delete"}[parts[-1]]
|
|
58
|
+
key = site.args_resolved[0] if site.args_resolved else None
|
|
59
|
+
if store is None or key is None or key.value is None or key.is_placeholder:
|
|
60
|
+
continue
|
|
61
|
+
self._record(ctx, pf, site.node, site.line, store, key.value, kind, key, add, g)
|
|
62
|
+
|
|
63
|
+
# document.cookie = "name=value; Secure; SameSite=Lax"
|
|
64
|
+
for node in ctx.ast.walk(pf.tree.root_node):
|
|
65
|
+
if node.type != "assignment_expression":
|
|
66
|
+
continue
|
|
67
|
+
left = node.child_by_field_name("left")
|
|
68
|
+
if left is None or pf.node_text(left).replace("window.", "") != "document.cookie":
|
|
69
|
+
continue
|
|
70
|
+
rv = ctx.ast.resolve_value(pf, node.child_by_field_name("right"), Q.bindings_for(ctx.ast, pf))
|
|
71
|
+
if rv.value is None:
|
|
72
|
+
continue
|
|
73
|
+
m = _COOKIE_NAME_RE.match(rv.value)
|
|
74
|
+
if m:
|
|
75
|
+
self._record(ctx, pf, node, pf.line_of(node), "cookie", m.group(1), "write", rv, add,
|
|
76
|
+
g, cookie_attrs=rv.value.lower())
|
|
77
|
+
|
|
78
|
+
out = []
|
|
79
|
+
for subject, c in cands.items():
|
|
80
|
+
c.metadata["operations"] = sorted(ops[subject])
|
|
81
|
+
out.append(c)
|
|
82
|
+
return out
|
|
83
|
+
|
|
84
|
+
def _record(self, ctx, pf: ParsedFile, node, line: int, store: str, key: str, op: str, rv, add, g,
|
|
85
|
+
cookie_attrs: str | None = None) -> None:
|
|
86
|
+
subject = f"{store}:{key}"
|
|
87
|
+
ev = [Evidence(ET.AST_PATTERN_MATCH, f"{store} {op} with key '{key}'", self.name, pf.path, line,
|
|
88
|
+
pf.node_text(node)[:100].replace("\n", " "))]
|
|
89
|
+
if rv.resolved_via_symbol:
|
|
90
|
+
ev.append(Evidence(ET.SYMBOL_RESOLVED, "storage key resolved through a constant", self.name))
|
|
91
|
+
sensitive = bool(SENSITIVE_KEY_RE.search(key))
|
|
92
|
+
if sensitive:
|
|
93
|
+
ev.append(Evidence(ET.NAMING_CONVENTION, f"key '{key}' looks credential-bearing", self.name))
|
|
94
|
+
if ctx.ast.is_in_dead_branch(pf, node):
|
|
95
|
+
ev.append(Evidence(ET.DEAD_CODE, "access sits in a statically dead branch", self.name))
|
|
96
|
+
if is_test_path(pf.path):
|
|
97
|
+
ev.append(Evidence(ET.TEST_FIXTURE_CONTEXT, "file looks like a test/mock", self.name))
|
|
98
|
+
|
|
99
|
+
surface = test = None
|
|
100
|
+
meta = {"store": store, "key": key, "sensitive": sensitive}
|
|
101
|
+
if sensitive and store in _STORES:
|
|
102
|
+
surface = "token_storage"
|
|
103
|
+
test = ("Script-readable storage: any XSS (or malicious third-party script) can read this. "
|
|
104
|
+
"Look for XSS sinks, and check whether the token is long-lived and unscoped.")
|
|
105
|
+
elif store == "cookie" and cookie_attrs is not None:
|
|
106
|
+
missing = [f for f in ("secure", "samesite") if f not in cookie_attrs]
|
|
107
|
+
meta["missing_flags"] = missing
|
|
108
|
+
meta["note"] = "document.cookie cannot set HttpOnly, so this cookie is script-readable by construction"
|
|
109
|
+
if sensitive:
|
|
110
|
+
surface = "cookie_handling"
|
|
111
|
+
test = "Cookie is set from JS (so never HttpOnly). Check scope/expiry and whether the server trusts it for auth."
|
|
112
|
+
add(subject, ev, op, surface, test, meta)
|
|
113
|
+
|
|
114
|
+
owner = Q.enclosing_function_name(pf, node)
|
|
115
|
+
sid = f"storage:{subject}"
|
|
116
|
+
g.add_node(sid, NodeKind.STORAGE, subject, sensitive=sensitive)
|
|
117
|
+
if owner:
|
|
118
|
+
cid = f"component:{pf.path}:{owner}"
|
|
119
|
+
g.add_node(cid, NodeKind.COMPONENT, owner, file=pf.path)
|
|
120
|
+
g.add_node(f"file:{pf.path}", NodeKind.FILE, pf.path)
|
|
121
|
+
g.add_edge(f"file:{pf.path}", cid, EdgeKind.CONTAINS, self.name, "AST_PATTERN_MATCH", pf.path)
|
|
122
|
+
g.add_edge(cid, sid, EdgeKind.USES_STORAGE, self.name, "AST_PATTERN_MATCH", pf.path, line)
|
|
123
|
+
else:
|
|
124
|
+
g.add_node(f"file:{pf.path}", NodeKind.FILE, pf.path)
|
|
125
|
+
g.add_edge(f"file:{pf.path}", sid, EdgeKind.USES_STORAGE, self.name, "AST_PATTERN_MATCH", pf.path, line)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""WebAssembly Analyzer (Phase 4).
|
|
2
|
+
|
|
3
|
+
WASM modules hide client logic (validation, licensing, crypto, anti-abuse) and often embed endpoints. This analyzer is
|
|
4
|
+
static and offline: it parses the module's structure, derives the toolchain and the host capabilities the module imports
|
|
5
|
+
(fetch/eval/storage/websocket), extracts endpoint- and secret-shaped strings, and ties the module to the JS that loads it.
|
|
6
|
+
|
|
7
|
+
Strings found inside a real data segment get AST_PATTERN_MATCH (we know where they live); strings found only by a raw byte
|
|
8
|
+
scan get literal-match weight only.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from pathlib import PurePosixPath
|
|
15
|
+
|
|
16
|
+
from jscope.analyzers.base import Analyzer, ProjectContext
|
|
17
|
+
from jscope.analyzers.common import PATH_RE, classify, is_test_path
|
|
18
|
+
from jscope.analyzers.secret_analyzer import _PATTERNS as SECRET_PATTERNS
|
|
19
|
+
from jscope.core.artifacts import KIND_WASM, Artifact
|
|
20
|
+
from jscope.core.evidence import Candidate, Evidence, EvidenceType as ET
|
|
21
|
+
from jscope.parsing.wasm import parse_wasm, printable_strings
|
|
22
|
+
|
|
23
|
+
_URL = re.compile(r"https?://[^\s\"'<>\\\x00-\x1f]{6,}")
|
|
24
|
+
_APIPATH = re.compile(r"(?<![\w.:/])/(?:api|v\d+|graphql|rest|admin|internal|auth|oauth|_debug|debug)(?:/[A-Za-z0-9_\-.{}:]+)*")
|
|
25
|
+
_HOST_CAPS = [("fetch", re.compile(r"fetch|xmlhttprequest|xhr", re.I), "makes network requests from inside the module"),
|
|
26
|
+
("eval", re.compile(r"(^|_)(eval|function_new|newfunction)(_|$)|__wbg_eval", re.I), "can evaluate dynamic JavaScript"),
|
|
27
|
+
("storage", re.compile(r"localstorage|sessionstorage|indexeddb|cookie", re.I), "reads/writes browser storage"),
|
|
28
|
+
("websocket", re.compile(r"websocket", re.I), "opens WebSockets"),
|
|
29
|
+
("crypto", re.compile(r"crypto|subtle|getrandomvalues", re.I), "uses WebCrypto/random from the host")]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _toolchain(info: dict) -> str | None:
|
|
33
|
+
mods = {i["module"] for i in info["imports"]}
|
|
34
|
+
names = " ".join(i["name"] for i in info["imports"])
|
|
35
|
+
exps = " ".join(e["name"] for e in info["exports"])
|
|
36
|
+
prod = " ".join(" ".join(v) for v in info["producers"].values()).lower()
|
|
37
|
+
if "wbg" in mods or "__wbindgen" in exps:
|
|
38
|
+
return "Rust/wasm-bindgen"
|
|
39
|
+
if "gojs" in mods:
|
|
40
|
+
return "Go"
|
|
41
|
+
if "emscripten" in names or any(m == "env" for m in mods) and "emscripten" in prod:
|
|
42
|
+
return "Emscripten (C/C++)"
|
|
43
|
+
if "wasi_snapshot_preview1" in mods or "wasi_unstable" in mods:
|
|
44
|
+
return "WASI"
|
|
45
|
+
if "__new" in exps and "__pin" in exps:
|
|
46
|
+
return "AssemblyScript"
|
|
47
|
+
if "rust" in prod:
|
|
48
|
+
return "Rust"
|
|
49
|
+
if "clang" in prod or "c++" in prod:
|
|
50
|
+
return "C/C++"
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class WasmAnalyzer(Analyzer):
|
|
55
|
+
name = "wasm_analyzer"
|
|
56
|
+
depends_on = ["framework_detector"]
|
|
57
|
+
produces_categories = ["wasm_module", "endpoint", "secret"]
|
|
58
|
+
|
|
59
|
+
def analyze_project(self, ctx: ProjectContext) -> list[Candidate]:
|
|
60
|
+
out: list[Candidate] = []
|
|
61
|
+
for art in ctx.artifacts_of(KIND_WASM):
|
|
62
|
+
try:
|
|
63
|
+
out.extend(self._module(art, ctx))
|
|
64
|
+
except Exception as exc: # noqa: BLE001
|
|
65
|
+
ctx.diagnostics.append(f"[wasm_analyzer] {art.path}: {type(exc).__name__}: {exc}")
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
def _module(self, art: Artifact, ctx: ProjectContext) -> list[Candidate]:
|
|
69
|
+
out: list[Candidate] = []
|
|
70
|
+
fname = PurePosixPath(art.path.replace("\\", "/")).name
|
|
71
|
+
penalty = [Evidence(ET.TEST_FIXTURE_CONTEXT, "module lives in a test/fixture path", self.name)] if is_test_path(art.path) else []
|
|
72
|
+
info = parse_wasm(art.data)
|
|
73
|
+
if info["version"] is None:
|
|
74
|
+
ctx.diagnostics.append(f"[wasm_analyzer] {art.path}: not a valid WebAssembly module")
|
|
75
|
+
return out
|
|
76
|
+
if info["truncated"]:
|
|
77
|
+
ctx.diagnostics.append(f"[wasm_analyzer] {art.path}: module is truncated or malformed; partial results")
|
|
78
|
+
tool = _toolchain(info)
|
|
79
|
+
caps = []
|
|
80
|
+
for key, rx, what in _HOST_CAPS:
|
|
81
|
+
hit = [i["name"] for i in info["imports"] if rx.search(i["name"])]
|
|
82
|
+
if hit:
|
|
83
|
+
caps.append((key, what, hit[0]))
|
|
84
|
+
refs = [pf.path for pf in ctx.files if pf.origin in ("disk", "html-inline", "vue-sfc") and not pf.skipped_reason and fname in pf.text]
|
|
85
|
+
refs += [a.path for a in ctx.artifacts if a.kind in ("html", "vue") and fname in a.text]
|
|
86
|
+
mem = info["memories"][0] if info["memories"] else None
|
|
87
|
+
|
|
88
|
+
ev = [Evidence(ET.ARTIFACT_RESOLVED, f"WebAssembly module parsed (v{info['version']}, {len(info['sections'])} section(s), "
|
|
89
|
+
f"{len(info['imports'])} import(s), {len(info['exports'])} export(s)" + (f", {tool}" if tool else "") + ")", self.name, art.path),
|
|
90
|
+
Evidence(ET.AST_PATTERN_MATCH, "binary section table read", self.name, art.path)]
|
|
91
|
+
for r in sorted(set(refs))[:3]:
|
|
92
|
+
ev.append(Evidence(ET.IMPORT_CHAIN, f"referenced by {PurePosixPath(r.replace(chr(92), '/')).name}", self.name, r))
|
|
93
|
+
tests = ["Client logic compiled to WASM is harder to review but not hidden: dump its exports/strings and check whether validation, licensing or "
|
|
94
|
+
"access decisions made here are re-checked by the server."]
|
|
95
|
+
if caps:
|
|
96
|
+
tests.append("It imports host capabilities (" + ", ".join(c[0] for c in caps) + "): follow the data it sends out or evaluates.")
|
|
97
|
+
if info["has_name_section"]:
|
|
98
|
+
tests.append("A `name` section is present, so function names are recoverable (easier reverse engineering).")
|
|
99
|
+
out.append(Candidate(
|
|
100
|
+
category="wasm_module", subject=fname, analyzer=self.name, evidence=ev + penalty, attack_surface="wasm",
|
|
101
|
+
suggested_test=" ".join(tests),
|
|
102
|
+
metadata={"file": art.path, "toolchain": tool, "imports": len(info["imports"]), "exports": [e["name"] for e in info["exports"]][:40],
|
|
103
|
+
"host_capabilities": [c[0] for c in caps], "has_name_section": info["has_name_section"], "referenced_by": sorted(set(refs))[:10],
|
|
104
|
+
"memory": mem, "customs": info["customs"], "truncated": info["truncated"]}))
|
|
105
|
+
|
|
106
|
+
# ---- strings: endpoints and secrets
|
|
107
|
+
lo, hi = info["data_range"] or (0, 0)
|
|
108
|
+
seen: set[str] = set()
|
|
109
|
+
for off, s in printable_strings(art.data):
|
|
110
|
+
in_data = lo <= off < hi
|
|
111
|
+
where = "inside a data segment" if in_data else "found by raw byte scan only"
|
|
112
|
+
base = [Evidence(ET.AST_PATTERN_MATCH, f"string located {where}", self.name, art.path)] if in_data else []
|
|
113
|
+
for service_pat, (rx, service) in SECRET_PATTERNS.items():
|
|
114
|
+
m = rx.search(s)
|
|
115
|
+
if not m:
|
|
116
|
+
continue
|
|
117
|
+
v = m.group(0)
|
|
118
|
+
subject = f"{service}:{v[:8]}...{v[-4:]}" if len(v) > 14 else f"{service}:{v}"
|
|
119
|
+
if subject in seen:
|
|
120
|
+
continue
|
|
121
|
+
seen.add(subject)
|
|
122
|
+
out.append(Candidate(category="secret", subject=subject, analyzer=self.name,
|
|
123
|
+
evidence=[Evidence(ET.LITERAL_STRING_MATCH, f"{service} {service_pat} pattern inside WASM module", self.name, art.path, None, v[:60])] + base + penalty,
|
|
124
|
+
suggested_test="Credentials compiled into a client WASM module are public. Verify the secret is live, scope it, and rotate if so.",
|
|
125
|
+
metadata={"source": "wasm"}))
|
|
126
|
+
cands = [m.group(0).rstrip(".,);") for m in _URL.finditer(s)] + [m.group(0) for m in _APIPATH.finditer(s)]
|
|
127
|
+
for c in cands:
|
|
128
|
+
path = c
|
|
129
|
+
if not PATH_RE.match(path) or path in seen or len(path) < 4:
|
|
130
|
+
continue
|
|
131
|
+
seen.add(path)
|
|
132
|
+
tag = classify(path)
|
|
133
|
+
out.append(Candidate(
|
|
134
|
+
category="endpoint", subject=path, analyzer=self.name,
|
|
135
|
+
evidence=[Evidence(ET.LITERAL_STRING_MATCH, "endpoint-shaped string embedded in WASM module", self.name, art.path, None, path[:100])] + base
|
|
136
|
+
+ ([Evidence(ET.NAMING_CONVENTION, f"path matches '{tag}' naming convention", self.name)] if tag else []) + penalty,
|
|
137
|
+
attack_surface=tag, metadata={"source": "wasm", "callee": "wasm-string"},
|
|
138
|
+
suggested_test="Endpoints compiled into WASM are invisible to JS-only recon. Request it with and without a session and compare with the endpoints the JS client uses."))
|
|
139
|
+
return out
|