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,502 @@
1
+ """Router Analyzer.
2
+
3
+ Reconstructs the application's routes and ties each one to the components,
4
+ API calls, guards and roles behind it. This is the second independent
5
+ analyzer that makes CROSS_ANALYZER_AGREEMENT fire organically: when
6
+ endpoint_analyzer sees `fetch('/api/x')` and this analyzer independently
7
+ shows that call lives inside a component rendered at route `/admin`, the two
8
+ agree on the same endpoint subject.
9
+
10
+ Recognized route shapes (all AST-based, none regex-over-source):
11
+ * React Router / Remix : <Route path element|component|Component>, route objects
12
+ for createBrowserRouter, minified jsx()/createElement(Route, {...})
13
+ * Vue Router / Nuxt : { path, component, meta: { requiresAuth, roles }, beforeEnter }
14
+ * Angular : { path, component|loadChildren, canActivate: [...], data: { roles } }
15
+ * Next.js : file-convention routes from chunk/source paths (pages/, app/**/page)
16
+
17
+ Honest limits (also in README): component->API association is exact when the
18
+ component is defined in the same file (byte-range containment), and
19
+ FILE-LEVEL when it is imported from another project file (every API call in
20
+ that file is attributed, weaker evidence). Alias imports ('@/x') and
21
+ .vue/.ts sources are not resolved.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import posixpath
27
+ import re
28
+ from dataclasses import dataclass, field
29
+ from typing import Any
30
+
31
+ from jscope.analyzers.base import Analyzer, ProjectContext
32
+ from jscope.analyzers.common import GUARD_NAME_RE, PATH_RE, classify, is_test_path
33
+ from jscope.core.evidence import Candidate, Evidence, EvidenceType as ET
34
+ from jscope.core.graph import EdgeKind, NodeKind
35
+ from jscope.parsing import queries as Q
36
+ from jscope.parsing.ast_engine import ParsedFile
37
+
38
+ _COMPONENT_PROPS = ("element", "component", "Component", "loadComponent", "loadChildren", "lazy")
39
+ _OTHER_ROUTE_PROPS = {"children", "loader", "action", "redirect", "redirectTo", "meta", "canActivate",
40
+ "handle", "beforeEnter", "data"}
41
+ _AUTH_FLAGS = {"requiresAuth", "requireAuth", "requiresLogin", "auth", "authRequired", "isAuthenticated",
42
+ "protected", "private", "requiresAuthentication"}
43
+ _ROLE_KEYS = {"roles", "role", "permissions", "permission", "authorities", "authority", "allowedRoles",
44
+ "requiredRole", "requiredRoles", "requiredPermission", "scopes"}
45
+ _GUARD_PROPS = ("canActivate", "canActivateChild", "canMatch", "canLoad", "beforeEnter")
46
+ _ROUTER_FRAMEWORKS = {"React", "Next.js", "Remix", "Vue", "Nuxt", "Angular", "Svelte"}
47
+
48
+ _NEXT_PAGES = re.compile(r"(?:^|/)pages/(.+?)(?:[-.][0-9a-f]{8,})?\.(?:js|jsx|mjs)$")
49
+ _NEXT_APP = re.compile(r"(?:^|/)app/(?:(.+?)/)?(page|route)(?:[-.][0-9a-f]{8,})?\.(?:js|jsx|mjs)$")
50
+
51
+
52
+ @dataclass
53
+ class RouteDef:
54
+ raw_path: str | None # None => pathless layout route
55
+ node: Any
56
+ pf: ParsedFile
57
+ line: int
58
+ shape: str # "config" | "jsx"
59
+ chain: list[Q.ElementUse] = field(default_factory=list)
60
+ own_guards: list[str] = field(default_factory=list)
61
+ own_roles: list[str] = field(default_factory=list)
62
+ own_auth_flag: bool = False
63
+ code_nodes: list[Any] = field(default_factory=list) # loader/action/lazy function nodes
64
+ lazy_sources: list[str] = field(default_factory=list)
65
+ features: set[str] = field(default_factory=set) # shape hints used for framework fit
66
+ parent: "RouteDef | None" = None
67
+ full_path: str | None = None
68
+
69
+ @property
70
+ def emit(self) -> bool:
71
+ return self.raw_path is not None
72
+
73
+
74
+ def _join(parent: str | None, raw: str) -> str:
75
+ if raw.startswith("/"):
76
+ return raw
77
+ if raw in ("", "index"):
78
+ return parent or "/"
79
+ base = (parent or "").rstrip("/")
80
+ return "/" + posixpath.join(base, raw).lstrip("/")
81
+
82
+
83
+ def _node_key(n) -> tuple[int, int]:
84
+ return (n.start_byte, n.end_byte)
85
+
86
+
87
+ class RouterAnalyzer(Analyzer):
88
+ name = "router_analyzer"
89
+ depends_on = ["framework_detector"]
90
+ produces_categories = ["route", "endpoint", "authorization"]
91
+
92
+ # ------------------------------------------------------------------ shapes
93
+ def _truthy(self, node) -> bool:
94
+ return node is not None and node.type in ("true", "string", "number", "object", "array", "identifier")
95
+
96
+ def _meta_from(self, pf, props: dict, rd: RouteDef, ctx) -> None:
97
+ for holder_key in ("meta", "data", "handle"):
98
+ holder = Q.object_props(pf, props.get(holder_key)) if holder_key in props else {}
99
+ for k, v in holder.items():
100
+ if k in _AUTH_FLAGS and pf.node_text(v) not in ("false", "0"):
101
+ rd.own_auth_flag = True
102
+ rd.features.add("meta")
103
+ if k in _ROLE_KEYS:
104
+ rd.own_roles += Q.string_list(ctx.ast, pf, v)
105
+ rd.features.add("meta")
106
+ for gk in _GUARD_PROPS:
107
+ if gk in props:
108
+ v = props[gk]
109
+ if v.type == "array":
110
+ rd.own_guards += [pf.node_text(i) for i in Q.array_items(v)]
111
+ else:
112
+ rd.own_guards.append(pf.node_text(v) if v.type == "identifier" else gk)
113
+ rd.features.add("angular_or_vue_guard")
114
+
115
+ def _chain_protection(self, rd: RouteDef) -> None:
116
+ for use in rd.chain:
117
+ if GUARD_NAME_RE.search(use.name.split(".")[-1]):
118
+ rd.own_guards.append(use.name)
119
+ for k, raw in use.props.items():
120
+ if k in _ROLE_KEYS:
121
+ rd.own_roles += Q.quoted_strings(raw) or ([raw] if raw and "{" not in raw and "[" not in raw else [])
122
+
123
+ def _config_routes(self, pf: ParsedFile, ctx: ProjectContext) -> list[RouteDef]:
124
+ found: dict[tuple[int, int], RouteDef] = {}
125
+ for node in ctx.ast.walk(pf.tree.root_node):
126
+ if node.type != "object":
127
+ continue
128
+ props = Q.object_props(pf, node)
129
+ has_comp = any(k in props for k in _COMPONENT_PROPS)
130
+ raw = None
131
+ if "path" in props:
132
+ rv = ctx.ast.resolve_value(pf, props["path"], Q.bindings_for(ctx.ast, pf))
133
+ if rv.value is None or rv.is_placeholder:
134
+ continue
135
+ raw = rv.value
136
+ starts = raw.startswith("/") or raw == "*"
137
+ if not (has_comp or (starts and (_OTHER_ROUTE_PROPS & props.keys()))):
138
+ continue
139
+ elif "children" in props and has_comp and props["children"].type == "array":
140
+ raw = None # pathless layout route
141
+ elif props.get("index") is not None and has_comp:
142
+ raw = "" # index route
143
+ else:
144
+ continue
145
+ rd = RouteDef(raw_path=raw, node=node, pf=pf, line=pf.line_of(node), shape="config")
146
+ for ck in _COMPONENT_PROPS:
147
+ if ck in props:
148
+ rd.chain = Q.element_chain(ctx.ast, pf, props[ck])
149
+ src = Q._dynamic_import_source(pf, props[ck])
150
+ if src:
151
+ rd.lazy_sources.append(src)
152
+ rd.features.add(ck)
153
+ break
154
+ for k in ("loader", "action"):
155
+ if k in props:
156
+ rd.code_nodes.append(props[k])
157
+ rd.features.add(k)
158
+ self._meta_from(pf, props, rd, ctx)
159
+ self._chain_protection(rd)
160
+ found[_node_key(node)] = rd
161
+ # parent resolution: climb through a `children`/`routes` pair to the owning route object
162
+ for key, rd in found.items():
163
+ cur, via = rd.node.parent, False
164
+ while cur is not None:
165
+ if cur.type == "pair" and Q.key_name(pf, cur.child_by_field_name("key")) in ("children", "routes"):
166
+ via = True
167
+ elif cur.type == "object" and via and _node_key(cur) in found:
168
+ rd.parent = found[_node_key(cur)]
169
+ break
170
+ cur = cur.parent
171
+ return list(found.values())
172
+
173
+ def _jsx_routes(self, pf: ParsedFile, ctx: ProjectContext) -> list[RouteDef]:
174
+ found: dict[tuple[int, int], RouteDef] = {}
175
+ by_opening: dict[tuple[int, int], RouteDef] = {}
176
+ for node in ctx.ast.walk(pf.tree.root_node):
177
+ if node.type not in ("jsx_self_closing_element", "jsx_opening_element"):
178
+ continue
179
+ name = Q._jsx_name(pf, node)
180
+ if not name:
181
+ continue
182
+ leaf = name.split(".")[-1]
183
+ attrs = Q.jsx_attrs(pf, node)
184
+ is_route = leaf == "Route" or (leaf.endswith("Route") and "path" in attrs)
185
+ if not is_route:
186
+ continue
187
+ raw = attrs["path"][0] if "path" in attrs else ("" if "index" in attrs else None)
188
+ rd = RouteDef(raw_path=raw, node=node, pf=pf, line=pf.line_of(node), shape="jsx")
189
+ for ck in ("element", "component", "Component", "render"):
190
+ if ck in attrs:
191
+ rd.chain = Q.element_chain(ctx.ast, pf, attrs[ck][1]) if attrs[ck][1] is not None else []
192
+ rd.features.add(ck)
193
+ break
194
+ if leaf != "Route": # PrivateRoute path="/x" component={X}
195
+ rd.own_guards.append(name)
196
+ for k in ("loader", "action"):
197
+ if k in attrs and attrs[k][1] is not None:
198
+ rd.code_nodes.append(attrs[k][1])
199
+ for k, (rawv, vnode) in attrs.items():
200
+ if k in _ROLE_KEYS:
201
+ rd.own_roles += Q.quoted_strings(rawv) or ([rawv] if rawv and "{" not in rawv else [])
202
+ self._chain_protection(rd)
203
+ found[_node_key(node)] = rd
204
+ if node.type == "jsx_opening_element":
205
+ by_opening[_node_key(node.parent)] = rd # keyed by enclosing jsx_element
206
+ for rd in found.values():
207
+ cur = rd.node.parent
208
+ if rd.node.type == "jsx_opening_element":
209
+ cur = rd.node.parent.parent
210
+ while cur is not None:
211
+ if cur.type == "jsx_element" and _node_key(cur) in by_opening:
212
+ rd.parent = by_opening[_node_key(cur)]
213
+ break
214
+ cur = cur.parent
215
+ return list(found.values())
216
+
217
+ @staticmethod
218
+ def _next_route(path: str) -> tuple[str, str] | None:
219
+ p = path.replace("\\", "/")
220
+ m = _NEXT_APP.search(p)
221
+ if m:
222
+ segs, kind = (m.group(1) or ""), ("api" if m.group(2) == "route" else "page")
223
+ else:
224
+ m = _NEXT_PAGES.search(p)
225
+ if not m:
226
+ return None
227
+ segs, kind = m.group(1), "page"
228
+ parts = []
229
+ for s in segs.split("/"):
230
+ if not s or s.startswith("_") or s.startswith("@") or (s.startswith("(") and s.endswith(")")):
231
+ continue
232
+ s = re.sub(r"\[\[\.\.\.(\w+)\]\]", r"{...\1}", s)
233
+ s = re.sub(r"\[\.\.\.(\w+)\]", r"{...\1}", s)
234
+ s = re.sub(r"\[(\w+)\]", r"{\1}", s)
235
+ parts.append(s)
236
+ if parts and parts[-1] == "index":
237
+ parts.pop()
238
+ if segs.startswith("_") and not parts:
239
+ return None
240
+ route = "/" + "/".join(parts)
241
+ if kind == "page" and parts[:1] == ["api"]:
242
+ kind = "api"
243
+ return route, kind
244
+
245
+ # -------------------------------------------------------------- association
246
+ def _http_sites(self, ctx: ProjectContext, pf: ParsedFile):
247
+ if "http" not in pf.memo:
248
+ out = []
249
+ for site in ctx.ast.find_calls(pf):
250
+ if not site.args_resolved:
251
+ continue
252
+ rv = site.args_resolved[0]
253
+ if rv.value is None or rv.is_placeholder or not PATH_RE.match(rv.value):
254
+ continue
255
+ out.append((site, rv.value))
256
+ pf.memo["http"] = out
257
+ return pf.memo["http"]
258
+
259
+ def _protection(self, rd: RouteDef) -> tuple[list[str], list[str], bool]:
260
+ guards, roles, flag, cur = [], [], False, rd
261
+ while cur is not None:
262
+ guards += cur.own_guards
263
+ roles += cur.own_roles
264
+ flag = flag or cur.own_auth_flag
265
+ cur = cur.parent
266
+ return sorted(set(guards)), sorted(set(roles)), flag
267
+
268
+ # ------------------------------------------------------------------ driver
269
+ def analyze_project(self, ctx: ProjectContext) -> list[Candidate]:
270
+ index = Q.FileIndex(ctx.files)
271
+ g = ctx.graph
272
+ hints = set(ctx.framework_hints) & _ROUTER_FRAMEWORKS
273
+ routes: dict[str, Candidate] = {}
274
+ endpoints: dict[str, Candidate] = {}
275
+ endpoint_routes: dict[str, set[str]] = {}
276
+ authz: dict[str, Candidate] = {}
277
+
278
+ def endpoint_cand(path: str) -> Candidate:
279
+ if path not in endpoints:
280
+ endpoints[path] = Candidate(category="endpoint", subject=path, analyzer=self.name,
281
+ attack_surface=classify(path))
282
+ return endpoints[path]
283
+
284
+ def link_calls(route_id: str, route_label: str, pf: ParsedFile, container, comp_label: str,
285
+ comp_id: str, mode: str, evidence_pf: ParsedFile):
286
+ """Attribute HTTP calls (inside `container`, or the whole file if None) to a component."""
287
+ for site, path in self._http_sites(ctx, pf):
288
+ if container is not None and not (container.start_byte <= site.node.start_byte
289
+ and site.node.end_byte <= container.end_byte):
290
+ continue
291
+ if ctx.ast.is_in_dead_branch(pf, site.node):
292
+ continue # never claim reachability for gated-off calls
293
+ c = endpoint_cand(path)
294
+ if mode == "local":
295
+ c.evidence.append(Evidence(
296
+ ET.CALL_GRAPH_REACHABLE,
297
+ f"call sits inside {comp_label}, which is rendered at route {route_label}",
298
+ self.name, pf.path, site.line, site.args_raw[0][:100]))
299
+ else:
300
+ c.evidence.append(Evidence(
301
+ ET.IMPORT_CHAIN,
302
+ f"{comp_label} (imported by route {route_label}) lives in this file; "
303
+ "file-level attribution of all its API calls",
304
+ self.name, pf.path, site.line, site.args_raw[0][:100]))
305
+ g.add_node(f"endpoint:{path}", NodeKind.ENDPOINT, path)
306
+ g.add_edge(comp_id, f"endpoint:{path}", EdgeKind.CALLS, self.name,
307
+ "AST_PATTERN_MATCH" if mode == "local" else "IMPORT_CHAIN", pf.path, site.line)
308
+ endpoint_routes.setdefault(path, set()).add(route_label)
309
+
310
+ # 1) collect route definitions (all files)
311
+ defs: list[RouteDef] = []
312
+ for pf in ctx.files:
313
+ if pf.skipped_reason or pf.tree is None:
314
+ continue
315
+ defs += self._config_routes(pf, ctx) + self._jsx_routes(pf, ctx)
316
+
317
+ # 2) resolve full paths (parents first)
318
+ def full(rd: RouteDef) -> str | None:
319
+ if rd.full_path is not None:
320
+ return rd.full_path
321
+ parent_full = full(rd.parent) if rd.parent else None
322
+ if rd.raw_path is None:
323
+ rd.full_path = parent_full or ""
324
+ else:
325
+ rd.full_path = _join(parent_full, rd.raw_path)
326
+ return rd.full_path
327
+ for rd in defs:
328
+ full(rd)
329
+
330
+ # 3) emit
331
+ for rd in defs:
332
+ if not rd.emit or not rd.full_path:
333
+ continue
334
+ pf, path = rd.pf, rd.full_path
335
+ guards, roles, flag = self._protection(rd)
336
+ comps = [u.name for u in rd.chain if not GUARD_NAME_RE.search(u.name.split(".")[-1])]
337
+ primary = comps[-1] if comps else None
338
+ fw_fit = []
339
+ if rd.shape == "jsx" or {"element", "Component", "loader", "action"} & rd.features:
340
+ fw_fit = sorted({"React", "Next.js", "Remix"} & hints)
341
+ if {"meta", "component", "angular_or_vue_guard"} & rd.features:
342
+ fw_fit = sorted(set(fw_fit) | ({"Vue", "Nuxt", "Angular"} & hints))
343
+ if "loadChildren" in rd.features or "canActivate" in " ".join(rd.own_guards + list(rd.features)):
344
+ fw_fit = sorted(set(fw_fit) | ({"Angular"} & hints))
345
+
346
+ cand = routes.get(path)
347
+ ev: list[Evidence] = [Evidence(
348
+ ET.AST_PATTERN_MATCH,
349
+ f"{rd.shape} route definition path={rd.raw_path!r}"
350
+ + (f" -> {primary}" if primary else "") + (f"; resolved full path {path}" if path != rd.raw_path else ""),
351
+ self.name, pf.path, rd.line, pf.node_text(rd.node)[:100].replace("\n", " "))]
352
+ if fw_fit:
353
+ ev.append(Evidence(ET.FRAMEWORK_CONTEXT, f"{', '.join(fw_fit)} detected and route shape matches its router", self.name))
354
+ tag = classify(path)
355
+ if tag:
356
+ ev.append(Evidence(ET.NAMING_CONVENTION, f"route path matches '{tag}' naming convention", self.name))
357
+ if ctx.ast.is_in_dead_branch(pf, rd.node):
358
+ ev.append(Evidence(ET.DEAD_CODE, "route definition sits in a statically dead branch", self.name))
359
+ if is_test_path(pf.path):
360
+ ev.append(Evidence(ET.TEST_FIXTURE_CONTEXT, "route defined in a test/mock file", self.name))
361
+
362
+ # component resolution -> graph + endpoint attribution
363
+ route_id = f"route:{path}"
364
+ g.add_node(route_id, NodeKind.ROUTE, path, file=pf.path, line=rd.line,
365
+ framework=(fw_fit[0] if fw_fit else None), shape=rd.shape)
366
+ by_name, _ = Q.imports(ctx.ast, pf)
367
+ funcs = Q.function_index(ctx.ast, pf)
368
+ cross_file = False
369
+ for cname in reversed(comps): # innermost first
370
+ short = cname.split(".")[-1]
371
+ ref0 = by_name.get(short)
372
+ if short in funcs and not (ref0 and ref0.kind == "lazy_import"):
373
+ comp_id = f"component:{pf.path}:{short}"
374
+ g.add_node(comp_id, NodeKind.COMPONENT, short, file=pf.path)
375
+ g.add_node(f"file:{pf.path}", NodeKind.FILE, pf.path)
376
+ g.add_edge(f"file:{pf.path}", comp_id, EdgeKind.CONTAINS, self.name, "AST_PATTERN_MATCH", pf.path)
377
+ g.add_edge(route_id, comp_id, EdgeKind.RENDERS, self.name, "AST_PATTERN_MATCH", pf.path, rd.line)
378
+ link_calls(route_id, path, pf, funcs[short], short, comp_id, "local", pf)
379
+ break
380
+ ref = by_name.get(short)
381
+ if ref:
382
+ target = index.resolve(pf.path, ref.source)
383
+ if target is not None:
384
+ cross_file = True
385
+ fid = f"file:{target.path}"
386
+ g.add_node(fid, NodeKind.FILE, target.path)
387
+ g.add_edge(route_id, fid, EdgeKind.RENDERS, self.name, "IMPORT_CHAIN", pf.path, rd.line)
388
+ ev.append(Evidence(ET.IMPORT_CHAIN,
389
+ f"component {short} imported from {ref.source!r} resolved to {target.path}",
390
+ self.name, pf.path, ref.line))
391
+ link_calls(route_id, path, target, None, short, fid, "import", target)
392
+ break
393
+ for src in rd.lazy_sources:
394
+ target = index.resolve(pf.path, src)
395
+ if target is not None and not cross_file:
396
+ cross_file = True
397
+ fid = f"file:{target.path}"
398
+ g.add_node(fid, NodeKind.FILE, target.path)
399
+ g.add_edge(route_id, fid, EdgeKind.RENDERS, self.name, "IMPORT_CHAIN", pf.path, rd.line)
400
+ ev.append(Evidence(ET.IMPORT_CHAIN, f"lazy import {src!r} resolved to {target.path}", self.name, pf.path, rd.line))
401
+ link_calls(route_id, path, target, None, src, fid, "import", target)
402
+ for cn in rd.code_nodes: # loader / action
403
+ target_node = funcs.get(pf.node_text(cn)) if cn.type == "identifier" else cn
404
+ if target_node is not None:
405
+ comp_id = f"component:{pf.path}:loader@{rd.line}"
406
+ g.add_node(comp_id, NodeKind.COMPONENT, f"loader@{rd.line}", file=pf.path)
407
+ g.add_edge(route_id, comp_id, EdgeKind.RENDERS, self.name, "AST_PATTERN_MATCH", pf.path, rd.line)
408
+ link_calls(route_id, path, pf, target_node, "route loader/action", comp_id, "local", pf)
409
+
410
+ # protection -> graph + authorization candidates
411
+ for gname in guards:
412
+ gid = f"guard:{gname}"
413
+ g.add_node(gid, NodeKind.GUARD, gname)
414
+ g.add_edge(route_id, gid, EdgeKind.GUARDED_BY, self.name, "AST_PATTERN_MATCH", pf.path, rd.line)
415
+ self._authz(authz, f"guard:{gname}", pf, rd.line,
416
+ f"route {path} is wrapped/guarded by {gname}", self.name)
417
+ if flag:
418
+ gid = "guard:meta.requiresAuth"
419
+ g.add_node(gid, NodeKind.GUARD, "meta.requiresAuth")
420
+ g.add_edge(route_id, gid, EdgeKind.GUARDED_BY, self.name, "AST_PATTERN_MATCH", pf.path, rd.line)
421
+ for r in roles:
422
+ rid = f"role:{r}"
423
+ g.add_node(rid, NodeKind.ROLE, r)
424
+ g.add_edge(route_id, rid, EdgeKind.REQUIRES_ROLE, self.name, "AST_PATTERN_MATCH", pf.path, rd.line)
425
+ self._authz(authz, f"role:{r}", pf, rd.line, f"route {path} declares it requires '{r}'", self.name)
426
+
427
+ test = None
428
+ if tag in ("admin", "internal", "debug") or not (guards or roles or flag):
429
+ test = ("Client-side guards are not access control: replay this route's API calls "
430
+ "(see related endpoints) without credentials and as a low-privilege user.")
431
+ if cand is None:
432
+ routes[path] = Candidate(
433
+ category="route", subject=path, analyzer=self.name, evidence=ev, attack_surface=tag,
434
+ suggested_test=test,
435
+ metadata={"file": pf.path, "line": rd.line, "guards": guards, "roles": roles,
436
+ "client_auth_flag": flag, "component": primary, "shape": rd.shape,
437
+ "unguarded": not (guards or roles or flag)})
438
+ else:
439
+ cand.evidence += ev
440
+
441
+ # 4) Next.js file-convention routes
442
+ for pf in ctx.files:
443
+ if pf.skipped_reason or pf.tree is None or pf.origin != "disk":
444
+ continue
445
+ nr = self._next_route(pf.path)
446
+ if nr is None:
447
+ continue
448
+ if "Next.js" not in ctx.framework_hints and "_next" not in pf.path.replace("\\", "/") \
449
+ and ".next" not in pf.path:
450
+ continue
451
+ path, kind = nr
452
+ tag = classify(path)
453
+ ev = [Evidence(ET.FRAMEWORK_CONTEXT,
454
+ f"file path follows the Next.js {'API route' if kind == 'api' else 'page'} convention -> {path}",
455
+ self.name, pf.path)]
456
+ if tag:
457
+ ev.append(Evidence(ET.NAMING_CONVENTION, f"route path matches '{tag}' naming convention", self.name))
458
+ if is_test_path(pf.path):
459
+ ev.append(Evidence(ET.TEST_FIXTURE_CONTEXT, "file lives in a test/mock location", self.name))
460
+ route_id = f"route:{path}"
461
+ fid = f"file:{pf.path}"
462
+ g.add_node(route_id, NodeKind.ROUTE, path, file=pf.path, framework="Next.js", shape="file", route_kind=kind)
463
+ g.add_node(fid, NodeKind.FILE, pf.path)
464
+ g.add_edge(route_id, fid, EdgeKind.RENDERS, self.name, "FRAMEWORK_CONTEXT", pf.path)
465
+ for site, epath in self._http_sites(ctx, pf):
466
+ if ctx.ast.is_in_dead_branch(pf, site.node):
467
+ continue
468
+ c = endpoint_cand(epath)
469
+ c.evidence.append(Evidence(ET.FRAMEWORK_CONTEXT,
470
+ f"issued from the Next.js {'handler' if kind == 'api' else 'page chunk'} for {path}",
471
+ self.name, pf.path, site.line, site.args_raw[0][:100]))
472
+ g.add_node(f"endpoint:{epath}", NodeKind.ENDPOINT, epath)
473
+ g.add_edge(fid, f"endpoint:{epath}", EdgeKind.CALLS, self.name, "FRAMEWORK_CONTEXT", pf.path, site.line)
474
+ endpoint_routes.setdefault(epath, set()).add(path)
475
+ if path not in routes:
476
+ routes[path] = Candidate(
477
+ category="route", subject=path, analyzer=self.name, evidence=ev, attack_surface=tag,
478
+ suggested_test=("Next.js API route: test auth/authorization on each HTTP method directly."
479
+ if kind == "api" else None),
480
+ metadata={"file": pf.path, "shape": "file", "kind": kind, "guards": [], "roles": [],
481
+ "unguarded": True, "client_auth_flag": False})
482
+ else:
483
+ routes[path].evidence += ev
484
+
485
+ # 5) finalize endpoint candidates (aggregate route list once, here)
486
+ for path, c in endpoints.items():
487
+ c.metadata["routes"] = sorted(endpoint_routes.get(path, []))
488
+ c.related = [f"route:{r}" for r in c.metadata["routes"]]
489
+ for path, c in routes.items():
490
+ c.related = [e for e, rs in endpoint_routes.items() if path in rs]
491
+
492
+ return list(routes.values()) + list(endpoints.values()) + list(authz.values())
493
+
494
+ def _authz(self, store: dict, subject: str, pf: ParsedFile, line: int, desc: str, who: str) -> None:
495
+ ev = Evidence(ET.AST_PATTERN_MATCH, desc, who, pf.path, line)
496
+ if subject in store:
497
+ store[subject].evidence.append(ev)
498
+ else:
499
+ store[subject] = Candidate(
500
+ category="authorization", subject=subject, analyzer=who, evidence=[ev],
501
+ attack_surface="authorization",
502
+ suggested_test="Route guards run in the browser. Strip/alter the client check and call the protected API directly.")
@@ -0,0 +1,99 @@
1
+ """Secret Analyzer.
2
+
3
+ Regex is the entry evidence (LITERAL_STRING_MATCH / AST_PATTERN_MATCH), but
4
+ context downgrades or upgrades it:
5
+ - inside process.env.X / import.meta.env.X with no literal value -> informational placeholder
6
+ - file path contains test/mock/spec/__tests__ -> TEST_FIXTURE_CONTEXT penalty
7
+ - value assigned to a var literally named *_KEY/*_SECRET/*_TOKEN -> AST_PATTERN_MATCH boost
8
+ No secret is ever "confirmed" by regex alone -- that requires
9
+ CROSS_ANALYZER_AGREEMENT or (future) RUNTIME_CONTEXT.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+
16
+ from jscope.analyzers.base import Analyzer, ProjectContext
17
+ from jscope.core.evidence import Candidate, Evidence, EvidenceType
18
+ from jscope.parsing.ast_engine import ParsedFile
19
+
20
+ # pattern name -> (regex, service)
21
+ _PATTERNS: dict[str, tuple[re.Pattern, str]] = {
22
+ "aws_access_key": (re.compile(r"\bAKIA[0-9A-Z]{16}\b"), "AWS"),
23
+ "google_api_key": (re.compile(r"\bAIza[0-9A-Za-z\-_]{35}\b"), "Google/Firebase"),
24
+ "stripe_live_key": (re.compile(r"\bsk_live_[0-9a-zA-Z]{20,}\b"), "Stripe"),
25
+ "stripe_publishable_key": (re.compile(r"\bpk_live_[0-9a-zA-Z]{20,}\b"), "Stripe"),
26
+ "slack_token": (re.compile(r"\bxox[baprs]-[0-9A-Za-z-]{10,}\b"), "Slack"),
27
+ "supabase_url": (re.compile(r"\bhttps://[a-z0-9]{20}\.supabase\.co\b"), "Supabase"),
28
+ "jwt_like": (re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b"), "JWT"),
29
+ "generic_private_key": (re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"), "PKI"),
30
+ }
31
+
32
+ _TEST_PATH_RE = re.compile(r"(__tests__|__mocks__|\.test\.|\.spec\.|/test/|/tests/|/mock/|/fixtures?/)", re.I)
33
+ _SENSITIVE_VAR_RE = re.compile(r"(KEY|SECRET|TOKEN|CREDENTIAL|PASSWORD)$", re.I)
34
+
35
+
36
+ class SecretAnalyzer(Analyzer):
37
+ name = "secret_analyzer"
38
+ depends_on = []
39
+ produces_categories = ["secret"]
40
+
41
+ def analyze_file(self, pf: ParsedFile, ctx: ProjectContext) -> list[Candidate]:
42
+ out: list[Candidate] = []
43
+ is_test_path = bool(_TEST_PATH_RE.search(pf.path))
44
+
45
+ for node, text, line in ctx.ast.find_string_literals(pf):
46
+ for pattern_name, (regex, service) in _PATTERNS.items():
47
+ m = regex.search(text)
48
+ if not m:
49
+ continue
50
+ matched = m.group(0)
51
+ subject = f"{service}:{matched[:8]}...{matched[-4:]}" if len(matched) > 14 else f"{service}:{matched}"
52
+
53
+ evidence = [Evidence(
54
+ type=EvidenceType.LITERAL_STRING_MATCH,
55
+ description=f"string literal matches {service} {pattern_name} pattern",
56
+ analyzer=self.name,
57
+ file=pf.path,
58
+ line=line,
59
+ snippet=matched[:60],
60
+ )]
61
+
62
+ # look at the enclosing variable_declarator (if any) for naming context
63
+ parent = node.parent
64
+ while parent is not None and parent.type != "variable_declarator":
65
+ parent = parent.parent
66
+ if parent is not None:
67
+ name_node = parent.child_by_field_name("name")
68
+ if name_node is not None and _SENSITIVE_VAR_RE.search(pf.node_text(name_node)):
69
+ evidence.append(Evidence(
70
+ type=EvidenceType.AST_PATTERN_MATCH,
71
+ description=f"assigned to variable '{pf.node_text(name_node)}' matching secret-naming convention",
72
+ analyzer=self.name,
73
+ ))
74
+
75
+ if is_test_path:
76
+ evidence.append(Evidence(
77
+ type=EvidenceType.TEST_FIXTURE_CONTEXT,
78
+ description="file path looks like a test/mock/fixture file",
79
+ analyzer=self.name,
80
+ ))
81
+
82
+ if ctx.ast.is_in_dead_branch(pf, node):
83
+ evidence.append(Evidence(
84
+ type=EvidenceType.DEAD_CODE,
85
+ description="literal is inside a branch gated on a falsy/dev-only condition",
86
+ analyzer=self.name,
87
+ ))
88
+
89
+ out.append(Candidate(
90
+ category="secret",
91
+ subject=subject,
92
+ analyzer=self.name,
93
+ evidence=evidence,
94
+ attack_surface=None,
95
+ suggested_test=f"Verify if this {service} credential is live/scoped; check for excess privilege if confirmed valid.",
96
+ metadata={"pattern": pattern_name, "service": service},
97
+ ))
98
+
99
+ return out