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
jscope/config.py ADDED
@@ -0,0 +1,72 @@
1
+ """Configuration. Supports a jscope.yml/json in cwd or --config path.
2
+ Users can enable/disable analyzers, set confidence threshold, output format,
3
+ concurrency, exclusions. """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ DEFAULTS: dict[str, Any] = {
13
+ "analyzers": {
14
+ "enabled": ["framework_detector", "endpoint_analyzer", "secret_analyzer", "router_analyzer",
15
+ "auth_analyzer", "authorization_analyzer", "storage_analyzer", "graphql_analyzer",
16
+ "service_worker_analyzer", "source_map_analyzer",
17
+ "html_analyzer", "openapi_analyzer", "graphql_schema_analyzer", "wasm_analyzer",
18
+ "dataflow_analyzer"],
19
+ },
20
+ "artifacts": {"enabled": True}, # Phase 4: HTML, Vue SFC, OpenAPI, GraphQL schema, WASM
21
+ "incremental": {"cache_dir": None, "enabled": True},
22
+ "plugins": {"paths": [], "verified": [], "community_confidence_cap": "MEDIUM", "entry_points": True,
23
+ "isolation": "auto", "timeout_seconds": 30, "memory_mb": 1024, "grant_read": []},
24
+ "source_maps": {"expand": False},
25
+ "min_confidence": "LOW",
26
+ "output": {"format": "human", "verbose": False},
27
+ "concurrency": 4,
28
+ "exclude": ["**/node_modules/**"],
29
+ "max_file_bytes": 5 * 1024 * 1024,
30
+ "runtime": {"rate_per_second": 2.0, "max_requests": 100, "timeout_seconds": 8},
31
+ "ai": {"enabled": False, "provider": "anthropic", "model": "claude-sonnet-5-5", "api_key_env": "ANTHROPIC_API_KEY",
32
+ "top_n": 8, "redact_paths": True, "max_tokens": 1500},
33
+ }
34
+
35
+
36
+ @dataclass
37
+ class Config:
38
+ data: dict[str, Any] = field(default_factory=lambda: json.loads(json.dumps(DEFAULTS)))
39
+
40
+ @classmethod
41
+ def load(cls, path: str | None) -> "Config":
42
+ cfg = cls()
43
+ if path and Path(path).exists():
44
+ text = Path(path).read_text()
45
+ if path.endswith((".yml", ".yaml")):
46
+ try:
47
+ import yaml # optional dependency
48
+ user_cfg = yaml.safe_load(text) or {}
49
+ except ImportError:
50
+ raise RuntimeError("PyYAML not installed; use a .json config or `pip install pyyaml`")
51
+ else:
52
+ user_cfg = json.loads(text)
53
+ cfg.data = _deep_merge(cfg.data, user_cfg)
54
+ return cfg
55
+
56
+ def get(self, dotted_key: str, default: Any = None) -> Any:
57
+ node: Any = self.data
58
+ for part in dotted_key.split("."):
59
+ if not isinstance(node, dict) or part not in node:
60
+ return default
61
+ node = node[part]
62
+ return node
63
+
64
+
65
+ def _deep_merge(base: dict, override: dict) -> dict:
66
+ out = dict(base)
67
+ for k, v in override.items():
68
+ if isinstance(v, dict) and isinstance(out.get(k), dict):
69
+ out[k] = _deep_merge(out[k], v)
70
+ else:
71
+ out[k] = v
72
+ return out
File without changes
@@ -0,0 +1,139 @@
1
+ """Non-JavaScript artifacts (Phase 4).
2
+
3
+ JScope started as a JavaScript tool; the PRD's final phase widens it to the other client-side and
4
+ API-description artifacts that sit next to a web app's JS: HTML pages, Vue single-file components,
5
+ OpenAPI/Swagger specs, GraphQL schemas (SDL or introspection JSON) and WebAssembly modules.
6
+
7
+ Collection is purely offline and content-sniffed (a `.json` file is only treated as an OpenAPI spec if it
8
+ parses as one), so dropping a whole downloaded site into a directory just works and unrelated JSON is ignored.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import fnmatch
14
+ import json
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from jscope.parsing.ast_engine import ASTEngine
20
+
21
+ KIND_HTML = "html"
22
+ KIND_VUE = "vue"
23
+ KIND_OPENAPI = "openapi"
24
+ KIND_GRAPHQL_SCHEMA = "graphql_schema"
25
+ KIND_WASM = "wasm"
26
+ ALL_KINDS = (KIND_HTML, KIND_VUE, KIND_OPENAPI, KIND_GRAPHQL_SCHEMA, KIND_WASM)
27
+
28
+ _HTML_EXT = (".html", ".htm")
29
+ _DATA_EXT = (".json", ".yaml", ".yml")
30
+ _GQL_EXT = (".graphql", ".gql", ".graphqls")
31
+ _MAX_ARTIFACT_BYTES = 20 * 1024 * 1024
32
+ _WASM_MAGIC = b"\x00asm"
33
+
34
+
35
+ @dataclass
36
+ class Artifact:
37
+ kind: str
38
+ path: str
39
+ data: bytes
40
+ content_hash: str
41
+ origin: str = "disk" # "disk" | "html-inline" ...
42
+ memo: dict[str, Any] = field(default_factory=dict, repr=False) # parse-once cache shared by pipeline + analyzers
43
+
44
+ @property
45
+ def text(self) -> str:
46
+ return self.data.decode("utf-8", errors="replace")
47
+
48
+
49
+ def _load_structured(text: str, path: str) -> Any | None:
50
+ """JSON or (if PyYAML is installed) YAML. Returns None when it cannot be parsed."""
51
+ if path.lower().endswith(".json") or text.lstrip().startswith(("{", "[")):
52
+ try:
53
+ return json.loads(text)
54
+ except ValueError:
55
+ return None
56
+ try:
57
+ import yaml
58
+ except ImportError:
59
+ return None
60
+ try:
61
+ return yaml.safe_load(text)
62
+ except Exception: # noqa: BLE001 -- any YAML error just means "not a spec we can read"
63
+ return None
64
+
65
+
66
+ def structured(art: Artifact) -> Any | None:
67
+ if "structured" not in art.memo:
68
+ art.memo["structured"] = _load_structured(art.text, art.path)
69
+ return art.memo["structured"]
70
+
71
+
72
+ def _is_openapi(doc: Any) -> bool:
73
+ return (isinstance(doc, dict) and isinstance(doc.get("paths"), dict)
74
+ and (str(doc.get("openapi", "")).startswith("3") or str(doc.get("swagger", "")).startswith("2")))
75
+
76
+
77
+ def _is_introspection(doc: Any) -> bool:
78
+ if not isinstance(doc, dict):
79
+ return False
80
+ schema = (doc.get("data") or doc).get("__schema") if isinstance(doc.get("data") or doc, dict) else None
81
+ return isinstance(schema, dict) and isinstance(schema.get("types"), list)
82
+
83
+
84
+ def classify_artifact(path: str, data: bytes) -> str | None:
85
+ """Content-sniffing classifier. Returns an artifact kind or None (not an artifact we handle)."""
86
+ low = path.lower()
87
+ if low.endswith(".wasm") or data[:4] == _WASM_MAGIC:
88
+ return KIND_WASM if data[:4] == _WASM_MAGIC else None
89
+ if low.endswith(_HTML_EXT):
90
+ return KIND_HTML
91
+ if low.endswith(".vue"):
92
+ return KIND_VUE
93
+ if low.endswith(_GQL_EXT):
94
+ text = data[:200_000].decode("utf-8", errors="replace")
95
+ return KIND_GRAPHQL_SCHEMA if _looks_like_sdl(text) else None
96
+ if low.endswith(_DATA_EXT):
97
+ head = data[:4096].decode("utf-8", errors="replace").lower()
98
+ if "openapi" in head or "swagger" in head:
99
+ doc = _load_structured(data.decode("utf-8", errors="replace"), path)
100
+ if _is_openapi(doc):
101
+ return KIND_OPENAPI
102
+ if "__schema" in data[:2_000_000].decode("utf-8", errors="replace"):
103
+ doc = _load_structured(data.decode("utf-8", errors="replace"), path)
104
+ if _is_introspection(doc):
105
+ return KIND_GRAPHQL_SCHEMA
106
+ return None
107
+
108
+
109
+ def _looks_like_sdl(text: str) -> bool:
110
+ import re
111
+ return bool(re.search(r"^\s*(extend\s+)?(type|input|interface|enum|schema|union|scalar)\s+\w*", text, re.M)
112
+ and re.search(r"\btype\s+(Query|Mutation|Subscription)\b|\bschema\s*\{", text))
113
+
114
+
115
+ def collect_artifacts(paths: list[str], exclude: list[str], kinds: tuple[str, ...] = ALL_KINDS) -> list[Artifact]:
116
+ out: dict[str, Artifact] = {}
117
+
118
+ def consider(f: Path):
119
+ if not f.is_file() or f.stat().st_size > _MAX_ARTIFACT_BYTES:
120
+ return
121
+ rel = str(f)
122
+ if any(fnmatch.fnmatch(rel, pat) for pat in exclude):
123
+ return
124
+ suffix = f.suffix.lower()
125
+ if suffix not in _HTML_EXT + _DATA_EXT + _GQL_EXT + (".wasm", ".vue"):
126
+ return
127
+ data = f.read_bytes()
128
+ kind = classify_artifact(rel, data)
129
+ if kind and kind in kinds:
130
+ out[rel] = Artifact(kind, rel, data, ASTEngine.hash_bytes(data))
131
+
132
+ for p in paths:
133
+ path = Path(p)
134
+ if path.is_file():
135
+ consider(path)
136
+ elif path.is_dir():
137
+ for f in path.rglob("*"):
138
+ consider(f)
139
+ return [out[k] for k in sorted(out)]
jscope/core/cache.py ADDED
@@ -0,0 +1,198 @@
1
+ """Dependency-aware incremental analysis (FR-07).
2
+
3
+ What is cached: the data-flow engine's per-root results. A *root* is a file; its result (flows, limitations, unresolved
4
+ imports) is a pure function of the file's content, the content of every file it transitively imports, the resolver
5
+ configuration (tsconfig/aliases), the Vue templates and the engine version. That function's input is hashed into a
6
+ `closure hash`; an entry is reused only when its closure hash is unchanged, so:
7
+
8
+ * unchanged project -> every root reused, no analysis
9
+ * one file edited -> that file and everything that (transitively) imports it is re-analysed
10
+ * a file removed -> its entry is dropped; importers' closure hashes change so they re-analyse
11
+ * corrupt / foreign cache file -> ignored, full scan (never an error)
12
+
13
+ Parsed ASTs are NOT persisted (tree-sitter trees are not serialisable and parsing is cheap relative to analysis).
14
+ `--no-cache` / clean scans bypass the cache entirely, and a cold incremental run uses the same per-root isolation as a
15
+ warm one, so incremental output equals clean output for the same inputs (verified by tests/test_incremental.py).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import json
22
+ import os
23
+ from dataclasses import dataclass, field
24
+ from pathlib import Path
25
+
26
+ from jscope.dataflow.engine import DataFlowEngine, Flow, Limitation
27
+ from jscope.dataflow.rules import Mitigation
28
+ from jscope.parsing.ast_engine import ParsedFile
29
+
30
+ CACHE_VERSION = 3
31
+ ENGINE_VERSION = "dataflow-0.5.0"
32
+ CACHE_FILE = "dataflow-cache.json"
33
+
34
+
35
+ def _h(*parts: str) -> str:
36
+ m = hashlib.sha256()
37
+ for p in parts:
38
+ m.update(p.encode("utf-8", "replace"))
39
+ m.update(b"\x00")
40
+ return m.hexdigest()
41
+
42
+
43
+ def flow_to_json(f: Flow) -> dict:
44
+ return {"source_kind": f.source_kind, "source_file": f.source_file, "source_line": f.source_line, "source_snippet": f.source_snippet,
45
+ "sink_class": f.sink_class, "sink_name": f.sink_name, "sink_file": f.sink_file, "sink_line": f.sink_line,
46
+ "sink_snippet": f.sink_snippet, "steps": [list(s) for s in f.steps], "status": f.status,
47
+ "mitigations": [{"name": m.name, "effective": sorted(m.effective), "uncertain": sorted(m.uncertain), "note": m.note}
48
+ for m in f.mitigations],
49
+ "assumptions": list(f.assumptions), "files": list(f.files), "root": f.root}
50
+
51
+
52
+ def flow_from_json(d: dict) -> Flow:
53
+ return Flow(d["source_kind"], d["source_file"], d["source_line"], d["source_snippet"], d["sink_class"], d["sink_name"],
54
+ d["sink_file"], d["sink_line"], d["sink_snippet"], tuple(tuple(s) for s in d["steps"]), d["status"],
55
+ tuple(Mitigation(m["name"], frozenset(m["effective"]), frozenset(m["uncertain"]), m.get("note", "")) for m in d["mitigations"]),
56
+ tuple(d["assumptions"]), tuple(d["files"]), d.get("root", ""))
57
+
58
+
59
+ @dataclass
60
+ class CacheStats:
61
+ reused: list[str] = field(default_factory=list)
62
+ reanalyzed: list[str] = field(default_factory=list)
63
+ dropped: list[str] = field(default_factory=list)
64
+ note: str = ""
65
+
66
+ def summary(self) -> str:
67
+ return (f"[incremental] reused {len(self.reused)} file(s), re-analyzed {len(self.reanalyzed)}, "
68
+ f"dropped {len(self.dropped)} removed" + (f" ({self.note})" if self.note else ""))
69
+
70
+
71
+ def static_import_specs(pf: ParsedFile) -> list[str]:
72
+ """Every literal module specifier in the file, without executing anything (import/export-from/require/import())."""
73
+ out: list[str] = []
74
+ stack = [pf.tree.root_node]
75
+ while stack:
76
+ n = stack.pop()
77
+ t = n.type
78
+ if t in ("import_statement", "export_statement"):
79
+ src = n.child_by_field_name("source")
80
+ if src is not None:
81
+ out.append(pf.node_text(src).strip("'\"`"))
82
+ elif t == "call_expression":
83
+ fn = n.child_by_field_name("function")
84
+ args = n.child_by_field_name("arguments")
85
+ if fn is not None and args is not None and (fn.type == "import" or (fn.type == "identifier" and pf.node_text(fn) == "require")):
86
+ ch = [c for c in args.children if c.is_named]
87
+ if ch and ch[0].type == "string":
88
+ out.append(pf.node_text(ch[0]).strip("'\"`"))
89
+ stack.extend(n.children)
90
+ return out
91
+
92
+
93
+ class AnalysisCache:
94
+ def __init__(self, directory: str | None, enabled: bool = True):
95
+ self.dir = Path(directory) if directory else None
96
+ self.enabled = enabled and self.dir is not None
97
+ self.stats = CacheStats()
98
+
99
+ # -- persistence -------------------------------------------------------------------------------
100
+ def _load(self) -> dict:
101
+ if not self.enabled:
102
+ return {}
103
+ p = self.dir / CACHE_FILE
104
+ try:
105
+ data = json.loads(p.read_text(encoding="utf-8"))
106
+ if data.get("cache_version") != CACHE_VERSION or data.get("engine") != ENGINE_VERSION or not isinstance(data.get("entries"), dict):
107
+ self.stats.note = "cache from a different JScope version ignored"
108
+ return {}
109
+ return data["entries"]
110
+ except FileNotFoundError:
111
+ return {}
112
+ except (OSError, ValueError, KeyError, TypeError):
113
+ self.stats.note = "cache unreadable; performing a full scan"
114
+ return {}
115
+
116
+ def _save(self, entries: dict) -> None:
117
+ if not self.enabled:
118
+ return
119
+ try:
120
+ self.dir.mkdir(parents=True, exist_ok=True)
121
+ tmp = self.dir / (CACHE_FILE + ".tmp")
122
+ tmp.write_text(json.dumps({"cache_version": CACHE_VERSION, "engine": ENGINE_VERSION, "entries": entries}), encoding="utf-8")
123
+ os.replace(tmp, self.dir / CACHE_FILE)
124
+ except OSError:
125
+ self.stats.note = (self.stats.note + "; " if self.stats.note else "") + "cache could not be written"
126
+
127
+ # -- closure hashing -----------------------------------------------------------------------------
128
+ def closure_hashes(self, engine: DataFlowEngine, files: list[ParsedFile]) -> tuple[dict[str, str], dict[str, set[str]]]:
129
+ resolver = engine.resolver
130
+ edges: dict[str, set[str]] = {}
131
+ for pf in files:
132
+ deps: set[str] = set()
133
+ for spec in static_import_specs(pf):
134
+ r = resolver.resolve(spec, pf.path)
135
+ if r.status == "resolved" and r.path:
136
+ real = engine.real_path(r.path)
137
+ if real and real != pf.path:
138
+ deps.add(real)
139
+ elif r.status == "unresolved":
140
+ deps.add("unresolved:" + spec) # a spec that starts resolving later must invalidate the entry
141
+ edges[pf.path] = deps
142
+ ts_digest = _h(*[Path(p).read_text(errors="replace") for p in sorted(set(resolver.tsconfig_files)) if os.path.exists(p)],
143
+ json.dumps(sorted(resolver.aliases.items())))
144
+ tpl_digest = _h(repr(sorted(map(repr, engine.vue_templates))))
145
+ content = {pf.path: pf.content_hash for pf in files}
146
+ out: dict[str, str] = {}
147
+ for pf in files:
148
+ seen, stack = set(), [pf.path]
149
+ while stack:
150
+ cur = stack.pop()
151
+ if cur in seen:
152
+ continue
153
+ seen.add(cur)
154
+ stack.extend(d for d in edges.get(cur, ()) if not d.startswith("unresolved:"))
155
+ parts = [f"{p}={content.get(p, '')}" for p in sorted(seen)]
156
+ parts += sorted(d for p in seen for d in edges.get(p, ()) if d.startswith("unresolved:"))
157
+ out[pf.path] = _h(ENGINE_VERSION, ts_digest, tpl_digest, *parts)
158
+ return out, edges
159
+
160
+ # -- the one entry point used by DataFlowAnalyzer ------------------------------------------------------
161
+ def run(self, engine: DataFlowEngine, files: list[ParsedFile]) -> CacheStats:
162
+ engine.isolate_roots = True
163
+ entries = self._load()
164
+ hashes, edges = self.closure_hashes(engine, files)
165
+ engine.import_edges = {k: {d for d in v if not d.startswith("unresolved:")} for k, v in edges.items()}
166
+ current = set(hashes)
167
+ reuse = {p for p, h in hashes.items() if entries.get(p, {}).get("closure") == h}
168
+ self.stats.reused = sorted(reuse)
169
+ self.stats.reanalyzed = sorted(current - reuse)
170
+ self.stats.dropped = sorted(set(entries) - current)
171
+ engine.suppressed = set(reuse)
172
+ engine.run(only_files=current - reuse)
173
+ for p in reuse: # replay cached roots
174
+ e = entries[p]
175
+ for fd in e["flows"]:
176
+ engine.keep(flow_from_json(fd))
177
+ for ld in e["limitations"]:
178
+ engine.lim(ld["file"], ld["line"], ld["kind"], ld["message"])
179
+ for u in e["unresolved_imports"]:
180
+ engine.unresolved_imports.append(tuple(u))
181
+ engine.unresolved_imports = sorted(set(engine.unresolved_imports))
182
+ by_root: dict[str, list[Flow]] = {}
183
+ for f in engine.flows.values():
184
+ by_root.setdefault(f.root, []).append(f)
185
+ new_entries = {}
186
+ for p in current:
187
+ if p in reuse:
188
+ new_entries[p] = entries[p]
189
+ continue
190
+ new_entries[p] = {
191
+ "closure": hashes[p],
192
+ "flows": [flow_to_json(f) for f in sorted(by_root.get(p, []), key=lambda f: f.key)],
193
+ "limitations": [{"file": l.file, "line": l.line, "kind": l.kind, "message": l.message}
194
+ for l in engine.limitations if l.file == p],
195
+ "unresolved_imports": [list(u) for u in engine.unresolved_imports if u[0] == p],
196
+ }
197
+ self._save(new_entries)
198
+ return self.stats
@@ -0,0 +1,56 @@
1
+ """Finding classification (FR-08): what KIND of statement is a finding, and how far has it been verified?
2
+
3
+ Two orthogonal axes, deliberately separate from `confidence`:
4
+
5
+ evidence_class observed_fact | static_inference | security_hypothesis | verified_behavior | confirmed_vulnerability
6
+ verification_status unverified | probable | confirmed | inconclusive | rejected
7
+
8
+ `confidence` says how sure JScope is that a code pattern EXISTS and means what it looks like. It never says the
9
+ weakness is exploitable. Only verification can move a finding to confirmed_vulnerability, and only by the documented
10
+ procedure for its class (docs/VERIFICATION.md).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+ from jscope import __version__
18
+
19
+ EVIDENCE_CLASSES = ("observed_fact", "static_inference", "security_hypothesis", "verified_behavior", "confirmed_vulnerability")
20
+ VERIFICATION_STATUSES = ("unverified", "probable", "confirmed", "inconclusive", "rejected")
21
+ SCHEMA_VERSION = "0.5"
22
+
23
+ HYPOTHESIS_CATEGORIES = {"dataflow", "authorization", "idor", "csrf", "xss", "redirect", "cors", "session"}
24
+
25
+
26
+ def classify(category: str, metadata: dict[str, Any], evidence_types: set[str], confidence: str) -> dict[str, Any]:
27
+ ver = metadata.get("verification") or {}
28
+ status = ver.get("status") or metadata.get("verification_status") or "unverified"
29
+ if status not in VERIFICATION_STATUSES:
30
+ status = "unverified"
31
+ explicit = metadata.get("evidence_class")
32
+ if status == "confirmed" and ver.get("procedure"):
33
+ cls = "confirmed_vulnerability"
34
+ elif status == "probable":
35
+ cls = "verified_behavior" if ver.get("behavior_reproduced") else "security_hypothesis"
36
+ elif status == "rejected" and ver.get("procedure"):
37
+ cls = "verified_behavior"
38
+ elif explicit in EVIDENCE_CLASSES and explicit not in ("verified_behavior", "confirmed_vulnerability"):
39
+ cls = explicit # an analyzer cannot claim verification by labelling itself
40
+ elif "RUNTIME_CONTEXT" in evidence_types or "RUNTIME_NOT_FOUND" in evidence_types:
41
+ cls = "observed_fact"
42
+ elif category in HYPOTHESIS_CATEGORIES or metadata.get("attack_surface_hypothesis"):
43
+ cls = "security_hypothesis"
44
+ else:
45
+ cls = "static_inference"
46
+ out: dict[str, Any] = {
47
+ "evidence_class": cls,
48
+ "verification_status": status,
49
+ "analysis_version": __version__,
50
+ "confidence_note": "confidence measures how well the evidence supports that this pattern exists; it is not proof of exploitability",
51
+ }
52
+ if metadata.get("limitations"):
53
+ out["limitations"] = list(metadata["limitations"])
54
+ if ver:
55
+ out["verification"] = ver
56
+ return out