From 1e5f27f56db33c457702cf2229016396892dcc8e Mon Sep 17 00:00:00 2001 From: pitboss Date: Fri, 22 May 2026 18:30:16 -0500 Subject: [PATCH] [pitboss/grind] deferred session-0017 (20260522T163126Z-7d60) --- src/dynamic/framework/adapters/crypto_go.rs | 247 +++++++++++++++++ src/dynamic/framework/adapters/crypto_rust.rs | 254 ++++++++++++++++++ .../framework/adapters/data_exfil_java.rs | 228 ++++++++++++++++ .../framework/adapters/data_exfil_php.rs | 234 ++++++++++++++++ .../framework/adapters/data_exfil_rust.rs | 239 ++++++++++++++++ src/dynamic/framework/adapters/mod.rs | 10 + src/dynamic/framework/mod.rs | 22 +- src/dynamic/framework/registry.rs | 5 + 8 files changed, 1231 insertions(+), 8 deletions(-) create mode 100644 src/dynamic/framework/adapters/crypto_go.rs create mode 100644 src/dynamic/framework/adapters/crypto_rust.rs create mode 100644 src/dynamic/framework/adapters/data_exfil_java.rs create mode 100644 src/dynamic/framework/adapters/data_exfil_php.rs create mode 100644 src/dynamic/framework/adapters/data_exfil_rust.rs diff --git a/src/dynamic/framework/adapters/crypto_go.rs b/src/dynamic/framework/adapters/crypto_go.rs new file mode 100644 index 00000000..648ac523 --- /dev/null +++ b/src/dynamic/framework/adapters/crypto_go.rs @@ -0,0 +1,247 @@ +//! Go [`super::super::FrameworkAdapter`] matching weak-crypto sink +//! constructions (`math/rand.Int*` non-CSPRNG randomness used for +//! key material, `crypto/md5.Sum` / `crypto/sha1.Sum` / +//! `crypto/des.NewCipher` / `crypto/rc4.NewCipher`). +//! +//! Phase 11 (Track L.9). Fires when the function body invokes one +//! of the canonical Go weak-crypto entry points and the surrounding +//! source imports the matching stdlib module. +//! +//! See sibling adapters [`super::crypto_python::CryptoPythonAdapter`], +//! [`super::crypto_java::CryptoJavaAdapter`], and +//! [`super::crypto_ruby::CryptoRubyAdapter`] for the same shape on +//! other languages. + +use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding}; +use crate::evidence::EntryKind; +use crate::summary::FuncSummary; +use crate::symbol::Lang; + +pub struct CryptoGoAdapter; + +const ADAPTER_NAME: &str = "crypto-go"; + +fn callee_is_weak_crypto(name: &str) -> bool { + let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name); + matches!( + last, + "Int" + | "Intn" + | "Int31" + | "Int31n" + | "Int63" + | "Int63n" + | "Uint32" + | "Uint64" + | "Float32" + | "Float64" + | "Read" + | "Sum" + | "New" + | "NewCipher" + ) || matches!( + name, + "rand.Int" + | "rand.Intn" + | "rand.Int31" + | "rand.Int31n" + | "rand.Int63" + | "rand.Int63n" + | "rand.Uint32" + | "rand.Uint64" + | "rand.Float32" + | "rand.Float64" + | "rand.Read" + | "md5.Sum" + | "md5.New" + | "sha1.Sum" + | "sha1.New" + | "des.NewCipher" + | "des.NewTripleDESCipher" + | "rc4.NewCipher" + ) +} + +fn source_imports_go_crypto(file_bytes: &[u8]) -> bool { + const NEEDLES: &[&[u8]] = &[ + b"\"math/rand\"", + b"math/rand\"", + b"\"crypto/md5\"", + b"crypto/md5\"", + b"\"crypto/sha1\"", + b"crypto/sha1\"", + b"\"crypto/des\"", + b"crypto/des\"", + b"\"crypto/rc4\"", + b"crypto/rc4\"", + ]; + 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 (`crypto/rand` CSPRNG, +/// `crypto/sha256` or stronger, `crypto/aes` paired with `GCM`, +/// `golang.org/x/crypto/chacha20poly1305`). +fn source_routed_through_strong_path(file_bytes: &[u8]) -> bool { + const NEEDLES: &[&[u8]] = &[ + b"\"crypto/rand\"", + b"crypto/rand\"", + b"\"crypto/sha256\"", + b"crypto/sha256\"", + b"\"crypto/sha512\"", + b"crypto/sha512\"", + b"sha3.New", + b"chacha20poly1305", + b"cipher.NewGCM", + b"argon2.Key", + b"argon2.IDKey", + b"bcrypt.GenerateFromPassword", + ]; + NEEDLES + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + +impl FrameworkAdapter for CryptoGoAdapter { + fn name(&self) -> &'static str { + ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::Go + } + + 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_go_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_go(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_go::LANGUAGE); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() + } + + #[test] + fn fires_on_math_rand_intn() { + let src: &[u8] = b"package vuln\nimport \"math/rand\"\nfunc Run() int {\n return rand.Intn(1000)\n}\n"; + let tree = parse_go(src); + let summary = FuncSummary { + name: "Run".into(), + callees: vec![crate::summary::CalleeSite::bare("rand.Intn")], + ..Default::default() + }; + assert!( + CryptoGoAdapter + .detect(&summary, tree.root_node(), src) + .is_some() + ); + } + + #[test] + fn fires_on_md5_sum() { + let src: &[u8] = b"package vuln\nimport \"crypto/md5\"\nfunc Sign(b []byte) [16]byte {\n return md5.Sum(b)\n}\n"; + let tree = parse_go(src); + let summary = FuncSummary { + name: "Sign".into(), + callees: vec![crate::summary::CalleeSite::bare("md5.Sum")], + ..Default::default() + }; + assert!( + CryptoGoAdapter + .detect(&summary, tree.root_node(), src) + .is_some() + ); + } + + #[test] + fn fires_on_des_newcipher() { + let src: &[u8] = b"package vuln\nimport \"crypto/des\"\nimport \"crypto/cipher\"\nfunc Enc(key []byte) (cipher.Block, error) {\n return des.NewCipher(key)\n}\n"; + let tree = parse_go(src); + let summary = FuncSummary { + name: "Enc".into(), + callees: vec![crate::summary::CalleeSite::bare("des.NewCipher")], + ..Default::default() + }; + assert!( + CryptoGoAdapter + .detect(&summary, tree.root_node(), src) + .is_some() + ); + } + + #[test] + fn skips_when_source_routes_through_crypto_rand() { + let src: &[u8] = b"package vuln\nimport \"math/rand\"\nimport \"crypto/rand\"\nfunc Run() ([]byte, error) {\n key := make([]byte, 32)\n if _, err := rand.Read(key); err != nil { return nil, err }\n return key, nil\n}\n"; + let tree = parse_go(src); + let summary = FuncSummary { + name: "Run".into(), + callees: vec![crate::summary::CalleeSite::bare("rand.Read")], + ..Default::default() + }; + assert!( + CryptoGoAdapter + .detect(&summary, tree.root_node(), src) + .is_none() + ); + } + + #[test] + fn skips_when_sha256_in_source() { + let src: &[u8] = b"package vuln\nimport \"crypto/sha256\"\nfunc Sign(b []byte) [32]byte {\n return sha256.Sum256(b)\n}\n"; + let tree = parse_go(src); + let summary = FuncSummary { + name: "Sign".into(), + callees: vec![crate::summary::CalleeSite::bare("sha256.Sum256")], + ..Default::default() + }; + assert!( + CryptoGoAdapter + .detect(&summary, tree.root_node(), src) + .is_none() + ); + } + + #[test] + fn skips_plain_function() { + let src: &[u8] = b"package vuln\nfunc Add(a, b int) int { return a + b }\n"; + let tree = parse_go(src); + let summary = FuncSummary { + name: "Add".into(), + ..Default::default() + }; + assert!( + CryptoGoAdapter + .detect(&summary, tree.root_node(), src) + .is_none() + ); + } +} diff --git a/src/dynamic/framework/adapters/crypto_rust.rs b/src/dynamic/framework/adapters/crypto_rust.rs new file mode 100644 index 00000000..7621dc03 --- /dev/null +++ b/src/dynamic/framework/adapters/crypto_rust.rs @@ -0,0 +1,254 @@ +//! Rust [`super::super::FrameworkAdapter`] matching weak-crypto sink +//! constructions (`md5::compute` / `Md5::digest`, `sha1::Sha1::digest`, +//! `rand::random` / non-CSPRNG `rand::Rng::gen_*`, `crypto::des` DES / +//! `crypto::rc4` RC4 ciphers). +//! +//! Phase 11 (Track L.9). Fires when the function body invokes one +//! of the canonical Rust weak-crypto entry points and the surrounding +//! source imports the matching crate. +//! +//! See sibling adapters [`super::crypto_python::CryptoPythonAdapter`], +//! [`super::crypto_java::CryptoJavaAdapter`], +//! [`super::crypto_ruby::CryptoRubyAdapter`], and +//! [`super::crypto_go::CryptoGoAdapter`] for the same shape on other +//! languages. + +use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding}; +use crate::evidence::EntryKind; +use crate::summary::FuncSummary; +use crate::symbol::Lang; + +pub struct CryptoRustAdapter; + +const ADAPTER_NAME: &str = "crypto-rust"; + +fn callee_is_weak_crypto(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, + "compute" + | "digest" + | "finalize" + | "random" + | "gen" + | "gen_range" + | "gen_bool" + | "thread_rng" + | "new_unkeyed" + ) || matches!( + name, + "md5::compute" + | "Md5::digest" + | "Md5::new" + | "md_5::Md5::digest" + | "md_5::Md5::new" + | "sha1::Sha1::digest" + | "sha1::Sha1::new" + | "Sha1::digest" + | "Sha1::new" + | "rand::random" + | "rand::thread_rng" + | "rand::Rng::gen" + | "rand::Rng::gen_range" + | "rand::rngs::ThreadRng::gen" + | "Des::new" + | "TdesEde3::new" + | "Rc4::new" + ) +} + +fn source_imports_rust_crypto(file_bytes: &[u8]) -> bool { + const NEEDLES: &[&[u8]] = &[ + b"use md5", + b"use md_5", + b"use sha1", + b"use sha_1", + b"use rand", + b"md5::", + b"md_5::Md5", + b"sha1::Sha1", + b"sha_1::Sha1", + b"rand::random", + b"rand::thread_rng", + b"rand::Rng", + b"des::Des", + b"rc4::Rc4", + ]; + 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 (CSPRNG via `getrandom` / +/// `OsRng`, SHA-256+ digests, AES-GCM / ChaCha20-Poly1305 / Argon2 +/// authenticated encryption + KDF, `ring` constants). +fn source_routed_through_strong_path(file_bytes: &[u8]) -> bool { + const NEEDLES: &[&[u8]] = &[ + b"getrandom::getrandom", + b"rand::rngs::OsRng", + b"OsRng", + b"sha2::Sha256", + b"sha2::Sha384", + b"sha2::Sha512", + b"sha3::Sha3_256", + b"sha3::Sha3_512", + b"ring::digest::SHA256", + b"ring::digest::SHA384", + b"ring::digest::SHA512", + b"aes_gcm", + b"AesGcm", + b"chacha20poly1305", + b"ChaCha20Poly1305", + b"argon2::Argon2", + b"argon2::PasswordHash", + b"bcrypt::hash", + b"ed25519_dalek", + ]; + NEEDLES + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + +impl FrameworkAdapter for CryptoRustAdapter { + fn name(&self) -> &'static str { + ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::Rust + } + + 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_rust_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_rust(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_rust::LANGUAGE); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() + } + + #[test] + fn fires_on_md5_compute() { + let src: &[u8] = b"use md5;\npub fn sign(value: &[u8]) -> md5::Digest {\n md5::compute(value)\n}\n"; + let tree = parse_rust(src); + let summary = FuncSummary { + name: "sign".into(), + callees: vec![crate::summary::CalleeSite::bare("md5::compute")], + ..Default::default() + }; + assert!( + CryptoRustAdapter + .detect(&summary, tree.root_node(), src) + .is_some() + ); + } + + #[test] + fn fires_on_sha1_digest() { + let src: &[u8] = b"use sha1::Sha1;\nuse sha1::Digest;\npub fn sign(value: &[u8]) -> Vec {\n let mut h = Sha1::new();\n h.update(value);\n h.finalize().to_vec()\n}\n"; + let tree = parse_rust(src); + let summary = FuncSummary { + name: "sign".into(), + callees: vec![crate::summary::CalleeSite::bare("Sha1::new")], + ..Default::default() + }; + assert!( + CryptoRustAdapter + .detect(&summary, tree.root_node(), src) + .is_some() + ); + } + + #[test] + fn fires_on_rand_random() { + let src: &[u8] = b"use rand;\npub fn token() -> u64 {\n rand::random::()\n}\n"; + let tree = parse_rust(src); + let summary = FuncSummary { + name: "token".into(), + callees: vec![crate::summary::CalleeSite::bare("rand::random")], + ..Default::default() + }; + assert!( + CryptoRustAdapter + .detect(&summary, tree.root_node(), src) + .is_some() + ); + } + + #[test] + fn skips_when_source_routes_through_osrng() { + let src: &[u8] = b"use rand;\nuse rand::rngs::OsRng;\nuse rand::RngCore;\npub fn token() -> [u8; 32] {\n let mut buf = [0u8; 32];\n OsRng.fill_bytes(&mut buf);\n buf\n}\n"; + let tree = parse_rust(src); + let summary = FuncSummary { + name: "token".into(), + callees: vec![crate::summary::CalleeSite::bare("rand::random")], + ..Default::default() + }; + assert!( + CryptoRustAdapter + .detect(&summary, tree.root_node(), src) + .is_none() + ); + } + + #[test] + fn skips_when_sha256_in_source() { + let src: &[u8] = b"use sha2::Sha256;\nuse sha2::Digest;\npub fn sign(value: &[u8]) -> Vec {\n let mut h = Sha256::new();\n h.update(value);\n h.finalize().to_vec()\n}\n"; + let tree = parse_rust(src); + let summary = FuncSummary { + name: "sign".into(), + callees: vec![crate::summary::CalleeSite::bare("Sha256::new")], + ..Default::default() + }; + assert!( + CryptoRustAdapter + .detect(&summary, tree.root_node(), src) + .is_none() + ); + } + + #[test] + fn skips_plain_function() { + let src: &[u8] = b"pub fn add(a: i64, b: i64) -> i64 { a + b }\n"; + let tree = parse_rust(src); + let summary = FuncSummary { + name: "add".into(), + ..Default::default() + }; + assert!( + CryptoRustAdapter + .detect(&summary, tree.root_node(), src) + .is_none() + ); + } +} diff --git a/src/dynamic/framework/adapters/data_exfil_java.rs b/src/dynamic/framework/adapters/data_exfil_java.rs new file mode 100644 index 00000000..1b8ffeb0 --- /dev/null +++ b/src/dynamic/framework/adapters/data_exfil_java.rs @@ -0,0 +1,228 @@ +//! Java [`super::super::FrameworkAdapter`] matching outbound-HTTP +//! sink constructions (`java.net.HttpURLConnection`, the modern +//! `java.net.http.HttpClient`, OkHttp, Apache HttpClient). +//! +//! Phase 11 (Track L.9). Fires when the function body invokes one +//! of the canonical Java HTTP-client entry points and the surrounding +//! source imports the matching stdlib / third-party module. +//! +//! See sibling adapters +//! [`super::data_exfil_python::DataExfilPythonAdapter`], +//! [`super::data_exfil_js::DataExfilJsAdapter`], +//! [`super::data_exfil_go::DataExfilGoAdapter`], and +//! [`super::data_exfil_ruby::DataExfilRubyAdapter`] for the same +//! shape on other languages. + +use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding}; +use crate::evidence::EntryKind; +use crate::summary::FuncSummary; +use crate::symbol::Lang; + +pub struct DataExfilJavaAdapter; + +const ADAPTER_NAME: &str = "data-exfil-java"; + +fn callee_is_outbound_http(name: &str) -> bool { + let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name); + matches!( + last, + "openConnection" + | "openStream" + | "send" + | "sendAsync" + | "execute" + | "newCall" + | "newBuilder" + | "build" + | "connect" + ) || matches!( + name, + "java.net.URL.openConnection" + | "java.net.URL.openStream" + | "URL.openConnection" + | "URL.openStream" + | "HttpClient.send" + | "HttpClient.sendAsync" + | "HttpClient.newHttpClient" + | "HttpRequest.newBuilder" + | "OkHttpClient.newCall" + | "Call.execute" + | "HttpClients.createDefault" + | "CloseableHttpClient.execute" + | "Request.Builder.url" + ) +} + +fn source_imports_java_http_client(file_bytes: &[u8]) -> bool { + const NEEDLES: &[&[u8]] = &[ + b"java.net.HttpURLConnection", + b"java.net.URL", + b"java.net.http.HttpClient", + b"java.net.http.HttpRequest", + b"okhttp3.OkHttpClient", + b"okhttp3.Request", + b"okhttp3.Call", + b"org.apache.http.client.HttpClient", + b"org.apache.http.impl.client.HttpClients", + b"org.apache.http.impl.client.CloseableHttpClient", + b"org.apache.hc.client5.http", + ]; + 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"ALLOWED_HOSTS", + b"allowedHosts", + b"allowlist", + b"\"127.0.0.1\"", + b"\"localhost\"", + b".equals(\"localhost\")", + b".contains(host)", + b".containsKey(host)", + ]; + NEEDLES + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + +impl FrameworkAdapter for DataExfilJavaAdapter { + fn name(&self) -> &'static str { + ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::Java + } + + 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_java_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_java(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_java::LANGUAGE); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() + } + + #[test] + fn fires_on_url_open_connection() { + let src: &[u8] = b"import java.net.HttpURLConnection;\nimport java.net.URL;\n\ + public class Vuln {\n public static void run(String host) throws Exception {\n URL u = new URL(\"http://\" + host + \"/exfil\");\n HttpURLConnection conn = (HttpURLConnection) u.openConnection();\n conn.connect();\n }\n}\n"; + let tree = parse_java(src); + let summary = FuncSummary { + name: "run".into(), + callees: vec![crate::summary::CalleeSite::bare("URL.openConnection")], + ..Default::default() + }; + assert!( + DataExfilJavaAdapter + .detect(&summary, tree.root_node(), src) + .is_some() + ); + } + + #[test] + fn fires_on_httpclient_send() { + let src: &[u8] = b"import java.net.http.HttpClient;\nimport java.net.http.HttpRequest;\nimport java.net.URI;\n\ + public class Vuln {\n public static void run(String host) throws Exception {\n HttpClient c = HttpClient.newHttpClient();\n HttpRequest r = HttpRequest.newBuilder(URI.create(\"http://\" + host)).build();\n c.send(r, java.net.http.HttpResponse.BodyHandlers.discarding());\n }\n}\n"; + let tree = parse_java(src); + let summary = FuncSummary { + name: "run".into(), + callees: vec![ + crate::summary::CalleeSite::bare("HttpRequest.newBuilder"), + crate::summary::CalleeSite::bare("HttpClient.send"), + ], + ..Default::default() + }; + assert!( + DataExfilJavaAdapter + .detect(&summary, tree.root_node(), src) + .is_some() + ); + } + + #[test] + fn fires_on_okhttp_newcall_execute() { + let src: &[u8] = b"import okhttp3.OkHttpClient;\nimport okhttp3.Request;\n\ + public class Vuln {\n public static void run(String host) throws Exception {\n OkHttpClient c = new OkHttpClient();\n Request r = new Request.Builder().url(\"http://\" + host).build();\n c.newCall(r).execute();\n }\n}\n"; + let tree = parse_java(src); + let summary = FuncSummary { + name: "run".into(), + callees: vec![ + crate::summary::CalleeSite::bare("OkHttpClient.newCall"), + crate::summary::CalleeSite::bare("Call.execute"), + ], + ..Default::default() + }; + assert!( + DataExfilJavaAdapter + .detect(&summary, tree.root_node(), src) + .is_some() + ); + } + + #[test] + fn skips_when_host_in_allowlist_literal() { + let src: &[u8] = b"import java.net.HttpURLConnection;\nimport java.net.URL;\n\ + public class Vuln {\n public static void run(String host) throws Exception {\n if (!host.equals(\"127.0.0.1\")) { return; }\n URL u = new URL(\"http://\" + host + \"/exfil\");\n ((HttpURLConnection) u.openConnection()).connect();\n }\n}\n"; + let tree = parse_java(src); + let summary = FuncSummary { + name: "run".into(), + callees: vec![crate::summary::CalleeSite::bare("URL.openConnection")], + ..Default::default() + }; + assert!( + DataExfilJavaAdapter + .detect(&summary, tree.root_node(), src) + .is_none() + ); + } + + #[test] + fn skips_plain_method() { + let src: &[u8] = b"public class Plain { public static int add(int a, int b) { return a + b; } }\n"; + let tree = parse_java(src); + let summary = FuncSummary { + name: "add".into(), + ..Default::default() + }; + assert!( + DataExfilJavaAdapter + .detect(&summary, tree.root_node(), src) + .is_none() + ); + } +} diff --git a/src/dynamic/framework/adapters/data_exfil_php.rs b/src/dynamic/framework/adapters/data_exfil_php.rs new file mode 100644 index 00000000..c324b386 --- /dev/null +++ b/src/dynamic/framework/adapters/data_exfil_php.rs @@ -0,0 +1,234 @@ +//! PHP [`super::super::FrameworkAdapter`] matching outbound-HTTP +//! sink constructions (`curl_init` / `curl_exec`, `file_get_contents` +//! against a remote URL, `fopen`/`fsockopen`/`stream_socket_client`, +//! Guzzle). +//! +//! Phase 11 (Track L.9). Fires when the function body invokes one +//! of the canonical PHP HTTP-client entry points and the surrounding +//! source carries the ` bool { + let last = name.rsplit_once('\\').map(|(_, s)| s).unwrap_or(name); + let last = last.rsplit_once("::").map(|(_, s)| s).unwrap_or(last); + let last = last.rsplit_once("->").map(|(_, s)| s).unwrap_or(last); + matches!( + last, + "curl_init" + | "curl_exec" + | "curl_setopt" + | "curl_multi_exec" + | "file_get_contents" + | "fopen" + | "fsockopen" + | "stream_socket_client" + | "stream_context_create" + | "get" + | "post" + | "put" + | "delete" + | "request" + | "sendRequest" + | "send" + ) || matches!( + name, + "curl_init" + | "curl_exec" + | "file_get_contents" + | "fopen" + | "fsockopen" + | "stream_socket_client" + | "GuzzleHttp\\Client.get" + | "GuzzleHttp\\Client.post" + | "GuzzleHttp\\Client.request" + | "GuzzleHttp\\Client.send" + | "Symfony\\Component\\HttpClient\\HttpClient.create" + | "Symfony\\Contracts\\HttpClient\\HttpClientInterface.request" + ) +} + +fn source_imports_php_http_client(file_bytes: &[u8]) -> bool { + const NEEDLES: &[&[u8]] = &[ + b" 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"in_array($host", + b"isset($allow", + ]; + NEEDLES + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + +impl FrameworkAdapter for DataExfilPhpAdapter { + 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 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_php_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_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_curl_init_exec() { + let src: &[u8] = b"request('GET', 'http://' . $host);\n}\n"; + let tree = parse_php(src); + let summary = FuncSummary { + name: "run".into(), + callees: vec![crate::summary::CalleeSite::bare("request")], + ..Default::default() + }; + assert!( + DataExfilPhpAdapter + .detect(&summary, tree.root_node(), src) + .is_some() + ); + } + + #[test] + fn skips_when_host_in_allowlist_literal() { + 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, + "get" + | "post" + | "put" + | "patch" + | "delete" + | "head" + | "send" + | "send_async" + | "execute" + | "fetch" + | "request" + | "call" + ) || matches!( + name, + "reqwest::get" + | "reqwest::blocking::get" + | "reqwest::Client::get" + | "reqwest::Client::post" + | "reqwest::Client::execute" + | "reqwest::blocking::Client::get" + | "reqwest::blocking::Client::post" + | "reqwest::blocking::Client::execute" + | "reqwest::RequestBuilder::send" + | "reqwest::blocking::RequestBuilder::send" + | "hyper::Client::request" + | "hyper::Client::get" + | "ureq::get" + | "ureq::post" + | "ureq::request" + | "surf::get" + | "surf::post" + | "isahc::get" + | "isahc::post" + | "isahc::send" + ) +} + +fn source_imports_rust_http_client(file_bytes: &[u8]) -> bool { + const NEEDLES: &[&[u8]] = &[ + b"use reqwest", + b"reqwest::", + b"use hyper", + b"hyper::Client", + b"use ureq", + b"ureq::", + b"use surf", + b"surf::", + b"use isahc", + b"isahc::", + b"use awc", + b"awc::Client", + ]; + 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"\"localhost\"", + b".contains(host)", + b".contains(&host)", + b".contains(\"localhost\")", + ]; + NEEDLES + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + +impl FrameworkAdapter for DataExfilRustAdapter { + fn name(&self) -> &'static str { + ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::Rust + } + + 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_rust_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_rust(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_rust::LANGUAGE); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() + } + + #[test] + fn fires_on_reqwest_blocking_get() { + let src: &[u8] = b"use reqwest;\npub fn run(host: &str) -> Result<(), Box> {\n let url = format!(\"http://{}/exfil\", host);\n let _ = reqwest::blocking::get(&url)?;\n Ok(())\n}\n"; + let tree = parse_rust(src); + let summary = FuncSummary { + name: "run".into(), + callees: vec![crate::summary::CalleeSite::bare("reqwest::blocking::get")], + ..Default::default() + }; + assert!( + DataExfilRustAdapter + .detect(&summary, tree.root_node(), src) + .is_some() + ); + } + + #[test] + fn fires_on_reqwest_client_post() { + let src: &[u8] = b"use reqwest::Client;\npub async fn run(host: &str) -> Result<(), Box> {\n let c = Client::new();\n let _ = c.post(format!(\"http://{}/exfil\", host)).send().await?;\n Ok(())\n}\n"; + let tree = parse_rust(src); + let summary = FuncSummary { + name: "run".into(), + callees: vec![ + crate::summary::CalleeSite::bare("reqwest::Client::post"), + crate::summary::CalleeSite::bare("reqwest::RequestBuilder::send"), + ], + ..Default::default() + }; + assert!( + DataExfilRustAdapter + .detect(&summary, tree.root_node(), src) + .is_some() + ); + } + + #[test] + fn fires_on_ureq_get() { + let src: &[u8] = b"use ureq;\npub fn run(host: &str) -> Result<(), ureq::Error> {\n let _ = ureq::get(&format!(\"http://{}/exfil\", host)).call()?;\n Ok(())\n}\n"; + let tree = parse_rust(src); + let summary = FuncSummary { + name: "run".into(), + callees: vec![ + crate::summary::CalleeSite::bare("ureq::get"), + crate::summary::CalleeSite::bare("call"), + ], + ..Default::default() + }; + assert!( + DataExfilRustAdapter + .detect(&summary, tree.root_node(), src) + .is_some() + ); + } + + #[test] + fn skips_when_host_in_allowlist_literal() { + let src: &[u8] = b"use reqwest;\npub fn run(host: &str) -> Result<(), Box> {\n if host != \"127.0.0.1\" { return Ok(()); }\n let _ = reqwest::blocking::get(format!(\"http://{}/\", host))?;\n Ok(())\n}\n"; + let tree = parse_rust(src); + let summary = FuncSummary { + name: "run".into(), + callees: vec![crate::summary::CalleeSite::bare("reqwest::blocking::get")], + ..Default::default() + }; + assert!( + DataExfilRustAdapter + .detect(&summary, tree.root_node(), src) + .is_none() + ); + } + + #[test] + fn skips_plain_function() { + let src: &[u8] = b"pub fn add(a: i64, b: i64) -> i64 { a + b }\n"; + let tree = parse_rust(src); + let summary = FuncSummary { + name: "add".into(), + ..Default::default() + }; + assert!( + DataExfilRustAdapter + .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 a7be4536..7a8287eb 100644 --- a/src/dynamic/framework/adapters/mod.rs +++ b/src/dynamic/framework/adapters/mod.rs @@ -11,15 +11,20 @@ //! the route / framework adapters; the per-cap sink adapters live //! here so the per-language verticals can ship independently. +pub mod crypto_go; pub mod crypto_java; pub mod crypto_js; pub mod crypto_php; pub mod crypto_python; pub mod crypto_ruby; +pub mod crypto_rust; pub mod data_exfil_go; +pub mod data_exfil_java; pub mod data_exfil_js; +pub mod data_exfil_php; pub mod data_exfil_python; pub mod data_exfil_ruby; +pub mod data_exfil_rust; pub mod go_chi; pub mod go_echo; pub mod go_fiber; @@ -131,15 +136,20 @@ pub mod xxe_php; pub mod xxe_python; pub mod xxe_ruby; +pub use crypto_go::CryptoGoAdapter; 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 crypto_rust::CryptoRustAdapter; pub use data_exfil_go::DataExfilGoAdapter; +pub use data_exfil_java::DataExfilJavaAdapter; pub use data_exfil_js::DataExfilJsAdapter; +pub use data_exfil_php::DataExfilPhpAdapter; pub use data_exfil_python::DataExfilPythonAdapter; pub use data_exfil_ruby::DataExfilRubyAdapter; +pub use data_exfil_rust::DataExfilRustAdapter; 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 5962dbb1..d8adb824 100644 --- a/src/dynamic/framework/mod.rs +++ b/src/dynamic/framework/mod.rs @@ -291,11 +291,17 @@ mod tests { // CRYPTO × {Php, Ruby} + DATA_EXFIL × Ruby. // Php: +1 (CryptoPhp) 12 → 13 // Ruby: +2 (CryptoRuby, DataExfilRuby) 12 → 14 + // Track L.9 closing slice (session-0017 of run 7d60): + // CRYPTO × {Go, Rust} + DATA_EXFIL × {Java, Php, Rust}. + // Go: +1 (CryptoGo) 12 → 13 + // Java: +1 (DataExfilJava) 19 → 20 + // Php: +1 (DataExfilPhp) 13 → 14 + // Rust: +2 (CryptoRust, DataExfilRust) 8 → 10 let java_registered = registry::adapters_for(Lang::Java); assert_eq!( java_registered.len(), - 19, - "Java must have Phase 21 baseline (18) + Track L.9 CryptoJava (1)", + 20, + "Java must have Phase 21 baseline (18) + Track L.9 (CryptoJava, DataExfilJava)", ); for adapter in java_registered { assert_eq!(adapter.lang(), Lang::Java); @@ -303,8 +309,8 @@ mod tests { let php_registered = registry::adapters_for(Lang::Php); assert_eq!( php_registered.len(), - 13, - "Php must have Phase 20 baseline (10) + M.3 Laravel middleware+migration (2) + Track L.9 CryptoPhp (1)", + 14, + "Php must have Phase 20 baseline (10) + M.3 Laravel middleware+migration (2) + Track L.9 (CryptoPhp, DataExfilPhp)", ); for adapter in php_registered { assert_eq!(adapter.lang(), Lang::Php); @@ -348,8 +354,8 @@ mod tests { let go_registered = registry::adapters_for(Lang::Go); assert_eq!( go_registered.len(), - 12, - "Go must have Phase 21 baseline (11) + Track L.9 DataExfilGo (1)", + 13, + "Go must have Phase 21 baseline (11) + Track L.9 (CryptoGo, DataExfilGo)", ); for adapter in go_registered { assert_eq!(adapter.lang(), Lang::Go); @@ -357,8 +363,8 @@ mod tests { let rust_registered = registry::adapters_for(Lang::Rust); assert_eq!( rust_registered.len(), - 8, - "Rust must have Phase 20 baseline (6) + M.3 juniper (1) + refinery (1)", + 10, + "Rust must have Phase 20 baseline (6) + M.3 juniper (1) + refinery (1) + Track L.9 (CryptoRust, DataExfilRust)", ); for adapter in rust_registered { assert_eq!(adapter.lang(), Lang::Rust); diff --git a/src/dynamic/framework/registry.rs b/src/dynamic/framework/registry.rs index 8a18b780..eba006a4 100644 --- a/src/dynamic/framework/registry.rs +++ b/src/dynamic/framework/registry.rs @@ -45,6 +45,8 @@ pub fn adapters_for(lang: Lang) -> &'static [&'static dyn FrameworkAdapter] { // later phase that appends a new adapter cannot silently re-order // the existing first-match. static RUST: &[&dyn FrameworkAdapter] = &[ + &super::adapters::CryptoRustAdapter, + &super::adapters::DataExfilRustAdapter, &super::adapters::GraphqlJuniperAdapter, &super::adapters::HeaderRustAdapter, &super::adapters::MigrationRefineryAdapter, @@ -58,6 +60,7 @@ static C: &[&dyn FrameworkAdapter] = &[]; static CPP: &[&dyn FrameworkAdapter] = &[]; static JAVA: &[&dyn FrameworkAdapter] = &[ &super::adapters::CryptoJavaAdapter, + &super::adapters::DataExfilJavaAdapter, &super::adapters::HeaderJavaAdapter, &super::adapters::JavaDeserializeAdapter, &super::adapters::JavaMicronautAdapter, @@ -78,6 +81,7 @@ static JAVA: &[&dyn FrameworkAdapter] = &[ &super::adapters::XxeJavaAdapter, ]; static GO: &[&dyn FrameworkAdapter] = &[ + &super::adapters::CryptoGoAdapter, &super::adapters::DataExfilGoAdapter, &super::adapters::GoChiAdapter, &super::adapters::GoEchoAdapter, @@ -93,6 +97,7 @@ static GO: &[&dyn FrameworkAdapter] = &[ ]; static PHP: &[&dyn FrameworkAdapter] = &[ &super::adapters::CryptoPhpAdapter, + &super::adapters::DataExfilPhpAdapter, &super::adapters::HeaderPhpAdapter, &super::adapters::LdapPhpAdapter, &super::adapters::MiddlewareLaravelAdapter,