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,133 @@
|
|
|
1
|
+
"""Domain-centric assessment scope and audit records.
|
|
2
|
+
|
|
3
|
+
This module deliberately separates discovered hosts from hosts approved for the
|
|
4
|
+
current assessment. Workspace history is knowledge, never standing permission.
|
|
5
|
+
No network activity is performed here.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import ipaddress
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import secrets
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from urllib.parse import urlparse
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from jscope.workspace.common import WorkspaceError, _atomic_write
|
|
20
|
+
|
|
21
|
+
_LABEL = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def normalize_domain(value: str) -> str:
|
|
25
|
+
"""Normalize a DNS name or URL to a hostname; reject ambiguous/non-domain inputs."""
|
|
26
|
+
raw = value.strip()
|
|
27
|
+
if not raw:
|
|
28
|
+
raise WorkspaceError("target domain cannot be empty")
|
|
29
|
+
if "://" in raw:
|
|
30
|
+
parsed = urlparse(raw)
|
|
31
|
+
if parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.username or parsed.password:
|
|
32
|
+
raise WorkspaceError("target must be a DNS domain or an http(s) URL without credentials")
|
|
33
|
+
if parsed.path not in ("", "/") or parsed.query or parsed.fragment:
|
|
34
|
+
raise WorkspaceError("provide a domain/origin only, not a path, query, or fragment")
|
|
35
|
+
host = parsed.hostname
|
|
36
|
+
else:
|
|
37
|
+
if any(ch in raw for ch in "/?#@"):
|
|
38
|
+
raise WorkspaceError(f"invalid domain {value!r}; provide a hostname only")
|
|
39
|
+
host = raw.rstrip(".")
|
|
40
|
+
if ":" in host:
|
|
41
|
+
raise WorkspaceError("ports and IP literals are not supported as domain workspace roots")
|
|
42
|
+
try:
|
|
43
|
+
host = host.encode("idna").decode("ascii").lower().rstrip(".")
|
|
44
|
+
except UnicodeError as exc:
|
|
45
|
+
raise WorkspaceError(f"invalid internationalized domain: {value!r}") from exc
|
|
46
|
+
if len(host) > 253 or "." not in host or host.startswith("."):
|
|
47
|
+
raise WorkspaceError(f"{value!r} is not a fully qualified DNS domain")
|
|
48
|
+
try:
|
|
49
|
+
ipaddress.ip_address(host)
|
|
50
|
+
except ValueError:
|
|
51
|
+
pass
|
|
52
|
+
else:
|
|
53
|
+
raise WorkspaceError("domain-centric workspaces require a DNS name, not an IP address")
|
|
54
|
+
labels = host.split(".")
|
|
55
|
+
if any(not _LABEL.fullmatch(label) for label in labels):
|
|
56
|
+
raise WorkspaceError(f"invalid DNS hostname: {value!r}")
|
|
57
|
+
return host
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def validate_scope_file(path: str | Path, root_domain: str) -> dict[str, Any]:
|
|
61
|
+
"""Parse a host-per-line authorization file; no DNS/network calls are made."""
|
|
62
|
+
root = normalize_domain(root_domain)
|
|
63
|
+
file_path = Path(path)
|
|
64
|
+
try:
|
|
65
|
+
lines = file_path.read_text(encoding="utf-8").splitlines()
|
|
66
|
+
except OSError as exc:
|
|
67
|
+
raise WorkspaceError(f"cannot read scope file {str(path)!r}: {exc}") from exc
|
|
68
|
+
approved: list[str] = []
|
|
69
|
+
rejected: list[dict[str, str | int]] = []
|
|
70
|
+
for number, line in enumerate(lines, 1):
|
|
71
|
+
item = line.strip()
|
|
72
|
+
if not item or item.startswith("#"):
|
|
73
|
+
continue
|
|
74
|
+
try:
|
|
75
|
+
host = normalize_domain(item)
|
|
76
|
+
if host != root and not host.endswith("." + root):
|
|
77
|
+
raise WorkspaceError(f"host is outside root domain {root}")
|
|
78
|
+
approved.append(host)
|
|
79
|
+
except WorkspaceError as exc:
|
|
80
|
+
rejected.append({"line": number, "value": item, "reason": str(exc)})
|
|
81
|
+
# Preserve order while removing duplicates.
|
|
82
|
+
approved = list(dict.fromkeys(approved))
|
|
83
|
+
return {
|
|
84
|
+
"path": str(file_path.resolve()),
|
|
85
|
+
"sha256": hashlib.sha256(file_path.read_bytes()).hexdigest(),
|
|
86
|
+
"approved_hosts": approved,
|
|
87
|
+
"rejected_entries": rejected,
|
|
88
|
+
"valid": not rejected and bool(approved),
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def create_assessment_record(root: str, known_hosts: list[str] | None = None,
|
|
93
|
+
approved_hosts: list[str] | None = None,
|
|
94
|
+
limits: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
95
|
+
"""Create a new assessment manifest; approvals are explicit and per-run."""
|
|
96
|
+
domain = normalize_domain(root)
|
|
97
|
+
known = list(dict.fromkeys(normalize_domain(h) for h in (known_hosts or [domain])))
|
|
98
|
+
approved = list(dict.fromkeys(normalize_domain(h) for h in (approved_hosts or [])))
|
|
99
|
+
invalid = [h for h in known + approved if h != domain and not h.endswith("." + domain)]
|
|
100
|
+
if invalid:
|
|
101
|
+
raise WorkspaceError(f"hosts outside root domain {domain}: {', '.join(invalid)}")
|
|
102
|
+
if not set(approved).issubset(set(known)):
|
|
103
|
+
raise WorkspaceError("every approved host must first be present in the known-host list")
|
|
104
|
+
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
105
|
+
return {
|
|
106
|
+
"schema": "jscope-assessment/1",
|
|
107
|
+
"assessment_id": datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "-" + secrets.token_hex(3),
|
|
108
|
+
"root_domain": domain,
|
|
109
|
+
"created_at": now,
|
|
110
|
+
"status": "approved" if approved else "planned",
|
|
111
|
+
"authorization": {"fresh_for_this_run": True, "approved_at": now if approved else None, "approved_hosts": approved},
|
|
112
|
+
"hosts": {h: {"state": "approved" if h in approved else "known", "discovery_source": "user-supplied" if h == domain else "scope-input"} for h in known},
|
|
113
|
+
"limits": limits or {"max_requests": 500, "rate_per_second": 2.0, "depth": "deep"},
|
|
114
|
+
"stages": [],
|
|
115
|
+
"findings": [],
|
|
116
|
+
"evidence": [],
|
|
117
|
+
"coverage": {"tested_hosts": [], "untested_hosts": known, "limitations": []},
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def record_stage(manifest: dict[str, Any], stage: str, outcome: str, detail: str = "") -> None:
|
|
122
|
+
manifest.setdefault("stages", []).append({
|
|
123
|
+
"stage": stage, "outcome": outcome,
|
|
124
|
+
"at": datetime.now(timezone.utc).isoformat(timespec="seconds"), "detail": detail,
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def write_assessment(root: str | Path, manifest: dict[str, Any]) -> Path:
|
|
129
|
+
"""Atomically persist a manifest under .jscope-ws/assessments."""
|
|
130
|
+
root = Path(root)
|
|
131
|
+
out = root / ".jscope-ws" / "assessments" / f"{manifest['assessment_id']}.json"
|
|
132
|
+
_atomic_write(out, manifest)
|
|
133
|
+
return out
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Small workspace primitives kept independent from the analysis engine."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class WorkspaceError(Exception):
|
|
11
|
+
"""A user-correctable workspace or assessment error."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _atomic_write(path: Path, data: dict) -> None:
|
|
15
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
16
|
+
fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
|
|
17
|
+
try:
|
|
18
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
19
|
+
json.dump(data, f, indent=2, sort_keys=True)
|
|
20
|
+
f.write("\n")
|
|
21
|
+
os.replace(tmp, path)
|
|
22
|
+
except BaseException:
|
|
23
|
+
if os.path.exists(tmp):
|
|
24
|
+
os.unlink(tmp)
|
|
25
|
+
raise
|
jscope/workspace/diff.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Scan-to-scan diffing (Phase 3).
|
|
2
|
+
|
|
3
|
+
Compares two saved scan records at three levels: files (by content hash), findings (by stable
|
|
4
|
+
`category|subject` key, NOT the order-based JS-#### id) and the knowledge graph's route map.
|
|
5
|
+
|
|
6
|
+
The graph-level diff is the interesting one: "this route lost its guard" or "this route now reaches a
|
|
7
|
+
new endpoint" is a change in application *behavior*, which a file diff cannot express. These changes
|
|
8
|
+
are inherently worth re-testing, so they feed the deterministic priority ranking (see priority.py).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from jscope.reporting.priority import PriorityDelta, finding_key
|
|
17
|
+
|
|
18
|
+
_ORDER = {"SUPPRESSED": 0, "INFORMATIONAL": 1, "LOW": 2, "MEDIUM": 3, "HIGH": 4, "CONFIRMED": 5}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _protection(chain: dict) -> set[str]:
|
|
22
|
+
return {f"guard:{g}" for g in chain.get("guards", [])} | {f"role:{r}" for r in chain.get("roles", [])}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ScanDiff:
|
|
27
|
+
old_id: str
|
|
28
|
+
new_id: str
|
|
29
|
+
files_added: list[str] = field(default_factory=list)
|
|
30
|
+
files_removed: list[str] = field(default_factory=list)
|
|
31
|
+
files_modified: list[str] = field(default_factory=list)
|
|
32
|
+
findings_new: list[dict] = field(default_factory=list)
|
|
33
|
+
findings_resolved: list[dict] = field(default_factory=list)
|
|
34
|
+
findings_changed: list[dict] = field(default_factory=list)
|
|
35
|
+
routes_added: list[dict] = field(default_factory=list)
|
|
36
|
+
routes_removed: list[dict] = field(default_factory=list)
|
|
37
|
+
routes_changed: list[dict] = field(default_factory=list)
|
|
38
|
+
|
|
39
|
+
def is_empty(self) -> bool:
|
|
40
|
+
return not any([self.files_added, self.files_removed, self.files_modified, self.findings_new,
|
|
41
|
+
self.findings_resolved, self.findings_changed, self.routes_added,
|
|
42
|
+
self.routes_removed, self.routes_changed])
|
|
43
|
+
|
|
44
|
+
def to_dict(self) -> dict[str, Any]:
|
|
45
|
+
return {k: v for k, v in self.__dict__.items()}
|
|
46
|
+
|
|
47
|
+
def priority_delta(self) -> PriorityDelta:
|
|
48
|
+
return PriorityDelta(
|
|
49
|
+
new_keys={f["key"] for f in self.findings_new},
|
|
50
|
+
raised_keys={f["key"] for f in self.findings_changed if f["direction"] == "up"},
|
|
51
|
+
weakened_routes={r["id"] for r in self.routes_changed if r["change"] == "weakened"})
|
|
52
|
+
|
|
53
|
+
def regressions(self, min_confidence: str = "HIGH") -> list[str]:
|
|
54
|
+
"""Human-readable reasons a CI/cron run should alert. Deliberately narrow: new findings at/above
|
|
55
|
+
`min_confidence`, confidence rising to it, and routes that lost protection."""
|
|
56
|
+
bar = _ORDER[min_confidence]
|
|
57
|
+
out = [f"new {f['confidence']} finding: {f['category']} {f['subject']}"
|
|
58
|
+
for f in self.findings_new if _ORDER[f["confidence"]] >= bar]
|
|
59
|
+
out += [f"{f['category']} {f['subject']} rose {f['old']} -> {f['new']}"
|
|
60
|
+
for f in self.findings_changed if f["direction"] == "up" and _ORDER[f["new"]] >= bar]
|
|
61
|
+
out += [f"route {r['route']} lost client-side protection ({', '.join(sorted(r['lost']))})"
|
|
62
|
+
for r in self.routes_changed if r["change"] == "weakened"]
|
|
63
|
+
return out
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _brief(f: dict) -> dict:
|
|
67
|
+
return {"key": finding_key(f["category"], f["subject"]), "id": f["id"], "category": f["category"],
|
|
68
|
+
"subject": f["subject"], "confidence": f["confidence"], "attack_surface": f.get("attack_surface")}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def diff_scans(old: dict, new: dict) -> ScanDiff:
|
|
72
|
+
"""`old`/`new` are scan records as written by Workspace (they carry `scan_id`, `files`, `payload`)."""
|
|
73
|
+
d = ScanDiff(old_id=old["scan_id"], new_id=new["scan_id"])
|
|
74
|
+
|
|
75
|
+
of, nf = old.get("files", {}), new.get("files", {})
|
|
76
|
+
d.files_added = sorted(set(nf) - set(of))
|
|
77
|
+
d.files_removed = sorted(set(of) - set(nf))
|
|
78
|
+
d.files_modified = sorted(p for p in set(of) & set(nf) if of[p] != nf[p])
|
|
79
|
+
|
|
80
|
+
ofi = {finding_key(f["category"], f["subject"]): f for f in old["payload"]["findings"]}
|
|
81
|
+
nfi = {finding_key(f["category"], f["subject"]): f for f in new["payload"]["findings"]}
|
|
82
|
+
d.findings_new = [_brief(nfi[k]) for k in sorted(set(nfi) - set(ofi))]
|
|
83
|
+
d.findings_resolved = [_brief(ofi[k]) for k in sorted(set(ofi) - set(nfi))]
|
|
84
|
+
for k in sorted(set(ofi) & set(nfi)):
|
|
85
|
+
a, b = ofi[k]["confidence"], nfi[k]["confidence"]
|
|
86
|
+
if a != b:
|
|
87
|
+
d.findings_changed.append({**_brief(nfi[k]), "old": a, "new": b,
|
|
88
|
+
"direction": "up" if _ORDER[b] > _ORDER[a] else "down"})
|
|
89
|
+
|
|
90
|
+
orm = {c["id"]: c for c in old["payload"].get("route_map", [])}
|
|
91
|
+
nrm = {c["id"]: c for c in new["payload"].get("route_map", [])}
|
|
92
|
+
d.routes_added = [{"route": nrm[i]["route"], "id": i, "unguarded": nrm[i]["unguarded"],
|
|
93
|
+
"endpoints": nrm[i]["endpoints"]} for i in sorted(set(nrm) - set(orm))]
|
|
94
|
+
d.routes_removed = [{"route": orm[i]["route"], "id": i} for i in sorted(set(orm) - set(nrm))]
|
|
95
|
+
for i in sorted(set(orm) & set(nrm)):
|
|
96
|
+
o, n = orm[i], nrm[i]
|
|
97
|
+
po, pn = _protection(o), _protection(n)
|
|
98
|
+
if not o["unguarded"] and n["unguarded"] or pn < po:
|
|
99
|
+
change = "weakened"
|
|
100
|
+
elif pn > po:
|
|
101
|
+
change = "strengthened"
|
|
102
|
+
elif pn != po:
|
|
103
|
+
change = "changed"
|
|
104
|
+
else:
|
|
105
|
+
change = None
|
|
106
|
+
ep_add = sorted(set(n["endpoints"]) - set(o["endpoints"]))
|
|
107
|
+
ep_rem = sorted(set(o["endpoints"]) - set(n["endpoints"]))
|
|
108
|
+
if change or ep_add or ep_rem:
|
|
109
|
+
d.routes_changed.append({
|
|
110
|
+
"route": n["route"], "id": i, "change": change or "endpoints_only",
|
|
111
|
+
"lost": sorted(po - pn), "gained": sorted(pn - po),
|
|
112
|
+
"endpoints_added": ep_add, "endpoints_removed": ep_rem,
|
|
113
|
+
"now_unguarded": n["unguarded"]})
|
|
114
|
+
return d
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def render_diff(d: ScanDiff) -> str:
|
|
118
|
+
if d.is_empty():
|
|
119
|
+
return f"[changes since scan {d.old_id}] none"
|
|
120
|
+
L = [f"[changes since scan {d.old_id}] (-> {d.new_id})"]
|
|
121
|
+
L.append(f" files : +{len(d.files_added)} -{len(d.files_removed)} ~{len(d.files_modified)}")
|
|
122
|
+
for r in d.routes_changed:
|
|
123
|
+
if r["change"] == "weakened":
|
|
124
|
+
L.append(f" !! route {r['route']} LOST protection: {', '.join(r['lost']) or 'all guards'}")
|
|
125
|
+
for r in d.routes_changed:
|
|
126
|
+
if r["change"] == "strengthened":
|
|
127
|
+
L.append(f" + route {r['route']} gained protection: {', '.join(r['gained'])}")
|
|
128
|
+
for e in r["endpoints_added"]:
|
|
129
|
+
L.append(f" + route {r['route']} now reaches {e}")
|
|
130
|
+
for e in r["endpoints_removed"]:
|
|
131
|
+
L.append(f" - route {r['route']} no longer reaches {e}")
|
|
132
|
+
for r in d.routes_added:
|
|
133
|
+
L.append(f" + new route {r['route']}" + (" (UNPROTECTED)" if r["unguarded"] else ""))
|
|
134
|
+
for r in d.routes_removed:
|
|
135
|
+
L.append(f" - route removed {r['route']}")
|
|
136
|
+
for f in d.findings_new:
|
|
137
|
+
L.append(f" + new [{f['confidence']}] {f['category']}: {f['subject']}")
|
|
138
|
+
for f in d.findings_changed:
|
|
139
|
+
arrow = "^" if f["direction"] == "up" else "v"
|
|
140
|
+
L.append(f" {arrow} {f['category']}: {f['subject']} {f['old']} -> {f['new']}")
|
|
141
|
+
for f in d.findings_resolved:
|
|
142
|
+
L.append(f" - gone [{f['confidence']}] {f['category']}: {f['subject']}")
|
|
143
|
+
return "\n".join(L)
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Workspaces: named targets, immutable scan history, incremental rescans (Phase 3).
|
|
2
|
+
|
|
3
|
+
Layout (all JSON, human-inspectable, safe to commit or rsync):
|
|
4
|
+
|
|
5
|
+
.jscope-ws/
|
|
6
|
+
workspace.json {"version": 1, "targets": {name: {"paths": [...]}}}
|
|
7
|
+
targets/<name>/index.json {"scans": [{id, timestamp, fingerprint, finding_count}]}
|
|
8
|
+
targets/<name>/scans/0001.json {scan_id, timestamp, fingerprint, files{path:hash}, payload}
|
|
9
|
+
|
|
10
|
+
Incremental rescans, stated precisely: a scan is skipped when its *fingerprint* (every input file's content
|
|
11
|
+
hash + the analysis-relevant config + plugin source hashes + the JScope version) equals the previous scan's.
|
|
12
|
+
When ANY file changed the whole target is re-analyzed, because route -> component -> API correlation is
|
|
13
|
+
cross-file: re-analyzing only the changed file would silently produce a wrong graph. Skipping is therefore
|
|
14
|
+
all-or-nothing, which is the only form that is both fast in the common cron case ("nothing changed") and
|
|
15
|
+
never wrong. (tree-sitter trees are not serializable, so there is no per-file parse cache on disk.)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
import json
|
|
22
|
+
import re
|
|
23
|
+
import shutil
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from datetime import datetime, timezone
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from jscope import __version__
|
|
30
|
+
from jscope.config import Config
|
|
31
|
+
from jscope.pipeline import collect_manifest, run_analysis
|
|
32
|
+
from jscope.reporting import json_report
|
|
33
|
+
from jscope.workspace.diff import ScanDiff, diff_scans
|
|
34
|
+
|
|
35
|
+
_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
|
|
36
|
+
_SCAN_RE = re.compile(r"^\d{4,}$")
|
|
37
|
+
DIRNAME = ".jscope-ws"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
from jscope.workspace.common import WorkspaceError, _atomic_write
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class ScanOutcome:
|
|
45
|
+
status: str # "scanned" | "unchanged"
|
|
46
|
+
record: dict[str, Any] # the scan record now considered latest
|
|
47
|
+
previous: dict[str, Any] | None
|
|
48
|
+
diff: ScanDiff | None # None on a first scan (a baseline has no diff)
|
|
49
|
+
result: Any = None # AnalysisResult when status == "scanned" (graph, findings objects)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def config_fingerprint(config: Config) -> str:
|
|
53
|
+
"""Hash of every setting that can change analysis OUTPUT. Output-format/verbosity are excluded on
|
|
54
|
+
purpose, and so is concurrency. Plugin *source* is hashed too: editing a plugin must invalidate."""
|
|
55
|
+
relevant = {k: config.get(k) for k in ("analyzers.enabled", "min_confidence", "exclude", "max_file_bytes",
|
|
56
|
+
"source_maps.expand", "artifacts.enabled", "plugins.verified",
|
|
57
|
+
"plugins.community_confidence_cap")}
|
|
58
|
+
plugin_hashes = {}
|
|
59
|
+
for p in config.get("plugins.paths", []) or []:
|
|
60
|
+
pp = Path(p)
|
|
61
|
+
for f in ([pp] if pp.is_file() else sorted(pp.rglob("*.py")) if pp.is_dir() else []):
|
|
62
|
+
plugin_hashes[str(f)] = hashlib.sha256(f.read_bytes()).hexdigest()[:16]
|
|
63
|
+
blob = json.dumps({"cfg": relevant, "plugins": plugin_hashes, "v": __version__}, sort_keys=True)
|
|
64
|
+
return hashlib.sha256(blob.encode()).hexdigest()[:16]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def scan_fingerprint(manifest: dict[str, str], config: Config) -> str:
|
|
68
|
+
blob = json.dumps({"files": manifest, "cfg": config_fingerprint(config)}, sort_keys=True)
|
|
69
|
+
return hashlib.sha256(blob.encode()).hexdigest()[:16]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Workspace:
|
|
73
|
+
def __init__(self, root: Path):
|
|
74
|
+
self.root = Path(root)
|
|
75
|
+
self.dir = self.root / DIRNAME
|
|
76
|
+
|
|
77
|
+
# -- lifecycle --------------------------------------------------------
|
|
78
|
+
@classmethod
|
|
79
|
+
def init(cls, root: str | Path) -> "Workspace":
|
|
80
|
+
ws = cls(Path(root))
|
|
81
|
+
if (ws.dir / "workspace.json").exists():
|
|
82
|
+
raise WorkspaceError(f"workspace already exists at {ws.dir}")
|
|
83
|
+
_atomic_write(ws.dir / "workspace.json", {"version": 1, "targets": {}})
|
|
84
|
+
return ws
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def open(cls, root: str | Path) -> "Workspace":
|
|
88
|
+
ws = cls(Path(root))
|
|
89
|
+
if not (ws.dir / "workspace.json").exists():
|
|
90
|
+
raise WorkspaceError(f"no workspace at {ws.dir} (run `jscope ws init`)")
|
|
91
|
+
return ws
|
|
92
|
+
|
|
93
|
+
def _meta(self) -> dict:
|
|
94
|
+
return json.loads((self.dir / "workspace.json").read_text())
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def _check_name(name: str) -> str:
|
|
98
|
+
if not _NAME_RE.match(name):
|
|
99
|
+
raise WorkspaceError(f"invalid target name {name!r} (letters, digits, '_', '.', '-'; max 64)")
|
|
100
|
+
return name
|
|
101
|
+
|
|
102
|
+
def _tdir(self, name: str) -> Path:
|
|
103
|
+
return self.dir / "targets" / self._check_name(name)
|
|
104
|
+
|
|
105
|
+
# -- targets ----------------------------------------------------------
|
|
106
|
+
def add_target(self, name: str, paths: list[str]) -> None:
|
|
107
|
+
self._check_name(name)
|
|
108
|
+
meta = self._meta()
|
|
109
|
+
if name in meta["targets"]:
|
|
110
|
+
raise WorkspaceError(f"target {name!r} already exists")
|
|
111
|
+
missing = [p for p in paths if not Path(p).exists()]
|
|
112
|
+
if missing:
|
|
113
|
+
raise WorkspaceError(f"path(s) not found: {', '.join(missing)}")
|
|
114
|
+
meta["targets"][name] = {"paths": [str(Path(p).resolve()) for p in paths]}
|
|
115
|
+
_atomic_write(self.dir / "workspace.json", meta)
|
|
116
|
+
|
|
117
|
+
def remove_target(self, name: str) -> None:
|
|
118
|
+
meta = self._meta()
|
|
119
|
+
if name not in meta["targets"]:
|
|
120
|
+
raise WorkspaceError(f"no such target {name!r}")
|
|
121
|
+
del meta["targets"][name]
|
|
122
|
+
_atomic_write(self.dir / "workspace.json", meta)
|
|
123
|
+
shutil.rmtree(self._tdir(name), ignore_errors=True) # name validated by _tdir
|
|
124
|
+
|
|
125
|
+
def targets(self) -> dict[str, dict]:
|
|
126
|
+
return self._meta()["targets"]
|
|
127
|
+
|
|
128
|
+
# -- history ----------------------------------------------------------
|
|
129
|
+
def history(self, name: str) -> list[dict]:
|
|
130
|
+
idx = self._tdir(name) / "index.json"
|
|
131
|
+
return json.loads(idx.read_text())["scans"] if idx.exists() else []
|
|
132
|
+
|
|
133
|
+
def load_scan(self, name: str, ref: str = "latest") -> dict:
|
|
134
|
+
hist = self.history(name)
|
|
135
|
+
if not hist:
|
|
136
|
+
raise WorkspaceError(f"target {name!r} has no scans yet")
|
|
137
|
+
if ref == "latest":
|
|
138
|
+
sid = hist[-1]["id"]
|
|
139
|
+
elif ref == "previous":
|
|
140
|
+
if len(hist) < 2:
|
|
141
|
+
raise WorkspaceError(f"target {name!r} has only one scan")
|
|
142
|
+
sid = hist[-2]["id"]
|
|
143
|
+
else:
|
|
144
|
+
sid = ref.zfill(4)
|
|
145
|
+
if not _SCAN_RE.match(sid):
|
|
146
|
+
raise WorkspaceError(f"invalid scan id {ref!r}")
|
|
147
|
+
path = self._tdir(name) / "scans" / f"{sid}.json"
|
|
148
|
+
if not path.exists():
|
|
149
|
+
raise WorkspaceError(f"no scan {sid} for target {name!r}")
|
|
150
|
+
return json.loads(path.read_text())
|
|
151
|
+
|
|
152
|
+
# -- scanning ---------------------------------------------------------
|
|
153
|
+
def scan(self, name: str, config: Config, force: bool = False, now: datetime | None = None) -> ScanOutcome:
|
|
154
|
+
targets = self.targets()
|
|
155
|
+
if name not in targets:
|
|
156
|
+
raise WorkspaceError(f"no such target {name!r}")
|
|
157
|
+
paths = targets[name]["paths"]
|
|
158
|
+
hist = self.history(name)
|
|
159
|
+
prev = self.load_scan(name, "latest") if hist else None
|
|
160
|
+
|
|
161
|
+
manifest = collect_manifest(paths, config)
|
|
162
|
+
fp = scan_fingerprint(manifest, config)
|
|
163
|
+
if prev is not None and prev["fingerprint"] == fp and not force:
|
|
164
|
+
return ScanOutcome("unchanged", prev, prev, None)
|
|
165
|
+
|
|
166
|
+
res = run_analysis(paths, config)
|
|
167
|
+
payload = json_report.payload(res.findings, res.frameworks, res.file_count, res.graph, res.diagnostics,
|
|
168
|
+
dataflow=res.dataflow, file_diagnostics=res.file_diagnostics)
|
|
169
|
+
sid = f"{(int(hist[-1]['id']) + 1) if hist else 1:04d}"
|
|
170
|
+
ts = (now or datetime.now(timezone.utc)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
171
|
+
record = {"scan_id": sid, "target": name, "timestamp": ts, "fingerprint": fp,
|
|
172
|
+
"jscope_version": __version__, "files": manifest, "payload": payload}
|
|
173
|
+
diff = diff_scans(prev, record) if prev is not None else None
|
|
174
|
+
# re-render priority with the delta so the stored ranking already reflects "what changed"
|
|
175
|
+
if diff is not None:
|
|
176
|
+
payload = json_report.payload(res.findings, res.frameworks, res.file_count, res.graph,
|
|
177
|
+
res.diagnostics, diff.priority_delta(), dataflow=res.dataflow,
|
|
178
|
+
file_diagnostics=res.file_diagnostics)
|
|
179
|
+
record["payload"] = payload
|
|
180
|
+
_atomic_write(self._tdir(name) / "scans" / f"{sid}.json", record)
|
|
181
|
+
_atomic_write(self._tdir(name) / "index.json", {"scans": hist + [{
|
|
182
|
+
"id": sid, "timestamp": ts, "fingerprint": fp, "finding_count": len(res.findings),
|
|
183
|
+
"file_count": len(manifest)}]})
|
|
184
|
+
return ScanOutcome("scanned", record, prev, diff, res)
|
|
185
|
+
|
|
186
|
+
# -- multi-target overview -------------------------------------------
|
|
187
|
+
def summary(self) -> list[dict]:
|
|
188
|
+
rows = []
|
|
189
|
+
for name in sorted(self.targets()):
|
|
190
|
+
hist = self.history(name)
|
|
191
|
+
if not hist:
|
|
192
|
+
rows.append({"target": name, "scans": 0})
|
|
193
|
+
continue
|
|
194
|
+
rec = self.load_scan(name, "latest")
|
|
195
|
+
fs = rec["payload"]["findings"]
|
|
196
|
+
conf: dict[str, int] = {}
|
|
197
|
+
for f in fs:
|
|
198
|
+
conf[f["confidence"]] = conf.get(f["confidence"], 0) + 1
|
|
199
|
+
top = rec["payload"]["priority"][0] if rec["payload"]["priority"] else None
|
|
200
|
+
top_f = next((f for f in fs if top and f["id"] == top["id"]), None)
|
|
201
|
+
rows.append({
|
|
202
|
+
"target": name, "scans": len(hist), "last_scan": rec["timestamp"], "files": len(rec["files"]),
|
|
203
|
+
"findings": len(fs), "by_confidence": conf, "frameworks": rec["payload"]["summary"]["frameworks"],
|
|
204
|
+
"unprotected_routes": sum(1 for c in rec["payload"].get("route_map", []) if c["unguarded"]),
|
|
205
|
+
"top": {"id": top_f["id"], "subject": top_f["subject"], "priority": top["priority"]} if top_f else None})
|
|
206
|
+
return rows
|