use crate::rules::common::AliasTable;
use crate::rules::taint_engine::{
analyze_function_generic, attribution_hint_for_sink, match_call_sink, node_text,
taint_finding_for_node, AnalysisContext, TaintLanguageAdapter, TaintState,
};
pub use crate::rules::taint_engine::{NodeMatcher, TaintFinding, TaintSpec};
use tree_sitter::Node;
type PhpCtx<'a> = AnalysisContext<'a, ()>;
pub fn analyze_tree(
root: Node<'_>,
source: &str,
spec: &TaintSpec,
_aliases: Option<&AliasTable>,
) -> Vec<TaintFinding> {
let empty_summary = crate::rules::taint_engine::ReturnSummary::new();
let ctx = AnalysisContext {
source,
spec,
aliases: None,
summaries: &empty_summary,
cross_file: None,
sink_to_rules: None,
};
let mut findings = Vec::new();
collect_function_defs(root, &mut |func_node| {
analyze_function_generic::<PhpTaintAdapter, ()>(func_node, &ctx, &mut findings);
});
findings
}
pub fn php_taint_sources() -> Vec<NodeMatcher> {
vec![
NodeMatcher::ParamName {
names: vec![
"$_GET".into(),
"$_POST".into(),
"$_REQUEST".into(),
"$_COOKIE".into(),
"$_SERVER".into(),
"$_FILES".into(),
"$_ENV".into(),
],
description: "HTTP superglobal".into(),
},
NodeMatcher::Call {
canonical: "file_get_contents".into(),
description: "file_get_contents()".into(),
},
NodeMatcher::Call {
canonical: "fread".into(),
description: "fread()".into(),
},
NodeMatcher::Call {
canonical: "stream_get_contents".into(),
description: "stream_get_contents()".into(),
},
]
}
pub fn php_taint_sinks() -> Vec<NodeMatcher> {
vec![
NodeMatcher::Call {
canonical: "system".into(),
description: "system()".into(),
},
NodeMatcher::Call {
canonical: "exec".into(),
description: "exec()".into(),
},
NodeMatcher::Call {
canonical: "shell_exec".into(),
description: "shell_exec()".into(),
},
NodeMatcher::Call {
canonical: "passthru".into(),
description: "passthru()".into(),
},
NodeMatcher::Call {
canonical: "popen".into(),
description: "popen()".into(),
},
NodeMatcher::Call {
canonical: "proc_open".into(),
description: "proc_open()".into(),
},
NodeMatcher::Call {
canonical: "eval".into(),
description: "eval()".into(),
},
NodeMatcher::Call {
canonical: "preg_replace".into(),
description: "preg_replace()".into(),
},
NodeMatcher::Call {
canonical: "include".into(),
description: "include".into(),
},
NodeMatcher::Call {
canonical: "require".into(),
description: "require".into(),
},
NodeMatcher::Call {
canonical: "include_once".into(),
description: "include_once".into(),
},
NodeMatcher::Call {
canonical: "require_once".into(),
description: "require_once".into(),
},
NodeMatcher::Call {
canonical: "mysqli_query".into(),
description: "mysqli_query()".into(),
},
NodeMatcher::Call {
canonical: "mysql_query".into(),
description: "mysql_query()".into(),
},
NodeMatcher::MethodName {
method: "query".into(),
description: "->query()".into(),
},
NodeMatcher::MethodName {
method: "exec".into(),
description: "->exec()".into(),
},
NodeMatcher::MethodName {
method: "prepare".into(),
description: "->prepare()".into(),
},
NodeMatcher::Call {
canonical: "echo".into(),
description: "echo".into(),
},
NodeMatcher::Call {
canonical: "print".into(),
description: "print()".into(),
},
NodeMatcher::Call {
canonical: "printf".into(),
description: "printf()".into(),
},
NodeMatcher::Call {
canonical: "die".into(),
description: "die()".into(),
},
NodeMatcher::Call {
canonical: "file_put_contents".into(),
description: "file_put_contents()".into(),
},
NodeMatcher::Call {
canonical: "fwrite".into(),
description: "fwrite()".into(),
},
]
}
pub fn php_taint_sanitizers() -> Vec<NodeMatcher> {
vec![
NodeMatcher::Call {
canonical: "escapeshellarg".into(),
description: "escapeshellarg()".into(),
},
NodeMatcher::Call {
canonical: "escapeshellcmd".into(),
description: "escapeshellcmd()".into(),
},
NodeMatcher::Call {
canonical: "htmlspecialchars".into(),
description: "htmlspecialchars()".into(),
},
NodeMatcher::Call {
canonical: "htmlentities".into(),
description: "htmlentities()".into(),
},
NodeMatcher::Call {
canonical: "strip_tags".into(),
description: "strip_tags()".into(),
},
NodeMatcher::Call {
canonical: "mysqli_real_escape_string".into(),
description: "mysqli_real_escape_string()".into(),
},
NodeMatcher::Call {
canonical: "mysql_real_escape_string".into(),
description: "mysql_real_escape_string()".into(),
},
NodeMatcher::MethodName {
method: "quote".into(),
description: "->quote()".into(),
},
NodeMatcher::Call {
canonical: "intval".into(),
description: "intval()".into(),
},
NodeMatcher::Call {
canonical: "floatval".into(),
description: "floatval()".into(),
},
NodeMatcher::Call {
canonical: "abs".into(),
description: "abs()".into(),
},
NodeMatcher::Call {
canonical: "preg_quote".into(),
description: "preg_quote()".into(),
},
NodeMatcher::Call {
canonical: "basename".into(),
description: "basename()".into(),
},
NodeMatcher::Call {
canonical: "realpath".into(),
description: "realpath()".into(),
},
]
}
struct PhpTaintAdapter;
impl TaintLanguageAdapter<()> for PhpTaintAdapter {
fn is_nested_scope(kind: &str) -> bool {
matches!(
kind,
"function_definition" | "method_declaration" | "arrow_function"
)
}
fn get_body(func_node: Node<'_>) -> Option<Node<'_>> {
func_node.child_by_field_name("body")
}
fn seed_params(func_node: Node<'_>, ctx: &PhpCtx<'_>, state: &mut TaintState) {
if let Some(params) = func_node.child_by_field_name("parameters") {
seed_param_sources(params, ctx.source, ctx.spec, state);
}
}
fn dispatch_walk_node(
node: Node<'_>,
ctx: &PhpCtx<'_>,
state: &mut TaintState,
findings: &mut Vec<TaintFinding>,
) {
if node.kind() == "assignment_expression" {
handle_assignment(node, ctx, state);
}
if node.kind() == "function_call_expression" {
handle_function_call(node, ctx, state, findings);
}
if node.kind() == "member_call_expression" {
handle_member_call(node, ctx, state, findings);
}
if node.kind() == "echo_statement" {
handle_echo(node, ctx, state, findings);
}
}
fn dispatch_summary_node(
node: Node<'_>,
ctx: &PhpCtx<'_>,
state: &mut TaintState,
findings: &mut Vec<TaintFinding>,
return_taint: &mut Option<String>,
) {
Self::dispatch_walk_node(node, ctx, state, findings);
if node.kind() == "return_statement" && return_taint.is_none() {
let mut cursor = node.walk();
for child in node.named_children(&mut cursor) {
if let Some((desc, _line)) = expression_taint(child, ctx, state) {
*return_taint = Some(desc);
break;
}
}
}
}
fn expression_taint(
expr: Node<'_>,
ctx: &PhpCtx<'_>,
state: &TaintState,
) -> Option<(String, usize)> {
expression_taint(expr, ctx, state)
}
}
fn collect_function_defs<'tree, F>(node: Node<'tree>, visit: &mut F)
where
F: FnMut(Node<'tree>),
{
if matches!(node.kind(), "function_definition" | "method_declaration") {
visit(node);
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
collect_function_defs(child, visit);
}
}
fn seed_param_sources(params: Node<'_>, source: &str, spec: &TaintSpec, state: &mut TaintState) {
let mut cursor = params.walk();
for child in params.named_children(&mut cursor) {
let var_node = if child.kind() == "variable_name" {
Some(child)
} else {
let mut found = None;
let mut inner = child.walk();
for n in child.named_children(&mut inner) {
if n.kind() == "variable_name" {
found = Some(n);
break;
}
}
found
};
if let Some(v) = var_node {
let name = node_text(v, source);
for matcher in &spec.sources {
if let NodeMatcher::ParamName { names, description } = matcher {
if names.iter().any(|n| n == name)
|| crate::rules::taint_engine::param_names_are_wildcard(names)
{
let line = v.start_position().row + 1;
state.taint(name.to_string(), description.clone(), line);
break;
}
}
}
}
}
}
fn function_call_callee<'a>(node: Node<'_>, source: &'a str) -> &'a str {
node.child_by_field_name("function")
.map(|n| node_text(n, source))
.unwrap_or("")
}
fn handle_assignment(node: Node<'_>, ctx: &PhpCtx<'_>, state: &mut TaintState) {
let (Some(left), Some(right)) = (
node.child_by_field_name("left"),
node.child_by_field_name("right"),
) else {
return;
};
if left.kind() != "variable_name" {
return;
}
let lhs_name = node_text(left, ctx.source).to_string();
if let Some((desc, src_line)) = expression_taint(right, ctx, state) {
state.taint(lhs_name, desc, src_line);
} else {
state.clear(&lhs_name);
}
}
fn handle_function_call(
node: Node<'_>,
ctx: &PhpCtx<'_>,
state: &mut TaintState,
findings: &mut Vec<TaintFinding>,
) {
let callee = function_call_callee(node, ctx.source);
if callee.is_empty() {
return;
}
if let Some(sink) = match_call_sink(ctx.spec, callee, ctx.sink_to_rules) {
check_args_for_sink(node, ctx, state, findings, sink);
}
}
fn handle_member_call(
node: Node<'_>,
ctx: &PhpCtx<'_>,
state: &mut TaintState,
findings: &mut Vec<TaintFinding>,
) {
let method_name = node
.child_by_field_name("name")
.map(|n| node_text(n, ctx.source))
.unwrap_or("");
let before = findings.len();
let method_sink_desc = ctx.spec.sinks.iter().find_map(|m| {
if let NodeMatcher::MethodName {
method,
description,
} = m
{
if method.as_str() == method_name {
Some(description.clone())
} else {
None
}
} else {
None
}
});
if let Some(desc) = method_sink_desc {
check_args_for_sink_by_desc(node, ctx, state, findings, desc);
}
if findings.len() == before {
if let Some(obj) = node.child_by_field_name("object") {
let obj_text = node_text(obj, ctx.source).trim_start_matches('$');
let callee = format!("{}.{}", obj_text, method_name);
if let Some(sink) = match_call_sink(ctx.spec, &callee, ctx.sink_to_rules) {
check_args_for_sink(node, ctx, state, findings, sink);
}
}
}
}
fn handle_echo(
node: Node<'_>,
ctx: &PhpCtx<'_>,
state: &mut TaintState,
findings: &mut Vec<TaintFinding>,
) {
let echo_sink_desc = ctx.spec.sinks.iter().find_map(|m| {
if let NodeMatcher::Call {
canonical,
description,
} = m
{
if canonical == "echo" {
Some(description.clone())
} else {
None
}
} else {
None
}
});
let Some(sink_desc) = echo_sink_desc else {
return;
};
let mut cursor = node.walk();
for child in node.named_children(&mut cursor) {
if let Some((source_desc, src_line)) = expression_taint(child, ctx, state) {
findings.push(taint_finding_for_node(
node,
source_desc,
sink_desc.clone(),
src_line,
None,
1,
));
return;
}
}
}
fn check_args_for_sink(
node: Node<'_>,
ctx: &PhpCtx<'_>,
state: &mut TaintState,
findings: &mut Vec<TaintFinding>,
sink: crate::rules::taint_engine::MatchedSink,
) {
let Some(args) = node.child_by_field_name("arguments") else {
return;
};
let mut cursor = args.walk();
for arg in args.named_children(&mut cursor) {
let expr = if arg.kind() == "argument" {
arg.named_child(0).unwrap_or(arg)
} else {
arg
};
if let Some((source_desc, src_line)) = expression_taint(expr, ctx, state) {
let rule_hint = attribution_hint_for_sink(&sink);
findings.push(taint_finding_for_node(
node,
source_desc,
sink.description.clone(),
src_line,
rule_hint,
1,
));
return;
}
}
}
fn check_args_for_sink_by_desc(
node: Node<'_>,
ctx: &PhpCtx<'_>,
state: &mut TaintState,
findings: &mut Vec<TaintFinding>,
sink_desc: String,
) {
let Some(args) = node.child_by_field_name("arguments") else {
return;
};
let mut cursor = args.walk();
for arg in args.named_children(&mut cursor) {
let expr = if arg.kind() == "argument" {
arg.named_child(0).unwrap_or(arg)
} else {
arg
};
if let Some((source_desc, src_line)) = expression_taint(expr, ctx, state) {
findings.push(taint_finding_for_node(
node,
source_desc,
sink_desc.clone(),
src_line,
None,
1,
));
return;
}
}
}
fn expression_taint(
expr: Node<'_>,
ctx: &PhpCtx<'_>,
state: &TaintState,
) -> Option<(String, usize)> {
let expr_line = expr.start_position().row + 1;
if let Some(desc) = match_source(expr, ctx.source, ctx.spec) {
return Some((desc, expr_line));
}
if expr.kind() == "variable_name" {
let name = node_text(expr, ctx.source);
if let Some(info) = state.info(name) {
return Some((info.description.clone(), info.line));
}
}
if expr.kind() == "subscript_expression" {
if let Some(receiver) = expr.named_child(0) {
if let Some(result) = expression_taint(receiver, ctx, state) {
return Some(result);
}
}
}
if expr.kind() == "encapsed_string" {
let mut cursor = expr.walk();
for child in expr.named_children(&mut cursor) {
if let Some(result) = expression_taint(child, ctx, state) {
return Some(result);
}
}
}
if expr.kind() == "binary_expression" {
for i in 0..2 {
if let Some(child) = expr.named_child(i) {
if let Some(result) = expression_taint(child, ctx, state) {
return Some(result);
}
}
}
}
if expr.kind() == "function_call_expression" {
if is_sanitizer_function_call(expr, ctx.source, ctx.spec) {
return None;
}
if let Some(args) = expr.child_by_field_name("arguments") {
let mut cursor = args.walk();
for arg in args.named_children(&mut cursor) {
let inner = if arg.kind() == "argument" {
arg.named_child(0).unwrap_or(arg)
} else {
arg
};
if let Some(result) = expression_taint(inner, ctx, state) {
return Some(result);
}
}
}
}
if expr.kind() == "member_call_expression" {
if is_sanitizer_method_call(expr, ctx.source, ctx.spec) {
return None;
}
if let Some(args) = expr.child_by_field_name("arguments") {
let mut cursor = args.walk();
for arg in args.named_children(&mut cursor) {
let inner = if arg.kind() == "argument" {
arg.named_child(0).unwrap_or(arg)
} else {
arg
};
if let Some(result) = expression_taint(inner, ctx, state) {
return Some(result);
}
}
}
if let Some(receiver) = expr.child_by_field_name("object") {
if let Some(result) = expression_taint(receiver, ctx, state) {
return Some(result);
}
}
}
if expr.kind() == "conditional_expression" {
let mut cursor = expr.walk();
for child in expr.named_children(&mut cursor) {
if let Some(result) = expression_taint(child, ctx, state) {
return Some(result);
}
}
}
if expr.kind() == "cast_expression" && !is_sanitizer_cast(expr, ctx.source) {
if let Some(inner) = expr.named_child(expr.named_child_count().saturating_sub(1)) {
return expression_taint(inner, ctx, state);
}
}
None
}
fn is_sanitizer_function_call(call_node: Node<'_>, source: &str, spec: &TaintSpec) -> bool {
if call_node.kind() != "function_call_expression" {
return false;
}
let callee = function_call_callee(call_node, source);
spec.sanitizers.iter().any(|m| {
if let NodeMatcher::Call { canonical, .. } = m {
canonical.as_str() == callee
} else {
false
}
})
}
fn is_sanitizer_method_call(call_node: Node<'_>, source: &str, spec: &TaintSpec) -> bool {
if call_node.kind() != "member_call_expression" {
return false;
}
let method = call_node
.child_by_field_name("name")
.map(|n| node_text(n, source))
.unwrap_or("");
spec.sanitizers.iter().any(|m| match m {
NodeMatcher::MethodName { method: m_name, .. } => m_name.as_str() == method,
NodeMatcher::Call { canonical, .. } => canonical.as_str() == method,
_ => false,
})
}
fn is_sanitizer_cast(cast_node: Node<'_>, source: &str) -> bool {
let text = node_text(cast_node, source).to_lowercase();
text.starts_with("(int)")
|| text.starts_with("(integer)")
|| text.starts_with("(bool)")
|| text.starts_with("(boolean)")
|| text.starts_with("(float)")
|| text.starts_with("(double)")
}
fn match_source(node: Node<'_>, source: &str, spec: &TaintSpec) -> Option<String> {
for matcher in &spec.sources {
match matcher {
NodeMatcher::ParamName { names, description } => {
let matches_name = |n: &str| names.iter().any(|name| name == n);
match node.kind() {
"variable_name" => {
let var = node_text(node, source);
if matches_name(var) {
return Some(description.clone());
}
}
"subscript_expression" => {
if let Some(receiver) = node.named_child(0) {
if receiver.kind() == "variable_name" {
let var = node_text(receiver, source);
if matches_name(var) {
return Some(description.clone());
}
}
}
}
_ => {}
}
}
NodeMatcher::Call {
canonical,
description,
} => {
if node.kind() == "function_call_expression" {
let callee = function_call_callee(node, source);
if callee == canonical.as_str() {
return Some(description.clone());
}
}
}
NodeMatcher::Attribute {
root,
field,
description,
} => {
if node.kind() == "member_access_expression" {
let recv_text = node
.child_by_field_name("object")
.map(|n| node_text(n, source).trim_start_matches('$'))
.unwrap_or("");
let member_text = node
.child_by_field_name("member")
.or_else(|| node.child_by_field_name("name"))
.map(|n| node_text(n, source))
.unwrap_or("");
if recv_text == root.as_str() && member_text == field.as_str() {
return Some(description.clone());
}
}
}
NodeMatcher::FieldName { field, description } => {
if node.kind() == "member_access_expression" {
let member_text = node
.child_by_field_name("member")
.or_else(|| node.child_by_field_name("name"))
.map(|n| node_text(n, source))
.unwrap_or("");
if member_text == field.as_str() {
return Some(description.clone());
}
}
}
NodeMatcher::Subscript { base, description } => {
if node.kind() == "subscript_expression" {
let Some(receiver) = node.named_child(0) else {
continue;
};
let Some(want) = base.as_deref() else {
return Some(description.clone());
};
let final_seg = match receiver.kind() {
"variable_name" => {
Some(node_text(receiver, source).trim_start_matches('$'))
}
"member_access_expression" => receiver
.child_by_field_name("member")
.or_else(|| receiver.child_by_field_name("name"))
.map(|n| node_text(n, source)),
"name" => Some(node_text(receiver, source)),
_ => None,
};
if final_seg == Some(want) {
return Some(description.clone());
}
}
}
NodeMatcher::MethodName { .. }
| NodeMatcher::CallRegex { .. }
| NodeMatcher::MethodNameRegex { .. }
| NodeMatcher::ReceiverCall { .. }
| NodeMatcher::MemberAssign { .. }
| NodeMatcher::BinopFormat { .. }
| NodeMatcher::ObjectLiteralValue { .. }
| NodeMatcher::ReturnValue { .. } => {
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::parser::parse_file;
use crate::Language;
fn run(src: &str, spec: &TaintSpec) -> Vec<TaintFinding> {
let tree = parse_file(src, Language::Php).expect("parse");
analyze_tree(tree.root_node(), src, spec, None)
}
fn spec_get_to_system() -> TaintSpec {
TaintSpec {
sources: vec![NodeMatcher::ParamName {
names: vec!["$_GET".into()],
description: "$_GET".into(),
}],
sinks: vec![NodeMatcher::Call {
canonical: "system".into(),
description: "system()".into(),
}],
sanitizers: vec![],
}
}
fn spec_get_to_system_with_sanitizer() -> TaintSpec {
TaintSpec {
sources: vec![NodeMatcher::ParamName {
names: vec!["$_GET".into()],
description: "$_GET".into(),
}],
sinks: vec![NodeMatcher::Call {
canonical: "system".into(),
description: "system()".into(),
}],
sanitizers: vec![NodeMatcher::Call {
canonical: "escapeshellarg".into(),
description: "escapeshellarg()".into(),
}],
}
}
#[test]
fn get_subscript_to_system_via_assignment() {
let src = "<?php\nfunction handle() {\n $c = $_GET['cmd'];\n system($c);\n}\n";
let f = run(src, &spec_get_to_system());
assert_eq!(f.len(), 1, "expected finding, got {:?}", f);
assert!(f[0].source_description.contains("$_GET"));
assert!(f[0].sink_description.contains("system"));
}
#[test]
fn get_subscript_directly_to_system() {
let src = "<?php\nfunction handle() {\n system($_GET['cmd']);\n}\n";
let f = run(src, &spec_get_to_system());
assert_eq!(f.len(), 1, "expected direct finding, got {:?}", f);
}
#[test]
fn literal_cmd_no_finding() {
let src = "<?php\nfunction handle() {\n $c = 'ls -la';\n system($c);\n}\n";
let f = run(src, &spec_get_to_system());
assert_eq!(f.len(), 0, "literal must produce no finding");
}
#[test]
fn escapeshellarg_sanitizes() {
let src =
"<?php\nfunction handle() {\n $c = escapeshellarg($_GET['cmd']);\n system($c);\n}\n";
let f = run(src, &spec_get_to_system_with_sanitizer());
assert_eq!(f.len(), 0, "escapeshellarg must sanitize taint");
}
#[test]
fn taint_not_reaching_sink() {
let src = "<?php\nfunction handle() {\n $tainted = $_GET['cmd'];\n $safe = 'ls';\n system($safe);\n}\n";
let f = run(src, &spec_get_to_system());
assert_eq!(f.len(), 0, "safe arg must produce no finding");
}
#[test]
fn chained_assignment_propagates() {
let src = "<?php\nfunction handle() {\n $a = $_GET['x'];\n $b = $a;\n $c = $b;\n system($c);\n}\n";
let f = run(src, &spec_get_to_system());
assert_eq!(f.len(), 1, "chained assignment must propagate taint");
}
#[test]
fn reassignment_to_literal_kills_taint() {
let src =
"<?php\nfunction handle() {\n $c = $_GET['cmd'];\n $c = 'ls';\n system($c);\n}\n";
let f = run(src, &spec_get_to_system());
assert_eq!(f.len(), 0, "reassignment kills taint");
}
#[test]
fn post_superglobal_is_source() {
let spec = TaintSpec {
sources: vec![NodeMatcher::ParamName {
names: vec!["$_POST".into()],
description: "$_POST".into(),
}],
sinks: vec![NodeMatcher::Call {
canonical: "system".into(),
description: "system()".into(),
}],
sanitizers: vec![],
};
let src = "<?php\nfunction handle() {\n $c = $_POST['cmd'];\n system($c);\n}\n";
let f = run(src, &spec);
assert_eq!(f.len(), 1, "$_POST must be a source");
}
#[test]
fn method_name_sink_fires_on_query() {
let spec = TaintSpec {
sources: vec![NodeMatcher::ParamName {
names: vec!["$_GET".into()],
description: "$_GET".into(),
}],
sinks: vec![NodeMatcher::MethodName {
method: "query".into(),
description: "->query()".into(),
}],
sanitizers: vec![],
};
let src = "<?php\nfunction handle() {\n $q = $_GET['q'];\n $pdo->query($q);\n}\n";
let f = run(src, &spec);
assert_eq!(f.len(), 1, "->query() must be a sink, got {:?}", f);
}
#[test]
fn sanitizer_on_other_var_does_not_block_original() {
let src = "<?php\nfunction handle() {\n $raw = $_GET['cmd'];\n $safe = escapeshellarg($raw);\n system($raw);\n}\n";
let f = run(src, &spec_get_to_system_with_sanitizer());
assert_eq!(f.len(), 1, "sanitizing to $safe must not clear $raw taint");
}
}