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