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
jscope/cli.py
ADDED
|
@@ -0,0 +1,1241 @@
|
|
|
1
|
+
"""JScope CLI. CLI-native, scriptable (PRD §4). Subcommands map 1:1 to the
|
|
2
|
+
operating modes in PRD §6: crawl, download, analyze, scan, report, explain."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import socket
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from urllib.parse import urlparse
|
|
14
|
+
|
|
15
|
+
from jscope.config import Config
|
|
16
|
+
from jscope.core.evidence import Finding
|
|
17
|
+
from jscope.core.graph import KnowledgeGraph
|
|
18
|
+
from jscope import __version__
|
|
19
|
+
from jscope.pipeline import run_analysis
|
|
20
|
+
from jscope.reporting import human, json_report
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _emit(text: str, output: str | None) -> None:
|
|
24
|
+
if output:
|
|
25
|
+
with open(output, "w", encoding="utf-8") as f:
|
|
26
|
+
f.write(text if text.endswith("\n") else text + "\n")
|
|
27
|
+
print(f"[wrote {output}]", file=sys.stderr)
|
|
28
|
+
else:
|
|
29
|
+
print(text)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _print_report(res, fmt: str, verbose: bool, output: str | None = None):
|
|
33
|
+
if fmt in ("markdown", "md", "html", "sarif"):
|
|
34
|
+
from jscope.reporting.formats import render_payload
|
|
35
|
+
_emit(render_payload(json_report.payload(res.findings, res.frameworks, res.file_count, res.graph, res.diagnostics,
|
|
36
|
+
dataflow=res.dataflow, file_diagnostics=res.file_diagnostics), fmt), output)
|
|
37
|
+
return
|
|
38
|
+
if fmt == "json":
|
|
39
|
+
_emit(json_report.render(res.findings, res.frameworks, res.file_count, res.graph, res.diagnostics,
|
|
40
|
+
dataflow=res.dataflow, file_diagnostics=res.file_diagnostics), output)
|
|
41
|
+
else:
|
|
42
|
+
text = human.render(res.findings, res.frameworks, res.file_count, verbose=verbose,
|
|
43
|
+
graph=res.graph, diagnostics=res.diagnostics, artifacts=res.artifact_counts)
|
|
44
|
+
if res.expanded_sources:
|
|
45
|
+
text += f"\n(source maps expanded: {res.expanded_sources} original source file(s) analyzed)"
|
|
46
|
+
_emit(text, output)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def cmd_analyze(args: argparse.Namespace) -> int:
|
|
50
|
+
config = Config.load(args.config)
|
|
51
|
+
if args.min_confidence:
|
|
52
|
+
config.data["min_confidence"] = args.min_confidence
|
|
53
|
+
if args.format:
|
|
54
|
+
config.data["output"]["format"] = args.format
|
|
55
|
+
|
|
56
|
+
if args.expand_source_maps:
|
|
57
|
+
config.data["source_maps"]["expand"] = True
|
|
58
|
+
for pth in args.plugin_path or []:
|
|
59
|
+
config.data["plugins"]["paths"].append(pth)
|
|
60
|
+
res = run_analysis(args.paths, config)
|
|
61
|
+
|
|
62
|
+
if res.file_count == 0:
|
|
63
|
+
print("no analyzable files (JavaScript, HTML, OpenAPI, GraphQL schema, WASM) found at given path(s)", file=sys.stderr)
|
|
64
|
+
return 1
|
|
65
|
+
|
|
66
|
+
_print_report(res, config.get("output.format", "human"), args.verbose, args.output)
|
|
67
|
+
|
|
68
|
+
if args.save_findings:
|
|
69
|
+
with open(args.save_findings, "w") as f:
|
|
70
|
+
json.dump(json_report.payload(res.findings, res.frameworks, res.file_count, res.graph,
|
|
71
|
+
res.diagnostics, dataflow=res.dataflow,
|
|
72
|
+
file_diagnostics=res.file_diagnostics), f, indent=2)
|
|
73
|
+
print(f"\n[saved findings -> {args.save_findings}]", file=sys.stderr)
|
|
74
|
+
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------- go: the one-command entry point
|
|
80
|
+
def _slug(text: str) -> str:
|
|
81
|
+
text = re.sub(r"^https?://", "", text)
|
|
82
|
+
text = re.sub(r"[^A-Za-z0-9.-]+", "-", text).strip("-")
|
|
83
|
+
return text[:80] or "target"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _ext_for(fmt: str) -> str:
|
|
87
|
+
return {"markdown": "md", "md": "md", "html": "html", "sarif": "sarif", "json": "json"}.get(fmt, "txt")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _confirm_target(url: str, yes: bool) -> bool:
|
|
91
|
+
"""One-line banner + a single Enter press before any request leaves the machine. `-y` skips it;
|
|
92
|
+
a non-interactive session without `-y` is refused rather than silently sent (this is the direct fix for
|
|
93
|
+
'my $VAR was empty/wrong and it scanned the wrong host without telling me')."""
|
|
94
|
+
host = urlparse(url).netloc or url
|
|
95
|
+
try:
|
|
96
|
+
ip = socket.gethostbyname(host.split(":")[0])
|
|
97
|
+
resolved = f"resolves to {ip}"
|
|
98
|
+
except OSError:
|
|
99
|
+
resolved = "could NOT be resolved -- double check the hostname before continuing"
|
|
100
|
+
print(f"\n target : {url}", file=sys.stderr)
|
|
101
|
+
print(f" host : {host} ({resolved})", file=sys.stderr)
|
|
102
|
+
print(" action : discover + download + analyze (read-only GET requests only; no probing without --confirm)\n", file=sys.stderr)
|
|
103
|
+
if yes:
|
|
104
|
+
return True
|
|
105
|
+
if not sys.stdin.isatty():
|
|
106
|
+
print("error: not an interactive terminal -- re-run with -y/--yes to proceed without the prompt", file=sys.stderr)
|
|
107
|
+
return False
|
|
108
|
+
try:
|
|
109
|
+
input(" press Enter to continue, Ctrl+C to abort... ")
|
|
110
|
+
except (EOFError, KeyboardInterrupt):
|
|
111
|
+
print("\naborted", file=sys.stderr)
|
|
112
|
+
return False
|
|
113
|
+
return True
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _print_synopsis(res, out_dir: Path, report_path: Path, findings_path: Path, site_dir: Path | None) -> None:
|
|
117
|
+
from jscope.reporting.priority import prioritize
|
|
118
|
+
counts: dict[str, int] = {}
|
|
119
|
+
for f in res.findings:
|
|
120
|
+
counts[f.confidence.value] = counts.get(f.confidence.value, 0) + 1
|
|
121
|
+
order = ["CONFIRMED", "HIGH", "MEDIUM", "LOW", "INFORMATIONAL"]
|
|
122
|
+
print(f"\n{len(res.findings)} finding(s): " + ", ".join(f"{k}={counts[k]}" for k in order if k in counts))
|
|
123
|
+
top = sorted(prioritize(res.findings, res.graph), key=lambda t: -t[1])[:5]
|
|
124
|
+
if top:
|
|
125
|
+
print("\ntop priority:")
|
|
126
|
+
for f, pts, why in top:
|
|
127
|
+
print(f" [{f.confidence.value:<13}] {f.category}: {f.subject}")
|
|
128
|
+
if why:
|
|
129
|
+
print(f" {why[0]}")
|
|
130
|
+
print(f"\nreport : {report_path}")
|
|
131
|
+
print(f"findings : {findings_path}")
|
|
132
|
+
if site_dir:
|
|
133
|
+
print(f"downloaded: {site_dir}")
|
|
134
|
+
print(f"\n(all output is under {out_dir}; full report: jscope report {findings_path} --format html)")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _target_policy(url: str, prefix: str, allow_private: bool = False):
|
|
138
|
+
"""Transport policy bound to one target host. Loopback/private destinations stay blocked unless the user
|
|
139
|
+
explicitly opts in with --allow-private (controlled local testing only)."""
|
|
140
|
+
from jscope.security_transport import TransportPolicy, normalize_host
|
|
141
|
+
host = urlparse(url).hostname
|
|
142
|
+
if not host:
|
|
143
|
+
return None
|
|
144
|
+
if allow_private:
|
|
145
|
+
print("note: --allow-private set; loopback/private destinations are permitted for this run", file=sys.stderr)
|
|
146
|
+
return TransportPolicy(hosts={normalize_host(host)}, assessment_id=f"{prefix}-{normalize_host(host)}",
|
|
147
|
+
total_budget=100, max_response_bytes=8 * 1024 * 1024,
|
|
148
|
+
allow_loopback=allow_private, allow_private=allow_private)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def cmd_go(args: argparse.Namespace) -> int:
|
|
152
|
+
"""The one command most people need: point it at a URL/host or a local path and it does the right thing."""
|
|
153
|
+
target = args.target
|
|
154
|
+
config = Config.load(args.config)
|
|
155
|
+
if args.min_confidence:
|
|
156
|
+
config.data["min_confidence"] = args.min_confidence
|
|
157
|
+
report_fmt = args.format or "markdown"
|
|
158
|
+
local_path = Path(target)
|
|
159
|
+
|
|
160
|
+
if local_path.exists():
|
|
161
|
+
print(f"-> local path: {local_path}", file=sys.stderr)
|
|
162
|
+
res = run_analysis([str(local_path)], config)
|
|
163
|
+
if res.file_count == 0:
|
|
164
|
+
print("no analyzable files (JavaScript, HTML, OpenAPI, GraphQL schema, WASM) found at that path", file=sys.stderr)
|
|
165
|
+
return 1
|
|
166
|
+
out_dir = Path(args.out) if args.out else Path("jscope-out") / _slug(local_path.name or "local")
|
|
167
|
+
site_dir = None
|
|
168
|
+
else:
|
|
169
|
+
url = target if urlparse(target).scheme else "https://" + target
|
|
170
|
+
if not _confirm_target(url, args.yes):
|
|
171
|
+
return 1
|
|
172
|
+
out_dir = Path(args.out) if args.out else Path("jscope-out") / _slug(urlparse(url).netloc or url)
|
|
173
|
+
site_dir = out_dir / "site"
|
|
174
|
+
from jscope.crawler.crawler import CrawlError, discover
|
|
175
|
+
from jscope.downloader.downloader import DownloadError, download_urls
|
|
176
|
+
print("[1/3] discovering ...", file=sys.stderr)
|
|
177
|
+
go_policy = _target_policy(url, "go", getattr(args, "allow_private", False))
|
|
178
|
+
try:
|
|
179
|
+
assets = discover(url, probe=not args.no_probe, policy=go_policy)
|
|
180
|
+
except CrawlError as exc:
|
|
181
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
182
|
+
return 1
|
|
183
|
+
kinds: dict[str, int] = {}
|
|
184
|
+
for a in assets:
|
|
185
|
+
kinds[a.kind] = kinds.get(a.kind, 0) + 1
|
|
186
|
+
print(" found " + (", ".join(f"{v} {k}" for k, v in sorted(kinds.items())) or "nothing"), file=sys.stderr)
|
|
187
|
+
if not assets:
|
|
188
|
+
print("nothing discovered", file=sys.stderr)
|
|
189
|
+
return 1
|
|
190
|
+
site_dir.mkdir(parents=True, exist_ok=True)
|
|
191
|
+
url_list = site_dir / "_urls.txt"
|
|
192
|
+
url_list.write_text("\n".join(a.url for a in assets) + "\n")
|
|
193
|
+
print("[2/3] downloading ...", file=sys.stderr)
|
|
194
|
+
try:
|
|
195
|
+
results = download_urls(str(url_list), str(site_dir), policy=go_policy)
|
|
196
|
+
except DownloadError as exc:
|
|
197
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
198
|
+
return 1
|
|
199
|
+
ok = sum(1 for r in results if r.ok and not r.duplicate_of)
|
|
200
|
+
failed = [r for r in results if not r.ok]
|
|
201
|
+
print(f" saved {ok} file(s)" + (f", {len(failed)} failed (run `jscope download {url_list} --out <dir>` to see why)" if failed else ""), file=sys.stderr)
|
|
202
|
+
print("[3/3] analyzing ...", file=sys.stderr)
|
|
203
|
+
res = run_analysis([str(site_dir)], config)
|
|
204
|
+
if res.file_count == 0:
|
|
205
|
+
print("downloaded files contained nothing analyzable", file=sys.stderr)
|
|
206
|
+
return 1
|
|
207
|
+
|
|
208
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
209
|
+
report_path = Path(args.output) if args.output else out_dir / f"report.{_ext_for(report_fmt)}"
|
|
210
|
+
findings_path = Path(args.save_findings) if args.save_findings else out_dir / "findings.json"
|
|
211
|
+
config.data["output"]["format"] = report_fmt
|
|
212
|
+
_print_report(res, report_fmt, args.verbose, str(report_path))
|
|
213
|
+
payload = json_report.payload(res.findings, res.frameworks, res.file_count, res.graph, res.diagnostics,
|
|
214
|
+
dataflow=res.dataflow, file_diagnostics=res.file_diagnostics)
|
|
215
|
+
with open(findings_path, "w") as f:
|
|
216
|
+
json.dump(payload, f, indent=2)
|
|
217
|
+
_print_synopsis(res, out_dir, report_path, findings_path, site_dir)
|
|
218
|
+
|
|
219
|
+
if args.confirm:
|
|
220
|
+
if local_path.exists():
|
|
221
|
+
print("\n--confirm needs a URL/host target, not a local path; skipping", file=sys.stderr)
|
|
222
|
+
return 0
|
|
223
|
+
print("\n-- runtime confirmation --", file=sys.stderr)
|
|
224
|
+
if not _confirm_target(url, args.yes):
|
|
225
|
+
return 0
|
|
226
|
+
host = urlparse(url).netloc
|
|
227
|
+
headers = _parse_headers(args.header)
|
|
228
|
+
if headers is None:
|
|
229
|
+
return 1
|
|
230
|
+
rc, updated = _confirm_core(payload, url, args.allow_host or [host], args.rate, args.max_requests,
|
|
231
|
+
headers, config, dry_run=False, i_have_permission=args.i_have_permission)
|
|
232
|
+
if rc == 0 and updated:
|
|
233
|
+
with open(findings_path, "w") as f:
|
|
234
|
+
json.dump(updated, f, indent=2)
|
|
235
|
+
print(f"[updated {findings_path} with runtime results]", file=sys.stderr)
|
|
236
|
+
return rc
|
|
237
|
+
return 0
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def cmd_doctor(args: argparse.Namespace) -> int:
|
|
241
|
+
"""Diagnose install problems: which copy of JScope is actually running, what's on PATH, what's missing."""
|
|
242
|
+
import importlib
|
|
243
|
+
import platform
|
|
244
|
+
print(f"jscope : {__version__}")
|
|
245
|
+
print(f"package : {Path(__file__).resolve().parent}")
|
|
246
|
+
print(f"python : {platform.python_version()} ({sys.executable})")
|
|
247
|
+
|
|
248
|
+
launchers, seen = [], set()
|
|
249
|
+
for d in os.environ.get("PATH", "").split(os.pathsep):
|
|
250
|
+
cand = Path(d) / "jscope"
|
|
251
|
+
if cand.is_file() and str(cand) not in seen:
|
|
252
|
+
seen.add(str(cand))
|
|
253
|
+
launchers.append(cand)
|
|
254
|
+
if len(launchers) > 1:
|
|
255
|
+
print(f"\nWARNING: {len(launchers)} 'jscope' launchers on PATH -- the first one found wins and may be stale:")
|
|
256
|
+
for lp in launchers:
|
|
257
|
+
print(f" {lp}")
|
|
258
|
+
print(" Fix: pip uninstall -y jscope (repeat until none are left), then reinstall, or just use `python -m jscope`.")
|
|
259
|
+
elif launchers:
|
|
260
|
+
print(f"launcher: {launchers[0]}")
|
|
261
|
+
else:
|
|
262
|
+
print("launcher: none on PATH -- run with `python -m jscope <command>` from the project directory")
|
|
263
|
+
|
|
264
|
+
print("\ndependencies:")
|
|
265
|
+
for mod, label in (("tree_sitter", "tree_sitter"), ("tree_sitter_javascript", "tree_sitter_javascript"),
|
|
266
|
+
("requests", "requests"), ("bs4", "beautifulsoup4"), ("yaml", "PyYAML (optional: YAML OpenAPI specs)")):
|
|
267
|
+
try:
|
|
268
|
+
m = importlib.import_module(mod)
|
|
269
|
+
print(f" ok {label:<38} {getattr(m, '__version__', '?')}")
|
|
270
|
+
except ImportError:
|
|
271
|
+
print(f" MISSING {label}" + ("" if "optional" in label else " -- pip install jscope"))
|
|
272
|
+
|
|
273
|
+
if args.check_network:
|
|
274
|
+
print()
|
|
275
|
+
try:
|
|
276
|
+
socket.gethostbyname("github.com")
|
|
277
|
+
print("network : DNS resolution OK")
|
|
278
|
+
except OSError as exc:
|
|
279
|
+
print(f"network : DNS resolution FAILED ({exc})")
|
|
280
|
+
|
|
281
|
+
print("\nIf a command's --help looks out of date, you're running a stale install. Fix:")
|
|
282
|
+
print(" pip uninstall -y jscope && pip install -e . --no-deps --force-reinstall --no-cache-dir && hash -r")
|
|
283
|
+
print("Or skip installing entirely: `python -m jscope <command>` always uses the current source.")
|
|
284
|
+
return 0
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def finding_from_dict(match: dict) -> Finding:
|
|
288
|
+
from jscope.core.evidence import Evidence, EvidenceType, Confidence
|
|
289
|
+
return Finding(
|
|
290
|
+
id=match["id"], category=match["category"], subject=match["subject"],
|
|
291
|
+
confidence=Confidence(match["confidence"]), score=match["score"],
|
|
292
|
+
evidence=[Evidence(type=EvidenceType(e["type"]), description=e["description"],
|
|
293
|
+
analyzer=e["analyzer"], file=e["file"], line=e["line"],
|
|
294
|
+
snippet=e["snippet"], metadata=e["metadata"])
|
|
295
|
+
for e in match["evidence"]],
|
|
296
|
+
contributing_analyzers=match["contributing_analyzers"],
|
|
297
|
+
attack_surface=match["attack_surface"], suggested_test=match["suggested_test"],
|
|
298
|
+
reasoning=match["reasoning"], related=match["related"], metadata=match.get("metadata", {}))
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def cmd_explain(args: argparse.Namespace) -> int:
|
|
302
|
+
if not args.findings_file:
|
|
303
|
+
print("error: --findings-file is required (produced via `jscope analyze --save-findings`)",
|
|
304
|
+
file=sys.stderr)
|
|
305
|
+
return 1
|
|
306
|
+
with open(args.findings_file) as f:
|
|
307
|
+
data = json.load(f)
|
|
308
|
+
match = next((fnd for fnd in data["findings"] if fnd["id"] == args.finding_id), None)
|
|
309
|
+
if match is None:
|
|
310
|
+
print(f"no finding with id {args.finding_id}", file=sys.stderr)
|
|
311
|
+
return 1
|
|
312
|
+
|
|
313
|
+
from jscope.core.evidence import Evidence, EvidenceType, Confidence
|
|
314
|
+
finding = Finding(
|
|
315
|
+
id=match["id"], category=match["category"], subject=match["subject"],
|
|
316
|
+
confidence=Confidence(match["confidence"]), score=match["score"],
|
|
317
|
+
evidence=[Evidence(type=EvidenceType(e["type"]), description=e["description"],
|
|
318
|
+
analyzer=e["analyzer"], file=e["file"], line=e["line"],
|
|
319
|
+
snippet=e["snippet"], metadata=e["metadata"])
|
|
320
|
+
for e in match["evidence"]],
|
|
321
|
+
contributing_analyzers=match["contributing_analyzers"],
|
|
322
|
+
attack_surface=match["attack_surface"], suggested_test=match["suggested_test"],
|
|
323
|
+
reasoning=match["reasoning"], related=match["related"], metadata=match.get("metadata", {}),
|
|
324
|
+
)
|
|
325
|
+
print(human.render_explain(finding))
|
|
326
|
+
return 0
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def cmd_download(args: argparse.Namespace) -> int:
|
|
330
|
+
from jscope.downloader.downloader import DownloadError, download_urls
|
|
331
|
+
from jscope.security_transport import TransportPolicy, normalize_host
|
|
332
|
+
try:
|
|
333
|
+
if not args.allow_host:
|
|
334
|
+
raise DownloadError("pass --allow-host for every explicitly authorized hostname; use `jscope scan` for target-bound discovery")
|
|
335
|
+
policy = TransportPolicy(hosts={normalize_host(h) for h in args.allow_host},
|
|
336
|
+
assessment_id="download-cli", total_budget=500,
|
|
337
|
+
allow_loopback=args.allow_private, allow_private=args.allow_private)
|
|
338
|
+
results = download_urls(args.url_file, args.out, policy=policy)
|
|
339
|
+
except DownloadError as exc:
|
|
340
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
341
|
+
return 1
|
|
342
|
+
ok = sum(1 for r in results if r.ok and not r.duplicate_of)
|
|
343
|
+
dup = sum(1 for r in results if r.duplicate_of)
|
|
344
|
+
failed = sum(1 for r in results if not r.ok)
|
|
345
|
+
print(f"downloaded={ok} duplicates_skipped={dup} failed={failed} -> {args.out}")
|
|
346
|
+
for r in results:
|
|
347
|
+
if not r.ok:
|
|
348
|
+
print(f" FAILED {r.url} ({r.error})", file=sys.stderr)
|
|
349
|
+
return 0 if failed == 0 else 2
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def cmd_crawl(args: argparse.Namespace) -> int:
|
|
353
|
+
from jscope.crawler.crawler import CrawlError, discover
|
|
354
|
+
try:
|
|
355
|
+
assets = discover(args.url, probe=not args.no_probe,
|
|
356
|
+
policy=_target_policy(args.url if "://" in args.url else "https://" + args.url, "crawl", args.allow_private))
|
|
357
|
+
except CrawlError as exc:
|
|
358
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
359
|
+
return 1
|
|
360
|
+
for a in assets:
|
|
361
|
+
print(f"{a.kind:<15}{a.source:<16} {a.url}")
|
|
362
|
+
if args.out:
|
|
363
|
+
out = Path(args.out)
|
|
364
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
365
|
+
out.write_text("\n".join(a.url for a in assets) + "\n")
|
|
366
|
+
print(f"\n[wrote {len(assets)} URLs -> {args.out}]", file=sys.stderr)
|
|
367
|
+
return 0
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def cmd_scan(args: argparse.Namespace) -> int:
|
|
371
|
+
"""Full workflow: crawl -> download -> analyze -> report, in one command (JS, HTML, specs, schemas, WASM)."""
|
|
372
|
+
import tempfile
|
|
373
|
+
from jscope.crawler.crawler import CrawlError, discover
|
|
374
|
+
from jscope.downloader.downloader import DownloadError, download_urls
|
|
375
|
+
from jscope.security_transport import TransportPolicy, normalize_host
|
|
376
|
+
target = args.url if "://" in args.url else "https://" + args.url
|
|
377
|
+
target_host = urlparse(target).hostname
|
|
378
|
+
if not target_host:
|
|
379
|
+
print("error: target URL has no valid hostname", file=sys.stderr)
|
|
380
|
+
return 1
|
|
381
|
+
scan_policy = _target_policy(target, "scan", getattr(args, "allow_private", False))
|
|
382
|
+
print(f"[1/3] discovering from {args.url} ...", file=sys.stderr)
|
|
383
|
+
try:
|
|
384
|
+
assets = discover(args.url, probe=not args.no_probe, policy=scan_policy)
|
|
385
|
+
except CrawlError as exc:
|
|
386
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
387
|
+
return 1
|
|
388
|
+
kinds: dict[str, int] = {}
|
|
389
|
+
for a in assets:
|
|
390
|
+
kinds[a.kind] = kinds.get(a.kind, 0) + 1
|
|
391
|
+
print(" found " + (", ".join(f"{v} {k}" for k, v in sorted(kinds.items())) or "nothing"), file=sys.stderr)
|
|
392
|
+
if not assets:
|
|
393
|
+
print("nothing discovered", file=sys.stderr)
|
|
394
|
+
return 1
|
|
395
|
+
|
|
396
|
+
tmp_ctx = None if args.out else tempfile.TemporaryDirectory(prefix="jscope_")
|
|
397
|
+
try:
|
|
398
|
+
root = Path(args.out) if args.out else Path(tmp_ctx.name)
|
|
399
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
400
|
+
url_list = root / "_urls.txt"
|
|
401
|
+
url_list.write_text("\n".join(a.url for a in assets) + "\n")
|
|
402
|
+
print("[2/3] downloading ...", file=sys.stderr)
|
|
403
|
+
try:
|
|
404
|
+
results = download_urls(str(url_list), str(root), policy=scan_policy)
|
|
405
|
+
except DownloadError as exc:
|
|
406
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
407
|
+
return 1
|
|
408
|
+
ok = sum(1 for r in results if r.ok and not r.duplicate_of)
|
|
409
|
+
failed = [r for r in results if not r.ok]
|
|
410
|
+
print(f" saved {ok} file(s)" + (f" -> {root}" if args.out else "") + (f", {len(failed)} failed" if failed else ""), file=sys.stderr)
|
|
411
|
+
|
|
412
|
+
print("[3/3] analyzing ...", file=sys.stderr)
|
|
413
|
+
config = Config.load(args.config)
|
|
414
|
+
if args.min_confidence:
|
|
415
|
+
config.data["min_confidence"] = args.min_confidence
|
|
416
|
+
if args.format:
|
|
417
|
+
config.data["output"]["format"] = args.format
|
|
418
|
+
res = run_analysis([str(root)], config)
|
|
419
|
+
if res.file_count == 0:
|
|
420
|
+
print("downloaded files contained nothing analyzable", file=sys.stderr)
|
|
421
|
+
return 1
|
|
422
|
+
_print_report(res, config.get("output.format", "human"), args.verbose, args.output)
|
|
423
|
+
if args.save_findings:
|
|
424
|
+
with open(args.save_findings, "w") as f:
|
|
425
|
+
json.dump(json_report.payload(res.findings, res.frameworks, res.file_count, res.graph, res.diagnostics,
|
|
426
|
+
dataflow=res.dataflow, file_diagnostics=res.file_diagnostics), f, indent=2)
|
|
427
|
+
print(f"[saved findings -> {args.save_findings}]", file=sys.stderr)
|
|
428
|
+
finally:
|
|
429
|
+
if tmp_ctx:
|
|
430
|
+
tmp_ctx.cleanup()
|
|
431
|
+
return 0
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def cmd_graph(args: argparse.Namespace) -> int:
|
|
435
|
+
"""Print the route -> component -> API -> guard/role map."""
|
|
436
|
+
if args.source.endswith(".json"):
|
|
437
|
+
with open(args.source) as f:
|
|
438
|
+
data = json.load(f)
|
|
439
|
+
if "graph" not in data:
|
|
440
|
+
print("findings file has no graph (re-run `jscope analyze --save-findings`)", file=sys.stderr)
|
|
441
|
+
return 1
|
|
442
|
+
graph = KnowledgeGraph.from_dict(data["graph"])
|
|
443
|
+
else:
|
|
444
|
+
config = Config.load(args.config)
|
|
445
|
+
res = run_analysis([args.source], config)
|
|
446
|
+
graph = res.graph
|
|
447
|
+
chains = graph.route_chains()
|
|
448
|
+
if args.format == "json":
|
|
449
|
+
print(json.dumps({"stats": graph.stats(), "routes": chains}, indent=2))
|
|
450
|
+
return 0
|
|
451
|
+
print(f"graph: {graph.stats()}")
|
|
452
|
+
for c in chains:
|
|
453
|
+
prot = "UNPROTECTED" if c["unguarded"] else "guards=" + ",".join(c["guards"] or ["meta"]) + (
|
|
454
|
+
" roles=" + ",".join(c["roles"]) if c["roles"] else "")
|
|
455
|
+
print(f"R {c['route']:<30} {prot} [{c['file'] or '-'}]")
|
|
456
|
+
for ep in c["endpoints"]:
|
|
457
|
+
print(f" -> E {ep}")
|
|
458
|
+
for r in c["roles_checked"]:
|
|
459
|
+
print(f" -> role checked in code: {r}")
|
|
460
|
+
for st in c["storage"]:
|
|
461
|
+
print(f" -> storage: {st}")
|
|
462
|
+
return 0
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def cmd_plugins(args: argparse.Namespace) -> int:
|
|
466
|
+
from jscope.pipeline import core_analyzers
|
|
467
|
+
from jscope.plugin.loader import PLUGIN_API_VERSION, load_plugins
|
|
468
|
+
config = Config.load(args.config)
|
|
469
|
+
paths = list(config.get("plugins.paths", [])) + (args.plugin_path or [])
|
|
470
|
+
core = core_analyzers()
|
|
471
|
+
res = load_plugins(paths, {a.name for a in core}, set(config.get("plugins.verified", [])),
|
|
472
|
+
use_entry_points=config.get("plugins.entry_points", True))
|
|
473
|
+
print(f"plugin API version {PLUGIN_API_VERSION}")
|
|
474
|
+
print(f"{'analyzer':<28}{'tier':<11}{'depends_on':<34}source")
|
|
475
|
+
for a in core + res.analyzers:
|
|
476
|
+
print(f"{a.name:<28}{a.tier:<11}{','.join(a.depends_on) or '-':<34}{res.sources.get(a.name, 'built-in')}")
|
|
477
|
+
for e in res.errors:
|
|
478
|
+
print(f" ! {e}", file=sys.stderr)
|
|
479
|
+
return 0 if not res.errors else 2
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def cmd_plugin_new(args: argparse.Namespace) -> int:
|
|
483
|
+
from jscope.plugin.loader import scaffold
|
|
484
|
+
try:
|
|
485
|
+
target = scaffold(args.name, args.out)
|
|
486
|
+
except (ValueError, FileExistsError) as exc:
|
|
487
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
488
|
+
return 1
|
|
489
|
+
print(f"created {target}\nrun it with: jscope analyze <path> --plugin-path {args.out}")
|
|
490
|
+
return 0
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
# ---------------------------------------------------------------- workspaces (Phase 3)
|
|
494
|
+
def _ws(args):
|
|
495
|
+
from jscope.workspace.store import Workspace
|
|
496
|
+
return Workspace.open(args.workspace)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _ws_cmd(fn):
|
|
500
|
+
from jscope.workspace.store import WorkspaceError
|
|
501
|
+
|
|
502
|
+
def wrapper(args):
|
|
503
|
+
try:
|
|
504
|
+
return fn(args)
|
|
505
|
+
except WorkspaceError as exc:
|
|
506
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
507
|
+
return 1
|
|
508
|
+
return wrapper
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
@_ws_cmd
|
|
512
|
+
def cmd_new(args):
|
|
513
|
+
"""Create a domain-centric workspace. This records a target but sends no network traffic."""
|
|
514
|
+
from jscope.workspace.assessment import normalize_domain
|
|
515
|
+
from jscope.workspace.store import Workspace, WorkspaceError
|
|
516
|
+
try:
|
|
517
|
+
domain = normalize_domain(args.target)
|
|
518
|
+
root = Path(args.workspace).resolve()
|
|
519
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
520
|
+
ws = Workspace.init(root)
|
|
521
|
+
# The existing target store remains compatible with source-analysis workspaces.
|
|
522
|
+
meta_path = ws.dir / "workspace.json"
|
|
523
|
+
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
524
|
+
meta["schema"] = "jscope-domain-workspace/1"
|
|
525
|
+
meta["domain"] = {"root": domain, "created_at": __import__("datetime").datetime.now(__import__("datetime").timezone.utc).isoformat(timespec="seconds")}
|
|
526
|
+
from jscope.workspace.store import _atomic_write
|
|
527
|
+
_atomic_write(meta_path, meta)
|
|
528
|
+
(ws.dir / "assessments").mkdir(parents=True, exist_ok=True)
|
|
529
|
+
print(f"created domain workspace: {domain}")
|
|
530
|
+
print(f"workspace: {ws.dir}")
|
|
531
|
+
print("network activity: none")
|
|
532
|
+
print("next: provide an explicit scope file for each assessment; discovery does not grant permission")
|
|
533
|
+
return 0
|
|
534
|
+
except WorkspaceError as exc:
|
|
535
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
536
|
+
return 1
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def cmd_scope_discover(args):
|
|
540
|
+
"""Inventory candidate hostnames from supplied files; never performs active network tests."""
|
|
541
|
+
from jscope.passive_discovery import discover_candidates, persist_candidates
|
|
542
|
+
from jscope.workspace.common import WorkspaceError
|
|
543
|
+
try:
|
|
544
|
+
path = Path(args.manifest)
|
|
545
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
546
|
+
candidates = discover_candidates(data["root_domain"], args.host_file or [], data.get("hosts", {}).keys())
|
|
547
|
+
persist_candidates(path, candidates)
|
|
548
|
+
print(json.dumps(candidates, indent=2))
|
|
549
|
+
print("Passive inventory only: no active testing was started; candidates remain unapproved.")
|
|
550
|
+
return 0
|
|
551
|
+
except (OSError, ValueError, KeyError, WorkspaceError) as exc:
|
|
552
|
+
print(f"error: {exc}", file=sys.stderr); return 2
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def cmd_scope_review(args):
|
|
556
|
+
from jscope.workspace.common import WorkspaceError
|
|
557
|
+
try:
|
|
558
|
+
data=json.loads(Path(args.manifest).read_text(encoding="utf-8"))
|
|
559
|
+
print(json.dumps({"assessment_id":data.get("assessment_id"),"root_domain":data.get("root_domain"),"hosts":data.get("hosts",{}),"approval_history":data.get("approval_history",[]),"authorization":data.get("authorization",{})},indent=2))
|
|
560
|
+
return 0
|
|
561
|
+
except (OSError, ValueError) as exc:
|
|
562
|
+
print(f"error: manifest missing or corrupt: {exc}",file=sys.stderr); return 2
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def cmd_scope_approve(args):
|
|
566
|
+
from jscope.passive_discovery import approve_hosts
|
|
567
|
+
from jscope.workspace.common import WorkspaceError
|
|
568
|
+
try:
|
|
569
|
+
data=json.loads(Path(args.manifest).read_text(encoding="utf-8"))
|
|
570
|
+
if not args.yes:
|
|
571
|
+
print("Assessment:",data.get("assessment_id")); print("Hosts to approve:",", ".join(args.hosts))
|
|
572
|
+
if not sys.stdin.isatty():
|
|
573
|
+
print("error: non-interactive approval requires --yes",file=sys.stderr); return 2
|
|
574
|
+
if input("Explicitly approve these hosts for this assessment? [y/N] ").strip().lower() != "y":
|
|
575
|
+
print("approval declined"); return 1
|
|
576
|
+
decision=approve_hosts(args.manifest,args.hosts)
|
|
577
|
+
print(json.dumps(decision,indent=2)); return 0
|
|
578
|
+
except (OSError, ValueError, KeyError, WorkspaceError) as exc:
|
|
579
|
+
print(f"error: {exc}",file=sys.stderr); return 2
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def cmd_assessment_plan(args):
|
|
583
|
+
"""Create an auditable per-run scope manifest; does not perform network activity."""
|
|
584
|
+
from jscope.workspace.assessment import validate_scope_file, create_assessment_record, write_assessment
|
|
585
|
+
from jscope.workspace.store import Workspace, WorkspaceError
|
|
586
|
+
try:
|
|
587
|
+
domain = args.domain
|
|
588
|
+
scope = validate_scope_file(args.scope_file, domain)
|
|
589
|
+
if not scope["valid"]:
|
|
590
|
+
print(json.dumps(scope, indent=2))
|
|
591
|
+
print("error: scope file has rejected entries or no valid hosts; fix it before planning", file=sys.stderr)
|
|
592
|
+
return 2
|
|
593
|
+
approved = scope["approved_hosts"] if args.approve_scope else []
|
|
594
|
+
manifest = create_assessment_record(domain, scope["approved_hosts"], approved, {
|
|
595
|
+
"max_requests": args.max_requests, "rate_per_second": args.rate,
|
|
596
|
+
"depth": args.depth, "mode": "black-box-baseline",
|
|
597
|
+
})
|
|
598
|
+
manifest["scope_file"] = {"path": scope["path"], "sha256": scope["sha256"]}
|
|
599
|
+
root = Path(args.workspace).resolve()
|
|
600
|
+
ws = Workspace.open(root)
|
|
601
|
+
meta = json.loads((ws.dir / "workspace.json").read_text(encoding="utf-8"))
|
|
602
|
+
recorded_domain = meta.get("domain", {}).get("root")
|
|
603
|
+
if recorded_domain and recorded_domain != manifest["root_domain"]:
|
|
604
|
+
raise WorkspaceError(f"workspace root is {recorded_domain}, not {manifest['root_domain']}")
|
|
605
|
+
out = write_assessment(root, manifest)
|
|
606
|
+
print(f"assessment: {manifest['assessment_id']}")
|
|
607
|
+
print(f"scope hosts: {len(scope['approved_hosts'])}")
|
|
608
|
+
print(f"approved for this run: {len(approved)}")
|
|
609
|
+
print(f"manifest: {out}")
|
|
610
|
+
print("network activity: none (planning only)")
|
|
611
|
+
if not approved:
|
|
612
|
+
print("status: planned — rerun with --approve-scope to record explicit approval")
|
|
613
|
+
else:
|
|
614
|
+
print("status: approved — this records scope approval; it does not start testing")
|
|
615
|
+
return 0
|
|
616
|
+
except WorkspaceError as exc:
|
|
617
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
618
|
+
return 1
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def cmd_scope_check(args):
|
|
622
|
+
"""Validate a host-per-line scope file without resolving hosts or sending requests."""
|
|
623
|
+
from jscope.workspace.assessment import validate_scope_file
|
|
624
|
+
from jscope.workspace.store import WorkspaceError
|
|
625
|
+
try:
|
|
626
|
+
result = validate_scope_file(args.file, args.domain)
|
|
627
|
+
except WorkspaceError as exc:
|
|
628
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
629
|
+
return 1
|
|
630
|
+
print(json.dumps(result, indent=2))
|
|
631
|
+
return 0 if result["valid"] else 2
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def cmd_ws_init(args):
|
|
635
|
+
from jscope.workspace.store import Workspace
|
|
636
|
+
ws = Workspace.init(args.workspace)
|
|
637
|
+
print(f"initialized workspace at {ws.dir}")
|
|
638
|
+
return 0
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
@_ws_cmd
|
|
642
|
+
def cmd_ws_add(args):
|
|
643
|
+
_ws(args).add_target(args.name, args.paths)
|
|
644
|
+
print(f"added target {args.name}")
|
|
645
|
+
return 0
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
@_ws_cmd
|
|
649
|
+
def cmd_ws_remove(args):
|
|
650
|
+
_ws(args).remove_target(args.name)
|
|
651
|
+
print(f"removed target {args.name} (and its scan history)")
|
|
652
|
+
return 0
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
@_ws_cmd
|
|
656
|
+
def cmd_ws_list(args):
|
|
657
|
+
ws = _ws(args)
|
|
658
|
+
for name, t in sorted(ws.targets().items()):
|
|
659
|
+
h = ws.history(name)
|
|
660
|
+
last = f"scans={len(h)} last={h[-1]['timestamp']}" if h else "never scanned"
|
|
661
|
+
print(f"{name:<24} {last} {', '.join(t['paths'])}")
|
|
662
|
+
return 0
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
@_ws_cmd
|
|
666
|
+
def cmd_ws_scan(args):
|
|
667
|
+
from jscope.workspace.diff import render_diff
|
|
668
|
+
ws = _ws(args)
|
|
669
|
+
config = Config.load(args.config)
|
|
670
|
+
names = sorted(ws.targets()) if args.all else [args.name]
|
|
671
|
+
if not args.all and not args.name:
|
|
672
|
+
print("error: give a target name or --all", file=sys.stderr)
|
|
673
|
+
return 1
|
|
674
|
+
exit_code = 0
|
|
675
|
+
for name in names:
|
|
676
|
+
out = ws.scan(name, config, force=args.force)
|
|
677
|
+
if out.status == "unchanged":
|
|
678
|
+
print(f"[{name}] unchanged since scan {out.record['scan_id']} -- nothing re-analyzed")
|
|
679
|
+
continue
|
|
680
|
+
sid = out.record["scan_id"]
|
|
681
|
+
print(f"[{name}] scan {sid}: {out.record['payload']['summary']['finding_count']} finding(s), "
|
|
682
|
+
f"{len(out.record['files'])} file(s)")
|
|
683
|
+
if out.diff is None:
|
|
684
|
+
print(f" baseline recorded (no previous scan to compare)")
|
|
685
|
+
else:
|
|
686
|
+
print(render_diff(out.diff))
|
|
687
|
+
reasons = out.diff.regressions(args.fail_level)
|
|
688
|
+
if reasons and args.fail_on_regression:
|
|
689
|
+
exit_code = 3
|
|
690
|
+
for r in reasons:
|
|
691
|
+
print(f" REGRESSION: {r}", file=sys.stderr)
|
|
692
|
+
if args.report:
|
|
693
|
+
_print_report(out.result, config.get("output.format", "human"), args.verbose)
|
|
694
|
+
return exit_code
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
@_ws_cmd
|
|
698
|
+
def cmd_ws_history(args):
|
|
699
|
+
for h in _ws(args).history(args.name):
|
|
700
|
+
print(f"{h['id']} {h['timestamp']} findings={h['finding_count']:<4} files={h['file_count']:<4} fp={h['fingerprint']}")
|
|
701
|
+
return 0
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
@_ws_cmd
|
|
705
|
+
def cmd_ws_diff(args):
|
|
706
|
+
from jscope.workspace.diff import diff_scans, render_diff
|
|
707
|
+
ws = _ws(args)
|
|
708
|
+
new = ws.load_scan(args.name, args.to)
|
|
709
|
+
old = ws.load_scan(args.name, args.frm)
|
|
710
|
+
d = diff_scans(old, new)
|
|
711
|
+
print(json.dumps(d.to_dict(), indent=2) if args.format == "json" else render_diff(d))
|
|
712
|
+
return 0
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
@_ws_cmd
|
|
716
|
+
def cmd_ws_summary(args):
|
|
717
|
+
rows = _ws(args).summary()
|
|
718
|
+
if args.format == "json":
|
|
719
|
+
print(json.dumps(rows, indent=2))
|
|
720
|
+
return 0
|
|
721
|
+
for r in rows:
|
|
722
|
+
if not r["scans"]:
|
|
723
|
+
print(f"{r['target']:<24} never scanned")
|
|
724
|
+
continue
|
|
725
|
+
conf = " ".join(f"{k}={v}" for k, v in r["by_confidence"].items())
|
|
726
|
+
print(f"{r['target']:<24} scans={r['scans']} findings={r['findings']} ({conf}) "
|
|
727
|
+
f"unprotected_routes={r['unprotected_routes']} frameworks={','.join(r['frameworks']) or '-'}")
|
|
728
|
+
if r["top"]:
|
|
729
|
+
print(f" start here: {r['top']['id']} {r['top']['subject']} (priority {r['top']['priority']})")
|
|
730
|
+
return 0
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
# ---------------------------------------------------------------- AI (Phase 3)
|
|
734
|
+
def cmd_ai(args):
|
|
735
|
+
"""Explain EXISTING findings from a saved findings file or a workspace scan. Opt-in by invocation."""
|
|
736
|
+
from jscope.ai.explain import build_payload, explain_findings, render_ai
|
|
737
|
+
from jscope.ai.providers import AIError, get_provider
|
|
738
|
+
config = Config.load(args.config)
|
|
739
|
+
if args.source.startswith("ws:"):
|
|
740
|
+
from jscope.workspace.store import Workspace, WorkspaceError
|
|
741
|
+
try:
|
|
742
|
+
data = Workspace.open(args.workspace).load_scan(args.source[3:], args.scan)["payload"]
|
|
743
|
+
except WorkspaceError as exc:
|
|
744
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
745
|
+
return 1
|
|
746
|
+
else:
|
|
747
|
+
with open(args.source) as f:
|
|
748
|
+
data = json.load(f)
|
|
749
|
+
findings = [finding_from_dict(m) for m in data["findings"]]
|
|
750
|
+
graph = KnowledgeGraph.from_dict(data["graph"]) if "graph" in data else None
|
|
751
|
+
acfg = config.get("ai", {})
|
|
752
|
+
top_n = args.top or acfg.get("top_n", 8)
|
|
753
|
+
redact = not args.send_paths and acfg.get("redact_paths", True)
|
|
754
|
+
if args.dry_run:
|
|
755
|
+
print(json.dumps(build_payload(findings, graph, None, top_n, redact), indent=2))
|
|
756
|
+
print("[dry run: this is exactly what would be sent; nothing was sent]", file=sys.stderr)
|
|
757
|
+
return 0
|
|
758
|
+
try:
|
|
759
|
+
provider = get_provider(acfg)
|
|
760
|
+
except AIError as exc:
|
|
761
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
762
|
+
return 1
|
|
763
|
+
print(f"[sending up to {top_n} redacted finding(s) to {provider.name}; use --dry-run to inspect]", file=sys.stderr)
|
|
764
|
+
rep = explain_findings(findings, provider, graph, None, top_n, redact, acfg.get("max_tokens", 1500))
|
|
765
|
+
print(render_ai(rep, findings))
|
|
766
|
+
return 0 if rep.ok else 2
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _load_payload(source: str, workspace: str, scan: str) -> dict:
|
|
770
|
+
if source.startswith("ws:"):
|
|
771
|
+
from jscope.workspace.store import Workspace
|
|
772
|
+
return Workspace.open(workspace).load_scan(source[3:], scan)["payload"]
|
|
773
|
+
with open(source) as f:
|
|
774
|
+
return json.load(f)
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def cmd_report(args: argparse.Namespace) -> int:
|
|
778
|
+
"""Report Mode (PRD section 6): re-render any saved analysis without re-analyzing."""
|
|
779
|
+
from jscope.workspace.store import WorkspaceError
|
|
780
|
+
try:
|
|
781
|
+
data = _load_payload(args.source, args.workspace, args.scan)
|
|
782
|
+
except (WorkspaceError, OSError, ValueError) as exc:
|
|
783
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
784
|
+
return 1
|
|
785
|
+
if args.format == "json":
|
|
786
|
+
_emit(json.dumps(data, indent=2), args.output)
|
|
787
|
+
elif args.format == "human":
|
|
788
|
+
findings = [finding_from_dict(m) for m in data["findings"]]
|
|
789
|
+
graph = KnowledgeGraph.from_dict(data["graph"]) if "graph" in data else None
|
|
790
|
+
s = data["summary"]
|
|
791
|
+
_emit(human.render(findings, s["frameworks"], s["file_count"], verbose=args.verbose, graph=graph,
|
|
792
|
+
diagnostics=data.get("diagnostics")), args.output)
|
|
793
|
+
else:
|
|
794
|
+
from jscope.reporting.formats import render_payload
|
|
795
|
+
_emit(render_payload(data, args.format), args.output)
|
|
796
|
+
return 0
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _confirm_core(data: dict, base_url: str, allow_hosts: list[str], rate: float | None, max_requests: int | None,
|
|
800
|
+
headers: dict, config: Config, dry_run: bool, i_have_permission: bool) -> tuple[int, dict | None]:
|
|
801
|
+
"""Shared by `jscope confirm` and `jscope go --confirm`. Returns (exit_code, updated_payload_or_None)."""
|
|
802
|
+
from jscope.runtime.prober import Prober, ScopeError, apply_results
|
|
803
|
+
findings = [finding_from_dict(m) for m in data["findings"]]
|
|
804
|
+
rt = config.get("runtime", {})
|
|
805
|
+
try:
|
|
806
|
+
prober = Prober(base_url, allow_hosts or [], rate or rt.get("rate_per_second", 2.0),
|
|
807
|
+
max_requests or rt.get("max_requests", 100), rt.get("timeout_seconds", 8), headers)
|
|
808
|
+
except ScopeError as exc:
|
|
809
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
810
|
+
return 1, None
|
|
811
|
+
plan = prober.plan(findings)
|
|
812
|
+
todo = [p for p in plan if p.url]
|
|
813
|
+
print(f"plan: {len(todo)} request(s) to {prober.host} (+1 baseline), {len(plan) - len(todo)} skipped", file=sys.stderr)
|
|
814
|
+
for p in plan:
|
|
815
|
+
print(f" {'SEND' if p.url else 'SKIP'} {p.method:<7} {p.url or p.subject}" + (f" ({p.skip_reason})" if p.skip_reason else ""))
|
|
816
|
+
if dry_run:
|
|
817
|
+
print("[dry run: nothing was sent]", file=sys.stderr)
|
|
818
|
+
return 0, None
|
|
819
|
+
if not i_have_permission:
|
|
820
|
+
print("error: refusing to send requests without --i-have-permission (you are authorized to test this host). "
|
|
821
|
+
"Use --dry-run to review the plan first.", file=sys.stderr)
|
|
822
|
+
return 1, None
|
|
823
|
+
_, results = prober.run(findings)
|
|
824
|
+
for r in results:
|
|
825
|
+
print(f" {r.verdict:<17} {r.method:<7} {r.subject} {r.reason}")
|
|
826
|
+
updated = apply_results(findings, results)
|
|
827
|
+
by_id = {f.id: f for f in updated}
|
|
828
|
+
for o in findings:
|
|
829
|
+
n = by_id[o.id]
|
|
830
|
+
if n.confidence != o.confidence:
|
|
831
|
+
print(f" {o.id} {o.subject}: {o.confidence.value} -> {n.confidence.value}")
|
|
832
|
+
data = dict(data)
|
|
833
|
+
data["findings"] = [f.to_dict() for f in updated]
|
|
834
|
+
data["runtime"] = {"base_url": prober.base, "requests_sent": prober.sent,
|
|
835
|
+
"results": [{"id": r.finding_id, "subject": r.subject, "method": r.method, "status": r.status,
|
|
836
|
+
"verdict": r.verdict, "reason": r.reason} for r in results]}
|
|
837
|
+
graph = KnowledgeGraph.from_dict(data["graph"]) if "graph" in data else None
|
|
838
|
+
from jscope.reporting.priority import prioritize
|
|
839
|
+
data["priority"] = [{"id": f.id, "priority": pts, "reasons": why} for f, pts, why in prioritize(updated, graph)]
|
|
840
|
+
return 0, data
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def _parse_headers(raw: list[str] | None) -> dict | None:
|
|
844
|
+
headers = {}
|
|
845
|
+
for h in raw or []:
|
|
846
|
+
if ":" not in h:
|
|
847
|
+
print(f"error: --header must look like 'Name: value', got {h!r}", file=sys.stderr)
|
|
848
|
+
return None
|
|
849
|
+
k, v = h.split(":", 1)
|
|
850
|
+
headers[k.strip()] = v.strip()
|
|
851
|
+
return headers
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
def _verify_core(data: dict, base_url: str, allow_hosts: list[str], identities: list, authorize: bool,
|
|
855
|
+
category: str | None, finding_ids: list[str] | None, config: Config, allow_private: bool) -> tuple[int, dict | None]:
|
|
856
|
+
"""Shared by `jscope verify`. Returns (exit_code, updated_payload_or_None)."""
|
|
857
|
+
from jscope.runtime.scope import EmergencyStop, ScopePolicy
|
|
858
|
+
from jscope.verify.engine import VerificationEngine
|
|
859
|
+
rt = config.get("runtime", {})
|
|
860
|
+
policy = ScopePolicy(allow_hosts=set(allow_hosts), allow_private=allow_private,
|
|
861
|
+
rate_per_second=rt.get("rate_per_second", 2.0), max_requests=rt.get("max_requests", 100),
|
|
862
|
+
timeout=rt.get("timeout_seconds", 8.0), stop=EmergencyStop())
|
|
863
|
+
eng = VerificationEngine(policy, base_url, identities=identities, authorized=authorize)
|
|
864
|
+
targets = [f for f in data["findings"] if (category is None or f["category"] == category)
|
|
865
|
+
and (finding_ids is None or f["id"] in finding_ids)
|
|
866
|
+
and f.get("metadata", {}).get("verification_status", "unverified") != "confirmed"]
|
|
867
|
+
if not targets:
|
|
868
|
+
print("no eligible findings matched the given filters (already-confirmed findings are skipped)", file=sys.stderr)
|
|
869
|
+
if not authorize:
|
|
870
|
+
print("NOTE: running without --authorize-active-tests; every result will be 'rejected' (authorization required)", file=sys.stderr)
|
|
871
|
+
results = []
|
|
872
|
+
for f in targets:
|
|
873
|
+
r = eng.verify(f)
|
|
874
|
+
results.append(r)
|
|
875
|
+
f.setdefault("metadata", {})["verification"] = r.to_metadata()
|
|
876
|
+
print(f" {r.status:<12} {f['id']:<10} {f['category']}: {f['subject'][:70]} [{r.procedure}]")
|
|
877
|
+
data = dict(data)
|
|
878
|
+
print(f"\nverified {len(results)} finding(s): " +
|
|
879
|
+
", ".join(f"{s}={sum(1 for r in results if r.status == s)}" for s in ("confirmed", "probable", "inconclusive", "rejected", "unverified")),
|
|
880
|
+
file=sys.stderr)
|
|
881
|
+
return 0, data
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def cmd_verify(args: argparse.Namespace) -> int:
|
|
885
|
+
"""FR-04: attempt to reproduce selected candidate vulnerabilities. Requires --authorize-active-tests."""
|
|
886
|
+
from jscope.verify.engine import TestIdentity
|
|
887
|
+
config = Config.load(args.config)
|
|
888
|
+
with open(args.findings_file) as f:
|
|
889
|
+
data = json.load(f)
|
|
890
|
+
identities = []
|
|
891
|
+
for spec in args.identity or []:
|
|
892
|
+
if "=" not in spec:
|
|
893
|
+
print(f"error: --identity must be NAME=header:value,header:value (got {spec!r})", file=sys.stderr)
|
|
894
|
+
return 1
|
|
895
|
+
name, rest = spec.split("=", 1)
|
|
896
|
+
headers = {}
|
|
897
|
+
for pair in rest.split(","):
|
|
898
|
+
if ":" not in pair:
|
|
899
|
+
print(f"error: malformed header in --identity {spec!r}", file=sys.stderr)
|
|
900
|
+
return 1
|
|
901
|
+
k, v = pair.split(":", 1)
|
|
902
|
+
headers[k.strip()] = v.strip()
|
|
903
|
+
identities.append(TestIdentity(name, headers=headers))
|
|
904
|
+
role_map = {}
|
|
905
|
+
for spec in args.identity_role or []:
|
|
906
|
+
if "=" in spec:
|
|
907
|
+
role_map[spec.split("=", 1)[0]] = spec.split("=", 1)[1]
|
|
908
|
+
for ident in identities:
|
|
909
|
+
if ident.name in role_map:
|
|
910
|
+
ident.role = role_map[ident.name]
|
|
911
|
+
rc, updated = _verify_core(data, args.base_url, args.allow_host or [], identities, args.authorize_active_tests,
|
|
912
|
+
args.category, args.finding_id, config, args.allow_private)
|
|
913
|
+
if rc == 0 and updated and args.out:
|
|
914
|
+
with open(args.out, "w") as fh:
|
|
915
|
+
json.dump(updated, fh, indent=2)
|
|
916
|
+
print(f"[wrote {args.out}]", file=sys.stderr)
|
|
917
|
+
return rc
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def cmd_observe(args: argparse.Namespace) -> int:
|
|
921
|
+
"""FR-03: runtime browser observation, saved as a trace; optionally correlated against a findings payload."""
|
|
922
|
+
from jscope.runtime.observe import Scenario, correlate, novel_routes, observe
|
|
923
|
+
from jscope.runtime.scope import EmergencyStop, ScopePolicy
|
|
924
|
+
scenario = Scenario.load(args.scenario) if args.scenario else Scenario.default(args.path or "/")
|
|
925
|
+
policy = ScopePolicy(allow_hosts=set(args.allow_host or []), allow_private=args.allow_private,
|
|
926
|
+
rate_per_second=args.rate, max_requests=args.max_requests, stop=EmergencyStop())
|
|
927
|
+
trace = observe(args.base_url, policy, scenario, driver=args.driver)
|
|
928
|
+
print(f"driver={trace.driver} status={trace.status} events={len(trace.events)}", file=sys.stderr)
|
|
929
|
+
for r in trace.incomplete_reasons:
|
|
930
|
+
print(f" ! {r}", file=sys.stderr)
|
|
931
|
+
out = trace.to_dict()
|
|
932
|
+
if args.findings_file:
|
|
933
|
+
with open(args.findings_file) as f:
|
|
934
|
+
payload = json.load(f)
|
|
935
|
+
out["correlation"] = correlate(trace, payload)
|
|
936
|
+
out["novel_routes"] = novel_routes(trace, payload)
|
|
937
|
+
if args.out:
|
|
938
|
+
with open(args.out, "w") as fh:
|
|
939
|
+
json.dump(out, fh, indent=2)
|
|
940
|
+
print(f"[wrote {args.out}]", file=sys.stderr)
|
|
941
|
+
else:
|
|
942
|
+
print(json.dumps(out, indent=2))
|
|
943
|
+
return 0
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def cmd_benchmark(args: argparse.Namespace) -> int:
|
|
947
|
+
"""FR-06: run the real-world accuracy benchmark suite and report precision/recall/FP/FN by vulnerability class."""
|
|
948
|
+
from jscope.benchmark.runner import run_benchmark
|
|
949
|
+
report = run_benchmark(check_reproducibility=not args.no_reproducibility_check)
|
|
950
|
+
d = report.to_dict()
|
|
951
|
+
print(f"ran {d['entries']} fixture(s) ({d['difficult_entries']} difficult, reported separately) in {d['duration_s']}s, "
|
|
952
|
+
f"peak memory {d['peak_memory_mb']}MB, reproducible={d['reproducible']}", file=sys.stderr)
|
|
953
|
+
for cls, m in d["by_class"].items():
|
|
954
|
+
print(f" {cls:<20} TP={m['true_positives']} FP={m['false_positives']} FN={m['false_negatives']} "
|
|
955
|
+
f"TN={m['true_negatives']} precision={m['precision']} recall={m['recall']}")
|
|
956
|
+
print(f"overall: precision={d['overall']['precision']} recall={d['overall']['recall']}", file=sys.stderr)
|
|
957
|
+
regressions = []
|
|
958
|
+
if args.baseline:
|
|
959
|
+
with open(args.baseline) as f:
|
|
960
|
+
baseline = json.load(f)
|
|
961
|
+
regressions = report.regressions_against(baseline)
|
|
962
|
+
for r in regressions:
|
|
963
|
+
print(f" REGRESSION: {r['name']} was {r['was']}, now {r['now']}", file=sys.stderr)
|
|
964
|
+
if args.out:
|
|
965
|
+
with open(args.out, "w") as f:
|
|
966
|
+
json.dump(d, f, indent=2)
|
|
967
|
+
print(f"[wrote {args.out}]", file=sys.stderr)
|
|
968
|
+
fail = (d["overall"]["false_positives"] > 0 or d["overall"]["false_negatives"] > 0 or regressions
|
|
969
|
+
or (not d["reproducible"] and d["reproducible"] is not None))
|
|
970
|
+
return 1 if (fail and args.strict) else 0
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
def cmd_confirm(args: argparse.Namespace) -> int:
|
|
974
|
+
"""Opt-in runtime confirmation: safe methods only, allow-listed hosts only. See docs/RUNTIME.md."""
|
|
975
|
+
config = Config.load(args.config)
|
|
976
|
+
with open(args.findings_file) as f:
|
|
977
|
+
data = json.load(f)
|
|
978
|
+
headers = _parse_headers(args.header)
|
|
979
|
+
if headers is None:
|
|
980
|
+
return 1
|
|
981
|
+
rc, updated = _confirm_core(data, args.base_url, args.allow_host or [], args.rate, args.max_requests,
|
|
982
|
+
headers, config, args.dry_run, args.i_have_permission)
|
|
983
|
+
if rc == 0 and updated and args.out:
|
|
984
|
+
with open(args.out, "w") as fh:
|
|
985
|
+
json.dump(updated, fh, indent=2)
|
|
986
|
+
print(f"[wrote {args.out}; {updated['runtime']['requests_sent']} request(s) sent]", file=sys.stderr)
|
|
987
|
+
return rc
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
# Every subcommand name the parser below registers. Used to decide whether a bare `jscope <arg>` should be
|
|
991
|
+
# rewritten to `jscope go <arg>` -- kept as a literal set (not derived from the parser) so it is cheap to check
|
|
992
|
+
# before argparse runs at all; test_cli_entry.py asserts it stays in sync with the real subparser list.
|
|
993
|
+
KNOWN_COMMANDS = {
|
|
994
|
+
"analyze", "explain", "report", "confirm", "verify", "observe", "benchmark", "graph", "plugins", "plugin-new", "download", "crawl", "scan",
|
|
995
|
+
"go", "doctor", "new", "scope-check", "assessment-plan", "scope-discover", "scope-review", "scope-approve", "ws-init", "ws-add", "ws-remove", "ws-list", "ws-scan", "ws-history", "ws-diff", "ws-summary", "ai",
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
def _rewrite_bare_target(argv: list[str]) -> list[str]:
|
|
1000
|
+
"""`jscope <target>` (no subcommand) is shorthand for `jscope go <target>`. Only rewrites when the first
|
|
1001
|
+
token isn't a flag, isn't a known subcommand, and there's at least one argument -- so `jscope`, `jscope -h`
|
|
1002
|
+
and `jscope analyze ...` are all untouched."""
|
|
1003
|
+
if not argv or argv[0].startswith("-") or argv[0] in KNOWN_COMMANDS:
|
|
1004
|
+
return argv
|
|
1005
|
+
return ["go", *argv]
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
1009
|
+
p = argparse.ArgumentParser(prog="jscope", description="JavaScript Intelligence Framework. Run with no subcommand "
|
|
1010
|
+
"and a target (`jscope <url-or-path>`) to just do the right thing.")
|
|
1011
|
+
p.add_argument("--version", "-V", action="version", version=f"jscope {__version__}")
|
|
1012
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
1013
|
+
|
|
1014
|
+
pg2 = sub.add_parser("go", help="do the right thing: analyze a local path, or crawl+download+analyze a URL/host")
|
|
1015
|
+
pg2.add_argument("target", help="a URL/host to scan, or a local file/directory to analyze")
|
|
1016
|
+
pg2.add_argument("--out", default=None, help="output directory (default: jscope-out/<target>/)")
|
|
1017
|
+
pg2.add_argument("--output", "-o", default=None, help="override the report file path")
|
|
1018
|
+
pg2.add_argument("--save-findings", default=None, help="override the findings JSON path")
|
|
1019
|
+
pg2.add_argument("--format", choices=["human", "json", "markdown", "html", "sarif"], default=None,
|
|
1020
|
+
help="report format (default: markdown)")
|
|
1021
|
+
pg2.add_argument("--min-confidence", choices=["CONFIRMED", "HIGH", "MEDIUM", "LOW", "INFORMATIONAL"], default=None)
|
|
1022
|
+
pg2.add_argument("--allow-private", action="store_true", help="permit loopback/private destinations (controlled local testing only)")
|
|
1023
|
+
pg2.add_argument("--no-probe", action="store_true", help="skip well-known OpenAPI/GraphQL spec paths (remote targets only)")
|
|
1024
|
+
pg2.add_argument("-y", "--yes", action="store_true", help="skip the target-confirmation prompt (required in non-interactive sessions)")
|
|
1025
|
+
pg2.add_argument("--confirm", action="store_true", help="after analyzing, also run opt-in runtime confirmation (still needs --i-have-permission to send)")
|
|
1026
|
+
pg2.add_argument("--allow-host", action="append", help="host(s) allowed for --confirm (default: the target's own host)")
|
|
1027
|
+
pg2.add_argument("--i-have-permission", action="store_true", help="required for --confirm to actually send requests")
|
|
1028
|
+
pg2.add_argument("--header", action="append", help="extra header for --confirm, e.g. 'Cookie: session=...'")
|
|
1029
|
+
pg2.add_argument("--rate", type=float, default=None)
|
|
1030
|
+
pg2.add_argument("--max-requests", type=int, default=None)
|
|
1031
|
+
pg2.add_argument("--verbose", action="store_true")
|
|
1032
|
+
pg2.add_argument("--config", default=None)
|
|
1033
|
+
pg2.set_defaults(func=cmd_go)
|
|
1034
|
+
|
|
1035
|
+
pnw = sub.add_parser("new", help="create a domain-centric workspace (no network traffic)")
|
|
1036
|
+
pnw.add_argument("target", help="root DNS domain, e.g. example.com")
|
|
1037
|
+
pnw.add_argument("--workspace", default=".", help="workspace directory (default: current directory)")
|
|
1038
|
+
pnw.set_defaults(func=cmd_new)
|
|
1039
|
+
|
|
1040
|
+
psd = sub.add_parser("scope-discover", help="passively inventory candidate hostnames from host files; no probing")
|
|
1041
|
+
psd.add_argument("manifest", help="current assessment JSON manifest")
|
|
1042
|
+
psd.add_argument("--host-file", action="append", default=[], help="host-per-line candidate file (repeatable)")
|
|
1043
|
+
psd.set_defaults(func=cmd_scope_discover)
|
|
1044
|
+
|
|
1045
|
+
psr = sub.add_parser("scope-review", help="show current assessment scope and approval history")
|
|
1046
|
+
psr.add_argument("manifest")
|
|
1047
|
+
psr.set_defaults(func=cmd_scope_review)
|
|
1048
|
+
|
|
1049
|
+
psa = sub.add_parser("scope-approve", help="explicitly approve known hosts for one assessment")
|
|
1050
|
+
psa.add_argument("manifest")
|
|
1051
|
+
psa.add_argument("hosts", nargs="+", help="candidate hostnames already recorded as known")
|
|
1052
|
+
psa.add_argument("--yes", action="store_true", help="explicit non-interactive approval")
|
|
1053
|
+
psa.set_defaults(func=cmd_scope_approve)
|
|
1054
|
+
|
|
1055
|
+
pap = sub.add_parser("assessment-plan", help="create a per-run scope manifest; planning only, no network traffic")
|
|
1056
|
+
pap.add_argument("domain", help="workspace root domain")
|
|
1057
|
+
pap.add_argument("--scope-file", required=True, help="host-per-line list of authorized in-domain hosts")
|
|
1058
|
+
pap.add_argument("--workspace", default=".", help="directory containing .jscope-ws")
|
|
1059
|
+
pap.add_argument("--approve-scope", action="store_true", help="record explicit approval of every valid host in this scope file for this run")
|
|
1060
|
+
pap.add_argument("--max-requests", type=int, default=500)
|
|
1061
|
+
pap.add_argument("--rate", type=float, default=2.0)
|
|
1062
|
+
pap.add_argument("--depth", choices=["baseline", "deep"], default="deep")
|
|
1063
|
+
pap.set_defaults(func=cmd_assessment_plan)
|
|
1064
|
+
|
|
1065
|
+
psc = sub.add_parser("scope-check", help="validate an explicit host-per-line scope file without network traffic")
|
|
1066
|
+
psc.add_argument("domain", help="root domain for this assessment")
|
|
1067
|
+
psc.add_argument("file", help="text file containing authorized hostnames, one per line")
|
|
1068
|
+
psc.set_defaults(func=cmd_scope_check)
|
|
1069
|
+
|
|
1070
|
+
pdoc = sub.add_parser("doctor", help="diagnose install problems (which jscope is running, what's missing)")
|
|
1071
|
+
pdoc.add_argument("--check-network", action="store_true", help="also check DNS resolution")
|
|
1072
|
+
pdoc.set_defaults(func=cmd_doctor)
|
|
1073
|
+
|
|
1074
|
+
pa = sub.add_parser("analyze", help="analyze existing JS files/directories")
|
|
1075
|
+
pa.add_argument("paths", nargs="+", help="files or directories to analyze")
|
|
1076
|
+
pa.add_argument("--config", default=None)
|
|
1077
|
+
pa.add_argument("--format", choices=["human", "json", "markdown", "html", "sarif"], default=None)
|
|
1078
|
+
pa.add_argument("--output", "-o", default=None, help="write the report to this file instead of stdout")
|
|
1079
|
+
pa.add_argument("--min-confidence", choices=["CONFIRMED", "HIGH", "MEDIUM", "LOW", "INFORMATIONAL"],
|
|
1080
|
+
default=None)
|
|
1081
|
+
pa.add_argument("--verbose", action="store_true")
|
|
1082
|
+
pa.add_argument("--save-findings", default=None, help="path to save findings JSON for `jscope explain` / `jscope graph`")
|
|
1083
|
+
pa.add_argument("--expand-source-maps", action="store_true",
|
|
1084
|
+
help="analyze original sources recovered from on-disk source maps (offline)")
|
|
1085
|
+
pa.add_argument("--plugin-path", action="append", help="directory or file of analyzer plugins (repeatable)")
|
|
1086
|
+
pa.set_defaults(func=cmd_analyze)
|
|
1087
|
+
|
|
1088
|
+
pe = sub.add_parser("explain", help="show full evidence chain for one finding")
|
|
1089
|
+
pe.add_argument("finding_id")
|
|
1090
|
+
pe.add_argument("--findings-file", required=True)
|
|
1091
|
+
pe.set_defaults(func=cmd_explain)
|
|
1092
|
+
|
|
1093
|
+
pr = sub.add_parser("report", help="re-render a saved analysis as human/json/markdown/html/sarif (no re-analysis)")
|
|
1094
|
+
pr.add_argument("source", help="findings .json, or ws:<target> for a workspace scan")
|
|
1095
|
+
pr.add_argument("--format", choices=["human", "json", "markdown", "html", "sarif"], default="markdown")
|
|
1096
|
+
pr.add_argument("--output", "-o", default=None)
|
|
1097
|
+
pr.add_argument("--workspace", default="."); pr.add_argument("--scan", default="latest")
|
|
1098
|
+
pr.add_argument("--verbose", action="store_true")
|
|
1099
|
+
pr.set_defaults(func=cmd_report)
|
|
1100
|
+
|
|
1101
|
+
pk = sub.add_parser("confirm", help="OPT-IN: probe endpoint findings with safe methods to confirm they respond (docs/RUNTIME.md)")
|
|
1102
|
+
pk.add_argument("findings_file", help="saved with `jscope analyze --save-findings`")
|
|
1103
|
+
pk.add_argument("--base-url", required=True, help="origin to test, e.g. https://app.example.com")
|
|
1104
|
+
pk.add_argument("--allow-host", action="append", help="host[:port] you are authorized to test (repeatable, required)")
|
|
1105
|
+
pk.add_argument("--i-have-permission", action="store_true", help="required to actually send; confirms you may test the host")
|
|
1106
|
+
pk.add_argument("--dry-run", action="store_true", help="print the request plan and send nothing")
|
|
1107
|
+
pk.add_argument("--header", action="append", help="extra request header 'Name: value' (e.g. a session cookie you own)")
|
|
1108
|
+
pk.add_argument("--rate", type=float, default=None, help="requests per second (default 2)")
|
|
1109
|
+
pk.add_argument("--max-requests", type=int, default=None, help="hard cap (default 100)")
|
|
1110
|
+
pk.add_argument("--out", default=None, help="write the re-scored findings JSON here")
|
|
1111
|
+
pk.add_argument("--config", default=None)
|
|
1112
|
+
pk.set_defaults(func=cmd_confirm)
|
|
1113
|
+
|
|
1114
|
+
pv = sub.add_parser("verify", help="OPT-IN: attempt to reproduce candidate vulnerabilities (FR-04, docs/VERIFICATION.md)")
|
|
1115
|
+
pv.add_argument("findings_file", help="saved with `jscope analyze --save-findings`")
|
|
1116
|
+
pv.add_argument("--base-url", required=True, help="origin to test, e.g. https://app.example.com")
|
|
1117
|
+
pv.add_argument("--allow-host", action="append", help="host[:port] you are authorized to test (repeatable, required)")
|
|
1118
|
+
pv.add_argument("--authorize-active-tests", action="store_true", help="required to actually run tests; confirms you may test the host")
|
|
1119
|
+
pv.add_argument("--allow-private", action="store_true", help="permit loopback/private destinations (controlled local test)")
|
|
1120
|
+
pv.add_argument("--category", default=None, help="only verify findings in this category (e.g. dataflow, idor)")
|
|
1121
|
+
pv.add_argument("--finding-id", action="append", dest="finding_id", default=None, help="only verify this finding id (repeatable)")
|
|
1122
|
+
pv.add_argument("--identity", action="append", help="test identity NAME=Header:value,Header2:value2 (repeatable; need >=2 for authz checks)")
|
|
1123
|
+
pv.add_argument("--identity-role", action="append", default=None, help="NAME=role for an --identity (e.g. admin=admin)")
|
|
1124
|
+
pv.add_argument("--out", default=None, help="write the updated findings JSON (with verification metadata) here")
|
|
1125
|
+
pv.add_argument("--config", default=None)
|
|
1126
|
+
pv.set_defaults(func=cmd_verify, identity_role=None)
|
|
1127
|
+
|
|
1128
|
+
po = sub.add_parser("observe", help="OPT-IN: runtime browser observation, correlated with static findings (FR-03)")
|
|
1129
|
+
po.add_argument("base_url", help="origin to observe, e.g. https://app.example.com")
|
|
1130
|
+
po.add_argument("--path", default="/", help="path to navigate to if no --scenario is given (default /)")
|
|
1131
|
+
po.add_argument("--scenario", default=None, help="JSON scenario file: {\"steps\": [{\"goto\": \"/\"}, {\"click\": \"#x\"}]}")
|
|
1132
|
+
po.add_argument("--allow-host", action="append", help="host[:port] you are authorized to observe (repeatable, required)")
|
|
1133
|
+
po.add_argument("--allow-private", action="store_true")
|
|
1134
|
+
po.add_argument("--driver", choices=["auto", "playwright", "http"], default="auto")
|
|
1135
|
+
po.add_argument("--rate", type=float, default=2.0)
|
|
1136
|
+
po.add_argument("--max-requests", type=int, default=200)
|
|
1137
|
+
po.add_argument("--findings-file", default=None, help="correlate the trace against a saved findings JSON")
|
|
1138
|
+
po.add_argument("--out", default=None, help="write the trace JSON here (default: print to stdout)")
|
|
1139
|
+
po.set_defaults(func=cmd_observe)
|
|
1140
|
+
|
|
1141
|
+
pb = sub.add_parser("benchmark", help="FR-06: run the accuracy benchmark suite (precision/recall by vuln class, CI gate)")
|
|
1142
|
+
pb.add_argument("--out", default=None, help="write the full benchmark report JSON here")
|
|
1143
|
+
pb.add_argument("--baseline", default=None, help="a previous benchmark report JSON to diff against for regressions")
|
|
1144
|
+
pb.add_argument("--no-reproducibility-check", action="store_true", help="skip the repeated-run determinism check (faster)")
|
|
1145
|
+
pb.add_argument("--strict", action="store_true", help="exit 1 if any false positive/negative, regression, or non-determinism is found")
|
|
1146
|
+
pb.set_defaults(func=cmd_benchmark)
|
|
1147
|
+
|
|
1148
|
+
pg = sub.add_parser("graph", help="print the route -> component -> API -> guard map")
|
|
1149
|
+
pg.add_argument("source", help="a path to analyze, or a findings .json saved with --save-findings")
|
|
1150
|
+
pg.add_argument("--config", default=None)
|
|
1151
|
+
pg.add_argument("--format", choices=["human", "json"], default="human")
|
|
1152
|
+
pg.set_defaults(func=cmd_graph)
|
|
1153
|
+
|
|
1154
|
+
pl = sub.add_parser("plugins", help="list core and loaded plugin analyzers with trust tier")
|
|
1155
|
+
pl.add_argument("--config", default=None)
|
|
1156
|
+
pl.add_argument("--plugin-path", action="append")
|
|
1157
|
+
pl.set_defaults(func=cmd_plugins)
|
|
1158
|
+
|
|
1159
|
+
pn = sub.add_parser("plugin-new", help="scaffold a new analyzer plugin")
|
|
1160
|
+
pn.add_argument("name")
|
|
1161
|
+
pn.add_argument("--out", default="./jscope_plugins")
|
|
1162
|
+
pn.set_defaults(func=cmd_plugin_new)
|
|
1163
|
+
|
|
1164
|
+
pd = sub.add_parser("download", help="download JS from a URL list")
|
|
1165
|
+
pd.add_argument("url_file")
|
|
1166
|
+
pd.add_argument("--out", default="./jscope_project")
|
|
1167
|
+
pd.add_argument("--allow-host", action="append", default=[],
|
|
1168
|
+
help="explicitly authorized hostname (repeat for each host); unguarded downloads are refused")
|
|
1169
|
+
pd.add_argument("--allow-private", action="store_true", help="permit loopback/private destinations (controlled local testing only)")
|
|
1170
|
+
pd.set_defaults(func=cmd_download)
|
|
1171
|
+
|
|
1172
|
+
pc = sub.add_parser("crawl", help="discover JS assets from a live site")
|
|
1173
|
+
pc.add_argument("url")
|
|
1174
|
+
pc.add_argument("--out", default=None, help="write discovered URLs to this file")
|
|
1175
|
+
pc.add_argument("--allow-private", action="store_true", help="permit loopback/private destinations (controlled local testing only)")
|
|
1176
|
+
pc.add_argument("--no-probe", action="store_true", help="do not request well-known spec paths (openapi.json, swagger.json, ...)")
|
|
1177
|
+
pc.set_defaults(func=cmd_crawl)
|
|
1178
|
+
|
|
1179
|
+
ps = sub.add_parser("scan", help="full workflow: crawl -> download -> analyze -> report")
|
|
1180
|
+
ps.add_argument("url")
|
|
1181
|
+
ps.add_argument("--config", default=None)
|
|
1182
|
+
ps.add_argument("--verbose", action="store_true")
|
|
1183
|
+
ps.add_argument("--out", default=None, help="keep the downloaded project in this directory (default: temporary, deleted after)")
|
|
1184
|
+
ps.add_argument("--format", choices=["human", "json", "markdown", "html", "sarif"], default=None)
|
|
1185
|
+
ps.add_argument("--output", "-o", default=None, help="write the report to this file")
|
|
1186
|
+
ps.add_argument("--save-findings", default=None, help="save findings JSON (for report/confirm/explain/graph)")
|
|
1187
|
+
ps.add_argument("--min-confidence", choices=["CONFIRMED", "HIGH", "MEDIUM", "LOW", "INFORMATIONAL"], default=None)
|
|
1188
|
+
ps.add_argument("--allow-private", action="store_true", help="permit loopback/private destinations (controlled local testing only)")
|
|
1189
|
+
ps.add_argument("--no-probe", action="store_true", help="do not request well-known spec paths")
|
|
1190
|
+
ps.set_defaults(func=cmd_scan)
|
|
1191
|
+
|
|
1192
|
+
# --- workspaces
|
|
1193
|
+
def wsp(name, help_, fn):
|
|
1194
|
+
q = sub.add_parser(name, help=help_)
|
|
1195
|
+
q.add_argument("--workspace", default=".", help="directory containing .jscope-ws (default: .)")
|
|
1196
|
+
q.set_defaults(func=fn)
|
|
1197
|
+
return q
|
|
1198
|
+
|
|
1199
|
+
wsp("ws-init", "create a workspace here", cmd_ws_init)
|
|
1200
|
+
q = wsp("ws-add", "register a target (name + one or more paths)", cmd_ws_add)
|
|
1201
|
+
q.add_argument("name"); q.add_argument("paths", nargs="+")
|
|
1202
|
+
q = wsp("ws-remove", "delete a target and its history", cmd_ws_remove)
|
|
1203
|
+
q.add_argument("name")
|
|
1204
|
+
wsp("ws-list", "list targets", cmd_ws_list)
|
|
1205
|
+
q = wsp("ws-scan", "scan a target (or --all); skips if nothing changed, else diffs vs previous", cmd_ws_scan)
|
|
1206
|
+
q.add_argument("name", nargs="?"); q.add_argument("--all", action="store_true")
|
|
1207
|
+
q.add_argument("--config", default=None); q.add_argument("--force", action="store_true")
|
|
1208
|
+
q.add_argument("--report", action="store_true", help="also print the full report")
|
|
1209
|
+
q.add_argument("--verbose", action="store_true")
|
|
1210
|
+
q.add_argument("--fail-on-regression", action="store_true", help="exit 3 on new/raised findings or lost route protection")
|
|
1211
|
+
q.add_argument("--fail-level", default="HIGH", choices=["CONFIRMED", "HIGH", "MEDIUM", "LOW"])
|
|
1212
|
+
q = wsp("ws-history", "list scans for a target", cmd_ws_history)
|
|
1213
|
+
q.add_argument("name")
|
|
1214
|
+
q = wsp("ws-diff", "diff two scans of a target (default: previous -> latest)", cmd_ws_diff)
|
|
1215
|
+
q.add_argument("name"); q.add_argument("--from", dest="frm", default="previous")
|
|
1216
|
+
q.add_argument("--to", default="latest"); q.add_argument("--format", choices=["human", "json"], default="human")
|
|
1217
|
+
q = wsp("ws-summary", "one-line status per target", cmd_ws_summary)
|
|
1218
|
+
q.add_argument("--format", choices=["human", "json"], default="human")
|
|
1219
|
+
|
|
1220
|
+
# --- AI
|
|
1221
|
+
q = sub.add_parser("ai", help="AI commentary over existing findings (opt-in; secrets redacted)")
|
|
1222
|
+
q.add_argument("source", help="findings .json, or ws:<target> for a workspace scan")
|
|
1223
|
+
q.add_argument("--workspace", default="."); q.add_argument("--scan", default="latest")
|
|
1224
|
+
q.add_argument("--config", default=None); q.add_argument("--top", type=int, default=None)
|
|
1225
|
+
q.add_argument("--dry-run", action="store_true", help="print exactly what would be sent, send nothing")
|
|
1226
|
+
q.add_argument("--send-paths", action="store_true", help="include full local file paths (default: basenames)")
|
|
1227
|
+
q.set_defaults(func=cmd_ai)
|
|
1228
|
+
|
|
1229
|
+
return p
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1233
|
+
argv = sys.argv[1:] if argv is None else list(argv)
|
|
1234
|
+
argv = _rewrite_bare_target(argv)
|
|
1235
|
+
parser = build_parser()
|
|
1236
|
+
args = parser.parse_args(argv)
|
|
1237
|
+
return args.func(args)
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
if __name__ == "__main__":
|
|
1241
|
+
sys.exit(main())
|