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.
@@ -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,6 @@
1
+ """Source kind values, one per Semgrep taint rule file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ USER_INPUT = "user_input"
6
+ DATABASE = "database"
@@ -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