use super::common::AliasTable;
use crate::rules::cross_file::{CrossFileSummaryMap, FunctionTaintSummary, ParamSinkFlow};
use std::borrow::Cow;
use std::collections::HashMap;
use std::path::PathBuf;
use tree_sitter::{Node, Tree};
#[derive(Debug, Clone)]
pub enum NodeMatcher {
Attribute {
root: String,
field: String,
description: String,
},
Call {
canonical: String,
description: String,
},
ParamName {
names: Vec<String>,
description: String,
},
MethodName { method: String, description: String },
MemberAssign { field: String, description: String },
}
impl NodeMatcher {
pub fn description(&self) -> &str {
match self {
NodeMatcher::Attribute { description, .. } => description,
NodeMatcher::Call { description, .. } => description,
NodeMatcher::ParamName { description, .. } => description,
NodeMatcher::MethodName { description, .. } => description,
NodeMatcher::MemberAssign { description, .. } => description,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct TaintSpec {
pub sources: Vec<NodeMatcher>,
pub sinks: Vec<NodeMatcher>,
pub sanitizers: Vec<NodeMatcher>,
}
#[derive(Debug, Clone)]
pub struct TaintFinding {
pub sink_start_byte: usize,
pub sink_end_byte: usize,
pub sink_line: usize,
pub sink_column: usize,
pub sink_end_line: usize,
pub sink_end_column: usize,
pub source_description: String,
pub sink_description: String,
pub source_line: usize,
}
#[derive(Clone)]
pub struct CrossFileInfo<'a> {
pub import_to_path: &'a HashMap<String, PathBuf>,
pub summaries: &'a CrossFileSummaryMap,
pub current_rule_id: &'a str,
}
pub type ReturnSummary = HashMap<String, Option<String>>;
struct AnalysisContext<'a> {
source: &'a str,
spec: &'a TaintSpec,
aliases: Option<&'a AliasTable>,
summaries: &'a ReturnSummary,
cross_file: Option<&'a CrossFileInfo<'a>>,
}
pub fn analyze_tree(
root: Node<'_>,
source: &str,
spec: &TaintSpec,
aliases: Option<&AliasTable>,
) -> Vec<TaintFinding> {
analyze_tree_with_cross_file(root, source, spec, aliases, None)
}
pub fn analyze_tree_with_cross_file<'a>(
root: Node<'_>,
source: &'a str,
spec: &'a TaintSpec,
aliases: Option<&'a AliasTable>,
cross_file: Option<&'a CrossFileInfo<'a>>,
) -> Vec<TaintFinding> {
let empty_summary = ReturnSummary::new();
let mut summaries = ReturnSummary::new();
let pass1_ctx = AnalysisContext {
source,
spec,
aliases,
summaries: &empty_summary,
cross_file: None,
};
collect_summary_targets(root, source, &mut |name, func_node| {
let ret = summarize_function(func_node, &pass1_ctx);
summaries.insert(name, ret);
});
let ctx = AnalysisContext {
source,
spec,
aliases,
summaries: &summaries,
cross_file,
};
let mut findings = Vec::new();
collect_function_scopes(root, &mut |func_node| {
analyze_function(func_node, &ctx, &mut findings);
});
findings
}
fn collect_summary_targets<'tree, F>(node: Node<'tree>, source: &str, visit: &mut F)
where
F: FnMut(String, Node<'tree>),
{
if matches!(
node.kind(),
"function_declaration" | "generator_function_declaration"
) {
if let Some(name) = node.child_by_field_name("name") {
visit(node_text(name, source).to_string(), node);
}
return;
}
if node.kind() == "variable_declarator" {
if let (Some(name), Some(value)) = (
node.child_by_field_name("name"),
node.child_by_field_name("value"),
) {
if name.kind() == "identifier"
&& matches!(value.kind(), "arrow_function" | "function_expression")
{
visit(node_text(name, source).to_string(), value);
return;
}
}
}
if is_function_scope(node.kind()) {
return;
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
collect_summary_targets(child, source, visit);
}
}
fn summarize_function(func_node: Node<'_>, ctx: &AnalysisContext<'_>) -> Option<String> {
let mut state = TaintState::default();
if let Some(params) = func_node.child_by_field_name("parameters") {
seed_param_sources(params, ctx.source, ctx.spec, &mut state);
}
if let Some(single) = func_node.child_by_field_name("parameter") {
if single.kind() == "identifier" {
let name = node_text(single, ctx.source);
for matcher in &ctx.spec.sources {
if let NodeMatcher::ParamName { names, description } = matcher {
if names.iter().any(|n| n == name) {
let line = single.start_position().row + 1;
state.taint(name.to_string(), description.clone(), line);
break;
}
}
}
}
}
let body = func_node.child_by_field_name("body")?;
if func_node.kind() == "arrow_function" && body.kind() != "statement_block" {
return expression_taint(body, ctx, &state).map(|(desc, _line)| desc);
}
let mut scratch: Vec<TaintFinding> = Vec::new();
let mut return_taint: Option<String> = None;
walk_body_for_summary(body, ctx, &mut state, &mut scratch, &mut return_taint);
return_taint
}
fn walk_body_for_summary(
node: Node<'_>,
ctx: &AnalysisContext<'_>,
state: &mut TaintState,
findings: &mut Vec<TaintFinding>,
return_taint: &mut Option<String>,
) {
if is_function_scope(node.kind()) {
return;
}
match node.kind() {
"variable_declarator" => {
handle_variable_declarator(node, ctx, state);
}
"assignment_expression" => {
handle_assignment(node, ctx, state, findings);
}
"call_expression" => {
handle_call(node, ctx, state, findings);
}
"return_statement" => {
if return_taint.is_none() {
let mut cursor = node.walk();
for child in node.named_children(&mut cursor) {
if let Some((desc, _line)) = expression_taint(child, ctx, state) {
*return_taint = Some(desc);
break;
}
}
}
}
_ => {}
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
walk_body_for_summary(child, ctx, state, findings, return_taint);
}
}
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_exported_functions(root, source, &mut |func_name, func_node| {
let param_names = collect_param_names(func_node, source);
if param_names.is_empty() {
return;
}
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() {
let synthetic_source = NodeMatcher::ParamName {
names: vec![param_name.clone()],
description: format!("parameter '{}'", param_name),
};
let return_spec = TaintSpec {
sources: vec![synthetic_source.clone()],
sinks: vec![],
sanitizers: vec![],
};
let empty_summary = ReturnSummary::new();
let return_ctx = AnalysisContext {
source,
spec: &return_spec,
aliases,
summaries: &empty_summary,
cross_file: None,
};
let ret_taint = summarize_function(func_node, &return_ctx);
if ret_taint.is_some() && !params_to_return.contains(¶m_idx) {
params_to_return.push(param_idx);
}
for (rule_id, rule_spec) in rule_specs {
let synthetic_spec = TaintSpec {
sources: vec![synthetic_source.clone()],
sinks: rule_spec.sinks.clone(),
sanitizers: rule_spec.sanitizers.clone(),
};
let sink_ctx = AnalysisContext {
source,
spec: &synthetic_spec,
aliases,
summaries: &empty_summary,
cross_file: None,
};
let mut findings = Vec::new();
analyze_function(func_node, &sink_ctx, &mut findings);
if !findings.is_empty() {
let already = params_to_sink
.iter()
.any(|f| f.param_index == param_idx && f.sink_rule_id == *rule_id);
if !already {
params_to_sink.push(ParamSinkFlow {
param_index: param_idx,
sink_rule_id: rule_id.to_string(),
sink_description: findings[0].sink_description.clone(),
});
}
}
}
}
if !params_to_sink.is_empty() || !params_to_return.is_empty() {
summaries.push(FunctionTaintSummary {
name: func_name,
params_to_return,
params_to_sink,
});
}
});
summaries
}
fn collect_param_names(func_node: Node<'_>, source: &str) -> Vec<String> {
let Some(params) = func_node.child_by_field_name("parameters") else {
if let Some(single) = func_node.child_by_field_name("parameter") {
if single.kind() == "identifier" {
return vec![node_text(single, source).to_string()];
}
}
return Vec::new();
};
let mut names = Vec::new();
let mut cursor = params.walk();
for child in params.children(&mut cursor) {
let param_name = match child.kind() {
"identifier" => Some(node_text(child, source)),
"assignment_pattern" => child
.child_by_field_name("left")
.filter(|n| n.kind() == "identifier")
.map(|n| node_text(n, source)),
"rest_pattern" => {
let mut inner = child.walk();
let mut found: Option<&str> = None;
for c in child.named_children(&mut inner) {
if c.kind() == "identifier" {
found = Some(node_text(c, source));
break;
}
}
found
}
_ => None,
};
if let Some(name) = param_name {
names.push(name.to_string());
}
}
names
}
fn collect_exported_functions<'tree, F>(root: Node<'tree>, source: &str, visit: &mut F)
where
F: FnMut(String, Node<'tree>),
{
let mut func_defs: HashMap<String, Node<'tree>> = HashMap::new();
let mut cursor = root.walk();
for child in root.children(&mut cursor) {
match child.kind() {
"function_declaration" | "generator_function_declaration" => {
if let Some(name) = child.child_by_field_name("name") {
func_defs.insert(node_text(name, source).to_string(), child);
}
}
"lexical_declaration" | "variable_declaration" => {
let mut inner = child.walk();
for decl in child.children(&mut inner) {
if decl.kind() == "variable_declarator" {
if let (Some(name), Some(value)) = (
decl.child_by_field_name("name"),
decl.child_by_field_name("value"),
) {
if name.kind() == "identifier"
&& matches!(value.kind(), "arrow_function" | "function_expression")
{
func_defs.insert(node_text(name, source).to_string(), value);
}
}
}
}
}
"export_statement" => {
let mut inner = child.walk();
for c in child.children(&mut inner) {
match c.kind() {
"function_declaration" | "generator_function_declaration" => {
if let Some(name) = c.child_by_field_name("name") {
let n = node_text(name, source).to_string();
func_defs.insert(n.clone(), c);
visit(n.clone(), c);
let is_default = child.children(&mut child.walk()).any(|sib| {
!sib.is_named() && node_text(sib, source) == "default"
});
if is_default {
func_defs.insert("default".to_string(), c);
visit("default".to_string(), c);
}
}
}
"lexical_declaration" | "variable_declaration" => {
let mut d = c.walk();
for decl in c.children(&mut d) {
if decl.kind() == "variable_declarator" {
if let (Some(name), Some(value)) = (
decl.child_by_field_name("name"),
decl.child_by_field_name("value"),
) {
if name.kind() == "identifier"
&& matches!(
value.kind(),
"arrow_function" | "function_expression"
)
{
let n = node_text(name, source).to_string();
func_defs.insert(n.clone(), value);
visit(n, value);
}
}
}
}
}
"function_expression" | "arrow_function" => {
let is_default = child
.children(&mut child.walk())
.any(|sib| !sib.is_named() && node_text(sib, source) == "default");
if is_default {
let n = "default".to_string();
func_defs.insert(n.clone(), c);
visit(n, c);
}
}
_ => {}
}
}
}
_ => {}
}
}
scan_module_exports(root, source, &func_defs, visit);
}
fn scan_module_exports<'tree, F>(
root: Node<'tree>,
source: &str,
func_defs: &HashMap<String, Node<'tree>>,
visit: &mut F,
) where
F: FnMut(String, Node<'tree>),
{
let mut cursor = root.walk();
for child in root.children(&mut cursor) {
if child.kind() != "expression_statement" {
continue;
}
let Some(expr) = child.named_child(0) else {
continue;
};
if expr.kind() != "assignment_expression" {
continue;
}
let Some(left) = expr.child_by_field_name("left") else {
continue;
};
let Some(right) = expr.child_by_field_name("right") else {
continue;
};
let left_text = node_text(left, source);
if left_text == "module.exports" && right.kind() == "object" {
let mut obj_cursor = right.walk();
for prop in right.named_children(&mut obj_cursor) {
match prop.kind() {
"shorthand_property_identifier" | "shorthand_property" => {
let name = node_text(prop, source).to_string();
if let Some(func_node) = func_defs.get(&name) {
visit(name, *func_node);
}
}
"pair" => {
if let Some(key) = prop.child_by_field_name("key") {
let export_name = node_text(key, source).to_string();
if let Some(value) = prop.child_by_field_name("value") {
if matches!(value.kind(), "arrow_function" | "function_expression")
{
visit(export_name, value);
} else if value.kind() == "identifier" {
let ref_name = node_text(value, source);
if let Some(func_node) = func_defs.get(ref_name) {
visit(export_name, *func_node);
}
}
}
}
}
_ => {}
}
}
}
if left_text.starts_with("module.exports.") {
let export_name = left_text.strip_prefix("module.exports.").unwrap();
if matches!(right.kind(), "arrow_function" | "function_expression") {
visit(export_name.to_string(), right);
} else if right.kind() == "identifier" {
let ref_name = node_text(right, source);
if let Some(func_node) = func_defs.get(ref_name) {
visit(export_name.to_string(), *func_node);
}
}
}
}
}
pub fn resolve_js_imports_to_paths(
source: &str,
tree: &Tree,
current_file: &std::path::Path,
) -> HashMap<String, PathBuf> {
let mut result = HashMap::new();
let Some(parent_dir) = current_file.parent() else {
return result;
};
resolve_js_imports_walk(&mut result, tree.root_node(), source, parent_dir);
result
}
const JS_EXTENSIONS: &[&str] = &[".js", ".ts", ".mjs", ".cjs", ".jsx", ".tsx"];
fn resolve_js_module_path(parent_dir: &std::path::Path, specifier: &str) -> Option<PathBuf> {
if !specifier.starts_with('.') {
return None;
}
let base = parent_dir.join(specifier);
if base.is_file() {
return Some(base);
}
for ext in JS_EXTENSIONS {
let candidate = parent_dir.join(format!("{}{}", specifier, ext));
if candidate.is_file() {
return Some(candidate);
}
}
if base.is_dir() {
for ext in JS_EXTENSIONS {
let candidate = base.join(format!("index{}", ext));
if candidate.is_file() {
return Some(candidate);
}
}
}
None
}
fn resolve_js_imports_walk(
result: &mut HashMap<String, PathBuf>,
node: Node<'_>,
source: &str,
parent_dir: &std::path::Path,
) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"import_statement" => {
resolve_js_import_statement(result, child, source, parent_dir);
}
"lexical_declaration" | "variable_declaration" => {
resolve_js_require_decl(result, child, source, parent_dir);
}
"export_statement" => {
resolve_js_import_statement(result, child, source, parent_dir);
}
"program" | "statement_block" => {
resolve_js_imports_walk(result, child, source, parent_dir);
}
_ => {}
}
}
}
fn resolve_js_import_statement(
result: &mut HashMap<String, PathBuf>,
node: Node<'_>,
source: &str,
parent_dir: &std::path::Path,
) {
let Some(src_node) = node.child_by_field_name("source") else {
return;
};
let module = string_literal_text(src_node, source);
if module.is_empty() {
return;
}
let Some(resolved) = resolve_js_module_path(parent_dir, &module) else {
return;
};
result.insert(module.clone(), resolved.clone());
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() != "import_clause" {
continue;
}
let mut inner = child.walk();
for spec in child.children(&mut inner) {
match spec.kind() {
"named_imports" => {
let mut n_cursor = spec.walk();
for isp in spec.children(&mut n_cursor) {
if isp.kind() != "import_specifier" {
continue;
}
let name = isp
.child_by_field_name("name")
.map(|n| node_text(n, source).to_string());
let alias = isp
.child_by_field_name("alias")
.map(|n| node_text(n, source).to_string());
if let Some(real) = name {
let local = alias.unwrap_or_else(|| real.clone());
let key = format!("__from__:{}:{}", module, real);
result.insert(key, resolved.clone());
let local_key = format!("__from__:{}:{}", module, local);
if local != real {
result.insert(local_key, resolved.clone());
}
}
}
}
"identifier" => {
let local = node_text(spec, source).to_string();
let key = format!("__default__:{}:{}", module, local);
result.insert(key, resolved.clone());
}
_ => {}
}
}
}
}
fn resolve_js_require_decl(
result: &mut HashMap<String, PathBuf>,
node: Node<'_>,
source: &str,
parent_dir: &std::path::Path,
) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() != "variable_declarator" {
continue;
}
let Some(value) = child.child_by_field_name("value") else {
continue;
};
let Some(module) = require_call_module(value, source) else {
continue;
};
let Some(resolved) = resolve_js_module_path(parent_dir, &module) else {
continue;
};
let Some(name_node) = child.child_by_field_name("name") else {
continue;
};
result.insert(module.clone(), resolved.clone());
match name_node.kind() {
"identifier" => {
let local = node_text(name_node, source).to_string();
result.insert(local, resolved.clone());
}
"object_pattern" => {
let mut p_cursor = name_node.walk();
for p in name_node.children(&mut p_cursor) {
match p.kind() {
"shorthand_property_identifier_pattern" => {
let local = node_text(p, source).to_string();
let key = format!("__from__:{}:{}", module, local);
result.insert(key, resolved.clone());
}
"pair_pattern" => {
let key_name = p
.child_by_field_name("key")
.map(|n| node_text(n, source).to_string());
let value_name = p
.child_by_field_name("value")
.map(|n| node_text(n, source).to_string());
if let (Some(real), Some(local)) = (key_name, value_name) {
let key = format!("__from__:{}:{}", module, real);
result.insert(key, resolved.clone());
if local != real {
let lkey = format!("__from__:{}:{}", module, local);
result.insert(lkey, resolved.clone());
}
}
}
_ => {}
}
}
}
_ => {}
}
}
}
pub fn js_aliases_from_tree(source: &str, tree: &Tree) -> AliasTable {
let mut aliases = AliasTable::new();
js_walk_for_imports(&mut aliases, tree.root_node(), source);
aliases
}
fn js_walk_for_imports(aliases: &mut AliasTable, node: Node<'_>, source: &str) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"import_statement" => js_collect_import(aliases, child, source),
"lexical_declaration" | "variable_declaration" => {
js_collect_require_decl(aliases, child, source);
}
"program" | "statement_block" | "if_statement" | "try_statement"
| "labeled_statement" | "export_statement" => {
js_walk_for_imports(aliases, child, source);
}
_ => {}
}
}
}
fn js_collect_import(aliases: &mut AliasTable, node: Node<'_>, source: &str) {
let Some(src_node) = node.child_by_field_name("source") else {
return;
};
let module = string_literal_text(src_node, source);
if module.is_empty() {
return;
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() != "import_clause" {
continue;
}
let mut inner = child.walk();
for spec in child.children(&mut inner) {
match spec.kind() {
"identifier" => {
let local = node_text(spec, source).to_string();
aliases.insert(local, module.clone());
}
"namespace_import" => {
let mut ns_cursor = spec.walk();
for c in spec.children(&mut ns_cursor) {
if c.kind() == "identifier" {
let local = node_text(c, source).to_string();
aliases.insert(local, module.clone());
}
}
}
"named_imports" => {
let mut n_cursor = spec.walk();
for isp in spec.children(&mut n_cursor) {
if isp.kind() != "import_specifier" {
continue;
}
let name = isp
.child_by_field_name("name")
.map(|n| node_text(n, source).to_string());
let alias = isp
.child_by_field_name("alias")
.map(|n| node_text(n, source).to_string());
if let Some(real) = name {
let canonical = format!("{}.{}", module, real);
let local = alias.unwrap_or(real);
aliases.insert(local, canonical);
}
}
}
_ => {}
}
}
}
}
fn js_collect_require_decl(aliases: &mut AliasTable, node: Node<'_>, source: &str) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() != "variable_declarator" {
continue;
}
let Some(value) = child.child_by_field_name("value") else {
continue;
};
let Some(module) = require_call_module(value, source) else {
continue;
};
let Some(name_node) = child.child_by_field_name("name") else {
continue;
};
match name_node.kind() {
"identifier" => {
let local = node_text(name_node, source).to_string();
aliases.insert(local, module);
}
"object_pattern" => {
let mut p_cursor = name_node.walk();
for p in name_node.children(&mut p_cursor) {
match p.kind() {
"shorthand_property_identifier_pattern" => {
let local = node_text(p, source).to_string();
let canonical = format!("{}.{}", module, local);
aliases.insert(local, canonical);
}
"pair_pattern" => {
let key = p
.child_by_field_name("key")
.map(|n| node_text(n, source).to_string());
let value = p
.child_by_field_name("value")
.map(|n| node_text(n, source).to_string());
if let (Some(key), Some(value)) = (key, value) {
let canonical = format!("{}.{}", module, key);
aliases.insert(value, canonical);
}
}
_ => {}
}
}
}
_ => {}
}
}
}
fn require_call_module(expr: Node<'_>, source: &str) -> Option<String> {
if expr.kind() != "call_expression" {
return None;
}
let func = expr.child_by_field_name("function")?;
if func.kind() != "identifier" || node_text(func, source) != "require" {
return None;
}
let args = expr.child_by_field_name("arguments")?;
let mut cursor = args.walk();
for arg in args.named_children(&mut cursor) {
if arg.kind() == "string" {
return Some(string_literal_text(arg, source));
}
}
None
}
fn string_literal_text(node: Node<'_>, source: &str) -> String {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "string_fragment" {
return node_text(child, source).to_string();
}
}
let raw = node_text(node, source);
raw.trim_matches(|c: char| c == '"' || c == '\'' || c == '`')
.to_string()
}
fn is_function_scope(kind: &str) -> bool {
matches!(
kind,
"function_declaration"
| "function_expression"
| "arrow_function"
| "method_definition"
| "generator_function"
| "generator_function_declaration"
)
}
fn collect_function_scopes<'tree, F>(node: Node<'tree>, visit: &mut F)
where
F: FnMut(Node<'tree>),
{
if is_function_scope(node.kind()) {
visit(node);
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
collect_function_scopes(child, visit);
}
}
#[derive(Clone, Debug)]
struct TaintInfo {
description: String,
line: usize,
}
#[derive(Default)]
struct TaintState {
tainted: HashMap<String, TaintInfo>,
}
impl TaintState {
fn taint(&mut self, name: String, description: String, line: usize) {
self.tainted.insert(name, TaintInfo { description, line });
}
fn clear(&mut self, name: &str) {
self.tainted.remove(name);
}
fn info(&self, name: &str) -> Option<&TaintInfo> {
self.tainted.get(name)
}
}
fn analyze_function(
func_node: Node<'_>,
ctx: &AnalysisContext<'_>,
findings: &mut Vec<TaintFinding>,
) {
let mut state = TaintState::default();
if let Some(params) = func_node.child_by_field_name("parameters") {
seed_param_sources(params, ctx.source, ctx.spec, &mut state);
}
if let Some(single) = func_node.child_by_field_name("parameter") {
if single.kind() == "identifier" {
let name = node_text(single, ctx.source);
let line = single.start_position().row + 1;
for matcher in &ctx.spec.sources {
if let NodeMatcher::ParamName { names, description } = matcher {
if names.iter().any(|n| n == name) {
state.taint(name.to_string(), description.clone(), line);
break;
}
}
}
}
}
let Some(body) = func_node.child_by_field_name("body") else {
return;
};
walk_body(body, ctx, &mut state, findings);
}
fn seed_param_sources(params: Node<'_>, source: &str, spec: &TaintSpec, state: &mut TaintState) {
let mut cursor = params.walk();
for child in params.children(&mut cursor) {
let param_name = match child.kind() {
"identifier" => node_text(child, source),
"assignment_pattern" => {
let Some(left) = child.child_by_field_name("left") else {
continue;
};
if left.kind() != "identifier" {
continue;
}
node_text(left, source)
}
"rest_pattern" => {
let mut inner = child.walk();
let mut found: Option<&str> = None;
for c in child.named_children(&mut inner) {
if c.kind() == "identifier" {
found = Some(node_text(c, source));
break;
}
}
match found {
Some(n) => n,
None => continue,
}
}
_ => continue,
};
for matcher in &spec.sources {
if let NodeMatcher::ParamName { names, description } = matcher {
if names.iter().any(|n| n == param_name) {
let line = child.start_position().row + 1;
state.taint(param_name.to_string(), description.clone(), line);
break;
}
}
}
}
}
fn walk_body(
node: Node<'_>,
ctx: &AnalysisContext<'_>,
state: &mut TaintState,
findings: &mut Vec<TaintFinding>,
) {
if is_function_scope(node.kind()) {
return;
}
match node.kind() {
"variable_declarator" => handle_variable_declarator(node, ctx, state),
"assignment_expression" => handle_assignment(node, ctx, state, findings),
"call_expression" => handle_call(node, ctx, state, findings),
_ => {}
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
walk_body(child, ctx, state, findings);
}
}
fn handle_variable_declarator(node: Node<'_>, ctx: &AnalysisContext<'_>, state: &mut TaintState) {
let Some(name) = node.child_by_field_name("name") else {
return;
};
let Some(value) = node.child_by_field_name("value") else {
return;
};
if name.kind() == "identifier" {
let lhs = node_text(name, ctx.source).to_string();
if let Some((desc, src_line)) = expression_taint(value, ctx, state) {
state.taint(lhs, desc, src_line);
} else {
state.clear(&lhs);
}
return;
}
if matches!(name.kind(), "object_pattern" | "array_pattern") {
let targets = collect_destructuring_targets(name, ctx.source);
if let Some((desc, src_line)) = expression_taint(value, ctx, state) {
for t in &targets {
state.taint(t.clone(), desc.clone(), src_line);
}
} else {
for t in &targets {
state.clear(t);
}
}
}
}
fn collect_destructuring_targets(node: Node<'_>, source: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cursor = node.walk();
for child in node.named_children(&mut cursor) {
match child.kind() {
"identifier" | "shorthand_property_identifier_pattern" => {
out.push(node_text(child, source).to_string());
}
"pair_pattern" => {
if let Some(v) = child.child_by_field_name("value") {
if v.kind() == "identifier" {
out.push(node_text(v, source).to_string());
} else if matches!(v.kind(), "object_pattern" | "array_pattern") {
out.extend(collect_destructuring_targets(v, source));
}
}
}
"object_pattern" | "array_pattern" => {
out.extend(collect_destructuring_targets(child, source));
}
"rest_pattern" => {
let mut inner = child.walk();
for c in child.named_children(&mut inner) {
if c.kind() == "identifier" {
out.push(node_text(c, source).to_string());
}
}
}
_ => {}
}
}
out
}
fn handle_assignment(
node: Node<'_>,
ctx: &AnalysisContext<'_>,
state: &mut TaintState,
findings: &mut Vec<TaintFinding>,
) {
let (Some(left), Some(right)) = (
node.child_by_field_name("left"),
node.child_by_field_name("right"),
) else {
return;
};
if left.kind() == "member_expression" {
if let Some(prop) = left.child_by_field_name("property") {
let prop_name = node_text(prop, ctx.source);
if let Some(sink_desc) = ctx.spec.sinks.iter().find_map(|m| match m {
NodeMatcher::MemberAssign { field, description } if field == prop_name => {
Some(description.clone())
}
_ => None,
}) {
if let Some((src_desc, src_line)) = expression_taint(right, ctx, state) {
let start = node.start_position();
let end = node.end_position();
findings.push(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: src_desc,
sink_description: sink_desc,
source_line: src_line,
});
}
}
}
return;
}
if left.kind() == "identifier" {
let lhs = node_text(left, ctx.source).to_string();
if let Some((desc, src_line)) = expression_taint(right, ctx, state) {
state.taint(lhs, desc, src_line);
} else {
state.clear(&lhs);
}
return;
}
if matches!(left.kind(), "object_pattern" | "array_pattern") {
let targets = collect_destructuring_targets(left, ctx.source);
if let Some((desc, src_line)) = expression_taint(right, ctx, state) {
for t in &targets {
state.taint(t.clone(), desc.clone(), src_line);
}
} else {
for t in &targets {
state.clear(t);
}
}
}
}
fn handle_call(
node: Node<'_>,
ctx: &AnalysisContext<'_>,
state: &mut TaintState,
findings: &mut Vec<TaintFinding>,
) {
let Some(func) = node.child_by_field_name("function") else {
return;
};
let callee_text = node_text(func, ctx.source);
let resolved: Cow<'_, str> = match ctx.aliases {
Some(a) => a.resolve(callee_text),
None => Cow::Borrowed(callee_text),
};
let final_segment = resolved.rsplit('.').next().unwrap_or(resolved.as_ref());
let sink_desc = ctx.spec.sinks.iter().find_map(|m| match m {
NodeMatcher::Call {
canonical,
description,
} if canonical.as_str() == resolved.as_ref() => Some(description.clone()),
NodeMatcher::MethodName {
method,
description,
} if method == final_segment => Some(description.clone()),
_ => None,
});
if let Some(sink_desc) = sink_desc {
let Some(args) = node.child_by_field_name("arguments") else {
return;
};
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 start = node.start_position();
let end = node.end_position();
findings.push(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_desc,
sink_description: sink_desc.clone(),
source_line: src_line,
});
break;
}
}
return;
}
if let Some(cross_file) = ctx.cross_file {
handle_cross_file_call(node, func, callee_text, ctx, state, findings, cross_file);
}
}
fn handle_cross_file_call(
node: Node<'_>,
func: Node<'_>,
callee_text: &str,
ctx: &AnalysisContext<'_>,
state: &TaintState,
findings: &mut Vec<TaintFinding>,
cross_file: &CrossFileInfo<'_>,
) {
let resolved = resolve_cross_file_callee(func, callee_text, ctx.source, cross_file);
let Some((file_path, func_name)) = resolved else {
return;
};
let Some(file_summaries) = cross_file.summaries.get(&file_path) else {
return;
};
let Some(summary) = file_summaries.iter().find(|s| s.name == func_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 flow.sink_rule_id != cross_file.current_rule_id {
continue;
}
if flow.param_index >= arg_nodes.len() {
continue;
}
let arg = arg_nodes[flow.param_index];
if let Some((source_desc, src_line)) = expression_taint(arg, ctx, state) {
let start = node.start_position();
let end = node.end_position();
findings.push(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_desc,
sink_description: format!(
"{} (via cross-file call to {})",
flow.sink_description, func_name
),
source_line: src_line,
});
return;
}
}
}
fn resolve_cross_file_callee(
func: Node<'_>,
callee_text: &str,
source: &str,
cross_file: &CrossFileInfo<'_>,
) -> Option<(PathBuf, String)> {
if func.kind() == "member_expression" {
if let Some(object) = func.child_by_field_name("object") {
if object.kind() == "identifier" {
let module_name = node_text(object, source);
if let Some(file_path) = cross_file.import_to_path.get(module_name) {
if let Some(prop) = func.child_by_field_name("property") {
let func_name = node_text(prop, source).to_string();
return Some((file_path.clone(), func_name));
}
}
}
}
}
if func.kind() == "identifier" {
for (key, file_path) in cross_file.import_to_path.iter() {
if let Some(rest) = key.strip_prefix("__from__:") {
if let Some((_module, name)) = rest.split_once(':') {
if name == callee_text {
return Some((file_path.clone(), name.to_string()));
}
}
}
}
}
if callee_text.contains('.') {
let parts: Vec<&str> = callee_text.splitn(2, '.').collect();
if parts.len() == 2 {
if let Some(file_path) = cross_file.import_to_path.get(parts[0]) {
return Some((file_path.clone(), parts[1].to_string()));
}
}
}
if func.kind() == "identifier" {
for (key, file_path) in cross_file.import_to_path.iter() {
if let Some(rest) = key.strip_prefix("__default__:") {
if let Some((_module, local_name)) = rest.split_once(':') {
if local_name == callee_text {
return Some((file_path.clone(), "default".to_string()));
}
}
}
}
}
None
}
fn expression_taint(
expr: Node<'_>,
ctx: &AnalysisContext<'_>,
state: &TaintState,
) -> Option<(String, usize)> {
let expr_line = expr.start_position().row + 1;
if let Some(desc) = match_source(expr, ctx.source, ctx.spec, ctx.aliases) {
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() == "member_expression" {
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() == "subscript_expression" {
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() == "template_string" {
let mut cursor = expr.walk();
for child in expr.children(&mut cursor) {
if child.kind() == "template_substitution" {
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() == "ternary_expression" {
if let Some(consequence) = expr.child_by_field_name("consequence") {
if let Some(result) = expression_taint(consequence, ctx, state) {
return Some(result);
}
}
if let Some(alternative) = expr.child_by_field_name("alternative") {
if let Some(result) = expression_taint(alternative, ctx, state) {
return Some(result);
}
}
}
if expr.kind() == "binary_expression" {
let mut cursor = expr.walk();
for child in expr.named_children(&mut cursor) {
if let Some(result) = expression_taint(child, ctx, state) {
return Some(result);
}
}
}
if expr.kind() == "await_expression" {
let mut cursor = expr.walk();
for child in expr.named_children(&mut cursor) {
if let Some(result) = expression_taint(child, ctx, state) {
return Some(result);
}
}
}
if matches!(expr.kind(), "array" | "object") {
let mut cursor = expr.walk();
for child in expr.named_children(&mut cursor) {
if let Some(result) = expression_taint(child, ctx, state) {
return Some(result);
}
}
}
if expr.kind() == "spread_element" {
let mut cursor = expr.walk();
for child in expr.named_children(&mut cursor) {
if let Some(result) = expression_taint(child, ctx, state) {
return Some(result);
}
}
}
if matches!(
expr.kind(),
"parenthesized_expression" | "unary_expression" | "sequence_expression"
) {
let mut cursor = expr.walk();
for child in expr.named_children(&mut cursor) {
if let Some(result) = expression_taint(child, ctx, state) {
return Some(result);
}
}
}
if expr.kind() == "call_expression" {
if is_sanitizer_call(expr, ctx.source, ctx.spec, ctx.aliases) {
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(func) = expr.child_by_field_name("function") {
if func.kind() == "member_expression" {
if let Some(object) = func.child_by_field_name("object") {
if let Some(result) = expression_taint(object, ctx, state) {
return Some(result);
}
}
}
}
if let Some(func) = expr.child_by_field_name("function") {
if func.kind() == "identifier" {
let callee = node_text(func, ctx.source);
if let Some(Some(desc)) = ctx.summaries.get(callee) {
return Some((format!("{desc} (via {callee})"), expr_line));
}
}
}
}
None
}
fn is_sanitizer_call(
call_node: Node<'_>,
source: &str,
spec: &TaintSpec,
aliases: Option<&AliasTable>,
) -> bool {
if call_node.kind() != "call_expression" {
return false;
}
let Some(func) = call_node.child_by_field_name("function") else {
return false;
};
let callee_text = node_text(func, source);
let resolved: Cow<'_, str> = match aliases {
Some(a) => a.resolve(callee_text),
None => Cow::Borrowed(callee_text),
};
let final_segment = resolved.rsplit('.').next().unwrap_or(&resolved);
for matcher in &spec.sanitizers {
match matcher {
NodeMatcher::Call { canonical, .. } => {
if callee_text == canonical.as_str() || resolved.as_ref() == canonical.as_str() {
return true;
}
}
NodeMatcher::MethodName { method, .. } => {
if method == final_segment {
return true;
}
}
_ => {}
}
}
false
}
fn match_source(
node: Node<'_>,
source: &str,
spec: &TaintSpec,
aliases: Option<&AliasTable>,
) -> Option<String> {
for matcher in &spec.sources {
match matcher {
NodeMatcher::Attribute {
root,
field,
description,
} => {
if node.kind() != "member_expression" {
continue;
}
let Some(prop) = node.child_by_field_name("property") else {
continue;
};
if node_text(prop, source) != field.as_str() {
continue;
}
let Some(raw_root) = leftmost_identifier(node, source) else {
continue;
};
if raw_root == root.as_str() {
return Some(description.clone());
}
if let Some(a) = aliases {
if a.resolve(raw_root).as_ref() == root.as_str() {
return Some(description.clone());
}
}
}
NodeMatcher::Call {
canonical,
description,
} => {
if node.kind() != "call_expression" {
continue;
}
let Some(func) = node.child_by_field_name("function") else {
continue;
};
let callee_text = node_text(func, source);
if callee_text == canonical.as_str() {
return Some(description.clone());
}
if let Some(a) = aliases {
if a.resolve(callee_text).as_ref() == canonical.as_str() {
return Some(description.clone());
}
}
}
NodeMatcher::ParamName { .. } => {
}
NodeMatcher::MethodName { .. } | NodeMatcher::MemberAssign { .. } => {
}
}
}
None
}
pub fn javascript_taint_sources() -> Vec<NodeMatcher> {
vec![
NodeMatcher::ParamName {
names: vec!["req".into(), "request".into()],
description: "untrusted request parameter".into(),
},
NodeMatcher::Attribute {
root: "req".into(),
field: "body".into(),
description: "req.body".into(),
},
NodeMatcher::Attribute {
root: "req".into(),
field: "query".into(),
description: "req.query".into(),
},
NodeMatcher::Attribute {
root: "req".into(),
field: "params".into(),
description: "req.params".into(),
},
NodeMatcher::Attribute {
root: "req".into(),
field: "headers".into(),
description: "req.headers".into(),
},
NodeMatcher::Attribute {
root: "req".into(),
field: "cookies".into(),
description: "req.cookies".into(),
},
NodeMatcher::Attribute {
root: "request".into(),
field: "body".into(),
description: "request.body".into(),
},
NodeMatcher::Attribute {
root: "request".into(),
field: "query".into(),
description: "request.query".into(),
},
NodeMatcher::Attribute {
root: "request".into(),
field: "params".into(),
description: "request.params".into(),
},
NodeMatcher::Attribute {
root: "request".into(),
field: "headers".into(),
description: "request.headers".into(),
},
NodeMatcher::Attribute {
root: "request".into(),
field: "cookies".into(),
description: "request.cookies".into(),
},
NodeMatcher::Attribute {
root: "request".into(),
field: "nextUrl".into(),
description: "Next.js request.nextUrl".into(),
},
NodeMatcher::Attribute {
root: "c".into(),
field: "req".into(),
description: "Hono c.req".into(),
},
NodeMatcher::Call {
canonical: "c.req.query".into(),
description: "Hono c.req.query()".into(),
},
NodeMatcher::Call {
canonical: "c.req.param".into(),
description: "Hono c.req.param()".into(),
},
NodeMatcher::Call {
canonical: "c.req.header".into(),
description: "Hono c.req.header()".into(),
},
NodeMatcher::Call {
canonical: "c.req.json".into(),
description: "Hono c.req.json()".into(),
},
NodeMatcher::Call {
canonical: "c.req.formData".into(),
description: "Hono c.req.formData()".into(),
},
NodeMatcher::Call {
canonical: "c.req.parseBody".into(),
description: "Hono c.req.parseBody()".into(),
},
NodeMatcher::Attribute {
root: "event".into(),
field: "request".into(),
description: "SvelteKit event.request".into(),
},
NodeMatcher::Attribute {
root: "event".into(),
field: "params".into(),
description: "SvelteKit event.params".into(),
},
NodeMatcher::Attribute {
root: "event".into(),
field: "url".into(),
description: "SvelteKit event.url".into(),
},
NodeMatcher::Attribute {
root: "Deno".into(),
field: "args".into(),
description: "Deno.args".into(),
},
NodeMatcher::Call {
canonical: "Deno.env.get".into(),
description: "Deno.env.get()".into(),
},
NodeMatcher::ParamName {
names: vec!["ctx".into()],
description: "Koa context parameter".into(),
},
NodeMatcher::Attribute {
root: "ctx".into(),
field: "body".into(),
description: "Koa ctx.request.body".into(),
},
NodeMatcher::Attribute {
root: "ctx".into(),
field: "query".into(),
description: "Koa ctx.query".into(),
},
NodeMatcher::Attribute {
root: "ctx".into(),
field: "params".into(),
description: "Koa ctx.params".into(),
},
NodeMatcher::Attribute {
root: "ctx".into(),
field: "headers".into(),
description: "Koa ctx.headers".into(),
},
NodeMatcher::ParamName {
names: vec![
"body".into(),
"query".into(),
"params".into(),
"headers".into(),
],
description: "NestJS decorated parameter".into(),
},
]
}
fn leftmost_identifier<'a>(mut node: Node<'_>, source: &'a str) -> Option<&'a str> {
loop {
match node.kind() {
"identifier" => return Some(node_text(node, source)),
"member_expression" => {
node = node.child_by_field_name("object")?;
}
"subscript_expression" => {
node = node.child_by_field_name("object")?;
}
_ => return None,
}
}
}
fn node_text<'a>(node: Node<'_>, source: &'a str) -> &'a str {
&source[node.byte_range()]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::parser::parse_file;
use crate::Language;
fn spec_innerhtml_from_req() -> TaintSpec {
TaintSpec {
sources: javascript_taint_sources(),
sinks: vec![
NodeMatcher::MemberAssign {
field: "innerHTML".into(),
description: "innerHTML assignment".into(),
},
NodeMatcher::MemberAssign {
field: "outerHTML".into(),
description: "outerHTML assignment".into(),
},
NodeMatcher::Call {
canonical: "document.write".into(),
description: "document.write".into(),
},
],
sanitizers: vec![],
}
}
fn run(source: &str) -> Vec<TaintFinding> {
let tree = parse_file(source, Language::JavaScript).expect("parse");
let aliases = js_aliases_from_tree(source, &tree);
analyze_tree(
tree.root_node(),
source,
&spec_innerhtml_from_req(),
Some(&aliases),
)
}
#[test]
fn direct_flow_req_body_to_innerhtml() {
let src = r#"
function handler(req) {
document.getElementById("x").innerHTML = req.body;
}
"#;
let f = run(src);
assert_eq!(f.len(), 1);
assert!(f[0].source_description.contains("req.body"));
assert_eq!(f[0].sink_description, "innerHTML assignment");
}
#[test]
fn express_param_source_is_implicit() {
let src = r#"
app.get("/", function(req, res) {
document.write(req);
});
"#;
assert_eq!(run(src).len(), 1);
}
#[test]
fn express_param_source_via_field_access() {
let src = r#"
app.get("/", function(req, res) {
document.write(req.body.title);
});
"#;
assert_eq!(run(src).len(), 1);
}
#[test]
fn template_literal_propagates_taint() {
let src = r#"
function handler(req) {
const el = document.getElementById("x");
el.innerHTML = `<p>${req.body.name}</p>`;
}
"#;
assert_eq!(run(src).len(), 1);
}
#[test]
fn reassignment_to_literal_kills_taint() {
let src = r#"
function handler(req) {
let data = req.body.data;
data = "clean";
document.write(data);
}
"#;
assert_eq!(run(src).len(), 0);
}
#[test]
fn subscript_on_tainted_root_is_tainted() {
let src = r#"
function handler(req) {
document.write(req.body["payload"]);
}
"#;
assert_eq!(run(src).len(), 1);
}
#[test]
fn one_hop_assignment_propagates() {
let src = r#"
function handler(req) {
const name = req.query.name;
document.getElementById("x").innerHTML = name;
}
"#;
assert_eq!(run(src).len(), 1);
}
#[test]
fn alias_chain_propagates() {
let src = r#"
function handler(req) {
const data = req.body.data;
const moreData = data;
document.write(moreData);
}
"#;
assert_eq!(run(src).len(), 1);
}
#[test]
fn no_source_no_finding() {
let src = r#"
function handler() {
const x = "static";
document.write(x);
document.getElementById("a").innerHTML = "<p>hi</p>";
}
"#;
assert_eq!(run(src).len(), 0);
}
#[test]
fn nested_function_has_independent_taint() {
let src = r#"
function outer(req) {
const data = req.body;
function inner() {
document.write(data);
}
return inner;
}
"#;
assert_eq!(run(src).len(), 0);
}
#[test]
fn arrow_function_body_is_analyzed() {
let src = r#"
const handler = (req, res) => {
document.write(req.body.x);
};
"#;
assert_eq!(run(src).len(), 1);
}
#[test]
fn alias_resolution_through_import_table() {
let src = r#"
const { loads } = require("pickle");
function handler(req) {
loads(req.body);
}
"#;
let spec = TaintSpec {
sources: javascript_taint_sources(),
sinks: vec![NodeMatcher::Call {
canonical: "pickle.loads".into(),
description: "pickle.loads".into(),
}],
sanitizers: vec![],
};
let tree = parse_file(src, Language::JavaScript).expect("parse");
let aliases = js_aliases_from_tree(src, &tree);
let findings = analyze_tree(tree.root_node(), src, &spec, Some(&aliases));
assert_eq!(findings.len(), 1);
}
#[test]
fn alias_import_star_as_namespace() {
let src = r#"
import * as pickle from "pickle";
function handler(req) {
pickle.loads(req.body);
}
"#;
let spec = TaintSpec {
sources: javascript_taint_sources(),
sinks: vec![NodeMatcher::Call {
canonical: "pickle.loads".into(),
description: "pickle.loads".into(),
}],
sanitizers: vec![],
};
let tree = parse_file(src, Language::JavaScript).expect("parse");
let aliases = js_aliases_from_tree(src, &tree);
let findings = analyze_tree(tree.root_node(), src, &spec, Some(&aliases));
assert_eq!(findings.len(), 1);
}
#[test]
fn require_default_binding_resolves() {
let src = r#"const pk = require("pickle");"#;
let tree = parse_file(src, Language::JavaScript).expect("parse");
let a = js_aliases_from_tree(src, &tree);
assert_eq!(a.get("pk"), Some("pickle"));
assert_eq!(a.resolve("pk.loads"), "pickle.loads");
}
#[test]
fn named_import_with_alias_resolves() {
let src = r#"import { loads as l2 } from "pickle";"#;
let tree = parse_file(src, Language::JavaScript).expect("parse");
let a = js_aliases_from_tree(src, &tree);
assert_eq!(a.get("l2"), Some("pickle.loads"));
}
#[test]
fn string_concat_propagates_taint() {
let src = r#"
function handler(req) {
document.write("<h1>" + req.body.title + "</h1>");
}
"#;
assert_eq!(run(src).len(), 1);
}
#[test]
fn interprocedural_tainted_return_propagates_to_caller() {
let src = r#"
function getUserInput() {
return req.body;
}
function handler() {
const data = getUserInput();
document.write(data);
}
"#;
let f = run(src);
assert_eq!(f.len(), 1);
assert!(f[0].source_description.contains("getUserInput"));
}
#[test]
fn interprocedural_clean_return_does_not_fire() {
let src = r#"
function cleanHelper() {
return "static";
}
function handler() {
document.write(cleanHelper());
}
"#;
assert_eq!(run(src).len(), 0);
}
#[test]
fn interprocedural_late_definition_still_found() {
let src = r#"
function handler() {
const data = helper();
document.write(data);
}
function helper() {
return req.body;
}
"#;
let f = run(src);
assert_eq!(f.len(), 1);
assert!(f[0].source_description.contains("helper"));
}
#[test]
fn multi_hop_chain_is_out_of_scope_v1() {
let src = r#"
function sourceFn() {
return req.body;
}
function middle() {
return sourceFn();
}
function handler() {
document.write(middle());
}
"#;
assert_eq!(run(src).len(), 0);
}
#[test]
fn interprocedural_arrow_function_helper_propagates() {
let src = r#"
const getInput = () => req.body;
function handler() {
document.write(getInput());
}
"#;
let f = run(src);
assert_eq!(f.len(), 1);
assert!(f[0].source_description.contains("getInput"));
}
#[test]
fn interprocedural_arrow_function_block_body_propagates() {
let src = r#"
const getInput = () => { return req.body; };
function handler() {
const data = getInput();
document.write(data);
}
"#;
let f = run(src);
assert_eq!(f.len(), 1);
assert!(f[0].source_description.contains("getInput"));
}
#[test]
fn method_call_on_tainted_source_propagates() {
let src = r#"
function handler(req) {
const data = req.body.get("x");
document.write(data);
}
"#;
let f = run(src);
assert_eq!(f.len(), 1);
assert!(f[0].source_description.contains("req.body"));
}
#[test]
fn method_call_with_args_still_tainted() {
let src = r#"
function handler(req) {
const data = req.body.get("x", "default");
document.write(data);
}
"#;
assert_eq!(run(src).len(), 1);
}
#[test]
fn chained_method_calls_preserve_taint() {
let src = r#"
function handler(req) {
const data = req.body.get("x").trim().toUpperCase();
document.write(data);
}
"#;
assert_eq!(run(src).len(), 1);
}
#[test]
fn to_string_on_tainted_value_is_tainted() {
let src = r#"
function handler(req) {
const data = req.body.toString();
document.write(data);
}
"#;
assert_eq!(run(src).len(), 1);
}
#[test]
fn sanitizer_call_kills_taint() {
let mut spec = spec_innerhtml_from_req();
spec.sanitizers = vec![NodeMatcher::Call {
canonical: "escapeHtml".into(),
description: "escapeHtml".into(),
}];
let src = r#"
function handler(req) {
const raw = req.body;
const clean = escapeHtml(raw);
document.write(clean);
}
"#;
let tree = parse_file(src, Language::JavaScript).expect("parse");
let aliases = js_aliases_from_tree(src, &tree);
assert_eq!(
analyze_tree(tree.root_node(), src, &spec, Some(&aliases)).len(),
0
);
}
#[test]
fn ssti_ejs_render_from_req_body() {
let spec = super::super::javascript::TaintSsti::spec();
let src = r#"
const ejs = require("ejs");
function handler(req, res) {
const template = req.body.template;
ejs.render(template);
}
"#;
let tree = parse_file(src, Language::JavaScript).expect("parse");
let aliases = js_aliases_from_tree(src, &tree);
let findings = analyze_tree(tree.root_node(), src, &spec, Some(&aliases));
assert!(!findings.is_empty(), "expected SSTI finding for ejs.render");
assert!(findings[0].sink_description.contains("ejs.render"));
}
#[test]
fn ssti_no_finding_when_static_template() {
let spec = super::super::javascript::TaintSsti::spec();
let src = r#"
const ejs = require("ejs");
function handler(req, res) {
ejs.render("<h1>Hello</h1>");
}
"#;
let tree = parse_file(src, Language::JavaScript).expect("parse");
let aliases = js_aliases_from_tree(src, &tree);
let findings = analyze_tree(tree.root_node(), src, &spec, Some(&aliases));
assert!(findings.is_empty(), "static template should not fire SSTI");
}
#[test]
fn xpath_select_from_req_query() {
let spec = super::super::javascript::TaintXpathInjection::spec();
let src = r#"
const xpath = require("xpath");
function handler(req, res) {
const expr = req.query.path;
xpath.select(expr, doc);
}
"#;
let tree = parse_file(src, Language::JavaScript).expect("parse");
let aliases = js_aliases_from_tree(src, &tree);
let findings = analyze_tree(tree.root_node(), src, &spec, Some(&aliases));
assert!(!findings.is_empty(), "expected XPath injection finding");
assert!(findings[0].sink_description.contains("xpath.select"));
}
#[test]
fn xpath_no_finding_when_static_expression() {
let spec = super::super::javascript::TaintXpathInjection::spec();
let src = r#"
const xpath = require("xpath");
function handler(req, res) {
xpath.select("//book/title", doc);
}
"#;
let tree = parse_file(src, Language::JavaScript).expect("parse");
let aliases = js_aliases_from_tree(src, &tree);
let findings = analyze_tree(tree.root_node(), src, &spec, Some(&aliases));
assert!(findings.is_empty(), "static XPath should not fire");
}
#[test]
fn ldap_search_from_req_body() {
let spec = super::super::javascript::TaintLdapInjection::spec();
let src = r#"
function handler(req, res) {
const filter = req.body.username;
client.search(filter);
}
"#;
let tree = parse_file(src, Language::JavaScript).expect("parse");
let aliases = js_aliases_from_tree(src, &tree);
let findings = analyze_tree(tree.root_node(), src, &spec, Some(&aliases));
assert!(
!findings.is_empty(),
"expected LDAP injection finding for .search()"
);
assert!(findings[0]
.sink_description
.contains("LDAP client.search()"));
}
#[test]
fn ldap_no_finding_when_static_filter() {
let spec = super::super::javascript::TaintLdapInjection::spec();
let src = r#"
function handler(req, res) {
client.search("dc=example", { filter: "(cn=admin)" });
}
"#;
let tree = parse_file(src, Language::JavaScript).expect("parse");
let aliases = js_aliases_from_tree(src, &tree);
let findings = analyze_tree(tree.root_node(), src, &spec, Some(&aliases));
assert!(findings.is_empty(), "static LDAP filter should not fire");
}
#[test]
fn ldap_no_finding_for_string_search() {
let spec = super::super::javascript::TaintLdapInjection::spec();
let src = r#"
function handler(req, res) {
const pattern = req.body.pattern;
"hello world".search(pattern);
}
"#;
let tree = parse_file(src, Language::JavaScript).expect("parse");
let aliases = js_aliases_from_tree(src, &tree);
let findings = analyze_tree(tree.root_node(), src, &spec, Some(&aliases));
assert!(
findings.is_empty(),
"String.prototype.search() should not fire LDAP rule"
);
}
#[test]
fn ldap_no_finding_for_function_bind() {
let spec = super::super::javascript::TaintLdapInjection::spec();
let src = r#"
function handler(req, res) {
const ctx = req.body.context;
handler.bind(ctx);
}
"#;
let tree = parse_file(src, Language::JavaScript).expect("parse");
let aliases = js_aliases_from_tree(src, &tree);
let findings = analyze_tree(tree.root_node(), src, &spec, Some(&aliases));
assert!(
findings.is_empty(),
"Function.prototype.bind() should not fire LDAP rule"
);
}
#[test]
fn cross_file_summary_extraction_finds_exported_functions() {
let src = r#"
const db = { query(_q) { return []; } };
function runQuery(name) {
return db.query("SELECT * FROM users WHERE name = '" + name + "'");
}
function evalExpression(expr) {
return eval(expr);
}
module.exports = { runQuery, evalExpression };
"#;
let tree = parse_file(src, Language::JavaScript).expect("parse");
let aliases = js_aliases_from_tree(src, &tree);
let rule_specs = crate::rules::javascript::js_taint_rule_specs();
let summaries =
extract_cross_file_summaries(tree.root_node(), src, Some(&aliases), &rule_specs);
eprintln!("summaries: {:#?}", summaries);
let names: Vec<&str> = summaries.iter().map(|s| s.name.as_str()).collect();
assert!(
names.contains(&"runQuery"),
"expected runQuery in summaries, got {:?}",
names
);
assert!(
names.contains(&"evalExpression"),
"expected evalExpression in summaries, got {:?}",
names
);
let rq = summaries.iter().find(|s| s.name == "runQuery").unwrap();
assert!(
rq.params_to_sink
.iter()
.any(|f| f.sink_rule_id == "js/taint-sql-injection"),
"expected runQuery to have sql-injection sink flow, got {:?}",
rq.params_to_sink
);
let ee = summaries
.iter()
.find(|s| s.name == "evalExpression")
.unwrap();
assert!(
ee.params_to_sink
.iter()
.any(|f| f.sink_rule_id == "js/taint-eval"),
"expected evalExpression to have eval sink flow, got {:?}",
ee.params_to_sink
);
}
#[test]
fn export_default_named_function_produces_summary() {
let src = r#"
export default function handler(req) {
db.query("SELECT " + req.body);
}
"#;
let tree = parse_file(src, Language::JavaScript).expect("parse");
let aliases = js_aliases_from_tree(src, &tree);
let rule_specs = crate::rules::javascript::js_taint_rule_specs();
let summaries =
extract_cross_file_summaries(tree.root_node(), src, Some(&aliases), &rule_specs);
eprintln!("summaries: {:#?}", summaries);
let names: Vec<&str> = summaries.iter().map(|s| s.name.as_str()).collect();
assert!(
names.contains(&"handler"),
"expected 'handler' in summaries, got {:?}",
names
);
assert!(
names.contains(&"default"),
"expected 'default' in summaries, got {:?}",
names
);
let def = summaries.iter().find(|s| s.name == "default").unwrap();
assert!(
def.params_to_sink
.iter()
.any(|f| f.sink_rule_id == "js/taint-sql-injection"),
"expected default export to have sql-injection sink flow, got {:?}",
def.params_to_sink
);
}
#[test]
fn export_default_anonymous_function_produces_summary() {
let src = r#"
export default function(req) {
db.query("SELECT " + req.body);
}
"#;
let tree = parse_file(src, Language::JavaScript).expect("parse");
let aliases = js_aliases_from_tree(src, &tree);
let rule_specs = crate::rules::javascript::js_taint_rule_specs();
let summaries =
extract_cross_file_summaries(tree.root_node(), src, Some(&aliases), &rule_specs);
let names: Vec<&str> = summaries.iter().map(|s| s.name.as_str()).collect();
assert!(
names.contains(&"default"),
"expected 'default' in summaries, got {:?}",
names
);
}
#[test]
fn export_default_arrow_function_produces_summary() {
let src = r#"
export default (req) => {
db.query("SELECT " + req.body);
}
"#;
let tree = parse_file(src, Language::JavaScript).expect("parse");
let aliases = js_aliases_from_tree(src, &tree);
let rule_specs = crate::rules::javascript::js_taint_rule_specs();
let summaries =
extract_cross_file_summaries(tree.root_node(), src, Some(&aliases), &rule_specs);
let names: Vec<&str> = summaries.iter().map(|s| s.name.as_str()).collect();
assert!(
names.contains(&"default"),
"expected 'default' in summaries for arrow default export, got {:?}",
names
);
}
#[test]
fn import_default_cross_file_finding() {
let module_src = r#"
export default function handler(req) {
db.query("SELECT " + req.body);
}
"#;
let module_tree = parse_file(module_src, Language::JavaScript).expect("parse module");
let module_aliases = js_aliases_from_tree(module_src, &module_tree);
let rule_specs = crate::rules::javascript::js_taint_rule_specs();
let summaries = extract_cross_file_summaries(
module_tree.root_node(),
module_src,
Some(&module_aliases),
&rule_specs,
);
assert!(
!summaries.is_empty(),
"expected at least one summary from default export"
);
let module_path = PathBuf::from("/fake/handler.js");
let mut summary_map: CrossFileSummaryMap = HashMap::new();
summary_map.insert(module_path.clone(), summaries);
let caller_src = r#"
import handler from "./handler";
function route(req) {
handler(req.body);
}
"#;
let caller_tree = parse_file(caller_src, Language::JavaScript).expect("parse caller");
let caller_aliases = js_aliases_from_tree(caller_src, &caller_tree);
let mut import_to_path: HashMap<String, PathBuf> = HashMap::new();
import_to_path.insert("./handler".to_string(), module_path.clone());
import_to_path.insert(
"__default__:./handler:handler".to_string(),
module_path.clone(),
);
let cross_file = CrossFileInfo {
import_to_path: &import_to_path,
summaries: &summary_map,
current_rule_id: "js/taint-sql-injection",
};
let spec = rule_specs
.iter()
.find(|(id, _)| *id == "js/taint-sql-injection")
.map(|(_, s)| s)
.expect("sql-injection spec");
let findings = analyze_tree_with_cross_file(
caller_tree.root_node(),
caller_src,
spec,
Some(&caller_aliases),
Some(&cross_file),
);
eprintln!("findings: {:#?}", findings);
assert!(
!findings.is_empty(),
"expected cross-file finding for default import call"
);
assert!(
findings
.iter()
.any(|f| f.sink_description.contains("cross-file")),
"expected finding to mention cross-file, got: {:?}",
findings
.iter()
.map(|f| &f.sink_description)
.collect::<Vec<_>>()
);
}
#[test]
fn ternary_tainted_consequence_propagates() {
let src = r#"
function handler(req) {
const data = true ? req.body : "safe";
document.getElementById("x").innerHTML = data;
}
"#;
let f = run(src);
assert_eq!(f.len(), 1);
assert!(f[0].source_description.contains("req.body"));
}
#[test]
fn ternary_tainted_alternative_propagates() {
let src = r#"
function handler(req) {
const data = false ? "safe" : req.body;
document.getElementById("x").innerHTML = data;
}
"#;
let f = run(src);
assert_eq!(f.len(), 1);
assert!(f[0].source_description.contains("req.body"));
}
#[test]
fn ternary_clean_both_branches_is_clean() {
let src = r#"
function handler(req) {
const data = true ? "a" : "b";
document.getElementById("x").innerHTML = data;
}
"#;
assert!(run(src).is_empty());
}
#[test]
fn koa_ctx_request_body_is_tainted() {
let src = r#"
router.post("/", async (ctx) => {
document.write(ctx.request.body);
});
"#;
let f = run(src);
assert_eq!(f.len(), 1);
assert!(
f[0].source_description.contains("ctx"),
"expected Koa ctx source, got: {}",
f[0].source_description
);
}
#[test]
fn koa_ctx_query_is_tainted() {
let src = r#"
router.get("/search", async (ctx) => {
document.getElementById("x").innerHTML = ctx.query.q;
});
"#;
assert_eq!(run(src).len(), 1);
}
#[test]
fn koa_ctx_params_is_tainted() {
let src = r#"
router.get("/user/:id", async (ctx) => {
document.write(ctx.params.id);
});
"#;
assert_eq!(run(src).len(), 1);
}
#[test]
fn koa_ctx_headers_is_tainted() {
let src = r#"
router.get("/", async (ctx) => {
document.write(ctx.headers["x-forwarded-for"]);
});
"#;
assert_eq!(run(src).len(), 1);
}
#[test]
fn koa_ctx_param_name_taints_bare_ctx() {
let src = r#"
router.get("/", async (ctx) => {
document.write(ctx);
});
"#;
assert_eq!(run(src).len(), 1);
}
#[test]
fn nestjs_body_param_is_tainted() {
let src = r#"
function createUser(body) {
document.write(body.username);
}
"#;
assert_eq!(run(src).len(), 1);
}
#[test]
fn nestjs_query_param_is_tainted() {
let src = r#"
function searchUsers(query) {
document.getElementById("x").innerHTML = query.term;
}
"#;
assert_eq!(run(src).len(), 1);
}
#[test]
fn nestjs_params_param_is_tainted() {
let src = r#"
function getUser(params) {
document.write(params.id);
}
"#;
assert_eq!(run(src).len(), 1);
}
#[test]
fn nestjs_headers_param_is_tainted() {
let src = r#"
function handler(headers) {
document.write(headers["authorization"]);
}
"#;
assert_eq!(run(src).len(), 1);
}
#[test]
fn await_expression_propagates_taint() {
let src = r#"
async function handler(req) {
const data = await req.json();
document.getElementById("x").innerHTML = data;
}
"#;
let f = run(src);
assert_eq!(f.len(), 1);
assert_eq!(f[0].sink_description, "innerHTML assignment");
}
}