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
cobraphp/__init__.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Python interface of CobraPHP. The implementation waits for the CobraPHP jar; see docs/design.md."""
|
|
2
|
+
|
|
3
|
+
from cobraphp.config import AnalysisConfig, StorageImplementation, TargetFileSystem
|
|
4
|
+
from cobraphp.entries import analyze_project, build_project, diff_results, validate_document_sets
|
|
5
|
+
from cobraphp.errors import (
|
|
6
|
+
CobraPhpError,
|
|
7
|
+
DependencyUnavailableError,
|
|
8
|
+
DocumentSetInvalidError,
|
|
9
|
+
GraphExportError,
|
|
10
|
+
ParsingFailedError,
|
|
11
|
+
ProjectNotFoundError,
|
|
12
|
+
)
|
|
13
|
+
from cobraphp.graphs import GraphEdge, GraphExport, GraphNode, LabelOrder, read_graph_export
|
|
14
|
+
from cobraphp.project import BuiltProject
|
|
15
|
+
from cobraphp.results import (
|
|
16
|
+
CallSite,
|
|
17
|
+
Diagnostic,
|
|
18
|
+
DiffReport,
|
|
19
|
+
Finding,
|
|
20
|
+
Location,
|
|
21
|
+
Output,
|
|
22
|
+
TaintSource,
|
|
23
|
+
Unanalyzed,
|
|
24
|
+
UnanalyzedReason,
|
|
25
|
+
ValidationReport,
|
|
26
|
+
VulnerableCallPath,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"AnalysisConfig",
|
|
31
|
+
"BuiltProject",
|
|
32
|
+
"CallSite",
|
|
33
|
+
"CobraPhpError",
|
|
34
|
+
"DependencyUnavailableError",
|
|
35
|
+
"Diagnostic",
|
|
36
|
+
"DiffReport",
|
|
37
|
+
"DocumentSetInvalidError",
|
|
38
|
+
"Finding",
|
|
39
|
+
"GraphEdge",
|
|
40
|
+
"GraphExport",
|
|
41
|
+
"GraphExportError",
|
|
42
|
+
"GraphNode",
|
|
43
|
+
"LabelOrder",
|
|
44
|
+
"Location",
|
|
45
|
+
"Output",
|
|
46
|
+
"ParsingFailedError",
|
|
47
|
+
"ProjectNotFoundError",
|
|
48
|
+
"StorageImplementation",
|
|
49
|
+
"TaintSource",
|
|
50
|
+
"TargetFileSystem",
|
|
51
|
+
"Unanalyzed",
|
|
52
|
+
"UnanalyzedReason",
|
|
53
|
+
"ValidationReport",
|
|
54
|
+
"VulnerableCallPath",
|
|
55
|
+
"analyze_project",
|
|
56
|
+
"build_project",
|
|
57
|
+
"diff_results",
|
|
58
|
+
"read_graph_export",
|
|
59
|
+
"validate_document_sets",
|
|
60
|
+
]
|
cobraphp/_backend.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""The internal backend protocol (docs/design.md, Backend Boundary).
|
|
2
|
+
|
|
3
|
+
Every public function and `BuiltProject` method calls one operation of this protocol. A backend that runs the
|
|
4
|
+
CobraPHP jar in a JVM and a backend that calls a native shared library both implement it. No implementation exists.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import NewType, Protocol
|
|
10
|
+
|
|
11
|
+
from cobraphp.config import AnalysisConfig
|
|
12
|
+
from cobraphp.results import DiffReport, Output, ValidationReport
|
|
13
|
+
|
|
14
|
+
BuiltHandle = NewType("BuiltHandle", int)
|
|
15
|
+
"""An opaque handle of a built project; its value means nothing to the caller."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Backend(Protocol):
|
|
19
|
+
"""The operations a backend provides. A failure raises the `*Error` class its code names."""
|
|
20
|
+
|
|
21
|
+
def analyze_project(self, project_dir: Path, config: AnalysisConfig) -> list[Output]:
|
|
22
|
+
"""Run the one-shot analysis."""
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
def build_project(self, project_dir: Path, config: AnalysisConfig) -> BuiltHandle:
|
|
26
|
+
"""Build and freeze a project and return its handle."""
|
|
27
|
+
...
|
|
28
|
+
|
|
29
|
+
def analyze_built(self, handle: BuiltHandle, config: AnalysisConfig) -> list[Output]:
|
|
30
|
+
"""Analyze a built project; `config` carries the `build` fields of the build configuration."""
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
def export_graphs(self, handle: BuiltHandle, output_file: Path) -> None:
|
|
34
|
+
"""Write the graph export of a built project."""
|
|
35
|
+
...
|
|
36
|
+
|
|
37
|
+
def close(self, handle: BuiltHandle) -> None:
|
|
38
|
+
"""Release a built project; a second call has no effect."""
|
|
39
|
+
...
|
|
40
|
+
|
|
41
|
+
def validate_document_sets(self, document_sets: Sequence[Path]) -> ValidationReport:
|
|
42
|
+
"""Verify Document Sets."""
|
|
43
|
+
...
|
|
44
|
+
|
|
45
|
+
def diff_results(self, before: Sequence[Output], after: Sequence[Output]) -> DiffReport:
|
|
46
|
+
"""Compare two output lists by `Finding.id`."""
|
|
47
|
+
...
|
cobraphp/_placeholder.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""The body shared by every entry while the implementation does not exist."""
|
|
2
|
+
|
|
3
|
+
from typing import NoReturn
|
|
4
|
+
|
|
5
|
+
PLACEHOLDER_MESSAGE = "cobraphp declares its interface only: the implementation waits for the CobraPHP jar."
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def not_implemented() -> NoReturn:
|
|
9
|
+
"""Raise `NotImplementedError` with `PLACEHOLDER_MESSAGE`."""
|
|
10
|
+
raise NotImplementedError(PLACEHOLDER_MESSAGE)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Static analysis of a php-parser-py AST in Python: call graph, data-flow graph, backward slice.
|
|
2
|
+
|
|
3
|
+
The call graph comes back as CCG records of `cobraphp.GraphExport`; Semgrep runs as the `semgrep` program
|
|
4
|
+
to find the expressions each Source kind's rule matches. See docs/design-analysis-subpackage.md.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from php_parser_py import AST
|
|
10
|
+
|
|
11
|
+
from ._call_builder import CallBuilder
|
|
12
|
+
from ._errors import AnalysisTimeoutError, StaticAnalysisError
|
|
13
|
+
from ._file_filter import make_app_file_filter
|
|
14
|
+
from ._records import DataFlowGraph, backward_slice, call_graph_export, data_flow_graph
|
|
15
|
+
from ._source_kinds import DATABASE, USER_INPUT
|
|
16
|
+
from ._taint_propagator import TaintPropagator
|
|
17
|
+
from ..graphs import GraphExport
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_call_graph(project_ast: AST) -> GraphExport:
|
|
21
|
+
"""The call graph of `project_ast` as CCG records; runs no Semgrep.
|
|
22
|
+
|
|
23
|
+
Raises `StaticAnalysisError` when the AST is malformed.
|
|
24
|
+
"""
|
|
25
|
+
return call_graph_export(project_ast, CallBuilder(project_ast).build())
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_data_flow(
|
|
29
|
+
project_ast: AST,
|
|
30
|
+
*,
|
|
31
|
+
timeout_seconds: int | None = None,
|
|
32
|
+
target_files: frozenset[str] | None = None,
|
|
33
|
+
) -> DataFlowGraph:
|
|
34
|
+
"""The data-flow graph of `project_ast` with the AST node ids each Source kind reaches.
|
|
35
|
+
|
|
36
|
+
Semgrep finds the expressions each Source kind's rule matches; def-use edges inside each function,
|
|
37
|
+
argument and return edges over the call graph, then a breadth-first walk from those expressions mark the
|
|
38
|
+
reached nodes. `timeout_seconds` bounds each Semgrep run; `target_files` (project-relative) restricts
|
|
39
|
+
Semgrep to those files, and when none of them exists Semgrep does not run.
|
|
40
|
+
|
|
41
|
+
Raises `DependencyUnavailableError` when the `semgrep` program is not on PATH, `AnalysisTimeoutError`
|
|
42
|
+
when a Semgrep run exceeds `timeout_seconds`, and `StaticAnalysisError` when Semgrep fails, its output is
|
|
43
|
+
malformed or the AST is malformed.
|
|
44
|
+
"""
|
|
45
|
+
call_graph = CallBuilder(project_ast).build()
|
|
46
|
+
targets = None if target_files is None else set(target_files)
|
|
47
|
+
return data_flow_graph(TaintPropagator(project_ast, call_graph, timeout_seconds, targets).propagate())
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"DATABASE",
|
|
52
|
+
"USER_INPUT",
|
|
53
|
+
"AnalysisTimeoutError",
|
|
54
|
+
"DataFlowGraph",
|
|
55
|
+
"StaticAnalysisError",
|
|
56
|
+
"backward_slice",
|
|
57
|
+
"build_call_graph",
|
|
58
|
+
"build_data_flow",
|
|
59
|
+
"make_app_file_filter",
|
|
60
|
+
]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Load the PHP AST node type sets of php_ast_types.toml."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import tomllib
|
|
7
|
+
from importlib import resources
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@functools.cache
|
|
11
|
+
def load_php_ast_types() -> tuple[frozenset[str], frozenset[str], frozenset[str]]:
|
|
12
|
+
"""Return (constant, callable, assign) node type sets."""
|
|
13
|
+
raw = tomllib.loads((resources.files("cobraphp.analysis") / "php_ast_types.toml").read_text(encoding="utf-8"))
|
|
14
|
+
constant = frozenset(raw["constant_node_types"]["values"])
|
|
15
|
+
callable_ = frozenset(raw["callable_types"]["values"])
|
|
16
|
+
assign = frozenset(raw["assign_node_types"]["values"])
|
|
17
|
+
return constant, callable_, assign
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Build CallGraph from PHP AST by extracting function and method declarations and call sites."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from php_parser_py import AST, Node, NodeNotInFileError
|
|
6
|
+
|
|
7
|
+
from ._errors import StaticAnalysisError
|
|
8
|
+
from ._call_graph import CallGraph
|
|
9
|
+
from ._callee_index import CALL_SITE_TYPES, NAMED_CALLABLE_TYPES, CalleeIndex
|
|
10
|
+
|
|
11
|
+
# PHP AST node types of anonymous callables; with the named ones, every callable declaration.
|
|
12
|
+
CLOSURE_TYPE = "Expr_Closure"
|
|
13
|
+
ARROW_FUNCTION_TYPE = "Expr_ArrowFunction"
|
|
14
|
+
CALLABLE_DECL_TYPES = (*NAMED_CALLABLE_TYPES, CLOSURE_TYPE, ARROW_FUNCTION_TYPE)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CallBuilder:
|
|
18
|
+
"""Builds CallGraph from PHP AST.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
_ast: PHP project AST.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, ast: AST) -> None:
|
|
25
|
+
self._ast = ast
|
|
26
|
+
|
|
27
|
+
def build(self) -> CallGraph:
|
|
28
|
+
"""Build and return populated CallGraph from PHP AST.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Populated CallGraph instance.
|
|
32
|
+
|
|
33
|
+
Raises:
|
|
34
|
+
StaticAnalysisError: Invalid AST structure or missing required properties.
|
|
35
|
+
"""
|
|
36
|
+
call_graph = CallGraph()
|
|
37
|
+
callee_index = self._build_callables(call_graph)
|
|
38
|
+
self._build_call_sites(call_graph, callee_index)
|
|
39
|
+
return call_graph
|
|
40
|
+
|
|
41
|
+
def _build_callables(self, call_graph: CallGraph) -> CalleeIndex:
|
|
42
|
+
"""Register every callable declaration; index the named ones as callees.
|
|
43
|
+
|
|
44
|
+
Closures and arrow functions carry synthetic names and are never
|
|
45
|
+
resolution targets.
|
|
46
|
+
"""
|
|
47
|
+
callee_index = CalleeIndex(self._ast)
|
|
48
|
+
for callable_node in self._ast.nodes(lambda n: n.node_type in CALLABLE_DECL_TYPES):
|
|
49
|
+
callable_name = self._register_callable(call_graph, callable_node)
|
|
50
|
+
if callable_node.node_type in NAMED_CALLABLE_TYPES:
|
|
51
|
+
callee_index.add(callable_node, callable_name)
|
|
52
|
+
return callee_index
|
|
53
|
+
|
|
54
|
+
def _register_callable(self, call_graph: CallGraph, callable_node: Node) -> str:
|
|
55
|
+
"""Register a single callable declaration in the call graph; return its name."""
|
|
56
|
+
callable_id = callable_node.id
|
|
57
|
+
callable_name = self._extract_callable_name(callable_node)
|
|
58
|
+
|
|
59
|
+
param_nodes = [child for child in self._ast.succ(callable_node) if child.node_type == "Param"]
|
|
60
|
+
parameter_ids = [param.id for param in param_nodes]
|
|
61
|
+
|
|
62
|
+
func_node = call_graph.add_func_decl(node_id=callable_id)
|
|
63
|
+
func_node.name = callable_name
|
|
64
|
+
func_node.parameter_ids = parameter_ids
|
|
65
|
+
|
|
66
|
+
return_type = self._extract_return_type(callable_node)
|
|
67
|
+
if return_type:
|
|
68
|
+
func_node.return_type_hint = return_type
|
|
69
|
+
return callable_name
|
|
70
|
+
|
|
71
|
+
def _extract_callable_name(self, callable_node: Node) -> str:
|
|
72
|
+
"""Extract name from a callable declaration node."""
|
|
73
|
+
if callable_node.node_type in NAMED_CALLABLE_TYPES:
|
|
74
|
+
identifier_node = next(
|
|
75
|
+
(child for child in self._ast.succ(callable_node) if child.node_type == "Identifier"), None
|
|
76
|
+
)
|
|
77
|
+
if not identifier_node:
|
|
78
|
+
raise StaticAnalysisError(f"Callable node {callable_node.id} missing Identifier child for name")
|
|
79
|
+
callable_name = identifier_node.all_properties.get("name", "")
|
|
80
|
+
if not callable_name:
|
|
81
|
+
raise StaticAnalysisError(f"Callable {callable_node.id} Identifier missing 'name' property")
|
|
82
|
+
return str(callable_name)
|
|
83
|
+
if callable_node.node_type == CLOSURE_TYPE:
|
|
84
|
+
return "{closure}"
|
|
85
|
+
if callable_node.node_type == ARROW_FUNCTION_TYPE:
|
|
86
|
+
return "{arrow}"
|
|
87
|
+
return "{unknown}"
|
|
88
|
+
|
|
89
|
+
def _extract_return_type(self, callable_node: Node) -> str:
|
|
90
|
+
"""Extract return type hint from a callable node, if present."""
|
|
91
|
+
identifier_children = [child for child in self._ast.succ(callable_node) if child.node_type == "Identifier"]
|
|
92
|
+
|
|
93
|
+
if callable_node.node_type in NAMED_CALLABLE_TYPES:
|
|
94
|
+
if len(identifier_children) >= 2:
|
|
95
|
+
return str(identifier_children[1].all_properties.get("name", ""))
|
|
96
|
+
elif callable_node.node_type in (CLOSURE_TYPE, ARROW_FUNCTION_TYPE):
|
|
97
|
+
if len(identifier_children) >= 1:
|
|
98
|
+
return str(identifier_children[0].all_properties.get("name", ""))
|
|
99
|
+
return ""
|
|
100
|
+
|
|
101
|
+
def _build_call_sites(self, call_graph: CallGraph, callee_index: CalleeIndex) -> None:
|
|
102
|
+
"""Extract call sites and link to callees."""
|
|
103
|
+
call_site_nodes = list(self._ast.nodes(lambda n: n.node_type in CALL_SITE_TYPES))
|
|
104
|
+
|
|
105
|
+
for call_site_node in call_site_nodes:
|
|
106
|
+
try:
|
|
107
|
+
self._register_call_site(call_graph, call_site_node, callee_index)
|
|
108
|
+
except (KeyError, NodeNotInFileError) as e:
|
|
109
|
+
raise StaticAnalysisError(
|
|
110
|
+
f"Failed to extract call site metadata from node {call_site_node.id}: {e}"
|
|
111
|
+
) from e
|
|
112
|
+
|
|
113
|
+
def _register_call_site(self, call_graph: CallGraph, call_site_node: Node, callee_index: CalleeIndex) -> None:
|
|
114
|
+
"""Register a call site, including one with a dynamic name, and link its callee when resolved."""
|
|
115
|
+
call_site_id = call_site_node.id
|
|
116
|
+
site_node = call_graph.add_call_site(node_id=call_site_id)
|
|
117
|
+
site_node.argument_ids = [child.id for child in self._ast.succ(call_site_node) if child.node_type == "Arg"]
|
|
118
|
+
site_node.caller_id = self._resolve_caller(call_site_id)
|
|
119
|
+
|
|
120
|
+
callee_func = callee_index.resolve(call_site_node)
|
|
121
|
+
if callee_func is not None:
|
|
122
|
+
call_graph.add_call_edge(callsite_node_id=call_site_id, callee_node_id=callee_func.id)
|
|
123
|
+
|
|
124
|
+
def _resolve_caller(self, call_site_id: str) -> str:
|
|
125
|
+
"""Find the enclosing callable for a call site, or use file node."""
|
|
126
|
+
call_site_node = self._ast.node(call_site_id)
|
|
127
|
+
for ancestor in self._ast.ancestors(call_site_node):
|
|
128
|
+
if ancestor.node_type in CALLABLE_DECL_TYPES:
|
|
129
|
+
return str(ancestor.id)
|
|
130
|
+
|
|
131
|
+
containing_file = self._ast.get_file_node(call_site_id)
|
|
132
|
+
file_path = containing_file.get("absolutePath")
|
|
133
|
+
if not file_path:
|
|
134
|
+
raise StaticAnalysisError(f"File node for call site {call_site_id} has no absolutePath property")
|
|
135
|
+
return str(file_path)
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Call graph with function declarations, call sites, and edges."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import cast
|
|
6
|
+
|
|
7
|
+
from cpg2py import AbcGraphQuerier, Storage
|
|
8
|
+
|
|
9
|
+
from ._errors import StaticAnalysisError
|
|
10
|
+
from ._call_graph_nodes import CallEdge, CallNode, FuncNode, SiteNode
|
|
11
|
+
|
|
12
|
+
__all__ = ["CallGraph", "CallNode", "FuncNode", "SiteNode", "CallEdge"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CallGraph(AbcGraphQuerier[CallNode, CallEdge]): # type: ignore[misc]
|
|
16
|
+
"""Call graph tracking function declarations and call sites.
|
|
17
|
+
|
|
18
|
+
Uses cpg2py.Storage backend for directed multigraph representation.
|
|
19
|
+
Each node stores CallNode metadata; edges store CallEdge metadata.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
storage: Underlying cpg2py Storage instance.
|
|
23
|
+
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self) -> None:
|
|
27
|
+
"""Initialize CallGraph with separate Storage instance."""
|
|
28
|
+
storage = Storage()
|
|
29
|
+
super().__init__(target=storage, maxdepth=-1)
|
|
30
|
+
|
|
31
|
+
def node(self, whose_id_is: str) -> CallNode | None:
|
|
32
|
+
"""Returns FuncNode or SiteNode by ID, None if not found."""
|
|
33
|
+
if not self.storage.contains_node(whose_id_is):
|
|
34
|
+
return None
|
|
35
|
+
node_type = self.storage.get_node_prop(whose_id_is, "node_type")
|
|
36
|
+
if node_type == "function_declaration":
|
|
37
|
+
return FuncNode(self.storage, whose_id_is)
|
|
38
|
+
if node_type == "call_site":
|
|
39
|
+
return SiteNode(self.storage, whose_id_is)
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
def edge(self, fid: str, tid: str, eid: str) -> CallEdge | None:
|
|
43
|
+
"""Returns CallEdge by source, target, edge type, None if not found."""
|
|
44
|
+
edge_id = (fid, tid, eid)
|
|
45
|
+
if not self.storage.contains_edge(edge_id):
|
|
46
|
+
return None
|
|
47
|
+
return CallEdge(self.storage, fid, tid, eid)
|
|
48
|
+
|
|
49
|
+
# -- Mutation: add nodes and edges --
|
|
50
|
+
|
|
51
|
+
def add_func_decl(self, node_id: str) -> FuncNode:
|
|
52
|
+
"""Add function declaration node.
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
ValueError: If node_id is empty.
|
|
56
|
+
StaticAnalysisError: If node_id already exists.
|
|
57
|
+
|
|
58
|
+
"""
|
|
59
|
+
self._validate_new_node(node_id)
|
|
60
|
+
self.storage.add_node(node_id)
|
|
61
|
+
return self._init_func_node(node_id, is_file=False)
|
|
62
|
+
|
|
63
|
+
def add_file_node(self, file_path: str) -> FuncNode:
|
|
64
|
+
"""Add file-level container node for top-level code.
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
ValueError: If file_path is empty.
|
|
68
|
+
StaticAnalysisError: If file_path already exists.
|
|
69
|
+
|
|
70
|
+
"""
|
|
71
|
+
if not file_path:
|
|
72
|
+
raise ValueError("file_path must be non-empty")
|
|
73
|
+
if self.storage.contains_node(file_path):
|
|
74
|
+
msg = f"File node {file_path} already exists"
|
|
75
|
+
raise StaticAnalysisError(msg)
|
|
76
|
+
self.storage.add_node(file_path)
|
|
77
|
+
func_node = self._init_func_node(file_path, is_file=True)
|
|
78
|
+
func_node.name = file_path
|
|
79
|
+
return func_node
|
|
80
|
+
|
|
81
|
+
def add_call_site(self, node_id: str) -> SiteNode:
|
|
82
|
+
"""Add call site node.
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
ValueError: If node_id is empty.
|
|
86
|
+
StaticAnalysisError: If node_id already exists.
|
|
87
|
+
|
|
88
|
+
"""
|
|
89
|
+
self._validate_new_node(node_id)
|
|
90
|
+
self.storage.add_node(node_id)
|
|
91
|
+
site_node = SiteNode(self.storage, node_id)
|
|
92
|
+
site_node.argument_ids = []
|
|
93
|
+
return site_node
|
|
94
|
+
|
|
95
|
+
def add_call_edge(self, callsite_node_id: str, callee_node_id: str) -> CallEdge:
|
|
96
|
+
"""Add call edge from callsite to callee.
|
|
97
|
+
|
|
98
|
+
Raises:
|
|
99
|
+
ValueError: If an ID is empty.
|
|
100
|
+
StaticAnalysisError: If a node doesn't exist.
|
|
101
|
+
|
|
102
|
+
"""
|
|
103
|
+
self._validate_edge_endpoints(callsite_node_id, callee_node_id)
|
|
104
|
+
edge_id = (callsite_node_id, callee_node_id, "CALLS")
|
|
105
|
+
self.storage.add_edge(edge_id)
|
|
106
|
+
return CallEdge(self.storage, callsite_node_id, callee_node_id, "CALLS")
|
|
107
|
+
|
|
108
|
+
# -- Query: traverse graph --
|
|
109
|
+
|
|
110
|
+
def get_callees(self, callsite_node_id: str) -> list[FuncNode]:
|
|
111
|
+
"""Get callee function nodes for a callsite.
|
|
112
|
+
|
|
113
|
+
Raises:
|
|
114
|
+
StaticAnalysisError: If callsite_node does not exist.
|
|
115
|
+
|
|
116
|
+
"""
|
|
117
|
+
self._require_node_exists(callsite_node_id, "Callsite")
|
|
118
|
+
callee_ids = list(self.storage.successors(callsite_node_id))
|
|
119
|
+
return [FuncNode(self.storage, cid) for cid in callee_ids]
|
|
120
|
+
|
|
121
|
+
def get_callsites(self, function_id: str) -> list[SiteNode]:
|
|
122
|
+
"""Get callsite nodes that call this function.
|
|
123
|
+
|
|
124
|
+
Raises:
|
|
125
|
+
StaticAnalysisError: If function_id does not exist.
|
|
126
|
+
|
|
127
|
+
"""
|
|
128
|
+
self._require_node_exists(function_id, "Function")
|
|
129
|
+
callsite_ids = list(self.storage.predecessors(function_id))
|
|
130
|
+
return [SiteNode(self.storage, sid) for sid in callsite_ids]
|
|
131
|
+
|
|
132
|
+
def get_caller_of_callsite(self, callsite_node_id: str) -> str:
|
|
133
|
+
"""Get function or file containing this callsite.
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
StaticAnalysisError: If callsite doesn't exist or
|
|
137
|
+
caller_id not set.
|
|
138
|
+
|
|
139
|
+
"""
|
|
140
|
+
self._require_node_exists(callsite_node_id, "Callsite")
|
|
141
|
+
value = self.storage.get_node_prop(callsite_node_id, "caller_id")
|
|
142
|
+
if value is None:
|
|
143
|
+
raise StaticAnalysisError(f"Property 'caller_id' not found for callsite {callsite_node_id}")
|
|
144
|
+
return cast(str, value)
|
|
145
|
+
|
|
146
|
+
def get_func_node(self, node_id: str) -> FuncNode:
|
|
147
|
+
"""Retrieve FuncNode wrapper by ID.
|
|
148
|
+
|
|
149
|
+
Raises:
|
|
150
|
+
StaticAnalysisError: If node doesn't exist or isn't
|
|
151
|
+
a function declaration.
|
|
152
|
+
|
|
153
|
+
"""
|
|
154
|
+
self._require_node_exists(node_id, "Node")
|
|
155
|
+
node_type_str = self.storage.get_node_prop(node_id, "node_type")
|
|
156
|
+
if node_type_str != "function_declaration":
|
|
157
|
+
raise StaticAnalysisError(f"Node {node_id} is not a function declaration (type: {node_type_str})")
|
|
158
|
+
return FuncNode(self.storage, node_id)
|
|
159
|
+
|
|
160
|
+
def get_or_create_file_node(self, file_path: str) -> FuncNode:
|
|
161
|
+
"""Get existing file node or create if not exists."""
|
|
162
|
+
if self.storage.contains_node(file_path):
|
|
163
|
+
return self.get_func_node(file_path)
|
|
164
|
+
return self.add_file_node(file_path)
|
|
165
|
+
|
|
166
|
+
def get_site_node(self, node_id: str) -> SiteNode:
|
|
167
|
+
"""Retrieve SiteNode wrapper by ID.
|
|
168
|
+
|
|
169
|
+
Raises:
|
|
170
|
+
StaticAnalysisError: If node doesn't exist or isn't
|
|
171
|
+
a call site.
|
|
172
|
+
|
|
173
|
+
"""
|
|
174
|
+
self._require_node_exists(node_id, "Node")
|
|
175
|
+
node_type_str = self.storage.get_node_prop(node_id, "node_type")
|
|
176
|
+
if node_type_str != "call_site":
|
|
177
|
+
raise StaticAnalysisError(f"Node {node_id} is not a call site (type: {node_type_str})")
|
|
178
|
+
return SiteNode(self.storage, node_id)
|
|
179
|
+
|
|
180
|
+
# -- Private helpers --
|
|
181
|
+
|
|
182
|
+
def _validate_new_node(self, node_id: str) -> None:
|
|
183
|
+
"""Raise if node_id is empty or already exists."""
|
|
184
|
+
if not node_id:
|
|
185
|
+
raise ValueError("node_id must be non-empty")
|
|
186
|
+
if self.storage.contains_node(node_id):
|
|
187
|
+
raise StaticAnalysisError(f"Node {node_id} already exists")
|
|
188
|
+
|
|
189
|
+
def _validate_edge_endpoints(self, callsite_id: str, callee_id: str) -> None:
|
|
190
|
+
"""Raise if edge endpoint IDs are empty or missing."""
|
|
191
|
+
if not callsite_id:
|
|
192
|
+
raise ValueError("callsite_node_id must be non-empty")
|
|
193
|
+
if not callee_id:
|
|
194
|
+
raise ValueError("callee_node_id must be non-empty")
|
|
195
|
+
if not self.storage.contains_node(callsite_id):
|
|
196
|
+
raise StaticAnalysisError(f"Callsite node {callsite_id} does not exist")
|
|
197
|
+
if not self.storage.contains_node(callee_id):
|
|
198
|
+
raise StaticAnalysisError(f"Callee node {callee_id} does not exist")
|
|
199
|
+
|
|
200
|
+
def _require_node_exists(self, node_id: str, label: str) -> None:
|
|
201
|
+
"""Raise if node_id is not in storage."""
|
|
202
|
+
if not self.storage.contains_node(node_id):
|
|
203
|
+
raise StaticAnalysisError(f"{label} node {node_id} does not exist")
|
|
204
|
+
|
|
205
|
+
def _init_func_node(self, node_id: str, *, is_file: bool) -> FuncNode:
|
|
206
|
+
"""Initialize a FuncNode with default property values."""
|
|
207
|
+
func_node = FuncNode(self.storage, node_id)
|
|
208
|
+
func_node.name = ""
|
|
209
|
+
func_node.parameter_ids = []
|
|
210
|
+
self.storage.set_node_prop(node_id, "is_file", is_file)
|
|
211
|
+
return func_node
|