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,176 @@
1
+ """Crawl Mode: discover a site's client-side artifacts from a live URL. Download-only, no analysis (PRD section 6).
2
+
3
+ Phase 4 widened this from "JavaScript only" to everything the analyzers can read:
4
+ - the page itself (HTML: forms, inline scripts) kind=html
5
+ - <script src>, static imports inside discovered JS (one hop), manifests kind=js
6
+ - *.wasm references found in the page or in discovered JS kind=wasm
7
+ - well-known API-description paths (openapi.json, swagger.json, /v3/api-docs, schema.graphql ...), GET only.
8
+ A probe is kept ONLY if the response really is an OpenAPI document or GraphQL schema, so single-page apps that answer
9
+ 200/HTML for every path do not litter the project with junk. Disable probing with probe=False (`--no-probe`).
10
+
11
+ Limits, stated honestly: dynamic import() paths built at runtime are not resolved; GraphQL introspection is a POST and is
12
+ never done here; only the one page you name is fetched (no link following).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import re
19
+ import time
20
+ from dataclasses import dataclass
21
+ from urllib.parse import urljoin, urlparse
22
+
23
+ import requests
24
+ from bs4 import BeautifulSoup
25
+
26
+ from jscope.core.artifacts import KIND_GRAPHQL_SCHEMA, KIND_OPENAPI, classify_artifact
27
+ from jscope.security_transport import GuardedSession, TransportPolicy, normalize_host, PolicyError
28
+
29
+ DEFAULT_UA = "JScope/0.4 (+https://github.com/jscope)"
30
+
31
+ _STATIC_IMPORT_RE = re.compile(r"""import\s*(?:[^'"]*from\s*)?['"]([^'"]+\.js[^'"]*)['"]""")
32
+ _DYNAMIC_IMPORT_RE = re.compile(r"""import\(\s*['"]([^'"]+\.js[^'"]*)['"]\s*\)""")
33
+ _WASM_RE = re.compile(r"""['"(=\s]([A-Za-z0-9_@./%+-]+\.wasm)(?:\?[^'")\s]*)?""")
34
+
35
+ WELL_KNOWN_SPECS = (
36
+ "/openapi.json", "/openapi.yaml", "/swagger.json", "/swagger.yaml", "/v3/api-docs", "/v2/api-docs", "/api-docs",
37
+ "/api/openapi.json", "/api/swagger.json", "/api/v1/openapi.json", "/swagger/v1/swagger.json",
38
+ "/schema.graphql", "/schema.gql", "/graphql/schema.graphql",
39
+ )
40
+ _RETRY_STATUS = (502, 503, 504)
41
+ _MAX_PROBE_BYTES = 8 * 1024 * 1024
42
+
43
+
44
+ class CrawlError(Exception):
45
+ """A user-facing failure (unreachable site, HTTP error) -- never a traceback."""
46
+
47
+
48
+ @dataclass
49
+ class DiscoveredAsset:
50
+ url: str
51
+ source: str # "page" | "script_tag" | "module_import" | "manifest" | "wasm_reference" | "well_known_path"
52
+ kind: str = "js" # "html" | "js" | "wasm" | "openapi" | "graphql_schema"
53
+
54
+
55
+ def _get(session, url: str, timeout: int, retries: int = 2) -> requests.Response:
56
+ """GET with a short retry for flaky/overloaded sites (connection errors and 502/503/504)."""
57
+ last: Exception | None = None
58
+ for attempt in range(retries + 1):
59
+ try:
60
+ resp = session.get(url, timeout=timeout)
61
+ except requests.RequestException as exc:
62
+ last = exc
63
+ else:
64
+ if resp.status_code in _RETRY_STATUS and attempt < retries:
65
+ time.sleep(1.5 * (attempt + 1))
66
+ continue
67
+ return resp
68
+ if attempt < retries:
69
+ time.sleep(1.5 * (attempt + 1))
70
+ raise CrawlError(f"could not reach {url}: {type(last).__name__}: {last}")
71
+
72
+
73
+ def _explain_status(status: int) -> str:
74
+ if status in _RETRY_STATUS:
75
+ return "the site is down or overloaded right now, or it is rejecting automated requests; try again later and confirm it loads in a browser"
76
+ if status in (401, 403):
77
+ return "the site refuses this request (login wall, WAF or bot protection); JScope does not bypass that"
78
+ if status == 404:
79
+ return "no page at that URL; check the address"
80
+ if status == 429:
81
+ return "rate limited; wait and retry"
82
+ return "unexpected response"
83
+
84
+
85
+ def _probe_kind(path: str, data: bytes) -> str | None:
86
+ low = path.lower()
87
+ ext = ".yaml" if low.endswith((".yaml", ".yml")) else ".graphql" if low.endswith((".graphql", ".gql")) else ".json"
88
+ kind = classify_artifact("probe" + ext, data)
89
+ return kind if kind in (KIND_OPENAPI, KIND_GRAPHQL_SCHEMA) else None
90
+
91
+
92
+ def discover(target_url: str, timeout: int = 15, user_agent: str = DEFAULT_UA, probe: bool = True, policy: TransportPolicy | None = None) -> list[DiscoveredAsset]:
93
+ if not urlparse(target_url).scheme:
94
+ target_url = "https://" + target_url
95
+ if policy is None:
96
+ parsed = urlparse(target_url)
97
+ if not parsed.hostname:
98
+ raise CrawlError("target URL has no valid hostname")
99
+ # Default scope is the explicitly supplied target host; expansion requires a caller-supplied policy.
100
+ policy = TransportPolicy(hosts={normalize_host(parsed.hostname)}, assessment_id="crawl-" + normalize_host(parsed.hostname), timeout=timeout, max_response_bytes=_MAX_PROBE_BYTES, total_budget=100, retries=0)
101
+ session = GuardedSession(policy)
102
+ session.session.headers.update({"User-Agent": user_agent})
103
+ found: dict[str, DiscoveredAsset] = {}
104
+
105
+ try:
106
+ resp = _get(session, target_url, timeout)
107
+ except PolicyError as exc:
108
+ raise CrawlError(f"request blocked by scope policy: {exc}") from exc
109
+ if resp.status_code >= 400:
110
+ raise CrawlError(f"{target_url} answered HTTP {resp.status_code}: {_explain_status(resp.status_code)}")
111
+ final_url = resp.url or target_url
112
+ found[final_url] = DiscoveredAsset(url=final_url, source="page", kind="html")
113
+ soup = BeautifulSoup(resp.text, "html.parser")
114
+
115
+ for tag in soup.find_all("script"):
116
+ src = tag.get("src")
117
+ if src:
118
+ full = urljoin(final_url, src)
119
+ found.setdefault(full, DiscoveredAsset(url=full, source="script_tag"))
120
+
121
+ for manifest_path in ("/asset-manifest.json", "/manifest.json"): # CRA/webpack convention
122
+ try:
123
+ m_resp = session.get(urljoin(final_url, manifest_path), timeout=timeout)
124
+ if m_resp.ok and "json" in m_resp.headers.get("content-type", ""):
125
+ for v in _walk_strings(m_resp.json()):
126
+ if v.endswith(".js"):
127
+ full = urljoin(final_url, v)
128
+ found.setdefault(full, DiscoveredAsset(url=full, source="manifest"))
129
+ except (requests.RequestException, ValueError):
130
+ pass
131
+
132
+ for m in _WASM_RE.finditer(resp.text):
133
+ full = urljoin(final_url, m.group(1))
134
+ found.setdefault(full, DiscoveredAsset(url=full, source="wasm_reference", kind="wasm"))
135
+
136
+ for js_url in [a.url for a in found.values() if a.kind == "js"]: # one hop into discovered JS
137
+ try:
138
+ js_resp = session.get(js_url, timeout=timeout)
139
+ if not js_resp.ok:
140
+ continue
141
+ except requests.RequestException:
142
+ continue
143
+ for pattern in (_STATIC_IMPORT_RE, _DYNAMIC_IMPORT_RE):
144
+ for m in pattern.finditer(js_resp.text):
145
+ full = urljoin(js_url, m.group(1))
146
+ found.setdefault(full, DiscoveredAsset(url=full, source="module_import"))
147
+ for m in _WASM_RE.finditer(js_resp.text):
148
+ full = urljoin(js_url, m.group(1))
149
+ found.setdefault(full, DiscoveredAsset(url=full, source="wasm_reference", kind="wasm"))
150
+
151
+ if probe:
152
+ base = f"{urlparse(final_url).scheme}://{urlparse(final_url).netloc}"
153
+ for path in WELL_KNOWN_SPECS:
154
+ url = base + path
155
+ try:
156
+ r = session.get(url, timeout=timeout)
157
+ except requests.RequestException:
158
+ continue
159
+ if r.status_code != 200 or len(r.content) > _MAX_PROBE_BYTES:
160
+ continue
161
+ kind = _probe_kind(path, r.content)
162
+ if kind:
163
+ found.setdefault(url, DiscoveredAsset(url=url, source="well_known_path", kind=kind))
164
+ session.close()
165
+ return list(found.values())
166
+
167
+
168
+ def _walk_strings(obj):
169
+ if isinstance(obj, str):
170
+ yield obj
171
+ elif isinstance(obj, dict):
172
+ for v in obj.values():
173
+ yield from _walk_strings(v)
174
+ elif isinstance(obj, list):
175
+ for v in obj:
176
+ yield from _walk_strings(v)
@@ -0,0 +1 @@
1
+ """Interprocedural data-flow engine (JSC-PRD-NEW-001 FR-01) and module resolution (FR-02)."""