//! Intra-procedural control-flow graph construction. //! //! Walks tree-sitter ASTs for all ten supported languages and builds a //! [`Cfg`] (a petgraph `DiGraph`) 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)> { 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, Vec, )> { 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> = 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> = 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> = 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> = 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 { 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 { 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 { 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) { 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 { FN_DFS_INDICES.with(|cell| cell.borrow().get(&start_byte).copied()) } /// Synthetic name for an anonymous function: `` from the DFS /// index when available, `` as fallback. pub(crate) fn anon_fn_name(start_byte: usize) -> String { match fn_dfs_index(start_byte) { Some(idx) => format!(""), None => format!(""), } } /// True for any anonymous-function synthesis prefix. pub(crate) fn is_anon_fn_name(name: &str) -> bool { name.starts_with(" 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), /// Binary arithmetic / bitwise / comparison. Bin(BinOp, Box, Box), } /// 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) -> Option { 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| 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) -> Option { 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) { 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, /// 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, /// 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, /// 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>, /// 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, /// 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>, /// 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-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>, /// 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>, /// 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, /// 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, /// 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>, /// 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, } /// 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, pub defines: Option, // variable written by this stmt pub uses: Vec, // 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, /// 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, } #[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, /// For If nodes: identifiers referenced in the condition (sorted, deduped, max 8). pub condition_vars: Vec, /// 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, /// 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>, /// 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, /// 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, /// 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, /// 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, /// 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, /// 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, /// Which parameter indices (0‑based) flow through to the return value. pub propagating_params: Vec, /// Which parameter indices flow to internal sinks. pub tainted_sink_params: Vec, /// 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, /// 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, /// Structural role of this definition. pub kind: crate::symbol::FuncKind, } pub type Cfg = Graph; pub type FuncSummaries = HashMap; // 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, pub params: Vec, /// 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>, /// 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>, pub param_count: usize, pub span: (usize, usize), pub parent_body_id: Option, /// 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, /// 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, /// 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/")`, blueprint-prefixed `@bp.get("/u/")`, /// 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, } /// 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, } /// 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; /// 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; /// All CFGs for a file. #[derive(Debug)] pub struct FileCfg { pub bodies: Vec, 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, /// 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, /// 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>, /// 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, } 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, Vec, bool, Option) { // 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 { 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 { // 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 { 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::() { 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 { 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 { 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> { 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 { // 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 { // 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 = 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 { 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 = 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 { 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 { 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::() { 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 { 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 { // 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 { 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> { 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> = 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 { 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 = 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 = 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> = None; let mut destination_uses: Option> = None; let mut gate_filters: Vec = 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 = 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::()); 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 = 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 = 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> = None; let mut dest_fields: Vec = 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, Vec) = 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 `[, "-c", ]` (and `cmd /c `) // 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 = 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: 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 { // 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 = 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 = 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> = 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 { 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 = 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 { if !matches!(lang, "javascript" | "js" | "typescript" | "ts" | "tsx") { return preds.to_vec(); } let mut attrs: Vec = 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 = 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 = if jsx_value_is_sanitized(html_value, lang, code, extra) { Vec::new() } else { let mut idents: Vec = 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>) { let mut stack: Vec> = 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 = () =>
{bio}
`) 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 (``); 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 = 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: }}` attribute. Returns the /// AST node for `` or `None` if the shape doesn't match. fn jsx_extract_html_value<'a>(attr: Node<'a>, code: &[u8]) -> Option> { 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") ` 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 { 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, descendant: Node, idx_text: &str, code: &[u8], ) -> Option { 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> { 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>) { 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); 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 = 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 = 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, continue_targets: &mut Vec, throw_targets: &mut Vec, bodies: &mut Vec, next_body_id: &mut u32, current_body_id: BodyId, ) -> Vec { // 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 = preds.to_vec(); let mut chain_edge = incoming_edge; let mut exits: Vec = 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>, 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, continue_targets: &mut Vec, throw_targets: &mut Vec, bodies: &mut Vec, next_body_id: &mut u32, current_body_id: BodyId, ) -> Vec { 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, continue_targets: &mut Vec, throw_targets: &mut Vec, bodies: &mut Vec, next_body_id: &mut u32, current_body_id: BodyId, ) -> Vec { 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 = 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 = 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 = 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 = 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 = 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 = param_meta.iter().map(|(n, _, _)| n.clone()).collect(); let param_types: Vec> = param_meta.iter().map(|(_, t, _)| t.clone()).collect(); let param_destructured_fields: Vec> = 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 = 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::>() { 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::::new(); let mut node_bits = HashMap::::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::::new(); let mut tainted_sink_params: Vec = 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 = 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 // `
;` (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 = match { if => .., ... }` // collapses to this single Call node, hiding the guard from the predicate-classification // pipeline. Append a synthetic If node (condition_vars includes ) 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 =
`. 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 // `() =>
` 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 = 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 = 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 = 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 = 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, ) { let ctx = crate::labels::ClassificationContext { local_imports: Some(local_imports), }; for body in bodies.iter_mut() { let indices: Vec = 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 { 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;