diff --git a/src/dynamic/framework/adapters/kafka_java.rs b/src/dynamic/framework/adapters/kafka_java.rs index 7a206d87..299d37a6 100644 --- a/src/dynamic/framework/adapters/kafka_java.rs +++ b/src/dynamic/framework/adapters/kafka_java.rs @@ -8,6 +8,7 @@ use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding}; use crate::evidence::EntryKind; use crate::summary::FuncSummary; +use crate::summary::ssa_summary::SsaFuncSummary; use crate::symbol::Lang; pub struct KafkaJavaAdapter; @@ -63,32 +64,64 @@ impl FrameworkAdapter for KafkaJavaAdapter { fn detect( &self, summary: &FuncSummary, - _ast: tree_sitter::Node<'_>, + ast: tree_sitter::Node<'_>, file_bytes: &[u8], ) -> Option { - let matches_call = super::any_callee_matches(summary, callee_is_kafka); - let matches_source = source_imports_kafka(file_bytes); - if matches_call || matches_source { - Some(FrameworkBinding { - adapter: ADAPTER_NAME.to_owned(), - kind: EntryKind::MessageHandler { - queue: extract_topic(file_bytes), - message_schema: None, - }, - route: None, - request_params: Vec::new(), - response_writer: None, - middleware: Vec::new(), - }) - } else { - None - } + detect_kafka_java(summary, None, ast, file_bytes) } + + fn detect_with_context( + &self, + summary: &FuncSummary, + ssa_summary: Option<&SsaFuncSummary>, + ast: tree_sitter::Node<'_>, + file_bytes: &[u8], + ) -> Option { + detect_kafka_java(summary, ssa_summary, ast, file_bytes) + } +} + +fn detect_kafka_java( + summary: &FuncSummary, + ssa_summary: Option<&SsaFuncSummary>, + _ast: tree_sitter::Node<'_>, + file_bytes: &[u8], +) -> Option { + let matches_call = super::any_callee_matches(summary, callee_is_kafka); + let matches_source = source_imports_kafka(file_bytes); + if !(matches_call || matches_source) { + return None; + } + if !super::typed_receiver_facts_allow( + summary, + ssa_summary, + callee_is_kafka, + typed_container_allows_kafka, + ) { + return None; + } + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::MessageHandler { + queue: extract_topic(file_bytes), + message_schema: None, + }, + route: None, + request_params: Vec::new(), + response_writer: None, + middleware: Vec::new(), + }) +} + +fn typed_container_allows_kafka(container: &str) -> bool { + let lc = container.to_ascii_lowercase(); + lc.contains("kafka") || lc.contains("consumer") } #[cfg(test)] mod tests { use super::*; + use crate::summary::CalleeSite; fn parse_java(src: &[u8]) -> tree_sitter::Tree { let mut parser = tree_sitter::Parser::new(); @@ -117,4 +150,57 @@ mod tests { assert_eq!(queue, "orders"); } } + + #[test] + fn ssa_receiver_type_rejects_non_kafka_poll_collision() { + let src: &[u8] = b"import org.springframework.kafka.annotation.KafkaListener;\n\ + public class Vuln {\n\ + public void onMessage(String body) { timer.poll(); }\n\ + }\n"; + let tree = parse_java(src); + let mut summary = FuncSummary { + name: "onMessage".into(), + ..Default::default() + }; + summary.callees.push(CalleeSite { + name: "timer.poll".to_owned(), + receiver: Some("timer".to_owned()), + ordinal: 0, + ..Default::default() + }); + let mut ssa = SsaFuncSummary::default(); + ssa.typed_call_receivers.push((0, "Timer".to_owned())); + assert!( + KafkaJavaAdapter + .detect_with_context(&summary, Some(&ssa), tree.root_node(), src) + .is_none() + ); + } + + #[test] + fn ssa_receiver_type_keeps_kafka_consumer() { + let src: &[u8] = b"import org.apache.kafka.clients.consumer.KafkaConsumer;\n\ + public class Vuln {\n\ + public void onMessage(String body) { consumer.poll(); }\n\ + }\n"; + let tree = parse_java(src); + let mut summary = FuncSummary { + name: "onMessage".into(), + ..Default::default() + }; + summary.callees.push(CalleeSite { + name: "consumer.poll".to_owned(), + receiver: Some("consumer".to_owned()), + ordinal: 0, + ..Default::default() + }); + let mut ssa = SsaFuncSummary::default(); + ssa.typed_call_receivers + .push((0, "KafkaConsumer".to_owned())); + assert!( + KafkaJavaAdapter + .detect_with_context(&summary, Some(&ssa), tree.root_node(), src) + .is_some() + ); + } } diff --git a/src/dynamic/framework/adapters/kafka_python.rs b/src/dynamic/framework/adapters/kafka_python.rs index 8a91db70..4d57dc26 100644 --- a/src/dynamic/framework/adapters/kafka_python.rs +++ b/src/dynamic/framework/adapters/kafka_python.rs @@ -11,6 +11,7 @@ use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding}; use crate::evidence::EntryKind; use crate::summary::FuncSummary; +use crate::summary::ssa_summary::SsaFuncSummary; use crate::symbol::Lang; pub struct KafkaPythonAdapter; @@ -67,32 +68,64 @@ impl FrameworkAdapter for KafkaPythonAdapter { fn detect( &self, summary: &FuncSummary, - _ast: tree_sitter::Node<'_>, + ast: tree_sitter::Node<'_>, file_bytes: &[u8], ) -> Option { - let matches_call = super::any_callee_matches(summary, callee_is_kafka_consumer); - let matches_source = source_imports_kafka(file_bytes); - if matches_call || matches_source { - Some(FrameworkBinding { - adapter: ADAPTER_NAME.to_owned(), - kind: EntryKind::MessageHandler { - queue: extract_topic_literal(file_bytes), - message_schema: None, - }, - route: None, - request_params: Vec::new(), - response_writer: None, - middleware: Vec::new(), - }) - } else { - None - } + detect_kafka_python(summary, None, ast, file_bytes) } + + fn detect_with_context( + &self, + summary: &FuncSummary, + ssa_summary: Option<&SsaFuncSummary>, + ast: tree_sitter::Node<'_>, + file_bytes: &[u8], + ) -> Option { + detect_kafka_python(summary, ssa_summary, ast, file_bytes) + } +} + +fn detect_kafka_python( + summary: &FuncSummary, + ssa_summary: Option<&SsaFuncSummary>, + _ast: tree_sitter::Node<'_>, + file_bytes: &[u8], +) -> Option { + let matches_call = super::any_callee_matches(summary, callee_is_kafka_consumer); + let matches_source = source_imports_kafka(file_bytes); + if !(matches_call || matches_source) { + return None; + } + if !super::typed_receiver_facts_allow( + summary, + ssa_summary, + callee_is_kafka_consumer, + typed_container_allows_kafka, + ) { + return None; + } + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::MessageHandler { + queue: extract_topic_literal(file_bytes), + message_schema: None, + }, + route: None, + request_params: Vec::new(), + response_writer: None, + middleware: Vec::new(), + }) +} + +fn typed_container_allows_kafka(container: &str) -> bool { + let lc = container.to_ascii_lowercase(); + lc.contains("kafka") || lc.contains("consumer") } #[cfg(test)] mod tests { use super::*; + use crate::summary::CalleeSite; fn parse_python(src: &[u8]) -> tree_sitter::Tree { let mut parser = tree_sitter::Parser::new(); @@ -135,4 +168,53 @@ mod tests { .is_none() ); } + + #[test] + fn ssa_receiver_type_rejects_non_kafka_poll_collision() { + let src: &[u8] = b"from kafka import KafkaConsumer\n\ + def handler(msg):\n cache.poll(msg)\n"; + let tree = parse_python(src); + let mut summary = FuncSummary { + name: "handler".into(), + ..Default::default() + }; + summary.callees.push(CalleeSite { + name: "cache.poll".to_owned(), + receiver: Some("cache".to_owned()), + ordinal: 0, + ..Default::default() + }); + let mut ssa = SsaFuncSummary::default(); + ssa.typed_call_receivers.push((0, "Cache".to_owned())); + assert!( + KafkaPythonAdapter + .detect_with_context(&summary, Some(&ssa), tree.root_node(), src) + .is_none() + ); + } + + #[test] + fn ssa_receiver_type_keeps_kafka_consumer() { + let src: &[u8] = b"from kafka import KafkaConsumer\n\ + def handler(msg):\n consumer.poll()\n"; + let tree = parse_python(src); + let mut summary = FuncSummary { + name: "handler".into(), + ..Default::default() + }; + summary.callees.push(CalleeSite { + name: "consumer.poll".to_owned(), + receiver: Some("consumer".to_owned()), + ordinal: 0, + ..Default::default() + }); + let mut ssa = SsaFuncSummary::default(); + ssa.typed_call_receivers + .push((0, "KafkaConsumer".to_owned())); + assert!( + KafkaPythonAdapter + .detect_with_context(&summary, Some(&ssa), tree.root_node(), src) + .is_some() + ); + } } diff --git a/src/dynamic/framework/adapters/mod.rs b/src/dynamic/framework/adapters/mod.rs index 7a8287eb..323efe52 100644 --- a/src/dynamic/framework/adapters/mod.rs +++ b/src/dynamic/framework/adapters/mod.rs @@ -262,6 +262,40 @@ fn any_callee_matches( summary.callees.iter().any(|c| predicate(c.name.as_str())) } +/// Use SSA receiver facts, when available, to reject permissive callee +/// matches whose receiver is known to belong to a different runtime. +/// +/// Adapters still accept source-only matches and call sites without typed +/// receiver facts. A typed incompatible receiver is stronger evidence than a +/// broad method name such as `send`, `poll`, `process`, or `receive`. +fn typed_receiver_facts_allow( + summary: &crate::summary::FuncSummary, + ssa_summary: Option<&crate::summary::ssa_summary::SsaFuncSummary>, + callee_pred: impl Fn(&str) -> bool, + container_pred: impl Fn(&str) -> bool, +) -> bool { + let Some(ssa_summary) = ssa_summary else { + return true; + }; + for site in &summary.callees { + if !callee_pred(site.name.as_str()) || site.receiver.is_none() { + continue; + } + let Some(container) = ssa_summary + .typed_call_receivers + .iter() + .find(|(ord, _)| *ord == site.ordinal) + .map(|(_, container)| container.as_str()) + else { + continue; + }; + if !container_pred(container) { + return false; + } + } + true +} + /// True when any callee in `summary.callees` matches `name_pred` AND /// (its receiver matches `receiver_pred` OR its receiver is `None`). /// diff --git a/src/dynamic/framework/adapters/nats_go.rs b/src/dynamic/framework/adapters/nats_go.rs index c494a62c..bf15c9af 100644 --- a/src/dynamic/framework/adapters/nats_go.rs +++ b/src/dynamic/framework/adapters/nats_go.rs @@ -3,6 +3,7 @@ use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding}; use crate::evidence::EntryKind; use crate::summary::FuncSummary; +use crate::summary::ssa_summary::SsaFuncSummary; use crate::symbol::Lang; pub struct NatsGoAdapter; @@ -49,32 +50,64 @@ impl FrameworkAdapter for NatsGoAdapter { fn detect( &self, summary: &FuncSummary, - _ast: tree_sitter::Node<'_>, + ast: tree_sitter::Node<'_>, file_bytes: &[u8], ) -> Option { - let matches_call = super::any_callee_matches(summary, callee_is_nats); - let matches_source = source_imports_nats(file_bytes); - if matches_call || matches_source { - Some(FrameworkBinding { - adapter: ADAPTER_NAME.to_owned(), - kind: EntryKind::MessageHandler { - queue: extract_subject(file_bytes), - message_schema: None, - }, - route: None, - request_params: Vec::new(), - response_writer: None, - middleware: Vec::new(), - }) - } else { - None - } + detect_nats_go(summary, None, ast, file_bytes) } + + fn detect_with_context( + &self, + summary: &FuncSummary, + ssa_summary: Option<&SsaFuncSummary>, + ast: tree_sitter::Node<'_>, + file_bytes: &[u8], + ) -> Option { + detect_nats_go(summary, ssa_summary, ast, file_bytes) + } +} + +fn detect_nats_go( + summary: &FuncSummary, + ssa_summary: Option<&SsaFuncSummary>, + _ast: tree_sitter::Node<'_>, + file_bytes: &[u8], +) -> Option { + let matches_call = super::any_callee_matches(summary, callee_is_nats); + let matches_source = source_imports_nats(file_bytes); + if !(matches_call || matches_source) { + return None; + } + if !super::typed_receiver_facts_allow( + summary, + ssa_summary, + callee_is_nats, + typed_container_allows_nats, + ) { + return None; + } + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::MessageHandler { + queue: extract_subject(file_bytes), + message_schema: None, + }, + route: None, + request_params: Vec::new(), + response_writer: None, + middleware: Vec::new(), + }) +} + +fn typed_container_allows_nats(container: &str) -> bool { + let lc = container.to_ascii_lowercase(); + lc.contains("nats") || lc.contains("subscription") } #[cfg(test)] mod tests { use super::*; + use crate::summary::CalleeSite; fn parse_go(src: &[u8]) -> tree_sitter::Tree { let mut parser = tree_sitter::Parser::new(); @@ -101,4 +134,52 @@ mod tests { assert_eq!(queue, "events"); } } + + #[test] + fn ssa_receiver_type_rejects_non_nats_publish_collision() { + let src: &[u8] = b"package entry\nimport \"github.com/nats-io/nats.go\"\n\ + func OnMessage(msg *nats.Msg) { bus.Publish(msg) }\n"; + let tree = parse_go(src); + let mut summary = FuncSummary { + name: "OnMessage".into(), + ..Default::default() + }; + summary.callees.push(CalleeSite { + name: "bus.Publish".to_owned(), + receiver: Some("bus".to_owned()), + ordinal: 0, + ..Default::default() + }); + let mut ssa = SsaFuncSummary::default(); + ssa.typed_call_receivers.push((0, "EventBus".to_owned())); + assert!( + NatsGoAdapter + .detect_with_context(&summary, Some(&ssa), tree.root_node(), src) + .is_none() + ); + } + + #[test] + fn ssa_receiver_type_keeps_nats_connection() { + let src: &[u8] = b"package entry\nimport \"github.com/nats-io/nats.go\"\n\ + func OnMessage(msg *nats.Msg) { nc.Subscribe(\"events\", OnMessage) }\n"; + let tree = parse_go(src); + let mut summary = FuncSummary { + name: "OnMessage".into(), + ..Default::default() + }; + summary.callees.push(CalleeSite { + name: "nc.Subscribe".to_owned(), + receiver: Some("nc".to_owned()), + ordinal: 0, + ..Default::default() + }); + let mut ssa = SsaFuncSummary::default(); + ssa.typed_call_receivers.push((0, "nats.Conn".to_owned())); + assert!( + NatsGoAdapter + .detect_with_context(&summary, Some(&ssa), tree.root_node(), src) + .is_some() + ); + } } diff --git a/src/dynamic/framework/adapters/pubsub_go.rs b/src/dynamic/framework/adapters/pubsub_go.rs index dfbbd7bb..b6d9eff9 100644 --- a/src/dynamic/framework/adapters/pubsub_go.rs +++ b/src/dynamic/framework/adapters/pubsub_go.rs @@ -4,6 +4,7 @@ use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding}; use crate::evidence::EntryKind; use crate::summary::FuncSummary; +use crate::summary::ssa_summary::SsaFuncSummary; use crate::symbol::Lang; pub struct PubsubGoAdapter; @@ -54,32 +55,64 @@ impl FrameworkAdapter for PubsubGoAdapter { fn detect( &self, summary: &FuncSummary, - _ast: tree_sitter::Node<'_>, + ast: tree_sitter::Node<'_>, file_bytes: &[u8], ) -> Option { - let matches_call = super::any_callee_matches(summary, callee_is_pubsub); - let matches_source = source_imports_pubsub(file_bytes); - if matches_call || matches_source { - Some(FrameworkBinding { - adapter: ADAPTER_NAME.to_owned(), - kind: EntryKind::MessageHandler { - queue: extract_topic(file_bytes), - message_schema: None, - }, - route: None, - request_params: Vec::new(), - response_writer: None, - middleware: Vec::new(), - }) - } else { - None - } + detect_pubsub_go(summary, None, ast, file_bytes) } + + fn detect_with_context( + &self, + summary: &FuncSummary, + ssa_summary: Option<&SsaFuncSummary>, + ast: tree_sitter::Node<'_>, + file_bytes: &[u8], + ) -> Option { + detect_pubsub_go(summary, ssa_summary, ast, file_bytes) + } +} + +fn detect_pubsub_go( + summary: &FuncSummary, + ssa_summary: Option<&SsaFuncSummary>, + _ast: tree_sitter::Node<'_>, + file_bytes: &[u8], +) -> Option { + let matches_call = super::any_callee_matches(summary, callee_is_pubsub); + let matches_source = source_imports_pubsub(file_bytes); + if !(matches_call || matches_source) { + return None; + } + if !super::typed_receiver_facts_allow( + summary, + ssa_summary, + callee_is_pubsub, + typed_container_allows_pubsub, + ) { + return None; + } + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::MessageHandler { + queue: extract_topic(file_bytes), + message_schema: None, + }, + route: None, + request_params: Vec::new(), + response_writer: None, + middleware: Vec::new(), + }) +} + +fn typed_container_allows_pubsub(container: &str) -> bool { + let lc = container.to_ascii_lowercase(); + lc.contains("pubsub") || lc.contains("subscription") || lc.contains("subscriber") } #[cfg(test)] mod tests { use super::*; + use crate::summary::CalleeSite; fn parse_go(src: &[u8]) -> tree_sitter::Tree { let mut parser = tree_sitter::Parser::new(); @@ -105,4 +138,53 @@ mod tests { assert_eq!(queue, "my-sub"); } } + + #[test] + fn ssa_receiver_type_rejects_non_pubsub_receive_collision() { + let src: &[u8] = b"package entry\nimport \"cloud.google.com/go/pubsub\"\n\ + func Handle(msg *pubsub.Message) { inbox.Receive() }\n"; + let tree = parse_go(src); + let mut summary = FuncSummary { + name: "Handle".into(), + ..Default::default() + }; + summary.callees.push(CalleeSite { + name: "inbox.Receive".to_owned(), + receiver: Some("inbox".to_owned()), + ordinal: 0, + ..Default::default() + }); + let mut ssa = SsaFuncSummary::default(); + ssa.typed_call_receivers.push((0, "Inbox".to_owned())); + assert!( + PubsubGoAdapter + .detect_with_context(&summary, Some(&ssa), tree.root_node(), src) + .is_none() + ); + } + + #[test] + fn ssa_receiver_type_keeps_pubsub_subscription() { + let src: &[u8] = b"package entry\nimport \"cloud.google.com/go/pubsub\"\n\ + func Handle(msg *pubsub.Message) { sub.Receive(ctx, cb) }\n"; + let tree = parse_go(src); + let mut summary = FuncSummary { + name: "Handle".into(), + ..Default::default() + }; + summary.callees.push(CalleeSite { + name: "sub.Receive".to_owned(), + receiver: Some("sub".to_owned()), + ordinal: 0, + ..Default::default() + }); + let mut ssa = SsaFuncSummary::default(); + ssa.typed_call_receivers + .push((0, "pubsub.Subscription".to_owned())); + assert!( + PubsubGoAdapter + .detect_with_context(&summary, Some(&ssa), tree.root_node(), src) + .is_some() + ); + } } diff --git a/src/dynamic/framework/adapters/pubsub_python.rs b/src/dynamic/framework/adapters/pubsub_python.rs index d113f96a..87d0fc3e 100644 --- a/src/dynamic/framework/adapters/pubsub_python.rs +++ b/src/dynamic/framework/adapters/pubsub_python.rs @@ -3,6 +3,7 @@ use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding}; use crate::evidence::EntryKind; use crate::summary::FuncSummary; +use crate::summary::ssa_summary::SsaFuncSummary; use crate::symbol::Lang; pub struct PubsubPythonAdapter; @@ -57,32 +58,64 @@ impl FrameworkAdapter for PubsubPythonAdapter { fn detect( &self, summary: &FuncSummary, - _ast: tree_sitter::Node<'_>, + ast: tree_sitter::Node<'_>, file_bytes: &[u8], ) -> Option { - let matches_call = super::any_callee_matches(summary, callee_is_pubsub); - let matches_source = source_imports_pubsub(file_bytes); - if matches_call || matches_source { - Some(FrameworkBinding { - adapter: ADAPTER_NAME.to_owned(), - kind: EntryKind::MessageHandler { - queue: extract_topic(file_bytes), - message_schema: None, - }, - route: None, - request_params: Vec::new(), - response_writer: None, - middleware: Vec::new(), - }) - } else { - None - } + detect_pubsub_python(summary, None, ast, file_bytes) } + + fn detect_with_context( + &self, + summary: &FuncSummary, + ssa_summary: Option<&SsaFuncSummary>, + ast: tree_sitter::Node<'_>, + file_bytes: &[u8], + ) -> Option { + detect_pubsub_python(summary, ssa_summary, ast, file_bytes) + } +} + +fn detect_pubsub_python( + summary: &FuncSummary, + ssa_summary: Option<&SsaFuncSummary>, + _ast: tree_sitter::Node<'_>, + file_bytes: &[u8], +) -> Option { + let matches_call = super::any_callee_matches(summary, callee_is_pubsub); + let matches_source = source_imports_pubsub(file_bytes); + if !(matches_call || matches_source) { + return None; + } + if !super::typed_receiver_facts_allow( + summary, + ssa_summary, + callee_is_pubsub, + typed_container_allows_pubsub, + ) { + return None; + } + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::MessageHandler { + queue: extract_topic(file_bytes), + message_schema: None, + }, + route: None, + request_params: Vec::new(), + response_writer: None, + middleware: Vec::new(), + }) +} + +fn typed_container_allows_pubsub(container: &str) -> bool { + let lc = container.to_ascii_lowercase(); + lc.contains("pubsub") || lc.contains("subscriber") || lc.contains("subscription") } #[cfg(test)] mod tests { use super::*; + use crate::summary::CalleeSite; fn parse_python(src: &[u8]) -> tree_sitter::Tree { let mut parser = tree_sitter::Parser::new(); @@ -109,4 +142,53 @@ mod tests { assert_eq!(queue, "projects/p/subscriptions/s"); } } + + #[test] + fn ssa_receiver_type_rejects_non_pubsub_callback_collision() { + let src: &[u8] = b"from google.cloud import pubsub_v1\n\ + def callback(message):\n timer.callback(message)\n"; + let tree = parse_python(src); + let mut summary = FuncSummary { + name: "callback".into(), + ..Default::default() + }; + summary.callees.push(CalleeSite { + name: "timer.callback".to_owned(), + receiver: Some("timer".to_owned()), + ordinal: 0, + ..Default::default() + }); + let mut ssa = SsaFuncSummary::default(); + ssa.typed_call_receivers.push((0, "Timer".to_owned())); + assert!( + PubsubPythonAdapter + .detect_with_context(&summary, Some(&ssa), tree.root_node(), src) + .is_none() + ); + } + + #[test] + fn ssa_receiver_type_keeps_pubsub_subscriber() { + let src: &[u8] = b"from google.cloud import pubsub_v1\n\ + def callback(message):\n sub.subscribe('projects/p/subscriptions/s')\n"; + let tree = parse_python(src); + let mut summary = FuncSummary { + name: "callback".into(), + ..Default::default() + }; + summary.callees.push(CalleeSite { + name: "sub.subscribe".to_owned(), + receiver: Some("sub".to_owned()), + ordinal: 0, + ..Default::default() + }); + let mut ssa = SsaFuncSummary::default(); + ssa.typed_call_receivers + .push((0, "PubsubSubscriberClient".to_owned())); + assert!( + PubsubPythonAdapter + .detect_with_context(&summary, Some(&ssa), tree.root_node(), src) + .is_some() + ); + } } diff --git a/src/dynamic/framework/adapters/rabbit_java.rs b/src/dynamic/framework/adapters/rabbit_java.rs index 0991f077..c2f07abe 100644 --- a/src/dynamic/framework/adapters/rabbit_java.rs +++ b/src/dynamic/framework/adapters/rabbit_java.rs @@ -5,6 +5,7 @@ use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding}; use crate::evidence::EntryKind; use crate::summary::FuncSummary; +use crate::summary::ssa_summary::SsaFuncSummary; use crate::symbol::Lang; pub struct RabbitJavaAdapter; @@ -60,32 +61,64 @@ impl FrameworkAdapter for RabbitJavaAdapter { fn detect( &self, summary: &FuncSummary, - _ast: tree_sitter::Node<'_>, + ast: tree_sitter::Node<'_>, file_bytes: &[u8], ) -> Option { - let matches_call = super::any_callee_matches(summary, callee_is_rabbit); - let matches_source = source_imports_rabbit(file_bytes); - if matches_call || matches_source { - Some(FrameworkBinding { - adapter: ADAPTER_NAME.to_owned(), - kind: EntryKind::MessageHandler { - queue: extract_queue(file_bytes), - message_schema: None, - }, - route: None, - request_params: Vec::new(), - response_writer: None, - middleware: Vec::new(), - }) - } else { - None - } + detect_rabbit_java(summary, None, ast, file_bytes) } + + fn detect_with_context( + &self, + summary: &FuncSummary, + ssa_summary: Option<&SsaFuncSummary>, + ast: tree_sitter::Node<'_>, + file_bytes: &[u8], + ) -> Option { + detect_rabbit_java(summary, ssa_summary, ast, file_bytes) + } +} + +fn detect_rabbit_java( + summary: &FuncSummary, + ssa_summary: Option<&SsaFuncSummary>, + _ast: tree_sitter::Node<'_>, + file_bytes: &[u8], +) -> Option { + let matches_call = super::any_callee_matches(summary, callee_is_rabbit); + let matches_source = source_imports_rabbit(file_bytes); + if !(matches_call || matches_source) { + return None; + } + if !super::typed_receiver_facts_allow( + summary, + ssa_summary, + callee_is_rabbit, + typed_container_allows_rabbit, + ) { + return None; + } + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::MessageHandler { + queue: extract_queue(file_bytes), + message_schema: None, + }, + route: None, + request_params: Vec::new(), + response_writer: None, + middleware: Vec::new(), + }) +} + +fn typed_container_allows_rabbit(container: &str) -> bool { + let lc = container.to_ascii_lowercase(); + lc.contains("rabbit") || lc.contains("amqp") || lc.contains("channel") } #[cfg(test)] mod tests { use super::*; + use crate::summary::CalleeSite; fn parse_java(src: &[u8]) -> tree_sitter::Tree { let mut parser = tree_sitter::Parser::new(); @@ -113,4 +146,56 @@ mod tests { assert_eq!(queue, "work"); } } + + #[test] + fn ssa_receiver_type_rejects_non_rabbit_receive_collision() { + let src: &[u8] = b"import org.springframework.amqp.rabbit.annotation.RabbitListener;\n\ + public class Vuln {\n\ + public void onMessage(String body) { inbox.receive(); }\n\ + }\n"; + let tree = parse_java(src); + let mut summary = FuncSummary { + name: "onMessage".into(), + ..Default::default() + }; + summary.callees.push(CalleeSite { + name: "inbox.receive".to_owned(), + receiver: Some("inbox".to_owned()), + ordinal: 0, + ..Default::default() + }); + let mut ssa = SsaFuncSummary::default(); + ssa.typed_call_receivers.push((0, "Inbox".to_owned())); + assert!( + RabbitJavaAdapter + .detect_with_context(&summary, Some(&ssa), tree.root_node(), src) + .is_none() + ); + } + + #[test] + fn ssa_receiver_type_keeps_rabbit_channel() { + let src: &[u8] = b"import com.rabbitmq.client.Channel;\n\ + public class Vuln {\n\ + public void onMessage(String body) { channel.basicConsume(\"work\", true, consumer); }\n\ + }\n"; + let tree = parse_java(src); + let mut summary = FuncSummary { + name: "onMessage".into(), + ..Default::default() + }; + summary.callees.push(CalleeSite { + name: "channel.basicConsume".to_owned(), + receiver: Some("channel".to_owned()), + ordinal: 0, + ..Default::default() + }); + let mut ssa = SsaFuncSummary::default(); + ssa.typed_call_receivers.push((0, "Channel".to_owned())); + assert!( + RabbitJavaAdapter + .detect_with_context(&summary, Some(&ssa), tree.root_node(), src) + .is_some() + ); + } } diff --git a/src/dynamic/framework/adapters/rabbit_python.rs b/src/dynamic/framework/adapters/rabbit_python.rs index 74e2778f..54f50575 100644 --- a/src/dynamic/framework/adapters/rabbit_python.rs +++ b/src/dynamic/framework/adapters/rabbit_python.rs @@ -4,6 +4,7 @@ use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding}; use crate::evidence::EntryKind; use crate::summary::FuncSummary; +use crate::summary::ssa_summary::SsaFuncSummary; use crate::symbol::Lang; pub struct RabbitPythonAdapter; @@ -56,32 +57,64 @@ impl FrameworkAdapter for RabbitPythonAdapter { fn detect( &self, summary: &FuncSummary, - _ast: tree_sitter::Node<'_>, + ast: tree_sitter::Node<'_>, file_bytes: &[u8], ) -> Option { - let matches_call = super::any_callee_matches(summary, callee_is_rabbit); - let matches_source = source_imports_rabbit(file_bytes); - if matches_call || matches_source { - Some(FrameworkBinding { - adapter: ADAPTER_NAME.to_owned(), - kind: EntryKind::MessageHandler { - queue: extract_queue(file_bytes), - message_schema: None, - }, - route: None, - request_params: Vec::new(), - response_writer: None, - middleware: Vec::new(), - }) - } else { - None - } + detect_rabbit_python(summary, None, ast, file_bytes) } + + fn detect_with_context( + &self, + summary: &FuncSummary, + ssa_summary: Option<&SsaFuncSummary>, + ast: tree_sitter::Node<'_>, + file_bytes: &[u8], + ) -> Option { + detect_rabbit_python(summary, ssa_summary, ast, file_bytes) + } +} + +fn detect_rabbit_python( + summary: &FuncSummary, + ssa_summary: Option<&SsaFuncSummary>, + _ast: tree_sitter::Node<'_>, + file_bytes: &[u8], +) -> Option { + let matches_call = super::any_callee_matches(summary, callee_is_rabbit); + let matches_source = source_imports_rabbit(file_bytes); + if !(matches_call || matches_source) { + return None; + } + if !super::typed_receiver_facts_allow( + summary, + ssa_summary, + callee_is_rabbit, + typed_container_allows_rabbit, + ) { + return None; + } + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::MessageHandler { + queue: extract_queue(file_bytes), + message_schema: None, + }, + route: None, + request_params: Vec::new(), + response_writer: None, + middleware: Vec::new(), + }) +} + +fn typed_container_allows_rabbit(container: &str) -> bool { + let lc = container.to_ascii_lowercase(); + lc.contains("rabbit") || lc.contains("pika") || lc.contains("amqp") || lc.contains("channel") } #[cfg(test)] mod tests { use super::*; + use crate::summary::CalleeSite; fn parse_python(src: &[u8]) -> tree_sitter::Tree { let mut parser = tree_sitter::Parser::new(); @@ -108,4 +141,53 @@ mod tests { assert_eq!(queue, "work"); } } + + #[test] + fn ssa_receiver_type_rejects_non_rabbit_process_collision() { + let src: &[u8] = b"import pika\n\ + def on_message(ch, method, properties, body):\n worker.process(body)\n"; + let tree = parse_python(src); + let mut summary = FuncSummary { + name: "on_message".into(), + ..Default::default() + }; + summary.callees.push(CalleeSite { + name: "worker.process".to_owned(), + receiver: Some("worker".to_owned()), + ordinal: 0, + ..Default::default() + }); + let mut ssa = SsaFuncSummary::default(); + ssa.typed_call_receivers.push((0, "Worker".to_owned())); + assert!( + RabbitPythonAdapter + .detect_with_context(&summary, Some(&ssa), tree.root_node(), src) + .is_none() + ); + } + + #[test] + fn ssa_receiver_type_keeps_rabbit_channel() { + let src: &[u8] = b"import pika\n\ + def on_message(ch, method, properties, body):\n channel.basic_consume(queue='work')\n"; + let tree = parse_python(src); + let mut summary = FuncSummary { + name: "on_message".into(), + ..Default::default() + }; + summary.callees.push(CalleeSite { + name: "channel.basic_consume".to_owned(), + receiver: Some("channel".to_owned()), + ordinal: 0, + ..Default::default() + }); + let mut ssa = SsaFuncSummary::default(); + ssa.typed_call_receivers + .push((0, "BlockingChannel".to_owned())); + assert!( + RabbitPythonAdapter + .detect_with_context(&summary, Some(&ssa), tree.root_node(), src) + .is_some() + ); + } } diff --git a/src/dynamic/framework/adapters/sqs_java.rs b/src/dynamic/framework/adapters/sqs_java.rs index 78914147..8969ce06 100644 --- a/src/dynamic/framework/adapters/sqs_java.rs +++ b/src/dynamic/framework/adapters/sqs_java.rs @@ -3,6 +3,7 @@ use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding}; use crate::evidence::EntryKind; use crate::summary::FuncSummary; +use crate::summary::ssa_summary::SsaFuncSummary; use crate::symbol::Lang; pub struct SqsJavaAdapter; @@ -54,32 +55,64 @@ impl FrameworkAdapter for SqsJavaAdapter { fn detect( &self, summary: &FuncSummary, - _ast: tree_sitter::Node<'_>, + ast: tree_sitter::Node<'_>, file_bytes: &[u8], ) -> Option { - let matches_call = super::any_callee_matches(summary, callee_is_sqs); - let matches_source = source_imports_sqs(file_bytes); - if matches_call || matches_source { - Some(FrameworkBinding { - adapter: ADAPTER_NAME.to_owned(), - kind: EntryKind::MessageHandler { - queue: extract_queue(file_bytes), - message_schema: None, - }, - route: None, - request_params: Vec::new(), - response_writer: None, - middleware: Vec::new(), - }) - } else { - None - } + detect_sqs_java(summary, None, ast, file_bytes) } + + fn detect_with_context( + &self, + summary: &FuncSummary, + ssa_summary: Option<&SsaFuncSummary>, + ast: tree_sitter::Node<'_>, + file_bytes: &[u8], + ) -> Option { + detect_sqs_java(summary, ssa_summary, ast, file_bytes) + } +} + +fn detect_sqs_java( + summary: &FuncSummary, + ssa_summary: Option<&SsaFuncSummary>, + _ast: tree_sitter::Node<'_>, + file_bytes: &[u8], +) -> Option { + let matches_call = super::any_callee_matches(summary, callee_is_sqs); + let matches_source = source_imports_sqs(file_bytes); + if !(matches_call || matches_source) { + return None; + } + if !super::typed_receiver_facts_allow( + summary, + ssa_summary, + callee_is_sqs, + typed_container_allows_sqs, + ) { + return None; + } + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::MessageHandler { + queue: extract_queue(file_bytes), + message_schema: None, + }, + route: None, + request_params: Vec::new(), + response_writer: None, + middleware: Vec::new(), + }) +} + +fn typed_container_allows_sqs(container: &str) -> bool { + let lc = container.to_ascii_lowercase(); + lc.contains("sqs") || lc.contains("queue") } #[cfg(test)] mod tests { use super::*; + use crate::summary::CalleeSite; fn parse_java(src: &[u8]) -> tree_sitter::Tree { let mut parser = tree_sitter::Parser::new(); @@ -107,4 +140,56 @@ mod tests { assert_eq!(queue, "jobs"); } } + + #[test] + fn ssa_receiver_type_rejects_non_sqs_handle_collision() { + let src: &[u8] = b"import io.awspring.cloud.sqs.annotation.SqsListener;\n\ + public class Vuln {\n\ + public void handleMessage(String env) { worker.handleMessage(env); }\n\ + }\n"; + let tree = parse_java(src); + let mut summary = FuncSummary { + name: "handleMessage".into(), + ..Default::default() + }; + summary.callees.push(CalleeSite { + name: "worker.handleMessage".to_owned(), + receiver: Some("worker".to_owned()), + ordinal: 0, + ..Default::default() + }); + let mut ssa = SsaFuncSummary::default(); + ssa.typed_call_receivers.push((0, "Worker".to_owned())); + assert!( + SqsJavaAdapter + .detect_with_context(&summary, Some(&ssa), tree.root_node(), src) + .is_none() + ); + } + + #[test] + fn ssa_receiver_type_keeps_sqs_client() { + let src: &[u8] = b"import software.amazon.awssdk.services.sqs.SqsClient;\n\ + public class Vuln {\n\ + public void handleMessage(String env) { client.receiveMessage(); }\n\ + }\n"; + let tree = parse_java(src); + let mut summary = FuncSummary { + name: "handleMessage".into(), + ..Default::default() + }; + summary.callees.push(CalleeSite { + name: "client.receiveMessage".to_owned(), + receiver: Some("client".to_owned()), + ordinal: 0, + ..Default::default() + }); + let mut ssa = SsaFuncSummary::default(); + ssa.typed_call_receivers.push((0, "SqsClient".to_owned())); + assert!( + SqsJavaAdapter + .detect_with_context(&summary, Some(&ssa), tree.root_node(), src) + .is_some() + ); + } } diff --git a/src/dynamic/framework/adapters/sqs_python.rs b/src/dynamic/framework/adapters/sqs_python.rs index bbb355a8..b6a0a686 100644 --- a/src/dynamic/framework/adapters/sqs_python.rs +++ b/src/dynamic/framework/adapters/sqs_python.rs @@ -3,6 +3,7 @@ use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding}; use crate::evidence::EntryKind; use crate::summary::FuncSummary; +use crate::summary::ssa_summary::SsaFuncSummary; use crate::symbol::Lang; pub struct SqsPythonAdapter; @@ -57,32 +58,64 @@ impl FrameworkAdapter for SqsPythonAdapter { fn detect( &self, summary: &FuncSummary, - _ast: tree_sitter::Node<'_>, + ast: tree_sitter::Node<'_>, file_bytes: &[u8], ) -> Option { - let matches_call = super::any_callee_matches(summary, callee_is_sqs); - let matches_source = source_imports_sqs(file_bytes); - if matches_call || matches_source { - Some(FrameworkBinding { - adapter: ADAPTER_NAME.to_owned(), - kind: EntryKind::MessageHandler { - queue: extract_queue(file_bytes), - message_schema: None, - }, - route: None, - request_params: Vec::new(), - response_writer: None, - middleware: Vec::new(), - }) - } else { - None - } + detect_sqs_python(summary, None, ast, file_bytes) } + + fn detect_with_context( + &self, + summary: &FuncSummary, + ssa_summary: Option<&SsaFuncSummary>, + ast: tree_sitter::Node<'_>, + file_bytes: &[u8], + ) -> Option { + detect_sqs_python(summary, ssa_summary, ast, file_bytes) + } +} + +fn detect_sqs_python( + summary: &FuncSummary, + ssa_summary: Option<&SsaFuncSummary>, + _ast: tree_sitter::Node<'_>, + file_bytes: &[u8], +) -> Option { + let matches_call = super::any_callee_matches(summary, callee_is_sqs); + let matches_source = source_imports_sqs(file_bytes); + if !(matches_call || matches_source) { + return None; + } + if !super::typed_receiver_facts_allow( + summary, + ssa_summary, + callee_is_sqs, + typed_container_allows_sqs, + ) { + return None; + } + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::MessageHandler { + queue: extract_queue(file_bytes), + message_schema: None, + }, + route: None, + request_params: Vec::new(), + response_writer: None, + middleware: Vec::new(), + }) +} + +fn typed_container_allows_sqs(container: &str) -> bool { + let lc = container.to_ascii_lowercase(); + lc.contains("sqs") || lc.contains("queue") } #[cfg(test)] mod tests { use super::*; + use crate::summary::CalleeSite; fn parse_python(src: &[u8]) -> tree_sitter::Tree { let mut parser = tree_sitter::Parser::new(); @@ -109,4 +142,54 @@ mod tests { assert_eq!(queue, "jobs"); } } + + #[test] + fn ssa_receiver_type_rejects_non_sqs_process_collision() { + let src: &[u8] = b"import boto3\n\ + boto3.client('sqs')\n\ + def handler(envelope):\n cache.process_message(envelope)\n"; + let tree = parse_python(src); + let mut summary = FuncSummary { + name: "handler".into(), + ..Default::default() + }; + summary.callees.push(CalleeSite { + name: "cache.process_message".to_owned(), + receiver: Some("cache".to_owned()), + ordinal: 0, + ..Default::default() + }); + let mut ssa = SsaFuncSummary::default(); + ssa.typed_call_receivers.push((0, "Cache".to_owned())); + assert!( + SqsPythonAdapter + .detect_with_context(&summary, Some(&ssa), tree.root_node(), src) + .is_none() + ); + } + + #[test] + fn ssa_receiver_type_keeps_sqs_queue_receiver() { + let src: &[u8] = b"import boto3\n\ + def handler(envelope):\n queue.process_message(envelope)\n"; + let tree = parse_python(src); + let mut summary = FuncSummary { + name: "handler".into(), + ..Default::default() + }; + summary.callees.push(CalleeSite { + name: "queue.process_message".to_owned(), + receiver: Some("queue".to_owned()), + ordinal: 0, + ..Default::default() + }); + let mut ssa = SsaFuncSummary::default(); + ssa.typed_call_receivers + .push((0, "SqsQueueClient".to_owned())); + assert!( + SqsPythonAdapter + .detect_with_context(&summary, Some(&ssa), tree.root_node(), src) + .is_some() + ); + } } diff --git a/tests/dynamic_fixtures/php_frameworks/codeigniter/benign.php b/tests/dynamic_fixtures/php_frameworks/codeigniter/benign.php index 3eb3e222..38aaab6c 100644 --- a/tests/dynamic_fixtures/php_frameworks/codeigniter/benign.php +++ b/tests/dynamic_fixtures/php_frameworks/codeigniter/benign.php @@ -1,8 +1,33 @@ $method($payload); + }; + return $this; + } +} + +$routes = new NyxRoutes(); $routes->get('run', 'UserController::run'); class UserController extends BaseController @@ -10,9 +35,10 @@ class UserController extends BaseController public function run($payload) { echo "__NYX_SINK_HIT__\n"; - $cmd = "echo hello " . escapeshellarg($payload); + $cmd = "true " . escapeshellarg($payload); $out = shell_exec($cmd); echo $out; return $out; } } +} diff --git a/tests/dynamic_fixtures/php_frameworks/codeigniter/vuln.php b/tests/dynamic_fixtures/php_frameworks/codeigniter/vuln.php index 88a70f49..a890f28b 100644 --- a/tests/dynamic_fixtures/php_frameworks/codeigniter/vuln.php +++ b/tests/dynamic_fixtures/php_frameworks/codeigniter/vuln.php @@ -3,8 +3,33 @@ // `$routes->get('run', 'UserController::run')` references the // controller method whose body shells out without sanitisation. +namespace CodeIgniter\Router { + class RouteCollection + { + } +} + +namespace { use CodeIgniter\Router\RouteCollection; +class BaseController +{ +} + +class NyxRoutes extends RouteCollection +{ + public function get(string $path, string $callable) + { + $GLOBALS['__nyx_route'] = function (string $payload) use ($callable) { + [$class, $method] = explode('::', $callable, 2); + $controller = new $class(); + return $controller->$method($payload); + }; + return $this; + } +} + +$routes = new NyxRoutes(); $routes->get('run', 'UserController::run'); class UserController extends BaseController @@ -18,3 +43,4 @@ class UserController extends BaseController return $out; } } +} diff --git a/tests/dynamic_fixtures/php_frameworks/laravel/benign.php b/tests/dynamic_fixtures/php_frameworks/laravel/benign.php index 4da700ec..e7cffb72 100644 --- a/tests/dynamic_fixtures/php_frameworks/laravel/benign.php +++ b/tests/dynamic_fixtures/php_frameworks/laravel/benign.php @@ -1,6 +1,27 @@ $method($payload); + }; + return new class { + public function middleware($value) + { + return $this; + } + }; + } + } +} + +namespace { use Illuminate\Support\Facades\Route; Route::get('/run', 'UserController@run'); @@ -10,9 +31,10 @@ class UserController public function run($payload) { echo "__NYX_SINK_HIT__\n"; - $cmd = "echo hello " . escapeshellarg($payload); + $cmd = "true " . escapeshellarg($payload); $out = shell_exec($cmd); echo $out; return $out; } } +} diff --git a/tests/dynamic_fixtures/php_frameworks/laravel/vuln.php b/tests/dynamic_fixtures/php_frameworks/laravel/vuln.php index 822036b6..717dd93c 100644 --- a/tests/dynamic_fixtures/php_frameworks/laravel/vuln.php +++ b/tests/dynamic_fixtures/php_frameworks/laravel/vuln.php @@ -3,6 +3,27 @@ // `Route::get('/run', 'UserController@run')` references the // controller method whose body shells out without sanitisation. +namespace Illuminate\Support\Facades { + class Route + { + public static function get(string $path, string $callable) + { + $GLOBALS['__nyx_route'] = function (string $payload) use ($callable) { + [$class, $method] = preg_split('/@|::/', $callable); + $controller = new $class(); + return $controller->$method($payload); + }; + return new class { + public function middleware($value) + { + return $this; + } + }; + } + } +} + +namespace { use Illuminate\Support\Facades\Route; Route::get('/run', 'UserController@run'); @@ -18,3 +39,4 @@ class UserController return $out; } } +} diff --git a/tests/dynamic_fixtures/php_frameworks/symfony/benign.php b/tests/dynamic_fixtures/php_frameworks/symfony/benign.php index 3187e2a9..a788acf8 100644 --- a/tests/dynamic_fixtures/php_frameworks/symfony/benign.php +++ b/tests/dynamic_fixtures/php_frameworks/symfony/benign.php @@ -2,7 +2,31 @@ // Phase 16 — Symfony-style route via `#[Route]` attribute, // benign sanitised payload. -namespace App\Controller; +namespace Symfony\Component\HttpFoundation { + class Response + { + public function __construct(private string $content) + { + } + + public function __toString(): string + { + return $this->content; + } + } +} + +namespace Symfony\Component\Routing\Annotation { + #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)] + class Route + { + public function __construct(...$args) + { + } + } +} + +namespace App\Controller { use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; @@ -13,9 +37,12 @@ class UserController public function run($payload) { echo "__NYX_SINK_HIT__\n"; - $cmd = "echo hello " . escapeshellarg($payload); + $cmd = "true " . escapeshellarg($payload); $out = shell_exec($cmd); echo $out; return new Response($out); } } + +$GLOBALS['__nyx_controller'] = new UserController(); +} diff --git a/tests/dynamic_fixtures/php_frameworks/symfony/vuln.php b/tests/dynamic_fixtures/php_frameworks/symfony/vuln.php index bd595b14..62b0d6c5 100644 --- a/tests/dynamic_fixtures/php_frameworks/symfony/vuln.php +++ b/tests/dynamic_fixtures/php_frameworks/symfony/vuln.php @@ -2,7 +2,31 @@ // Phase 16 — Symfony-style route via `#[Route]` attribute, // vulnerable. -namespace App\Controller; +namespace Symfony\Component\HttpFoundation { + class Response + { + public function __construct(private string $content) + { + } + + public function __toString(): string + { + return $this->content; + } + } +} + +namespace Symfony\Component\Routing\Annotation { + #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)] + class Route + { + public function __construct(...$args) + { + } + } +} + +namespace App\Controller { use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; @@ -19,3 +43,6 @@ class UserController return new Response($out); } } + +$GLOBALS['__nyx_controller'] = new UserController(); +} diff --git a/tests/php_frameworks_corpus.rs b/tests/php_frameworks_corpus.rs index 0ec6e8d2..6960983b 100644 --- a/tests/php_frameworks_corpus.rs +++ b/tests/php_frameworks_corpus.rs @@ -156,6 +156,162 @@ fn laravel_adapter_ignores_helper_method() { assert!(binding.is_none()); } +mod e2e_phase_16_framework_dispatchers { + use super::{common::fixture_harness::FIXTURE_LOCK, parse_php, summary_for}; + use nyx_scanner::dynamic::framework::detect_binding; + use nyx_scanner::dynamic::runner::{RunError, RunOutcome, run_spec}; + use nyx_scanner::dynamic::sandbox::{SandboxBackend, SandboxOptions}; + use nyx_scanner::dynamic::spec::{ + EntryKind, HarnessSpec, JavaToolchain, PayloadSlot, SpecDerivationStrategy, + default_toolchain_id, + }; + use nyx_scanner::evidence::DifferentialVerdict; + use nyx_scanner::labels::Cap; + use nyx_scanner::symbol::Lang; + use std::path::PathBuf; + use std::process::Command; + use tempfile::TempDir; + + fn command_available(bin: &str) -> bool { + Command::new(bin) + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } + + fn fixture_path(framework: &str, file: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/dynamic_fixtures/php_frameworks") + .join(framework) + .join(file) + } + + fn build_spec(framework: &str, file: &str) -> (HarnessSpec, TempDir) { + let src = fixture_path(framework, file); + let tmp = TempDir::new().expect("create tempdir"); + let dst = tmp.path().join(file); + std::fs::copy(&src, &dst).expect("copy fixture into tempdir"); + let entry_file = dst.to_string_lossy().into_owned(); + let bytes = std::fs::read(&dst).expect("copied fixture readable"); + let tree = parse_php(&bytes); + let summary = summary_for("run", &entry_file); + let framework_binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::Php) + .unwrap_or_else(|| panic!("{framework}/{file} must bind")); + + let mut digest = blake3::Hasher::new(); + digest.update(b"phase16-e2e-php-framework-dispatcher|"); + digest.update(framework.as_bytes()); + digest.update(b"|"); + digest.update(file.as_bytes()); + let spec_hash = format!("{:016x}", { + let bytes = digest.finalize(); + u64::from_le_bytes(bytes.as_bytes()[..8].try_into().unwrap()) + }); + + let spec = HarnessSpec { + finding_id: spec_hash.clone(), + entry_file: entry_file.clone(), + entry_name: "run".to_owned(), + entry_kind: EntryKind::HttpRoute, + lang: Lang::Php, + toolchain_id: default_toolchain_id(Lang::Php).to_owned(), + payload_slot: PayloadSlot::Param(0), + expected_cap: Cap::CODE_EXEC, + constraint_hints: vec![], + sink_file: entry_file, + sink_line: 1, + spec_hash, + derivation: SpecDerivationStrategy::FromFlowSteps, + stubs_required: vec![], + framework: Some(framework_binding), + java_toolchain: JavaToolchain::default(), + }; + (spec, tmp) + } + + fn run(framework: &str, file: &str) -> Option { + if !command_available("php") { + eprintln!("SKIP {framework}/{file}: missing php"); + return None; + } + let _guard = FIXTURE_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let (spec, _tmp) = build_spec(framework, file); + let opts = SandboxOptions { + backend: SandboxBackend::Process, + ..SandboxOptions::default() + }; + match run_spec(&spec, &opts) { + Ok(outcome) => Some(outcome), + Err(RunError::BuildFailed { stderr, attempts }) => { + eprintln!( + "SKIP {framework}/{file}: harness build failed after {attempts} attempts: {stderr}", + ); + None + } + Err(e) => panic!("run_spec({framework}/{file}) errored: {e:?}"), + } + } + + fn assert_vuln_confirms(framework: &str) { + let Some(outcome) = run(framework, "vuln.php") else { + return; + }; + assert!( + outcome.triggered_by.is_some(), + "{framework} vuln must Confirm via run_spec; got {outcome:?}", + ); + let diff = outcome + .differential + .as_ref() + .expect("confirmed run must carry differential outcome"); + assert_eq!(diff.verdict, DifferentialVerdict::Confirmed); + } + + fn assert_benign_does_not_confirm(framework: &str) { + let Some(outcome) = run(framework, "benign.php") else { + return; + }; + assert!( + outcome.triggered_by.is_none(), + "{framework} benign control must not Confirm; got {outcome:?}", + ); + if let Some(diff) = &outcome.differential { + assert_ne!(diff.verdict, DifferentialVerdict::Confirmed); + } + } + + #[test] + fn laravel_vuln_confirms_via_run_spec() { + assert_vuln_confirms("laravel"); + } + + #[test] + fn laravel_benign_does_not_confirm_via_run_spec() { + assert_benign_does_not_confirm("laravel"); + } + + #[test] + fn symfony_vuln_confirms_via_run_spec() { + assert_vuln_confirms("symfony"); + } + + #[test] + fn symfony_benign_does_not_confirm_via_run_spec() { + assert_benign_does_not_confirm("symfony"); + } + + #[test] + fn codeigniter_vuln_confirms_via_run_spec() { + assert_vuln_confirms("codeigniter"); + } + + #[test] + fn codeigniter_benign_does_not_confirm_via_run_spec() { + assert_benign_does_not_confirm("codeigniter"); + } +} + mod e2e_phase_16_laravel_multi_verb { use super::{common::fixture_harness::FIXTURE_LOCK, parse_php, summary_for}; use nyx_scanner::dynamic::framework::{HttpMethod, detect_binding};