new capacity bits (#67)

This commit is contained in:
Eli Peter 2026-05-07 01:29:31 -04:00 committed by GitHub
parent afaffc0df6
commit 7d0e7320e2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
261 changed files with 10591 additions and 231 deletions

View file

@ -1190,6 +1190,7 @@ fn clone_preserves_all_sub_structs() {
destination_uses: None,
gate_filters: Vec::new(),
is_constructor: false,
produces_null_proto: false,
},
taint: TaintMeta {
labels: {
@ -1841,9 +1842,12 @@ def outer(cmd):
assert_eq!(kwargs[1].0, "check");
}
/// Languages without keyword-argument grammar should leave `kwargs` empty.
/// JS object-literal positional args lift their `pair` children into
/// `kwargs` so consumers like xml_config's `processEntities` /
/// `resolve_entities` opt-in detector can read them without re-walking
/// the tree-sitter AST.
#[test]
fn call_node_kwargs_empty_for_javascript() {
fn call_node_kwargs_lifts_javascript_object_literal_pairs() {
let src = br"
function outer(cmd) {
child_process.exec(cmd, { shell: true });
@ -1861,9 +1865,12 @@ fn call_node_kwargs_empty_for_javascript() {
.is_some_and(|c| c.ends_with("exec"))
})
.expect("child_process.exec call node should exist");
let kwargs = &call_node.call.kwargs;
assert!(
call_node.call.kwargs.is_empty(),
"JS object-literal arg is not a keyword_argument — kwargs should stay empty"
kwargs
.iter()
.any(|(k, vs)| k == "shell" && vs.iter().any(|v| v == "true")),
"JS object-literal `{{ shell: true }}` should surface as kwarg, got {kwargs:?}"
);
}

View file

@ -7,7 +7,7 @@
//! Strictly additive: classes whose fields cannot be classified produce
//! a `DtoFields` with an empty `fields` map, the caller must decide
//! whether to use that as a "Dto with no inferred fields" or fall back
//! to the pre-Phase-6 Object/Unknown classification.
//! to the generic Object/Unknown classification.
use std::collections::{HashMap, HashSet};

View file

@ -35,6 +35,16 @@ pub(crate) fn root_receiver_text(n: Node, lang: &str, code: &[u8]) -> Option<Str
None => text_of(n, code),
}
}
// PHP `variable_name` text carries a leading `$` (`$smarty`, `$twig`).
// Strip it so chain text built downstream (`{recv}.{method}`) presents
// a `.`-only delimiter sequence — required by the suffix-matcher
// boundary rule, which only accepts `.`/`:` as chain separators.
// Without this strip, gate matchers like `Smarty.fetch` /
// `Environment.createTemplate` never fire on idiomatic
// `$smarty->fetch(...)` / `$twig->createTemplate(...)` shapes.
_ if lang == "php" && n.kind() == "variable_name" => {
text_of(n, code).map(|s| s.trim_start_matches('$').to_string())
}
_ => text_of(n, code),
}
}

View file

@ -195,6 +195,56 @@ pub(super) fn extract_destination_kwarg_pairs(
/// Extract the string-literal content at argument position `index` (0-based).
/// Returns `None` if the argument is not a string literal or the index is out of range.
/// True when `call_node` is `Object.create(null)` (or its parenthesised /
/// awaited / type-cast wrappers). Strict literal-`null` first-arg match,
/// no aliasing through intermediate variables. Caller restricts to JS/TS.
pub(super) fn is_object_create_null_call(call_node: Node, code: &[u8]) -> bool {
if !matches!(call_node.kind(), "call_expression") {
return false;
}
let callee = call_node
.child_by_field_name("function")
.and_then(|f| text_of(f, code))
.unwrap_or_default();
if callee != "Object.create" {
return false;
}
let Some(args) = call_node.child_by_field_name("arguments") else {
return false;
};
let mut cursor = args.walk();
let named: Vec<Node> = args.named_children(&mut cursor).collect();
if named.len() != 1 {
return false;
}
let mut arg = named[0];
// Unwrap parens / await / TS type-assertions.
for _ in 0..4 {
match arg.kind() {
"parenthesized_expression" => {
if let Some(inner) = arg.named_child(0) {
arg = inner;
continue;
}
}
"await_expression" => {
if let Some(inner) = arg.child_by_field_name("argument") {
arg = inner;
continue;
}
}
"as_expression" | "type_assertion" => {
if let Some(inner) = arg.named_child(0) {
arg = inner;
continue;
}
}
_ => break,
}
}
arg.kind() == "null" || text_of(arg, code).as_deref() == Some("null")
}
pub(super) fn extract_const_string_arg(
call_node: Node,
index: usize,
@ -222,6 +272,37 @@ pub(super) fn extract_const_string_arg(
None
}
}
// Boolean literals — JS/TS `true`/`false` are their own node kinds; some
// grammars wrap them as identifiers carrying the keyword text. Returned
// verbatim so `dangerous_values` matching can detect deep-flag forms
// like `extend(true, target, src)`.
"true" | "false" => Some(arg.kind().to_string()),
// PHP double-quoted strings parse as `encapsed_string` whose body is
// a sequence of `string_content` / `escape_sequence` / interpolation
// nodes. Treat the string as constant only when every child is a
// pure-literal segment (no `variable_name` / `subscript_expression`
// interpolations); the returned value is the concatenation of the
// literal segments verbatim.
"encapsed_string" => {
let mut c = arg.walk();
let mut buf = String::new();
for ch in arg.named_children(&mut c) {
match ch.kind() {
"string_content" => {
if let Some(s) = text_of(ch, code) {
buf.push_str(&s);
}
}
"escape_sequence" => {
if let Some(s) = text_of(ch, code) {
buf.push_str(&s);
}
}
_ => return None,
}
}
Some(buf)
}
"template_string" => {
// Only treat as constant if no interpolation (no template_substitution children)
let mut c = arg.walk();
@ -238,6 +319,44 @@ pub(super) fn extract_const_string_arg(
None
}
}
// Concat-style binary expression with a leading string literal, e.g.
// PHP `"Location: " . $url`, JS/TS `"Location: " + url`. Returns the
// left-most literal so prefix-driven gates (`dangerous_prefixes`) can
// activate on partially-dynamic concatenations; falls through to
// `None` when the leading segment is not a string literal so
// exact-`dangerous_values` matching keeps its strict semantics.
"binary_expression" => {
let left = arg.child_by_field_name("left")?;
match left.kind() {
"string"
| "string_literal"
| "interpreted_string_literal"
| "raw_string_literal" => {
let raw = text_of(left, code)?;
if raw.len() >= 2 {
Some(raw[1..raw.len() - 1].to_string())
} else {
None
}
}
"encapsed_string" => {
let mut c = left.walk();
let mut buf = String::new();
for ch in left.named_children(&mut c) {
match ch.kind() {
"string_content" | "escape_sequence" => {
if let Some(s) = text_of(ch, code) {
buf.push_str(&s);
}
}
_ => return None,
}
}
Some(buf)
}
_ => None,
}
}
_ => None,
}
}
@ -271,6 +390,27 @@ pub(super) fn extract_const_macro_arg(
"identifier" | "name" | "qualified_name" | "scoped_identifier" => {
text_of(arg, code).map(|s| s.to_string())
}
// Ruby bare constant (`NOENT`) — leaf form.
"constant" => text_of(arg, code).map(|s| s.to_string()),
// Ruby scope-qualified constant (`Nokogiri::XML::ParseOptions::NOENT`).
// Return only the rightmost `name` segment so the gate's
// `dangerous_values` list can stay identifier-bare instead of
// enumerating every possible namespacing. Falls back to the full
// text if the `name` field is missing for any reason.
"scope_resolution" => arg
.child_by_field_name("name")
.and_then(|n| text_of(n, code))
.map(|s| s.to_string())
.or_else(|| text_of(arg, code).map(|s| s.to_string())),
// Integer literals at the activation arg position. PHP / C / C++
// commonly use plain `0` to opt into the safe-default option set
// (e.g. `simplexml_load_string($xml, "SimpleXMLElement", 0)`). The
// gate's `dangerous_values` list is identifier-only, so returning
// the literal text lets the comparison fail against `LIBXML_NOENT`
// and suppresses the conservative-fire branch.
"integer" | "integer_literal" | "number_literal" | "decimal_integer_literal" => {
text_of(arg, code).map(|s| s.to_string())
}
_ => None,
}
}
@ -728,35 +868,72 @@ pub(super) fn find_chained_inner_call<'a>(
return Some((function, inner_text));
}
// The function/method field for a chained call is a member_expression
// (JS/TS) or attribute (Python) etc.; its `object` field is the
// receiver expression. Only proceed when that receiver is itself a
// call.
let object = function.child_by_field_name("object")?;
// (JS/TS), attribute (Python), or field_expression (Rust); its
// receiver is the `object` field (JS/TS/Python) or `value` field
// (Rust). Only proceed when that receiver is itself a call.
let object = function
.child_by_field_name("object")
.or_else(|| function.child_by_field_name("value"))?;
if !matches!(lookup(lang, object.kind()), Kind::CallFn | Kind::CallMethod) {
return None;
}
// Recurse: the inner call may itself be chained
// (`axios.get(u).then(h).catch(h)`, innermost is `axios.get`).
if let Some(inner) = find_chained_inner_call(object, lang, code) {
return Some(inner);
}
// `object` is the innermost call_expression in the chain. Extract
// its callee identifier the same way `first_call_ident_with_span`
// does for a CallFn (member_expression text → "http.get").
let inner_func = object
// Decide whether `object` is itself a chained method call (its
// function/method field is a member-style expression). When yes,
// recurse one more level so deeper chains resolve to their innermost
// method (e.g. `axios.get(u).then(h).catch(h)` → `axios.get`).
// When no — the receiver is a plain function/constructor call like
// Rust's `HttpResponse::Found()` — descending one more level would
// strand us on the non-method leaf whose text would not match any
// gate matcher. Stop here and return the current `outer` level,
// which IS the innermost method call.
let object_function = object
.child_by_field_name("function")
.or_else(|| object.child_by_field_name("method"))
.or_else(|| object.child_by_field_name("name"))?;
// Multi-line dotted member expressions (`http\n .get`) include
// formatting whitespace in the source-text slice. The labels map
// keys are literal `"http.get"` etc., strip whitespace so the
// chained-call inner-gate rebinding fires for both single-line and
// multi-line chain styles. Also strips `\r` for CRLF sources.
// Motivated by upstream Parse Server CVE-2025-64430 which uses the
// multi-line `http\n .get(uri, ...)\n .on(...)` form.
let raw = text_of(inner_func, code)?;
.or_else(|| object.child_by_field_name("method"));
let object_is_chained_method = object_function
.map(|f| {
matches!(
f.kind(),
"member_expression"
| "attribute"
| "field_expression"
| "scoped_identifier"
| "scope_resolution"
) && f
.child_by_field_name("object")
.or_else(|| f.child_by_field_name("value"))
.is_some()
})
.unwrap_or(false);
if object_is_chained_method {
// Recurse: the inner call may itself be chained.
if let Some(inner) = find_chained_inner_call(object, lang, code) {
return Some(inner);
}
// `object` is the innermost call_expression in the chain. Extract
// its callee identifier the same way `first_call_ident_with_span`
// does for a CallFn (member_expression text → "http.get").
let inner_func = object
.child_by_field_name("function")
.or_else(|| object.child_by_field_name("method"))
.or_else(|| object.child_by_field_name("name"))?;
// Multi-line dotted member expressions (`http\n .get`) include
// formatting whitespace in the source-text slice. The labels map
// keys are literal `"http.get"` etc., strip whitespace so the
// chained-call inner-gate rebinding fires for both single-line and
// multi-line chain styles. Also strips `\r` for CRLF sources.
// Motivated by upstream Parse Server CVE-2025-64430 which uses the
// multi-line `http\n .get(uri, ...)\n .on(...)` form.
let raw = text_of(inner_func, code)?;
let inner_text: String = raw.chars().filter(|c| !c.is_whitespace()).collect();
return Some((object, inner_text));
}
// Receiver is a non-chained call (Rust constructor `Foo::new()` /
// `HttpResponse::Found()`, JS bare `f()`). Outer level IS the
// innermost method call — return its own function text so gate
// matching sees the method name.
let raw = text_of(function, code)?;
let inner_text: String = raw.chars().filter(|c| !c.is_whitespace()).collect();
Some((object, inner_text))
Some((outer, inner_text))
}
/// Recursively walk the receiver chain of `outer` (a CallFn / CallMethod
@ -1389,6 +1566,47 @@ pub(super) fn extract_kwargs(call_node: Node, code: &[u8]) -> Vec<(String, Vec<S
let mut cursor = args_node.walk();
for child in args_node.named_children(&mut cursor) {
let kind = child.kind();
// JS/TS object-literal positional arg: `f(x, { a: true, b: 'str' })`.
// The pairs inside the object are not tree-sitter
// `keyword_argument` nodes (those are Python/Ruby), but
// downstream consumers (xml_config's
// `lookup_kwargs(inst.cfg_node)` JS branch checking
// `processEntities`) expect these fields in the kwargs vector.
// Lift each `pair` (and `shorthand_property_identifier`) into
// the kwargs list using the property name as kwarg name and the
// raw text of the value expression as the single value.
// Boolean / numeric / string / identifier values all surface as
// their textual form, which is what xml_config's kwarg-value
// matchers (e.g. `v == "true"`) compare against.
if kind == "object" {
let mut oc = child.walk();
for pair in child.named_children(&mut oc) {
let pk = pair.kind();
if pk == "pair" {
let Some(kn) = pair.child_by_field_name("key") else {
continue;
};
let Some(vn) = pair.child_by_field_name("value") else {
continue;
};
let Some(raw_name) = text_of(kn, code) else {
continue;
};
let name = raw_name
.trim_start_matches(['"', '\''])
.trim_end_matches(['"', '\''])
.to_string();
if let Some(val_text) = text_of(vn, code) {
out.push((name, vec![val_text.to_string()]));
}
} else if pk == "shorthand_property_identifier" {
if let Some(name) = text_of(pair, code) {
out.push((name.to_string(), vec![name.to_string()]));
}
}
}
continue;
}
if kind != "keyword_argument" && kind != "named_argument" {
continue;
}
@ -1413,6 +1631,32 @@ pub(super) fn extract_kwargs(call_node: Node, code: &[u8]) -> Vec<(String, Vec<S
collect_idents_with_paths(vn, code, &mut idents, &mut paths);
let mut combined = paths;
combined.extend(idents);
// Boolean / numeric literal kwarg values (Python `True`/`False`,
// Ruby `true`/`false`/integer/float, JS `true`/`false`/number)
// do not surface through `collect_idents_with_paths` — the value
// node's kind is `true`/`false`/`integer`/`float`/`number`, not
// an identifier kind. Capture the raw text so consumers like
// `xml_config::classify_call` (which checks
// `values.iter().any(|v| v == "True" || v == "true")` for the
// lxml `resolve_entities=True` opt-in) can match.
if combined.is_empty() {
if matches!(
vn.kind(),
"true"
| "false"
| "integer"
| "float"
| "number"
| "string"
| "string_literal"
| "true_constant"
| "false_constant"
) {
if let Some(txt) = text_of(vn, code) {
combined.push(txt.trim_matches(['"', '\'']).to_string());
}
}
}
out.push((name, combined));
}
out
@ -1718,6 +1962,29 @@ pub(super) fn extract_arg_string_literals(call_node: Node, code: &[u8]) -> Vec<O
let raw = text_of(target, code);
raw.and_then(|s| strip_literal_quotes(&s, target, code))
}
// Boolean / null / numeric literal tokens — capture verbatim so
// downstream pattern-aware analysis (e.g. the XXE config-fact
// pass that needs to read the boolean polarity arg of
// `setFeature(NAME, true)`) can recover the literal text without
// re-walking the AST. Existing string-only consumers (URL
// prefix matching, etc.) are unaffected: a "true" / "false"
// token never satisfies their matching predicates.
"true"
| "false"
| "null"
| "null_literal"
| "nil"
| "nil_literal"
| "none"
| "boolean_literal"
| "true_literal"
| "false_literal"
| "decimal_integer_literal"
| "integer_literal"
| "integer"
| "number"
| "number_literal"
| "decimal_literal" => text_of(target, code).map(|s| s.to_string()),
_ => None,
};
result.push(literal);

View file

@ -70,8 +70,8 @@ use literals::{
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, is_parameterized_query_call,
java_chain_arg0_kind_for_method, js_chain_arg0_kind_for_method,
has_object_arg_property, has_only_literal_args, 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::{
@ -359,6 +359,14 @@ pub struct CallMeta {
/// 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
@ -601,8 +609,7 @@ pub struct BodyMeta {
/// 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 pre-Phase-1
/// engine.
/// `None`, downstream behaviour is identical to the type-unaware path.
pub param_types: Vec<Option<crate::ssa::type_facts::TypeKind>>,
/// Per-parameter destructured-binding sibling names. Same length
/// as `params`; entry `i` lists field names bound by the same
@ -1811,6 +1818,31 @@ pub(super) fn push_node<'a>(
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);
}
}
}
}
@ -1933,18 +1965,45 @@ pub(super) fn push_node<'a>(
{
let gate_call = call_ast.or_else(|| find_call_node_deep(ast, lang, 4));
if let Some(cn) = gate_call {
let gate_callee_text = if call_ast.is_some() {
// Derive the gate's callee text from the call's
// `function`/`method`/`name` field, falling back to `text`.
//
// The default is `text`, which by this point reflects the
// qualified callee for method calls (`Velocity.evaluate`,
// `$smarty->fetch`) reconstructed in the `Kind::CallMethod`
// arm. When `first_member_label` rewrites `text` to a member
// Source like `req.body` (because the wrapper carries one as
// an argument), the rewrite is correct for source attribution
// but defeats gate matching against a bare callee
// (`setValue(target, req.body, …)` would gate-match
// `req.body` instead of `setValue`).
//
// Detect that case structurally: a Source label is present AND
// the call's function-field text differs from `text`. The
// function field carries the actual callee identifier; when it
// disagrees with `text`, `text` was clobbered by a member-source
// override and the function field is the right gate target.
// Whitespace is stripped to mirror `find_chained_inner_call`
// so multi-line chains (`http\n .get(...)`) still match flat
// gate matchers like `http.get`.
let function_field_text: Option<String> = cn
.child_by_field_name("function")
.or_else(|| cn.child_by_field_name("method"))
.or_else(|| cn.child_by_field_name("name"))
.and_then(|f| text_of(f, code))
.map(|t| t.chars().filter(|c| !c.is_whitespace()).collect::<String>());
let has_source_label = labels
.iter()
.any(|l| matches!(l, crate::labels::DataLabel::Source(_)));
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 {
// Inner call reached via wrapper, use the call-expression's
// function name directly. Falls back to `text` so non-call-
// expression kinds (method calls, Ruby `call` nodes, macros)
// still have a usable callee string.
cn.child_by_field_name("function")
.or_else(|| cn.child_by_field_name("method"))
.or_else(|| cn.child_by_field_name("name"))
.and_then(|f| text_of(f, code))
.unwrap_or_else(|| text.clone())
function_field_text.unwrap_or_else(|| text.clone())
};
let matches = classify_gated_sink(
lang,
@ -1953,12 +2012,15 @@ pub(super) fn push_node<'a>(
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.
// Falling back to the macro-arg extractor for those
// languages lets gates like `curl_easy_setopt` /
// `curl_setopt` activate on a `CURLOPT_POSTFIELDS`
// ident match instead of firing conservatively on
// every positional arg.
if matches!(lang, "c" | "cpp" | "c++" | "php") {
// 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
@ -2656,6 +2718,13 @@ pub(super) fn push_node<'a>(
|| 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 {
@ -2672,6 +2741,7 @@ pub(super) fn push_node<'a>(
destination_uses,
gate_filters,
is_constructor,
produces_null_proto,
},
taint: TaintMeta {
labels,
@ -2860,6 +2930,31 @@ fn try_lower_subscript_write(
*call_ordinal += 1;
let mut uses_all: Vec<String> = vec![arr_text.clone(), idx_text.clone()];
uses_all.extend(rhs_uses.iter().cloned());
// Prototype pollution sink classification on the synthetic
// `__index_set__` node for JS/TS. Tainted *key* in `obj[key] = val`
// is the pollution channel (a `__proto__` / `constructor` literal flowing
// through `key` mutates `Object.prototype` globally), so the gate's
// payload arg list is `[0]` (the key only — the value at index 1 is
// benign on its own). Sanitizer recognition is structural (no taint
// engine plumbing) and runs before label attachment, so suppressed
// shapes never enter the SSA sink scan:
// * constant string key whose literal value is not in the dangerous
// set (`__proto__` / `constructor` / `prototype`),
// * receiver was assigned `Object.create(null)` in this function
// (no prototype chain to pollute),
// * the assignment is dominated by an `if` whose condition rejects
// dangerous keys with an early `return` / `throw` / `break`, or
// that allowlists the key against safe constants on its true arm.
let mut pp_labels: smallvec::SmallVec<[DataLabel; 2]> = smallvec::SmallVec::new();
let mut pp_payload_args: Option<Vec<usize>> = None;
if matches!(lang, "javascript" | "typescript" | "js" | "ts")
&& !pp_should_suppress_index_set(assign_ast, subscript_node, &arr_text, &idx_text, code)
{
pp_labels.push(DataLabel::Sink(Cap::PROTOTYPE_POLLUTION));
pp_payload_args = Some(vec![0]);
}
let n = g.add_node(NodeInfo {
kind: StmtKind::Call,
call: CallMeta {
@ -2867,9 +2962,11 @@ fn try_lower_subscript_write(
receiver: Some(arr_text.clone()),
arg_uses: vec![vec![idx_text.clone()], rhs_uses.clone()],
call_ordinal: ord,
sink_payload_args: pp_payload_args,
..Default::default()
},
taint: TaintMeta {
labels: pp_labels,
uses: uses_all,
..Default::default()
},
@ -2883,6 +2980,477 @@ fn try_lower_subscript_write(
Some(n)
}
/// Spring MVC controller-return open-redirect recogniser. Detects the
/// shape `return "redirect:" + tainted` (Java string concatenation) and
/// emits a synthetic `__spring_redirect__` Call sink with
/// `Sink(OPEN_REDIRECT)` so the existing taint pipeline propagates the
/// concatenated suffix through the OPEN_REDIRECT cap. The synthetic
/// node sequences between `preds` and the eventual Return node.
///
/// Returns `Some(synthetic_idx)` when matched, otherwise `None`.
/// Java only — Spring's `redirect:` view-name convention has no
/// counterpart in the other supported languages, and matching the
/// literal across non-Spring code would over-fire.
fn try_lower_spring_redirect_return(
ast: Node,
preds: &[NodeIndex],
g: &mut Cfg,
lang: &str,
code: &[u8],
enclosing_func: Option<&str>,
call_ordinal: &mut u32,
) -> Option<NodeIndex> {
if lang != "java" {
return None;
}
// `return EXPR ;` — find the returned expression. tree-sitter-java
// wraps the value in a `return_statement` whose first named child
// is the expression.
let expr = ast.named_child(0)?;
// Strip parentheses.
let mut cur = expr;
while cur.kind() == "parenthesized_expression" {
cur = cur.named_child(0)?;
}
if cur.kind() != "binary_expression" {
return None;
}
let op = cur.child_by_field_name("operator")?;
let op_text = text_of(op, code)?;
if op_text != "+" {
return None;
}
// Walk leftmost descent through left-associated `+` chains so that
// `"redirect:" + a + b` still matches (the AST nests as
// `(("redirect:" + a) + b)`).
let mut leftmost = cur;
loop {
let left = leftmost.child_by_field_name("left")?;
let mut left_inner = left;
while left_inner.kind() == "parenthesized_expression" {
left_inner = left_inner.named_child(0)?;
}
if left_inner.kind() == "binary_expression" {
let op_l = left_inner.child_by_field_name("operator")?;
if text_of(op_l, code).as_deref() == Some("+") {
leftmost = left_inner;
continue;
}
}
// `left_inner` is the leftmost atom — must be a string literal
// whose constant value starts with `redirect:`.
if !matches!(left_inner.kind(), "string_literal" | "string") {
return None;
}
let lit = text_of(left_inner, code)?;
if lit.len() < 2 {
return None;
}
let inner = &lit[1..lit.len() - 1];
if !inner.starts_with("redirect:") {
return None;
}
break;
}
// Collect identifiers referenced anywhere in the original concat
// expression — the tainted URL piece is one of them. Receiver-style
// method calls (`view.toString()`) are intentionally captured via
// the bare identifier; precision improvements are deferred to the
// SSA / abstract-string layer.
let mut concat_uses: Vec<String> = Vec::new();
collect_idents(cur, code, &mut concat_uses);
if concat_uses.is_empty() {
return None;
}
let span = (ast.start_byte(), ast.end_byte());
let ord = *call_ordinal;
*call_ordinal += 1;
let mut labels: smallvec::SmallVec<[DataLabel; 2]> = smallvec::SmallVec::new();
labels.push(DataLabel::Sink(Cap::OPEN_REDIRECT));
let n = g.add_node(NodeInfo {
kind: StmtKind::Call,
call: CallMeta {
callee: Some("__spring_redirect__".to_string()),
arg_uses: vec![concat_uses.clone()],
call_ordinal: ord,
sink_payload_args: Some(vec![0]),
..Default::default()
},
taint: TaintMeta {
labels,
uses: concat_uses,
..Default::default()
},
ast: AstMeta {
span,
enclosing_func: enclosing_func.map(|s| s.to_string()),
},
..Default::default()
});
connect_all(g, preds, n, EdgeKind::Seq);
Some(n)
}
/// Prototype-pollution suppression decisions for the synthetic
/// `__index_set__` node emitted by `try_lower_subscript_write`.
///
/// Returns `true` when the assignment is provably safe and the
/// `Cap::PROTOTYPE_POLLUTION` sink label should be elided. The three
/// CFG-layer recognised shapes are flow-insensitive AST patterns:
///
/// 1. Constant string key whose value is not one of the dangerous
/// keys (`__proto__`, `constructor`, `prototype`). A literal-keyed
/// write cannot pollute even if the value is tainted.
/// 2. Reject pattern `if (idx === "__proto__" || idx === "constructor"
/// || idx === "prototype") <return/throw/break>` enclosing the
/// assignment. The dangerous-key path terminates before reaching
/// the synthesised store.
/// 3. Allowlist pattern `if (idx === "name" || idx === "id") { obj[idx]
/// = v }`. The assignment only executes when `idx` is one of a
/// small set of known-safe constants.
///
/// The null-prototype receiver suppression (`Object.create(null)`) is
/// handled flow-sensitively in the SSA taint engine via
/// `TypeKind::NullPrototypeObject`, since AST scans cannot honour
/// branch-local re-bindings or phi joins.
///
/// Conservative: any unrecognised shape returns `false` so the sink
/// label is attached and the SSA layer decides on taint reachability.
fn pp_should_suppress_index_set(
assign_ast: Node,
subscript_node: Node,
_arr_text: &str,
idx_text: &str,
code: &[u8],
) -> bool {
// 1. Constant-key fold.
if let Some(idx_node) = subscript_node
.child_by_field_name("index")
.or_else(|| subscript_node.child_by_field_name("subscript"))
.or_else(|| {
let mut cur = subscript_node.walk();
subscript_node.named_children(&mut cur).nth(1)
})
{
if let Some(literal) = pp_string_literal_value(idx_node, code) {
return !pp_is_dangerous_proto_key(&literal);
}
}
// 2 + 3. Dominator-style guard ancestors (reject + allowlist).
if pp_is_guarded_by_proto_check(assign_ast, idx_text, code) {
return true;
}
false
}
/// Dangerous prototype-pollution key strings. Matches the literal
/// values that JS engines treat as references into the prototype chain.
fn pp_is_dangerous_proto_key(s: &str) -> bool {
matches!(s, "__proto__" | "constructor" | "prototype")
}
/// Extract the value of a JS/TS string literal node, stripping the
/// outer quote bytes (single, double, or backtick). Returns `None`
/// for non-literal nodes, template literals containing interpolation,
/// or anything that doesn't resemble a single-segment string.
fn pp_string_literal_value(n: Node, code: &[u8]) -> Option<String> {
let kind = n.kind();
if !matches!(kind, "string" | "string_literal" | "template_string") {
return None;
}
let raw = std::str::from_utf8(&code[n.start_byte()..n.end_byte()]).ok()?;
if raw.len() < 2 {
return None;
}
let bytes = raw.as_bytes();
let first = bytes[0];
let last = bytes[bytes.len() - 1];
if !matches!(first, b'"' | b'\'' | b'`') || first != last {
return None;
}
let inner = &raw[1..raw.len() - 1];
// Reject template literals carrying `${...}` interpolation — we
// can't fold those to a single concrete value.
if first == b'`' && inner.contains("${") {
return None;
}
Some(inner.to_string())
}
/// Walk up from the assignment node looking for two structural guard
/// shapes:
///
/// * **Reject pattern** — a *previous sibling* `if_statement` in any
/// enclosing block whose condition is `idx === DANGEROUS [|| …]` and
/// whose consequence terminates control flow (`return` / `throw` /
/// `break` / `continue`). The dangerous-key path never reaches the
/// subsequent assignment.
/// * **Allowlist pattern** — an *ancestor* `if_statement` whose
/// condition is `idx === SAFE [|| …]` and through whose consequence
/// the descendant flows. Only the safe-key arm reaches the
/// assignment.
///
/// Both shapes must compare against the same key variable as the
/// synthetic `__index_set__` node. Stops at the enclosing function so
/// guards in an outer scope around a closure passed elsewhere don't
/// accidentally suppress inner assignments.
fn pp_is_guarded_by_proto_check(from: Node, idx_text: &str, code: &[u8]) -> bool {
let mut cur = from;
while let Some(parent) = cur.parent() {
match parent.kind() {
"function_declaration"
| "function"
| "function_expression"
| "arrow_function"
| "method_definition"
| "generator_function_declaration"
| "program"
| "source_file" => return false,
"if_statement" => {
if let Some(cond) = parent.child_by_field_name("condition") {
let consequence = parent.child_by_field_name("consequence");
if let Some(verdict) =
pp_classify_proto_guard(cond, consequence, cur, idx_text, code)
{
return verdict;
}
}
}
_ => {}
}
// Reject pattern: scan previous siblings in the parent block
// for `if (idx === DANGEROUS [|| …]) { return; }` shapes that
// dominate the assignment via early-return.
let mut sibling_cursor = parent.walk();
for sibling in parent.named_children(&mut sibling_cursor) {
if sibling.start_byte() >= cur.start_byte() {
break;
}
if sibling.kind() != "if_statement" {
continue;
}
if pp_is_reject_pattern(sibling, idx_text, code) {
return true;
}
}
cur = parent;
}
false
}
/// True when `if_node` is `if (idx === DANGEROUS [|| idx === DANGEROUS]
/// …) { return; / throw …; / break; }` shaped — every disjunct
/// compares the named key variable to a dangerous prototype key, and
/// the consequence terminates control flow.
fn pp_is_reject_pattern(if_node: Node, idx_text: &str, code: &[u8]) -> bool {
let Some(cond) = if_node.child_by_field_name("condition") else {
return false;
};
let consequence = if_node.child_by_field_name("consequence");
let clauses = pp_split_or_clauses(cond);
if clauses.is_empty() {
return false;
}
for clause in &clauses {
let Some((var, lit)) = pp_extract_eq_compare(*clause, code) else {
return false;
};
if var != idx_text || !pp_is_dangerous_proto_key(&lit) {
return false;
}
}
consequence.map(pp_block_terminates).unwrap_or(false)
}
/// Decide whether an enclosing `if` clause around an `__index_set__`
/// statement constitutes a prototype-pollution guard.
///
/// `cond` is the if's condition expression, `consequence` is the
/// optional consequence block, and `descendant` is the node on the
/// path from the if-statement down to the assignment (used to
/// distinguish "assignment lives inside the consequence" from
/// "assignment lives after the if"). `idx_text` is the textual key
/// variable used by the synthetic `__index_set__`.
///
/// Returns `Some(true)` to suppress, `Some(false)` to keep the gate
/// (e.g. an unrelated guard), and `None` when the if-statement is
/// not a recognised guard so the walker continues outward.
fn pp_classify_proto_guard(
cond: Node,
consequence: Option<Node>,
descendant: Node,
idx_text: &str,
code: &[u8],
) -> Option<bool> {
let cond_clauses = pp_split_or_clauses(cond);
if cond_clauses.is_empty() {
return None;
}
let mut all_against_idx = true;
let mut all_dangerous = true;
let mut all_safe = true;
for clause in &cond_clauses {
let (var, lit) = pp_extract_eq_compare(*clause, code)?;
if var != idx_text {
all_against_idx = false;
break;
}
let dangerous = pp_is_dangerous_proto_key(&lit);
if dangerous {
all_safe = false;
} else {
all_dangerous = false;
}
}
if !all_against_idx {
return None;
}
let consequence_contains_descendant = consequence
.map(|c| pp_subtree_contains(c, descendant))
.unwrap_or(false);
// Allowlist pattern: every clause is `idx === SAFE` and the
// assignment lives inside the consequence (true arm).
if all_safe && consequence_contains_descendant {
return Some(true);
}
// Reject pattern: every clause is `idx === DANGEROUS` and the
// consequence terminates control flow before reaching the
// assignment. Only suppress when the assignment is *outside* the
// consequence (i.e., follows the if).
if all_dangerous
&& !consequence_contains_descendant
&& consequence.map(pp_block_terminates).unwrap_or(false)
{
return Some(true);
}
None
}
/// True when `descendant` is identical to or transitively a child of
/// `root`. Identity is checked via byte-range equality because
/// tree-sitter `Node` doesn't implement `Eq` directly.
fn pp_subtree_contains(root: Node, descendant: Node) -> bool {
let dr = (descendant.start_byte(), descendant.end_byte());
let rr = (root.start_byte(), root.end_byte());
dr.0 >= rr.0 && dr.1 <= rr.1
}
/// True when `block` (typically an `if` consequence) terminates
/// control flow on every path: the last meaningful statement is a
/// return / throw / break / continue. Conservative — falls back to
/// `false` for empty blocks or anything non-trivial.
fn pp_block_terminates(block: Node) -> bool {
// Bare statement consequence (no braces): the if's consequence is
// the terminator itself.
if pp_is_terminator(block) {
return true;
}
if !matches!(block.kind(), "statement_block" | "block") {
return false;
}
let mut cursor = block.walk();
let last_stmt = block.named_children(&mut cursor).last();
match last_stmt {
Some(s) => pp_is_terminator(s),
None => false,
}
}
/// True when `n` is a control-flow-ending statement: return / throw /
/// break / continue.
fn pp_is_terminator(n: Node) -> bool {
matches!(
n.kind(),
"return_statement" | "throw_statement" | "break_statement" | "continue_statement"
)
}
/// Split an expression by top-level `||` operators. Returns the
/// individual disjunct sub-expressions. Single (non-OR) expressions
/// yield a one-element vector. Walks `binary_expression` nodes whose
/// `operator` field is `||` and recurses into both sides.
fn pp_split_or_clauses<'a>(expr: Node<'a>) -> Vec<Node<'a>> {
let mut out = Vec::new();
pp_collect_or_clauses(expr, &mut out);
out
}
fn pp_collect_or_clauses<'a>(expr: Node<'a>, out: &mut Vec<Node<'a>>) {
let stripped = pp_unwrap_paren(expr);
if matches!(stripped.kind(), "binary_expression") {
let op = stripped
.child_by_field_name("operator")
.map(|o| o.kind())
.unwrap_or("");
if op == "||" {
if let Some(l) = stripped.child_by_field_name("left") {
pp_collect_or_clauses(l, out);
}
if let Some(r) = stripped.child_by_field_name("right") {
pp_collect_or_clauses(r, out);
}
return;
}
}
out.push(stripped);
}
fn pp_unwrap_paren(n: Node) -> Node {
let mut cur = n;
while matches!(cur.kind(), "parenthesized_expression") {
match cur.named_child(0) {
Some(inner) => cur = inner,
None => break,
}
}
cur
}
/// Extract `(var_text, literal_value)` from an equality comparison
/// `var === "literal"` / `var == "literal"` (and reversed forms).
/// Returns `None` for any other shape.
fn pp_extract_eq_compare(expr: Node, code: &[u8]) -> Option<(String, String)> {
let stripped = pp_unwrap_paren(expr);
if !matches!(stripped.kind(), "binary_expression") {
return None;
}
let op = stripped
.child_by_field_name("operator")
.map(|o| o.kind())
.unwrap_or("");
if !matches!(op, "===" | "==") {
return None;
}
let left = stripped.child_by_field_name("left")?;
let right = stripped.child_by_field_name("right")?;
let left = pp_unwrap_paren(left);
let right = pp_unwrap_paren(right);
if let (Some(lv), Some(rs)) = (text_of(left, code), pp_string_literal_value(right, code)) {
if matches!(left.kind(), "identifier" | "shorthand_property_identifier") {
return Some((lv, rs));
}
}
if let (Some(rv), Some(ls)) = (text_of(right, code), pp_string_literal_value(left, code)) {
if matches!(right.kind(), "identifier" | "shorthand_property_identifier") {
return Some((rv, ls));
}
}
None
}
/// Step 1 (`pre_emit_arg_source_nodes`): scan the AST, create Source nodes,
/// wire them to `preds`, and return (effective_preds, synth_bindings,
/// uses_only_synth_names).
@ -3682,6 +4250,21 @@ pub(super) fn build_sub<'a>(
Vec::new()
} else {
// Spring MVC `return "redirect:" + url` open-redirect
// synthetic-sink emission. When matched the synthetic
// call sequences between `preds` and the Return node.
let mut effective_preds: Vec<NodeIndex> = preds.to_vec();
if let Some(synth) = try_lower_spring_redirect_return(
ast,
&effective_preds,
g,
lang,
code,
enclosing_func,
call_ordinal,
) {
effective_preds = vec![synth];
}
let ret = push_node(
g,
StmtKind::Return,
@ -3692,7 +4275,7 @@ pub(super) fn build_sub<'a>(
0,
analysis_rules,
);
connect_all(g, preds, ret, EdgeKind::Seq);
connect_all(g, &effective_preds, ret, EdgeKind::Seq);
Vec::new() // terminates this path
}
}

View file

@ -13,7 +13,7 @@ use tree_sitter::Node;
/// of `build_cfg`. Returns the [`TypeKind::Dto`] carrying the
/// per-field type map when the class is declared in the same file;
/// returns `None` otherwise so callers can fall through to the
/// pre-Phase-6 behaviour (Object / Unknown).
/// generic Object / Unknown classification.
fn lookup_dto_class(class_name: &str) -> Option<TypeKind> {
DTO_CLASSES.with(|cell| cell.borrow().get(class_name).cloned().map(TypeKind::Dto))
}
@ -27,7 +27,7 @@ fn lookup_dto_class(class_name: &str) -> Option<TypeKind> {
/// for the JS/TS object-pattern formal `({ a, b, c })`, the entry is
/// `("a", None, ["b", "c"])`. Strictly additive: when the param is
/// not a destructured pattern (or the language has no destructure
/// concept), behaviour is identical to the pre-Phase-5 names-only path.
/// concept), behaviour is identical to the names-only path.
///
/// Closes the residual gap behind CVE-2026-25544 (PayloadCMS Drizzle
/// SQL injection): a per-parameter taint probe that seeds only the