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/analyzers/base.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Analyzer base class. Every analyzer: independent, declares deps, proposes
|
|
2
|
+
Candidates only -- never Findings directly (Evidence Engine owns promotion)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from jscope.core.artifacts import Artifact
|
|
10
|
+
from jscope.core.evidence import Candidate
|
|
11
|
+
from jscope.core.graph import KnowledgeGraph
|
|
12
|
+
from jscope.parsing.ast_engine import ASTEngine, ParsedFile
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class ProjectContext:
|
|
17
|
+
"""Shared read-only context passed to every analyzer run."""
|
|
18
|
+
files: list[ParsedFile]
|
|
19
|
+
ast: ASTEngine
|
|
20
|
+
framework_hints: dict[str, Any] = field(default_factory=dict)
|
|
21
|
+
config: dict[str, Any] = field(default_factory=dict)
|
|
22
|
+
graph: KnowledgeGraph = field(default_factory=KnowledgeGraph)
|
|
23
|
+
diagnostics: list[str] = field(default_factory=list)
|
|
24
|
+
artifacts: list[Artifact] = field(default_factory=list) # non-JS inputs (Phase 4)
|
|
25
|
+
shared: dict[str, Any] = field(default_factory=dict) # analyzer -> analyzer facts (declare depends_on!)
|
|
26
|
+
file_diagnostics: dict[str, list[str]] = field(default_factory=dict) # path -> what was skipped / partially analyzed (FR-02)
|
|
27
|
+
|
|
28
|
+
def add_file_diagnostic(self, path: str, message: str) -> None:
|
|
29
|
+
bucket = self.file_diagnostics.setdefault(path, [])
|
|
30
|
+
if message not in bucket:
|
|
31
|
+
bucket.append(message)
|
|
32
|
+
|
|
33
|
+
def artifacts_of(self, kind: str) -> list[Artifact]:
|
|
34
|
+
return [a for a in self.artifacts if a.kind == kind]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Analyzer:
|
|
38
|
+
name: str = "base"
|
|
39
|
+
version: str = "0.1.0"
|
|
40
|
+
depends_on: list[str] = [] # names of analyzers that must run first (hard: error if missing/disabled)
|
|
41
|
+
run_after: list[str] = [] # soft ordering hint: run after these IF they are enabled; ignored otherwise (Phase 4)
|
|
42
|
+
produces_categories: list[str] = [] # e.g. ["endpoint"]
|
|
43
|
+
tier: str = "core" # "core" | "verified" | "community" (set by the plugin loader for plugins)
|
|
44
|
+
|
|
45
|
+
def analyze_file(self, pf: ParsedFile, ctx: ProjectContext) -> list[Candidate]:
|
|
46
|
+
"""Per-file hook. Override in subclasses that work file-by-file."""
|
|
47
|
+
return []
|
|
48
|
+
|
|
49
|
+
def analyze_project(self, ctx: ProjectContext) -> list[Candidate]:
|
|
50
|
+
"""Project-wide hook (cross-file). Default: run analyze_file over all files."""
|
|
51
|
+
out: list[Candidate] = []
|
|
52
|
+
for pf in ctx.files:
|
|
53
|
+
if pf.skipped_reason:
|
|
54
|
+
continue
|
|
55
|
+
out.extend(self.analyze_file(pf, ctx))
|
|
56
|
+
return out
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Helpers shared by analyzers. Kept tiny on purpose: analyzers must stay
|
|
2
|
+
independently usable, so only genuinely common vocabulary lives here."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
from jscope.analyzers.endpoint_analyzer import _PATH_RE as PATH_RE, _classify as classify # noqa: F401
|
|
10
|
+
|
|
11
|
+
TEST_PATH_RE = re.compile(r"(__tests__|__mocks__|\.test\.|\.spec\.|/test/|/tests/|/mock/|/fixtures?/)", re.I)
|
|
12
|
+
|
|
13
|
+
GUARD_NAME_RE = re.compile(
|
|
14
|
+
r"(protected|private|require|guard|authenticated|withauth|authorized|rolegate|permission|"
|
|
15
|
+
r"canactivate|authgate|loginrequired)", re.I)
|
|
16
|
+
|
|
17
|
+
SENSITIVE_KEY_RE = re.compile(
|
|
18
|
+
r"(token|jwt|auth|session|secret|passw(or)?d|credential|api[_-]?key|refresh|bearer|otp)", re.I)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_ANALYSIS_ROOTS: list[str] = []
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def set_analysis_roots(paths) -> None:
|
|
25
|
+
"""Remember the directories being analyzed so test-path heuristics only look *inside* the analysis root.
|
|
26
|
+
|
|
27
|
+
Without this, a project that merely lives under e.g. `/home/me/tests/app/` would have every file treated as a test
|
|
28
|
+
fixture (and its findings demoted), purely because of where it sits on disk."""
|
|
29
|
+
roots: list[str] = []
|
|
30
|
+
for p in paths:
|
|
31
|
+
ap = os.path.abspath(str(p))
|
|
32
|
+
roots.append((ap if os.path.isdir(ap) else os.path.dirname(ap)).replace("\\", "/").rstrip("/"))
|
|
33
|
+
_ANALYSIS_ROOTS[:] = roots
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_test_path(path: str) -> bool:
|
|
37
|
+
norm = path.replace("\\", "/")
|
|
38
|
+
if _ANALYSIS_ROOTS:
|
|
39
|
+
absp = os.path.abspath(path).replace("\\", "/")
|
|
40
|
+
for root in _ANALYSIS_ROOTS:
|
|
41
|
+
if absp.startswith(root + "/"):
|
|
42
|
+
norm = absp[len(root):] # keeps a leading "/" so a top-level `tests/` dir still matches
|
|
43
|
+
break
|
|
44
|
+
elif not norm.startswith("/"):
|
|
45
|
+
norm = "/" + norm
|
|
46
|
+
return bool(TEST_PATH_RE.search(norm))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# --------------------------------------------------------------------------
|
|
50
|
+
# API path matching (Phase 4): spec paths <-> client paths
|
|
51
|
+
# --------------------------------------------------------------------------
|
|
52
|
+
from urllib.parse import urlparse # noqa: E402
|
|
53
|
+
|
|
54
|
+
_PARAM_SEG = re.compile(r"^(\{[^}]*\}|\$\{[^}]*\}|:[A-Za-z_]\w*|\[[^\]]*\]|<[^>]*>)$")
|
|
55
|
+
_STATIC_EXT = re.compile(r"\.(js|mjs|css|png|jpe?g|gif|svg|ico|woff2?|ttf|map|wasm|html?|webp|avif)$", re.I)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def path_segments(path: str) -> list[str]:
|
|
59
|
+
"""Normalize a path to segments, collapsing every parameter style ({id} ${id} :id [id] <id>) to '{}'."""
|
|
60
|
+
path = re.sub(r"\$\{[^}]*\}", "{}", path.split("?")[0].split("#")[0])
|
|
61
|
+
segs = [s for s in path.split("/") if s]
|
|
62
|
+
return ["{}" if (_PARAM_SEG.match(s) or "{}" in s) else s for s in segs]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def segs_match(a: list[str], b: list[str]) -> bool:
|
|
66
|
+
return len(a) == len(b) and all(x == y or x == "{}" or y == "{}" for x, y in zip(a, b))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def split_origin(url: str) -> tuple[str | None, str]:
|
|
70
|
+
"""('host', '/path') for an absolute URL, (None, '/path') for a relative one."""
|
|
71
|
+
if "://" in url:
|
|
72
|
+
u = urlparse(url)
|
|
73
|
+
return u.netloc.lower(), u.path or "/"
|
|
74
|
+
return None, url
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def is_static_asset(path: str) -> bool:
|
|
78
|
+
return bool(_STATIC_EXT.search(path.split("?")[0]))
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Data-flow analyzer (FR-01/FR-02): proposes Candidates for source -> sink paths found by the interprocedural engine.
|
|
2
|
+
|
|
3
|
+
Every Candidate states what is known and what is assumed. A static data-flow path is a *security hypothesis*: it never
|
|
4
|
+
reaches CONFIRMED on its own (the evidence weights cap it below that) and it carries `verification_status: unverified`
|
|
5
|
+
until `jscope verify` reproduces it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from jscope.analyzers.base import Analyzer, ProjectContext
|
|
14
|
+
from jscope.analyzers.common import is_test_path
|
|
15
|
+
from jscope.core.evidence import Candidate, Evidence, EvidenceType as ET
|
|
16
|
+
from jscope.core.graph import EdgeKind, NodeKind
|
|
17
|
+
from jscope.dataflow import rules as R
|
|
18
|
+
from jscope.dataflow.engine import DataFlowEngine, Flow
|
|
19
|
+
from jscope.dataflow.modules import ModuleResolver
|
|
20
|
+
|
|
21
|
+
SURFACE = {"html": "dom_xss_candidate", "code": "code_injection_candidate", "navigation": "open_redirect_candidate",
|
|
22
|
+
"sql": "sql_injection_candidate", "command": "command_injection_candidate"}
|
|
23
|
+
TESTS = {
|
|
24
|
+
"html": "Put a unique marker (e.g. `jscope\"><i id=jscx>`) into the source and check, in a browser, whether it is parsed as markup at the sink. "
|
|
25
|
+
"Use `jscope verify` with a scoped, authorised target to do this safely.",
|
|
26
|
+
"code": "Send a harmless unique expression through the source and confirm in the browser console whether it is evaluated.",
|
|
27
|
+
"navigation": "Supply an absolute URL on a domain you control and observe whether the browser navigates there; also try `//host` and `javascript:` forms.",
|
|
28
|
+
"sql": "Review the server code that builds this query; send a single quote and compare errors/timing in an authorised environment.",
|
|
29
|
+
"command": "Review how the command string is built; test only with harmless metacharacter probes in an authorised environment.",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _short(path: str) -> str:
|
|
34
|
+
return os.path.basename(path.split("#")[0]) + ("#" + path.split("#")[1] if "#" in path else "")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def flow_to_dict(f: Flow) -> dict[str, Any]:
|
|
38
|
+
return {
|
|
39
|
+
"source": {"kind": f.source_kind, "description": R.HUMAN_SOURCE.get(f.source_kind, f.source_kind), "file": f.source_file,
|
|
40
|
+
"line": f.source_line, "snippet": f.source_snippet},
|
|
41
|
+
"sink": {"class": f.sink_class, "name": f.sink_name, "file": f.sink_file, "line": f.sink_line, "snippet": f.sink_snippet},
|
|
42
|
+
"status": f.status, "cross_file": f.cross_file,
|
|
43
|
+
"path": [{"file": s[0], "line": s[1], "step": s[2]} for s in f.steps],
|
|
44
|
+
"mitigations": [{"name": m.name, "effective_for": sorted(m.effective), "uncertain_for": sorted(m.uncertain), "note": m.note}
|
|
45
|
+
for m in f.mitigations],
|
|
46
|
+
"assumptions": list(f.assumptions),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class DataFlowAnalyzer(Analyzer):
|
|
51
|
+
name = "dataflow_analyzer"
|
|
52
|
+
version = "0.5.0"
|
|
53
|
+
depends_on: list[str] = []
|
|
54
|
+
run_after = ["framework_detector"]
|
|
55
|
+
produces_categories = ["dataflow"]
|
|
56
|
+
|
|
57
|
+
def analyze_project(self, ctx: ProjectContext) -> list[Candidate]:
|
|
58
|
+
usable = []
|
|
59
|
+
for pf in ctx.files:
|
|
60
|
+
if pf.skipped_reason:
|
|
61
|
+
ctx.add_file_diagnostic(pf.path, f"not analyzed: {pf.skipped_reason}")
|
|
62
|
+
elif pf.tree is None:
|
|
63
|
+
ctx.add_file_diagnostic(pf.path, "not analyzed: no syntax tree")
|
|
64
|
+
else:
|
|
65
|
+
if pf.syntax_errors:
|
|
66
|
+
ctx.add_file_diagnostic(pf.path, f"partially analyzed: {pf.syntax_errors} syntax error node(s); "
|
|
67
|
+
"constructs inside them are invisible to JScope")
|
|
68
|
+
usable.append(pf)
|
|
69
|
+
keys = [(pf.path.split("#")[0] if pf.origin == "vue-sfc" else pf.path) for pf in usable]
|
|
70
|
+
resolver = ModuleResolver(keys, aliases=ctx.config.get("module_aliases") or {})
|
|
71
|
+
vue_templates = self._vue_templates(ctx)
|
|
72
|
+
engine = DataFlowEngine(usable, resolver, vue_templates)
|
|
73
|
+
|
|
74
|
+
cache = ctx.shared.get("dataflow_cache") # jscope.core.cache.AnalysisCache | None (FR-07)
|
|
75
|
+
if cache is not None:
|
|
76
|
+
stats = cache.run(engine, usable)
|
|
77
|
+
ctx.diagnostics.append(stats.summary())
|
|
78
|
+
ctx.shared["incremental"] = {"reused": stats.reused, "reanalyzed": stats.reanalyzed, "dropped": stats.dropped}
|
|
79
|
+
else:
|
|
80
|
+
engine.run()
|
|
81
|
+
|
|
82
|
+
for lim in engine.limitations:
|
|
83
|
+
if lim.kind in ("unresolved-import", "dynamic-import", "dynamic-require", "budget", "circular-import"):
|
|
84
|
+
ctx.add_file_diagnostic(lim.file, f"{lim.kind}: {lim.message}")
|
|
85
|
+
for path in engine.truncated_files:
|
|
86
|
+
ctx.add_file_diagnostic(path, "partially analyzed: data-flow budget exceeded")
|
|
87
|
+
if resolver.tsconfig_files:
|
|
88
|
+
ctx.shared["tsconfig_files"] = sorted(set(resolver.tsconfig_files))
|
|
89
|
+
|
|
90
|
+
flows = sorted(engine.flows.values(), key=lambda f: (f.sink_file, f.sink_line, f.source_file, f.source_line))
|
|
91
|
+
self._populate_graph(ctx, flows)
|
|
92
|
+
ctx.shared["dataflow"] = {
|
|
93
|
+
"flows": [flow_to_dict(f) for f in flows],
|
|
94
|
+
"limitations": [{"file": l.file, "line": l.line, "kind": l.kind, "message": l.message} for l in engine.limitations],
|
|
95
|
+
"import_edges": {k: sorted(v) for k, v in engine.import_edges.items()},
|
|
96
|
+
"unresolved_imports": [{"file": a, "line": b, "specifier": c, "reason": d} for a, b, c, d in engine.unresolved_imports],
|
|
97
|
+
"counts": {s: sum(1 for f in flows if f.status == s) for s in ("candidate", "uncertain", "sanitized", "unreachable")},
|
|
98
|
+
}
|
|
99
|
+
return [self._candidate(f, ctx, engine) for f in flows if f.status != "sanitized"]
|
|
100
|
+
|
|
101
|
+
def _populate_graph(self, ctx: ProjectContext, flows) -> None:
|
|
102
|
+
"""FR-05: every flow becomes `data_source -[flows_to]-> data_sink` in the knowledge graph, file-attributed and
|
|
103
|
+
tagged with how certain the path is -- distinct from an unrelated finding simply mentioning the same file."""
|
|
104
|
+
g = ctx.graph
|
|
105
|
+
for f in flows:
|
|
106
|
+
src_id = f"dsrc:{f.source_file}:{f.source_line}:{f.source_kind}"
|
|
107
|
+
sink_id = f"dsink:{f.sink_file}:{f.sink_line}:{f.sink_name}"
|
|
108
|
+
g.add_node(src_id, NodeKind.DATA_SOURCE, R.HUMAN_SOURCE.get(f.source_kind, f.source_kind), kind_detail=f.source_kind)
|
|
109
|
+
g.add_node(sink_id, NodeKind.DATA_SINK, f.sink_name, sink_class=f.sink_class)
|
|
110
|
+
exactness = "exact" if not f.assumptions and f.status != "uncertain" else "heuristic"
|
|
111
|
+
g.add_edge(src_id, sink_id, EdgeKind.FLOWS_TO, self.name,
|
|
112
|
+
"DATA_FLOW" if f.status != "sanitized" else "DATA_FLOW_SANITIZED", f.sink_file, f.sink_line,
|
|
113
|
+
note=f"status={f.status} exactness={exactness}" + (f"; assumes {'; '.join(f.assumptions)}" if f.assumptions else ""))
|
|
114
|
+
src_file_node, sink_file_node = f"file:{f.source_file}", f"file:{f.sink_file}"
|
|
115
|
+
g.add_node(src_file_node, NodeKind.FILE, f.source_file.rsplit("/", 1)[-1])
|
|
116
|
+
g.add_node(sink_file_node, NodeKind.FILE, f.sink_file.rsplit("/", 1)[-1])
|
|
117
|
+
g.add_edge(src_file_node, src_id, EdgeKind.CONTAINS, self.name, "AST_PATTERN_MATCH", f.source_file, f.source_line)
|
|
118
|
+
g.add_edge(sink_file_node, sink_id, EdgeKind.CONTAINS, self.name, "AST_PATTERN_MATCH", f.sink_file, f.sink_line)
|
|
119
|
+
|
|
120
|
+
# --------------------------------------------------------------------------------------------------
|
|
121
|
+
def _vue_templates(self, ctx: ProjectContext) -> list[tuple]:
|
|
122
|
+
out = []
|
|
123
|
+
try:
|
|
124
|
+
from jscope.parsing.html import soup
|
|
125
|
+
except Exception: # noqa: BLE001
|
|
126
|
+
return out
|
|
127
|
+
for art in ctx.artifacts_of("vue"):
|
|
128
|
+
try:
|
|
129
|
+
tpl = soup(art).find("template")
|
|
130
|
+
if tpl is None:
|
|
131
|
+
continue
|
|
132
|
+
for el in tpl.find_all(True):
|
|
133
|
+
expr = el.attrs.get("v-html")
|
|
134
|
+
if isinstance(expr, str) and expr.strip():
|
|
135
|
+
out.append((art.path + "#script", art.path, getattr(el, "sourceline", None) or 1, expr))
|
|
136
|
+
except Exception as exc: # noqa: BLE001
|
|
137
|
+
ctx.add_file_diagnostic(art.path, f"template analysis failed: {type(exc).__name__}")
|
|
138
|
+
# script paths for lang="ts" blocks carry a suffix; make both spellings match
|
|
139
|
+
extra = []
|
|
140
|
+
pfs = {pf.path for pf in ctx.files}
|
|
141
|
+
for sp, ap, ln, ex in out:
|
|
142
|
+
for alt in (sp + ".ts", sp + ".tsx"):
|
|
143
|
+
if alt in pfs:
|
|
144
|
+
extra.append((alt, ap, ln, ex))
|
|
145
|
+
return out + extra
|
|
146
|
+
|
|
147
|
+
def _candidate(self, f: Flow, ctx: ProjectContext, engine: DataFlowEngine) -> Candidate:
|
|
148
|
+
src_h = R.HUMAN_SOURCE.get(f.source_kind, f.source_kind)
|
|
149
|
+
subject = (f"{f.source_kind} -> {f.sink_name} @ {_short(f.sink_file)}:{f.sink_line}"
|
|
150
|
+
f" (source {_short(f.source_file)}:{f.source_line})")
|
|
151
|
+
path_txt = " -> ".join(f"{_short(s[0])}:{s[1]} {s[2]}" for s in f.steps)
|
|
152
|
+
partial = bool(f.assumptions) or f.status == "uncertain"
|
|
153
|
+
ev: list[Evidence] = [
|
|
154
|
+
Evidence(ET.AST_PATTERN_MATCH, f"{R.sink_label(f.sink_class)} sink {f.sink_name} receives a value derived from {src_h}",
|
|
155
|
+
self.name, f.sink_file, f.sink_line, f.sink_snippet, {"sink_class": f.sink_class}),
|
|
156
|
+
]
|
|
157
|
+
why = [f"source: {src_h} at {_short(f.source_file)}:{f.source_line}"]
|
|
158
|
+
if f.assumptions:
|
|
159
|
+
why.append("assumptions: " + "; ".join(f.assumptions))
|
|
160
|
+
if f.status == "uncertain":
|
|
161
|
+
why.append("uncertain validation: " + "; ".join(f"{m.name} ({m.note})" if m.note else m.name for m in f.mitigations))
|
|
162
|
+
ev.append(Evidence(
|
|
163
|
+
ET.DATA_FLOW_PARTIAL if partial else ET.DATA_FLOW,
|
|
164
|
+
("PARTIAL data-flow path (" + "; ".join(why[1:]) + "): " if partial else "data-flow path: ") + path_txt,
|
|
165
|
+
self.name, f.source_file, f.source_line, f.source_snippet,
|
|
166
|
+
{"path": [{"file": s[0], "line": s[1], "step": s[2]} for s in f.steps], "partial": partial}))
|
|
167
|
+
if f.cross_file:
|
|
168
|
+
ev.append(Evidence(ET.IMPORT_CHAIN, f"the path crosses {len(set(f.files))} files: " + " -> ".join(_short(x) for x in f.files),
|
|
169
|
+
self.name, f.sink_file, f.sink_line, None, {"files": list(f.files)}))
|
|
170
|
+
if f.status == "unreachable":
|
|
171
|
+
ev.append(Evidence(ET.DEAD_CODE, "the sink sits in a branch that is statically never taken", self.name, f.sink_file, f.sink_line))
|
|
172
|
+
if is_test_path(f.sink_file):
|
|
173
|
+
ev.append(Evidence(ET.TEST_FIXTURE_CONTEXT, "sink lives in a test/mock/fixture path", self.name, f.sink_file, f.sink_line))
|
|
174
|
+
low = f.sink_file.lower()
|
|
175
|
+
if ".min." in low or "/vendor/" in low or "node_modules" in low:
|
|
176
|
+
ev.append(Evidence(ET.VENDOR_CODE, "sink is inside vendor/minified code", self.name, f.sink_file, f.sink_line))
|
|
177
|
+
hints = ctx.framework_hints
|
|
178
|
+
if (f.sink_name == "dangerouslySetInnerHTML" and "react" in hints) or (f.sink_name == "v-html" and "vue" in hints):
|
|
179
|
+
ev.append(Evidence(ET.FRAMEWORK_CONTEXT, f"{f.sink_name} is the framework's explicit raw-HTML escape hatch", self.name,
|
|
180
|
+
f.sink_file, f.sink_line))
|
|
181
|
+
limitations = list(f.assumptions)
|
|
182
|
+
limitations.append("static analysis only: exploitability depends on runtime behaviour and server-side handling that JScope has not observed")
|
|
183
|
+
if f.status == "uncertain":
|
|
184
|
+
limitations.append("a validator on the path may or may not be sufficient; verify manually")
|
|
185
|
+
return Candidate(
|
|
186
|
+
category="dataflow", subject=subject, analyzer=self.name, evidence=ev,
|
|
187
|
+
attack_surface=SURFACE.get(f.sink_class), suggested_test=TESTS.get(f.sink_class),
|
|
188
|
+
metadata={
|
|
189
|
+
"flow": flow_to_dict(f), "sink_class": f.sink_class, "source_kind": f.source_kind, "flow_status": f.status,
|
|
190
|
+
"cross_file": f.cross_file, "limitations": limitations,
|
|
191
|
+
"evidence_class": "security_hypothesis", "verification_status": "unverified",
|
|
192
|
+
})
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Endpoint Analyzer.
|
|
2
|
+
|
|
3
|
+
Understanding over extraction: doesn't just regex for path-shaped strings.
|
|
4
|
+
Looks at fetch/axios/$.ajax/XHR call *shapes* via the AST, tries to resolve
|
|
5
|
+
the URL argument through simple const propagation, and classifies the path
|
|
6
|
+
by naming convention (admin/internal/debug/versioned) to seed attack-surface
|
|
7
|
+
tagging. Depends on framework_detector so it can flag /api/* differently
|
|
8
|
+
when Next.js is present (Server Actions/API routes have a different auth
|
|
9
|
+
model than an arbitrary fetch target).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
|
|
16
|
+
from jscope.analyzers.base import Analyzer, ProjectContext
|
|
17
|
+
from jscope.core.evidence import Candidate, Evidence, EvidenceType
|
|
18
|
+
from jscope.parsing.ast_engine import ParsedFile
|
|
19
|
+
|
|
20
|
+
_PATH_RE = re.compile(r"^(https?://[^\s'\"]+|/[a-zA-Z0-9_\-./{}\[\]:]*)$")
|
|
21
|
+
|
|
22
|
+
_SENSITIVE_PATTERNS = {
|
|
23
|
+
"admin": re.compile(r"/(admin|administrator)(/|$)", re.I),
|
|
24
|
+
"internal": re.compile(r"/(internal|_internal)(/|$)", re.I),
|
|
25
|
+
"debug": re.compile(r"/(_debug|debug)(/|$)", re.I),
|
|
26
|
+
"export": re.compile(r"/(export|download)(/|$)", re.I),
|
|
27
|
+
"upload": re.compile(r"/(upload)(/|$)", re.I),
|
|
28
|
+
"payment": re.compile(r"/(payment|billing|checkout|stripe)(/|$)", re.I),
|
|
29
|
+
"idor_candidate": re.compile(r"(^|/)[{:\[]+(id|[a-z]+_?id)[}\]]*(/|$)|(^|/)(id|user_?id|account_?id)(/|$)", re.I),
|
|
30
|
+
"graphql": re.compile(r"/graphql", re.I),
|
|
31
|
+
"auth": re.compile(r"/(auth|login|logout|register|password|token|oauth)(/|$)", re.I),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
_METHOD_FROM_CALLEE = {
|
|
35
|
+
"axios.get": "GET", "axios.post": "POST", "axios.put": "PUT",
|
|
36
|
+
"axios.delete": "DELETE", "axios.patch": "PATCH",
|
|
37
|
+
"$.get": "GET", "$.post": "POST",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _classify(path: str) -> str | None:
|
|
42
|
+
for tag, pattern in _SENSITIVE_PATTERNS.items():
|
|
43
|
+
if pattern.search(path):
|
|
44
|
+
return tag
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class EndpointAnalyzer(Analyzer):
|
|
49
|
+
name = "endpoint_analyzer"
|
|
50
|
+
depends_on = ["framework_detector"]
|
|
51
|
+
produces_categories = ["endpoint"]
|
|
52
|
+
|
|
53
|
+
def analyze_project(self, ctx: ProjectContext) -> list[Candidate]:
|
|
54
|
+
ctx.shared.setdefault("js_endpoints", {})
|
|
55
|
+
ctx.shared["endpoint_analyzer_ran"] = True # consumers only make "client never calls X" claims if this is set
|
|
56
|
+
return super().analyze_project(ctx)
|
|
57
|
+
|
|
58
|
+
def analyze_file(self, pf: ParsedFile, ctx: ProjectContext) -> list[Candidate]:
|
|
59
|
+
out: list[Candidate] = []
|
|
60
|
+
for site in ctx.ast.find_calls(pf):
|
|
61
|
+
request_like = site.callee == "fetch" or site.callee.startswith(("axios", "$.get", "$.post", "$.ajax"))
|
|
62
|
+
resolved = site.args_resolved[0] if site.args_resolved else None
|
|
63
|
+
if resolved is None or resolved.value is None or resolved.is_placeholder:
|
|
64
|
+
if request_like:
|
|
65
|
+
# a request whose URL we cannot resolve: it may be the "missing" caller of a documented operation
|
|
66
|
+
ctx.shared["unresolved_client_calls"] = ctx.shared.get("unresolved_client_calls", 0) + 1
|
|
67
|
+
continue
|
|
68
|
+
path = resolved.value
|
|
69
|
+
if not _PATH_RE.match(path):
|
|
70
|
+
if request_like and ("${" in path or "`" in path or not path.startswith(("/", "http"))):
|
|
71
|
+
ctx.shared["unresolved_client_calls"] = ctx.shared.get("unresolved_client_calls", 0) + 1 # dynamic URL
|
|
72
|
+
continue
|
|
73
|
+
if "${" in path:
|
|
74
|
+
# unresolved template interpolation -- still informative but weaker
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
evidence = [Evidence(
|
|
78
|
+
type=EvidenceType.AST_PATTERN_MATCH,
|
|
79
|
+
description=f"{site.callee}() call with this URL argument",
|
|
80
|
+
analyzer=self.name,
|
|
81
|
+
file=pf.path,
|
|
82
|
+
line=site.line,
|
|
83
|
+
snippet=site.args_raw[0][:120],
|
|
84
|
+
)]
|
|
85
|
+
if resolved.is_literal:
|
|
86
|
+
evidence.append(Evidence(
|
|
87
|
+
type=EvidenceType.LITERAL_STRING_MATCH,
|
|
88
|
+
description="URL is a literal string, not dynamically constructed",
|
|
89
|
+
analyzer=self.name,
|
|
90
|
+
))
|
|
91
|
+
if resolved.resolved_via_symbol:
|
|
92
|
+
evidence.append(Evidence(
|
|
93
|
+
type=EvidenceType.SYMBOL_RESOLVED,
|
|
94
|
+
description="URL resolved through a local variable binding, not a raw literal in the call",
|
|
95
|
+
analyzer=self.name,
|
|
96
|
+
))
|
|
97
|
+
|
|
98
|
+
tag = _classify(path)
|
|
99
|
+
if tag:
|
|
100
|
+
evidence.append(Evidence(
|
|
101
|
+
type=EvidenceType.NAMING_CONVENTION,
|
|
102
|
+
description=f"path matches '{tag}' naming convention",
|
|
103
|
+
analyzer=self.name,
|
|
104
|
+
))
|
|
105
|
+
|
|
106
|
+
if "Next.js" in ctx.framework_hints and path.startswith("/api/"):
|
|
107
|
+
evidence.append(Evidence(
|
|
108
|
+
type=EvidenceType.FRAMEWORK_CONTEXT,
|
|
109
|
+
description="Next.js detected in project; /api/* path matches its API route convention",
|
|
110
|
+
analyzer=self.name,
|
|
111
|
+
))
|
|
112
|
+
|
|
113
|
+
if ctx.ast.is_in_dead_branch(pf, site.node):
|
|
114
|
+
evidence.append(Evidence(
|
|
115
|
+
type=EvidenceType.DEAD_CODE,
|
|
116
|
+
description="call is inside a branch gated on a falsy/dev-only condition",
|
|
117
|
+
analyzer=self.name,
|
|
118
|
+
))
|
|
119
|
+
|
|
120
|
+
method = _METHOD_FROM_CALLEE.get(site.callee, "GET" if site.callee == "fetch" else None)
|
|
121
|
+
suggested_test = None
|
|
122
|
+
if tag == "idor_candidate":
|
|
123
|
+
suggested_test = "Vary the ID parameter across accounts/sessions and diff authorization results."
|
|
124
|
+
elif tag == "admin" or tag == "internal":
|
|
125
|
+
suggested_test = "Request as an unauthenticated / low-privilege user and check for missing access control."
|
|
126
|
+
elif tag == "auth":
|
|
127
|
+
suggested_test = "Test auth flow for JWT/session weaknesses, rate limiting, and response differences on valid vs invalid input."
|
|
128
|
+
|
|
129
|
+
if not any(e.type == EvidenceType.DEAD_CODE for e in evidence):
|
|
130
|
+
# shared fact for api_correlation: "the client really calls this" (not dead code)
|
|
131
|
+
info = ctx.shared.setdefault("js_endpoints", {}).setdefault(
|
|
132
|
+
path, {"methods": set(), "reliable_methods": set(), "fetch": False, "sites": []})
|
|
133
|
+
if method:
|
|
134
|
+
info["methods"].add(method)
|
|
135
|
+
if site.callee in _METHOD_FROM_CALLEE: # axios.post etc. state the method; bare fetch() does not
|
|
136
|
+
info["reliable_methods"].add(method)
|
|
137
|
+
if site.callee == "fetch":
|
|
138
|
+
info["fetch"] = True
|
|
139
|
+
info["sites"].append((pf.path, site.line))
|
|
140
|
+
|
|
141
|
+
out.append(Candidate(
|
|
142
|
+
category="endpoint",
|
|
143
|
+
subject=path,
|
|
144
|
+
analyzer=self.name,
|
|
145
|
+
evidence=evidence,
|
|
146
|
+
attack_surface=tag,
|
|
147
|
+
suggested_test=suggested_test,
|
|
148
|
+
metadata={"method": method, "callee": site.callee},
|
|
149
|
+
))
|
|
150
|
+
return out
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Framework Detector.
|
|
2
|
+
|
|
3
|
+
No deps on other analyzers -- it runs first, and other analyzers (Endpoint,
|
|
4
|
+
Router) consult `ctx.framework_hints` afterward for framework-aware
|
|
5
|
+
suppression/classification (design-brainstorm.md §3.3).
|
|
6
|
+
|
|
7
|
+
v1 scope, deliberately phased down per design-brainstorm.md §5:
|
|
8
|
+
React + Next.js get deep-ish treatment; Vue/Nuxt basic; everything else is
|
|
9
|
+
"detected, generic analysis applied" via a lexical marker table.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from jscope.analyzers.base import Analyzer, ProjectContext
|
|
15
|
+
from jscope.core.evidence import Candidate, Evidence, EvidenceType
|
|
16
|
+
from jscope.parsing.ast_engine import ParsedFile
|
|
17
|
+
|
|
18
|
+
# marker -> (framework, evidence description)
|
|
19
|
+
_MARKERS: dict[str, tuple[str, str]] = {
|
|
20
|
+
"__NEXT_DATA__": ("Next.js", "found __NEXT_DATA__ runtime marker"),
|
|
21
|
+
"_next/static": ("Next.js", "found _next/static chunk path convention"),
|
|
22
|
+
"__next_f": ("Next.js", "found Next.js App Router flight marker (__next_f)"),
|
|
23
|
+
"ReactDOM": ("React", "found ReactDOM global reference"),
|
|
24
|
+
"react.production.min": ("React", "found React production bundle fingerprint"),
|
|
25
|
+
"__vue__": ("Vue", "found __vue__ instance marker"),
|
|
26
|
+
"Vue.config": ("Vue", "found Vue.config reference"),
|
|
27
|
+
"_nuxt/": ("Nuxt", "found _nuxt/ chunk path convention"),
|
|
28
|
+
"__NUXT__": ("Nuxt", "found __NUXT__ runtime marker"),
|
|
29
|
+
"ng-version": ("Angular", "found ng-version attribute reference"),
|
|
30
|
+
"@angular/core": ("Angular", "found @angular/core import reference"),
|
|
31
|
+
"svelte-hydrate": ("Svelte", "found svelte-hydrate marker"),
|
|
32
|
+
"__SVELTEKIT__": ("Svelte", "found SvelteKit runtime marker"),
|
|
33
|
+
"__remixContext": ("Remix", "found __remixContext runtime marker"),
|
|
34
|
+
"astro-island": ("Astro", "found astro-island custom element marker"),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class FrameworkDetector(Analyzer):
|
|
39
|
+
name = "framework_detector"
|
|
40
|
+
depends_on = []
|
|
41
|
+
produces_categories = ["framework"]
|
|
42
|
+
|
|
43
|
+
def analyze_file(self, pf: ParsedFile, ctx: ProjectContext) -> list[Candidate]:
|
|
44
|
+
text = pf.text
|
|
45
|
+
out: list[Candidate] = []
|
|
46
|
+
for marker, (framework, desc) in _MARKERS.items():
|
|
47
|
+
if marker in text:
|
|
48
|
+
line = text[: text.index(marker)].count("\n") + 1
|
|
49
|
+
out.append(Candidate(
|
|
50
|
+
category="framework",
|
|
51
|
+
subject=framework,
|
|
52
|
+
analyzer=self.name,
|
|
53
|
+
evidence=[Evidence(
|
|
54
|
+
type=EvidenceType.LITERAL_STRING_MATCH,
|
|
55
|
+
description=desc,
|
|
56
|
+
analyzer=self.name,
|
|
57
|
+
file=pf.path,
|
|
58
|
+
line=line,
|
|
59
|
+
snippet=marker,
|
|
60
|
+
)],
|
|
61
|
+
))
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
def analyze_project(self, ctx: ProjectContext) -> list[Candidate]:
|
|
65
|
+
candidates = super().analyze_project(ctx)
|
|
66
|
+
# aggregate: bump confidence by adding a second, cross-file
|
|
67
|
+
# NAMING_CONVENTION-style piece of evidence when a framework shows
|
|
68
|
+
# up in 2+ files (more than a stray string in a vendor chunk).
|
|
69
|
+
counts: dict[str, int] = {}
|
|
70
|
+
for c in candidates:
|
|
71
|
+
counts[c.subject] = counts.get(c.subject, 0) + 1
|
|
72
|
+
for c in candidates:
|
|
73
|
+
if counts[c.subject] >= 2:
|
|
74
|
+
c.evidence.append(Evidence(
|
|
75
|
+
type=EvidenceType.AST_PATTERN_MATCH,
|
|
76
|
+
description=f"{c.subject} marker recurs across {counts[c.subject]} files",
|
|
77
|
+
analyzer=self.name,
|
|
78
|
+
))
|
|
79
|
+
# populate framework_hints for downstream analyzers
|
|
80
|
+
ctx.framework_hints.setdefault(c.subject, 0)
|
|
81
|
+
ctx.framework_hints[c.subject] += 1
|
|
82
|
+
return candidates
|