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