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
|
@@ -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
|