use crate::rules::common::AliasTable;
use crate::rules::cross_file::{CrossFileSummaryMap, FunctionTaintSummary, ParamSinkFlow};
use crate::rules::taint_engine::{
analyze_function_generic, attribution_hint_for_sink, cross_file_taint_finding,
extract_cross_file_summary_for_function, 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;
type RubyCtx<'a> = AnalysisContext<'a, ()>;
pub fn analyze_tree(
root: Node<'_>,
source: &str,
spec: &TaintSpec,
_aliases: Option<&AliasTable>,
) -> Vec<TaintFinding> {
let empty_summary = crate::rules::taint_engine::ReturnSummary::new();
let ctx = AnalysisContext {
source,
spec,
aliases: None,
summaries: &empty_summary,
cross_file: None,
sink_to_rules: None,
label_policy: None,
};
let mut findings = Vec::new();
collect_method_defs(root, &mut |method_node| {
analyze_function_generic::<RubyTaintAdapter, ()>(method_node, &ctx, &mut findings);
});
findings
}
pub fn ruby_taint_rule_specs() -> Vec<(&'static str, TaintSpec)> {
vec![
("rb/taint-command-injection", command_injection_spec()),
("rb/taint-sql-injection", sql_injection_spec()),
("rb/taint-xss", xss_spec()),
(
"rb/taint-unsafe-deserialization",
unsafe_deserialization_spec(),
),
("rb/taint-open-redirect", open_redirect_spec()),
]
}
fn pick_sinks(keys: &[&str]) -> Vec<NodeMatcher> {
ruby_taint_sinks()
.into_iter()
.filter(|m| match m {
NodeMatcher::Call { canonical, .. } => keys.contains(&canonical.as_str()),
NodeMatcher::MethodName { method, .. } => keys.contains(&method.as_str()),
_ => false,
})
.collect()
}
fn command_injection_spec() -> TaintSpec {
TaintSpec {
sources: ruby_taint_sources(),
sinks: pick_sinks(&[
"system",
"exec",
"spawn",
"Kernel.system",
"Kernel.exec",
"Kernel.spawn",
"eval",
"instance_eval",
]),
sanitizers: ruby_taint_sanitizers(),
}
}
fn sql_injection_spec() -> TaintSpec {
TaintSpec {
sources: ruby_taint_sources(),
sinks: pick_sinks(&["where", "find_by_sql", "execute"]),
sanitizers: ruby_taint_sanitizers(),
}
}
fn xss_spec() -> TaintSpec {
TaintSpec {
sources: ruby_taint_sources(),
sinks: pick_sinks(&["html_safe", "raw"]),
sanitizers: ruby_taint_sanitizers(),
}
}
fn unsafe_deserialization_spec() -> TaintSpec {
TaintSpec {
sources: ruby_taint_sources(),
sinks: pick_sinks(&["Marshal.load", "YAML.load", "YAML.unsafe_load"]),
sanitizers: ruby_taint_sanitizers(),
}
}
fn open_redirect_spec() -> TaintSpec {
TaintSpec {
sources: ruby_taint_sources(),
sinks: pick_sinks(&["redirect_to"]),
sanitizers: ruby_taint_sanitizers(),
}
}
pub fn ruby_taint_sources() -> Vec<NodeMatcher> {
vec![
NodeMatcher::ParamName {
names: vec!["params".into()],
description: "request params".into(),
},
NodeMatcher::Attribute {
root: "request".into(),
field: "params".into(),
description: "request.params".into(),
},
NodeMatcher::Attribute {
root: "request".into(),
field: "body".into(),
description: "request.body".into(),
},
NodeMatcher::Attribute {
root: "request".into(),
field: "env".into(),
description: "request.env".into(),
},
NodeMatcher::Call {
canonical: "gets".into(),
description: "gets()".into(),
},
NodeMatcher::Call {
canonical: "STDIN.gets".into(),
description: "STDIN.gets".into(),
},
NodeMatcher::Call {
canonical: "STDIN.read".into(),
description: "STDIN.read".into(),
},
NodeMatcher::Call {
canonical: "STDIN.readline".into(),
description: "STDIN.readline".into(),
},
NodeMatcher::Attribute {
root: "ENV".into(),
field: "[]".into(),
description: "ENV[...]".into(),
},
NodeMatcher::Call {
canonical: "ENV.fetch".into(),
description: "ENV.fetch".into(),
},
NodeMatcher::ParamName {
names: vec!["request".into(), "req".into()],
description: "untrusted request parameter".into(),
},
]
}
pub fn ruby_taint_sinks() -> Vec<NodeMatcher> {
vec![
NodeMatcher::Call {
canonical: "system".into(),
description: "system()".into(),
},
NodeMatcher::Call {
canonical: "exec".into(),
description: "exec()".into(),
},
NodeMatcher::Call {
canonical: "spawn".into(),
description: "spawn()".into(),
},
NodeMatcher::Call {
canonical: "Kernel.system".into(),
description: "Kernel.system()".into(),
},
NodeMatcher::Call {
canonical: "Kernel.exec".into(),
description: "Kernel.exec()".into(),
},
NodeMatcher::Call {
canonical: "Kernel.spawn".into(),
description: "Kernel.spawn()".into(),
},
NodeMatcher::Call {
canonical: "eval".into(),
description: "eval()".into(),
},
NodeMatcher::Call {
canonical: "instance_eval".into(),
description: "instance_eval()".into(),
},
NodeMatcher::Call {
canonical: "send".into(),
description: "send()".into(),
},
NodeMatcher::Call {
canonical: "public_send".into(),
description: "public_send()".into(),
},
NodeMatcher::Call {
canonical: "Marshal.load".into(),
description: "Marshal.load()".into(),
},
NodeMatcher::Call {
canonical: "YAML.load".into(),
description: "YAML.load()".into(),
},
NodeMatcher::Call {
canonical: "YAML.unsafe_load".into(),
description: "YAML.unsafe_load()".into(),
},
NodeMatcher::MethodName {
method: "where".into(),
description: "ActiveRecord.where()".into(),
},
NodeMatcher::MethodName {
method: "find_by_sql".into(),
description: "find_by_sql()".into(),
},
NodeMatcher::MethodName {
method: "execute".into(),
description: "connection.execute()".into(),
},
NodeMatcher::MethodName {
method: "redirect_to".into(),
description: "redirect_to()".into(),
},
NodeMatcher::MethodName {
method: "html_safe".into(),
description: "html_safe".into(),
},
NodeMatcher::MethodName {
method: "raw".into(),
description: "raw()".into(),
},
]
}
pub fn ruby_taint_sanitizers() -> Vec<NodeMatcher> {
vec![
NodeMatcher::Call {
canonical: "Shellwords.escape".into(),
description: "Shellwords.escape".into(),
},
NodeMatcher::Call {
canonical: "ERB::Util.html_escape".into(),
description: "ERB::Util.html_escape".into(),
},
NodeMatcher::Call {
canonical: "CGI.escapeHTML".into(),
description: "CGI.escapeHTML".into(),
},
NodeMatcher::Call {
canonical: "sanitize".into(),
description: "sanitize()".into(),
},
]
}
struct RubyTaintAdapter;
impl TaintLanguageAdapter<()> for RubyTaintAdapter {
fn is_nested_scope(kind: &str) -> bool {
kind == "method" || kind == "singleton_method"
}
fn get_body(func_node: Node<'_>) -> Option<Node<'_>> {
func_node.child_by_field_name("body")
}
fn seed_params(func_node: Node<'_>, ctx: &RubyCtx<'_>, 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: &RubyCtx<'_>,
state: &mut TaintState,
findings: &mut Vec<TaintFinding>,
) {
if node.kind() == "assignment" {
handle_assignment(node, ctx, state);
}
if node.kind() == "call" {
handle_call(node, ctx, state, findings);
}
if node.kind() == "subshell" {
handle_subshell(node, ctx, state, findings);
}
}
fn dispatch_summary_node(
node: Node<'_>,
ctx: &RubyCtx<'_>,
state: &mut TaintState,
findings: &mut Vec<TaintFinding>,
return_taint: &mut Option<String>,
) {
Self::dispatch_walk_node(node, ctx, state, findings);
if node.kind() == "return" && return_taint.is_none() {
let mut cursor = node.walk();
for child in node.named_children(&mut cursor) {
if child.kind() == "argument_list" {
let mut inner = child.walk();
for expr in child.named_children(&mut inner) {
if let Some((desc, _line)) = expression_taint(expr, ctx, state) {
*return_taint = Some(desc);
break;
}
}
} else if let Some((desc, _line)) = expression_taint(child, ctx, state) {
*return_taint = Some(desc);
}
}
}
}
fn expression_taint(
expr: Node<'_>,
ctx: &RubyCtx<'_>,
state: &TaintState,
) -> Option<(String, usize)> {
expression_taint(expr, ctx, state)
}
}
fn collect_method_defs<'tree, F>(node: Node<'tree>, visit: &mut F)
where
F: FnMut(Node<'tree>),
{
if node.kind() == "method" || node.kind() == "singleton_method" {
visit(node);
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
collect_method_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) {
if child.kind() != "identifier" {
continue;
}
let param_name = node_text(child, source);
for matcher in &spec.sources {
if let NodeMatcher::ParamName { names, description } = matcher {
if names.iter().any(|n| n == param_name)
|| crate::rules::taint_engine::param_names_are_wildcard(names)
{
let line = child.start_position().row + 1;
state.taint(param_name.to_string(), description.clone(), line);
break;
}
}
}
}
}
fn handle_assignment(node: Node<'_>, ctx: &RubyCtx<'_>, 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() != "identifier" {
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_call(
node: Node<'_>,
ctx: &RubyCtx<'_>,
state: &mut TaintState,
findings: &mut Vec<TaintFinding>,
) {
let callee = resolve_callee(node, ctx.source);
if let Some(sink) = match_call_sink(ctx.spec, &callee, ctx.sink_to_rules) {
if let Some(args) = node.child_by_field_name("arguments") {
let mut cursor = args.walk();
for arg in args.named_children(&mut cursor) {
if let Some((source_desc, src_line)) = expression_taint(arg, ctx, state) {
let rule_hint = attribution_hint_for_sink(&sink);
findings.push(taint_finding_for_node(
node,
source_desc,
sink.description,
src_line,
rule_hint,
1,
));
break;
}
}
}
}
}
fn handle_subshell(
node: Node<'_>,
ctx: &RubyCtx<'_>,
state: &mut TaintState,
findings: &mut Vec<TaintFinding>,
) {
let mut cursor = node.walk();
for child in node.named_children(&mut cursor) {
if child.kind() != "interpolation" {
continue;
}
let mut inner = child.walk();
for expr in child.named_children(&mut inner) {
if let Some((source_desc, src_line)) = expression_taint(expr, ctx, state) {
findings.push(taint_finding_for_node(
node,
source_desc,
"subshell/backtick execution".to_string(),
src_line,
None,
1,
));
return;
}
}
}
}
fn resolve_callee(node: Node<'_>, source: &str) -> String {
let method = node
.child_by_field_name("method")
.map(|n| node_text(n, source))
.unwrap_or("");
if let Some(recv) = node.child_by_field_name("receiver") {
let recv_text = node_text(recv, source);
format!("{}.{}", recv_text, method)
} else {
method.to_string()
}
}
fn expression_taint(
expr: Node<'_>,
ctx: &RubyCtx<'_>,
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() == "identifier" {
let name = node_text(expr, ctx.source);
if let Some(info) = state.info(name) {
return Some((info.description.clone(), info.line));
}
}
if expr.kind() == "call" && expr.child_by_field_name("arguments").is_none() {
if let Some(recv) = expr.child_by_field_name("receiver") {
if let Some(result) = expression_taint(recv, ctx, state) {
return Some(result);
}
}
}
if expr.kind() == "element_reference" {
if let Some(object) = expr.child_by_field_name("object") {
if let Some(result) = expression_taint(object, ctx, state) {
return Some(result);
}
}
}
if expr.kind() == "string" {
let mut cursor = expr.walk();
for child in expr.children(&mut cursor) {
if child.kind() == "interpolation" {
let mut inner = child.walk();
for inner_child in child.named_children(&mut inner) {
if let Some(result) = expression_taint(inner_child, ctx, state) {
return Some(result);
}
}
}
}
}
if expr.kind() == "call" {
if is_sanitizer_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) {
if let Some(result) = expression_taint(arg, ctx, state) {
return Some(result);
}
}
}
if let Some(recv) = expr.child_by_field_name("receiver") {
if let Some(result) = expression_taint(recv, ctx, state) {
return Some(result);
}
}
}
if expr.kind() == "binary" {
if let Some(left) = expr.child_by_field_name("left") {
if let Some(result) = expression_taint(left, ctx, state) {
return Some(result);
}
}
if let Some(right) = expr.child_by_field_name("right") {
if let Some(result) = expression_taint(right, ctx, state) {
return Some(result);
}
}
}
None
}
fn is_sanitizer_call(call_node: Node<'_>, source: &str, spec: &TaintSpec) -> bool {
if call_node.kind() != "call" {
return false;
}
let callee = resolve_callee(call_node, source);
for matcher in &spec.sanitizers {
if let NodeMatcher::Call { canonical, .. } = matcher {
if callee == canonical.as_str() {
return true;
}
}
}
false
}
fn match_source(node: Node<'_>, source: &str, spec: &TaintSpec) -> Option<String> {
for matcher in &spec.sources {
match matcher {
NodeMatcher::Attribute {
root,
field,
description,
} => {
if node.kind() == "call" && node.child_by_field_name("arguments").is_none() {
if let (Some(recv), Some(method)) = (
node.child_by_field_name("receiver"),
node.child_by_field_name("method"),
) {
let recv_text = node_text(recv, source);
let method_text = node_text(method, source);
if recv_text == root.as_str() && method_text == field.as_str() {
return Some(description.clone());
}
if method_text == field.as_str() {
if let Some(leftmost) = leftmost_receiver_text(recv, source) {
if leftmost == root.as_str() {
return Some(description.clone());
}
}
}
}
}
if node.kind() == "element_reference" {
if let Some(object) = node.child_by_field_name("object") {
let obj_text = node_text(object, source);
if obj_text == root.as_str() {
return Some(description.clone());
}
}
}
}
NodeMatcher::Call {
canonical,
description,
} => {
if node.kind() == "call" {
let callee = resolve_callee(node, source);
if callee == canonical.as_str() {
return Some(description.clone());
}
}
if node.kind() == "identifier"
&& !canonical.contains('.')
&& node_text(node, source) == canonical.as_str()
{
return Some(description.clone());
}
if node.kind() == "scope_resolution"
&& node_text(node, source) == canonical.as_str()
{
return Some(description.clone());
}
}
NodeMatcher::ParamName { names, description } => {
let matches_name = |n: &str| names.iter().any(|name| name == n);
match node.kind() {
"identifier" | "constant" => {
if matches_name(node_text(node, source)) {
return Some(description.clone());
}
}
"element_reference" => {
if let Some(object) = node.child_by_field_name("object") {
if matches!(object.kind(), "identifier" | "constant")
&& matches_name(node_text(object, source))
{
return Some(description.clone());
}
}
}
"call" => {
if node.child_by_field_name("receiver").is_none() {
if let Some(method) = node.child_by_field_name("method") {
if matches_name(node_text(method, source)) {
return Some(description.clone());
}
}
} else if let Some(recv) = node.child_by_field_name("receiver") {
if let Some(leftmost) = leftmost_receiver_text(recv, source) {
if matches_name(leftmost) {
return Some(description.clone());
}
}
}
}
_ => {}
}
}
NodeMatcher::FieldName { field, description } => {
if node.kind() == "call"
&& node.child_by_field_name("receiver").is_some()
&& node.child_by_field_name("arguments").is_none()
{
if let Some(method) = node.child_by_field_name("method") {
if node_text(method, source) == field.as_str() {
return Some(description.clone());
}
}
}
}
NodeMatcher::Subscript { base, description } => {
if node.kind() == "element_reference" {
if let Some(object) = node.child_by_field_name("object") {
match base.as_deref() {
None => return Some(description.clone()),
Some(want) => match object.kind() {
"identifier" | "constant" => {
if node_text(object, source) == want {
return Some(description.clone());
}
}
"call" => {
if let Some(method) = object.child_by_field_name("method") {
if node_text(method, source) == 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
}
fn leftmost_receiver_text<'a>(mut node: Node<'_>, source: &'a str) -> Option<&'a str> {
loop {
match node.kind() {
"identifier" | "constant" => return Some(node_text(node, source)),
"call" => {
node = node.child_by_field_name("receiver")?;
}
_ => return None,
}
}
}
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_method_defs(root, &mut |method_node| {
let Some(method_name) = method_node
.child_by_field_name("name")
.map(|n| node_text(n, source).to_string())
else {
return;
};
let param_names = method_param_names(method_node, source);
if let Some(summary) = extract_cross_file_summary_for_function::<RubyTaintAdapter, ()>(
method_node,
&method_name,
¶m_names,
source,
aliases,
rule_specs,
) {
summaries.push(summary);
}
});
summaries
}
fn method_param_names(method_node: Node<'_>, source: &str) -> Vec<String> {
let mut names = Vec::new();
if let Some(params) = method_node.child_by_field_name("parameters") {
let mut cursor = params.walk();
for child in params.named_children(&mut cursor) {
if child.kind() == "identifier" {
names.push(node_text(child, source).to_string());
}
}
}
names
}
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 empty_summary = crate::rules::taint_engine::ReturnSummary::new();
let ctx: RubyCtx<'_> = AnalysisContext {
source,
spec: &source_spec,
aliases: None,
summaries: &empty_summary,
cross_file: None,
sink_to_rules: None,
label_policy: None,
};
let mut out = Vec::new();
collect_method_defs(root, &mut |method_node| {
resolve_cross_file_scope(method_node, &ctx, cross_file, &mut out);
});
out
}
fn resolve_cross_file_scope(
method_node: Node<'_>,
ctx: &RubyCtx<'_>,
cross_file: &CrossFileInfo<'_>,
out: &mut Vec<TaintFinding>,
) {
let mut state = TaintState::default();
if let Some(params) = method_node.child_by_field_name("parameters") {
seed_param_sources(params, ctx.source, ctx.spec, &mut state);
}
let Some(body) = method_node.child_by_field_name("body") else {
return;
};
for _ in 0..3 {
propagate_assignments_only(body, ctx, &mut state);
}
walk_cross_file_calls(body, ctx, cross_file, &state, out);
}
fn propagate_assignments_only(node: Node<'_>, ctx: &RubyCtx<'_>, state: &mut TaintState) {
if RubyTaintAdapter::is_nested_scope(node.kind()) {
return;
}
if node.kind() == "assignment" {
handle_assignment(node, ctx, state);
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
propagate_assignments_only(child, ctx, state);
}
}
fn walk_cross_file_calls(
node: Node<'_>,
ctx: &RubyCtx<'_>,
cross_file: &CrossFileInfo<'_>,
state: &TaintState,
out: &mut Vec<TaintFinding>,
) {
if RubyTaintAdapter::is_nested_scope(node.kind()) {
return;
}
if node.kind() == "call" {
resolve_cross_file_call(node, ctx, cross_file, state, out);
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
walk_cross_file_calls(child, ctx, cross_file, state, out);
}
}
fn resolve_cross_file_call(
node: Node<'_>,
ctx: &RubyCtx<'_>,
cross_file: &CrossFileInfo<'_>,
state: &TaintState,
out: &mut Vec<TaintFinding>,
) {
let Some(method_name) = node
.child_by_field_name("method")
.map(|n| node_text(n, ctx.source))
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((desc, line)) = expression_taint(arg, ctx, state) {
out.push(cross_file_taint_finding(
node,
desc,
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 sanitizers = Vec::new();
for (_, rule_spec) in rule_specs {
sanitizers.extend(rule_spec.sanitizers.iter().cloned());
}
let mut out = Vec::new();
collect_method_defs(root, &mut |method_node| {
let Some(method_name) = method_node
.child_by_field_name("name")
.map(|n| node_text(n, source).to_string())
else {
return;
};
let param_names = method_param_names(method_node, source);
if let Some(summary) = compose_ruby_method(
method_node,
&method_name,
¶m_names,
source,
&sanitizers,
&cross_file,
) {
out.push(summary);
}
});
out
}
fn compose_ruby_method(
method_node: Node<'_>,
method_name: &str,
param_names: &[String],
source: &str,
sanitizers: &[NodeMatcher],
cross_file: &CrossFileInfo<'_>,
) -> Option<FunctionTaintSummary> {
if param_names.is_empty() {
return None;
}
let body = method_node.child_by_field_name("body")?;
let empty_summary = ReturnSummary::new();
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.to_vec(),
};
let ctx: RubyCtx<'_> = AnalysisContext {
source,
spec: &synthetic,
aliases: None,
summaries: &empty_summary,
cross_file: None,
sink_to_rules: None,
label_policy: None,
};
let mut state = TaintState::default();
if let Some(params) = method_node.child_by_field_name("parameters") {
seed_param_sources(params, ctx.source, ctx.spec, &mut state);
}
for _ in 0..3 {
propagate_assignments_only(body, &ctx, &mut state);
}
compose_walk_cross_file_calls(
body,
&ctx,
cross_file,
&state,
param_idx,
&mut params_to_sink,
);
}
if params_to_sink.is_empty() {
return None;
}
Some(FunctionTaintSummary {
name: method_name.to_string(),
params_to_return: Vec::new(),
params_to_sink,
})
}
fn compose_walk_cross_file_calls(
node: Node<'_>,
ctx: &RubyCtx<'_>,
cross_file: &CrossFileInfo<'_>,
state: &TaintState,
param_idx: usize,
out: &mut Vec<ParamSinkFlow>,
) {
if RubyTaintAdapter::is_nested_scope(node.kind()) {
return;
}
if node.kind() == "call" {
if let Some(method_name) = node
.child_by_field_name("method")
.map(|n| node_text(n, ctx.source))
{
if let Some(summary) = lookup_cross_file_summary(cross_file, method_name) {
if let Some(args) = node.child_by_field_name("arguments") {
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, ctx, state).is_none() {
continue;
}
let dup = out.iter().any(|f| {
f.param_index == param_idx && f.sink_rule_id == flow.sink_rule_id
});
if !dup {
out.push(ParamSinkFlow {
param_index: param_idx,
sink_rule_id: flow.sink_rule_id.clone(),
sink_description: flow.sink_description.clone(),
});
}
}
}
}
}
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
compose_walk_cross_file_calls(child, ctx, cross_file, state, param_idx, out);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::parser::parse_file;
use crate::Language;
fn spec_gets_to_system() -> TaintSpec {
TaintSpec {
sources: vec![NodeMatcher::Call {
canonical: "gets".into(),
description: "gets()".into(),
}],
sinks: vec![NodeMatcher::Call {
canonical: "system".into(),
description: "system()".into(),
}],
sanitizers: vec![],
}
}
fn spec_params_to_system() -> TaintSpec {
TaintSpec {
sources: vec![NodeMatcher::ParamName {
names: vec!["params".into()],
description: "request params".into(),
}],
sinks: vec![NodeMatcher::Call {
canonical: "system".into(),
description: "system()".into(),
}],
sanitizers: vec![],
}
}
fn spec_params_to_eval() -> TaintSpec {
TaintSpec {
sources: vec![NodeMatcher::ParamName {
names: vec!["params".into()],
description: "request params".into(),
}],
sinks: vec![NodeMatcher::Call {
canonical: "eval".into(),
description: "eval()".into(),
}],
sanitizers: vec![],
}
}
fn spec_with_shellwords_sanitizer() -> TaintSpec {
let mut spec = spec_gets_to_system();
spec.sanitizers = vec![NodeMatcher::Call {
canonical: "Shellwords.escape".into(),
description: "Shellwords.escape".into(),
}];
spec
}
fn run(source: &str, spec: &TaintSpec) -> Vec<TaintFinding> {
let tree = parse_file(source, Language::Ruby).expect("parse");
analyze_tree(tree.root_node(), source, spec, None)
}
#[test]
fn gets_to_system_via_assignment() {
let src = r#"
def run
cmd = gets
system(cmd)
end
"#;
let f = run(src, &spec_gets_to_system());
assert_eq!(
f.len(),
1,
"expected finding for gets -> system, got {:?}",
f
);
assert!(f[0].source_description.contains("gets"));
assert!(f[0].sink_description.contains("system"));
assert_eq!(f[0].sink_line, 4);
assert_eq!(f[0].source_line, 3);
}
#[test]
fn gets_directly_to_system() {
let src = r#"
def run
system(gets)
end
"#;
let f = run(src, &spec_gets_to_system());
assert_eq!(f.len(), 1, "expected direct gets->system finding");
}
#[test]
fn chained_assignment_propagates_taint() {
let src = r#"
def run
a = gets
b = a
c = b
system(c)
end
"#;
let f = run(src, &spec_gets_to_system());
assert_eq!(
f.len(),
1,
"taint must propagate through chained assignments"
);
}
#[test]
fn reassignment_to_literal_kills_taint() {
let src = r#"
def run
cmd = gets
cmd = "ls -la"
system(cmd)
end
"#;
let f = run(src, &spec_gets_to_system());
assert_eq!(f.len(), 0, "reassignment to literal must kill taint");
}
#[test]
fn params_param_seeds_taint() {
let src = r#"
def handle(params)
cmd = params[:id]
system(cmd)
end
"#;
let f = run(src, &spec_params_to_system());
assert_eq!(f.len(), 1, "params param -> system must fire, got {:?}", f);
assert!(f[0].source_description.contains("params"));
}
#[test]
fn subscript_on_tainted_root_is_tainted() {
let src = r#"
def handle(params)
val = params[:q]
eval(val)
end
"#;
let f = run(src, &spec_params_to_eval());
assert_eq!(f.len(), 1, "params[:q] must propagate taint to eval");
}
#[test]
fn no_source_no_finding() {
let src = r#"
def run
cmd = "ls -la"
system(cmd)
end
"#;
let f = run(src, &spec_gets_to_system());
assert_eq!(f.len(), 0, "clean literal must produce no finding");
}
#[test]
fn sanitizer_kills_taint() {
let src = r#"
def run
raw = gets
clean = Shellwords.escape(raw)
system(clean)
end
"#;
let f = run(src, &spec_with_shellwords_sanitizer());
assert_eq!(f.len(), 0, "Shellwords.escape must sanitize taint");
}
#[test]
fn sanitizer_on_other_var_does_not_block_original() {
let src = r#"
def run
raw = gets
_safe = Shellwords.escape(raw)
system(raw)
end
"#;
let f = run(src, &spec_with_shellwords_sanitizer());
assert_eq!(
f.len(),
1,
"sanitizing 'raw' into '_safe' must not clear 'raw' taint"
);
}
#[test]
fn env_subscript_is_source() {
let src = r#"
def run
val = ENV["PATH"]
system(val)
end
"#;
let spec_with_env = TaintSpec {
sources: vec![NodeMatcher::Call {
canonical: "gets".into(),
description: "gets".into(),
}],
sinks: vec![NodeMatcher::Call {
canonical: "system".into(),
description: "system()".into(),
}],
sanitizers: vec![],
};
let f_no_env = run(src, &spec_with_env);
assert_eq!(
f_no_env.len(),
0,
"ENV should not match gets spec; got {:?}",
f_no_env
);
let spec_full = TaintSpec {
sources: ruby_taint_sources(),
sinks: vec![NodeMatcher::Call {
canonical: "system".into(),
description: "system()".into(),
}],
sanitizers: ruby_taint_sanitizers(),
};
let f_full = run(src, &spec_full);
assert_eq!(
f_full.len(),
1,
"ENV[...] must be tainted with full ruby sources, got {:?}",
f_full
);
}
#[test]
fn string_interpolation_propagates_taint() {
let src = r#"
def run
user_input = gets
cmd = "ls #{user_input}"
system(cmd)
end
"#;
let f = run(src, &spec_gets_to_system());
assert_eq!(
f.len(),
1,
"string interpolation must propagate taint, got {:?}",
f
);
}
#[test]
fn kernel_exec_is_sink() {
let src = r#"
def run
cmd = gets
Kernel.exec(cmd)
end
"#;
let spec = TaintSpec {
sources: vec![NodeMatcher::Call {
canonical: "gets".into(),
description: "gets()".into(),
}],
sinks: vec![NodeMatcher::Call {
canonical: "Kernel.exec".into(),
description: "Kernel.exec()".into(),
}],
sanitizers: vec![],
};
let f = run(src, &spec);
assert_eq!(f.len(), 1, "Kernel.exec must be recognized as sink");
}
#[test]
fn method_name_sink_fires_on_where() {
let src = r#"
def search(params)
User.where("name = '#{params[:name]}'")
end
"#;
let spec = TaintSpec {
sources: vec![NodeMatcher::ParamName {
names: vec!["params".into()],
description: "params".into(),
}],
sinks: vec![NodeMatcher::MethodName {
method: "where".into(),
description: "where()".into(),
}],
sanitizers: vec![],
};
let f = run(src, &spec);
assert_eq!(
f.len(),
1,
"MethodName sink must fire for .where, got {:?}",
f
);
}
#[test]
fn taint_not_reaching_sink_no_finding() {
let src = r#"
def run
tainted = gets
safe_cmd = "echo hello"
system(safe_cmd)
end
"#;
let f = run(src, &spec_gets_to_system());
assert_eq!(f.len(), 0, "safe_cmd is a literal; tainted is not the arg");
}
#[test]
fn request_params_attribute_source() {
let src = r#"
def handle(request)
val = request.params[:q]
system(val)
end
"#;
let spec = TaintSpec {
sources: vec![NodeMatcher::Attribute {
root: "request".into(),
field: "params".into(),
description: "request.params".into(),
}],
sinks: vec![NodeMatcher::Call {
canonical: "system".into(),
description: "system()".into(),
}],
sanitizers: vec![],
};
let f = run(src, &spec);
assert_eq!(
f.len(),
1,
"request.params must be tainted as Attribute source, got {:?}",
f
);
}
fn summaries(src: &str) -> Vec<FunctionTaintSummary> {
let tree = parse_file(src, Language::Ruby).expect("parse");
let specs = ruby_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#"
module CommandHelper
def self.run(term)
system("grep #{term} /var/log/app.log")
end
end
"#;
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, "rb/taint-command-injection");
}
#[test]
fn cross_file_summary_records_param_to_return() {
let src = r#"
module Passthrough
def self.clean(value)
return value
end
end
"#;
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#"
module Plain
def self.log(message)
puts "constant"
end
end
"#;
let found = summaries(src);
assert!(
found.iter().all(|s| s.name != "log"),
"method with no param flow should not be summarized: {found:?}"
);
}
const COMPOSE_SINK_SRC: &str = r#"
class CommandHelper
def run_cmd(arg)
system(arg)
end
end
"#;
#[test]
fn compose_lifts_forwarded_param_to_cross_file_sink() {
let middle_src = r#"
class Service
def forward(term)
run_cmd(term)
end
end
"#;
let specs = ruby_taint_rule_specs();
let sink_path = PathBuf::from("command_helper.rb");
let mut map = CrossFileSummaryMap::new();
map.insert(sink_path.clone(), summaries(COMPOSE_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::Ruby).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 == "rb/taint-command-injection"),
"param 0 should reach the cross-file sink: {forward:?}"
);
}
#[test]
fn compose_is_taint_sensitive_across_the_hop() {
let middle_src = r#"
class Service
def forward(term)
safe = "constant"
run_cmd(safe)
end
end
"#;
let specs = ruby_taint_rule_specs();
let sink_path = PathBuf::from("command_helper.rb");
let mut map = CrossFileSummaryMap::new();
map.insert(sink_path.clone(), summaries(COMPOSE_SINK_SRC));
let mid_tree = parse_file(middle_src, Language::Ruby).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:?}"
);
}
}