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
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())