use crate::rules::common::{walk_tree, AliasTable};
use crate::rules::cross_file::{CrossFileSummaryMap, FunctionTaintSummary, ParamSinkFlow};
use crate::rules::taint_engine::cross_file_taint_finding;
pub use crate::rules::taint_engine::{NodeMatcher, TaintFinding, TaintSpec};
use std::collections::HashSet;
use std::path::PathBuf;
use tree_sitter::Node;
pub fn analyze_tree(
root: Node<'_>,
source: &str,
spec: &TaintSpec,
_aliases: Option<&AliasTable>,
) -> Vec<TaintFinding> {
let mut findings = Vec::new();
walk_tree(root, source, &mut |node, src| {
if node.kind() == "function_declaration" || node.kind() == "lambda_literal" {
analyze_scope(node, src, spec, &mut findings);
}
});
findings
}
pub fn kotlin_taint_rule_specs() -> Vec<(&'static str, TaintSpec)> {
vec![
("kt/taint-sql-injection", sql_injection_spec()),
("kt/taint-command-injection", command_injection_spec()),
("kt/taint-ssrf", ssrf_spec()),
]
}
pub fn kotlin_taint_sources() -> Vec<NodeMatcher> {
vec![
NodeMatcher::Call {
canonical: "call.receiveText".into(),
description: "call.receiveText()".into(),
},
NodeMatcher::Call {
canonical: "call.receive".into(),
description: "call.receive()".into(),
},
NodeMatcher::Call {
canonical: "request.header".into(),
description: "request.header()".into(),
},
NodeMatcher::Call {
canonical: "request.queryParameter".into(),
description: "request.queryParameter()".into(),
},
NodeMatcher::ParamName {
names: vec![
"@RequestParam".into(),
"@RequestBody".into(),
"@PathVariable".into(),
"@RequestHeader".into(),
],
description: "Spring annotation parameter".into(),
},
]
}
fn sql_injection_spec() -> TaintSpec {
TaintSpec {
sources: kotlin_taint_sources(),
sinks: vec![
NodeMatcher::MethodName {
method: "executeQuery".into(),
description: "executeQuery() with tainted argument".into(),
},
NodeMatcher::MethodName {
method: "execute".into(),
description: "execute() with tainted argument".into(),
},
NodeMatcher::MethodName {
method: "createQuery".into(),
description: "createQuery() with tainted argument".into(),
},
NodeMatcher::MethodName {
method: "createNativeQuery".into(),
description: "createNativeQuery() with tainted argument".into(),
},
NodeMatcher::MethodName {
method: "rawQuery".into(),
description: "rawQuery() with tainted argument".into(),
},
NodeMatcher::MethodName {
method: "execSQL".into(),
description: "execSQL() with tainted argument".into(),
},
NodeMatcher::MethodName {
method: "prepareStatement".into(),
description: "prepareStatement() with tainted argument".into(),
},
],
sanitizers: vec![],
}
}
fn command_injection_spec() -> TaintSpec {
TaintSpec {
sources: kotlin_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: kotlin_taint_sources(),
sinks: vec![
NodeMatcher::Call {
canonical: "URL".into(),
description: "URL() with tainted argument".into(),
},
NodeMatcher::Call {
canonical: "URI".into(),
description: "URI() with tainted argument".into(),
},
NodeMatcher::MethodName {
method: "getForObject".into(),
description: "getForObject() with tainted URL".into(),
},
NodeMatcher::MethodName {
method: "getForEntity".into(),
description: "getForEntity() with tainted URL".into(),
},
NodeMatcher::MethodName {
method: "postForObject".into(),
description: "postForObject() with tainted URL".into(),
},
NodeMatcher::MethodName {
method: "postForEntity".into(),
description: "postForEntity() with tainted URL".into(),
},
NodeMatcher::MethodName {
method: "exchange".into(),
description: "exchange() with tainted URL".into(),
},
],
sanitizers: vec![],
}
}
struct TaintSource {
var_name: Option<String>,
description: String,
line: usize,
}
struct TaintSink {
start_byte: usize,
end_byte: usize,
description: String,
}
fn analyze_scope(
scope_node: Node<'_>,
source: &str,
spec: &TaintSpec,
out: &mut Vec<TaintFinding>,
) {
let body = find_function_body(scope_node).unwrap_or(scope_node);
let mut sources = collect_body_sources(body, source, spec);
if matches!(scope_node.kind(), "function_declaration") {
collect_param_sources(scope_node, source, spec, &mut sources);
}
if sources.is_empty() {
return;
}
let tainted = build_tainted_set(body, source, &sources);
if tainted.is_empty() {
return;
}
let sinks = find_sinks(body, source, spec, &tainted);
if sinks.is_empty() {
return;
}
let (source_desc, source_line) = sources
.first()
.map(|s| (s.description.clone(), s.line))
.unwrap_or_else(|| ("user input".to_string(), 0));
for sink in sinks {
let start = byte_to_position(source, sink.start_byte);
let end = byte_to_position(source, sink.end_byte);
out.push(TaintFinding {
sink_start_byte: sink.start_byte,
sink_end_byte: sink.end_byte,
sink_line: start.0,
sink_column: start.1,
sink_end_line: end.0,
sink_end_column: end.1,
source_description: source_desc.clone(),
sink_description: sink.description,
source_line,
source_range: None,
rule_id_hint: None,
hops: 0,
});
}
}
fn collect_body_sources(scope: Node<'_>, source: &str, spec: &TaintSpec) -> Vec<TaintSource> {
let mut sources = Vec::new();
walk_tree(scope, source, &mut |n, s| {
if n.kind() == "property_declaration" {
let var = extract_property_var_name(n, s);
let initializer = extract_property_initializer(n);
if let (Some(var_name), Some(init)) = (var, initializer) {
if let Some(desc) = classify_source_expr(init, s, spec) {
sources.push(TaintSource {
var_name: Some(var_name),
description: desc,
line: n.start_position().row + 1,
});
}
}
}
if n.kind() == "assignment" {
if let (Some(left), Some(right)) =
(n.child(0), n.child(n.child_count().saturating_sub(1)))
{
if left.kind() == "simple_identifier" {
let left_text = &s[left.byte_range()];
if let Some(desc) = classify_source_expr(right, s, spec) {
sources.push(TaintSource {
var_name: Some(left_text.to_string()),
description: desc,
line: n.start_position().row + 1,
});
}
}
}
}
});
sources
}
fn classify_source_expr(node: Node<'_>, src: &str, spec: &TaintSpec) -> Option<String> {
if node.kind() == "call_expression" {
if let Some(method) = call_method_name(node, src) {
if let Some(receiver) = call_receiver_text(node, src) {
for matcher in &spec.sources {
if let NodeMatcher::Call { canonical, .. } = matcher {
if let Some(desc) = match_kotlin_call_canonical(canonical, receiver, method)
{
return Some(desc);
}
}
}
}
}
}
if is_indexing_source(node, src) {
return Some(src[node.byte_range()].to_string());
}
None
}
fn is_indexing_source(node: Node<'_>, src: &str) -> bool {
let text = &src[node.byte_range()];
(node.kind() == "indexing_expression"
|| text.contains("queryParameters[")
|| text.contains("parameters["))
&& (text.contains("request") || text.contains("call"))
&& (text.contains("queryParameters")
|| text.contains("parameters[")
|| text.contains("header"))
}
fn match_kotlin_call_canonical(canonical: &str, receiver: &str, method: &str) -> Option<String> {
if let Some((expected_recv, expected_method)) = canonical.split_once('.') {
if method == expected_method && receiver.contains(expected_recv) {
return Some(format!("{}.{}()", receiver, method));
}
}
None
}
fn match_kotlin_sink_call(
canonical: &str,
receiver: Option<&str>,
method: Option<&str>,
ctor_name: Option<&str>,
) -> bool {
if let Some((expected_recv, expected_method)) = canonical.split_once('.') {
if let (Some(recv), Some(m)) = (receiver, method) {
return m == expected_method && recv.contains(expected_recv);
}
return false;
}
if let Some(ctor) = ctor_name {
return ctor == canonical;
}
false
}
fn collect_param_sources(
func_node: Node<'_>,
source: &str,
spec: &TaintSpec,
out: &mut Vec<TaintSource>,
) {
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());
}
}
}
}
let mut cursor = func_node.walk();
for child in func_node.children(&mut cursor) {
if child.kind() != "function_value_parameters" {
continue;
}
let mut c2 = child.walk();
let children: Vec<_> = child.children(&mut c2).collect();
let mut pending_annotation: Option<&str> = None;
for ch in &children {
if ch.kind() == "parameter_modifiers" {
let mod_text = &source[ch.byte_range()];
for ann in &annotation_names {
if mod_text.contains(ann) {
pending_annotation = Some(ann);
break;
}
}
} else if ch.kind() == "parameter" {
let mut c3 = ch.walk();
let mut param_name: Option<&str> = None;
for pc in ch.children(&mut c3) {
if pc.kind() == "simple_identifier" {
param_name = Some(&source[pc.byte_range()]);
break;
}
}
if let Some(name) = param_name {
if let Some(ann) = pending_annotation.take() {
out.push(TaintSource {
var_name: Some(name.to_string()),
description: format!("@{} parameter '{}'", ann, name),
line: ch.start_position().row + 1,
});
} else if bare_names.contains(&name) || wildcard {
out.push(TaintSource {
var_name: Some(name.to_string()),
description: format!("parameter '{}'", name),
line: ch.start_position().row + 1,
});
}
}
pending_annotation = None;
} else if ch.kind() != "," && ch.kind() != "(" && ch.kind() != ")" {
pending_annotation = None;
}
}
}
}
fn build_tainted_set(scope: Node<'_>, source: &str, sources: &[TaintSource]) -> HashSet<String> {
let mut tainted: HashSet<String> = HashSet::new();
for s in sources {
if let Some(ref name) = s.var_name {
tainted.insert(name.clone());
}
}
if tainted.is_empty() {
return tainted;
}
for _ in 0..2 {
walk_tree(scope, source, &mut |n, s| {
if n.kind() == "property_declaration" {
let var = extract_property_var_name(n, s);
let init = extract_property_initializer(n);
if let (Some(var_name), Some(init_node)) = (var, init) {
if !tainted.contains(&var_name) && expr_uses_tainted(init_node, s, &tainted) {
tainted.insert(var_name);
}
}
}
if n.kind() == "assignment" {
if let (Some(left), Some(right)) =
(n.child(0), n.child(n.child_count().saturating_sub(1)))
{
if left.kind() == "simple_identifier" {
let left_text = s[left.byte_range()].to_string();
if !tainted.contains(&left_text) && expr_uses_tainted(right, s, &tainted) {
tainted.insert(left_text);
}
}
}
}
});
}
tainted
}
fn expr_uses_tainted(node: Node<'_>, src: &str, tainted: &HashSet<String>) -> bool {
if node.kind() == "simple_identifier" {
let name = &src[node.byte_range()];
return tainted.contains(name);
}
if node.kind() == "string_literal" {
let text = &src[node.byte_range()];
for t in tainted {
if text.contains(&format!("${{{}}}", t)) || text.contains(&format!("${}", t)) {
return true;
}
}
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if expr_uses_tainted(child, src, tainted) {
return true;
}
}
false
}
fn find_sinks(
scope: Node<'_>,
source: &str,
spec: &TaintSpec,
tainted: &HashSet<String>,
) -> Vec<TaintSink> {
let mut sinks = Vec::new();
walk_tree(scope, source, &mut |n, s| {
if n.kind() != "call_expression" {
return;
}
let method = call_method_name(n, s);
let receiver = call_receiver_text(n, s);
let ctor = call_constructor_name(n, s);
let Some(args) = call_arguments(n) else {
return;
};
if matches!(method, Some("setString" | "setInt" | "setObject")) {
return;
}
if method == Some("prepareStatement") {
let first_tainted = first_argument(args)
.map(|first| expr_uses_tainted(first, s, tainted))
.unwrap_or(false);
if first_tainted {
sinks.push(TaintSink {
start_byte: n.start_byte(),
end_byte: n.end_byte(),
description: "prepareStatement() with tainted argument".into(),
});
}
return;
}
if !expr_uses_tainted(args, s, tainted) {
return;
}
for matcher in &spec.sinks {
match matcher {
NodeMatcher::MethodName {
method: expected,
description,
} if method == Some(expected.as_str()) => {
sinks.push(TaintSink {
start_byte: n.start_byte(),
end_byte: n.end_byte(),
description: description.clone(),
});
return;
}
NodeMatcher::Call {
canonical,
description,
} if match_kotlin_sink_call(canonical, receiver, method, ctor) => {
sinks.push(TaintSink {
start_byte: n.start_byte(),
end_byte: n.end_byte(),
description: description.clone(),
});
return;
}
_ => {}
}
}
});
sinks
}
fn call_method_name<'a>(node: Node<'a>, src: &'a str) -> Option<&'a str> {
if node.kind() != "call_expression" {
return None;
}
let callee = node.child(0)?;
if callee.kind() == "navigation_expression" {
let nav_suffix = callee.child(callee.child_count().checked_sub(1)?)?;
if nav_suffix.kind() == "navigation_suffix" {
let mut cursor = nav_suffix.walk();
for child in nav_suffix.children(&mut cursor) {
if child.kind() == "simple_identifier" {
return Some(&src[child.byte_range()]);
}
}
}
}
None
}
fn call_receiver_text<'a>(node: Node<'a>, src: &'a str) -> Option<&'a str> {
if node.kind() != "call_expression" {
return None;
}
let callee = node.child(0)?;
if callee.kind() == "navigation_expression" {
let receiver = callee.child(0)?;
return Some(&src[receiver.byte_range()]);
}
None
}
fn call_constructor_name<'a>(node: Node<'a>, src: &'a str) -> Option<&'a str> {
if node.kind() != "call_expression" {
return None;
}
let callee = node.child(0)?;
if callee.kind() == "simple_identifier" {
return Some(&src[callee.byte_range()]);
}
None
}
fn call_arguments(node: Node<'_>) -> Option<Node<'_>> {
if node.kind() != "call_expression" {
return None;
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "call_suffix" {
let mut c2 = child.walk();
for grandchild in child.children(&mut c2) {
if grandchild.kind() == "value_arguments" {
return Some(grandchild);
}
}
}
}
None
}
fn first_argument(args_node: Node<'_>) -> Option<Node<'_>> {
let mut cursor = args_node.walk();
for child in args_node.children(&mut cursor) {
if child.kind() == "value_argument" {
return child.child(0);
}
}
None
}
fn extract_property_var_name(node: Node<'_>, src: &str) -> Option<String> {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "variable_declaration" {
let mut c2 = child.walk();
for gc in child.children(&mut c2) {
if gc.kind() == "simple_identifier" {
return Some(src[gc.byte_range()].to_string());
}
}
}
}
None
}
fn extract_property_initializer(node: Node<'_>) -> Option<Node<'_>> {
if node.child_count() < 3 {
return None;
}
let mut found_eq = false;
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if found_eq && child.kind() != "=" {
return Some(child);
}
if child.kind() == "=" {
found_eq = true;
}
}
None
}
fn find_function_body(func_node: Node<'_>) -> Option<Node<'_>> {
let mut cursor = func_node.walk();
let body = func_node
.children(&mut cursor)
.find(|child| child.kind() == "function_body");
body
}
fn byte_to_position(source: &str, byte: usize) -> (usize, usize) {
let byte = byte.min(source.len());
let line = source[..byte].bytes().filter(|b| *b == b'\n').count() + 1;
let line_start = source[..byte].rfind('\n').map_or(0, |idx| idx + 1);
let column = source[line_start..byte].chars().count() + 1;
(line, column)
}
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() != "function_declaration" {
return;
}
let Some(name) = kotlin_function_name(node, src) else {
return;
};
let param_names = kotlin_function_param_names(node, src);
if let Some(summary) = summarize_kotlin_function(node, name, ¶m_names, src, rule_specs)
{
summaries.push(summary);
}
});
summaries
}
fn kotlin_function_name<'a>(node: Node<'a>, src: &'a str) -> Option<&'a str> {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "simple_identifier" {
return Some(&src[child.byte_range()]);
}
}
None
}
fn kotlin_function_param_names(node: Node<'_>, source: &str) -> Vec<String> {
let mut names = Vec::new();
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() != "function_value_parameters" {
continue;
}
let mut c2 = child.walk();
for param in child.children(&mut c2) {
if param.kind() != "parameter" {
continue;
}
let mut c3 = param.walk();
for pc in param.children(&mut c3) {
if pc.kind() == "simple_identifier" {
names.push(source[pc.byte_range()].to_string());
break;
}
}
}
}
names
}
fn summarize_kotlin_function(
func_node: Node<'_>,
func_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 kotlin_param_flows_to_return(func_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(func_node, source, &synthetic, &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: func_name.to_string(),
params_to_return,
params_to_sink,
})
}
fn kotlin_param_flows_to_return(func_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_function_body(func_node).unwrap_or(func_node);
let mut sources = collect_body_sources(body, source, &synthetic);
collect_param_sources(func_node, source, &synthetic, &mut sources);
let tainted = build_tainted_set(body, source, &sources);
if tainted.is_empty() {
return false;
}
let mut flows = false;
walk_tree(body, source, &mut |node, src| {
if flows || node.kind() != "jump_expression" {
return;
}
if src[node.byte_range()].trim_start().starts_with("return")
&& expr_uses_tainted(node, src, &tainted)
{
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 node.kind() == "function_declaration" || node.kind() == "lambda_literal" {
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_function_body(scope_node).unwrap_or(scope_node);
let mut sources = collect_body_sources(body, source, source_spec);
if matches!(scope_node.kind(), "function_declaration") {
collect_param_sources(scope_node, source, source_spec, &mut sources);
}
let tainted = build_tainted_set(body, source, &sources);
walk_tree(body, source, &mut |node, src| {
if node.kind() != "call_expression" {
return;
}
let Some(callee) = call_callee_name(node, src) else {
return;
};
let Some(summary) = lookup_cross_file_summary(cross_file, callee) else {
return;
};
let Some(args) = call_arguments(node) else {
return;
};
let arg_nodes: Vec<Node<'_>> = {
let mut cursor = args.walk();
let mut v = Vec::new();
for child in args.children(&mut cursor) {
if child.kind() == "value_argument" {
if let Some(expr) = child.child(0) {
v.push(expr);
}
}
}
v
};
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((desc, line)) = caller_arg_taint(arg, src, &sources, &tainted, source_spec)
{
out.push(cross_file_taint_finding(
node,
desc,
line,
&flow.sink_description,
callee,
&flow.sink_rule_id,
));
}
}
});
}
fn call_callee_name<'a>(node: Node<'a>, src: &'a str) -> Option<&'a str> {
call_method_name(node, src).or_else(|| call_constructor_name(node, src))
}
fn caller_arg_taint(
arg: Node<'_>,
src: &str,
sources: &[TaintSource],
tainted: &HashSet<String>,
source_spec: &TaintSpec,
) -> Option<(String, usize)> {
if let Some(desc) = classify_source_expr(arg, src, source_spec) {
return Some((desc, arg.start_position().row + 1));
}
if !tainted.is_empty() && expr_uses_tainted(arg, src, tainted) {
if let Some(s) = sources.first() {
return Some((s.description.clone(), s.line));
}
return Some(("user input".to_string(), arg.start_position().row + 1));
}
None
}
fn lookup_cross_file_summary<'a>(
cross_file: &'a CrossFileInfo<'_>,
callee_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 == callee_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() != "function_declaration" {
return;
}
let Some(name) = kotlin_function_name(node, src) else {
return;
};
let param_names = kotlin_function_param_names(node, src);
if let Some(summary) =
compose_kotlin_function(node, name, ¶m_names, src, rule_specs, &cross_file)
{
out.push(summary);
}
});
out
}
fn compose_kotlin_function(
func_node: Node<'_>,
func_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_function_body(func_node).unwrap_or(func_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 sources = collect_body_sources(body, source, &synthetic);
collect_param_sources(func_node, source, &synthetic, &mut sources);
let tainted = build_tainted_set(body, source, &sources);
if tainted.is_empty() {
continue;
}
walk_tree(body, source, &mut |node, src| {
if node.kind() != "call_expression" {
return;
}
let Some(callee) = call_callee_name(node, src) else {
return;
};
let Some(summary) = lookup_cross_file_summary(cross_file, callee) else {
return;
};
let Some(args) = call_arguments(node) else {
return;
};
let arg_nodes: Vec<Node<'_>> = {
let mut cursor = args.walk();
let mut v = Vec::new();
for child in args.children(&mut cursor) {
if child.kind() == "value_argument" {
if let Some(expr) = child.child(0) {
v.push(expr);
}
}
}
v
};
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 caller_arg_taint(arg, src, &sources, &tainted, &synthetic).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: func_name.to_string(),
params_to_return: Vec::new(),
params_to_sink,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::parser::parse_file;
use crate::Language;
fn analyze(src: &str, spec: &TaintSpec) -> Vec<TaintFinding> {
let tree = parse_file(src, Language::Kotlin).expect("parse");
analyze_tree(tree.root_node(), src, spec, None)
}
#[test]
fn sql_injection_via_ktor_receive() {
let src = r#"
fun Application.module() {
routing {
post("/users") {
val body = call.receiveText()
val query = "SELECT * FROM users WHERE name = '" + body + "'"
db.executeQuery(query)
}
}
}
"#;
let findings = analyze(src, &sql_injection_spec());
assert!(
!findings.is_empty(),
"should detect tainted SQL via call.receiveText(): {:?}",
findings
);
}
#[test]
fn command_injection_via_request_param() {
let src = r#"
@PostMapping("/exec")
fun execute(@RequestBody input: String) {
val args = input.split(" ")
ProcessBuilder(args).start()
}
"#;
let findings = analyze(src, &command_injection_spec());
assert!(
!findings.is_empty(),
"should detect tainted ProcessBuilder via @RequestBody: {:?}",
findings
);
}
#[test]
fn ssrf_transitive_flow() {
let src = r#"
fun Application.module() {
routing {
post("/fetch") {
val body = call.receiveText()
val target = body
val endpoint = "https://internal/" + target
val url = URL(endpoint)
}
}
}
"#;
let findings = analyze(src, &ssrf_spec());
assert!(
!findings.is_empty(),
"should detect SSRF via transitive flow: {:?}",
findings
);
}
#[test]
fn clean_literal_no_finding() {
let src = r#"
fun handler(call: ApplicationCall) {
val data = call.receiveText()
Runtime.getRuntime().exec("ls -la")
}
"#;
let findings = analyze(src, &command_injection_spec());
assert!(
findings.is_empty(),
"literal command should not trigger taint: {:?}",
findings
);
}
#[test]
fn prepared_statement_with_binding_is_clean() {
let src = r#"
fun handler(call: ApplicationCall, conn: Connection) {
val id = call.request.queryParameters["id"]
val stmt = conn.prepareStatement("SELECT * FROM users WHERE id = ?")
stmt.setString(1, id)
}
"#;
let findings = analyze(src, &sql_injection_spec());
assert!(
findings.is_empty(),
"parameterized prepared statement should not flag: {:?}",
findings
);
}
#[test]
fn prepared_statement_with_tainted_sql_flags() {
let src = r#"
fun handler(call: ApplicationCall, conn: Connection) {
val id = call.request.queryParameters["id"]
val stmt = conn.prepareStatement("SELECT * FROM users WHERE id = " + id)
}
"#;
let findings = analyze(src, &sql_injection_spec());
assert!(
!findings.is_empty(),
"tainted SQL in prepareStatement should flag: {:?}",
findings
);
}
fn summaries(src: &str) -> Vec<FunctionTaintSummary> {
let tree = parse_file(src, Language::Kotlin).expect("parse");
let specs = kotlin_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#"
object CommandHelper {
fun run(term: String) {
Runtime.getRuntime().exec(term)
}
}
"#;
let found = summaries(src);
let helper = found
.iter()
.find(|s| s.name == "run")
.expect("run 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, "kt/taint-command-injection");
}
#[test]
fn cross_file_summary_skips_functions_with_no_flow() {
let src = r#"
object Plain {
fun log(message: String) {
println("constant")
}
}
"#;
let found = summaries(src);
assert!(
found.iter().all(|s| s.name != "log"),
"function with no param flow should not be summarized: {found:?}"
);
}
#[test]
fn cross_file_findings_resolve_helper_call() {
let helper_src = r#"
object CommandHelper {
fun run(term: String) {
Runtime.getRuntime().exec(term)
}
}
"#;
let caller_src = r#"
fun handle(call: ApplicationCall) {
val cmd = call.receiveText()
CommandHelper.run(cmd)
}
"#;
let specs = kotlin_taint_rule_specs();
let helper_tree = parse_file(helper_src, Language::Kotlin).expect("parse helper");
let helper_summaries =
extract_cross_file_summaries(helper_tree.root_node(), helper_src, None, &specs);
let helper_path = PathBuf::from("CommandHelper.kt");
let mut summary_map = CrossFileSummaryMap::new();
summary_map.insert(helper_path.clone(), helper_summaries);
let allowed: HashSet<String> = specs.iter().map(|(id, _)| id.to_string()).collect();
let paths = vec![helper_path];
let cross = CrossFileInfo {
same_package_paths: &paths,
summaries: &summary_map,
allowed_rule_ids: &allowed,
};
let caller_tree = parse_file(caller_src, Language::Kotlin).expect("parse caller");
let findings =
extract_cross_file_findings(caller_tree.root_node(), caller_src, &specs, &cross);
assert_eq!(
findings.len(),
1,
"expected exactly one cross-file finding: {findings:?}"
);
assert_eq!(
findings[0].rule_id_hint.as_deref(),
Some("kt/taint-command-injection")
);
assert!(findings[0]
.sink_description
.contains("via cross-file call to run"));
}
const COMPOSE_SINK_SRC: &str = r#"
fun runQuery(term: String) {
db.executeQuery("SELECT * FROM users WHERE name = '" + term + "'")
}
"#;
#[test]
fn compose_lifts_forwarded_param_to_cross_file_sink() {
let middle_src = r#"
fun forward(term: String) {
runQuery(term)
}
"#;
let specs = kotlin_taint_rule_specs();
let sink_tree = parse_file(COMPOSE_SINK_SRC, Language::Kotlin).expect("parse sink");
let sink_path = PathBuf::from("QueryHelper.kt");
let mut map = CrossFileSummaryMap::new();
map.insert(
sink_path.clone(),
extract_cross_file_summaries(sink_tree.root_node(), COMPOSE_SINK_SRC, None, &specs),
);
let mid_tree = parse_file(middle_src, Language::Kotlin).expect("parse mid");
assert!(
extract_cross_file_summaries(mid_tree.root_node(), middle_src, None, &specs)
.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 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 == "kt/taint-sql-injection"),
"param 0 should reach the cross-file sink: {forward:?}"
);
}
#[test]
fn compose_is_taint_sensitive_across_the_hop() {
let middle_src = r#"
fun forward(term: String) {
val safe = "constant"
runQuery(safe)
}
"#;
let specs = kotlin_taint_rule_specs();
let sink_tree = parse_file(COMPOSE_SINK_SRC, Language::Kotlin).expect("parse sink");
let sink_path = PathBuf::from("QueryHelper.kt");
let mut map = CrossFileSummaryMap::new();
map.insert(
sink_path.clone(),
extract_cross_file_summaries(sink_tree.root_node(), COMPOSE_SINK_SRC, None, &specs),
);
let mid_tree = parse_file(middle_src, Language::Kotlin).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:?}"
);
}
}