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::{NodeMatcher, Propagator, TaintFinding, TaintSpec};
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use tree_sitter::Node;
#[derive(Clone, Debug)]
struct TaintInfo {
description: String,
line: usize,
hops: u8,
}
#[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> {
let mut findings = Vec::new();
walk_tree(root, source, &mut |node, src| {
if is_scope_node(node.kind()) {
analyze_scope(node, src, spec, propagators, &mut findings);
}
});
findings
}
pub fn csharp_taint_rule_specs() -> Vec<(&'static str, TaintSpec)> {
vec![
("csharp/taint-sql-injection", sql_injection_spec()),
("csharp/taint-command-injection", command_injection_spec()),
("csharp/taint-xss", xss_spec()),
("csharp/taint-open-redirect", open_redirect_spec()),
("csharp/taint-xxe", xxe_spec()),
("csharp/taint-unsafe-load", unsafe_load_spec()),
]
}
pub fn csharp_taint_sources() -> Vec<NodeMatcher> {
vec![
NodeMatcher::Attribute {
root: "Request".into(),
field: "QueryString".into(),
description: "Request.QueryString".into(),
},
NodeMatcher::Attribute {
root: "Request".into(),
field: "Form".into(),
description: "Request.Form".into(),
},
NodeMatcher::Attribute {
root: "Request".into(),
field: "Params".into(),
description: "Request.Params".into(),
},
NodeMatcher::Attribute {
root: "Request".into(),
field: "Cookies".into(),
description: "Request.Cookies".into(),
},
NodeMatcher::Attribute {
root: "Request".into(),
field: "Headers".into(),
description: "Request.Headers".into(),
},
NodeMatcher::Attribute {
root: "Request".into(),
field: "RawUrl".into(),
description: "Request.RawUrl".into(),
},
NodeMatcher::Attribute {
root: "Request".into(),
field: "Url".into(),
description: "Request.Url".into(),
},
NodeMatcher::Attribute {
root: "Request".into(),
field: "Path".into(),
description: "Request.Path".into(),
},
NodeMatcher::Attribute {
root: "Request".into(),
field: "UserAgent".into(),
description: "Request.UserAgent".into(),
},
NodeMatcher::Attribute {
root: "Request".into(),
field: "ServerVariables".into(),
description: "Request.ServerVariables".into(),
},
NodeMatcher::Attribute {
root: "HttpContext".into(),
field: "Request".into(),
description: "HttpContext.Request".into(),
},
NodeMatcher::Call {
canonical: "Console.ReadLine".into(),
description: "Console.ReadLine()".into(),
},
NodeMatcher::Call {
canonical: "Console.Read".into(),
description: "Console.Read()".into(),
},
NodeMatcher::Call {
canonical: "Console.ReadKey".into(),
description: "Console.ReadKey()".into(),
},
NodeMatcher::Call {
canonical: "Environment.GetEnvironmentVariable".into(),
description: "Environment.GetEnvironmentVariable()".into(),
},
NodeMatcher::Attribute {
root: "Environment".into(),
field: "GetCommandLineArgs".into(),
description: "Environment.GetCommandLineArgs()".into(),
},
]
}
pub fn csharp_taint_sanitizers() -> Vec<NodeMatcher> {
vec![
NodeMatcher::Call {
canonical: "HttpUtility.HtmlEncode".into(),
description: "HttpUtility.HtmlEncode".into(),
},
NodeMatcher::Call {
canonical: "HttpUtility.HtmlAttributeEncode".into(),
description: "HttpUtility.HtmlAttributeEncode".into(),
},
NodeMatcher::Call {
canonical: "HttpUtility.UrlEncode".into(),
description: "HttpUtility.UrlEncode".into(),
},
NodeMatcher::Call {
canonical: "HtmlEncoder.Default.Encode".into(),
description: "HtmlEncoder.Default.Encode".into(),
},
NodeMatcher::MethodName {
method: "HtmlEncode".into(),
description: "HtmlEncode".into(),
},
NodeMatcher::Call {
canonical: "SqlParameter".into(),
description: "SqlParameter (parameterized query)".into(),
},
NodeMatcher::Call {
canonical: "int.Parse".into(),
description: "int.Parse (numeric conversion)".into(),
},
NodeMatcher::Call {
canonical: "Convert.ToInt32".into(),
description: "Convert.ToInt32 (numeric conversion)".into(),
},
NodeMatcher::Call {
canonical: "Convert.ToInt64".into(),
description: "Convert.ToInt64 (numeric conversion)".into(),
},
NodeMatcher::Call {
canonical: "Path.GetFileName".into(),
description: "Path.GetFileName (path sanitizer)".into(),
},
NodeMatcher::Call {
canonical: "Path.GetFullPath".into(),
description: "Path.GetFullPath (path canonicalization)".into(),
},
]
}
fn sql_injection_spec() -> TaintSpec {
TaintSpec {
sources: csharp_taint_sources(),
sinks: vec![
NodeMatcher::Call {
canonical: "SqlCommand".into(),
description: "SqlCommand() with tainted query (SQL injection)".into(),
},
NodeMatcher::Call {
canonical: "OleDbCommand".into(),
description: "OleDbCommand() with tainted query (SQL injection)".into(),
},
NodeMatcher::Call {
canonical: "MySqlCommand".into(),
description: "MySqlCommand() with tainted query (SQL injection)".into(),
},
NodeMatcher::MethodName {
method: "ExecuteReader".into(),
description: "ExecuteReader() with tainted query (SQL injection)".into(),
},
NodeMatcher::MethodName {
method: "ExecuteNonQuery".into(),
description: "ExecuteNonQuery() with tainted query (SQL injection)".into(),
},
NodeMatcher::MethodName {
method: "ExecuteScalar".into(),
description: "ExecuteScalar() with tainted query (SQL injection)".into(),
},
NodeMatcher::MethodName {
method: "ExecuteXmlReader".into(),
description: "ExecuteXmlReader() with tainted query (SQL injection)".into(),
},
NodeMatcher::MethodName {
method: "FromSqlRaw".into(),
description: "FromSqlRaw() with tainted query (SQL injection)".into(),
},
NodeMatcher::MethodName {
method: "ExecuteSqlRaw".into(),
description: "ExecuteSqlRaw() with tainted query (SQL injection)".into(),
},
NodeMatcher::MethodName {
method: "Query".into(),
description: "Dapper.Query() with tainted SQL (SQL injection)".into(),
},
NodeMatcher::MethodName {
method: "Execute".into(),
description: "Dapper.Execute() with tainted SQL (SQL injection)".into(),
},
],
sanitizers: csharp_taint_sanitizers(),
}
}
fn command_injection_spec() -> TaintSpec {
TaintSpec {
sources: csharp_taint_sources(),
sinks: vec![
NodeMatcher::Call {
canonical: "Process.Start".into(),
description: "Process.Start() with tainted argument (command injection)".into(),
},
NodeMatcher::Call {
canonical: "ProcessStartInfo".into(),
description: "ProcessStartInfo() with tainted argument (command injection)".into(),
},
NodeMatcher::Attribute {
root: "ProcessStartInfo".into(),
field: "Arguments".into(),
description: "ProcessStartInfo.Arguments tainted (command injection)".into(),
},
NodeMatcher::Attribute {
root: "ProcessStartInfo".into(),
field: "FileName".into(),
description: "ProcessStartInfo.FileName tainted (command injection)".into(),
},
NodeMatcher::MethodName {
method: "Start".into(),
description: "Process.Start() with tainted argument (command injection)".into(),
},
],
sanitizers: csharp_taint_sanitizers(),
}
}
fn xss_spec() -> TaintSpec {
TaintSpec {
sources: csharp_taint_sources(),
sinks: vec![
NodeMatcher::Call {
canonical: "Response.Write".into(),
description: "Response.Write() with tainted content (XSS)".into(),
},
NodeMatcher::MethodName {
method: "Write".into(),
description: "Response.Write() with tainted content (XSS)".into(),
},
],
sanitizers: csharp_taint_sanitizers(),
}
}
fn open_redirect_spec() -> TaintSpec {
TaintSpec {
sources: csharp_taint_sources(),
sinks: vec![
NodeMatcher::Call {
canonical: "Response.Redirect".into(),
description: "Response.Redirect() with tainted URL (open redirect)".into(),
},
NodeMatcher::MethodName {
method: "Redirect".into(),
description: "Response.Redirect() with tainted URL (open redirect)".into(),
},
],
sanitizers: csharp_taint_sanitizers(),
}
}
fn xxe_spec() -> TaintSpec {
TaintSpec {
sources: csharp_taint_sources(),
sinks: vec![
NodeMatcher::Call {
canonical: "XmlReader.Create".into(),
description: "XmlReader.Create() with tainted input (XXE)".into(),
},
NodeMatcher::MethodName {
method: "LoadXml".into(),
description: "XmlDocument.LoadXml() with tainted input (XXE)".into(),
},
NodeMatcher::Call {
canonical: "XmlDocument.Load".into(),
description: "XmlDocument.Load() with tainted input (XXE)".into(),
},
NodeMatcher::Call {
canonical: "XDocument.Load".into(),
description: "XDocument.Load() with tainted input (XXE)".into(),
},
],
sanitizers: csharp_taint_sanitizers(),
}
}
fn unsafe_load_spec() -> TaintSpec {
TaintSpec {
sources: csharp_taint_sources(),
sinks: vec![
NodeMatcher::Call {
canonical: "Assembly.Load".into(),
description: "Assembly.Load() with tainted name (unsafe load)".into(),
},
NodeMatcher::Call {
canonical: "Assembly.LoadFrom".into(),
description: "Assembly.LoadFrom() with tainted path (unsafe load)".into(),
},
NodeMatcher::Call {
canonical: "Activator.CreateInstance".into(),
description: "Activator.CreateInstance() with tainted type (unsafe load)".into(),
},
NodeMatcher::Call {
canonical: "Type.GetType".into(),
description: "Type.GetType() with tainted type name (reflection injection)".into(),
},
],
sanitizers: csharp_taint_sanitizers(),
}
}
fn analyze_scope(
scope_node: Node<'_>,
source: &str,
spec: &TaintSpec,
propagators: &[Propagator],
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, &mut state);
for _ in 0..3 {
propagate_assignments(body, source, spec, &mut state);
seed_call_arg_sources(body, source, spec, &mut state);
apply_propagators(body, source, spec, propagators, &mut state);
}
find_sinks(body, source, spec, &state, out);
}
fn apply_propagators(
scope: Node<'_>,
source: &str,
spec: &TaintSpec,
propagators: &[Propagator],
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() != "invocation_expression" {
return;
}
let Some(func) = node.child_by_field_name("function") else {
return;
};
if func.kind() != "member_access_expression" {
return;
}
let Some(recv) = func.child_by_field_name("expression") else {
return;
};
if recv.kind() != "identifier" {
return;
}
let Some(method) = final_name_segment(func, 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, state) {
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,
state: &mut TaintState,
) {
let mut bare_names: Vec<&str> = Vec::new();
let mut wildcard = false;
let mut has_typed = false;
let mut first_param_desc: Option<&str> = None;
for matcher in &spec.sources {
match matcher {
NodeMatcher::ParamName { names, .. } => {
if crate::rules::taint_engine::param_names_are_wildcard(names) {
wildcard = true;
}
for name in names {
bare_names.push(name.as_str());
}
}
NodeMatcher::TypedName { .. } => has_typed = true,
NodeMatcher::FirstParamSource { description } => {
first_param_desc = Some(description.as_str());
}
_ => {}
}
}
if bare_names.is_empty() && !wildcard && !has_typed && first_param_desc.is_none() {
return;
}
for (index, param) in scope_parameter_nodes(scope_node).into_iter().enumerate() {
let Some(name_node) = param.child_by_field_name("name") else {
continue;
};
let name = node_text(name_node, source);
if let Some(description) =
csharp_parameter_type(param, source).and_then(|ty| typed_source_description(spec, ty))
{
state.taint(
name.to_string(),
TaintInfo {
description,
line: param.start_position().row + 1,
hops: 0,
},
);
} else if wildcard || bare_names.contains(&name) {
state.taint(
name.to_string(),
TaintInfo {
description: format!("parameter '{name}'"),
line: param.start_position().row + 1,
hops: 0,
},
);
} else if index == 0 {
if let Some(description) = first_param_desc {
state.taint(
name.to_string(),
TaintInfo {
description: description.to_string(),
line: param.start_position().row + 1,
hops: 0,
},
);
}
}
}
}
fn seed_call_arg_sources(scope: Node<'_>, source: &str, spec: &TaintSpec, state: &mut TaintState) {
let targets: Vec<(&str, usize, &str)> = spec
.sources
.iter()
.filter_map(|m| match m {
NodeMatcher::CallArgSource {
method,
arg_index,
description,
} => Some((method.as_str(), *arg_index, description.as_str())),
_ => None,
})
.collect();
if targets.is_empty() {
return;
}
let mut pending: Vec<(String, TaintInfo)> = Vec::new();
walk_scope_nodes(scope, source, &mut |node, src| {
if node.kind() != "invocation_expression" {
return;
}
let Some(func) = node.child_by_field_name("function") else {
return;
};
let Some(method_name) = final_name_segment(func, src) else {
return;
};
let Some(args) = call_arguments(node) else {
return;
};
for (method, arg_index, description) in &targets {
if method_name != *method {
continue;
}
if let Some(ident) = nth_argument_identifier(args, *arg_index, src) {
pending.push((
ident.to_string(),
TaintInfo {
description: description.to_string(),
line: node.start_position().row + 1,
hops: 0,
},
));
}
}
});
for (name, info) in pending {
state.taint(name, info);
}
}
fn nth_argument_identifier<'a>(
arg_list: Node<'_>,
index: usize,
source: &'a str,
) -> Option<&'a str> {
let mut cursor = arg_list.walk();
let mut position = 0usize;
for child in arg_list.children(&mut cursor) {
if child.kind() != "argument" {
continue;
}
if position == index {
let mut acursor = child.walk();
for expr in child.children(&mut acursor) {
if expr.is_named() {
if expr.kind() == "identifier" {
return Some(node_text(expr, source));
}
return None;
}
}
return None;
}
position += 1;
}
None
}
fn propagate_assignments(scope: Node<'_>, source: &str, spec: &TaintSpec, 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();
if let Some(value) = variable_declarator_value(node) {
match expression_taint(value, src, spec, state) {
Some(info) => state.taint(name, bump_hops(info)),
None => {
match csharp_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,
},
),
None => state.clear(&name),
}
}
}
}
}
if node.kind() == "assignment_expression" {
let Some(left) = node.child_by_field_name("left") else {
return;
};
let Some(right) = node.child_by_field_name("right") else {
return;
};
let name = assignment_target_name(left, src);
if let Some(name) = name {
match expression_taint(right, src, spec, state) {
Some(info) => state.taint(name.to_string(), bump_hops(info)),
None => state.clear(name),
}
}
}
});
}
fn find_sinks(
scope: Node<'_>,
source: &str,
spec: &TaintSpec,
state: &TaintState,
out: &mut Vec<TaintFinding>,
) {
walk_scope_nodes(scope, source, &mut |node, src| {
let is_call = node.kind() == "invocation_expression";
let is_new = node.kind() == "object_creation_expression";
if node.kind() == "assignment_expression" {
for matcher in &spec.sinks {
if let NodeMatcher::PropertyAssignSink {
property_names,
description,
} = matcher
{
if let Some(info) =
property_assign_taint(node, src, property_names, spec, state)
{
out.push(taint_finding_for_node(node, info, description.clone()));
return;
}
}
}
return;
}
if !is_call && !is_new {
return;
}
if is_new {
for matcher in &spec.sinks {
if let NodeMatcher::ConstructorArgSink {
class_names,
arg_index,
description,
} = matcher
{
if construction_class_matches(node, src, class_names) {
if let Some(info) = nth_argument_taint(node, *arg_index, src, spec, state) {
out.push(taint_finding_for_node(node, info, description.clone()));
return;
}
}
}
}
}
for matcher in &spec.sinks {
if let NodeMatcher::CallArgConcat {
method,
description,
} = matcher
{
if call_final_method_is(node, src, method) {
if let Some(info) = concat_arg_taint(node, src, spec, state) {
out.push(taint_finding_for_node(node, info, description.clone()));
return;
}
}
}
}
let Some(sink_desc) = match_sink(node, src, spec) else {
return;
};
if let Some(info) = sink_argument_taint(node, src, spec, state) {
out.push(taint_finding_for_node(node, info, sink_desc));
}
});
}
fn construction_class_matches(node: Node<'_>, source: &str, class_names: &[String]) -> bool {
node.child_by_field_name("type")
.map(|ty| node_text(ty, source))
.is_some_and(|name| class_names.iter().any(|c| c == name))
}
fn nth_argument_taint(
node: Node<'_>,
arg_index: usize,
source: &str,
spec: &TaintSpec,
state: &TaintState,
) -> Option<TaintInfo> {
let args = call_arguments(node)?;
let mut cursor = args.walk();
let mut idx = 0usize;
for child in args.children(&mut cursor) {
if child.kind() != "argument" {
continue;
}
if idx == arg_index {
let mut acursor = child.walk();
for expr in child.children(&mut acursor) {
if expr.is_named() {
return expression_taint(expr, source, spec, state);
}
}
return None;
}
idx += 1;
}
None
}
fn property_assign_taint(
node: Node<'_>,
source: &str,
property_names: &[String],
spec: &TaintSpec,
state: &TaintState,
) -> Option<TaintInfo> {
let left = node.child_by_field_name("left")?;
if left.kind() != "member_access_expression" {
return None;
}
let prop = left.child_by_field_name("name")?;
let prop_name = node_text(prop, source);
if !property_names.iter().any(|p| p == prop_name) {
return None;
}
let right = node.child_by_field_name("right")?;
expression_taint(right, source, spec, state)
}
fn call_final_method_is(node: Node<'_>, source: &str, method: &str) -> bool {
if node.kind() != "invocation_expression" {
return false;
}
node.child_by_field_name("function")
.and_then(|func| final_name_segment(func, source))
.is_some_and(|name| name == method)
}
fn concat_arg_taint(
node: Node<'_>,
source: &str,
spec: &TaintSpec,
state: &TaintState,
) -> Option<TaintInfo> {
let args = call_arguments(node)?;
let mut cursor = args.walk();
for child in args.children(&mut cursor) {
if child.kind() != "argument" {
continue;
}
let mut acursor = child.walk();
for expr in child.children(&mut acursor) {
if !expr.is_named() {
continue;
}
if is_string_concat(expr, source) {
if let Some(info) = expression_taint(expr, source, spec, state) {
return Some(info);
}
}
break;
}
}
None
}
fn is_string_concat(node: Node<'_>, source: &str) -> bool {
if node.kind() != "binary_expression" {
return false;
}
node.child_by_field_name("operator")
.is_some_and(|op| node_text(op, source) == "+")
}
fn expression_taint(
node: Node<'_>,
source: &str,
spec: &TaintSpec,
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(desc) = classify_source_expr(node, source, spec) {
return Some(TaintInfo {
description: desc,
line: node.start_position().row + 1,
hops: 0,
});
}
if node.kind() == "member_access_expression" {
if let Some(recv) = node.child_by_field_name("expression") {
if let Some(info) = expression_taint(recv, source, spec, state) {
return Some(bump_hops(info));
}
}
}
if node.kind() == "element_access_expression" {
if let Some(expr) = node.child_by_field_name("expression") {
if let Some(info) = expression_taint(expr, source, spec, state) {
return Some(bump_hops(info));
}
}
}
if node.kind() == "invocation_expression" {
if let Some(args) = call_arguments(node) {
if let Some(info) = argument_list_taint(args, source, spec, state) {
return Some(bump_hops(info));
}
}
}
if node.kind() == "binary_expression" {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if !child.is_named() {
continue;
}
if let Some(info) = expression_taint(child, source, spec, state) {
return Some(info);
}
}
}
if node.kind() == "interpolated_string_expression" {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "interpolation" {
let mut icursor = child.walk();
for inner in child.children(&mut icursor) {
if let Some(info) = expression_taint(inner, source, spec, state) {
return Some(info);
}
}
}
}
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if let Some(info) = expression_taint(child, source, spec, state) {
return Some(info);
}
}
None
}
fn classify_source_expr(node: Node<'_>, source: &str, spec: &TaintSpec) -> Option<String> {
for matcher in &spec.sources {
match matcher {
NodeMatcher::Attribute {
root,
field,
description,
} => {
if node.kind() == "member_access_expression" {
let recv = node.child_by_field_name("expression");
let name_node = node.child_by_field_name("name");
if let (Some(recv), Some(name_node)) = (recv, name_node) {
let recv_text = node_text(recv, source);
let name_text = node_text(name_node, source);
if recv_text == root.as_str() && name_text == field.as_str() {
return Some(description.clone());
}
}
}
if node.kind() == "element_access_expression" {
if let Some(expr) = node.child_by_field_name("expression") {
if let Some(found) = classify_source_expr(expr, source, spec) {
return Some(found);
}
}
}
}
NodeMatcher::Call {
canonical,
description,
} => {
if node.kind() == "invocation_expression" {
if let Some(func) = node.child_by_field_name("function") {
let resolved = resolve_callee(func, source);
if resolved == canonical.as_str() {
return Some(description.clone());
}
}
}
if node.kind() == "object_creation_expression" {
if let Some(type_node) = node.child_by_field_name("type") {
if node_text(type_node, source) == canonical.as_str() {
return Some(description.clone());
}
}
}
}
NodeMatcher::ParamName { names, description } => {
if node.kind() == "identifier" {
let text = node_text(node, source);
if names.iter().any(|n| n == text) {
return Some(description.clone());
}
}
if node.kind() == "member_access_expression"
|| node.kind() == "element_access_expression"
{
if let Some(root_name) = leftmost_receiver_name(node, source) {
if names.iter().any(|n| n == root_name) {
return Some(description.clone());
}
}
}
}
NodeMatcher::FieldName { field, description } => {
if node.kind() == "member_access_expression" {
if let Some(name_node) = node.child_by_field_name("name") {
if node_text(name_node, source) == field.as_str() {
return Some(description.clone());
}
}
}
}
NodeMatcher::Subscript { base, description } => {
if node.kind() == "element_access_expression" {
if let Some(expr) = node.child_by_field_name("expression") {
match base.as_deref() {
None => return Some(description.clone()),
Some(want) => {
let final_seg = match expr.kind() {
"identifier" => Some(node_text(expr, source)),
"member_access_expression" => expr
.child_by_field_name("name")
.map(|n| node_text(n, source)),
_ => None,
};
if final_seg == Some(want) {
return Some(description.clone());
}
}
}
}
}
}
NodeMatcher::MethodName { .. }
| NodeMatcher::CallRegex { .. }
| NodeMatcher::MethodNameRegex { .. }
| NodeMatcher::ReceiverCall { .. }
| NodeMatcher::MemberAssign { .. } => {
}
NodeMatcher::BinopFormat { .. }
| NodeMatcher::ObjectLiteralValue { .. }
| NodeMatcher::ReturnValue { .. } => {
}
NodeMatcher::TypedName { .. } => {
}
NodeMatcher::TypedAssignTarget { .. } => {
}
NodeMatcher::LiteralString { .. } => {
}
NodeMatcher::LooseEquality { .. }
| NodeMatcher::TaintedCallee { .. }
| NodeMatcher::TaintedSubscriptKey { .. } => {
}
NodeMatcher::CallArgSource { .. } => {
}
NodeMatcher::FirstParamSource { .. } => {
}
NodeMatcher::DecoratedParamSource { .. } => {
}
NodeMatcher::CallArgConcat { .. } => {
}
NodeMatcher::ConstructorArgSink { .. } | NodeMatcher::PropertyAssignSink { .. } => {
}
NodeMatcher::MethodArgSink { .. }
| NodeMatcher::ReceiverProvenanceCall { .. }
| NodeMatcher::LiteralArgCall { .. } => {
}
}
}
None
}
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, .. } => {
if node.kind() == "invocation_expression" {
if let Some(func) = node.child_by_field_name("function") {
if let Some(method_name) = final_name_segment(func, source) {
return method_name == method.as_str();
}
}
}
false
}
NodeMatcher::Call { canonical, .. } => {
if node.kind() == "invocation_expression" {
if let Some(func) = node.child_by_field_name("function") {
let resolved = resolve_callee(func, source);
return resolved == canonical.as_str();
}
}
if node.kind() == "object_creation_expression" {
if let Some(type_node) = node.child_by_field_name("type") {
return node_text(type_node, source) == canonical.as_str();
}
}
false
}
NodeMatcher::ReceiverCall { receiver, .. } => {
if node.kind() == "invocation_expression" {
if let Some(func) = node.child_by_field_name("function") {
let resolved = resolve_callee(func, source);
return resolved.contains('.')
&& resolved.split('.').next() == Some(receiver.as_str());
}
}
false
}
NodeMatcher::CallRegex { regex, .. } => {
if node.kind() == "invocation_expression" {
if let Some(func) = node.child_by_field_name("function") {
let resolved = resolve_callee(func, source);
return regex.is_match(resolved);
}
}
false
}
NodeMatcher::MethodNameRegex { regex, .. } => {
if node.kind() == "invocation_expression" {
if let Some(func) = node.child_by_field_name("function") {
if let Some(method_name) = final_name_segment(func, source) {
return regex.is_match(method_name);
}
}
}
false
}
NodeMatcher::Attribute { root, field, .. } => {
if node.kind() == "member_access_expression" {
let recv = node.child_by_field_name("expression");
let name_node = node.child_by_field_name("name");
if let (Some(recv), Some(name_node)) = (recv, name_node) {
let recv_text = node_text(recv, source);
let name_text = node_text(name_node, source);
return recv_text == root.as_str() && name_text == field.as_str();
}
}
false
}
NodeMatcher::FieldName { .. }
| NodeMatcher::Subscript { .. }
| NodeMatcher::ParamName { .. }
| NodeMatcher::MemberAssign { .. }
| NodeMatcher::BinopFormat { .. }
| NodeMatcher::ObjectLiteralValue { .. }
| NodeMatcher::ReturnValue { .. }
| NodeMatcher::TypedName { .. }
| NodeMatcher::TypedAssignTarget { .. }
| NodeMatcher::LooseEquality { .. }
| NodeMatcher::TaintedCallee { .. }
| NodeMatcher::TaintedSubscriptKey { .. }
| NodeMatcher::CallArgSource { .. }
| NodeMatcher::FirstParamSource { .. }
| NodeMatcher::DecoratedParamSource { .. }
| NodeMatcher::CallArgConcat { .. }
| NodeMatcher::ConstructorArgSink { .. }
| NodeMatcher::PropertyAssignSink { .. }
| NodeMatcher::MethodArgSink { .. }
| NodeMatcher::ReceiverProvenanceCall { .. }
| NodeMatcher::LiteralArgCall { .. }
| NodeMatcher::LiteralString { .. } => false,
}
}
fn sink_argument_taint(
node: Node<'_>,
source: &str,
spec: &TaintSpec,
state: &TaintState,
) -> Option<TaintInfo> {
call_arguments(node).and_then(|args| argument_list_taint(args, source, spec, state))
}
fn is_scope_node(kind: &str) -> bool {
matches!(
kind,
"method_declaration"
| "constructor_declaration"
| "local_function_statement"
| "lambda_expression"
| "anonymous_method_expression"
)
}
fn find_scope_body(node: Node<'_>) -> Option<Node<'_>> {
if let Some(body) = node.child_by_field_name("body") {
return Some(body);
}
let mut cursor = node.walk();
let result = node
.children(&mut cursor)
.find(|child| matches!(child.kind(), "block" | "arrow_expression_clause"));
result
}
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 resolve_callee<'a>(func_node: Node<'a>, source: &'a str) -> &'a str {
node_text(func_node, source)
}
fn final_name_segment<'a>(func_node: Node<'a>, source: &'a str) -> Option<&'a str> {
if func_node.kind() == "member_access_expression" {
return func_node
.child_by_field_name("name")
.map(|n| node_text(n, source));
}
if func_node.kind() == "identifier" {
return Some(node_text(func_node, source));
}
None
}
fn leftmost_receiver_name<'a>(node: Node<'_>, source: &'a str) -> Option<&'a str> {
match node.kind() {
"identifier" => Some(node_text(node, source)),
"member_access_expression" => {
if let Some(recv) = node.child_by_field_name("expression") {
leftmost_receiver_name(recv, source)
} else {
None
}
}
"element_access_expression" => {
if let Some(recv) = node.child_by_field_name("expression") {
leftmost_receiver_name(recv, source)
} else {
None
}
}
_ => None,
}
}
fn variable_declarator_value(node: Node<'_>) -> Option<Node<'_>> {
let count = node.child_count();
if count < 3 {
return None;
}
let last = node.child(count - 1)?;
if last.kind() == "=" {
return None;
}
Some(last)
}
fn call_arguments(node: Node<'_>) -> Option<Node<'_>> {
if node.kind() == "invocation_expression" || node.kind() == "object_creation_expression" {
return node.child_by_field_name("arguments");
}
None
}
fn argument_list_taint(
arg_list: Node<'_>,
source: &str,
spec: &TaintSpec,
state: &TaintState,
) -> Option<TaintInfo> {
let mut cursor = arg_list.walk();
for child in arg_list.children(&mut cursor) {
if child.kind() == "argument" {
let mut acursor = child.walk();
for expr in child.children(&mut acursor) {
if expr.is_named() {
if let Some(info) = expression_taint(expr, source, spec, state) {
return Some(info);
}
}
}
} else if child.is_named() {
if let Some(info) = expression_taint(child, source, spec, state) {
return Some(info);
}
}
}
None
}
fn assignment_target_name<'a>(node: Node<'a>, source: &'a str) -> Option<&'a str> {
match node.kind() {
"identifier" => Some(node_text(node, source)),
"member_access_expression" => Some(node_text(node, source)),
_ => 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 node_text<'a>(node: Node<'_>, source: &'a str) -> &'a str {
&source[node.byte_range()]
}
fn csharp_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 csharp_local_declarator_type<'a>(declarator: Node<'a>, source: &'a str) -> Option<&'a str> {
let parent = declarator.parent()?;
if parent.kind() != "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 = csharp_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 csharp_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()
}
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" && node.kind() != "local_function_statement" {
return;
}
let Some(method_name) = node
.child_by_field_name("name")
.map(|n| node_text(n, src).to_string())
else {
return;
};
let param_names = csharp_method_param_names(node, src);
if let Some(summary) =
summarize_csharp_method(node, &method_name, ¶m_names, src, rule_specs)
{
summaries.push(summary);
}
});
summaries
}
fn scope_parameter_nodes(scope_node: Node<'_>) -> Vec<Node<'_>> {
let mut out = Vec::new();
if let Some(plist) = scope_node.child_by_field_name("parameters") {
let mut cursor = plist.walk();
for child in plist.named_children(&mut cursor) {
if child.kind() == "parameter" {
out.push(child);
}
}
}
out
}
fn csharp_method_param_names(scope_node: Node<'_>, source: &str) -> Vec<String> {
scope_parameter_nodes(scope_node)
.into_iter()
.filter_map(|node| {
node.child_by_field_name("name")
.map(|n| node_text(n, source).to_string())
})
.collect()
}
fn summarize_csharp_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 csharp_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, &[], &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 csharp_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, &mut state);
for _ in 0..3 {
propagate_assignments(body, source, &synthetic, &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, &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, &mut state);
for _ in 0..3 {
propagate_assignments(body, source, source_spec, &mut state);
}
walk_scope_nodes(body, source, &mut |node, src| {
if node.kind() != "invocation_expression" {
return;
}
let Some(func) = node.child_by_field_name("function") else {
return;
};
let Some(method_name) = final_name_segment(func, 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)
.filter(|n| n.kind() == "argument")
.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, &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" && node.kind() != "local_function_statement" {
return;
}
let Some(method_name) = node
.child_by_field_name("name")
.map(|n| node_text(n, src).to_string())
else {
return;
};
let param_names = csharp_method_param_names(node, src);
if let Some(summary) = compose_csharp_method(
node,
&method_name,
¶m_names,
src,
rule_specs,
&cross_file,
) {
out.push(summary);
}
});
out
}
fn compose_csharp_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, &mut state);
for _ in 0..3 {
propagate_assignments(body, source, &synthetic, &mut state);
}
walk_scope_nodes(body, source, &mut |node, src| {
if node.kind() != "invocation_expression" {
return;
}
let Some(func) = node.child_by_field_name("function") else {
return;
};
let Some(callee) = final_name_segment(func, 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)
.filter(|n| n.kind() == "argument")
.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, &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,
})
}
#[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::CSharp) else {
panic!("C# fixture should parse");
};
analyze_tree(tree.root_node(), src, spec, None)
}
#[test]
#[ignore]
fn dump_ast_for_debug() {
let src = r#"
class Controller {
public void Handle() {
string cmd = Request.QueryString["cmd"];
Process.Start(cmd);
}
}
"#;
let Some(tree) = parse_file(src, Language::CSharp) else {
panic!("should parse");
};
fn dump(node: tree_sitter::Node, source: &str, depth: usize) {
let indent = " ".repeat(depth);
let text = &source[node.byte_range()];
let text_short: String = text.chars().take(50).collect();
let mut cursor = node.walk();
let has_fields = cursor.goto_first_child();
if has_fields {
loop {
let field_name = cursor.field_name().unwrap_or("<anon>");
eprintln!(
"{}{}.{} = {:?}",
indent,
node.kind(),
field_name,
cursor.node().kind()
);
if !cursor.goto_next_sibling() {
break;
}
}
}
eprintln!("{}{} = {:?}", indent, node.kind(), text_short);
let mut c = node.walk();
for child in node.children(&mut c) {
dump(child, source, depth + 1);
}
}
dump(tree.root_node(), src, 0);
panic!("dump complete — check stderr");
}
#[test]
fn command_injection_request_querystring_to_process_start() {
let src = r#"
using System.Diagnostics;
using System.Web;
class Controller {
public void Handle() {
string cmd = Request.QueryString["cmd"];
Process.Start(cmd);
}
}
"#;
let findings = analyze(src, &command_injection_spec());
assert!(
!findings.is_empty(),
"should detect Request.QueryString -> Process.Start: {findings:?}"
);
}
#[test]
fn sql_injection_request_form_to_execute_reader() {
let src = r#"
using System.Data.SqlClient;
using System.Web;
class Dao {
public void Query() {
string id = Request.Form["id"];
string sql = "SELECT * FROM Users WHERE Id = " + id;
var cmd = new SqlCommand(sql);
cmd.ExecuteReader();
}
}
"#;
let findings = analyze(src, &sql_injection_spec());
assert!(
!findings.is_empty(),
"should detect Request.Form -> ExecuteReader: {findings:?}"
);
}
#[test]
fn sanitizer_htmlencode_blocks_xss() {
let src = r#"
using System.Web;
class Controller {
public void Handle() {
string raw = Request.QueryString["q"];
string safe = HttpUtility.HtmlEncode(raw);
Response.Write(safe);
}
}
"#;
let findings = analyze(src, &xss_spec());
assert!(
findings.is_empty(),
"HtmlEncode must block XSS finding: {findings:?}"
);
}
#[test]
fn xss_direct_response_write_no_sanitizer() {
let src = r#"
using System.Web;
class Controller {
public void Handle() {
string raw = Request.QueryString["q"];
Response.Write(raw);
}
}
"#;
let findings = analyze(src, &xss_spec());
assert!(!findings.is_empty(), "should detect XSS: {findings:?}");
}
#[test]
fn console_readline_to_process_start() {
let src = r#"
using System;
using System.Diagnostics;
class App {
static void Main() {
string cmd = Console.ReadLine();
Process.Start(cmd);
}
}
"#;
let findings = analyze(src, &command_injection_spec());
assert!(
!findings.is_empty(),
"should detect Console.ReadLine -> Process.Start: {findings:?}"
);
}
#[test]
fn clean_literal_no_finding() {
let src = r#"
using System.Diagnostics;
class App {
static void Main() {
string _ = Console.ReadLine();
Process.Start("notepad.exe");
}
}
"#;
let findings = analyze(src, &command_injection_spec());
assert!(
findings.is_empty(),
"literal argument must not trigger taint: {findings:?}"
);
}
#[test]
fn int_parse_sanitizes_numeric_sql_injection() {
let src = r#"
using System.Data.SqlClient;
using System.Web;
class Dao {
void Query() {
string rawId = Request.QueryString["id"];
int safeId = int.Parse(rawId);
string sql = "SELECT * FROM Users WHERE Id = " + safeId;
var cmd = new SqlCommand(sql);
cmd.ExecuteReader();
}
}
"#;
let findings = analyze(src, &sql_injection_spec());
assert!(
findings.is_empty(),
"int.Parse should sanitize SQL injection: {findings:?}"
);
}
#[test]
fn cross_file_summary_records_param_to_sink() {
let helper = r#"
using System.Data.SqlClient;
class QueryHelper {
public static void RunQuery(string term) {
string sql = "SELECT * FROM users WHERE name = '" + term + "'";
var cmd = new SqlCommand(sql);
cmd.ExecuteReader();
}
}
"#;
let tree = parse_file(helper, Language::CSharp).expect("parse");
let specs = csharp_taint_rule_specs();
let summaries = extract_cross_file_summaries(tree.root_node(), helper, None, &specs);
let run = summaries
.iter()
.find(|s| s.name == "RunQuery")
.expect("RunQuery should be summarized");
assert!(
run.params_to_sink
.iter()
.any(|f| f.param_index == 0 && f.sink_rule_id == "csharp/taint-sql-injection"),
"param 0 must reach the SQL sink: {run:?}"
);
}
#[test]
fn cross_file_findings_resolve_helper_call() {
use std::path::PathBuf;
let helper = r#"
using System.Data.SqlClient;
class QueryHelper {
public static void RunQuery(string term) {
var cmd = new SqlCommand("SELECT * FROM users WHERE name = '" + term + "'");
cmd.ExecuteReader();
}
}
"#;
let caller = r#"
using System.Web;
class Handler {
public void Search() {
string name = Request.QueryString["name"];
QueryHelper.RunQuery(name);
}
}
"#;
let specs = csharp_taint_rule_specs();
let helper_tree = parse_file(helper, Language::CSharp).expect("parse helper");
let helper_path = PathBuf::from("QueryHelper.cs");
let helper_summaries =
extract_cross_file_summaries(helper_tree.root_node(), helper, None, &specs);
let mut summary_map = CrossFileSummaryMap::new();
summary_map.insert(helper_path.clone(), helper_summaries);
let allowed: HashSet<String> = ["csharp/taint-sql-injection".to_string()]
.into_iter()
.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, Language::CSharp).expect("parse caller");
let findings = extract_cross_file_findings(
caller_tree.root_node(),
caller,
&specs
.iter()
.map(|(id, s)| (*id, s.clone()))
.collect::<Vec<_>>(),
&cross,
);
assert_eq!(
findings.len(),
1,
"expected exactly one cross-file finding: {findings:?}"
);
assert!(findings[0]
.sink_description
.contains("via cross-file call to RunQuery"));
}
const COMPOSE_SINK_SRC: &str = r#"
using System.Data.SqlClient;
class QueryHelper {
public static void RunQuery(string term) {
var cmd = new SqlCommand("SELECT * FROM users WHERE name = '" + term + "'");
cmd.ExecuteReader();
}
}
"#;
#[test]
fn compose_lifts_forwarded_param_to_cross_file_sink() {
let middle_src = r#"
class Service {
public static void Forward(string term) {
QueryHelper.RunQuery(term);
}
}
"#;
let specs = csharp_taint_rule_specs();
let sink_tree = parse_file(COMPOSE_SINK_SRC, Language::CSharp).expect("parse sink");
let sink_path = PathBuf::from("QueryHelper.cs");
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::CSharp).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 == "csharp/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#"
class Service {
public static void Forward(string term) {
string safe = "constant";
QueryHelper.RunQuery(safe);
}
}
"#;
let specs = csharp_taint_rule_specs();
let sink_tree = parse_file(COMPOSE_SINK_SRC, Language::CSharp).expect("parse sink");
let sink_path = PathBuf::from("QueryHelper.cs");
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::CSharp).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:?}"
);
}
#[test]
fn open_redirect_via_request_params() {
let src = r#"
using System.Web;
class Controller {
void Redirect() {
string url = Request.Params["returnUrl"];
Response.Redirect(url);
}
}
"#;
let findings = analyze(src, &open_redirect_spec());
assert!(
!findings.is_empty(),
"should detect open redirect: {findings:?}"
);
}
}