use super::common::AliasTable;
use std::borrow::Cow;
use std::collections::HashMap;
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,
}
pub type ReturnSummary = HashMap<String, Option<String>>;
struct AnalysisContext<'a> {
source: &'a str,
spec: &'a TaintSpec,
aliases: Option<&'a AliasTable>,
summaries: &'a ReturnSummary,
}
pub fn analyze_tree(
root: Node<'_>,
source: &str,
spec: &TaintSpec,
aliases: Option<&AliasTable>,
) -> Vec<TaintFinding> {
let empty_summary = ReturnSummary::new();
let mut summaries = ReturnSummary::new();
let pass1_ctx = AnalysisContext {
source,
spec,
aliases,
summaries: &empty_summary,
};
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,
};
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 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,
});
let Some(sink_desc) = sink_desc else {
return;
};
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;
}
}
}
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() == "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 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),
};
for matcher in &spec.sanitizers {
if let NodeMatcher::Call { canonical, .. } = matcher {
if callee_text == canonical.as_str() || resolved.as_ref() == canonical.as_str() {
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(),
},
]
}
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 .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");
}
}