use crate::rules::common::{walk_tree, AliasTable};
use crate::rules::cross_file::{CrossFileSummaryMap, FunctionTaintSummary, ParamSinkFlow};
use crate::rules::taint_engine::{
cross_file_taint_finding, walk_scope_nodes as walk_taint_scope_nodes,
};
pub use crate::rules::taint_engine::{
LabelPolicy, NodeMatcher, Propagator, TaintFinding, TaintSpec,
};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::path::PathBuf;
use tree_sitter::Node;
#[derive(Clone, Debug)]
struct TaintInfo {
description: String,
line: usize,
hops: u8,
labels: Option<BTreeSet<String>>,
}
#[derive(Default)]
struct TaintState {
tainted: HashMap<String, TaintInfo>,
}
impl TaintState {
fn taint(&mut self, name: String, info: TaintInfo) {
self.tainted.insert(name, info);
}
fn clear(&mut self, name: &str) {
self.tainted.remove(name);
}
fn info(&self, name: &str) -> Option<&TaintInfo> {
self.tainted.get(name)
}
}
pub fn analyze_tree(
root: Node<'_>,
source: &str,
spec: &TaintSpec,
aliases: Option<&AliasTable>,
) -> Vec<TaintFinding> {
analyze_tree_with_propagators(root, source, spec, aliases, &[])
}
pub fn analyze_tree_with_propagators(
root: Node<'_>,
source: &str,
spec: &TaintSpec,
aliases: Option<&AliasTable>,
propagators: &[Propagator],
) -> Vec<TaintFinding> {
analyze_tree_labeled(root, source, spec, aliases, propagators, None)
}
pub fn analyze_tree_labeled(
root: Node<'_>,
source: &str,
spec: &TaintSpec,
_aliases: Option<&AliasTable>,
propagators: &[Propagator],
policy: Option<&LabelPolicy>,
) -> Vec<TaintFinding> {
let mut findings = Vec::new();
walk_tree(root, source, &mut |node, src| {
if is_scope_node(node.kind()) {
analyze_scope(node, src, spec, propagators, policy, &mut findings);
}
});
findings
}
pub fn java_taint_rule_specs() -> Vec<(&'static str, TaintSpec)> {
vec![
("java/taint-sql-injection", sql_injection_spec()),
("java/taint-command-injection", command_injection_spec()),
("java/taint-ssrf", ssrf_spec()),
(
"java/taint-unsafe-deserialization",
unsafe_deserialization_spec(),
),
]
}
pub fn java_taint_sources() -> Vec<NodeMatcher> {
let mut sources = Vec::new();
for receiver in ["request", "req"] {
for method in [
"getParameter",
"getParameterMap",
"getParameterValues",
"getHeader",
"getHeaders",
"getQueryString",
"getInputStream",
"getReader",
"getCookies",
] {
sources.push(NodeMatcher::Call {
canonical: format!("{receiver}.{method}"),
description: format!("HttpServletRequest.{method}()"),
});
}
}
sources.push(NodeMatcher::Call {
canonical: "System.getenv".into(),
description: "System.getenv()".into(),
});
sources.push(NodeMatcher::ParamName {
names: vec![
"@RequestParam".into(),
"@RequestBody".into(),
"@PathVariable".into(),
"@RequestHeader".into(),
"@CookieValue".into(),
"@ModelAttribute".into(),
],
description: "Spring MVC annotated parameter".into(),
});
sources
}
fn sql_injection_spec() -> TaintSpec {
TaintSpec {
sources: java_taint_sources(),
sinks: vec![
NodeMatcher::MethodName {
method: "executeQuery".into(),
description: "executeQuery() with tainted query".into(),
},
NodeMatcher::MethodName {
method: "execute".into(),
description: "execute() with tainted query".into(),
},
NodeMatcher::MethodName {
method: "createQuery".into(),
description: "createQuery() with tainted query".into(),
},
NodeMatcher::MethodName {
method: "createNativeQuery".into(),
description: "createNativeQuery() with tainted query".into(),
},
NodeMatcher::MethodName {
method: "prepareStatement".into(),
description: "prepareStatement() with tainted query".into(),
},
],
sanitizers: vec![],
}
}
fn command_injection_spec() -> TaintSpec {
TaintSpec {
sources: java_taint_sources(),
sinks: vec![
NodeMatcher::Call {
canonical: "Runtime.exec".into(),
description: "Runtime.exec() with tainted argument".into(),
},
NodeMatcher::Call {
canonical: "ProcessBuilder".into(),
description: "ProcessBuilder() with tainted argument".into(),
},
],
sanitizers: vec![],
}
}
fn ssrf_spec() -> TaintSpec {
TaintSpec {
sources: java_taint_sources(),
sinks: vec![
NodeMatcher::Call {
canonical: "URL".into(),
description: "URL() with tainted target".into(),
},
NodeMatcher::Call {
canonical: "URI".into(),
description: "URI() with tainted target".into(),
},
NodeMatcher::MethodName {
method: "getForObject".into(),
description: "RestTemplate.getForObject() with tainted URL".into(),
},
NodeMatcher::MethodName {
method: "getForEntity".into(),
description: "RestTemplate.getForEntity() with tainted URL".into(),
},
NodeMatcher::MethodName {
method: "postForObject".into(),
description: "RestTemplate.postForObject() with tainted URL".into(),
},
NodeMatcher::MethodName {
method: "postForEntity".into(),
description: "RestTemplate.postForEntity() with tainted URL".into(),
},
NodeMatcher::MethodName {
method: "exchange".into(),
description: "RestTemplate.exchange() with tainted URL".into(),
},
],
sanitizers: vec![],
}
}
fn unsafe_deserialization_spec() -> TaintSpec {
TaintSpec {
sources: java_taint_sources(),
sinks: vec![
NodeMatcher::Call {
canonical: "ObjectInputStream".into(),
description: "ObjectInputStream() with tainted stream".into(),
},
NodeMatcher::Call {
canonical: "XMLDecoder".into(),
description: "XMLDecoder() with tainted stream".into(),
},
NodeMatcher::MethodName {
method: "load".into(),
description: "Yaml.load() with tainted data".into(),
},
],
sanitizers: vec![],
}
}
pub fn extract_cross_file_summaries(
root: Node<'_>,
source: &str,
_aliases: Option<&AliasTable>,
rule_specs: &[(&str, TaintSpec)],
) -> Vec<FunctionTaintSummary> {
let mut summaries = Vec::new();
walk_tree(root, source, &mut |node, src| {
if node.kind() != "method_declaration" {
return;
}
let Some(method_name) = node
.child_by_field_name("name")
.map(|n| node_text(n, src).to_string())
else {
return;
};
let param_names = method_param_names(node, src);
if let Some(summary) =
summarize_java_method(node, &method_name, ¶m_names, src, rule_specs)
{
summaries.push(summary);
}
});
summaries
}
fn method_param_names(scope_node: Node<'_>, source: &str) -> Vec<String> {
scope_parameter_nodes(scope_node)
.into_iter()
.filter_map(|node| formal_parameter_name(node, source).map(|s| s.to_string()))
.collect()
}
fn summarize_java_method(
method_node: Node<'_>,
method_name: &str,
param_names: &[String],
source: &str,
rule_specs: &[(&str, TaintSpec)],
) -> Option<FunctionTaintSummary> {
if param_names.is_empty() {
return None;
}
let mut params_to_sink: Vec<ParamSinkFlow> = Vec::new();
let mut params_to_return: Vec<usize> = Vec::new();
for (param_idx, param_name) in param_names.iter().enumerate() {
if java_param_flows_to_return(method_node, param_name, source) {
params_to_return.push(param_idx);
}
for (rule_id, rule_spec) in rule_specs {
let synthetic = TaintSpec {
sources: vec![NodeMatcher::ParamName {
names: vec![param_name.clone()],
description: format!("parameter '{param_name}'"),
}],
sinks: rule_spec.sinks.clone(),
sanitizers: rule_spec.sanitizers.clone(),
};
let mut findings = Vec::new();
analyze_scope(method_node, source, &synthetic, &[], None, &mut findings);
if let Some(finding) = findings.first() {
params_to_sink.push(ParamSinkFlow {
param_index: param_idx,
sink_rule_id: rule_id.to_string(),
sink_description: finding.sink_description.clone(),
});
}
}
}
if params_to_sink.is_empty() && params_to_return.is_empty() {
return None;
}
Some(FunctionTaintSummary {
name: method_name.to_string(),
params_to_return,
params_to_sink,
})
}
fn java_param_flows_to_return(method_node: Node<'_>, param_name: &str, source: &str) -> bool {
let synthetic = TaintSpec {
sources: vec![NodeMatcher::ParamName {
names: vec![param_name.to_string()],
description: format!("parameter '{param_name}'"),
}],
sinks: vec![],
sanitizers: vec![],
};
let body = find_scope_body(method_node).unwrap_or(method_node);
let mut state = TaintState::default();
collect_param_sources(method_node, source, &synthetic, None, &mut state);
for _ in 0..3 {
propagate_assignments(body, source, &synthetic, None, &mut state);
}
let mut flows = false;
walk_scope_nodes(body, source, &mut |node, src| {
if flows || node.kind() != "return_statement" {
return;
}
if let Some(expr) = node.named_child(0) {
if expression_taint(expr, src, &synthetic, None, &state).is_some() {
flows = true;
}
}
});
flows
}
pub struct CrossFileInfo<'a> {
pub same_package_paths: &'a [PathBuf],
pub summaries: &'a CrossFileSummaryMap,
pub allowed_rule_ids: &'a HashSet<String>,
}
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 mut out = Vec::new();
walk_tree(root, source, &mut |node, src| {
if is_scope_node(node.kind()) {
resolve_cross_file_scope(node, src, &source_spec, cross_file, &mut out);
}
});
out
}
fn resolve_cross_file_scope(
scope_node: Node<'_>,
source: &str,
source_spec: &TaintSpec,
cross_file: &CrossFileInfo<'_>,
out: &mut Vec<TaintFinding>,
) {
let body = find_scope_body(scope_node).unwrap_or(scope_node);
let mut state = TaintState::default();
collect_param_sources(scope_node, source, source_spec, None, &mut state);
for _ in 0..3 {
propagate_assignments(body, source, source_spec, None, &mut state);
}
walk_scope_nodes(body, source, &mut |node, src| {
if node.kind() != "method_invocation" {
return;
}
let Some(method_name) = call_method_name(node, src) else {
return;
};
let Some(summary) = lookup_cross_file_summary(cross_file, method_name) 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];
if let Some(info) = expression_taint(arg, src, source_spec, None, &state) {
out.push(cross_file_taint_finding(
node,
info.description,
info.line,
&flow.sink_description,
method_name,
&flow.sink_rule_id,
));
}
}
});
}
fn lookup_cross_file_summary<'a>(
cross_file: &'a CrossFileInfo<'_>,
method_name: &str,
) -> Option<&'a FunctionTaintSummary> {
for path in cross_file.same_package_paths {
if let Some(file_summaries) = cross_file.summaries.get(path) {
if let Some(summary) = file_summaries.iter().find(|s| s.name == method_name) {
return Some(summary);
}
}
}
None
}
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();
walk_tree(root, source, &mut |node, src| {
if node.kind() != "method_declaration" {
return;
}
let Some(method_name) = node
.child_by_field_name("name")
.map(|n| node_text(n, src).to_string())
else {
return;
};
let param_names = method_param_names(node, src);
if let Some(summary) = compose_java_method(
node,
&method_name,
¶m_names,
src,
rule_specs,
&cross_file,
) {
out.push(summary);
}
});
out
}
fn compose_java_method(
method_node: Node<'_>,
method_name: &str,
param_names: &[String],
source: &str,
rule_specs: &[(&str, TaintSpec)],
cross_file: &CrossFileInfo<'_>,
) -> Option<FunctionTaintSummary> {
if param_names.is_empty() {
return None;
}
let body = find_scope_body(method_node).unwrap_or(method_node);
let mut sanitizers = Vec::new();
for (_, rule_spec) in rule_specs {
sanitizers.extend(rule_spec.sanitizers.iter().cloned());
}
let mut params_to_sink: Vec<ParamSinkFlow> = Vec::new();
for (param_idx, param_name) in param_names.iter().enumerate() {
let synthetic = TaintSpec {
sources: vec![NodeMatcher::ParamName {
names: vec![param_name.clone()],
description: format!("parameter '{param_name}'"),
}],
sinks: vec![],
sanitizers: sanitizers.clone(),
};
let mut state = TaintState::default();
collect_param_sources(method_node, source, &synthetic, None, &mut state);
for _ in 0..3 {
propagate_assignments(body, source, &synthetic, None, &mut state);
}
walk_scope_nodes(body, source, &mut |node, src| {
if node.kind() != "method_invocation" {
return;
}
let Some(callee) = call_method_name(node, src) else {
return;
};
let Some(summary) = lookup_cross_file_summary(cross_file, callee) 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];
if expression_taint(arg, src, &synthetic, None, &state).is_none() {
continue;
}
let dup = params_to_sink
.iter()
.any(|f| f.param_index == param_idx && f.sink_rule_id == flow.sink_rule_id);
if !dup {
params_to_sink.push(ParamSinkFlow {
param_index: param_idx,
sink_rule_id: flow.sink_rule_id.clone(),
sink_description: flow.sink_description.clone(),
});
}
}
});
}
if params_to_sink.is_empty() {
return None;
}
Some(FunctionTaintSummary {
name: method_name.to_string(),
params_to_return: Vec::new(),
params_to_sink,
})
}
fn analyze_scope(
scope_node: Node<'_>,
source: &str,
spec: &TaintSpec,
propagators: &[Propagator],
policy: Option<&LabelPolicy>,
out: &mut Vec<TaintFinding>,
) {
let body = find_scope_body(scope_node).unwrap_or(scope_node);
let mut state = TaintState::default();
collect_param_sources(scope_node, source, spec, policy, &mut state);
for _ in 0..3 {
propagate_assignments(body, source, spec, policy, &mut state);
apply_propagators(body, source, spec, propagators, policy, &mut state);
}
find_sinks(body, source, spec, policy, &state, out);
}
fn apply_propagators(
scope: Node<'_>,
source: &str,
spec: &TaintSpec,
propagators: &[Propagator],
policy: Option<&LabelPolicy>,
state: &mut TaintState,
) {
if propagators.is_empty() {
return;
}
let mut pending: Vec<(String, TaintInfo)> = Vec::new();
walk_scope_nodes(scope, source, &mut |node, src| {
if node.kind() != "method_invocation" {
return;
}
let Some(recv) = node.child_by_field_name("object") else {
return;
};
if recv.kind() != "identifier" {
return;
}
let Some(method) = call_method_name(node, src) else {
return;
};
let method_matches = propagators
.iter()
.any(|p| p.method.as_deref().is_none_or(|m| m == method));
if !method_matches {
return;
}
let recv_name = node_text(recv, src);
if state.info(recv_name).is_some() {
return;
}
if let Some(info) = sink_argument_taint(node, src, spec, policy, state) {
let info = relabel_through(node, src, info, policy);
pending.push((recv_name.to_string(), bump_hops(info)));
}
});
for (name, info) in pending {
state.taint(name, info);
}
}
fn collect_param_sources(
scope_node: Node<'_>,
source: &str,
spec: &TaintSpec,
policy: Option<&LabelPolicy>,
state: &mut TaintState,
) {
let mut annotation_names: Vec<&str> = Vec::new();
let mut bare_names: Vec<&str> = Vec::new();
let mut wildcard = false;
for matcher in &spec.sources {
if let NodeMatcher::ParamName { names, .. } = matcher {
for name in names {
if let Some(rest) = name.strip_prefix('@') {
annotation_names.push(rest);
} else if name == crate::rules::taint_engine::ANY_PARAM_WILDCARD {
wildcard = true;
} else {
bare_names.push(name.as_str());
}
}
}
}
for node in scope_parameter_nodes(scope_node) {
let Some(name) = formal_parameter_name(node, source) else {
continue;
};
let text = node_text(node, source);
let matched_annotation = annotation_names
.iter()
.find(|annotation| text.contains(&format!("@{annotation}")));
let typed_desc = formal_parameter_type(node, source)
.and_then(|ty| typed_source_description(spec, ty).map(|d| (d, name)));
if let Some(annotation) = matched_annotation {
state.taint(
name.to_string(),
TaintInfo {
description: format!("@{annotation} parameter '{name}'"),
line: node.start_position().row + 1,
hops: 0,
labels: source_labels(policy),
},
);
} else if let Some((description, name)) = typed_desc {
state.taint(
name.to_string(),
TaintInfo {
description,
line: node.start_position().row + 1,
hops: 0,
labels: source_labels(policy),
},
);
} else if bare_names.contains(&name) || wildcard {
state.taint(
name.to_string(),
TaintInfo {
description: format!("parameter '{name}'"),
line: node.start_position().row + 1,
hops: 0,
labels: source_labels(policy),
},
);
}
}
}
fn propagate_assignments(
scope: Node<'_>,
source: &str,
spec: &TaintSpec,
policy: Option<&LabelPolicy>,
state: &mut TaintState,
) {
walk_scope_nodes(scope, source, &mut |node, src| {
if node.kind() == "variable_declarator" {
let Some(name_node) = node.child_by_field_name("name") else {
return;
};
let name = node_text(name_node, src).to_string();
let Some(value) = node.child_by_field_name("value") else {
return;
};
match expression_taint(value, src, spec, policy, state) {
Some(info) => state.taint(name, bump_hops(info)),
None => {
match local_declarator_type(node, src)
.and_then(|ty| typed_source_description(spec, ty))
{
Some(description) => state.taint(
name,
TaintInfo {
description,
line: node.start_position().row + 1,
hops: 0,
labels: source_labels(policy),
},
),
None => state.clear(&name),
}
}
}
}
if node.kind() == "enhanced_for_statement" {
if let (Some(name_node), Some(iterable)) = (
node.child_by_field_name("name"),
node.child_by_field_name("value"),
) {
let name = node_text(name_node, src).to_string();
match expression_taint(iterable, src, spec, policy, state) {
Some(info) => state.taint(name, bump_hops(info)),
None => state.clear(&name),
}
}
}
if node.kind() == "assignment_expression" {
let Some(left) = node.child_by_field_name("left") else {
return;
};
let Some(name) = assignment_target_name(left, src) else {
return;
};
let Some(right) = node.child_by_field_name("right") else {
return;
};
match expression_taint(right, src, spec, policy, state) {
Some(info) => {
let info = if assignment_is_concat(node, src) {
apply_relabel(info, policy)
} else {
info
};
state.taint(name.to_string(), bump_hops(info));
}
None => state.clear(name),
}
}
});
}
fn find_sinks(
scope: Node<'_>,
source: &str,
spec: &TaintSpec,
policy: Option<&LabelPolicy>,
state: &TaintState,
out: &mut Vec<TaintFinding>,
) {
let has_typed_assign = spec
.sinks
.iter()
.any(|m| matches!(m, NodeMatcher::TypedAssignTarget { .. }));
let local_types = if has_typed_assign {
collect_local_var_types(scope, source)
} else {
HashMap::new()
};
let has_method_arg_sink = spec
.sinks
.iter()
.any(|m| matches!(m, NodeMatcher::MethodArgSink { .. }));
walk_scope_nodes(scope, source, &mut |node, src| {
if has_typed_assign {
check_typed_assign_sink(node, src, spec, policy, state, &local_types, out);
}
if has_method_arg_sink {
check_method_arg_sinks(node, src, spec, policy, state, out);
}
if node.kind() != "method_invocation" && node.kind() != "object_creation_expression" {
return;
}
let Some(sink_description) = match_sink(node, src, spec) else {
return;
};
let taint = sink_argument_taint(node, src, spec, policy, state)
.or_else(|| read_object_receiver_taint(node, src, state));
if let Some(info) = taint {
if let Some(p) = policy {
let labels = info.labels.clone().unwrap_or_default();
if !p.sink_requires.eval(&labels) {
return;
}
}
out.push(taint_finding_for_node(node, info, sink_description));
}
});
}
fn check_typed_assign_sink(
node: Node<'_>,
source: &str,
spec: &TaintSpec,
policy: Option<&LabelPolicy>,
state: &TaintState,
local_types: &HashMap<String, String>,
out: &mut Vec<TaintFinding>,
) {
let (decl_type, rhs) = match node.kind() {
"variable_declarator" => {
let Some(value) = node.child_by_field_name("value") else {
return;
};
let Some(ty) = local_declarator_type(node, source) else {
return;
};
(ty.to_string(), value)
}
"assignment_expression" => {
let Some(left) = node.child_by_field_name("left") else {
return;
};
let Some(name) = assignment_target_name(left, source) else {
return;
};
let Some(ty) = local_types.get(name) else {
return;
};
let Some(right) = node.child_by_field_name("right") else {
return;
};
(ty.clone(), right)
}
_ => return,
};
let Some(sink_description) = typed_assign_sink_description(spec, &decl_type) else {
return;
};
let Some(info) = expression_taint(rhs, source, spec, policy, state) else {
return;
};
if let Some(p) = policy {
let labels = info.labels.clone().unwrap_or_default();
if !p.sink_requires.eval(&labels) {
return;
}
}
out.push(taint_finding_for_node(node, info, sink_description));
}
fn check_method_arg_sinks(
node: Node<'_>,
source: &str,
spec: &TaintSpec,
policy: Option<&LabelPolicy>,
state: &TaintState,
out: &mut Vec<TaintFinding>,
) {
if node.kind() != "method_invocation" {
return;
}
let Some(method) = call_method_name(node, source) 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 matcher in &spec.sinks {
let NodeMatcher::MethodArgSink {
methods,
arg_index,
description,
} = matcher
else {
continue;
};
if !methods.iter().any(|m| m == method) {
continue;
}
let Some(arg) = arg_nodes.get(*arg_index) else {
continue;
};
let Some(info) = expression_taint(*arg, source, spec, policy, state) else {
continue;
};
if let Some(p) = policy {
let labels = info.labels.clone().unwrap_or_default();
if !p.sink_requires.eval(&labels) {
continue;
}
}
out.push(taint_finding_for_node(node, info, description.clone()));
}
}
fn collect_local_var_types(scope: Node<'_>, source: &str) -> HashMap<String, String> {
let mut types = HashMap::new();
walk_scope_nodes(scope, source, &mut |node, src| {
if node.kind() != "local_variable_declaration" {
return;
}
let Some(ty) = node.child_by_field_name("type").map(|t| node_text(t, src)) else {
return;
};
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() != "variable_declarator" {
continue;
}
if let Some(name) = child.child_by_field_name("name") {
types.insert(node_text(name, src).to_string(), ty.to_string());
}
}
});
types
}
fn typed_assign_sink_description(spec: &TaintSpec, decl_type: &str) -> Option<String> {
let seg = type_final_segment(decl_type);
spec.sinks.iter().find_map(|matcher| match matcher {
NodeMatcher::TypedAssignTarget {
type_name,
description,
} if type_name == seg => Some(description.clone()),
_ => None,
})
}
fn expression_taint(
node: Node<'_>,
source: &str,
spec: &TaintSpec,
policy: Option<&LabelPolicy>,
state: &TaintState,
) -> Option<TaintInfo> {
let info = expression_taint_core(node, source, spec, policy, state)?;
Some(relabel_through(node, source, info, policy))
}
fn expression_taint_core(
node: Node<'_>,
source: &str,
spec: &TaintSpec,
policy: Option<&LabelPolicy>,
state: &TaintState,
) -> Option<TaintInfo> {
let text = node_text(node, source);
if let Some(info) = state.info(text) {
return Some(info.clone());
}
if node.kind() == "identifier" {
return state.info(text).cloned();
}
if is_sanitizer_call(node, source, spec) {
return None;
}
if let Some(description) = classify_source_expr(node, source, spec) {
return Some(TaintInfo {
description,
line: node.start_position().row + 1,
hops: 0,
labels: source_labels(policy),
});
}
if node.kind() == "method_invocation" {
if let Some(receiver) = node.child_by_field_name("object") {
if let Some(info) = expression_taint(receiver, source, spec, policy, state) {
return Some(bump_hops(info));
}
}
}
if let Some(args) = call_arguments(node) {
if let Some(info) = expression_taint(args, source, spec, policy, state) {
return Some(bump_hops(info));
}
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if let Some(info) = expression_taint(child, source, spec, policy, state) {
return Some(info);
}
}
None
}
fn source_labels(policy: Option<&LabelPolicy>) -> Option<BTreeSet<String>> {
policy.map(|p| {
let mut set = BTreeSet::new();
set.insert(p.source_label.clone());
set
})
}
fn apply_relabel(mut info: TaintInfo, policy: Option<&LabelPolicy>) -> TaintInfo {
if let Some(p) = policy {
let additions: Vec<String> = p
.relabels
.iter()
.filter(|r| info.labels.as_ref().is_some_and(|ls| ls.contains(&r.from)))
.map(|r| r.to.clone())
.collect();
if !additions.is_empty() {
let set = info.labels.get_or_insert_with(BTreeSet::new);
for a in additions {
set.insert(a);
}
}
}
info
}
fn relabel_through(
node: Node<'_>,
source: &str,
info: TaintInfo,
policy: Option<&LabelPolicy>,
) -> TaintInfo {
if policy.is_some() && is_string_building_node(node, source) {
apply_relabel(info, policy)
} else {
info
}
}
fn is_string_building_node(node: Node<'_>, source: &str) -> bool {
match node.kind() {
"binary_expression" => {
node.child_by_field_name("operator")
.map(|op| node_text(op, source))
== Some("+")
}
"method_invocation" => matches!(
call_method_name(node, source),
Some("format" | "join" | "concat" | "append")
),
"object_creation_expression" => object_creation_type(node, source)
.map(type_final_segment)
.is_some_and(|ty| ty == "StringBuilder" || ty == "StringBuffer"),
_ => false,
}
}
fn assignment_is_concat(node: Node<'_>, source: &str) -> bool {
node.child_by_field_name("operator")
.map(|op| node_text(op, source))
== Some("+=")
}
fn classify_source_expr(node: Node<'_>, source: &str, spec: &TaintSpec) -> Option<String> {
if node.kind() != "method_invocation" {
return None;
}
let method = call_method_name(node, source)?;
let receiver = call_receiver_text(node, source)?;
for matcher in &spec.sources {
if let NodeMatcher::ReceiverProvenanceCall {
init_receiver,
init_method,
init_arg,
method: call_method,
description,
} = matcher
{
if method == call_method
&& receiver_has_init_provenance(
node,
receiver,
init_receiver,
init_method,
init_arg,
source,
)
{
return Some(description.clone());
}
}
}
spec.sources.iter().find_map(|matcher| {
if let NodeMatcher::Call {
canonical,
description,
} = matcher
{
if match_java_method_canonical(canonical, receiver, method) {
return Some(description.clone());
}
}
None
})
}
fn enclosing_scope(node: Node<'_>) -> Option<Node<'_>> {
let mut current = node.parent();
while let Some(n) = current {
if is_scope_node(n.kind()) {
return Some(n);
}
current = n.parent();
}
None
}
fn receiver_has_init_provenance(
node: Node<'_>,
receiver: &str,
init_receiver: &str,
init_method: &str,
init_arg: &str,
source: &str,
) -> bool {
if !is_plain_identifier(receiver) {
return false;
}
let Some(scope) = enclosing_scope(node) else {
return false;
};
let body = find_scope_body(scope).unwrap_or(scope);
let mut found = false;
walk_scope_nodes(body, source, &mut |n, src| {
if found || n.kind() != "variable_declarator" {
return;
}
let Some(name_node) = n.child_by_field_name("name") else {
return;
};
if node_text(name_node, src) != receiver {
return;
}
let Some(value) = n.child_by_field_name("value") else {
return;
};
if init_call_matches(value, init_receiver, init_method, init_arg, src) {
found = true;
}
});
found
}
fn init_call_matches(
node: Node<'_>,
init_receiver: &str,
init_method: &str,
init_arg: &str,
source: &str,
) -> bool {
if node.kind() != "method_invocation" {
return false;
}
if call_method_name(node, source) != Some(init_method) {
return false;
}
let Some(receiver) = call_receiver_text(node, source) else {
return false;
};
if type_final_segment(receiver) != init_receiver {
return false;
}
let Some(args) = node.child_by_field_name("arguments") else {
return false;
};
let mut cursor = args.walk();
for arg in args.named_children(&mut cursor) {
if arg.kind() == "string_literal" {
let text = node_text(arg, source);
let inner = text
.strip_prefix('"')
.and_then(|t| t.strip_suffix('"'))
.unwrap_or(text);
return inner == init_arg;
}
}
false
}
fn is_plain_identifier(s: &str) -> bool {
!s.is_empty()
&& !s.contains('.')
&& !s.contains('(')
&& !s.contains('[')
&& s.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == '$')
}
fn is_sanitizer_call(node: Node<'_>, source: &str, spec: &TaintSpec) -> bool {
spec.sanitizers
.iter()
.any(|matcher| matcher_matches_call(matcher, node, source))
}
fn match_sink(node: Node<'_>, source: &str, spec: &TaintSpec) -> Option<String> {
spec.sinks.iter().find_map(|matcher| {
if matcher_matches_call(matcher, node, source) {
Some(matcher.description().to_string())
} else {
None
}
})
}
fn matcher_matches_call(matcher: &NodeMatcher, node: Node<'_>, source: &str) -> bool {
match matcher {
NodeMatcher::MethodName { method, .. } => {
node.kind() == "method_invocation"
&& call_method_name(node, source).is_some_and(|actual| actual == method)
}
NodeMatcher::Call { canonical, .. } if node.kind() == "method_invocation" => {
let Some(method) = call_method_name(node, source) else {
return false;
};
let Some(receiver) = call_receiver_text(node, source) else {
return false;
};
match_java_method_canonical(canonical, receiver, method)
}
NodeMatcher::Call { canonical, .. } if node.kind() == "object_creation_expression" => {
object_creation_type(node, source).is_some_and(|actual| actual == canonical)
}
NodeMatcher::MethodNameRegex { regex, .. } => {
node.kind() == "method_invocation"
&& call_method_name(node, source).is_some_and(|m| regex.is_match(m))
}
NodeMatcher::CallRegex { regex, .. } if node.kind() == "method_invocation" => {
call_full_callee_text(node, source).is_some_and(|c| regex.is_match(&c))
}
NodeMatcher::ReceiverCall { receiver, .. } if node.kind() == "method_invocation" => {
call_receiver_text(node, source)
.is_some_and(|r| r.split('.').next().unwrap_or(r) == receiver)
}
_ => false,
}
}
fn call_full_callee_text(node: Node<'_>, source: &str) -> Option<String> {
let method = call_method_name(node, source)?;
match call_receiver_text(node, source) {
Some(recv) => Some(format!("{recv}.{method}")),
None => Some(method.to_string()),
}
}
fn match_java_method_canonical(canonical: &str, receiver: &str, method: &str) -> bool {
let Some((expected_receiver, expected_method)) = canonical.rsplit_once('.') else {
return false;
};
if method != expected_method {
return false;
}
let receiver_lower = receiver.to_ascii_lowercase();
let expected_lower = expected_receiver.to_ascii_lowercase();
receiver_lower.contains(&expected_lower)
|| (expected_lower == "request" && receiver_lower.contains("request"))
|| (expected_lower == "runtime" && receiver_lower.contains("runtime"))
}
fn sink_argument_taint(
node: Node<'_>,
source: &str,
spec: &TaintSpec,
policy: Option<&LabelPolicy>,
state: &TaintState,
) -> Option<TaintInfo> {
call_arguments(node).and_then(|args| expression_taint(args, source, spec, policy, state))
}
fn read_object_receiver_taint(
node: Node<'_>,
source: &str,
state: &TaintState,
) -> Option<TaintInfo> {
if node.kind() != "method_invocation" {
return None;
}
if call_method_name(node, source) != Some("readObject") {
return None;
}
let receiver = node.child_by_field_name("object")?;
expression_taint_without_sources(receiver, source, state)
}
fn expression_taint_without_sources(
node: Node<'_>,
source: &str,
state: &TaintState,
) -> Option<TaintInfo> {
let text = node_text(node, source);
if let Some(info) = state.info(text) {
return Some(info.clone());
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if let Some(info) = expression_taint_without_sources(child, source, state) {
return Some(info);
}
}
None
}
fn bump_hops(mut info: TaintInfo) -> TaintInfo {
info.hops = info.hops.saturating_add(1);
info
}
fn taint_finding_for_node(
node: Node<'_>,
source_info: TaintInfo,
sink_description: String,
) -> TaintFinding {
let start = node.start_position();
let end = node.end_position();
TaintFinding {
sink_start_byte: node.start_byte(),
sink_end_byte: node.end_byte(),
sink_line: start.row + 1,
sink_column: start.column + 1,
sink_end_line: end.row + 1,
sink_end_column: end.column + 1,
source_description: source_info.description,
sink_description,
source_line: source_info.line,
source_range: None,
rule_id_hint: None,
hops: source_info.hops.max(1),
}
}
fn walk_scope_nodes(scope: Node<'_>, source: &str, visitor: &mut impl FnMut(Node<'_>, &str)) {
walk_taint_scope_nodes(scope, source, is_scope_node, visitor);
}
fn is_scope_node(kind: &str) -> bool {
matches!(
kind,
"method_declaration" | "constructor_declaration" | "lambda_expression"
)
}
fn scope_parameter_nodes<'a>(scope_node: Node<'a>) -> Vec<Node<'a>> {
if let Some(parameters) = scope_node.child_by_field_name("parameters") {
return direct_formal_parameters(parameters);
}
let mut params = Vec::new();
let mut cursor = scope_node.walk();
for child in scope_node.children(&mut cursor) {
match child.kind() {
"formal_parameter" | "spread_parameter" => params.push(child),
"formal_parameters" | "inferred_parameters" => {
params.extend(direct_formal_parameters(child));
}
_ => {}
}
}
params
}
fn direct_formal_parameters<'a>(parameters: Node<'a>) -> Vec<Node<'a>> {
let mut params = Vec::new();
let mut cursor = parameters.walk();
for child in parameters.children(&mut cursor) {
match child.kind() {
"formal_parameter" | "spread_parameter" => params.push(child),
"formal_parameters" | "inferred_parameters" => {
params.extend(direct_formal_parameters(child));
}
_ => {}
}
}
params
}
fn find_scope_body(node: Node<'_>) -> Option<Node<'_>> {
node.child_by_field_name("body").or_else(|| {
let mut cursor = node.walk();
let body = node
.children(&mut cursor)
.find(|child| child.kind() == "block");
body
})
}
fn call_method_name<'a>(node: Node<'a>, source: &'a str) -> Option<&'a str> {
if node.kind() != "method_invocation" {
return None;
}
node.child_by_field_name("name")
.map(|name| node_text(name, source))
}
fn call_receiver_text<'a>(node: Node<'a>, source: &'a str) -> Option<&'a str> {
if node.kind() != "method_invocation" {
return None;
}
node.child_by_field_name("object")
.map(|object| node_text(object, source))
}
fn object_creation_type<'a>(node: Node<'a>, source: &'a str) -> Option<&'a str> {
if node.kind() != "object_creation_expression" {
return None;
}
node.child_by_field_name("type")
.map(|ty| node_text(ty, source))
}
fn call_arguments(node: Node<'_>) -> Option<Node<'_>> {
if node.kind() != "method_invocation" && node.kind() != "object_creation_expression" {
return None;
}
node.child_by_field_name("arguments")
}
fn assignment_target_name<'a>(node: Node<'a>, source: &'a str) -> Option<&'a str> {
match node.kind() {
"identifier" | "field_access" => Some(node_text(node, source)),
_ => None,
}
}
fn formal_parameter_name<'a>(node: Node<'a>, source: &'a str) -> Option<&'a str> {
if let Some(name) = node.child_by_field_name("name") {
return Some(node_text(name, source));
}
let mut last_identifier = None;
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "identifier" {
last_identifier = Some(node_text(child, source));
}
}
last_identifier
}
fn formal_parameter_type<'a>(node: Node<'a>, source: &'a str) -> Option<&'a str> {
node.child_by_field_name("type")
.map(|ty| node_text(ty, source))
}
fn local_declarator_type<'a>(declarator: Node<'a>, source: &'a str) -> Option<&'a str> {
let parent = declarator.parent()?;
if parent.kind() != "local_variable_declaration" {
return None;
}
parent
.child_by_field_name("type")
.map(|ty| node_text(ty, source))
}
fn typed_source_description(spec: &TaintSpec, decl_type: &str) -> Option<String> {
let seg = type_final_segment(decl_type);
spec.sources.iter().find_map(|matcher| match matcher {
NodeMatcher::TypedName {
type_name,
description,
} if type_name == seg => Some(description.clone()),
_ => None,
})
}
fn type_final_segment(type_text: &str) -> &str {
let mut base = type_text.trim();
if base.ends_with('>') {
if let Some(lt) = base.find('<') {
base = base[..lt].trim_end();
}
}
while let Some(stripped) = base.strip_suffix("[]") {
base = stripped.trim_end();
}
base.rsplit('.').next().unwrap_or(base).trim()
}
fn node_text<'a>(node: Node<'_>, source: &'a str) -> &'a str {
&source[node.byte_range()]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::parser::parse_file;
use crate::Language;
fn analyze(src: &str, spec: &TaintSpec) -> Vec<TaintFinding> {
let Some(tree) = parse_file(src, Language::Java) else {
panic!("Java fixture should parse");
};
analyze_tree(tree.root_node(), src, spec, None)
}
#[test]
fn sql_injection_via_spring_request_param() {
let src = r#"
class Controller {
void find(@RequestParam String name, Statement stmt) throws Exception {
String query = "SELECT * FROM users WHERE name = '" + name + "'";
stmt.executeQuery(query);
}
}
"#;
let findings = analyze(src, &sql_injection_spec());
assert!(
!findings.is_empty(),
"should detect Spring param into SQL: {findings:?}"
);
}
#[test]
fn command_injection_via_servlet_request() {
let src = r#"
class Controller {
void run(HttpServletRequest request) throws Exception {
String cmd = request.getParameter("cmd");
Runtime.getRuntime().exec(cmd);
}
}
"#;
let findings = analyze(src, &command_injection_spec());
assert!(
!findings.is_empty(),
"should detect servlet request into Runtime.exec: {findings:?}"
);
}
#[test]
fn ssrf_transitive_flow() {
let src = r#"
class Controller {
void fetch(HttpServletRequest req) throws Exception {
String raw = req.getParameter("url");
String target = "https://internal/" + raw;
new URL(target);
}
}
"#;
let findings = analyze(src, &ssrf_spec());
assert!(
!findings.is_empty(),
"should detect transitive servlet SSRF: {findings:?}"
);
}
#[test]
fn unsafe_deserialization_constructor_sink() {
let src = r#"
class Controller {
void load(HttpServletRequest request) throws Exception {
InputStream input = request.getInputStream();
ObjectInputStream ois = new ObjectInputStream(input);
ois.readObject();
}
}
"#;
let findings = analyze(src, &unsafe_deserialization_spec());
assert!(
findings
.iter()
.any(|finding| finding.sink_description.contains("ObjectInputStream")),
"should detect tainted ObjectInputStream constructor: {findings:?}"
);
}
#[test]
fn clean_literal_no_command_finding() {
let src = r#"
class Controller {
void run(HttpServletRequest request) throws Exception {
String cmd = request.getParameter("cmd");
Runtime.getRuntime().exec("id");
}
}
"#;
let findings = analyze(src, &command_injection_spec());
assert!(
findings.is_empty(),
"literal command should not trigger taint: {findings:?}"
);
}
fn summaries(src: &str) -> Vec<FunctionTaintSummary> {
let Some(tree) = parse_file(src, Language::Java) else {
panic!("Java fixture should parse");
};
let specs = java_taint_rule_specs();
extract_cross_file_summaries(tree.root_node(), src, None, &specs)
}
#[test]
fn cross_file_summary_records_param_to_sink() {
let src = r#"
class QueryHelper {
static Statement stmt;
public static void runQuery(String term) throws Exception {
stmt.executeQuery("SELECT * FROM users WHERE name = '" + term + "'");
}
}
"#;
let found = summaries(src);
let helper = found
.iter()
.find(|s| s.name == "runQuery")
.expect("runQuery 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, "java/taint-sql-injection");
}
#[test]
fn cross_file_summary_records_param_to_return() {
let src = r#"
class Passthrough {
public static String clean(String value) {
return value.trim();
}
}
"#;
let found = summaries(src);
let helper = found
.iter()
.find(|s| s.name == "clean")
.expect("clean should be summarized");
assert!(
helper.params_to_return.contains(&0),
"param 0 should flow to the return value: {helper:?}"
);
}
#[test]
fn cross_file_summary_skips_methods_with_no_flow() {
let src = r#"
class Plain {
public static void log(String message) {
System.out.println("constant");
}
}
"#;
let found = summaries(src);
assert!(
found.iter().all(|s| s.name != "log"),
"method with no param flow should not be summarized: {found:?}"
);
}
#[test]
fn compose_lifts_forwarded_param_to_cross_file_sink() {
let sink_src = r#"
class QueryHelper {
static Statement stmt;
static void runQuery(String term) throws Exception {
stmt.executeQuery("SELECT * FROM users WHERE name = '" + term + "'");
}
}
"#;
let middle_src = r#"
class Service {
static void process(String term) throws Exception {
QueryHelper.runQuery(term);
}
}
"#;
let sink_path = PathBuf::from("QueryHelper.java");
let mut map = CrossFileSummaryMap::new();
map.insert(sink_path.clone(), summaries(sink_src));
assert!(
summaries(middle_src)
.iter()
.find(|s| s.name == "process")
.is_none_or(|s| s.params_to_sink.is_empty()),
"base summary of process must not record a sink flow"
);
let tree = parse_file(middle_src, Language::Java).expect("parse middle");
let specs = java_taint_rule_specs();
let allowed: HashSet<String> = specs.iter().map(|(id, _)| id.to_string()).collect();
let composed = compose_cross_file_summaries(
tree.root_node(),
middle_src,
None,
&specs,
std::slice::from_ref(&sink_path),
&map,
&allowed,
);
let process = composed
.iter()
.find(|s| s.name == "process")
.expect("process should gain a composed summary");
let flow = process
.params_to_sink
.iter()
.find(|f| f.param_index == 0)
.expect("param 0 should reach the cross-file sink");
assert_eq!(flow.sink_rule_id, "java/taint-sql-injection");
}
#[test]
fn compose_is_taint_sensitive_across_the_hop() {
let sink_src = r#"
class QueryHelper {
static Statement stmt;
static void runQuery(String term) throws Exception {
stmt.executeQuery("SELECT * FROM users WHERE name = '" + term + "'");
}
}
"#;
let middle_src = r#"
class Service {
static void process(String term) throws Exception {
String safe = "constant";
QueryHelper.runQuery(safe);
}
}
"#;
let sink_path = PathBuf::from("QueryHelper.java");
let mut map = CrossFileSummaryMap::new();
map.insert(sink_path.clone(), summaries(sink_src));
let tree = parse_file(middle_src, Language::Java).expect("parse middle");
let specs = java_taint_rule_specs();
let allowed: HashSet<String> = specs.iter().map(|(id, _)| id.to_string()).collect();
let composed = compose_cross_file_summaries(
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:?}"
);
}
#[test]
fn nested_lambda_parameter_does_not_taint_parent_scope() {
let src = r#"
class Controller {
private String name = "https://example.com";
void fetch() throws Exception {
Function<String, String> normalize = (@RequestParam String name) -> name.trim();
new URL(name);
}
}
"#;
let findings = analyze(src, &ssrf_spec());
assert!(
findings.is_empty(),
"nested lambda parameter should not taint parent scope: {findings:?}"
);
}
}