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