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,402 @@
|
|
|
1
|
+
"""Intra-procedural def-use chain and inter-procedural edge construction.
|
|
2
|
+
|
|
3
|
+
Phase 2: AST-based def-use chains create cross-statement edges within functions.
|
|
4
|
+
Phase 3: CallGraph creates cross-function edges (argument->parameter, return->callsite).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import cast
|
|
12
|
+
|
|
13
|
+
from php_parser_py import AST, Node
|
|
14
|
+
|
|
15
|
+
from ._ast_types import load_php_ast_types
|
|
16
|
+
from ._call_graph import CallGraph
|
|
17
|
+
from ._taint_graph import DataFlowGraph
|
|
18
|
+
from ._taint_rules import CALLABLE_TYPES
|
|
19
|
+
|
|
20
|
+
# Accessor nodes whose taint is inherited from the base Expr_Variable.
|
|
21
|
+
_ACCESSOR_NODE_TYPES = frozenset({"Expr_ArrayDimFetch", "Expr_PropertyFetch"})
|
|
22
|
+
|
|
23
|
+
# Compound assignments (`+=`, `.=`, ...): the target keeps its old value and
|
|
24
|
+
# folds in the RHS, so the target variable node itself becomes the definition.
|
|
25
|
+
_COMPOUND_ASSIGN_TYPES = load_php_ast_types()[2] - {"Expr_Assign"}
|
|
26
|
+
|
|
27
|
+
# Callables with their own variable scope. An arrow function is absent: PHP
|
|
28
|
+
# gives it read access to the enclosing scope's variables.
|
|
29
|
+
_NESTED_SCOPE_TYPES = frozenset({"Stmt_Function", "Stmt_ClassMethod", "Expr_Closure"})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_intraproc_edges(ast: AST, dataflow_graph: DataFlowGraph) -> None:
|
|
33
|
+
"""Build variable def-use chain edges for all functions and file-level code."""
|
|
34
|
+
for func_node in ast.nodes(lambda n: n.node_type in CALLABLE_TYPES):
|
|
35
|
+
param_defs = _extract_param_defs(ast, func_node)
|
|
36
|
+
body_stmts = _get_body_statements(ast, func_node)
|
|
37
|
+
_build_defuse_for_stmts(ast, body_stmts, dataflow_graph, initial_defs=param_defs)
|
|
38
|
+
|
|
39
|
+
for file_node in ast.file_nodes():
|
|
40
|
+
top_stmts = [c for c in ast.succ(file_node) if c.node_type.startswith("Stmt_")]
|
|
41
|
+
_build_defuse_for_stmts(ast, top_stmts, dataflow_graph)
|
|
42
|
+
|
|
43
|
+
_build_accessor_chain_edges(ast, dataflow_graph)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def build_interproc_edges(
|
|
47
|
+
ast: AST,
|
|
48
|
+
call_graph: CallGraph,
|
|
49
|
+
dataflow_graph: DataFlowGraph,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Build argument->parameter and return->callsite edges via CallGraph."""
|
|
52
|
+
for cg_node_id in call_graph.storage.get_nodes():
|
|
53
|
+
node_type = call_graph.storage.get_node_prop(cg_node_id, "node_type")
|
|
54
|
+
if node_type != "call_site":
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
site_node = call_graph.get_site_node(cg_node_id)
|
|
58
|
+
callees = call_graph.get_callees(cg_node_id)
|
|
59
|
+
|
|
60
|
+
if not callees:
|
|
61
|
+
_build_overapprox_edges(ast, site_node.argument_ids, cg_node_id, dataflow_graph)
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
for callee in callees:
|
|
65
|
+
_build_arg_to_param_edges(
|
|
66
|
+
ast,
|
|
67
|
+
site_node.argument_ids,
|
|
68
|
+
callee.parameter_ids,
|
|
69
|
+
dataflow_graph,
|
|
70
|
+
)
|
|
71
|
+
_build_return_to_callsite_edges(
|
|
72
|
+
ast,
|
|
73
|
+
callee.node_id,
|
|
74
|
+
cg_node_id,
|
|
75
|
+
dataflow_graph,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _extract_param_defs(ast: AST, func_node: Node) -> dict[str, str]:
|
|
80
|
+
"""Extract parameter variable definitions: var_name -> param_var_node_id."""
|
|
81
|
+
param_defs: dict[str, str] = {}
|
|
82
|
+
for child in ast.succ(func_node):
|
|
83
|
+
if child.node_type != "Param":
|
|
84
|
+
continue
|
|
85
|
+
for param_child in ast.succ(child):
|
|
86
|
+
if param_child.node_type != "Expr_Variable":
|
|
87
|
+
continue
|
|
88
|
+
var_name = param_child.all_properties.get("name", "")
|
|
89
|
+
if var_name:
|
|
90
|
+
param_defs[cast(str, var_name)] = param_child.id
|
|
91
|
+
return param_defs
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _get_body_statements(ast: AST, func_node: Node) -> list[Node]:
|
|
95
|
+
"""Extract ordered statement children from a callable node."""
|
|
96
|
+
return [child for child in ast.succ(func_node) if child.node_type.startswith("Stmt_")]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _build_defuse_for_stmts(
|
|
100
|
+
ast: AST,
|
|
101
|
+
stmts: list[Node],
|
|
102
|
+
dataflow_graph: DataFlowGraph,
|
|
103
|
+
initial_defs: dict[str, str] | None = None,
|
|
104
|
+
) -> None:
|
|
105
|
+
"""Build reaching-definition def-use chains across statements."""
|
|
106
|
+
var_defs: dict[str, str] = dict(initial_defs) if initial_defs else {}
|
|
107
|
+
|
|
108
|
+
for stmt in stmts:
|
|
109
|
+
_connect_uses_to_defs(ast, stmt, var_defs, dataflow_graph)
|
|
110
|
+
for var_def in _find_variable_defs(ast, stmt):
|
|
111
|
+
_link_compound_rhs(var_def, dataflow_graph)
|
|
112
|
+
var_defs[var_def.name] = var_def.node_id
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _connect_uses_to_defs(
|
|
116
|
+
ast: AST,
|
|
117
|
+
stmt: Node,
|
|
118
|
+
var_defs: dict[str, str],
|
|
119
|
+
dataflow_graph: DataFlowGraph,
|
|
120
|
+
) -> None:
|
|
121
|
+
"""Connect variable uses in a statement to their definitions."""
|
|
122
|
+
uses = _find_variable_uses(ast, stmt)
|
|
123
|
+
for use_node_id, var_name in uses:
|
|
124
|
+
if var_name not in var_defs:
|
|
125
|
+
continue
|
|
126
|
+
def_node_id = var_defs[var_name]
|
|
127
|
+
if def_node_id == use_node_id:
|
|
128
|
+
continue
|
|
129
|
+
_ensure_node(dataflow_graph, def_node_id)
|
|
130
|
+
_ensure_node(dataflow_graph, use_node_id)
|
|
131
|
+
dataflow_graph.add_structural_edge(def_node_id, use_node_id)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(frozen=True)
|
|
135
|
+
class _VarDef:
|
|
136
|
+
"""One variable definition inside a statement.
|
|
137
|
+
|
|
138
|
+
``node_id`` carries the assigned value. ``rhs_id`` is set for a compound
|
|
139
|
+
assignment, whose RHS feeds the target variable node.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
name: str
|
|
143
|
+
node_id: str
|
|
144
|
+
rhs_id: str | None = None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _link_compound_rhs(var_def: _VarDef, dataflow_graph: DataFlowGraph) -> None:
|
|
148
|
+
# A compound assignment's RHS flows into the target it updates.
|
|
149
|
+
if var_def.rhs_id is None:
|
|
150
|
+
return
|
|
151
|
+
_ensure_node(dataflow_graph, var_def.rhs_id)
|
|
152
|
+
_ensure_node(dataflow_graph, var_def.node_id)
|
|
153
|
+
dataflow_graph.add_structural_edge(var_def.rhs_id, var_def.node_id)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _find_variable_defs(ast: AST, stmt: Node) -> list[_VarDef]:
|
|
157
|
+
"""Find variable definitions in a statement, outside nested scopes."""
|
|
158
|
+
# Post-order over an explicit stack of child iterators: children before
|
|
159
|
+
# their parent, which is evaluation order. A nested scope is skipped with
|
|
160
|
+
# its subtree. No recursion, so a deeply nested expression fits.
|
|
161
|
+
result: list[_VarDef] = []
|
|
162
|
+
if stmt.node_type in _NESTED_SCOPE_TYPES:
|
|
163
|
+
return result
|
|
164
|
+
pending: list[tuple[Node, Iterator[Node]]] = [(stmt, iter(ast.succ(stmt)))]
|
|
165
|
+
while pending:
|
|
166
|
+
node, children = pending[-1]
|
|
167
|
+
child = next(children, None)
|
|
168
|
+
if child is None:
|
|
169
|
+
pending.pop()
|
|
170
|
+
var_def = _assignment_def(ast, node)
|
|
171
|
+
if var_def is not None:
|
|
172
|
+
result.append(var_def)
|
|
173
|
+
continue
|
|
174
|
+
if child.node_type not in _NESTED_SCOPE_TYPES:
|
|
175
|
+
pending.append((child, iter(ast.succ(child))))
|
|
176
|
+
return result
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _assignment_def(ast: AST, node: Node) -> _VarDef | None:
|
|
180
|
+
# The definition an assignment to a plain variable makes, else None.
|
|
181
|
+
target = _assignment_target(ast, node)
|
|
182
|
+
if target is None:
|
|
183
|
+
return None
|
|
184
|
+
var_name, lhs, rhs = target
|
|
185
|
+
if node.node_type == "Expr_Assign":
|
|
186
|
+
return _VarDef(var_name, _assigned_value_id(ast, rhs))
|
|
187
|
+
return _VarDef(var_name, lhs.id, rhs_id=rhs.id)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _assignment_target(ast: AST, node: Node) -> tuple[str, Node, Node] | None:
|
|
191
|
+
# (variable name, LHS, RHS) of an assignment to a plain variable, else None.
|
|
192
|
+
if node.node_type != "Expr_Assign" and node.node_type not in _COMPOUND_ASSIGN_TYPES:
|
|
193
|
+
return None
|
|
194
|
+
children = list(ast.succ(node))
|
|
195
|
+
if len(children) < 2 or children[0].node_type != "Expr_Variable":
|
|
196
|
+
return None
|
|
197
|
+
lhs, rhs = children[0], children[1]
|
|
198
|
+
var_name = cast(str, lhs.all_properties.get("name", ""))
|
|
199
|
+
if not var_name:
|
|
200
|
+
return None
|
|
201
|
+
return var_name, lhs, rhs
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _assigned_value_id(ast: AST, expr: Node) -> str:
|
|
205
|
+
# The node holding an expression's value: a nested assignment yields the
|
|
206
|
+
# definition node it makes, so `$a = $b = x` gives both the same value.
|
|
207
|
+
# A plain assignment passes its RHS's value on; a loop follows the chain.
|
|
208
|
+
while True:
|
|
209
|
+
target = _assignment_target(ast, expr)
|
|
210
|
+
if target is None:
|
|
211
|
+
return expr.id
|
|
212
|
+
_, lhs, rhs = target
|
|
213
|
+
if expr.node_type != "Expr_Assign":
|
|
214
|
+
return lhs.id
|
|
215
|
+
expr = rhs
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _find_variable_uses(ast: AST, stmt: Node) -> list[tuple[str, str]]:
|
|
219
|
+
"""Find variable uses excluding assignment LHS: list of (node_id, var_name)."""
|
|
220
|
+
# Pre-order over an explicit stack: children are pushed reversed so they
|
|
221
|
+
# pop in source order. No recursion, so a deeply nested expression fits.
|
|
222
|
+
result: list[tuple[str, str]] = []
|
|
223
|
+
lhs_ids: set[str] = set()
|
|
224
|
+
pending: list[Node] = [stmt]
|
|
225
|
+
while pending:
|
|
226
|
+
node = pending.pop()
|
|
227
|
+
pending.extend(reversed(_use_scan_children(ast, node, result, lhs_ids)))
|
|
228
|
+
return result
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _use_scan_children(
|
|
232
|
+
ast: AST,
|
|
233
|
+
node: Node,
|
|
234
|
+
result: list[tuple[str, str]],
|
|
235
|
+
lhs_ids: set[str],
|
|
236
|
+
) -> list[Node]:
|
|
237
|
+
"""Record node if it is a variable use; return the children to scan next, in order."""
|
|
238
|
+
if node.node_type == "Expr_Assign":
|
|
239
|
+
# Only the RHS is read; the LHS is a definition.
|
|
240
|
+
children = list(ast.succ(node))
|
|
241
|
+
if len(children) < 2:
|
|
242
|
+
return []
|
|
243
|
+
lhs_ids.add(children[0].id)
|
|
244
|
+
return [children[1]]
|
|
245
|
+
if node.node_type in _NESTED_SCOPE_TYPES:
|
|
246
|
+
# Only a closure's `use (...)` list reads the enclosing scope.
|
|
247
|
+
return _closure_use_children(ast, node)
|
|
248
|
+
|
|
249
|
+
if node.node_type == "Expr_Variable" and node.id not in lhs_ids:
|
|
250
|
+
var_name = node.all_properties.get("name", "")
|
|
251
|
+
# Skip superglobals — they are sources, not def-use targets
|
|
252
|
+
if var_name and not str(var_name).startswith("_"):
|
|
253
|
+
result.append((node.id, cast(str, var_name)))
|
|
254
|
+
|
|
255
|
+
return list(ast.succ(node))
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _closure_use_children(ast: AST, node: Node) -> list[Node]:
|
|
259
|
+
# A closure's captured-variable list; empty for any other callable.
|
|
260
|
+
return [child for child in ast.succ(node) if child.node_type == "ClosureUse"]
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _build_accessor_chain_edges(ast: AST, dataflow_graph: DataFlowGraph) -> None:
|
|
264
|
+
"""Create edges from Expr_Variable to its enclosing accessor chain.
|
|
265
|
+
|
|
266
|
+
A tainted object/array variable must propagate taint to its element and
|
|
267
|
+
property reads. Semgrep only flags these intraprocedurally; when the
|
|
268
|
+
variable is tainted via a call-graph return edge, the enclosing
|
|
269
|
+
ArrayDimFetch/PropertyFetch nodes have no incoming edge and stay untainted.
|
|
270
|
+
"""
|
|
271
|
+
for node in ast.nodes():
|
|
272
|
+
if node.node_type != "Expr_Variable":
|
|
273
|
+
continue
|
|
274
|
+
if not dataflow_graph.storage.contains_node(node.id):
|
|
275
|
+
continue
|
|
276
|
+
_walk_accessor_parents(ast, node, dataflow_graph)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _walk_accessor_parents(
|
|
280
|
+
ast: AST,
|
|
281
|
+
node: Node,
|
|
282
|
+
dataflow_graph: DataFlowGraph,
|
|
283
|
+
) -> None:
|
|
284
|
+
"""Walk up from variable through accessor parents, adding edges."""
|
|
285
|
+
current = node
|
|
286
|
+
while True:
|
|
287
|
+
parents = list(ast.prev(current))
|
|
288
|
+
parent = next(
|
|
289
|
+
(p for p in parents if p.node_type in _ACCESSOR_NODE_TYPES),
|
|
290
|
+
None,
|
|
291
|
+
)
|
|
292
|
+
if parent is None:
|
|
293
|
+
break
|
|
294
|
+
_ensure_node(dataflow_graph, parent.id)
|
|
295
|
+
dataflow_graph.add_structural_edge(current.id, parent.id)
|
|
296
|
+
current = parent
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _build_arg_to_param_edges(
|
|
300
|
+
ast: AST,
|
|
301
|
+
argument_ids: list[str],
|
|
302
|
+
parameter_ids: list[str],
|
|
303
|
+
dataflow_graph: DataFlowGraph,
|
|
304
|
+
) -> None:
|
|
305
|
+
"""Create edges from call site arguments to callee parameters by position."""
|
|
306
|
+
min_len = min(len(argument_ids), len(parameter_ids))
|
|
307
|
+
for i in range(min_len):
|
|
308
|
+
arg_expr_id = _get_arg_expression_id(ast, argument_ids[i])
|
|
309
|
+
if not arg_expr_id:
|
|
310
|
+
continue
|
|
311
|
+
param_var_id = _get_param_variable_id(ast, parameter_ids[i])
|
|
312
|
+
if not param_var_id:
|
|
313
|
+
continue
|
|
314
|
+
_ensure_node(dataflow_graph, arg_expr_id)
|
|
315
|
+
_ensure_node(dataflow_graph, param_var_id)
|
|
316
|
+
dataflow_graph.add_structural_edge(arg_expr_id, param_var_id)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _build_overapprox_edges(
|
|
320
|
+
ast: AST,
|
|
321
|
+
argument_ids: list[str],
|
|
322
|
+
callsite_id: str,
|
|
323
|
+
dataflow_graph: DataFlowGraph,
|
|
324
|
+
) -> None:
|
|
325
|
+
"""Over-approximate taint through an unresolved call.
|
|
326
|
+
|
|
327
|
+
For a call site with no resolved callee (library, builtin, or dynamic
|
|
328
|
+
dispatch), connect each argument expression to the call-site node so a
|
|
329
|
+
tainted argument taints the call's result. Semgrep OSS provides no
|
|
330
|
+
interprocedural taint to fall back on.
|
|
331
|
+
"""
|
|
332
|
+
for arg_node_id in argument_ids:
|
|
333
|
+
arg_expr_id = _get_arg_expression_id(ast, arg_node_id)
|
|
334
|
+
if not arg_expr_id:
|
|
335
|
+
continue
|
|
336
|
+
_ensure_node(dataflow_graph, arg_expr_id)
|
|
337
|
+
_ensure_node(dataflow_graph, callsite_id)
|
|
338
|
+
dataflow_graph.add_structural_edge(arg_expr_id, callsite_id)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _build_return_to_callsite_edges(
|
|
342
|
+
ast: AST,
|
|
343
|
+
callee_func_id: str,
|
|
344
|
+
callsite_id: str,
|
|
345
|
+
dataflow_graph: DataFlowGraph,
|
|
346
|
+
) -> None:
|
|
347
|
+
"""Create edges from callee return expressions to call site node."""
|
|
348
|
+
func_node = ast.node(callee_func_id)
|
|
349
|
+
if func_node is None:
|
|
350
|
+
return
|
|
351
|
+
|
|
352
|
+
for return_stmt in _own_return_statements(ast, func_node):
|
|
353
|
+
return_children = list(ast.succ(return_stmt))
|
|
354
|
+
if not return_children:
|
|
355
|
+
continue
|
|
356
|
+
return_expr = return_children[0]
|
|
357
|
+
_ensure_node(dataflow_graph, return_expr.id)
|
|
358
|
+
_ensure_node(dataflow_graph, callsite_id)
|
|
359
|
+
dataflow_graph.add_structural_edge(return_expr.id, callsite_id)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _own_return_statements(ast: AST, func_node: Node) -> list[Node]:
|
|
363
|
+
# Stmt_Return nodes at any depth in a callable's body, excluding those of
|
|
364
|
+
# callables nested inside it.
|
|
365
|
+
found: list[Node] = []
|
|
366
|
+
pending = list(ast.succ(func_node))
|
|
367
|
+
while pending:
|
|
368
|
+
node = pending.pop()
|
|
369
|
+
if node.node_type in CALLABLE_TYPES:
|
|
370
|
+
continue
|
|
371
|
+
if node.node_type == "Stmt_Return":
|
|
372
|
+
found.append(node)
|
|
373
|
+
pending.extend(ast.succ(node))
|
|
374
|
+
return found
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _get_arg_expression_id(ast: AST, arg_node_id: str) -> str | None:
|
|
378
|
+
"""Get the expression node ID inside an Arg wrapper node."""
|
|
379
|
+
arg_node = ast.node(arg_node_id)
|
|
380
|
+
if arg_node is None:
|
|
381
|
+
return None
|
|
382
|
+
children = list(ast.succ(arg_node))
|
|
383
|
+
if not children:
|
|
384
|
+
return None
|
|
385
|
+
return cast(str, children[0].id)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _get_param_variable_id(ast: AST, param_node_id: str) -> str | None:
|
|
389
|
+
"""Get the Expr_Variable node ID inside a Param wrapper node."""
|
|
390
|
+
param_node = ast.node(param_node_id)
|
|
391
|
+
if param_node is None:
|
|
392
|
+
return None
|
|
393
|
+
for child in ast.succ(param_node):
|
|
394
|
+
if child.node_type == "Expr_Variable":
|
|
395
|
+
return cast(str, child.id)
|
|
396
|
+
return None
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _ensure_node(dataflow_graph: DataFlowGraph, node_id: str) -> None:
|
|
400
|
+
"""Ensure node exists in graph."""
|
|
401
|
+
if not dataflow_graph.storage.contains_node(node_id):
|
|
402
|
+
dataflow_graph.add_dataflow_node(node_id)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Errors of the static analysis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..errors import CobraPhpError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class StaticAnalysisError(CobraPhpError):
|
|
9
|
+
"""Semgrep failed, its output was malformed, or the AST is malformed."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AnalysisTimeoutError(StaticAnalysisError):
|
|
13
|
+
"""A Semgrep run exceeded the time limit; carries `timeout_seconds`."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, timeout_seconds: int):
|
|
16
|
+
super().__init__(f"Analysis timed out after {timeout_seconds} seconds")
|
|
17
|
+
self.timeout_seconds = timeout_seconds
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Shared file filter for parse_project calls.
|
|
2
|
+
|
|
3
|
+
Excludes third-party and generated code from PHP AST parsing via three layers:
|
|
4
|
+
convention directories (vendor/, node_modules/), named bundle files
|
|
5
|
+
(yiilite.php), and framework code detected by an identity-marker file — either
|
|
6
|
+
the whole marker subtree or only named subpaths under it (for monorepo
|
|
7
|
+
frameworks that also hold application code).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import tomllib
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from importlib import resources
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
_EXCLUDED_DIRS = frozenset(
|
|
22
|
+
{
|
|
23
|
+
"vendor",
|
|
24
|
+
"vendors",
|
|
25
|
+
"node_modules",
|
|
26
|
+
"generated",
|
|
27
|
+
".git",
|
|
28
|
+
"patches",
|
|
29
|
+
".porifera_backup",
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# yiilite.php is the whole framework concatenated into one file: not business
|
|
34
|
+
# logic, it duplicates class definitions from the separate framework files, and
|
|
35
|
+
# its size makes format-preserving instrumentation exhaust the printer's memory.
|
|
36
|
+
_EXCLUDED_FILES = frozenset({"yiilite.php"})
|
|
37
|
+
|
|
38
|
+
_REGISTRY_PATH = resources.files("cobraphp.analysis") / "frameworks.toml"
|
|
39
|
+
# Bytes of a marker file head scanned for its signature substring; a bootstrap
|
|
40
|
+
# class declaration always appears near the top.
|
|
41
|
+
_MARKER_SCAN_BYTES = 8192
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class _FrameworkSignature:
|
|
46
|
+
"""Identity marker locating one vendored framework's subtree root.
|
|
47
|
+
|
|
48
|
+
Attributes:
|
|
49
|
+
name: Framework identifier, logged on exclusion.
|
|
50
|
+
marker: POSIX relative-path suffix of the identity bootstrap file.
|
|
51
|
+
contains: Substring required in the marker file head.
|
|
52
|
+
exclude_subpaths: Subpaths under the detected root to exclude. Empty
|
|
53
|
+
excludes the whole subtree; non-empty keeps the rest as business
|
|
54
|
+
logic (e.g. a monorepo framework that also holds application code).
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
name: str
|
|
58
|
+
marker: str
|
|
59
|
+
contains: str
|
|
60
|
+
exclude_subpaths: tuple[str, ...] = ()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _load_signatures() -> tuple[_FrameworkSignature, ...]:
|
|
64
|
+
"""Load framework identity markers from the TOML registry."""
|
|
65
|
+
raw = tomllib.loads(_REGISTRY_PATH.read_text(encoding="utf-8"))
|
|
66
|
+
return tuple(
|
|
67
|
+
_FrameworkSignature(
|
|
68
|
+
name=e["name"],
|
|
69
|
+
marker=e["marker"],
|
|
70
|
+
contains=e["contains"],
|
|
71
|
+
exclude_subpaths=tuple(e.get("exclude_subpaths", ())),
|
|
72
|
+
)
|
|
73
|
+
for e in raw["framework"]
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
_SIGNATURES = _load_signatures()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def app_file_filter(path: Path) -> bool:
|
|
81
|
+
"""Return True for application PHP files, excluding vendor/generated dirs."""
|
|
82
|
+
if path.suffix != ".php":
|
|
83
|
+
return False
|
|
84
|
+
if path.name in _EXCLUDED_FILES:
|
|
85
|
+
return False
|
|
86
|
+
return not any(part in _EXCLUDED_DIRS for part in path.parts)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _marker_root(candidate: Path, signature: _FrameworkSignature) -> Path | None:
|
|
90
|
+
"""Return the subtree root if candidate is signature's identity marker.
|
|
91
|
+
|
|
92
|
+
An unreadable candidate is not a marker: it is skipped with a warning.
|
|
93
|
+
"""
|
|
94
|
+
marker_parts = Path(signature.marker).parts
|
|
95
|
+
if candidate.parts[-len(marker_parts) :] != marker_parts:
|
|
96
|
+
return None
|
|
97
|
+
try:
|
|
98
|
+
head = candidate.read_bytes()[:_MARKER_SCAN_BYTES].decode("latin-1")
|
|
99
|
+
except OSError as exc:
|
|
100
|
+
logger.warning("skipping unreadable framework marker: name=%s path=%s error=%s", signature.name, candidate, exc)
|
|
101
|
+
return None
|
|
102
|
+
if signature.contains not in head:
|
|
103
|
+
return None
|
|
104
|
+
depth = len(marker_parts)
|
|
105
|
+
return candidate.parents[depth - 1]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _excluded_dirs_for(root: Path, signature: _FrameworkSignature) -> list[Path]:
|
|
109
|
+
"""Absolute dirs to exclude for one detected framework root.
|
|
110
|
+
|
|
111
|
+
Empty ``exclude_subpaths`` excludes the whole subtree; otherwise only the
|
|
112
|
+
named subpaths under the root, keeping the rest as application code.
|
|
113
|
+
"""
|
|
114
|
+
if not signature.exclude_subpaths:
|
|
115
|
+
return [root]
|
|
116
|
+
return [root / sub for sub in signature.exclude_subpaths]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def framework_excluded_dirs(project_root: Path) -> frozenset[Path]:
|
|
120
|
+
"""Resolve registry signatures to excluded framework directories.
|
|
121
|
+
|
|
122
|
+
Detects every vendored-framework subtree under project_root and returns
|
|
123
|
+
the directories to exclude from PHP source processing. Shared by AST
|
|
124
|
+
parsing (make_app_file_filter) and source normalization so both apply one
|
|
125
|
+
framework-exclusion set and keep their line numbers consistent.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
project_root: Project directory to scan for framework markers. Returned
|
|
129
|
+
paths are anchored to it; the caller resolves it when its consumers
|
|
130
|
+
supply resolved paths.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Directories under project_root to exclude as framework code.
|
|
134
|
+
"""
|
|
135
|
+
excluded: set[Path] = set()
|
|
136
|
+
for signature in _SIGNATURES:
|
|
137
|
+
basename = Path(signature.marker).name
|
|
138
|
+
for candidate in project_root.rglob(basename):
|
|
139
|
+
root = _marker_root(candidate, signature)
|
|
140
|
+
if root is None:
|
|
141
|
+
continue
|
|
142
|
+
dirs = _excluded_dirs_for(root, signature)
|
|
143
|
+
excluded.update(dirs)
|
|
144
|
+
logger.info(
|
|
145
|
+
"excluding framework dirs: name=%s root=%s dirs=%s",
|
|
146
|
+
signature.name,
|
|
147
|
+
root,
|
|
148
|
+
dirs,
|
|
149
|
+
)
|
|
150
|
+
return frozenset(excluded)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def make_app_file_filter(project_root: Path) -> Callable[[Path], bool]:
|
|
154
|
+
"""Build a filter excluding vendor/generated dirs and framework subtrees.
|
|
155
|
+
|
|
156
|
+
Detects framework subtree roots under project_root once, then returns a
|
|
157
|
+
predicate over the absolute paths parse_project supplies.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
project_root: Project directory passed to parse_project.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Predicate returning True for application PHP files.
|
|
164
|
+
"""
|
|
165
|
+
excluded_dirs = framework_excluded_dirs(project_root.resolve())
|
|
166
|
+
|
|
167
|
+
def _filter(path: Path) -> bool:
|
|
168
|
+
if not app_file_filter(path):
|
|
169
|
+
return False
|
|
170
|
+
return not any(path.is_relative_to(excluded) for excluded in excluded_dirs)
|
|
171
|
+
|
|
172
|
+
return _filter
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Project root path of a parsed project AST.
|
|
2
|
+
|
|
3
|
+
The Semgrep runner reads its scan root here: the project node's
|
|
4
|
+
``absolutePath`` is required, with no fallback to file nodes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from php_parser_py import AST
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def project_root_text(project_ast: AST) -> str:
|
|
13
|
+
"""Return the project node's ``absolutePath`` unchanged.
|
|
14
|
+
|
|
15
|
+
A directory name may begin or end with a space, so the path is never
|
|
16
|
+
stripped; only a value that is blank is rejected.
|
|
17
|
+
|
|
18
|
+
Raises:
|
|
19
|
+
ValueError: If the AST has no project node, or the project node has no
|
|
20
|
+
path, an empty path, or a whitespace-only path.
|
|
21
|
+
"""
|
|
22
|
+
try:
|
|
23
|
+
project_node = project_ast.project_node()
|
|
24
|
+
except KeyError as exc:
|
|
25
|
+
raise ValueError("Cannot resolve project root from AST: no project node") from exc
|
|
26
|
+
path_val = project_node.get("absolutePath")
|
|
27
|
+
if not isinstance(path_val, str) or not path_val.strip():
|
|
28
|
+
raise ValueError("Cannot resolve project root from AST: project node has no absolutePath")
|
|
29
|
+
return path_val
|