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
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
|
jscope/core/__init__.py
ADDED
|
File without changes
|
jscope/core/artifacts.py
ADDED
|
@@ -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
|