cobraphp 0.1.0.dev1__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.
- cobraphp/__init__.py +60 -0
- cobraphp/_backend.py +47 -0
- cobraphp/_placeholder.py +10 -0
- cobraphp/analysis/__init__.py +60 -0
- cobraphp/analysis/_ast_types.py +17 -0
- cobraphp/analysis/_call_builder.py +135 -0
- cobraphp/analysis/_call_graph.py +211 -0
- cobraphp/analysis/_call_graph_nodes.py +195 -0
- cobraphp/analysis/_callee_index.py +112 -0
- cobraphp/analysis/_defuse_builder.py +402 -0
- cobraphp/analysis/_errors.py +17 -0
- cobraphp/analysis/_file_filter.py +172 -0
- cobraphp/analysis/_project_root.py +29 -0
- cobraphp/analysis/_records.py +140 -0
- cobraphp/analysis/_semgrep_runner.py +322 -0
- cobraphp/analysis/_source_kinds.py +6 -0
- cobraphp/analysis/_taint_graph.py +200 -0
- cobraphp/analysis/_taint_propagator.py +295 -0
- cobraphp/analysis/_taint_rules.py +123 -0
- cobraphp/analysis/frameworks.toml +38 -0
- cobraphp/analysis/php_ast_types.toml +29 -0
- cobraphp/analysis/rules/database_taint.yaml +94 -0
- cobraphp/analysis/rules/user_input_taint.yaml +186 -0
- cobraphp/config.py +50 -0
- cobraphp/entries.py +39 -0
- cobraphp/errors.py +25 -0
- cobraphp/graphs.py +63 -0
- cobraphp/project.py +49 -0
- cobraphp/py.typed +0 -0
- cobraphp/results.py +116 -0
- cobraphp-0.1.0.dev1.dist-info/METADATA +105 -0
- cobraphp-0.1.0.dev1.dist-info/RECORD +35 -0
- cobraphp-0.1.0.dev1.dist-info/WHEEL +5 -0
- cobraphp-0.1.0.dev1.dist-info/licenses/LICENSE +339 -0
- cobraphp-0.1.0.dev1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Plain-data records of the call graph and the data-flow graph.
|
|
2
|
+
|
|
3
|
+
The call graph becomes CCG records of `GraphExport`; the cpg2py data-flow graph becomes the frozen
|
|
4
|
+
`DataFlowGraph`. Node ids are php-parser-py AST node ids of the AST the caller passed in.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections import deque
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from php_parser_py import AST, Node
|
|
15
|
+
|
|
16
|
+
from ..graphs import GraphEdge, GraphExport, GraphNode
|
|
17
|
+
from . import _taint_graph
|
|
18
|
+
from ._call_graph import CallGraph
|
|
19
|
+
from ._callee_index import FUNCTION_CALL_TYPE, CalleeIndex, _enclosing_class_name
|
|
20
|
+
from ._errors import StaticAnalysisError
|
|
21
|
+
from ._taint_rules import TAINT_RULES
|
|
22
|
+
|
|
23
|
+
CCG = "CCG"
|
|
24
|
+
FUNC_ENTITY = "CCG_FUNC_ENTITY"
|
|
25
|
+
CALL_SITE = "CCG_CALL_SITE"
|
|
26
|
+
FUNC_CONTAIN = "CCG_FUNC_CONTAIN"
|
|
27
|
+
SITE_CALL = "CCG_SITE_CALL"
|
|
28
|
+
CALL_CERTAINTY = "ccg_call_certainty"
|
|
29
|
+
# A callee named by the call itself: a free function, or a method of the class $this, self, static or a
|
|
30
|
+
# literal class name denotes.
|
|
31
|
+
STATIC = "STATIC"
|
|
32
|
+
# A callee found by method name alone, because the receiver names no class or that class has no such method.
|
|
33
|
+
SUSPICIOUS = "SUSPICIOUS"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
37
|
+
class DataFlowGraph:
|
|
38
|
+
"""AST node ids reached by each Source kind, and each node's direct data-flow predecessors.
|
|
39
|
+
|
|
40
|
+
`tainted` maps every Source kind to its sorted node ids; `predecessors` maps every data-flow node to its
|
|
41
|
+
predecessor ids in edge insertion order.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
tainted: Mapping[str, tuple[str, ...]]
|
|
45
|
+
predecessors: Mapping[str, tuple[str, ...]]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def call_graph_export(project_ast: AST, call_graph: CallGraph) -> GraphExport:
|
|
49
|
+
"""Convert `call_graph` into CCG records.
|
|
50
|
+
|
|
51
|
+
Top-level code is the function entity of its file, keyed by the file node id.
|
|
52
|
+
"""
|
|
53
|
+
callee_index = CalleeIndex(project_ast)
|
|
54
|
+
entities: list[GraphNode] = []
|
|
55
|
+
sites: list[GraphNode] = []
|
|
56
|
+
edges: list[GraphEdge] = []
|
|
57
|
+
file_entities: set[str] = set()
|
|
58
|
+
storage = call_graph.storage
|
|
59
|
+
for node_id in storage.get_nodes():
|
|
60
|
+
node_type = storage.get_node_prop(node_id, "node_type")
|
|
61
|
+
if node_type == "function_declaration":
|
|
62
|
+
declaration = project_ast.node(node_id)
|
|
63
|
+
entities.append(_entity(node_id, call_graph.get_func_node(node_id).name, declaration, declaration.start_line))
|
|
64
|
+
continue
|
|
65
|
+
if node_type != "call_site":
|
|
66
|
+
continue
|
|
67
|
+
site = project_ast.node(node_id)
|
|
68
|
+
caller_id = call_graph.get_caller_of_callsite(node_id)
|
|
69
|
+
if storage.get_node_prop(caller_id, "node_type") != "function_declaration":
|
|
70
|
+
file_node = project_ast.get_file_node(node_id)
|
|
71
|
+
caller_id = file_node.id
|
|
72
|
+
if caller_id not in file_entities:
|
|
73
|
+
file_entities.add(caller_id)
|
|
74
|
+
entities.append(_entity(caller_id, _relative_file(file_node), file_node, None))
|
|
75
|
+
sites.append(
|
|
76
|
+
GraphNode(id=node_id, graph=CCG, type=CALL_SITE, state=None, path=None,
|
|
77
|
+
props={"file": _relative_file(site), "line": site.start_line})
|
|
78
|
+
)
|
|
79
|
+
edges.append(GraphEdge(source=caller_id, target=node_id, type=FUNC_CONTAIN, labels=(), props={}))
|
|
80
|
+
for callee in call_graph.get_callees(node_id):
|
|
81
|
+
certainty = _call_certainty(project_ast, callee_index, site, project_ast.node(callee.node_id))
|
|
82
|
+
edges.append(
|
|
83
|
+
GraphEdge(source=node_id, target=callee.node_id, type=SITE_CALL, labels=(),
|
|
84
|
+
props={CALL_CERTAINTY: certainty})
|
|
85
|
+
)
|
|
86
|
+
return GraphExport(header={}, nodes=(*entities, *sites), edges=tuple(edges), label_order=())
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def data_flow_graph(graph: _taint_graph.DataFlowGraph) -> DataFlowGraph:
|
|
90
|
+
"""Copy the taints and predecessor lists of the cpg2py data-flow graph."""
|
|
91
|
+
storage = graph.storage
|
|
92
|
+
return DataFlowGraph(
|
|
93
|
+
tainted={kind: tuple(sorted(graph.get_sources_by_type(kind))) for kind in TAINT_RULES},
|
|
94
|
+
predecessors={node_id: tuple(storage.predecessors(node_id)) for node_id in storage.get_nodes()},
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def backward_slice(graph: DataFlowGraph, node_id: str, *, max_depth: int = 10) -> tuple[str, ...]:
|
|
99
|
+
"""Predecessors of `node_id` within `max_depth` edges, breadth-first, excluding `node_id`."""
|
|
100
|
+
if node_id not in graph.predecessors:
|
|
101
|
+
return ()
|
|
102
|
+
visited: set[str] = set()
|
|
103
|
+
queue: deque[tuple[str, int]] = deque([(node_id, 0)])
|
|
104
|
+
result: list[str] = []
|
|
105
|
+
while queue:
|
|
106
|
+
current, depth = queue.popleft()
|
|
107
|
+
if current in visited:
|
|
108
|
+
continue
|
|
109
|
+
visited.add(current)
|
|
110
|
+
if current != node_id:
|
|
111
|
+
result.append(current)
|
|
112
|
+
if depth >= max_depth:
|
|
113
|
+
continue
|
|
114
|
+
for predecessor in graph.predecessors.get(current, ()):
|
|
115
|
+
if predecessor not in visited:
|
|
116
|
+
queue.append((predecessor, depth + 1))
|
|
117
|
+
return tuple(result)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _entity(node_id: str, name: str, node: Node, line: int | None) -> GraphNode:
|
|
121
|
+
return GraphNode(id=node_id, graph=CCG, type=FUNC_ENTITY, state=None, path=None,
|
|
122
|
+
props={"name": name, "file": _relative_file(node), "line": line})
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# Project-relative POSIX path of the file that holds `node`.
|
|
126
|
+
def _relative_file(node: Node) -> str:
|
|
127
|
+
relative = node.relative_path
|
|
128
|
+
if relative is None:
|
|
129
|
+
raise StaticAnalysisError(f"Node {node.id} has no file relativePath")
|
|
130
|
+
return Path(relative).as_posix()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# STATIC when the resolution used the call's own name or receiver class, SUSPICIOUS for the name-only fallback.
|
|
134
|
+
def _call_certainty(project_ast: AST, callee_index: CalleeIndex, site: Node, callee: Node) -> str:
|
|
135
|
+
if site.node_type == FUNCTION_CALL_TYPE:
|
|
136
|
+
return STATIC
|
|
137
|
+
receiver_class = callee_index._receiver_class_name(site)
|
|
138
|
+
if receiver_class is not None and _enclosing_class_name(project_ast, callee) == receiver_class:
|
|
139
|
+
return STATIC
|
|
140
|
+
return SUSPICIOUS
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Semgrep process execution and AST offset mapping utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
import tempfile
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from php_parser_py import AST, NodeNotInFileError
|
|
14
|
+
|
|
15
|
+
from ..errors import DependencyUnavailableError
|
|
16
|
+
from ._errors import AnalysisTimeoutError, StaticAnalysisError
|
|
17
|
+
from ._file_filter import _EXCLUDED_DIRS, _EXCLUDED_FILES, framework_excluded_dirs
|
|
18
|
+
from ._project_root import project_root_text
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# Type alias for Semgrep JSON output structure.
|
|
23
|
+
SemgrepResult = dict[str, object]
|
|
24
|
+
SemgrepOutput = dict[str, list[SemgrepResult]]
|
|
25
|
+
|
|
26
|
+
# Program the scan runs, looked up on PATH.
|
|
27
|
+
_SEMGREP_PROGRAM = "semgrep"
|
|
28
|
+
|
|
29
|
+
# Environment settings of the scan process that override the caller's values.
|
|
30
|
+
# SEMGREP_ENABLE_VERSION_CHECK=0 stops the request to the Semgrep version-check
|
|
31
|
+
# URL. It is set in the child environment rather than as --disable-version-check,
|
|
32
|
+
# because Semgrep 1.85 exits with "mutually exclusive options" when that option
|
|
33
|
+
# meets a SEMGREP_ENABLE_VERSION_CHECK value in the caller's environment.
|
|
34
|
+
_SEMGREP_ENVIRONMENT = {"SEMGREP_ENABLE_VERSION_CHECK": "0"}
|
|
35
|
+
|
|
36
|
+
# Per-(rule, file) wall-clock budget. On exceeding it Semgrep skips that file
|
|
37
|
+
# and continues, returning partial results instead of hanging — the mechanism
|
|
38
|
+
# that keeps pathological files from blocking the whole scan.
|
|
39
|
+
_PER_FILE_TIMEOUT_SECONDS = 5
|
|
40
|
+
# A file is dropped entirely after this many rules time out on it.
|
|
41
|
+
_TIMEOUT_THRESHOLD = 3
|
|
42
|
+
# Per-file memory ceiling (MiB); guards against OOM SIGKILL on huge files.
|
|
43
|
+
_MAX_MEMORY_MB = 5000
|
|
44
|
+
|
|
45
|
+
# Semgrep tags non-fatal, file-level problems (per-file timeout, partial parse,
|
|
46
|
+
# out-of-memory on one file) with these levels. They leave the rest of the scan
|
|
47
|
+
# intact, so they degrade to a warning rather than failing the whole analysis.
|
|
48
|
+
_RECOVERABLE_ERROR_LEVELS = frozenset({"warn", "warning", "info"})
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def run_semgrep(
|
|
52
|
+
rule_yaml: str,
|
|
53
|
+
project_path: Path,
|
|
54
|
+
timeout: int | None,
|
|
55
|
+
target_files: set[str] | None = None,
|
|
56
|
+
) -> SemgrepOutput:
|
|
57
|
+
"""Execute Semgrep and return parsed JSON output.
|
|
58
|
+
|
|
59
|
+
When ``target_files`` (relative paths under ``project_path``) is given,
|
|
60
|
+
Semgrep scans only those that exist instead of the whole tree — keeping
|
|
61
|
+
analysis to the executed/covered files so large projects do not time out.
|
|
62
|
+
When none of them exists, Semgrep is not run and the output is empty.
|
|
63
|
+
"""
|
|
64
|
+
targets = None if target_files is None else _existing_targets(project_path, target_files)
|
|
65
|
+
if targets == []:
|
|
66
|
+
logger.warning("Semgrep scan skipped: no target file exists: project=%s", project_path)
|
|
67
|
+
return {"results": [], "errors": []}
|
|
68
|
+
rule_path: Path | None = None
|
|
69
|
+
try:
|
|
70
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as rule_file:
|
|
71
|
+
rule_path = Path(rule_file.name)
|
|
72
|
+
rule_file.write(rule_yaml)
|
|
73
|
+
return _run_and_parse(rule_path, project_path, targets, timeout)
|
|
74
|
+
except subprocess.TimeoutExpired as exc:
|
|
75
|
+
# Only reachable when a non-None timeout was set; exc.timeout carries it.
|
|
76
|
+
raise AnalysisTimeoutError(int(exc.timeout)) from exc
|
|
77
|
+
except FileNotFoundError as exc:
|
|
78
|
+
if exc.filename != _SEMGREP_PROGRAM:
|
|
79
|
+
raise StaticAnalysisError(f"Semgrep input or process failed: {exc}") from exc
|
|
80
|
+
raise DependencyUnavailableError(f"The {_SEMGREP_PROGRAM} program is not on PATH") from exc
|
|
81
|
+
except OSError as exc:
|
|
82
|
+
raise StaticAnalysisError(f"Semgrep input or process failed: {exc}") from exc
|
|
83
|
+
finally:
|
|
84
|
+
if rule_path is not None:
|
|
85
|
+
rule_path.unlink(missing_ok=True)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _run_and_parse(
|
|
89
|
+
rule_path: Path,
|
|
90
|
+
project_path: Path,
|
|
91
|
+
targets: list[Path] | None,
|
|
92
|
+
timeout: int | None,
|
|
93
|
+
) -> SemgrepOutput:
|
|
94
|
+
"""Run Semgrep on the rule file and return its parsed, error-checked output."""
|
|
95
|
+
result = subprocess.run(
|
|
96
|
+
_build_semgrep_command(rule_path, project_path, targets),
|
|
97
|
+
capture_output=True,
|
|
98
|
+
text=True,
|
|
99
|
+
timeout=timeout,
|
|
100
|
+
check=False,
|
|
101
|
+
env=_semgrep_environment(),
|
|
102
|
+
)
|
|
103
|
+
output = _parse_semgrep_output(result.stdout)
|
|
104
|
+
_check_semgrep_errors(output, result.returncode, result.stderr)
|
|
105
|
+
return output
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _semgrep_environment() -> dict[str, str]:
|
|
109
|
+
"""The caller's environment with the scan's overrides; ``os.environ`` is unchanged."""
|
|
110
|
+
return {**os.environ, **_SEMGREP_ENVIRONMENT}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _existing_targets(project_path: Path, target_files: set[str]) -> list[Path]:
|
|
114
|
+
# Target files resolved under the project root, keeping only existing files.
|
|
115
|
+
candidates = (project_path / f for f in sorted(target_files))
|
|
116
|
+
return [p for p in candidates if p.is_file()]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _tree_exclude_args(project_path: Path) -> list[str]:
|
|
120
|
+
# Mirror the AST file filter: dependency dirs and monolith files match by
|
|
121
|
+
# name at any depth; framework subtrees are anchored at the scan root.
|
|
122
|
+
framework_dirs = framework_excluded_dirs(project_path)
|
|
123
|
+
anchored = sorted("/" + d.relative_to(project_path).as_posix() for d in framework_dirs)
|
|
124
|
+
patterns = [*sorted(_EXCLUDED_DIRS), *sorted(_EXCLUDED_FILES), *anchored]
|
|
125
|
+
return [arg for pattern in patterns for arg in ("--exclude", pattern)]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _build_semgrep_command(
|
|
129
|
+
rule_path: Path,
|
|
130
|
+
project_path: Path,
|
|
131
|
+
targets: list[Path] | None = None,
|
|
132
|
+
) -> list[str]:
|
|
133
|
+
"""Build the Semgrep argv.
|
|
134
|
+
|
|
135
|
+
With ``targets``, scan exactly those files; they are already the
|
|
136
|
+
covered/relevant set, so no directory traversal or exclude globs are
|
|
137
|
+
needed. Without it, scan the whole tree excluding what the AST file
|
|
138
|
+
filter excludes.
|
|
139
|
+
"""
|
|
140
|
+
command = [
|
|
141
|
+
_SEMGREP_PROGRAM,
|
|
142
|
+
"--config",
|
|
143
|
+
str(rule_path),
|
|
144
|
+
"--json",
|
|
145
|
+
"--no-git-ignore",
|
|
146
|
+
"--timeout",
|
|
147
|
+
str(_PER_FILE_TIMEOUT_SECONDS),
|
|
148
|
+
"--timeout-threshold",
|
|
149
|
+
str(_TIMEOUT_THRESHOLD),
|
|
150
|
+
"--max-memory",
|
|
151
|
+
str(_MAX_MEMORY_MB),
|
|
152
|
+
]
|
|
153
|
+
if targets is not None:
|
|
154
|
+
return [*command, *(str(p) for p in targets)]
|
|
155
|
+
return [*command, *_tree_exclude_args(project_path), str(project_path)]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _parse_semgrep_output(stdout: str) -> SemgrepOutput:
|
|
159
|
+
"""Parse Semgrep JSON stdout into typed dict."""
|
|
160
|
+
try:
|
|
161
|
+
return json.loads(stdout) # type: ignore[no-any-return]
|
|
162
|
+
except json.JSONDecodeError as exc:
|
|
163
|
+
raise StaticAnalysisError(f"Failed to parse Semgrep JSON output: {exc}") from exc
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _check_semgrep_errors(
|
|
167
|
+
output: SemgrepOutput,
|
|
168
|
+
returncode: int,
|
|
169
|
+
stderr: str,
|
|
170
|
+
) -> None:
|
|
171
|
+
"""Raise StaticAnalysisError on fatal Semgrep errors only.
|
|
172
|
+
|
|
173
|
+
File-level problems (per-file timeout, partial parse, single-file OOM) are
|
|
174
|
+
recoverable: Semgrep skips the offending file and returns results for the
|
|
175
|
+
rest. Discarding those partial results would turn one slow file into a
|
|
176
|
+
whole-repo failure, so they are logged and tolerated. Only rule-execution
|
|
177
|
+
failures (level ``error``) or a crash with no structured error are fatal.
|
|
178
|
+
"""
|
|
179
|
+
errors = output.get("errors", [])
|
|
180
|
+
fatal = [e for e in errors if not _is_recoverable_error(e)]
|
|
181
|
+
recoverable = len(errors) - len(fatal)
|
|
182
|
+
if recoverable:
|
|
183
|
+
logger.warning(
|
|
184
|
+
"Semgrep skipped %d target(s) (timeout/partial-parse); continuing with partial results",
|
|
185
|
+
recoverable,
|
|
186
|
+
)
|
|
187
|
+
if fatal:
|
|
188
|
+
messages = [e.get("message") or e.get("short_msg") or "Unknown error" for e in fatal]
|
|
189
|
+
raise StaticAnalysisError("Semgrep rule execution failed: " + "; ".join(str(m) for m in messages))
|
|
190
|
+
if returncode and not errors:
|
|
191
|
+
stderr_text = stderr.strip() if stderr else ""
|
|
192
|
+
raise StaticAnalysisError(f"Semgrep execution failed with code {returncode}. {stderr_text}")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _is_recoverable_error(error: SemgrepResult) -> bool:
|
|
196
|
+
"""Whether a Semgrep error entry is a tolerable file-level problem."""
|
|
197
|
+
return str(error.get("level", "error")).lower() in _RECOVERABLE_ERROR_LEVELS
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ── Path resolution ─────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def resolve_project_path(ast: AST) -> Path:
|
|
204
|
+
"""Resolve project root path from the AST project node.
|
|
205
|
+
|
|
206
|
+
Raises:
|
|
207
|
+
ValueError: If the project node is missing or has no usable absolutePath.
|
|
208
|
+
"""
|
|
209
|
+
return Path(project_root_text(ast)).resolve()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def build_file_id_index(ast: AST) -> dict[str, str]:
|
|
213
|
+
"""Build mapping from resolved absolute path to file node ID."""
|
|
214
|
+
index: dict[str, str] = {}
|
|
215
|
+
for file_node in ast.file_nodes():
|
|
216
|
+
abs_path = file_node.get("absolutePath")
|
|
217
|
+
if abs_path:
|
|
218
|
+
index[str(Path(str(abs_path)).resolve())] = file_node.id
|
|
219
|
+
return index
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def resolve_file_node_id(
|
|
223
|
+
semgrep_path: str,
|
|
224
|
+
project_path: Path,
|
|
225
|
+
file_id_by_path: dict[str, str],
|
|
226
|
+
) -> str | None:
|
|
227
|
+
"""Resolve Semgrep result path to a file node ID."""
|
|
228
|
+
if not semgrep_path:
|
|
229
|
+
return None
|
|
230
|
+
resolved = str((project_path / semgrep_path).resolve())
|
|
231
|
+
if resolved in file_id_by_path:
|
|
232
|
+
return file_id_by_path[resolved]
|
|
233
|
+
resolved_direct = str(Path(semgrep_path).resolve())
|
|
234
|
+
return file_id_by_path.get(resolved_direct)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# ── Offset mapping ──────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@dataclass(frozen=True)
|
|
241
|
+
class _BestNode:
|
|
242
|
+
"""A span rank preferring expression nodes on ties."""
|
|
243
|
+
|
|
244
|
+
node_id: str
|
|
245
|
+
span: int
|
|
246
|
+
is_expr: bool
|
|
247
|
+
|
|
248
|
+
def better_than(self, other: _BestNode | None) -> bool:
|
|
249
|
+
"""Whether this candidate beats the incumbent (None means no incumbent)."""
|
|
250
|
+
if other is None:
|
|
251
|
+
return True
|
|
252
|
+
if self.span != other.span:
|
|
253
|
+
return self.span < other.span
|
|
254
|
+
return self.is_expr and not other.is_expr
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@dataclass(frozen=True)
|
|
258
|
+
class _IndexedNode:
|
|
259
|
+
"""One indexed node and its containing file, independent of live wrappers."""
|
|
260
|
+
|
|
261
|
+
node_id: str
|
|
262
|
+
span: int
|
|
263
|
+
is_expr: bool
|
|
264
|
+
file_id: str | None
|
|
265
|
+
is_leaf: bool
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@dataclass(frozen=True)
|
|
269
|
+
class OffsetIndex:
|
|
270
|
+
"""Frozen AST start-offset buckets shared by point and span lookups."""
|
|
271
|
+
|
|
272
|
+
_by_offset: dict[int, list[_IndexedNode]]
|
|
273
|
+
|
|
274
|
+
def resolve(self, byte_offset: int, file_node_id: str | None = None) -> str | None:
|
|
275
|
+
"""Resolve the most specific node at an optionally file-scoped offset."""
|
|
276
|
+
return self._resolve(byte_offset, file_node_id, end=None)
|
|
277
|
+
|
|
278
|
+
def resolve_source_span(self, start: int, end: int, file_node_id: str) -> str | None:
|
|
279
|
+
"""Resolve only a leaf expression matching the trace's half-open span."""
|
|
280
|
+
return self._resolve(start, file_node_id, end=end)
|
|
281
|
+
|
|
282
|
+
def _resolve(self, byte_offset: int, file_node_id: str | None, end: int | None) -> str | None:
|
|
283
|
+
best: _BestNode | None = None
|
|
284
|
+
for indexed in self._by_offset.get(byte_offset, ()):
|
|
285
|
+
if file_node_id is not None and indexed.file_id != file_node_id:
|
|
286
|
+
continue
|
|
287
|
+
if end is not None and (
|
|
288
|
+
not indexed.is_expr or not indexed.is_leaf or indexed.span + 1 != end - byte_offset
|
|
289
|
+
):
|
|
290
|
+
continue
|
|
291
|
+
candidate = _BestNode(indexed.node_id, indexed.span, indexed.is_expr)
|
|
292
|
+
if candidate.better_than(best):
|
|
293
|
+
best = candidate
|
|
294
|
+
return best.node_id if best is not None else None
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def build_offset_index(ast: AST) -> OffsetIndex:
|
|
298
|
+
"""Index positioned nodes once, using the graph for containing-file identity."""
|
|
299
|
+
by_offset: dict[int, list[_IndexedNode]] = {}
|
|
300
|
+
for node in ast.nodes():
|
|
301
|
+
start_pos = node.start_file_pos
|
|
302
|
+
end_pos = node.end_file_pos
|
|
303
|
+
if start_pos is None or end_pos is None:
|
|
304
|
+
continue
|
|
305
|
+
by_offset.setdefault(start_pos, []).append(
|
|
306
|
+
_IndexedNode(
|
|
307
|
+
node_id=node.id,
|
|
308
|
+
span=end_pos - start_pos,
|
|
309
|
+
is_expr=node.node_type.startswith("Expr_"),
|
|
310
|
+
file_id=_containing_file_id(ast, node.id),
|
|
311
|
+
is_leaf=not any(ast.succ(node)),
|
|
312
|
+
)
|
|
313
|
+
)
|
|
314
|
+
return OffsetIndex(by_offset)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _containing_file_id(ast: AST, node_id: str) -> str | None:
|
|
318
|
+
"""Return the ID of the File node containing ``node_id``, or None outside every file."""
|
|
319
|
+
try:
|
|
320
|
+
return ast.get_file_node(node_id).id
|
|
321
|
+
except NodeNotInFileError:
|
|
322
|
+
return None
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Data flow graph for taint propagation tracking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import deque
|
|
6
|
+
from collections.abc import Iterable, Iterator
|
|
7
|
+
|
|
8
|
+
from cpg2py import AbcEdgeQuerier, AbcGraphQuerier, AbcNodeQuerier, Storage
|
|
9
|
+
|
|
10
|
+
from ._errors import StaticAnalysisError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DataFlowNode(AbcNodeQuerier): # type: ignore[misc]
|
|
14
|
+
"""Storage wrapper for dataflow graph node taint metadata."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, storage: Storage, node_id: str) -> None:
|
|
17
|
+
super().__init__(storage, node_id)
|
|
18
|
+
self._storage_ref = storage
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def taints(self) -> list[str]:
|
|
22
|
+
"""List of Source kinds that have tainted this node."""
|
|
23
|
+
taints_list = self.get_property("taints") or []
|
|
24
|
+
return [str(t) for t in taints_list]
|
|
25
|
+
|
|
26
|
+
@taints.setter
|
|
27
|
+
def taints(self, value: list[str]) -> None:
|
|
28
|
+
"""Set taints list."""
|
|
29
|
+
self._storage_ref.set_node_prop(self.node_id, "taints", list(value))
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def is_tainted(self) -> bool:
|
|
33
|
+
"""Whether this node is tainted (computed from taints)."""
|
|
34
|
+
return len(self.taints) > 0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DataFlowEdge(AbcEdgeQuerier): # type: ignore[misc]
|
|
38
|
+
"""Storage wrapper for dataflow graph edge."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, storage: Storage, f_nid: str, t_nid: str, e_type: str) -> None:
|
|
41
|
+
super().__init__(storage, f_nid, t_nid, e_type)
|
|
42
|
+
self._storage_ref = storage
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class DataFlowGraph(AbcGraphQuerier[DataFlowNode, DataFlowEdge]): # type: ignore[misc]
|
|
46
|
+
"""Data flow graph tracking taint propagation via cpg2py Storage."""
|
|
47
|
+
|
|
48
|
+
def __init__(self) -> None:
|
|
49
|
+
storage = Storage()
|
|
50
|
+
super().__init__(target=storage, maxdepth=-1)
|
|
51
|
+
|
|
52
|
+
def node(self, whose_id_is: str) -> DataFlowNode:
|
|
53
|
+
"""Returns DataFlowNode by ID."""
|
|
54
|
+
if not self.storage.contains_node(whose_id_is):
|
|
55
|
+
raise StaticAnalysisError(f"Node {whose_id_is} does not exist in dataflow graph")
|
|
56
|
+
return DataFlowNode(self.storage, whose_id_is)
|
|
57
|
+
|
|
58
|
+
def edge(self, fid: str, tid: str, eid: str) -> DataFlowEdge:
|
|
59
|
+
"""Returns DataFlowEdge by source, target, edge type."""
|
|
60
|
+
edge_id = (fid, tid, eid)
|
|
61
|
+
if not self.storage.contains_edge(edge_id):
|
|
62
|
+
raise StaticAnalysisError(f"Edge {edge_id} does not exist in dataflow graph")
|
|
63
|
+
return DataFlowEdge(self.storage, fid, tid, eid)
|
|
64
|
+
|
|
65
|
+
def add_dataflow_node(self, node_id: str) -> DataFlowNode:
|
|
66
|
+
"""Add source or sink node."""
|
|
67
|
+
if not node_id:
|
|
68
|
+
raise ValueError("node_id must be non-empty")
|
|
69
|
+
if self.storage.contains_node(node_id):
|
|
70
|
+
msg = f"Node {node_id} already exists"
|
|
71
|
+
raise StaticAnalysisError(msg)
|
|
72
|
+
|
|
73
|
+
self.storage.add_node(node_id)
|
|
74
|
+
df_node = DataFlowNode(self.storage, node_id)
|
|
75
|
+
df_node.taints = []
|
|
76
|
+
return df_node
|
|
77
|
+
|
|
78
|
+
def add_dataflow_edge(
|
|
79
|
+
self,
|
|
80
|
+
from_nid: str,
|
|
81
|
+
to_nid: str,
|
|
82
|
+
source_type: str,
|
|
83
|
+
) -> DataFlowEdge:
|
|
84
|
+
"""Add data flow edge and taint both endpoints.
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
ValueError: If an ID is empty.
|
|
88
|
+
StaticAnalysisError: If a node does not exist.
|
|
89
|
+
"""
|
|
90
|
+
self._validate_edge_nodes(from_nid, to_nid)
|
|
91
|
+
|
|
92
|
+
edge_id = (from_nid, to_nid, "FLOWS_TO")
|
|
93
|
+
self.storage.add_edge(edge_id)
|
|
94
|
+
|
|
95
|
+
self._mark_tainted(from_nid, source_type)
|
|
96
|
+
self._mark_tainted(to_nid, source_type)
|
|
97
|
+
|
|
98
|
+
return DataFlowEdge(self.storage, from_nid, to_nid, "FLOWS_TO")
|
|
99
|
+
|
|
100
|
+
def _validate_edge_nodes(self, from_nid: str, to_nid: str) -> None:
|
|
101
|
+
"""Validate that edge endpoint nodes exist."""
|
|
102
|
+
if not from_nid:
|
|
103
|
+
raise ValueError("from_nid must be non-empty")
|
|
104
|
+
if not to_nid:
|
|
105
|
+
raise ValueError("to_nid must be non-empty")
|
|
106
|
+
if not self.storage.contains_node(from_nid):
|
|
107
|
+
raise StaticAnalysisError(f"Source node {from_nid} does not exist")
|
|
108
|
+
if not self.storage.contains_node(to_nid):
|
|
109
|
+
raise StaticAnalysisError(f"Sink node {to_nid} does not exist")
|
|
110
|
+
|
|
111
|
+
def _mark_tainted(self, node_id: str, source_type: str) -> None:
|
|
112
|
+
"""Add source_type to node's taint list if not already present."""
|
|
113
|
+
node = self.get_dataflow_node(node_id)
|
|
114
|
+
current = node.taints
|
|
115
|
+
if source_type not in current:
|
|
116
|
+
current.append(source_type)
|
|
117
|
+
node.taints = current
|
|
118
|
+
|
|
119
|
+
def add_structural_edge(self, from_nid: str, to_nid: str) -> None:
|
|
120
|
+
"""Add a structural dataflow edge without marking taint.
|
|
121
|
+
|
|
122
|
+
Raises:
|
|
123
|
+
ValueError: If an ID is empty.
|
|
124
|
+
StaticAnalysisError: If a node does not exist.
|
|
125
|
+
"""
|
|
126
|
+
self._validate_edge_nodes(from_nid, to_nid)
|
|
127
|
+
edge_id = (from_nid, to_nid, "FLOWS_TO")
|
|
128
|
+
if not self.storage.contains_edge(edge_id):
|
|
129
|
+
self.storage.add_edge(edge_id)
|
|
130
|
+
|
|
131
|
+
def get_sinks_reachable_from(self, source_node_id: str) -> list[str]:
|
|
132
|
+
"""Get sink node IDs reachable from source via BFS.
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
StaticAnalysisError: If source_node does not exist.
|
|
136
|
+
"""
|
|
137
|
+
if not self.storage.contains_node(source_node_id):
|
|
138
|
+
raise StaticAnalysisError(f"Source node {source_node_id} does not exist")
|
|
139
|
+
return self._bfs_forward(source_node_id)
|
|
140
|
+
|
|
141
|
+
def get_dataflow_node(self, node_id: str) -> DataFlowNode:
|
|
142
|
+
"""Retrieve DataFlowNode by ID (alias for node())."""
|
|
143
|
+
return self.node(whose_id_is=node_id)
|
|
144
|
+
|
|
145
|
+
def get_sources_by_type(self, source_type: str) -> set[str]:
|
|
146
|
+
"""Get node IDs tainted by the given Source kind."""
|
|
147
|
+
source_ids = set()
|
|
148
|
+
for node_id in self.storage.get_nodes():
|
|
149
|
+
node = self.get_dataflow_node(node_id)
|
|
150
|
+
if node and source_type in node.taints:
|
|
151
|
+
source_ids.add(node_id)
|
|
152
|
+
return source_ids
|
|
153
|
+
|
|
154
|
+
def get_backward_slice(self, node_id: str, max_depth: int = 10) -> list[str]:
|
|
155
|
+
"""Get predecessor node IDs reachable backward up to max_depth hops."""
|
|
156
|
+
if not self.storage.contains_node(node_id):
|
|
157
|
+
return []
|
|
158
|
+
return self._bfs_backward(node_id, max_depth)
|
|
159
|
+
|
|
160
|
+
def reachable_from(self, starts: Iterable[str]) -> Iterator[str]:
|
|
161
|
+
"""Yield every node reachable forward from any start node, each once.
|
|
162
|
+
|
|
163
|
+
Start nodes are themselves yielded. Nodes absent from the graph are
|
|
164
|
+
skipped without traversal.
|
|
165
|
+
"""
|
|
166
|
+
visited: set[str] = set()
|
|
167
|
+
queue: deque[str] = deque(starts)
|
|
168
|
+
while queue:
|
|
169
|
+
current = queue.popleft()
|
|
170
|
+
if current in visited:
|
|
171
|
+
continue
|
|
172
|
+
visited.add(current)
|
|
173
|
+
if not self.storage.contains_node(current):
|
|
174
|
+
continue
|
|
175
|
+
yield current
|
|
176
|
+
queue.extend(self.storage.successors(current))
|
|
177
|
+
|
|
178
|
+
def _bfs_forward(self, start: str) -> list[str]:
|
|
179
|
+
"""BFS forward from start, returning all reachable nodes (excluding start)."""
|
|
180
|
+
return [node for node in self.reachable_from([start]) if node != start]
|
|
181
|
+
|
|
182
|
+
def _bfs_backward(self, start: str, max_depth: int) -> list[str]:
|
|
183
|
+
"""BFS backward from start up to max_depth hops."""
|
|
184
|
+
visited: set[str] = set()
|
|
185
|
+
queue: deque[tuple[str, int]] = deque([(start, 0)])
|
|
186
|
+
result: list[str] = []
|
|
187
|
+
|
|
188
|
+
while queue:
|
|
189
|
+
current, depth = queue.popleft()
|
|
190
|
+
if current in visited:
|
|
191
|
+
continue
|
|
192
|
+
visited.add(current)
|
|
193
|
+
if current != start:
|
|
194
|
+
result.append(current)
|
|
195
|
+
if depth >= max_depth:
|
|
196
|
+
continue
|
|
197
|
+
for pred in self.storage.predecessors(current):
|
|
198
|
+
if pred not in visited:
|
|
199
|
+
queue.append((pred, depth + 1))
|
|
200
|
+
return result
|