//! 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"