[pitboss] phase 20: Track M.2 — MessageHandler end-to-end (Kafka / SQS / Pub-Sub / NATS / RabbitMQ)

This commit is contained in:
pitboss 2026-05-20 16:03:40 -05:00
parent fedc507e6a
commit bd0135e423
45 changed files with 3227 additions and 25 deletions

View file

@ -0,0 +1,115 @@
//! Phase 20 (Track M.2) — Java Kafka consumer adapter.
//!
//! Fires on Spring Kafka `@KafkaListener` annotations or
//! `org.apache.kafka.clients.consumer.KafkaConsumer` references. Best-
//! effort topic extraction reads the literal that follows `topics =
//! "..."` / `topics = {"..."}` / `subscribe(Arrays.asList("..."))`.
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
pub struct KafkaJavaAdapter;
const ADAPTER_NAME: &str = "kafka-java";
fn callee_is_kafka(name: &str) -> bool {
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
matches!(
last,
"KafkaConsumer" | "subscribe" | "poll" | "onMessage" | "consume"
)
}
fn source_imports_kafka(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"org.apache.kafka",
b"org.springframework.kafka",
b"@KafkaListener",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
fn extract_topic(file_bytes: &[u8]) -> String {
let text = std::str::from_utf8(file_bytes).unwrap_or("");
for needle in ["topics = \"", "topics=\"", "topics = {\"", "subscribe(Arrays.asList(\""] {
if let Some(idx) = text.find(needle) {
let after = &text[idx + needle.len()..];
if let Some(end) = after.find('"') {
return after[..end].to_owned();
}
}
}
String::new()
}
impl FrameworkAdapter for KafkaJavaAdapter {
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<FrameworkBinding> {
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
}
}
}
#[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_spring_kafka_listener() {
let src: &[u8] = b"import org.springframework.kafka.annotation.KafkaListener;\n\
public class Vuln {\n\
@KafkaListener(topics = \"orders\")\n\
public void onMessage(String body) {}\n\
}\n";
let tree = parse_java(src);
let summary = FuncSummary {
name: "onMessage".into(),
..Default::default()
};
let binding = KafkaJavaAdapter
.detect(&summary, tree.root_node(), src)
.expect("@KafkaListener binds");
assert!(matches!(binding.kind, EntryKind::MessageHandler { .. }));
if let EntryKind::MessageHandler { queue, .. } = binding.kind {
assert_eq!(queue, "orders");
}
}
}

View file

@ -0,0 +1,136 @@
//! Phase 20 (Track M.2) — Python Kafka consumer adapter.
//!
//! Fires when the surrounding source imports the canonical Python
//! Kafka clients (`kafka-python` or `confluent-kafka`) and the function
//! body invokes a consumer-shaped callee. The binding's
//! [`EntryKind::MessageHandler`] is stamped with a best-effort `queue`
//! extracted from the source (a `KafkaConsumer('topic', ...)` /
//! `Consumer({"group.id": ..., "topics": ["t"]}).subscribe([...])`
//! literal); a missing topic falls back to the empty string.
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
pub struct KafkaPythonAdapter;
const ADAPTER_NAME: &str = "kafka-python";
fn callee_is_kafka_consumer(name: &str) -> bool {
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
matches!(
last,
"KafkaConsumer" | "Consumer" | "subscribe" | "poll" | "consume" | "process_message"
)
}
fn source_imports_kafka(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"from kafka",
b"import kafka",
b"from confluent_kafka",
b"import confluent_kafka",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
fn extract_topic_literal(file_bytes: &[u8]) -> String {
let text = std::str::from_utf8(file_bytes).unwrap_or("");
for needle in ["KafkaConsumer(", ".subscribe(", "topic="] {
if let Some(idx) = text.find(needle) {
let after = &text[idx + needle.len()..];
for (open, close) in [('"', '"'), ('\'', '\'')] {
if let Some(o) = after.find(open) {
let rest = &after[o + 1..];
if let Some(c) = rest.find(close) {
return rest[..c].to_owned();
}
}
}
}
}
String::new()
}
impl FrameworkAdapter for KafkaPythonAdapter {
fn name(&self) -> &'static str {
ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::Python
}
fn detect(
&self,
summary: &FuncSummary,
_ast: tree_sitter::Node<'_>,
file_bytes: &[u8],
) -> Option<FrameworkBinding> {
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
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_python(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_python::LANGUAGE);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
#[test]
fn fires_on_kafka_python_consumer() {
let src: &[u8] = b"from kafka import KafkaConsumer\n\n\
def handler(msg):\n print(msg)\n\n\
consumer = KafkaConsumer('orders', bootstrap_servers='broker:9092')\n";
let tree = parse_python(src);
let summary = FuncSummary {
name: "handler".into(),
..Default::default()
};
let binding = KafkaPythonAdapter
.detect(&summary, tree.root_node(), src)
.expect("kafka import binds");
assert_eq!(binding.adapter, "kafka-python");
assert!(matches!(binding.kind, EntryKind::MessageHandler { .. }));
if let EntryKind::MessageHandler { queue, .. } = binding.kind {
assert_eq!(queue, "orders");
}
}
#[test]
fn skips_plain_function() {
let src: &[u8] = b"def add(a, b):\n return a + b\n";
let tree = parse_python(src);
let summary = FuncSummary {
name: "add".into(),
..Default::default()
};
assert!(KafkaPythonAdapter
.detect(&summary, tree.root_node(), src)
.is_none());
}
}

View file

@ -36,9 +36,12 @@ pub mod js_handlebars;
pub mod js_koa;
pub mod js_nest;
pub mod js_routes;
pub mod kafka_java;
pub mod kafka_python;
pub mod ldap_php;
pub mod ldap_python;
pub mod ldap_spring;
pub mod nats_go;
pub mod php_codeigniter;
pub mod php_laravel;
pub mod php_routes;
@ -48,6 +51,8 @@ pub mod php_unserialize;
pub mod pp_json_deep_assign;
pub mod pp_lodash_merge;
pub mod pp_object_assign;
pub mod pubsub_go;
pub mod pubsub_python;
pub mod python_django;
pub mod python_fastapi;
pub mod python_flask;
@ -55,6 +60,8 @@ pub mod python_jinja2;
pub mod python_pickle;
pub mod python_routes;
pub mod python_starlette;
pub mod rabbit_java;
pub mod rabbit_python;
pub mod redirect_go;
pub mod redirect_java;
pub mod redirect_js;
@ -73,6 +80,9 @@ pub mod rust_axum;
pub mod rust_rocket;
pub mod rust_routes;
pub mod rust_warp;
pub mod sqs_java;
pub mod sqs_node;
pub mod sqs_python;
pub mod xpath_java;
pub mod xpath_js;
pub mod xpath_php;
@ -105,9 +115,12 @@ pub use js_fastify::JsFastifyAdapter;
pub use js_handlebars::JsHandlebarsAdapter;
pub use js_koa::JsKoaAdapter;
pub use js_nest::{JsNestAdapter, TsNestAdapter};
pub use kafka_java::KafkaJavaAdapter;
pub use kafka_python::KafkaPythonAdapter;
pub use ldap_php::LdapPhpAdapter;
pub use ldap_python::LdapPythonAdapter;
pub use ldap_spring::LdapSpringAdapter;
pub use nats_go::NatsGoAdapter;
pub use php_codeigniter::PhpCodeIgniterAdapter;
pub use php_laravel::PhpLaravelAdapter;
pub use php_symfony::PhpSymfonyAdapter;
@ -116,12 +129,16 @@ pub use php_unserialize::PhpUnserializeAdapter;
pub use pp_json_deep_assign::{PpJsonDeepAssignJsAdapter, PpJsonDeepAssignTsAdapter};
pub use pp_lodash_merge::{PpLodashMergeJsAdapter, PpLodashMergeTsAdapter};
pub use pp_object_assign::{PpObjectAssignJsAdapter, PpObjectAssignTsAdapter};
pub use pubsub_go::PubsubGoAdapter;
pub use pubsub_python::PubsubPythonAdapter;
pub use python_django::PythonDjangoAdapter;
pub use python_fastapi::PythonFastApiAdapter;
pub use python_flask::PythonFlaskAdapter;
pub use python_jinja2::PythonJinja2Adapter;
pub use python_pickle::PythonPickleAdapter;
pub use python_starlette::PythonStarletteAdapter;
pub use rabbit_java::RabbitJavaAdapter;
pub use rabbit_python::RabbitPythonAdapter;
pub use redirect_go::RedirectGoAdapter;
pub use redirect_java::RedirectJavaAdapter;
pub use redirect_js::RedirectJsAdapter;
@ -138,6 +155,9 @@ pub use rust_actix::RustActixAdapter;
pub use rust_axum::RustAxumAdapter;
pub use rust_rocket::RustRocketAdapter;
pub use rust_warp::RustWarpAdapter;
pub use sqs_java::SqsJavaAdapter;
pub use sqs_node::SqsNodeAdapter;
pub use sqs_python::SqsPythonAdapter;
pub use xpath_java::XpathJavaAdapter;
pub use xpath_js::XpathJsAdapter;
pub use xpath_php::XpathPhpAdapter;

View file

@ -0,0 +1,108 @@
//! Phase 20 (Track M.2) — Go NATS subscriber adapter (`nats.go`).
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
pub struct NatsGoAdapter;
const ADAPTER_NAME: &str = "nats-go";
fn callee_is_nats(name: &str) -> bool {
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
matches!(
last,
"Subscribe" | "QueueSubscribe" | "Publish" | "HandleMessage" | "OnMessage"
)
}
fn source_imports_nats(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"github.com/nats-io/nats.go",
b"nats.Connect",
b"nats.Msg",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
fn extract_subject(file_bytes: &[u8]) -> String {
let text = std::str::from_utf8(file_bytes).unwrap_or("");
for needle in [".Subscribe(\"", ".QueueSubscribe(\""] {
if let Some(idx) = text.find(needle) {
let after = &text[idx + needle.len()..];
if let Some(end) = after.find('"') {
return after[..end].to_owned();
}
}
}
String::new()
}
impl FrameworkAdapter for NatsGoAdapter {
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<FrameworkBinding> {
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
}
}
}
#[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_nats_subscribe() {
let src: &[u8] = b"package entry\nimport \"github.com/nats-io/nats.go\"\n\
func OnMessage(msg *nats.Msg) {}\n\
var nc = nats.Connect()\n\
var sub, _ = nc.Subscribe(\"events\", OnMessage)\n";
let tree = parse_go(src);
let summary = FuncSummary {
name: "OnMessage".into(),
..Default::default()
};
let binding = NatsGoAdapter
.detect(&summary, tree.root_node(), src)
.expect("nats.Subscribe binds");
if let EntryKind::MessageHandler { queue, .. } = binding.kind {
assert_eq!(queue, "events");
}
}
}

View file

@ -0,0 +1,108 @@
//! Phase 20 (Track M.2) — Go Google Pub/Sub subscriber adapter
//! (`cloud.google.com/go/pubsub`).
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
pub struct PubsubGoAdapter;
const ADAPTER_NAME: &str = "pubsub-go";
fn callee_is_pubsub(name: &str) -> bool {
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
matches!(
last,
"Receive" | "Subscription" | "Pull" | "Handle" | "OnMessage"
)
}
fn source_imports_pubsub(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"cloud.google.com/go/pubsub",
b"pubsub.NewClient",
b"pubsub.Message",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
fn extract_topic(file_bytes: &[u8]) -> String {
let text = std::str::from_utf8(file_bytes).unwrap_or("");
for needle in [".Subscription(\"", "SubscriptionID(\"", "TopicID(\""] {
if let Some(idx) = text.find(needle) {
let after = &text[idx + needle.len()..];
if let Some(end) = after.find('"') {
return after[..end].to_owned();
}
}
}
String::new()
}
impl FrameworkAdapter for PubsubGoAdapter {
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<FrameworkBinding> {
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
}
}
}
#[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_pubsub_subscription() {
let src: &[u8] = b"package entry\nimport \"cloud.google.com/go/pubsub\"\n\
func Handle(msg *pubsub.Message) {}\n\
var sub = pubsub.NewClient.Subscription(\"my-sub\")\n";
let tree = parse_go(src);
let summary = FuncSummary {
name: "Handle".into(),
..Default::default()
};
let binding = PubsubGoAdapter
.detect(&summary, tree.root_node(), src)
.expect("pubsub.Subscription binds");
if let EntryKind::MessageHandler { queue, .. } = binding.kind {
assert_eq!(queue, "my-sub");
}
}
}

View file

@ -0,0 +1,115 @@
//! Phase 20 (Track M.2) — Python Google Pub/Sub subscriber adapter.
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
pub struct PubsubPythonAdapter;
const ADAPTER_NAME: &str = "pubsub-python";
fn callee_is_pubsub(name: &str) -> bool {
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
matches!(
last,
"subscribe" | "pull" | "callback" | "process_message"
)
}
fn source_imports_pubsub(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"google.cloud.pubsub",
b"from google.cloud import pubsub",
b"google.cloud.pubsub_v1",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
fn extract_topic(file_bytes: &[u8]) -> String {
let text = std::str::from_utf8(file_bytes).unwrap_or("");
// Needles include the opening quote so we only need to find the
// closing one — avoids picking up the next literal after a comma.
for (needle, close) in [
(".subscribe(\"", '"'),
(".subscribe('", '\''),
("subscription_path(\"", '"'),
("subscription_path('", '\''),
] {
if let Some(idx) = text.find(needle) {
let after = &text[idx + needle.len()..];
if let Some(end) = after.find(close) {
return after[..end].to_owned();
}
}
}
String::new()
}
impl FrameworkAdapter for PubsubPythonAdapter {
fn name(&self) -> &'static str {
ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::Python
}
fn detect(
&self,
summary: &FuncSummary,
_ast: tree_sitter::Node<'_>,
file_bytes: &[u8],
) -> Option<FrameworkBinding> {
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
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_python(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_python::LANGUAGE);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
#[test]
fn fires_on_pubsub_v1_subscribe() {
let src: &[u8] = b"from google.cloud import pubsub_v1\n\
def callback(message):\n pass\n\
sub = pubsub_v1.SubscriberClient()\n\
sub.subscribe(\"projects/p/subscriptions/s\", callback=callback)\n";
let tree = parse_python(src);
let summary = FuncSummary {
name: "callback".into(),
..Default::default()
};
let binding = PubsubPythonAdapter
.detect(&summary, tree.root_node(), src)
.expect("pubsub_v1 binds");
if let EntryKind::MessageHandler { queue, .. } = binding.kind {
assert_eq!(queue, "projects/p/subscriptions/s");
}
}
}

View file

@ -0,0 +1,116 @@
//! Phase 20 (Track M.2) — Java RabbitMQ consumer adapter
//! (`com.rabbitmq.client.Channel.basicConsume`, Spring AMQP
//! `@RabbitListener`).
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
pub struct RabbitJavaAdapter;
const ADAPTER_NAME: &str = "rabbit-java";
fn callee_is_rabbit(name: &str) -> bool {
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
matches!(
last,
"basicConsume" | "basicGet" | "handleDelivery" | "onMessage" | "receive"
)
}
fn source_imports_rabbit(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"com.rabbitmq.client",
b"org.springframework.amqp.rabbit",
b"@RabbitListener",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
fn extract_queue(file_bytes: &[u8]) -> String {
let text = std::str::from_utf8(file_bytes).unwrap_or("");
for needle in [
"@RabbitListener(queues = \"",
"@RabbitListener(queues=\"",
"basicConsume(\"",
"queueDeclare(\"",
] {
if let Some(idx) = text.find(needle) {
let after = &text[idx + needle.len()..];
if let Some(end) = after.find('"') {
return after[..end].to_owned();
}
}
}
String::new()
}
impl FrameworkAdapter for RabbitJavaAdapter {
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<FrameworkBinding> {
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
}
}
}
#[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_rabbit_listener_annotation() {
let src: &[u8] = b"import org.springframework.amqp.rabbit.annotation.RabbitListener;\n\
public class Vuln {\n\
@RabbitListener(queues = \"work\")\n\
public void onMessage(String mid, String body) {}\n\
}\n";
let tree = parse_java(src);
let summary = FuncSummary {
name: "onMessage".into(),
..Default::default()
};
let binding = RabbitJavaAdapter
.detect(&summary, tree.root_node(), src)
.expect("@RabbitListener binds");
if let EntryKind::MessageHandler { queue, .. } = binding.kind {
assert_eq!(queue, "work");
}
}
}

View file

@ -0,0 +1,111 @@
//! Phase 20 (Track M.2) — Python RabbitMQ consumer adapter
//! (`pika.BlockingConnection`, `aio-pika`).
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
pub struct RabbitPythonAdapter;
const ADAPTER_NAME: &str = "rabbit-python";
fn callee_is_rabbit(name: &str) -> bool {
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
matches!(
last,
"basic_consume" | "basic_get" | "handle" | "on_message" | "process"
)
}
fn source_imports_rabbit(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"import pika",
b"from pika",
b"import aio_pika",
b"from aio_pika",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
fn extract_queue(file_bytes: &[u8]) -> String {
let text = std::str::from_utf8(file_bytes).unwrap_or("");
for needle in ["queue=\"", "queue='", "queue_declare(\"", "queue_declare('"] {
if let Some(idx) = text.find(needle) {
let after = &text[idx + needle.len()..];
let close = if needle.ends_with('"') { '"' } else { '\'' };
if let Some(end) = after.find(close) {
return after[..end].to_owned();
}
}
}
String::new()
}
impl FrameworkAdapter for RabbitPythonAdapter {
fn name(&self) -> &'static str {
ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::Python
}
fn detect(
&self,
summary: &FuncSummary,
_ast: tree_sitter::Node<'_>,
file_bytes: &[u8],
) -> Option<FrameworkBinding> {
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
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_python(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_python::LANGUAGE);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
#[test]
fn fires_on_pika_basic_consume() {
let src: &[u8] = b"import pika\n\
def on_message(ch, method, properties, body):\n pass\n\
chan = pika.BlockingConnection().channel()\n\
chan.basic_consume(queue=\"work\", on_message_callback=on_message)\n";
let tree = parse_python(src);
let summary = FuncSummary {
name: "on_message".into(),
..Default::default()
};
let binding = RabbitPythonAdapter
.detect(&summary, tree.root_node(), src)
.expect("pika binds");
if let EntryKind::MessageHandler { queue, .. } = binding.kind {
assert_eq!(queue, "work");
}
}
}

View file

@ -0,0 +1,110 @@
//! Phase 20 (Track M.2) — Java SQS consumer adapter.
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
pub struct SqsJavaAdapter;
const ADAPTER_NAME: &str = "sqs-java";
fn callee_is_sqs(name: &str) -> bool {
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
matches!(
last,
"receiveMessage" | "deleteMessage" | "onMessage" | "handleMessage"
)
}
fn source_imports_sqs(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"software.amazon.awssdk.services.sqs",
b"com.amazonaws.services.sqs",
b"@SqsListener",
b"io.awspring.cloud.sqs",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
fn extract_queue(file_bytes: &[u8]) -> String {
let text = std::str::from_utf8(file_bytes).unwrap_or("");
for needle in ["@SqsListener(\"", "queueUrl(\"", "queueName(\""] {
if let Some(idx) = text.find(needle) {
let after = &text[idx + needle.len()..];
if let Some(end) = after.find('"') {
return after[..end].to_owned();
}
}
}
String::new()
}
impl FrameworkAdapter for SqsJavaAdapter {
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<FrameworkBinding> {
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
}
}
}
#[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_sqs_listener_annotation() {
let src: &[u8] = b"import io.awspring.cloud.sqs.annotation.SqsListener;\n\
public class Vuln {\n\
@SqsListener(\"jobs\")\n\
public void handleMessage(java.util.Map<String,String> env) {}\n\
}\n";
let tree = parse_java(src);
let summary = FuncSummary {
name: "handleMessage".into(),
..Default::default()
};
let binding = SqsJavaAdapter
.detect(&summary, tree.root_node(), src)
.expect("@SqsListener binds");
if let EntryKind::MessageHandler { queue, .. } = binding.kind {
assert_eq!(queue, "jobs");
}
}
}

View file

@ -0,0 +1,112 @@
//! Phase 20 (Track M.2) — Node SQS consumer adapter (`@aws-sdk/client-sqs`,
//! `aws-sdk`, `sqs-consumer`).
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
pub struct SqsNodeAdapter;
const ADAPTER_NAME: &str = "sqs-node";
fn callee_is_sqs(name: &str) -> bool {
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
matches!(
last,
"receiveMessage" | "deleteMessage" | "handleMessage" | "send" | "Consumer"
)
}
fn source_imports_sqs(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"@aws-sdk/client-sqs",
b"aws-sdk/clients/sqs",
b"require('sqs-consumer')",
b"require(\"sqs-consumer\")",
b"from 'sqs-consumer'",
b"from \"sqs-consumer\"",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
fn extract_queue(file_bytes: &[u8]) -> String {
let text = std::str::from_utf8(file_bytes).unwrap_or("");
for needle in ["QueueUrl: \"", "QueueUrl: '", "queueUrl: \"", "queueUrl: '"] {
if let Some(idx) = text.find(needle) {
let after = &text[idx + needle.len()..];
let close = if needle.ends_with('"') { '"' } else { '\'' };
if let Some(end) = after.find(close) {
return after[..end].to_owned();
}
}
}
String::new()
}
impl FrameworkAdapter for SqsNodeAdapter {
fn name(&self) -> &'static str {
ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::JavaScript
}
fn detect(
&self,
summary: &FuncSummary,
_ast: tree_sitter::Node<'_>,
file_bytes: &[u8],
) -> Option<FrameworkBinding> {
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
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_js(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_javascript::LANGUAGE);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
#[test]
fn fires_on_sqs_consumer() {
let src: &[u8] = b"const { Consumer } = require('sqs-consumer');\n\
module.exports.handler = function(env) {};\n\
const c = Consumer.create({ queueUrl: 'http://localhost/q', handleMessage: handler });\n";
let tree = parse_js(src);
let summary = FuncSummary {
name: "handler".into(),
..Default::default()
};
let binding = SqsNodeAdapter
.detect(&summary, tree.root_node(), src)
.expect("sqs-consumer binds");
if let EntryKind::MessageHandler { queue, .. } = binding.kind {
assert_eq!(queue, "http://localhost/q");
}
}
}

View file

@ -0,0 +1,112 @@
//! Phase 20 (Track M.2) — Python SQS consumer adapter.
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
pub struct SqsPythonAdapter;
const ADAPTER_NAME: &str = "sqs-python";
fn callee_is_sqs(name: &str) -> bool {
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
matches!(
last,
"receive_message" | "delete_message" | "process_message" | "handler"
)
}
fn source_imports_sqs(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"boto3.client('sqs'",
b"boto3.client(\"sqs\"",
b"boto3.resource('sqs'",
b"boto3.resource(\"sqs\"",
b"@sqs_listener",
b"from aws_lambda_powertools.utilities.batch import sqs_batch_processor",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
fn extract_queue(file_bytes: &[u8]) -> String {
let text = std::str::from_utf8(file_bytes).unwrap_or("");
for needle in ["QueueUrl=\"", "QueueUrl='", "QueueName=\"", "QueueName='"] {
if let Some(idx) = text.find(needle) {
let after = &text[idx + needle.len()..];
let close = if needle.ends_with('"') { '"' } else { '\'' };
if let Some(end) = after.find(close) {
return after[..end].to_owned();
}
}
}
String::new()
}
impl FrameworkAdapter for SqsPythonAdapter {
fn name(&self) -> &'static str {
ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::Python
}
fn detect(
&self,
summary: &FuncSummary,
_ast: tree_sitter::Node<'_>,
file_bytes: &[u8],
) -> Option<FrameworkBinding> {
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
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_python(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_python::LANGUAGE);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
#[test]
fn fires_on_boto3_sqs_receive() {
let src: &[u8] = b"import boto3\n\
sqs = boto3.client('sqs')\n\
def handler(envelope):\n pass\n\
sqs.receive_message(QueueUrl=\"jobs\")\n";
let tree = parse_python(src);
let summary = FuncSummary {
name: "handler".into(),
..Default::default()
};
let binding = SqsPythonAdapter
.detect(&summary, tree.root_node(), src)
.expect("boto3 sqs binds");
if let EntryKind::MessageHandler { queue, .. } = binding.kind {
assert_eq!(queue, "jobs");
}
}
}