jscope 0.4.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. jscope/__init__.py +1 -0
  2. jscope/__main__.py +10 -0
  3. jscope/ai/__init__.py +0 -0
  4. jscope/ai/explain.py +180 -0
  5. jscope/ai/providers.py +71 -0
  6. jscope/analyzers/__init__.py +0 -0
  7. jscope/analyzers/auth_analyzer.py +170 -0
  8. jscope/analyzers/authorization_analyzer.py +196 -0
  9. jscope/analyzers/base.py +56 -0
  10. jscope/analyzers/common.py +78 -0
  11. jscope/analyzers/dataflow_analyzer.py +192 -0
  12. jscope/analyzers/endpoint_analyzer.py +150 -0
  13. jscope/analyzers/framework_detector.py +82 -0
  14. jscope/analyzers/graphql_analyzer.py +198 -0
  15. jscope/analyzers/graphql_schema_analyzer.py +132 -0
  16. jscope/analyzers/html_analyzer.py +196 -0
  17. jscope/analyzers/manager.py +114 -0
  18. jscope/analyzers/openapi_analyzer.py +278 -0
  19. jscope/analyzers/router_analyzer.py +502 -0
  20. jscope/analyzers/secret_analyzer.py +99 -0
  21. jscope/analyzers/service_worker_analyzer.py +142 -0
  22. jscope/analyzers/source_map_analyzer.py +119 -0
  23. jscope/analyzers/storage_analyzer.py +125 -0
  24. jscope/analyzers/wasm_analyzer.py +139 -0
  25. jscope/assessment_engine.py +285 -0
  26. jscope/assessment_reporting.py +71 -0
  27. jscope/benchmark/__init__.py +1 -0
  28. jscope/benchmark/fixtures.py +105 -0
  29. jscope/benchmark/runner.py +155 -0
  30. jscope/cli.py +1241 -0
  31. jscope/config.py +72 -0
  32. jscope/core/__init__.py +0 -0
  33. jscope/core/artifacts.py +139 -0
  34. jscope/core/cache.py +198 -0
  35. jscope/core/classification.py +56 -0
  36. jscope/core/evidence.py +411 -0
  37. jscope/core/graph.py +196 -0
  38. jscope/core/redact.py +67 -0
  39. jscope/crawler/__init__.py +0 -0
  40. jscope/crawler/crawler.py +176 -0
  41. jscope/dataflow/__init__.py +1 -0
  42. jscope/dataflow/engine.py +1684 -0
  43. jscope/dataflow/modules.py +205 -0
  44. jscope/dataflow/rules.py +118 -0
  45. jscope/downloader/__init__.py +0 -0
  46. jscope/downloader/downloader.py +116 -0
  47. jscope/parsing/__init__.py +0 -0
  48. jscope/parsing/ast_engine.py +322 -0
  49. jscope/parsing/graphql_schema.py +153 -0
  50. jscope/parsing/html.py +55 -0
  51. jscope/parsing/queries.py +385 -0
  52. jscope/parsing/wasm.py +146 -0
  53. jscope/passive_discovery.py +63 -0
  54. jscope/pipeline.py +196 -0
  55. jscope/plugin/__init__.py +0 -0
  56. jscope/plugin/_worker.py +199 -0
  57. jscope/plugin/loader.py +225 -0
  58. jscope/plugin/sandbox.py +207 -0
  59. jscope/reporting/__init__.py +0 -0
  60. jscope/reporting/formats.py +208 -0
  61. jscope/reporting/human.py +137 -0
  62. jscope/reporting/json_report.py +35 -0
  63. jscope/reporting/priority.py +116 -0
  64. jscope/runtime/__init__.py +0 -0
  65. jscope/runtime/observe.py +397 -0
  66. jscope/runtime/prober.py +259 -0
  67. jscope/runtime/scope.py +288 -0
  68. jscope/security_transport.py +246 -0
  69. jscope/verify/__init__.py +1 -0
  70. jscope/verify/engine.py +290 -0
  71. jscope/workspace/__init__.py +0 -0
  72. jscope/workspace/assessment.py +133 -0
  73. jscope/workspace/common.py +25 -0
  74. jscope/workspace/diff.py +143 -0
  75. jscope/workspace/store.py +206 -0
  76. jscope-0.4.2.dist-info/METADATA +180 -0
  77. jscope-0.4.2.dist-info/RECORD +81 -0
  78. jscope-0.4.2.dist-info/WHEEL +5 -0
  79. jscope-0.4.2.dist-info/entry_points.txt +2 -0
  80. jscope-0.4.2.dist-info/licenses/LICENSE +21 -0
  81. jscope-0.4.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,207 @@
1
+ """Parent side of plugin isolation (FR-09): run untrusted plugin code in a restricted child process.
2
+
3
+ What the parent guarantees, regardless of what the plugin does:
4
+ * plugin code is never imported into the JScope process (discovery also happens in the child)
5
+ * the child gets a scrubbed environment (no tokens, no HOME), an empty temp cwd, hard wall-clock timeout, rlimits
6
+ * its output is parsed as data and validated: only Candidate-shaped dicts, bounded sizes, known evidence types
7
+ * a plugin can never create a Finding or a confirmed status: output goes through AnalyzerManager like any analyzer's
8
+ * every run is recorded (identity, version, outcome, duration, policy violations)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import base64
14
+ import json
15
+ import os
16
+ import subprocess
17
+ import sys
18
+ import tempfile
19
+ import time
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ import jscope
25
+ from jscope.analyzers.base import Analyzer, ProjectContext
26
+ from jscope.core.evidence import Candidate, Evidence, EvidenceType
27
+
28
+ WORKER = str(Path(__file__).with_name("_worker.py"))
29
+ JSCOPE_ROOT = str(Path(jscope.__file__).resolve().parent.parent)
30
+ MARK = "@@JSCOPE-RESULT@@"
31
+ MAX_OUTPUT_BYTES = 32 * 1024 * 1024
32
+
33
+
34
+ @dataclass
35
+ class SandboxPolicy:
36
+ timeout_seconds: float = 30.0
37
+ memory_mb: int = 1024
38
+ grant_read: list[str] = field(default_factory=list) # extra read-only roots the plugin may open
39
+ max_candidates: int = 5000
40
+
41
+
42
+ @dataclass
43
+ class PluginRun:
44
+ plugin: str
45
+ analyzer: str
46
+ version: str
47
+ tier: str
48
+ isolation: str # "process" | "none"
49
+ outcome: str = "pending" # ok | error | timeout | resource_exhausted | policy_violation | invalid_output
50
+ duration_s: float = 0.0
51
+ violations: list[str] = field(default_factory=list)
52
+ detail: str = ""
53
+ candidates: int = 0
54
+
55
+ def to_dict(self) -> dict[str, Any]:
56
+ return dict(self.__dict__)
57
+
58
+
59
+ def _child_env() -> dict[str, str]:
60
+ return {"PATH": "/usr/bin:/bin", "LANG": "C.UTF-8", "PYTHONDONTWRITEBYTECODE": "1", "PYTHONHASHSEED": "0"}
61
+
62
+
63
+ def _run_worker(request: dict, policy: SandboxPolicy) -> tuple[dict | None, str, str]:
64
+ """Returns (parsed_result|None, outcome_if_failed, detail)."""
65
+ with tempfile.TemporaryDirectory(prefix="jscope-sbx-") as cwd:
66
+ try:
67
+ proc = subprocess.run(
68
+ [sys.executable, "-I", "-B", WORKER], input=json.dumps(request), capture_output=True, text=True, cwd=cwd,
69
+ env=_child_env(), timeout=policy.timeout_seconds)
70
+ except subprocess.TimeoutExpired:
71
+ return None, "timeout", f"killed after {policy.timeout_seconds:g}s wall-clock limit"
72
+ out = proc.stdout or ""
73
+ if len(out.encode("utf-8", "ignore")) > MAX_OUTPUT_BYTES:
74
+ return None, "invalid_output", "plugin output exceeded the size limit"
75
+ idx = out.rfind(MARK)
76
+ if idx < 0:
77
+ if proc.returncode < 0:
78
+ return None, "resource_exhausted", f"worker terminated by signal {-proc.returncode} (CPU/memory limit?)"
79
+ return None, "error", f"worker produced no result (exit {proc.returncode}): {(proc.stderr or '')[-300:].strip()}"
80
+ try:
81
+ return json.loads(out[idx + len(MARK):].strip().splitlines()[0]), "", ""
82
+ except (ValueError, IndexError):
83
+ return None, "invalid_output", "worker result was not valid JSON"
84
+
85
+
86
+ def _base_request(op: str, plugin_path: str, policy: SandboxPolicy) -> dict:
87
+ return {"op": op, "plugin_path": os.path.abspath(plugin_path), "jscope_root": JSCOPE_ROOT,
88
+ "limits": {"cpu_seconds": int(policy.timeout_seconds), "memory_mb": policy.memory_mb},
89
+ "grant_read": policy.grant_read}
90
+
91
+
92
+ def discover(plugin_path: str, policy: SandboxPolicy) -> tuple[list[dict], str | None, list[str]]:
93
+ """Import the plugin inside the sandbox and report its analyzers' metadata. Returns (metas, error, violations)."""
94
+ res, outcome, detail = _run_worker(_base_request("discover", plugin_path, policy), policy)
95
+ if res is None:
96
+ return [], f"sandboxed import failed ({outcome}): {detail}", []
97
+ if not res.get("ok"):
98
+ return [], f"import failed in sandbox: {res.get('error')}", res.get("violations", [])
99
+ if not res.get("declared"):
100
+ return [], "defines neither ANALYZERS nor register(); skipped", []
101
+ return res.get("analyzers", []), None, res.get("violations", [])
102
+
103
+
104
+ class _Foreign:
105
+ """Stand-in for a non-Candidate object the plugin returned; keeps the manager's wording identical."""
106
+
107
+
108
+ def _foreign(name: str):
109
+ return type(name, (_Foreign,), {})()
110
+
111
+
112
+ class SandboxedAnalyzer(Analyzer):
113
+ """Proxy Analyzer whose analyze_project runs in the sandbox worker."""
114
+
115
+ def __init__(self, meta: dict, plugin_path: str, policy: SandboxPolicy, tier: str = "community"):
116
+ self.name = meta["name"]
117
+ self.version = meta.get("version", "0.1.0")
118
+ self.depends_on = list(meta.get("depends_on", []))
119
+ self.run_after = list(meta.get("run_after", []))
120
+ self.produces_categories = list(meta.get("produces_categories", []))
121
+ self.tier = tier
122
+ self.plugin_path = plugin_path
123
+ self.policy = policy
124
+ self.sandboxed = True
125
+
126
+ def analyze_project(self, ctx: ProjectContext) -> list[Any]:
127
+ run = PluginRun(self.plugin_path, self.name, self.version, self.tier, "process")
128
+ ctx.shared.setdefault("plugin_runs", []).append(run)
129
+ t0 = time.monotonic()
130
+ req = _base_request("analyze", self.plugin_path, self.policy)
131
+ req.update({
132
+ "analyzer": self.name, "max_candidates": self.policy.max_candidates,
133
+ "files": [{"path": pf.path, "data": base64.b64encode(pf.source).decode(), "origin": pf.origin}
134
+ for pf in ctx.files if not pf.skipped_reason],
135
+ "artifacts": [{"kind": a.kind, "path": a.path, "data": base64.b64encode(a.data).decode(),
136
+ "content_hash": a.content_hash, "origin": a.origin} for a in ctx.artifacts],
137
+ "framework_hints": _json_safe(ctx.framework_hints), "config": _json_safe(ctx.config), "shared": _json_safe(ctx.shared),
138
+ })
139
+ res, outcome, detail = _run_worker(req, self.policy)
140
+ run.duration_s = round(time.monotonic() - t0, 3)
141
+ if res is None:
142
+ run.outcome, run.detail = outcome, detail
143
+ ctx.diagnostics.append(f"[plugin-isolation] {self.name}: {outcome}: {detail}")
144
+ return []
145
+ run.violations = list(res.get("violations", []))
146
+ if not res.get("ok"):
147
+ err = res.get("error", "")
148
+ run.outcome = "resource_exhausted" if err.startswith("MemoryError") else "error"
149
+ run.detail = err
150
+ if run.violations:
151
+ ctx.diagnostics.append(f"[plugin-isolation] {self.name} attempted forbidden operations: {'; '.join(run.violations)}")
152
+ raise RuntimeError(err) # manager records "[name] crashed: ..." like any analyzer
153
+ if res.get("returned"):
154
+ run.outcome = "invalid_output"
155
+ return _foreign_list(res["returned"])
156
+ out: list[Any] = []
157
+ for d in res.get("candidates", []):
158
+ if "_rejected" in d:
159
+ out.append(_foreign(str(d["_rejected"])))
160
+ continue
161
+ c = _candidate_from_dict(d, ctx)
162
+ if c is not None:
163
+ out.append(c)
164
+ run.candidates = len(out)
165
+ run.outcome = "policy_violation" if run.violations else "ok"
166
+ if run.violations:
167
+ ctx.diagnostics.append(f"[plugin-isolation] {self.name} attempted forbidden operations (blocked): {'; '.join(run.violations)}")
168
+ for d in res.get("diagnostics", []):
169
+ ctx.diagnostics.append(f"[{self.name}] {d}")
170
+ return out
171
+
172
+
173
+ def _foreign_list(name: str):
174
+ return _foreign(name)
175
+
176
+
177
+ def _json_safe(obj, depth=0):
178
+ if depth > 5:
179
+ return None
180
+ if isinstance(obj, (str, int, float, bool)) or obj is None:
181
+ return obj
182
+ if isinstance(obj, dict):
183
+ return {str(k): _json_safe(v, depth + 1) for k, v in obj.items() if not str(k).startswith("_")}
184
+ if isinstance(obj, (list, tuple, set)):
185
+ return [_json_safe(v, depth + 1) for v in obj]
186
+ return None
187
+
188
+
189
+ _VALID_TYPES = {e.value: e for e in EvidenceType}
190
+
191
+
192
+ def _candidate_from_dict(d: dict, ctx: ProjectContext) -> Candidate | None:
193
+ try:
194
+ ev = []
195
+ for e in d.get("evidence", []):
196
+ t = _VALID_TYPES.get(e.get("type"))
197
+ if t is None:
198
+ ctx.diagnostics.append(f"[plugin-isolation] unknown evidence type {e.get('type')!r} dropped")
199
+ continue
200
+ ev.append(Evidence(t, str(e.get("description", "")), str(e.get("analyzer", "")), e.get("file"),
201
+ e.get("line") if isinstance(e.get("line"), int) else None, e.get("snippet"), dict(e.get("metadata") or {})))
202
+ return Candidate(category=str(d["category"]), subject=str(d["subject"]), analyzer=str(d.get("analyzer", "")), evidence=ev,
203
+ attack_surface=d.get("attack_surface"), suggested_test=d.get("suggested_test"),
204
+ related=list(d.get("related") or []), metadata=dict(d.get("metadata") or {}))
205
+ except (KeyError, TypeError, ValueError) as exc:
206
+ ctx.diagnostics.append(f"[plugin-isolation] malformed candidate dropped: {type(exc).__name__}")
207
+ return None
File without changes
@@ -0,0 +1,208 @@
1
+ """Report formats rendered from a saved payload dict (Phase 4: Report Mode, PRD sections 6 and 15).
2
+
3
+ Because they consume the JSON payload, any past scan (a --save-findings file, a workspace scan, a `confirm` output) can be
4
+ re-rendered without re-analysis. Everything that originates in analyzed code (subjects, snippets, file paths) is UNTRUSTED
5
+ and is escaped for the target format: HTML is entity-escaped, Markdown is neutralised, SARIF is data not markup.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import html
12
+ import json
13
+ from typing import Any
14
+
15
+ from jscope import __version__
16
+
17
+ _ORDER = ["CONFIRMED", "HIGH", "MEDIUM", "LOW", "INFORMATIONAL"]
18
+ _SARIF_LEVEL = {"CONFIRMED": "error", "HIGH": "warning", "MEDIUM": "note", "LOW": "note", "INFORMATIONAL": "none"}
19
+
20
+
21
+ def _prio(payload: dict) -> dict[str, dict]:
22
+ return {p["id"]: p for p in payload.get("priority", [])}
23
+
24
+
25
+ def _counts(payload: dict) -> dict[str, int]:
26
+ out: dict[str, int] = {}
27
+ for f in payload["findings"]:
28
+ out[f["confidence"]] = out.get(f["confidence"], 0) + 1
29
+ return out
30
+
31
+
32
+ def _first_loc(f: dict) -> tuple[str | None, int | None]:
33
+ for e in f["evidence"]:
34
+ if e.get("file"):
35
+ return e["file"], e.get("line")
36
+ return None, None
37
+
38
+
39
+ # ----------------------------------------------------------------------------- markdown
40
+ def _md(s: Any) -> str:
41
+ """Neutralise untrusted text for inline Markdown: no formatting, links, HTML or table breaks."""
42
+ t = str(s).replace("\\", "\\\\").replace("\n", " ").replace("\r", " ")
43
+ t = t.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
44
+ for ch in "`*_[]|#!":
45
+ t = t.replace(ch, "\\" + ch)
46
+ return t
47
+
48
+
49
+ def _fence(s: str) -> str:
50
+ longest = max([len(r) for r in __import__("re").findall(r"`+", s)] + [0])
51
+ bar = "`" * max(3, longest + 1)
52
+ return f"{bar}\n{s}\n{bar}"
53
+
54
+
55
+ def to_markdown(payload: dict, top_n: int = 10) -> str:
56
+ fs, prio = payload["findings"], _prio(payload)
57
+ by_id = {f["id"]: f for f in fs}
58
+ L = ["# JScope report", "", f"_JScope {__version__} · schema {payload.get('schema_version', '?')}_", ""]
59
+ s = payload["summary"]
60
+ L += ["## Snapshot", "", f"- files analyzed: **{s['file_count']}**",
61
+ f"- frameworks: {', '.join(map(_md, s['frameworks'])) or 'none detected'}", f"- findings: **{len(fs)}** (" +
62
+ ", ".join(f"{k} {v}" for k, v in sorted(_counts(payload).items(), key=lambda kv: _ORDER.index(kv[0]))) + ")", ""]
63
+ ranked = sorted((p for p in payload.get("priority", []) if p["id"] in by_id), key=lambda p: -p["priority"])[:top_n]
64
+ if ranked:
65
+ L += ["## Where to start testing", "", "| # | Priority | Confidence | Finding | Why |", "|---|---|---|---|---|"]
66
+ for i, p in enumerate(ranked, 1):
67
+ f = by_id[p["id"]]
68
+ L.append(f"| {i} | {p['priority']} | {f['confidence']} | `{f['id']}` {_md(f['category'])}: {_md(f['subject'])} | {_md('; '.join(p['reasons']))} |")
69
+ L.append("")
70
+ L += ["### Suggested first tests", ""]
71
+ for p in ranked[:5]:
72
+ f = by_id[p["id"]]
73
+ if f.get("suggested_test"):
74
+ L.append(f"- **{f['id']}** {_md(f['subject'])}: {_md(f['suggested_test'])}")
75
+ L.append("")
76
+ routes = payload.get("route_map") or []
77
+ if routes:
78
+ L += ["## Route map", "", "| Route | Protection | Endpoints |", "|---|---|---|"]
79
+ for r in routes[:40]:
80
+ prot = "**UNPROTECTED**" if r["unguarded"] else _md(", ".join(r["guards"] or ["meta"]) + (" / roles: " + ", ".join(r["roles"]) if r["roles"] else ""))
81
+ L.append(f"| `{_md(r['route'])}` | {prot} | {_md(', '.join(r['endpoints'][:6]))} |")
82
+ L.append("")
83
+ L += ["## Findings", ""]
84
+ cats: dict[str, list[dict]] = {}
85
+ for f in fs:
86
+ cats.setdefault(f["category"], []).append(f)
87
+ for cat in sorted(cats):
88
+ L += [f"### {_md(cat)} ({len(cats[cat])})", ""]
89
+ for f in cats[cat]:
90
+ L += [f"#### `{f['id']}` {_md(f['subject'])}", "",
91
+ f"**{f['confidence']}** (score {f['score']}) · analyzers: {_md(', '.join(f['contributing_analyzers']))}"
92
+ + (f" · surface: {_md(f['attack_surface'])}" if f["attack_surface"] else ""), ""]
93
+ cl = f.get("classification") or {}
94
+ if cl:
95
+ L += [f"Class: **{_md(cl.get('evidence_class', '?'))}** · verification: **{_md(cl.get('verification_status', '?'))}**"
96
+ " (confidence is not exploitability)", ""]
97
+ L += [f"- limitation: {_md(x)}" for x in cl.get("limitations", [])] + ([""] if cl.get("limitations") else [])
98
+ if f.get("suggested_test"):
99
+ L += [f"> {_md(f['suggested_test'])}", ""]
100
+ L += ["Reasoning:", ""] + [f"- {_md(r)}" for r in f["reasoning"]] + [""]
101
+ ev = [e for e in f["evidence"] if e.get("snippet")][:3]
102
+ for e in ev:
103
+ loc = f"{e['file']}:{e['line']}" if e.get("file") else "-"
104
+ L += [f"`{_md(e['type'])}` {_md(loc)}", "", _fence(str(e["snippet"])), ""]
105
+ if payload.get("diagnostics"):
106
+ L += ["## Diagnostics", ""] + [f"- {_md(d)}" for d in payload["diagnostics"]] + [""]
107
+ return "\n".join(L)
108
+
109
+
110
+ # ----------------------------------------------------------------------------- html
111
+ _CSS = ("body{font:15px/1.5 system-ui,sans-serif;max-width:980px;margin:2rem auto;padding:0 1rem;color:#1b1f23;background:#fff}"
112
+ "h1,h2{border-bottom:1px solid #d0d7de;padding-bottom:.3rem}code,pre{background:#f6f8fa;border-radius:4px;font-size:13px}"
113
+ "pre{padding:.6rem;overflow:auto}code{padding:.1rem .3rem}table{border-collapse:collapse;width:100%}"
114
+ "td,th{border:1px solid #d0d7de;padding:.35rem .5rem;text-align:left;vertical-align:top}"
115
+ ".c{display:inline-block;border-radius:10px;padding:0 .55rem;font-size:12px;font-weight:600;color:#fff}"
116
+ ".CONFIRMED{background:#8250df}.HIGH{background:#cf222e}.MEDIUM{background:#bc4c00}.LOW{background:#9a6700}.INFORMATIONAL{background:#57606a}"
117
+ "details{border:1px solid #d0d7de;border-radius:6px;margin:.4rem 0;padding:.3rem .7rem}summary{cursor:pointer}"
118
+ "@media(prefers-color-scheme:dark){body{background:#0d1117;color:#e6edf3}code,pre{background:#161b22}td,th,details,h1,h2{border-color:#30363d}}")
119
+
120
+
121
+ def to_html(payload: dict, top_n: int = 10) -> str:
122
+ e = html.escape
123
+ fs, prio = payload["findings"], _prio(payload)
124
+ by_id = {f["id"]: f for f in fs}
125
+ s = payload["summary"]
126
+ chip = lambda c: f'<span class="c {e(c)}">{e(c)}</span>'
127
+ h = ["<!doctype html><html lang=en><head><meta charset=utf-8>",
128
+ "<meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'none'; style-src 'unsafe-inline'; img-src data:\">",
129
+ f"<meta name=viewport content=\"width=device-width,initial-scale=1\"><title>JScope report</title><style>{_CSS}</style></head><body>",
130
+ f"<h1>JScope report</h1><p><small>JScope {e(__version__)} · schema {e(str(payload.get('schema_version', '?')))}</small></p>",
131
+ "<h2>Snapshot</h2><ul>"
132
+ f"<li>files analyzed: <b>{s['file_count']}</b></li><li>frameworks: {e(', '.join(s['frameworks']) or 'none detected')}</li>"
133
+ f"<li>findings: <b>{len(fs)}</b> " + " ".join(f"{chip(k)} {v}" for k, v in sorted(_counts(payload).items(), key=lambda kv: _ORDER.index(kv[0]))) + "</li></ul>"]
134
+ ranked = sorted((p for p in payload.get("priority", []) if p["id"] in by_id), key=lambda p: -p["priority"])[:top_n]
135
+ if ranked:
136
+ h.append("<h2>Where to start testing</h2><table><tr><th>#<th>Priority<th>Finding<th>Why / first test</tr>")
137
+ for i, p in enumerate(ranked, 1):
138
+ f = by_id[p["id"]]
139
+ h.append(f"<tr><td>{i}<td>{p['priority']}<td>{chip(f['confidence'])} <code>{e(f['id'])}</code> {e(f['category'])}: {e(f['subject'])}"
140
+ f"<td>{e('; '.join(p['reasons']))}" + (f"<br><b>Test:</b> {e(f['suggested_test'])}" if f.get("suggested_test") else "") + "</tr>")
141
+ h.append("</table>")
142
+ routes = payload.get("route_map") or []
143
+ if routes:
144
+ h.append("<h2>Route map</h2><table><tr><th>Route<th>Protection<th>Endpoints</tr>")
145
+ for r in routes[:60]:
146
+ prot = "<b>UNPROTECTED</b>" if r["unguarded"] else e(", ".join(r["guards"] or ["meta"]) + (" / roles: " + ", ".join(r["roles"]) if r["roles"] else ""))
147
+ h.append(f"<tr><td><code>{e(r['route'])}</code><td>{prot}<td>{e(', '.join(r['endpoints'][:8]))}</tr>")
148
+ h.append("</table>")
149
+ h.append("<h2>Findings</h2>")
150
+ for f in fs:
151
+ h.append(f"<details><summary>{chip(f['confidence'])} <code>{e(f['id'])}</code> {e(f['category'])}: {e(f['subject'])}</summary>"
152
+ f"<p>score {f['score']} · analyzers: {e(', '.join(f['contributing_analyzers']))}"
153
+ + (f" · surface: {e(f['attack_surface'])}" if f["attack_surface"] else "") + "</p>"
154
+ + (f"<p><b>Class:</b> {e((f.get('classification') or {}).get('evidence_class', '?'))} · <b>verification:</b> "
155
+ f"{e((f.get('classification') or {}).get('verification_status', '?'))} <small>(confidence is not exploitability)</small></p>"
156
+ + "".join(f"<p><small>limitation: {e(x)}</small></p>" for x in (f.get('classification') or {}).get('limitations', [])))
157
+ + (f"<p><b>Suggested test:</b> {e(f['suggested_test'])}</p>" if f.get("suggested_test") else "")
158
+ + "<p><b>Reasoning</b></p><ul>" + "".join(f"<li>{e(r)}</li>" for r in f["reasoning"]) + "</ul>")
159
+ for ev in [x for x in f["evidence"] if x.get("snippet")][:4]:
160
+ loc = f"{ev['file']}:{ev['line']}" if ev.get("file") else "-"
161
+ h.append(f"<p><small>{e(ev['type'])} · {e(loc)}</small></p><pre>{e(str(ev['snippet']))}</pre>")
162
+ h.append("</details>")
163
+ if payload.get("diagnostics"):
164
+ h.append("<h2>Diagnostics</h2><ul>" + "".join(f"<li>{e(d)}</li>" for d in payload["diagnostics"]) + "</ul>")
165
+ h.append("</body></html>")
166
+ return "".join(h)
167
+
168
+
169
+ # ----------------------------------------------------------------------------- SARIF 2.1.0
170
+ def to_sarif(payload: dict) -> str:
171
+ prio = _prio(payload)
172
+ rules: dict[str, dict] = {}
173
+ results = []
174
+ for f in payload["findings"]:
175
+ rid = f"jscope/{f['category']}" + (f"/{f['attack_surface']}" if f["attack_surface"] else "")
176
+ rules.setdefault(rid, {"id": rid, "name": rid.replace("/", "."),
177
+ "shortDescription": {"text": f"JScope {f['category']} finding" + (f" ({f['attack_surface']})" if f["attack_surface"] else "")},
178
+ "helpUri": "https://github.com/jscope/jscope/blob/main/docs/EVIDENCE_SCHEMA.md"})
179
+ file, line = _first_loc(f)
180
+ key = hashlib.sha256(f"{f['category']}|{f['subject']}".encode()).hexdigest()[:32] # stable across scans (JS-#### ids are not)
181
+ msg = f"{f['subject']} [{f['confidence']}, score {f['score']}]" + (f". Suggested test: {f['suggested_test']}" if f.get("suggested_test") else "")
182
+ res: dict[str, Any] = {
183
+ "ruleId": rid, "level": _SARIF_LEVEL.get(f["confidence"], "note"), "message": {"text": msg},
184
+ "partialFingerprints": {"jscopeKey/v1": key},
185
+ "properties": {"jscopeId": f["id"], "evidenceClass": (f.get("classification") or {}).get("evidence_class"),
186
+ "verificationStatus": (f.get("classification") or {}).get("verification_status"), "confidence": f["confidence"], "score": f["score"], "attackSurface": f["attack_surface"],
187
+ "analyzers": f["contributing_analyzers"], "reasoning": f["reasoning"],
188
+ **({"priority": prio[f["id"]]["priority"]} if f["id"] in prio else {})}}
189
+ if file:
190
+ loc: dict[str, Any] = {"artifactLocation": {"uri": str(file).replace("\\", "/")}}
191
+ if line:
192
+ loc["region"] = {"startLine": int(line)}
193
+ res["locations"] = [{"physicalLocation": loc}]
194
+ results.append(res)
195
+ doc = {"$schema": "https://json.schemastore.org/sarif-2.1.0.json", "version": "2.1.0",
196
+ "runs": [{"tool": {"driver": {"name": "JScope", "version": __version__, "informationUri": "https://github.com/jscope/jscope",
197
+ "rules": sorted(rules.values(), key=lambda r: r["id"])}},
198
+ "results": results,
199
+ "invocations": [{"executionSuccessful": True, "toolExecutionNotifications":
200
+ [{"level": "note", "message": {"text": d}} for d in payload.get("diagnostics", [])]}]}]}
201
+ return json.dumps(doc, indent=2)
202
+
203
+
204
+ FORMATS = {"markdown": to_markdown, "md": to_markdown, "html": to_html, "sarif": to_sarif}
205
+
206
+
207
+ def render_payload(payload: dict, fmt: str) -> str:
208
+ return FORMATS[fmt](payload)
@@ -0,0 +1,137 @@
1
+ """Human-readable report. Structure per design-brainstorm.md §7:
2
+ executive snapshot -> attack-surface dossiers -> full findings appendix.
3
+ Greppable, not decorative -- no ASCII art, stable field ordering."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from jscope.core.evidence import Confidence, Finding
8
+ from jscope.core.graph import KnowledgeGraph, NodeKind
9
+ from jscope.reporting.priority import PriorityDelta, prioritize
10
+
11
+ _CONF_ORDER = [Confidence.CONFIRMED, Confidence.HIGH, Confidence.MEDIUM,
12
+ Confidence.LOW, Confidence.INFORMATIONAL]
13
+
14
+
15
+ def render(findings: list[Finding], frameworks: list[str], file_count: int,
16
+ verbose: bool = False, top_n: int = 5, graph: KnowledgeGraph | None = None,
17
+ diagnostics: list[str] | None = None, delta: PriorityDelta | None = None,
18
+ preface: str | None = None, artifacts: dict | None = None) -> str:
19
+ lines: list[str] = []
20
+ lines.append("=" * 70)
21
+ lines.append("JSCOPE ANALYSIS REPORT")
22
+ lines.append("=" * 70)
23
+ lines.append("")
24
+ if preface:
25
+ lines.append(preface)
26
+ lines.append("")
27
+ lines.append(f"[executive snapshot]")
28
+ lines.append(f" files analyzed : {file_count}")
29
+ lines.append(f" frameworks : {', '.join(frameworks) if frameworks else 'none detected'}")
30
+ if artifacts:
31
+ lines.append(" artifacts : " + ", ".join(f"{k}={v}" for k, v in sorted(artifacts.items())))
32
+ lines.append(f" findings : {len(findings)}")
33
+ for level in _CONF_ORDER:
34
+ n = sum(1 for f in findings if f.confidence == level)
35
+ if n:
36
+ lines.append(f" {level.value:<14} {n}")
37
+ lines.append("")
38
+
39
+ ranked = prioritize(findings, graph, delta)
40
+ if ranked:
41
+ lines.append("[where to start testing] (deterministic ranking; reasons shown)")
42
+ for f, pts, why in ranked[:top_n]:
43
+ lines.append(f" {pts:>3} [{f.confidence.value}] {f.id} {f.category}: {f.subject}")
44
+ if f.suggested_test:
45
+ lines.append(f" test: {f.suggested_test}")
46
+ if verbose:
47
+ lines.append(f" why : {'; '.join(why)}")
48
+ lines.append("")
49
+
50
+ if graph is not None and graph.by_kind(NodeKind.ROUTE):
51
+ chains = graph.route_chains()
52
+ lines.append(f"[route map] {len(chains)} route(s) (R=route G=guard role=declared role E=endpoint)")
53
+ shown = chains if verbose else sorted(chains, key=lambda c: (not c["unguarded"], c["route"]))[:top_n * 2]
54
+ for c in shown:
55
+ prot = "UNPROTECTED" if c["unguarded"] else "guards=" + ",".join(c["guards"] or ["meta"]) + (
56
+ " roles=" + ",".join(c["roles"]) if c["roles"] else "")
57
+ lines.append(f" R {c['route']:<28} {prot}")
58
+ for ep in c["endpoints"]:
59
+ lines.append(f" E {ep}")
60
+ if not verbose and len(chains) > len(shown):
61
+ lines.append(f" ... {len(chains) - len(shown)} more (use --verbose)")
62
+ lines.append("")
63
+
64
+ surfaces: dict[str, list[Finding]] = {}
65
+ for f in findings:
66
+ if f.attack_surface:
67
+ surfaces.setdefault(f.attack_surface, []).append(f)
68
+
69
+ if surfaces:
70
+ lines.append("[priority attack surfaces]")
71
+ ranked = sorted(surfaces.items(), key=lambda kv: -len(kv[1]))
72
+ for surface, items in ranked[:top_n]:
73
+ lines.append(f" * {surface} ({len(items)} finding(s))")
74
+ for f in sorted(items, key=lambda x: -x.score)[:3]:
75
+ lines.append(f" - [{f.confidence.value}] {f.id} {f.subject}")
76
+ if f.suggested_test:
77
+ lines.append(f" test: {f.suggested_test}")
78
+ lines.append("")
79
+
80
+ lines.append("[findings appendix]")
81
+ by_category: dict[str, list[Finding]] = {}
82
+ for f in findings:
83
+ by_category.setdefault(f.category, []).append(f)
84
+
85
+ for category, items in sorted(by_category.items()):
86
+ lines.append(f" -- {category} ({len(items)}) --")
87
+ for f in items:
88
+ lines.append(f" {f.id} [{f.confidence.value:<13} score={f.score:>2}] {f.subject}")
89
+ if verbose:
90
+ lines.append(f" analyzers: {', '.join(f.contributing_analyzers)}")
91
+ for r in f.reasoning:
92
+ lines.append(f" - {r}")
93
+ lines.append("")
94
+
95
+ if diagnostics:
96
+ lines.append("[diagnostics]")
97
+ for d in diagnostics:
98
+ lines.append(f" ! {d}")
99
+ lines.append("")
100
+ lines.append(f"(run `jscope explain <finding-id>` for the full evidence chain on any finding)")
101
+ return "\n".join(lines)
102
+
103
+
104
+ def render_explain(finding: Finding) -> str:
105
+ lines = [f"Finding {finding.id}: {finding.subject}", "-" * 60]
106
+ lines.append(f"category : {finding.category}")
107
+ lines.append(f"confidence : {finding.confidence.value} (score={finding.score})")
108
+ lines.append(f"attack_surface : {finding.attack_surface or '-'}")
109
+ tiers = finding.metadata.get("analyzer_tiers", {})
110
+ lines.append("contributed_by : " + ", ".join(f"{a} [{tiers.get(a, 'core')}]" for a in finding.contributing_analyzers))
111
+ lines.append(f"suggested_test : {finding.suggested_test or '-'}")
112
+ from jscope.core.classification import classify
113
+ cl = classify(finding.category, finding.metadata, {e.type.value for e in finding.evidence}, finding.confidence.value)
114
+ lines.append(f"evidence_class : {cl['evidence_class']}")
115
+ lines.append(f"verification : {cl['verification_status']} (confidence is not exploitability)")
116
+ for lim in cl.get("limitations", []):
117
+ lines.append(f"limitation : {lim}")
118
+ flow = finding.metadata.get("flow")
119
+ if flow:
120
+ lines.append("")
121
+ lines.append("data-flow path:")
122
+ for st in flow["path"]:
123
+ lines.append(f" {st['file'].split('/')[-1]}:{st['line']} {st['step']}")
124
+ for m in flow.get("mitigations", []):
125
+ lines.append(f" mitigation: {m['name']} effective_for={m['effective_for']} uncertain_for={m['uncertain_for']}")
126
+ lines.append("")
127
+ lines.append("reasoning chain:")
128
+ for r in finding.reasoning:
129
+ lines.append(f" - {r}")
130
+ lines.append("")
131
+ lines.append("raw evidence:")
132
+ for e in finding.evidence:
133
+ loc = f"{e.file}:{e.line}" if e.file else "-"
134
+ lines.append(f" [{e.type.value}] ({loc}) {e.description}")
135
+ if e.snippet:
136
+ lines.append(f" snippet: {e.snippet}")
137
+ return "\n".join(lines)
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ from jscope.core.evidence import Finding
6
+ from jscope.core.graph import KnowledgeGraph
7
+ from jscope.reporting.priority import PriorityDelta, prioritize
8
+
9
+
10
+ def payload(findings: list[Finding], frameworks: list[str], file_count: int,
11
+ graph: KnowledgeGraph | None = None, diagnostics: list[str] | None = None,
12
+ delta: PriorityDelta | None = None, dataflow: dict | None = None,
13
+ file_diagnostics: dict | None = None) -> dict:
14
+ out = {
15
+ "schema_version": "0.5",
16
+ "summary": {"file_count": file_count, "frameworks": frameworks, "finding_count": len(findings)},
17
+ "findings": [f.to_dict() for f in findings],
18
+ "priority": [{"id": f.id, "priority": pts, "reasons": why} for f, pts, why in prioritize(findings, graph, delta)],
19
+ "diagnostics": diagnostics or [],
20
+ }
21
+ if dataflow:
22
+ out["dataflow"] = dataflow
23
+ if file_diagnostics:
24
+ out["file_diagnostics"] = file_diagnostics
25
+ if graph is not None:
26
+ out["graph"] = graph.to_dict()
27
+ out["route_map"] = graph.route_chains()
28
+ return out
29
+
30
+
31
+ def render(findings: list[Finding], frameworks: list[str], file_count: int,
32
+ graph: KnowledgeGraph | None = None, diagnostics: list[str] | None = None,
33
+ delta: PriorityDelta | None = None, dataflow: dict | None = None,
34
+ file_diagnostics: dict | None = None) -> str:
35
+ return json.dumps(payload(findings, frameworks, file_count, graph, diagnostics, delta, dataflow, file_diagnostics), indent=2)