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