jscope 0.4.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jscope/__init__.py +1 -0
- jscope/__main__.py +10 -0
- jscope/ai/__init__.py +0 -0
- jscope/ai/explain.py +180 -0
- jscope/ai/providers.py +71 -0
- jscope/analyzers/__init__.py +0 -0
- jscope/analyzers/auth_analyzer.py +170 -0
- jscope/analyzers/authorization_analyzer.py +196 -0
- jscope/analyzers/base.py +56 -0
- jscope/analyzers/common.py +78 -0
- jscope/analyzers/dataflow_analyzer.py +192 -0
- jscope/analyzers/endpoint_analyzer.py +150 -0
- jscope/analyzers/framework_detector.py +82 -0
- jscope/analyzers/graphql_analyzer.py +198 -0
- jscope/analyzers/graphql_schema_analyzer.py +132 -0
- jscope/analyzers/html_analyzer.py +196 -0
- jscope/analyzers/manager.py +114 -0
- jscope/analyzers/openapi_analyzer.py +278 -0
- jscope/analyzers/router_analyzer.py +502 -0
- jscope/analyzers/secret_analyzer.py +99 -0
- jscope/analyzers/service_worker_analyzer.py +142 -0
- jscope/analyzers/source_map_analyzer.py +119 -0
- jscope/analyzers/storage_analyzer.py +125 -0
- jscope/analyzers/wasm_analyzer.py +139 -0
- jscope/assessment_engine.py +285 -0
- jscope/assessment_reporting.py +71 -0
- jscope/benchmark/__init__.py +1 -0
- jscope/benchmark/fixtures.py +105 -0
- jscope/benchmark/runner.py +155 -0
- jscope/cli.py +1241 -0
- jscope/config.py +72 -0
- jscope/core/__init__.py +0 -0
- jscope/core/artifacts.py +139 -0
- jscope/core/cache.py +198 -0
- jscope/core/classification.py +56 -0
- jscope/core/evidence.py +411 -0
- jscope/core/graph.py +196 -0
- jscope/core/redact.py +67 -0
- jscope/crawler/__init__.py +0 -0
- jscope/crawler/crawler.py +176 -0
- jscope/dataflow/__init__.py +1 -0
- jscope/dataflow/engine.py +1684 -0
- jscope/dataflow/modules.py +205 -0
- jscope/dataflow/rules.py +118 -0
- jscope/downloader/__init__.py +0 -0
- jscope/downloader/downloader.py +116 -0
- jscope/parsing/__init__.py +0 -0
- jscope/parsing/ast_engine.py +322 -0
- jscope/parsing/graphql_schema.py +153 -0
- jscope/parsing/html.py +55 -0
- jscope/parsing/queries.py +385 -0
- jscope/parsing/wasm.py +146 -0
- jscope/passive_discovery.py +63 -0
- jscope/pipeline.py +196 -0
- jscope/plugin/__init__.py +0 -0
- jscope/plugin/_worker.py +199 -0
- jscope/plugin/loader.py +225 -0
- jscope/plugin/sandbox.py +207 -0
- jscope/reporting/__init__.py +0 -0
- jscope/reporting/formats.py +208 -0
- jscope/reporting/human.py +137 -0
- jscope/reporting/json_report.py +35 -0
- jscope/reporting/priority.py +116 -0
- jscope/runtime/__init__.py +0 -0
- jscope/runtime/observe.py +397 -0
- jscope/runtime/prober.py +259 -0
- jscope/runtime/scope.py +288 -0
- jscope/security_transport.py +246 -0
- jscope/verify/__init__.py +1 -0
- jscope/verify/engine.py +290 -0
- jscope/workspace/__init__.py +0 -0
- jscope/workspace/assessment.py +133 -0
- jscope/workspace/common.py +25 -0
- jscope/workspace/diff.py +143 -0
- jscope/workspace/store.py +206 -0
- jscope-0.4.2.dist-info/METADATA +180 -0
- jscope-0.4.2.dist-info/RECORD +81 -0
- jscope-0.4.2.dist-info/WHEEL +5 -0
- jscope-0.4.2.dist-info/entry_points.txt +2 -0
- jscope-0.4.2.dist-info/licenses/LICENSE +21 -0
- jscope-0.4.2.dist-info/top_level.txt +1 -0
jscope/plugin/sandbox.py
ADDED
|
@@ -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("&", "&").replace("<", "<").replace(">", ">")
|
|
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)
|