use crate::rules::common::AliasTable;
use crate::rules::cross_file::{CrossFileSummaryMap, FunctionTaintSummary};
use crate::rules::taint_engine::{
analyze_function_generic, attribution_hint_for_sink, cross_file_taint_finding,
extract_cross_file_summary_for_function, extract_cross_file_summary_for_function_cf,
match_call_sink, node_text, taint_finding_for_node, AnalysisContext, ReturnSummary,
TaintLanguageAdapter, TaintState,
};
pub use crate::rules::taint_engine::{NodeMatcher, TaintFinding, TaintSpec};
use std::collections::HashSet;
use std::path::PathBuf;
use tree_sitter::Node;
pub struct CrossFileInfo<'a> {
pub same_package_paths: &'a [PathBuf],
pub summaries: &'a CrossFileSummaryMap,
pub allowed_rule_ids: &'a HashSet<String>,
}
type PhpCtx<'a> = AnalysisContext<'a, CrossFileInfo<'a>>;
pub fn analyze_tree(
root: Node<'_>,
source: &str,
spec: &TaintSpec,
_aliases: Option<&AliasTable>,
) -> Vec<TaintFinding> {
let empty_summary = ReturnSummary::new();
let ctx = AnalysisContext {
source,
spec,
aliases: None,
summaries: &empty_summary,
cross_file: None,
sink_to_rules: None,
label_policy: None,
};
let mut findings = Vec::new();
collect_function_defs(root, &mut |func_node| {
analyze_function_generic::<PhpTaintAdapter, CrossFileInfo<'_>>(
func_node,
&ctx,
&mut findings,
);
});
findings
}
pub fn extract_cross_file_summaries(
root: Node<'_>,
source: &str,
aliases: Option<&AliasTable>,
rule_specs: &[(&str, TaintSpec)],
) -> Vec<FunctionTaintSummary> {
let mut summaries = Vec::new();
collect_function_defs(root, &mut |func_node| {
let Some(name_node) = func_node.child_by_field_name("name") else {
return;
};
let func_name = node_text(name_node, source).to_string();
let param_names = collect_param_names(func_node, source);
if let Some(summary) =
extract_cross_file_summary_for_function::<PhpTaintAdapter, CrossFileInfo<'_>>(
func_node,
&func_name,
¶m_names,
source,
aliases,
rule_specs,
)
{
summaries.push(summary);
}
});
summaries
}
fn collect_param_names(func_node: Node<'_>, source: &str) -> Vec<String> {
let Some(params) = func_node.child_by_field_name("parameters") else {
return Vec::new();
};
let mut names = Vec::new();
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 {
names.push(node_text(v, source).to_string());
}
}
names
}
pub fn compose_cross_file_summaries(
root: Node<'_>,
source: &str,
aliases: Option<&AliasTable>,
rule_specs: &[(&str, TaintSpec)],
same_package_paths: &[PathBuf],
summaries: &CrossFileSummaryMap,
allowed_rule_ids: &HashSet<String>,
) -> Vec<FunctionTaintSummary> {
let cross_file = CrossFileInfo {
same_package_paths,
summaries,
allowed_rule_ids,
};
let mut out = Vec::new();
collect_function_defs(root, &mut |func_node| {
let Some(name_node) = func_node.child_by_field_name("name") else {
return;
};
let func_name = node_text(name_node, source).to_string();
let param_names = collect_param_names(func_node, source);
if let Some(summary) =
extract_cross_file_summary_for_function_cf::<PhpTaintAdapter, CrossFileInfo<'_>>(
func_node,
&func_name,
¶m_names,
source,
aliases,
rule_specs,
Some(&cross_file),
)
{
out.push(summary);
}
});
out
}
pub fn extract_cross_file_findings(
root: Node<'_>,
source: &str,
rule_specs: &[(&str, TaintSpec)],
cross_file: &CrossFileInfo<'_>,
) -> Vec<TaintFinding> {
let mut source_spec = TaintSpec::default();
for (_, spec) in rule_specs {
source_spec.sources.extend(spec.sources.iter().cloned());
source_spec
.sanitizers
.extend(spec.sanitizers.iter().cloned());
}
let empty_summary = ReturnSummary::new();
let ctx = AnalysisContext {
source,
spec: &source_spec,
aliases: None,
summaries: &empty_summary,
cross_file: Some(cross_file),
sink_to_rules: None,
label_policy: None,
};
let mut findings = Vec::new();
collect_function_defs(root, &mut |func_node| {
analyze_function_generic::<PhpTaintAdapter, CrossFileInfo<'_>>(
func_node,
&ctx,
&mut findings,
);
});
findings
}
fn handle_cross_file_call(
node: Node<'_>,
callee_name: &str,
ctx: &PhpCtx<'_>,
state: &TaintState,
findings: &mut Vec<TaintFinding>,
cross_file: &CrossFileInfo<'_>,
) {
if callee_name.is_empty() {
return;
}
let mut resolved: Option<&FunctionTaintSummary> = None;
for pkg_path in cross_file.same_package_paths {
if let Some(file_summaries) = cross_file.summaries.get(pkg_path) {
if let Some(summary) = file_summaries.iter().find(|s| s.name == callee_name) {
resolved = Some(summary);
break;
}
}
}
let Some(summary) = resolved else {
return;
};
let Some(args) = node.child_by_field_name("arguments") else {
return;
};
let mut cursor = args.walk();
let arg_nodes: Vec<Node<'_>> = args.named_children(&mut cursor).collect();
for flow in &summary.params_to_sink {
if !cross_file.allowed_rule_ids.contains(&flow.sink_rule_id) {
continue;
}
if flow.param_index >= arg_nodes.len() {
continue;
}
let arg = arg_nodes[flow.param_index];
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(cross_file_taint_finding(
node,
source_desc,
src_line,
&flow.sink_description,
callee_name,
&flow.sink_rule_id,
));
return;
}
}
}
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(),
},
]
}
pub fn php_taint_rule_specs() -> Vec<(&'static str, TaintSpec)> {
vec![
("php/taint-command-injection", command_injection_spec()),
("php/taint-sql-injection", sql_injection_spec()),
("php/taint-xss", xss_spec()),
("php/taint-file-inclusion", file_inclusion_spec()),
(
"php/taint-unsafe-deserialization",
unsafe_deserialization_spec(),
),
]
}
fn command_injection_spec() -> TaintSpec {
TaintSpec {
sources: php_taint_sources(),
sinks: vec![
NodeMatcher::Call {
canonical: "system".into(),
description: "system() with tainted argument (OS command injection)".into(),
},
NodeMatcher::Call {
canonical: "exec".into(),
description: "exec() with tainted argument (OS command injection)".into(),
},
NodeMatcher::Call {
canonical: "shell_exec".into(),
description: "shell_exec() with tainted argument (OS command injection)".into(),
},
NodeMatcher::Call {
canonical: "passthru".into(),
description: "passthru() with tainted argument (OS command injection)".into(),
},
NodeMatcher::Call {
canonical: "popen".into(),
description: "popen() with tainted argument (OS command injection)".into(),
},
NodeMatcher::Call {
canonical: "proc_open".into(),
description: "proc_open() with tainted argument (OS command injection)".into(),
},
],
sanitizers: php_taint_sanitizers(),
}
}
fn sql_injection_spec() -> TaintSpec {
TaintSpec {
sources: php_taint_sources(),
sinks: vec![
NodeMatcher::Call {
canonical: "mysqli_query".into(),
description: "mysqli_query() with tainted query (SQL injection)".into(),
},
NodeMatcher::Call {
canonical: "mysql_query".into(),
description: "mysql_query() with tainted query (SQL injection)".into(),
},
NodeMatcher::MethodName {
method: "query".into(),
description: "->query() with tainted query (SQL injection)".into(),
},
NodeMatcher::MethodName {
method: "exec".into(),
description: "->exec() with tainted query (SQL injection)".into(),
},
NodeMatcher::MethodName {
method: "prepare".into(),
description: "->prepare() with tainted query (SQL injection)".into(),
},
],
sanitizers: php_taint_sanitizers(),
}
}
fn xss_spec() -> TaintSpec {
TaintSpec {
sources: php_taint_sources(),
sinks: vec![
NodeMatcher::Call {
canonical: "echo".into(),
description: "echo of tainted value (reflected XSS)".into(),
},
NodeMatcher::Call {
canonical: "print".into(),
description: "print of tainted value (reflected XSS)".into(),
},
NodeMatcher::Call {
canonical: "printf".into(),
description: "printf() of tainted value (reflected XSS)".into(),
},
],
sanitizers: php_taint_sanitizers(),
}
}
fn file_inclusion_spec() -> TaintSpec {
TaintSpec {
sources: php_taint_sources(),
sinks: vec![
NodeMatcher::Call {
canonical: "include".into(),
description: "include of tainted path (local/remote file inclusion)".into(),
},
NodeMatcher::Call {
canonical: "require".into(),
description: "require of tainted path (local/remote file inclusion)".into(),
},
NodeMatcher::Call {
canonical: "include_once".into(),
description: "include_once of tainted path (local/remote file inclusion)".into(),
},
NodeMatcher::Call {
canonical: "require_once".into(),
description: "require_once of tainted path (local/remote file inclusion)".into(),
},
],
sanitizers: php_taint_sanitizers(),
}
}
fn unsafe_deserialization_spec() -> TaintSpec {
TaintSpec {
sources: php_taint_sources(),
sinks: vec![NodeMatcher::Call {
canonical: "unserialize".into(),
description: "unserialize() of tainted data (unsafe deserialization)".into(),
}],
sanitizers: vec![],
}
}
pub(super) struct PhpTaintAdapter;
impl<'a> TaintLanguageAdapter<CrossFileInfo<'a>> 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_subscript_key_sink(node, ctx, state, findings);
handle_assignment(node, ctx, state);
}
if node.kind() == "object_creation_expression" {
handle_tainted_callee(node, ctx, state, findings);
}
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() == "scoped_call_expression" {
handle_scoped_call(node, ctx, state, findings);
}
if node.kind() == "echo_statement" {
handle_echo(node, ctx, state, findings);
}
if node.kind() == "binary_expression" {
handle_loose_equality(node, ctx, state, findings);
}
if node.kind() == "print_intrinsic" {
handle_print(node, ctx, state, findings);
}
if matches!(
node.kind(),
"include_expression"
| "include_once_expression"
| "require_expression"
| "require_once_expression"
) {
handle_include_require(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);
}
if let Some(cross_file) = ctx.cross_file {
handle_cross_file_call(node, callee, ctx, state, findings, cross_file);
}
}
fn handle_scoped_call(
node: Node<'_>,
ctx: &PhpCtx<'_>,
state: &mut TaintState,
findings: &mut Vec<TaintFinding>,
) {
let Some(cross_file) = ctx.cross_file else {
return;
};
let method_name = node
.child_by_field_name("name")
.map(|n| node_text(n, ctx.source))
.unwrap_or("");
handle_cross_file_call(node, method_name, ctx, state, findings, cross_file);
}
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);
}
}
}
if let Some(cross_file) = ctx.cross_file {
handle_cross_file_call(node, method_name, ctx, state, findings, cross_file);
}
}
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 handle_loose_equality(
node: Node<'_>,
ctx: &PhpCtx<'_>,
state: &mut TaintState,
findings: &mut Vec<TaintFinding>,
) {
let sink_desc = ctx.spec.sinks.iter().find_map(|m| {
if let NodeMatcher::LooseEquality { description } = m {
Some(description.clone())
} else {
None
}
});
let Some(sink_desc) = sink_desc else {
return;
};
let mut is_loose = false;
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if matches!(child.kind(), "==" | "!=") {
is_loose = true;
break;
}
}
if !is_loose {
return;
}
for i in 0..2 {
if let Some(operand) = node.named_child(i) {
if let Some((source_desc, src_line)) = expression_taint(operand, ctx, state) {
findings.push(taint_finding_for_node(
node,
source_desc,
sink_desc.clone(),
src_line,
None,
1,
));
return;
}
}
}
}
fn handle_tainted_callee(
node: Node<'_>,
ctx: &PhpCtx<'_>,
state: &mut TaintState,
findings: &mut Vec<TaintFinding>,
) {
let sink_desc = ctx.spec.sinks.iter().find_map(|m| {
if let NodeMatcher::TaintedCallee { description } = m {
Some(description.clone())
} else {
None
}
});
let Some(sink_desc) = sink_desc else {
return;
};
let Some(class_name) = node.named_child(0) else {
return;
};
if matches!(class_name.kind(), "arguments" | "anonymous_class") {
return;
}
if let Some((source_desc, src_line)) = expression_taint(class_name, ctx, state) {
findings.push(taint_finding_for_node(
node,
source_desc,
sink_desc,
src_line,
None,
1,
));
}
}
fn handle_subscript_key_sink(
node: Node<'_>,
ctx: &PhpCtx<'_>,
state: &mut TaintState,
findings: &mut Vec<TaintFinding>,
) {
let sink = ctx.spec.sinks.iter().find_map(|m| {
if let NodeMatcher::TaintedSubscriptKey { base, description } = m {
Some((base.clone(), description.clone()))
} else {
None
}
});
let Some((want_base, sink_desc)) = sink else {
return;
};
let Some(left) = node.child_by_field_name("left") else {
return;
};
if left.kind() != "subscript_expression" {
return;
}
let Some(base_node) = left.named_child(0) else {
return;
};
if let Some(want) = want_base.as_deref() {
if base_node.kind() != "variable_name" {
return;
}
let base_name = node_text(base_node, ctx.source).trim_start_matches('$');
if base_name != want {
return;
}
}
let Some(key) = left.named_child(1) else {
return;
};
if let Some((source_desc, src_line)) = expression_taint(key, ctx, state) {
findings.push(taint_finding_for_node(
node,
source_desc,
sink_desc,
src_line,
None,
1,
));
}
}
fn handle_print(
node: Node<'_>,
ctx: &PhpCtx<'_>,
state: &mut TaintState,
findings: &mut Vec<TaintFinding>,
) {
let print_sink_desc = ctx.spec.sinks.iter().find_map(|m| {
if let NodeMatcher::Call {
canonical,
description,
} = m
{
if canonical == "print" {
Some(description.clone())
} else {
None
}
} else {
None
}
});
let Some(sink_desc) = print_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 handle_include_require(
node: Node<'_>,
ctx: &PhpCtx<'_>,
state: &mut TaintState,
findings: &mut Vec<TaintFinding>,
) {
let kind = node.kind();
let canonical = kind.strip_suffix("_expression").unwrap_or(kind);
let sink_desc = ctx.spec.sinks.iter().find_map(|m| {
if let NodeMatcher::Call {
canonical: c,
description,
} = m
{
if c.as_str() == canonical {
Some(description.clone())
} else {
None
}
} else {
None
}
});
let Some(sink_desc) = 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() == "parenthesized_expression" {
if let Some(inner) = expr.named_child(0) {
if let Some(result) = expression_taint(inner, ctx, state) {
return Some(result);
}
}
}
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 { .. }
| NodeMatcher::TypedName { .. }
| NodeMatcher::TypedAssignTarget { .. }
| NodeMatcher::LiteralString { .. }
| NodeMatcher::LooseEquality { .. }
| NodeMatcher::TaintedCallee { .. }
| NodeMatcher::TaintedSubscriptKey { .. }
| NodeMatcher::CallArgSource { .. }
| NodeMatcher::FirstParamSource { .. }
| NodeMatcher::DecoratedParamSource { .. }
| NodeMatcher::CallArgConcat { .. }
| NodeMatcher::ConstructorArgSink { .. }
| NodeMatcher::PropertyAssignSink { .. }
| NodeMatcher::MethodArgSink { .. }
| NodeMatcher::ReceiverProvenanceCall { .. }
| NodeMatcher::LiteralArgCall { .. } => {
}
}
}
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");
}
fn summaries(src: &str) -> Vec<FunctionTaintSummary> {
let tree = parse_file(src, Language::Php).expect("parse");
let specs = php_taint_rule_specs();
extract_cross_file_summaries(tree.root_node(), src, None, &specs)
}
#[test]
fn cross_file_summary_records_param_to_sink() {
let src = "<?php\nfunction run_cmd($arg) {\n system($arg);\n}\n";
let found = summaries(src);
let helper = found
.iter()
.find(|s| s.name == "run_cmd")
.expect("run_cmd should be summarized");
let flow = helper
.params_to_sink
.iter()
.find(|f| f.param_index == 0)
.expect("param 0 should reach a sink");
assert_eq!(flow.sink_rule_id, "php/taint-command-injection");
}
#[test]
fn cross_file_summary_skips_functions_with_no_flow() {
let src = "<?php\nfunction log_it($message) {\n echo \"constant\";\n}\n";
let found = summaries(src);
assert!(
found.iter().all(|s| s.name != "log_it"),
"function with no param flow should not be summarized: {found:?}"
);
}
#[test]
fn cross_file_findings_resolve_helper_in_sibling_summary() {
use std::collections::HashSet;
use std::path::PathBuf;
let caller = "<?php\nfunction handle() {\n $cmd = $_GET['cmd'];\n run_cmd($cmd);\n}\n";
let helper = "<?php\nfunction run_cmd($arg) {\n system($arg);\n}\n";
let helper_tree = parse_file(helper, Language::Php).expect("parse helper");
let specs = php_taint_rule_specs();
let helper_summaries =
extract_cross_file_summaries(helper_tree.root_node(), helper, None, &specs);
let helper_path = PathBuf::from("/pkg/helper.php");
let mut summary_map = CrossFileSummaryMap::new();
summary_map.insert(helper_path.clone(), helper_summaries);
let same_package = vec![helper_path];
let allowed: HashSet<String> = specs.iter().map(|(id, _)| id.to_string()).collect();
let cross = CrossFileInfo {
same_package_paths: &same_package,
summaries: &summary_map,
allowed_rule_ids: &allowed,
};
let caller_tree = parse_file(caller, Language::Php).expect("parse caller");
let findings = extract_cross_file_findings(caller_tree.root_node(), caller, &specs, &cross);
assert_eq!(
findings.len(),
1,
"tainted arg into run_cmd must produce one cross-file finding: {findings:?}"
);
assert_eq!(
findings[0].rule_id_hint.as_deref(),
Some("php/taint-command-injection")
);
assert!(findings[0]
.sink_description
.contains("via cross-file call to run_cmd"));
}
#[test]
fn cross_file_findings_clean_arg_does_not_fire() {
use std::collections::HashSet;
use std::path::PathBuf;
let caller = "<?php\nfunction handle() {\n run_cmd('ls -la');\n}\n";
let helper = "<?php\nfunction run_cmd($arg) {\n system($arg);\n}\n";
let helper_tree = parse_file(helper, Language::Php).expect("parse helper");
let specs = php_taint_rule_specs();
let helper_summaries =
extract_cross_file_summaries(helper_tree.root_node(), helper, None, &specs);
let helper_path = PathBuf::from("/pkg/helper.php");
let mut summary_map = CrossFileSummaryMap::new();
summary_map.insert(helper_path.clone(), helper_summaries);
let same_package = vec![helper_path];
let allowed: HashSet<String> = specs.iter().map(|(id, _)| id.to_string()).collect();
let cross = CrossFileInfo {
same_package_paths: &same_package,
summaries: &summary_map,
allowed_rule_ids: &allowed,
};
let caller_tree = parse_file(caller, Language::Php).expect("parse caller");
let findings = extract_cross_file_findings(caller_tree.root_node(), caller, &specs, &cross);
assert!(
findings.is_empty(),
"clean literal argument must not fire cross-file: {findings:?}"
);
}
#[test]
fn compose_lifts_forwarded_param_to_cross_file_sink() {
let sink_src = "<?php\nfunction run_cmd($arg) {\n system($arg);\n}\n";
let middle_src = "<?php\nfunction forward($term) {\n run_cmd($term);\n}\n";
let specs = php_taint_rule_specs();
let sink_path = PathBuf::from("command_helper.php");
let mut map = CrossFileSummaryMap::new();
map.insert(sink_path.clone(), summaries(sink_src));
assert!(
summaries(middle_src)
.iter()
.find(|s| s.name == "forward")
.is_none_or(|s| s.params_to_sink.is_empty()),
"base summary of forward must not record a sink flow"
);
let mid_tree = parse_file(middle_src, Language::Php).expect("parse mid");
let allowed: HashSet<String> = specs.iter().map(|(id, _)| id.to_string()).collect();
let composed = compose_cross_file_summaries(
mid_tree.root_node(),
middle_src,
None,
&specs,
std::slice::from_ref(&sink_path),
&map,
&allowed,
);
let forward = composed
.iter()
.find(|s| s.name == "forward")
.expect("forward should gain a composed summary");
assert!(
forward
.params_to_sink
.iter()
.any(|f| f.param_index == 0 && f.sink_rule_id == "php/taint-command-injection"),
"param 0 should reach the cross-file sink: {forward:?}"
);
}
#[test]
fn compose_is_taint_sensitive_across_the_hop() {
let sink_src = "<?php\nfunction run_cmd($arg) {\n system($arg);\n}\n";
let middle_src =
"<?php\nfunction forward($term) {\n $safe = \"constant\";\n run_cmd($safe);\n}\n";
let specs = php_taint_rule_specs();
let sink_path = PathBuf::from("command_helper.php");
let mut map = CrossFileSummaryMap::new();
map.insert(sink_path.clone(), summaries(sink_src));
let mid_tree = parse_file(middle_src, Language::Php).expect("parse mid");
let allowed: HashSet<String> = specs.iter().map(|(id, _)| id.to_string()).collect();
let composed = compose_cross_file_summaries(
mid_tree.root_node(),
middle_src,
None,
&specs,
std::slice::from_ref(&sink_path),
&map,
&allowed,
);
assert!(
composed.iter().all(|s| s.params_to_sink.is_empty()),
"a clean (constant) argument must not compose a sink flow: {composed:?}"
);
}
}