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,285 @@
1
+ """Deterministic, scope-aware assessment orchestration primitives.
2
+
3
+ This module intentionally ships conservative baseline/passive stages and module
4
+ contracts. Active checks are opt-in modules and must use the supplied guarded
5
+ transport; discovery never grants authorization.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import re
12
+ import time
13
+ from dataclasses import asdict, dataclass, field
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+ from typing import Any, Callable
17
+ from urllib.parse import urljoin, urlsplit
18
+
19
+ from jscope.security_transport import PolicyError, TransportPolicy, GuardedSession, normalize_host
20
+
21
+
22
+ class AssessmentError(RuntimeError):
23
+ pass
24
+
25
+
26
+ def _stable_id(prefix: str, value: Any) -> str:
27
+ blob = json.dumps(value, sort_keys=True, separators=(",", ":"), default=str).encode()
28
+ return f"{prefix}-{hashlib.sha256(blob).hexdigest()[:12]}"
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class ModuleMetadata:
33
+ name: str
34
+ version: str = "1.0"
35
+ required_permissions: tuple[str, ...] = ()
36
+ traffic: str = "passive"
37
+ authentication: str = "optional"
38
+ impact: str = "low"
39
+ input_types: tuple[str, ...] = ("endpoint",)
40
+ evidence_required: bool = True
41
+ request_budget: int = 10
42
+ time_budget_seconds: float = 30.0
43
+
44
+
45
+ @dataclass
46
+ class Endpoint:
47
+ url: str
48
+ method: str = "GET"
49
+ source: str = "observed"
50
+ parameters: list[str] = field(default_factory=list)
51
+ auth_context: str | None = None
52
+ status: int | None = None
53
+ content_type: str | None = None
54
+ endpoint_id: str = ""
55
+
56
+ def __post_init__(self):
57
+ if not self.endpoint_id:
58
+ self.endpoint_id = _stable_id("EP", [self.method.upper(), self.url])
59
+
60
+
61
+ @dataclass
62
+ class Relationship:
63
+ source_id: str
64
+ target_id: str
65
+ relation: str
66
+ provenance: dict[str, Any]
67
+ confidence: str = "medium"
68
+ observed: bool = False
69
+ relationship_id: str = ""
70
+
71
+ def __post_init__(self):
72
+ if not self.relationship_id:
73
+ self.relationship_id = _stable_id("REL", [self.source_id, self.target_id, self.relation, self.provenance])
74
+
75
+
76
+ @dataclass
77
+ class FindingRecord:
78
+ title: str
79
+ category: str
80
+ severity: str
81
+ confidence: str
82
+ endpoint: str
83
+ method: str
84
+ summary: str
85
+ expected: str = ""
86
+ observed: str = ""
87
+ remediation: str = ""
88
+ evidence: list[dict[str, Any]] = field(default_factory=list)
89
+ related_endpoints: list[str] = field(default_factory=list)
90
+ status: str = "hypothesis"
91
+ finding_id: str = ""
92
+ validated_at: str | None = None
93
+
94
+ def __post_init__(self):
95
+ if not self.finding_id:
96
+ self.finding_id = _stable_id("JSF", [self.category, self.method.upper(), self.endpoint, self.title])
97
+
98
+
99
+ class EvidenceStore:
100
+ """Stores sanitized, content-addressed evidence records."""
101
+ SENSITIVE = re.compile(r"(?i)(authorization|cookie|set-cookie|token|api[_-]?key|password|secret)(\s*[:=]\s*)([^&\s,;]+)")
102
+
103
+ def __init__(self):
104
+ self.records: dict[str, dict[str, Any]] = {}
105
+
106
+ @classmethod
107
+ def sanitize(cls, value: str, limit: int = 4000) -> str:
108
+ value = re.sub(r"(?im)(authorization|proxy-authorization|cookie|set-cookie)(\s*[:=]\s*)[^\r\n]+", r"\1\2[REDACTED]", value)
109
+ value = cls.SENSITIVE.sub(r"\1\2[REDACTED]", value)
110
+ value = re.sub(r"(?i)([?&](?:token|key|password|secret|access_token)=)[^&#\s]+", r"\1[REDACTED]", value)
111
+ return value[:limit]
112
+
113
+ def add(self, kind: str, content: str, metadata: dict[str, Any] | None = None) -> str:
114
+ clean = self.sanitize(content)
115
+ digest = hashlib.sha256(clean.encode()).hexdigest()
116
+ evidence_id = "E-" + digest[:16]
117
+ self.records.setdefault(evidence_id, {
118
+ "evidence_id": evidence_id, "kind": kind, "sha256": digest,
119
+ "content": clean, "metadata": metadata or {},
120
+ "created_at": datetime.now(timezone.utc).isoformat(),
121
+ })
122
+ return evidence_id
123
+
124
+
125
+ class ModuleRegistry:
126
+ def __init__(self):
127
+ self._modules: dict[str, tuple[ModuleMetadata, Callable]] = {}
128
+
129
+ def register(self, metadata: ModuleMetadata, runner: Callable) -> None:
130
+ if metadata.name in self._modules:
131
+ raise AssessmentError(f"duplicate module: {metadata.name}")
132
+ if metadata.traffic not in {"passive", "active"} or metadata.impact not in {"low", "moderate", "high"}:
133
+ raise AssessmentError("invalid module capability metadata")
134
+ if metadata.request_budget < 0 or metadata.time_budget_seconds <= 0:
135
+ raise AssessmentError("invalid module budgets")
136
+ self._modules[metadata.name] = (metadata, runner)
137
+
138
+ def get(self, name: str):
139
+ return self._modules[name]
140
+
141
+ def all(self):
142
+ return [metadata for metadata, _ in self._modules.values()]
143
+
144
+
145
+ class CorrelationEngine:
146
+ """Creates traceable relationships; references are hypotheses, not authorization."""
147
+ def correlate(self, resources: list[dict[str, Any]]) -> list[Relationship]:
148
+ by_url = {}
149
+ by_path = {}
150
+ for item in resources:
151
+ if item.get("url"):
152
+ by_url[item["url"]] = item
153
+ try:
154
+ by_path[urlsplit(item["url"]).path] = item
155
+ except ValueError:
156
+ pass
157
+ links = []
158
+ for item in resources:
159
+ source_id = item.get("id") or _stable_id("R", item)
160
+ text = item.get("content", "")
161
+ base = item.get("url", "")
162
+ pattern = r"""(?:fetch|axios\.(?:get|post|put|delete))\s*\(\s*['"]([^'"]+)['"]|(?:src|href)\s*=\s*['"]([^'"]+)['"]"""
163
+ for match in re.finditer(pattern, text, re.I):
164
+ raw = match.group(1) or match.group(2)
165
+ absolute = urljoin(base, raw) if base else raw
166
+ target = by_url.get(absolute) or by_path.get(urlsplit(absolute).path)
167
+ if target:
168
+ target_id = target.get("id") or _stable_id("R", target)
169
+ links.append(Relationship(source_id, target_id, "references", {
170
+ "source_url": base, "reference": raw, "resolved_url": absolute,
171
+ "method": "static-string-reference",
172
+ }, confidence="medium", observed=False))
173
+ return links
174
+
175
+
176
+ class AssessmentOrchestrator:
177
+ STAGES = ("authorization", "inventory", "passive_discovery", "baseline", "low_impact",
178
+ "correlation", "additional_approval", "active_testing", "validation", "reporting")
179
+
180
+ def __init__(self, assessment_id: str, manifest_path: str | Path,
181
+ policy: TransportPolicy, registry: ModuleRegistry | None = None):
182
+ self.assessment_id = assessment_id
183
+ self.manifest_path = Path(manifest_path)
184
+ self.policy = policy
185
+ self.registry = registry or ModuleRegistry()
186
+ self.evidence = EvidenceStore()
187
+ self.endpoints: dict[str, Endpoint] = {}
188
+ self.relationships: list[Relationship] = []
189
+ self.findings: dict[str, FindingRecord] = {}
190
+ self.events: list[dict[str, Any]] = []
191
+ self.cancelled = False
192
+ if policy.assessment_id != assessment_id:
193
+ raise AssessmentError("transport assessment ID mismatch")
194
+ self._validate_authorization()
195
+
196
+ def _validate_authorization(self):
197
+ try:
198
+ data = json.loads(self.manifest_path.read_text(encoding="utf-8"))
199
+ stat = self.manifest_path.stat()
200
+ except (OSError, ValueError) as exc:
201
+ raise AssessmentError("authorization manifest unavailable or corrupt") from exc
202
+ if data.get("schema") != "jscope-assessment/1" or data.get("assessment_id") != self.assessment_id:
203
+ raise AssessmentError("authorization manifest identity/schema mismatch")
204
+ auth = data.get("authorization")
205
+ if not isinstance(auth, dict) or auth.get("fresh_for_this_run") is not True:
206
+ raise AssessmentError("assessment lacks fresh authorization")
207
+ approved = auth.get("approved_hosts")
208
+ if not isinstance(approved, list) or not approved:
209
+ raise AssessmentError("assessment has no approved hosts")
210
+ normalized = {normalize_host(h) for h in approved}
211
+ if normalized != {normalize_host(h) for h in self.policy.hosts}:
212
+ raise AssessmentError("transport scope does not match approved manifest hosts")
213
+ if data.get("status") != "approved":
214
+ raise AssessmentError("assessment is not in approved state")
215
+ self.manifest_digest = hashlib.sha256(self.manifest_path.read_bytes()).hexdigest()
216
+ self._manifest_mtime_ns = stat.st_mtime_ns
217
+ self._manifest_size = stat.st_size
218
+ self.manifest = data
219
+
220
+ def revalidate(self):
221
+ try:
222
+ stat = self.manifest_path.stat()
223
+ digest = hashlib.sha256(self.manifest_path.read_bytes()).hexdigest()
224
+ except OSError as exc:
225
+ raise AssessmentError("authorization manifest unavailable") from exc
226
+ if (stat.st_mtime_ns != self._manifest_mtime_ns or stat.st_size != self._manifest_size
227
+ or digest != self.manifest_digest):
228
+ raise AssessmentError("authorization manifest changed; restart approval review")
229
+ self._validate_authorization()
230
+
231
+ def add_endpoint(self, endpoint: Endpoint):
232
+ self.revalidate()
233
+ host = normalize_host(urlsplit(endpoint.url).hostname or "")
234
+ if host not in {normalize_host(h) for h in self.policy.hosts}:
235
+ raise AssessmentError("endpoint host is not explicitly approved")
236
+ self.endpoints.setdefault(endpoint.endpoint_id, endpoint)
237
+
238
+ def add_finding(self, finding: FindingRecord):
239
+ # Dedupe key includes category, method and endpoint; title alone is insufficient.
240
+ self.findings.setdefault(finding.finding_id, finding)
241
+
242
+ def cancel(self):
243
+ self.cancelled = True
244
+ self.policy.event("assessment-cancelled", outcome="info", reason="operator-request")
245
+
246
+ def run_modules(self, names: list[str], context: dict[str, Any] | None = None):
247
+ results = []
248
+ for name in names:
249
+ if self.cancelled:
250
+ break
251
+ self.revalidate()
252
+ metadata, runner = self.registry.get(name)
253
+ if metadata.traffic == "active":
254
+ if "active_testing" not in self.manifest.get("authorization", {}).get("permissions", ["active_testing"]):
255
+ raise AssessmentError(f"active permission missing for module {name}")
256
+ if self.manifest.get("status") != "approved":
257
+ raise AssessmentError("active testing requires approved assessment")
258
+ start = time.monotonic()
259
+ before = self.policy._count
260
+ global_budget = self.policy.total_budget
261
+ self.policy.total_budget = min(global_budget, before + metadata.request_budget)
262
+ try:
263
+ result = runner(context or {}, self)
264
+ if time.monotonic() - start > metadata.time_budget_seconds:
265
+ raise AssessmentError(f"module time budget exceeded: {name}")
266
+ results.append({"module": name, "status": "completed", "result": result})
267
+ except Exception as exc:
268
+ results.append({"module": name, "status": "failed", "error": type(exc).__name__})
269
+ self.policy.event("module-failed", outcome="error", reason=type(exc).__name__)
270
+ finally:
271
+ self.policy.total_budget = global_budget
272
+ return results
273
+
274
+ def to_dict(self):
275
+ return {
276
+ "schema_version": "jscope-assessment-result/1",
277
+ "assessment_id": self.assessment_id,
278
+ "manifest_sha256": self.manifest_digest,
279
+ "status": "cancelled" if self.cancelled else "completed",
280
+ "endpoints": [asdict(x) for x in self.endpoints.values()],
281
+ "relationships": [asdict(x) for x in self.relationships],
282
+ "findings": [asdict(x) for x in self.findings.values()],
283
+ "evidence": list(self.evidence.records.values()),
284
+ "coverage": {"known_endpoints": len(self.endpoints), "finding_count": len(self.findings)},
285
+ }
@@ -0,0 +1,71 @@
1
+ """Versioned JSON and Markdown exports for assessment-engine results."""
2
+ from __future__ import annotations
3
+ import html
4
+ import json
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ def assessment_payload(result: dict[str, Any], tool_version: str | None = None) -> dict[str, Any]:
11
+ from jscope import __version__
12
+ out = dict(result)
13
+ out["schema_version"] = out.get("schema_version", "jscope-assessment-result/1")
14
+ out["tool_version"] = tool_version or __version__
15
+ out["generated_at"] = datetime.now(timezone.utc).isoformat()
16
+ findings = out.get("findings", [])
17
+ counts = {}
18
+ for finding in findings:
19
+ sev = str(finding.get("severity", "unknown")).lower()
20
+ counts[sev] = counts.get(sev, 0) + 1
21
+ out["summary"] = {"finding_count": len(findings), "by_severity": counts,
22
+ "endpoint_count": len(out.get("endpoints", []))}
23
+ return out
24
+
25
+
26
+ def render_json(result: dict[str, Any]) -> str:
27
+ return json.dumps(assessment_payload(result), indent=2, sort_keys=True)
28
+
29
+
30
+ def render_markdown(result: dict[str, Any]) -> str:
31
+ p = assessment_payload(result)
32
+ lines = ["# JScope Assessment Report", "",
33
+ f"- Assessment: `{p.get('assessment_id', 'unknown')}`",
34
+ f"- Status: **{p.get('status', 'unknown')}**",
35
+ f"- Generated: {p['generated_at']}",
36
+ f"- Endpoints inventoried: {p['summary']['endpoint_count']}",
37
+ f"- Findings: {p['summary']['finding_count']}", "",
38
+ "## Coverage and limitations", "",
39
+ "Static references and inferred relationships are not proof of authorization or vulnerability.",
40
+ "Untested components must not be interpreted as secure.", "", "## Findings", ""]
41
+ findings = p.get("findings", [])
42
+ if not findings:
43
+ lines.append("No findings were recorded. This is not a statement that the application is secure.")
44
+ for f in findings:
45
+ lines += [f"### {f.get('title', 'Untitled finding')}",
46
+ f"- ID: `{f.get('finding_id', 'unknown')}`",
47
+ f"- Severity / confidence: **{f.get('severity', 'unknown')}** / {f.get('confidence', 'unknown')}",
48
+ f"- Status: {f.get('status', 'hypothesis')}",
49
+ f"- Endpoint: `{f.get('method', 'GET')} {f.get('endpoint', 'unknown')}`",
50
+ "", f.get("summary", ""), ""]
51
+ if f.get("expected"): lines += [f"**Expected:** {f['expected']}", ""]
52
+ if f.get("observed"): lines += [f"**Observed:** {f['observed']}", ""]
53
+ if f.get("remediation"): lines += [f"**Remediation:** {f['remediation']}", ""]
54
+ if f.get("evidence"):
55
+ lines.append("Evidence references: " + ", ".join(f"`{e}`" for e in f["evidence"]))
56
+ lines.append("")
57
+ lines += ["## Endpoints", ""]
58
+ for ep in p.get("endpoints", []):
59
+ lines.append(f"- `{ep.get('method', 'GET')} {ep.get('url', '')}` ({ep.get('source', 'unknown')})")
60
+ return "\n".join(lines).rstrip() + "\n"
61
+
62
+
63
+ def write_report(result: dict[str, Any], path: str | Path, fmt: str = "json") -> Path:
64
+ fmt = fmt.lower()
65
+ if fmt not in {"json", "md", "markdown"}:
66
+ raise ValueError("format must be json or markdown")
67
+ out = Path(path)
68
+ out.parent.mkdir(parents=True, exist_ok=True)
69
+ content = render_json(result) if fmt == "json" else render_markdown(result)
70
+ out.write_text(content, encoding="utf-8")
71
+ return out
@@ -0,0 +1 @@
1
+ """Real-world accuracy benchmark (FR-06)."""
@@ -0,0 +1,105 @@
1
+ """Benchmark fixtures (FR-06): small, hand-verified applications with documented ground truth.
2
+
3
+ Each fixture is a tiny project plus a `BenchmarkEntry` that states what JScope SHOULD conclude and why. Fixtures come in
4
+ matched vulnerable/patched/absent triples wherever practical, because a detector that cannot tell them apart is not
5
+ measuring anything. `difficult` fixtures are known-hard cases (documented limitations, not failures) that are reported
6
+ separately so they never silently drag down the headline numbers.
7
+
8
+ Real downloadable third-party applications are deliberately NOT vendored here (licensing + reproducibility): the
9
+ benchmark is built from synthetic, framework-representative fixtures modelled on common real-world patterns, which is
10
+ what FR-06 asks for when "licensing and test reproducibility permit" -- synthetic fixtures always do.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field
16
+
17
+
18
+ @dataclass
19
+ class BenchmarkEntry:
20
+ name: str
21
+ vuln_class: str # dom_xss | open_redirect | sql_injection | command_injection | idor | none
22
+ files: dict[str, str]
23
+ expect_flag: bool # should JScope (at default min-confidence) report this vuln_class present?
24
+ ground_truth_basis: str # why we know this (hand-verified code review of the fixture)
25
+ test_conditions: str = "default analyzer config, min_confidence=LOW, dataflow_analyzer enabled"
26
+ difficult: bool = False # a known-hard case; reported separately, not folded into headline precision/recall
27
+ note: str = ""
28
+ framework: str = "vanilla"
29
+ minified: bool = False
30
+ tags: tuple[str, ...] = field(default_factory=tuple)
31
+
32
+
33
+ def _entries() -> list[BenchmarkEntry]:
34
+ e: list[BenchmarkEntry] = []
35
+
36
+ e.append(BenchmarkEntry("dom_xss_vulnerable", "dom_xss", {
37
+ "app.js": "const q = new URLSearchParams(location.search).get('q');\ndocument.getElementById('out').innerHTML = q;\n"
38
+ }, True, "innerHTML assigned directly from an unsanitised URL query parameter"))
39
+ e.append(BenchmarkEntry("dom_xss_patched_sanitized", "dom_xss", {
40
+ "app.js": "import DOMPurify from 'dompurify';\nconst q = new URLSearchParams(location.search).get('q');\n"
41
+ "document.getElementById('out').innerHTML = DOMPurify.sanitize(q);\n"
42
+ }, False, "value passes through DOMPurify.sanitize() before reaching innerHTML"))
43
+ e.append(BenchmarkEntry("dom_xss_patched_textcontent", "dom_xss", {
44
+ "app.js": "const q = new URLSearchParams(location.search).get('q');\ndocument.getElementById('out').textContent = q;\n"
45
+ }, False, "textContent is not an HTML sink; value is rendered as text, not markup"))
46
+ e.append(BenchmarkEntry("dom_xss_vulnerable_react", "dom_xss", {
47
+ "Page.jsx": "import React from 'react';\nexport function Page() {\n const h = new URLSearchParams(window.location.search).get('h') ?? '';\n"
48
+ " return <div dangerouslySetInnerHTML={{ __html: h }} />;\n}\n"
49
+ }, True, "dangerouslySetInnerHTML fed directly from the URL", framework="react"))
50
+ e.append(BenchmarkEntry("dom_xss_safe_react_text_prop", "dom_xss", {
51
+ "Page.jsx": "import React from 'react';\nexport function Page({ title }) { const h = new URLSearchParams(window.location.search).get('h');"
52
+ "\n return <div title={h}>{title}</div>;\n}\n"
53
+ }, False, "URL value flows only into a text prop, never into dangerouslySetInnerHTML", framework="react"))
54
+
55
+ e.append(BenchmarkEntry("open_redirect_vulnerable", "open_redirect", {
56
+ "nav.js": "const next = new URLSearchParams(location.search).get('next');\nlocation.href = next;\n"
57
+ }, True, "location.href assigned directly from an unsanitised query parameter"))
58
+ e.append(BenchmarkEntry("open_redirect_patched_allowlist", "open_redirect", {
59
+ "nav.js": "const next = new URLSearchParams(location.search).get('next');\nconst ALLOWED = ['/home', '/profile'];\n"
60
+ "if (ALLOWED.includes(next)) { location.href = next; }\n"
61
+ }, False, "destination is checked against a fixed allow-list of literal paths before navigation"))
62
+
63
+ e.append(BenchmarkEntry("sql_injection_vulnerable", "sql_injection", {
64
+ "db.js": "const mysql = require('mysql');\nfunction getUser(id) { return conn.query('SELECT * FROM users WHERE id = ' + id); }\n"
65
+ "getUser(new URLSearchParams(location.search).get('id'));\n"
66
+ }, True, "query string concatenated directly into SQL text"))
67
+ e.append(BenchmarkEntry("sql_injection_patched_parameterized", "sql_injection", {
68
+ "db.js": "const mysql = require('mysql');\nfunction getUser(id) { return conn.query('SELECT * FROM users WHERE id = ?', [id]); }\n"
69
+ "getUser(new URLSearchParams(location.search).get('id'));\n"
70
+ }, False, "value passed as a bound parameter, never concatenated into the query text"))
71
+
72
+ e.append(BenchmarkEntry("command_injection_vulnerable", "command_injection", {
73
+ "run.js": "const cp = require('child_process');\nconst name = new URLSearchParams(location.search).get('name');\n"
74
+ "cp.exec('echo ' + name);\n"
75
+ }, True, "shell command string built via concatenation with untrusted input"))
76
+ e.append(BenchmarkEntry("command_injection_safe_no_shell_package", "command_injection", {
77
+ "run.js": "const name = new URLSearchParams(location.search).get('name');\nthing.exec('echo ' + name);\n"
78
+ }, False, "no child_process/execa/etc. package is used; `.exec()` here is an unrelated API"))
79
+
80
+ e.append(BenchmarkEntry("clean_app_no_findings", "none", {
81
+ "app.js": "function add(a, b) { return a + b; }\nconsole.log(add(1, 2));\nconst el = document.getElementById('x');\n"
82
+ "el.textContent = 'static label';\n"
83
+ }, False, "no untrusted source reaches any sink at all"))
84
+
85
+ # -- difficult / documented-limitation cases: reported separately, never silently lowering headline numbers --
86
+ e.append(BenchmarkEntry("difficult_minified_bundle", "dom_xss", {
87
+ "bundle.min.js": "function a(b){return document.getElementById(\"x\").innerHTML=b}a(new URLSearchParams(location.search).get(\"q\"))"
88
+ }, True, "same vulnerable pattern, minified: tests that minification alone does not defeat detection",
89
+ difficult=True, minified=True, note="minified identifiers; verifies the engine isn't name-dependent"))
90
+ e.append(BenchmarkEntry("difficult_dynamic_alias_unresolved", "dom_xss", {
91
+ "app.js": "import { render } from '@app/render';\nrender(location.hash);\n" # no tsconfig/alias config provided on purpose
92
+ }, False, "alias has no configuration in this fixture, so the import is legitimately UNRESOLVED; JScope must not "
93
+ "guess a vulnerability out of an unresolved import -- expected outcome is 'not flagged', not a false negative",
94
+ difficult=True, note="documents the resolver's honest behaviour on an unconfigured bundler alias"))
95
+ return e
96
+
97
+
98
+ ENTRIES: list[BenchmarkEntry] = _entries()
99
+
100
+
101
+ def by_class() -> dict[str, list[BenchmarkEntry]]:
102
+ out: dict[str, list[BenchmarkEntry]] = {}
103
+ for e in ENTRIES:
104
+ out.setdefault(e.vuln_class, []).append(e)
105
+ return out
@@ -0,0 +1,155 @@
1
+ """Benchmark runner (FR-06): executes fixtures, scores against ground truth, measures reproducibility and performance.
2
+
3
+ `vuln_class -> sink_class` lets a single lookup table decide whether a fixture's expected vulnerability class was (or
4
+ was not) reported, from the dataflow payload already on AnalysisResult -- no string-matching on finding text.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import statistics
10
+ import time
11
+ import tracemalloc
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from tempfile import TemporaryDirectory
15
+ from typing import Any
16
+
17
+ from jscope.benchmark.fixtures import ENTRIES, BenchmarkEntry
18
+ from jscope.config import Config
19
+ from jscope.pipeline import run_analysis
20
+
21
+ CLASS_TO_SINK = {"dom_xss": "html", "open_redirect": "navigation", "sql_injection": "sql", "command_injection": "command",
22
+ "code_injection": "code"}
23
+
24
+
25
+ @dataclass
26
+ class EntryResult:
27
+ name: str
28
+ vuln_class: str
29
+ difficult: bool
30
+ expected: bool
31
+ actual: bool
32
+ outcome: str # true_positive | true_negative | false_positive | false_negative
33
+ duration_s: float
34
+ findings: int
35
+ note: str = ""
36
+
37
+
38
+ @dataclass
39
+ class ClassMetrics:
40
+ vuln_class: str
41
+ tp: int = 0
42
+ fp: int = 0
43
+ fn: int = 0
44
+ tn: int = 0
45
+
46
+ @property
47
+ def precision(self) -> float | None:
48
+ return self.tp / (self.tp + self.fp) if (self.tp + self.fp) else None
49
+
50
+ @property
51
+ def recall(self) -> float | None:
52
+ return self.tp / (self.tp + self.fn) if (self.tp + self.fn) else None
53
+
54
+ def to_dict(self) -> dict[str, Any]:
55
+ return {"vuln_class": self.vuln_class, "true_positives": self.tp, "false_positives": self.fp,
56
+ "false_negatives": self.fn, "true_negatives": self.tn, "precision": self.precision, "recall": self.recall}
57
+
58
+
59
+ @dataclass
60
+ class BenchmarkReport:
61
+ entries: list[EntryResult] = field(default_factory=list)
62
+ by_class: dict[str, ClassMetrics] = field(default_factory=dict)
63
+ difficult: list[EntryResult] = field(default_factory=list)
64
+ duration_s: float = 0.0
65
+ peak_memory_mb: float = 0.0
66
+ reproducible: bool | None = None
67
+ reproducibility_detail: str = ""
68
+
69
+ def to_dict(self) -> dict[str, Any]:
70
+ agg_tp = sum(m.tp for m in self.by_class.values())
71
+ agg_fp = sum(m.fp for m in self.by_class.values())
72
+ agg_fn = sum(m.fn for m in self.by_class.values())
73
+ return {
74
+ "schema": "jscope-benchmark/1", "entries": len(self.entries), "difficult_entries": len(self.difficult),
75
+ "duration_s": round(self.duration_s, 3), "peak_memory_mb": round(self.peak_memory_mb, 1),
76
+ "reproducible": self.reproducible, "reproducibility_detail": self.reproducibility_detail,
77
+ "overall": {"true_positives": agg_tp, "false_positives": agg_fp, "false_negatives": agg_fn,
78
+ "precision": (agg_tp / (agg_tp + agg_fp)) if (agg_tp + agg_fp) else None,
79
+ "recall": (agg_tp / (agg_tp + agg_fn)) if (agg_tp + agg_fn) else None},
80
+ "by_class": {k: v.to_dict() for k, v in sorted(self.by_class.items())},
81
+ "results": [e.__dict__ for e in self.entries],
82
+ "difficult_results": [e.__dict__ for e in self.difficult],
83
+ }
84
+
85
+ def regressions_against(self, baseline: dict) -> list[dict]:
86
+ """Entries that passed in `baseline` but fail now, by name -- each a line item CI can block a release on."""
87
+ base_by_name = {r["name"]: r["outcome"] for r in baseline.get("results", [])}
88
+ out = []
89
+ for e in self.entries:
90
+ prev = base_by_name.get(e.name)
91
+ if prev in ("true_positive", "true_negative") and e.outcome not in ("true_positive", "true_negative"):
92
+ out.append({"name": e.name, "was": prev, "now": e.outcome})
93
+ return out
94
+
95
+
96
+ def _run_one(entry: BenchmarkEntry) -> tuple[bool, int, float]:
97
+ with TemporaryDirectory(prefix="jscope-bench-") as d:
98
+ root = Path(d)
99
+ for rel, src in entry.files.items():
100
+ p = root / rel
101
+ p.parent.mkdir(parents=True, exist_ok=True)
102
+ p.write_text(src)
103
+ cfg = Config()
104
+ cfg.data["analyzers"]["enabled"] = ["framework_detector", "dataflow_analyzer"]
105
+ cfg.data["min_confidence"] = "LOW"
106
+ t0 = time.monotonic()
107
+ res = run_analysis([str(root)], cfg)
108
+ dt = time.monotonic() - t0
109
+ wanted_sink = CLASS_TO_SINK.get(entry.vuln_class)
110
+ flagged = wanted_sink is not None and any(
111
+ f.category == "dataflow" and f.metadata.get("sink_class") == wanted_sink for f in res.findings)
112
+ return flagged, len(res.findings), dt
113
+
114
+
115
+ def run_benchmark(entries: list[BenchmarkEntry] | None = None, check_reproducibility: bool = True) -> BenchmarkReport:
116
+ entries = entries if entries is not None else ENTRIES
117
+ report = BenchmarkReport()
118
+ tracemalloc.start()
119
+ t0 = time.monotonic()
120
+ for entry in entries:
121
+ actual, n, dt = _run_one(entry)
122
+ if entry.expect_flag and actual:
123
+ outcome = "true_positive"
124
+ elif entry.expect_flag and not actual:
125
+ outcome = "false_negative"
126
+ elif not entry.expect_flag and actual:
127
+ outcome = "false_positive"
128
+ else:
129
+ outcome = "true_negative"
130
+ er = EntryResult(entry.name, entry.vuln_class, entry.difficult, entry.expect_flag, actual, outcome, dt, n, entry.note)
131
+ if entry.difficult:
132
+ report.difficult.append(er)
133
+ continue
134
+ report.entries.append(er)
135
+ m = report.by_class.setdefault(entry.vuln_class, ClassMetrics(entry.vuln_class))
136
+ setattr(m, {"true_positive": "tp", "false_positive": "fp", "false_negative": "fn", "true_negative": "tn"}[outcome],
137
+ getattr(m, {"true_positive": "tp", "false_positive": "fp", "false_negative": "fn", "true_negative": "tn"}[outcome]) + 1)
138
+ report.duration_s = time.monotonic() - t0
139
+ report.peak_memory_mb = tracemalloc.get_traced_memory()[1] / (1024 * 1024)
140
+ tracemalloc.stop()
141
+
142
+ if check_reproducibility:
143
+ sample = [e for e in entries if not e.difficult][:4] or entries[:4]
144
+ runs = []
145
+ for entry in sample:
146
+ flagged, n, _ = _run_one(entry)
147
+ runs.append((flagged, n))
148
+ runs2 = []
149
+ for entry in sample:
150
+ flagged, n, _ = _run_one(entry)
151
+ runs2.append((flagged, n))
152
+ report.reproducible = runs == runs2
153
+ report.reproducibility_detail = "repeated runs of the same fixtures produced identical flagged/finding-count results" \
154
+ if report.reproducible else f"non-deterministic output detected: {runs} != {runs2}"
155
+ return report