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,205 @@
|
|
|
1
|
+
"""Module resolution (FR-02): maps an import specifier to a file that is part of the analyzed project.
|
|
2
|
+
|
|
3
|
+
Supported, in order: relative paths (extension probing incl. the TypeScript ESM habit of writing `./x.js` for `x.ts`,
|
|
4
|
+
and `dir/index.*`), tsconfig/jsconfig `baseUrl` + `paths` (wildcards, `extends` chain), explicit `module_aliases` from
|
|
5
|
+
the JScope config (bundler aliases such as `@` -> `src`). Bare package specifiers resolve to nothing and are reported as
|
|
6
|
+
EXTERNAL; anything else that fails is reported as UNRESOLVED with a reason. Resolution never touches the network and
|
|
7
|
+
never leaves the set of files given to the analyzer.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
PROBE_EXTS = (".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue")
|
|
19
|
+
_JS_TO_TS = {".js": (".ts", ".tsx"), ".jsx": (".tsx",), ".mjs": (".mts",), ".cjs": (".cts",)}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Resolution:
|
|
24
|
+
path: str | None
|
|
25
|
+
status: str # "resolved" | "external" | "unresolved"
|
|
26
|
+
reason: str = ""
|
|
27
|
+
via: str = "relative" # relative | tsconfig-paths | tsconfig-baseUrl | config-alias
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _strip_jsonc(text: str) -> str:
|
|
31
|
+
"""tsconfig.json is JSON-with-comments-and-trailing-commas. Strip both without touching string contents."""
|
|
32
|
+
out, i, n, in_str, esc = [], 0, len(text), False, False
|
|
33
|
+
while i < n:
|
|
34
|
+
c = text[i]
|
|
35
|
+
if in_str:
|
|
36
|
+
out.append(c)
|
|
37
|
+
if esc:
|
|
38
|
+
esc = False
|
|
39
|
+
elif c == "\\":
|
|
40
|
+
esc = True
|
|
41
|
+
elif c == '"':
|
|
42
|
+
in_str = False
|
|
43
|
+
i += 1
|
|
44
|
+
continue
|
|
45
|
+
if c == '"':
|
|
46
|
+
in_str = True
|
|
47
|
+
out.append(c)
|
|
48
|
+
elif text.startswith("//", i):
|
|
49
|
+
while i < n and text[i] != "\n":
|
|
50
|
+
i += 1
|
|
51
|
+
continue
|
|
52
|
+
elif text.startswith("/*", i):
|
|
53
|
+
j = text.find("*/", i + 2)
|
|
54
|
+
i = n if j < 0 else j + 2
|
|
55
|
+
continue
|
|
56
|
+
else:
|
|
57
|
+
out.append(c)
|
|
58
|
+
i += 1
|
|
59
|
+
return re.sub(r",(\s*[}\]])", r"\1", "".join(out))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class TsConfig:
|
|
64
|
+
directory: str
|
|
65
|
+
base_url: str | None = None # absolute directory
|
|
66
|
+
paths: dict[str, list[str]] = field(default_factory=dict)
|
|
67
|
+
paths_base: str | None = None # directory `paths` targets are relative to
|
|
68
|
+
source: str = ""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def load_tsconfig(path: Path, _seen: set[str] | None = None) -> TsConfig | None:
|
|
72
|
+
_seen = _seen or set()
|
|
73
|
+
key = str(path.resolve())
|
|
74
|
+
if key in _seen:
|
|
75
|
+
return None
|
|
76
|
+
_seen.add(key)
|
|
77
|
+
try:
|
|
78
|
+
raw = json.loads(_strip_jsonc(path.read_text(encoding="utf-8", errors="replace")))
|
|
79
|
+
except (OSError, ValueError):
|
|
80
|
+
return None
|
|
81
|
+
cfg = TsConfig(directory=str(path.parent), source=str(path))
|
|
82
|
+
ext = raw.get("extends")
|
|
83
|
+
if isinstance(ext, str) and ext.startswith("."):
|
|
84
|
+
parent = (path.parent / ext)
|
|
85
|
+
for cand in (parent, parent.with_suffix(".json")):
|
|
86
|
+
if cand.is_file():
|
|
87
|
+
base = load_tsconfig(cand, _seen)
|
|
88
|
+
if base:
|
|
89
|
+
cfg.base_url, cfg.paths, cfg.paths_base = base.base_url, dict(base.paths), base.paths_base
|
|
90
|
+
break
|
|
91
|
+
co = raw.get("compilerOptions") or {}
|
|
92
|
+
if isinstance(co.get("baseUrl"), str):
|
|
93
|
+
cfg.base_url = str((path.parent / co["baseUrl"]).resolve())
|
|
94
|
+
if isinstance(co.get("paths"), dict):
|
|
95
|
+
cfg.paths = {k: [x for x in v if isinstance(x, str)] for k, v in co["paths"].items() if isinstance(v, list)}
|
|
96
|
+
cfg.paths_base = cfg.base_url or str(path.parent.resolve())
|
|
97
|
+
return cfg
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ModuleResolver:
|
|
101
|
+
def __init__(self, files: list[str], aliases: dict[str, str] | None = None, roots: list[str] | None = None):
|
|
102
|
+
self.files = {os.path.normpath(os.path.abspath(f)): f for f in files}
|
|
103
|
+
self.aliases = dict(aliases or {})
|
|
104
|
+
self._tscache: dict[str, TsConfig | None] = {}
|
|
105
|
+
dirs = [os.path.dirname(a) for a in self.files] or ["."]
|
|
106
|
+
self.root = os.path.commonpath(dirs) if dirs else "."
|
|
107
|
+
self.search_roots = [os.path.abspath(r) for r in (roots or [])]
|
|
108
|
+
self.tsconfig_files: list[str] = []
|
|
109
|
+
|
|
110
|
+
# -- tsconfig discovery ----------------------------------------------------------------------
|
|
111
|
+
def tsconfig_for(self, file: str) -> TsConfig | None:
|
|
112
|
+
d = os.path.dirname(os.path.abspath(file))
|
|
113
|
+
ceiling = self.root
|
|
114
|
+
for _ in range(4): # allow the config to sit a few levels above the analyzed folder
|
|
115
|
+
ceiling = os.path.dirname(ceiling) or ceiling
|
|
116
|
+
return self.tsconfig_for_dir(d, ceiling)
|
|
117
|
+
|
|
118
|
+
def tsconfig_for_dir(self, d: str, ceiling: str) -> TsConfig | None:
|
|
119
|
+
while True:
|
|
120
|
+
if d in self._tscache:
|
|
121
|
+
return self._tscache[d]
|
|
122
|
+
for name in ("tsconfig.json", "jsconfig.json"):
|
|
123
|
+
p = Path(d) / name
|
|
124
|
+
if p.is_file():
|
|
125
|
+
cfg = load_tsconfig(p)
|
|
126
|
+
if cfg:
|
|
127
|
+
self.tsconfig_files.append(str(p))
|
|
128
|
+
self._tscache[d] = cfg
|
|
129
|
+
return cfg
|
|
130
|
+
if d == ceiling or d == os.path.dirname(d):
|
|
131
|
+
self._tscache[d] = None
|
|
132
|
+
return None
|
|
133
|
+
d = os.path.dirname(d)
|
|
134
|
+
|
|
135
|
+
# -- probing ---------------------------------------------------------------------------------
|
|
136
|
+
def _probe(self, base: str) -> str | None:
|
|
137
|
+
base = os.path.normpath(base)
|
|
138
|
+
if base in self.files:
|
|
139
|
+
return self.files[base]
|
|
140
|
+
stem, ext = os.path.splitext(base)
|
|
141
|
+
for alt in _JS_TO_TS.get(ext, ()): # ./x.js written in TS source really means ./x.ts
|
|
142
|
+
if stem + alt in self.files:
|
|
143
|
+
return self.files[stem + alt]
|
|
144
|
+
for e in PROBE_EXTS:
|
|
145
|
+
if base + e in self.files:
|
|
146
|
+
return self.files[base + e]
|
|
147
|
+
for e in PROBE_EXTS:
|
|
148
|
+
idx = os.path.join(base, "index" + e)
|
|
149
|
+
if idx in self.files:
|
|
150
|
+
return self.files[idx]
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
def resolve(self, spec: str, from_file: str) -> Resolution:
|
|
154
|
+
if not spec:
|
|
155
|
+
return Resolution(None, "unresolved", "empty specifier")
|
|
156
|
+
spec = spec.split("?")[0]
|
|
157
|
+
fdir = os.path.dirname(os.path.abspath(from_file))
|
|
158
|
+
if spec.startswith((".", "/")):
|
|
159
|
+
base = spec if spec.startswith("/") else os.path.join(fdir, spec)
|
|
160
|
+
hit = self._probe(base)
|
|
161
|
+
if hit:
|
|
162
|
+
return Resolution(hit, "resolved", via="relative")
|
|
163
|
+
return Resolution(None, "unresolved", f"no file matches '{spec}' relative to {os.path.basename(fdir) or '.'}/")
|
|
164
|
+
tried: list[str] = []
|
|
165
|
+
cfg = self.tsconfig_for(from_file)
|
|
166
|
+
if cfg and cfg.paths:
|
|
167
|
+
for pattern, targets in cfg.paths.items():
|
|
168
|
+
m = self._match_pattern(pattern, spec)
|
|
169
|
+
if m is None:
|
|
170
|
+
continue
|
|
171
|
+
for t in targets:
|
|
172
|
+
cand = os.path.join(cfg.paths_base or cfg.directory, t.replace("*", m))
|
|
173
|
+
tried.append(cand)
|
|
174
|
+
hit = self._probe(cand)
|
|
175
|
+
if hit:
|
|
176
|
+
return Resolution(hit, "resolved", via="tsconfig-paths")
|
|
177
|
+
for alias, target in self.aliases.items():
|
|
178
|
+
if spec == alias or spec.startswith(alias.rstrip("/") + "/"):
|
|
179
|
+
rest = spec[len(alias):].lstrip("/")
|
|
180
|
+
cand = os.path.join(target if os.path.isabs(target) else os.path.join(self.root, target), rest) \
|
|
181
|
+
if rest else (target if os.path.isabs(target) else os.path.join(self.root, target))
|
|
182
|
+
tried.append(cand)
|
|
183
|
+
hit = self._probe(cand)
|
|
184
|
+
if hit:
|
|
185
|
+
return Resolution(hit, "resolved", via="config-alias")
|
|
186
|
+
if cfg and cfg.base_url:
|
|
187
|
+
cand = os.path.join(cfg.base_url, spec)
|
|
188
|
+
hit = self._probe(cand)
|
|
189
|
+
if hit:
|
|
190
|
+
return Resolution(hit, "resolved", via="tsconfig-baseUrl")
|
|
191
|
+
if tried:
|
|
192
|
+
return Resolution(None, "unresolved", f"alias matched but no file found (tried {len(tried)} location(s))")
|
|
193
|
+
if spec.startswith(("@/", "~/", "#")):
|
|
194
|
+
return Resolution(None, "unresolved", f"'{spec}' looks like a bundler alias but none is configured "
|
|
195
|
+
"(add tsconfig `paths` or config `module_aliases`)")
|
|
196
|
+
return Resolution(None, "external", "package import (not part of the analyzed files)")
|
|
197
|
+
|
|
198
|
+
@staticmethod
|
|
199
|
+
def _match_pattern(pattern: str, spec: str) -> str | None:
|
|
200
|
+
if "*" not in pattern:
|
|
201
|
+
return "" if pattern == spec else None
|
|
202
|
+
pre, post = pattern.split("*", 1)
|
|
203
|
+
if spec.startswith(pre) and spec.endswith(post) and len(spec) >= len(pre) + len(post):
|
|
204
|
+
return spec[len(pre):len(spec) - len(post) if post else len(spec)]
|
|
205
|
+
return None
|
jscope/dataflow/rules.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Sources, sinks, sanitizers and validators for the data-flow engine (FR-01).
|
|
2
|
+
|
|
3
|
+
Everything the engine believes about the web platform lives here as plain data so it can be reviewed and extended
|
|
4
|
+
without touching the engine. Sink *classes* group sinks that are neutralised by the same kind of sanitizer:
|
|
5
|
+
html DOM HTML insertion / client-side HTML rendering
|
|
6
|
+
code dynamic code evaluation / script loading
|
|
7
|
+
navigation location changes, window.open
|
|
8
|
+
sql database query text
|
|
9
|
+
command OS command execution
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
SINK_CLASSES = ("html", "code", "navigation", "sql", "command")
|
|
18
|
+
ALL = frozenset(SINK_CLASSES)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Mitigation:
|
|
23
|
+
"""A sanitizer / validator applied to a value. `effective` classes are neutralised, `uncertain` classes MAY be,
|
|
24
|
+
anything else is untouched (an ineffective mitigation is recorded for the report but changes nothing)."""
|
|
25
|
+
name: str
|
|
26
|
+
effective: frozenset = frozenset()
|
|
27
|
+
uncertain: frozenset = frozenset()
|
|
28
|
+
note: str = ""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------------------------------- sources
|
|
32
|
+
URL_MEMBER = re.compile(r"^(?:(?:window|self|globalThis|top|parent)\.)?(?:document\.)?location\.(hash|search|href|pathname)$")
|
|
33
|
+
SOURCE_DOTTED = {
|
|
34
|
+
"document.URL": ("url", "document.URL"), "document.documentURI": ("url", "document.documentURI"),
|
|
35
|
+
"document.referrer": ("referrer", "document.referrer"), "document.cookie": ("cookie", "document.cookie"),
|
|
36
|
+
"window.name": ("window_name", "window.name"), "name": None,
|
|
37
|
+
}
|
|
38
|
+
URL_KIND = {"hash": "url_hash", "search": "url_query", "href": "url", "pathname": "url_path"}
|
|
39
|
+
STORAGE_OBJECTS = {"localStorage", "sessionStorage"}
|
|
40
|
+
FORM_CALL_RE = re.compile(r"(getElementById|querySelector|querySelectorAll|getElementsBy\w+|forms)\b")
|
|
41
|
+
NETWORK_CALLEES = {"fetch", "axios", "$http.get", "$http.post", "$.get", "$.post", "$.getJSON", "ky", "got"}
|
|
42
|
+
NETWORK_PREFIX = ("axios.",)
|
|
43
|
+
HUMAN_SOURCE = {
|
|
44
|
+
"url_hash": "URL fragment (location.hash)", "url_query": "URL query string (location.search)",
|
|
45
|
+
"url": "page URL", "url_path": "URL path", "referrer": "document.referrer", "cookie": "document.cookie",
|
|
46
|
+
"window_name": "window.name", "postmessage": "postMessage event data", "storage": "web storage",
|
|
47
|
+
"form_input": "user-controlled form input", "network_response": "network response body",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------------------------------- sinks
|
|
51
|
+
HTML_ASSIGN_PROPS = {"innerHTML": "innerHTML", "outerHTML": "outerHTML", "srcdoc": "iframe.srcdoc"}
|
|
52
|
+
CODE_ASSIGN_PROPS = {"src": "script.src (only when the element is a <script>)", "text": None}
|
|
53
|
+
NAV_ASSIGN_DOTTED = re.compile(r"^(?:(?:window|self|top)\.)?(?:document\.)?location(?:\.href)?$")
|
|
54
|
+
JQUERY_HTML_METHODS = {"html", "append", "prepend", "after", "before", "replaceWith", "wrap", "wrapAll", "appendTo", "prependTo"}
|
|
55
|
+
JQUERY_RECEIVER = re.compile(r"^(?:\$|jQuery)\(")
|
|
56
|
+
DB_PACKAGES = {"mysql", "mysql2", "pg", "sqlite3", "better-sqlite3", "mssql", "knex", "sequelize", "typeorm", "oracledb",
|
|
57
|
+
"pg-promise", "slonik", "@prisma/client", "mysql2/promise", "sql.js", "alasql"}
|
|
58
|
+
DB_METHODS = {"query", "execute", "raw", "queryRaw", "$queryRawUnsafe", "$executeRawUnsafe", "exec", "run", "all", "get"}
|
|
59
|
+
CMD_PACKAGES = {"child_process", "node:child_process", "execa", "shelljs", "cross-spawn"}
|
|
60
|
+
CMD_FUNCS = {"exec", "execSync", "spawn", "spawnSync", "execFile", "execFileSync", "fork", "shell.exec"}
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------------------------------- sanitizers
|
|
63
|
+
_HTML = frozenset({"html"})
|
|
64
|
+
SANITIZERS: list[tuple[re.Pattern, Mitigation]] = [
|
|
65
|
+
(re.compile(r"(^|\.)(dompurify\.)?sanitize$|^sanitizeHtml$|^xss$|(^|\.)filterXSS$", re.I),
|
|
66
|
+
Mitigation("HTML sanitizer (DOMPurify/sanitize-html/xss)", effective=_HTML)),
|
|
67
|
+
(re.compile(r"(^|\.)(escapeHtml|escapeHTML|escape)$|^he\.(encode|escape)$|validator\.escape$"),
|
|
68
|
+
Mitigation("HTML escaping", effective=_HTML, note="does not make a value safe as a URL, SQL or shell string")),
|
|
69
|
+
(re.compile(r"^(encodeURIComponent)$"),
|
|
70
|
+
Mitigation("encodeURIComponent", effective=_HTML, uncertain=frozenset({"navigation"}),
|
|
71
|
+
note="neutralises markup but is not a URL validator")),
|
|
72
|
+
(re.compile(r"^(encodeURI|escape|encodeURL)$"),
|
|
73
|
+
Mitigation("encodeURI", uncertain=_HTML, note="leaves < > \" ' ( ) intact")),
|
|
74
|
+
(re.compile(r"^(Number|parseInt|parseFloat|Boolean|BigInt)$|^Math\.(floor|ceil|round|abs|trunc)$|^Number\.parse(Int|Float)$"),
|
|
75
|
+
Mitigation("numeric coercion", effective=ALL, note="value can only be a number/boolean afterwards")),
|
|
76
|
+
(re.compile(r"(^|\.)quote$|^shellescape$|^escapeShellArg$"),
|
|
77
|
+
Mitigation("shell quoting", effective=frozenset({"command"}))),
|
|
78
|
+
]
|
|
79
|
+
# A distinct, narrow table for SQL escapers so `escape` on a random object does not count as SQL escaping.
|
|
80
|
+
DB_ESCAPERS = re.compile(r"(^|\.)(mysql|connection|pool|db|client|sqlstring|SqlString|pg)\.(escape|escapeId|escapeLiteral|format)$")
|
|
81
|
+
PARTIAL_REPLACE = Mitigation("string replace()", uncertain=frozenset({"html", "navigation", "code"}),
|
|
82
|
+
note="partial filtering by replace() is rarely complete")
|
|
83
|
+
# Methods that transform a string but do NOT neutralise it. Taint passes straight through.
|
|
84
|
+
PASS_METHODS = {"trim", "trimStart", "trimEnd", "toLowerCase", "toUpperCase", "slice", "substring", "substr", "split", "join",
|
|
85
|
+
"concat", "toString", "padStart", "padEnd", "repeat", "normalize", "at", "charAt", "json", "text", "blob",
|
|
86
|
+
"get", "getAll", "toJSON", "valueOf", "flat", "reverse", "sort", "pop", "shift", "values", "entries", "keys",
|
|
87
|
+
"decodeURIComponent", "decodeURI", "atob", "btoa", "stringify", "parse", "from", "assign", "freeze",
|
|
88
|
+
"clone", "cloneDeep", "map", "filter", "find", "reduce", "forEach", "some", "every", "flatMap", "entries"}
|
|
89
|
+
PASSTHROUGH_FUNCS = {"String", "decodeURIComponent", "decodeURI", "atob", "btoa", "JSON.parse", "JSON.stringify", "Object.assign",
|
|
90
|
+
"Object.values", "Object.entries", "Object.keys", "Array.from", "Array.of", "structuredClone",
|
|
91
|
+
"Object.freeze", "unescape", "Symbol", "Promise.resolve", "Promise.all", "Promise.race", "Promise.allSettled",
|
|
92
|
+
"ref", "reactive", "computed", "shallowRef", "toRef", "toRefs", "useState", "useMemo", "useRef", "useCallback",
|
|
93
|
+
"useReducer", "Array", "Object", "Buffer.from", "Reflect.get"}
|
|
94
|
+
PASSTHROUGH_CTORS = {"URLSearchParams", "URL", "String", "Array", "Object", "Set", "Map", "Buffer", "Blob", "Uint8Array", "FormData"}
|
|
95
|
+
CLEAN_FUNCS = {"Array.isArray", "isNaN", "Number.isInteger", "Number.isFinite", "Number.isNaN", "console.log", "console.error",
|
|
96
|
+
"console.warn", "console.info", "console.debug", "Date.now", "Math.random", "Math.max", "Math.min", "setState"}
|
|
97
|
+
|
|
98
|
+
# ---------------------------------------------------------------------------------------------------- validators
|
|
99
|
+
_SAFE_ATOM = r"(?:\[[A-Za-z0-9_\-\\wd.\s]+\]|\\w|\\d|[A-Za-z0-9_\-])"
|
|
100
|
+
_STRICT_RE = re.compile(r"^\^\(?(?:" + _SAFE_ATOM + r"(?:[+*]|\{\d+(?:,\d*)?\})?)+\)?\$$")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def regex_is_strict_allowlist(src: str) -> bool:
|
|
104
|
+
"""True for an anchored pattern built only from alphanumeric classes (`^[a-z0-9_-]+$`, `^\\d{1,6}$`).
|
|
105
|
+
No `.`, no negated class, no alternation: those can admit markup, `javascript:` or quotes. Anything we cannot
|
|
106
|
+
positively show is strict is reported as UNCERTAIN, never as effective."""
|
|
107
|
+
src = src.strip()
|
|
108
|
+
if "[^" in src or "|" in src or ".*" in src or ".+" in src:
|
|
109
|
+
return False
|
|
110
|
+
body = src.replace("\\.", "") # escaped dot is allowed only inside a class below
|
|
111
|
+
if "." in re.sub(r"\[[^\]]*\]", "", body):
|
|
112
|
+
return False
|
|
113
|
+
return bool(_STRICT_RE.match(src))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def sink_label(kind: str) -> str:
|
|
117
|
+
return {"html": "DOM HTML insertion", "code": "dynamic code execution", "navigation": "URL navigation",
|
|
118
|
+
"sql": "database query", "command": "OS command execution"}.get(kind, kind)
|
|
File without changes
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Download Mode: text file of URLs -> project directory (JS, HTML, specs, schemas, WASM).
|
|
2
|
+
Dedupes by content hash, preserves metadata (source URL, fetch time, status).
|
|
3
|
+
No analysis happens here (PRD §6: crawling/downloading produce no findings)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from urllib.parse import urlparse
|
|
13
|
+
|
|
14
|
+
import requests
|
|
15
|
+
from jscope.security_transport import GuardedSession, TransportPolicy, PolicyError, normalize_host
|
|
16
|
+
|
|
17
|
+
DEFAULT_TIMEOUT = 15
|
|
18
|
+
DEFAULT_UA = "JScope/0.4 (+https://github.com/jscope)"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DownloadError(Exception):
|
|
22
|
+
"""A user-facing failure (bad input file) -- never a traceback."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def extension_for(url: str, content: bytes, content_type: str = "") -> str:
|
|
26
|
+
"""Pick the saved file's extension from what the bytes ARE, so the analyzers' content sniffing sees the right kind."""
|
|
27
|
+
path = urlparse(url).path.lower()
|
|
28
|
+
ctype = content_type.lower()
|
|
29
|
+
if content[:4] == b"\x00asm":
|
|
30
|
+
return ".wasm"
|
|
31
|
+
if path.endswith((".js", ".mjs", ".cjs", ".jsx")) or "javascript" in ctype:
|
|
32
|
+
return ".js"
|
|
33
|
+
head = content[:512].lstrip().lower()
|
|
34
|
+
if "html" in ctype or head.startswith((b"<!doctype html", b"<html", b"<head", b"<body")):
|
|
35
|
+
return ".html"
|
|
36
|
+
if path.endswith((".yaml", ".yml")) or "yaml" in ctype:
|
|
37
|
+
return ".yaml"
|
|
38
|
+
if path.endswith((".graphql", ".gql", ".graphqls")):
|
|
39
|
+
return ".graphql"
|
|
40
|
+
if "json" in ctype or head.startswith((b"{", b"[")):
|
|
41
|
+
return ".json"
|
|
42
|
+
return ".js"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class DownloadResult:
|
|
47
|
+
url: str
|
|
48
|
+
ok: bool
|
|
49
|
+
path: str | None = None
|
|
50
|
+
error: str | None = None
|
|
51
|
+
status_code: int | None = None
|
|
52
|
+
duplicate_of: str | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def download_urls(url_file: str, out_dir: str, timeout: int = DEFAULT_TIMEOUT,
|
|
56
|
+
user_agent: str = DEFAULT_UA, policy: TransportPolicy | None = None) -> list[DownloadResult]:
|
|
57
|
+
if policy is None:
|
|
58
|
+
raise DownloadError("network downloads require an explicit assessment TransportPolicy; refusing unguarded URL-list downloads")
|
|
59
|
+
try:
|
|
60
|
+
text = Path(url_file).read_text()
|
|
61
|
+
except OSError as exc:
|
|
62
|
+
raise DownloadError(f"cannot read URL list {url_file!r}: {exc.strerror or exc}. "
|
|
63
|
+
f"Create it first with `jscope crawl <url> --out {url_file}`.") from exc
|
|
64
|
+
urls = [
|
|
65
|
+
line.strip() for line in text.splitlines()
|
|
66
|
+
if line.strip() and not line.strip().startswith("#")
|
|
67
|
+
]
|
|
68
|
+
urls = list(dict.fromkeys(urls)) # de-dupe identical URL strings, preserve order
|
|
69
|
+
|
|
70
|
+
out_path = Path(out_dir)
|
|
71
|
+
out_path.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
meta_path = out_path / "_jscope_meta.json"
|
|
73
|
+
meta: dict[str, dict] = {}
|
|
74
|
+
seen_hashes: dict[str, str] = {} # content_hash -> first filename
|
|
75
|
+
results: list[DownloadResult] = []
|
|
76
|
+
|
|
77
|
+
session = GuardedSession(policy)
|
|
78
|
+
session.session.headers.update({"User-Agent": user_agent})
|
|
79
|
+
|
|
80
|
+
for i, url in enumerate(urls):
|
|
81
|
+
try:
|
|
82
|
+
resp = session.get(url, timeout=timeout)
|
|
83
|
+
except (requests.RequestException, PolicyError) as e:
|
|
84
|
+
results.append(DownloadResult(url=url, ok=False, error=str(e)))
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
if resp.status_code != 200:
|
|
88
|
+
results.append(DownloadResult(url=url, ok=False, status_code=resp.status_code,
|
|
89
|
+
error=f"HTTP {resp.status_code}"))
|
|
90
|
+
resp.close()
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
content = resp.content
|
|
94
|
+
resp.close()
|
|
95
|
+
content_hash = hashlib.sha256(content).hexdigest()[:16]
|
|
96
|
+
|
|
97
|
+
if content_hash in seen_hashes:
|
|
98
|
+
results.append(DownloadResult(url=url, ok=True, duplicate_of=seen_hashes[content_hash]))
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
safe_name = f"{i:05d}_{content_hash}{extension_for(url, content, resp.headers.get('Content-Type', ''))}"
|
|
102
|
+
(out_path / safe_name).write_bytes(content)
|
|
103
|
+
seen_hashes[content_hash] = safe_name
|
|
104
|
+
meta[safe_name] = {
|
|
105
|
+
"source_url": url,
|
|
106
|
+
"fetched_at": time.time(),
|
|
107
|
+
"status_code": resp.status_code,
|
|
108
|
+
"content_hash": content_hash,
|
|
109
|
+
"bytes": len(content),
|
|
110
|
+
}
|
|
111
|
+
results.append(DownloadResult(url=url, ok=True, path=str(out_path / safe_name),
|
|
112
|
+
status_code=resp.status_code))
|
|
113
|
+
|
|
114
|
+
meta_path.write_text(json.dumps(meta, indent=2))
|
|
115
|
+
session.close()
|
|
116
|
+
return results
|
|
File without changes
|