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,295 @@
|
|
|
1
|
+
"""Build DataFlowGraph via Semgrep source identification + AST def-use chains + CallGraph inter-proc edges.
|
|
2
|
+
|
|
3
|
+
Four-phase pipeline:
|
|
4
|
+
1. Semgrep identifies tainted expressions per Source kind (intraprocedural)
|
|
5
|
+
2. AST-based def-use chains create cross-statement edges within functions
|
|
6
|
+
3. CallGraph creates cross-function edges (argument->parameter, return->callsite)
|
|
7
|
+
4. BFS propagates taint from Semgrep seeds through the combined graph
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from php_parser_py import AST, Node
|
|
17
|
+
|
|
18
|
+
from ._errors import StaticAnalysisError
|
|
19
|
+
from ._call_graph import CallGraph
|
|
20
|
+
from ._defuse_builder import build_interproc_edges, build_intraproc_edges
|
|
21
|
+
from ._semgrep_runner import (
|
|
22
|
+
OffsetIndex,
|
|
23
|
+
SemgrepResult,
|
|
24
|
+
build_file_id_index,
|
|
25
|
+
build_offset_index,
|
|
26
|
+
resolve_file_node_id,
|
|
27
|
+
resolve_project_path,
|
|
28
|
+
run_semgrep,
|
|
29
|
+
)
|
|
30
|
+
from ._taint_graph import DataFlowGraph
|
|
31
|
+
from ._taint_rules import CONSTANT_NODE_TYPES, TAINT_RULES, select_seed_locations
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class _SeedExpansion:
|
|
36
|
+
"""Shared context for the seed-expansion pass.
|
|
37
|
+
|
|
38
|
+
Bundles the AST, the dataflow graph being populated, and the accumulator
|
|
39
|
+
of newly discovered seeds so expansion helpers take one workspace rather
|
|
40
|
+
than threading four positional arguments each.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
ast: AST
|
|
44
|
+
dataflow_graph: DataFlowGraph
|
|
45
|
+
expanded: dict[str, set[str]]
|
|
46
|
+
|
|
47
|
+
def record(self, node_id: str, source_types: set[str]) -> None:
|
|
48
|
+
"""Ensure node exists and merge source_types into its expanded seeds."""
|
|
49
|
+
_ensure_node(self.dataflow_graph, node_id)
|
|
50
|
+
self.expanded.setdefault(node_id, set()).update(source_types)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class _SeedCollection:
|
|
55
|
+
"""Shared context for mapping Semgrep results into seed nodes.
|
|
56
|
+
|
|
57
|
+
Bundles the project path, file-id index, offset index, and graph so
|
|
58
|
+
result processing takes one workspace rather than threading positionally.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
project_path: Path
|
|
62
|
+
file_id_by_path: dict[str, str]
|
|
63
|
+
offset_index: OffsetIndex
|
|
64
|
+
dataflow_graph: DataFlowGraph
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class TaintPropagator:
|
|
68
|
+
"""Combines Semgrep intraprocedural taint identification with AST def-use chains and CallGraph inter-proc edges.
|
|
69
|
+
|
|
70
|
+
Attributes:
|
|
71
|
+
_ast: PHP project AST.
|
|
72
|
+
_call_graph: Populated CallGraph for inter-procedural analysis.
|
|
73
|
+
_timeout: Maximum execution time in seconds.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
ast: AST,
|
|
79
|
+
call_graph: CallGraph,
|
|
80
|
+
timeout: int | None,
|
|
81
|
+
target_files: set[str] | None = None,
|
|
82
|
+
) -> None:
|
|
83
|
+
self._ast: AST = ast
|
|
84
|
+
self._call_graph: CallGraph = call_graph
|
|
85
|
+
self._timeout: int | None = timeout
|
|
86
|
+
self._target_files: set[str] | None = target_files
|
|
87
|
+
|
|
88
|
+
def propagate(self) -> DataFlowGraph:
|
|
89
|
+
"""Run 4-phase taint analysis pipeline.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Populated DataFlowGraph with connected taint paths.
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
StaticAnalysisError: Semgrep execution failure or invalid AST structure.
|
|
96
|
+
"""
|
|
97
|
+
dataflow_graph = DataFlowGraph()
|
|
98
|
+
project_path = resolve_project_path(self._ast)
|
|
99
|
+
if not project_path.is_dir():
|
|
100
|
+
raise StaticAnalysisError(f"Project path is not a directory: {project_path}")
|
|
101
|
+
|
|
102
|
+
collection = _SeedCollection(
|
|
103
|
+
project_path=project_path,
|
|
104
|
+
file_id_by_path=build_file_id_index(self._ast),
|
|
105
|
+
offset_index=build_offset_index(self._ast),
|
|
106
|
+
dataflow_graph=dataflow_graph,
|
|
107
|
+
)
|
|
108
|
+
seeds: dict[str, set[str]] = {}
|
|
109
|
+
for source_type in TAINT_RULES:
|
|
110
|
+
self._collect_semgrep_seeds(collection, source_type, seeds)
|
|
111
|
+
|
|
112
|
+
_expand_seeds_to_subexpressions(self._ast, seeds, dataflow_graph)
|
|
113
|
+
build_intraproc_edges(self._ast, dataflow_graph)
|
|
114
|
+
build_interproc_edges(self._ast, self._call_graph, dataflow_graph)
|
|
115
|
+
_propagate_taint(seeds, dataflow_graph)
|
|
116
|
+
|
|
117
|
+
return dataflow_graph
|
|
118
|
+
|
|
119
|
+
# ── Phase 1: Semgrep seed collection ─────────────────────────────
|
|
120
|
+
|
|
121
|
+
def _collect_semgrep_seeds(
|
|
122
|
+
self,
|
|
123
|
+
collection: _SeedCollection,
|
|
124
|
+
source_type: str,
|
|
125
|
+
seeds: dict[str, set[str]],
|
|
126
|
+
) -> None:
|
|
127
|
+
"""Run Semgrep for source_type, map results to AST nodes, populate seeds."""
|
|
128
|
+
rule_yaml = TAINT_RULES.get(source_type)
|
|
129
|
+
if not rule_yaml:
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
semgrep_output = run_semgrep(rule_yaml, collection.project_path, self._timeout, self._target_files)
|
|
133
|
+
|
|
134
|
+
for result in semgrep_output.get("results", []):
|
|
135
|
+
_process_semgrep_result(collection, result, source_type, seeds)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ── Phase 1: Semgrep result processing ──────────────────────────────
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _process_semgrep_result(
|
|
142
|
+
collection: _SeedCollection,
|
|
143
|
+
result: SemgrepResult,
|
|
144
|
+
source_type: str,
|
|
145
|
+
seeds: dict[str, set[str]],
|
|
146
|
+
) -> None:
|
|
147
|
+
"""Map a single Semgrep result to an AST node and register as seed."""
|
|
148
|
+
locations = select_seed_locations(result, collection.project_path, collection.file_id_by_path)
|
|
149
|
+
for location in locations:
|
|
150
|
+
file_node_id = resolve_file_node_id(location.path, collection.project_path, collection.file_id_by_path)
|
|
151
|
+
if location.end_offset is None:
|
|
152
|
+
node_id = collection.offset_index.resolve(location.offset, file_node_id)
|
|
153
|
+
elif file_node_id is not None:
|
|
154
|
+
node_id = collection.offset_index.resolve_source_span(location.offset, location.end_offset, file_node_id)
|
|
155
|
+
else:
|
|
156
|
+
continue
|
|
157
|
+
if node_id:
|
|
158
|
+
seeds.setdefault(node_id, set()).add(source_type)
|
|
159
|
+
_ensure_node(collection.dataflow_graph, node_id, source_type)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ── Phase 1b: Seed expansion ────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _expand_seeds_to_subexpressions(
|
|
166
|
+
ast: AST,
|
|
167
|
+
seeds: dict[str, set[str]],
|
|
168
|
+
dataflow_graph: DataFlowGraph,
|
|
169
|
+
) -> None:
|
|
170
|
+
"""Expand Semgrep seed nodes to their child expressions via AST edges.
|
|
171
|
+
|
|
172
|
+
Bridges statement-level Semgrep results to expression-level
|
|
173
|
+
nodes that Phase 2/3 edges connect at.
|
|
174
|
+
"""
|
|
175
|
+
ctx = _SeedExpansion(ast=ast, dataflow_graph=dataflow_graph, expanded={})
|
|
176
|
+
|
|
177
|
+
for node_id, source_types in list(seeds.items()):
|
|
178
|
+
_expand_assignment_context(ctx, node_id, source_types)
|
|
179
|
+
_expand_node(ctx, node_id, source_types)
|
|
180
|
+
|
|
181
|
+
for node_id, source_types in ctx.expanded.items():
|
|
182
|
+
seeds.setdefault(node_id, set()).update(source_types)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _expand_assignment_context(
|
|
186
|
+
ctx: _SeedExpansion,
|
|
187
|
+
node_id: str,
|
|
188
|
+
source_types: set[str],
|
|
189
|
+
) -> None:
|
|
190
|
+
"""If node is LHS of an assignment, also expand the assignment RHS as seed."""
|
|
191
|
+
node = ctx.ast.node(node_id)
|
|
192
|
+
if node is None:
|
|
193
|
+
return
|
|
194
|
+
|
|
195
|
+
for parent in ctx.ast.prev(node):
|
|
196
|
+
if parent.node_type != "Expr_Assign":
|
|
197
|
+
continue
|
|
198
|
+
children = list(ctx.ast.succ(parent))
|
|
199
|
+
if len(children) < 2:
|
|
200
|
+
continue
|
|
201
|
+
if children[0].id != node_id:
|
|
202
|
+
continue
|
|
203
|
+
_seed_assignment_and_rhs(ctx, parent, children[1], source_types)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _seed_assignment_and_rhs(
|
|
207
|
+
ctx: _SeedExpansion,
|
|
208
|
+
parent: Node,
|
|
209
|
+
rhs: Node,
|
|
210
|
+
source_types: set[str],
|
|
211
|
+
) -> None:
|
|
212
|
+
"""Seed an assignment node and its RHS subexpressions."""
|
|
213
|
+
ctx.record(parent.id, source_types)
|
|
214
|
+
|
|
215
|
+
if rhs.node_type not in CONSTANT_NODE_TYPES:
|
|
216
|
+
ctx.record(rhs.id, source_types)
|
|
217
|
+
ctx.dataflow_graph.add_structural_edge(parent.id, rhs.id)
|
|
218
|
+
_expand_node(ctx, rhs.id, source_types)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _expand_node(
|
|
222
|
+
ctx: _SeedExpansion,
|
|
223
|
+
node_id: str,
|
|
224
|
+
source_types: set[str],
|
|
225
|
+
) -> None:
|
|
226
|
+
"""Expand a node to its AST descendants in pre-order, creating structural edges.
|
|
227
|
+
|
|
228
|
+
Walks with an explicit stack of child iterators, so a deeply nested
|
|
229
|
+
expression (a long generated concatenation) does not exhaust the Python
|
|
230
|
+
call stack. Constant children are skipped together with their subtrees.
|
|
231
|
+
"""
|
|
232
|
+
node = ctx.ast.node(node_id)
|
|
233
|
+
if node is None:
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
pending: list[tuple[str, Iterator[Node]]] = [(node_id, iter(ctx.ast.succ(node)))]
|
|
237
|
+
while pending:
|
|
238
|
+
parent_id, children = pending[-1]
|
|
239
|
+
child = next(children, None)
|
|
240
|
+
if child is None:
|
|
241
|
+
pending.pop()
|
|
242
|
+
continue
|
|
243
|
+
if child.node_type in CONSTANT_NODE_TYPES:
|
|
244
|
+
continue
|
|
245
|
+
ctx.record(child.id, source_types)
|
|
246
|
+
ctx.dataflow_graph.add_structural_edge(parent_id, child.id)
|
|
247
|
+
child_node = ctx.ast.node(child.id)
|
|
248
|
+
if child_node is not None:
|
|
249
|
+
pending.append((child.id, iter(ctx.ast.succ(child_node))))
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ── Phase 4: BFS taint propagation ──────────────────────────────────
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _propagate_taint(
|
|
256
|
+
seeds: dict[str, set[str]],
|
|
257
|
+
dataflow_graph: DataFlowGraph,
|
|
258
|
+
) -> None:
|
|
259
|
+
"""BFS from seed nodes, propagating taint through graph edges."""
|
|
260
|
+
for source_type in TAINT_RULES:
|
|
261
|
+
seed_ids = [nid for nid, types in seeds.items() if source_type in types]
|
|
262
|
+
for current in dataflow_graph.reachable_from(seed_ids):
|
|
263
|
+
_mark_taint(dataflow_graph, current, source_type)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _mark_taint(
|
|
267
|
+
dataflow_graph: DataFlowGraph,
|
|
268
|
+
node_id: str,
|
|
269
|
+
source_type: str,
|
|
270
|
+
) -> None:
|
|
271
|
+
"""Mark a node with a taint source if not already present."""
|
|
272
|
+
df_node = dataflow_graph.get_dataflow_node(node_id)
|
|
273
|
+
if source_type not in df_node.taints:
|
|
274
|
+
current_taints = df_node.taints.copy()
|
|
275
|
+
current_taints.append(source_type)
|
|
276
|
+
df_node.taints = current_taints
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# ── Node helpers ─────────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _ensure_node(
|
|
283
|
+
dataflow_graph: DataFlowGraph,
|
|
284
|
+
node_id: str,
|
|
285
|
+
source_type: str | None = None,
|
|
286
|
+
) -> None:
|
|
287
|
+
"""Ensure node exists in graph; optionally set initial taint."""
|
|
288
|
+
if not dataflow_graph.storage.contains_node(node_id):
|
|
289
|
+
df_node = dataflow_graph.add_dataflow_node(node_id)
|
|
290
|
+
if source_type is not None:
|
|
291
|
+
df_node.taints = [source_type]
|
|
292
|
+
return
|
|
293
|
+
|
|
294
|
+
if source_type is not None:
|
|
295
|
+
_mark_taint(dataflow_graph, node_id, source_type)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Semgrep taint rule loading and AST node type constants.
|
|
2
|
+
|
|
3
|
+
Loads taint rules from YAML files in the rules/ directory.
|
|
4
|
+
Node type sets loaded from php_ast_types.toml.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import cast
|
|
12
|
+
|
|
13
|
+
from ._ast_types import load_php_ast_types
|
|
14
|
+
from ._semgrep_runner import SemgrepResult
|
|
15
|
+
from ._source_kinds import DATABASE, USER_INPUT
|
|
16
|
+
|
|
17
|
+
_RULES_DIR = Path(__file__).parent / "rules"
|
|
18
|
+
|
|
19
|
+
# Insertion order is the order of the Semgrep runs and of each node's taints list.
|
|
20
|
+
_RULE_FILES: dict[str, str] = {
|
|
21
|
+
USER_INPUT: "user_input_taint.yaml",
|
|
22
|
+
DATABASE: "database_taint.yaml",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _load_rule(filename: str) -> str:
|
|
27
|
+
"""Load a YAML rule file from the rules directory."""
|
|
28
|
+
path = _RULES_DIR / filename
|
|
29
|
+
return path.read_text(encoding="utf-8")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
TAINT_RULES: dict[str, str] = {source: _load_rule(filename) for source, filename in _RULE_FILES.items()}
|
|
33
|
+
|
|
34
|
+
CONSTANT_NODE_TYPES, CALLABLE_TYPES, _ = load_php_ast_types()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Identity and metadata are the contract of the call-argument rule in the YAML.
|
|
38
|
+
_CALL_ARGUMENT_RULE = "user-input-call-argument-reachability"
|
|
39
|
+
_INPUT_OUTPUT_RULE = "user-input-pollution-reachability"
|
|
40
|
+
_TRACE_SEED_METADATA = "seed_from_source_trace"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class SeedLocation:
|
|
45
|
+
"""An offset seed or an exact expression span from a source trace."""
|
|
46
|
+
|
|
47
|
+
path: str
|
|
48
|
+
offset: int
|
|
49
|
+
end_offset: int | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def select_seed_locations(
|
|
53
|
+
result: SemgrepResult, project_path: Path, file_id_by_path: dict[str, str]
|
|
54
|
+
) -> list[SeedLocation]:
|
|
55
|
+
"""Keep the rule's seed and supplement input/output matches with a source."""
|
|
56
|
+
seed = select_seed_result(result, project_path, file_id_by_path)
|
|
57
|
+
path, offset = _field(seed, "path"), _field(_field(seed, "start"), "offset")
|
|
58
|
+
if not isinstance(path, str) or not isinstance(offset, int):
|
|
59
|
+
return []
|
|
60
|
+
selected = [SeedLocation(path, offset)]
|
|
61
|
+
check_id = result.get("check_id")
|
|
62
|
+
if isinstance(check_id, str) and check_id.rsplit(".", 1)[-1] == _INPUT_OUTPUT_RULE:
|
|
63
|
+
source = _trace_seed_location(result, project_path, file_id_by_path)
|
|
64
|
+
if source is not None:
|
|
65
|
+
selected.append(source)
|
|
66
|
+
return selected
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def select_seed_result(
|
|
70
|
+
result: SemgrepResult, project_path: Path, file_id_by_path: dict[str, str]
|
|
71
|
+
) -> SemgrepResult | None:
|
|
72
|
+
"""Select a validated source location for the call-argument rule.
|
|
73
|
+
|
|
74
|
+
Existing rules retain their result location. Unsupported or missing source
|
|
75
|
+
traces on the call rule produce no seed, rather than a whole-argument seed.
|
|
76
|
+
"""
|
|
77
|
+
check_id = result.get("check_id")
|
|
78
|
+
if not isinstance(check_id, str) or check_id.rsplit(".", 1)[-1] != _CALL_ARGUMENT_RULE:
|
|
79
|
+
return result
|
|
80
|
+
extra = result.get("extra")
|
|
81
|
+
if _field(_field(extra, "metadata"), _TRACE_SEED_METADATA) is not True:
|
|
82
|
+
return None
|
|
83
|
+
source = _trace_seed_location(result, project_path, file_id_by_path)
|
|
84
|
+
return {"path": source.path, "start": {"offset": source.offset}} if source is not None else None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _trace_seed_location(
|
|
88
|
+
result: SemgrepResult, project_path: Path, file_id_by_path: dict[str, str]
|
|
89
|
+
) -> SeedLocation | None:
|
|
90
|
+
extra = result.get("extra")
|
|
91
|
+
location = _source_location(_field(_field(extra, "dataflow_trace"), "taint_source"))
|
|
92
|
+
seed = _local_source_result(location, result, project_path)
|
|
93
|
+
if seed is None or str(seed["path"]) not in file_id_by_path:
|
|
94
|
+
return None
|
|
95
|
+
offset = cast(int, _field(seed["start"], "offset"))
|
|
96
|
+
end = cast(int, _field(_field(location, "end"), "offset"))
|
|
97
|
+
return SeedLocation(str(seed["path"]), offset, end)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _field(value: object, name: str) -> object:
|
|
101
|
+
# Semgrep's optional trace payload is validated at this JSON boundary.
|
|
102
|
+
return cast(object, value.get(name)) if isinstance(value, dict) else None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _source_location(source: object) -> object:
|
|
106
|
+
if not isinstance(source, list) or len(source) != 2 or source[0] != "CliLoc":
|
|
107
|
+
return None
|
|
108
|
+
payload = source[1]
|
|
109
|
+
return payload[0] if isinstance(payload, list) and len(payload) == 2 else None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _local_source_result(location: object, result: SemgrepResult, project_path: Path) -> SemgrepResult | None:
|
|
113
|
+
source_path, sink_path = _field(location, "path"), result.get("path")
|
|
114
|
+
if not isinstance(source_path, str) or not isinstance(sink_path, str):
|
|
115
|
+
return None
|
|
116
|
+
source = (project_path / source_path).resolve()
|
|
117
|
+
if source != (project_path / sink_path).resolve() or not source.is_relative_to(project_path.resolve()):
|
|
118
|
+
return None
|
|
119
|
+
start = _field(_field(location, "start"), "offset")
|
|
120
|
+
end = _field(_field(location, "end"), "offset")
|
|
121
|
+
if type(start) is not int or type(end) is not int or not 0 <= start < end:
|
|
122
|
+
return None
|
|
123
|
+
return {"path": str(source), "start": {"offset": start}}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Framework identity-marker registry for in-tree (vendored, no-vendor/no-namespace)
|
|
2
|
+
# framework detection. Each entry names ONE framework by a UNIQUE identity
|
|
3
|
+
# bootstrap file, never a role-generic name (e.g. Kernel.php). When the marker
|
|
4
|
+
# file is found AND its head contains the signature string, the marker's subtree
|
|
5
|
+
# root (or only its exclude_subpaths) is excluded from the parsed files and the Semgrep scan.
|
|
6
|
+
#
|
|
7
|
+
# Fields:
|
|
8
|
+
# name framework identifier (logged on exclusion).
|
|
9
|
+
# marker POSIX relative-path suffix of the identity bootstrap file. Its
|
|
10
|
+
# component count fixes how many path levels to strip to the root.
|
|
11
|
+
# contains substring that must appear in the marker file head; disambiguates
|
|
12
|
+
# name collisions. Empty match = no exclusion (conservative bias).
|
|
13
|
+
# exclude_subpaths (optional) subpaths under the detected root to exclude.
|
|
14
|
+
# Omitted/empty = exclude the whole subtree. Set it for a monorepo
|
|
15
|
+
# framework that also ships application code under the same root, so
|
|
16
|
+
# only the framework dirs drop and business code is kept.
|
|
17
|
+
|
|
18
|
+
[[framework]]
|
|
19
|
+
name = "yii1"
|
|
20
|
+
marker = "YiiBase.php"
|
|
21
|
+
contains = "class YiiBase"
|
|
22
|
+
|
|
23
|
+
# Magento 2 ships framework, tooling, and application code under one root. Drop
|
|
24
|
+
# the non-business dirs (dev tooling/tests, low-level lib internals, the setup
|
|
25
|
+
# installer) and keep app/code (e.g. Magento/Sales, Checkout, Quote).
|
|
26
|
+
[[framework]]
|
|
27
|
+
name = "magento2"
|
|
28
|
+
marker = "app/etc/NonComposerComponentRegistration.php"
|
|
29
|
+
contains = 'namespace Magento\NonComposerComponentRegistration;'
|
|
30
|
+
exclude_subpaths = ["dev", "lib/internal", "setup"]
|
|
31
|
+
|
|
32
|
+
# ThinkPHP deployments can write generated PHP below runtime/cache.
|
|
33
|
+
# Keep application and framework source under the same server root in scope.
|
|
34
|
+
[[framework]]
|
|
35
|
+
name = "thinkphp"
|
|
36
|
+
marker = "thinkphp/base.php"
|
|
37
|
+
contains = "namespace think;"
|
|
38
|
+
exclude_subpaths = ["runtime/cache"]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# PHP AST node type classifications read by the data-flow analysis.
|
|
2
|
+
|
|
3
|
+
[constant_node_types]
|
|
4
|
+
# Scalar literals and magic constants — not taintable
|
|
5
|
+
values = [
|
|
6
|
+
"Scalar_String", "Scalar_Int", "Scalar_Float",
|
|
7
|
+
"Scalar_MagicConst_Line", "Scalar_MagicConst_File",
|
|
8
|
+
"Scalar_MagicConst_Dir", "Scalar_MagicConst_Class",
|
|
9
|
+
"Scalar_MagicConst_Trait", "Scalar_MagicConst_Method",
|
|
10
|
+
"Scalar_MagicConst_Function", "Scalar_MagicConst_Namespace",
|
|
11
|
+
"Scalar_MagicConst_Property",
|
|
12
|
+
"Expr_ConstFetch", "Expr_ClassConstFetch",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[callable_types]
|
|
16
|
+
values = [
|
|
17
|
+
"Stmt_Function", "Stmt_ClassMethod", "Expr_Closure", "Expr_ArrowFunction",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[assign_node_types]
|
|
21
|
+
values = [
|
|
22
|
+
"Expr_Assign",
|
|
23
|
+
"Expr_AssignOp_Plus", "Expr_AssignOp_Minus",
|
|
24
|
+
"Expr_AssignOp_Mul", "Expr_AssignOp_Div", "Expr_AssignOp_Mod",
|
|
25
|
+
"Expr_AssignOp_Concat",
|
|
26
|
+
"Expr_AssignOp_BitwiseAnd", "Expr_AssignOp_BitwiseOr", "Expr_AssignOp_BitwiseXor",
|
|
27
|
+
"Expr_AssignOp_ShiftLeft", "Expr_AssignOp_ShiftRight",
|
|
28
|
+
"Expr_AssignOp_Pow", "Expr_AssignOp_Coalesce",
|
|
29
|
+
]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
rules:
|
|
2
|
+
- id: database-pollution-reachability
|
|
3
|
+
message: "Database pollution reachability analysis"
|
|
4
|
+
severity: WARNING
|
|
5
|
+
languages: [php]
|
|
6
|
+
mode: taint
|
|
7
|
+
pattern-sources:
|
|
8
|
+
# ── Bare PHP database functions ──
|
|
9
|
+
- pattern: mysqli_query(...)
|
|
10
|
+
- pattern: mysql_query(...)
|
|
11
|
+
- pattern: $PDO->query(...)
|
|
12
|
+
- pattern: $PDO->prepare(...)
|
|
13
|
+
- pattern: $STMT->execute(...)
|
|
14
|
+
- pattern: $PDO->exec(...)
|
|
15
|
+
- pattern: pg_query(...)
|
|
16
|
+
- pattern: pg_fetch_assoc(...)
|
|
17
|
+
- pattern: pg_fetch_row(...)
|
|
18
|
+
- pattern: pg_fetch_array(...)
|
|
19
|
+
# ── MySQLi fetch ──
|
|
20
|
+
- pattern: mysqli_fetch_assoc(...)
|
|
21
|
+
- pattern: mysqli_fetch_array(...)
|
|
22
|
+
- pattern: mysqli_fetch_row(...)
|
|
23
|
+
- pattern: $STMT->fetch(...)
|
|
24
|
+
- pattern: $STMT->fetchAll(...)
|
|
25
|
+
- pattern: $RESULT->fetch_assoc()
|
|
26
|
+
- pattern: $RESULT->fetch_array()
|
|
27
|
+
# ── SQLite3 ──
|
|
28
|
+
- pattern: $DB->querySingle(...)
|
|
29
|
+
- pattern: $RESULT->fetchArray(...)
|
|
30
|
+
# ── CodeIgniter query builder ──
|
|
31
|
+
- pattern: $this->db->query(...)
|
|
32
|
+
- pattern: $this->db->get(...)
|
|
33
|
+
- pattern: $this->db->get_where(...)
|
|
34
|
+
- pattern: $QUERY->result()
|
|
35
|
+
- pattern: $QUERY->result_array()
|
|
36
|
+
- pattern: $QUERY->row()
|
|
37
|
+
- pattern: $QUERY->row_array()
|
|
38
|
+
- pattern: $QUERY->num_rows()
|
|
39
|
+
# ── Laravel / Eloquent ──
|
|
40
|
+
- pattern: $MODEL::find(...)
|
|
41
|
+
- pattern: $MODEL::findOrFail(...)
|
|
42
|
+
- pattern: $MODEL::where(...)->get()
|
|
43
|
+
- pattern: $MODEL::where(...)->first()
|
|
44
|
+
- pattern: $MODEL::all()
|
|
45
|
+
- pattern: $QUERY->get()
|
|
46
|
+
- pattern: $QUERY->first()
|
|
47
|
+
- pattern: $QUERY->pluck(...)
|
|
48
|
+
- pattern: $QUERY->value(...)
|
|
49
|
+
- pattern: DB::table(...)->get()
|
|
50
|
+
- pattern: DB::table(...)->first()
|
|
51
|
+
- pattern: DB::select(...)
|
|
52
|
+
- pattern: DB::table(...)->where(...)->get()
|
|
53
|
+
- pattern: DB::table(...)->where(...)->first()
|
|
54
|
+
# ── WordPress ──
|
|
55
|
+
- pattern: $wpdb->get_results(...)
|
|
56
|
+
- pattern: $wpdb->get_row(...)
|
|
57
|
+
- pattern: $wpdb->get_var(...)
|
|
58
|
+
- pattern: $wpdb->get_col(...)
|
|
59
|
+
- pattern: get_post_meta(...)
|
|
60
|
+
- pattern: get_option(...)
|
|
61
|
+
- pattern: get_user_meta(...)
|
|
62
|
+
# ── Doctrine ORM (Symfony, PrestaShop, Sylius, Shopware) ──
|
|
63
|
+
- pattern: $REPO->find(...)
|
|
64
|
+
- pattern: $REPO->findOneBy(...)
|
|
65
|
+
- pattern: $REPO->findBy(...)
|
|
66
|
+
- pattern: $REPO->findAll()
|
|
67
|
+
- pattern: $EM->getRepository(...)->find(...)
|
|
68
|
+
- pattern: $EM->getRepository(...)->findOneBy(...)
|
|
69
|
+
- pattern: $this->getDoctrine()->getRepository(...)
|
|
70
|
+
# ── Yii ActiveRecord (Yii1 + Yii2) ──
|
|
71
|
+
- pattern: $MODEL::findOne(...)
|
|
72
|
+
- pattern: $MODEL::findAll(...)
|
|
73
|
+
- pattern: $MODEL::find()->one()
|
|
74
|
+
- pattern: $MODEL::find()->all()
|
|
75
|
+
- pattern: $MODEL::find()->where(...)->one()
|
|
76
|
+
- pattern: $MODEL::find()->where(...)->all()
|
|
77
|
+
- pattern: $MODEL::model()->findByPk(...)
|
|
78
|
+
- pattern: $MODEL::model()->find(...)
|
|
79
|
+
# ── CakePHP ORM ──
|
|
80
|
+
- pattern: $TABLE->get(...)
|
|
81
|
+
- pattern: $TABLE->find(...)->first()
|
|
82
|
+
- pattern: $TABLE->find(...)->all()
|
|
83
|
+
# ── Magento ──
|
|
84
|
+
- pattern: $COLLECTION->getItems()
|
|
85
|
+
- pattern: $MODEL->load(...)->getData(...)
|
|
86
|
+
pattern-sinks:
|
|
87
|
+
- pattern: return $X;
|
|
88
|
+
exact: false
|
|
89
|
+
- pattern: echo $X;
|
|
90
|
+
exact: false
|
|
91
|
+
- pattern: print $X;
|
|
92
|
+
exact: false
|
|
93
|
+
- pattern: $X = $Y;
|
|
94
|
+
exact: false
|