use crate::rules::common::AliasTable;
use crate::rules::taint_engine::{
analyze_function_generic, attribution_hint_for_sink, match_call_sink, node_text,
taint_finding_for_node, AnalysisContext, TaintLanguageAdapter, TaintState,
};
pub use crate::rules::taint_engine::{NodeMatcher, TaintFinding, TaintSpec};
use tree_sitter::Node;
type 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,
};
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_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());
}
}
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 { .. } => {
}
}
}
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" => {
if let Some(recv) = node.child_by_field_name("receiver") {
node = recv;
} else {
return None;
}
}
_ => return None,
}
}
}
#[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
);
}
}