Skip to main content

clawft_kernel/assessment/analyzers/
rabbitmq.rs

1//! RabbitMQ analyzer — discovers messaging topology (queues, exchanges, AMQP connections).
2
3use std::path::{Path, PathBuf};
4
5use crate::assessment::Finding;
6use crate::assessment::analyzer::{AnalysisContext, Analyzer};
7
8/// Analyzer that identifies RabbitMQ configuration, AMQP connections, and messaging topology.
9pub struct RabbitMQAnalyzer;
10
11impl Analyzer for RabbitMQAnalyzer {
12    fn id(&self) -> &str {
13        "rabbitmq"
14    }
15
16    fn name(&self) -> &str {
17        "RabbitMQ Analyzer"
18    }
19
20    fn categories(&self) -> &[&str] {
21        &["messaging", "security"]
22    }
23
24    fn analyze(
25        &self,
26        project: &Path,
27        files: &[PathBuf],
28        _context: &AnalysisContext,
29    ) -> Vec<Finding> {
30        let mut findings = Vec::new();
31
32        for path in files {
33            let rel = path.strip_prefix(project).unwrap_or(path);
34            let rel_str = rel.display().to_string();
35            let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
36
37            // rabbitmq.conf
38            if name == "rabbitmq.conf" {
39                let content = match std::fs::read_to_string(path) {
40                    Ok(c) => c,
41                    Err(_) => continue,
42                };
43                findings.push(Finding {
44                    severity: "info".into(),
45                    category: "messaging".into(),
46                    file: rel_str.clone(),
47                    line: None,
48                    message: "RabbitMQ configuration file detected".into(),
49                });
50                extract_rabbitmq_conf(&content, &rel_str, &mut findings);
51                continue;
52            }
53
54            // definitions.json (RabbitMQ exported definitions)
55            if name == "definitions.json" {
56                let content = match std::fs::read_to_string(path) {
57                    Ok(c) => c,
58                    Err(_) => continue,
59                };
60                if content.contains("\"exchanges\"")
61                    || content.contains("\"queues\"")
62                    || content.contains("\"bindings\"")
63                {
64                    findings.push(Finding {
65                        severity: "info".into(),
66                        category: "messaging".into(),
67                        file: rel_str.clone(),
68                        line: None,
69                        message: "RabbitMQ definitions file detected".into(),
70                    });
71                    extract_definitions_json(&content, &rel_str, &mut findings);
72                }
73                continue;
74            }
75
76            // Docker compose — look for rabbitmq service images
77            if name.starts_with("docker-compose")
78                && (name.ends_with(".yml") || name.ends_with(".yaml"))
79            {
80                let content = match std::fs::read_to_string(path) {
81                    Ok(c) => c,
82                    Err(_) => continue,
83                };
84                extract_compose_rabbitmq(&content, &rel_str, &mut findings);
85                continue;
86            }
87
88            // All other files — scan for AMQP URIs, env vars, and queue/exchange declarations
89            let content = match std::fs::read_to_string(path) {
90                Ok(c) => c,
91                Err(_) => continue,
92            };
93            scan_file_content(&content, &rel_str, &mut findings);
94        }
95
96        findings
97    }
98}
99
100/// Extract listener and vhost settings from `rabbitmq.conf`.
101fn extract_rabbitmq_conf(content: &str, rel_str: &str, findings: &mut Vec<Finding>) {
102    for (i, line) in content.lines().enumerate() {
103        let trimmed = line.trim();
104        if trimmed.starts_with('#') || trimmed.is_empty() {
105            continue;
106        }
107
108        if let Some(val) = trimmed.strip_prefix("listeners.tcp.default") {
109            let val = val.trim_start_matches(|c: char| c == '=' || c.is_whitespace());
110            findings.push(Finding {
111                severity: "info".into(),
112                category: "messaging".into(),
113                file: rel_str.to_string(),
114                line: Some(i + 1),
115                message: format!("RabbitMQ TCP listener: {val}"),
116            });
117        }
118
119        if let Some(val) = trimmed.strip_prefix("listeners.ssl.default") {
120            let val = val.trim_start_matches(|c: char| c == '=' || c.is_whitespace());
121            findings.push(Finding {
122                severity: "info".into(),
123                category: "messaging".into(),
124                file: rel_str.to_string(),
125                line: Some(i + 1),
126                message: format!("RabbitMQ SSL listener: {val}"),
127            });
128        }
129
130        if let Some(val) = trimmed.strip_prefix("default_vhost") {
131            let val = val.trim_start_matches(|c: char| c == '=' || c.is_whitespace());
132            findings.push(Finding {
133                severity: "info".into(),
134                category: "messaging".into(),
135                file: rel_str.to_string(),
136                line: Some(i + 1),
137                message: format!("RabbitMQ default vhost: {val}"),
138            });
139        }
140
141        // Detect default credentials
142        if let Some(val) = trimmed.strip_prefix("default_user") {
143            let val = val
144                .trim_start_matches(|c: char| c == '=' || c.is_whitespace())
145                .trim();
146            if val == "guest" {
147                findings.push(Finding {
148                    severity: "medium".into(),
149                    category: "messaging".into(),
150                    file: rel_str.to_string(),
151                    line: Some(i + 1),
152                    message: "RabbitMQ default user is 'guest' (default credentials)".into(),
153                });
154            }
155        }
156
157        if let Some(val) = trimmed.strip_prefix("default_pass") {
158            let val = val
159                .trim_start_matches(|c: char| c == '=' || c.is_whitespace())
160                .trim();
161            if val == "guest" {
162                findings.push(Finding {
163                    severity: "medium".into(),
164                    category: "messaging".into(),
165                    file: rel_str.to_string(),
166                    line: Some(i + 1),
167                    message: "RabbitMQ default password is 'guest' (default credentials)".into(),
168                });
169            }
170        }
171    }
172}
173
174/// Extract exchanges, queues, bindings, and users from `definitions.json` (heuristic JSON scan).
175fn extract_definitions_json(content: &str, rel_str: &str, findings: &mut Vec<Finding>) {
176    // Simple line-by-line scan for "name" fields inside known sections.
177    // We track which section we're in by looking for top-level keys.
178    let mut section = "";
179
180    for line in content.lines() {
181        let trimmed = line.trim().trim_matches(',');
182
183        if trimmed.contains("\"exchanges\"") && trimmed.contains('[') {
184            section = "exchanges";
185            continue;
186        }
187        if trimmed.contains("\"queues\"") && trimmed.contains('[') {
188            section = "queues";
189            continue;
190        }
191        if trimmed.contains("\"bindings\"") && trimmed.contains('[') {
192            section = "bindings";
193            continue;
194        }
195        if trimmed.contains("\"users\"") && trimmed.contains('[') {
196            section = "users";
197            continue;
198        }
199
200        // Detect end of section
201        if trimmed == "]" {
202            section = "";
203            continue;
204        }
205
206        if let Some(name) = extract_json_string_field(trimmed, "name") {
207            match section {
208                "exchanges" if !name.is_empty() => {
209                    findings.push(Finding {
210                        severity: "info".into(),
211                        category: "messaging".into(),
212                        file: rel_str.to_string(),
213                        line: None,
214                        message: format!("RabbitMQ exchange: {name}"),
215                    });
216                }
217                "queues" => {
218                    findings.push(Finding {
219                        severity: "info".into(),
220                        category: "messaging".into(),
221                        file: rel_str.to_string(),
222                        line: None,
223                        message: format!("RabbitMQ queue: {name}"),
224                    });
225                }
226                "users" => {
227                    findings.push(Finding {
228                        severity: "info".into(),
229                        category: "messaging".into(),
230                        file: rel_str.to_string(),
231                        line: None,
232                        message: format!("RabbitMQ user: {name}"),
233                    });
234                    if name == "guest" {
235                        findings.push(Finding {
236                            severity: "medium".into(),
237                            category: "messaging".into(),
238                            file: rel_str.to_string(),
239                            line: None,
240                            message: "RabbitMQ guest user present (default credentials)".into(),
241                        });
242                    }
243                }
244                _ => {}
245            }
246        }
247
248        // Bindings: look for source/destination
249        if section == "bindings"
250            && let Some(src) = extract_json_string_field(trimmed, "source")
251            && let Some(dst) = extract_json_string_field(trimmed, "destination")
252        {
253            findings.push(Finding {
254                severity: "info".into(),
255                category: "messaging".into(),
256                file: rel_str.to_string(),
257                line: None,
258                message: format!("RabbitMQ binding: {src} -> {dst}"),
259            });
260        }
261    }
262}
263
264/// Extract a JSON string value for a given key from a line like `"key": "value"`.
265fn extract_json_string_field<'a>(line: &'a str, key: &str) -> Option<&'a str> {
266    let pattern = format!("\"{key}\"");
267    let idx = line.find(&pattern)?;
268    let after_key = &line[idx + pattern.len()..];
269    // Skip optional whitespace and colon
270    let after_colon = after_key.trim_start().strip_prefix(':')?;
271    let after_ws = after_colon.trim_start();
272    // Extract quoted string
273    let after_quote = after_ws.strip_prefix('"')?;
274    let end = after_quote.find('"')?;
275    Some(&after_quote[..end])
276}
277
278/// Look for RabbitMQ services in docker-compose content.
279fn extract_compose_rabbitmq(content: &str, rel_str: &str, findings: &mut Vec<Finding>) {
280    let mut in_services = false;
281    let mut current_service: Option<String> = None;
282    let mut in_rabbitmq_service = false;
283    let mut in_environment = false;
284
285    for line in content.lines() {
286        let trimmed = line.trim();
287
288        if trimmed == "services:" {
289            in_services = true;
290            continue;
291        }
292
293        if in_services {
294            if !line.starts_with(' ') && !line.is_empty() {
295                in_services = false;
296                current_service = None;
297                in_rabbitmq_service = false;
298                in_environment = false;
299                continue;
300            }
301
302            let stripped = line.trim_start();
303
304            // Service name detection
305            if stripped.ends_with(':') && !stripped.contains(' ') {
306                let svc_name = stripped.trim_end_matches(':');
307                current_service = Some(svc_name.to_string());
308                in_rabbitmq_service = false;
309                in_environment = false;
310            }
311
312            // Image reference
313            if let Some(rest) = stripped.strip_prefix("image:") {
314                let image = rest.trim().trim_matches('"').trim_matches('\'');
315                if image.starts_with("rabbitmq:") || image == "rabbitmq" {
316                    in_rabbitmq_service = true;
317                    if let Some(ref svc) = current_service {
318                        findings.push(Finding {
319                            severity: "info".into(),
320                            category: "messaging".into(),
321                            file: rel_str.to_string(),
322                            line: None,
323                            message: format!("RabbitMQ Docker service '{svc}' image: {image}"),
324                        });
325                    }
326                }
327            }
328
329            // Port mappings for RabbitMQ services
330            if in_rabbitmq_service
331                && (stripped.starts_with("- \"")
332                    || stripped.starts_with("- '")
333                    || stripped.starts_with("- "))
334            {
335                let val = stripped
336                    .trim_start_matches("- ")
337                    .trim_matches('"')
338                    .trim_matches('\'');
339                if val.contains(':')
340                    && val.chars().all(|c| c.is_ascii_digit() || c == ':')
341                    && let Some(ref svc) = current_service
342                {
343                    findings.push(Finding {
344                        severity: "info".into(),
345                        category: "messaging".into(),
346                        file: rel_str.to_string(),
347                        line: None,
348                        message: format!("RabbitMQ service '{svc}' port mapping: {val}"),
349                    });
350                }
351            }
352
353            // Environment section
354            if stripped == "environment:" {
355                in_environment = true;
356                continue;
357            }
358            if in_environment && in_rabbitmq_service {
359                if let Some(rest) = stripped.strip_prefix("- ") {
360                    check_rabbitmq_env_var(rest, rel_str, findings);
361                } else if stripped.contains(':') && !stripped.ends_with(':') {
362                    check_rabbitmq_env_var(stripped, rel_str, findings);
363                }
364                // Leaving environment block
365                if !stripped.starts_with('-') && !stripped.contains('=') && stripped.ends_with(':')
366                {
367                    in_environment = false;
368                }
369            }
370        }
371    }
372}
373
374/// Check for RabbitMQ-related environment variable assignments (detect default creds).
375fn check_rabbitmq_env_var(entry: &str, rel_str: &str, findings: &mut Vec<Finding>) {
376    let clean = entry.trim_matches('"').trim_matches('\'');
377    if clean.contains("RABBITMQ_DEFAULT_USER=guest")
378        || clean.contains("RABBITMQ_DEFAULT_PASS=guest")
379    {
380        findings.push(Finding {
381            severity: "medium".into(),
382            category: "messaging".into(),
383            file: rel_str.to_string(),
384            line: None,
385            message: format!("RabbitMQ default credentials in compose: {clean}"),
386        });
387    }
388}
389
390/// Scan arbitrary file content for AMQP URIs, environment variable references,
391/// and queue/exchange declaration patterns.
392fn scan_file_content(content: &str, rel_str: &str, findings: &mut Vec<Finding>) {
393    for (i, line) in content.lines().enumerate() {
394        // AMQP connection strings
395        if line.contains("amqp://") || line.contains("amqps://") {
396            findings.push(Finding {
397                severity: "info".into(),
398                category: "messaging".into(),
399                file: rel_str.to_string(),
400                line: Some(i + 1),
401                message: "AMQP connection string detected".into(),
402            });
403            // Check for guest credentials in URI
404            if line.contains("amqp://guest:guest@") || line.contains("amqps://guest:guest@") {
405                findings.push(Finding {
406                    severity: "medium".into(),
407                    category: "messaging".into(),
408                    file: rel_str.to_string(),
409                    line: Some(i + 1),
410                    message: "AMQP URI contains default guest:guest credentials".into(),
411                });
412            }
413        }
414
415        // Environment variable references
416        for var in &["RABBITMQ_HOST", "RABBITMQ_PORT", "AMQP_URL"] {
417            if line.contains(var) {
418                findings.push(Finding {
419                    severity: "info".into(),
420                    category: "messaging".into(),
421                    file: rel_str.to_string(),
422                    line: Some(i + 1),
423                    message: format!("RabbitMQ environment variable reference: {var}"),
424                });
425            }
426        }
427
428        // Queue/exchange declaration patterns (Python, Node.js, etc.)
429        for pattern in &[
430            "channel.queue_declare",
431            "channel.exchange_declare",
432            "assertQueue",
433            "assertExchange",
434        ] {
435            if line.contains(pattern) {
436                findings.push(Finding {
437                    severity: "info".into(),
438                    category: "messaging".into(),
439                    file: rel_str.to_string(),
440                    line: Some(i + 1),
441                    message: format!("RabbitMQ queue/exchange declaration: {pattern}"),
442                });
443            }
444        }
445    }
446}