mirror of
https://github.com/elicpeter/nyx.git
synced 2026-07-24 21:41:02 +02:00
7517 lines
313 KiB
Rust
7517 lines
313 KiB
Rust
//! Intra-procedural control-flow graph construction.
|
||
//!
|
||
//! Walks tree-sitter ASTs for all ten supported languages and builds a
|
||
//! [`Cfg`] (a petgraph `DiGraph<NodeInfo, EdgeKind>`) per function.
|
||
//! [`NodeInfo`] carries the statement kind, label classification, callee
|
||
//! name, taint and gate metadata. [`EdgeKind`] distinguishes normal flow,
|
||
//! true/false branches, and exception edges.
|
||
//!
|
||
//! `build_cfg` is the main entry point: given a parsed tree and language,
|
||
//! it produces a [`FileCfg`] (one [`Cfg`] per function in the file) along
|
||
//! with a [`FuncSummaries`] map for pass-1 summary extraction.
|
||
//! `export_summaries` converts in-graph [`LocalFuncSummary`] values to
|
||
//! the serializable [`crate::summary::FuncSummary`] form.
|
||
|
||
#![allow(clippy::let_and_return, clippy::unnecessary_map_or)]
|
||
|
||
use petgraph::algo::dominators::{Dominators, simple_fast};
|
||
use petgraph::prelude::*;
|
||
use tracing::{debug, warn};
|
||
use tree_sitter::{Node, Tree};
|
||
|
||
use crate::labels::{
|
||
Cap, DataLabel, Kind, LangAnalysisRules, classify, classify_all, classify_gated_sink, lookup,
|
||
};
|
||
use crate::summary::FuncSummary;
|
||
use crate::symbol::{FuncKey, Lang};
|
||
use crate::utils::snippet::truncate_at_char_boundary;
|
||
use smallvec::SmallVec;
|
||
use std::cell::RefCell;
|
||
use std::collections::{HashMap, HashSet};
|
||
|
||
mod blocks;
|
||
mod conditions;
|
||
mod decorators;
|
||
mod dto;
|
||
mod helpers;
|
||
mod hierarchy;
|
||
mod imports;
|
||
mod literals;
|
||
mod params;
|
||
pub mod safe_fields;
|
||
use blocks::{build_begin_rescue, build_switch, build_try};
|
||
use helpers::{
|
||
collect_nested_function_nodes, derive_anon_fn_name_from_context, find_classifiable_inner_call,
|
||
first_call_ident_with_span, first_member_label, first_member_text, is_raii_factory,
|
||
is_subscript_kind, root_member_receiver, subscript_components, subscript_lhs_node,
|
||
};
|
||
// Re-exports so sibling submodules can keep using `super::name` for
|
||
// helpers that physically live in `helpers.rs`.
|
||
use conditions::{
|
||
build_condition_chain, build_ternary_diamond, classify_ternary_lhs,
|
||
detect_rust_let_match_guard, emit_rust_match_guard_if, find_ternary_rhs_wrapper,
|
||
is_boolean_operator, unwrap_parens,
|
||
};
|
||
use decorators::{extract_auth_decorators, extract_route_path_captures};
|
||
pub(crate) use helpers::{
|
||
collect_idents, collect_idents_with_paths, find_constructor_type_child, first_call_ident,
|
||
has_call_descendant, member_expr_text, root_receiver_text, text_of,
|
||
};
|
||
use imports::{
|
||
extract_import_bindings, extract_local_import_view, extract_promisify_aliases,
|
||
rust_bare_join_crate_prefix,
|
||
};
|
||
#[cfg(test)]
|
||
use literals::has_sql_placeholders;
|
||
use literals::{
|
||
arg0_kind_and_interpolation, call_ident_of, def_use, detect_go_replace_call_sanitizer,
|
||
detect_rust_replace_chain_sanitizer, extract_arg_callees, extract_arg_string_literals,
|
||
extract_arg_uses, extract_const_keyword_arg, extract_const_macro_arg, extract_const_string_arg,
|
||
extract_destination_field_pairs, extract_destination_kwarg_pairs, extract_kwargs,
|
||
extract_literal_rhs, extract_object_arg_property, extract_shell_array_payload_idents,
|
||
find_call_node, find_call_node_deep, find_chained_inner_call, has_keyword_arg,
|
||
has_object_arg_property, has_only_literal_args, has_string_interpolation,
|
||
is_object_create_null_call, is_parameterized_query_call, java_chain_arg0_kind_for_method,
|
||
js_chain_arg0_kind_for_method, js_chain_outer_method_for_inner, ruby_chain_arg0_for_method,
|
||
walk_chain_inner_call_args,
|
||
};
|
||
use params::{
|
||
compute_container_and_kind, extract_param_meta, inject_framework_param_sources,
|
||
is_configured_terminator,
|
||
};
|
||
|
||
/// Test-only re-export of `extract_param_meta` so the external
|
||
/// `tests/typed_extractors_audit.rs` harness can drive the per-param
|
||
/// classifier directly without spinning up the full scan pipeline.
|
||
/// Projects away the destructured-siblings third tuple slot so the
|
||
/// existing tuple-shape assertions in the audit harness keep working;
|
||
/// the sibling info is plumbed separately through `BodyMeta`.
|
||
pub fn extract_param_meta_for_test<'a>(
|
||
func_node: tree_sitter::Node<'a>,
|
||
lang: &str,
|
||
code: &'a [u8],
|
||
) -> Vec<(String, Option<crate::ssa::type_facts::TypeKind>)> {
|
||
extract_param_meta(func_node, lang, code)
|
||
.into_iter()
|
||
.map(|(name, ty, _siblings)| (name, ty))
|
||
.collect()
|
||
}
|
||
|
||
/// Test-only re-export that returns the full per-slot tuple including
|
||
/// destructured sibling names. Used by the destructured-arg-probe
|
||
/// regression tests in `src/taint/tests.rs` and the params unit tests
|
||
/// in `src/cfg/cfg_tests.rs`.
|
||
pub fn extract_param_meta_with_destructured_for_test<'a>(
|
||
func_node: tree_sitter::Node<'a>,
|
||
lang: &str,
|
||
code: &'a [u8],
|
||
) -> Vec<(
|
||
String,
|
||
Option<crate::ssa::type_facts::TypeKind>,
|
||
Vec<String>,
|
||
)> {
|
||
extract_param_meta(func_node, lang, code)
|
||
}
|
||
|
||
/// Test-only helper to populate the per-file DTO class map without
|
||
/// running `build_cfg`. Used by the DTO audit harness in
|
||
/// `tests/typed_extractors_audit.rs` to verify that
|
||
/// `classify_param_type_*` resolves a same-file DTO via the
|
||
/// thread-local map.
|
||
pub fn populate_dto_classes_for_test(root: tree_sitter::Node<'_>, lang: &str, code: &[u8]) {
|
||
DTO_CLASSES.with(|cell| {
|
||
*cell.borrow_mut() = dto::collect_dto_classes(root, lang, code);
|
||
});
|
||
}
|
||
|
||
/// Test-only counterpart to [`populate_dto_classes_for_test`]. Always
|
||
/// call this at the end of a test that populated the map so per-thread
|
||
/// state never leaks into another test.
|
||
pub fn clear_dto_classes_for_test() {
|
||
DTO_CLASSES.with(|cell| cell.borrow_mut().clear());
|
||
}
|
||
|
||
// Per-file map of function-node start_byte → DFS preorder index. Stable
|
||
// against unrelated edits (inserting a line above a function doesn't
|
||
// change its index). Thread-local is safe, `build_cfg` is not
|
||
// re-entrant within a single rayon worker.
|
||
thread_local! {
|
||
static FN_DFS_INDICES: RefCell<HashMap<usize, u32>> = RefCell::new(HashMap::new());
|
||
/// Per-file DTO class definitions, populated at the top of
|
||
/// [`build_cfg`] so per-parameter classifiers can resolve typed
|
||
/// extractors against same-file DTOs.
|
||
pub(crate) static DTO_CLASSES: RefCell<HashMap<String, crate::ssa::type_facts::DtoFields>>
|
||
= RefCell::new(HashMap::new());
|
||
/// Per-file set of TS / JS `type X = Map<...>` (or `Set<...>` /
|
||
/// `Array<...>` / `T[]`) aliases, populated at the top of
|
||
/// [`build_cfg`]. Lets `classify_param_type_ts` resolve a
|
||
/// parameter typed `m: ElementsMap` to
|
||
/// [`crate::ssa::type_facts::TypeKind::LocalCollection`] via
|
||
/// same-file alias lookup. Cross-file aliases are not yet
|
||
/// resolved.
|
||
pub(crate) static TYPE_ALIAS_LC: RefCell<std::collections::HashSet<String>>
|
||
= RefCell::new(std::collections::HashSet::new());
|
||
/// Per-file map of `(enclosing-function start_byte, local-variable
|
||
/// name)` → [`crate::ssa::type_facts::TypeKind`]. Populated at the
|
||
/// top of [`build_cfg`] by walking each function body for local
|
||
/// variable declarations whose RHS callee is recognised by
|
||
/// [`crate::ssa::type_facts::constructor_type`]. Consulted by
|
||
/// `find_classifiable_inner_call` (in `helpers.rs`) to rewrite the
|
||
/// receiver in a chained inner call (`sess.createNativeQuery(...)`)
|
||
/// to its type prefix (`HibernateSession.createNativeQuery`) so a
|
||
/// type-qualified label rule fires when the legacy literal-receiver
|
||
/// rule misses. Java-only today; extends to any language whose
|
||
/// `constructor_type` arm fires on the RHS callee.
|
||
pub(crate) static LOCAL_RECEIVER_TYPES:
|
||
RefCell<HashMap<(usize, String), crate::ssa::type_facts::TypeKind>>
|
||
= RefCell::new(HashMap::new());
|
||
}
|
||
|
||
/// Walk every function-kind node in the tree. Within each function
|
||
/// body, scan non-nested local variable declarations whose RHS is a
|
||
/// call expression and whose callee is recognised by
|
||
/// [`crate::ssa::type_facts::constructor_type`]. Record
|
||
/// `(fn_start, var_name) → TypeKind` so chained inner calls receive a
|
||
/// type-qualified rewrite at classify time.
|
||
fn populate_local_receiver_types(tree: &Tree, lang: &str, code: &[u8]) {
|
||
use crate::ssa::type_facts::TypeKind;
|
||
let Some(lang_enum) = Lang::from_slug(lang) else {
|
||
return;
|
||
};
|
||
let mut out: HashMap<(usize, String), TypeKind> = HashMap::new();
|
||
walk_functions_for_locals(tree.root_node(), lang, lang_enum, code, &mut out);
|
||
LOCAL_RECEIVER_TYPES.with(|cell| *cell.borrow_mut() = out);
|
||
}
|
||
|
||
fn walk_functions_for_locals(
|
||
root: Node<'_>,
|
||
lang: &str,
|
||
lang_enum: Lang,
|
||
code: &[u8],
|
||
out: &mut HashMap<(usize, String), crate::ssa::type_facts::TypeKind>,
|
||
) {
|
||
if lookup(lang, root.kind()) == Kind::Function {
|
||
let fn_start = root.start_byte();
|
||
collect_locals_in_fn(root, fn_start, true, lang, lang_enum, code, out);
|
||
}
|
||
let mut cursor = root.walk();
|
||
for child in root.children(&mut cursor) {
|
||
walk_functions_for_locals(child, lang, lang_enum, code, out);
|
||
}
|
||
}
|
||
|
||
fn collect_locals_in_fn(
|
||
node: Node<'_>,
|
||
fn_start: usize,
|
||
is_root: bool,
|
||
lang: &str,
|
||
lang_enum: Lang,
|
||
code: &[u8],
|
||
out: &mut HashMap<(usize, String), crate::ssa::type_facts::TypeKind>,
|
||
) {
|
||
use crate::ssa::type_facts::constructor_type;
|
||
// Don't descend into nested function bodies — they own their own
|
||
// scope and get their own (fn_start, var_name) bindings via the
|
||
// outer walk.
|
||
if !is_root && lookup(lang, node.kind()) == Kind::Function {
|
||
return;
|
||
}
|
||
if node.kind() == "local_variable_declaration"
|
||
|| node.kind() == "variable_declarator"
|
||
|| node.kind() == "let_declaration"
|
||
|| node.kind() == "short_var_declaration"
|
||
|| node.kind() == "var_spec"
|
||
{
|
||
let mut cursor = node.walk();
|
||
for declarator in node.children(&mut cursor) {
|
||
if declarator.kind() != "variable_declarator" {
|
||
continue;
|
||
}
|
||
let Some(name_node) = declarator.child_by_field_name("name") else {
|
||
continue;
|
||
};
|
||
let Some(name) = text_of(name_node, code) else {
|
||
continue;
|
||
};
|
||
let Some(value_node) = declarator
|
||
.child_by_field_name("value")
|
||
.or_else(|| declarator.child_by_field_name("right"))
|
||
else {
|
||
continue;
|
||
};
|
||
// The RHS may be a chain like `sf.openSession()`; we want
|
||
// the callee text to feed `constructor_type`. For
|
||
// method_invocation / call_expression nodes, build the
|
||
// dotted callee path.
|
||
let Some(callee) = callee_text_for_constructor(value_node, lang, code) else {
|
||
continue;
|
||
};
|
||
if let Some(kind) = constructor_type(lang_enum, &callee) {
|
||
out.insert((fn_start, name), kind);
|
||
}
|
||
}
|
||
}
|
||
let mut cursor = node.walk();
|
||
for child in node.children(&mut cursor) {
|
||
collect_locals_in_fn(child, fn_start, false, lang, lang_enum, code, out);
|
||
}
|
||
}
|
||
|
||
fn callee_text_for_constructor(node: Node<'_>, lang: &str, code: &[u8]) -> Option<String> {
|
||
match lookup(lang, node.kind()) {
|
||
Kind::CallFn => node
|
||
.child_by_field_name("function")
|
||
.or_else(|| node.child_by_field_name("name"))
|
||
.and_then(|f| text_of(f, code)),
|
||
Kind::CallMethod => {
|
||
let method = node
|
||
.child_by_field_name("method")
|
||
.or_else(|| node.child_by_field_name("name"))
|
||
.and_then(|f| text_of(f, code))?;
|
||
let recv = node
|
||
.child_by_field_name("object")
|
||
.or_else(|| node.child_by_field_name("receiver"))
|
||
.or_else(|| node.child_by_field_name("scope"))
|
||
.and_then(|f| root_receiver_text(f, lang, code));
|
||
match recv {
|
||
Some(r) => Some(format!("{r}.{method}")),
|
||
None => Some(method),
|
||
}
|
||
}
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Walk up from `n` to find the enclosing function-kind node's
|
||
/// `start_byte`. Returns `None` for top-level nodes.
|
||
fn enclosing_fn_start(n: Node<'_>, lang: &str) -> Option<usize> {
|
||
let mut cur = n.parent()?;
|
||
loop {
|
||
if lookup(lang, cur.kind()) == Kind::Function {
|
||
return Some(cur.start_byte());
|
||
}
|
||
cur = cur.parent()?;
|
||
}
|
||
}
|
||
|
||
/// Look up `(fn_start, var_name)` in the per-file local-receiver-types
|
||
/// map populated by [`populate_local_receiver_types`]. Returns `None`
|
||
/// when no binding was recorded (no view published, name not bound, or
|
||
/// RHS callee not recognised by `constructor_type`).
|
||
pub(crate) fn lookup_local_receiver_type(
|
||
fn_start: usize,
|
||
var_name: &str,
|
||
) -> Option<crate::ssa::type_facts::TypeKind> {
|
||
LOCAL_RECEIVER_TYPES.with(|cell| {
|
||
cell.borrow()
|
||
.get(&(fn_start, var_name.to_string()))
|
||
.cloned()
|
||
})
|
||
}
|
||
|
||
/// Public entry consulted by `find_classifiable_inner_call`: given the
|
||
/// inner call's AST node and its bare receiver text, return the
|
||
/// `label_prefix()` for the receiver's locally-bound TypeKind, when
|
||
/// available. Returns `None` when no enclosing function is found, no
|
||
/// binding was recorded, or the bound `TypeKind` has no label prefix.
|
||
pub(crate) fn local_receiver_type_prefix(
|
||
inner_call: Node<'_>,
|
||
receiver: &str,
|
||
lang: &str,
|
||
) -> Option<&'static str> {
|
||
let fn_start = enclosing_fn_start(inner_call, lang)?;
|
||
let kind = lookup_local_receiver_type(fn_start, receiver)?;
|
||
kind.label_prefix()
|
||
}
|
||
|
||
/// Populate the per-file DFS-index map from a preorder walk of the
|
||
/// tree-sitter AST. Every node classifying as `Kind::Function` gets
|
||
/// a monotonically increasing `u32` starting at 0.
|
||
fn populate_fn_dfs_indices(tree: &Tree, lang: &str) {
|
||
fn walk(n: Node, lang: &str, counter: &mut u32, map: &mut HashMap<usize, u32>) {
|
||
if lookup(lang, n.kind()) == Kind::Function {
|
||
map.insert(n.start_byte(), *counter);
|
||
*counter += 1;
|
||
}
|
||
let mut c = n.walk();
|
||
for child in n.children(&mut c) {
|
||
walk(child, lang, counter, map);
|
||
}
|
||
}
|
||
let mut map = HashMap::new();
|
||
let mut counter: u32 = 0;
|
||
walk(tree.root_node(), lang, &mut counter, &mut map);
|
||
FN_DFS_INDICES.with(|cell| *cell.borrow_mut() = map);
|
||
}
|
||
|
||
/// Clear the per-file DFS-index map. Called at the end of `build_cfg`
|
||
/// to avoid leaking state between files on the same thread.
|
||
fn clear_fn_dfs_indices() {
|
||
FN_DFS_INDICES.with(|cell| cell.borrow_mut().clear());
|
||
}
|
||
|
||
/// Lookup a function node's DFS index by its `start_byte`.
|
||
fn fn_dfs_index(start_byte: usize) -> Option<u32> {
|
||
FN_DFS_INDICES.with(|cell| cell.borrow().get(&start_byte).copied())
|
||
}
|
||
|
||
/// Synthetic name for an anonymous function: `<anon#N>` from the DFS
|
||
/// index when available, `<anon@OFFSET>` as fallback.
|
||
pub(crate) fn anon_fn_name(start_byte: usize) -> String {
|
||
match fn_dfs_index(start_byte) {
|
||
Some(idx) => format!("<anon#{idx}>"),
|
||
None => format!("<anon@{start_byte}>"),
|
||
}
|
||
}
|
||
|
||
/// True for any anonymous-function synthesis prefix.
|
||
pub(crate) fn is_anon_fn_name(name: &str) -> bool {
|
||
name.starts_with("<anon#") || name.starts_with("<anon@")
|
||
}
|
||
|
||
/// -------------------------------------------------------------------------
|
||
/// Public AST‑to‑CFG data structures
|
||
/// -------------------------------------------------------------------------
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||
pub enum StmtKind {
|
||
Entry,
|
||
Exit,
|
||
#[default]
|
||
Seq,
|
||
If,
|
||
Loop,
|
||
Break,
|
||
Continue,
|
||
Return,
|
||
Throw,
|
||
Call,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum EdgeKind {
|
||
Seq, // ordinary fall‑through
|
||
True, // `cond == true` branch
|
||
False, // `cond == false` branch
|
||
Back, // back‑edge that closes a loop
|
||
Exception, // from call/throw inside try body → catch entry
|
||
}
|
||
|
||
/// Maximum number of identifiers to store from a condition expression.
|
||
pub(super) const MAX_COND_VARS: usize = 8;
|
||
pub(super) const MAX_CONDITION_TEXT_LEN: usize = 256;
|
||
|
||
/// Binary operator extracted from the AST.
|
||
///
|
||
/// Only set when the SSA assignment maps one-to-one to a single
|
||
/// binary expression. Left `None` for nested, compound, or boolean
|
||
/// expressions.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||
pub enum BinOp {
|
||
Add,
|
||
Sub,
|
||
Mul,
|
||
Div,
|
||
Mod,
|
||
// Bitwise
|
||
BitAnd,
|
||
BitOr,
|
||
BitXor,
|
||
LeftShift,
|
||
RightShift,
|
||
// Comparison
|
||
Eq,
|
||
NotEq,
|
||
Lt,
|
||
LtEq,
|
||
Gt,
|
||
GtEq,
|
||
}
|
||
|
||
impl BinOp {
|
||
/// True for the six comparison operators (result is a boolean 0/1).
|
||
pub fn is_comparison(self) -> bool {
|
||
matches!(
|
||
self,
|
||
BinOp::Eq | BinOp::NotEq | BinOp::Lt | BinOp::LtEq | BinOp::Gt | BinOp::GtEq
|
||
)
|
||
}
|
||
}
|
||
|
||
/// A branch condition captured as a pure integer-arithmetic + comparison
|
||
/// expression tree at CFG-build time (where the real tree-sitter AST is
|
||
/// available, so operator precedence and parentheses are correct by
|
||
/// construction — no text re-parsing downstream).
|
||
///
|
||
/// Built only when *every* leaf is an integer literal or a plain identifier
|
||
/// and *every* interior node is an arithmetic / comparison / bitwise operator,
|
||
/// a unary `-`, or a parenthesis. Any call, field access, string, container,
|
||
/// or compound-boolean (`&&` / `||`) subtree makes the builder return `None`
|
||
/// for the whole condition. Identifiers are stored by name and resolved to
|
||
/// their constant SSA value at fold time
|
||
/// ([`crate::ssa::const_prop::fold_constant_branches`]); the actual numeric
|
||
/// evaluation is shared in [`CondArith::eval`].
|
||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||
pub enum CondArith {
|
||
/// Integer literal.
|
||
Lit(i64),
|
||
/// Identifier — resolved to a constant integer at fold time, else unknown.
|
||
Var(String),
|
||
/// Unary integer negation: `-x`.
|
||
Neg(Box<CondArith>),
|
||
/// Binary arithmetic / bitwise / comparison.
|
||
Bin(BinOp, Box<CondArith>, Box<CondArith>),
|
||
}
|
||
|
||
/// Result of folding a [`CondArith`] against a constant environment.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum CondVal {
|
||
Int(i64),
|
||
Bool(bool),
|
||
}
|
||
|
||
impl CondArith {
|
||
/// Evaluate against a variable→constant-integer resolver. Returns `None`
|
||
/// the moment anything is non-constant or an operation is undefined
|
||
/// (division/modulo by zero, arithmetic overflow, type mismatch), so a
|
||
/// caller can only ever prune on a *definite* result. All integer
|
||
/// arithmetic is checked; overflow yields `None` rather than a wrapped
|
||
/// value, which keeps the fold sound across the i32/i64 gap.
|
||
pub fn eval(&self, resolve: &impl Fn(&str) -> Option<i64>) -> Option<CondVal> {
|
||
match self {
|
||
CondArith::Lit(n) => Some(CondVal::Int(*n)),
|
||
CondArith::Var(name) => resolve(name).map(CondVal::Int),
|
||
CondArith::Neg(inner) => match inner.eval(resolve)? {
|
||
CondVal::Int(n) => n.checked_neg().map(CondVal::Int),
|
||
CondVal::Bool(_) => None,
|
||
},
|
||
CondArith::Bin(op, l, r) => {
|
||
let lhs = match l.eval(resolve)? {
|
||
CondVal::Int(n) => n,
|
||
CondVal::Bool(_) => return None,
|
||
};
|
||
let rhs = match r.eval(resolve)? {
|
||
CondVal::Int(n) => n,
|
||
CondVal::Bool(_) => return None,
|
||
};
|
||
let arith = |v: Option<i64>| v.map(CondVal::Int);
|
||
match op {
|
||
BinOp::Add => arith(lhs.checked_add(rhs)),
|
||
BinOp::Sub => arith(lhs.checked_sub(rhs)),
|
||
BinOp::Mul => arith(lhs.checked_mul(rhs)),
|
||
// Java/Rust integer division and modulo both truncate
|
||
// toward zero; `checked_*` rejects div-by-zero and
|
||
// i64::MIN / -1 overflow.
|
||
BinOp::Div => arith(lhs.checked_div(rhs)),
|
||
BinOp::Mod => arith(lhs.checked_rem(rhs)),
|
||
BinOp::BitAnd => arith(Some(lhs & rhs)),
|
||
BinOp::BitOr => arith(Some(lhs | rhs)),
|
||
BinOp::BitXor => arith(Some(lhs ^ rhs)),
|
||
BinOp::LeftShift => u32::try_from(rhs)
|
||
.ok()
|
||
.and_then(|s| lhs.checked_shl(s))
|
||
.map(CondVal::Int),
|
||
BinOp::RightShift => u32::try_from(rhs)
|
||
.ok()
|
||
.and_then(|s| lhs.checked_shr(s))
|
||
.map(CondVal::Int),
|
||
BinOp::Eq => Some(CondVal::Bool(lhs == rhs)),
|
||
BinOp::NotEq => Some(CondVal::Bool(lhs != rhs)),
|
||
BinOp::Lt => Some(CondVal::Bool(lhs < rhs)),
|
||
BinOp::LtEq => Some(CondVal::Bool(lhs <= rhs)),
|
||
BinOp::Gt => Some(CondVal::Bool(lhs > rhs)),
|
||
BinOp::GtEq => Some(CondVal::Bool(lhs >= rhs)),
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Evaluate to a definite boolean, or `None`. The top-level node must be a
|
||
/// comparison (a bare integer is not a branch condition we fold).
|
||
pub fn eval_bool(&self, resolve: &impl Fn(&str) -> Option<i64>) -> Option<bool> {
|
||
match self.eval(resolve)? {
|
||
CondVal::Bool(b) => Some(b),
|
||
CondVal::Int(_) => None,
|
||
}
|
||
}
|
||
|
||
/// Collect every identifier name referenced by the tree.
|
||
pub fn collect_vars(&self, out: &mut Vec<String>) {
|
||
match self {
|
||
CondArith::Lit(_) => {}
|
||
CondArith::Var(name) => {
|
||
if !out.iter().any(|v| v == name) {
|
||
out.push(name.clone());
|
||
}
|
||
}
|
||
CondArith::Neg(inner) => inner.collect_vars(out),
|
||
CondArith::Bin(_, l, r) => {
|
||
l.collect_vars(out);
|
||
r.collect_vars(out);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Call-related metadata for CFG nodes.
|
||
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||
pub struct CallMeta {
|
||
pub callee: Option<String>,
|
||
/// Original textual callee path (e.g. `"c.mu.Lock"`) preserved for legacy
|
||
/// consumers when SSA lowering decomposes a chained-receiver call into a
|
||
/// `FieldProj` chain plus a bare-method `Call`.
|
||
///
|
||
/// CFG construction does NOT populate this field today (callee already
|
||
/// carries the full path). It is the canonical place to read the original
|
||
/// textual callee for **debug/display only**, analysis code should walk
|
||
/// SSA `FieldProj` receivers or use the
|
||
/// [`crate::labels::bare_method_name`] textual fallback.
|
||
#[doc(hidden)]
|
||
#[serde(default)]
|
||
pub callee_text: Option<String>,
|
||
/// When `find_classifiable_inner_call` overrides the primary callee
|
||
/// (e.g. `parts.add(req.getParameter("input"))` → callee becomes
|
||
/// "req.getParameter"), this field preserves the original outer callee
|
||
/// ("parts.add") so container propagation can still recognise it.
|
||
pub outer_callee: Option<String>,
|
||
/// Byte span of the inner call that supplied the classification, when
|
||
/// `find_classifiable_inner_call` overrode the outer callee. `None` when
|
||
/// the classification came from the outer AST node directly, in that
|
||
/// case `AstMeta.span` already points at the classified expression.
|
||
///
|
||
/// Consumers that want the location of the *labeled* call (sink/source/
|
||
/// sanitizer display, flow-step rendering, taint origin attribution)
|
||
/// should use [`NodeInfo::classification_span`] rather than reading this
|
||
/// field directly. `AstMeta.span` remains the authoritative "whole
|
||
/// statement" span, used by structural passes (unreachability,
|
||
/// resource lifecycle, guard byte scans, CFG/taint span dedup).
|
||
#[serde(default)]
|
||
pub callee_span: Option<(usize, usize)>,
|
||
/// Per-function call ordinal (0-based, only meaningful for Call nodes).
|
||
pub call_ordinal: u32,
|
||
/// Per-argument identifiers for Call nodes. Each inner Vec holds the
|
||
/// identifiers from one argument expression, in parameter-position order.
|
||
/// Empty for non-call nodes or when argument boundaries can't be determined.
|
||
pub arg_uses: Vec<Vec<String>>,
|
||
/// For `CallMethod` nodes: the receiver identifier (e.g. `tainted` in
|
||
/// `tainted.foo()`). `None` for non-method calls or complex receivers
|
||
/// (member expressions, call expressions, etc.).
|
||
pub receiver: Option<String>,
|
||
/// For gated sinks: which argument positions carry the tainted payload.
|
||
/// When `Some`, only variables from these `arg_uses` positions are checked
|
||
/// for taint. `None` = all arguments are payload (default).
|
||
pub sink_payload_args: Option<Vec<usize>>,
|
||
/// Keyword/named arguments attached to this call, in source order.
|
||
///
|
||
/// Each entry is `(keyword_name, uses)` where `uses` are the identifier
|
||
/// references from the keyword's value expression (same shape as an entry
|
||
/// in `arg_uses`). Populated for languages that expose named arguments
|
||
/// at the call site (e.g. Python `func(shell=True)`, Ruby hash-arg style).
|
||
/// Empty for languages without named arguments and for calls that use
|
||
/// only positional arguments.
|
||
pub kwargs: Vec<(String, Vec<String>)>,
|
||
/// String-literal value at each positional argument of this call, parallel
|
||
/// to `arg_uses`, `Some(s)` when the argument is a syntactic string
|
||
/// literal, `None` otherwise. Empty for non-call nodes or when positional
|
||
/// boundaries can't be determined. Consumed by the static-map abstract
|
||
/// analysis (and future literal-aware passes) so they don't need the
|
||
/// source bytes.
|
||
pub arg_string_literals: Vec<Option<String>>,
|
||
/// Destination-aware sink filter for outbound-HTTP gates.
|
||
///
|
||
/// When `Some(names)`, the SSA sink scan restricts taint checks to
|
||
/// identifiers whose `var_name` matches one of `names`. Populated by
|
||
/// gated sinks whose activation is [`crate::labels::GateActivation::Destination`]
|
||
/// with `object_destination_fields` set and whose positional destination
|
||
/// arg is an object literal: CFG walks the object literal, collects
|
||
/// identifiers from the named destination fields (url, host, path, …),
|
||
/// and stores them here so `fetch({url: taintedUrl, body: fixed})` fires
|
||
/// while `fetch({url: fixed, body: taintedData})` does not.
|
||
///
|
||
/// Takes priority over `sink_payload_args` in the SSA sink scan: when a
|
||
/// call has an object-literal destination arg, only idents under the
|
||
/// listed fields may contribute sink findings, not every ident in the
|
||
/// positional slot.
|
||
///
|
||
/// Legacy single-gate path: populated only when this call site matched
|
||
/// exactly one gate. When a callee carries multiple gates (e.g. `fetch`
|
||
/// is both an SSRF and a `DATA_EXFIL` gate), per-gate filters live in
|
||
/// [`Self::gate_filters`] and this field is left `None`.
|
||
#[serde(default)]
|
||
pub destination_uses: Option<Vec<String>>,
|
||
/// Per-gate filters for callees that carry multiple gated-sink rules.
|
||
///
|
||
/// Each entry preserves one matching gate's `(label_caps, payload_args,
|
||
/// destination_uses)` so the SSA sink scan can attribute findings
|
||
/// per-cap. Empty when the call site matches zero or exactly one gate
|
||
/// (the single-gate case continues to use [`Self::sink_payload_args`] +
|
||
/// [`Self::destination_uses`]).
|
||
#[serde(default)]
|
||
pub gate_filters: Vec<GateFilter>,
|
||
/// True when this call expression is a constructor invocation
|
||
/// (e.g. JS/TS `new Stripe(key)`, PHP `new PDO(...)`). The SSA Call
|
||
/// transfer uses this to narrow the constructed value's caps: a wrapper
|
||
/// object instance is structurally not a path string, format string,
|
||
/// URL component, or JSON input, so out-of-process side-effect bits
|
||
/// (FILE_IO, FMT_STRING, URL_ENCODE, JSON_PARSE) on the arguments
|
||
/// must not survive into the constructed object.
|
||
#[serde(default)]
|
||
pub is_constructor: bool,
|
||
/// True when this call is `Object.create(null)` (or alias). The returned
|
||
/// value has no prototype chain. Consumed by TypeFacts to tag the
|
||
/// SsaValue with [`crate::ssa::type_facts::TypeKind::NullPrototypeObject`]
|
||
/// so PROTOTYPE_POLLUTION suppression can fire flow-sensitively at the
|
||
/// synthetic `__index_set__` sink. Set during CFG node construction so
|
||
/// SSA does not need to re-walk the AST.
|
||
#[serde(default)]
|
||
pub produces_null_proto: bool,
|
||
}
|
||
|
||
/// One gate's contribution at a call site whose callee matches multiple
|
||
/// gates. The SSA taint engine processes each filter independently so a
|
||
/// `fetch({url: tainted}, {body: tainted})` flow surfaces as one SSRF
|
||
/// finding (URL filter) plus one `DATA_EXFIL` finding (body filter), each
|
||
/// carrying its own cap mask rather than a conflated union.
|
||
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||
pub struct GateFilter {
|
||
/// Sink caps emitted by this gate (e.g. `Cap::SSRF`, `Cap::DATA_EXFIL`).
|
||
pub label_caps: crate::labels::Cap,
|
||
/// Argument positions that carry the tainted payload for this gate.
|
||
pub payload_args: Vec<usize>,
|
||
/// Destination-aware filter: when `Some(names)`, the sink check only
|
||
/// considers SSA values whose `var_name` matches one of `names` (object-
|
||
/// literal destination fields lifted at CFG time). `None` ⇒ whole arg.
|
||
pub destination_uses: Option<Vec<String>>,
|
||
/// Parallel to [`Self::destination_uses`]: for each entry, the
|
||
/// destination object-literal field name (e.g. `"body"`, `"headers"`,
|
||
/// `"json"`) where the corresponding ident was bound. Empty when
|
||
/// `destination_uses` is `None` or the gate had no
|
||
/// `object_destination_fields` configured. Consumed by diag rendering
|
||
/// to embed the destination field in `DATA_EXFIL` messages and SARIF
|
||
/// `properties.data_exfil_field`.
|
||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||
pub destination_fields: Vec<String>,
|
||
}
|
||
|
||
/// Taint-classification and variable-flow metadata.
|
||
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||
pub struct TaintMeta {
|
||
pub labels: SmallVec<[DataLabel; 2]>, // taint classifications (multi-label)
|
||
/// Raw text of a constant/literal RHS when this node defines a variable
|
||
/// from a syntactic literal with no uses. Used by SSA constant propagation.
|
||
pub const_text: Option<String>,
|
||
pub defines: Option<String>, // variable written by this stmt
|
||
pub uses: Vec<String>, // variables read
|
||
/// Additional variable definitions from destructuring patterns.
|
||
/// E.g. `const { a, b, c } = source()` → defines="a", extra_defines=["b", "c"].
|
||
pub extra_defines: Vec<String>,
|
||
/// Pattern-position indices for array-pattern destructure bindings.
|
||
/// When non-empty, `array_pattern_indices[0]` is the position index for
|
||
/// `defines`, and `array_pattern_indices[1..]` are the indices for each
|
||
/// element of `extra_defines` in order. Populated only when the LHS is
|
||
/// an `array_pattern` (or tuple_pattern) so consumers can map binding
|
||
/// positions back to source-order arguments — e.g. `const [, b] =
|
||
/// Promise.all([safe, tainted])` records `array_pattern_indices=[1]`
|
||
/// so the SSA destructure-promise rewrite picks index 1 (tainted)
|
||
/// instead of index 0 (safe). Empty for object-destructure, plain
|
||
/// single-binding assignments, and non-array patterns.
|
||
#[serde(default, skip_serializing_if = "SmallVec::is_empty")]
|
||
pub array_pattern_indices: SmallVec<[usize; 4]>,
|
||
/// Source-order RHS array-literal slots for destructure assignments.
|
||
/// Populated only when the LHS is a destructure pattern (`array_pattern`,
|
||
/// `tuple_pattern`, `pattern_list`, `left_assignment_list`) AND the RHS
|
||
/// is an array-literal shape (JS/TS `array`, Python `list`/`tuple`/
|
||
/// `expression_list`, Ruby `array`, Rust `tuple_expression`). Each slot
|
||
/// carries one of: a bare identifier (`Ident`), a syntactic literal
|
||
/// (`Literal`), or a complex expression with its inner identifier uses
|
||
/// (`Complex`). Empty when the RHS shape doesn't match OR a slot is
|
||
/// unrepresentable (spread / list splat) — callers fall back to the
|
||
/// existing scalar-union behavior in that case.
|
||
///
|
||
/// Used by the SSA destructure rewrite in `lower.rs` so each binding sees
|
||
/// only its index's element instead of the scalar union of every ident on
|
||
/// the RHS. Closes FPs like `const [a, b] = [safe, tainted]; exec(b);`
|
||
/// (Ident shape) as well as `const [c, d] = [fn(req.x), 'lit']; exec(d);`
|
||
/// (Complex shape) where the legacy union painted `d` with `req.x`.
|
||
#[serde(default, skip_serializing_if = "SmallVec::is_empty")]
|
||
pub rhs_array_elements: SmallVec<[RhsArraySlot; 4]>,
|
||
}
|
||
|
||
/// Source-order slot for an RHS array-literal element in a destructure
|
||
/// assignment. See [`TaintMeta::rhs_array_elements`] for context.
|
||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||
pub enum RhsArraySlot {
|
||
/// Bare identifier (`safe`, `$user`, `req`). The SSA lowering looks up
|
||
/// the reaching def via `var_stacks` and emits an `Assign` of that value.
|
||
Ident(String),
|
||
/// Syntactic literal (string, number, bool, null/nil/None). The SSA
|
||
/// lowering emits a `Const(None)` so the binding carries no taint.
|
||
Literal,
|
||
/// Complex expression (call, binary, subscript, member access, nested
|
||
/// array literal). Carries the inner identifier uses harvested from the
|
||
/// slot's subtree plus a per-slot `source_cap` recognised by classifying
|
||
/// the slot's own subtree (via `first_member_label`).
|
||
///
|
||
/// When `source_cap` is non-empty the SSA lowering knows the source
|
||
/// pattern lives in THIS slot and emits `SsaOp::Source` for the binding.
|
||
/// Sibling Complex slots whose `source_cap` is empty fall through to the
|
||
/// slot-scoped `Assign(inner reaching defs)` path, so a safe Complex
|
||
/// sibling stops inheriting the outer node's Source label.
|
||
Complex {
|
||
uses: SmallVec<[String; 4]>,
|
||
#[serde(default, skip_serializing_if = "crate::labels::Cap::is_empty")]
|
||
source_cap: crate::labels::Cap,
|
||
},
|
||
}
|
||
|
||
/// AST origin/location metadata.
|
||
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||
pub struct AstMeta {
|
||
pub span: (usize, usize), // byte offsets in the original file
|
||
/// Name of the enclosing function (set during CFG construction).
|
||
pub enclosing_func: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||
pub struct NodeInfo {
|
||
pub kind: StmtKind,
|
||
pub call: CallMeta,
|
||
pub taint: TaintMeta,
|
||
pub ast: AstMeta,
|
||
/// For If nodes: raw condition text (truncated to 256 chars). None for non-If nodes.
|
||
pub condition_text: Option<String>,
|
||
/// For If nodes: identifiers referenced in the condition (sorted, deduped, max 8).
|
||
pub condition_vars: Vec<String>,
|
||
/// For If nodes: whether the condition has a leading negation (`!` / `not`).
|
||
pub condition_negated: bool,
|
||
/// For If / conditional (ternary) nodes: the condition as a pure
|
||
/// integer-arithmetic + comparison expression tree, when the whole
|
||
/// condition is built only from integer literals, identifiers, arithmetic
|
||
/// / comparison operators, and parentheses. `None` for any condition that
|
||
/// touches a call, field access, string, compound boolean (`&&`/`||`), or
|
||
/// any shape this evaluator cannot prove constant. Consumed by
|
||
/// [`crate::ssa::const_prop::fold_constant_branches`] to prune branches
|
||
/// whose condition folds to a definite boolean once its variables are
|
||
/// resolved to constants — closing the synthetic "dead branch keeps the
|
||
/// tainted phi operand alive" false positive without any text re-parsing.
|
||
pub cond_arith: Option<CondArith>,
|
||
/// True when this is a Call node whose argument list contains only
|
||
/// syntactic literal values (strings, numbers, booleans, null/nil,
|
||
/// arrays/lists/tuples of literals). Also true for zero-argument calls
|
||
/// (no argument-carried taint vector).
|
||
///
|
||
/// This flag is scoped to taint-style sink suppression: it indicates
|
||
/// that no attacker-controlled data enters through the immediate
|
||
/// arguments. It does NOT mean the call is "safe" in general, other
|
||
/// detectors (resource lifecycle, structural analysis) may still
|
||
/// legitimately flag these calls.
|
||
pub all_args_literal: bool,
|
||
/// True for synthetic catch-parameter nodes injected at catch clause entry.
|
||
/// The taint transfer function uses this to conservatively taint the
|
||
/// caught exception variable.
|
||
pub catch_param: bool,
|
||
/// For Call nodes: the callee name of the call expression wrapping each
|
||
/// argument (per-position, matching arg_uses). For Assignment sink nodes:
|
||
/// the RHS call callee at position 0 (if the RHS is a call expression).
|
||
/// Used by SSA sink detection for interprocedural sanitizer resolution.
|
||
pub arg_callees: Vec<Option<String>>,
|
||
/// For cast/type-assertion expressions: the target type name extracted
|
||
/// from the AST. E.g. `(String) x` → `"String"`, `x as number` → `"number"`,
|
||
/// `x.(io.Reader)` → `"io.Reader"`. Used by type-flow constraint solving
|
||
/// to refine the type environment at the SSA level.
|
||
pub cast_target_type: Option<String>,
|
||
/// Arithmetic operator for binary expression assignments.
|
||
/// Only set when the CFG node is a single binary expression with a
|
||
/// clear one-to-one operator mapping. `None` for nested, compound,
|
||
/// boolean, or ambiguous expressions.
|
||
pub bin_op: Option<BinOp>,
|
||
/// Parsed literal operand from a binary expression.
|
||
/// When `bin_op` is set and one operand is a numeric literal (the other
|
||
/// being an identifier captured in `uses`), this holds the parsed value.
|
||
/// Enables abstract-domain transfer even when the SSA instruction has
|
||
/// only one use (the literal isn't an identifier and isn't in `uses`).
|
||
pub bin_op_const: Option<i64>,
|
||
/// True when this acquisition node is inside a language-managed cleanup
|
||
/// scope (Python `with`, Java try-with-resources, C# `using`).
|
||
/// Only meaningful on Call nodes that define a resource variable.
|
||
/// Leak detectors check this flag on the acquire site, not the variable.
|
||
pub managed_resource: bool,
|
||
/// True when this Call node is a deferred release (Go `defer f.Close()`).
|
||
/// Deferred releases are not processed as immediate closes; instead they
|
||
/// suppress leak findings (defer guarantees cleanup at function exit).
|
||
/// Only set on Call nodes, not on all nodes within a defer_statement.
|
||
pub in_defer: bool,
|
||
/// True when this is a SQL_QUERY sink whose first argument is a string
|
||
/// literal containing parameterized-query placeholders (`$1`, `?`, `%s`,
|
||
/// `:name`) AND the call has >= 2 arguments (the params array/tuple).
|
||
/// Both CFG analysis and SSA taint suppress findings on such nodes.
|
||
pub parameterized_query: bool,
|
||
/// Constant leading string prefix recovered from the node's RHS when it
|
||
/// is a template literal (JS/TS) with a leading `string_fragment` or an
|
||
/// equivalent constant-string-then-interpolation shape. Populated for
|
||
/// assignment-like nodes (`variable_declarator`, `assignment_expression`,
|
||
/// `lexical_declaration`). Consumed by the abstract string domain in
|
||
/// `transfer_abstract` to seed a `StringFact::from_prefix` on the result
|
||
/// SSA value so SSRF prefix-suppression can fire for values constructed
|
||
/// from template literals.
|
||
pub string_prefix: Option<String>,
|
||
/// True when this node is a binary equality/inequality expression whose
|
||
/// operator is `==` / `!=` / `===` / `!==` and exactly one operand is a
|
||
/// syntactic literal (string / number / null / boolean). The SSA taint
|
||
/// transfer uses this to suppress boolean-result taint propagation: the
|
||
/// boolean outcome of `x === 'literal'` carries no attacker-controlled
|
||
/// data, so downstream branches on it should not inherit x's caps.
|
||
pub is_eq_with_const: bool,
|
||
/// True when this node reads a numeric-length property on a container:
|
||
/// `arr.length`, `map.size`, `buf.byteLength`, `items.count`, `vec.len()`
|
||
///, either as a pure property access or as a zero-arg method call.
|
||
/// Populated by inspecting the AST in `push_node` across JS/TS, Python,
|
||
/// Ruby, Java, Rust, PHP, and C/C++ idioms where these accessors return
|
||
/// an integer. Consumed by the type-fact analysis (`ssa::type_facts`)
|
||
/// to infer `TypeKind::Int`, which drives HTML_ESCAPE / SQL_QUERY /
|
||
/// FILE_IO / SHELL_ESCAPE sink suppression for provably numeric
|
||
/// payloads.
|
||
pub is_numeric_length_access: bool,
|
||
/// the field name read on the RHS of an assignment whose
|
||
/// RHS is a single member-access expression (e.g. `let x = dto.email`).
|
||
/// Set to `Some("email")` for that shape; left `None` otherwise.
|
||
/// Consumed by the type-fact analysis (`ssa::type_facts`) so reads
|
||
/// against a [`crate::ssa::type_facts::TypeKind::Dto`] receiver pick
|
||
/// up the field's declared `TypeKind`. Strictly additive, when
|
||
/// `None`, the legacy copy-prop semantics apply.
|
||
pub member_field: Option<String>,
|
||
/// True when this assignment / declaration's RHS is a function or
|
||
/// lambda literal (`obj.handler = (e) => {...}`, `let f = function(){}`).
|
||
/// State analysis uses this to suppress resource-ownership transfer:
|
||
/// storing a function reference into a property does not move the
|
||
/// resources captured by the closure body, so the lifecycle of those
|
||
/// captures must remain unchanged on the assignment node.
|
||
pub rhs_is_function_literal: bool,
|
||
/// True when this CFG node was produced from a tree-sitter
|
||
/// `await_expression` (JS/TS `Kind::AwaitForward`). The SSA lowering
|
||
/// emits `SsaOp::Assign(operand)` for such nodes so taint, origins,
|
||
/// and abstract-domain facts forward 1:1 across the await boundary.
|
||
/// Strictly additive: when `false`, legacy lowering applies.
|
||
#[serde(default)]
|
||
pub is_await_forward: bool,
|
||
}
|
||
|
||
impl NodeInfo {
|
||
/// Byte span of the *labeled* sub-expression in this CFG node.
|
||
///
|
||
/// When `find_classifiable_inner_call` found the source/sink/sanitizer
|
||
/// deep inside an enclosing statement (e.g. `escapeHtml(...)` buried in
|
||
/// a template literal whose outer node is the `overlay.innerHTML = ...`
|
||
/// assignment), `call.callee_span` pinpoints the inner call; otherwise
|
||
/// the whole node's span is the classification span.
|
||
///
|
||
/// Use this for **display and source-attribution**: taint finding sink
|
||
/// lines, flow-step rendering, symbolic witness extraction, debug views.
|
||
///
|
||
/// Use `ast.span` directly for **structural grain**: unreachability,
|
||
/// resource lifecycle, guard byte scans, CFG/taint span dedup, anywhere
|
||
/// the enclosing statement is the meaningful unit.
|
||
#[inline]
|
||
pub fn classification_span(&self) -> (usize, usize) {
|
||
self.call.callee_span.unwrap_or(self.ast.span)
|
||
}
|
||
}
|
||
|
||
/// Intra‑file function summary with graph‑local node indices.
|
||
///
|
||
/// Keeps all three cap dimensions independently so that a function that is
|
||
/// *both* a source and a sink (e.g. reads env then shells out) does not
|
||
/// lose information.
|
||
#[derive(Debug, Clone)]
|
||
pub struct LocalFuncSummary {
|
||
pub entry: NodeIndex,
|
||
pub source_caps: Cap,
|
||
pub sanitizer_caps: Cap,
|
||
pub sink_caps: Cap,
|
||
pub param_count: usize,
|
||
pub param_names: Vec<String>,
|
||
/// Which parameter indices (0‑based) flow through to the return value.
|
||
pub propagating_params: Vec<usize>,
|
||
/// Which parameter indices flow to internal sinks.
|
||
pub tainted_sink_params: Vec<usize>,
|
||
/// Per-call-site metadata for every call inside this function body.
|
||
/// Each entry carries the callee's raw name plus arity, receiver,
|
||
/// qualifier, and ordinal so callers can resolve overloads and
|
||
/// method-call targets without re-parsing.
|
||
pub callees: Vec<crate::summary::CalleeSite>,
|
||
/// Identity discriminator: enclosing container path, `""` for free
|
||
/// top-level functions. Copied into `FuncSummary.container` at export.
|
||
pub container: String,
|
||
/// Identity discriminator: byte offset / occurrence index for disambiguating
|
||
/// same-name siblings (closures, duplicate defs).
|
||
pub disambig: Option<u32>,
|
||
/// Structural role of this definition.
|
||
pub kind: crate::symbol::FuncKind,
|
||
}
|
||
|
||
pub type Cfg = Graph<NodeInfo, EdgeKind>;
|
||
pub type FuncSummaries = HashMap<FuncKey, LocalFuncSummary>;
|
||
|
||
// Per-body CFG types
|
||
|
||
/// Opaque identifier for an executable body within a file.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||
pub struct BodyId(pub u32);
|
||
|
||
/// Identifies the kind of executable body.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum BodyKind {
|
||
TopLevel,
|
||
NamedFunction,
|
||
AnonymousFunction,
|
||
}
|
||
|
||
/// Metadata for a single executable body.
|
||
#[derive(Debug, Clone)]
|
||
pub struct BodyMeta {
|
||
pub id: BodyId,
|
||
pub kind: BodyKind,
|
||
pub name: Option<String>,
|
||
pub params: Vec<String>,
|
||
/// Per-parameter [`crate::ssa::type_facts::TypeKind`] inferred from
|
||
/// decorators / annotations / static type text at CFG construction
|
||
/// time. Same length as `params`; positions with no recoverable
|
||
/// type info are `None`. Strictly additive, when every entry is
|
||
/// `None`, downstream behaviour is identical to the type-unaware path.
|
||
pub param_types: Vec<Option<crate::ssa::type_facts::TypeKind>>,
|
||
/// Per-parameter destructured-binding sibling names. Same length
|
||
/// as `params`; entry `i` lists field names bound by the same
|
||
/// argument slot as `params[i]`, excluding the primary name itself.
|
||
/// Empty for non-destructured params. Today populated only for
|
||
/// JS/TS object-pattern formals (`({ a, b, c })` → params=["a"],
|
||
/// destructured=[["b","c"]]). Used by per-parameter taint-summary
|
||
/// probing in `extract_ssa_func_summary` so destructured bindings
|
||
/// inside the body share the slot's seeded caps and any of them
|
||
/// being in `validated_must` at a return path counts as the slot
|
||
/// being validated. Closes the residual gap behind CVE-2026-25544.
|
||
pub param_destructured_fields: Vec<Vec<String>>,
|
||
pub param_count: usize,
|
||
pub span: (usize, usize),
|
||
pub parent_body_id: Option<BodyId>,
|
||
/// Canonical identity for this body.
|
||
///
|
||
/// `Some(..)` for named/anonymous function bodies, carrying the same
|
||
/// `FuncKey` under which `FileCfg::summaries` stores its
|
||
/// `LocalFuncSummary`. `None` for the synthetic top-level body.
|
||
///
|
||
/// All intra-file maps keyed on function identity (SSA summaries, callee
|
||
/// bodies, inline cache, callback bindings) use this key, never the bare
|
||
/// leaf `name`, which is collision-prone across (container, arity,
|
||
/// disambig, kind).
|
||
pub func_key: Option<FuncKey>,
|
||
/// Normalized auth-decorator/annotation/attribute names attached to this
|
||
/// function (Python `@login_required`, Java `@PreAuthorize`, Ruby class
|
||
/// `before_action :authenticate_user!`, etc.). Lowercased, bare names
|
||
/// without `@`, `#[..]`, `[[..]]` wrappers or argument tails. The state
|
||
/// machine consumes this to seed the entry `AuthLevel` for privileged-sink
|
||
/// checks. Empty for top-level and for functions without auth markers.
|
||
pub auth_decorators: Vec<String>,
|
||
/// Per-formal route-capture flag. Same length as `params`. `true` at
|
||
/// position `i` iff the formal name appears as a path capture in a
|
||
/// framework routing decorator on this function (Flask
|
||
/// `@app.route("/users/<name>")`, blueprint-prefixed `@bp.get("/u/<int:id>")`,
|
||
/// FastAPI / Starlette verb decorators). Today populated only for Python.
|
||
/// The entry-kind seeding pass consults this for `FlaskRoute` so only
|
||
/// path-bound formals (not implicit globals or DI handles) are painted
|
||
/// as adversary input. Empty for top-level and for functions without
|
||
/// matching decorators.
|
||
pub param_route_capture: Vec<bool>,
|
||
}
|
||
|
||
/// A single executable body's CFG plus metadata.
|
||
#[derive(Debug)]
|
||
pub struct BodyCfg {
|
||
pub meta: BodyMeta,
|
||
pub graph: Cfg,
|
||
pub entry: NodeIndex,
|
||
}
|
||
|
||
/// A single import alias binding: local alias → original exported name + module.
|
||
#[derive(Debug, Clone)]
|
||
pub struct ImportBinding {
|
||
/// The original exported symbol name (e.g. `getInput`).
|
||
pub original: String,
|
||
/// The module path (e.g. `./source`), if extractable.
|
||
pub module_path: Option<String>,
|
||
}
|
||
|
||
/// Per-file map from locally-bound alias name to its import origin.
|
||
/// Populated during CFG construction for ES6 `import { A as B }` and
|
||
/// CommonJS `const { A: B } = require(...)` patterns.
|
||
pub type ImportBindings = HashMap<String, ImportBinding>;
|
||
|
||
/// A single promisify alias binding: local name bound to `util.promisify(X)`
|
||
/// carries the labels of its wrapped callee `X`.
|
||
#[derive(Debug, Clone)]
|
||
pub struct PromisifyAlias {
|
||
/// The wrapped callee's canonical textual name (e.g. `child_process.exec`
|
||
/// or `fs.readFile`). Used directly for label classification so downstream
|
||
/// sink / source detection treats the alias the same as the original.
|
||
pub wrapped: String,
|
||
}
|
||
|
||
/// Per-file map from local binding name to its promisify wrap origin.
|
||
/// Populated for JS/TS files at CFG construction time for patterns like
|
||
/// `const alias = util.promisify(wrapped)` or `const alias = promisify(wrapped)`.
|
||
pub type PromisifyAliases = HashMap<String, PromisifyAlias>;
|
||
|
||
/// All CFGs for a file.
|
||
#[derive(Debug)]
|
||
pub struct FileCfg {
|
||
pub bodies: Vec<BodyCfg>,
|
||
pub summaries: FuncSummaries,
|
||
/// Import alias bindings: local alias → (original name, module path).
|
||
pub import_bindings: ImportBindings,
|
||
/// Promisify wrapper aliases: local name → wrapped callee name.
|
||
/// Only populated for JS/TS files.
|
||
pub promisify_aliases: PromisifyAliases,
|
||
/// per-file class / trait / interface hierarchy edges.
|
||
/// Each entry is `(sub_container, super_container)` after
|
||
/// language-specific normalisation. See
|
||
/// `crate::cfg::hierarchy` for the per-language extraction
|
||
/// rules and [`crate::callgraph::TypeHierarchyIndex`] for the
|
||
/// downstream consumer. Empty for languages without an
|
||
/// extractor (Go, C) and for files with no inheritance / impl
|
||
/// declarations.
|
||
pub hierarchy_edges: Vec<(String, String)>,
|
||
/// Phase-04 resolver output: per-file import bindings resolved
|
||
/// against the project [`crate::resolve::ModuleGraph`]. Populated
|
||
/// post-`build_cfg` by `crate::ast::ParsedFile::from_source` when
|
||
/// a [`crate::resolve::ModuleGraph`] is available on the active
|
||
/// `Config`. Empty for non-JS/TS files, scans without a configured
|
||
/// resolver, and unit tests that build a CFG directly.
|
||
pub resolved_imports: Vec<crate::resolve::ImportBinding>,
|
||
/// Phase 10 — Next.js entry-point classification keyed by the
|
||
/// function definition's tree-sitter byte span. Populated for
|
||
/// JS/TS files, empty otherwise. The summary-extraction pipeline
|
||
/// matches against [`BodyMeta::span`] to attach the
|
||
/// [`crate::entry_points::EntryKind`] to the resulting summary.
|
||
pub entry_kinds: std::collections::HashMap<(usize, usize), crate::entry_points::EntryKind>,
|
||
/// Per-file local import view: local-name → source-module specifier.
|
||
/// Built once during JS/TS CFG construction (empty for other langs).
|
||
/// Consumed by gated label rules and by the ORM TypeKind import gate
|
||
/// in `crate::ssa::type_facts::constructor_type` (via the
|
||
/// `FILE_IMPORTS_TLS` thread-local set around per-body SSA passes).
|
||
pub local_imports: HashMap<String, String>,
|
||
/// Class fields whose `.get(...)` lookups are bounded to a finite
|
||
/// set of literal string values. Populated for Java
|
||
/// `final ... = Map.of(literal, literal, ...)` declarations; empty
|
||
/// for other languages and shapes. Consumed by the SSA taint
|
||
/// engine's container-Load fallback (via the
|
||
/// `JAVA_SAFE_FIELDS_TLS` thread-local) so a tainted lookup key
|
||
/// does not light up downstream sinks when the receiver is a
|
||
/// known-safe map field.
|
||
pub safe_lookup_fields: HashMap<String, Vec<String>>,
|
||
/// Class-level constant scalars: field name → literal text.
|
||
/// Populated for Java `static final TYPE NAME = LITERAL;` declarations
|
||
/// where the RHS is a primitive scalar literal (string, integer,
|
||
/// floating-point, char, boolean, null). Consumed by
|
||
/// `cfg_analysis::guards` to recognise sink arguments that resolve to
|
||
/// class-level constants (the per-function SSA const-prop sees a free
|
||
/// identifier and would otherwise treat the binding as runtime-dynamic).
|
||
/// Empty for non-Java files.
|
||
pub class_constant_scalars: HashMap<String, String>,
|
||
}
|
||
|
||
impl FileCfg {
|
||
/// The top-level / module body (always `BodyId(0)`).
|
||
pub fn toplevel(&self) -> &BodyCfg {
|
||
&self.bodies[0]
|
||
}
|
||
/// Look up a body by its `BodyId`.
|
||
pub fn body(&self, id: BodyId) -> &BodyCfg {
|
||
&self.bodies[id.0 as usize]
|
||
}
|
||
/// All non-top-level bodies (functions, closures, callbacks).
|
||
pub fn function_bodies(&self) -> &[BodyCfg] {
|
||
&self.bodies[1..]
|
||
}
|
||
/// The first function body, or top-level if no functions exist.
|
||
/// Useful for tests where source is wrapped in a single function.
|
||
pub fn first_body(&self) -> &BodyCfg {
|
||
if self.bodies.len() > 1 {
|
||
&self.bodies[1]
|
||
} else {
|
||
&self.bodies[0]
|
||
}
|
||
}
|
||
/// Total CFG node count across all bodies.
|
||
pub fn total_node_count(&self) -> usize {
|
||
self.bodies.iter().map(|b| b.graph.node_count()).sum()
|
||
}
|
||
}
|
||
|
||
/// Create a `NodeInfo` with only kind, span, and enclosing_func set.
|
||
/// All other fields are empty/default.
|
||
fn make_empty_node_info(
|
||
kind: StmtKind,
|
||
span: (usize, usize),
|
||
enclosing_func: Option<&str>,
|
||
) -> NodeInfo {
|
||
NodeInfo {
|
||
kind,
|
||
ast: AstMeta {
|
||
span,
|
||
enclosing_func: enclosing_func.map(|s| s.to_owned()),
|
||
},
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
/// Create a fresh body-level `Cfg` with synthetic Entry and Exit nodes.
|
||
fn create_body_graph(
|
||
span_start: usize,
|
||
span_end: usize,
|
||
enclosing_func: Option<&str>,
|
||
) -> (Cfg, NodeIndex, NodeIndex) {
|
||
let mut g: Cfg = Graph::with_capacity(32, 64);
|
||
let entry = g.add_node(make_empty_node_info(
|
||
StmtKind::Entry,
|
||
(span_start, span_start),
|
||
enclosing_func,
|
||
));
|
||
let exit = g.add_node(make_empty_node_info(
|
||
StmtKind::Exit,
|
||
(span_end, span_end),
|
||
enclosing_func,
|
||
));
|
||
(g, entry, exit)
|
||
}
|
||
|
||
/// Extract raw condition metadata from an If AST node.
|
||
///
|
||
/// Returns `(condition_text, condition_vars, condition_negated)`.
|
||
/// The condition subtree is located via `child_by_field_name("condition")`
|
||
/// for most languages, with a positional fallback for Rust `if_expression`.
|
||
///
|
||
/// Negation is detected by checking for a leading unary `!` operator or
|
||
/// `not` keyword. Variables are sorted, deduped, and capped at
|
||
/// [`MAX_COND_VARS`].
|
||
fn extract_condition_raw<'a>(
|
||
ast: Node<'a>,
|
||
lang: &str,
|
||
code: &'a [u8],
|
||
) -> (Option<String>, Vec<String>, bool, Option<CondArith>) {
|
||
// 1. Find the condition subtree.
|
||
let cond_node = ast.child_by_field_name("condition").or_else(|| {
|
||
// Rust `if_expression` uses positional children: the condition is
|
||
// the first child that is not a keyword, block, or `let` pattern.
|
||
let mut cursor = ast.walk();
|
||
ast.children(&mut cursor).find(|c| {
|
||
let k = c.kind();
|
||
!matches!(lookup(lang, k), Kind::Block | Kind::Trivia)
|
||
&& k != "if"
|
||
&& k != "else"
|
||
&& k != "let"
|
||
&& k != "{"
|
||
&& k != "}"
|
||
&& k != "("
|
||
&& k != ")"
|
||
})
|
||
});
|
||
|
||
let Some(cond) = cond_node else {
|
||
return (None, Vec::new(), false, None);
|
||
};
|
||
|
||
// 2. Detect leading negation (`!expr`, `not expr`, Ruby `unless`).
|
||
let (inner, negated) = detect_negation(cond, ast, lang);
|
||
|
||
// 3. Collect identifiers from the (inner) condition subtree.
|
||
let mut vars = Vec::new();
|
||
collect_idents(inner, code, &mut vars);
|
||
vars.sort();
|
||
vars.dedup();
|
||
vars.truncate(MAX_COND_VARS);
|
||
|
||
// 4. Extract text, truncated. UTF-8-safe, gogs (Gurmukhi) /
|
||
// discourse (Cyrillic) trip raw byte slices on regex literals.
|
||
let text = text_of(cond, code)
|
||
.map(|t| truncate_at_char_boundary(&t, MAX_CONDITION_TEXT_LEN).to_string());
|
||
|
||
// 5. Capture the pure integer-arithmetic + comparison tree (for constant
|
||
// branch folding). Built from the FULL condition node `cond` (not the
|
||
// negation-stripped `inner`) so the folded boolean matches the
|
||
// Branch terminator's `true_blk = cond-true` semantics directly. Ruby
|
||
// `unless` swaps the True/False edges in the CFG builder (lines
|
||
// ~5029), so the branch polarity would be inverted — skip it to stay
|
||
// sound (`unless` with a constant arithmetic guard is negligible).
|
||
let cond_arith = if ast.kind() == "unless" {
|
||
None
|
||
} else {
|
||
build_cond_arith(cond, lang, code, 0)
|
||
};
|
||
|
||
(text, vars, negated, cond_arith)
|
||
}
|
||
|
||
/// Detect leading negation and return the inner expression.
|
||
///
|
||
/// Handles:
|
||
/// - `!expr` (unary_expression / prefix_unary_expression with `!` operator)
|
||
/// - `not expr` (Python `not_operator`, Ruby)
|
||
///
|
||
/// NOTE: Ruby `unless` is NOT handled here. The CFG builder already swaps
|
||
/// True/False edges for `unless` (cfg.rs lines 2076-2085), so the edge labels
|
||
/// encode the correct branch semantics. Setting `condition_negated=true` here
|
||
/// would cause a double-negation in `compute_succ_states`, applying validation
|
||
/// to the wrong branch.
|
||
pub(super) fn detect_negation<'a>(
|
||
cond: Node<'a>,
|
||
_if_ast: Node<'a>,
|
||
_lang: &str,
|
||
) -> (Node<'a>, bool) {
|
||
// Unwrap parenthesized_expression, JS/Java/PHP wrap if-conditions in parens.
|
||
// This lets us detect negation inside: `if (!expr)` → cond is `(!expr)`.
|
||
let cond = if cond.kind() == "parenthesized_expression" {
|
||
cond.child_by_field_name("expression")
|
||
.or_else(|| {
|
||
let mut cursor = cond.walk();
|
||
cond.children(&mut cursor)
|
||
.find(|c| c.kind() != "(" && c.kind() != ")")
|
||
})
|
||
.unwrap_or(cond)
|
||
} else {
|
||
cond
|
||
};
|
||
|
||
// `!expr` appears as unary_expression, not_operator, or prefix_unary_expression
|
||
// with a `!` or `not` operator child. PHP's tree-sitter grammar emits
|
||
// `unary_op_expression` for unary `!` (and `-`/`+`/`~`) — without it,
|
||
// `if (!validate($x))` carries `condition_negated=false` and the
|
||
// True branch is treated as the validated path even though it is the
|
||
// rejection path, leaving downstream sinks unsuppressed.
|
||
let is_negation_wrapper = matches!(
|
||
cond.kind(),
|
||
"unary_expression"
|
||
| "not_operator"
|
||
| "prefix_unary_expression"
|
||
| "unary_not"
|
||
| "unary_op_expression"
|
||
);
|
||
|
||
if is_negation_wrapper {
|
||
// Check if the first child is a `!` or `not` operator.
|
||
let has_not = cond
|
||
.child(0)
|
||
.is_some_and(|c| c.kind() == "!" || c.kind() == "not");
|
||
|
||
if has_not {
|
||
// Return the operand (inner expression after the `!` / `not`).
|
||
let inner = cond
|
||
.child_by_field_name("argument")
|
||
.or_else(|| cond.child_by_field_name("operand"))
|
||
.or_else(|| {
|
||
// Last non-operator child.
|
||
let mut cursor = cond.walk();
|
||
cond.children(&mut cursor)
|
||
.filter(|c| c.kind() != "!" && c.kind() != "not")
|
||
.last()
|
||
})
|
||
.unwrap_or(cond);
|
||
return (inner, true);
|
||
}
|
||
}
|
||
|
||
(cond, false)
|
||
}
|
||
|
||
/// Extract a binary operator from an AST node.
|
||
///
|
||
/// Covers arithmetic, bitwise, and comparison operators. Conservative
|
||
/// policy: only returns `Some(BinOp)` when the AST node directly IS a
|
||
/// binary expression or is an assignment/expression wrapper containing
|
||
/// a single binary expression as its immediate RHS. Returns `None` for
|
||
/// nested binary expressions, compound assignments (`+=`), boolean
|
||
/// operators (`&&`, `||`), and any ambiguous cases.
|
||
/// Phase 12 deferred fix: when the file imports `tokio::join` / `futures::join`
|
||
/// (or `_::try_join`) via `use`, rewrite a bare `join` / `try_join` macro
|
||
/// callee to its qualified form so the SSA-level promise-combinator
|
||
/// recogniser fires. Returns `None` for every non-Rust input and for
|
||
/// macro callees that already carry a `::` prefix.
|
||
fn rewrite_rust_bare_join_macro(raw: &str, ast: Node, lang: &str, code: &[u8]) -> Option<String> {
|
||
if lang != "rust" || raw.contains("::") {
|
||
return None;
|
||
}
|
||
if !matches!(raw, "join" | "try_join") {
|
||
return None;
|
||
}
|
||
let mut root = ast;
|
||
while let Some(parent) = root.parent() {
|
||
root = parent;
|
||
}
|
||
let prefix = rust_bare_join_crate_prefix(root, code, raw)?;
|
||
Some(format!("{prefix}::{raw}"))
|
||
}
|
||
|
||
fn extract_bin_op(ast: Node, lang: &str) -> Option<BinOp> {
|
||
// Find the binary expression node: either ast itself or immediate child.
|
||
let bin_expr = find_single_binary_expr(ast, lang)?;
|
||
|
||
// Walk children to find the operator token (anonymous node between operands).
|
||
let mut cursor = bin_expr.walk();
|
||
for child in bin_expr.children(&mut cursor) {
|
||
if child.is_named() {
|
||
continue; // Skip named children (operands)
|
||
}
|
||
let kind = child.kind();
|
||
return match kind {
|
||
"+" => Some(BinOp::Add),
|
||
"-" => Some(BinOp::Sub),
|
||
"*" => Some(BinOp::Mul),
|
||
"/" => Some(BinOp::Div),
|
||
"%" => Some(BinOp::Mod),
|
||
// Bitwise (single-char tokens, no conflict with && / ||)
|
||
"&" => Some(BinOp::BitAnd),
|
||
"|" => Some(BinOp::BitOr),
|
||
"^" => Some(BinOp::BitXor),
|
||
"<<" => Some(BinOp::LeftShift),
|
||
">>" => Some(BinOp::RightShift),
|
||
// Comparison (=== / !== are JS/TS strict equality)
|
||
"==" | "===" => Some(BinOp::Eq),
|
||
"!=" | "!==" => Some(BinOp::NotEq),
|
||
"<" => Some(BinOp::Lt),
|
||
"<=" => Some(BinOp::LtEq),
|
||
">" => Some(BinOp::Gt),
|
||
">=" => Some(BinOp::GtEq),
|
||
_ => None, // Boolean (&&, ||), assignment ops, etc.
|
||
};
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Parse an integer literal node to its `i64` value, honouring hex / octal /
|
||
/// binary radix prefixes and Java/Rust digit separators (`1_000`). Returns
|
||
/// `None` for floats, non-literals, or values that overflow `i64`.
|
||
fn parse_int_literal(node: Node, code: &[u8]) -> Option<i64> {
|
||
let kind = node.kind();
|
||
let is_int = matches!(
|
||
kind,
|
||
"integer"
|
||
| "integer_literal"
|
||
| "int_literal"
|
||
| "number"
|
||
| "number_literal"
|
||
| "decimal_integer_literal"
|
||
| "hex_integer_literal"
|
||
| "octal_integer_literal"
|
||
| "binary_integer_literal"
|
||
);
|
||
if !is_int {
|
||
return None;
|
||
}
|
||
let raw = std::str::from_utf8(&code[node.byte_range()]).ok()?.trim();
|
||
// Strip Java long suffix and digit separators.
|
||
let cleaned: String = raw
|
||
.trim_end_matches(['l', 'L'])
|
||
.chars()
|
||
.filter(|c| *c != '_')
|
||
.collect();
|
||
if let Ok(v) = cleaned.parse::<i64>() {
|
||
return Some(v);
|
||
}
|
||
if let Some(h) = cleaned
|
||
.strip_prefix("0x")
|
||
.or_else(|| cleaned.strip_prefix("0X"))
|
||
{
|
||
return i64::from_str_radix(h, 16).ok();
|
||
}
|
||
if let Some(o) = cleaned
|
||
.strip_prefix("0o")
|
||
.or_else(|| cleaned.strip_prefix("0O"))
|
||
{
|
||
return i64::from_str_radix(o, 8).ok();
|
||
}
|
||
if let Some(b) = cleaned
|
||
.strip_prefix("0b")
|
||
.or_else(|| cleaned.strip_prefix("0B"))
|
||
{
|
||
return i64::from_str_radix(b, 2).ok();
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Map the operator token of a binary expression node to a [`BinOp`].
|
||
/// Scans for the single anonymous operator child (operands are named).
|
||
/// Returns `None` for boolean operators (`&&` / `||`), assignment, or any
|
||
/// token not in the arithmetic / bitwise / comparison set — those make the
|
||
/// enclosing [`CondArith`] build bail.
|
||
fn binary_op_token(node: Node) -> Option<BinOp> {
|
||
let mut cursor = node.walk();
|
||
for child in node.children(&mut cursor) {
|
||
if child.is_named() {
|
||
continue;
|
||
}
|
||
return match child.kind() {
|
||
"+" => Some(BinOp::Add),
|
||
"-" => Some(BinOp::Sub),
|
||
"*" => Some(BinOp::Mul),
|
||
"/" => Some(BinOp::Div),
|
||
"%" => Some(BinOp::Mod),
|
||
"&" => Some(BinOp::BitAnd),
|
||
"|" => Some(BinOp::BitOr),
|
||
"^" => Some(BinOp::BitXor),
|
||
"<<" => Some(BinOp::LeftShift),
|
||
">>" => Some(BinOp::RightShift),
|
||
"==" | "===" => Some(BinOp::Eq),
|
||
"!=" | "!==" => Some(BinOp::NotEq),
|
||
"<" => Some(BinOp::Lt),
|
||
"<=" => Some(BinOp::LtEq),
|
||
">" => Some(BinOp::Gt),
|
||
">=" => Some(BinOp::GtEq),
|
||
_ => None,
|
||
};
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Build a [`CondArith`] tree from a condition AST subtree, or `None` if the
|
||
/// condition is not a pure integer-arithmetic + comparison expression. Uses
|
||
/// the real tree-sitter node so operator precedence and parentheses are
|
||
/// already encoded in the tree shape — no text parsing. Conservative by
|
||
/// construction: any unrecognised node kind (call, field access, string,
|
||
/// boolean `&&`/`||`, unary `!`) returns `None`, which disables folding for
|
||
/// that branch (never a wrong fold). Depth-bounded to guard against
|
||
/// pathological nesting.
|
||
pub(super) fn build_cond_arith(
|
||
node: Node,
|
||
lang: &str,
|
||
code: &[u8],
|
||
depth: u32,
|
||
) -> Option<CondArith> {
|
||
if depth > 64 {
|
||
return None;
|
||
}
|
||
let kind = node.kind();
|
||
|
||
// Unwrap parentheses (transparent to value).
|
||
if matches!(
|
||
kind,
|
||
"parenthesized_expression" | "parenthesized" | "parenthesized_statement"
|
||
) {
|
||
let inner = node.named_child(0)?;
|
||
return build_cond_arith(inner, lang, code, depth + 1);
|
||
}
|
||
|
||
if let Some(n) = parse_int_literal(node, code) {
|
||
return Some(CondArith::Lit(n));
|
||
}
|
||
|
||
// Bare identifier (reject dotted paths / field access — those are not
|
||
// captured here; only a plain local whose const value we can resolve).
|
||
if matches!(kind, "identifier" | "simple_identifier") {
|
||
let name = text_of(node, code)?;
|
||
if !name.is_empty()
|
||
&& name
|
||
.chars()
|
||
.all(|c| c.is_alphanumeric() || c == '_' || c == '$')
|
||
{
|
||
return Some(CondArith::Var(name));
|
||
}
|
||
return None;
|
||
}
|
||
|
||
// Unary `-` only (boolean `!` / `not` is intentionally unsupported: its
|
||
// operand would be a boolean, which `CondArith::eval` rejects, so folding
|
||
// a negated condition is left to the conservative `None` path).
|
||
if matches!(
|
||
kind,
|
||
"unary_expression" | "unary_operator" | "prefix_unary_expression" | "unary"
|
||
) {
|
||
let operand = node.named_child(0)?;
|
||
let mut cursor = node.walk();
|
||
let is_neg = node
|
||
.children(&mut cursor)
|
||
.any(|c| !c.is_named() && c.kind() == "-");
|
||
if is_neg {
|
||
return Some(CondArith::Neg(Box::new(build_cond_arith(
|
||
operand,
|
||
lang,
|
||
code,
|
||
depth + 1,
|
||
)?)));
|
||
}
|
||
return None;
|
||
}
|
||
|
||
// Binary arithmetic / comparison: exactly two operands + one operator.
|
||
if is_binary_expr_kind(kind, lang) {
|
||
if node.named_child_count() != 2 {
|
||
return None; // chained comparison (Python `a < b < c`) etc.
|
||
}
|
||
let op = binary_op_token(node)?;
|
||
let lhs = build_cond_arith(node.named_child(0)?, lang, code, depth + 1)?;
|
||
let rhs = build_cond_arith(node.named_child(1)?, lang, code, depth + 1)?;
|
||
return Some(CondArith::Bin(op, Box::new(lhs), Box::new(rhs)));
|
||
}
|
||
|
||
None
|
||
}
|
||
|
||
/// Find the RHS value node of an assignment-like AST node (variable declarator,
|
||
/// lexical declaration, assignment expression). Used by helpers that need to
|
||
/// inspect what an identifier is being initialized to.
|
||
fn assignment_rhs<'a>(ast: Node<'a>) -> Option<Node<'a>> {
|
||
match ast.kind() {
|
||
"variable_declarator" | "assignment_expression" | "assignment" => ast
|
||
.child_by_field_name("value")
|
||
.or_else(|| ast.child_by_field_name("right")),
|
||
// Phase 14 — Java `local_variable_declaration`, Go
|
||
// `short_var_declaration` / `var_spec`, Rust `let_declaration`,
|
||
// Python `assignment` (already covered above), and PHP
|
||
// `assignment_expression` (covered above). Added here so the
|
||
// `string_prefix` extractor can walk the RHS of a plain
|
||
// declaration in any supported language.
|
||
"variable_declaration"
|
||
| "lexical_declaration"
|
||
| "local_variable_declaration"
|
||
| "short_var_declaration"
|
||
| "var_spec"
|
||
| "var_declaration"
|
||
| "let_declaration" => {
|
||
// Walk direct children for the first variable_declarator with a value.
|
||
let mut w = ast.walk();
|
||
ast.named_children(&mut w)
|
||
.find(|c| c.kind() == "variable_declarator")
|
||
.and_then(|d| {
|
||
d.child_by_field_name("value")
|
||
.or_else(|| d.child_by_field_name("right"))
|
||
})
|
||
.or_else(|| {
|
||
// Go: short_var_declaration's value is on a
|
||
// `expression_list` field "right".
|
||
ast.child_by_field_name("right")
|
||
.or_else(|| ast.child_by_field_name("value"))
|
||
})
|
||
.or_else(|| {
|
||
// Rust let_declaration: value field directly on the
|
||
// node (no wrapping declarator).
|
||
ast.child_by_field_name("value")
|
||
})
|
||
}
|
||
"expression_statement" => {
|
||
// expression_statement wraps an assignment_expression
|
||
let mut w = ast.walk();
|
||
ast.named_children(&mut w).find_map(|c| match c.kind() {
|
||
"assignment_expression" | "assignment" => c
|
||
.child_by_field_name("right")
|
||
.or_else(|| c.child_by_field_name("value")),
|
||
_ => None,
|
||
})
|
||
}
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Extract a constant leading string prefix from an assignment-like node's
|
||
/// RHS when the RHS is a JS/TS template literal beginning with a
|
||
/// `string_fragment` or a binary `+` expression whose left operand is a string
|
||
/// literal. Returns `None` if the grammar does not expose such a shape.
|
||
///
|
||
/// The recovered prefix is used by the abstract string domain to seed a
|
||
/// `StringFact::from_prefix` on the result SSA value. For SSRF detection,
|
||
/// when the prefix contains `scheme://host/`, the sink is suppressed because
|
||
/// the attacker cannot reach a different host.
|
||
fn extract_template_prefix(ast: Node, lang: &str, code: &[u8]) -> Option<String> {
|
||
// Phase 14 — extended beyond JS/TS so the SSRF prefix-lock fires
|
||
// across every supported language whose origin-locked URL shape
|
||
// is a literal+tainted string concatenation. The grammar
|
||
// dispatch lives in [`prefix_of_expression`]; this function only
|
||
// walks the assignment-RHS / first-call-arg slots that consume
|
||
// the prefix.
|
||
let supported = matches!(
|
||
lang,
|
||
"javascript" | "typescript" | "java" | "go" | "php" | "ruby" | "python" | "rust"
|
||
);
|
||
if !supported {
|
||
return None;
|
||
}
|
||
|
||
// Assignment-like node: inspect the RHS directly.
|
||
if let Some(rhs) = assignment_rhs(ast) {
|
||
if let Some(p) = prefix_of_expression(rhs, code) {
|
||
return Some(p);
|
||
}
|
||
}
|
||
|
||
// Call expression (including sink call nodes): inspect the first
|
||
// positional argument. Covers `axios.get(\`https://host/…${x}\`)` shape
|
||
// where the template literal is inline at the sink.
|
||
if matches!(
|
||
ast.kind(),
|
||
"call_expression"
|
||
| "call"
|
||
| "new_expression"
|
||
| "object_creation_expression"
|
||
| "method_invocation"
|
||
| "macro_invocation"
|
||
| "function_call_expression"
|
||
) {
|
||
let args = ast
|
||
.child_by_field_name("arguments")
|
||
.or_else(|| ast.child_by_field_name("argument_list"));
|
||
if let Some(args_node) = args {
|
||
let mut w = args_node.walk();
|
||
if let Some(first) = args_node.named_children(&mut w).next() {
|
||
if let Some(p) = prefix_of_expression(first, code) {
|
||
return Some(p);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
None
|
||
}
|
||
|
||
/// Return the leading constant string of `node` if it is a template literal or
|
||
/// a left-associated `"lit" + x` binary expression. Used by
|
||
/// `extract_template_prefix` for both assignment RHS and call arguments.
|
||
///
|
||
/// Also descends through `await` / `yield` wrappers and into the first
|
||
/// argument of a call expression, this covers the common sink shape
|
||
/// `await axios.get(\`https://host/…${x}\`)` where the template literal lives
|
||
/// inside a call inside an `await` wrapper.
|
||
fn prefix_of_expression(node: Node, code: &[u8]) -> Option<String> {
|
||
// Unwrap trivial wrappers (parentheses, TS `as` / type assertions, await/yield).
|
||
let mut cur = node;
|
||
for _ in 0..6 {
|
||
match cur.kind() {
|
||
"parenthesized_expression" => {
|
||
cur = cur.named_child(0)?;
|
||
}
|
||
"as_expression" | "type_assertion" | "satisfies_expression" | "non_null_expression" => {
|
||
cur = cur
|
||
.child_by_field_name("expression")
|
||
.or_else(|| cur.named_child(0))?;
|
||
}
|
||
"await_expression" | "yield_expression" => {
|
||
cur = cur.named_child(0)?;
|
||
}
|
||
"call_expression" | "call" | "new_expression" => {
|
||
// Descend into the first positional argument (e.g.
|
||
// `axios.get(\`https://…${x}\`)`, the URL we want to lock
|
||
// is the template-literal first argument of the call).
|
||
let args = cur
|
||
.child_by_field_name("arguments")
|
||
.or_else(|| cur.child_by_field_name("argument_list"))?;
|
||
let mut w = args.walk();
|
||
cur = args.named_children(&mut w).next()?;
|
||
}
|
||
_ => break,
|
||
}
|
||
}
|
||
|
||
// Case 1: template literal, `\`scheme://host/…${x}…\``.
|
||
if cur.kind() == "template_string" {
|
||
let mut w = cur.walk();
|
||
let first_child = cur.named_children(&mut w).next()?;
|
||
// Leading fragment only counts when the very first piece is a literal
|
||
// text fragment (not an interpolation like `\`${x}…\``).
|
||
if first_child.kind() == "string_fragment" {
|
||
let frag = text_of(first_child, code)?;
|
||
if !frag.is_empty() {
|
||
return Some(frag);
|
||
}
|
||
}
|
||
return None;
|
||
}
|
||
|
||
// Case 2: `"scheme://host/" + x` / PHP `"scheme://host/" . $x`,
|
||
// LHS is a string literal. Phase 14: also accept `.` as the
|
||
// concat operator so PHP's `"prefix" . $tainted` shape locks the
|
||
// SSRF prefix the same way `+`-using languages do.
|
||
if matches!(
|
||
cur.kind(),
|
||
"binary_expression" | "binary_operator" | "binary"
|
||
) {
|
||
let mut w2 = cur.walk();
|
||
let mut ops = cur.children(&mut w2).filter(|c| !c.is_named());
|
||
if !ops.any(|c| matches!(c.kind(), "+" | ".")) {
|
||
return None;
|
||
}
|
||
let left = cur.named_child(0)?;
|
||
if matches!(
|
||
left.kind(),
|
||
"string"
|
||
| "string_fragment"
|
||
| "string_literal"
|
||
| "interpreted_string_literal"
|
||
| "raw_string_literal"
|
||
| "encapsed_string"
|
||
) {
|
||
// For strings with embedded fragments (Java string_literal
|
||
// wraps a string_fragment child), recurse one level into
|
||
// the fragment to get the raw text without quote tokens.
|
||
let inner_text = if matches!(left.kind(), "string_literal" | "encapsed_string") {
|
||
let mut iw = left.walk();
|
||
left.named_children(&mut iw)
|
||
.find(|c| c.kind() == "string_fragment")
|
||
.and_then(|n| text_of(n, code))
|
||
} else {
|
||
None
|
||
};
|
||
let raw = match inner_text {
|
||
Some(t) => t,
|
||
None => text_of(left, code)?,
|
||
};
|
||
let trimmed = strip_string_quotes_loose(&raw);
|
||
if !trimmed.is_empty() {
|
||
return Some(trimmed);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Case 3: Rust `format!("scheme://host/{}", x)` macro invocation.
|
||
// The first positional arg is the format string literal whose
|
||
// leading literal text (up to the first `{`) is the locked prefix.
|
||
if cur.kind() == "macro_invocation" {
|
||
let macro_name = cur
|
||
.child_by_field_name("macro")
|
||
.and_then(|n| text_of(n, code))
|
||
.unwrap_or_default();
|
||
if matches!(
|
||
macro_name.as_str(),
|
||
"format" | "write" | "writeln" | "println" | "eprintln" | "print" | "eprint"
|
||
) {
|
||
// tree-sitter-rust models macro args under a named
|
||
// `token_tree` child rather than via the `arguments` field.
|
||
// Walk every direct child looking for the first string
|
||
// literal — that's the format-string positional arg.
|
||
let mut iw = cur.walk();
|
||
let mut first_string: Option<Node> = None;
|
||
for child in cur.named_children(&mut iw) {
|
||
if matches!(child.kind(), "string_literal" | "raw_string_literal") {
|
||
first_string = Some(child);
|
||
break;
|
||
}
|
||
if child.kind() == "token_tree" {
|
||
let mut ttw = child.walk();
|
||
for inner in child.named_children(&mut ttw) {
|
||
if matches!(inner.kind(), "string_literal" | "raw_string_literal") {
|
||
first_string = Some(inner);
|
||
break;
|
||
}
|
||
}
|
||
if first_string.is_some() {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if let Some(first) = first_string {
|
||
let mut iw = first.walk();
|
||
let frag_text = first
|
||
.named_children(&mut iw)
|
||
.find(|c| c.kind() == "string_content" || c.kind() == "string_fragment")
|
||
.and_then(|n| text_of(n, code));
|
||
let raw = match frag_text {
|
||
Some(t) => t,
|
||
None => text_of(first, code)?,
|
||
};
|
||
let trimmed = strip_string_quotes_loose(&raw);
|
||
if let Some(idx) = trimmed.find('{') {
|
||
let head = trimmed[..idx].to_string();
|
||
if !head.is_empty() {
|
||
return Some(head);
|
||
}
|
||
} else if !trimmed.is_empty() {
|
||
return Some(trimmed);
|
||
}
|
||
} else if let Some(prefix) = rust_macro_const_first_arg_prefix(cur, code) {
|
||
// No literal first arg, but the first non-literal token is an
|
||
// identifier that resolves to a top-level `const NAME: &str = "lit";`
|
||
// declaration in the same file. Treat the const value as if it
|
||
// had been written inline so `format!(URL_FMT, x)` locks the
|
||
// host the same way `format!("https://api/{}", x)` does.
|
||
return Some(prefix);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Case 4: interpolated-string leading literal fragment.
|
||
// Python f-strings parse as `formatted_string`; Ruby interpolated
|
||
// strings parse as `string` with an `interpolation` child. The
|
||
// `string + has_interpolation child` gate keeps plain JS / TS /
|
||
// Java `string` nodes (whose children are only
|
||
// `string_content`/`string_fragment`) from accidentally seeding a
|
||
// phantom prefix on every literal-URL call site. PHP double-
|
||
// quoted strings parse as `encapsed_string`, distinct kind, so
|
||
// they don't trip this branch either.
|
||
let is_fstring = cur.kind() == "formatted_string";
|
||
let is_interp_string = cur.kind() == "string" && has_string_interpolation(cur);
|
||
if is_fstring || is_interp_string {
|
||
let mut w = cur.walk();
|
||
let first = cur.named_children(&mut w).next()?;
|
||
if matches!(first.kind(), "string_content" | "string_fragment") {
|
||
let raw = text_of(first, code)?;
|
||
let trimmed = strip_string_quotes_loose(&raw);
|
||
if !trimmed.is_empty() {
|
||
return Some(trimmed);
|
||
}
|
||
}
|
||
}
|
||
|
||
None
|
||
}
|
||
|
||
/// Resolve the leading prefix of a Rust `format!(IDENT, ...)`-style macro
|
||
/// when the first arg is a bare identifier bound to a top-level
|
||
/// `const NAME: &str = "literal";` or `static NAME: &str = "literal";`
|
||
/// declaration in the same file. Returns the leading literal text up to
|
||
/// the first `{` placeholder, or the whole literal when no placeholder is
|
||
/// present.
|
||
///
|
||
/// Walks the macro's `token_tree` for the first identifier (skipping the
|
||
/// `(` `)` `,` punctuation), then ascends to the file root and scans direct
|
||
/// `const_item` / `static_item` children for a name match. Bypasses inner
|
||
/// functions / impl blocks: only file-level declarations participate, which
|
||
/// keeps the lookup deterministic and avoids shadowing surprises.
|
||
fn rust_macro_const_first_arg_prefix(macro_node: Node, code: &[u8]) -> Option<String> {
|
||
let token_tree = {
|
||
let mut w = macro_node.walk();
|
||
macro_node
|
||
.named_children(&mut w)
|
||
.find(|c| c.kind() == "token_tree")?
|
||
};
|
||
let first_ident_name = {
|
||
let mut w = token_tree.walk();
|
||
let mut found: Option<String> = None;
|
||
for child in token_tree.named_children(&mut w) {
|
||
match child.kind() {
|
||
"string_literal" | "raw_string_literal" => return None,
|
||
"identifier" => {
|
||
found = text_of(child, code);
|
||
break;
|
||
}
|
||
_ => continue,
|
||
}
|
||
}
|
||
found?
|
||
};
|
||
let mut root = macro_node;
|
||
while let Some(parent) = root.parent() {
|
||
root = parent;
|
||
}
|
||
let mut rw = root.walk();
|
||
for child in root.named_children(&mut rw) {
|
||
if !matches!(child.kind(), "const_item" | "static_item") {
|
||
continue;
|
||
}
|
||
let name = child
|
||
.child_by_field_name("name")
|
||
.and_then(|n| text_of(n, code));
|
||
if name.as_deref() != Some(first_ident_name.as_str()) {
|
||
continue;
|
||
}
|
||
let value = child.child_by_field_name("value")?;
|
||
let lit = if matches!(value.kind(), "string_literal" | "raw_string_literal") {
|
||
value
|
||
} else {
|
||
continue;
|
||
};
|
||
let mut iw = lit.walk();
|
||
let frag_text = lit
|
||
.named_children(&mut iw)
|
||
.find(|c| c.kind() == "string_content" || c.kind() == "string_fragment")
|
||
.and_then(|n| text_of(n, code));
|
||
let raw = match frag_text {
|
||
Some(t) => t,
|
||
None => text_of(lit, code)?,
|
||
};
|
||
let trimmed = strip_string_quotes_loose(&raw);
|
||
if let Some(idx) = trimmed.find('{') {
|
||
let head = trimmed[..idx].to_string();
|
||
if !head.is_empty() {
|
||
return Some(head);
|
||
}
|
||
} else if !trimmed.is_empty() {
|
||
return Some(trimmed);
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Strip surrounding `"`/`'`/`` ` `` quotes if present.
|
||
fn strip_string_quotes_loose(raw: &str) -> String {
|
||
if raw.len() >= 2
|
||
&& ((raw.starts_with('"') && raw.ends_with('"'))
|
||
|| (raw.starts_with('\'') && raw.ends_with('\''))
|
||
|| (raw.starts_with('`') && raw.ends_with('`')))
|
||
{
|
||
raw[1..raw.len() - 1].to_string()
|
||
} else {
|
||
raw.to_string()
|
||
}
|
||
}
|
||
|
||
/// Extract the numeric literal operand from a binary expression.
|
||
///
|
||
/// When a binary expression has one identifier operand (captured in `uses`)
|
||
/// and one numeric literal operand, this returns the parsed literal value.
|
||
/// Used for abstract-domain transfer when the SSA only has the identifier use.
|
||
fn extract_bin_op_const(ast: Node, lang: &str, code: &[u8]) -> Option<i64> {
|
||
let bin_expr = find_single_binary_expr(ast, lang)?;
|
||
// Look for a numeric literal child
|
||
let left = bin_expr.named_child(0)?;
|
||
let right = bin_expr.named_child(1)?;
|
||
|
||
fn try_parse_number(n: Node, code: &[u8]) -> Option<i64> {
|
||
let kind = n.kind();
|
||
if kind == "number"
|
||
|| kind == "integer"
|
||
|| kind == "integer_literal"
|
||
|| kind == "number_literal"
|
||
|| kind == "float"
|
||
{
|
||
let text = std::str::from_utf8(&code[n.byte_range()]).ok()?.trim();
|
||
// Try standard decimal parse first
|
||
if let Ok(v) = text.parse::<i64>() {
|
||
return Some(v);
|
||
}
|
||
// Try hex (0x...), octal (0o...), binary (0b...) prefixed literals
|
||
if let Some(hex) = text.strip_prefix("0x").or_else(|| text.strip_prefix("0X")) {
|
||
return i64::from_str_radix(hex, 16).ok();
|
||
}
|
||
if let Some(oct) = text.strip_prefix("0o").or_else(|| text.strip_prefix("0O")) {
|
||
return i64::from_str_radix(oct, 8).ok();
|
||
}
|
||
if let Some(bin) = text.strip_prefix("0b").or_else(|| text.strip_prefix("0B")) {
|
||
return i64::from_str_radix(bin, 2).ok();
|
||
}
|
||
None
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
// Try left, then right, one of them should be a literal
|
||
try_parse_number(left, code).or_else(|| try_parse_number(right, code))
|
||
}
|
||
|
||
/// Detect whether the expression(s) in `ast` produce a boolean-only result
|
||
/// rooted in equality/inequality comparisons against literals.
|
||
///
|
||
/// True when `ast` is (or wraps) either:
|
||
/// - a direct equality comparison (`==` / `!=` / `===` / `!==`) with exactly
|
||
/// one literal operand, or
|
||
/// - a compound boolean expression (`&&`, `||`, `!`, `and`, `or`, `not`)
|
||
/// whose every leaf is a qualifying equality comparison.
|
||
///
|
||
/// Covers JS/TS `binary_expression`, Python `comparison_operator`, Ruby
|
||
/// `binary`, and languages that share the `binary_expression` kind (Java, Go,
|
||
/// PHP, C/C++, Rust). Compound chains like `a === 'x' || b === 'y'` qualify
|
||
/// because their result is provably a boolean even though the taint engine
|
||
/// sees all leaf operands on a single CFG Assign node.
|
||
///
|
||
/// The SSA taint transfer uses this flag to suppress propagation of operand
|
||
/// taint into the boolean result: the outcome carries no attacker-controlled
|
||
/// data, so downstream ternaries/branches should not inherit operand caps.
|
||
pub(super) fn detect_eq_with_const(ast: Node, lang: &str) -> bool {
|
||
// Prefer inspecting the RHS of assignment-like wrappers, so flags on e.g.
|
||
// `var ok = a === 'x' || b === 'y'` examine the full right-hand side.
|
||
let target = assignment_rhs(ast).unwrap_or(ast);
|
||
is_boolean_eq_const_tree(target, lang)
|
||
}
|
||
|
||
/// Recursive predicate: does `node` evaluate to a boolean whose value is
|
||
/// determined solely by equality comparisons against literals, joined by
|
||
/// boolean operators? Parentheses, `!`/`not`, and `&&`/`||`/`and`/`or` are
|
||
/// transparent; every leaf must be a direct equality-with-constant.
|
||
fn is_boolean_eq_const_tree(node: Node, lang: &str) -> bool {
|
||
match node.kind() {
|
||
"parenthesized_expression" => node
|
||
.named_child(0)
|
||
.is_some_and(|c| is_boolean_eq_const_tree(c, lang)),
|
||
"unary_expression" | "not_operator" => {
|
||
// `!` / `not`, operator is an anonymous child; operand is the
|
||
// single named child.
|
||
let mut w = node.walk();
|
||
let mut op_is_not = false;
|
||
for child in node.children(&mut w) {
|
||
if !child.is_named() && matches!(child.kind(), "!" | "not") {
|
||
op_is_not = true;
|
||
break;
|
||
}
|
||
}
|
||
if !op_is_not {
|
||
return false;
|
||
}
|
||
node.named_child(0)
|
||
.is_some_and(|c| is_boolean_eq_const_tree(c, lang))
|
||
}
|
||
"boolean_operator" => {
|
||
// Python `and`/`or`, operands are named children.
|
||
let l = node.named_child(0);
|
||
let r = node.named_child(1);
|
||
l.is_some_and(|n| is_boolean_eq_const_tree(n, lang))
|
||
&& r.is_some_and(|n| is_boolean_eq_const_tree(n, lang))
|
||
}
|
||
_ => {
|
||
if !is_binary_expr_kind(node.kind(), lang) {
|
||
return false;
|
||
}
|
||
let op = binary_operator_token(node);
|
||
match op.as_deref() {
|
||
Some("&&") | Some("||") | Some("and") | Some("or") => {
|
||
node.named_child(0)
|
||
.is_some_and(|l| is_boolean_eq_const_tree(l, lang))
|
||
&& node
|
||
.named_child(1)
|
||
.is_some_and(|r| is_boolean_eq_const_tree(r, lang))
|
||
}
|
||
Some("==") | Some("===") | Some("!=") | Some("!==") => {
|
||
let Some(left) = node.named_child(0) else {
|
||
return false;
|
||
};
|
||
let Some(right) = node.named_child(1) else {
|
||
return false;
|
||
};
|
||
let left_lit = is_equality_literal_kind(left.kind());
|
||
let right_lit = is_equality_literal_kind(right.kind());
|
||
// Exactly one side literal. Both-literal is constant-fold
|
||
// territory; neither-literal is a generic identity check
|
||
// whose operands may both be tainted.
|
||
left_lit ^ right_lit
|
||
}
|
||
_ => false,
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Return the anonymous operator token text of a binary expression node.
|
||
fn binary_operator_token(node: Node) -> Option<String> {
|
||
let mut w = node.walk();
|
||
for child in node.children(&mut w) {
|
||
if !child.is_named() {
|
||
return Some(child.kind().to_string());
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Property names whose value is provably an integer across the supported
|
||
/// languages: JS/TS `arr.length` (Array/String/TypedArray), `map.size`
|
||
/// (Map/Set), `buffer.byteLength` (ArrayBuffer/TypedArray); Python `.count`
|
||
/// (`str.count`, `list.count`, `tuple.count`, all return int); Ruby `.length`
|
||
/// / `.size` / `.count`; Java `.size()` / `.length()`; Rust `.len()`. This
|
||
/// list is intentionally narrow, only properties whose semantics across every
|
||
/// host we scan return an integer, so the `TypeKind::Int` fact is sound.
|
||
fn is_numeric_length_property(name: &str) -> bool {
|
||
matches!(name, "length" | "size" | "byteLength" | "count" | "len")
|
||
}
|
||
|
||
/// Detect whether this CFG node is a read of a numeric-length property on a
|
||
/// container. Covers both pure property access (`arr.length` as the RHS of
|
||
/// an assignment or declaration) and zero-argument method calls
|
||
/// (`list.size()`, `vec.len()`). Returns `true` when the relevant value
|
||
/// expression is a `member_expression` / `attribute` / `selector_expression`
|
||
/// / `field_expression` whose property leaf matches
|
||
/// [`is_numeric_length_property`], or a zero-arg call around such an
|
||
/// expression.
|
||
///
|
||
/// Consumed by the type-fact analysis (`ssa::type_facts::analyze_types`) to
|
||
/// infer `TypeKind::Int` on the defined value so sink-cap suppression can
|
||
/// treat `"row " + arr.length` as a non-injectable payload.
|
||
/// when the RHS of an assignment / declaration is a single
|
||
/// member-access expression (`let x = dto.email`, `x = obj.field`,
|
||
/// `let x = obj["field"]`), return the property name. The CFG type-fact
|
||
/// analysis uses the recovered name to look up the field's declared
|
||
/// [`crate::ssa::type_facts::TypeKind`] when the receiver is a
|
||
/// [`crate::ssa::type_facts::TypeKind::Dto`].
|
||
///
|
||
/// Returns `None` for any other shape (function calls, complex
|
||
/// expressions, computed-key subscripts, optional-chaining, etc.) so
|
||
/// the legacy copy-prop / Unknown propagation continues to apply.
|
||
fn detect_member_field_assignment(ast: Node, code: &[u8]) -> Option<String> {
|
||
// Pull the RHS the same way `detect_numeric_length_access` does so
|
||
// both detectors look at the same node grain.
|
||
let target = ast
|
||
.child_by_field_name("value")
|
||
.or_else(|| ast.child_by_field_name("right"))
|
||
.or_else(|| {
|
||
let mut cursor = ast.walk();
|
||
ast.named_children(&mut cursor)
|
||
.find(|c| matches!(c.kind(), "variable_declarator" | "init_declarator"))
|
||
.and_then(|d| {
|
||
d.child_by_field_name("value")
|
||
.or_else(|| d.child_by_field_name("initializer"))
|
||
})
|
||
})
|
||
.or_else(|| {
|
||
// Python wraps assignment in `expression_statement`; drill into
|
||
// the inner `assignment` node to reach its `right` field. Ruby
|
||
// wraps simple `x = rhs` in `assignment` directly so this arm is
|
||
// a no-op for Ruby, but the Python case is load-bearing for the
|
||
// `qs = User.objects` shape where `member_field` drives the
|
||
// Django ORM type-fact tagging.
|
||
let mut cursor = ast.walk();
|
||
ast.named_children(&mut cursor)
|
||
.find(|c| matches!(c.kind(), "assignment"))
|
||
.and_then(|a| a.child_by_field_name("right"))
|
||
})
|
||
.unwrap_or(ast);
|
||
extract_member_field_name(target, code)
|
||
}
|
||
|
||
fn extract_member_field_name(node: Node, code: &[u8]) -> Option<String> {
|
||
match node.kind() {
|
||
// JS / TS / Java / C / C++ / Go (selector) / Rust (field).
|
||
"member_expression"
|
||
| "member_access_expression"
|
||
| "field_expression"
|
||
| "selector_expression"
|
||
| "attribute" => {
|
||
let prop = node
|
||
.child_by_field_name("property")
|
||
.or_else(|| node.child_by_field_name("attribute"))
|
||
.or_else(|| node.child_by_field_name("field"))
|
||
.or_else(|| node.child_by_field_name("name"))?;
|
||
let text = text_of(prop, code)?;
|
||
// Defensive: reject anything that doesn't look like an
|
||
// identifier (e.g. numeric subscripts). Allows ASCII
|
||
// letters / digits / underscore.
|
||
if text.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') && !text.is_empty() {
|
||
Some(text)
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
fn detect_numeric_length_access(ast: Node, _lang: &str, code: &[u8]) -> bool {
|
||
// Pull the value expression for variable declarations / assignments.
|
||
// Other node shapes (e.g. plain member-expression reads) are checked
|
||
// as-is.
|
||
let target = ast
|
||
.child_by_field_name("value")
|
||
.or_else(|| ast.child_by_field_name("right"))
|
||
.or_else(|| {
|
||
let mut cursor = ast.walk();
|
||
ast.named_children(&mut cursor)
|
||
.find(|c| matches!(c.kind(), "variable_declarator" | "init_declarator"))
|
||
.and_then(|d| {
|
||
d.child_by_field_name("value")
|
||
.or_else(|| d.child_by_field_name("initializer"))
|
||
})
|
||
})
|
||
.unwrap_or(ast);
|
||
is_numeric_length_access_expr(target, code)
|
||
}
|
||
|
||
fn is_numeric_length_access_expr(node: Node, code: &[u8]) -> bool {
|
||
match node.kind() {
|
||
"member_expression"
|
||
| "attribute"
|
||
| "selector_expression"
|
||
| "field_expression"
|
||
| "member_access_expression" => {
|
||
let prop = node
|
||
.child_by_field_name("property")
|
||
.or_else(|| node.child_by_field_name("attribute"))
|
||
.or_else(|| node.child_by_field_name("field"))
|
||
.or_else(|| node.child_by_field_name("name"));
|
||
prop.and_then(|p| text_of(p, code))
|
||
.is_some_and(|t| is_numeric_length_property(&t))
|
||
}
|
||
// Zero-arg method call: `list.size()` / `vec.len()` / `str.length()`.
|
||
"call_expression" | "method_invocation" | "method_call_expression" | "call" => {
|
||
let args = node
|
||
.child_by_field_name("arguments")
|
||
.or_else(|| node.child_by_field_name("argument_list"));
|
||
let arity = args
|
||
.map(|a| {
|
||
let mut c = a.walk();
|
||
a.named_children(&mut c).count()
|
||
})
|
||
.unwrap_or(0);
|
||
if arity != 0 {
|
||
return false;
|
||
}
|
||
let callee = node
|
||
.child_by_field_name("function")
|
||
.or_else(|| node.child_by_field_name("name"))
|
||
.or_else(|| node.child_by_field_name("method"));
|
||
match callee {
|
||
Some(c) => is_numeric_length_access_expr(c, code),
|
||
None => false,
|
||
}
|
||
}
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
/// Literal kinds accepted for equality-with-constant detection. Conservatively
|
||
/// limited to scalar literals across the supported tree-sitter grammars.
|
||
fn is_equality_literal_kind(kind: &str) -> bool {
|
||
matches!(
|
||
kind,
|
||
// Strings
|
||
"string"
|
||
| "string_literal"
|
||
| "interpreted_string_literal"
|
||
| "raw_string_literal"
|
||
| "encapsed_string"
|
||
// Numbers
|
||
| "number"
|
||
| "integer"
|
||
| "float"
|
||
| "integer_literal"
|
||
| "float_literal"
|
||
| "number_literal"
|
||
| "decimal_integer_literal"
|
||
| "hex_integer_literal"
|
||
| "octal_integer_literal"
|
||
| "binary_integer_literal"
|
||
| "decimal_floating_point_literal"
|
||
| "hex_floating_point_literal"
|
||
// Null / nil / none / undefined
|
||
| "null"
|
||
| "null_literal"
|
||
| "nil"
|
||
| "none"
|
||
| "undefined"
|
||
// Booleans
|
||
| "true"
|
||
| "false"
|
||
| "boolean_literal"
|
||
)
|
||
}
|
||
|
||
/// Find a single binary expression node at or directly under `ast`.
|
||
///
|
||
/// Returns `None` if there are zero or multiple binary expressions
|
||
/// (ambiguous). Only descends one level into assignment/expression wrappers.
|
||
fn find_single_binary_expr<'a>(ast: Node<'a>, lang: &str) -> Option<Node<'a>> {
|
||
let ast_kind = ast.kind();
|
||
|
||
// Check if ast itself is a binary expression
|
||
if is_binary_expr_kind(ast_kind, lang) {
|
||
// Verify it has exactly 2 named children (left, right), no nesting
|
||
let named_count = ast.named_child_count();
|
||
if named_count == 2 {
|
||
// Ensure neither child is itself a binary expression (that would
|
||
// mean the operator is for a compound expression like `a + b * c`)
|
||
let left = ast.named_child(0);
|
||
let right = ast.named_child(1);
|
||
let left_is_bin = left.is_some_and(|n| is_binary_expr_kind(n.kind(), lang));
|
||
let right_is_bin = right.is_some_and(|n| is_binary_expr_kind(n.kind(), lang));
|
||
if !left_is_bin && !right_is_bin {
|
||
return Some(ast);
|
||
}
|
||
}
|
||
return None; // Nested or complex
|
||
}
|
||
|
||
// Check one level down for assignment wrappers, expression statements, etc.
|
||
let wrapper_kinds = [
|
||
"expression_statement",
|
||
"assignment_expression",
|
||
"assignment",
|
||
"variable_declaration",
|
||
"variable_declarator",
|
||
"short_var_declaration",
|
||
"lexical_declaration",
|
||
];
|
||
if wrapper_kinds.contains(&ast_kind) || ast_kind.ends_with("_statement") {
|
||
let mut found: Option<Node<'a>> = None;
|
||
let mut cursor = ast.walk();
|
||
for child in ast.named_children(&mut cursor) {
|
||
if is_binary_expr_kind(child.kind(), lang) {
|
||
if found.is_some() {
|
||
return None; // Multiple binary expressions → ambiguous
|
||
}
|
||
// Same check: must have exactly 2 non-binary named children
|
||
if child.named_child_count() == 2 {
|
||
let l = child.named_child(0);
|
||
let r = child.named_child(1);
|
||
let l_bin = l.is_some_and(|n| is_binary_expr_kind(n.kind(), lang));
|
||
let r_bin = r.is_some_and(|n| is_binary_expr_kind(n.kind(), lang));
|
||
if !l_bin && !r_bin {
|
||
found = Some(child);
|
||
}
|
||
}
|
||
} else if wrapper_kinds.contains(&child.kind()) {
|
||
// Recurse one more level into nested wrappers (e.g.,
|
||
// variable_declaration → variable_declarator → binary_expression)
|
||
let mut inner_cursor = child.walk();
|
||
for grandchild in child.named_children(&mut inner_cursor) {
|
||
if is_binary_expr_kind(grandchild.kind(), lang) {
|
||
if found.is_some() {
|
||
return None;
|
||
}
|
||
if grandchild.named_child_count() == 2 {
|
||
let l = grandchild.named_child(0);
|
||
let r = grandchild.named_child(1);
|
||
let l_bin = l.is_some_and(|n| is_binary_expr_kind(n.kind(), lang));
|
||
let r_bin = r.is_some_and(|n| is_binary_expr_kind(n.kind(), lang));
|
||
if !l_bin && !r_bin {
|
||
found = Some(grandchild);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return found;
|
||
}
|
||
|
||
None
|
||
}
|
||
|
||
/// Check if an AST node kind is a binary expression in the given language.
|
||
///
|
||
/// Python uses `binary_operator` for arithmetic/bitwise and
|
||
/// `comparison_operator` for comparisons. Chained Python comparisons
|
||
/// (`a < b < c`) have 3+ named children and are rejected by the
|
||
/// `named_child_count() == 2` guard in `find_single_binary_expr`.
|
||
fn is_binary_expr_kind(kind: &str, lang: &str) -> bool {
|
||
match lang {
|
||
"python" => kind == "binary_operator" || kind == "comparison_operator",
|
||
"ruby" => kind == "binary",
|
||
_ => kind == "binary_expression",
|
||
}
|
||
}
|
||
|
||
/// Classification text for a for-each loop's iterable expression.
|
||
///
|
||
/// Subscript / index iterables (`$_GET['x']`, `params[:list]`, `arr[i]`)
|
||
/// classify on their **base object**: taint sources are keyed on the base
|
||
/// name (`$_GET`, `params`), and the trailing index would otherwise break
|
||
/// the word-boundary suffix match in `classify`. Non-subscript iterables
|
||
/// (method calls, member chains, bare identifiers) use their full text.
|
||
fn iterable_label_text(iter: Node, code: &[u8]) -> Option<String> {
|
||
if matches!(
|
||
iter.kind(),
|
||
"subscript_expression" | "subscript" | "index_expression" | "element_reference"
|
||
) {
|
||
let base = iter
|
||
.child_by_field_name("object")
|
||
.or_else(|| iter.child_by_field_name("operand"))
|
||
.or_else(|| iter.child_by_field_name("value"))
|
||
.or_else(|| iter.child(0));
|
||
if let Some(b) = base
|
||
&& let Some(t) = text_of(b, code)
|
||
{
|
||
return Some(t);
|
||
}
|
||
}
|
||
text_of(iter, code)
|
||
}
|
||
|
||
/// Create a node in one short borrow and optionally attach a taint label.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub(super) fn push_node<'a>(
|
||
g: &mut Cfg,
|
||
kind: StmtKind,
|
||
ast: Node<'a>,
|
||
lang: &str,
|
||
code: &'a [u8],
|
||
enclosing_func: Option<&str>,
|
||
call_ordinal: u32,
|
||
analysis_rules: Option<&LangAnalysisRules>,
|
||
) -> NodeIndex {
|
||
/* ── 1. IDENTIFIER EXTRACTION ─────────────────────────────────────── */
|
||
|
||
// Primary guess (varies by AST kind)
|
||
let mut text = match lookup(lang, ast.kind()) {
|
||
// plain `foo(bar)` style call
|
||
Kind::CallFn => ast
|
||
.child_by_field_name("function")
|
||
.or_else(|| ast.child_by_field_name("method"))
|
||
.or_else(|| ast.child_by_field_name("name"))
|
||
.or_else(|| ast.child_by_field_name("type"))
|
||
// JS/TS `new_expression` uses `constructor` field.
|
||
.or_else(|| ast.child_by_field_name("constructor"))
|
||
// Fallback for constructors whose grammar lacks field names
|
||
// (e.g. PHP `object_creation_expression` has positional children).
|
||
.or_else(|| find_constructor_type_child(ast))
|
||
.and_then(|n| {
|
||
// IIFE: `(function(x){...})(arg)`, the called expression is a
|
||
// function literal with no identifier. Bind the call to the
|
||
// anonymous body's synthetic name so resolve_callee can find
|
||
// the extracted BodyCfg/summary. Without this, text_of() would
|
||
// return the function's full source slice, which matches no
|
||
// summary key.
|
||
let unwrapped = unwrap_parens(n);
|
||
if lookup(lang, unwrapped.kind()) == Kind::Function {
|
||
Some(anon_fn_name(unwrapped.start_byte()))
|
||
} else {
|
||
text_of(n, code)
|
||
}
|
||
})
|
||
.unwrap_or_default(),
|
||
|
||
// method / UFCS call `recv.method()` or `Type::func()`
|
||
Kind::CallMethod => {
|
||
let func = ast
|
||
.child_by_field_name("method")
|
||
.or_else(|| ast.child_by_field_name("name"))
|
||
.and_then(|n| text_of(n, code));
|
||
let recv = ast
|
||
.child_by_field_name("object")
|
||
.or_else(|| ast.child_by_field_name("receiver"))
|
||
.or_else(|| ast.child_by_field_name("scope"))
|
||
.and_then(|n| root_receiver_text(n, lang, code));
|
||
match (recv, func) {
|
||
(Some(r), Some(f)) => format!("{r}.{f}"),
|
||
(_, Some(f)) => f,
|
||
_ => String::new(),
|
||
}
|
||
}
|
||
|
||
// `my_macro!(…)`
|
||
Kind::CallMacro => ast
|
||
.child_by_field_name("macro")
|
||
.and_then(|n| text_of(n, code))
|
||
.map(|raw| rewrite_rust_bare_join_macro(&raw, ast, lang, code).unwrap_or(raw))
|
||
.unwrap_or_default(),
|
||
|
||
// Function definitions: use just the function name, not the full
|
||
// body text. The raw body text can spuriously match label rules
|
||
// (e.g. `def search\n find_by_sql(…)\nend` would suffix-match
|
||
// the `find_by_sql` sink via the `head = text.split('(')` logic
|
||
// in classify_all).
|
||
Kind::Function => ast
|
||
.child_by_field_name("name")
|
||
.or_else(|| ast.child_by_field_name("declarator"))
|
||
.and_then(|n| text_of(n, code))
|
||
.unwrap_or_default(),
|
||
|
||
// everything else – fallback to raw slice
|
||
_ => text_of(ast, code).unwrap_or_default(),
|
||
};
|
||
|
||
// C++ new/delete: normalize callee to "new"/"delete" for resource pair
|
||
// matching. Without this, new_expression extracts the type name (e.g.
|
||
// "int") and delete_expression extracts the full expression text.
|
||
// Guarded to C++ only so JS/TS `new_expression` is unaffected.
|
||
if lang == "cpp" {
|
||
if ast.kind() == "new_expression" {
|
||
text = "new".to_string();
|
||
} else if ast.kind() == "delete_expression" {
|
||
text = "delete".to_string();
|
||
}
|
||
}
|
||
|
||
// Ruby backtick shell execution: the `subshell` AST node has no
|
||
// `function`/`method` field so the CallFn text extraction above yields
|
||
// "". Stamp a synthetic callee name so the Sink(SHELL_ESCAPE) rule in
|
||
// labels/ruby.rs fires.
|
||
if lang == "ruby" && ast.kind() == "subshell" {
|
||
text = "subshell".to_string();
|
||
}
|
||
|
||
// JS/TS `for (… of iter)` / `for (… in iter)` / `for await (… of iter)`:
|
||
// tree-sitter classifies all three as `for_in_statement` with the
|
||
// iterator on the `right` field. Use the iterator expression's text
|
||
// (e.g. `"req.body"`) for label classification so the loop binding
|
||
// inherits a Source taint when the iterator matches a Source rule.
|
||
// Without this, the for_in_statement's text is the full multi-line
|
||
// loop, which never matches any short suffix-style Source matcher.
|
||
//
|
||
// Phase 03 originally proposed narrowing this rewrite to the
|
||
// `for await` form alone (where the iterator text classification
|
||
// was the immediate motivation). The rewrite is kept broader here
|
||
// because the same iterator-text classification benefits plain
|
||
// `for (const x of req.body)` and `for (const k in process.env)`
|
||
// identically — the loop-binding-inherits-iterator-taint semantics
|
||
// are uniform across all three forms, and narrowing would create
|
||
// an arbitrary distinction the source rules would have to mirror.
|
||
if matches!(lang, "javascript" | "typescript" | "tsx")
|
||
&& ast.kind() == "for_in_statement"
|
||
&& let Some(right) = ast.child_by_field_name("right")
|
||
&& let Some(iter_text) = text_of(right, code)
|
||
{
|
||
text = iter_text;
|
||
}
|
||
|
||
// Python `for x in iter:` / `async for x in iter:`: tree-sitter-python
|
||
// emits both shapes as `for_statement` (the `async` keyword is an
|
||
// unnamed leaf child). Same loop-binding-inherits-iterator-taint
|
||
// semantics as the JS rewrite above: classify against the iterator
|
||
// text so a `Source` matcher on `request.json` lights up when the
|
||
// loop iterates an awaitable request body.
|
||
if lang == "python"
|
||
&& ast.kind() == "for_statement"
|
||
&& let Some(right) = ast.child_by_field_name("right")
|
||
&& let Some(iter_text) = text_of(right, code)
|
||
{
|
||
text = iter_text;
|
||
}
|
||
|
||
// Java `for (T x : iter)`: tree-sitter-java emits `enhanced_for_statement`
|
||
// with the iterable on the `value` field. Classify against the iterable
|
||
// text so a source-returning call (`req.getCookies()`,
|
||
// `req.getParameterValues(..)`) lights up a Source on the loop node and
|
||
// the loop binding inherits its taint — the same loop-binding-inherits-
|
||
// iterator-taint contract the JS/Python rewrites above provide. The
|
||
// loop variable itself is recorded as a define by `def_use`'s Kind::For
|
||
// arm (via the `name`/`value` mapping), so the Source-labeled loop node
|
||
// taints the binding directly.
|
||
if lang == "java"
|
||
&& ast.kind() == "enhanced_for_statement"
|
||
&& let Some(value) = ast.child_by_field_name("value")
|
||
&& let Some(iter_text) = iterable_label_text(value, code)
|
||
{
|
||
text = iter_text;
|
||
}
|
||
|
||
// PHP `foreach ($iter as $v)` / `foreach ($iter as $k => $v)`: the
|
||
// iterable is the named child immediately preceding the `as` keyword
|
||
// (only `body` is a named field). Classify against the iterable text so
|
||
// a superglobal/source iterable (`$_GET[..]`, `$_POST[..]`) taints the
|
||
// loop binding, matching the JS/Python/Java rewrites.
|
||
if lang == "php" && ast.kind() == "foreach_statement" {
|
||
let mut cursor = ast.walk();
|
||
let kids: Vec<Node> = ast.children(&mut cursor).collect();
|
||
if let Some(as_pos) = kids.iter().position(|c| c.kind() == "as")
|
||
&& let Some(iter_node) = kids[..as_pos].iter().rev().find(|c| c.is_named()).copied()
|
||
&& let Some(iter_text) = iterable_label_text(iter_node, code)
|
||
{
|
||
text = iter_text;
|
||
}
|
||
}
|
||
|
||
// Ruby `for x in coll`: tree-sitter-ruby's `for` node carries the
|
||
// iterable on the `value` field. (The idiomatic `coll.each { |x| }`
|
||
// form is a method call with a block and is handled by the call/block
|
||
// machinery, not here.)
|
||
if lang == "ruby"
|
||
&& ast.kind() == "for"
|
||
&& let Some(value) = ast.child_by_field_name("value")
|
||
&& let Some(iter_text) = iterable_label_text(value, code)
|
||
{
|
||
text = iter_text;
|
||
}
|
||
|
||
// If this is a declaration/expression wrapper or an assignment that
|
||
// *contains* a call, prefer the first inner call identifier instead of
|
||
// the whole line. Track the inner call's byte span so we can populate
|
||
// `CallMeta.callee_span` once the labels settle, enabling narrow
|
||
// source-location reporting when the classified call lives several lines
|
||
// below the enclosing statement (e.g. call inside a multi-line template
|
||
// literal).
|
||
let mut inner_text_span: Option<(usize, usize)> = None;
|
||
if matches!(
|
||
lookup(lang, ast.kind()),
|
||
Kind::CallWrapper | Kind::Assignment | Kind::Return
|
||
) {
|
||
if let Some((inner, inner_span)) = first_call_ident_with_span(ast, lang, code) {
|
||
text = inner;
|
||
inner_text_span = Some(inner_span);
|
||
} else if matches!(lookup(lang, ast.kind()), Kind::CallWrapper) {
|
||
// Fallback for language-construct "calls" (e.g. PHP `echo_statement`,
|
||
// `print` expression): the first child is a keyword leaf (e.g. "echo")
|
||
// that acts as a callee but is not a function_call_expression.
|
||
let mut cursor = ast.walk();
|
||
if let Some(first) = ast.children(&mut cursor).next()
|
||
&& first.child_count() == 0
|
||
&& let Some(kw) = text_of(first, code)
|
||
&& kw.len() <= 16
|
||
{
|
||
text = kw;
|
||
inner_text_span = Some((first.start_byte(), first.end_byte()));
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ── 2. LABEL LOOK-UP ───────────────────────────────────────────── */
|
||
|
||
let extra = analysis_rules.map(|r| r.extra_labels.as_slice());
|
||
let mut labels = classify_all(lang, &text, extra);
|
||
|
||
// Rust chain-text classification. The default `text` for a Rust
|
||
// CallMethod is `{root_receiver}.{method}`, where `root_receiver`
|
||
// is the leftmost identifier after walking through every nested
|
||
// call/method receiver. That convention loses the intermediate
|
||
// chain methods, so a body-binding chain like
|
||
// `Client::post(url).body(payload).send()` reduces to
|
||
// `Client::post.send` and rules keyed on `body.send` /
|
||
// `RequestBuilder.body` cannot fire.
|
||
//
|
||
// Reclassify against the call-AST's source text (with paren groups
|
||
// stripped) so suffix matchers covering chain shapes
|
||
// (`body.send`, `body_string`, `Request::builder.body`, ...) attach.
|
||
// Strictly additive: we union new labels with the existing ones,
|
||
// never override. Limited to Rust to avoid disturbing the other
|
||
// languages' chain conventions.
|
||
if lang == "rust" {
|
||
if let Some(cn) = find_call_node(ast, lang) {
|
||
if let Some(chain_raw) = text_of(cn, code) {
|
||
// Multi-line Rust chains (`Client::new()\n .post(url)\n
|
||
// .body(p)\n .send()`) preserve interior whitespace in
|
||
// the source slice, which would prevent suffix matchers
|
||
// like `body.send` from firing. Strip whitespace before
|
||
// normalizing paren groups, mirroring the same trick
|
||
// used by `find_chained_inner_call` for JS/TS chains.
|
||
let chain_compact: String =
|
||
chain_raw.chars().filter(|c| !c.is_whitespace()).collect();
|
||
let chain_text = crate::labels::normalize_chained_call_for_classify(&chain_compact);
|
||
if chain_text != text {
|
||
let chain_labels = classify_all(lang, &chain_text, extra);
|
||
for l in chain_labels {
|
||
if !labels.contains(&l) {
|
||
labels.push(l);
|
||
}
|
||
}
|
||
}
|
||
// Also try classification against the chain with
|
||
// trailing identity methods peeled. Rust chains often
|
||
// end in `.unwrap()` / `.expect("...")` / `.await` /
|
||
// `.clone()` etc., which obscure the body-bind verb
|
||
// for suffix matchers. E.g. hyper's
|
||
// `Request::builder().method(..).uri(..).body(p).unwrap()`
|
||
// peels to `...body`, allowing a simpler `body` /
|
||
// `Request::builder.body` matcher to fire.
|
||
let peeled = crate::ssa::type_facts::peel_identity_suffix(&chain_text);
|
||
if peeled != chain_text && peeled != text {
|
||
let peeled_labels = classify_all(lang, &peeled, extra);
|
||
for l in peeled_labels {
|
||
if !labels.contains(&l) {
|
||
labels.push(l);
|
||
}
|
||
}
|
||
}
|
||
// Pattern synthesis: the hyper request-builder chain
|
||
// (`hyper::Request::builder().method(..).uri(..).body(p)`)
|
||
// can interleave `.method`, `.uri`, `.header`, `.version`
|
||
// etc. between `Request::builder` and the body-bind step.
|
||
// Suffix matchers can't span those, so synthesise a
|
||
// DATA_EXFIL sink whenever the chain begins with
|
||
// `Request::builder` and ends in a body-binding verb.
|
||
// Strictly additive: no labels are removed, only added,
|
||
// and the synthesis only fires when an explicit Sink
|
||
// hasn't already attached.
|
||
let chain_for_synth = if peeled != chain_text {
|
||
&peeled
|
||
} else {
|
||
&chain_text
|
||
};
|
||
if !labels
|
||
.iter()
|
||
.any(|l| matches!(l, DataLabel::Sink(c) if c.contains(crate::labels::Cap::DATA_EXFIL)))
|
||
&& (chain_for_synth.contains("Request::builder.")
|
||
|| chain_for_synth.contains("hyper::Request::builder."))
|
||
{
|
||
let last_seg =
|
||
chain_for_synth.rsplit('.').next().unwrap_or(chain_for_synth);
|
||
if matches!(
|
||
last_seg,
|
||
"body" | "body_mut" | "body_string" | "body_json" | "body_bytes"
|
||
) {
|
||
labels.push(DataLabel::Sink(crate::labels::Cap::DATA_EXFIL));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// If the outermost call didn't classify, try inner/nested calls.
|
||
// E.g. `str(eval(expr))`, `str` is not a sink, but `eval` is.
|
||
// When the callee is overridden, save the original for container ops
|
||
// (e.g. `parts.add(req.getParameter(...))`, callee becomes
|
||
// "req.getParameter" but outer_callee preserves "parts.add").
|
||
//
|
||
// Statement-level calls in languages without a separate
|
||
// `expression_statement` wrapper (Ruby, where `body_statement` directly
|
||
// contains the call AST node) reach `push_node` with `ast.kind() ==
|
||
// "call"` (`Kind::CallMethod`) rather than `Kind::CallWrapper`. Without
|
||
// including the call kinds in the gate, an unclassified outer wrapper
|
||
// around a sink (e.g. `YAML.safe_load(File.read(filename))` or
|
||
// `String.new(File.read(x))`) loses the inner sink's classification
|
||
// entirely — the outer call becomes a non-sink node, and the inner call
|
||
// is not emitted as a standalone CFG node because it sits inside the
|
||
// outer's `argument_list`. Cross-function summary extraction then
|
||
// misses the `param_to_sink` for the wrapper helper, breaking detection
|
||
// of every chain-style sink wrapper used in real Ruby CVEs (rswag
|
||
// CVE-2023-38337, the Marshal/JSON/YAML-of-File.read pattern, etc.).
|
||
let mut outer_callee: Option<String> = None;
|
||
let mut inner_callee_span: Option<(usize, usize)> = None;
|
||
// JS/TS Promise callback methods (`.then`/`.catch`/`.finally`) on chained
|
||
// receivers (`Promise.resolve(req.body).then(cb)`). Without this guard,
|
||
// `find_classifiable_inner_call` walks into the chain receiver and
|
||
// rewrites `text` from `.then` to `Promise.resolve` (which classifies as
|
||
// a Source), erasing the outer call's identity. The SSA layer then
|
||
// never sees a `then` callee, so `try_apply_promise_callback` never
|
||
// fires and taint on the resolved value is dropped. Detect the outer
|
||
// promise-callback method here and skip the rewrite — the outer call's
|
||
// identity is preserved, and the inner Promise.resolve's argument
|
||
// taint flows through `info.taint.uses` (implicit args) as the
|
||
// promise-callback handler already expects.
|
||
let outer_is_promise_callback = matches!(lang, "javascript" | "typescript" | "tsx")
|
||
&& find_call_node(ast, lang)
|
||
.and_then(|cn| {
|
||
cn.child_by_field_name("function")
|
||
.or_else(|| cn.child_by_field_name("method"))
|
||
})
|
||
.and_then(|fc| {
|
||
if matches!(fc.kind(), "member_expression" | "attribute") {
|
||
fc.child_by_field_name("property")
|
||
.or_else(|| fc.child_by_field_name("name"))
|
||
.and_then(|p| text_of(p, code))
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
.is_some_and(|leaf| crate::labels::is_promise_callback_method(lang, &leaf));
|
||
if labels.is_empty()
|
||
&& matches!(
|
||
lookup(lang, ast.kind()),
|
||
Kind::CallWrapper
|
||
| Kind::Assignment
|
||
| Kind::Return
|
||
| Kind::CallFn
|
||
| Kind::CallMethod
|
||
| Kind::CallMacro
|
||
)
|
||
&& let Some((inner_text, inner_label, inner_span)) =
|
||
find_classifiable_inner_call(ast, lang, code, extra)
|
||
{
|
||
labels.push(inner_label);
|
||
if !outer_is_promise_callback {
|
||
outer_callee = Some(text.clone());
|
||
text = inner_text;
|
||
inner_callee_span = Some(inner_span);
|
||
}
|
||
}
|
||
|
||
// For assignments like `element.innerHTML = value`, the inner-call heuristic
|
||
// above may have overridden `text` with a call on the RHS (e.g. getElementById).
|
||
// If that didn't produce a label, check the LHS property name, it may be a
|
||
// sink like `innerHTML`.
|
||
//
|
||
// This covers both direct `Kind::Assignment` nodes and `Kind::CallWrapper`
|
||
// nodes (expression_statement) that wrap an assignment.
|
||
if labels.is_empty() {
|
||
let assign_node = if matches!(lookup(lang, ast.kind()), Kind::Assignment) {
|
||
Some(ast)
|
||
} else if matches!(lookup(lang, ast.kind()), Kind::CallWrapper) {
|
||
// Walk children to find a nested assignment_expression
|
||
let mut cursor = ast.walk();
|
||
ast.children(&mut cursor)
|
||
.find(|c| matches!(lookup(lang, c.kind()), Kind::Assignment))
|
||
} else {
|
||
None
|
||
};
|
||
|
||
if let Some(assign) = assign_node
|
||
&& let Some(lhs) = assign.child_by_field_name("left")
|
||
{
|
||
// Try full member expression first (e.g. "location.href"), more
|
||
// specific and avoids false positives on `a.href`.
|
||
if let Some(full) = member_expr_text(lhs, code) {
|
||
if let Some(l) = classify(lang, &full, extra) {
|
||
labels.push(l);
|
||
}
|
||
}
|
||
// Fall back to property-only (e.g. "innerHTML") for sinks that
|
||
// don't need object context.
|
||
if labels.is_empty()
|
||
&& let Some(prop) = lhs.child_by_field_name("property")
|
||
&& let Some(prop_text) = text_of(prop, code)
|
||
{
|
||
if let Some(l) = classify(lang, &prop_text, extra) {
|
||
labels.push(l);
|
||
}
|
||
}
|
||
// Subscript-set form: `response.headers["X-Foo"] = bar`
|
||
// (Ruby `element_reference`, JS/TS `subscript_expression`,
|
||
// Python `subscript`). The LHS has no `property` field, so
|
||
// walk into the subscript's `object` and try classifying its
|
||
// member-expression text (e.g. `response.headers`). This
|
||
// lets header-injection sinks fire on the bare bracket form
|
||
// alongside the `set_header` / `headers_mut.insert` method
|
||
// shapes already covered above.
|
||
if labels.is_empty()
|
||
&& matches!(
|
||
lhs.kind(),
|
||
"subscript_expression" | "subscript" | "element_reference"
|
||
)
|
||
{
|
||
let obj = lhs
|
||
.child_by_field_name("object")
|
||
.or_else(|| lhs.child_by_field_name("value"))
|
||
.or_else(|| lhs.child(0));
|
||
if let Some(obj_node) = obj
|
||
&& let Some(obj_text) = member_expr_text(obj_node, code)
|
||
&& let Some(l) = classify(lang, &obj_text, extra)
|
||
{
|
||
labels.push(l);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// For declarations/assignments whose RHS is a member expression (not a call),
|
||
// try to classify the member expression text as a source.
|
||
// This handles `var x = process.env.CMD` (JS), `os.environ["KEY"]` (Python),
|
||
// and similar property-access-based source patterns.
|
||
// Skip when the assignment's RHS is itself a function/lambda literal ,
|
||
// labels found by `first_member_label` would come from inside the
|
||
// closure body and shouldn't tag the outer wrapper (e.g. Go's
|
||
// `run := func() { exec.Command(...) }` would otherwise inherit
|
||
// `exec.Command`'s Sink label). The function literal is handled as
|
||
// its own scope by `collect_nested_function_nodes`.
|
||
if labels.is_empty()
|
||
&& matches!(
|
||
lookup(lang, ast.kind()),
|
||
Kind::CallWrapper | Kind::Assignment
|
||
)
|
||
&& !rhs_is_function_literal(ast, lang)
|
||
&& let Some(found) = first_member_label(ast, lang, code, extra)
|
||
{
|
||
labels.push(found);
|
||
// Update text so the callee name reflects the source.
|
||
// Preserve the original callee in outer_callee so inter-procedural
|
||
// summary resolution can still find the wrapping function
|
||
// (e.g. `storeInto(req.query.input, items)` → callee="req.query.input"
|
||
// but outer_callee="storeInto").
|
||
//
|
||
// Skip the text rewrite when the outer call is a JS/TS promise
|
||
// callback method (`.then`/`.catch`/`.finally`). The `.then` call
|
||
// node must keep its `then` callee text so `try_apply_promise_callback`
|
||
// and the synthetic `source_to_callback` emission recognise it.
|
||
// The Source label still attaches, so the resolved-value taint
|
||
// flows from the inner `Promise.resolve(req.body)`.
|
||
if !outer_is_promise_callback {
|
||
if let Some(member_text) = first_member_text(ast, code) {
|
||
if outer_callee.is_none() && text != member_text {
|
||
outer_callee = Some(text.clone());
|
||
}
|
||
text = member_text;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Conditions can contain source/sink calls whose argument side effects are
|
||
// load-bearing for taint, e.g. C `if (!fgets(buf, n, stdin)) return;`.
|
||
// Classify the condition call so output-parameter sources still lower as
|
||
// SSA calls while the CFG node keeps its branch shape.
|
||
if labels.is_empty()
|
||
&& matches!(lookup(lang, ast.kind()), Kind::If | Kind::While)
|
||
&& let Some(cond) = ast.child_by_field_name("condition")
|
||
&& let Some((ident, ident_span)) = first_call_ident_with_span(cond, lang, code)
|
||
&& let Some(l) = classify(lang, &ident, extra)
|
||
{
|
||
labels.push(l);
|
||
text = ident;
|
||
if inner_text_span.is_none() {
|
||
inner_text_span = Some(ident_span);
|
||
}
|
||
}
|
||
|
||
// For `if let` / `while let` patterns: try to classify the value expression
|
||
// in the let-condition as a source/sink. E.g. `if let Ok(cmd) = env::var("CMD")`
|
||
// should recognise `env::var` as a taint source and label this node accordingly.
|
||
if labels.is_empty()
|
||
&& matches!(lookup(lang, ast.kind()), Kind::If | Kind::While)
|
||
&& let Some(cond) = ast.child_by_field_name("condition")
|
||
&& cond.kind() == "let_condition"
|
||
&& let Some(val) = cond.child_by_field_name("value")
|
||
{
|
||
if let Some((ident, ident_span)) = first_call_ident_with_span(val, lang, code)
|
||
&& let Some(l) = classify(lang, &ident, extra)
|
||
{
|
||
labels.push(l);
|
||
text = ident;
|
||
if inner_text_span.is_none() {
|
||
inner_text_span = Some(ident_span);
|
||
}
|
||
}
|
||
if labels.is_empty()
|
||
&& let Some(ident_text) = text_of(val, code)
|
||
&& let Some(l) = classify(lang, &ident_text, extra)
|
||
{
|
||
labels.push(l);
|
||
text = ident_text;
|
||
}
|
||
}
|
||
|
||
// Hoist call-node lookup: reused for gated sinks and arg_uses.
|
||
let mut call_ast = find_call_node(ast, lang);
|
||
|
||
// Chained-call inner-gate rebinding. When the outer call is a method-
|
||
// chain wrapper whose receiver is itself a call to a known gated sink
|
||
// (e.g. `http.get(uri, cb).on('error', e => ...)` or
|
||
// `axios.get(url).then(handler).catch(handler)`), the outer callee
|
||
// (`.on`, `.catch`) doesn't classify and the inner sink is invisible to
|
||
// gate classification + arg-use extraction. Rebind to the inner call
|
||
// so its sink fires and its args are checked.
|
||
//
|
||
// Only fires when:
|
||
// * `labels.is_empty()` (the outer call is non-classified)
|
||
// * the chain has a real inner call_expression
|
||
// * that inner callee actually matches a gate matcher for this lang
|
||
//
|
||
// Motivated by CVE-2025-64430 (Parse Server SSRF).
|
||
if labels.is_empty()
|
||
&& let Some(outer) = call_ast
|
||
&& let Some((inner, inner_callee_text)) = find_chained_inner_call(outer, lang, code)
|
||
&& !classify_gated_sink(lang, &inner_callee_text, |_| None, |_| None, |_| false).is_empty()
|
||
{
|
||
call_ast = Some(inner);
|
||
outer_callee = Some(text.clone());
|
||
text = inner_callee_text;
|
||
inner_callee_span = Some((inner.start_byte(), inner.end_byte()));
|
||
}
|
||
|
||
// Gated sinks: argument-sensitive classification (e.g., setAttribute).
|
||
// Runs for any node containing a classifiable call, regardless of StmtKind.
|
||
//
|
||
// Prefer the shallow `call_ast` from `find_call_node` when available, but
|
||
// fall back to a deeper walk (up to 4 levels) so wrapped calls still reach
|
||
// the gate. This is necessary for forms like `var r = await fetch(url)`
|
||
// (variable_declaration > variable_declarator > await_expression >
|
||
// call_expression) where the call sits at depth 3. When using the deeper
|
||
// walker we must also derive the callee text from the inner call node, not
|
||
// the outer statement `text`, so gate matcher names like `"fetch"` hit.
|
||
let mut sink_payload_args: Option<Vec<usize>> = None;
|
||
let mut destination_uses: Option<Vec<String>> = None;
|
||
let mut gate_filters: Vec<GateFilter> = Vec::new();
|
||
// Gates run when no flat `Sink` label is already present, OR when a
|
||
// matching gate restricts the payload-arg set on top of an existing flat
|
||
// sink. Source / Sanitizer labels are orthogonal — a callee like
|
||
// Python's `requests.post` is a `Source` for its response object AND a
|
||
// gated `Sink` for its URL/body argument positions; both should attach.
|
||
//
|
||
// Payload-arg refinement: when a flat sink matches a callee that ALSO
|
||
// has a gate entry restricting `payload_args`, the gate's `payload_args`
|
||
// are propagated to `sink_payload_args` so only those positions are
|
||
// taint-checked. Example: `execSync(cmd, { env: process.env })` matches
|
||
// the bare `execSync` flat `Sink(SHELL_ESCAPE)` AND the gate `=execSync`
|
||
// with `payload_args: &[0]`; without the refinement, the flat rule's
|
||
// implicit "all args" would flag `process.env` flowing into the options
|
||
// object's `env` field. The gate's labels themselves are deduped so a
|
||
// single capability never double-attributes.
|
||
let has_sink_label = labels.iter().any(|l| matches!(l, DataLabel::Sink(_)));
|
||
{
|
||
let gate_call = call_ast.or_else(|| find_call_node_deep(ast, lang, 4));
|
||
if let Some(cn) = gate_call {
|
||
// Derive the gate's callee text from the call's
|
||
// `function`/`method`/`name` field, falling back to `text`.
|
||
//
|
||
// The default is `text`, which by this point reflects the
|
||
// qualified callee for method calls (`Velocity.evaluate`,
|
||
// `$smarty->fetch`) reconstructed in the `Kind::CallMethod`
|
||
// arm. When `first_member_label` rewrites `text` to a member
|
||
// Source like `req.body` (because the wrapper carries one as
|
||
// an argument), the rewrite is correct for source attribution
|
||
// but defeats gate matching against a bare callee
|
||
// (`setValue(target, req.body, …)` would gate-match
|
||
// `req.body` instead of `setValue`).
|
||
//
|
||
// Detect that case structurally: a Source label is present AND
|
||
// the call's function-field text differs from `text`. The
|
||
// function field carries the actual callee identifier; when it
|
||
// disagrees with `text`, `text` was clobbered by a member-source
|
||
// override and the function field is the right gate target.
|
||
// Whitespace is stripped to mirror `find_chained_inner_call`
|
||
// so multi-line chains (`http\n .get(...)`) still match flat
|
||
// gate matchers like `http.get`.
|
||
let function_field_text: Option<String> = cn
|
||
.child_by_field_name("function")
|
||
.or_else(|| cn.child_by_field_name("method"))
|
||
.or_else(|| cn.child_by_field_name("name"))
|
||
.and_then(|f| text_of(f, code))
|
||
.map(|t| t.chars().filter(|c| !c.is_whitespace()).collect::<String>());
|
||
let has_source_label = labels
|
||
.iter()
|
||
.any(|l| matches!(l, crate::labels::DataLabel::Source(_)));
|
||
// Clippy flags one branch's clone as redundant because it cannot
|
||
// see that `text` is read after this `let` (further down in this
|
||
// function); silence the false positive without restructuring.
|
||
#[allow(clippy::redundant_clone)]
|
||
let gate_callee_text = if let Some(ff) = function_field_text.as_deref()
|
||
&& has_source_label
|
||
&& ff != text.as_str()
|
||
{
|
||
ff.to_string()
|
||
} else if call_ast.is_some() {
|
||
text.clone()
|
||
} else {
|
||
function_field_text.unwrap_or_else(|| text.clone())
|
||
};
|
||
let matches = classify_gated_sink(
|
||
lang,
|
||
&gate_callee_text,
|
||
|idx| {
|
||
extract_const_string_arg(cn, idx, code).or_else(|| {
|
||
// C/C++ preprocessor macros and PHP `define`d constants
|
||
// surface as identifier nodes, not string literals.
|
||
// Ruby option constants (e.g.
|
||
// `Nokogiri::XML::ParseOptions::NOENT`) surface as
|
||
// `scope_resolution` / `constant` nodes. Falling back
|
||
// to the macro-arg extractor for those languages lets
|
||
// gates like `curl_easy_setopt` / `curl_setopt` /
|
||
// `Nokogiri::XML` activate on a bare-leaf identifier
|
||
// match instead of firing conservatively on every
|
||
// positional arg.
|
||
if matches!(lang, "c" | "cpp" | "c++" | "php" | "ruby" | "rb") {
|
||
extract_const_macro_arg(cn, idx, code)
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
},
|
||
|kw| {
|
||
// For JS/TS, options-bearing args are passed as inline
|
||
// object literals (`fn(x, { evaluate: false })`) rather
|
||
// than language-level keyword arguments. When the
|
||
// standard `keyword_argument`-walking extractor returns
|
||
// None, fall back to inspecting arg 1's object literal
|
||
// for a property named `kw`. This lets gates like
|
||
// `_.template` consult `{ evaluate: false }` literally.
|
||
extract_const_keyword_arg(cn, kw, code).or_else(|| {
|
||
if matches!(lang, "javascript" | "typescript") {
|
||
extract_object_arg_property(cn, 1, kw, code)
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
},
|
||
|kw| {
|
||
has_keyword_arg(cn, kw, code)
|
||
|| (matches!(lang, "javascript" | "typescript")
|
||
&& has_object_arg_property(cn, 1, kw, code))
|
||
},
|
||
);
|
||
|
||
if !matches.is_empty() {
|
||
// Per-gate filter accumulation. Each match contributes:
|
||
// * its label (added to `labels` so `resolve_sink_caps`
|
||
// downstream sees the union),
|
||
// * a `GateFilter` carrying that gate's specific
|
||
// `(label_caps, payload_args, destination_uses)` so
|
||
// the SSA sink scan can attribute taint per-cap.
|
||
//
|
||
// When a flat sink already matches, gate labels are deduped
|
||
// so the same capability isn't attributed twice (once flat,
|
||
// once gated). Their `payload_args` still flow into
|
||
// `sink_payload_args` so the gate's arg-position restriction
|
||
// applies on top of the flat sink.
|
||
let mut union_payload: Vec<usize> = Vec::new();
|
||
for gm in &matches {
|
||
if has_sink_label {
|
||
if !labels.contains(&gm.label) {
|
||
labels.push(gm.label);
|
||
}
|
||
} else {
|
||
labels.push(gm.label);
|
||
}
|
||
|
||
let mut payload_vec: Vec<usize> =
|
||
if gm.payload_args == crate::labels::ALL_ARGS_PAYLOAD {
|
||
// Dynamic-activation sentinel: every positional arg is
|
||
// conservatively a payload. Expand using the actual
|
||
// call arity so `collect_tainted_sink_values` checks
|
||
// each one.
|
||
let arity = extract_arg_uses(cn, code).len();
|
||
(0..arity).collect()
|
||
} else {
|
||
gm.payload_args.to_vec()
|
||
};
|
||
|
||
// Destination-aware gates: when the gate declares
|
||
// destination-bearing object fields and a payload-position
|
||
// arg is an object literal at call time, narrow sink-taint
|
||
// checks to identifiers under those fields. Non-object
|
||
// arg forms return `None` from the extractor and the gate
|
||
// falls back to whole-arg positional filtering.
|
||
//
|
||
// The pair form preserves which object-literal field each
|
||
// ident was bound to (e.g. `body` vs `headers` vs `json`)
|
||
// so diag rendering can attribute `DATA_EXFIL` findings to
|
||
// a specific destination field.
|
||
let mut dest_uses: Option<Vec<String>> = None;
|
||
let mut dest_fields: Vec<String> = Vec::new();
|
||
if !gm.object_destination_fields.is_empty() {
|
||
let mut all_pairs: Vec<(String, String)> = Vec::new();
|
||
let mut had_object_match = false;
|
||
for &pos in gm.payload_args {
|
||
if let Some(pairs) = extract_destination_field_pairs(
|
||
cn,
|
||
pos,
|
||
gm.object_destination_fields,
|
||
code,
|
||
) {
|
||
all_pairs.extend(pairs);
|
||
had_object_match = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Direct kwargs: languages where destination-bearing
|
||
// fields are passed as `keyword_argument` siblings of
|
||
// the positional args (Python `data=`, Ruby kwargs).
|
||
// SSA lowering folds kwarg idents into the implicit
|
||
// args group at index `arity`, so we expand
|
||
// `payload_vec` to include that position; the
|
||
// `destination_filter` then narrows to the kwarg
|
||
// ident's `var_name`.
|
||
let kwarg_pairs =
|
||
extract_destination_kwarg_pairs(cn, gm.object_destination_fields, code);
|
||
if !kwarg_pairs.is_empty() {
|
||
let arity = extract_arg_uses(cn, code).len();
|
||
if !payload_vec.contains(&arity) {
|
||
payload_vec.push(arity);
|
||
}
|
||
for pair in kwarg_pairs {
|
||
if !all_pairs.iter().any(|(_, v)| v == &pair.1) {
|
||
all_pairs.push(pair);
|
||
}
|
||
}
|
||
}
|
||
|
||
if had_object_match || !all_pairs.is_empty() {
|
||
let (fields, vars): (Vec<String>, Vec<String>) =
|
||
all_pairs.into_iter().unzip();
|
||
dest_uses = Some(vars);
|
||
dest_fields = fields;
|
||
}
|
||
}
|
||
|
||
let label_caps = match gm.label {
|
||
crate::labels::DataLabel::Sink(c) => c,
|
||
_ => crate::labels::Cap::empty(),
|
||
};
|
||
|
||
for &p in &payload_vec {
|
||
if !union_payload.contains(&p) {
|
||
union_payload.push(p);
|
||
}
|
||
}
|
||
gate_filters.push(GateFilter {
|
||
label_caps,
|
||
payload_args: payload_vec,
|
||
destination_uses: dest_uses,
|
||
destination_fields: dest_fields,
|
||
});
|
||
}
|
||
if !union_payload.is_empty() {
|
||
sink_payload_args = Some(union_payload);
|
||
}
|
||
// Legacy single-gate path keeps `destination_uses` populated so
|
||
// the SSA fast-path (one filter) continues to work without
|
||
// consulting `gate_filters`. When multiple gates match,
|
||
// per-position filters live in `gate_filters` and the legacy
|
||
// field is intentionally left `None`.
|
||
if gate_filters.len() == 1 {
|
||
destination_uses = gate_filters[0].destination_uses.clone();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Inline shell-array sink synthesis ────────────────────────────────
|
||
//
|
||
// Recognise `[<shell>, "-c", <payload>]` (and `cmd /c <payload>`)
|
||
// appearing as an argument to *any* call. The shell-array shape itself
|
||
// is the gate, regardless of callee, so this fires through user-defined
|
||
// wrappers like `execInContainer(id, ["bash", "-c", `echo ${tainted}`])`
|
||
// without needing per-wrapper summary annotations. Only fires for JS/TS
|
||
// because the array-literal grammar (`array` node) and shell-form usage
|
||
// are JS/TS conventions; other languages use different shapes for
|
||
// shell-exec wrappers.
|
||
//
|
||
// The inner array also covers Dockerode's
|
||
// `container.exec({Cmd: [shell, "-c", payload]})`: the helper looks
|
||
// inside object-literal args for shell-array values under any field.
|
||
//
|
||
// Existing FP carve-outs are preserved. `["ls", "-la"]` doesn't match
|
||
// (element 0 is not a known shell). `untaintedArrayVariable` doesn't
|
||
// match (variable, not literal). `execSync(cmd, { env: process.env })`
|
||
// doesn't match (string + object args, no shell-array literal). When
|
||
// the payload elements are constant strings the helper returns no
|
||
// match, so a literal `["bash", "-c", "ls -la"]` doesn't fire either.
|
||
if matches!(lang, "javascript" | "js" | "typescript" | "ts") {
|
||
if let Some(cn) = call_ast.or_else(|| find_call_node_deep(ast, lang, 4)) {
|
||
let shell_matches = extract_shell_array_payload_idents(cn, code);
|
||
if !shell_matches.is_empty() {
|
||
let shell_label = DataLabel::Sink(Cap::SHELL_ESCAPE);
|
||
let already_has_shell_sink = labels.iter().any(|l| match l {
|
||
DataLabel::Sink(c) => c.contains(Cap::SHELL_ESCAPE),
|
||
_ => false,
|
||
});
|
||
if !already_has_shell_sink {
|
||
labels.push(shell_label);
|
||
}
|
||
|
||
let mut union_payload: Vec<usize> = sink_payload_args.clone().unwrap_or_default();
|
||
for sm in shell_matches {
|
||
if !union_payload.contains(&sm.arg_position) {
|
||
union_payload.push(sm.arg_position);
|
||
}
|
||
gate_filters.push(GateFilter {
|
||
label_caps: Cap::SHELL_ESCAPE,
|
||
payload_args: vec![sm.arg_position],
|
||
destination_uses: Some(sm.payload_idents),
|
||
destination_fields: Vec::new(),
|
||
});
|
||
}
|
||
if !union_payload.is_empty() {
|
||
sink_payload_args = Some(union_payload);
|
||
}
|
||
// Legacy single-gate path: when this is the only gate filter,
|
||
// populate the top-level destination_uses too so the SSA
|
||
// fast-path stays consistent with the multi-gate behaviour.
|
||
if gate_filters.len() == 1 {
|
||
destination_uses = gate_filters[0].destination_uses.clone();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Pattern-based sanitizer synthesis: recognise a Rust
|
||
// `param.replace(LIT, LIT)[.replace(LIT, LIT)]*` chain that provably strips
|
||
// path-traversal or HTML metacharacters. The CFG collapses the whole
|
||
// chain into a single call node, so detection must inspect the AST of
|
||
// that node directly. Only fires when no Sanitizer label already
|
||
// classifies this node, existing label rules win.
|
||
if lang == "rust" && !labels.iter().any(|l| matches!(l, DataLabel::Sanitizer(_))) {
|
||
if let Some(cn) = call_ast {
|
||
if cn.kind() == "call_expression" || cn.kind() == "method_call_expression" {
|
||
if let Some(caps) = detect_rust_replace_chain_sanitizer(cn, code) {
|
||
labels.push(DataLabel::Sanitizer(caps));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Pattern-based sanitizer synthesis for Go's `strings.Replace` /
|
||
// `strings.ReplaceAll`. When the call's OLD literal contains a known
|
||
// dangerous payload (shell metachars, path-traversal, HTML, SQL) and
|
||
// the NEW literal does not reintroduce one, treat the call as a
|
||
// Sanitizer over the matching caps. Same precedence as the Rust
|
||
// chain synthesis: explicit Sanitizer labels win, but otherwise the
|
||
// synthesised label feeds the standard sanitizer pathway in the
|
||
// taint engine. Motivated by helpers like
|
||
// `func validate(s string) string { return strings.ReplaceAll(s, ";", "") }`
|
||
// whose return is appended to a slice that later flows into
|
||
// `exec.Command(slice[i])`.
|
||
if lang == "go" && !labels.iter().any(|l| matches!(l, DataLabel::Sanitizer(_))) {
|
||
if let Some(cn) = call_ast {
|
||
if cn.kind() == "call_expression" {
|
||
if let Some(caps) = detect_go_replace_call_sanitizer(cn, code) {
|
||
labels.push(DataLabel::Sanitizer(caps));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// React JSX text-content auto-escape sanitizer synthesis. When the
|
||
// assignment / wrapper / return AST contains a `{expr}` interpolation as
|
||
// a direct child of a `jsx_element` or `jsx_fragment` (NOT inside a
|
||
// `jsx_attribute`), React's renderer escapes HTML metacharacters in the
|
||
// interpolated value. Tag the wrapping node `Sanitizer(HTML_ESCAPE)` so
|
||
// SSA-level Assign / Call processing clears `HTML_ESCAPE` from the
|
||
// resulting JSX value's caps. Strictly additive — Source / Sink labels
|
||
// already attached are preserved. Already-present `Sanitizer(HTML_ESCAPE)`
|
||
// is left untouched to avoid duplicate entries.
|
||
if matches!(lang, "javascript" | "typescript" | "tsx")
|
||
&& matches!(
|
||
lookup(lang, ast.kind()),
|
||
Kind::CallWrapper | Kind::Assignment | Kind::Return
|
||
)
|
||
&& !labels
|
||
.iter()
|
||
.any(|l| matches!(l, DataLabel::Sanitizer(c) if c.contains(Cap::HTML_ESCAPE)))
|
||
&& jsx_text_content_interp_present(ast, lang)
|
||
{
|
||
labels.push(DataLabel::Sanitizer(Cap::HTML_ESCAPE));
|
||
}
|
||
|
||
// Shape-based sanitizer synthesis for Ruby ActiveRecord query methods.
|
||
// The static label table marks `where` / `order` / `pluck` / `group` /
|
||
// `having` / `joins` as `Sink(SQL_QUERY)` because their string-interpolation
|
||
// form (`Model.where("id = #{x}")`) is a real SQLi vector. But the same
|
||
// methods are intrinsically parameterised when arg 0 is a hash, symbol,
|
||
// array, or non-interpolated string, Rails escapes the values. Rather
|
||
// than dropping the sink (which would lose the genuine TPs), synthesise
|
||
// a same-node `Sanitizer(SQL_QUERY)` for the safe shapes; this clears
|
||
// SQL taint at the call and reflexively dominates the sink, suppressing
|
||
// both `taint-unsanitised-flow` and `cfg-unguarded-sink` for the safe
|
||
// forms while leaving the dangerous ones to fire.
|
||
//
|
||
// Chained calls (`Model.where(...).preload(...).to_a`) collapse into a
|
||
// single CFG node whose outer `call_ast` may be `to_a` (no args). The
|
||
// shape inspection has to walk the receiver chain to reach the AR query
|
||
// call itself, `ruby_chain_arg0_for_method` does that walk.
|
||
if (lang == "ruby" || lang == "rb")
|
||
&& labels
|
||
.iter()
|
||
.any(|l| matches!(l, DataLabel::Sink(c) if c.contains(Cap::SQL_QUERY)))
|
||
&& !labels
|
||
.iter()
|
||
.any(|l| matches!(l, DataLabel::Sanitizer(c) if c.contains(Cap::SQL_QUERY)))
|
||
{
|
||
// Identify the matched AR query method from the callee `text`
|
||
// (e.g. "Issue.where" → "where", "joins(:project).where" → "where").
|
||
let leaf = text.rsplit(['.', ':']).next().unwrap_or(&text);
|
||
const AR_QUERY_METHODS: &[&str] = &["where", "order", "group", "having", "joins", "pluck"];
|
||
if AR_QUERY_METHODS.contains(&leaf) {
|
||
// Try the outer call's arg 0 first (handles direct calls);
|
||
// fall back to walking the receiver chain for collapsed
|
||
// chained-call CFG nodes.
|
||
let shape = call_ast
|
||
.and_then(arg0_kind_and_interpolation)
|
||
.or_else(|| ruby_chain_arg0_for_method(ast, &[leaf], code));
|
||
if let Some((arg0_kind, has_interp)) = shape
|
||
&& crate::labels::ruby::ar_query_safe_shape(&text, &arg0_kind, has_interp)
|
||
{
|
||
labels.push(DataLabel::Sanitizer(Cap::SQL_QUERY));
|
||
}
|
||
}
|
||
}
|
||
|
||
// Shape-based sanitizer synthesis for Java JPA / JDBC parameterised
|
||
// execute calls. `executeUpdate` and `executeQuery` are labelled
|
||
// `Sink(SQL_QUERY)` because the JDBC `Statement.executeUpdate(String)`
|
||
// and `Statement.executeQuery(String)` overloads are real injection
|
||
// sinks when given a concatenated SQL string. But the same method
|
||
// names on JPA `javax.persistence.Query` and JDBC `PreparedStatement`
|
||
// are zero-arg, they execute SQL that was bound upstream by
|
||
// `entityManager.createQuery(LITERAL)` / `connection.prepareStatement(LITERAL)`,
|
||
// and any bind values went through `setParameter` / `setString`
|
||
// (which the JDBC/JPA driver escapes). Walk the receiver chain to
|
||
// find the SQL-binding call and verify its arg 0 is a string literal;
|
||
// if so, synthesise a same-node `Sanitizer(SQL_QUERY)` which
|
||
// reflexively dominates the sink, suppressing both
|
||
// `cfg-unguarded-sink` and `taint-unsanitised-flow` for the safe
|
||
// chain shape while leaving `Statement.executeUpdate(concat)` and
|
||
// `createQuery(concat)` to fire as real findings.
|
||
if lang == "java"
|
||
&& labels
|
||
.iter()
|
||
.any(|l| matches!(l, DataLabel::Sink(c) if c.contains(Cap::SQL_QUERY)))
|
||
&& !labels
|
||
.iter()
|
||
.any(|l| matches!(l, DataLabel::Sanitizer(c) if c.contains(Cap::SQL_QUERY)))
|
||
{
|
||
let leaf = text.rsplit('.').next().unwrap_or(&text);
|
||
if matches!(leaf, "executeUpdate" | "executeQuery") {
|
||
// Outer call must be zero-arg (the prepared/parameterised
|
||
// execute shape). The N-arg overload `Statement.executeUpdate(SQL)`
|
||
// is a real sink and must continue to fire.
|
||
let outer_zero_arg = call_ast
|
||
.and_then(|cn| cn.child_by_field_name("arguments"))
|
||
.map(|args| {
|
||
let mut c = args.walk();
|
||
args.named_children(&mut c).count() == 0
|
||
})
|
||
.unwrap_or(false);
|
||
if outer_zero_arg {
|
||
// Walk the receiver chain to find a SQL-binding call
|
||
// (`createQuery` / `createNativeQuery` / `prepareStatement`)
|
||
// and require its arg 0 to be a string literal. Anything
|
||
// else (binary concat, identifier, method call) leaves
|
||
// the sink in place, we cannot prove the SQL is
|
||
// parameterised, so the structural finding stands.
|
||
const JPA_BIND_METHODS: &[&str] = &[
|
||
"createQuery",
|
||
"createNativeQuery",
|
||
"createNamedQuery",
|
||
"prepareStatement",
|
||
"prepareCall",
|
||
];
|
||
if let Some(call_node) = call_ast
|
||
&& let Some(arg0_kind) =
|
||
java_chain_arg0_kind_for_method(call_node, JPA_BIND_METHODS, code)
|
||
&& arg0_kind == "string_literal"
|
||
{
|
||
labels.push(DataLabel::Sanitizer(Cap::SQL_QUERY));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Shape-based sanitizer synthesis for JS/TS ORM-accessor chains.
|
||
// The static label table marks `db.query` / `connection.query` /
|
||
// `pool.query` / `client.query` / `db.execute` as `Sink(SQL_QUERY)`
|
||
// because the bare `connection.query("SELECT ..." + name)` form is a
|
||
// real SQLi sink. But the same `db.query` method on Strapi-style ORMs
|
||
// takes a model UID literal and returns a chainable model accessor:
|
||
// `strapi.db.query('admin::api-token').findOne({ where: whereParams })`.
|
||
// The trailing `.findOne({...})` / `.findMany({...})` / `.create(...)`
|
||
// calls are intrinsically parameterised, the actual SQL is generated
|
||
// by the ORM, and the per-call values arrive through field-keyed object
|
||
// literals that the ORM driver escapes.
|
||
//
|
||
// Recognition rule: when the CFG node's classified text reaches a sink
|
||
// with `SQL_QUERY` cap, walk the receiver chain looking for an inner
|
||
// `*.query(...)` / `*.execute(...)` whose arg 0 is a string literal
|
||
// and whose result has at least one chained method call appended whose
|
||
// name is in the ORM-accessor whitelist. If both hold, synthesise a
|
||
// same-node `Sanitizer(SQL_QUERY)` mirroring the Java JPA fix. Bare
|
||
// `connection.query("SELECT ...")` (no chained method) and
|
||
// `db.query("UPDATE x SET y=" + name)` (non-literal arg 0) leave the
|
||
// sink in place, both are genuine SQLi shapes.
|
||
if (lang == "javascript"
|
||
|| lang == "js"
|
||
|| lang == "typescript"
|
||
|| lang == "ts"
|
||
|| lang == "tsx")
|
||
&& labels
|
||
.iter()
|
||
.any(|l| matches!(l, DataLabel::Sink(c) if c.contains(Cap::SQL_QUERY)))
|
||
&& !labels
|
||
.iter()
|
||
.any(|l| matches!(l, DataLabel::Sanitizer(c) if c.contains(Cap::SQL_QUERY)))
|
||
{
|
||
const QUERY_TARGETS: &[&str] = &["query", "execute"];
|
||
// ORM-accessor methods that take object-literal args and return
|
||
// promises of rows / row counts. Promise methods (`then`, `catch`,
|
||
// `finally`) deliberately excluded, they don't prove ORM shape.
|
||
const ORM_CHAIN_METHODS: &[&str] = &[
|
||
"findOne",
|
||
"findMany",
|
||
"findFirst",
|
||
"findUnique",
|
||
"findById",
|
||
"find",
|
||
"create",
|
||
"createMany",
|
||
"update",
|
||
"updateMany",
|
||
"upsert",
|
||
"delete",
|
||
"deleteMany",
|
||
"count",
|
||
"aggregate",
|
||
"distinct",
|
||
"save",
|
||
];
|
||
// Fall back to a deeper walk (up to 4 levels) for await/return-
|
||
// wrapped calls (e.g. `const x = await db.query(...).findOne(...)` ,
|
||
// call sits at depth 3 inside lexical_declaration > variable_declarator
|
||
// > await_expression > call_expression).
|
||
let chain_call = call_ast.or_else(|| find_call_node_deep(ast, lang, 4));
|
||
if let Some(call_node) = chain_call {
|
||
// Outer method must be in the ORM whitelist *and* the chain must
|
||
// have a deeper inner call to a `query`/`execute` whose arg 0 is
|
||
// a string literal. Both checks gate the synthesis.
|
||
let outer_method = js_chain_outer_method_for_inner(call_node, QUERY_TARGETS, code);
|
||
let outer_is_orm = outer_method
|
||
.as_deref()
|
||
.is_some_and(|m| ORM_CHAIN_METHODS.contains(&m));
|
||
if outer_is_orm
|
||
&& let Some((arg0_kind, has_interp)) =
|
||
js_chain_arg0_kind_for_method(call_node, QUERY_TARGETS, code)
|
||
&& !has_interp
|
||
&& matches!(
|
||
arg0_kind.as_str(),
|
||
"string" | "string_fragment" | "template_string"
|
||
)
|
||
{
|
||
labels.push(DataLabel::Sanitizer(Cap::SQL_QUERY));
|
||
}
|
||
}
|
||
}
|
||
|
||
let span = (ast.start_byte(), ast.end_byte());
|
||
|
||
/* ── 3. GRAPH INSERTION + DEBUG ──────────────────────────────────── */
|
||
|
||
let (defines, uses, extra_defines, array_pattern_indices, rhs_array_elements) =
|
||
def_use(ast, lang, code, extra);
|
||
|
||
// Capture constant text for SSA constant propagation: when this node
|
||
// defines a variable from a syntactic literal (no identifier uses),
|
||
// extract the raw literal text from the AST. Also capture the
|
||
// argument of a const-return (`return []`) so the SSA const-return
|
||
// synthesis can emit `Const(Some(text))` instead of `Const(None)`,
|
||
// surfacing the literal text to downstream container-literal
|
||
// detection.
|
||
let const_text = if (defines.is_some() && uses.is_empty())
|
||
|| (kind == StmtKind::Return && uses.is_empty())
|
||
{
|
||
extract_literal_rhs(ast, lang, code)
|
||
} else {
|
||
None
|
||
};
|
||
|
||
let callee = if kind == StmtKind::Call || !labels.is_empty() {
|
||
Some(text.clone())
|
||
} else {
|
||
None
|
||
};
|
||
|
||
// Extract condition metadata for If nodes. Python `elif_clause` and PHP
|
||
// `else_if_clause` are lowered as guard nodes by `build_alternative_chain`
|
||
// (the flat-sibling elif-chain handler) and carry their own `condition`
|
||
// field, so they must also receive condition-metadata extraction even
|
||
// though their grammar kind maps to `Kind::Block`. These two node kinds
|
||
// only ever reach `push_node` from that handler, so widening the guard
|
||
// here cannot affect ordinary block lowering.
|
||
let (condition_text, condition_vars, condition_negated, cond_arith) =
|
||
if matches!(lookup(lang, ast.kind()), Kind::If)
|
||
|| matches!(ast.kind(), "elif_clause" | "else_if_clause")
|
||
{
|
||
extract_condition_raw(ast, lang, code)
|
||
} else {
|
||
(None, Vec::new(), false, None)
|
||
};
|
||
|
||
// Extract per-argument identifiers for Call nodes.
|
||
// Also extract for gated-sink nodes so payload-arg filtering works.
|
||
let arg_uses = if kind == StmtKind::Call || sink_payload_args.is_some() {
|
||
call_ast
|
||
.map(|cn| extract_arg_uses(cn, code))
|
||
.unwrap_or_default()
|
||
} else {
|
||
Vec::new()
|
||
};
|
||
|
||
// String-literal values at each positional argument, parallel to
|
||
// `arg_uses`. Populated whenever there is a call AST so downstream
|
||
// passes (static-map, symex, sink suppression) can consume literals
|
||
// without re-accessing source bytes.
|
||
let arg_string_literals = call_ast
|
||
.map(|cn| extract_arg_string_literals(cn, code))
|
||
.unwrap_or_default();
|
||
|
||
// Extract keyword / named arguments for Call and gated-sink nodes.
|
||
// Languages whose grammar doesn't produce `keyword_argument` / `named_argument`
|
||
// children return an empty Vec, so this costs nothing for C/Java/Go/etc.
|
||
let kwargs = if kind == StmtKind::Call || sink_payload_args.is_some() {
|
||
call_ast
|
||
.map(|cn| extract_kwargs(cn, code))
|
||
.unwrap_or_default()
|
||
} else {
|
||
Vec::new()
|
||
};
|
||
|
||
// Check whether all arguments are syntactic literals (for taint sink suppression).
|
||
let all_args_literal = if kind == StmtKind::Call {
|
||
call_ast
|
||
.map(|cn| has_only_literal_args(cn, code))
|
||
.unwrap_or(false)
|
||
} else {
|
||
false
|
||
};
|
||
|
||
// Detect parameterized SQL queries: arg 0 is a string literal with
|
||
// placeholder patterns ($1, ?, %s, :name) and >= 2 args present.
|
||
// Uses a deeper recursive search than `call_ast` (which only goes 2
|
||
// levels) to handle await-wrapped calls inside declarations.
|
||
let parameterized_query = labels
|
||
.iter()
|
||
.any(|l| matches!(l, DataLabel::Sink(c) if c.contains(Cap::SQL_QUERY)))
|
||
&& call_ast
|
||
.or_else(|| find_call_node_deep(ast, lang, 5))
|
||
.is_some_and(|cn| is_parameterized_query_call(cn, code));
|
||
|
||
// Extract per-argument inner call callees for interprocedural sanitizer resolution.
|
||
// Also extracted for non-Call kinds (e.g. Assign whose RHS is a call like
|
||
// `errs = append(errs, f.Close())`) so the inner-call-release-in-arg branch
|
||
// in src/state/transfer.rs sees the closing call.
|
||
let mut arg_callees = call_ast
|
||
.map(|cn| extract_arg_callees(cn, lang, code))
|
||
.unwrap_or_default();
|
||
|
||
// For assignment sinks (including CallWrapper-wrapped assignments like
|
||
// `element.innerHTML = clean(name)`), also extract the RHS callee.
|
||
// This runs regardless of kind because a CallWrapper node may have
|
||
// kind=Call (for the contained getElementById call) yet the actual
|
||
// sink is the assignment to innerHTML.
|
||
if !labels.is_empty() {
|
||
let assign_node = if matches!(lookup(lang, ast.kind()), Kind::Assignment) {
|
||
Some(ast)
|
||
} else if matches!(lookup(lang, ast.kind()), Kind::CallWrapper) {
|
||
let mut cursor = ast.walk();
|
||
ast.children(&mut cursor)
|
||
.find(|c| matches!(lookup(lang, c.kind()), Kind::Assignment))
|
||
} else {
|
||
None
|
||
};
|
||
if let Some(asgn) = assign_node
|
||
&& let Some(rhs) = asgn.child_by_field_name("right")
|
||
&& let Some(callee_name) = call_ident_of(rhs, lang, code)
|
||
{
|
||
arg_callees.push(Some(callee_name));
|
||
}
|
||
}
|
||
|
||
// For method-style calls, extract the receiver identifier as a separate
|
||
// channel on `CallMeta.receiver`. The receiver is **not** prepended to
|
||
// `arg_uses`: `arg_uses` contains positional-argument identifiers only,
|
||
// and the receiver is carried as its own typed channel end-to-end
|
||
// (SSA `SsaOp::Call.receiver`, summary `receiver_to_return`/`receiver_to_sink`).
|
||
//
|
||
// Two cases:
|
||
// 1. Kind::CallMethod, native method call AST (Java method_invocation,
|
||
// Rust method_call_expression, Ruby call, PHP member_call_expression).
|
||
// Receiver is exposed via "object"/"receiver"/"scope" field on the call.
|
||
// 2. Kind::CallFn whose function child is a member_expression (JS/TS) or
|
||
// attribute (Python). These grammars model `obj.method(x)` as a plain
|
||
// call_expression/call with a dotted-name function child. Without this
|
||
// branch the structured `receiver` stays `None` and type-qualified
|
||
// resolution loses its anchor.
|
||
let receiver = if let Some(cn) = call_ast {
|
||
match lookup(lang, cn.kind()) {
|
||
Kind::CallMethod => {
|
||
let recv_node = cn
|
||
.child_by_field_name("object")
|
||
.or_else(|| cn.child_by_field_name("receiver"))
|
||
.or_else(|| cn.child_by_field_name("scope"))
|
||
// Rust `method_call_expression` names the receiver "value".
|
||
.or_else(|| cn.child_by_field_name("value"));
|
||
if let Some(rn) = recv_node
|
||
&& matches!(rn.kind(), "identifier" | "variable_name")
|
||
&& let Some(recv_text) = text_of(rn, code)
|
||
{
|
||
Some(recv_text)
|
||
} else if let Some(rn) = recv_node {
|
||
// Complex receiver (chain / field access / nested call).
|
||
// Drill through member/field/call nodes to the leftmost
|
||
// plain identifier so var_stacks lookup resolves the SSA
|
||
// value, which is what type-qualified resolution
|
||
// anchors on. Falls back to `root_receiver_text` (which
|
||
// returns raw text like "conn.execute") only if drilling
|
||
// fails, preserving prior behavior for types we can't
|
||
// structurally reduce.
|
||
root_member_receiver(rn, code).or_else(|| root_receiver_text(cn, lang, code))
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
Kind::CallFn => {
|
||
// JS/TS `obj.method(x)`: call_expression.function = member_expression.
|
||
// Python `obj.method(x)`: call.function = attribute.
|
||
// Rust `obj.method(x)`: call_expression.function = field_expression
|
||
// (field on `value`, not `object`, value can be another call
|
||
// for chained forms like `Connection::open(p).unwrap().execute(...)`).
|
||
// Go `obj.method(x)`: call_expression.function = selector_expression
|
||
// (operand=receiver, field=method name). Without this branch,
|
||
// `userDb.Raw(sql)` where `userDb` was bound from `gorm.Open(...)`
|
||
// loses its receiver channel, so type-qualified resolution can't
|
||
// rewrite `userDb.Raw` → `GormDb.Raw`.
|
||
// Pull the receiver from the object/attribute-owner field.
|
||
let func_child = cn.child_by_field_name("function");
|
||
let recv_node = match func_child {
|
||
Some(fc) if fc.kind() == "member_expression" || fc.kind() == "attribute" => {
|
||
fc.child_by_field_name("object")
|
||
}
|
||
Some(fc) if fc.kind() == "field_expression" => fc.child_by_field_name("value"),
|
||
Some(fc) if fc.kind() == "selector_expression" => {
|
||
fc.child_by_field_name("operand")
|
||
}
|
||
_ => None,
|
||
};
|
||
if let Some(rn) = recv_node {
|
||
if matches!(rn.kind(), "identifier" | "variable_name" | "this" | "self") {
|
||
text_of(rn, code)
|
||
} else {
|
||
// Complex receiver (nested attribute, chained call, subscript).
|
||
// Drill to the leftmost plain identifier; when the chain is
|
||
// purely member_expression/attribute nodes, we want the base
|
||
// identifier (e.g. `request` for `request.args.get`).
|
||
root_member_receiver(rn, code)
|
||
.or_else(|| root_receiver_text(rn, lang, code))
|
||
}
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
_ => None,
|
||
}
|
||
} else {
|
||
None
|
||
};
|
||
|
||
// Extract cast/type-assertion target type from AST node.
|
||
let cast_target_type = match ast.kind() {
|
||
// Java: (Type) expr
|
||
"cast_expression" => ast
|
||
.child_by_field_name("type")
|
||
.filter(|n| matches!(n.kind(), "type_identifier" | "scoped_type_identifier"))
|
||
.and_then(|n| text_of(n, code)),
|
||
// TypeScript: expr as Type
|
||
"as_expression" => ast
|
||
.child_by_field_name("type")
|
||
.filter(|n| matches!(n.kind(), "type_identifier" | "predefined_type"))
|
||
.and_then(|n| text_of(n, code)),
|
||
// TypeScript: <Type>expr (angle-bracket syntax)
|
||
"type_assertion" => ast
|
||
.child(0)
|
||
.filter(|n| matches!(n.kind(), "type_identifier" | "predefined_type"))
|
||
.and_then(|n| text_of(n, code)),
|
||
// Go: expr.(Type)
|
||
"type_assertion_expression" => ast
|
||
.child_by_field_name("type")
|
||
.filter(|n| matches!(n.kind(), "type_identifier" | "qualified_type"))
|
||
.and_then(|n| text_of(n, code)),
|
||
_ => None,
|
||
};
|
||
|
||
// RAII-managed resource detection: tag acquire nodes whose resources
|
||
// are automatically cleaned up by language semantics (ownership/drop,
|
||
// smart pointers). Follows the same pattern as `managed_resource` for
|
||
// Python `with` and Java try-with-resources.
|
||
let is_raii_managed = is_raii_factory(lang, &text);
|
||
|
||
// Ruby block form auto-close: `File.open(path) { |f| f.read }` ,
|
||
// the block parameter receives the resource and Ruby guarantees close
|
||
// at block exit. If assigned (`f = File.open(p) { ... }`), the
|
||
// variable holds the block's return value, not an open resource.
|
||
let is_ruby_block_managed = lang == "ruby"
|
||
&& call_ast.is_some_and(|cn| {
|
||
let mut c = cn.walk();
|
||
cn.children(&mut c)
|
||
.any(|ch| ch.kind() == "do_block" || ch.kind() == "block")
|
||
});
|
||
|
||
let string_prefix = extract_template_prefix(ast, lang, code)
|
||
.or_else(|| call_ast.and_then(|cn| extract_template_prefix(cn, lang, code)));
|
||
|
||
// Prefer the span of the call found by `find_classifiable_inner_call`
|
||
// (deeper, classification-driven) over the one from `first_call_ident`
|
||
// (shallower, text-override-driven). Only record `callee_span` when it
|
||
// actually narrows against `ast.span`, storing a redundant copy would
|
||
// just bloat every labeled Call node.
|
||
let callee_span = inner_callee_span.or(inner_text_span).filter(|s| *s != span);
|
||
|
||
// Constructor detection: a `new X(...)` call carries different cap
|
||
// semantics than a plain function call. The SSA Call transfer uses
|
||
// this flag to narrow the constructed value's caps so out-of-process
|
||
// side-effect bits (FILE_IO, FMT_STRING, URL_ENCODE, JSON_PARSE) on
|
||
// the arguments don't survive into a wrapper-object instance.
|
||
// Recognised forms:
|
||
// * JS/TS `new_expression`
|
||
// * Java/C++ `object_creation_expression`
|
||
// * PHP `object_creation_expression`
|
||
let is_constructor = ast.kind() == "new_expression"
|
||
|| ast.kind() == "object_creation_expression"
|
||
|| call_ast
|
||
.is_some_and(|cn| matches!(cn.kind(), "new_expression" | "object_creation_expression"));
|
||
|
||
// Detect `Object.create(null)` so TypeFacts can tag the returned
|
||
// SsaValue with `NullPrototypeObject` for flow-sensitive
|
||
// prototype-pollution suppression. Restricted to JS/TS where
|
||
// `Object.create` is the idiomatic null-prototype constructor.
|
||
let produces_null_proto = matches!(lang, "javascript" | "typescript")
|
||
&& call_ast.is_some_and(|cn| is_object_create_null_call(cn, code));
|
||
|
||
let idx = g.add_node(NodeInfo {
|
||
kind,
|
||
call: CallMeta {
|
||
callee,
|
||
callee_text: None,
|
||
outer_callee,
|
||
callee_span,
|
||
call_ordinal,
|
||
arg_uses,
|
||
receiver,
|
||
sink_payload_args,
|
||
kwargs,
|
||
arg_string_literals,
|
||
destination_uses,
|
||
gate_filters,
|
||
is_constructor,
|
||
produces_null_proto,
|
||
},
|
||
taint: TaintMeta {
|
||
labels,
|
||
const_text,
|
||
defines,
|
||
uses,
|
||
extra_defines,
|
||
array_pattern_indices,
|
||
rhs_array_elements,
|
||
},
|
||
ast: AstMeta {
|
||
span,
|
||
enclosing_func: enclosing_func.map(|s| s.to_string()),
|
||
},
|
||
condition_text,
|
||
condition_vars,
|
||
condition_negated,
|
||
cond_arith,
|
||
all_args_literal,
|
||
catch_param: false,
|
||
arg_callees,
|
||
cast_target_type,
|
||
bin_op: extract_bin_op(ast, lang),
|
||
bin_op_const: extract_bin_op_const(ast, lang, code),
|
||
managed_resource: is_raii_managed || is_ruby_block_managed,
|
||
in_defer: false,
|
||
parameterized_query,
|
||
string_prefix,
|
||
is_eq_with_const: detect_eq_with_const(ast, lang),
|
||
is_numeric_length_access: detect_numeric_length_access(ast, lang, code),
|
||
member_field: detect_member_field_assignment(ast, code),
|
||
rhs_is_function_literal: rhs_is_function_literal(ast, lang),
|
||
is_await_forward: lookup(lang, ast.kind()) == Kind::AwaitForward,
|
||
});
|
||
|
||
debug!(
|
||
target: "cfg",
|
||
"node {} ← {:?} txt=`{}` span={:?} labels={:?}",
|
||
idx.index(),
|
||
kind,
|
||
text,
|
||
span,
|
||
g[idx].taint.labels
|
||
);
|
||
idx
|
||
}
|
||
|
||
/// Add the same edge (of the same kind) from every node in `froms` to `to`.
|
||
#[inline]
|
||
pub(super) fn connect_all(g: &mut Cfg, froms: &[NodeIndex], to: NodeIndex, kind: EdgeKind) {
|
||
for &f in froms {
|
||
debug!(target: "cfg", "edge {} → {} ({:?})", f.index(), to.index(), kind);
|
||
g.add_edge(f, to, kind);
|
||
}
|
||
}
|
||
|
||
/// Pre-emit dedicated Source CFG nodes for call arguments that contain source
|
||
/// member expressions.
|
||
///
|
||
/// **Two-step API**, Source nodes must be created *before* the Call node so
|
||
/// they receive lower graph indices. This is critical because the If handler
|
||
/// uses `NodeIndex::new(g.node_count())` to capture the first node built in a
|
||
/// branch and wires a True/False edge to it. If the Source node has a lower
|
||
/// index than the Call node, the True edge lands on the Source node, and the
|
||
/// engine's redundant-Seq-edge skip logic correctly drops the parallel Seq
|
||
/// edge from the condition. Without this ordering, the Seq edge would bypass
|
||
/// the auth-elevation transfer on the True edge and send Unauthed state into
|
||
/// the branch body.
|
||
///
|
||
/// True when `ast` is an assignment / declaration whose RHS is a
|
||
/// function or lambda literal, i.e. shapes like
|
||
/// * Go `run := func() { ... }`
|
||
/// * JS/TS `var run = function() { ... }` / `const run = () => ...`
|
||
/// * Python `run = lambda x: ...`
|
||
/// * Ruby `run = ->() { ... }` / `run = proc { ... }`
|
||
///
|
||
/// Detected by walking the assignment's `right` / `value` field (or the
|
||
/// `init` field for declarators) and checking whether the resolved RHS
|
||
/// node classifies as `Kind::Function`. Conservative: when the assignment
|
||
/// shape isn't recognised the function returns `false`.
|
||
///
|
||
/// Used by `push_node`'s RHS member-text fallback to suppress source/sink
|
||
/// label propagation from inside the literal's body up onto the outer
|
||
/// wrapper assignment. The literal is processed as its own scope by
|
||
/// `collect_nested_function_nodes`.
|
||
fn rhs_is_function_literal(ast: Node, lang: &str) -> bool {
|
||
use conditions::unwrap_parens;
|
||
|
||
// Find the RHS node across the languages we support. Most grammars
|
||
// expose `right` (assignment_statement, assignment_expression,
|
||
// short_var_declaration); JS / Java use `value` on
|
||
// `variable_declarator` / `init_declarator`; Rust uses `value` on
|
||
// `let_declaration`.
|
||
let mut candidate = ast.child_by_field_name("right");
|
||
|
||
if candidate.is_none() {
|
||
// Walk one level into declarations whose direct child is the
|
||
// declarator (variable_declaration → variable_declarator →
|
||
// value), or expression-statement wrappers whose direct child is
|
||
// an assignment_expression / assignment with a `right` field
|
||
// (JS `expression_statement > assignment_expression`, Python
|
||
// `expression_statement > assignment`).
|
||
let mut cursor = ast.walk();
|
||
for c in ast.children(&mut cursor) {
|
||
if matches!(
|
||
c.kind(),
|
||
"variable_declarator" | "init_declarator" | "let_declaration"
|
||
) {
|
||
candidate = c
|
||
.child_by_field_name("value")
|
||
.or_else(|| c.child_by_field_name("init"));
|
||
if candidate.is_some() {
|
||
break;
|
||
}
|
||
} else if matches!(lookup(lang, c.kind()), Kind::Assignment) {
|
||
candidate = c.child_by_field_name("right");
|
||
if candidate.is_some() {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if candidate.is_none() {
|
||
// Some grammars wrap the RHS in `expression_list` or similar.
|
||
// Search recursively for a Kind::Function descendant of the
|
||
// direct RHS-bearing fields.
|
||
candidate = ast
|
||
.child_by_field_name("value")
|
||
.or_else(|| ast.child_by_field_name("init"));
|
||
}
|
||
|
||
let Some(rhs) = candidate else { return false };
|
||
let rhs = unwrap_parens(rhs);
|
||
if matches!(lookup(lang, rhs.kind()), Kind::Function) && rhs.child_count() > 0 {
|
||
return true;
|
||
}
|
||
// Go's `expression_list` wrapping for short_var_declaration's RHS.
|
||
if rhs.kind() == "expression_list" {
|
||
let mut cursor = rhs.walk();
|
||
for c in rhs.named_children(&mut cursor) {
|
||
let c = unwrap_parens(c);
|
||
if matches!(lookup(lang, c.kind()), Kind::Function) && c.child_count() > 0 {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
false
|
||
}
|
||
|
||
/// when `ast` is (or wraps) an assignment whose
|
||
/// LHS is a single subscript / index expression with a plain-identifier
|
||
/// receiver, emit a synthetic `__index_set__` Call node and return its
|
||
/// `NodeIndex`. Returns `None` for non-subscript LHSs, multi-target
|
||
/// assignments, complex receivers, or when the RHS contains a call
|
||
/// (those still flow through the existing has_call_descendant path).
|
||
///
|
||
/// Gated on `pointer::is_enabled()` by the caller.
|
||
fn try_lower_subscript_write(
|
||
ast: Node,
|
||
preds: &[NodeIndex],
|
||
g: &mut Cfg,
|
||
lang: &str,
|
||
code: &[u8],
|
||
enclosing_func: Option<&str>,
|
||
call_ordinal: &mut u32,
|
||
) -> Option<NodeIndex> {
|
||
// Locate the assignment node, `ast` may be the assignment itself
|
||
// (Go `assignment_statement`) or a wrapper (`expression_statement`
|
||
// containing JS `assignment_expression` / Python `assignment`).
|
||
let assign_ast = if matches!(lookup(lang, ast.kind()), Kind::Assignment) {
|
||
ast
|
||
} else {
|
||
let mut cursor = ast.walk();
|
||
ast.children(&mut cursor)
|
||
.find(|c| matches!(lookup(lang, c.kind()), Kind::Assignment))?
|
||
};
|
||
let lhs = assign_ast.child_by_field_name("left")?;
|
||
if has_call_descendant(assign_ast, lang) {
|
||
return None;
|
||
}
|
||
let subscript_node = subscript_lhs_node(lhs, lang)?;
|
||
let (arr_text, idx_text) = subscript_components(subscript_node, code)?;
|
||
let rhs = assign_ast.child_by_field_name("right")?;
|
||
|
||
let mut rhs_uses: Vec<String> = Vec::new();
|
||
collect_idents(rhs, code, &mut rhs_uses);
|
||
let span = (ast.start_byte(), ast.end_byte());
|
||
let ord = *call_ordinal;
|
||
*call_ordinal += 1;
|
||
let mut uses_all: Vec<String> = vec![arr_text.clone(), idx_text.clone()];
|
||
uses_all.extend(rhs_uses.iter().cloned());
|
||
|
||
// Prototype pollution sink classification on the synthetic
|
||
// `__index_set__` node for JS/TS. Tainted *key* in `obj[key] = val`
|
||
// is the pollution channel (a `__proto__` / `constructor` literal flowing
|
||
// through `key` mutates `Object.prototype` globally), so the gate's
|
||
// payload arg list is `[0]` (the key only — the value at index 1 is
|
||
// benign on its own). Sanitizer recognition is structural (no taint
|
||
// engine plumbing) and runs before label attachment, so suppressed
|
||
// shapes never enter the SSA sink scan:
|
||
// * constant string key whose literal value is not in the dangerous
|
||
// set (`__proto__` / `constructor` / `prototype`),
|
||
// * receiver was assigned `Object.create(null)` in this function
|
||
// (no prototype chain to pollute),
|
||
// * the assignment is dominated by an `if` whose condition rejects
|
||
// dangerous keys with an early `return` / `throw` / `break`, or
|
||
// that allowlists the key against safe constants on its true arm.
|
||
let mut pp_labels: smallvec::SmallVec<[DataLabel; 2]> = smallvec::SmallVec::new();
|
||
let mut pp_payload_args: Option<Vec<usize>> = None;
|
||
if matches!(lang, "javascript" | "typescript" | "js" | "ts")
|
||
&& !pp_should_suppress_index_set(assign_ast, subscript_node, &arr_text, &idx_text, code)
|
||
{
|
||
pp_labels.push(DataLabel::Sink(Cap::PROTOTYPE_POLLUTION));
|
||
pp_payload_args = Some(vec![0]);
|
||
}
|
||
|
||
let n = g.add_node(NodeInfo {
|
||
kind: StmtKind::Call,
|
||
call: CallMeta {
|
||
callee: Some("__index_set__".to_string()),
|
||
receiver: Some(arr_text),
|
||
arg_uses: vec![vec![idx_text], rhs_uses],
|
||
call_ordinal: ord,
|
||
sink_payload_args: pp_payload_args,
|
||
..Default::default()
|
||
},
|
||
taint: TaintMeta {
|
||
labels: pp_labels,
|
||
uses: uses_all,
|
||
..Default::default()
|
||
},
|
||
ast: AstMeta {
|
||
span,
|
||
enclosing_func: enclosing_func.map(|s| s.to_string()),
|
||
},
|
||
..Default::default()
|
||
});
|
||
connect_all(g, preds, n, EdgeKind::Seq);
|
||
Some(n)
|
||
}
|
||
|
||
/// Spring MVC controller-return open-redirect recogniser. Detects the
|
||
/// shape `return "redirect:" + tainted` (Java string concatenation) and
|
||
/// emits a synthetic `__spring_redirect__` Call sink with
|
||
/// `Sink(OPEN_REDIRECT)` so the existing taint pipeline propagates the
|
||
/// concatenated suffix through the OPEN_REDIRECT cap. The synthetic
|
||
/// node sequences between `preds` and the eventual Return node.
|
||
///
|
||
/// Returns `Some(synthetic_idx)` when matched, otherwise `None`.
|
||
/// Java only — Spring's `redirect:` view-name convention has no
|
||
/// counterpart in the other supported languages, and matching the
|
||
/// literal across non-Spring code would over-fire.
|
||
fn try_lower_spring_redirect_return(
|
||
ast: Node,
|
||
preds: &[NodeIndex],
|
||
g: &mut Cfg,
|
||
lang: &str,
|
||
code: &[u8],
|
||
enclosing_func: Option<&str>,
|
||
call_ordinal: &mut u32,
|
||
) -> Option<NodeIndex> {
|
||
if lang != "java" {
|
||
return None;
|
||
}
|
||
// `return EXPR ;` — find the returned expression. tree-sitter-java
|
||
// wraps the value in a `return_statement` whose first named child
|
||
// is the expression.
|
||
let expr = ast.named_child(0)?;
|
||
// Strip parentheses.
|
||
let mut cur = expr;
|
||
while cur.kind() == "parenthesized_expression" {
|
||
cur = cur.named_child(0)?;
|
||
}
|
||
if cur.kind() != "binary_expression" {
|
||
return None;
|
||
}
|
||
let op = cur.child_by_field_name("operator")?;
|
||
let op_text = text_of(op, code)?;
|
||
if op_text != "+" {
|
||
return None;
|
||
}
|
||
// Walk leftmost descent through left-associated `+` chains so that
|
||
// `"redirect:" + a + b` still matches (the AST nests as
|
||
// `(("redirect:" + a) + b)`).
|
||
let mut leftmost = cur;
|
||
loop {
|
||
let left = leftmost.child_by_field_name("left")?;
|
||
let mut left_inner = left;
|
||
while left_inner.kind() == "parenthesized_expression" {
|
||
left_inner = left_inner.named_child(0)?;
|
||
}
|
||
if left_inner.kind() == "binary_expression" {
|
||
let op_l = left_inner.child_by_field_name("operator")?;
|
||
if text_of(op_l, code).as_deref() == Some("+") {
|
||
leftmost = left_inner;
|
||
continue;
|
||
}
|
||
}
|
||
// `left_inner` is the leftmost atom — must be a string literal
|
||
// whose constant value starts with `redirect:`.
|
||
if !matches!(left_inner.kind(), "string_literal" | "string") {
|
||
return None;
|
||
}
|
||
let lit = text_of(left_inner, code)?;
|
||
if lit.len() < 2 {
|
||
return None;
|
||
}
|
||
let inner = &lit[1..lit.len() - 1];
|
||
if !inner.starts_with("redirect:") {
|
||
return None;
|
||
}
|
||
break;
|
||
}
|
||
|
||
// Collect identifiers referenced anywhere in the original concat
|
||
// expression — the tainted URL piece is one of them. Receiver-style
|
||
// method calls (`view.toString()`) are intentionally captured via
|
||
// the bare identifier; precision improvements are deferred to the
|
||
// SSA / abstract-string layer.
|
||
let mut concat_uses: Vec<String> = Vec::new();
|
||
collect_idents(cur, code, &mut concat_uses);
|
||
if concat_uses.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
let span = (ast.start_byte(), ast.end_byte());
|
||
let ord = *call_ordinal;
|
||
*call_ordinal += 1;
|
||
|
||
let mut labels: smallvec::SmallVec<[DataLabel; 2]> = smallvec::SmallVec::new();
|
||
labels.push(DataLabel::Sink(Cap::OPEN_REDIRECT));
|
||
|
||
let n = g.add_node(NodeInfo {
|
||
kind: StmtKind::Call,
|
||
call: CallMeta {
|
||
callee: Some("__spring_redirect__".to_string()),
|
||
arg_uses: vec![concat_uses.clone()],
|
||
call_ordinal: ord,
|
||
sink_payload_args: Some(vec![0]),
|
||
..Default::default()
|
||
},
|
||
taint: TaintMeta {
|
||
labels,
|
||
uses: concat_uses,
|
||
..Default::default()
|
||
},
|
||
ast: AstMeta {
|
||
span,
|
||
enclosing_func: enclosing_func.map(|s| s.to_string()),
|
||
},
|
||
..Default::default()
|
||
});
|
||
connect_all(g, preds, n, EdgeKind::Seq);
|
||
Some(n)
|
||
}
|
||
|
||
/// React JSX `dangerouslySetInnerHTML={{ __html: x }}` recogniser. Walks
|
||
/// `stmt_ast` for every `jsx_attribute` named `dangerouslySetInnerHTML` whose
|
||
/// value is a `jsx_expression → object → pair[key="__html"]` shape, and
|
||
/// synthesises a CFG call node `dangerouslySetInnerHTML(__html_value)` with
|
||
/// `Sink(HTML_ESCAPE)` and `sink_payload_args = [0]`. The synthetic node's
|
||
/// span is the `__html` value subtree so finding-line attribution lands on
|
||
/// the payload, not the attribute name.
|
||
///
|
||
/// Returns the new frontier (synthetic exits) when one or more sinks were
|
||
/// emitted; otherwise returns `preds` unchanged.
|
||
///
|
||
/// Sanitizer-aware: when the `__html` value is a single call expression
|
||
/// whose callee classifies as a `Sanitizer`, the synthetic sink is still
|
||
/// emitted but its argument list is empty so no taint flows into it.
|
||
/// JS/TS only — JSX has no counterpart in the other supported languages.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub(super) fn try_lower_jsx_dangerous_html(
|
||
stmt_ast: Node,
|
||
preds: &[NodeIndex],
|
||
g: &mut Cfg,
|
||
lang: &str,
|
||
code: &[u8],
|
||
enclosing_func: Option<&str>,
|
||
call_ordinal: &mut u32,
|
||
analysis_rules: Option<&LangAnalysisRules>,
|
||
) -> Vec<NodeIndex> {
|
||
if !matches!(lang, "javascript" | "js" | "typescript" | "ts" | "tsx") {
|
||
return preds.to_vec();
|
||
}
|
||
let mut attrs: Vec<Node> = Vec::new();
|
||
collect_jsx_dangerous_html_attrs(stmt_ast, code, &mut attrs);
|
||
if attrs.is_empty() {
|
||
return preds.to_vec();
|
||
}
|
||
let extra = analysis_rules.map(|r| r.extra_labels.as_slice());
|
||
let mut frontier: Vec<NodeIndex> = preds.to_vec();
|
||
for attr in attrs {
|
||
let Some(html_value) = jsx_extract_html_value(attr, code) else {
|
||
continue;
|
||
};
|
||
let span = (html_value.start_byte(), html_value.end_byte());
|
||
let ord = *call_ordinal;
|
||
*call_ordinal += 1;
|
||
|
||
// Sanitizer-aware: if the value subtree is a call to a known
|
||
// sanitizer, emit the sink with no argument-side taint flow so the
|
||
// synthetic site stays silent on already-sanitized payloads.
|
||
let arg_uses_idents: Vec<String> = if jsx_value_is_sanitized(html_value, lang, code, extra)
|
||
{
|
||
Vec::new()
|
||
} else {
|
||
let mut idents: Vec<String> = Vec::new();
|
||
collect_idents(html_value, code, &mut idents);
|
||
idents
|
||
};
|
||
|
||
let mut labels: smallvec::SmallVec<[DataLabel; 2]> = smallvec::SmallVec::new();
|
||
labels.push(DataLabel::Sink(Cap::HTML_ESCAPE));
|
||
|
||
let n = g.add_node(NodeInfo {
|
||
kind: StmtKind::Call,
|
||
call: CallMeta {
|
||
callee: Some("dangerouslySetInnerHTML".to_string()),
|
||
arg_uses: vec![arg_uses_idents.clone()],
|
||
call_ordinal: ord,
|
||
sink_payload_args: Some(vec![0]),
|
||
..Default::default()
|
||
},
|
||
taint: TaintMeta {
|
||
labels,
|
||
uses: arg_uses_idents,
|
||
..Default::default()
|
||
},
|
||
ast: AstMeta {
|
||
span,
|
||
enclosing_func: enclosing_func.map(|s| s.to_string()),
|
||
},
|
||
..Default::default()
|
||
});
|
||
connect_all(g, &frontier, n, EdgeKind::Seq);
|
||
frontier = vec![n];
|
||
}
|
||
frontier
|
||
}
|
||
|
||
/// Walk `root` collecting every `jsx_attribute` descendant whose name (via
|
||
/// the source bytes in `code`) equals `dangerouslySetInnerHTML`.
|
||
fn collect_jsx_dangerous_html_attrs<'a>(root: Node<'a>, code: &[u8], out: &mut Vec<Node<'a>>) {
|
||
let mut stack: Vec<Node<'a>> = vec![root];
|
||
while let Some(node) = stack.pop() {
|
||
if node.kind() == "jsx_attribute" && jsx_attr_name_is(node, "dangerouslySetInnerHTML", code)
|
||
{
|
||
out.push(node);
|
||
// Don't recurse into the attribute's own subtree; nested JSX
|
||
// attributes inside the value are vanishingly rare and would
|
||
// double-emit if the value contained another React element.
|
||
continue;
|
||
}
|
||
let mut cursor = node.walk();
|
||
for child in node.children(&mut cursor) {
|
||
stack.push(child);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// True when `root`'s subtree contains a `jsx_expression` whose direct
|
||
/// parent is a `jsx_element` or `jsx_fragment` (i.e. a `{expr}` text-content
|
||
/// interpolation between JSX tags). React renders text content with HTML
|
||
/// metachar escaping, so any taint flowing through such an interpolation
|
||
/// has its `HTML_ESCAPE` cap cleared by the time the JSX value is rendered.
|
||
///
|
||
/// Bails at nested function-literal boundaries so JSX inside a closure body
|
||
/// (`const fn = () => <div>{bio}</div>`) does not falsely tag the outer
|
||
/// assignment — the closure's result only escapes when the closure is called
|
||
/// and rendered, which the outer assignment does not perform.
|
||
///
|
||
/// Excludes attribute interpolations (`<a href={url}/>`); React does
|
||
/// auto-escape attribute values as well, but the deferred-plan scope is
|
||
/// text-content only. Widen if a fixture surfaces a pure-attribute FP.
|
||
fn jsx_text_content_interp_present(root: Node, lang: &str) -> bool {
|
||
let mut stack: Vec<Node> = vec![root];
|
||
while let Some(node) = stack.pop() {
|
||
// Closure boundary: nested function bodies do not flow their JSX
|
||
// result out through this assignment's value.
|
||
if matches!(lookup(lang, node.kind()), Kind::Function) && node.id() != root.id() {
|
||
continue;
|
||
}
|
||
if node.kind() == "jsx_expression"
|
||
&& let Some(parent) = node.parent()
|
||
&& matches!(parent.kind(), "jsx_element" | "jsx_fragment")
|
||
{
|
||
return true;
|
||
}
|
||
let mut cursor = node.walk();
|
||
for child in node.children(&mut cursor) {
|
||
stack.push(child);
|
||
}
|
||
}
|
||
false
|
||
}
|
||
|
||
/// Read the attribute name off a `jsx_attribute` node and compare against
|
||
/// `expected`. Looks at the `name` field (or first named child) and reads
|
||
/// its UTF-8 text from `code`.
|
||
fn jsx_attr_name_is(attr: Node, expected: &str, code: &[u8]) -> bool {
|
||
let name_node = match attr
|
||
.child_by_field_name("name")
|
||
.or_else(|| attr.named_child(0))
|
||
{
|
||
Some(n) => n,
|
||
None => return false,
|
||
};
|
||
text_of(name_node, code)
|
||
.map(|t| t == expected)
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
/// Resolve the `__html` value subtree of a JSX
|
||
/// `dangerouslySetInnerHTML={{ __html: <value> }}` attribute. Returns the
|
||
/// AST node for `<value>` or `None` if the shape doesn't match.
|
||
fn jsx_extract_html_value<'a>(attr: Node<'a>, code: &[u8]) -> Option<Node<'a>> {
|
||
let value = attr
|
||
.child_by_field_name("value")
|
||
.or_else(|| attr.named_child(1))?;
|
||
// Strip the `{...}` wrapper. tree-sitter exposes this as
|
||
// `jsx_expression`; defensive against grammar variants by also
|
||
// accepting the inner expression directly.
|
||
let inner = if value.kind() == "jsx_expression" {
|
||
let mut cur = value.walk();
|
||
value
|
||
.named_children(&mut cur)
|
||
.find(|c| c.kind() != "comment")?
|
||
} else {
|
||
value
|
||
};
|
||
let object_kind = inner.kind();
|
||
if !matches!(
|
||
object_kind,
|
||
"object" | "object_expression" | "object_literal"
|
||
) {
|
||
return None;
|
||
}
|
||
let mut cur = inner.walk();
|
||
for pair in inner.named_children(&mut cur) {
|
||
if !matches!(
|
||
pair.kind(),
|
||
"pair" | "property" | "shorthand_property_identifier"
|
||
) {
|
||
continue;
|
||
}
|
||
let key_node = pair
|
||
.child_by_field_name("key")
|
||
.or_else(|| pair.named_child(0));
|
||
let val_node = pair
|
||
.child_by_field_name("value")
|
||
.or_else(|| pair.named_child(1));
|
||
let (Some(k), Some(v)) = (key_node, val_node) else {
|
||
continue;
|
||
};
|
||
let key_text = text_of(k, code).unwrap_or_default();
|
||
// Strip surrounding quotes for `"__html"` / `'__html'` literal keys.
|
||
let key_trim = key_text.trim_matches(|c| c == '"' || c == '\'' || c == '`');
|
||
if key_trim == "__html" {
|
||
return Some(v);
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Returns true when `value_ast` is a call expression whose payload is
|
||
/// already routed through a `Sanitizer`. Used to suppress argument-side
|
||
/// taint flow on the synthetic `dangerouslySetInnerHTML` sink.
|
||
///
|
||
/// Recognised shapes (JS/TS):
|
||
///
|
||
/// 1. Direct call: outer callee classifies as `Sanitizer` under the
|
||
/// rule set, e.g. `__html: DOMPurify.sanitize(input)`.
|
||
/// 2. Function-composition helpers — `pipe(input, sanitizeHtml, ...)`,
|
||
/// `compose(DOMPurify.sanitize, escapeHtml)(input)`, etc. When the
|
||
/// outer callee leaf is one of `pipe` / `flow` / `compose` /
|
||
/// `flowRight` / `pipeWith` (covers fp-ts, Ramda, Lodash/fp,
|
||
/// Effect-TS), any argument whose text classifies as `Sanitizer`
|
||
/// is treated as the sanitization step.
|
||
///
|
||
/// Variable-bound sanitization (`const clean = sanitize(x); __html: clean`)
|
||
/// is handled by SSA value tracking on the bound identifier and does not
|
||
/// pass through this recogniser.
|
||
fn jsx_value_is_sanitized(
|
||
value_ast: Node,
|
||
lang: &str,
|
||
code: &[u8],
|
||
extra: Option<&[crate::labels::RuntimeLabelRule]>,
|
||
) -> bool {
|
||
let mut cur = value_ast;
|
||
while cur.kind() == "parenthesized_expression" {
|
||
let Some(inner) = cur.named_child(0) else {
|
||
return false;
|
||
};
|
||
cur = inner;
|
||
}
|
||
if !matches!(cur.kind(), "call_expression" | "call") {
|
||
return false;
|
||
}
|
||
let callee = match cur
|
||
.child_by_field_name("function")
|
||
.or_else(|| cur.child_by_field_name("name"))
|
||
{
|
||
Some(c) => c,
|
||
None => return false,
|
||
};
|
||
let callee_text = match text_of(callee, code) {
|
||
Some(t) => t,
|
||
None => return false,
|
||
};
|
||
|
||
// 1. Direct sanitizer call.
|
||
let labels = classify_all(lang, &callee_text, extra);
|
||
if labels.iter().any(|l| matches!(l, DataLabel::Sanitizer(_))) {
|
||
return true;
|
||
}
|
||
|
||
// 2. Function-composition helper. Strip namespace qualifiers from the
|
||
// callee so `_.flow` / `R.pipe` / `fp.compose` all reduce to the
|
||
// leaf helper name.
|
||
let leaf_callee = callee_text
|
||
.rsplit(['.', ':'])
|
||
.next()
|
||
.unwrap_or(callee_text.as_str());
|
||
let is_compose_helper = matches!(
|
||
leaf_callee,
|
||
"pipe" | "flow" | "compose" | "flowRight" | "pipeWith"
|
||
);
|
||
if is_compose_helper {
|
||
if let Some(args) = cur.child_by_field_name("arguments") {
|
||
let mut walker = args.walk();
|
||
for arg in args.named_children(&mut walker) {
|
||
if matches!(arg.kind(), "comment") {
|
||
continue;
|
||
}
|
||
let Some(arg_text) = text_of(arg, code) else {
|
||
continue;
|
||
};
|
||
let arg_labels = classify_all(lang, &arg_text, extra);
|
||
if arg_labels
|
||
.iter()
|
||
.any(|l| matches!(l, DataLabel::Sanitizer(_)))
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
false
|
||
}
|
||
///
|
||
/// Returns `true` when the assignment is provably safe and the
|
||
/// `Cap::PROTOTYPE_POLLUTION` sink label should be elided. The three
|
||
/// CFG-layer recognised shapes are flow-insensitive AST patterns:
|
||
///
|
||
/// 1. Constant string key whose value is not one of the dangerous
|
||
/// keys (`__proto__`, `constructor`, `prototype`). A literal-keyed
|
||
/// write cannot pollute even if the value is tainted.
|
||
/// 2. Reject pattern `if (idx === "__proto__" || idx === "constructor"
|
||
/// || idx === "prototype") <return/throw/break>` enclosing the
|
||
/// assignment. The dangerous-key path terminates before reaching
|
||
/// the synthesised store.
|
||
/// 3. Allowlist pattern `if (idx === "name" || idx === "id") { obj[idx]
|
||
/// = v }`. The assignment only executes when `idx` is one of a
|
||
/// small set of known-safe constants.
|
||
///
|
||
/// The null-prototype receiver suppression (`Object.create(null)`) is
|
||
/// handled flow-sensitively in the SSA taint engine via
|
||
/// `TypeKind::NullPrototypeObject`, since AST scans cannot honour
|
||
/// branch-local re-bindings or phi joins.
|
||
///
|
||
/// Conservative: any unrecognised shape returns `false` so the sink
|
||
/// label is attached and the SSA layer decides on taint reachability.
|
||
fn pp_should_suppress_index_set(
|
||
assign_ast: Node,
|
||
subscript_node: Node,
|
||
_arr_text: &str,
|
||
idx_text: &str,
|
||
code: &[u8],
|
||
) -> bool {
|
||
// 1. Constant-key fold.
|
||
if let Some(idx_node) = subscript_node
|
||
.child_by_field_name("index")
|
||
.or_else(|| subscript_node.child_by_field_name("subscript"))
|
||
.or_else(|| {
|
||
let mut cur = subscript_node.walk();
|
||
subscript_node.named_children(&mut cur).nth(1)
|
||
})
|
||
{
|
||
if let Some(literal) = pp_string_literal_value(idx_node, code) {
|
||
return !pp_is_dangerous_proto_key(&literal);
|
||
}
|
||
}
|
||
|
||
// 2 + 3. Dominator-style guard ancestors (reject + allowlist).
|
||
if pp_is_guarded_by_proto_check(assign_ast, idx_text, code) {
|
||
return true;
|
||
}
|
||
|
||
false
|
||
}
|
||
|
||
/// Dangerous prototype-pollution key strings. Matches the literal
|
||
/// values that JS engines treat as references into the prototype chain.
|
||
fn pp_is_dangerous_proto_key(s: &str) -> bool {
|
||
matches!(s, "__proto__" | "constructor" | "prototype")
|
||
}
|
||
|
||
/// Extract the value of a JS/TS string literal node, stripping the
|
||
/// outer quote bytes (single, double, or backtick). Returns `None`
|
||
/// for non-literal nodes, template literals containing interpolation,
|
||
/// or anything that doesn't resemble a single-segment string.
|
||
fn pp_string_literal_value(n: Node, code: &[u8]) -> Option<String> {
|
||
let kind = n.kind();
|
||
if !matches!(kind, "string" | "string_literal" | "template_string") {
|
||
return None;
|
||
}
|
||
let raw = std::str::from_utf8(&code[n.start_byte()..n.end_byte()]).ok()?;
|
||
if raw.len() < 2 {
|
||
return None;
|
||
}
|
||
let bytes = raw.as_bytes();
|
||
let first = bytes[0];
|
||
let last = bytes[bytes.len() - 1];
|
||
if !matches!(first, b'"' | b'\'' | b'`') || first != last {
|
||
return None;
|
||
}
|
||
let inner = &raw[1..raw.len() - 1];
|
||
// Reject template literals carrying `${...}` interpolation — we
|
||
// can't fold those to a single concrete value.
|
||
if first == b'`' && inner.contains("${") {
|
||
return None;
|
||
}
|
||
Some(inner.to_string())
|
||
}
|
||
|
||
/// Walk up from the assignment node looking for two structural guard
|
||
/// shapes:
|
||
///
|
||
/// * **Reject pattern** — a *previous sibling* `if_statement` in any
|
||
/// enclosing block whose condition is `idx === DANGEROUS [|| …]` and
|
||
/// whose consequence terminates control flow (`return` / `throw` /
|
||
/// `break` / `continue`). The dangerous-key path never reaches the
|
||
/// subsequent assignment.
|
||
/// * **Allowlist pattern** — an *ancestor* `if_statement` whose
|
||
/// condition is `idx === SAFE [|| …]` and through whose consequence
|
||
/// the descendant flows. Only the safe-key arm reaches the
|
||
/// assignment.
|
||
///
|
||
/// Both shapes must compare against the same key variable as the
|
||
/// synthetic `__index_set__` node. Stops at the enclosing function so
|
||
/// guards in an outer scope around a closure passed elsewhere don't
|
||
/// accidentally suppress inner assignments.
|
||
fn pp_is_guarded_by_proto_check(from: Node, idx_text: &str, code: &[u8]) -> bool {
|
||
let mut cur = from;
|
||
while let Some(parent) = cur.parent() {
|
||
match parent.kind() {
|
||
"function_declaration"
|
||
| "function"
|
||
| "function_expression"
|
||
| "arrow_function"
|
||
| "method_definition"
|
||
| "generator_function_declaration"
|
||
| "program"
|
||
| "source_file" => return false,
|
||
"if_statement" => {
|
||
if let Some(cond) = parent.child_by_field_name("condition") {
|
||
let consequence = parent.child_by_field_name("consequence");
|
||
if let Some(verdict) =
|
||
pp_classify_proto_guard(cond, consequence, cur, idx_text, code)
|
||
{
|
||
return verdict;
|
||
}
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
|
||
// Reject pattern: scan previous siblings in the parent block
|
||
// for `if (idx === DANGEROUS [|| …]) { return; }` shapes that
|
||
// dominate the assignment via early-return.
|
||
let mut sibling_cursor = parent.walk();
|
||
for sibling in parent.named_children(&mut sibling_cursor) {
|
||
if sibling.start_byte() >= cur.start_byte() {
|
||
break;
|
||
}
|
||
if sibling.kind() != "if_statement" {
|
||
continue;
|
||
}
|
||
if pp_is_reject_pattern(sibling, idx_text, code) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
cur = parent;
|
||
}
|
||
false
|
||
}
|
||
|
||
/// True when `if_node` is `if (idx === DANGEROUS [|| idx === DANGEROUS]
|
||
/// …) { return; / throw …; / break; }` shaped — every disjunct
|
||
/// compares the named key variable to a dangerous prototype key, and
|
||
/// the consequence terminates control flow.
|
||
fn pp_is_reject_pattern(if_node: Node, idx_text: &str, code: &[u8]) -> bool {
|
||
let Some(cond) = if_node.child_by_field_name("condition") else {
|
||
return false;
|
||
};
|
||
let consequence = if_node.child_by_field_name("consequence");
|
||
let clauses = pp_split_or_clauses(cond);
|
||
if clauses.is_empty() {
|
||
return false;
|
||
}
|
||
for clause in &clauses {
|
||
let Some((var, lit)) = pp_extract_eq_compare(*clause, code) else {
|
||
return false;
|
||
};
|
||
if var != idx_text || !pp_is_dangerous_proto_key(&lit) {
|
||
return false;
|
||
}
|
||
}
|
||
consequence.map(pp_block_terminates).unwrap_or(false)
|
||
}
|
||
|
||
/// Decide whether an enclosing `if` clause around an `__index_set__`
|
||
/// statement constitutes a prototype-pollution guard.
|
||
///
|
||
/// `cond` is the if's condition expression, `consequence` is the
|
||
/// optional consequence block, and `descendant` is the node on the
|
||
/// path from the if-statement down to the assignment (used to
|
||
/// distinguish "assignment lives inside the consequence" from
|
||
/// "assignment lives after the if"). `idx_text` is the textual key
|
||
/// variable used by the synthetic `__index_set__`.
|
||
///
|
||
/// Returns `Some(true)` to suppress, `Some(false)` to keep the gate
|
||
/// (e.g. an unrelated guard), and `None` when the if-statement is
|
||
/// not a recognised guard so the walker continues outward.
|
||
fn pp_classify_proto_guard(
|
||
cond: Node,
|
||
consequence: Option<Node>,
|
||
descendant: Node,
|
||
idx_text: &str,
|
||
code: &[u8],
|
||
) -> Option<bool> {
|
||
let cond_clauses = pp_split_or_clauses(cond);
|
||
if cond_clauses.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
let mut all_against_idx = true;
|
||
let mut all_dangerous = true;
|
||
let mut all_safe = true;
|
||
for clause in &cond_clauses {
|
||
let (var, lit) = pp_extract_eq_compare(*clause, code)?;
|
||
if var != idx_text {
|
||
all_against_idx = false;
|
||
break;
|
||
}
|
||
let dangerous = pp_is_dangerous_proto_key(&lit);
|
||
if dangerous {
|
||
all_safe = false;
|
||
} else {
|
||
all_dangerous = false;
|
||
}
|
||
}
|
||
if !all_against_idx {
|
||
return None;
|
||
}
|
||
|
||
let consequence_contains_descendant = consequence
|
||
.map(|c| pp_subtree_contains(c, descendant))
|
||
.unwrap_or(false);
|
||
|
||
// Allowlist pattern: every clause is `idx === SAFE` and the
|
||
// assignment lives inside the consequence (true arm).
|
||
if all_safe && consequence_contains_descendant {
|
||
return Some(true);
|
||
}
|
||
|
||
// Reject pattern: every clause is `idx === DANGEROUS` and the
|
||
// consequence terminates control flow before reaching the
|
||
// assignment. Only suppress when the assignment is *outside* the
|
||
// consequence (i.e., follows the if).
|
||
if all_dangerous
|
||
&& !consequence_contains_descendant
|
||
&& consequence.map(pp_block_terminates).unwrap_or(false)
|
||
{
|
||
return Some(true);
|
||
}
|
||
|
||
None
|
||
}
|
||
|
||
/// True when `descendant` is identical to or transitively a child of
|
||
/// `root`. Identity is checked via byte-range equality because
|
||
/// tree-sitter `Node` doesn't implement `Eq` directly.
|
||
fn pp_subtree_contains(root: Node, descendant: Node) -> bool {
|
||
let dr = (descendant.start_byte(), descendant.end_byte());
|
||
let rr = (root.start_byte(), root.end_byte());
|
||
dr.0 >= rr.0 && dr.1 <= rr.1
|
||
}
|
||
|
||
/// True when `block` (typically an `if` consequence) terminates
|
||
/// control flow on every path: the last meaningful statement is a
|
||
/// return / throw / break / continue. Conservative — falls back to
|
||
/// `false` for empty blocks or anything non-trivial.
|
||
fn pp_block_terminates(block: Node) -> bool {
|
||
// Bare statement consequence (no braces): the if's consequence is
|
||
// the terminator itself.
|
||
if pp_is_terminator(block) {
|
||
return true;
|
||
}
|
||
if !matches!(block.kind(), "statement_block" | "block") {
|
||
return false;
|
||
}
|
||
let mut cursor = block.walk();
|
||
let last_stmt = block.named_children(&mut cursor).last();
|
||
match last_stmt {
|
||
Some(s) => pp_is_terminator(s),
|
||
None => false,
|
||
}
|
||
}
|
||
|
||
/// True when `n` is a control-flow-ending statement: return / throw /
|
||
/// break / continue.
|
||
fn pp_is_terminator(n: Node) -> bool {
|
||
matches!(
|
||
n.kind(),
|
||
"return_statement" | "throw_statement" | "break_statement" | "continue_statement"
|
||
)
|
||
}
|
||
|
||
/// Split an expression by top-level `||` operators. Returns the
|
||
/// individual disjunct sub-expressions. Single (non-OR) expressions
|
||
/// yield a one-element vector. Walks `binary_expression` nodes whose
|
||
/// `operator` field is `||` and recurses into both sides.
|
||
fn pp_split_or_clauses<'a>(expr: Node<'a>) -> Vec<Node<'a>> {
|
||
let mut out = Vec::new();
|
||
pp_collect_or_clauses(expr, &mut out);
|
||
out
|
||
}
|
||
|
||
fn pp_collect_or_clauses<'a>(expr: Node<'a>, out: &mut Vec<Node<'a>>) {
|
||
let stripped = pp_unwrap_paren(expr);
|
||
if matches!(stripped.kind(), "binary_expression") {
|
||
let op = stripped
|
||
.child_by_field_name("operator")
|
||
.map(|o| o.kind())
|
||
.unwrap_or("");
|
||
if op == "||" {
|
||
if let Some(l) = stripped.child_by_field_name("left") {
|
||
pp_collect_or_clauses(l, out);
|
||
}
|
||
if let Some(r) = stripped.child_by_field_name("right") {
|
||
pp_collect_or_clauses(r, out);
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
out.push(stripped);
|
||
}
|
||
|
||
fn pp_unwrap_paren(n: Node) -> Node {
|
||
let mut cur = n;
|
||
while matches!(cur.kind(), "parenthesized_expression") {
|
||
match cur.named_child(0) {
|
||
Some(inner) => cur = inner,
|
||
None => break,
|
||
}
|
||
}
|
||
cur
|
||
}
|
||
|
||
/// Extract `(var_text, literal_value)` from an equality comparison
|
||
/// `var === "literal"` / `var == "literal"` (and reversed forms).
|
||
/// Returns `None` for any other shape.
|
||
fn pp_extract_eq_compare(expr: Node, code: &[u8]) -> Option<(String, String)> {
|
||
let stripped = pp_unwrap_paren(expr);
|
||
if !matches!(stripped.kind(), "binary_expression") {
|
||
return None;
|
||
}
|
||
let op = stripped
|
||
.child_by_field_name("operator")
|
||
.map(|o| o.kind())
|
||
.unwrap_or("");
|
||
if !matches!(op, "===" | "==") {
|
||
return None;
|
||
}
|
||
let left = stripped.child_by_field_name("left")?;
|
||
let right = stripped.child_by_field_name("right")?;
|
||
let left = pp_unwrap_paren(left);
|
||
let right = pp_unwrap_paren(right);
|
||
if let (Some(lv), Some(rs)) = (text_of(left, code), pp_string_literal_value(right, code)) {
|
||
if matches!(left.kind(), "identifier" | "shorthand_property_identifier") {
|
||
return Some((lv, rs));
|
||
}
|
||
}
|
||
if let (Some(rv), Some(ls)) = (text_of(right, code), pp_string_literal_value(left, code)) {
|
||
if matches!(right.kind(), "identifier" | "shorthand_property_identifier") {
|
||
return Some((rv, ls));
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Step 1 (`pre_emit_arg_source_nodes`): scan the AST, create Source nodes,
|
||
/// wire them to `preds`, and return (effective_preds, synth_bindings,
|
||
/// uses_only_synth_names).
|
||
///
|
||
/// `synth_bindings` carry `(arg_pos, synth_name)` pairs that should be
|
||
/// appended to both the call's `arg_uses[arg_pos]` and its `taint.uses`.
|
||
/// `uses_only_synth_names` carry synth names that should *only* be
|
||
/// appended to `taint.uses`, used for chain-inner-arg sources where the
|
||
/// synth value is not a positional argument of the OUTER call but still
|
||
/// participates in the call's implicit dependency chain (e.g. `r.Body`
|
||
/// inside `json.NewDecoder(r.Body).Decode(emoji)`'s receiver).
|
||
///
|
||
/// Step 2 (`apply_arg_source_bindings`): after `push_node` creates the Call
|
||
/// node, add the synthetic variable names to its `arg_uses` and `uses`.
|
||
type PreEmitArgSourceResult = (SmallVec<[NodeIndex; 4]>, Vec<(usize, String)>, Vec<String>);
|
||
|
||
fn pre_emit_arg_source_nodes(
|
||
g: &mut Cfg,
|
||
ast: Node,
|
||
lang: &str,
|
||
code: &[u8],
|
||
enclosing_func: Option<&str>,
|
||
analysis_rules: Option<&LangAnalysisRules>,
|
||
preds: &[NodeIndex],
|
||
) -> PreEmitArgSourceResult {
|
||
let mut effective_preds: SmallVec<[NodeIndex; 4]> = SmallVec::from_slice(preds);
|
||
let mut bindings: Vec<(usize, String)> = Vec::new();
|
||
let mut uses_only: Vec<String> = Vec::new();
|
||
|
||
let extra = analysis_rules.and_then(|r| {
|
||
if r.extra_labels.is_empty() {
|
||
None
|
||
} else {
|
||
Some(r.extra_labels.as_slice())
|
||
}
|
||
});
|
||
|
||
let Some(call_ast) = find_call_node(ast, lang) else {
|
||
return (effective_preds, bindings, uses_only);
|
||
};
|
||
let Some(args_node) = call_ast.child_by_field_name("arguments") else {
|
||
return (effective_preds, bindings, uses_only);
|
||
};
|
||
|
||
// Collect children first (can't borrow cursor across mutable graph ops).
|
||
let children: Vec<_> = {
|
||
let mut cursor = args_node.walk();
|
||
args_node.named_children(&mut cursor).collect()
|
||
};
|
||
|
||
// Bail on spread/splat/keyword arguments where positional mapping is unreliable.
|
||
for child in &children {
|
||
let k = child.kind();
|
||
if k == "spread_element"
|
||
|| k == "dictionary_splat"
|
||
|| k == "list_splat"
|
||
|| k == "keyword_argument"
|
||
|| k == "splat_argument"
|
||
|| k == "hash_splat_argument"
|
||
|| k == "named_argument"
|
||
{
|
||
return (effective_preds, bindings, uses_only);
|
||
}
|
||
}
|
||
|
||
let pointer_on = crate::pointer::is_enabled();
|
||
|
||
for (pos, child) in children.iter().enumerate() {
|
||
let src_label = first_member_label(*child, lang, code, extra);
|
||
if let Some(DataLabel::Source(caps)) = src_label {
|
||
// Use the *current* node count as a unique token, it equals the
|
||
// index the new Source node will receive.
|
||
let synth_name = format!("__nyx_src_{}_{}", g.node_count(), pos);
|
||
let member_text = first_member_text(*child, code);
|
||
let span = (child.start_byte(), child.end_byte());
|
||
|
||
let mut src_labels: SmallVec<[DataLabel; 2]> = SmallVec::new();
|
||
src_labels.push(DataLabel::Source(caps));
|
||
|
||
let src_idx = g.add_node(NodeInfo {
|
||
kind: StmtKind::Seq,
|
||
call: CallMeta {
|
||
callee: member_text,
|
||
..Default::default()
|
||
},
|
||
taint: TaintMeta {
|
||
labels: src_labels,
|
||
defines: Some(synth_name.clone()),
|
||
..Default::default()
|
||
},
|
||
ast: AstMeta {
|
||
span,
|
||
enclosing_func: enclosing_func.map(|s| s.to_string()),
|
||
},
|
||
..Default::default()
|
||
});
|
||
|
||
connect_all(g, &effective_preds, src_idx, EdgeKind::Seq);
|
||
effective_preds.clear();
|
||
effective_preds.push(src_idx);
|
||
|
||
bindings.push((pos, synth_name));
|
||
continue;
|
||
}
|
||
|
||
//pre-emit `__index_get__` Call nodes for
|
||
// subscript / index-expression args when pointer analysis is
|
||
// enabled. This lets the W2/W4 container ELEM read hook fire
|
||
// on the synth call, propagating must/may/caps from the cell
|
||
// to the consuming sink call's argument.
|
||
//
|
||
// Gated on `pointer::is_enabled()` so the env-var=0 path keeps
|
||
// CFG shapes bit-identical to today's output. Only fires when
|
||
// the array operand resolves to a plain identifier, see
|
||
// `subscript_components` for the bail conditions.
|
||
if pointer_on
|
||
&& is_subscript_kind(child.kind())
|
||
&& let Some((arr_text, idx_text)) = subscript_components(*child, code)
|
||
{
|
||
let synth_name = format!("__nyx_idxget_{}_{}", g.node_count(), pos);
|
||
let span = (child.start_byte(), child.end_byte());
|
||
|
||
let idx_node = g.add_node(NodeInfo {
|
||
kind: StmtKind::Call,
|
||
call: CallMeta {
|
||
callee: Some("__index_get__".to_string()),
|
||
receiver: Some(arr_text.clone()),
|
||
arg_uses: vec![vec![idx_text.clone()]],
|
||
..Default::default()
|
||
},
|
||
taint: TaintMeta {
|
||
defines: Some(synth_name.clone()),
|
||
uses: vec![arr_text, idx_text],
|
||
..Default::default()
|
||
},
|
||
ast: AstMeta {
|
||
span,
|
||
enclosing_func: enclosing_func.map(|s| s.to_string()),
|
||
},
|
||
..Default::default()
|
||
});
|
||
|
||
connect_all(g, &effective_preds, idx_node, EdgeKind::Seq);
|
||
effective_preds.clear();
|
||
effective_preds.push(idx_node);
|
||
|
||
bindings.push((pos, synth_name));
|
||
}
|
||
}
|
||
|
||
// Chain-shape source pre-emission: walk the receiver chain of `call_ast`
|
||
// and emit synth Source nodes for any source-labeled inner-call ARGs.
|
||
//
|
||
// This is what carries `r.Body` into the OUTER call's implicit-uses
|
||
// group for shapes like `json.NewDecoder(r.Body).Decode(emoji)`, where
|
||
// the outer callee text (`json.NewDecoder.Decode` after chain
|
||
// normalisation) doesn't classify as a Source on its own. Without
|
||
// this, the writeback receiver-resolution path has nothing to read
|
||
// from and the CVE-2024-31450 chain stays clean.
|
||
//
|
||
// Gated to Go and to writeback-shaped outer callees (`Decode` /
|
||
// `Unmarshal`) because the synth-source emission is only useful when
|
||
// a downstream writeback consumer reads from the chain's tainted
|
||
// receiver, broader gating risks emitting synth sources whose taint
|
||
// never propagates and whose presence trips Layer B AST-pattern
|
||
// suppression on unrelated sinks (see
|
||
// `tests/fixtures/real_world/go/taint/func_literal_capture.go`).
|
||
// Synth names land in `uses_only` (not `bindings`) because they
|
||
// don't correspond to a positional outer-call argument; they surface
|
||
// only via `info.taint.uses`.
|
||
let outer_method_is_writeback = call_ast
|
||
.child_by_field_name("function")
|
||
.or_else(|| call_ast.child_by_field_name("method"))
|
||
.and_then(|f| {
|
||
f.child_by_field_name("field")
|
||
.or_else(|| f.child_by_field_name("property"))
|
||
.or_else(|| f.child_by_field_name("name"))
|
||
})
|
||
.and_then(|n| text_of(n, code))
|
||
.is_some_and(|name| name == "Decode" || name == "Unmarshal");
|
||
if lang == "go" && outer_method_is_writeback {
|
||
let mut inner_args: Vec<Node> = Vec::new();
|
||
walk_chain_inner_call_args(call_ast, lang, &mut inner_args);
|
||
for arg in inner_args {
|
||
let k = arg.kind();
|
||
// Mirror the splat/keyword bail from the outer-args pass.
|
||
if k == "spread_element"
|
||
|| k == "dictionary_splat"
|
||
|| k == "list_splat"
|
||
|| k == "keyword_argument"
|
||
|| k == "splat_argument"
|
||
|| k == "hash_splat_argument"
|
||
|| k == "named_argument"
|
||
{
|
||
continue;
|
||
}
|
||
let src_label = first_member_label(arg, lang, code, extra);
|
||
if let Some(DataLabel::Source(caps)) = src_label {
|
||
let synth_name = format!("__nyx_chainsrc_{}_{}", g.node_count(), uses_only.len());
|
||
let member_text = first_member_text(arg, code);
|
||
let span = (arg.start_byte(), arg.end_byte());
|
||
|
||
let mut src_labels: SmallVec<[DataLabel; 2]> = SmallVec::new();
|
||
src_labels.push(DataLabel::Source(caps));
|
||
|
||
let src_idx = g.add_node(NodeInfo {
|
||
kind: StmtKind::Seq,
|
||
call: CallMeta {
|
||
callee: member_text,
|
||
..Default::default()
|
||
},
|
||
taint: TaintMeta {
|
||
labels: src_labels,
|
||
defines: Some(synth_name.clone()),
|
||
..Default::default()
|
||
},
|
||
ast: AstMeta {
|
||
span,
|
||
enclosing_func: enclosing_func.map(|s| s.to_string()),
|
||
},
|
||
..Default::default()
|
||
});
|
||
|
||
connect_all(g, &effective_preds, src_idx, EdgeKind::Seq);
|
||
effective_preds.clear();
|
||
effective_preds.push(src_idx);
|
||
|
||
uses_only.push(synth_name);
|
||
}
|
||
}
|
||
}
|
||
|
||
(effective_preds, bindings, uses_only)
|
||
}
|
||
|
||
/// Step 2: wire synthetic variable names from pre-emitted Source nodes into
|
||
/// the Call node's `arg_uses` and `uses`. `uses_only` synth names are
|
||
/// appended only to `taint.uses`, used for chain-inner-arg sources whose
|
||
/// synth value is not a positional outer-call argument.
|
||
fn apply_arg_source_bindings(
|
||
g: &mut Cfg,
|
||
call_node: NodeIndex,
|
||
bindings: &[(usize, String)],
|
||
uses_only: &[String],
|
||
) {
|
||
for (pos, synth_name) in bindings {
|
||
let arg_uses = &mut g[call_node].call.arg_uses;
|
||
if *pos < arg_uses.len() {
|
||
arg_uses[*pos].push(synth_name.clone());
|
||
} else {
|
||
while arg_uses.len() < *pos {
|
||
arg_uses.push(vec![]);
|
||
}
|
||
arg_uses.push(vec![synth_name.clone()]);
|
||
}
|
||
g[call_node].taint.uses.push(synth_name.clone());
|
||
}
|
||
for synth_name in uses_only {
|
||
g[call_node].taint.uses.push(synth_name.clone());
|
||
}
|
||
}
|
||
|
||
/// Lower a flat chain of `alternative` siblings (Python `elif_clause`s / PHP
|
||
/// `else_if_clause`s, optionally trailed by an `else_clause`) into a properly
|
||
/// nested else-if CFG.
|
||
///
|
||
/// tree-sitter exposes these clauses as repeated, FLAT `alternative` fields on
|
||
/// a single `if_statement` rather than nesting each `else if` inside the
|
||
/// previous `else` (as JS/TS/Rust/Go/Java/C do). The default single-`else`
|
||
/// lowering only consumed the first sibling, silently dropping every 2nd+ elif
|
||
/// and the trailing else — so any source/sink/sanitizer/guard living there was
|
||
/// invisible to the whole pipeline. This builder restores the missing CFG
|
||
/// nodes by chaining each elif as a guard whose False edge flows into the next
|
||
/// alternative.
|
||
///
|
||
/// `incoming_edge` is the edge label used to enter the chain (the parent `if`'s
|
||
/// false edge — `EdgeKind::False` normally, `EdgeKind::True` for Ruby
|
||
/// `unless`). Returns the union of all branch exits plus the final
|
||
/// fall-through (when the chain has no terminal `else`).
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub(super) fn build_alternative_chain<'a>(
|
||
alternatives: &[Node<'a>],
|
||
preds: &[NodeIndex],
|
||
incoming_edge: EdgeKind,
|
||
g: &mut Cfg,
|
||
lang: &str,
|
||
code: &'a [u8],
|
||
summaries: &mut FuncSummaries,
|
||
file_path: &str,
|
||
enclosing_func: Option<&str>,
|
||
call_ordinal: &mut u32,
|
||
analysis_rules: Option<&LangAnalysisRules>,
|
||
break_targets: &mut Vec<NodeIndex>,
|
||
continue_targets: &mut Vec<NodeIndex>,
|
||
throw_targets: &mut Vec<NodeIndex>,
|
||
bodies: &mut Vec<BodyCfg>,
|
||
next_body_id: &mut u32,
|
||
current_body_id: BodyId,
|
||
) -> Vec<NodeIndex> {
|
||
// Predecessor frontier entering the current alternative, and the edge label
|
||
// to use when wiring into it. Updated as we descend the chain: each elif's
|
||
// False edge becomes the predecessor/edge for the next alternative.
|
||
let mut chain_preds: Vec<NodeIndex> = preds.to_vec();
|
||
let mut chain_edge = incoming_edge;
|
||
let mut exits: Vec<NodeIndex> = Vec::new();
|
||
|
||
for &alt in alternatives {
|
||
let is_elif = matches!(alt.kind(), "elif_clause" | "else_if_clause");
|
||
|
||
if is_elif {
|
||
// Guard node for the elif condition. `push_node` with `StmtKind::If`
|
||
// extracts condition metadata and runs label classification on the
|
||
// clause (its `condition` field), so a source/sink call inside an
|
||
// elif condition is no longer dropped.
|
||
let guard = push_node(
|
||
g,
|
||
StmtKind::If,
|
||
alt,
|
||
lang,
|
||
code,
|
||
enclosing_func,
|
||
0,
|
||
analysis_rules,
|
||
);
|
||
connect_all(g, &chain_preds, guard, chain_edge);
|
||
|
||
// True branch: the elif body (`consequence` for Python, `body` for
|
||
// PHP / colon-block forms).
|
||
let body = alt
|
||
.child_by_field_name("consequence")
|
||
.or_else(|| alt.child_by_field_name("body"));
|
||
if let Some(b) = body {
|
||
let body_first = NodeIndex::new(g.node_count());
|
||
let body_exits = build_sub(
|
||
b,
|
||
&[guard],
|
||
g,
|
||
lang,
|
||
code,
|
||
summaries,
|
||
file_path,
|
||
enclosing_func,
|
||
call_ordinal,
|
||
analysis_rules,
|
||
break_targets,
|
||
continue_targets,
|
||
throw_targets,
|
||
bodies,
|
||
next_body_id,
|
||
current_body_id,
|
||
);
|
||
if body_first.index() < g.node_count() {
|
||
connect_all(g, &[guard], body_first, EdgeKind::True);
|
||
exits.extend(body_exits);
|
||
} else if let Some(&first) = body_exits.first() {
|
||
connect_all(g, &[guard], first, EdgeKind::True);
|
||
exits.extend(body_exits);
|
||
} else {
|
||
// Empty body: the guard's True edge falls through.
|
||
exits.push(guard);
|
||
}
|
||
} else {
|
||
exits.push(guard);
|
||
}
|
||
|
||
// False branch descends to the next alternative.
|
||
chain_preds = vec![guard];
|
||
chain_edge = EdgeKind::False;
|
||
} else {
|
||
// Terminal `else_clause` (or any non-guard block): lower its body and
|
||
// end the chain. The else body field is `body` for both Python and
|
||
// PHP `else_clause`; fall back to the clause node itself.
|
||
let body = alt.child_by_field_name("body").unwrap_or(alt);
|
||
let body_first = NodeIndex::new(g.node_count());
|
||
let body_exits = build_sub(
|
||
body,
|
||
&chain_preds,
|
||
g,
|
||
lang,
|
||
code,
|
||
summaries,
|
||
file_path,
|
||
enclosing_func,
|
||
call_ordinal,
|
||
analysis_rules,
|
||
break_targets,
|
||
continue_targets,
|
||
throw_targets,
|
||
bodies,
|
||
next_body_id,
|
||
current_body_id,
|
||
);
|
||
if body_first.index() < g.node_count() {
|
||
connect_all(g, &chain_preds, body_first, chain_edge);
|
||
exits.extend(body_exits);
|
||
} else if let Some(&first) = body_exits.first() {
|
||
connect_all(g, &chain_preds, first, chain_edge);
|
||
exits.extend(body_exits);
|
||
} else {
|
||
exits.extend(chain_preds.iter().copied());
|
||
}
|
||
// An else clause terminates the chain; nothing follows it.
|
||
return exits;
|
||
}
|
||
}
|
||
|
||
// Chain ended with an elif (no terminal else): the last guard's False edge
|
||
// is the fall-through path out of the whole if/elif construct. Materialise
|
||
// it as a synthetic pass-through so the false edge has a concrete target,
|
||
// mirroring the no-else branch in the `Kind::If` arm.
|
||
let pass = g.add_node(NodeInfo {
|
||
kind: StmtKind::Seq,
|
||
ast: AstMeta {
|
||
span: chain_preds
|
||
.first()
|
||
.map(|&n| g[n].ast.span)
|
||
.unwrap_or((0, 0)),
|
||
enclosing_func: enclosing_func.map(|s| s.to_string()),
|
||
},
|
||
..Default::default()
|
||
});
|
||
connect_all(g, &chain_preds, pass, chain_edge);
|
||
exits.push(pass);
|
||
exits
|
||
}
|
||
|
||
/// Lower a C-style `for` loop's increment/update clause onto the back-edge
|
||
/// path. `back_sources` are the body/continue exits that would otherwise
|
||
/// back-edge straight to the loop header. When an `update_subtree` exists it
|
||
/// is lowered from those sources and its exits are returned, so callers
|
||
/// back-edge the update's exits to the header instead — making increment-clause
|
||
/// side effects (assignments, sanitizer calls) visible to taint analysis.
|
||
/// Without an update clause the input sources are returned unchanged, so the
|
||
/// CFG is bit-identical to the pre-fix behaviour.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub(super) fn lower_loop_update<'a>(
|
||
update_subtree: Option<Node<'a>>,
|
||
back_sources: &[NodeIndex],
|
||
g: &mut Cfg,
|
||
lang: &str,
|
||
code: &'a [u8],
|
||
summaries: &mut FuncSummaries,
|
||
file_path: &str,
|
||
enclosing_func: Option<&str>,
|
||
call_ordinal: &mut u32,
|
||
analysis_rules: Option<&LangAnalysisRules>,
|
||
break_targets: &mut Vec<NodeIndex>,
|
||
continue_targets: &mut Vec<NodeIndex>,
|
||
throw_targets: &mut Vec<NodeIndex>,
|
||
bodies: &mut Vec<BodyCfg>,
|
||
next_body_id: &mut u32,
|
||
current_body_id: BodyId,
|
||
) -> Vec<NodeIndex> {
|
||
let Some(update) = update_subtree else {
|
||
return back_sources.to_vec();
|
||
};
|
||
if back_sources.is_empty() {
|
||
return Vec::new();
|
||
}
|
||
let update_exits = build_sub(
|
||
update,
|
||
back_sources,
|
||
g,
|
||
lang,
|
||
code,
|
||
summaries,
|
||
file_path,
|
||
enclosing_func,
|
||
call_ordinal,
|
||
analysis_rules,
|
||
break_targets,
|
||
continue_targets,
|
||
throw_targets,
|
||
bodies,
|
||
next_body_id,
|
||
current_body_id,
|
||
);
|
||
// If the update produced no CFG nodes (e.g. it was pure trivia), preserve
|
||
// the original back-edge sources so the loop still closes.
|
||
if update_exits.is_empty() {
|
||
back_sources.to_vec()
|
||
} else {
|
||
update_exits
|
||
}
|
||
}
|
||
|
||
// The recursive *work‑horse* that converts an AST node into a CFG slice.
|
||
// Returns the set of *exit* nodes that need to be wired further.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub(super) fn build_sub<'a>(
|
||
ast: Node<'a>,
|
||
preds: &[NodeIndex], // predecessor frontier
|
||
g: &mut Cfg,
|
||
lang: &str,
|
||
code: &'a [u8],
|
||
summaries: &mut FuncSummaries,
|
||
file_path: &str,
|
||
enclosing_func: Option<&str>,
|
||
call_ordinal: &mut u32,
|
||
analysis_rules: Option<&LangAnalysisRules>,
|
||
break_targets: &mut Vec<NodeIndex>,
|
||
continue_targets: &mut Vec<NodeIndex>,
|
||
throw_targets: &mut Vec<NodeIndex>,
|
||
bodies: &mut Vec<BodyCfg>,
|
||
next_body_id: &mut u32,
|
||
current_body_id: BodyId,
|
||
) -> Vec<NodeIndex> {
|
||
match lookup(lang, ast.kind()) {
|
||
// IF‑/ELSE: two branches that re‑merge afterwards
|
||
Kind::If => {
|
||
// Some grammars (Go `if init; cond {}`, sibling C-style forms)
|
||
// attach an init / "initializer" subtree that runs before the
|
||
// condition. Tree-sitter exposes it under the `initializer`
|
||
// field. Without lowering it, side-effecting calls in the
|
||
// init (e.g. Owncast CVE-2024-31450's
|
||
// `if err := json.NewDecoder(r.Body).Decode(emoji); err != nil`)
|
||
// disappear from the CFG and downstream taint never sees the
|
||
// call. Languages that don't expose `initializer` here return
|
||
// None and the post-init `preds` is bit-identical to the
|
||
// pre-fix behaviour. The init's exits become the predecessors
|
||
// for the condition so its side effects are visible to both
|
||
// branches.
|
||
let init_exits_owned = ast.child_by_field_name("initializer").map(|init| {
|
||
build_sub(
|
||
init,
|
||
preds,
|
||
g,
|
||
lang,
|
||
code,
|
||
summaries,
|
||
file_path,
|
||
enclosing_func,
|
||
call_ordinal,
|
||
analysis_rules,
|
||
break_targets,
|
||
continue_targets,
|
||
throw_targets,
|
||
bodies,
|
||
next_body_id,
|
||
current_body_id,
|
||
)
|
||
});
|
||
let preds: &[NodeIndex] = match &init_exits_owned {
|
||
Some(exits) => exits.as_slice(),
|
||
None => preds,
|
||
};
|
||
// Check if condition contains a boolean operator for short-circuit decomposition.
|
||
let cond_subtree = ast.child_by_field_name("condition").or_else(|| {
|
||
// Rust `if_expression` uses positional children
|
||
let mut cursor = ast.walk();
|
||
ast.children(&mut cursor).find(|c| {
|
||
let k = c.kind();
|
||
!matches!(lookup(lang, k), Kind::Block | Kind::Trivia)
|
||
&& k != "if"
|
||
&& k != "else"
|
||
&& k != "let"
|
||
&& k != "{"
|
||
&& k != "}"
|
||
&& k != "("
|
||
&& k != ")"
|
||
})
|
||
});
|
||
|
||
let has_short_circuit = cond_subtree
|
||
.map(|c| is_boolean_operator(unwrap_parens(c)).is_some())
|
||
.unwrap_or(false);
|
||
|
||
// Check for negation wrapping the entire condition (e.g. `!(a && b)`)
|
||
//, if present, skip short-circuit decomposition (De Morgan out of scope).
|
||
let has_short_circuit = has_short_circuit
|
||
&& cond_subtree.map_or(false, |c| {
|
||
let unwrapped = unwrap_parens(c);
|
||
!matches!(
|
||
unwrapped.kind(),
|
||
"unary_expression"
|
||
| "not_operator"
|
||
| "prefix_unary_expression"
|
||
| "unary_not"
|
||
| "unary_op_expression"
|
||
)
|
||
});
|
||
|
||
let is_unless = ast.kind() == "unless";
|
||
|
||
// Determine true/false exit sets for wiring branches.
|
||
let (true_exits, false_exits) = if has_short_circuit {
|
||
let cond_ast = cond_subtree.unwrap();
|
||
build_condition_chain(
|
||
cond_ast,
|
||
preds,
|
||
EdgeKind::Seq,
|
||
g,
|
||
lang,
|
||
code,
|
||
enclosing_func,
|
||
call_ordinal,
|
||
analysis_rules,
|
||
)
|
||
} else {
|
||
// Single-node path (original behavior)
|
||
let cond = push_node(
|
||
g,
|
||
StmtKind::If,
|
||
ast,
|
||
lang,
|
||
code,
|
||
enclosing_func,
|
||
0,
|
||
analysis_rules,
|
||
);
|
||
connect_all(g, preds, cond, EdgeKind::Seq);
|
||
(vec![cond], vec![cond])
|
||
};
|
||
|
||
// For `unless`, swap: body runs when condition is false.
|
||
let (then_preds, else_preds) = if is_unless {
|
||
(&false_exits, &true_exits)
|
||
} else {
|
||
(&true_exits, &false_exits)
|
||
};
|
||
let (then_edge, else_edge) = if is_unless {
|
||
(EdgeKind::False, EdgeKind::True)
|
||
} else {
|
||
(EdgeKind::True, EdgeKind::False)
|
||
};
|
||
|
||
// Locate then & else blocks using field-based lookup first,
|
||
// then positional fallback (Rust uses positional blocks).
|
||
//
|
||
// `alternatives` collects *every* `alternative` field, not just the
|
||
// first. In tree-sitter-python and tree-sitter-php an
|
||
// `if/elif/.../else` (Python) or `if/elseif/.../else` (PHP) chain
|
||
// produces several FLAT sibling `alternative` fields on one
|
||
// `if_statement` (a list of `elif_clause`/`else_if_clause` nodes
|
||
// optionally trailed by an `else_clause`). JS/TS/Rust/Go/Java/C
|
||
// nest their `else if`, so they expose at most one `alternative`
|
||
// (the nested if) and the list has length ≤ 1.
|
||
let (then_block, else_block, alternatives) = {
|
||
let field_then = ast
|
||
.child_by_field_name("consequence")
|
||
.or_else(|| ast.child_by_field_name("body"));
|
||
let mut alt_cursor = ast.walk();
|
||
let alternatives: Vec<Node> = ast
|
||
.children_by_field_name("alternative", &mut alt_cursor)
|
||
.collect();
|
||
let field_else = alternatives.first().copied();
|
||
|
||
if field_then.is_some() || field_else.is_some() {
|
||
(field_then, field_else, alternatives)
|
||
} else {
|
||
// Fallback: positional block children (Rust `if_expression`)
|
||
let mut cursor = ast.walk();
|
||
let blocks: Vec<_> = ast
|
||
.children(&mut cursor)
|
||
.filter(|n| lookup(lang, n.kind()) == Kind::Block)
|
||
.collect();
|
||
(blocks.first().copied(), blocks.get(1).copied(), Vec::new())
|
||
}
|
||
};
|
||
|
||
// A flat elif/elseif chain has 2+ `alternative` siblings, or a
|
||
// single `alternative` that is itself an elif/else-if clause (the
|
||
// `if a: .. elif b: ..` form with no trailing `else`, where the lone
|
||
// alternative is an `elif_clause` rather than a nested if). In both
|
||
// cases the default single-`else_block` lowering below would either
|
||
// drop later siblings entirely or fail to treat the elif condition
|
||
// as a branch guard, so route through the dedicated chain builder.
|
||
let is_flat_elif_chain = alternatives.len() > 1
|
||
|| alternatives
|
||
.first()
|
||
.is_some_and(|a| matches!(a.kind(), "elif_clause" | "else_if_clause"));
|
||
|
||
// THEN branch
|
||
let then_first_node = NodeIndex::new(g.node_count());
|
||
let then_exits = if let Some(b) = then_block {
|
||
let exits = build_sub(
|
||
b,
|
||
then_preds,
|
||
g,
|
||
lang,
|
||
code,
|
||
summaries,
|
||
file_path,
|
||
enclosing_func,
|
||
call_ordinal,
|
||
analysis_rules,
|
||
break_targets,
|
||
continue_targets,
|
||
throw_targets,
|
||
bodies,
|
||
next_body_id,
|
||
current_body_id,
|
||
);
|
||
// Add True/False edge from condition exit(s) to first node of then-branch.
|
||
if then_first_node.index() < g.node_count() {
|
||
connect_all(g, then_preds, then_first_node, then_edge);
|
||
} else if let Some(&first) = exits.first() {
|
||
connect_all(g, then_preds, first, then_edge);
|
||
}
|
||
exits
|
||
} else {
|
||
then_preds.to_vec()
|
||
};
|
||
|
||
// ELSE branch
|
||
let else_first_node = NodeIndex::new(g.node_count());
|
||
let else_exits = if is_flat_elif_chain {
|
||
// Flat elif/elseif chain: lower every alternative sibling as a
|
||
// proper nested else-if so 2nd+ elif and trailing else clauses
|
||
// are no longer dropped from the CFG.
|
||
build_alternative_chain(
|
||
&alternatives,
|
||
else_preds,
|
||
else_edge,
|
||
g,
|
||
lang,
|
||
code,
|
||
summaries,
|
||
file_path,
|
||
enclosing_func,
|
||
call_ordinal,
|
||
analysis_rules,
|
||
break_targets,
|
||
continue_targets,
|
||
throw_targets,
|
||
bodies,
|
||
next_body_id,
|
||
current_body_id,
|
||
)
|
||
} else if let Some(b) = else_block {
|
||
let exits = build_sub(
|
||
b,
|
||
else_preds,
|
||
g,
|
||
lang,
|
||
code,
|
||
summaries,
|
||
file_path,
|
||
enclosing_func,
|
||
call_ordinal,
|
||
analysis_rules,
|
||
break_targets,
|
||
continue_targets,
|
||
throw_targets,
|
||
bodies,
|
||
next_body_id,
|
||
current_body_id,
|
||
);
|
||
if else_first_node.index() < g.node_count() {
|
||
connect_all(g, else_preds, else_first_node, else_edge);
|
||
} else if let Some(&first) = exits.first() {
|
||
connect_all(g, else_preds, first, else_edge);
|
||
}
|
||
exits
|
||
} else {
|
||
// No explicit else → create a synthetic pass-through node
|
||
// for the false path.
|
||
let pass = g.add_node(NodeInfo {
|
||
kind: StmtKind::Seq,
|
||
ast: AstMeta {
|
||
span: (ast.end_byte(), ast.end_byte()),
|
||
enclosing_func: enclosing_func.map(|s| s.to_string()),
|
||
},
|
||
..Default::default()
|
||
});
|
||
connect_all(g, else_preds, pass, else_edge);
|
||
vec![pass]
|
||
};
|
||
|
||
// Frontier = union of both branches
|
||
then_exits.into_iter().chain(else_exits).collect()
|
||
}
|
||
|
||
Kind::InfiniteLoop => {
|
||
// Synthetic header node
|
||
let header = push_node(
|
||
g,
|
||
StmtKind::Loop,
|
||
ast,
|
||
lang,
|
||
code,
|
||
enclosing_func,
|
||
0,
|
||
analysis_rules,
|
||
);
|
||
connect_all(g, preds, header, EdgeKind::Seq);
|
||
|
||
// Fresh break/continue targets scoped to this loop
|
||
let mut loop_breaks = Vec::new();
|
||
let mut loop_continues = Vec::new();
|
||
|
||
// The body is the single `block` child
|
||
let body = match ast.child_by_field_name("body") {
|
||
Some(b) => b,
|
||
None => {
|
||
warn!(
|
||
"loop without body (error recovery?): kind={} byte={}",
|
||
ast.kind(),
|
||
ast.start_byte()
|
||
);
|
||
return vec![header];
|
||
}
|
||
};
|
||
let body_exits = build_sub(
|
||
body,
|
||
&[header],
|
||
g,
|
||
lang,
|
||
code,
|
||
summaries,
|
||
file_path,
|
||
enclosing_func,
|
||
call_ordinal,
|
||
analysis_rules,
|
||
&mut loop_breaks,
|
||
&mut loop_continues,
|
||
throw_targets,
|
||
bodies,
|
||
next_body_id,
|
||
current_body_id,
|
||
);
|
||
|
||
// Back-edge from every linear exit to header
|
||
for &e in &body_exits {
|
||
connect_all(g, &[e], header, EdgeKind::Back);
|
||
}
|
||
// Wire continue targets as back edges to header
|
||
for &c in &loop_continues {
|
||
connect_all(g, &[c], header, EdgeKind::Back);
|
||
}
|
||
// Break targets become exits of the loop
|
||
if loop_breaks.is_empty() {
|
||
// No break → infinite loop; header is the only exit for
|
||
// downstream code (fallthrough semantics)
|
||
vec![header]
|
||
} else {
|
||
loop_breaks
|
||
}
|
||
}
|
||
|
||
// WHILE / FOR: classic loop with a back edge.
|
||
Kind::While | Kind::For => {
|
||
// C-style `for (init; cond; incr) body` loops (C/C++, JS/TS, Go
|
||
// three-clause, PHP) attach `initializer`/`update` (or `increment`)
|
||
// subtrees that the previous loop lowering ignored entirely — so a
|
||
// taint source bound in the init (`for (cmd = getenv("X"); …)`) or a
|
||
// side effect in the increment had no CFG node at all and was
|
||
// invisible to taint analysis. Tree-sitter exposes these either as
|
||
// direct fields on the loop node (C/C++/JS/TS/PHP) or nested under a
|
||
// `for_clause` child (Go's three-clause form). The init runs once
|
||
// before the header; its exits become the header's predecessors so
|
||
// its defs flow into both the condition and the body.
|
||
let clause_owner = ast
|
||
.child_by_field_name("body")
|
||
.is_none()
|
||
.then(|| {
|
||
let mut c = ast.walk();
|
||
ast.children(&mut c).find(|n| n.kind() == "for_clause")
|
||
})
|
||
.flatten();
|
||
let init_subtree = ast
|
||
.child_by_field_name("initializer")
|
||
.or_else(|| ast.child_by_field_name("initialize"))
|
||
.or_else(|| clause_owner.and_then(|fc| fc.child_by_field_name("initializer")));
|
||
let update_subtree = ast
|
||
.child_by_field_name("update")
|
||
.or_else(|| ast.child_by_field_name("increment"))
|
||
.or_else(|| clause_owner.and_then(|fc| fc.child_by_field_name("update")));
|
||
|
||
// Lower the initializer (if any) from `preds`; its exits become the
|
||
// header's predecessors. Empty / absent inits leave `preds`
|
||
// bit-identical to the pre-fix behaviour.
|
||
let init_exits_owned = init_subtree.map(|init| {
|
||
build_sub(
|
||
init,
|
||
preds,
|
||
g,
|
||
lang,
|
||
code,
|
||
summaries,
|
||
file_path,
|
||
enclosing_func,
|
||
call_ordinal,
|
||
analysis_rules,
|
||
break_targets,
|
||
continue_targets,
|
||
throw_targets,
|
||
bodies,
|
||
next_body_id,
|
||
current_body_id,
|
||
)
|
||
});
|
||
let header_preds: &[NodeIndex] = match &init_exits_owned {
|
||
Some(exits) if !exits.is_empty() => exits.as_slice(),
|
||
_ => preds,
|
||
};
|
||
|
||
let header = push_node(
|
||
g,
|
||
StmtKind::Loop,
|
||
ast,
|
||
lang,
|
||
code,
|
||
enclosing_func,
|
||
0,
|
||
analysis_rules,
|
||
);
|
||
connect_all(g, header_preds, header, EdgeKind::Seq);
|
||
|
||
// Check for short-circuit condition
|
||
let cond_subtree = ast.child_by_field_name("condition");
|
||
let has_short_circuit = cond_subtree
|
||
.map(|c| {
|
||
let unwrapped = unwrap_parens(c);
|
||
is_boolean_operator(unwrapped).is_some()
|
||
&& !matches!(
|
||
unwrapped.kind(),
|
||
"unary_expression"
|
||
| "not_operator"
|
||
| "prefix_unary_expression"
|
||
| "unary_not"
|
||
| "unary_op_expression"
|
||
)
|
||
})
|
||
.unwrap_or(false);
|
||
|
||
// Fresh break/continue targets scoped to this loop
|
||
let mut loop_breaks = Vec::new();
|
||
let mut loop_continues = Vec::new();
|
||
|
||
// Body = first (and usually only) block child. Tree-sitter error
|
||
// recovery (or a fuzz mutation that truncates a `for`/`while`
|
||
// header before the block) can leave a loop node with no body
|
||
// child at all. Match the InfiniteLoop arm above and degrade
|
||
// gracefully instead of panicking — header alone is a valid CFG
|
||
// skeleton for the malformed input.
|
||
let body = match ast.child_by_field_name("body").or_else(|| {
|
||
let mut c = ast.walk();
|
||
ast.children(&mut c)
|
||
.find(|n| lookup(lang, n.kind()) == Kind::Block)
|
||
}) {
|
||
Some(b) => b,
|
||
None => {
|
||
warn!(
|
||
"loop without body (error recovery?): kind={} byte={}",
|
||
ast.kind(),
|
||
ast.start_byte()
|
||
);
|
||
return vec![header];
|
||
}
|
||
};
|
||
|
||
if has_short_circuit {
|
||
let cond_ast = cond_subtree.unwrap();
|
||
let (true_exits, false_exits) = build_condition_chain(
|
||
cond_ast,
|
||
&[header],
|
||
EdgeKind::Seq,
|
||
g,
|
||
lang,
|
||
code,
|
||
enclosing_func,
|
||
call_ordinal,
|
||
analysis_rules,
|
||
);
|
||
|
||
// Wire body from true_exits
|
||
let body_first = NodeIndex::new(g.node_count());
|
||
let body_exits = build_sub(
|
||
body,
|
||
&true_exits,
|
||
g,
|
||
lang,
|
||
code,
|
||
summaries,
|
||
file_path,
|
||
enclosing_func,
|
||
call_ordinal,
|
||
analysis_rules,
|
||
&mut loop_breaks,
|
||
&mut loop_continues,
|
||
throw_targets,
|
||
bodies,
|
||
next_body_id,
|
||
current_body_id,
|
||
);
|
||
// Add True edges from condition chain to body
|
||
if body_first.index() < g.node_count() {
|
||
connect_all(g, &true_exits, body_first, EdgeKind::True);
|
||
}
|
||
|
||
// The increment runs at the end of each iteration before the
|
||
// condition is re-checked, so it sits on the back-edge path
|
||
// between the body/continue exits and the header.
|
||
let mut back_sources: Vec<NodeIndex> = body_exits;
|
||
back_sources.extend(loop_continues.iter().copied());
|
||
let back_sources = lower_loop_update(
|
||
update_subtree,
|
||
&back_sources,
|
||
g,
|
||
lang,
|
||
code,
|
||
summaries,
|
||
file_path,
|
||
enclosing_func,
|
||
call_ordinal,
|
||
analysis_rules,
|
||
break_targets,
|
||
continue_targets,
|
||
throw_targets,
|
||
bodies,
|
||
next_body_id,
|
||
current_body_id,
|
||
);
|
||
// Back-edges go to header (not into the condition chain)
|
||
for &e in &back_sources {
|
||
connect_all(g, &[e], header, EdgeKind::Back);
|
||
}
|
||
|
||
// Loop exits = false_exits + breaks
|
||
let mut exits: Vec<NodeIndex> = false_exits;
|
||
exits.extend(loop_breaks);
|
||
exits
|
||
} else {
|
||
let body_exits = build_sub(
|
||
body,
|
||
&[header],
|
||
g,
|
||
lang,
|
||
code,
|
||
summaries,
|
||
file_path,
|
||
enclosing_func,
|
||
call_ordinal,
|
||
analysis_rules,
|
||
&mut loop_breaks,
|
||
&mut loop_continues,
|
||
throw_targets,
|
||
bodies,
|
||
next_body_id,
|
||
current_body_id,
|
||
);
|
||
|
||
// The increment runs on the back-edge path (end of each
|
||
// iteration, before the next condition check).
|
||
let mut back_sources: Vec<NodeIndex> = body_exits;
|
||
back_sources.extend(loop_continues.iter().copied());
|
||
let back_sources = lower_loop_update(
|
||
update_subtree,
|
||
&back_sources,
|
||
g,
|
||
lang,
|
||
code,
|
||
summaries,
|
||
file_path,
|
||
enclosing_func,
|
||
call_ordinal,
|
||
analysis_rules,
|
||
break_targets,
|
||
continue_targets,
|
||
throw_targets,
|
||
bodies,
|
||
next_body_id,
|
||
current_body_id,
|
||
);
|
||
// Back‑edge for every linear exit → header.
|
||
for &e in &back_sources {
|
||
connect_all(g, &[e], header, EdgeKind::Back);
|
||
}
|
||
// Falling out of the loop = header’s false branch +
|
||
// any break targets that exit the loop.
|
||
let mut exits = vec![header];
|
||
exits.extend(loop_breaks);
|
||
exits
|
||
}
|
||
}
|
||
|
||
// Control-flow sinks (return / break / continue).
|
||
Kind::Return => {
|
||
if has_call_descendant(ast, lang) {
|
||
// Return-call bug fix: emit a Call node BEFORE the Return so
|
||
// that callee labels (source/sanitizer/sink) are applied.
|
||
let ord = *call_ordinal;
|
||
*call_ordinal += 1;
|
||
let (effective_preds, src_bindings, src_uses_only) = pre_emit_arg_source_nodes(
|
||
g,
|
||
ast,
|
||
lang,
|
||
code,
|
||
enclosing_func,
|
||
analysis_rules,
|
||
preds,
|
||
);
|
||
let call_idx = push_node(
|
||
g,
|
||
StmtKind::Call,
|
||
ast,
|
||
lang,
|
||
code,
|
||
enclosing_func,
|
||
ord,
|
||
analysis_rules,
|
||
);
|
||
apply_arg_source_bindings(g, call_idx, &src_bindings, &src_uses_only);
|
||
connect_all(g, &effective_preds, call_idx, EdgeKind::Seq);
|
||
// React JSX `dangerouslySetInnerHTML={{__html: x}}` synthesis
|
||
// (Phase 06): inserted between the wrapping Call (the inner
|
||
// sanitizer / source call picked up by find_classifiable_inner_call)
|
||
// and the Return so the synthetic sink fires on the
|
||
// post-sanitization payload.
|
||
let post_jsx = try_lower_jsx_dangerous_html(
|
||
ast,
|
||
&[call_idx],
|
||
g,
|
||
lang,
|
||
code,
|
||
enclosing_func,
|
||
call_ordinal,
|
||
analysis_rules,
|
||
);
|
||
let ret = push_node(
|
||
g,
|
||
StmtKind::Return,
|
||
ast,
|
||
lang,
|
||
code,
|
||
enclosing_func,
|
||
0,
|
||
analysis_rules,
|
||
);
|
||
connect_all(g, &post_jsx, ret, EdgeKind::Seq);
|
||
|
||
// Recurse into any function expressions nested inside the
|
||
// returned call's arguments (e.g.
|
||
// `return new Promise((res, rej) => { ... })`). Without this
|
||
// the executor and any further inner callbacks are silently
|
||
// swallowed and the gated sinks they contain become invisible
|
||
// to classification. Mirrors the same recursion done by the
|
||
// CallWrapper / CallFn arms. Motivated by CVE-2025-64430.
|
||
//
|
||
// Disconnect the placeholder Seq edge from the call after
|
||
// build_sub returns; the inner body is independently
|
||
// registered, so the outer call should flow straight to its
|
||
// real successor (the Return below) without a phantom branch.
|
||
let nested = collect_nested_function_nodes(ast, lang);
|
||
for func_node in nested {
|
||
let placeholders = build_sub(
|
||
func_node,
|
||
&[call_idx],
|
||
g,
|
||
lang,
|
||
code,
|
||
summaries,
|
||
file_path,
|
||
enclosing_func,
|
||
call_ordinal,
|
||
analysis_rules,
|
||
break_targets,
|
||
continue_targets,
|
||
throw_targets,
|
||
bodies,
|
||
next_body_id,
|
||
current_body_id,
|
||
);
|
||
for ph in placeholders {
|
||
let to_remove: Vec<_> =
|
||
g.edges_connecting(call_idx, ph).map(|e| e.id()).collect();
|
||
for eid in to_remove {
|
||
g.remove_edge(eid);
|
||
}
|
||
}
|
||
}
|
||
|
||
Vec::new()
|
||
} else {
|
||
// Spring MVC `return "redirect:" + url` open-redirect
|
||
// synthetic-sink emission. When matched the synthetic
|
||
// call sequences between `preds` and the Return node.
|
||
let mut effective_preds: Vec<NodeIndex> = preds.to_vec();
|
||
if let Some(synth) = try_lower_spring_redirect_return(
|
||
ast,
|
||
&effective_preds,
|
||
g,
|
||
lang,
|
||
code,
|
||
enclosing_func,
|
||
call_ordinal,
|
||
) {
|
||
effective_preds = vec![synth];
|
||
}
|
||
// React JSX `dangerouslySetInnerHTML={{__html: x}}` synthesis
|
||
// (Phase 06) — fires when the JSX has no descendant call so
|
||
// the wrapping Return arm reaches this branch.
|
||
effective_preds = try_lower_jsx_dangerous_html(
|
||
ast,
|
||
&effective_preds,
|
||
g,
|
||
lang,
|
||
code,
|
||
enclosing_func,
|
||
call_ordinal,
|
||
analysis_rules,
|
||
);
|
||
let ret = push_node(
|
||
g,
|
||
StmtKind::Return,
|
||
ast,
|
||
lang,
|
||
code,
|
||
enclosing_func,
|
||
0,
|
||
analysis_rules,
|
||
);
|
||
connect_all(g, &effective_preds, ret, EdgeKind::Seq);
|
||
Vec::new() // terminates this path
|
||
}
|
||
}
|
||
Kind::Throw => {
|
||
if has_call_descendant(ast, lang) {
|
||
let ord = *call_ordinal;
|
||
*call_ordinal += 1;
|
||
let (effective_preds, src_bindings, src_uses_only) = pre_emit_arg_source_nodes(
|
||
g,
|
||
ast,
|
||
lang,
|
||
code,
|
||
enclosing_func,
|
||
analysis_rules,
|
||
preds,
|
||
);
|
||
let call_idx = push_node(
|
||
g,
|
||
StmtKind::Call,
|
||
ast,
|
||
lang,
|
||
code,
|
||
enclosing_func,
|
||
ord,
|
||
analysis_rules,
|
||
);
|
||
apply_arg_source_bindings(g, call_idx, &src_bindings, &src_uses_only);
|
||
connect_all(g, &effective_preds, call_idx, EdgeKind::Seq);
|
||
let ret = push_node(
|
||
g,
|
||
StmtKind::Throw,
|
||
ast,
|
||
lang,
|
||
code,
|
||
enclosing_func,
|
||
0,
|
||
analysis_rules,
|
||
);
|
||
connect_all(g, &[call_idx], ret, EdgeKind::Seq);
|
||
throw_targets.push(ret);
|
||
|
||
// Same nested-function recursion as the Return arm: a
|
||
// `throw new Promise(() => { ... })` would otherwise lose
|
||
// any inner gated sinks. Disconnect the placeholder edge
|
||
// (see Return arm comment).
|
||
let nested = collect_nested_function_nodes(ast, lang);
|
||
for func_node in nested {
|
||
let placeholders = build_sub(
|
||
func_node,
|
||
&[call_idx],
|
||
g,
|
||
lang,
|
||
code,
|
||
summaries,
|
||
file_path,
|
||
enclosing_func,
|
||
call_ordinal,
|
||
analysis_rules,
|
||
break_targets,
|
||
continue_targets,
|
||
throw_targets,
|
||
bodies,
|
||
next_body_id,
|
||
current_body_id,
|
||
);
|
||
for ph in placeholders {
|
||
let to_remove: Vec<_> =
|
||
g.edges_connecting(call_idx, ph).map(|e| e.id()).collect();
|
||
for eid in to_remove {
|
||
g.remove_edge(eid);
|
||
}
|
||
}
|
||
}
|
||
|
||
Vec::new()
|
||
} else {
|
||
let ret = push_node(
|
||
g,
|
||
StmtKind::Throw,
|
||
ast,
|
||
lang,
|
||
code,
|
||
enclosing_func,
|
||
0,
|
||
analysis_rules,
|
||
);
|
||
connect_all(g, preds, ret, EdgeKind::Seq);
|
||
throw_targets.push(ret);
|
||
Vec::new()
|
||
}
|
||
}
|
||
Kind::Try => build_try(
|
||
ast,
|
||
preds,
|
||
g,
|
||
lang,
|
||
code,
|
||
summaries,
|
||
file_path,
|
||
enclosing_func,
|
||
call_ordinal,
|
||
analysis_rules,
|
||
break_targets,
|
||
continue_targets,
|
||
throw_targets,
|
||
bodies,
|
||
next_body_id,
|
||
current_body_id,
|
||
),
|
||
Kind::Break => {
|
||
let brk = push_node(
|
||
g,
|
||
StmtKind::Break,
|
||
ast,
|
||
lang,
|
||
code,
|
||
enclosing_func,
|
||
0,
|
||
analysis_rules,
|
||
);
|
||
connect_all(g, preds, brk, EdgeKind::Seq);
|
||
break_targets.push(brk);
|
||
Vec::new()
|
||
}
|
||
Kind::Continue => {
|
||
let cont = push_node(
|
||
g,
|
||
StmtKind::Continue,
|
||
ast,
|
||
lang,
|
||
code,
|
||
enclosing_func,
|
||
0,
|
||
analysis_rules,
|
||
);
|
||
connect_all(g, preds, cont, EdgeKind::Seq);
|
||
continue_targets.push(cont);
|
||
Vec::new()
|
||
}
|
||
|
||
Kind::Switch => build_switch(
|
||
ast,
|
||
preds,
|
||
g,
|
||
lang,
|
||
code,
|
||
summaries,
|
||
file_path,
|
||
enclosing_func,
|
||
call_ordinal,
|
||
analysis_rules,
|
||
break_targets,
|
||
continue_targets,
|
||
throw_targets,
|
||
bodies,
|
||
next_body_id,
|
||
current_body_id,
|
||
),
|
||
|
||
// BLOCK: statements execute sequentially
|
||
Kind::SourceFile | Kind::Block => {
|
||
// Ruby body_statement with rescue/ensure = implicit begin/rescue
|
||
if lang == "ruby" && ast.kind() == "body_statement" {
|
||
let mut check = ast.walk();
|
||
if ast
|
||
.children(&mut check)
|
||
.any(|c| c.kind() == "rescue" || c.kind() == "ensure")
|
||
{
|
||
return build_begin_rescue(
|
||
ast,
|
||
preds,
|
||
g,
|
||
lang,
|
||
code,
|
||
summaries,
|
||
file_path,
|
||
enclosing_func,
|
||
call_ordinal,
|
||
analysis_rules,
|
||
break_targets,
|
||
continue_targets,
|
||
throw_targets,
|
||
bodies,
|
||
next_body_id,
|
||
current_body_id,
|
||
);
|
||
}
|
||
}
|
||
|
||
let mut cursor = ast.walk();
|
||
let mut frontier = preds.to_vec();
|
||
// With per-body CFGs, function definitions become placeholder
|
||
// nodes that always have exactly one exit. The frontier never
|
||
// empties due to a function's internal return. We still keep a
|
||
// last-live fallback for preprocessor dangling-else edge cases.
|
||
let mut last_live_frontier = preds.to_vec();
|
||
let mut prev_was_preproc = false;
|
||
for child in ast.children(&mut cursor) {
|
||
let child_preds = if frontier.is_empty() && prev_was_preproc {
|
||
last_live_frontier.clone()
|
||
} else {
|
||
frontier.clone()
|
||
};
|
||
|
||
// Go `defer`: record node count before recursing so we can
|
||
// mark the deferred Call node(s) afterward.
|
||
let is_defer = lang == "go" && child.kind() == "defer_statement";
|
||
let defer_first_idx = if is_defer { g.node_count() } else { 0 };
|
||
|
||
let child_exits = build_sub(
|
||
child,
|
||
&child_preds,
|
||
g,
|
||
lang,
|
||
code,
|
||
summaries,
|
||
file_path,
|
||
enclosing_func,
|
||
call_ordinal,
|
||
analysis_rules,
|
||
break_targets,
|
||
continue_targets,
|
||
throw_targets,
|
||
bodies,
|
||
next_body_id,
|
||
current_body_id,
|
||
);
|
||
|
||
// Mark only Call nodes inside the defer as deferred releases.
|
||
if is_defer {
|
||
for raw in defer_first_idx..g.node_count() {
|
||
let idx = NodeIndex::new(raw);
|
||
if g[idx].kind == StmtKind::Call {
|
||
g[idx].in_defer = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
let is_preproc = child.kind().starts_with("preproc_");
|
||
if !child_exits.is_empty() {
|
||
last_live_frontier = child_exits.clone();
|
||
}
|
||
frontier = child_exits;
|
||
prev_was_preproc = is_preproc;
|
||
}
|
||
frontier
|
||
}
|
||
|
||
// Function item – create a header and dive into its body
|
||
Kind::Function => {
|
||
// ── 1) Extract function name ──────────────────────────────────────
|
||
// Lambda expressions don't have meaningful names; force the
|
||
// synthetic anon name to avoid C++ lambdas picking up parameter
|
||
// names via "declarator".
|
||
let fn_name = if ast.kind() == "lambda_expression" {
|
||
anon_fn_name(ast.start_byte())
|
||
} else {
|
||
ast.child_by_field_name("name")
|
||
.or_else(|| ast.child_by_field_name("declarator"))
|
||
.and_then(|n| {
|
||
let mut tmp = Vec::new();
|
||
collect_idents(n, code, &mut tmp);
|
||
tmp.into_iter().next()
|
||
})
|
||
.unwrap_or_else(|| anon_fn_name(ast.start_byte()))
|
||
};
|
||
|
||
// When the grammar-level name is anonymous, try to derive a binding
|
||
// name from the surrounding declaration or assignment. This lets
|
||
// `var h = function(x){...}` / `this.run = () => {...}` participate
|
||
// in callback resolution, callers referencing `h` or `run` can
|
||
// find the body via `resolve_local_func_key` and intra-file calls
|
||
// like `h()` can resolve to the anonymous body's summary. Without
|
||
// this, the body is keyed with the synthetic anon name and there
|
||
// is no path from the variable identifier to the body.
|
||
let fn_name = if is_anon_fn_name(&fn_name) {
|
||
derive_anon_fn_name_from_context(ast, lang, code).unwrap_or(fn_name)
|
||
} else {
|
||
fn_name
|
||
};
|
||
|
||
let is_anon = is_anon_fn_name(&fn_name);
|
||
let param_meta = extract_param_meta(ast, lang, code);
|
||
let param_count = param_meta.len();
|
||
let param_names: Vec<String> = param_meta.iter().map(|(n, _, _)| n.clone()).collect();
|
||
let param_types: Vec<Option<crate::ssa::type_facts::TypeKind>> =
|
||
param_meta.iter().map(|(_, t, _)| t.clone()).collect();
|
||
let param_destructured_fields: Vec<Vec<String>> = param_meta
|
||
.iter()
|
||
.map(|(_, _, siblings)| siblings.clone())
|
||
.collect();
|
||
|
||
// ── 1b) Compute identity discriminators ───────────────────────────
|
||
let (fn_container, fn_kind) =
|
||
compute_container_and_kind(ast, ast.kind(), &fn_name, code);
|
||
// Disambiguator: depth-first preorder index of this function node
|
||
// within the file. Always populated so two same-name, same-
|
||
// container definitions never collide (e.g. duplicate defs in a
|
||
// file, overload-like patterns, nested defs with identical names
|
||
// in sibling scopes). Stable against unrelated edits above the
|
||
// function. Falls back to the start byte when the DFS-index
|
||
// map is absent (tests bypassing build_cfg).
|
||
let fn_disambig: Option<u32> =
|
||
Some(fn_dfs_index(ast.start_byte()).unwrap_or(ast.start_byte() as u32));
|
||
|
||
// ── 2) Create a separate body graph for this function ─────────────
|
||
let (mut fn_graph, fn_entry, fn_exit) =
|
||
create_body_graph(ast.start_byte(), ast.end_byte(), Some(&fn_name));
|
||
|
||
let body_ast = match ast.child_by_field_name("body").or_else(|| {
|
||
let mut c = ast.walk();
|
||
ast.children(&mut c)
|
||
.find(|n| matches!(lookup(lang, n.kind()), Kind::Block | Kind::SourceFile))
|
||
}) {
|
||
Some(b) => b,
|
||
None => {
|
||
warn!(
|
||
"fn without body (forward decl / abstract / error recovery): kind={} name=’{}’",
|
||
ast.kind(),
|
||
fn_name
|
||
);
|
||
// Insert placeholder in parent graph and skip body processing
|
||
let placeholder = g.add_node(make_empty_node_info(
|
||
StmtKind::Seq,
|
||
(ast.start_byte(), ast.end_byte()),
|
||
enclosing_func,
|
||
));
|
||
connect_all(g, preds, placeholder, EdgeKind::Seq);
|
||
return vec![placeholder];
|
||
}
|
||
};
|
||
|
||
// Allocate a BodyId for this function
|
||
let fn_body_id = BodyId(*next_body_id);
|
||
*next_body_id += 1;
|
||
|
||
let entry_preds = inject_framework_param_sources(
|
||
ast,
|
||
code,
|
||
analysis_rules,
|
||
&mut fn_graph,
|
||
fn_entry,
|
||
Some(&fn_name),
|
||
);
|
||
|
||
let mut fn_call_ordinal: u32 = 0;
|
||
let mut fn_breaks = Vec::new();
|
||
let mut fn_continues = Vec::new();
|
||
let mut fn_throws = Vec::new();
|
||
let body_exits = build_sub(
|
||
body_ast,
|
||
&entry_preds,
|
||
&mut fn_graph,
|
||
lang,
|
||
code,
|
||
summaries,
|
||
file_path,
|
||
Some(&fn_name),
|
||
&mut fn_call_ordinal,
|
||
analysis_rules,
|
||
&mut fn_breaks,
|
||
&mut fn_continues,
|
||
&mut fn_throws,
|
||
bodies,
|
||
next_body_id,
|
||
fn_body_id,
|
||
);
|
||
|
||
// ── 3) Wire exits to Exit node ────────────────────────────────────
|
||
for &b in &body_exits {
|
||
connect_all(&mut fn_graph, &[b], fn_exit, EdgeKind::Seq);
|
||
}
|
||
// Wire internal Return/Throw nodes to Exit (both terminate this body)
|
||
for idx in fn_graph.node_indices().collect::<Vec<_>>() {
|
||
if matches!(fn_graph[idx].kind, StmtKind::Return | StmtKind::Throw)
|
||
&& idx != fn_exit
|
||
&& !fn_graph.contains_edge(idx, fn_exit)
|
||
{
|
||
connect_all(&mut fn_graph, &[idx], fn_exit, EdgeKind::Seq);
|
||
}
|
||
}
|
||
|
||
// ── 4) Light-weight dataflow on the body graph ────────────────────
|
||
let mut var_taint = HashMap::<String, Cap>::new();
|
||
let mut node_bits = HashMap::<NodeIndex, Cap>::new();
|
||
let mut fn_src_bits = Cap::empty();
|
||
let mut fn_sani_bits = Cap::empty();
|
||
let mut fn_sink_bits = Cap::empty();
|
||
let mut callees = Vec::<crate::summary::CalleeSite>::new();
|
||
let mut tainted_sink_params: Vec<usize> = Vec::new();
|
||
|
||
for idx in fn_graph.node_indices() {
|
||
let info = &fn_graph[idx];
|
||
if let Some(callee) = &info.call.callee {
|
||
let site = build_callee_site(callee, info, lang, code);
|
||
// Dedup by (name, arity, receiver, qualifier, ordinal). A
|
||
// single function may legitimately contain multiple distinct
|
||
// calls to the same callee (e.g. different ordinals or
|
||
// different receivers); all of those are kept.
|
||
if !callees.iter().any(|c| {
|
||
c.name == site.name
|
||
&& c.arity == site.arity
|
||
&& c.receiver == site.receiver
|
||
&& c.qualifier == site.qualifier
|
||
&& c.ordinal == site.ordinal
|
||
}) {
|
||
callees.push(site);
|
||
}
|
||
}
|
||
for lbl in &info.taint.labels {
|
||
match *lbl {
|
||
DataLabel::Source(bits) => fn_src_bits |= bits,
|
||
DataLabel::Sanitizer(bits) => fn_sani_bits |= bits,
|
||
DataLabel::Sink(bits) => {
|
||
fn_sink_bits |= bits;
|
||
for u in &info.taint.uses {
|
||
if let Some(pos) = param_names.iter().position(|p| p == u)
|
||
&& !tainted_sink_params.contains(&pos)
|
||
{
|
||
tainted_sink_params.push(pos);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let mut in_bits = Cap::empty();
|
||
for u in &info.taint.uses {
|
||
if let Some(b) = var_taint.get(u) {
|
||
in_bits |= *b;
|
||
}
|
||
}
|
||
let mut out_bits = in_bits;
|
||
for lab in &info.taint.labels {
|
||
match *lab {
|
||
DataLabel::Source(bits) => out_bits |= bits,
|
||
DataLabel::Sanitizer(bits) => out_bits &= !bits,
|
||
DataLabel::Sink(_) => {}
|
||
}
|
||
}
|
||
if let Some(def) = &info.taint.defines {
|
||
if out_bits.is_empty() {
|
||
var_taint.remove(def);
|
||
} else {
|
||
var_taint.insert(def.clone(), out_bits);
|
||
}
|
||
}
|
||
node_bits.insert(idx, out_bits);
|
||
}
|
||
for (&idx, &bits) in &node_bits {
|
||
if fn_graph[idx].kind == StmtKind::Return {
|
||
fn_src_bits |= bits;
|
||
}
|
||
}
|
||
for &pred in &body_exits {
|
||
if let Some(&bits) = node_bits.get(&pred) {
|
||
fn_src_bits |= bits;
|
||
}
|
||
}
|
||
|
||
// ── propagating_params ────────────────────────────────────────────
|
||
let propagating_params = {
|
||
let mut params = Vec::new();
|
||
for (i, pname) in param_names.iter().enumerate() {
|
||
let mut flows = false;
|
||
for &idx in node_bits.keys() {
|
||
if fn_graph[idx].kind == StmtKind::Return {
|
||
for u in &fn_graph[idx].taint.uses {
|
||
if u == pname {
|
||
flows = true;
|
||
}
|
||
if let Some(bits) = var_taint.get(u)
|
||
&& !bits.is_empty()
|
||
&& var_taint.contains_key(pname)
|
||
{
|
||
flows = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if !flows {
|
||
for &exit_pred in &body_exits {
|
||
let info = &fn_graph[exit_pred];
|
||
for u in &info.taint.uses {
|
||
if u == pname {
|
||
flows = true;
|
||
}
|
||
}
|
||
if let Some(def) = &info.taint.defines
|
||
&& def == pname
|
||
{
|
||
flows = true;
|
||
}
|
||
}
|
||
}
|
||
if flows {
|
||
params.push(i);
|
||
}
|
||
}
|
||
params
|
||
};
|
||
|
||
tainted_sink_params.sort_unstable();
|
||
tainted_sink_params.dedup();
|
||
|
||
// ── 5) Store summary (entry/exit are body-local) ──────────────────
|
||
let key = FuncKey {
|
||
lang: Lang::from_slug(lang).unwrap_or(Lang::Rust),
|
||
namespace: file_path.to_owned(),
|
||
container: fn_container.clone(),
|
||
name: fn_name.clone(),
|
||
arity: Some(param_count),
|
||
disambig: fn_disambig,
|
||
kind: fn_kind,
|
||
};
|
||
let body_func_key = key.clone();
|
||
summaries.insert(
|
||
key,
|
||
LocalFuncSummary {
|
||
entry: fn_entry,
|
||
source_caps: fn_src_bits,
|
||
sanitizer_caps: fn_sani_bits,
|
||
sink_caps: fn_sink_bits,
|
||
param_count,
|
||
param_names: param_names.clone(),
|
||
propagating_params,
|
||
tainted_sink_params,
|
||
callees,
|
||
container: fn_container,
|
||
disambig: fn_disambig,
|
||
kind: fn_kind,
|
||
},
|
||
);
|
||
|
||
// ── 6) Push BodyCfg ───────────────────────────────────────────────
|
||
let auth_decorators = extract_auth_decorators(ast, lang, code);
|
||
let route_captures = extract_route_path_captures(ast, lang, code);
|
||
let param_route_capture: Vec<bool> = if route_captures.is_empty() {
|
||
vec![false; param_names.len()]
|
||
} else {
|
||
param_names
|
||
.iter()
|
||
.map(|n| {
|
||
let lc = n.to_ascii_lowercase();
|
||
route_captures.iter().any(|c| c == &lc)
|
||
})
|
||
.collect()
|
||
};
|
||
bodies.push(BodyCfg {
|
||
meta: BodyMeta {
|
||
id: fn_body_id,
|
||
kind: if is_anon {
|
||
BodyKind::AnonymousFunction
|
||
} else {
|
||
BodyKind::NamedFunction
|
||
},
|
||
name: if is_anon { None } else { Some(fn_name.clone()) },
|
||
params: param_names,
|
||
param_types,
|
||
param_destructured_fields,
|
||
param_count,
|
||
span: (ast.start_byte(), ast.end_byte()),
|
||
parent_body_id: Some(current_body_id),
|
||
func_key: Some(body_func_key),
|
||
auth_decorators,
|
||
param_route_capture,
|
||
},
|
||
graph: fn_graph,
|
||
entry: fn_entry,
|
||
});
|
||
|
||
// ── 7) Insert placeholder in parent graph ─────────────────────────
|
||
// Declaration-marker only: no defines, uses, callee, or labels.
|
||
let placeholder = g.add_node(make_empty_node_info(
|
||
StmtKind::Seq,
|
||
(ast.start_byte(), ast.end_byte()),
|
||
enclosing_func,
|
||
));
|
||
connect_all(g, preds, placeholder, EdgeKind::Seq);
|
||
|
||
vec![placeholder]
|
||
}
|
||
|
||
// Statements that **may** contain a call ---------------------------------
|
||
Kind::CallWrapper => {
|
||
let mut cursor = ast.walk();
|
||
|
||
// Recurse into divergent control-flow constructs nested inside
|
||
// an expression-statement wrapper. Rust's `expression_statement`
|
||
// wraps `return_expression` / `break_expression` /
|
||
// `continue_expression`; without this delegation the wrapper
|
||
// would lower the return as a plain `StmtKind::Call`, losing
|
||
// the return semantics and letting fall-through Seq edges
|
||
// survive into the SSA terminator (the OR-chain rejection-arm
|
||
// defect, see `or_chain_rejection_block_terminates_with_return`).
|
||
if let Some(inner) = ast.children(&mut cursor).find(|c| {
|
||
matches!(
|
||
lookup(lang, c.kind()),
|
||
Kind::InfiniteLoop
|
||
| Kind::While
|
||
| Kind::For
|
||
| Kind::If
|
||
| Kind::Return
|
||
| Kind::Throw
|
||
| Kind::Break
|
||
| Kind::Continue
|
||
)
|
||
}) {
|
||
return build_sub(
|
||
inner,
|
||
preds,
|
||
g,
|
||
lang,
|
||
code,
|
||
summaries,
|
||
file_path,
|
||
enclosing_func,
|
||
call_ordinal,
|
||
analysis_rules,
|
||
break_targets,
|
||
continue_targets,
|
||
throw_targets,
|
||
bodies,
|
||
next_body_id,
|
||
current_body_id,
|
||
);
|
||
}
|
||
|
||
// JS/TS/Java ternary-RHS split: `var x = c ? a : b;` and
|
||
// `obj.prop = c ? a : b;` lower to a real diamond CFG so the
|
||
// condition is control-flow (not a data-flow `uses` entry).
|
||
// Java uses the same `ternary_expression` AST kind; routing it
|
||
// through the diamond lets `fold_constant_branches` prune dead
|
||
// constant-condition arms (`cond ? "const" : param`) the same way
|
||
// it does for the if-form.
|
||
if matches!(lang, "javascript" | "typescript" | "tsx" | "java")
|
||
&& let Some((lhs_ast, ternary_ast)) = find_ternary_rhs_wrapper(ast)
|
||
{
|
||
let (lhs_text, lhs_labels) =
|
||
classify_ternary_lhs(lhs_ast, lang, code, analysis_rules);
|
||
return build_ternary_diamond(
|
||
lhs_text,
|
||
lhs_labels,
|
||
ternary_ast,
|
||
preds,
|
||
EdgeKind::Seq,
|
||
g,
|
||
lang,
|
||
code,
|
||
enclosing_func,
|
||
call_ordinal,
|
||
analysis_rules,
|
||
);
|
||
}
|
||
|
||
//subscript-write lowering when the
|
||
// CallWrapper's inner expression is `arr[i] = v` (JS/TS,
|
||
// Python). See `try_lower_subscript_write` for shape +
|
||
// bail matrix.
|
||
if crate::pointer::is_enabled()
|
||
&& let Some(n) = try_lower_subscript_write(
|
||
ast,
|
||
preds,
|
||
g,
|
||
lang,
|
||
code,
|
||
enclosing_func,
|
||
call_ordinal,
|
||
)
|
||
{
|
||
return vec![n];
|
||
}
|
||
|
||
let has_call = has_call_descendant(ast, lang);
|
||
|
||
let kind = if has_call {
|
||
StmtKind::Call
|
||
} else {
|
||
StmtKind::Seq
|
||
};
|
||
let ord = if kind == StmtKind::Call {
|
||
let o = *call_ordinal;
|
||
*call_ordinal += 1;
|
||
o
|
||
} else {
|
||
0
|
||
};
|
||
|
||
// Pre-emit Source nodes for call arguments containing source
|
||
// member expressions (e.g. `req.body.returnTo` inside
|
||
// `res.redirect(req.body.returnTo)`). Created BEFORE the Call
|
||
// node so they get lower indices, see doc comment on
|
||
// `pre_emit_arg_source_nodes` for why this ordering matters.
|
||
let (effective_preds, src_bindings, src_uses_only) = if kind == StmtKind::Call {
|
||
pre_emit_arg_source_nodes(g, ast, lang, code, enclosing_func, analysis_rules, preds)
|
||
} else {
|
||
(SmallVec::from_slice(preds), Vec::new(), Vec::new())
|
||
};
|
||
|
||
let node = push_node(
|
||
g,
|
||
kind,
|
||
ast,
|
||
lang,
|
||
code,
|
||
enclosing_func,
|
||
ord,
|
||
analysis_rules,
|
||
);
|
||
apply_arg_source_bindings(g, node, &src_bindings, &src_uses_only);
|
||
|
||
// Python `with_item`: acquisition inside a context manager.
|
||
// Only mark if this is actually an acquisition (Call + defines).
|
||
if ast.kind() == "with_item"
|
||
&& g[node].kind == StmtKind::Call
|
||
&& g[node].taint.defines.is_some()
|
||
{
|
||
g[node].managed_resource = true;
|
||
}
|
||
|
||
connect_all(g, &effective_preds, node, EdgeKind::Seq);
|
||
|
||
// React JSX `dangerouslySetInnerHTML={{__html: x}}` synthesis
|
||
// (Phase 06): chained after the wrapper Call/Seq for cases like
|
||
// `<div .../>;` (expression_statement) where the JSX appears as
|
||
// a top-level expression statement. No-op when the wrapper has
|
||
// no matching JSX descendant.
|
||
let post_jsx_frontier = try_lower_jsx_dangerous_html(
|
||
ast,
|
||
&[node],
|
||
g,
|
||
lang,
|
||
code,
|
||
enclosing_func,
|
||
call_ordinal,
|
||
analysis_rules,
|
||
);
|
||
|
||
// If the callee is a configured terminator, treat as a dead end
|
||
if kind == StmtKind::Call
|
||
&& let Some(callee) = &g[node].call.callee
|
||
&& is_configured_terminator(callee, analysis_rules)
|
||
{
|
||
return Vec::new();
|
||
}
|
||
|
||
// Recurse into any function expressions nested in arguments
|
||
// (e.g. `app.get('/path', function(req, res) { ... })`)
|
||
// so that they get proper function summaries. The build_sub
|
||
// invocation registers the inner body but also adds a
|
||
// Seq-edge `node → placeholder` from the inner Kind::Function
|
||
// arm. That phantom successor turns the outer call into a
|
||
// 2-successor branch with an empty Return(None) leg, which
|
||
// breaks `validated_params_to_return` summary extraction
|
||
// (CVE-2026-25544). Disconnect the spurious edge after
|
||
// build_sub returns; the inner body is still reachable to
|
||
// closure-capture passes via `parent_body_id` metadata.
|
||
let nested = collect_nested_function_nodes(ast, lang);
|
||
for func_node in nested {
|
||
let placeholders = build_sub(
|
||
func_node,
|
||
&[node],
|
||
g,
|
||
lang,
|
||
code,
|
||
summaries,
|
||
file_path,
|
||
enclosing_func,
|
||
call_ordinal,
|
||
analysis_rules,
|
||
break_targets,
|
||
continue_targets,
|
||
throw_targets,
|
||
bodies,
|
||
next_body_id,
|
||
current_body_id,
|
||
);
|
||
for ph in placeholders {
|
||
let to_remove: Vec<_> = g.edges_connecting(node, ph).map(|e| e.id()).collect();
|
||
for eid in to_remove {
|
||
g.remove_edge(eid);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Rust match-guard synthesis: `let <name> = match <scrutinee> { <arm> if <guard> => .., ... }`
|
||
// collapses to this single Call node, hiding the guard from the predicate-classification
|
||
// pipeline. Append a synthetic If node (condition_vars includes <name>) so validation
|
||
// predicates like `.chars().all(|c| c.is_ascii_*())` narrow taint on the guarded branch.
|
||
if lang == "rust"
|
||
&& let Some((guard, let_name)) = detect_rust_let_match_guard(ast, code)
|
||
{
|
||
let if_node = emit_rust_match_guard_if(g, guard, &let_name, code, enclosing_func);
|
||
connect_all(g, &[node], if_node, EdgeKind::Seq);
|
||
let true_gate = g.add_node(NodeInfo {
|
||
kind: StmtKind::Seq,
|
||
ast: AstMeta {
|
||
span: (ast.end_byte(), ast.end_byte()),
|
||
enclosing_func: enclosing_func.map(|s| s.to_string()),
|
||
},
|
||
..Default::default()
|
||
});
|
||
let false_gate = g.add_node(NodeInfo {
|
||
kind: StmtKind::Seq,
|
||
ast: AstMeta {
|
||
span: (ast.end_byte(), ast.end_byte()),
|
||
enclosing_func: enclosing_func.map(|s| s.to_string()),
|
||
},
|
||
..Default::default()
|
||
});
|
||
connect_all(g, &[if_node], true_gate, EdgeKind::True);
|
||
connect_all(g, &[if_node], false_gate, EdgeKind::False);
|
||
return vec![true_gate, false_gate];
|
||
}
|
||
|
||
post_jsx_frontier
|
||
}
|
||
|
||
// Direct call nodes (Ruby `call`, Python `call`, etc. when they appear
|
||
// as direct children of a block rather than wrapped in expression_statement)
|
||
Kind::CallFn | Kind::CallMethod | Kind::CallMacro => {
|
||
let ord = *call_ordinal;
|
||
*call_ordinal += 1;
|
||
let (effective_preds, src_bindings, src_uses_only) = pre_emit_arg_source_nodes(
|
||
g,
|
||
ast,
|
||
lang,
|
||
code,
|
||
enclosing_func,
|
||
analysis_rules,
|
||
preds,
|
||
);
|
||
let n = push_node(
|
||
g,
|
||
StmtKind::Call,
|
||
ast,
|
||
lang,
|
||
code,
|
||
enclosing_func,
|
||
ord,
|
||
analysis_rules,
|
||
);
|
||
apply_arg_source_bindings(g, n, &src_bindings, &src_uses_only);
|
||
connect_all(g, &effective_preds, n, EdgeKind::Seq);
|
||
|
||
// If the callee is a configured terminator, treat as a dead end
|
||
if let Some(callee) = &g[n].call.callee
|
||
&& is_configured_terminator(callee, analysis_rules)
|
||
{
|
||
return Vec::new();
|
||
}
|
||
|
||
// Recurse into any function expressions nested in arguments.
|
||
// Each nested function hits Kind::Function and becomes a separate body.
|
||
// See sibling comment in CallWrapper arm: disconnect the
|
||
// declaration-marker placeholder Seq edge after build_sub
|
||
// returns, so the outer body's CFG isn't artificially branched.
|
||
let nested = collect_nested_function_nodes(ast, lang);
|
||
for func_node in nested {
|
||
let placeholders = build_sub(
|
||
func_node,
|
||
&[n],
|
||
g,
|
||
lang,
|
||
code,
|
||
summaries,
|
||
file_path,
|
||
enclosing_func,
|
||
call_ordinal,
|
||
analysis_rules,
|
||
break_targets,
|
||
continue_targets,
|
||
throw_targets,
|
||
bodies,
|
||
next_body_id,
|
||
current_body_id,
|
||
);
|
||
for ph in placeholders {
|
||
let to_remove: Vec<_> = g.edges_connecting(n, ph).map(|e| e.id()).collect();
|
||
for eid in to_remove {
|
||
g.remove_edge(eid);
|
||
}
|
||
}
|
||
}
|
||
|
||
vec![n]
|
||
}
|
||
|
||
// Assignment that may contain a call (Python `x = os.getenv(...)`, Ruby `x = gets()`)
|
||
Kind::Assignment => {
|
||
// JS/TS/Java ternary-RHS split, same rationale as the CallWrapper branch.
|
||
if matches!(lang, "javascript" | "typescript" | "tsx" | "java")
|
||
&& let (Some(left), Some(right)) = (
|
||
ast.child_by_field_name("left"),
|
||
ast.child_by_field_name("right"),
|
||
)
|
||
{
|
||
let rhs = unwrap_parens(right);
|
||
if rhs.kind() == "ternary_expression" {
|
||
let (lhs_text, lhs_labels) =
|
||
classify_ternary_lhs(left, lang, code, analysis_rules);
|
||
return build_ternary_diamond(
|
||
lhs_text,
|
||
lhs_labels,
|
||
rhs,
|
||
preds,
|
||
EdgeKind::Seq,
|
||
g,
|
||
lang,
|
||
code,
|
||
enclosing_func,
|
||
call_ordinal,
|
||
analysis_rules,
|
||
);
|
||
}
|
||
}
|
||
|
||
//subscript-write lowering. See
|
||
// `try_lower_subscript_write` for the per-language shape
|
||
// matrix and bail conditions.
|
||
if crate::pointer::is_enabled()
|
||
&& let Some(n) = try_lower_subscript_write(
|
||
ast,
|
||
preds,
|
||
g,
|
||
lang,
|
||
code,
|
||
enclosing_func,
|
||
call_ordinal,
|
||
)
|
||
{
|
||
return vec![n];
|
||
}
|
||
|
||
let has_call = has_call_descendant(ast, lang);
|
||
let kind = if has_call {
|
||
StmtKind::Call
|
||
} else {
|
||
StmtKind::Seq
|
||
};
|
||
let ord = if kind == StmtKind::Call {
|
||
let o = *call_ordinal;
|
||
*call_ordinal += 1;
|
||
o
|
||
} else {
|
||
0
|
||
};
|
||
let n = push_node(
|
||
g,
|
||
kind,
|
||
ast,
|
||
lang,
|
||
code,
|
||
enclosing_func,
|
||
ord,
|
||
analysis_rules,
|
||
);
|
||
connect_all(g, preds, n, EdgeKind::Seq);
|
||
// React JSX `dangerouslySetInnerHTML={{__html: x}}` synthesis
|
||
// (Phase 06): chained after the assignment for shapes like
|
||
// `const el = <div .../>`. No-op when no matching JSX descendant
|
||
// is found in the assignment subtree.
|
||
try_lower_jsx_dangerous_html(
|
||
ast,
|
||
&[n],
|
||
g,
|
||
lang,
|
||
code,
|
||
enclosing_func,
|
||
call_ordinal,
|
||
analysis_rules,
|
||
)
|
||
}
|
||
|
||
// Trivia we drop completely ---------------------------------------------
|
||
Kind::Trivia => preds.to_vec(),
|
||
|
||
// React JSX attribute (`name={value}`). The CFG builder synthesises
|
||
// a sink Call node when the attribute is `dangerouslySetInnerHTML`
|
||
// with a `{__html: x}` shape; otherwise no node is added (JSX
|
||
// attributes carry no execution semantics on their own).
|
||
Kind::JsxAttr => try_lower_jsx_dangerous_html(
|
||
ast,
|
||
preds,
|
||
g,
|
||
lang,
|
||
code,
|
||
enclosing_func,
|
||
call_ordinal,
|
||
analysis_rules,
|
||
),
|
||
|
||
// Every other node = simple sequential statement
|
||
_ => {
|
||
// React JSX `dangerouslySetInnerHTML={{__html: x}}` synthesis
|
||
// (Phase 06): handles arrow-bodied components like
|
||
// `() => <div .../>` that reach this arm without a wrapping
|
||
// return / call statement. Strictly additive — when no JSX
|
||
// attribute matches the helper returns `preds` unchanged.
|
||
let preds_v = try_lower_jsx_dangerous_html(
|
||
ast,
|
||
preds,
|
||
g,
|
||
lang,
|
||
code,
|
||
enclosing_func,
|
||
call_ordinal,
|
||
analysis_rules,
|
||
);
|
||
let n = push_node(
|
||
g,
|
||
StmtKind::Seq,
|
||
ast,
|
||
lang,
|
||
code,
|
||
enclosing_func,
|
||
0,
|
||
analysis_rules,
|
||
);
|
||
connect_all(g, &preds_v, n, EdgeKind::Seq);
|
||
vec![n]
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Build an intraprocedural CFG and return (graph, entry_node).
|
||
///
|
||
/// * Walks the Tree‑Sitter AST.
|
||
/// * Creates `StmtKind::*` nodes only for *statement‑level* constructs to keep
|
||
/// the graph compact.
|
||
/// * Wires a synthetic `Entry` node in front and a synthetic `Exit` node after
|
||
/// all real sinks.
|
||
pub(crate) fn build_cfg<'a>(
|
||
tree: &'a Tree,
|
||
code: &'a [u8],
|
||
lang: &str,
|
||
file_path: &str,
|
||
analysis_rules: Option<&LangAnalysisRules>,
|
||
) -> FileCfg {
|
||
debug!(target: "cfg", "Building CFG for {:?}", tree.root_node());
|
||
|
||
// Populate the per-file structural DFS-index map before any build_sub
|
||
// call reads from it. Cleared unconditionally at the end of this
|
||
// function so thread-local state never leaks between files.
|
||
populate_fn_dfs_indices(tree, lang);
|
||
|
||
// harvest DTO class definitions before any param classifier
|
||
// runs. Empty for languages without a collector. Cleared
|
||
// alongside the DFS map at end-of-build_cfg.
|
||
DTO_CLASSES.with(|cell| {
|
||
*cell.borrow_mut() = dto::collect_dto_classes(tree.root_node(), lang, code);
|
||
});
|
||
// harvest same-file `type X = Map<...>` / `Set<...>` / `T[]`
|
||
// aliases so JS/TS param classifiers resolve `m: ElementsMap`
|
||
// to `LocalCollection`. Empty for non-JS/TS languages.
|
||
TYPE_ALIAS_LC.with(|cell| {
|
||
*cell.borrow_mut() =
|
||
dto::collect_type_alias_local_collections(tree.root_node(), lang, code);
|
||
});
|
||
// harvest per-function local-receiver type bindings, so a chained
|
||
// inner call (`sess.createNativeQuery(sql).getResultList()`) can
|
||
// rewrite the receiver `sess` to its type prefix
|
||
// (`HibernateSession`) when the legacy literal-receiver classify
|
||
// misses. Java-only today; the helper is lang-agnostic, gated on
|
||
// `constructor_type` recognising the RHS callee.
|
||
populate_local_receiver_types(tree, lang, code);
|
||
|
||
// Create the top-level body graph (BodyId(0)).
|
||
let (mut g, entry, exit) = create_body_graph(0, code.len(), None);
|
||
|
||
let mut summaries = FuncSummaries::new();
|
||
let mut bodies: Vec<BodyCfg> = Vec::new();
|
||
// BodyId(0) is reserved for top-level; function bodies start at 1.
|
||
let mut next_body_id: u32 = 1;
|
||
|
||
// Build the body below the synthetic ENTRY.
|
||
let mut top_ordinal: u32 = 0;
|
||
let mut top_breaks = Vec::new();
|
||
let mut top_continues = Vec::new();
|
||
let mut top_throws = Vec::new();
|
||
let exits = build_sub(
|
||
tree.root_node(),
|
||
&[entry],
|
||
&mut g,
|
||
lang,
|
||
code,
|
||
&mut summaries,
|
||
file_path,
|
||
None,
|
||
&mut top_ordinal,
|
||
analysis_rules,
|
||
&mut top_breaks,
|
||
&mut top_continues,
|
||
&mut top_throws,
|
||
&mut bodies,
|
||
&mut next_body_id,
|
||
BodyId(0),
|
||
);
|
||
debug!(target: "cfg", "exits: {:?}", exits);
|
||
// Wire every real exit to our synthetic EXIT node.
|
||
for e in exits {
|
||
connect_all(&mut g, &[e], exit, EdgeKind::Seq);
|
||
}
|
||
|
||
debug!(target: "cfg", "CFG DONE, top-level nodes: {}, bodies: {}", g.node_count(), bodies.len() + 1);
|
||
|
||
if cfg!(debug_assertions) {
|
||
for idx in g.node_indices() {
|
||
debug!(target: "cfg", " node {:>3}: {:?}", idx.index(), g[idx]);
|
||
}
|
||
for e in g.edge_references() {
|
||
debug!(
|
||
target: "cfg",
|
||
" edge {:>3} → {:<3} ({:?})",
|
||
e.source().index(),
|
||
e.target().index(),
|
||
e.weight()
|
||
);
|
||
}
|
||
let mut reachable: HashSet<NodeIndex> = Default::default();
|
||
let mut bfs = Bfs::new(&g, entry);
|
||
while let Some(nx) = bfs.next(&g) {
|
||
reachable.insert(nx);
|
||
}
|
||
debug!(
|
||
target: "cfg",
|
||
"reachable nodes: {}/{}",
|
||
reachable.len(),
|
||
g.node_count()
|
||
);
|
||
if reachable.len() != g.node_count() {
|
||
let unreachable: Vec<_> = g
|
||
.node_indices()
|
||
.filter(|i| !reachable.contains(i))
|
||
.collect();
|
||
debug!(target: "cfg", "‼︎ unreachable nodes: {:?}", unreachable);
|
||
}
|
||
let doms: Dominators<_> = simple_fast(&g, entry);
|
||
debug!(target: "cfg", "dominator tree computed (len = {:?})", doms);
|
||
}
|
||
|
||
// Insert top-level body at position 0.
|
||
let toplevel = BodyCfg {
|
||
meta: BodyMeta {
|
||
id: BodyId(0),
|
||
kind: BodyKind::TopLevel,
|
||
name: None,
|
||
params: Vec::new(),
|
||
param_types: Vec::new(),
|
||
param_destructured_fields: Vec::new(),
|
||
param_count: 0,
|
||
span: (0, code.len()),
|
||
parent_body_id: None,
|
||
func_key: None,
|
||
auth_decorators: Vec::new(),
|
||
param_route_capture: Vec::new(),
|
||
},
|
||
graph: g,
|
||
entry,
|
||
};
|
||
bodies.insert(0, toplevel);
|
||
// Sort by BodyId so that bodies[i].meta.id == BodyId(i).
|
||
// Nested functions are pushed before their parents during build_sub,
|
||
// so the Vec may be out of order before this sort.
|
||
bodies.sort_by_key(|b| b.meta.id);
|
||
|
||
// Extract import alias bindings for JS/TS files.
|
||
let import_bindings = if matches!(
|
||
lang,
|
||
"javascript" | "typescript" | "tsx" | "python" | "php" | "rust"
|
||
) {
|
||
extract_import_bindings(tree, code)
|
||
} else {
|
||
HashMap::new()
|
||
};
|
||
|
||
// Extract promisify-alias bindings (JS/TS only). Applies a post-pass
|
||
// over every call node whose callee is a recorded alias so the wrapped
|
||
// function's labels (source/sanitizer/sink) carry through to the alias.
|
||
let promisify_aliases = if matches!(lang, "javascript" | "typescript" | "tsx") {
|
||
extract_promisify_aliases(tree, code)
|
||
} else {
|
||
HashMap::new()
|
||
};
|
||
|
||
let extra = analysis_rules.map(|r| r.extra_labels.as_slice());
|
||
if !promisify_aliases.is_empty() {
|
||
apply_promisify_labels(&mut bodies, &promisify_aliases, lang, extra);
|
||
}
|
||
|
||
// Phase 05 — JS/TS gated FILE_IO sinks (`readFile`, `writeFile`, ...)
|
||
// for `node:fs/promises` callees. Runs after CFG construction so the
|
||
// per-file local-import view is available; classify_all_ctx looks up
|
||
// each call's leading identifier in the view to decide whether the
|
||
// ImportedFromModule gate fires.
|
||
let local_imports = if matches!(lang, "javascript" | "typescript" | "tsx") {
|
||
let local_imports = extract_local_import_view(tree, code);
|
||
if !local_imports.is_empty() {
|
||
apply_gated_label_rules(&mut bodies, lang, extra, &local_imports);
|
||
}
|
||
local_imports
|
||
} else {
|
||
HashMap::new()
|
||
};
|
||
|
||
// Clear the per-file DFS-index map so it does not leak to the next
|
||
// file built on this thread.
|
||
clear_fn_dfs_indices();
|
||
// same hygiene for the DTO map.
|
||
DTO_CLASSES.with(|cell| cell.borrow_mut().clear());
|
||
TYPE_ALIAS_LC.with(|cell| cell.borrow_mut().clear());
|
||
LOCAL_RECEIVER_TYPES.with(|cell| cell.borrow_mut().clear());
|
||
|
||
// collect every
|
||
// declared inheritance / impl / implements relationship in the
|
||
// file. Per-language extractor in `cfg::hierarchy`; empty for
|
||
// Go and C. Each `(sub, super)` pair gets duplicated onto every
|
||
// FuncSummary produced for the file by
|
||
// `crate::cfg::export_summaries` so the information persists
|
||
// through SQLite round-trips and re-merges into
|
||
// `crate::callgraph::TypeHierarchyIndex` at call-graph build time.
|
||
let hierarchy_edges = hierarchy::collect_hierarchy_edges(tree.root_node(), lang, code);
|
||
|
||
// Phase 10 — Next.js entry-point detection. Empty for non-JS/TS
|
||
// languages; for JS/TS, keys each detected entry function by its
|
||
// tree-sitter byte span so the SSA pass can match against
|
||
// [`BodyMeta::span`] when seeding params.
|
||
let entry_kinds = crate::entry_points::detect_entries_in_file(
|
||
tree,
|
||
code,
|
||
std::path::Path::new(file_path),
|
||
lang,
|
||
);
|
||
|
||
// Java safe-lookup field map: `final ... = Map.of(literal, literal, ...)`
|
||
// declarations whose `.get(...)` results are bounded to the literal
|
||
// set. Empty for other languages.
|
||
let safe_lookup_fields = safe_fields::collect_safe_lookup_fields(tree.root_node(), lang, code);
|
||
|
||
// Java class-level constant scalars: `static final TYPE NAME = LITERAL;`
|
||
// declarations whose name surfaces at a sink as a compile-time-bounded
|
||
// value. Empty for other languages.
|
||
let class_constant_scalars =
|
||
safe_fields::collect_class_constant_scalars(tree.root_node(), lang, code);
|
||
|
||
FileCfg {
|
||
bodies,
|
||
summaries,
|
||
import_bindings,
|
||
promisify_aliases,
|
||
hierarchy_edges,
|
||
resolved_imports: Vec::new(),
|
||
local_imports,
|
||
entry_kinds,
|
||
safe_lookup_fields,
|
||
class_constant_scalars,
|
||
}
|
||
}
|
||
|
||
/// Walk every CFG node in every body; for Call nodes whose callee matches a
|
||
/// promisify alias, classify the wrapped callee and union the resulting labels
|
||
/// into `info.taint.labels` (dedup by variant+caps). The displayed callee
|
||
/// text is left unchanged so diagnostics still surface the alias name.
|
||
fn apply_promisify_labels(
|
||
bodies: &mut [BodyCfg],
|
||
aliases: &PromisifyAliases,
|
||
lang: &str,
|
||
extra: Option<&[crate::labels::RuntimeLabelRule]>,
|
||
) {
|
||
for body in bodies.iter_mut() {
|
||
let indices: Vec<NodeIndex> = body.graph.node_indices().collect();
|
||
for idx in indices {
|
||
let Some(callee) = body.graph[idx].call.callee.clone() else {
|
||
continue;
|
||
};
|
||
let Some(alias) = aliases.get(&callee) else {
|
||
continue;
|
||
};
|
||
// Inherit both flat and gated labels from the wrapped callee.
|
||
// Gated sinks (e.g. `child_process.exec`) carry the same
|
||
// capability semantics as flat sinks, just with arg-position
|
||
// filtering at the call site; the promisify alias should
|
||
// surface the wrapped function's sink class regardless of
|
||
// which arm originally classified it.
|
||
let mut wrapped_labels: Vec<crate::labels::DataLabel> =
|
||
classify_all(lang, &alias.wrapped, extra)
|
||
.into_iter()
|
||
.collect();
|
||
for gm in
|
||
classify_gated_sink(lang, &alias.wrapped, |_| None, |_| None, |_| false).iter()
|
||
{
|
||
if !wrapped_labels.contains(&gm.label) {
|
||
wrapped_labels.push(gm.label);
|
||
}
|
||
}
|
||
if wrapped_labels.is_empty() {
|
||
continue;
|
||
}
|
||
let info = &mut body.graph[idx];
|
||
for lbl in wrapped_labels {
|
||
if !info.taint.labels.contains(&lbl) {
|
||
info.taint.labels.push(lbl);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Phase 05 — apply [`crate::labels::GatedLabelRule`] entries against
|
||
/// every call node in the file. The local-import view supplies the
|
||
/// gate evaluation context so a bare-name `readFile(...)` only fires
|
||
/// when the file actually imports `readFile` from `fs/promises` /
|
||
/// `node:fs/promises` (or is renamed via `import * as fsp` /
|
||
/// `import { readFile as rf }`). Strictly additive: only inserts new
|
||
/// labels, never removes existing ones.
|
||
fn apply_gated_label_rules(
|
||
bodies: &mut [BodyCfg],
|
||
lang: &str,
|
||
_extra: Option<&[crate::labels::RuntimeLabelRule]>,
|
||
local_imports: &std::collections::HashMap<String, String>,
|
||
) {
|
||
let ctx = crate::labels::ClassificationContext {
|
||
local_imports: Some(local_imports),
|
||
};
|
||
for body in bodies.iter_mut() {
|
||
let indices: Vec<NodeIndex> = body.graph.node_indices().collect();
|
||
for idx in indices {
|
||
let Some(callee) = body.graph[idx].call.callee.clone() else {
|
||
continue;
|
||
};
|
||
let labels = crate::labels::classify_gated_only(lang, &callee, Some(&ctx));
|
||
if labels.is_empty() {
|
||
continue;
|
||
}
|
||
let info = &mut body.graph[idx];
|
||
for lbl in labels {
|
||
if !info.taint.labels.contains(&lbl) {
|
||
info.taint.labels.push(lbl);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Build a `CalleeSite` carrying the richer per-call-site metadata for a
|
||
/// CFG node.
|
||
///
|
||
/// * `arity`, positional argument count. `None` when `extract_arg_uses`
|
||
/// bailed out on splats/keyword-args (length 0 does not distinguish
|
||
/// zero-arg calls from unknown; we treat 0 as a concrete zero). The
|
||
/// receiver is a separate channel via `CallMeta.receiver` and is not
|
||
/// represented in `arg_uses`, so `arity == arg_uses.len()` for calls.
|
||
/// * `receiver`, forwarded verbatim from `CallMeta.receiver` (already
|
||
/// normalized to the root identifier).
|
||
/// * `qualifier`, the segment(s) before the leaf identifier of the callee.
|
||
/// For **Rust** specifically, this is the *full* `::`-joined prefix (e.g.
|
||
/// `"crate::auth::token"` for `crate::auth::token::validate`) so that
|
||
/// cross-file `use`-map resolution in `callgraph.rs` has everything it
|
||
/// needs to walk an import chain. For every other language the qualifier
|
||
/// remains the single segment immediately before the leaf (back-compat
|
||
/// with the legacy heuristic). For method calls the qualifier is
|
||
/// redundant with `receiver` and is left `None`.
|
||
fn build_callee_site(
|
||
callee: &str,
|
||
info: &NodeInfo,
|
||
lang: &str,
|
||
code: &[u8],
|
||
) -> crate::summary::CalleeSite {
|
||
use crate::summary::CalleeSite;
|
||
|
||
let receiver = info.call.receiver.clone();
|
||
|
||
let arity = if info.kind == StmtKind::Call || receiver.is_some() {
|
||
Some(info.call.arg_uses.len())
|
||
} else {
|
||
None
|
||
};
|
||
|
||
let qualifier = if receiver.is_some() {
|
||
None
|
||
} else if let Some(pos) = callee.rfind("::") {
|
||
let prefix = &callee[..pos];
|
||
if lang == "rust" {
|
||
// Rust: preserve the full module path prefix so use-map
|
||
// resolution can follow `use ...` chains without re-parsing.
|
||
Some(prefix.to_string()).filter(|s| !s.is_empty())
|
||
} else {
|
||
Some(prefix.rsplit("::").next().unwrap_or(prefix).to_string()).filter(|s| !s.is_empty())
|
||
}
|
||
} else if let Some(pos) = callee.rfind('.') {
|
||
let prefix = &callee[..pos];
|
||
Some(prefix.rsplit('.').next().unwrap_or(prefix).to_string()).filter(|s| !s.is_empty())
|
||
} else {
|
||
None
|
||
};
|
||
|
||
let span = callee_span_line_col(code, info.ast.span.0);
|
||
|
||
CalleeSite {
|
||
name: callee.to_string(),
|
||
arity,
|
||
receiver,
|
||
qualifier,
|
||
ordinal: info.call.call_ordinal,
|
||
span,
|
||
}
|
||
}
|
||
|
||
/// Convert a byte offset into a 1-based `(line, col)` pair against `code`.
|
||
///
|
||
/// Returns `None` only when `code` is empty (no source to resolve against);
|
||
/// out-of-range offsets are clamped to `code.len()` so a synthetic node
|
||
/// whose span overshoots the file still produces the last-line coordinate
|
||
/// rather than `None`.
|
||
fn callee_span_line_col(code: &[u8], offset: usize) -> Option<(u32, u32)> {
|
||
if code.is_empty() {
|
||
return None;
|
||
}
|
||
let clamped = offset.min(code.len());
|
||
let prefix = &code[..clamped];
|
||
let line = prefix.iter().filter(|&&b| b == b'\n').count() as u32 + 1;
|
||
let col_bytes = match prefix.iter().rposition(|&b| b == b'\n') {
|
||
Some(idx) => clamped - idx - 1,
|
||
None => clamped,
|
||
} as u32
|
||
+ 1;
|
||
Some((line, col_bytes))
|
||
}
|
||
|
||
/// Convert the graph‑local `FuncSummaries` into serialisable [`FuncSummary`]
|
||
/// values suitable for cross‑file persistence.
|
||
pub(crate) fn export_summaries(
|
||
summaries: &FuncSummaries,
|
||
file_path: &str,
|
||
lang: &str,
|
||
) -> Vec<FuncSummary> {
|
||
summaries
|
||
.iter()
|
||
.map(|(key, local)| FuncSummary {
|
||
name: key.name.clone(),
|
||
file_path: file_path.to_owned(),
|
||
lang: lang.to_owned(),
|
||
param_count: local.param_count,
|
||
param_names: local.param_names.clone(),
|
||
source_caps: local.source_caps.bits(),
|
||
sanitizer_caps: local.sanitizer_caps.bits(),
|
||
sink_caps: local.sink_caps.bits(),
|
||
propagating_params: local.propagating_params.clone(),
|
||
propagates_taint: false,
|
||
tainted_sink_params: local.tainted_sink_params.clone(),
|
||
// Primary sink-location attribution: the legacy
|
||
// `export_summaries` runs without tree/bytes access, so
|
||
// cannot resolve sink node spans to line/col/snippet.
|
||
// `ParsedFile::export_summaries_with_root` is responsible
|
||
// for populating this field when it has tree access.
|
||
param_to_sink: Vec::new(),
|
||
callees: local.callees.clone(),
|
||
container: local.container.clone(),
|
||
disambig: local.disambig,
|
||
kind: local.kind,
|
||
// Rust use-map metadata is attached later in
|
||
// `ParsedFile::export_summaries_with_root`, which has access to
|
||
// the file's tree and scan root. Leaving these `None` here keeps
|
||
// `export_summaries` a pure graph→summary transform.
|
||
module_path: None,
|
||
rust_use_map: None,
|
||
rust_wildcards: None,
|
||
// Hierarchy edges live on `FileCfg`, not on the
|
||
// graph-local `FuncSummaries`. `ParsedFile::export_summaries_with_root`
|
||
// attaches them after this transform returns.
|
||
hierarchy_edges: Vec::new(),
|
||
// Phase-10 entry-point classification is attached after
|
||
// this transform returns by
|
||
// `ParsedFile::export_summaries_with_root` (which has
|
||
// access to `FileCfg::entry_kinds`).
|
||
entry_kind: None,
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod cfg_tests;
|