mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
[pitboss/grind] deferred session-0006 (20260520T233019Z-6958)
This commit is contained in:
parent
c885a8d424
commit
9a0529e8f8
16 changed files with 1455 additions and 280 deletions
|
|
@ -4,19 +4,58 @@
|
|||
//! Phase 04 (Track J.2). Fires when the function body invokes
|
||||
//! `TemplateEngine::process(<tainted>)` (matched by the last segment
|
||||
//! of the callee — the call graph normaliser drops the receiver).
|
||||
//!
|
||||
//! Strengthened to walk the AST for a real `method_invocation` whose
|
||||
//! first positional argument names a parameter listed in
|
||||
//! `summary.tainted_sink_params` or `summary.propagating_params`,
|
||||
//! removing the comment-substring FP.
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
use tree_sitter::Node;
|
||||
|
||||
pub struct JavaThymeleafAdapter;
|
||||
|
||||
const ADAPTER_NAME: &str = "java-thymeleaf";
|
||||
|
||||
fn callee_is_thymeleaf(name: &str) -> bool {
|
||||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||
matches!(last, "process" | "processSpring")
|
||||
fn is_thymeleaf_entry(name: &str) -> bool {
|
||||
matches!(name, "process" | "processSpring")
|
||||
}
|
||||
|
||||
fn ast_confirms_tainted_call(root: Node<'_>, bytes: &[u8], summary: &FuncSummary) -> bool {
|
||||
let mut found = false;
|
||||
walk(root, bytes, summary, &mut found);
|
||||
found
|
||||
}
|
||||
|
||||
fn walk(node: Node<'_>, bytes: &[u8], summary: &FuncSummary, found: &mut bool) {
|
||||
if *found {
|
||||
return;
|
||||
}
|
||||
if node.kind() == "method_invocation"
|
||||
&& let Some(name) = node
|
||||
.child_by_field_name("name")
|
||||
.and_then(|n| n.utf8_text(bytes).ok())
|
||||
&& is_thymeleaf_entry(name)
|
||||
&& let Some(args) = node.child_by_field_name("arguments")
|
||||
&& let Some(first) = first_positional_arg(args)
|
||||
&& let Ok(text) = first.utf8_text(bytes)
|
||||
&& super::arg_is_tainted_param(summary, text)
|
||||
{
|
||||
*found = true;
|
||||
return;
|
||||
}
|
||||
let mut cur = node.walk();
|
||||
for child in node.children(&mut cur) {
|
||||
walk(child, bytes, summary, found);
|
||||
}
|
||||
}
|
||||
|
||||
fn first_positional_arg<'a>(args: Node<'a>) -> Option<Node<'a>> {
|
||||
let mut cur = args.walk();
|
||||
args.named_children(&mut cur).next()
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for JavaThymeleafAdapter {
|
||||
|
|
@ -31,41 +70,29 @@ impl FrameworkAdapter for JavaThymeleafAdapter {
|
|||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
_ast: tree_sitter::Node<'_>,
|
||||
ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_thymeleaf);
|
||||
let matches_source = file_bytes
|
||||
let cheap_filter = file_bytes
|
||||
.windows(b"org.thymeleaf".len())
|
||||
.any(|w| w == b"org.thymeleaf")
|
||||
|| file_bytes
|
||||
.windows(b"TemplateEngine".len())
|
||||
.any(|w| w == b"TemplateEngine");
|
||||
if matches_call && matches_source {
|
||||
return Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
});
|
||||
if !cheap_filter {
|
||||
return None;
|
||||
}
|
||||
if matches_source
|
||||
&& file_bytes
|
||||
.windows(b".process(".len())
|
||||
.any(|w| w == b".process(")
|
||||
{
|
||||
return Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
});
|
||||
if !ast_confirms_tainted_call(ast, file_bytes, summary) {
|
||||
return None;
|
||||
}
|
||||
None
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -80,15 +107,22 @@ mod tests {
|
|||
parser.parse(src, None).unwrap()
|
||||
}
|
||||
|
||||
fn summary_for(name: &str, params: &[&str], tainted: &[usize]) -> FuncSummary {
|
||||
FuncSummary {
|
||||
name: name.into(),
|
||||
param_count: params.len(),
|
||||
param_names: params.iter().map(|s| (*s).to_owned()).collect(),
|
||||
tainted_sink_params: tainted.to_vec(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("process")],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_template_engine_process() {
|
||||
let src: &[u8] = b"import org.thymeleaf.TemplateEngine;\npublic class V { public static String run(String body) { TemplateEngine e = new TemplateEngine(); return e.process(body, null); } }\n";
|
||||
let tree = parse_java(src);
|
||||
let summary = FuncSummary {
|
||||
name: "run".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("process")],
|
||||
..Default::default()
|
||||
};
|
||||
let summary = summary_for("run", &["body"], &[0]);
|
||||
assert!(JavaThymeleafAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_some());
|
||||
|
|
@ -107,4 +141,26 @@ mod tests {
|
|||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_comment_substring_with_constant_arg() {
|
||||
// The comment mentions `org.thymeleaf`; the call passes a
|
||||
// literal — no tainted parameter reaches the engine.
|
||||
let src: &[u8] = b"// org.thymeleaf.TemplateEngine is great\npublic class V { public static String run(String body) { TemplateEngine e = new TemplateEngine(); return e.process(\"static\", null); } }\n";
|
||||
let tree = parse_java(src);
|
||||
let summary = summary_for("run", &["body"], &[0]);
|
||||
assert!(JavaThymeleafAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_when_param_not_in_tainted_set() {
|
||||
let src: &[u8] = b"import org.thymeleaf.TemplateEngine;\npublic class V { public static String run(String body) { TemplateEngine e = new TemplateEngine(); return e.process(body, null); } }\n";
|
||||
let tree = parse_java(src);
|
||||
let summary = summary_for("run", &["body"], &[]);
|
||||
assert!(JavaThymeleafAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,19 +4,71 @@
|
|||
//! Phase 04 (Track J.2). Fires when the function body invokes
|
||||
//! `Handlebars.compile(<tainted>)` (matched by the last segment of the
|
||||
//! callee — the call graph normaliser drops the receiver).
|
||||
//!
|
||||
//! Strengthened to walk the AST for a real `call_expression` whose
|
||||
//! first positional argument names a parameter listed in
|
||||
//! `summary.tainted_sink_params` or `summary.propagating_params`,
|
||||
//! removing the comment-substring FP.
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
use tree_sitter::Node;
|
||||
|
||||
pub struct JsHandlebarsAdapter;
|
||||
|
||||
const ADAPTER_NAME: &str = "js-handlebars";
|
||||
|
||||
fn callee_is_handlebars(name: &str) -> bool {
|
||||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||
matches!(last, "compile" | "precompile" | "SafeString")
|
||||
fn callee_last_segment(name: &str) -> &str {
|
||||
name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name)
|
||||
}
|
||||
|
||||
fn is_handlebars_entry(name: &str) -> bool {
|
||||
matches!(
|
||||
callee_last_segment(name),
|
||||
"compile" | "precompile" | "SafeString"
|
||||
)
|
||||
}
|
||||
|
||||
fn ast_confirms_tainted_call(root: Node<'_>, bytes: &[u8], summary: &FuncSummary) -> bool {
|
||||
let mut found = false;
|
||||
walk(root, bytes, summary, &mut found);
|
||||
found
|
||||
}
|
||||
|
||||
fn walk(node: Node<'_>, bytes: &[u8], summary: &FuncSummary, found: &mut bool) {
|
||||
if *found {
|
||||
return;
|
||||
}
|
||||
if node.kind() == "call_expression"
|
||||
&& let Some(func) = node
|
||||
.child_by_field_name("function")
|
||||
.and_then(|n| n.utf8_text(bytes).ok())
|
||||
&& is_handlebars_entry(func)
|
||||
&& let Some(args) = node.child_by_field_name("arguments")
|
||||
&& let Some(first) = first_positional_arg(args)
|
||||
&& let Ok(text) = first.utf8_text(bytes)
|
||||
&& super::arg_is_tainted_param(summary, text)
|
||||
{
|
||||
*found = true;
|
||||
return;
|
||||
}
|
||||
let mut cur = node.walk();
|
||||
for child in node.children(&mut cur) {
|
||||
walk(child, bytes, summary, found);
|
||||
}
|
||||
}
|
||||
|
||||
fn first_positional_arg<'a>(args: Node<'a>) -> Option<Node<'a>> {
|
||||
let mut cur = args.walk();
|
||||
for arg in args.named_children(&mut cur) {
|
||||
if arg.kind() == "spread_element" {
|
||||
continue;
|
||||
}
|
||||
return Some(arg);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for JsHandlebarsAdapter {
|
||||
|
|
@ -31,27 +83,32 @@ impl FrameworkAdapter for JsHandlebarsAdapter {
|
|||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
_ast: tree_sitter::Node<'_>,
|
||||
ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_handlebars);
|
||||
let matches_source = file_bytes
|
||||
let cheap_filter = file_bytes
|
||||
.windows(b"handlebars".len())
|
||||
.any(|w| w.eq_ignore_ascii_case(b"handlebars"))
|
||||
|| file_bytes
|
||||
.windows(b"Handlebars".len())
|
||||
.any(|w| w == b"Handlebars");
|
||||
if matches_call && matches_source {
|
||||
return Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
});
|
||||
if !cheap_filter {
|
||||
return None;
|
||||
}
|
||||
None
|
||||
if !super::any_callee_matches(summary, is_handlebars_entry) {
|
||||
return None;
|
||||
}
|
||||
if !ast_confirms_tainted_call(ast, file_bytes, summary) {
|
||||
return None;
|
||||
}
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -66,15 +123,22 @@ mod tests {
|
|||
parser.parse(src, None).unwrap()
|
||||
}
|
||||
|
||||
fn summary_for(name: &str, params: &[&str], tainted: &[usize]) -> FuncSummary {
|
||||
FuncSummary {
|
||||
name: name.into(),
|
||||
param_count: params.len(),
|
||||
param_names: params.iter().map(|s| (*s).to_owned()).collect(),
|
||||
tainted_sink_params: tainted.to_vec(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("compile")],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_handlebars_compile() {
|
||||
let src: &[u8] = b"const Handlebars = require('handlebars');\nfunction render(body) {\n return Handlebars.compile(body)({});\n}\n";
|
||||
let tree = parse_js(src);
|
||||
let summary = FuncSummary {
|
||||
name: "render".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("compile")],
|
||||
..Default::default()
|
||||
};
|
||||
let summary = summary_for("render", &["body"], &[0]);
|
||||
assert!(JsHandlebarsAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_some());
|
||||
|
|
@ -92,4 +156,24 @@ mod tests {
|
|||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_comment_substring_with_constant_arg() {
|
||||
let src: &[u8] = b"// uses Handlebars\nfunction render(body) {\n return Handlebars.compile(\"static\")({});\n}\n";
|
||||
let tree = parse_js(src);
|
||||
let summary = summary_for("render", &["body"], &[0]);
|
||||
assert!(JsHandlebarsAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_when_param_not_in_tainted_set() {
|
||||
let src: &[u8] = b"const Handlebars = require('handlebars');\nfunction render(body) {\n return Handlebars.compile(body)({});\n}\n";
|
||||
let tree = parse_js(src);
|
||||
let summary = summary_for("render", &["body"], &[]);
|
||||
assert!(JsHandlebarsAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,24 +5,38 @@
|
|||
//! the canonical PHP directory-client entry points (`ldap_search`,
|
||||
//! `ldap_list`, `ldap_read`) and the surrounding source mentions the
|
||||
//! matching `ldap_*` API surface.
|
||||
//!
|
||||
//! Strengthened to walk the AST and reject the binding when any of
|
||||
//! the search call's argument subtrees flows through PHP's
|
||||
//! `ldap_escape` filter encoder.
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
use tree_sitter::Node;
|
||||
|
||||
pub struct LdapPhpAdapter;
|
||||
|
||||
const ADAPTER_NAME: &str = "ldap-php";
|
||||
|
||||
fn callee_is_ldap_search(name: &str) -> bool {
|
||||
let last = name
|
||||
.rsplit_once("::")
|
||||
fn callee_last_segment(name: &str) -> &str {
|
||||
name.rsplit_once("::")
|
||||
.map(|(_, s)| s)
|
||||
.or_else(|| name.rsplit_once('.').map(|(_, s)| s))
|
||||
.or_else(|| name.rsplit_once("->").map(|(_, s)| s))
|
||||
.unwrap_or(name);
|
||||
matches!(last, "ldap_search" | "ldap_list" | "ldap_read")
|
||||
.unwrap_or(name)
|
||||
}
|
||||
|
||||
fn callee_is_ldap_search(name: &str) -> bool {
|
||||
matches!(
|
||||
callee_last_segment(name),
|
||||
"ldap_search" | "ldap_list" | "ldap_read"
|
||||
)
|
||||
}
|
||||
|
||||
fn callee_is_ldap_sanitiser(name: &str) -> bool {
|
||||
matches!(callee_last_segment(name), "ldap_escape")
|
||||
}
|
||||
|
||||
fn source_imports_ldap(file_bytes: &[u8]) -> bool {
|
||||
|
|
@ -39,6 +53,68 @@ fn source_imports_ldap(file_bytes: &[u8]) -> bool {
|
|||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
}
|
||||
|
||||
fn ast_confirms_unsanitised_search(root: Node<'_>, bytes: &[u8]) -> bool {
|
||||
let mut found_unsanitised = false;
|
||||
let mut saw_any_search = false;
|
||||
walk(root, bytes, &mut found_unsanitised, &mut saw_any_search);
|
||||
found_unsanitised || !saw_any_search
|
||||
}
|
||||
|
||||
fn walk(node: Node<'_>, bytes: &[u8], unsanitised: &mut bool, saw_any: &mut bool) {
|
||||
if *unsanitised {
|
||||
return;
|
||||
}
|
||||
if matches!(
|
||||
node.kind(),
|
||||
"function_call_expression" | "member_call_expression" | "scoped_call_expression"
|
||||
) && let Some(name) = node
|
||||
.child_by_field_name("function")
|
||||
.or_else(|| node.child_by_field_name("name"))
|
||||
.and_then(|n| n.utf8_text(bytes).ok())
|
||||
&& callee_is_ldap_search(name)
|
||||
{
|
||||
*saw_any = true;
|
||||
if let Some(args) = node.child_by_field_name("arguments")
|
||||
&& !args_contain_sanitiser(args, bytes)
|
||||
{
|
||||
*unsanitised = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
let mut cur = node.walk();
|
||||
for child in node.children(&mut cur) {
|
||||
walk(child, bytes, unsanitised, saw_any);
|
||||
}
|
||||
}
|
||||
|
||||
fn args_contain_sanitiser(args: Node<'_>, bytes: &[u8]) -> bool {
|
||||
let mut hit = false;
|
||||
scan_for_sanitiser(args, bytes, &mut hit);
|
||||
hit
|
||||
}
|
||||
|
||||
fn scan_for_sanitiser(node: Node<'_>, bytes: &[u8], hit: &mut bool) {
|
||||
if *hit {
|
||||
return;
|
||||
}
|
||||
if matches!(
|
||||
node.kind(),
|
||||
"function_call_expression" | "member_call_expression" | "scoped_call_expression"
|
||||
) && let Some(name) = node
|
||||
.child_by_field_name("function")
|
||||
.or_else(|| node.child_by_field_name("name"))
|
||||
.and_then(|n| n.utf8_text(bytes).ok())
|
||||
&& callee_is_ldap_sanitiser(name)
|
||||
{
|
||||
*hit = true;
|
||||
return;
|
||||
}
|
||||
let mut cur = node.walk();
|
||||
for child in node.children(&mut cur) {
|
||||
scan_for_sanitiser(child, bytes, hit);
|
||||
}
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for LdapPhpAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
|
|
@ -51,23 +127,26 @@ impl FrameworkAdapter for LdapPhpAdapter {
|
|||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
_ast: tree_sitter::Node<'_>,
|
||||
ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_ldap_search);
|
||||
let matches_source = source_imports_ldap(file_bytes);
|
||||
if matches_call && matches_source {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
if !source_imports_ldap(file_bytes) {
|
||||
return None;
|
||||
}
|
||||
if !super::any_callee_matches(summary, callee_is_ldap_search) {
|
||||
return None;
|
||||
}
|
||||
if !ast_confirms_unsanitised_search(ast, file_bytes) {
|
||||
return None;
|
||||
}
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -111,4 +190,21 @@ mod tests {
|
|||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_when_filter_arg_is_sanitised() {
|
||||
let src: &[u8] = b"<?php\nfunction run($uid) {\n\
|
||||
$c = ldap_connect('127.0.0.1');\n\
|
||||
return ldap_search($c, 'ou=people', '(uid=' . ldap_escape($uid, '', LDAP_ESCAPE_FILTER) . ')');\n\
|
||||
}\n";
|
||||
let tree = parse_php(src);
|
||||
let summary = FuncSummary {
|
||||
name: "run".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("ldap_search")],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(LdapPhpAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,24 +6,40 @@
|
|||
//! (`ldap.search_s`, `ldap.search_ext_s`, `ldap.search`,
|
||||
//! `Connection.search`) and the surrounding source mentions the
|
||||
//! matching client module.
|
||||
//!
|
||||
//! Strengthened to walk the AST and reject the binding when any of
|
||||
//! the search call's argument subtrees flows through a known LDAP
|
||||
//! filter encoder (`ldap.filter.escape_filter_chars`,
|
||||
//! `escape_filter_chars`, `ldap.dn.escape_dn_chars`).
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
use tree_sitter::Node;
|
||||
|
||||
pub struct LdapPythonAdapter;
|
||||
|
||||
const ADAPTER_NAME: &str = "ldap-python";
|
||||
|
||||
fn callee_last_segment(name: &str) -> &str {
|
||||
name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name)
|
||||
}
|
||||
|
||||
fn callee_is_ldap_search(name: &str) -> bool {
|
||||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||
matches!(
|
||||
last,
|
||||
callee_last_segment(name),
|
||||
"search_s" | "search_ext_s" | "search" | "search_st" | "search_subtree_s"
|
||||
)
|
||||
}
|
||||
|
||||
fn callee_is_ldap_sanitiser(name: &str) -> bool {
|
||||
matches!(
|
||||
callee_last_segment(name),
|
||||
"escape_filter_chars" | "escape_dn_chars"
|
||||
)
|
||||
}
|
||||
|
||||
fn source_imports_ldap(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[
|
||||
b"import ldap",
|
||||
|
|
@ -38,6 +54,62 @@ fn source_imports_ldap(file_bytes: &[u8]) -> bool {
|
|||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
}
|
||||
|
||||
fn ast_confirms_unsanitised_search(root: Node<'_>, bytes: &[u8]) -> bool {
|
||||
let mut found_unsanitised = false;
|
||||
let mut saw_any_search = false;
|
||||
walk(root, bytes, &mut found_unsanitised, &mut saw_any_search);
|
||||
found_unsanitised || !saw_any_search
|
||||
}
|
||||
|
||||
fn walk(node: Node<'_>, bytes: &[u8], unsanitised: &mut bool, saw_any: &mut bool) {
|
||||
if *unsanitised {
|
||||
return;
|
||||
}
|
||||
if node.kind() == "call"
|
||||
&& let Some(func) = node
|
||||
.child_by_field_name("function")
|
||||
.and_then(|n| n.utf8_text(bytes).ok())
|
||||
&& callee_is_ldap_search(func)
|
||||
{
|
||||
*saw_any = true;
|
||||
if let Some(args) = node.child_by_field_name("arguments")
|
||||
&& !args_contain_sanitiser(args, bytes)
|
||||
{
|
||||
*unsanitised = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
let mut cur = node.walk();
|
||||
for child in node.children(&mut cur) {
|
||||
walk(child, bytes, unsanitised, saw_any);
|
||||
}
|
||||
}
|
||||
|
||||
fn args_contain_sanitiser(args: Node<'_>, bytes: &[u8]) -> bool {
|
||||
let mut hit = false;
|
||||
scan_for_sanitiser(args, bytes, &mut hit);
|
||||
hit
|
||||
}
|
||||
|
||||
fn scan_for_sanitiser(node: Node<'_>, bytes: &[u8], hit: &mut bool) {
|
||||
if *hit {
|
||||
return;
|
||||
}
|
||||
if node.kind() == "call"
|
||||
&& let Some(func) = node
|
||||
.child_by_field_name("function")
|
||||
.and_then(|n| n.utf8_text(bytes).ok())
|
||||
&& callee_is_ldap_sanitiser(func)
|
||||
{
|
||||
*hit = true;
|
||||
return;
|
||||
}
|
||||
let mut cur = node.walk();
|
||||
for child in node.children(&mut cur) {
|
||||
scan_for_sanitiser(child, bytes, hit);
|
||||
}
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for LdapPythonAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
|
|
@ -50,23 +122,26 @@ impl FrameworkAdapter for LdapPythonAdapter {
|
|||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
_ast: tree_sitter::Node<'_>,
|
||||
ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_ldap_search);
|
||||
let matches_source = source_imports_ldap(file_bytes);
|
||||
if matches_call && matches_source {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
if !source_imports_ldap(file_bytes) {
|
||||
return None;
|
||||
}
|
||||
if !super::any_callee_matches(summary, callee_is_ldap_search) {
|
||||
return None;
|
||||
}
|
||||
if !ast_confirms_unsanitised_search(ast, file_bytes) {
|
||||
return None;
|
||||
}
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -110,4 +185,21 @@ mod tests {
|
|||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_when_filter_arg_is_sanitised() {
|
||||
let src: &[u8] = b"import ldap\nfrom ldap.filter import escape_filter_chars\n\
|
||||
def run(uid):\n\
|
||||
con = ldap.initialize('ldap://127.0.0.1')\n\
|
||||
return con.search_s('ou=people', ldap.SCOPE_SUBTREE, '(uid=' + escape_filter_chars(uid) + ')')\n";
|
||||
let tree = parse_python(src);
|
||||
let summary = FuncSummary {
|
||||
name: "run".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("search_s")],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(LdapPythonAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,11 +8,19 @@
|
|||
//! surrounding source pulls in one of the matching package symbols —
|
||||
//! `org.springframework.ldap.*`, `javax.naming.directory.*`,
|
||||
//! `com.unboundid.ldap.*`.
|
||||
//!
|
||||
//! Strengthened to walk the AST and reject the binding when any of
|
||||
//! the search call's argument subtrees flows through a known LDAP
|
||||
//! filter encoder (`LdapEncoder.filterEncode`, `Filter.encodeValue`,
|
||||
//! `LdapUtils.encodeForLDAP`, `encodeForLdapFilter`). That removes
|
||||
//! the FP where the developer already wrapped the user input in a
|
||||
//! sanitiser but the adapter still stamped a binding.
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
use tree_sitter::Node;
|
||||
|
||||
pub struct LdapSpringAdapter;
|
||||
|
||||
|
|
@ -26,6 +34,19 @@ fn callee_is_ldap_search(name: &str) -> bool {
|
|||
)
|
||||
}
|
||||
|
||||
fn callee_is_ldap_sanitiser(name: &str) -> bool {
|
||||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||
matches!(
|
||||
last,
|
||||
"filterEncode"
|
||||
| "encodeValue"
|
||||
| "encodeForLDAP"
|
||||
| "encodeForLdapFilter"
|
||||
| "forLDAPFilter"
|
||||
| "forLDAP"
|
||||
)
|
||||
}
|
||||
|
||||
fn source_imports_ldap(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[
|
||||
b"org.springframework.ldap",
|
||||
|
|
@ -42,6 +63,70 @@ fn source_imports_ldap(file_bytes: &[u8]) -> bool {
|
|||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
}
|
||||
|
||||
/// True when any `method_invocation` in the file is a recognised LDAP
|
||||
/// search whose argument list does NOT pass through a known LDAP
|
||||
/// filter encoder. Bare-search calls (no encoder anywhere) keep
|
||||
/// firing; pre-sanitised calls bail out.
|
||||
fn ast_confirms_unsanitised_search(root: Node<'_>, bytes: &[u8]) -> bool {
|
||||
let mut found_unsanitised = false;
|
||||
let mut saw_any_search = false;
|
||||
walk(root, bytes, &mut found_unsanitised, &mut saw_any_search);
|
||||
// Conservative: when no AST search call was found at all, fall
|
||||
// through and let the cheap-filter / callee branch decide. When
|
||||
// AST search calls were seen, require at least one without a
|
||||
// sanitiser wrap.
|
||||
found_unsanitised || !saw_any_search
|
||||
}
|
||||
|
||||
fn walk(node: Node<'_>, bytes: &[u8], unsanitised: &mut bool, saw_any: &mut bool) {
|
||||
if *unsanitised {
|
||||
return;
|
||||
}
|
||||
if node.kind() == "method_invocation"
|
||||
&& let Some(name) = node
|
||||
.child_by_field_name("name")
|
||||
.and_then(|n| n.utf8_text(bytes).ok())
|
||||
&& callee_is_ldap_search(name)
|
||||
{
|
||||
*saw_any = true;
|
||||
if let Some(args) = node.child_by_field_name("arguments")
|
||||
&& !args_contain_sanitiser(args, bytes)
|
||||
{
|
||||
*unsanitised = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
let mut cur = node.walk();
|
||||
for child in node.children(&mut cur) {
|
||||
walk(child, bytes, unsanitised, saw_any);
|
||||
}
|
||||
}
|
||||
|
||||
fn args_contain_sanitiser(args: Node<'_>, bytes: &[u8]) -> bool {
|
||||
let mut hit = false;
|
||||
scan_for_sanitiser(args, bytes, &mut hit);
|
||||
hit
|
||||
}
|
||||
|
||||
fn scan_for_sanitiser(node: Node<'_>, bytes: &[u8], hit: &mut bool) {
|
||||
if *hit {
|
||||
return;
|
||||
}
|
||||
if node.kind() == "method_invocation"
|
||||
&& let Some(name) = node
|
||||
.child_by_field_name("name")
|
||||
.and_then(|n| n.utf8_text(bytes).ok())
|
||||
&& callee_is_ldap_sanitiser(name)
|
||||
{
|
||||
*hit = true;
|
||||
return;
|
||||
}
|
||||
let mut cur = node.walk();
|
||||
for child in node.children(&mut cur) {
|
||||
scan_for_sanitiser(child, bytes, hit);
|
||||
}
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for LdapSpringAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
|
|
@ -54,36 +139,30 @@ impl FrameworkAdapter for LdapSpringAdapter {
|
|||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
_ast: tree_sitter::Node<'_>,
|
||||
ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_ldap_search);
|
||||
let matches_source = source_imports_ldap(file_bytes);
|
||||
if matches_call && matches_source {
|
||||
return Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
});
|
||||
if !source_imports_ldap(file_bytes) {
|
||||
return None;
|
||||
}
|
||||
if matches_source
|
||||
&& file_bytes
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_ldap_search)
|
||||
|| file_bytes
|
||||
.windows(b".search(".len())
|
||||
.any(|w| w == b".search(")
|
||||
{
|
||||
return Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
});
|
||||
.any(|w| w == b".search(");
|
||||
if !matches_call {
|
||||
return None;
|
||||
}
|
||||
None
|
||||
if !ast_confirms_unsanitised_search(ast, file_bytes) {
|
||||
return None;
|
||||
}
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -130,4 +209,24 @@ mod tests {
|
|||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_when_filter_arg_is_sanitised() {
|
||||
// The user input is wrapped in LdapEncoder.filterEncode before
|
||||
// it reaches LdapTemplate.search; the binding must not fire.
|
||||
let src: &[u8] = b"import org.springframework.ldap.core.LdapTemplate;\n\
|
||||
import org.springframework.ldap.support.LdapEncoder;\n\
|
||||
public class V {\n public Object run(String uid, LdapTemplate t) {\n\
|
||||
return t.search(\"ou=people\", \"(uid=\" + LdapEncoder.filterEncode(uid) + \")\", null);\n\
|
||||
}\n}\n";
|
||||
let tree = parse_java(src);
|
||||
let summary = FuncSummary {
|
||||
name: "run".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("search")],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(LdapSpringAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -226,3 +226,240 @@ fn any_callee_matches(
|
|||
.iter()
|
||||
.any(|c| predicate(c.name.as_str()))
|
||||
}
|
||||
|
||||
/// True when `arg_text` resolves to a function parameter whose 0-based
|
||||
/// index participates in taint flow — either listed in
|
||||
/// `summary.tainted_sink_params` (param reaches an internal sink) or
|
||||
/// `summary.propagating_params` (param flows to the return value).
|
||||
///
|
||||
/// Used by the Phase 04 SSTI / Phase 05 XXE / Phase 06 LDAP adapters to
|
||||
/// reject substring matches in comments by confirming the call's first
|
||||
/// argument is a real tainted variable rather than a string literal or
|
||||
/// an unrelated local.
|
||||
///
|
||||
/// Per-language sigil stripping covers PHP (`$x`), Ruby (`@x`), and
|
||||
/// Java/Python/JS (no sigil). Leading whitespace is also trimmed so
|
||||
/// adapters can pass the raw `utf8_text` of the argument node.
|
||||
pub(super) fn arg_is_tainted_param(
|
||||
summary: &crate::summary::FuncSummary,
|
||||
arg_text: &str,
|
||||
) -> bool {
|
||||
fn strip(s: &str) -> &str {
|
||||
s.trim()
|
||||
.trim_start_matches('$')
|
||||
.trim_start_matches('@')
|
||||
.trim_start_matches('&')
|
||||
}
|
||||
let needle = strip(arg_text);
|
||||
let Some(idx) = summary
|
||||
.param_names
|
||||
.iter()
|
||||
.position(|p| strip(p) == needle)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
summary.tainted_sink_params.iter().any(|&i| i == idx)
|
||||
|| summary.propagating_params.iter().any(|&i| i == idx)
|
||||
}
|
||||
|
||||
/// True when any descendant identifier in `node`'s subtree resolves to
|
||||
/// a function parameter whose 0-based index participates in taint flow
|
||||
/// (same membership rule as [`arg_is_tainted_param`]).
|
||||
///
|
||||
/// Used by Phase 07 XPath adapters where the sink call's expression
|
||||
/// argument is typically a concat (`"//user[@name='" + name + "'"`)
|
||||
/// rather than a bare identifier — the walker collects every
|
||||
/// identifier-shaped leaf and checks each against the summary's
|
||||
/// tainted-param set. Pure-literal expressions and concats over
|
||||
/// unrelated locals fall through.
|
||||
///
|
||||
/// `function_scope` is the enclosing function-body subtree. When a
|
||||
/// direct identifier in `node` is not itself a tainted param, the
|
||||
/// walker chases its local assignment within `function_scope` and
|
||||
/// inspects the RHS for tainted-param references (one hop, enough to
|
||||
/// cover the common `expr = "..." + name + "..."; eval(expr)` shape
|
||||
/// without dragging full intra-procedural data flow into the
|
||||
/// adapter).
|
||||
pub(super) fn subtree_contains_tainted_param(
|
||||
node: tree_sitter::Node<'_>,
|
||||
bytes: &[u8],
|
||||
summary: &crate::summary::FuncSummary,
|
||||
function_scope: Option<tree_sitter::Node<'_>>,
|
||||
) -> bool {
|
||||
if summary.tainted_sink_params.is_empty() && summary.propagating_params.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut hit = false;
|
||||
walk_for_param(node, bytes, summary, function_scope, &mut hit);
|
||||
hit
|
||||
}
|
||||
|
||||
fn walk_for_param(
|
||||
node: tree_sitter::Node<'_>,
|
||||
bytes: &[u8],
|
||||
summary: &crate::summary::FuncSummary,
|
||||
function_scope: Option<tree_sitter::Node<'_>>,
|
||||
hit: &mut bool,
|
||||
) {
|
||||
if *hit {
|
||||
return;
|
||||
}
|
||||
if matches!(
|
||||
node.kind(),
|
||||
"identifier"
|
||||
| "variable_name"
|
||||
| "simple_identifier"
|
||||
| "name"
|
||||
| "type_identifier"
|
||||
| "scoped_identifier"
|
||||
| "field_identifier"
|
||||
| "property_identifier"
|
||||
) && let Ok(text) = node.utf8_text(bytes)
|
||||
{
|
||||
if arg_is_tainted_param(summary, text) {
|
||||
*hit = true;
|
||||
return;
|
||||
}
|
||||
if let Some(scope) = function_scope
|
||||
&& let Some(rhs) = find_local_assignment_rhs(scope, bytes, text)
|
||||
{
|
||||
let mut inner = false;
|
||||
walk_for_param_no_chase(rhs, bytes, summary, &mut inner);
|
||||
if inner {
|
||||
*hit = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut cur = node.walk();
|
||||
for child in node.children(&mut cur) {
|
||||
walk_for_param(child, bytes, summary, function_scope, hit);
|
||||
}
|
||||
}
|
||||
|
||||
fn walk_for_param_no_chase(
|
||||
node: tree_sitter::Node<'_>,
|
||||
bytes: &[u8],
|
||||
summary: &crate::summary::FuncSummary,
|
||||
hit: &mut bool,
|
||||
) {
|
||||
if *hit {
|
||||
return;
|
||||
}
|
||||
if matches!(
|
||||
node.kind(),
|
||||
"identifier"
|
||||
| "variable_name"
|
||||
| "simple_identifier"
|
||||
| "name"
|
||||
| "type_identifier"
|
||||
| "scoped_identifier"
|
||||
| "field_identifier"
|
||||
| "property_identifier"
|
||||
) && let Ok(text) = node.utf8_text(bytes)
|
||||
&& arg_is_tainted_param(summary, text)
|
||||
{
|
||||
*hit = true;
|
||||
return;
|
||||
}
|
||||
let mut cur = node.walk();
|
||||
for child in node.children(&mut cur) {
|
||||
walk_for_param_no_chase(child, bytes, summary, hit);
|
||||
}
|
||||
}
|
||||
|
||||
fn find_local_assignment_rhs<'a>(
|
||||
scope: tree_sitter::Node<'a>,
|
||||
bytes: &[u8],
|
||||
name: &str,
|
||||
) -> Option<tree_sitter::Node<'a>> {
|
||||
fn strip(s: &str) -> &str {
|
||||
s.trim()
|
||||
.trim_start_matches('$')
|
||||
.trim_start_matches('@')
|
||||
.trim_start_matches('&')
|
||||
}
|
||||
let needle = strip(name);
|
||||
let mut hit: Option<tree_sitter::Node<'a>> = None;
|
||||
visit(scope, bytes, needle, &mut hit);
|
||||
return hit;
|
||||
|
||||
fn visit<'a>(
|
||||
node: tree_sitter::Node<'a>,
|
||||
bytes: &[u8],
|
||||
needle: &str,
|
||||
hit: &mut Option<tree_sitter::Node<'a>>,
|
||||
) {
|
||||
if hit.is_some() {
|
||||
return;
|
||||
}
|
||||
match node.kind() {
|
||||
// Python `expr = rhs` / Ruby `expr = rhs` /
|
||||
// JS `expr = rhs` (no `let`).
|
||||
"assignment" | "assignment_expression" => {
|
||||
let lhs = node
|
||||
.child_by_field_name("left")
|
||||
.or_else(|| node.named_child(0));
|
||||
let rhs = node
|
||||
.child_by_field_name("right")
|
||||
.or_else(|| node.named_child(1));
|
||||
if let (Some(lhs), Some(rhs)) = (lhs, rhs)
|
||||
&& let Ok(text) = lhs.utf8_text(bytes)
|
||||
&& strip_sigils(text) == needle
|
||||
{
|
||||
*hit = Some(rhs);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// JS `let/const expr = rhs` / TS variant.
|
||||
"variable_declarator" => {
|
||||
let name_node = node
|
||||
.child_by_field_name("name")
|
||||
.or_else(|| node.named_child(0));
|
||||
let value = node
|
||||
.child_by_field_name("value")
|
||||
.or_else(|| node.named_child(1));
|
||||
if let (Some(n), Some(v)) = (name_node, value)
|
||||
&& let Ok(text) = n.utf8_text(bytes)
|
||||
&& strip_sigils(text) == needle
|
||||
{
|
||||
*hit = Some(v);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Java `Type expr = rhs;`.
|
||||
"local_variable_declaration" => {
|
||||
let mut cur = node.walk();
|
||||
for child in node.named_children(&mut cur) {
|
||||
if child.kind() == "variable_declarator" {
|
||||
let n = child
|
||||
.child_by_field_name("name")
|
||||
.or_else(|| child.named_child(0));
|
||||
let v = child
|
||||
.child_by_field_name("value")
|
||||
.or_else(|| child.named_child(1));
|
||||
if let (Some(n), Some(v)) = (n, v)
|
||||
&& let Ok(text) = n.utf8_text(bytes)
|
||||
&& strip_sigils(text) == needle
|
||||
{
|
||||
*hit = Some(v);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let mut cur = node.walk();
|
||||
for child in node.children(&mut cur) {
|
||||
visit(child, bytes, needle, hit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn strip_sigils(s: &str) -> &str {
|
||||
s.trim()
|
||||
.trim_start_matches('$')
|
||||
.trim_start_matches('@')
|
||||
.trim_start_matches('&')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,25 +6,75 @@
|
|||
//! `$twig->render($tainted)`. Callee matching is last-segment so
|
||||
//! receiver-prefixed calls (`$env->render`,
|
||||
//! `Twig\Environment::createTemplate`) hit the same predicate.
|
||||
//!
|
||||
//! Strengthened to walk the AST for a real `member_call_expression`
|
||||
//! or `scoped_call_expression` whose first positional argument names
|
||||
//! a parameter listed in `summary.tainted_sink_params` or
|
||||
//! `summary.propagating_params`, removing the comment-substring FP.
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
use tree_sitter::Node;
|
||||
|
||||
pub struct PhpTwigAdapter;
|
||||
|
||||
const ADAPTER_NAME: &str = "php-twig";
|
||||
|
||||
fn callee_is_twig(name: &str) -> bool {
|
||||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||
let last = last.rsplit_once("::").map(|(_, s)| s).unwrap_or(last);
|
||||
matches!(
|
||||
last,
|
||||
name,
|
||||
"createTemplate" | "render" | "renderBlock" | "display"
|
||||
)
|
||||
}
|
||||
|
||||
fn ast_confirms_tainted_call(root: Node<'_>, bytes: &[u8], summary: &FuncSummary) -> bool {
|
||||
let mut found = false;
|
||||
walk(root, bytes, summary, &mut found);
|
||||
found
|
||||
}
|
||||
|
||||
fn walk(node: Node<'_>, bytes: &[u8], summary: &FuncSummary, found: &mut bool) {
|
||||
if *found {
|
||||
return;
|
||||
}
|
||||
if matches!(
|
||||
node.kind(),
|
||||
"member_call_expression" | "scoped_call_expression" | "function_call_expression"
|
||||
) && let Some(name) = node
|
||||
.child_by_field_name("name")
|
||||
.or_else(|| node.child_by_field_name("function"))
|
||||
.and_then(|n| n.utf8_text(bytes).ok())
|
||||
&& callee_is_twig(name)
|
||||
&& let Some(args) = node.child_by_field_name("arguments")
|
||||
&& let Some(text) = first_positional_arg_text(args, bytes)
|
||||
&& super::arg_is_tainted_param(summary, &text)
|
||||
{
|
||||
*found = true;
|
||||
return;
|
||||
}
|
||||
let mut cur = node.walk();
|
||||
for child in node.children(&mut cur) {
|
||||
walk(child, bytes, summary, found);
|
||||
}
|
||||
}
|
||||
|
||||
fn first_positional_arg_text(args: Node<'_>, bytes: &[u8]) -> Option<String> {
|
||||
let mut cur = args.walk();
|
||||
for arg in args.named_children(&mut cur) {
|
||||
if arg.kind() != "argument" {
|
||||
continue;
|
||||
}
|
||||
if arg.child_by_field_name("name").is_some() {
|
||||
continue;
|
||||
}
|
||||
let value = arg.named_child(0)?;
|
||||
return value.utf8_text(bytes).ok().map(|s| s.to_owned());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for PhpTwigAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
|
|
@ -37,11 +87,10 @@ impl FrameworkAdapter for PhpTwigAdapter {
|
|||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
_ast: tree_sitter::Node<'_>,
|
||||
ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_twig);
|
||||
let matches_source = file_bytes
|
||||
let cheap_filter = file_bytes
|
||||
.windows(b"Twig\\Environment".len())
|
||||
.any(|w| w == b"Twig\\Environment")
|
||||
|| file_bytes
|
||||
|
|
@ -53,17 +102,20 @@ impl FrameworkAdapter for PhpTwigAdapter {
|
|||
|| file_bytes
|
||||
.windows(b"createTemplate".len())
|
||||
.any(|w| w == b"createTemplate");
|
||||
if matches_call && matches_source {
|
||||
return Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
});
|
||||
if !cheap_filter {
|
||||
return None;
|
||||
}
|
||||
None
|
||||
if !ast_confirms_tainted_call(ast, file_bytes, summary) {
|
||||
return None;
|
||||
}
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -78,15 +130,21 @@ mod tests {
|
|||
parser.parse(src, None).unwrap()
|
||||
}
|
||||
|
||||
fn summary_for(name: &str, params: &[&str], tainted: &[usize]) -> FuncSummary {
|
||||
FuncSummary {
|
||||
name: name.into(),
|
||||
param_count: params.len(),
|
||||
param_names: params.iter().map(|s| (*s).to_owned()).collect(),
|
||||
tainted_sink_params: tainted.to_vec(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_create_template() {
|
||||
let src: &[u8] = b"<?php\nuse Twig\\Environment;\nfunction render($body, $twig) {\n $tpl = $twig->createTemplate($body);\n return $tpl->render([]);\n}\n";
|
||||
let tree = parse_php(src);
|
||||
let summary = FuncSummary {
|
||||
name: "render".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("createTemplate")],
|
||||
..Default::default()
|
||||
};
|
||||
let summary = summary_for("render", &["body", "twig"], &[0]);
|
||||
assert!(PhpTwigAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_some());
|
||||
|
|
@ -104,4 +162,26 @@ mod tests {
|
|||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_comment_substring_with_constant_arg() {
|
||||
// The comment mentions `Twig\Environment` and the call uses a
|
||||
// literal — no tainted parameter reaches the engine.
|
||||
let src: &[u8] = b"<?php\n// Twig\\Environment is great\nfunction render($body, $twig) {\n $tpl = $twig->createTemplate('static');\n return $tpl->render([]);\n}\n";
|
||||
let tree = parse_php(src);
|
||||
let summary = summary_for("render", &["body", "twig"], &[0]);
|
||||
assert!(PhpTwigAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_when_param_not_in_tainted_set() {
|
||||
let src: &[u8] = b"<?php\nuse Twig\\Environment;\nfunction render($body, $twig) {\n $tpl = $twig->createTemplate($body);\n return $tpl->render([]);\n}\n";
|
||||
let tree = parse_php(src);
|
||||
let summary = summary_for("render", &["body", "twig"], &[]);
|
||||
assert!(PhpTwigAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,24 +6,77 @@
|
|||
//! `render_template_string(<tainted>)`. Callee matching is
|
||||
//! last-segment so receiver-prefixed calls (`env.from_string`,
|
||||
//! `flask.render_template_string`) hit the same predicate.
|
||||
//!
|
||||
//! The cheap byte-grep on `jinja2` / `from_string` /
|
||||
//! `render_template_string` is kept as an early filter, but the
|
||||
//! binding only fires after a tree-sitter walk confirms a real call
|
||||
//! node whose first argument names a function parameter listed in
|
||||
//! `summary.tainted_sink_params` or `summary.propagating_params`.
|
||||
//! That removes the comment-substring FP (a docstring mentioning
|
||||
//! `jinja2.Template` plus an unrelated `Template(constant)` call no
|
||||
//! longer trips the adapter).
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
use tree_sitter::Node;
|
||||
|
||||
pub struct PythonJinja2Adapter;
|
||||
|
||||
const ADAPTER_NAME: &str = "python-jinja2";
|
||||
|
||||
fn callee_is_jinja2(name: &str) -> bool {
|
||||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||
fn callee_last_segment(name: &str) -> &str {
|
||||
name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name)
|
||||
}
|
||||
|
||||
fn is_jinja2_entry(name: &str) -> bool {
|
||||
matches!(
|
||||
last,
|
||||
callee_last_segment(name),
|
||||
"Template" | "from_string" | "render_template_string"
|
||||
)
|
||||
}
|
||||
|
||||
fn ast_confirms_tainted_call(root: Node<'_>, bytes: &[u8], summary: &FuncSummary) -> bool {
|
||||
let mut found = false;
|
||||
walk(root, bytes, summary, &mut found);
|
||||
found
|
||||
}
|
||||
|
||||
fn walk(node: Node<'_>, bytes: &[u8], summary: &FuncSummary, found: &mut bool) {
|
||||
if *found {
|
||||
return;
|
||||
}
|
||||
if node.kind() == "call"
|
||||
&& let Some(func) = node
|
||||
.child_by_field_name("function")
|
||||
.and_then(|n| n.utf8_text(bytes).ok())
|
||||
&& is_jinja2_entry(func)
|
||||
&& let Some(args) = node.child_by_field_name("arguments")
|
||||
&& let Some(first) = first_positional_arg(args)
|
||||
&& let Ok(text) = first.utf8_text(bytes)
|
||||
&& super::arg_is_tainted_param(summary, text)
|
||||
{
|
||||
*found = true;
|
||||
return;
|
||||
}
|
||||
let mut cur = node.walk();
|
||||
for child in node.children(&mut cur) {
|
||||
walk(child, bytes, summary, found);
|
||||
}
|
||||
}
|
||||
|
||||
fn first_positional_arg<'a>(args: Node<'a>) -> Option<Node<'a>> {
|
||||
let mut cur = args.walk();
|
||||
for arg in args.named_children(&mut cur) {
|
||||
if arg.kind() == "keyword_argument" {
|
||||
continue;
|
||||
}
|
||||
return Some(arg);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for PythonJinja2Adapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
|
|
@ -36,11 +89,10 @@ impl FrameworkAdapter for PythonJinja2Adapter {
|
|||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
_ast: tree_sitter::Node<'_>,
|
||||
ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_jinja2);
|
||||
let matches_source = file_bytes
|
||||
let cheap_filter = file_bytes
|
||||
.windows(b"jinja2".len())
|
||||
.any(|w| w == b"jinja2")
|
||||
|| file_bytes
|
||||
|
|
@ -49,18 +101,23 @@ impl FrameworkAdapter for PythonJinja2Adapter {
|
|||
|| file_bytes
|
||||
.windows(b"render_template_string".len())
|
||||
.any(|w| w == b"render_template_string");
|
||||
if matches_call && matches_source {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
if !cheap_filter {
|
||||
return None;
|
||||
}
|
||||
if !super::any_callee_matches(summary, is_jinja2_entry) {
|
||||
return None;
|
||||
}
|
||||
if !ast_confirms_tainted_call(ast, file_bytes, summary) {
|
||||
return None;
|
||||
}
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -75,16 +132,23 @@ mod tests {
|
|||
parser.parse(src, None).unwrap()
|
||||
}
|
||||
|
||||
fn summary_for(name: &str, params: &[&str], tainted: &[usize]) -> FuncSummary {
|
||||
FuncSummary {
|
||||
name: name.into(),
|
||||
param_count: params.len(),
|
||||
param_names: params.iter().map(|s| (*s).to_owned()).collect(),
|
||||
tainted_sink_params: tainted.to_vec(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("Template")],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_when_source_imports_jinja2() {
|
||||
let src: &[u8] =
|
||||
b"from jinja2 import Template\ndef render(body):\n return Template(body).render()\n";
|
||||
let tree = parse_python(src);
|
||||
let summary = FuncSummary {
|
||||
name: "render".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("Template")],
|
||||
..Default::default()
|
||||
};
|
||||
let summary = summary_for("render", &["body"], &[0]);
|
||||
assert!(PythonJinja2Adapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_some());
|
||||
|
|
@ -95,11 +159,8 @@ mod tests {
|
|||
let src: &[u8] =
|
||||
b"from flask import render_template_string\ndef view(body):\n return render_template_string(body)\n";
|
||||
let tree = parse_python(src);
|
||||
let summary = FuncSummary {
|
||||
name: "view".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("render_template_string")],
|
||||
..Default::default()
|
||||
};
|
||||
let mut summary = summary_for("view", &["body"], &[0]);
|
||||
summary.callees = vec![crate::summary::CalleeSite::bare("render_template_string")];
|
||||
assert!(PythonJinja2Adapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_some());
|
||||
|
|
@ -117,4 +178,29 @@ mod tests {
|
|||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_comment_substring_with_constant_arg() {
|
||||
// Docstring mentions jinja2; the actual call passes a string
|
||||
// literal — no parameter taint reaches the engine.
|
||||
let src: &[u8] = b"\"\"\"renders via jinja2.Template\"\"\"\ndef render(body):\n return Template(\"hello\").render()\n";
|
||||
let tree = parse_python(src);
|
||||
let summary = summary_for("render", &["body"], &[0]);
|
||||
assert!(PythonJinja2Adapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_when_param_not_in_tainted_set() {
|
||||
// Engine never flagged `body` as tainted (no taint reached an
|
||||
// internal sink in pass 1); the adapter must not stamp.
|
||||
let src: &[u8] =
|
||||
b"from jinja2 import Template\ndef render(body):\n return Template(body).render()\n";
|
||||
let tree = parse_python(src);
|
||||
let summary = summary_for("render", &["body"], &[]);
|
||||
assert!(PythonJinja2Adapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,19 +5,68 @@
|
|||
//! variant). Callee matching is last-segment-aware so namespaced
|
||||
//! receivers (`Erubi::Engine.new`) reduce to `new` + a string-level
|
||||
//! check for the surrounding `ERB` / `Erubi` token in the source.
|
||||
//!
|
||||
//! Strengthened to require a real `call` node whose first positional
|
||||
//! argument names a parameter listed in `summary.tainted_sink_params`
|
||||
//! or `summary.propagating_params`, removing the comment-substring FP.
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
use tree_sitter::Node;
|
||||
|
||||
pub struct RubyErbAdapter;
|
||||
|
||||
const ADAPTER_NAME: &str = "ruby-erb";
|
||||
|
||||
fn callee_is_erb(name: &str) -> bool {
|
||||
fn callee_last_segment(name: &str) -> &str {
|
||||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||
matches!(last, "result" | "result_with_hash" | "new")
|
||||
last.rsplit_once("::").map(|(_, s)| s).unwrap_or(last)
|
||||
}
|
||||
|
||||
fn is_erb_entry(name: &str) -> bool {
|
||||
matches!(callee_last_segment(name), "result" | "result_with_hash" | "new")
|
||||
}
|
||||
|
||||
fn ast_confirms_tainted_call(root: Node<'_>, bytes: &[u8], summary: &FuncSummary) -> bool {
|
||||
let mut found = false;
|
||||
walk(root, bytes, summary, &mut found);
|
||||
found
|
||||
}
|
||||
|
||||
fn walk(node: Node<'_>, bytes: &[u8], summary: &FuncSummary, found: &mut bool) {
|
||||
if *found {
|
||||
return;
|
||||
}
|
||||
if matches!(node.kind(), "call" | "method_call")
|
||||
&& let Some(method) = node
|
||||
.child_by_field_name("method")
|
||||
.and_then(|n| n.utf8_text(bytes).ok())
|
||||
&& is_erb_entry(method)
|
||||
&& let Some(args) = node.child_by_field_name("arguments")
|
||||
&& let Some(first) = first_positional_arg(args)
|
||||
&& let Ok(text) = first.utf8_text(bytes)
|
||||
&& super::arg_is_tainted_param(summary, text)
|
||||
{
|
||||
*found = true;
|
||||
return;
|
||||
}
|
||||
let mut cur = node.walk();
|
||||
for child in node.children(&mut cur) {
|
||||
walk(child, bytes, summary, found);
|
||||
}
|
||||
}
|
||||
|
||||
fn first_positional_arg<'a>(args: Node<'a>) -> Option<Node<'a>> {
|
||||
let mut cur = args.walk();
|
||||
for arg in args.named_children(&mut cur) {
|
||||
if matches!(arg.kind(), "pair" | "hash_splat_argument" | "block_argument") {
|
||||
continue;
|
||||
}
|
||||
return Some(arg);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for RubyErbAdapter {
|
||||
|
|
@ -32,11 +81,10 @@ impl FrameworkAdapter for RubyErbAdapter {
|
|||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
_ast: tree_sitter::Node<'_>,
|
||||
ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_erb);
|
||||
let matches_source = file_bytes
|
||||
let cheap_filter = file_bytes
|
||||
.windows(b"ERB.new".len())
|
||||
.any(|w| w == b"ERB.new")
|
||||
|| file_bytes
|
||||
|
|
@ -48,31 +96,20 @@ impl FrameworkAdapter for RubyErbAdapter {
|
|||
|| file_bytes
|
||||
.windows(b"Erubi".len())
|
||||
.any(|w| w == b"Erubi");
|
||||
if matches_call && matches_source {
|
||||
return Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
});
|
||||
if !cheap_filter {
|
||||
return None;
|
||||
}
|
||||
if matches_source
|
||||
&& file_bytes
|
||||
.windows(b".result".len())
|
||||
.any(|w| w == b".result")
|
||||
{
|
||||
return Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
});
|
||||
if !ast_confirms_tainted_call(ast, file_bytes, summary) {
|
||||
return None;
|
||||
}
|
||||
None
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -87,14 +124,21 @@ mod tests {
|
|||
parser.parse(src, None).unwrap()
|
||||
}
|
||||
|
||||
fn summary_for(name: &str, params: &[&str], tainted: &[usize]) -> FuncSummary {
|
||||
FuncSummary {
|
||||
name: name.into(),
|
||||
param_count: params.len(),
|
||||
param_names: params.iter().map(|s| (*s).to_owned()).collect(),
|
||||
tainted_sink_params: tainted.to_vec(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_erb_new_result() {
|
||||
let src: &[u8] = b"require 'erb'\ndef render(body)\n ERB.new(body).result\nend\n";
|
||||
let tree = parse_ruby(src);
|
||||
let summary = FuncSummary {
|
||||
name: "render".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let summary = summary_for("render", &["body"], &[0]);
|
||||
assert!(RubyErbAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_some());
|
||||
|
|
@ -112,4 +156,25 @@ mod tests {
|
|||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_comment_substring_with_constant_arg() {
|
||||
let src: &[u8] =
|
||||
b"# require 'erb' is mentioned\ndef render(body)\n ERB.new(\"static\").result\nend\n";
|
||||
let tree = parse_ruby(src);
|
||||
let summary = summary_for("render", &["body"], &[0]);
|
||||
assert!(RubyErbAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_when_param_not_in_tainted_set() {
|
||||
let src: &[u8] = b"require 'erb'\ndef render(body)\n ERB.new(body).result\nend\n";
|
||||
let tree = parse_ruby(src);
|
||||
let summary = summary_for("render", &["body"], &[]);
|
||||
assert!(RubyErbAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,11 +7,19 @@
|
|||
//! and the surrounding source pulls in one of the matching package
|
||||
//! symbols — `javax.xml.xpath.*`, `XPathFactory`,
|
||||
//! `XPathConstants.NODESET`.
|
||||
//!
|
||||
//! Strengthened to walk the AST and only fire when the evaluator's
|
||||
//! expression argument carries a tainted-param identifier in its
|
||||
//! subtree. Pre-bound parameterised queries (`xp.setVariable("name",
|
||||
//! input)` + `xp.evaluate("//user[@name=$name]")`) leave the
|
||||
//! expression as a string literal, so the walker sees no tainted
|
||||
//! identifier and the binding is skipped.
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
use tree_sitter::Node;
|
||||
|
||||
pub struct XpathJavaAdapter;
|
||||
|
||||
|
|
@ -35,6 +43,39 @@ fn source_imports_xpath(file_bytes: &[u8]) -> bool {
|
|||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
}
|
||||
|
||||
fn ast_confirms_tainted_xpath(root: Node<'_>, bytes: &[u8], summary: &FuncSummary) -> bool {
|
||||
let mut found = false;
|
||||
walk(root, bytes, summary, root, &mut found);
|
||||
found
|
||||
}
|
||||
|
||||
fn walk<'a>(
|
||||
node: Node<'a>,
|
||||
bytes: &[u8],
|
||||
summary: &FuncSummary,
|
||||
scope: Node<'a>,
|
||||
found: &mut bool,
|
||||
) {
|
||||
if *found {
|
||||
return;
|
||||
}
|
||||
if node.kind() == "method_invocation"
|
||||
&& let Some(name) = node
|
||||
.child_by_field_name("name")
|
||||
.and_then(|n| n.utf8_text(bytes).ok())
|
||||
&& callee_is_xpath_eval(name)
|
||||
&& let Some(args) = node.child_by_field_name("arguments")
|
||||
&& super::subtree_contains_tainted_param(args, bytes, summary, Some(scope))
|
||||
{
|
||||
*found = true;
|
||||
return;
|
||||
}
|
||||
let mut cur = node.walk();
|
||||
for child in node.children(&mut cur) {
|
||||
walk(child, bytes, summary, scope, found);
|
||||
}
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for XpathJavaAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
|
|
@ -47,23 +88,26 @@ impl FrameworkAdapter for XpathJavaAdapter {
|
|||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
_ast: tree_sitter::Node<'_>,
|
||||
ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_xpath_eval);
|
||||
let matches_source = source_imports_xpath(file_bytes);
|
||||
if matches_call && matches_source {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
if !source_imports_xpath(file_bytes) {
|
||||
return None;
|
||||
}
|
||||
if !super::any_callee_matches(summary, callee_is_xpath_eval) {
|
||||
return None;
|
||||
}
|
||||
if !ast_confirms_tainted_xpath(ast, file_bytes, summary) {
|
||||
return None;
|
||||
}
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -78,6 +122,17 @@ mod tests {
|
|||
parser.parse(src, None).unwrap()
|
||||
}
|
||||
|
||||
fn summary_for(name: &str, params: &[&str], tainted: &[usize]) -> FuncSummary {
|
||||
FuncSummary {
|
||||
name: name.into(),
|
||||
param_count: params.len(),
|
||||
param_names: params.iter().map(|s| (*s).to_owned()).collect(),
|
||||
tainted_sink_params: tainted.to_vec(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("evaluate")],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_xpath_evaluate() {
|
||||
let src: &[u8] = b"import javax.xml.xpath.XPathFactory;\n\
|
||||
|
|
@ -86,11 +141,7 @@ mod tests {
|
|||
return xp.evaluate(\"//user[@name='\" + name + \"']\", null);\n\
|
||||
}\n}\n";
|
||||
let tree = parse_java(src);
|
||||
let summary = FuncSummary {
|
||||
name: "run".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("evaluate")],
|
||||
..Default::default()
|
||||
};
|
||||
let summary = summary_for("run", &["name"], &[0]);
|
||||
let binding = XpathJavaAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.expect("must fire on XPath.evaluate");
|
||||
|
|
@ -111,4 +162,22 @@ mod tests {
|
|||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_when_expression_uses_bound_variable() {
|
||||
// The expression is a literal containing `$name`; the actual
|
||||
// input is bound via `xp.setVariable`. No tainted identifier
|
||||
// appears inside `evaluate`'s argument subtree.
|
||||
let src: &[u8] = b"import javax.xml.xpath.XPathFactory;\n\
|
||||
public class V {\n public Object run(String name) throws Exception {\n\
|
||||
javax.xml.xpath.XPath xp = XPathFactory.newInstance().newXPath();\n\
|
||||
xp.setXPathVariableResolver(new Resolver(name));\n\
|
||||
return xp.evaluate(\"//user[@name=$name]\", null);\n\
|
||||
}\n}\n";
|
||||
let tree = parse_java(src);
|
||||
let summary = summary_for("run", &["name"], &[0]);
|
||||
assert!(XpathJavaAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,11 +6,18 @@
|
|||
//! browser DOM's `document.evaluate`) and the surrounding source
|
||||
//! imports / requires the `xpath` module or references
|
||||
//! `XPathResult` / `document.evaluate`.
|
||||
//!
|
||||
//! Strengthened to walk the AST and only fire when the selector's
|
||||
//! expression argument carries a tainted-param identifier in its
|
||||
//! subtree. Bound queries that build the expression as a literal
|
||||
//! and pass variables separately (`xpath.parse(expr).select({ vars
|
||||
//! })`) leave the first arg literal-only and skip the binding.
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
use tree_sitter::Node;
|
||||
|
||||
pub struct XpathJsAdapter;
|
||||
|
||||
|
|
@ -37,6 +44,39 @@ fn source_imports_xpath(file_bytes: &[u8]) -> bool {
|
|||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
}
|
||||
|
||||
fn ast_confirms_tainted_xpath(root: Node<'_>, bytes: &[u8], summary: &FuncSummary) -> bool {
|
||||
let mut found = false;
|
||||
walk(root, bytes, summary, root, &mut found);
|
||||
found
|
||||
}
|
||||
|
||||
fn walk<'a>(
|
||||
node: Node<'a>,
|
||||
bytes: &[u8],
|
||||
summary: &FuncSummary,
|
||||
scope: Node<'a>,
|
||||
found: &mut bool,
|
||||
) {
|
||||
if *found {
|
||||
return;
|
||||
}
|
||||
if node.kind() == "call_expression"
|
||||
&& let Some(func) = node
|
||||
.child_by_field_name("function")
|
||||
.and_then(|n| n.utf8_text(bytes).ok())
|
||||
&& callee_is_xpath_eval(func)
|
||||
&& let Some(args) = node.child_by_field_name("arguments")
|
||||
&& super::subtree_contains_tainted_param(args, bytes, summary, Some(scope))
|
||||
{
|
||||
*found = true;
|
||||
return;
|
||||
}
|
||||
let mut cur = node.walk();
|
||||
for child in node.children(&mut cur) {
|
||||
walk(child, bytes, summary, scope, found);
|
||||
}
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for XpathJsAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
|
|
@ -49,23 +89,26 @@ impl FrameworkAdapter for XpathJsAdapter {
|
|||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
_ast: tree_sitter::Node<'_>,
|
||||
ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_xpath_eval);
|
||||
let matches_source = source_imports_xpath(file_bytes);
|
||||
if matches_call && matches_source {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
if !source_imports_xpath(file_bytes) {
|
||||
return None;
|
||||
}
|
||||
if !super::any_callee_matches(summary, callee_is_xpath_eval) {
|
||||
return None;
|
||||
}
|
||||
if !ast_confirms_tainted_xpath(ast, file_bytes, summary) {
|
||||
return None;
|
||||
}
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -80,6 +123,17 @@ mod tests {
|
|||
parser.parse(src, None).unwrap()
|
||||
}
|
||||
|
||||
fn summary_for(name: &str, params: &[&str], tainted: &[usize]) -> FuncSummary {
|
||||
FuncSummary {
|
||||
name: name.into(),
|
||||
param_count: params.len(),
|
||||
param_names: params.iter().map(|s| (*s).to_owned()).collect(),
|
||||
tainted_sink_params: tainted.to_vec(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("select")],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_xpath_select() {
|
||||
let src: &[u8] = b"const xpath = require('xpath');\n\
|
||||
|
|
@ -87,11 +141,7 @@ mod tests {
|
|||
return xpath.select(\"//user[@name='\" + name + \"']\", doc);\n\
|
||||
}\nmodule.exports = { run };\n";
|
||||
let tree = parse_js(src);
|
||||
let summary = FuncSummary {
|
||||
name: "run".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("select")],
|
||||
..Default::default()
|
||||
};
|
||||
let summary = summary_for("run", &["name"], &[0]);
|
||||
assert!(XpathJsAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_some());
|
||||
|
|
@ -109,4 +159,17 @@ mod tests {
|
|||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_when_expression_is_literal_only() {
|
||||
let src: &[u8] = b"const xpath = require('xpath');\n\
|
||||
function run(name) {\n\
|
||||
return xpath.select(\"//user[@id=1]\", doc);\n\
|
||||
}\nmodule.exports = { run };\n";
|
||||
let tree = parse_js(src);
|
||||
let summary = summary_for("run", &["name"], &[0]);
|
||||
assert!(XpathJsAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,11 +4,17 @@
|
|||
//! Phase 07 (Track J.5). Fires when the function body invokes
|
||||
//! `DOMXPath::query` / `DOMXPath::evaluate` and the surrounding
|
||||
//! source pulls in the `DOMXPath` / `DOMDocument` family.
|
||||
//!
|
||||
//! Strengthened to walk the AST and only fire when the query call's
|
||||
//! expression argument carries a tainted-param identifier in its
|
||||
//! subtree. Pure-literal expressions (`$xp->query("//user[@id=1]")`)
|
||||
//! produce no tainted-identifier hit and the binding is skipped.
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
use tree_sitter::Node;
|
||||
|
||||
pub struct XpathPhpAdapter;
|
||||
|
||||
|
|
@ -33,6 +39,42 @@ fn source_uses_domxpath(file_bytes: &[u8]) -> bool {
|
|||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
}
|
||||
|
||||
fn ast_confirms_tainted_xpath(root: Node<'_>, bytes: &[u8], summary: &FuncSummary) -> bool {
|
||||
let mut found = false;
|
||||
walk(root, bytes, summary, root, &mut found);
|
||||
found
|
||||
}
|
||||
|
||||
fn walk<'a>(
|
||||
node: Node<'a>,
|
||||
bytes: &[u8],
|
||||
summary: &FuncSummary,
|
||||
scope: Node<'a>,
|
||||
found: &mut bool,
|
||||
) {
|
||||
if *found {
|
||||
return;
|
||||
}
|
||||
if matches!(
|
||||
node.kind(),
|
||||
"member_call_expression" | "scoped_call_expression" | "function_call_expression"
|
||||
) && let Some(name) = node
|
||||
.child_by_field_name("name")
|
||||
.or_else(|| node.child_by_field_name("function"))
|
||||
.and_then(|n| n.utf8_text(bytes).ok())
|
||||
&& callee_is_xpath_eval(name)
|
||||
&& let Some(args) = node.child_by_field_name("arguments")
|
||||
&& super::subtree_contains_tainted_param(args, bytes, summary, Some(scope))
|
||||
{
|
||||
*found = true;
|
||||
return;
|
||||
}
|
||||
let mut cur = node.walk();
|
||||
for child in node.children(&mut cur) {
|
||||
walk(child, bytes, summary, scope, found);
|
||||
}
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for XpathPhpAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
|
|
@ -45,23 +87,26 @@ impl FrameworkAdapter for XpathPhpAdapter {
|
|||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
_ast: tree_sitter::Node<'_>,
|
||||
ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_xpath_eval);
|
||||
let matches_source = source_uses_domxpath(file_bytes);
|
||||
if matches_call && matches_source {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
if !source_uses_domxpath(file_bytes) {
|
||||
return None;
|
||||
}
|
||||
if !super::any_callee_matches(summary, callee_is_xpath_eval) {
|
||||
return None;
|
||||
}
|
||||
if !ast_confirms_tainted_xpath(ast, file_bytes, summary) {
|
||||
return None;
|
||||
}
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -76,6 +121,17 @@ mod tests {
|
|||
parser.parse(src, None).unwrap()
|
||||
}
|
||||
|
||||
fn summary_for(name: &str, params: &[&str], tainted: &[usize]) -> FuncSummary {
|
||||
FuncSummary {
|
||||
name: name.into(),
|
||||
param_count: params.len(),
|
||||
param_names: params.iter().map(|s| (*s).to_owned()).collect(),
|
||||
tainted_sink_params: tainted.to_vec(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("query")],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_domxpath_query() {
|
||||
let src: &[u8] = b"<?php\n\
|
||||
|
|
@ -86,11 +142,7 @@ mod tests {
|
|||
return $xp->query(\"//user[@name='\" . $name . \"']\");\n\
|
||||
}\n";
|
||||
let tree = parse_php(src);
|
||||
let summary = FuncSummary {
|
||||
name: "run".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("query")],
|
||||
..Default::default()
|
||||
};
|
||||
let summary = summary_for("run", &["name"], &[0]);
|
||||
assert!(XpathPhpAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_some());
|
||||
|
|
@ -108,4 +160,20 @@ mod tests {
|
|||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_when_expression_is_literal_only() {
|
||||
let src: &[u8] = b"<?php\n\
|
||||
function run($name) {\n\
|
||||
$doc = new DOMDocument();\n\
|
||||
$doc->load('xpath_corpus.xml');\n\
|
||||
$xp = new DOMXPath($doc);\n\
|
||||
return $xp->query(\"//user[@id=1]\");\n\
|
||||
}\n";
|
||||
let tree = parse_php(src);
|
||||
let summary = summary_for("run", &["name"], &[0]);
|
||||
assert!(XpathPhpAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,11 +4,20 @@
|
|||
//! Phase 07 (Track J.5). Fires when the function body invokes
|
||||
//! `lxml.etree`'s XPath entry points (`Element.xpath`, `xpath`,
|
||||
//! `XPath` evaluator) and the surrounding source imports `lxml`.
|
||||
//!
|
||||
//! Strengthened to walk the AST and only fire when the evaluator's
|
||||
//! expression argument carries a tainted-param identifier in its
|
||||
//! subtree. Pre-bound parameterised queries
|
||||
//! (`etree.XPath("//user[@name=$name]")(tree, name=name)`) keep the
|
||||
//! template string literal-only, so the walker sees no tainted
|
||||
//! identifier inside the call to `XPath` / `xpath` and the binding
|
||||
//! is skipped.
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
use tree_sitter::Node;
|
||||
|
||||
pub struct XpathPythonAdapter;
|
||||
|
||||
|
|
@ -16,7 +25,7 @@ const ADAPTER_NAME: &str = "xpath-python";
|
|||
|
||||
fn callee_is_xpath_eval(name: &str) -> bool {
|
||||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||
matches!(last, "xpath" | "evaluate" | "find" | "findall" | "iterfind")
|
||||
matches!(last, "xpath" | "evaluate" | "find" | "findall" | "iterfind" | "XPath")
|
||||
}
|
||||
|
||||
fn source_imports_lxml(file_bytes: &[u8]) -> bool {
|
||||
|
|
@ -34,6 +43,39 @@ fn source_imports_lxml(file_bytes: &[u8]) -> bool {
|
|||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
}
|
||||
|
||||
fn ast_confirms_tainted_xpath(root: Node<'_>, bytes: &[u8], summary: &FuncSummary) -> bool {
|
||||
let mut found = false;
|
||||
walk(root, bytes, summary, root, &mut found);
|
||||
found
|
||||
}
|
||||
|
||||
fn walk<'a>(
|
||||
node: Node<'a>,
|
||||
bytes: &[u8],
|
||||
summary: &FuncSummary,
|
||||
scope: Node<'a>,
|
||||
found: &mut bool,
|
||||
) {
|
||||
if *found {
|
||||
return;
|
||||
}
|
||||
if node.kind() == "call"
|
||||
&& let Some(func) = node
|
||||
.child_by_field_name("function")
|
||||
.and_then(|n| n.utf8_text(bytes).ok())
|
||||
&& callee_is_xpath_eval(func)
|
||||
&& let Some(args) = node.child_by_field_name("arguments")
|
||||
&& super::subtree_contains_tainted_param(args, bytes, summary, Some(scope))
|
||||
{
|
||||
*found = true;
|
||||
return;
|
||||
}
|
||||
let mut cur = node.walk();
|
||||
for child in node.children(&mut cur) {
|
||||
walk(child, bytes, summary, scope, found);
|
||||
}
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for XpathPythonAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
|
|
@ -46,23 +88,26 @@ impl FrameworkAdapter for XpathPythonAdapter {
|
|||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
_ast: tree_sitter::Node<'_>,
|
||||
ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_xpath_eval);
|
||||
let matches_source = source_imports_lxml(file_bytes);
|
||||
if matches_call && matches_source {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
if !source_imports_lxml(file_bytes) {
|
||||
return None;
|
||||
}
|
||||
if !super::any_callee_matches(summary, callee_is_xpath_eval) {
|
||||
return None;
|
||||
}
|
||||
if !ast_confirms_tainted_xpath(ast, file_bytes, summary) {
|
||||
return None;
|
||||
}
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -77,6 +122,17 @@ mod tests {
|
|||
parser.parse(src, None).unwrap()
|
||||
}
|
||||
|
||||
fn summary_for(name: &str, params: &[&str], tainted: &[usize]) -> FuncSummary {
|
||||
FuncSummary {
|
||||
name: name.into(),
|
||||
param_count: params.len(),
|
||||
param_names: params.iter().map(|s| (*s).to_owned()).collect(),
|
||||
tainted_sink_params: tainted.to_vec(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("xpath")],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_lxml_xpath() {
|
||||
let src: &[u8] = b"from lxml import etree\n\
|
||||
|
|
@ -84,11 +140,7 @@ mod tests {
|
|||
tree = etree.fromstring(open('xpath_corpus.xml').read())\n\
|
||||
return tree.xpath(\"//user[@name='\" + name + \"']\")\n";
|
||||
let tree = parse_python(src);
|
||||
let summary = FuncSummary {
|
||||
name: "run".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("xpath")],
|
||||
..Default::default()
|
||||
};
|
||||
let summary = summary_for("run", &["name"], &[0]);
|
||||
assert!(XpathPythonAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_some());
|
||||
|
|
@ -106,4 +158,18 @@ mod tests {
|
|||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_when_expression_uses_bound_variable() {
|
||||
let src: &[u8] = b"from lxml import etree\n\
|
||||
def run(name):\n\
|
||||
tree = etree.fromstring(open('xpath_corpus.xml').read())\n\
|
||||
q = etree.XPath(\"//user[@name=$name]\")\n\
|
||||
return q(tree, name=name)\n";
|
||||
let tree = parse_python(src);
|
||||
let summary = summary_for("run", &["name"], &[0]);
|
||||
assert!(XpathPythonAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -509,19 +509,17 @@ mod escape_tests {
|
|||
let opts = escape_opts();
|
||||
|
||||
// First run — starts a new container.
|
||||
let r1 = sandbox::run(&harness, &noop_payload(), &opts);
|
||||
let r1 = sandbox::run(&harness, noop_payload(), &opts);
|
||||
// Second run — should exec into the running container.
|
||||
let r2 = sandbox::run(&harness, &noop_payload(), &opts);
|
||||
let r2 = sandbox::run(&harness, noop_payload(), &opts);
|
||||
|
||||
// Both should succeed (blocked, not escaped — dns_leak exits 1).
|
||||
// The important thing is neither panics or returns an unexpected error.
|
||||
match r1 {
|
||||
Err(SandboxError::BackendUnavailable(_)) => return,
|
||||
_ => {}
|
||||
if let Err(SandboxError::BackendUnavailable(_)) = r1 {
|
||||
return;
|
||||
}
|
||||
match r2 {
|
||||
Err(SandboxError::BackendUnavailable(_)) => return,
|
||||
_ => {}
|
||||
if let Err(SandboxError::BackendUnavailable(_)) = r2 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify the container is still running (not torn down between calls).
|
||||
|
|
|
|||
|
|
@ -248,10 +248,18 @@ fn framework_adapters_detect_ssti_sink() {
|
|||
let mut parser = tree_sitter::Parser::new();
|
||||
parser.set_language(&ts_lang).unwrap();
|
||||
let tree = parser.parse(&bytes, None).unwrap();
|
||||
// Each vuln fixture's `run` function takes `body` as its
|
||||
// single param and pipes it into the SSTI engine. Seed the
|
||||
// summary with `body` at index 0 and mark that index as a
|
||||
// tainted sink participant so the strengthened AST gate
|
||||
// (added with the comment-substring FP fix) fires.
|
||||
let mut summary = FuncSummary {
|
||||
name: "run".into(),
|
||||
file_path: fixture.to_owned(),
|
||||
lang: slug(lang).into(),
|
||||
param_count: 1,
|
||||
param_names: vec!["body".into()],
|
||||
tainted_sink_params: vec![0],
|
||||
..Default::default()
|
||||
};
|
||||
// Seed the canonical sink callee per language so the
|
||||
|
|
|
|||
|
|
@ -329,10 +329,18 @@ fn framework_adapters_detect_xpath_sink() {
|
|||
let mut parser = tree_sitter::Parser::new();
|
||||
parser.set_language(&ts_lang).unwrap();
|
||||
let tree = parser.parse(&bytes, None).unwrap();
|
||||
// Each vuln fixture's `run` function takes `name` as its
|
||||
// single param and concats it into the XPath expression.
|
||||
// The strengthened adapters (one-hop local-assignment chase
|
||||
// plus tainted-param participation) need the summary to
|
||||
// mark index 0 as a tainted sink participant.
|
||||
let mut summary = FuncSummary {
|
||||
name: "run".into(),
|
||||
file_path: fixture.to_owned(),
|
||||
lang: slug(lang).into(),
|
||||
param_count: 1,
|
||||
param_names: vec!["name".into()],
|
||||
tainted_sink_params: vec![0],
|
||||
..Default::default()
|
||||
};
|
||||
summary
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue