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,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)."""
|