diff --git a/src/dynamic/framework/adapters/crypto_php.rs b/src/dynamic/framework/adapters/crypto_php.rs new file mode 100644 index 00000000..159f77b4 --- /dev/null +++ b/src/dynamic/framework/adapters/crypto_php.rs @@ -0,0 +1,212 @@ +//! PHP [`super::super::FrameworkAdapter`] matching weak-crypto sink +//! constructions (`md5()` / `sha1()` for message digests, +//! `mt_rand()` / `rand()` for key material, `mcrypt_encrypt()` and +//! `mcrypt_create_iv()` legacy primitives, `hash('md5'|'sha1', …)`). +//! +//! Phase 11 (Track L.9). Fires when the function body invokes one +//! of the canonical PHP weak-crypto entry points and the surrounding +//! source is plausibly a PHP script (starts with ` bool { + let last = name + .rsplit_once("::") + .map(|(_, s)| s) + .unwrap_or(name) + .rsplit_once('\\') + .map(|(_, s)| s) + .unwrap_or(name); + let last = last.rsplit_once('.').map(|(_, s)| s).unwrap_or(last); + matches!( + last, + "md5" + | "sha1" + | "md5_file" + | "sha1_file" + | "mt_rand" + | "rand" + | "mt_srand" + | "srand" + | "crc32" + | "mcrypt_create_iv" + | "mcrypt_encrypt" + | "mcrypt_decrypt" + | "uniqid" + ) +} + +fn source_is_php_script(file_bytes: &[u8]) -> bool { + const NEEDLES: &[&[u8]] = &[b" bool { + const NEEDLES: &[&[u8]] = &[ + b"random_bytes(", + b"random_int(", + b"openssl_random_pseudo_bytes(", + b"sodium_crypto_", + b"hash('sha256'", + b"hash(\"sha256\"", + b"hash('sha384'", + b"hash(\"sha384\"", + b"hash('sha512'", + b"hash(\"sha512\"", + b"hash('sha3-256'", + b"hash(\"sha3-256\"", + b"'aes-256-gcm'", + b"\"aes-256-gcm\"", + b"'chacha20-poly1305'", + b"\"chacha20-poly1305\"", + b"password_hash(", + ]; + NEEDLES + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + +impl FrameworkAdapter for CryptoPhpAdapter { + fn name(&self) -> &'static str { + ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::Php + } + + fn detect( + &self, + summary: &FuncSummary, + _ast: tree_sitter::Node<'_>, + file_bytes: &[u8], + ) -> Option { + if source_routed_through_strong_path(file_bytes) { + return None; + } + let matches_call = super::any_callee_matches(summary, callee_is_weak_crypto); + let matches_source = source_is_php_script(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 + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_php(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_php::LANGUAGE_PHP); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() + } + + #[test] + fn fires_on_md5() { + let src: &[u8] = + b" 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, + "hexdigest" | "digest" | "base64digest" | "file" | "rand" | "srand" + ) || matches!( + name, + "Digest::MD5.hexdigest" + | "Digest::MD5.digest" + | "Digest::MD5.base64digest" + | "Digest::MD5.new" + | "Digest::SHA1.hexdigest" + | "Digest::SHA1.digest" + | "Digest::SHA1.base64digest" + | "Digest::SHA1.new" + | "OpenSSL::Digest.new" + | "OpenSSL::Digest::MD5.new" + | "OpenSSL::Digest::SHA1.new" + | "OpenSSL::HMAC.digest" + | "OpenSSL::HMAC.hexdigest" + | "Random.rand" + | "Kernel.rand" + | "Kernel.srand" + ) +} + +fn source_imports_ruby_crypto(file_bytes: &[u8]) -> bool { + const NEEDLES: &[&[u8]] = &[ + b"require 'digest'", + b"require \"digest\"", + b"require 'digest/md5'", + b"require \"digest/md5\"", + b"require 'digest/sha1'", + b"require \"digest/sha1\"", + b"require 'openssl'", + b"require \"openssl\"", + b"Digest::MD5", + b"Digest::SHA1", + b"OpenSSL::Digest", + b"OpenSSL::HMAC", + ]; + NEEDLES + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + +/// Returns `true` when the surrounding source visibly routes the +/// crypto call through a hardened path (`SecureRandom`, SHA-256+, +/// `OpenSSL::Cipher.new("AES-256-GCM")`, libsodium). +fn source_routed_through_strong_path(file_bytes: &[u8]) -> bool { + const NEEDLES: &[&[u8]] = &[ + b"require 'securerandom'", + b"require \"securerandom\"", + b"SecureRandom.", + b"Digest::SHA256", + b"Digest::SHA384", + b"Digest::SHA512", + b"\"SHA256\"", + b"'SHA256'", + b"\"SHA-256\"", + b"'SHA-256'", + b"\"SHA384\"", + b"\"SHA512\"", + b"\"AES-256-GCM\"", + b"'AES-256-GCM'", + b"\"ChaCha20-Poly1305\"", + b"RbNaCl::", + ]; + NEEDLES + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + +impl FrameworkAdapter for CryptoRubyAdapter { + fn name(&self) -> &'static str { + ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::Ruby + } + + fn detect( + &self, + summary: &FuncSummary, + _ast: tree_sitter::Node<'_>, + file_bytes: &[u8], + ) -> Option { + if source_routed_through_strong_path(file_bytes) { + return None; + } + let matches_call = super::any_callee_matches(summary, callee_is_weak_crypto); + let matches_source = source_imports_ruby_crypto(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 + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_ruby(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_ruby::LANGUAGE); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() + } + + #[test] + fn fires_on_digest_md5_hexdigest() { + let src: &[u8] = b"require 'digest'\n\ + def sign(value)\n Digest::MD5.hexdigest(value)\nend\n"; + let tree = parse_ruby(src); + let summary = FuncSummary { + name: "sign".into(), + callees: vec![crate::summary::CalleeSite::bare("Digest::MD5.hexdigest")], + ..Default::default() + }; + assert!( + CryptoRubyAdapter + .detect(&summary, tree.root_node(), src) + .is_some() + ); + } + + #[test] + fn fires_on_openssl_hmac_md5() { + let src: &[u8] = b"require 'openssl'\n\ + def sign(key, value)\n OpenSSL::HMAC.hexdigest('MD5', key, value)\nend\n"; + let tree = parse_ruby(src); + let summary = FuncSummary { + name: "sign".into(), + callees: vec![crate::summary::CalleeSite::bare("OpenSSL::HMAC.hexdigest")], + ..Default::default() + }; + assert!( + CryptoRubyAdapter + .detect(&summary, tree.root_node(), src) + .is_some() + ); + } + + #[test] + fn skips_when_source_routes_through_securerandom() { + let src: &[u8] = b"require 'digest'\nrequire 'securerandom'\n\ + def run(value)\n if value.include?('STRONG')\n SecureRandom.hex(32)\n else\n Digest::MD5.hexdigest(value)\n end\nend\n"; + let tree = parse_ruby(src); + let summary = FuncSummary { + name: "run".into(), + callees: vec![crate::summary::CalleeSite::bare("Digest::MD5.hexdigest")], + ..Default::default() + }; + assert!( + CryptoRubyAdapter + .detect(&summary, tree.root_node(), src) + .is_none() + ); + } + + #[test] + fn skips_when_sha256_in_source() { + let src: &[u8] = b"require 'digest'\n\ + def sign(value)\n Digest::SHA256.hexdigest(value)\nend\n"; + let tree = parse_ruby(src); + let summary = FuncSummary { + name: "sign".into(), + callees: vec![crate::summary::CalleeSite::bare("Digest::SHA256.hexdigest")], + ..Default::default() + }; + assert!( + CryptoRubyAdapter + .detect(&summary, tree.root_node(), src) + .is_none() + ); + } + + #[test] + fn skips_plain_function() { + let src: &[u8] = b"def add(a, b)\n a + b\nend\n"; + let tree = parse_ruby(src); + let summary = FuncSummary { + name: "add".into(), + ..Default::default() + }; + assert!( + CryptoRubyAdapter + .detect(&summary, tree.root_node(), src) + .is_none() + ); + } +} diff --git a/src/dynamic/framework/adapters/data_exfil_ruby.rs b/src/dynamic/framework/adapters/data_exfil_ruby.rs new file mode 100644 index 00000000..7d70a1e2 --- /dev/null +++ b/src/dynamic/framework/adapters/data_exfil_ruby.rs @@ -0,0 +1,225 @@ +//! Ruby [`super::super::FrameworkAdapter`] matching outbound-HTTP +//! sink constructions (`Net::HTTP.{get,get_response,post_form,start}`, +//! `RestClient.{get,post}`, `HTTParty.{get,post}`, `Faraday.get`, +//! `open-uri`'s `open(...)`). +//! +//! Phase 11 (Track L.9). Fires when the function body invokes one +//! of the canonical Ruby HTTP-client entry points and the +//! surrounding source requires the matching client module. + +use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding}; +use crate::evidence::EntryKind; +use crate::summary::FuncSummary; +use crate::symbol::Lang; + +pub struct DataExfilRubyAdapter; + +const ADAPTER_NAME: &str = "data-exfil-ruby"; + +fn callee_is_outbound_http(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, + "get_response" | "post_form" | "request" | "start" + ) || matches!( + name, + "Net::HTTP.get" + | "Net::HTTP.get_response" + | "Net::HTTP.post_form" + | "Net::HTTP.start" + | "Net::HTTP::Get.new" + | "Net::HTTP::Post.new" + | "RestClient.get" + | "RestClient.post" + | "RestClient.put" + | "RestClient.delete" + | "RestClient::Request.execute" + | "HTTParty.get" + | "HTTParty.post" + | "HTTParty.put" + | "HTTParty.delete" + | "Faraday.get" + | "Faraday.post" + | "Faraday.new" + | "Faraday::Connection.get" + | "URI.open" + | "Kernel.open" + ) +} + +fn source_imports_ruby_http_client(file_bytes: &[u8]) -> bool { + const NEEDLES: &[&[u8]] = &[ + b"require 'net/http'", + b"require \"net/http\"", + b"require 'open-uri'", + b"require \"open-uri\"", + b"require 'rest-client'", + b"require \"rest-client\"", + b"require 'rest_client'", + b"require 'httparty'", + b"require \"httparty\"", + b"require 'faraday'", + b"require \"faraday\"", + b"require 'http'", + b"require \"http\"", + b"Net::HTTP", + b"RestClient.", + b"HTTParty.", + b"Faraday.", + ]; + NEEDLES + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + +/// Returns `true` when the surrounding source visibly routes the +/// outbound URL through a host-allowlist / network-policy gate. +fn host_routed_through_allowlist(file_bytes: &[u8]) -> bool { + const NEEDLES: &[&[u8]] = &[ + b"ALLOWLIST", + b"allowlist", + b"ALLOWED_HOSTS", + b"allowed_hosts", + b"'127.0.0.1'", + b"\"127.0.0.1\"", + b"'localhost'", + b"\"localhost\"", + b"host == 'localhost'", + b"host == \"localhost\"", + ]; + NEEDLES + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + +impl FrameworkAdapter for DataExfilRubyAdapter { + fn name(&self) -> &'static str { + ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::Ruby + } + + fn detect( + &self, + summary: &FuncSummary, + _ast: tree_sitter::Node<'_>, + file_bytes: &[u8], + ) -> Option { + if host_routed_through_allowlist(file_bytes) { + return None; + } + let matches_call = super::any_callee_matches(summary, callee_is_outbound_http); + let matches_source = source_imports_ruby_http_client(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 + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_ruby(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_ruby::LANGUAGE); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() + } + + #[test] + fn fires_on_net_http_get() { + let src: &[u8] = b"require 'net/http'\n\ + def run(host)\n Net::HTTP.get(URI(\"http://#{host}/exfil\"))\nend\n"; + let tree = parse_ruby(src); + let summary = FuncSummary { + name: "run".into(), + callees: vec![crate::summary::CalleeSite::bare("Net::HTTP.get")], + ..Default::default() + }; + assert!( + DataExfilRubyAdapter + .detect(&summary, tree.root_node(), src) + .is_some() + ); + } + + #[test] + fn fires_on_restclient_post() { + let src: &[u8] = b"require 'rest-client'\n\ + def run(host)\n RestClient.post(\"http://#{host}/exfil\", { token: 'x' })\nend\n"; + let tree = parse_ruby(src); + let summary = FuncSummary { + name: "run".into(), + callees: vec![crate::summary::CalleeSite::bare("RestClient.post")], + ..Default::default() + }; + assert!( + DataExfilRubyAdapter + .detect(&summary, tree.root_node(), src) + .is_some() + ); + } + + #[test] + fn fires_on_faraday_get() { + let src: &[u8] = b"require 'faraday'\n\ + def run(host)\n Faraday.get(\"http://#{host}/exfil\")\nend\n"; + let tree = parse_ruby(src); + let summary = FuncSummary { + name: "run".into(), + callees: vec![crate::summary::CalleeSite::bare("Faraday.get")], + ..Default::default() + }; + assert!( + DataExfilRubyAdapter + .detect(&summary, tree.root_node(), src) + .is_some() + ); + } + + #[test] + fn skips_when_host_routed_through_allowlist() { + let src: &[u8] = b"require 'net/http'\n\ + ALLOWLIST = ['127.0.0.1', 'localhost'].freeze\n\ + def run(host)\n return unless ALLOWLIST.include?(host)\n Net::HTTP.get(URI(\"http://#{host}/exfil\"))\nend\n"; + let tree = parse_ruby(src); + let summary = FuncSummary { + name: "run".into(), + callees: vec![crate::summary::CalleeSite::bare("Net::HTTP.get")], + ..Default::default() + }; + assert!( + DataExfilRubyAdapter + .detect(&summary, tree.root_node(), src) + .is_none() + ); + } + + #[test] + fn skips_plain_function() { + let src: &[u8] = b"def add(a, b)\n a + b\nend\n"; + let tree = parse_ruby(src); + let summary = FuncSummary { + name: "add".into(), + ..Default::default() + }; + assert!( + DataExfilRubyAdapter + .detect(&summary, tree.root_node(), src) + .is_none() + ); + } +} diff --git a/src/dynamic/framework/adapters/mod.rs b/src/dynamic/framework/adapters/mod.rs index 249a09e9..a7be4536 100644 --- a/src/dynamic/framework/adapters/mod.rs +++ b/src/dynamic/framework/adapters/mod.rs @@ -13,10 +13,13 @@ pub mod crypto_java; pub mod crypto_js; +pub mod crypto_php; pub mod crypto_python; +pub mod crypto_ruby; pub mod data_exfil_go; pub mod data_exfil_js; pub mod data_exfil_python; +pub mod data_exfil_ruby; pub mod go_chi; pub mod go_echo; pub mod go_fiber; @@ -130,10 +133,13 @@ pub mod xxe_ruby; pub use crypto_java::CryptoJavaAdapter; pub use crypto_js::CryptoJsAdapter; +pub use crypto_php::CryptoPhpAdapter; pub use crypto_python::CryptoPythonAdapter; +pub use crypto_ruby::CryptoRubyAdapter; pub use data_exfil_go::DataExfilGoAdapter; pub use data_exfil_js::DataExfilJsAdapter; pub use data_exfil_python::DataExfilPythonAdapter; +pub use data_exfil_ruby::DataExfilRubyAdapter; pub use go_chi::GoChiAdapter; pub use go_echo::GoEchoAdapter; pub use go_fiber::GoFiberAdapter; diff --git a/src/dynamic/framework/mod.rs b/src/dynamic/framework/mod.rs index 6b8fe1cf..5962dbb1 100644 --- a/src/dynamic/framework/mod.rs +++ b/src/dynamic/framework/mod.rs @@ -287,6 +287,10 @@ mod tests { // Python: +2 (CryptoPython, DataExfilPython) 22 → 24 // JavaScript: +2 (CryptoJs, DataExfilJs) 20 → 22 // Go: +1 (DataExfilGo) 11 → 12 + // Track L.9 follow-up slice (session-0015 of run 7d60): + // CRYPTO × {Php, Ruby} + DATA_EXFIL × Ruby. + // Php: +1 (CryptoPhp) 12 → 13 + // Ruby: +2 (CryptoRuby, DataExfilRuby) 12 → 14 let java_registered = registry::adapters_for(Lang::Java); assert_eq!( java_registered.len(), @@ -299,8 +303,8 @@ mod tests { let php_registered = registry::adapters_for(Lang::Php); assert_eq!( php_registered.len(), - 12, - "Php must have Phase 20 baseline (10) + M.3 Laravel middleware+migration (2)", + 13, + "Php must have Phase 20 baseline (10) + M.3 Laravel middleware+migration (2) + Track L.9 CryptoPhp (1)", ); for adapter in php_registered { assert_eq!(adapter.lang(), Lang::Php); @@ -317,8 +321,8 @@ mod tests { let ruby_registered = registry::adapters_for(Lang::Ruby); assert_eq!( ruby_registered.len(), - 12, - "Ruby must have Phase 20 baseline (8) + M.3 Phase-21 (4)", + 14, + "Ruby must have Phase 20 baseline (8) + M.3 Phase-21 (4) + Track L.9 (CryptoRuby, DataExfilRuby)", ); for adapter in ruby_registered { assert_eq!(adapter.lang(), Lang::Ruby); diff --git a/src/dynamic/framework/registry.rs b/src/dynamic/framework/registry.rs index 4455bd31..8a18b780 100644 --- a/src/dynamic/framework/registry.rs +++ b/src/dynamic/framework/registry.rs @@ -92,6 +92,7 @@ static GO: &[&dyn FrameworkAdapter] = &[ &super::adapters::XxeGoAdapter, ]; static PHP: &[&dyn FrameworkAdapter] = &[ + &super::adapters::CryptoPhpAdapter, &super::adapters::HeaderPhpAdapter, &super::adapters::LdapPhpAdapter, &super::adapters::MiddlewareLaravelAdapter, @@ -132,6 +133,8 @@ static PYTHON: &[&dyn FrameworkAdapter] = &[ &super::adapters::XxePythonAdapter, ]; static RUBY: &[&dyn FrameworkAdapter] = &[ + &super::adapters::CryptoRubyAdapter, + &super::adapters::DataExfilRubyAdapter, &super::adapters::HeaderRubyAdapter, &super::adapters::MiddlewareRailsAdapter, &super::adapters::MigrationRailsAdapter, diff --git a/tests/dynamic_fixtures/go/cmdi_positive.go b/tests/dynamic_fixtures/go/cmdi_positive.go index 702d31b4..6c5857cf 100644 --- a/tests/dynamic_fixtures/go/cmdi_positive.go +++ b/tests/dynamic_fixtures/go/cmdi_positive.go @@ -12,7 +12,7 @@ import ( func RunPing(host string) { fmt.Print("__NYX_SINK_HIT__\n") - cmd := exec.Command("sh", "-c", "echo hello "+host) + cmd := exec.Command("/bin/sh", "-c", "/bin/echo hello "+host) out, _ := cmd.CombinedOutput() fmt.Print(string(out)) } diff --git a/tests/dynamic_fixtures/php/cmdi_negative.php b/tests/dynamic_fixtures/php/cmdi_negative.php index eb45c4a0..b70c0633 100644 --- a/tests/dynamic_fixtures/php/cmdi_negative.php +++ b/tests/dynamic_fixtures/php/cmdi_negative.php @@ -1,14 +1,22 @@