From bb8484bb28880f520fc8104a8544ac81bb045eee Mon Sep 17 00:00:00 2001 From: pitboss Date: Thu, 21 May 2026 01:36:46 -0500 Subject: [PATCH] [pitboss/grind] deferred session-0007 (20260520T233019Z-6958) --- src/dynamic/framework/adapters/header_go.rs | 35 +++++++ src/dynamic/framework/adapters/header_java.rs | 44 +++++++++ src/dynamic/framework/adapters/header_js.rs | 35 +++++++ src/dynamic/framework/adapters/header_php.rs | 35 +++++++ .../framework/adapters/header_python.rs | 44 +++++++++ src/dynamic/framework/adapters/header_ruby.rs | 39 ++++++++ src/dynamic/framework/adapters/header_rust.rs | 39 ++++++++ src/dynamic/framework/adapters/redirect_go.rs | 71 ++++++++++++++ .../framework/adapters/redirect_java.rs | 45 +++++++++ src/dynamic/framework/adapters/redirect_js.rs | 39 ++++++++ .../framework/adapters/redirect_php.rs | 41 ++++++++ .../framework/adapters/redirect_python.rs | 44 +++++++++ .../framework/adapters/redirect_ruby.rs | 42 +++++++++ .../framework/adapters/redirect_rust.rs | 42 +++++++++ src/dynamic/framework/adapters/xxe_go.rs | 39 ++++++++ src/dynamic/framework/adapters/xxe_java.rs | 68 ++++++++++++++ src/dynamic/framework/adapters/xxe_php.rs | 93 +++++++++++++++++++ src/dynamic/framework/adapters/xxe_python.rs | 58 ++++++++++++ src/dynamic/framework/adapters/xxe_ruby.rs | 81 ++++++++++++++++ 19 files changed, 934 insertions(+) diff --git a/src/dynamic/framework/adapters/header_go.rs b/src/dynamic/framework/adapters/header_go.rs index 18754dde..874b25f5 100644 --- a/src/dynamic/framework/adapters/header_go.rs +++ b/src/dynamic/framework/adapters/header_go.rs @@ -37,6 +37,20 @@ fn source_imports_go_http(file_bytes: &[u8]) -> bool { .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) } +/// Returns `true` when the surrounding source visibly routes the +/// header value through a canonical Go URL-encoder / HTML-escaper. +fn value_routed_through_encoder(file_bytes: &[u8]) -> bool { + const ENCODER_CALLS: &[&[u8]] = &[ + b"url.QueryEscape(", + b"url.PathEscape(", + b"template.HTMLEscapeString(", + b"template.JSEscapeString(", + ]; + ENCODER_CALLS + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + impl FrameworkAdapter for HeaderGoAdapter { fn name(&self) -> &'static str { ADAPTER_NAME @@ -52,6 +66,9 @@ impl FrameworkAdapter for HeaderGoAdapter { _ast: tree_sitter::Node<'_>, file_bytes: &[u8], ) -> Option { + if value_routed_through_encoder(file_bytes) { + return None; + } let matches_call = super::any_callee_matches(summary, callee_is_header_setter); let matches_source = source_imports_go_http(file_bytes); if matches_call && matches_source { @@ -107,4 +124,22 @@ mod tests { .detect(&summary, tree.root_node(), src) .is_none()); } + + #[test] + fn skips_when_value_url_encoded() { + let src: &[u8] = b"package x\nimport (\"net/http\"; \"net/url\")\n\ + func Run(w http.ResponseWriter, v string) { w.Header().Set(\"X-Token\", url.QueryEscape(v)) }\n"; + let tree = parse_go(src); + let summary = FuncSummary { + name: "Run".into(), + callees: vec![ + crate::summary::CalleeSite::bare("Set"), + crate::summary::CalleeSite::bare("QueryEscape"), + ], + ..Default::default() + }; + assert!(HeaderGoAdapter + .detect(&summary, tree.root_node(), src) + .is_none()); + } } diff --git a/src/dynamic/framework/adapters/header_java.rs b/src/dynamic/framework/adapters/header_java.rs index b29aba57..124b6b04 100644 --- a/src/dynamic/framework/adapters/header_java.rs +++ b/src/dynamic/framework/adapters/header_java.rs @@ -33,6 +33,27 @@ fn source_imports_servlet(file_bytes: &[u8]) -> bool { .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) } +/// Returns `true` when the surrounding source visibly routes the +/// header value through a canonical URL-encoder / HTML-escaper. The +/// header-setter then receives a CRLF-free string and cannot smuggle +/// a second header. +fn value_routed_through_encoder(file_bytes: &[u8]) -> bool { + const ENCODER_CALLS: &[&[u8]] = &[ + b"URLEncoder.encode(", + b"Encode.forHtml(", + b"Encode.forHtmlAttribute(", + b"Encode.forUri(", + b"Encode.forUriComponent(", + b"escapeHtml(", + b"escapeHtml4(", + b"escapeXml(", + b"StringEscapeUtils.escape", + ]; + ENCODER_CALLS + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + impl FrameworkAdapter for HeaderJavaAdapter { fn name(&self) -> &'static str { ADAPTER_NAME @@ -48,6 +69,9 @@ impl FrameworkAdapter for HeaderJavaAdapter { _ast: tree_sitter::Node<'_>, file_bytes: &[u8], ) -> Option { + if value_routed_through_encoder(file_bytes) { + return None; + } let matches_call = super::any_callee_matches(summary, callee_is_header_setter); let matches_source = source_imports_servlet(file_bytes); if matches_call && matches_source { @@ -103,4 +127,24 @@ mod tests { .detect(&summary, tree.root_node(), src) .is_none()); } + + #[test] + fn skips_when_value_url_encoded() { + let src: &[u8] = b"import javax.servlet.http.HttpServletResponse;\n\ + import java.net.URLEncoder;\n\ + class C { void run(HttpServletResponse r, String v) throws Exception { \ + String safe = URLEncoder.encode(v, \"UTF-8\"); r.setHeader(\"X-Token\", safe); } }\n"; + let tree = parse_java(src); + let summary = FuncSummary { + name: "run".into(), + callees: vec![ + crate::summary::CalleeSite::bare("setHeader"), + crate::summary::CalleeSite::bare("encode"), + ], + ..Default::default() + }; + assert!(HeaderJavaAdapter + .detect(&summary, tree.root_node(), src) + .is_none()); + } } diff --git a/src/dynamic/framework/adapters/header_js.rs b/src/dynamic/framework/adapters/header_js.rs index e38e1fa2..52587f73 100644 --- a/src/dynamic/framework/adapters/header_js.rs +++ b/src/dynamic/framework/adapters/header_js.rs @@ -45,6 +45,20 @@ fn source_uses_node_http(file_bytes: &[u8]) -> bool { .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) } +/// Returns `true` when the surrounding source visibly routes the +/// header value through a canonical Node / browser URL-encoder. +fn value_routed_through_encoder(file_bytes: &[u8]) -> bool { + const ENCODER_CALLS: &[&[u8]] = &[ + b"encodeURIComponent(", + b"encodeURI(", + b"querystring.escape(", + b"qs.escape(", + ]; + ENCODER_CALLS + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + impl FrameworkAdapter for HeaderJsAdapter { fn name(&self) -> &'static str { ADAPTER_NAME @@ -60,6 +74,9 @@ impl FrameworkAdapter for HeaderJsAdapter { _ast: tree_sitter::Node<'_>, file_bytes: &[u8], ) -> Option { + if value_routed_through_encoder(file_bytes) { + return None; + } let matches_call = super::any_callee_matches(summary, callee_is_header_setter); let matches_source = source_uses_node_http(file_bytes); if matches_call && matches_source { @@ -115,4 +132,22 @@ mod tests { .detect(&summary, tree.root_node(), src) .is_none()); } + + #[test] + fn skips_when_value_url_encoded() { + let src: &[u8] = b"const http = require('http');\n\ + function run(res, value) { res.setHeader('Set-Cookie', encodeURIComponent(value)); }\n"; + let tree = parse_js(src); + let summary = FuncSummary { + name: "run".into(), + callees: vec![ + crate::summary::CalleeSite::bare("setHeader"), + crate::summary::CalleeSite::bare("encodeURIComponent"), + ], + ..Default::default() + }; + assert!(HeaderJsAdapter + .detect(&summary, tree.root_node(), src) + .is_none()); + } } diff --git a/src/dynamic/framework/adapters/header_php.rs b/src/dynamic/framework/adapters/header_php.rs index 07b79e7d..454997ac 100644 --- a/src/dynamic/framework/adapters/header_php.rs +++ b/src/dynamic/framework/adapters/header_php.rs @@ -37,6 +37,20 @@ fn source_uses_php_response(file_bytes: &[u8]) -> bool { .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) } +/// Returns `true` when the surrounding source visibly routes the +/// header value through a canonical PHP URL-encoder / HTML-escaper. +fn value_routed_through_encoder(file_bytes: &[u8]) -> bool { + const ENCODER_CALLS: &[&[u8]] = &[ + b"urlencode(", + b"rawurlencode(", + b"htmlspecialchars(", + b"htmlentities(", + ]; + ENCODER_CALLS + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + impl FrameworkAdapter for HeaderPhpAdapter { fn name(&self) -> &'static str { ADAPTER_NAME @@ -52,6 +66,9 @@ impl FrameworkAdapter for HeaderPhpAdapter { _ast: tree_sitter::Node<'_>, file_bytes: &[u8], ) -> Option { + if value_routed_through_encoder(file_bytes) { + return None; + } let matches_call = super::any_callee_matches(summary, callee_is_header_setter); let matches_source = source_uses_php_response(file_bytes); if matches_call && matches_source { @@ -106,4 +123,22 @@ mod tests { .detect(&summary, tree.root_node(), src) .is_none()); } + + #[test] + fn skips_when_value_url_encoded() { + let src: &[u8] = + b" bool { .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) } +/// Returns `true` when the surrounding source visibly routes the +/// header value through a canonical URL-encoder / HTML-escaper. +fn value_routed_through_encoder(file_bytes: &[u8]) -> bool { + const ENCODER_CALLS: &[&[u8]] = &[ + b"urllib.parse.quote(", + b"parse.quote(", + b"urllib.parse.quote_plus(", + b"parse.quote_plus(", + b"quote_plus(", + b"werkzeug.urls.url_quote(", + b"url_quote(", + b"urlencode(", + b"html.escape(", + b"markupsafe.escape(", + b"escape_html(", + ]; + ENCODER_CALLS + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + impl FrameworkAdapter for HeaderPythonAdapter { fn name(&self) -> &'static str { ADAPTER_NAME @@ -54,6 +75,9 @@ impl FrameworkAdapter for HeaderPythonAdapter { _ast: tree_sitter::Node<'_>, file_bytes: &[u8], ) -> Option { + if value_routed_through_encoder(file_bytes) { + return None; + } let matches_call = super::any_callee_matches(summary, callee_is_header_setter); let matches_source = source_imports_python_web(file_bytes); if matches_call && matches_source { @@ -109,4 +133,24 @@ mod tests { .detect(&summary, tree.root_node(), src) .is_none()); } + + #[test] + fn skips_when_value_url_encoded() { + let src: &[u8] = b"from flask import make_response\n\ + from urllib.parse import quote\n\ + def run(value):\n resp = make_response('hi')\n \ + resp.headers['Set-Cookie'] = quote_plus(value)\n return resp\n"; + let tree = parse_python(src); + let summary = FuncSummary { + name: "run".into(), + callees: vec![ + crate::summary::CalleeSite::bare("__setitem__"), + crate::summary::CalleeSite::bare("quote_plus"), + ], + ..Default::default() + }; + assert!(HeaderPythonAdapter + .detect(&summary, tree.root_node(), src) + .is_none()); + } } diff --git a/src/dynamic/framework/adapters/header_ruby.rs b/src/dynamic/framework/adapters/header_ruby.rs index d768edcd..54d3e4a6 100644 --- a/src/dynamic/framework/adapters/header_ruby.rs +++ b/src/dynamic/framework/adapters/header_ruby.rs @@ -38,6 +38,23 @@ fn source_uses_ruby_web(file_bytes: &[u8]) -> bool { .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) } +/// Returns `true` when the surrounding source visibly routes the +/// header value through a canonical Ruby URL-encoder / HTML-escaper. +fn value_routed_through_encoder(file_bytes: &[u8]) -> bool { + const ENCODER_CALLS: &[&[u8]] = &[ + b"URI.encode_www_form_component(", + b"encode_www_form_component(", + b"CGI.escape(", + b"CGI.escapeHTML(", + b"ERB::Util.url_encode(", + b"ERB::Util.h(", + b"Rack::Utils.escape(", + ]; + ENCODER_CALLS + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + impl FrameworkAdapter for HeaderRubyAdapter { fn name(&self) -> &'static str { ADAPTER_NAME @@ -53,6 +70,9 @@ impl FrameworkAdapter for HeaderRubyAdapter { _ast: tree_sitter::Node<'_>, file_bytes: &[u8], ) -> Option { + if value_routed_through_encoder(file_bytes) { + return None; + } let matches_call = super::any_callee_matches(summary, callee_is_header_setter); let matches_source = source_uses_ruby_web(file_bytes); if matches_call && matches_source { @@ -108,4 +128,23 @@ mod tests { .detect(&summary, tree.root_node(), src) .is_none()); } + + #[test] + fn skips_when_value_url_encoded() { + let src: &[u8] = b"require 'rack'\nrequire 'uri'\n\ + def run(value)\n response = Rack::Response.new\n \ + response.set_header('Set-Cookie', URI.encode_www_form_component(value))\nend\n"; + let tree = parse_ruby(src); + let summary = FuncSummary { + name: "run".into(), + callees: vec![ + crate::summary::CalleeSite::bare("set_header"), + crate::summary::CalleeSite::bare("encode_www_form_component"), + ], + ..Default::default() + }; + assert!(HeaderRubyAdapter + .detect(&summary, tree.root_node(), src) + .is_none()); + } } diff --git a/src/dynamic/framework/adapters/header_rust.rs b/src/dynamic/framework/adapters/header_rust.rs index de7ad104..d7d21511 100644 --- a/src/dynamic/framework/adapters/header_rust.rs +++ b/src/dynamic/framework/adapters/header_rust.rs @@ -39,6 +39,20 @@ fn source_imports_rust_http(file_bytes: &[u8]) -> bool { .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) } +/// Returns `true` when the surrounding source visibly routes the +/// header value through a canonical Rust URL-encoder. +fn value_routed_through_encoder(file_bytes: &[u8]) -> bool { + const ENCODER_CALLS: &[&[u8]] = &[ + b"utf8_percent_encode(", + b"percent_encode(", + b"urlencoding::encode(", + b"form_urlencoded::byte_serialize(", + ]; + ENCODER_CALLS + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + impl FrameworkAdapter for HeaderRustAdapter { fn name(&self) -> &'static str { ADAPTER_NAME @@ -54,6 +68,9 @@ impl FrameworkAdapter for HeaderRustAdapter { _ast: tree_sitter::Node<'_>, file_bytes: &[u8], ) -> Option { + if value_routed_through_encoder(file_bytes) { + return None; + } let matches_call = super::any_callee_matches(summary, callee_is_header_setter); let matches_source = source_imports_rust_http(file_bytes); if matches_call && matches_source { @@ -109,4 +126,26 @@ mod tests { .detect(&summary, tree.root_node(), src) .is_none()); } + + #[test] + fn skips_when_value_url_encoded() { + let src: &[u8] = b"use axum::http::HeaderMap;\n\ + use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};\n\ + fn run(headers: &mut HeaderMap, value: &str) {\n\ + let safe = utf8_percent_encode(value, NON_ALPHANUMERIC).to_string();\n\ + headers.insert(\"set-cookie\", safe.parse().unwrap());\n\ + }\n"; + let tree = parse_rust(src); + let summary = FuncSummary { + name: "run".into(), + callees: vec![ + crate::summary::CalleeSite::bare("insert"), + crate::summary::CalleeSite::bare("utf8_percent_encode"), + ], + ..Default::default() + }; + assert!(HeaderRustAdapter + .detect(&summary, tree.root_node(), src) + .is_none()); + } } diff --git a/src/dynamic/framework/adapters/redirect_go.rs b/src/dynamic/framework/adapters/redirect_go.rs index ddfbba37..ff92e5be 100644 --- a/src/dynamic/framework/adapters/redirect_go.rs +++ b/src/dynamic/framework/adapters/redirect_go.rs @@ -31,6 +31,38 @@ fn source_imports_go_web(file_bytes: &[u8]) -> bool { .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) } +/// Returns `true` when the surrounding source visibly routes the +/// redirect URL through a canonical host-allowlist / URL-validator. +fn url_routed_through_validator(file_bytes: &[u8]) -> bool { + const VALIDATOR_TOKENS: &[&[u8]] = &[ + b"url.Parse(", + b"allowedHosts", + b"AllowedHosts", + b"allowlist", + b"Allowlist", + b".Host ==", + b".Hostname() ==", + ]; + VALIDATOR_TOKENS + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + +/// Returns `true` when the surrounding source looks like a mockgen- +/// generated mock (`gomock` / `EXPECT()` chains). The `Redirect` +/// callee on those receivers is a recorded-call assertion, not an +/// HTTP redirect. +fn looks_like_mockgen(file_bytes: &[u8]) -> bool { + const MOCK_TOKENS: &[&[u8]] = &[ + b"github.com/golang/mock/gomock", + b"go.uber.org/mock/gomock", + b".EXPECT().", + ]; + MOCK_TOKENS + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + impl FrameworkAdapter for RedirectGoAdapter { fn name(&self) -> &'static str { ADAPTER_NAME @@ -46,6 +78,9 @@ impl FrameworkAdapter for RedirectGoAdapter { _ast: tree_sitter::Node<'_>, file_bytes: &[u8], ) -> Option { + if looks_like_mockgen(file_bytes) || url_routed_through_validator(file_bytes) { + return None; + } let matches_call = super::any_callee_matches(summary, callee_is_redirect); let matches_source = source_imports_go_web(file_bytes); if matches_call && matches_source { @@ -101,4 +136,40 @@ mod tests { .detect(&summary, tree.root_node(), src) .is_none()); } + + #[test] + fn skips_when_url_validated_against_allowlist() { + let src: &[u8] = b"package vuln\n\nimport (\n\t\"net/http\"\n\t\"net/url\"\n\t\"github.com/gin-gonic/gin\"\n)\n\ + func Run(c *gin.Context, v string) {\n\t\ + u, err := url.Parse(v)\n\t\ + if err != nil || u.Hostname() != \"example.com\" { return }\n\t\ + c.Redirect(http.StatusFound, v)\n}\n"; + let tree = parse_go(src); + let summary = FuncSummary { + name: "Run".into(), + callees: vec![ + crate::summary::CalleeSite::bare("Redirect"), + crate::summary::CalleeSite::bare("Parse"), + ], + ..Default::default() + }; + assert!(RedirectGoAdapter + .detect(&summary, tree.root_node(), src) + .is_none()); + } + + #[test] + fn skips_when_file_uses_gomock() { + let src: &[u8] = b"package vuln\n\nimport (\n\t\"github.com/golang/mock/gomock\"\n)\n\ + func Run(m *MockRouter, v string) {\n\tm.EXPECT().Redirect(v)\n}\n"; + let tree = parse_go(src); + let summary = FuncSummary { + name: "Run".into(), + callees: vec![crate::summary::CalleeSite::bare("Redirect")], + ..Default::default() + }; + assert!(RedirectGoAdapter + .detect(&summary, tree.root_node(), src) + .is_none()); + } } diff --git a/src/dynamic/framework/adapters/redirect_java.rs b/src/dynamic/framework/adapters/redirect_java.rs index 1ba3c36a..83cd704f 100644 --- a/src/dynamic/framework/adapters/redirect_java.rs +++ b/src/dynamic/framework/adapters/redirect_java.rs @@ -33,6 +33,25 @@ fn source_imports_servlet(file_bytes: &[u8]) -> bool { .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) } +/// Returns `true` when the surrounding source visibly routes the +/// redirect URL through a canonical host-allowlist / URL-validator +/// helper, so the redirect cannot reach an off-origin attacker host. +fn url_routed_through_validator(file_bytes: &[u8]) -> bool { + const VALIDATOR_TOKENS: &[&[u8]] = &[ + b"UrlValidator", + b".isValid(", + b"allowedHosts", + b"allowlist", + b"allowList", + b"WHITELIST", + b"isAllowedHost", + b"isAllowedRedirect", + ]; + VALIDATOR_TOKENS + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + impl FrameworkAdapter for RedirectJavaAdapter { fn name(&self) -> &'static str { ADAPTER_NAME @@ -48,6 +67,9 @@ impl FrameworkAdapter for RedirectJavaAdapter { _ast: tree_sitter::Node<'_>, file_bytes: &[u8], ) -> Option { + if url_routed_through_validator(file_bytes) { + return None; + } let matches_call = super::any_callee_matches(summary, callee_is_redirect); let matches_source = source_imports_servlet(file_bytes); if matches_call && matches_source { @@ -103,4 +125,27 @@ mod tests { .detect(&summary, tree.root_node(), src) .is_none()); } + + #[test] + fn skips_when_url_validated_against_allowlist() { + let src: &[u8] = b"import javax.servlet.http.HttpServletResponse;\n\ + import org.apache.commons.validator.routines.UrlValidator;\n\ + class C { void run(HttpServletResponse r, String v) throws Exception {\n\ + UrlValidator vd = new UrlValidator();\n\ + if (!vd.isValid(v)) return;\n\ + r.sendRedirect(v);\n\ + } }\n"; + let tree = parse_java(src); + let summary = FuncSummary { + name: "run".into(), + callees: vec![ + crate::summary::CalleeSite::bare("sendRedirect"), + crate::summary::CalleeSite::bare("isValid"), + ], + ..Default::default() + }; + assert!(RedirectJavaAdapter + .detect(&summary, tree.root_node(), src) + .is_none()); + } } diff --git a/src/dynamic/framework/adapters/redirect_js.rs b/src/dynamic/framework/adapters/redirect_js.rs index a87e00e9..df462828 100644 --- a/src/dynamic/framework/adapters/redirect_js.rs +++ b/src/dynamic/framework/adapters/redirect_js.rs @@ -38,6 +38,24 @@ fn source_imports_node_web(file_bytes: &[u8]) -> bool { .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) } +/// Returns `true` when the surrounding source visibly routes the +/// redirect URL through a canonical host-allowlist / URL-validator. +fn url_routed_through_validator(file_bytes: &[u8]) -> bool { + const VALIDATOR_TOKENS: &[&[u8]] = &[ + b"new URL(", + b"allowedHosts", + b"allowedOrigins", + b"allowlist", + b"ALLOWLIST", + b".hostname ===", + b".origin ===", + b".host ===", + ]; + VALIDATOR_TOKENS + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + impl FrameworkAdapter for RedirectJsAdapter { fn name(&self) -> &'static str { ADAPTER_NAME @@ -53,6 +71,9 @@ impl FrameworkAdapter for RedirectJsAdapter { _ast: tree_sitter::Node<'_>, file_bytes: &[u8], ) -> Option { + if url_routed_through_validator(file_bytes) { + return None; + } let matches_call = super::any_callee_matches(summary, callee_is_redirect); let matches_source = source_imports_node_web(file_bytes); if matches_call && matches_source { @@ -108,4 +129,22 @@ mod tests { .detect(&summary, tree.root_node(), src) .is_none()); } + + #[test] + fn skips_when_url_validated_against_allowlist() { + let src: &[u8] = b"const express = require('express');\n\ + function run(req, res, v) {\n \ + const allowed = 'https://example.com';\n \ + if (new URL(v).origin !== allowed) return;\n \ + res.redirect(v);\n}\n"; + let tree = parse_js(src); + let summary = FuncSummary { + name: "run".into(), + callees: vec![crate::summary::CalleeSite::bare("redirect")], + ..Default::default() + }; + assert!(RedirectJsAdapter + .detect(&summary, tree.root_node(), src) + .is_none()); + } } diff --git a/src/dynamic/framework/adapters/redirect_php.rs b/src/dynamic/framework/adapters/redirect_php.rs index bfa56562..7cbec17e 100644 --- a/src/dynamic/framework/adapters/redirect_php.rs +++ b/src/dynamic/framework/adapters/redirect_php.rs @@ -38,6 +38,22 @@ fn source_imports_php_web(file_bytes: &[u8]) -> bool { .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) } +/// Returns `true` when the surrounding source visibly routes the +/// redirect URL through a canonical host-allowlist / URL-validator. +fn url_routed_through_validator(file_bytes: &[u8]) -> bool { + const VALIDATOR_TOKENS: &[&[u8]] = &[ + b"parse_url(", + b"allowedHosts", + b"allowed_hosts", + b"allowlist", + b"in_array(", + b"filter_var(", + ]; + VALIDATOR_TOKENS + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + impl FrameworkAdapter for RedirectPhpAdapter { fn name(&self) -> &'static str { ADAPTER_NAME @@ -53,6 +69,9 @@ impl FrameworkAdapter for RedirectPhpAdapter { _ast: tree_sitter::Node<'_>, file_bytes: &[u8], ) -> Option { + if url_routed_through_validator(file_bytes) { + return None; + } let matches_call = super::any_callee_matches(summary, callee_is_redirect); let matches_source = source_imports_php_web(file_bytes); if matches_call && matches_source { @@ -108,4 +127,26 @@ mod tests { .detect(&summary, tree.root_node(), src) .is_none()); } + + #[test] + fn skips_when_url_validated_against_allowlist() { + let src: &[u8] = b" bool { .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) } +/// Returns `true` when the surrounding source visibly routes the +/// redirect URL through a canonical host-allowlist / URL-validator. +fn url_routed_through_validator(file_bytes: &[u8]) -> bool { + const VALIDATOR_TOKENS: &[&[u8]] = &[ + b"is_safe_url(", + b"url_has_allowed_host_and_scheme(", + b"allowed_hosts", + b"ALLOWED_HOSTS", + b"ALLOWLIST", + b"allowlist", + b".netloc in ", + b".netloc.in_", + b"urlparse(", + b"url_parse(", + ]; + VALIDATOR_TOKENS + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + impl FrameworkAdapter for RedirectPythonAdapter { fn name(&self) -> &'static str { ADAPTER_NAME @@ -53,6 +73,9 @@ impl FrameworkAdapter for RedirectPythonAdapter { _ast: tree_sitter::Node<'_>, file_bytes: &[u8], ) -> Option { + if url_routed_through_validator(file_bytes) { + return None; + } let matches_call = super::any_callee_matches(summary, callee_is_redirect); let matches_source = source_imports_python_web(file_bytes); if matches_call && matches_source { @@ -108,4 +131,25 @@ mod tests { .detect(&summary, tree.root_node(), src) .is_none()); } + + #[test] + fn skips_when_url_validated_against_allowlist() { + let src: &[u8] = b"from flask import redirect\n\ + from django.utils.http import url_has_allowed_host_and_scheme\n\ + def run(value):\n \ + if not url_has_allowed_host_and_scheme(value, allowed_hosts={'example.com'}):\n \ + return None\n return redirect(value)\n"; + let tree = parse_python(src); + let summary = FuncSummary { + name: "run".into(), + callees: vec![ + crate::summary::CalleeSite::bare("redirect"), + crate::summary::CalleeSite::bare("url_has_allowed_host_and_scheme"), + ], + ..Default::default() + }; + assert!(RedirectPythonAdapter + .detect(&summary, tree.root_node(), src) + .is_none()); + } } diff --git a/src/dynamic/framework/adapters/redirect_ruby.rs b/src/dynamic/framework/adapters/redirect_ruby.rs index ac2d944b..7b7b7cbb 100644 --- a/src/dynamic/framework/adapters/redirect_ruby.rs +++ b/src/dynamic/framework/adapters/redirect_ruby.rs @@ -36,6 +36,24 @@ fn source_imports_ruby_web(file_bytes: &[u8]) -> bool { .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) } +/// Returns `true` when the surrounding source visibly routes the +/// redirect URL through a canonical host-allowlist / URL-validator. +fn url_routed_through_validator(file_bytes: &[u8]) -> bool { + const VALIDATOR_TOKENS: &[&[u8]] = &[ + b"URI.parse(", + b"URI(", + b"allowed_hosts", + b"ALLOWED_HOSTS", + b"allowlist", + b"ALLOWLIST", + b".host ==", + b".host?(", + ]; + VALIDATOR_TOKENS + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + impl FrameworkAdapter for RedirectRubyAdapter { fn name(&self) -> &'static str { ADAPTER_NAME @@ -51,6 +69,9 @@ impl FrameworkAdapter for RedirectRubyAdapter { _ast: tree_sitter::Node<'_>, file_bytes: &[u8], ) -> Option { + if url_routed_through_validator(file_bytes) { + return None; + } let matches_call = super::any_callee_matches(summary, callee_is_redirect); let matches_source = source_imports_ruby_web(file_bytes); if matches_call && matches_source { @@ -106,4 +127,25 @@ mod tests { .detect(&summary, tree.root_node(), src) .is_none()); } + + #[test] + fn skips_when_url_validated_against_allowlist() { + let src: &[u8] = b"require 'rack'\nrequire 'uri'\n\ + def run(value)\n allowed_hosts = ['example.com']\n \ + host = URI.parse(value).host\n \ + return unless allowed_hosts.include?(host)\n \ + resp = Rack::Response.new\n resp.redirect(value)\n resp\nend\n"; + let tree = parse_ruby(src); + let summary = FuncSummary { + name: "run".into(), + callees: vec![ + crate::summary::CalleeSite::bare("redirect"), + crate::summary::CalleeSite::bare("parse"), + ], + ..Default::default() + }; + assert!(RedirectRubyAdapter + .detect(&summary, tree.root_node(), src) + .is_none()); + } } diff --git a/src/dynamic/framework/adapters/redirect_rust.rs b/src/dynamic/framework/adapters/redirect_rust.rs index 2ec10425..e1f6cf6b 100644 --- a/src/dynamic/framework/adapters/redirect_rust.rs +++ b/src/dynamic/framework/adapters/redirect_rust.rs @@ -37,6 +37,23 @@ fn source_imports_rust_web(file_bytes: &[u8]) -> bool { .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) } +/// Returns `true` when the surrounding source visibly routes the +/// redirect URL through a canonical host-allowlist / URL-validator. +fn url_routed_through_validator(file_bytes: &[u8]) -> bool { + const VALIDATOR_TOKENS: &[&[u8]] = &[ + b"Url::parse(", + b"allowed_hosts", + b"AllowedHosts", + b"allowlist", + b"Allowlist", + b".host_str()", + b".host() ==", + ]; + VALIDATOR_TOKENS + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + impl FrameworkAdapter for RedirectRustAdapter { fn name(&self) -> &'static str { ADAPTER_NAME @@ -52,6 +69,9 @@ impl FrameworkAdapter for RedirectRustAdapter { _ast: tree_sitter::Node<'_>, file_bytes: &[u8], ) -> Option { + if url_routed_through_validator(file_bytes) { + return None; + } let matches_call = super::any_callee_matches(summary, callee_is_redirect); let matches_source = source_imports_rust_web(file_bytes); if matches_call && matches_source { @@ -107,4 +127,26 @@ mod tests { .detect(&summary, tree.root_node(), src) .is_none()); } + + #[test] + fn skips_when_url_validated_against_allowlist() { + let src: &[u8] = b"use axum::response::Redirect;\n\ + use url::Url;\n\n\ + fn run(v: String) -> Option {\n\ + let u = Url::parse(&v).ok()?;\n\ + if u.host_str() != Some(\"example.com\") { return None; }\n\ + Some(Redirect::to(&v))\n}\n"; + let tree = parse_rust(src); + let summary = FuncSummary { + name: "run".into(), + callees: vec![ + crate::summary::CalleeSite::bare("to"), + crate::summary::CalleeSite::bare("parse"), + ], + ..Default::default() + }; + assert!(RedirectRustAdapter + .detect(&summary, tree.root_node(), src) + .is_none()); + } } diff --git a/src/dynamic/framework/adapters/xxe_go.rs b/src/dynamic/framework/adapters/xxe_go.rs index f1bdfae7..54f23628 100644 --- a/src/dynamic/framework/adapters/xxe_go.rs +++ b/src/dynamic/framework/adapters/xxe_go.rs @@ -36,6 +36,23 @@ fn source_imports_xml(file_bytes: &[u8]) -> bool { .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) } +/// Returns `true` when the surrounding source visibly pins +/// `encoding/xml`'s `Decoder.Strict` to `true` (Go's safe-by-default +/// XML parser does not resolve external entities, but the brief +/// flags `Strict = false` as the XXE-prone shape, so explicit +/// `Strict = true` declarations are the canonical hardening marker). +fn parser_is_hardened(file_bytes: &[u8]) -> bool { + const HARDENING_NEEDLES: &[&[u8]] = &[ + b"Strict: true", + b"Strict:true", + b".Strict = true", + b".Strict=true", + ]; + HARDENING_NEEDLES + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + impl FrameworkAdapter for XxeGoAdapter { fn name(&self) -> &'static str { ADAPTER_NAME @@ -51,6 +68,9 @@ impl FrameworkAdapter for XxeGoAdapter { _ast: tree_sitter::Node<'_>, file_bytes: &[u8], ) -> Option { + if parser_is_hardened(file_bytes) { + return None; + } let matches_call = super::any_callee_matches(summary, callee_is_xml_parser); let matches_source = source_imports_xml(file_bytes); if matches_call && matches_source { @@ -110,4 +130,23 @@ mod tests { .detect(&summary, tree.root_node(), src) .is_none()); } + + #[test] + fn skips_when_decoder_strict_pinned_true() { + let src: &[u8] = b"package main\nimport (\"bytes\"; \"encoding/xml\")\n\ + func Run(body string) {\n\ + d := xml.NewDecoder(bytes.NewReader([]byte(body)))\n\ + d.Strict = true\n\ + _ = d.Decode(&struct{}{})\n\ + }\n"; + let tree = parse_go(src); + let summary = FuncSummary { + name: "Run".into(), + callees: vec![crate::summary::CalleeSite::bare("NewDecoder")], + ..Default::default() + }; + assert!(XxeGoAdapter + .detect(&summary, tree.root_node(), src) + .is_none()); + } } diff --git a/src/dynamic/framework/adapters/xxe_java.rs b/src/dynamic/framework/adapters/xxe_java.rs index 57b02f81..11f3bc3f 100644 --- a/src/dynamic/framework/adapters/xxe_java.rs +++ b/src/dynamic/framework/adapters/xxe_java.rs @@ -45,6 +45,32 @@ fn source_imports_xml_parser(file_bytes: &[u8]) -> bool { .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) } +/// Returns `true` when the surrounding source visibly hardens the +/// XML parser against external-entity / DTD expansion. Conservative: +/// only recognises hardening invocations in their canonical +/// syntactic form (quoted feature URIs or full call expressions) so +/// the detector ignores casual prose mentions in Javadoc / line +/// comments. False negatives turn into adapter fires, which the +/// rest of the pipeline still double-checks; false positives would +/// silently drop a real finding. +fn parser_is_hardened(file_bytes: &[u8]) -> bool { + const HARDENING_NEEDLES: &[&[u8]] = &[ + b"\"http://apache.org/xml/features/disallow-doctype-decl\"", + b"setFeature(XMLConstants.FEATURE_SECURE_PROCESSING", + b"setFeature( XMLConstants.FEATURE_SECURE_PROCESSING", + b"setExpandEntityReferences(false)", + b"setExpandEntityReferences (false)", + b"\"http://xml.org/sax/features/external-general-entities\"", + b"\"http://xml.org/sax/features/external-parameter-entities\"", + b"XMLConstants.ACCESS_EXTERNAL_DTD,", + b"XMLConstants.ACCESS_EXTERNAL_SCHEMA,", + b"setXIncludeAware(false)", + ]; + HARDENING_NEEDLES + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + impl FrameworkAdapter for XxeJavaAdapter { fn name(&self) -> &'static str { ADAPTER_NAME @@ -60,6 +86,9 @@ impl FrameworkAdapter for XxeJavaAdapter { _ast: tree_sitter::Node<'_>, file_bytes: &[u8], ) -> Option { + if parser_is_hardened(file_bytes) { + return None; + } let matches_call = super::any_callee_matches(summary, callee_is_xml_parse); let matches_source = source_imports_xml_parser(file_bytes); if matches_call && matches_source { @@ -136,4 +165,43 @@ mod tests { .detect(&summary, tree.root_node(), src) .is_none()); } + + #[test] + fn skips_when_disallow_doctype_decl_set() { + let src: &[u8] = b"import javax.xml.parsers.DocumentBuilderFactory;\n\ + public class V {\n public static void run(byte[] b) throws Exception {\n\ + DocumentBuilderFactory f = DocumentBuilderFactory.newInstance();\n\ + f.setFeature(\"http://apache.org/xml/features/disallow-doctype-decl\", true);\n\ + f.newDocumentBuilder().parse(new java.io.ByteArrayInputStream(b));\n\ + }\n}\n"; + let tree = parse_java(src); + let summary = FuncSummary { + name: "run".into(), + callees: vec![crate::summary::CalleeSite::bare("parse")], + ..Default::default() + }; + assert!(XxeJavaAdapter + .detect(&summary, tree.root_node(), src) + .is_none()); + } + + #[test] + fn skips_when_feature_secure_processing_set() { + let src: &[u8] = b"import javax.xml.parsers.DocumentBuilderFactory;\n\ + import javax.xml.XMLConstants;\n\ + public class V {\n public static void run(byte[] b) throws Exception {\n\ + DocumentBuilderFactory f = DocumentBuilderFactory.newInstance();\n\ + f.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);\n\ + f.newDocumentBuilder().parse(new java.io.ByteArrayInputStream(b));\n\ + }\n}\n"; + let tree = parse_java(src); + let summary = FuncSummary { + name: "run".into(), + callees: vec![crate::summary::CalleeSite::bare("parse")], + ..Default::default() + }; + assert!(XxeJavaAdapter + .detect(&summary, tree.root_node(), src) + .is_none()); + } } diff --git a/src/dynamic/framework/adapters/xxe_php.rs b/src/dynamic/framework/adapters/xxe_php.rs index 7c9c2294..74346202 100644 --- a/src/dynamic/framework/adapters/xxe_php.rs +++ b/src/dynamic/framework/adapters/xxe_php.rs @@ -48,6 +48,47 @@ fn source_imports_xml(file_bytes: &[u8]) -> bool { .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) } +/// Returns `true` when the surrounding source visibly hardens the +/// libxml-backed PHP parser against external-entity expansion. PHP +/// 8.0+ disables the entity loader by default, so the absence of the +/// `LIBXML_NOENT` flag combined with `libxml_disable_entity_loader(true)` +/// (the canonical PHP < 8.0 hardener) or the `LIBXML_NONET` flag is +/// the canonical safe shape. +fn parser_is_hardened(file_bytes: &[u8]) -> bool { + // If LIBXML_NOENT is explicitly used, the parser is *un*-hardened + // (the flag asks libxml to substitute entities). Treat as unsafe + // regardless of any other tokens. + let mentions_noent = file_bytes + .windows(b"LIBXML_NOENT".len()) + .any(|w| w == b"LIBXML_NOENT"); + if mentions_noent { + return false; + } + const HARDENING_NEEDLES: &[&[u8]] = &[ + b"libxml_disable_entity_loader(true)", + b"libxml_disable_entity_loader(TRUE)", + b"libxml_disable_entity_loader( true", + b"libxml_disable_entity_loader( TRUE", + b"LIBXML_NONET", + b"LIBXML_DTDLOAD", + ]; + // LIBXML_DTDLOAD on its own is neutral but commonly paired with + // explicit hardening; require at least one of the disable_entity + // / NONET tokens for a hardening verdict. + const STRONG: &[&[u8]] = &[ + b"libxml_disable_entity_loader(true)", + b"libxml_disable_entity_loader(TRUE)", + b"libxml_disable_entity_loader( true", + b"libxml_disable_entity_loader( TRUE", + b"LIBXML_NONET", + ]; + let has_strong = STRONG + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)); + let _ = HARDENING_NEEDLES; // retained for documentation of recognised tokens + has_strong +} + impl FrameworkAdapter for XxePhpAdapter { fn name(&self) -> &'static str { ADAPTER_NAME @@ -63,6 +104,9 @@ impl FrameworkAdapter for XxePhpAdapter { _ast: tree_sitter::Node<'_>, file_bytes: &[u8], ) -> Option { + if parser_is_hardened(file_bytes) { + return None; + } let matches_call = super::any_callee_matches(summary, callee_is_xml_parser); let matches_source = source_imports_xml(file_bytes); if matches_call || matches_source { @@ -117,4 +161,53 @@ mod tests { .detect(&summary, tree.root_node(), src) .is_none()); } + + #[test] + fn skips_when_disable_entity_loader_true() { + let src: &[u8] = b" bool { .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) } +/// Returns `true` when the surrounding source visibly hardens the +/// XML parser against external-entity expansion. Conservative: only +/// recognises canonical lxml `resolve_entities=False` / +/// `no_network=True` parser flags and the `defusedxml` package +/// (whose parsers are safe-by-default). +fn parser_is_hardened(file_bytes: &[u8]) -> bool { + const HARDENING_NEEDLES: &[&[u8]] = &[ + b"resolve_entities=False", + b"resolve_entities =False", + b"resolve_entities= False", + b"resolve_entities = False", + b"no_network=True", + b"no_network =True", + b"no_network= True", + b"no_network = True", + b"from defusedxml", + b"import defusedxml", + ]; + HARDENING_NEEDLES + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + impl FrameworkAdapter for XxePythonAdapter { fn name(&self) -> &'static str { ADAPTER_NAME @@ -62,6 +85,9 @@ impl FrameworkAdapter for XxePythonAdapter { _ast: tree_sitter::Node<'_>, file_bytes: &[u8], ) -> Option { + if parser_is_hardened(file_bytes) { + return None; + } let matches_call = super::any_callee_matches(summary, callee_is_xml_parser); let matches_source = source_imports_xml(file_bytes); if matches_call && matches_source { @@ -117,4 +143,36 @@ mod tests { .detect(&summary, tree.root_node(), src) .is_none()); } + + #[test] + fn skips_when_resolve_entities_false() { + let src: &[u8] = b"from lxml import etree\n\ + def run(body):\n\ + parser = etree.XMLParser(resolve_entities=False, no_network=True)\n\ + return etree.fromstring(body, parser)\n"; + let tree = parse_python(src); + let summary = FuncSummary { + name: "run".into(), + callees: vec![crate::summary::CalleeSite::bare("fromstring")], + ..Default::default() + }; + assert!(XxePythonAdapter + .detect(&summary, tree.root_node(), src) + .is_none()); + } + + #[test] + fn skips_when_defusedxml_imported() { + let src: &[u8] = b"from defusedxml import ElementTree\n\ + def run(body):\n return ElementTree.fromstring(body)\n"; + let tree = parse_python(src); + let summary = FuncSummary { + name: "run".into(), + callees: vec![crate::summary::CalleeSite::bare("fromstring")], + ..Default::default() + }; + assert!(XxePythonAdapter + .detect(&summary, tree.root_node(), src) + .is_none()); + } } diff --git a/src/dynamic/framework/adapters/xxe_ruby.rs b/src/dynamic/framework/adapters/xxe_ruby.rs index 17043fad..077740a1 100644 --- a/src/dynamic/framework/adapters/xxe_ruby.rs +++ b/src/dynamic/framework/adapters/xxe_ruby.rs @@ -36,6 +36,38 @@ fn source_imports_xml(file_bytes: &[u8]) -> bool { .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) } +/// Returns `true` when the surrounding source visibly hardens the +/// Ruby XML parser against external-entity expansion. Canonical +/// hardeners: `REXML::Document.entity_expansion_limit = 0` (kills +/// entity expansion outright) and `Nokogiri::XML::ParseOptions::NONET` +/// (no network for entity resolution). +/// +/// If `Nokogiri::XML::ParseOptions::NOENT` is present the parser is +/// explicitly *un*-hardened (the flag asks Nokogiri to expand +/// entities), so the hardening verdict is suppressed. +fn parser_is_hardened(file_bytes: &[u8]) -> bool { + let mentions_noent = file_bytes + .windows(b"ParseOptions::NOENT".len()) + .any(|w| w == b"ParseOptions::NOENT") + || file_bytes + .windows(b"::NOENT".len()) + .any(|w| w == b"::NOENT"); + if mentions_noent { + return false; + } + const HARDENING_NEEDLES: &[&[u8]] = &[ + b"entity_expansion_limit = 0", + b"entity_expansion_limit=0", + b"entity_expansion_limit =0", + b"entity_expansion_limit= 0", + b"ParseOptions::NONET", + b"Nokogiri::XML::ParseOptions::NONET", + ]; + HARDENING_NEEDLES + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + impl FrameworkAdapter for XxeRubyAdapter { fn name(&self) -> &'static str { ADAPTER_NAME @@ -51,6 +83,9 @@ impl FrameworkAdapter for XxeRubyAdapter { _ast: tree_sitter::Node<'_>, file_bytes: &[u8], ) -> Option { + if parser_is_hardened(file_bytes) { + return None; + } let matches_call = super::any_callee_matches(summary, callee_is_xml_parser); let matches_source = source_imports_xml(file_bytes); if matches_call && matches_source { @@ -106,4 +141,50 @@ mod tests { .detect(&summary, tree.root_node(), src) .is_none()); } + + #[test] + fn skips_when_entity_expansion_limit_zero() { + let src: &[u8] = b"require 'rexml/document'\n\ + REXML::Document.entity_expansion_limit = 0\n\ + def run(body)\n REXML::Document.new(body)\nend\n"; + let tree = parse_ruby(src); + let summary = FuncSummary { + name: "run".into(), + callees: vec![crate::summary::CalleeSite::bare("new")], + ..Default::default() + }; + assert!(XxeRubyAdapter + .detect(&summary, tree.root_node(), src) + .is_none()); + } + + #[test] + fn skips_when_nokogiri_nonet_used() { + let src: &[u8] = b"require 'nokogiri'\n\ + def run(body)\n Nokogiri::XML(body) { |c| c.options = Nokogiri::XML::ParseOptions::NONET }\nend\n"; + let tree = parse_ruby(src); + let summary = FuncSummary { + name: "run".into(), + callees: vec![crate::summary::CalleeSite::bare("XML")], + ..Default::default() + }; + assert!(XxeRubyAdapter + .detect(&summary, tree.root_node(), src) + .is_none()); + } + + #[test] + fn still_fires_when_nokogiri_noent_present() { + let src: &[u8] = b"require 'nokogiri'\n\ + def run(body)\n Nokogiri::XML(body) { |c| c.options = Nokogiri::XML::ParseOptions::NOENT | Nokogiri::XML::ParseOptions::DTDLOAD }\nend\n"; + let tree = parse_ruby(src); + let summary = FuncSummary { + name: "run".into(), + callees: vec![crate::summary::CalleeSite::bare("XML")], + ..Default::default() + }; + assert!(XxeRubyAdapter + .detect(&summary, tree.root_node(), src) + .is_some()); + } }