clawft_kernel/assessment/analyzers/
rabbitmq.rs1use std::path::{Path, PathBuf};
4
5use crate::assessment::Finding;
6use crate::assessment::analyzer::{AnalysisContext, Analyzer};
7
8pub 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 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 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 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 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
100fn 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 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
174fn extract_definitions_json(content: &str, rel_str: &str, findings: &mut Vec<Finding>) {
176 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 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 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
264fn 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 let after_colon = after_key.trim_start().strip_prefix(':')?;
271 let after_ws = after_colon.trim_start();
272 let after_quote = after_ws.strip_prefix('"')?;
274 let end = after_quote.find('"')?;
275 Some(&after_quote[..end])
276}
277
278fn 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 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 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 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 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 if !stripped.starts_with('-') && !stripped.contains('=') && stripped.ends_with(':')
366 {
367 in_environment = false;
368 }
369 }
370 }
371 }
372}
373
374fn 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
390fn scan_file_content(content: &str, rel_str: &str, findings: &mut Vec<Finding>) {
393 for (i, line) in content.lines().enumerate() {
394 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 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 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 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}