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,278 @@
1
+ """OpenAPI / Swagger Analyzer (Phase 4).
2
+
3
+ A spec is the server team's own statement of the API. JScope reads it offline and correlates it with what the
4
+ CLIENT actually calls, in both directions:
5
+
6
+ * documented AND called -> the matching `endpoint` finding gains ARTIFACT_RESOLVED from this analyzer, which (being an
7
+ independent source) also earns CROSS_ANALYZER_AGREEMENT. Spec security metadata rides along.
8
+ * called but NOT documented, while the spec documents siblings under the same prefix -> `undocumented_api`
9
+ (shadow/legacy/beta routes are classically under-reviewed).
10
+ * documented, sensitive, and referenced by no analyzed client code -> `api_operation` with client_referenced=False
11
+ (functionality the UI never exposes: test authorization directly).
12
+
13
+ Precision guards: an undocumented-API claim needs a documented sibling prefix (a spec for one service must not
14
+ condemn calls to another); "never called by the client" is only asserted when the endpoint analyzer actually ran and
15
+ found at least one call; non-sensitive, unreferenced operations are summarised, not listed one by one.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from pathlib import PurePosixPath
21
+ from urllib.parse import urlparse
22
+
23
+ from jscope.analyzers.base import Analyzer, ProjectContext
24
+ from jscope.analyzers.common import (classify, is_static_asset, is_test_path, path_segments, segs_match, split_origin)
25
+ from jscope.core.artifacts import KIND_OPENAPI, Artifact, structured
26
+ from jscope.core.evidence import Candidate, Evidence, EvidenceType as ET
27
+ from jscope.core.graph import EdgeKind, NodeKind
28
+
29
+ _METHODS = ("get", "put", "post", "delete", "patch", "options", "head")
30
+ _WRITE = {"POST", "PUT", "PATCH", "DELETE"}
31
+ _INTERNAL_HOST = ("localhost", "127.0.0.1", "0.0.0.0", ".local", ".internal", "staging", "stage.", "dev.", ".dev.", "test.", "uat", "sandbox", "preprod")
32
+
33
+
34
+ def _resolve(doc: dict, ref_obj):
35
+ """Resolve a local `$ref` ('#/components/parameters/x'); anything else is returned as-is."""
36
+ seen = 0
37
+ while isinstance(ref_obj, dict) and "$ref" in ref_obj and seen < 5:
38
+ ref = str(ref_obj["$ref"])
39
+ if not ref.startswith("#/"):
40
+ return {}
41
+ node = doc
42
+ for part in ref[2:].split("/"):
43
+ node = node.get(part.replace("~1", "/").replace("~0", "~"), {}) if isinstance(node, dict) else {}
44
+ ref_obj, seen = node, seen + 1
45
+ return ref_obj if isinstance(ref_obj, dict) else {}
46
+
47
+
48
+ def _bases(doc: dict) -> tuple[list[str], list[str]]:
49
+ """(base paths, hosts) from OAS3 `servers` or Swagger2 host/basePath."""
50
+ paths, hosts = [], []
51
+ if "servers" in doc:
52
+ for s in doc.get("servers") or []:
53
+ url = str((s or {}).get("url", ""))
54
+ for name, var in ((s or {}).get("variables") or {}).items():
55
+ url = url.replace("{" + name + "}", str((var or {}).get("default", "")))
56
+ u = urlparse(url if "://" in url else "//x" + url if url.startswith("/") else "//" + url)
57
+ if "://" in url and u.netloc:
58
+ hosts.append(u.netloc.lower())
59
+ paths.append(u.path.rstrip("/"))
60
+ else:
61
+ if doc.get("host"):
62
+ hosts.append(str(doc["host"]).lower())
63
+ paths.append(str(doc.get("basePath", "")).rstrip("/"))
64
+ return sorted(set(paths)) or [""], sorted(set(hosts))
65
+
66
+
67
+ def parse_operations(doc: dict) -> list[dict]:
68
+ ops = []
69
+ global_sec = doc.get("security")
70
+ schemes = list((doc.get("components") or {}).get("securitySchemes", {}) or doc.get("securityDefinitions", {}) or {})
71
+ for path, item in (doc.get("paths") or {}).items():
72
+ if not isinstance(item, dict):
73
+ continue
74
+ shared_params = [_resolve(doc, p) for p in item.get("parameters", []) or []]
75
+ for m in _METHODS:
76
+ op = item.get(m)
77
+ if not isinstance(op, dict):
78
+ continue
79
+ params = shared_params + [_resolve(doc, p) for p in op.get("parameters", []) or []]
80
+ bodies = list(((op.get("requestBody") or {}).get("content") or {}).keys()) or list(op.get("consumes") or doc.get("consumes") or [])
81
+ if "security" in op:
82
+ sec, sec_src = op["security"], "operation"
83
+ elif global_sec is not None:
84
+ sec, sec_src = global_sec, "global"
85
+ else:
86
+ sec, sec_src = None, "undeclared"
87
+ if sec == []:
88
+ security = "none-explicit"
89
+ elif sec is None:
90
+ security = "undeclared"
91
+ else:
92
+ security = sorted({k for req in sec if isinstance(req, dict) for k in req}) or ["declared"]
93
+ ops.append({
94
+ "method": m.upper(), "path": str(path), "operation_id": op.get("operationId"),
95
+ "summary": (op.get("summary") or "")[:80], "tags": [str(t) for t in op.get("tags", []) or []],
96
+ "path_params": [p.get("name") for p in params if p.get("in") == "path"],
97
+ "query_params": [p.get("name") for p in params if p.get("in") == "query"],
98
+ "content_types": [str(b) for b in bodies], "security": security, "security_source": sec_src,
99
+ "deprecated": bool(op.get("deprecated")), "has_schemes": bool(schemes),
100
+ "internal_flag": bool(op.get("x-internal") or op.get("x-hidden")),
101
+ })
102
+ return ops
103
+
104
+
105
+ class OpenApiAnalyzer(Analyzer):
106
+ name = "openapi_analyzer"
107
+ depends_on = ["framework_detector"]
108
+ run_after = ["endpoint_analyzer", "html_analyzer"] # soft: client-call index is filled by these when enabled
109
+ produces_categories = ["api_spec", "api_operation", "undocumented_api", "endpoint"]
110
+
111
+ def analyze_project(self, ctx: ProjectContext) -> list[Candidate]:
112
+ out: list[Candidate] = []
113
+ for art in ctx.artifacts_of(KIND_OPENAPI):
114
+ doc = structured(art)
115
+ if not isinstance(doc, dict):
116
+ ctx.diagnostics.append(f"[openapi_analyzer] {art.path}: could not parse (YAML needs `pip install pyyaml`)")
117
+ continue
118
+ try:
119
+ out.extend(self._spec(art, doc, ctx))
120
+ except Exception as exc: # noqa: BLE001
121
+ ctx.diagnostics.append(f"[openapi_analyzer] {art.path}: {type(exc).__name__}: {exc}")
122
+ return out
123
+
124
+ # ------------------------------------------------------------------
125
+ def _spec(self, art: Artifact, doc: dict, ctx: ProjectContext) -> list[Candidate]:
126
+ out: list[Candidate] = []
127
+ fname = PurePosixPath(art.path.replace("\\", "/")).name
128
+ test = is_test_path(art.path)
129
+ penalty = [Evidence(ET.TEST_FIXTURE_CONTEXT, "spec lives in a test/fixture path", self.name)] if test else []
130
+ version = str(doc.get("openapi") or f"swagger {doc.get('swagger')}")
131
+ ops = parse_operations(doc)
132
+ bases, hosts = _bases(doc)
133
+ base0 = bases[0]
134
+ js = ctx.shared.get("js_endpoints") # None => endpoint_analyzer did not run: no client claims at all
135
+ client_known = bool(js) and bool(ctx.shared.get("endpoint_analyzer_ran"))
136
+
137
+ def full(op): # the path as it appears on the wire
138
+ return (base0 + op["path"]) if op["path"].startswith("/") else op["path"]
139
+
140
+ # ---- 1) the spec itself
141
+ server_urls = [str(s.get("url")) for s in doc.get("servers", []) or [] if isinstance(s, dict)] or hosts
142
+ internal_hosts = [h for h in hosts + server_urls if any(t in h.lower() for t in _INTERNAL_HOST)]
143
+ unauth_all = sum(1 for o in ops if o["security"] in ("undeclared", "none-explicit"))
144
+ spec_ev = [Evidence(ET.ARTIFACT_RESOLVED, f"OpenAPI document parsed ({version}, {len(ops)} operation(s))", self.name, art.path),
145
+ Evidence(ET.AST_PATTERN_MATCH, "structured parse of paths/servers/security", self.name, art.path)]
146
+ if internal_hosts:
147
+ spec_ev.append(Evidence(ET.NAMING_CONVENTION, "server list names a non-production host: " + ", ".join(internal_hosts[:3]), self.name, art.path))
148
+ out.append(Candidate(
149
+ category="api_spec", subject=f"{fname} ({version})", analyzer=self.name, evidence=spec_ev + penalty,
150
+ attack_surface="internal" if internal_hosts else None,
151
+ suggested_test=("The spec lists non-production servers (" + ", ".join(internal_hosts[:3]) + "): check whether they resolve and expose the same API with weaker controls."
152
+ if internal_hosts else None),
153
+ metadata={"file": art.path, "servers": server_urls, "operations": len(ops), "security_schemes_defined": bool(ops and ops[0]["has_schemes"]),
154
+ "ops_without_declared_auth": unauth_all}))
155
+
156
+ # ---- 2) match each operation against the client's calls
157
+ matches: dict[tuple[str, str], list[tuple[str, dict]]] = {} # (METHOD, spec path) -> [(js subject, info)]
158
+ by_path: dict[str, int] = {}
159
+ for o in ops:
160
+ by_path[o["path"]] = by_path.get(o["path"], 0) + 1
161
+ spec_segs = [(o, path_segments(full(o)), path_segments(o["path"])) for o in ops]
162
+ if client_known:
163
+ for subject, info in js.items():
164
+ host, jpath = split_origin(subject)
165
+ if host and host not in hosts:
166
+ continue # third-party API: not this spec's business
167
+ jsegs = path_segments(jpath)
168
+ best: dict[str, tuple[int, dict]] = {} # METHOD -> (specificity, op): literal beats {template}
169
+ for o, sfull, sraw in spec_segs:
170
+ segs = sfull if segs_match(jsegs, sfull) else sraw if (not host and segs_match(jsegs, sraw)) else None
171
+ if segs is None:
172
+ continue
173
+ ok = (o["method"] in info["reliable_methods"]
174
+ or (info["fetch"] and (o["method"] == "GET" or by_path[o["path"]] == 1))
175
+ or (not info["methods"] and by_path[o["path"]] == 1))
176
+ if not ok:
177
+ continue
178
+ spec_lit = sum(1 for a, b in zip(jsegs, segs) if a == b and a != "{}")
179
+ if o["method"] not in best or spec_lit > best[o["method"]][0]:
180
+ best[o["method"]] = (spec_lit, o)
181
+ for m, (_, o) in best.items():
182
+ matches.setdefault((o["method"], o["path"]), []).append((subject, info))
183
+
184
+ for o, sfull, _ in spec_segs:
185
+ wire = full(o)
186
+ sub = f"{o['method']} {wire}"
187
+ tag = classify(wire)
188
+ multipart = any("multipart" in c or "octet-stream" in c for c in o["content_types"])
189
+ idor = any(p and (p.lower().endswith("id") or p.lower() == "id") for p in o["path_params"])
190
+ explicit_open = o["security"] == "none-explicit"
191
+ undeclared = o["security"] == "undeclared"
192
+ auth_path = tag == "auth"
193
+ unauth_write = o["method"] in _WRITE and not auth_path and (explicit_open or undeclared)
194
+ hits = matches.get((o["method"], o["path"]), [])
195
+ referenced = bool(hits)
196
+ surface = tag or ("upload" if multipart else "idor_candidate" if idor else "unauthenticated_write" if unauth_write
197
+ else "deprecated_api" if o["deprecated"] else None)
198
+ hidden = bool(surface) and client_known and not referenced and surface not in ("deprecated_api",)
199
+
200
+ # --- enrichment of the client's own endpoint findings (agreement with endpoint_analyzer)
201
+ for subject, info in hits:
202
+ host, jpath = split_origin(subject)
203
+ ev = [Evidence(ET.ARTIFACT_RESOLVED, f"spec {fname} documents {sub}" + (f" (security: {o['security']})" if o["security"] != "undeclared" else " (no security declared)"),
204
+ self.name, art.path),
205
+ ]
206
+ out.append(Candidate(category="endpoint", subject=subject, analyzer=self.name, evidence=ev + penalty,
207
+ metadata={"spec_operation": sub, "spec_security": o["security"], "spec_file": art.path,
208
+ "spec_deprecated": o["deprecated"]}))
209
+ ctx.graph.add_node(f"spec:{sub}", NodeKind.SPEC_OPERATION, sub, file=art.path, security=o["security"])
210
+ ctx.graph.add_node(f"endpoint:{subject}", NodeKind.ENDPOINT, subject)
211
+ ctx.graph.add_edge(f"spec:{sub}", f"endpoint:{subject}", EdgeKind.DOCUMENTS, self.name, "ARTIFACT_RESOLVED", art.path)
212
+
213
+ # --- the operation itself, only when it is interesting
214
+ if surface is None and not (referenced and (explicit_open or o["deprecated"])):
215
+ continue
216
+ ev = [Evidence(ET.ARTIFACT_RESOLVED, f"operation defined in spec {fname}", self.name, art.path),
217
+ Evidence(ET.AST_PATTERN_MATCH, f"paths['{o['path']}'].{o['method'].lower()} parsed", self.name, art.path)]
218
+ if tag:
219
+ ev.append(Evidence(ET.NAMING_CONVENTION, f"path matches '{tag}' naming convention", self.name))
220
+ if explicit_open:
221
+ ev.append(Evidence(ET.LITERAL_STRING_MATCH, "operation declares `security: []` (explicitly public)", self.name, art.path))
222
+ tests = []
223
+ if explicit_open and o["method"] in _WRITE:
224
+ tests.append("The spec itself marks this write operation as public: call it with no credentials and check it really is intended.")
225
+ elif undeclared and o["method"] in _WRITE and not auth_path:
226
+ tests.append("No security requirement is declared for this write operation (the spec may simply omit it): call it unauthenticated.")
227
+ if multipart:
228
+ tests.append("File-accepting operation: test type/size/filename handling and stored-content execution.")
229
+ if idor:
230
+ tests.append(f"Path parameter(s) {', '.join(p for p in o['path_params'] if p)} look like object IDs: swap in another account's IDs (BOLA/IDOR).")
231
+ if o["deprecated"]:
232
+ tests.append("Deprecated operations are often left running without current security fixes.")
233
+ if hidden:
234
+ unresolved = ctx.shared.get("unresolved_client_calls", 0)
235
+ tests.append("No resolvable client call matches this operation, so the UI may never expose it and authorization is often an afterthought: test it directly with a low-privilege and an unauthenticated session."
236
+ + (f" Caveat: {unresolved} client request call(s) have URLs JScope could not resolve, so a dynamic caller is possible." if unresolved else ""))
237
+ out.append(Candidate(
238
+ category="api_operation", subject=sub, analyzer=self.name, evidence=ev + penalty, attack_surface=surface,
239
+ suggested_test=" ".join(tests) or None,
240
+ metadata={"spec_file": art.path, "operation_id": o["operation_id"], "tags": o["tags"], "security": o["security"],
241
+ "path_params": o["path_params"], "content_types": o["content_types"], "deprecated": o["deprecated"],
242
+ "client_referenced": (referenced if client_known else None),
243
+ "client_unresolved_calls": ctx.shared.get("unresolved_client_calls", 0),
244
+ **({"absence_based": True} if surface == "unauthenticated_write" and undeclared and not explicit_open else {})}))
245
+
246
+ # ---- 3) client calls the spec does not document (only inside an API family the spec does document)
247
+ if client_known:
248
+ prefixes = {tuple(path_segments(b)) for b in bases if b}
249
+ first_segs = {s[0] for _, s, _ in spec_segs if s and s[0] != "{}"}
250
+ for subject, info in sorted(js.items()):
251
+ host, jpath = split_origin(subject)
252
+ if (host and host not in hosts) or is_static_asset(jpath):
253
+ continue
254
+ jsegs = path_segments(jpath)
255
+ if not jsegs:
256
+ continue
257
+ in_family = any(tuple(jsegs[:len(p)]) == p for p in prefixes) if prefixes else jsegs[0] in first_segs
258
+ if not in_family:
259
+ continue
260
+ path_documented = any(segs_match(jsegs, sf) or (not host and segs_match(jsegs, sr)) for _, sf, sr in spec_segs)
261
+ if path_documented:
262
+ continue
263
+ siblings = [o for o, sf, _ in spec_segs if sf[:max(1, len(jsegs) - 1)] == jsegs[:max(1, len(jsegs) - 1)]]
264
+ if not siblings and not prefixes:
265
+ siblings = [o for o, sf, _ in spec_segs if sf and sf[0] == jsegs[0]]
266
+ if not siblings and prefixes:
267
+ siblings = [o for o, sf, _ in spec_segs][:3]
268
+ site = info["sites"][0] if info["sites"] else (None, None)
269
+ tag = classify(jpath)
270
+ out.append(Candidate(
271
+ category="undocumented_api", subject=subject, analyzer=self.name,
272
+ evidence=[Evidence(ET.ARTIFACT_RESOLVED, f"spec {fname} documents {len(spec_segs)} operation(s) in this API family, none matching this path", self.name, art.path),
273
+ Evidence(ET.AST_PATTERN_MATCH, "client code calls this path", self.name, site[0], site[1])]
274
+ + ([Evidence(ET.NAMING_CONVENTION, f"path matches '{tag}' naming convention", self.name)] if tag else []) + penalty,
275
+ attack_surface="undocumented_api",
276
+ suggested_test="The client uses an endpoint the API spec omits. Undocumented routes (internal, legacy, beta) tend to get less review: test authentication, authorization and input handling, and compare against documented siblings.",
277
+ metadata={"spec_file": art.path, "sites": [f"{f}:{l}" for f, l in info["sites"][:5]]}))
278
+ return out