Skip to main content

icebox/modules/
recon_scanners.rs

1use crate::core::module::{Module, ModuleError, ModuleResult};
2use async_trait::async_trait;
3use icebox_macro::module;
4use sha1::{Digest, Sha1};
5use std::sync::Arc;
6use tokio::sync::Semaphore;
7
8use crate::modules::hex_encode;
9
10#[module(
11    name = "mysql_scanner",
12    kind = "Scanner",
13    description = "MySQL service scanner  -  version detection and default credential check",
14    author = "ICEBOX",
15    sandbox_image = "mysql:5.7"
16)]
17pub struct MysqlScanner {
18    #[option(required = true, help = "Target IP or hostname")]
19    pub host: String,
20    #[option(help = "MySQL port (default 3306)")]
21    pub port: u16,
22    #[option(help = "Timeout in milliseconds (default 5000)")]
23    pub timeout_ms: u64,
24    #[option(help = "Check default/built-in credentials")]
25    pub check_defaults: bool,
26    #[option(help = "Custom creds as user:pass,user:pass,... (overrides built-in when set)")]
27    pub wordlist: String,
28}
29
30fn mysql_parse_greeting(data: &[u8]) -> Option<(String, Vec<u8>, u8, Vec<u8>)> {
31    if data.len() < 5 {
32        return None;
33    }
34    let payload =
35        if data.len() > 4 && (data[0] as usize + 1 + data[1] as usize * 256 + 1) <= data.len() {
36            &data[4..]
37        } else {
38            data
39        };
40    if payload.is_empty() {
41        return None;
42    }
43    let proto_ver = payload[0];
44    let mut pos = 1;
45    let mut version_bytes = Vec::new();
46    while pos < payload.len() && payload[pos] != 0 {
47        version_bytes.push(payload[pos]);
48        pos += 1;
49    }
50    pos += 1;
51    if pos + 4 > payload.len() {
52        return None;
53    }
54    let _conn_id = u32::from_le_bytes([
55        payload[pos],
56        payload[pos + 1],
57        payload[pos + 2],
58        payload[pos + 3],
59    ]);
60    pos += 4;
61    if pos + 8 > payload.len() {
62        return None;
63    }
64    let auth_plugin_part1 = payload[pos..pos + 8].to_vec();
65    pos += 8;
66    pos += 1;
67    if pos + 2 > payload.len() {
68        return None;
69    }
70    let _caps_lower = u16::from_le_bytes([payload[pos], payload[pos + 1]]);
71    pos += 2;
72    pos += 1;
73    pos += 2;
74    let _caps_upper = u16::from_le_bytes([payload[pos], payload[pos + 1]]);
75    pos += 2;
76    let auth_plugin_len = if pos < payload.len() { payload[pos] } else { 0 };
77    pos += 1;
78    pos += 10;
79    let part2_len = if auth_plugin_len > 8 {
80        auth_plugin_len as usize - 8
81    } else {
82        12
83    };
84    let auth_plugin_part2 = if pos + part2_len <= payload.len() {
85        payload[pos..pos + part2_len].to_vec()
86    } else {
87        vec![]
88    };
89    let mut scramble = auth_plugin_part1.clone();
90    scramble.extend_from_slice(&auth_plugin_part2);
91    let version = String::from_utf8_lossy(&version_bytes).to_string();
92    Some((version, scramble, proto_ver, auth_plugin_part1))
93}
94
95fn mysql_native_password(password: &str, scramble: &[u8]) -> Vec<u8> {
96    let mut hasher1 = Sha1::new();
97    hasher1.update(password.as_bytes());
98    let stage1 = hasher1.finalize();
99
100    let mut hasher2 = Sha1::new();
101    hasher2.update(stage1);
102    let stage2 = hasher2.finalize();
103
104    let mut hasher3 = Sha1::new();
105    hasher3.update(scramble);
106    hasher3.update(stage2);
107    let stage3 = hasher3.finalize();
108
109    stage1
110        .iter()
111        .zip(stage3.iter())
112        .map(|(a, b)| a ^ b)
113        .collect()
114}
115
116fn mysql_build_handshake(username: &str, auth_response: &[u8]) -> Vec<u8> {
117    let caps: u32 = 0x08820F;
118    let mut pkt = Vec::new();
119    pkt.extend_from_slice(&caps.to_le_bytes());
120    pkt.extend_from_slice(&[0x00, 0x00, 0x00, 0x01]);
121    pkt.push(0x2D);
122    pkt.extend_from_slice(&[0x00; 23]);
123
124    pkt.extend_from_slice(username.as_bytes());
125    pkt.push(0x00);
126
127    if auth_response.is_empty() {
128        pkt.push(0x00);
129    } else {
130        pkt.push(auth_response.len() as u8);
131        pkt.extend_from_slice(auth_response);
132    }
133
134    pkt.push(0x00);
135
136    pkt
137}
138
139fn mysql_prepend_header(payload: &[u8], seq: u8) -> Vec<u8> {
140    let len = payload.len() as u32;
141    let mut pkt = Vec::new();
142    pkt.extend_from_slice(&len.to_le_bytes()[..3]);
143    pkt.push(seq);
144    pkt.extend_from_slice(payload);
145    pkt
146}
147
148fn mysql_parse_response(data: &[u8]) -> Result<serde_json::Value, String> {
149    if data.len() < 4 {
150        return Err("response too short".into());
151    }
152    let payload = &data[4..];
153    if payload.is_empty() {
154        return Err("empty payload".into());
155    }
156    match payload[0] {
157        0x00 => Ok(
158            serde_json::json!({"status": "ok", "affected_rows": payload.get(1).copied().unwrap_or(0)}),
159        ),
160        0xFF => {
161            let code = if payload.len() > 3 {
162                u16::from_le_bytes([payload[1], payload[2]])
163            } else {
164                0
165            };
166            let msg = if payload.len() > 9 {
167                String::from_utf8_lossy(&payload[9..]).to_string()
168            } else if payload.len() > 3 {
169                String::from_utf8_lossy(&payload[3..]).to_string()
170            } else {
171                "unknown error".into()
172            };
173            Ok(serde_json::json!({"status": "error", "code": code, "message": msg}))
174        }
175        0xFE => Ok(serde_json::json!({"status": "eof"})),
176        _ => Ok(serde_json::json!({"status": "unknown", "first_byte": payload[0]})),
177    }
178}
179
180#[async_trait]
181impl Module for MysqlScanner {
182    fn options_json(&self) -> serde_json::Value {
183        serde_json::to_value(&MysqlScannerOptions {
184            host: self.host.clone(),
185            port: self.port,
186            timeout_ms: self.timeout_ms,
187            check_defaults: self.check_defaults,
188            wordlist: self.wordlist.clone(),
189        })
190        .unwrap_or(serde_json::Value::Null)
191    }
192
193    fn set_option(&mut self, name: &str, value: &str) -> Result<(), ModuleError> {
194        let mut o = MysqlScannerOptions {
195            host: self.host.clone(),
196            port: self.port,
197            timeout_ms: self.timeout_ms,
198            check_defaults: self.check_defaults,
199            wordlist: self.wordlist.clone(),
200        };
201        o.set(name, value)?;
202        self.host = o.host;
203        self.port = o.port;
204        self.timeout_ms = o.timeout_ms;
205        self.check_defaults = o.check_defaults;
206        self.wordlist = o.wordlist;
207        Ok(())
208    }
209
210    fn validate(&self) -> Result<(), ModuleError> {
211        MysqlScannerOptions {
212            host: self.host.clone(),
213            port: self.port,
214            timeout_ms: self.timeout_ms,
215            check_defaults: self.check_defaults,
216            wordlist: self.wordlist.clone(),
217        }
218        .validate()
219    }
220
221    async fn run(&self) -> Result<ModuleResult, ModuleError> {
222        let port = if self.port > 0 { self.port } else { 3306 };
223        let timeout = std::time::Duration::from_millis(if self.timeout_ms > 0 {
224            self.timeout_ms
225        } else {
226            5000
227        });
228        let addr = crate::core::proxy::resolve_dial(&self.host, port);
229
230        let mut stream =
231            match tokio::time::timeout(timeout, tokio::net::TcpStream::connect(&addr)).await {
232                Ok(Ok(s)) => s,
233                Ok(Err(e)) => return Err(ModuleError::Other(format!("connection failed: {e}"))),
234                Err(_) => return Err(ModuleError::Other("connection timed out".into())),
235            };
236
237        let mut buf = vec![0u8; 8192];
238        let n = match tokio::time::timeout(timeout, stream.readable()).await {
239            Ok(Ok(_)) => stream.try_read(&mut buf).unwrap_or(0),
240            _ => 0,
241        };
242        if n < 5 {
243            return Ok(ModuleResult {
244                success: true,
245                finding: Some(format!("MySQL on tcp/{port}: no greeting received")),
246                data: serde_json::json!({"host": self.host, "port": port}),
247                ..Default::default()
248            });
249        }
250
251        let (version, _scramble, _proto_ver, _part1) = match mysql_parse_greeting(&buf[..n]) {
252            Some(v) => v,
253            None => {
254                return Ok(ModuleResult {
255                    success: true,
256                    finding: Some(format!("MySQL on tcp/{port}: unrecognized response")),
257                    data: serde_json::json!({"host": self.host, "port": port, "raw": hex_encode(&buf[..n.min(128)])}),
258                    ..Default::default()
259                })
260            }
261        };
262
263        let mut findings = vec![format!("version: {version}")];
264        let mut evidence = vec![format!("mysql/{version}")];
265
266        let mut found_creds: Vec<(String, String)> = Vec::new();
267
268        let creds_to_try: Vec<(&str, &str)> = if !self.wordlist.is_empty() {
269            self.wordlist
270                .split(',')
271                .filter_map(|pair| {
272                    let pair = pair.trim();
273                    if let Some((u, p)) = pair.split_once(':') {
274                        let u = u.trim();
275                        let p = p.trim();
276                        if !u.is_empty() {
277                            Some((u, p))
278                        } else {
279                            None
280                        }
281                    } else {
282                        None
283                    }
284                })
285                .collect()
286        } else if self.check_defaults {
287            vec![
288                ("root", ""),
289                ("root", "root"),
290                ("root", "admin"),
291                ("admin", ""),
292                ("mysql", ""),
293                ("test", ""),
294            ]
295        } else {
296            vec![]
297        };
298
299        for (user, pass) in &creds_to_try {
300            let addr = addr.clone();
301            match tokio::time::timeout(timeout, tokio::net::TcpStream::connect(&addr)).await {
302                Ok(Ok(s)) => {
303                    stream = s;
304                }
305                _ => continue,
306            };
307            let mut buf2 = vec![0u8; 8192];
308            let n2 = match tokio::time::timeout(timeout, stream.readable()).await {
309                Ok(Ok(_)) => stream.try_read(&mut buf2).unwrap_or(0),
310                _ => 0,
311            };
312            if n2 < 5 {
313                continue;
314            }
315            let (_v2, scramble2, _pv, _p1) = match mysql_parse_greeting(&buf2[..n2]) {
316                Some(v) => v,
317                None => continue,
318            };
319            let auth_resp = mysql_native_password(pass, &scramble2);
320            let login = mysql_build_handshake(user, &auth_resp);
321            let login_pkt = mysql_prepend_header(&login, 1);
322            if tokio::time::timeout(timeout, stream.writable())
323                .await
324                .is_ok()
325            {
326                let _ = stream.try_write(&login_pkt);
327            }
328            let mut buf3 = vec![0u8; 1024];
329            let n3 = match tokio::time::timeout(timeout, stream.readable()).await {
330                Ok(Ok(_)) => stream.try_read(&mut buf3).unwrap_or(0),
331                _ => 0,
332            };
333            if n3 > 4 {
334                if let Ok(resp) = mysql_parse_response(&buf3[..n3]) {
335                    if resp.get("status").and_then(|s| s.as_str()) == Some("ok") {
336                        let label = if pass.is_empty() {
337                            format!("{user}:\"\"")
338                        } else {
339                            format!("{user}:{pass}")
340                        };
341                        findings.push(format!("cred: {label}  -  SUCCESS"));
342                        evidence.push(format!("mysql/cred:{label}"));
343                        found_creds.push((user.to_string(), pass.to_string()));
344                    }
345                }
346            }
347        }
348
349        let finding = if found_creds.is_empty() {
350            format!(
351                "MySQL on tcp/{port}: {version} (no valid credentials found, tried {} pair(s))",
352                creds_to_try.len()
353            )
354        } else {
355            let labels: Vec<String> = found_creds
356                .iter()
357                .map(|(u, p)| {
358                    if p.is_empty() {
359                        format!("{u}:\"\"")
360                    } else {
361                        format!("{u}:{p}")
362                    }
363                })
364                .collect();
365            format!(
366                "MySQL on tcp/{port}: {version}  -  {} credential(s) work! {}",
367                found_creds.len(),
368                labels.join(", ")
369            )
370        };
371
372        Ok(ModuleResult {
373            success: true,
374            finding: Some(finding),
375            evidence,
376            data: serde_json::json!({
377                "host": self.host,
378                "port": port,
379                "version": version,
380                "found_credentials": found_creds,
381                "tried_count": creds_to_try.len(),
382            }),
383            ..Default::default()
384        })
385    }
386}
387
388const DEFAULT_WORDLIST: &[&str] = &[
389    "admin",
390    "login",
391    "wp-admin",
392    "wp-content",
393    "backup",
394    ".git",
395    ".env",
396    "config",
397    "robots.txt",
398    "sitemap.xml",
399    "api",
400    "test",
401    "dev",
402    "uploads",
403    "images",
404    "css",
405    "js",
406    "includes",
407    "assets",
408    "private",
409    "restricted",
410    "bak",
411    "old",
412    "src",
413    "data",
414    "console",
415    "dashboard",
416    "manager",
417    "phpmyadmin",
418    "adminer",
419    "setup",
420    "install",
421    "readme",
422    "changelog",
423    "LICENSE",
424    ".htaccess",
425    ".htpasswd",
426    "server-status",
427    "crossdomain.xml",
428    "clientaccesspolicy.xml",
429    "web.config",
430    "phpinfo.php",
431    "info.php",
432    "status",
433    "health",
434    "healthcheck",
435    "actuator",
436    "swagger",
437    "api-docs",
438    "openapi.json",
439    "graphql",
440    "v2",
441    "v1",
442    "api/v1",
443    "api/v2",
444    "ws",
445    "websocket",
446    "sockjs",
447    "sockjs-node",
448    ".well-known",
449];
450
451#[module(
452    name = "web_path_scanner",
453    kind = "Scanner",
454    description = "HTTP path/directory brute-forcer  -  discover hidden files and web resources",
455    author = "ICEBOX"
456)]
457pub struct WebPathScanner {
458    #[option(
459        required = true,
460        help = "Target base URL (e.g. http://10.0.0.1:80 or http://example.com)"
461    )]
462    pub target: String,
463    #[option(help = "Path wordlist, comma-separated (default: built-in common paths)")]
464    pub wordlist: String,
465    #[option(help = "Timeout per request in milliseconds (default 3000)")]
466    pub timeout_ms: u64,
467    #[option(help = "Concurrent requests (default 20)")]
468    pub concurrency: usize,
469    #[option(help = "Interesting status codes, comma-separated (default: 200,301,302,401,403)")]
470    pub filter_codes: String,
471}
472
473fn parse_url(target: &str) -> Result<(String, u16, bool), ModuleError> {
474    let target = target.trim();
475    let (rest, default_port, tls) = if let Some(r) = target.strip_prefix("https://") {
476        (r, 443u16, true)
477    } else if let Some(r) = target.strip_prefix("http://") {
478        (r, 80u16, false)
479    } else {
480        (target, 80u16, false)
481    };
482    let rest = rest.trim_end_matches('/');
483    if let Some((host, ps)) = rest.rsplit_once(':') {
484        let p: u16 = ps
485            .parse()
486            .map_err(|_| ModuleError::Parse("bad port".into()))?;
487        Ok((host.to_string(), p, tls))
488    } else {
489        Ok((rest.to_string(), default_port, tls))
490    }
491}
492
493#[async_trait]
494impl Module for WebPathScanner {
495    fn options_json(&self) -> serde_json::Value {
496        serde_json::to_value(&WebPathScannerOptions {
497            target: self.target.clone(),
498            wordlist: self.wordlist.clone(),
499            timeout_ms: self.timeout_ms,
500            concurrency: self.concurrency,
501            filter_codes: self.filter_codes.clone(),
502        })
503        .unwrap_or(serde_json::Value::Null)
504    }
505
506    fn set_option(&mut self, name: &str, value: &str) -> Result<(), ModuleError> {
507        let mut o = WebPathScannerOptions {
508            target: self.target.clone(),
509            wordlist: self.wordlist.clone(),
510            timeout_ms: self.timeout_ms,
511            concurrency: self.concurrency,
512            filter_codes: self.filter_codes.clone(),
513        };
514        o.set(name, value)?;
515        self.target = o.target;
516        self.wordlist = o.wordlist;
517        self.timeout_ms = o.timeout_ms;
518        self.concurrency = o.concurrency;
519        self.filter_codes = o.filter_codes;
520        Ok(())
521    }
522
523    fn validate(&self) -> Result<(), ModuleError> {
524        WebPathScannerOptions {
525            target: self.target.clone(),
526            wordlist: self.wordlist.clone(),
527            timeout_ms: self.timeout_ms,
528            concurrency: self.concurrency,
529            filter_codes: self.filter_codes.clone(),
530        }
531        .validate()?;
532        parse_url(&self.target).map(|_| ())
533    }
534
535    async fn run(&self) -> Result<ModuleResult, ModuleError> {
536        let (host, port, _tls) = parse_url(&self.target)?;
537        let timeout = std::time::Duration::from_millis(if self.timeout_ms > 0 {
538            self.timeout_ms
539        } else {
540            3000
541        });
542        let max_concurrency = if self.concurrency > 0 {
543            self.concurrency
544        } else {
545            20
546        };
547
548        let paths: Vec<String> = if self.wordlist.is_empty() {
549            DEFAULT_WORDLIST.iter().map(|s| s.to_string()).collect()
550        } else {
551            self.wordlist
552                .split(',')
553                .map(|s| s.trim().to_string())
554                .filter(|s| !s.is_empty())
555                .collect()
556        };
557
558        let filter_codes: Vec<u16> = if self.filter_codes.is_empty() {
559            vec![200, 301, 302, 401, 403]
560        } else {
561            self.filter_codes
562                .split(',')
563                .filter_map(|s| s.trim().parse::<u16>().ok())
564                .collect()
565        };
566
567        let semaphore = Arc::new(Semaphore::new(max_concurrency));
568        let mut handles = Vec::new();
569
570        for path in &paths {
571            let permit = match semaphore.clone().acquire_owned().await {
572                Ok(p) => p,
573                Err(_) => continue,
574            };
575            let path = path.clone();
576            let host = host.clone();
577            let filter_codes = filter_codes.clone();
578            handles.push(tokio::spawn(async move {
579                let _permit = permit;
580                let addr = crate::core::proxy::resolve_dial(&host, port);
581                let stream = match tokio::time::timeout(timeout, tokio::net::TcpStream::connect(&addr)).await {
582                    Ok(Ok(s)) => s,
583                    _ => return None,
584                };
585                let request = format!(
586                    "GET /{} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\nUser-Agent: ICEBOX/1.0\r\nAccept: */*\r\n\r\n",
587                    path, host
588                );
589                if tokio::time::timeout(timeout, stream.writable()).await.is_err() {
590                    return None;
591                }
592                let _ = stream.try_write(request.as_bytes());
593                let mut buf = vec![0u8; 4096];
594                let n = match tokio::time::timeout(timeout, stream.readable()).await {
595                    Ok(Ok(_)) => stream.try_read(&mut buf).unwrap_or(0),
596                    _ => 0,
597                };
598                if n == 0 { return None; }
599                let resp = String::from_utf8_lossy(&buf[..n.min(4000)]);
600                let first_line = resp.lines().next().unwrap_or("");
601                let status_code = first_line.split(' ').nth(1)
602                    .and_then(|s| s.parse::<u16>().ok())
603                    .unwrap_or(0);
604                if status_code > 0 && (filter_codes.is_empty() || filter_codes.contains(&status_code)) {
605                    let size_hint = resp.find("\r\n\r\n")
606                        .map(|i| resp.len() - i - 4)
607                        .unwrap_or(0);
608                    Some((path, status_code, first_line.to_string(), size_hint))
609                } else {
610                    None
611                }
612            }));
613        }
614
615        let mut discovered: Vec<serde_json::Value> = Vec::new();
616        for h in handles {
617            if let Ok(Some((path, code, line, size))) = h.await {
618                discovered.push(serde_json::json!({
619                    "path": format!("/{path}"),
620                    "status": code,
621                    "reason": line,
622                    "size": size,
623                }));
624            }
625        }
626
627        discovered.sort_by(|a, b| {
628            let pa = a["path"].as_str().unwrap_or("");
629            let pb = b["path"].as_str().unwrap_or("");
630            pa.cmp(pb)
631        });
632
633        let finding = if discovered.is_empty() {
634            "No interesting web paths discovered".to_string()
635        } else {
636            let paths_str: Vec<String> = discovered
637                .iter()
638                .map(|d| {
639                    let p = d["path"].as_str().unwrap_or("");
640                    let s = d["status"].as_u64().unwrap_or(0);
641                    format!("{p} [{s}]")
642                })
643                .collect();
644            format!(
645                "Discovered {} path(s): {}",
646                discovered.len(),
647                paths_str.join(", ")
648            )
649        };
650
651        Ok(ModuleResult {
652            success: true,
653            finding: Some(finding),
654            evidence: discovered
655                .iter()
656                .map(|d| {
657                    let p = d["path"].as_str().unwrap_or("");
658                    let s = d["status"].as_u64().unwrap_or(0);
659                    format!("http{s}{p}")
660                })
661                .collect(),
662            data: serde_json::json!({
663                "target": self.target,
664                "paths": discovered,
665                "count": discovered.len(),
666            }),
667            ..Default::default()
668        })
669    }
670}
671
672fn dns_encode_domain(domain: &str) -> Vec<u8> {
673    let mut buf = Vec::new();
674    for label in domain.split('.') {
675        if !label.is_empty() {
676            buf.push(label.len() as u8);
677            buf.extend_from_slice(label.as_bytes());
678        }
679    }
680    buf.push(0x00);
681    buf
682}
683
684fn dns_build_axfr_query(domain: &str, id: u16) -> Vec<u8> {
685    let mut msg = Vec::new();
686    msg.extend_from_slice(&id.to_be_bytes());
687    msg.extend_from_slice(&[0x01, 0x00]);
688    msg.extend_from_slice(&[0x00, 0x01]);
689    msg.extend_from_slice(&[0x00, 0x00]);
690    msg.extend_from_slice(&[0x00, 0x00]);
691    msg.extend_from_slice(&[0x00, 0x00]);
692    msg.extend_from_slice(&dns_encode_domain(domain));
693    msg.extend_from_slice(&[0x00, 0xFC]);
694    msg.extend_from_slice(&[0x00, 0x01]);
695
696    let len = msg.len() as u16;
697    let mut pkt = Vec::new();
698    pkt.extend_from_slice(&len.to_be_bytes());
699    pkt.extend_from_slice(&msg);
700    pkt
701}
702
703fn dns_decode_name(data: &[u8], pos: &mut usize) -> Result<String, ModuleError> {
704    let mut labels = Vec::new();
705    loop {
706        if *pos >= data.len() {
707            return Err(ModuleError::Other("DNS name: unexpected EOF".into()));
708        }
709        let byte = data[*pos];
710        if byte == 0x00 {
711            *pos += 1;
712            break;
713        }
714        if byte & 0xC0 == 0xC0 {
715            let offset = ((byte as usize & 0x3F) << 8) | data[*pos + 1] as usize;
716            *pos += 2;
717            let mut p = offset;
718            if let Ok(name) = dns_decode_name(data, &mut p) {
719                labels.push(name);
720            }
721            break;
722        }
723        let len = byte as usize;
724        *pos += 1;
725        if *pos + len > data.len() {
726            return Err(ModuleError::Other("DNS name: label too long".into()));
727        }
728        labels.push(String::from_utf8_lossy(&data[*pos..*pos + len]).to_string());
729        *pos += len;
730    }
731    Ok(labels.join("."))
732}
733
734fn dns_parse_response(data: &[u8]) -> Result<Vec<serde_json::Value>, ModuleError> {
735    if data.len() < 14 {
736        return Err(ModuleError::Other("response too short".into()));
737    }
738    let _id = u16::from_be_bytes([data[0], data[1]]);
739    let flags = u16::from_be_bytes([data[2], data[3]]);
740    let qr = (flags >> 15) & 1;
741    let rcode = flags & 0x0F;
742    if qr == 0 {
743        return Err(ModuleError::Other("not a response".into()));
744    }
745    if rcode != 0 {
746        let rcode_names = [
747            "NOERROR", "FORMERR", "SERVFAIL", "NXDOMAIN", "NOTIMP", "REFUSED",
748        ];
749        let name = rcode_names.get(rcode as usize).unwrap_or(&"UNKNOWN");
750        return Err(ModuleError::Other(format!(
751            "DNS error: {name} (rcode={rcode})"
752        )));
753    }
754
755    let _qdcount = u16::from_be_bytes([data[4], data[5]]);
756    let ancount = u16::from_be_bytes([data[6], data[7]]);
757    let _nscount = u16::from_be_bytes([data[8], data[9]]);
758    let _arcount = u16::from_be_bytes([data[10], data[11]]);
759
760    let mut pos = 12;
761    dns_decode_name(data, &mut pos)?;
762    pos += 4;
763
764    let mut records = Vec::new();
765    for _ in 0..ancount {
766        let name = match dns_decode_name(data, &mut pos) {
767            Ok(n) => n,
768            Err(_) => "?".to_string(),
769        };
770        if pos + 10 > data.len() {
771            break;
772        }
773        let rtype = u16::from_be_bytes([data[pos], data[pos + 1]]);
774        let _rclass = u16::from_be_bytes([data[pos + 2], data[pos + 3]]);
775        let ttl = u32::from_be_bytes([data[pos + 4], data[pos + 5], data[pos + 6], data[pos + 7]]);
776        let rdlength = u16::from_be_bytes([data[pos + 8], data[pos + 9]]) as usize;
777        pos += 10;
778        if pos + rdlength > data.len() {
779            break;
780        }
781
782        let rdata = match rtype {
783            1 => {
784                if rdlength >= 4 {
785                    Some(serde_json::Value::String(format!(
786                        "{}.{}.{}.{}",
787                        data[pos],
788                        data[pos + 1],
789                        data[pos + 2],
790                        data[pos + 3]
791                    )))
792                } else {
793                    None
794                }
795            }
796            28 => {
797                if rdlength >= 16 {
798                    let ip = std::net::Ipv6Addr::from(
799                        <[u8; 16]>::try_from(&data[pos..pos + 16]).unwrap_or([0u8; 16]),
800                    );
801                    Some(serde_json::Value::String(ip.to_string()))
802                } else {
803                    None
804                }
805            }
806            5 => {
807                let mut p = pos;
808                dns_decode_name(data, &mut p)
809                    .ok()
810                    .map(serde_json::Value::String)
811            }
812            2 => {
813                let mut p = pos;
814                dns_decode_name(data, &mut p)
815                    .ok()
816                    .map(serde_json::Value::String)
817            }
818            15 => {
819                let pref = u16::from_be_bytes([data[pos], data[pos + 1]]);
820                let mut p = pos + 2;
821                let target = dns_decode_name(data, &mut p).unwrap_or_default();
822                Some(serde_json::json!({"preference": pref, "target": target}))
823            }
824            6 => {
825                let mut p = pos;
826                let mname = dns_decode_name(data, &mut p).unwrap_or_default();
827                let rname = dns_decode_name(data, &mut p).unwrap_or_default();
828                if p + 20 <= data.len() {
829                    let serial =
830                        u32::from_be_bytes([data[p], data[p + 1], data[p + 2], data[p + 3]]);
831                    Some(serde_json::json!({"mname": mname, "rname": rname, "serial": serial}))
832                } else {
833                    None
834                }
835            }
836            16 => {
837                if rdlength > 0 {
838                    let txt_len = data[pos] as usize;
839                    let txt = String::from_utf8_lossy(
840                        &data[pos + 1..pos + 1 + txt_len.min(rdlength - 1)],
841                    )
842                    .to_string();
843                    Some(serde_json::Value::String(txt))
844                } else {
845                    None
846                }
847            }
848            33 => {
849                let mut p = pos;
850                if p + 6 <= data.len() {
851                    let priority = u16::from_be_bytes([data[p], data[p + 1]]);
852                    let weight = u16::from_be_bytes([data[p + 2], data[p + 3]]);
853                    let port = u16::from_be_bytes([data[p + 4], data[p + 5]]);
854                    p += 6;
855                    let target = dns_decode_name(data, &mut p).unwrap_or_default();
856                    Some(
857                        serde_json::json!({"priority": priority, "weight": weight, "port": port, "target": target}),
858                    )
859                } else {
860                    None
861                }
862            }
863            _ => None,
864        };
865
866        let type_names = [
867            "", "A", "NS", "MD", "MF", "CNAME", "SOA", "MB", "MG", "MR", "NULL", "WKS", "PTR",
868            "HINFO", "MINFO", "MX", "TXT",
869        ];
870        let type_name = if (rtype as usize) < type_names.len() {
871            type_names[rtype as usize]
872        } else {
873            "TYPE?"
874        };
875
876        records.push(serde_json::json!({
877            "name": name,
878            "type": type_name,
879            "type_code": rtype,
880            "ttl": ttl,
881            "value": rdata,
882        }));
883        pos += rdlength;
884    }
885
886    Ok(records)
887}
888
889#[module(
890    name = "dns_zone_transfer",
891    kind = "Auxiliary",
892    description = "Attempt DNS zone transfer (AXFR) to enumerate all DNS records for a domain",
893    author = "ICEBOX"
894)]
895pub struct DnsZoneTransfer {
896    #[option(required = true, help = "Domain to query (e.g. example.com)")]
897    pub domain: String,
898    #[option(help = "DNS server to query (IP, default: use configured resolver)")]
899    pub server: String,
900    #[option(help = "Timeout in milliseconds (default 10000)")]
901    pub timeout_ms: u64,
902}
903
904#[async_trait]
905impl Module for DnsZoneTransfer {
906    fn options_json(&self) -> serde_json::Value {
907        serde_json::to_value(&DnsZoneTransferOptions {
908            domain: self.domain.clone(),
909            server: self.server.clone(),
910            timeout_ms: self.timeout_ms,
911        })
912        .unwrap_or(serde_json::Value::Null)
913    }
914
915    fn set_option(&mut self, name: &str, value: &str) -> Result<(), ModuleError> {
916        let mut o = DnsZoneTransferOptions {
917            domain: self.domain.clone(),
918            server: self.server.clone(),
919            timeout_ms: self.timeout_ms,
920        };
921        o.set(name, value)?;
922        self.domain = o.domain;
923        self.server = o.server;
924        self.timeout_ms = o.timeout_ms;
925        Ok(())
926    }
927
928    fn validate(&self) -> Result<(), ModuleError> {
929        DnsZoneTransferOptions {
930            domain: self.domain.clone(),
931            server: self.server.clone(),
932            timeout_ms: self.timeout_ms,
933        }
934        .validate()
935    }
936
937    async fn run(&self) -> Result<ModuleResult, ModuleError> {
938        let domain = self.domain.trim().to_lowercase();
939        let timeout = std::time::Duration::from_millis(if self.timeout_ms > 0 {
940            self.timeout_ms
941        } else {
942            10000
943        });
944
945        let nameservers: Vec<&str> = if self.server.is_empty() {
946            vec!["8.8.8.8", "1.1.1.1", "208.67.222.222"]
947        } else {
948            vec![self.server.as_str()]
949        };
950
951        let mut all_records = Vec::new();
952        let mut errors = Vec::new();
953
954        for ns in &nameservers {
955            let addr = format!("{ns}:53");
956            let stream =
957                match tokio::time::timeout(timeout, tokio::net::TcpStream::connect(&addr)).await {
958                    Ok(Ok(s)) => s,
959                    Ok(Err(e)) => {
960                        errors.push(format!("{ns}: connect error: {e}"));
961                        continue;
962                    }
963                    Err(_) => {
964                        errors.push(format!("{ns}: connect timeout"));
965                        continue;
966                    }
967                };
968
969            let query = dns_build_axfr_query(&domain, 0x1337);
970            if tokio::time::timeout(timeout, stream.writable())
971                .await
972                .is_err()
973            {
974                errors.push(format!("{ns}: write timeout"));
975                continue;
976            }
977            let _ = stream.try_write(&query);
978
979            let mut buf = vec![0u8; 65536];
980            let n = match tokio::time::timeout(timeout, stream.readable()).await {
981                Ok(Ok(_)) => stream.try_read(&mut buf).unwrap_or(0),
982                _ => {
983                    errors.push(format!("{ns}: read timeout"));
984                    continue;
985                }
986            };
987
988            if n < 4 {
989                errors.push(format!("{ns}: response too short ({n} bytes)"));
990                continue;
991            }
992
993            let dns_msg = &buf[2..n];
994            match dns_parse_response(dns_msg) {
995                Ok(records) => {
996                    if !records.is_empty() {
997                        all_records.extend(records);
998                        break;
999                    }
1000                    errors.push(format!("{ns}: no records returned"));
1001                }
1002                Err(e) => {
1003                    errors.push(format!("{ns}: parse error: {e}"));
1004                }
1005            }
1006        }
1007
1008        let evidence: Vec<String> = all_records
1009            .iter()
1010            .map(|r| {
1011                let name = r["name"].as_str().unwrap_or("");
1012                let rtype = r["type"].as_str().unwrap_or("?");
1013                let value = r.get("value").and_then(|v| v.as_str()).unwrap_or("");
1014                format!("dns/{name} {rtype} {value}")
1015            })
1016            .collect();
1017
1018        let ns_used_str = all_records
1019            .first()
1020            .and_then(|_| nameservers.first())
1021            .unwrap_or(&"none");
1022
1023        let finding = if all_records.is_empty() {
1024            format!(
1025                "DNS zone transfer failed for {domain}: {}",
1026                errors.join("; ")
1027            )
1028        } else {
1029            let count = all_records.len();
1030            let soa_count = all_records.iter().filter(|r| r["type"] == "SOA").count();
1031            format!("Zone transfer SUCCESS for {domain} via {ns_used_str}: {count} records ({soa_count} SOA)")
1032        };
1033
1034        Ok(ModuleResult {
1035            success: !all_records.is_empty(),
1036            finding: Some(finding),
1037            evidence,
1038            data: serde_json::json!({
1039                "domain": domain,
1040                "nameserver": ns_used_str,
1041                "records": all_records,
1042                "record_count": all_records.len(),
1043                "errors": errors,
1044            }),
1045            ..Default::default()
1046        })
1047    }
1048}
1049
1050fn whois_server_for(query: &str) -> &'static str {
1051    let q = query.trim().to_lowercase();
1052    if q.contains('.') {
1053        if q.ends_with(".edu") {
1054            return "whois.educause.edu";
1055        }
1056        if q.ends_with(".gov") {
1057            return "whois.nic.gov";
1058        }
1059        if q.ends_with(".mil") {
1060            return "whois.nic.mil";
1061        }
1062        if q.ends_with(".org") || q.ends_with(".ngo") || q.ends_with(".ong") {
1063            return "whois.publicinterestregistry.net";
1064        }
1065        if q.ends_with(".info") {
1066            return "whois.afilias.net";
1067        }
1068        if q.ends_with(".biz") {
1069            return "whois.nic.biz";
1070        }
1071        if q.ends_with(".io") {
1072            return "whois.nic.io";
1073        }
1074        if q.ends_with(".co") {
1075            return "whois.nic.co";
1076        }
1077        if q.ends_with(".int") {
1078            return "whois.iana.org";
1079        }
1080        "whois.verisign-grs.com"
1081    } else {
1082        "whois.arin.net"
1083    }
1084}
1085
1086#[module(
1087    name = "whois_lookup",
1088    kind = "Auxiliary",
1089    description = "Perform whois lookup on a domain or IP address",
1090    author = "ICEBOX"
1091)]
1092pub struct WhoisLookup {
1093    #[option(required = true, help = "Domain or IP address to query")]
1094    pub query: String,
1095    #[option(help = "Whois server (default: auto-detected)")]
1096    pub server: String,
1097    #[option(help = "Timeout in milliseconds (default 15000)")]
1098    pub timeout_ms: u64,
1099}
1100
1101#[async_trait]
1102impl Module for WhoisLookup {
1103    fn options_json(&self) -> serde_json::Value {
1104        serde_json::to_value(&WhoisLookupOptions {
1105            query: self.query.clone(),
1106            server: self.server.clone(),
1107            timeout_ms: self.timeout_ms,
1108        })
1109        .unwrap_or(serde_json::Value::Null)
1110    }
1111
1112    fn set_option(&mut self, name: &str, value: &str) -> Result<(), ModuleError> {
1113        let mut o = WhoisLookupOptions {
1114            query: self.query.clone(),
1115            server: self.server.clone(),
1116            timeout_ms: self.timeout_ms,
1117        };
1118        o.set(name, value)?;
1119        self.query = o.query;
1120        self.server = o.server;
1121        self.timeout_ms = o.timeout_ms;
1122        Ok(())
1123    }
1124
1125    fn validate(&self) -> Result<(), ModuleError> {
1126        WhoisLookupOptions {
1127            query: self.query.clone(),
1128            server: self.server.clone(),
1129            timeout_ms: self.timeout_ms,
1130        }
1131        .validate()
1132    }
1133
1134    async fn run(&self) -> Result<ModuleResult, ModuleError> {
1135        let query = self.query.trim().to_string();
1136        let timeout = std::time::Duration::from_millis(if self.timeout_ms > 0 {
1137            self.timeout_ms
1138        } else {
1139            15000
1140        });
1141        let server = if self.server.is_empty() {
1142            whois_server_for(&query)
1143        } else {
1144            self.server.as_str()
1145        };
1146
1147        let addr = format!("{server}:43");
1148        let stream =
1149            match tokio::time::timeout(timeout, tokio::net::TcpStream::connect(&addr)).await {
1150                Ok(Ok(s)) => s,
1151                Ok(Err(e)) => return Err(ModuleError::Other(format!("connect to {server}: {e}"))),
1152                Err(_) => return Err(ModuleError::Other(format!("connect to {server}: timeout"))),
1153            };
1154
1155        let request = format!("{query}\r\n");
1156        match tokio::time::timeout(timeout, stream.writable()).await {
1157            Ok(Ok(_)) => {
1158                stream
1159                    .try_write(request.as_bytes())
1160                    .map_err(|e| ModuleError::Other(format!("write: {e}")))?;
1161            }
1162            _ => return Err(ModuleError::Other("write timeout".into())),
1163        }
1164
1165        let mut all_data = Vec::new();
1166        let mut buf = vec![0u8; 8192];
1167        loop {
1168            let n = match tokio::time::timeout(timeout, stream.readable()).await {
1169                Ok(Ok(_)) => match stream.try_read(&mut buf) {
1170                    Ok(n) => n,
1171                    Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => 0,
1172                    Err(_) => break,
1173                },
1174                _ => break,
1175            };
1176            if n == 0 {
1177                break;
1178            }
1179            all_data.extend_from_slice(&buf[..n]);
1180            if all_data.len() > 65536 {
1181                break;
1182            }
1183        }
1184
1185        let raw_text = String::from_utf8_lossy(&all_data).to_string();
1186        let summary_lines: Vec<&str> = raw_text
1187            .lines()
1188            .filter(|l| {
1189                let l = l.trim().to_lowercase();
1190                l.starts_with("domain")
1191                    || l.starts_with("registrar")
1192                    || l.starts_with("creation")
1193                    || l.starts_with("expir")
1194                    || l.starts_with("name server")
1195                    || l.starts_with("nserver")
1196                    || l.starts_with("org")
1197                    || l.starts_with("netrange")
1198                    || l.starts_with("cidr")
1199                    || l.starts_with("orgname")
1200            })
1201            .collect();
1202        let summary = summary_lines.join("\n");
1203
1204        Ok(ModuleResult {
1205            success: !raw_text.is_empty(),
1206            finding: Some(format!(
1207                "Whois lookup for {query} via {server}: {} lines",
1208                raw_text.lines().count()
1209            )),
1210            evidence: if summary.is_empty() {
1211                vec![format!("whois/{query} -> {} bytes", all_data.len())]
1212            } else {
1213                summary_lines.iter().map(|l| format!("whois/{l}")).collect()
1214            },
1215            data: serde_json::json!({
1216                "query": query,
1217                "server": server,
1218                "raw_length": all_data.len(),
1219                "summary": summary,
1220                "raw": if raw_text.len() > 10000 { format!("{}...", &raw_text[..10000]) } else { raw_text },
1221            }),
1222            ..Default::default()
1223        })
1224    }
1225}
1226
1227const DEFAULT_SUBDOMAINS: &[&str] = &[
1228    "www",
1229    "mail",
1230    "admin",
1231    "ftp",
1232    "ssh",
1233    "api",
1234    "dev",
1235    "test",
1236    "blog",
1237    "shop",
1238    "portal",
1239    "remote",
1240    "vpn",
1241    "webmail",
1242    "dns",
1243    "ns1",
1244    "ns2",
1245    "smtp",
1246    "pop3",
1247    "imap",
1248    "owa",
1249    "cpanel",
1250    "whm",
1251    "phpmyadmin",
1252    "jenkins",
1253    "gitlab",
1254    "jira",
1255    "wiki",
1256    "docs",
1257    "status",
1258    "support",
1259    "chat",
1260    "demo",
1261    "stage",
1262    "staging",
1263    "backup",
1264    "monitor",
1265    "logs",
1266    "db",
1267    "data",
1268    "sql",
1269    "mysql",
1270    "redis",
1271    "mongo",
1272    "cdn",
1273    "assets",
1274    "static",
1275    "download",
1276    "upload",
1277    "app",
1278    "apps",
1279    "server",
1280    "crm",
1281    "erp",
1282    "intranet",
1283    "biz",
1284    "info",
1285    "partner",
1286    "partners",
1287    "client",
1288    "clients",
1289    "user",
1290    "users",
1291    "account",
1292    "accounts",
1293    "login",
1294    "signin",
1295    "auth",
1296    "sso",
1297    "identity",
1298    "api-v1",
1299    "api-v2",
1300    "v1",
1301    "v2",
1302    "graphql",
1303    "rest",
1304    "service",
1305    "services",
1306    "mq",
1307    "kafka",
1308    "rabbitmq",
1309    "elasticsearch",
1310    "kibana",
1311    "grafana",
1312    "prometheus",
1313    "nagios",
1314    "zabbix",
1315    "mx",
1316    "mail2",
1317    "mail1",
1318    "ns",
1319    "ns3",
1320    "ns4",
1321    "direct",
1322    "direct-connect",
1323    "web",
1324    "webmail2",
1325    "forum",
1326    "help",
1327    "helpdesk",
1328    "knowledgebase",
1329    "kb",
1330    "tickets",
1331    "git",
1332    "svn",
1333    "cloud",
1334    "office",
1335    "outlook",
1336    "exchange",
1337    "lync",
1338    "skype",
1339];
1340
1341#[module(
1342    name = "subdomain_enum",
1343    kind = "Scanner",
1344    description = "Enumerate subdomains via DNS resolution",
1345    author = "ICEBOX"
1346)]
1347pub struct SubdomainEnum {
1348    #[option(required = true, help = "Domain to enumerate (e.g. example.com)")]
1349    pub domain: String,
1350    #[option(help = "Subdomain wordlist, comma-separated (default: built-in common subdomains)")]
1351    pub wordlist: String,
1352    #[option(help = "Timeout per lookup in milliseconds (default 3000)")]
1353    pub timeout_ms: u64,
1354    #[option(help = "Concurrent lookups (default 50)")]
1355    pub concurrency: usize,
1356}
1357
1358#[async_trait]
1359impl Module for SubdomainEnum {
1360    fn options_json(&self) -> serde_json::Value {
1361        serde_json::to_value(&SubdomainEnumOptions {
1362            domain: self.domain.clone(),
1363            wordlist: self.wordlist.clone(),
1364            timeout_ms: self.timeout_ms,
1365            concurrency: self.concurrency,
1366        })
1367        .unwrap_or(serde_json::Value::Null)
1368    }
1369
1370    fn set_option(&mut self, name: &str, value: &str) -> Result<(), ModuleError> {
1371        let mut o = SubdomainEnumOptions {
1372            domain: self.domain.clone(),
1373            wordlist: self.wordlist.clone(),
1374            timeout_ms: self.timeout_ms,
1375            concurrency: self.concurrency,
1376        };
1377        o.set(name, value)?;
1378        self.domain = o.domain;
1379        self.wordlist = o.wordlist;
1380        self.timeout_ms = o.timeout_ms;
1381        self.concurrency = o.concurrency;
1382        Ok(())
1383    }
1384
1385    fn validate(&self) -> Result<(), ModuleError> {
1386        SubdomainEnumOptions {
1387            domain: self.domain.clone(),
1388            wordlist: self.wordlist.clone(),
1389            timeout_ms: self.timeout_ms,
1390            concurrency: self.concurrency,
1391        }
1392        .validate()
1393    }
1394
1395    async fn run(&self) -> Result<ModuleResult, ModuleError> {
1396        let domain = self.domain.trim().to_lowercase();
1397        let timeout = std::time::Duration::from_millis(if self.timeout_ms > 0 {
1398            self.timeout_ms
1399        } else {
1400            3000
1401        });
1402        let max_concurrency = if self.concurrency > 0 {
1403            self.concurrency
1404        } else {
1405            50
1406        };
1407
1408        let subdomains: Vec<String> = if self.wordlist.is_empty() {
1409            DEFAULT_SUBDOMAINS.iter().map(|s| s.to_string()).collect()
1410        } else {
1411            self.wordlist
1412                .split(',')
1413                .map(|s| s.trim().to_string())
1414                .filter(|s| !s.is_empty())
1415                .collect()
1416        };
1417
1418        let semaphore = Arc::new(Semaphore::new(max_concurrency));
1419        let mut handles = Vec::new();
1420
1421        for sub in &subdomains {
1422            let permit = match semaphore.clone().acquire_owned().await {
1423                Ok(p) => p,
1424                Err(_) => continue,
1425            };
1426            let sub = sub.clone();
1427            let domain = domain.clone();
1428            handles.push(tokio::spawn(async move {
1429                let _permit = permit;
1430                let fqdn = format!("{sub}.{domain}");
1431                let result =
1432                    tokio::time::timeout(timeout, tokio::net::lookup_host((fqdn.clone(), 0))).await;
1433                match result {
1434                    Ok(Ok(addrs)) => {
1435                        let ips: Vec<String> = addrs.map(|a| a.ip().to_string()).collect();
1436                        if !ips.is_empty() {
1437                            Some((sub, fqdn, ips))
1438                        } else {
1439                            None
1440                        }
1441                    }
1442                    _ => None,
1443                }
1444            }));
1445        }
1446
1447        let mut resolved: Vec<serde_json::Value> = Vec::new();
1448        for h in handles {
1449            if let Ok(Some((sub, fqdn, ips))) = h.await {
1450                resolved.push(serde_json::json!({
1451                    "subdomain": sub,
1452                    "fqdn": fqdn,
1453                    "ips": ips,
1454                }));
1455            }
1456        }
1457
1458        resolved.sort_by(|a, b| {
1459            a["subdomain"]
1460                .as_str()
1461                .unwrap_or("")
1462                .cmp(b["subdomain"].as_str().unwrap_or(""))
1463        });
1464
1465        let evidence: Vec<String> = resolved
1466            .iter()
1467            .map(|r| {
1468                let f = r["fqdn"].as_str().unwrap_or("");
1469                let ips = r["ips"]
1470                    .as_array()
1471                    .map(|a| {
1472                        a.iter()
1473                            .filter_map(|v| v.as_str().map(|s| s.to_string()))
1474                            .collect::<Vec<_>>()
1475                            .join(",")
1476                    })
1477                    .unwrap_or_default();
1478                format!("dns/{f} -> {ips}")
1479            })
1480            .collect();
1481
1482        let finding = if resolved.is_empty() {
1483            "No subdomains found".to_string()
1484        } else {
1485            let list: Vec<String> = resolved
1486                .iter()
1487                .map(|r| {
1488                    let s = r["subdomain"].as_str().unwrap_or("");
1489                    let ips = r["ips"]
1490                        .as_array()
1491                        .map(|a| {
1492                            a.iter()
1493                                .filter_map(|v| v.as_str())
1494                                .collect::<Vec<_>>()
1495                                .join(",")
1496                        })
1497                        .unwrap_or_default();
1498                    format!("{s} ({ips})")
1499                })
1500                .collect();
1501            format!("Found {} subdomain(s): {}", resolved.len(), list.join(", "))
1502        };
1503
1504        Ok(ModuleResult {
1505            success: true,
1506            finding: Some(finding),
1507            evidence,
1508            data: serde_json::json!({
1509                "domain": domain,
1510                "subdomains": resolved,
1511                "count": resolved.len(),
1512                "tried": subdomains.len(),
1513            }),
1514            ..Default::default()
1515        })
1516    }
1517}
1518
1519fn ber_tag(tag: u8, contents: &[u8]) -> Vec<u8> {
1520    let mut buf = vec![tag];
1521    let lbytes = if contents.len() < 128 {
1522        vec![contents.len() as u8]
1523    } else if contents.len() <= 0xFF {
1524        vec![0x81, contents.len() as u8]
1525    } else {
1526        let bytes = (contents.len() as u16).to_be_bytes();
1527        vec![0x82, bytes[0], bytes[1]]
1528    };
1529    buf.extend_from_slice(&lbytes);
1530    buf.extend_from_slice(contents);
1531    buf
1532}
1533
1534fn ber_integer(value: i32) -> Vec<u8> {
1535    let bytes = if value == 0 {
1536        vec![0x00]
1537    } else if value > 0 {
1538        if value <= 0x7F {
1539            vec![value as u8]
1540        } else {
1541            value.to_be_bytes().to_vec()
1542        }
1543    } else {
1544        value.to_be_bytes().to_vec()
1545    };
1546    ber_tag(0x02, &bytes)
1547}
1548
1549fn ber_octet_string(s: &[u8]) -> Vec<u8> {
1550    ber_tag(0x04, s)
1551}
1552
1553fn ber_null() -> Vec<u8> {
1554    vec![0x05, 0x00]
1555}
1556
1557fn ber_sequence(contents: &[u8]) -> Vec<u8> {
1558    ber_tag(0x30, contents)
1559}
1560
1561fn ber_oid(components: &[u32]) -> Vec<u8> {
1562    let mut encoded = Vec::new();
1563    if components.len() < 2 {
1564        return ber_tag(0x06, &[]);
1565    }
1566    encoded.push((40 * components[0] + components[1]) as u8);
1567    for &c in &components[2..] {
1568        if c < 128 {
1569            encoded.push(c as u8);
1570        } else {
1571            let mut bytes = Vec::new();
1572            let mut v = c;
1573            while v > 0 {
1574                bytes.push((v & 0x7F) as u8);
1575                v >>= 7;
1576            }
1577            bytes.reverse();
1578            for (i, &b) in bytes.iter().enumerate() {
1579                if i < bytes.len() - 1 {
1580                    encoded.push(b | 0x80);
1581                } else {
1582                    encoded.push(b);
1583                }
1584            }
1585        }
1586    }
1587    ber_tag(0x06, &encoded)
1588}
1589
1590fn ber_context_specific(tag: u8, contents: &[u8]) -> Vec<u8> {
1591    ber_tag(0xA0 | tag, contents)
1592}
1593
1594fn snmp_build_get_request(community: &str, oid: &[u32], request_id: i32) -> Vec<u8> {
1595    let mut varbind_inner = Vec::new();
1596    varbind_inner.extend_from_slice(&ber_oid(oid));
1597    varbind_inner.extend_from_slice(&ber_null());
1598    let varbind = ber_sequence(&varbind_inner);
1599
1600    let mut varbind_list_inner = Vec::new();
1601    varbind_list_inner.extend_from_slice(&varbind);
1602    let varbind_list = ber_sequence(&varbind_list_inner);
1603
1604    let mut pdu_inner = Vec::new();
1605    pdu_inner.extend_from_slice(&ber_integer(request_id));
1606    pdu_inner.extend_from_slice(&ber_integer(0));
1607    pdu_inner.extend_from_slice(&ber_integer(0));
1608    pdu_inner.extend_from_slice(&varbind_list);
1609    let pdu = ber_context_specific(0, &pdu_inner);
1610
1611    let mut msg_inner = Vec::new();
1612    msg_inner.extend_from_slice(&ber_integer(1));
1613    msg_inner.extend_from_slice(&ber_octet_string(community.as_bytes()));
1614    msg_inner.extend_from_slice(&pdu);
1615    ber_sequence(&msg_inner)
1616}
1617
1618fn asn1_skip_tlv(data: &[u8], pos: &mut usize) -> Result<(), ModuleError> {
1619    if *pos >= data.len() {
1620        return Err(ModuleError::Other("ASN.1: unexpected EOF at tag".into()));
1621    }
1622    let _tag = data[*pos];
1623    *pos += 1;
1624    if *pos >= data.len() {
1625        return Err(ModuleError::Other("ASN.1: unexpected EOF at length".into()));
1626    }
1627    let len_byte = data[*pos];
1628    *pos += 1;
1629    let length: usize = if len_byte < 0x80 {
1630        len_byte as usize
1631    } else if len_byte == 0x81 {
1632        if *pos >= data.len() {
1633            return Err(ModuleError::Other("ASN.1: EOF in long length".into()));
1634        }
1635        let l = data[*pos] as usize;
1636        *pos += 1;
1637        l
1638    } else if len_byte == 0x82 {
1639        if *pos + 2 > data.len() {
1640            return Err(ModuleError::Other("ASN.1: EOF in long length".into()));
1641        }
1642        let l = u16::from_be_bytes([data[*pos], data[*pos + 1]]) as usize;
1643        *pos += 2;
1644        l
1645    } else {
1646        return Err(ModuleError::Other(
1647            "ASN.1: unsupported length encoding".into(),
1648        ));
1649    };
1650    *pos += length;
1651    Ok(())
1652}
1653
1654fn asn1_get_value(data: &[u8], pos: &mut usize) -> Result<Vec<u8>, ModuleError> {
1655    if *pos >= data.len() {
1656        return Err(ModuleError::Other("ASN.1: EOF at tag".into()));
1657    }
1658    *pos += 1;
1659    if *pos >= data.len() {
1660        return Err(ModuleError::Other("ASN.1: EOF at length".into()));
1661    }
1662    let len_byte = data[*pos];
1663    *pos += 1;
1664    let length: usize = if len_byte < 0x80 {
1665        len_byte as usize
1666    } else if len_byte == 0x81 {
1667        if *pos >= data.len() {
1668            return Err(ModuleError::Other("ASN.1: EOF".into()));
1669        }
1670        let l = data[*pos] as usize;
1671        *pos += 1;
1672        l
1673    } else if len_byte == 0x82 {
1674        if *pos + 2 > data.len() {
1675            return Err(ModuleError::Other("ASN.1: EOF".into()));
1676        }
1677        let l = (data[*pos] as usize) << 8 | data[*pos + 1] as usize;
1678        *pos += 2;
1679        l
1680    } else {
1681        return Err(ModuleError::Other("ASN.1: unsupported length".into()));
1682    };
1683    if *pos + length > data.len() {
1684        return Err(ModuleError::Other("ASN.1: value exceeds data".into()));
1685    }
1686    let val = data[*pos..*pos + length].to_vec();
1687    *pos += length;
1688    Ok(val)
1689}
1690
1691fn snmp_parse_response(data: &[u8]) -> Result<String, ModuleError> {
1692    let mut pos = 0;
1693    if pos >= data.len() || data[pos] != 0x30 {
1694        return Err(ModuleError::Other("not an ASN.1 SEQUENCE".into()));
1695    }
1696    let _outer_val = asn1_get_value(data, &mut pos)?;
1697
1698    let mut p = 0;
1699    asn1_skip_tlv(data, &mut p)?;
1700    asn1_skip_tlv(data, &mut p)?;
1701    asn1_skip_tlv(data, &mut p)?;
1702    if p >= data.len() || data[p] != 0xA2 {
1703        return Err(ModuleError::Other("expected GetResponse PDU".into()));
1704    }
1705    asn1_get_value(data, &mut p)?;
1706    let mut pp = 0;
1707    asn1_skip_tlv(data, &mut pp)?;
1708    asn1_skip_tlv(data, &mut pp)?;
1709    asn1_skip_tlv(data, &mut pp)?;
1710    if pp >= data.len() || (data[pp] != 0xA2 && data[pp] != 0xA0) {
1711        return Err(ModuleError::Other("expected SNMP PDU".into()));
1712    }
1713    let pdu_val = asn1_get_value(data, &mut pp)?;
1714    let mut pdu_pos = 0;
1715    asn1_skip_tlv(&pdu_val, &mut pdu_pos)?;
1716    asn1_skip_tlv(&pdu_val, &mut pdu_pos)?;
1717    asn1_skip_tlv(&pdu_val, &mut pdu_pos)?;
1718    if pdu_pos >= pdu_val.len() || pdu_val[pdu_pos] != 0x30 {
1719        return Err(ModuleError::Other("expected var-bind-list".into()));
1720    }
1721    let vb_list = asn1_get_value(&pdu_val, &mut pdu_pos)?;
1722    let mut vb_pos = 0;
1723    if vb_pos >= vb_list.len() || vb_list[vb_pos] != 0x30 {
1724        return Err(ModuleError::Other("expected var-bind".into()));
1725    }
1726    let vb_val = asn1_get_value(&vb_list, &mut vb_pos)?;
1727    let mut vv = 0;
1728    asn1_skip_tlv(&vb_val, &mut vv)?;
1729    if vv >= vb_val.len() {
1730        return Err(ModuleError::Other("no value in var-bind".into()));
1731    }
1732    let value = asn1_get_value(&vb_val, &mut vv)?;
1733    if value
1734        .iter()
1735        .all(|&b| (0x20..=0x7E).contains(&b) || b == b' ')
1736    {
1737        Ok(String::from_utf8_lossy(&value).to_string())
1738    } else if !value.is_empty() {
1739        Ok(format!("0x{}", hex_encode(&value)))
1740    } else {
1741        Ok("(null/empty)".into())
1742    }
1743}
1744
1745fn asn1_parse_for_text(data: &[u8]) -> Option<String> {
1746    for window in data.windows(4) {
1747        if window == [0x04, 0x81] || window == [0x04, 0x82] {
1748            break;
1749        }
1750    }
1751    let text = String::from_utf8_lossy(data);
1752    let mut candidates: Vec<String> = text
1753        .split(|c: char| !c.is_ascii_graphic() && c != ' ')
1754        .filter(|s| s.len() > 5 && s.is_ascii())
1755        .map(|s| s.trim().to_string())
1756        .collect();
1757    candidates.dedup();
1758    candidates.retain(|s| !s.contains('\0') && s.len() < 256);
1759    candidates.first().cloned()
1760}
1761
1762#[module(
1763    name = "snmp_scanner",
1764    kind = "Scanner",
1765    description = "SNMP service scanner  -  enumerate community strings and system information",
1766    author = "ICEBOX"
1767)]
1768pub struct SnmpScanner {
1769    #[option(required = true, help = "Target IP or hostname")]
1770    pub host: String,
1771    #[option(help = "SNMP port (default 161)")]
1772    pub port: u16,
1773    #[option(help = "Timeout in milliseconds (default 3000)")]
1774    pub timeout_ms: u64,
1775    #[option(help = "Community strings, comma-separated (default: public,private)")]
1776    pub communities: String,
1777}
1778
1779#[async_trait]
1780impl Module for SnmpScanner {
1781    fn options_json(&self) -> serde_json::Value {
1782        serde_json::to_value(&SnmpScannerOptions {
1783            host: self.host.clone(),
1784            port: self.port,
1785            timeout_ms: self.timeout_ms,
1786            communities: self.communities.clone(),
1787        })
1788        .unwrap_or(serde_json::Value::Null)
1789    }
1790
1791    fn set_option(&mut self, name: &str, value: &str) -> Result<(), ModuleError> {
1792        let mut o = SnmpScannerOptions {
1793            host: self.host.clone(),
1794            port: self.port,
1795            timeout_ms: self.timeout_ms,
1796            communities: self.communities.clone(),
1797        };
1798        o.set(name, value)?;
1799        self.host = o.host;
1800        self.port = o.port;
1801        self.timeout_ms = o.timeout_ms;
1802        self.communities = o.communities;
1803        Ok(())
1804    }
1805
1806    fn validate(&self) -> Result<(), ModuleError> {
1807        SnmpScannerOptions {
1808            host: self.host.clone(),
1809            port: self.port,
1810            timeout_ms: self.timeout_ms,
1811            communities: self.communities.clone(),
1812        }
1813        .validate()
1814    }
1815
1816    async fn run(&self) -> Result<ModuleResult, ModuleError> {
1817        let port = if self.port > 0 { self.port } else { 161 };
1818        let timeout = std::time::Duration::from_millis(if self.timeout_ms > 0 {
1819            self.timeout_ms
1820        } else {
1821            3000
1822        });
1823        let addr = crate::core::proxy::resolve_dial(&self.host, port);
1824
1825        let communities: Vec<&str> = if self.communities.is_empty() {
1826            vec![
1827                "public",
1828                "private",
1829                "community",
1830                "snmp",
1831                "manager",
1832                "admin",
1833                "read",
1834                "write",
1835            ]
1836        } else {
1837            self.communities
1838                .split(',')
1839                .map(|s| s.trim())
1840                .filter(|s| !s.is_empty())
1841                .collect()
1842        };
1843
1844        let sysdescr_oid: &[u32] = &[1, 3, 6, 1, 2, 1, 1, 1, 0];
1845        let mut found_communities: Vec<(String, String)> = Vec::new();
1846
1847        for &community in &communities {
1848            let sock = match tokio::net::UdpSocket::bind("0.0.0.0:0").await {
1849                Ok(s) => s,
1850                Err(_) => continue,
1851            };
1852            let request = snmp_build_get_request(community, sysdescr_oid, 1);
1853            if tokio::time::timeout(timeout, sock.send_to(&request, &addr))
1854                .await
1855                .is_err()
1856            {
1857                continue;
1858            }
1859            let mut buf = vec![0u8; 65536];
1860            let n = match tokio::time::timeout(timeout, sock.recv_from(&mut buf)).await {
1861                Ok(Ok((n, _))) => n,
1862                _ => continue,
1863            };
1864            if n < 10 {
1865                continue;
1866            }
1867
1868            let response = &buf[..n];
1869            match snmp_parse_response(response) {
1870                Ok(description) => {
1871                    found_communities.push((community.to_string(), description));
1872                }
1873                Err(_) => {
1874                    if let Some(text) = asn1_parse_for_text(response) {
1875                        found_communities.push((community.to_string(), text));
1876                    }
1877                }
1878            }
1879        }
1880
1881        let evidence: Vec<String> = found_communities
1882            .iter()
1883            .map(|(c, desc)| format!("snmp/community:{c} -> {desc}"))
1884            .collect();
1885
1886        let finding = if found_communities.is_empty() {
1887            format!("SNMP on udp/{port}: no working community string found")
1888        } else {
1889            let summaries: Vec<String> = found_communities
1890                .iter()
1891                .map(|(c, desc)| format!("{c}={desc}"))
1892                .collect();
1893            format!(
1894                "SNMP on udp/{port}: {} working community string(s): {}",
1895                found_communities.len(),
1896                summaries.join("; ")
1897            )
1898        };
1899
1900        Ok(ModuleResult {
1901            success: !found_communities.is_empty(),
1902            finding: Some(finding),
1903            evidence,
1904            data: serde_json::json!({
1905                "host": self.host,
1906                "port": port,
1907                "found_communities": found_communities,
1908                "tried": communities,
1909            }),
1910            ..Default::default()
1911        })
1912    }
1913}
1914
1915fn bson_encode_is_master() -> Vec<u8> {
1916    let mut doc = Vec::new();
1917    doc.push(0x10);
1918    doc.extend_from_slice(b"isMaster\x00");
1919    doc.extend_from_slice(&1i32.to_le_bytes());
1920    doc.push(0x00);
1921    let mut pkt = Vec::with_capacity(4 + doc.len());
1922    let len = (4 + doc.len()) as i32;
1923    pkt.extend_from_slice(&len.to_le_bytes());
1924    pkt.extend_from_slice(&doc);
1925    pkt
1926}
1927
1928fn build_mongo_query(db_cmd: &str, query: &[u8]) -> Vec<u8> {
1929    let msg_len = 16 + 4 + db_cmd.len() as u32 + 1 + 4 + 4 + query.len() as u32;
1930    let mut pkt = Vec::with_capacity(msg_len as usize);
1931    pkt.extend_from_slice(&msg_len.to_le_bytes());
1932    pkt.extend_from_slice(&1i32.to_le_bytes());
1933    pkt.extend_from_slice(&0i32.to_le_bytes());
1934    pkt.extend_from_slice(&2004i32.to_le_bytes());
1935    pkt.extend_from_slice(&0i32.to_le_bytes());
1936    pkt.extend_from_slice(db_cmd.as_bytes());
1937    pkt.push(0x00);
1938    pkt.extend_from_slice(&0i32.to_le_bytes());
1939    pkt.extend_from_slice(&(-1i32).to_le_bytes());
1940    pkt.extend_from_slice(query);
1941    pkt
1942}
1943
1944fn bson_find_string(doc: &[u8], field: &str) -> Option<String> {
1945    if doc.len() < 5 {
1946        return None;
1947    }
1948    let _total_len = i32::from_le_bytes([doc[0], doc[1], doc[2], doc[3]]) as usize;
1949    let mut pos = 4;
1950    while pos + 2 < doc.len() {
1951        let etype = doc[pos];
1952        pos += 1;
1953        let name_end = doc[pos..].iter().position(|&b| b == 0)?;
1954        let name = std::str::from_utf8(&doc[pos..pos + name_end]).ok()?;
1955        pos += name_end + 1;
1956        if name == field {
1957            return match etype {
1958                0x02 => {
1959                    if pos + 4 > doc.len() {
1960                        return None;
1961                    }
1962                    let slen =
1963                        i32::from_le_bytes([doc[pos], doc[pos + 1], doc[pos + 2], doc[pos + 3]])
1964                            as usize;
1965                    if pos + 4 + slen <= doc.len() {
1966                        Some(String::from_utf8_lossy(&doc[pos + 4..pos + 4 + slen - 1]).to_string())
1967                    } else {
1968                        None
1969                    }
1970                }
1971                0x08 => {
1972                    if pos < doc.len() {
1973                        Some(if doc[pos] != 0 {
1974                            "true".into()
1975                        } else {
1976                            "false".into()
1977                        })
1978                    } else {
1979                        None
1980                    }
1981                }
1982                _ => None,
1983            };
1984        }
1985        pos = match etype {
1986            0x01 => {
1987                if pos + 8 <= doc.len() {
1988                    pos + 8
1989                } else {
1990                    return None;
1991                }
1992            }
1993            0x02 => {
1994                if pos + 4 > doc.len() {
1995                    return None;
1996                }
1997                let slen = i32::from_le_bytes([doc[pos], doc[pos + 1], doc[pos + 2], doc[pos + 3]])
1998                    as usize;
1999                if pos + 4 + slen <= doc.len() {
2000                    pos + 4 + slen
2001                } else {
2002                    return None;
2003                }
2004            }
2005            0x03 | 0x04 => {
2006                if pos + 4 > doc.len() {
2007                    return None;
2008                }
2009                let dlen = i32::from_le_bytes([doc[pos], doc[pos + 1], doc[pos + 2], doc[pos + 3]])
2010                    as usize;
2011                if pos + dlen <= doc.len() {
2012                    pos + dlen
2013                } else {
2014                    return None;
2015                }
2016            }
2017            0x08 => pos + 1,
2018            0x09 | 0x12 => {
2019                if pos + 8 <= doc.len() {
2020                    pos + 8
2021                } else {
2022                    return None;
2023                }
2024            }
2025            0x10 if pos + 4 <= doc.len() => pos + 4,
2026            _ => {
2027                return None;
2028            }
2029        };
2030    }
2031    None
2032}
2033
2034fn bson_find_int32(doc: &[u8], field: &str) -> Option<i32> {
2035    if doc.len() < 5 {
2036        return None;
2037    }
2038    let mut pos = 4;
2039    while pos + 2 < doc.len() {
2040        let etype = doc[pos];
2041        pos += 1;
2042        let name_end = doc[pos..].iter().position(|&b| b == 0)?;
2043        let name = std::str::from_utf8(&doc[pos..pos + name_end]).ok()?;
2044        pos += name_end + 1;
2045        if name == field && etype == 0x10 && pos + 4 <= doc.len() {
2046            return Some(i32::from_le_bytes([
2047                doc[pos],
2048                doc[pos + 1],
2049                doc[pos + 2],
2050                doc[pos + 3],
2051            ]));
2052        }
2053        pos = match etype {
2054            0x01 => pos + 8,
2055            0x02 => {
2056                if pos + 4 > doc.len() {
2057                    return None;
2058                }
2059                pos + 4
2060                    + i32::from_le_bytes([doc[pos], doc[pos + 1], doc[pos + 2], doc[pos + 3]])
2061                        as usize
2062            }
2063            0x03 | 0x04 => {
2064                if pos + 4 > doc.len() {
2065                    return None;
2066                }
2067                pos + i32::from_le_bytes([doc[pos], doc[pos + 1], doc[pos + 2], doc[pos + 3]])
2068                    as usize
2069            }
2070            0x08 => pos + 1,
2071            0x09 | 0x12 => pos + 8,
2072            0x10 => pos + 4,
2073            _ => {
2074                return None;
2075            }
2076        };
2077    }
2078    None
2079}
2080
2081fn mongo_body(response: &[u8]) -> Option<&[u8]> {
2082    if response.len() < 16 {
2083        return None;
2084    }
2085    let _msg_len = i32::from_le_bytes([response[0], response[1], response[2], response[3]]);
2086    let _req_id = i32::from_le_bytes([response[4], response[5], response[6], response[7]]);
2087    let _resp_to = i32::from_le_bytes([response[8], response[9], response[10], response[11]]);
2088    let op_code = i32::from_le_bytes([response[12], response[13], response[14], response[15]]);
2089    if op_code != 1 {
2090        return None;
2091    }
2092    if response.len() < 36 {
2093        return None;
2094    }
2095    let _flags = i32::from_le_bytes([response[16], response[17], response[18], response[19]]);
2096    let _cursor = i64::from_le_bytes([
2097        response[20],
2098        response[21],
2099        response[22],
2100        response[23],
2101        response[24],
2102        response[25],
2103        response[26],
2104        response[27],
2105    ]);
2106    let _start = i32::from_le_bytes([response[28], response[29], response[30], response[31]]);
2107    let _num = i32::from_le_bytes([response[32], response[33], response[34], response[35]]);
2108    if response.len() > 36 {
2109        Some(&response[36..])
2110    } else {
2111        None
2112    }
2113}
2114
2115#[module(
2116    name = "mongo_scanner",
2117    kind = "Scanner",
2118    description = "MongoDB service scanner  -  version detection and no-auth access check",
2119    author = "ICEBOX"
2120)]
2121pub struct MongoScanner {
2122    #[option(required = true, help = "Target IP or hostname")]
2123    pub host: String,
2124    #[option(help = "MongoDB port (default 27017)")]
2125    pub port: u16,
2126    #[option(help = "Timeout in milliseconds (default 5000)")]
2127    pub timeout_ms: u64,
2128}
2129
2130#[async_trait]
2131impl Module for MongoScanner {
2132    fn options_json(&self) -> serde_json::Value {
2133        serde_json::to_value(&MongoScannerOptions {
2134            host: self.host.clone(),
2135            port: self.port,
2136            timeout_ms: self.timeout_ms,
2137        })
2138        .unwrap_or(serde_json::Value::Null)
2139    }
2140
2141    fn set_option(&mut self, name: &str, value: &str) -> Result<(), ModuleError> {
2142        let mut o = MongoScannerOptions {
2143            host: self.host.clone(),
2144            port: self.port,
2145            timeout_ms: self.timeout_ms,
2146        };
2147        o.set(name, value)?;
2148        self.host = o.host;
2149        self.port = o.port;
2150        self.timeout_ms = o.timeout_ms;
2151        Ok(())
2152    }
2153
2154    fn validate(&self) -> Result<(), ModuleError> {
2155        MongoScannerOptions {
2156            host: self.host.clone(),
2157            port: self.port,
2158            timeout_ms: self.timeout_ms,
2159        }
2160        .validate()
2161    }
2162
2163    async fn run(&self) -> Result<ModuleResult, ModuleError> {
2164        let port = if self.port > 0 { self.port } else { 27017 };
2165        let timeout_ms = if self.timeout_ms > 0 {
2166            self.timeout_ms
2167        } else {
2168            5000
2169        };
2170        let timeout = std::time::Duration::from_millis(timeout_ms);
2171        let addr = crate::core::proxy::resolve_dial(&self.host, port);
2172
2173        let stream =
2174            match tokio::time::timeout(timeout, tokio::net::TcpStream::connect(&addr)).await {
2175                Ok(Ok(s)) => s,
2176                Ok(Err(e)) => return Err(ModuleError::Other(format!("connection failed: {e}"))),
2177                Err(_) => return Err(ModuleError::Other("connection timed out".into())),
2178            };
2179
2180        let query = bson_encode_is_master();
2181        let pkt = build_mongo_query("admin.$cmd", &query);
2182        if tokio::time::timeout(timeout, stream.writable())
2183            .await
2184            .is_err()
2185        {
2186            return Err(ModuleError::Other("write timeout".into()));
2187        }
2188        let _ = stream.try_write(&pkt);
2189
2190        let mut buf = vec![0u8; 65536];
2191        let n = match tokio::time::timeout(timeout, stream.readable()).await {
2192            Ok(Ok(_)) => stream.try_read(&mut buf).unwrap_or(0),
2193            _ => 0,
2194        };
2195
2196        if n < 36 {
2197            return Ok(ModuleResult {
2198                success: true,
2199                finding: Some(format!("MongoDB on tcp/{port}: no valid response")),
2200                data: serde_json::json!({"host": self.host, "port": port}),
2201                ..Default::default()
2202            });
2203        }
2204
2205        let body = match mongo_body(&buf[..n]) {
2206            Some(b) => b,
2207            None => {
2208                return Ok(ModuleResult {
2209                    success: true,
2210                    finding: Some(format!("MongoDB on tcp/{port}: unparseable response")),
2211                    data: serde_json::json!({"host": self.host, "port": port, "hex": hex_encode(&buf[..n.min(128)])}),
2212                    ..Default::default()
2213                })
2214            }
2215        };
2216
2217        let is_master = bson_find_string(body, "ismaster").unwrap_or_default();
2218        let version = bson_find_string(body, "version").unwrap_or_default();
2219        let ok = bson_find_int32(body, "ok").unwrap_or(0);
2220        let max_bson = bson_find_int32(body, "maxBsonObjectSize").unwrap_or(0);
2221        let wire_ver = bson_find_int32(body, "minWireVersion")
2222            .map(|v| format!("{v}"))
2223            .or_else(|| bson_find_int32(body, "maxWireVersion").map(|v| format!("{v}")))
2224            .unwrap_or_default();
2225
2226        let mut findings = vec![];
2227        let mut evidence = vec![];
2228
2229        if ok == 1 {
2230            findings.push("MongoDB accessible (no auth triggered)".into());
2231            evidence.push("mongo/no_auth".into());
2232        }
2233        if !version.is_empty() {
2234            findings.push(format!("version: {version}"));
2235            evidence.push(format!("mongo/version:{version}"));
2236        }
2237        if !is_master.is_empty() {
2238            findings.push(format!("ismaster: {is_master}"));
2239        }
2240        if max_bson > 0 {
2241            evidence.push(format!("mongo/maxBsonSize:{max_bson}"));
2242        }
2243
2244        Ok(ModuleResult {
2245            success: true,
2246            finding: Some(format!("MongoDB on tcp/{port}: {}", findings.join("; "))),
2247            evidence,
2248            data: serde_json::json!({
2249                "host": self.host, "port": port,
2250                "version": version, "is_master": is_master,
2251                "ok": ok, "max_bson_size": max_bson,
2252                "wire_version": wire_ver,
2253            }),
2254            ..Default::default()
2255        })
2256    }
2257}
2258
2259#[module(
2260    name = "es_scanner",
2261    kind = "Scanner",
2262    description = "Elasticsearch service scanner  -  cluster info, version, and open access detection",
2263    author = "ICEBOX"
2264)]
2265pub struct EsScanner {
2266    #[option(required = true, help = "Target IP or hostname")]
2267    pub host: String,
2268    #[option(help = "Elasticsearch port (default 9200)")]
2269    pub port: u16,
2270    #[option(help = "Timeout in milliseconds (default 5000)")]
2271    pub timeout_ms: u64,
2272    #[option(help = "Check cluster health in addition to root endpoint (default true)")]
2273    pub check_health: bool,
2274}
2275
2276async fn http_get_line(stream: &tokio::net::TcpStream, timeout: std::time::Duration) -> Vec<u8> {
2277    let mut all_data = Vec::new();
2278    let mut buf = vec![0u8; 8192];
2279    loop {
2280        let n = match tokio::time::timeout(timeout, stream.readable()).await {
2281            Ok(Ok(_)) => match stream.try_read(&mut buf) {
2282                Ok(n) => n,
2283                Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => 0,
2284                Err(_) => break,
2285            },
2286            _ => break,
2287        };
2288        if n == 0 {
2289            break;
2290        }
2291        all_data.extend_from_slice(&buf[..n]);
2292        if all_data.len() > 65536 {
2293            break;
2294        }
2295    }
2296    all_data
2297}
2298
2299fn http_extract_body(raw: &[u8]) -> Option<&[u8]> {
2300    let text = String::from_utf8_lossy(raw);
2301    if let Some(pos) = text.find("\r\n\r\n") {
2302        let body_start = pos + 4;
2303        Some(&raw[body_start..])
2304    } else if let Some(pos) = text.find("\n\n") {
2305        let body_start = pos + 2;
2306        Some(&raw[body_start..])
2307    } else {
2308        None
2309    }
2310}
2311
2312async fn es_http_request(
2313    host: &str,
2314    port: u16,
2315    path: &str,
2316    timeout: std::time::Duration,
2317) -> Result<Vec<u8>, ModuleError> {
2318    let addr = crate::core::proxy::resolve_dial(host, port);
2319    let stream = match tokio::time::timeout(timeout, tokio::net::TcpStream::connect(&addr)).await {
2320        Ok(Ok(s)) => s,
2321        Ok(Err(e)) => return Err(ModuleError::Other(format!("connect: {e}"))),
2322        Err(_) => return Err(ModuleError::Other("connect timeout".into())),
2323    };
2324    let request = format!(
2325        "GET {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\nUser-Agent: ICEBOX/1.0\r\nAccept: application/json\r\n\r\n"
2326    );
2327    if tokio::time::timeout(timeout, stream.writable())
2328        .await
2329        .is_err()
2330    {
2331        return Err(ModuleError::Other("write timeout".into()));
2332    }
2333    let _ = stream.try_write(request.as_bytes());
2334    Ok(http_get_line(&stream, timeout).await)
2335}
2336
2337fn es_extract_json_string(body: &[u8], field: &str) -> Option<String> {
2338    let text = String::from_utf8_lossy(body);
2339    let search = format!("\"{field}\":\"");
2340    if let Some(start) = text.find(&search) {
2341        let val_start = start + search.len();
2342        if let Some(end) = text[val_start..].find('\"') {
2343            return Some(text[val_start..val_start + end].to_string());
2344        }
2345    }
2346    None
2347}
2348
2349#[async_trait]
2350impl Module for EsScanner {
2351    fn options_json(&self) -> serde_json::Value {
2352        serde_json::to_value(&EsScannerOptions {
2353            host: self.host.clone(),
2354            port: self.port,
2355            timeout_ms: self.timeout_ms,
2356            check_health: self.check_health,
2357        })
2358        .unwrap_or(serde_json::Value::Null)
2359    }
2360
2361    fn set_option(&mut self, name: &str, value: &str) -> Result<(), ModuleError> {
2362        let mut o = EsScannerOptions {
2363            host: self.host.clone(),
2364            port: self.port,
2365            timeout_ms: self.timeout_ms,
2366            check_health: self.check_health,
2367        };
2368        o.set(name, value)?;
2369        self.host = o.host;
2370        self.port = o.port;
2371        self.timeout_ms = o.timeout_ms;
2372        self.check_health = o.check_health;
2373        Ok(())
2374    }
2375
2376    fn validate(&self) -> Result<(), ModuleError> {
2377        EsScannerOptions {
2378            host: self.host.clone(),
2379            port: self.port,
2380            timeout_ms: self.timeout_ms,
2381            check_health: self.check_health,
2382        }
2383        .validate()
2384    }
2385
2386    async fn run(&self) -> Result<ModuleResult, ModuleError> {
2387        let port = if self.port > 0 { self.port } else { 9200 };
2388        let timeout_ms = if self.timeout_ms > 0 {
2389            self.timeout_ms
2390        } else {
2391            5000
2392        };
2393        let timeout = std::time::Duration::from_millis(timeout_ms);
2394
2395        let raw = es_http_request(&self.host, port, "/", timeout).await?;
2396        let body = http_extract_body(&raw).unwrap_or(&raw);
2397        let text = String::from_utf8_lossy(body);
2398
2399        let mut findings = Vec::new();
2400        let mut evidence = Vec::new();
2401
2402        let cluster_name = es_extract_json_string(body, "cluster_name");
2403        if let Some(ref cn) = cluster_name {
2404            findings.push(format!("cluster: {cn}"));
2405            evidence.push(format!("es/cluster:{cn}"));
2406        }
2407
2408        let version = es_extract_json_string(body, "number");
2409        if let Some(ref v) = version {
2410            findings.push(format!("version: {v}"));
2411            evidence.push(format!("es/version:{v}"));
2412        }
2413
2414        let tagline = es_extract_json_string(body, "tagline");
2415        if let Some(ref t) = tagline {
2416            evidence.push(format!("es/tagline:{t}"));
2417        }
2418
2419        let mut health_status = String::new();
2420        if self.check_health {
2421            if let Ok(health_raw) =
2422                es_http_request(&self.host, port, "/_cluster/health", timeout).await
2423            {
2424                if let Some(health_body) = http_extract_body(&health_raw) {
2425                    if let Some(status) = es_extract_json_string(health_body, "status") {
2426                        health_status = status.clone();
2427                        findings.push(format!("health: {status}"));
2428                        evidence.push(format!("es/health:{status}"));
2429                    }
2430                }
2431            }
2432        }
2433
2434        let finding = if findings.is_empty() {
2435            format!("ES on tcp/{port}: service detected (unrecognized response)")
2436        } else {
2437            format!("ES on tcp/{port}: {}", findings.join("; "))
2438        };
2439
2440        Ok(ModuleResult {
2441            success: true,
2442            finding: Some(finding),
2443            evidence,
2444            data: serde_json::json!({
2445                "host": self.host, "port": port,
2446                "cluster_name": cluster_name,
2447                "version": version,
2448                "health_status": health_status,
2449                "raw": text.chars().take(2000).collect::<String>(),
2450            }),
2451            ..Default::default()
2452        })
2453    }
2454}
2455
2456fn pg_startup_message(user: &str) -> Vec<u8> {
2457    let user_param = format!("\x00user\x00{user}\x00");
2458    let body = format!("\x00\x03\x00\x00{user_param}\x00");
2459    let len = (4 + body.len()) as u32;
2460    let mut pkt = Vec::new();
2461    pkt.extend_from_slice(&len.to_le_bytes());
2462    pkt.extend_from_slice(body.as_bytes());
2463    pkt
2464}
2465
2466fn pg_md5_auth(user: &str, password: &str, salt: &[u8]) -> String {
2467    let inner = md5::compute(format!("{password}{user}").as_bytes());
2468    let mut combined = format!("{inner:x}").into_bytes();
2469    combined.extend_from_slice(salt);
2470    let outer = md5::compute(&combined);
2471    format!("md5{outer:x}")
2472}
2473
2474const PG_DEFAULT_CREDS: &[(&str, &str)] = &[
2475    ("postgres", "postgres"),
2476    ("postgres", "admin"),
2477    ("postgres", "password"),
2478    ("postgres", ""),
2479    ("admin", "admin"),
2480    ("admin", "password"),
2481    ("root", "root"),
2482    ("root", "admin"),
2483    ("root", "password"),
2484];
2485
2486#[module(
2487    name = "postgres_scanner",
2488    kind = "Scanner",
2489    description = "PostgreSQL service scanner  -  version detection and default credential check",
2490    author = "ICEBOX"
2491)]
2492pub struct PostgresScanner {
2493    #[option(required = true, help = "Target IP or hostname")]
2494    pub host: String,
2495    #[option(help = "PostgreSQL port (default 5432)")]
2496    pub port: u16,
2497    #[option(help = "Timeout in milliseconds (default 5000)")]
2498    pub timeout_ms: u64,
2499    #[option(help = "Check default credentials (default true)")]
2500    pub check_defaults: bool,
2501}
2502
2503fn pg_auth_type_name(t: i32) -> &'static str {
2504    match t {
2505        0 => "OK",
2506        2 => "KerberosV5",
2507        3 => "CleartextPassword",
2508        5 => "MD5Password",
2509        6 => "SCMCredential",
2510        7 => "GSS",
2511        9 => "SSPI",
2512        10 => "SASL",
2513        11 => "SASLContinue",
2514        12 => "SASLFinal",
2515        _ => "Unknown",
2516    }
2517}
2518
2519#[async_trait]
2520impl Module for PostgresScanner {
2521    fn options_json(&self) -> serde_json::Value {
2522        serde_json::to_value(&PostgresScannerOptions {
2523            host: self.host.clone(),
2524            port: self.port,
2525            timeout_ms: self.timeout_ms,
2526            check_defaults: self.check_defaults,
2527        })
2528        .unwrap_or(serde_json::Value::Null)
2529    }
2530
2531    fn set_option(&mut self, name: &str, value: &str) -> Result<(), ModuleError> {
2532        let mut o = PostgresScannerOptions {
2533            host: self.host.clone(),
2534            port: self.port,
2535            timeout_ms: self.timeout_ms,
2536            check_defaults: self.check_defaults,
2537        };
2538        o.set(name, value)?;
2539        self.host = o.host;
2540        self.port = o.port;
2541        self.timeout_ms = o.timeout_ms;
2542        self.check_defaults = o.check_defaults;
2543        Ok(())
2544    }
2545
2546    fn validate(&self) -> Result<(), ModuleError> {
2547        PostgresScannerOptions {
2548            host: self.host.clone(),
2549            port: self.port,
2550            timeout_ms: self.timeout_ms,
2551            check_defaults: self.check_defaults,
2552        }
2553        .validate()
2554    }
2555
2556    async fn run(&self) -> Result<ModuleResult, ModuleError> {
2557        let port = if self.port > 0 { self.port } else { 5432 };
2558        let timeout_ms = if self.timeout_ms > 0 {
2559            self.timeout_ms
2560        } else {
2561            5000
2562        };
2563        let timeout = std::time::Duration::from_millis(timeout_ms);
2564        let addr = crate::core::proxy::resolve_dial(&self.host, port);
2565
2566        async fn pg_read_response(
2567            stream: &tokio::net::TcpStream,
2568            timeout: std::time::Duration,
2569        ) -> Result<Vec<u8>, ModuleError> {
2570            let mut buf = vec![0u8; 8192];
2571            let n = match tokio::time::timeout(timeout, stream.readable()).await {
2572                Ok(Ok(_)) => stream.try_read(&mut buf).unwrap_or(0),
2573                _ => return Err(ModuleError::Other("read timeout".into())),
2574            };
2575            Ok(buf[..n].to_vec())
2576        }
2577
2578        let creds_to_try: Vec<(&str, &str)> = if self.check_defaults {
2579            PG_DEFAULT_CREDS.to_vec()
2580        } else {
2581            Vec::new()
2582        };
2583
2584        let mut found_creds: Vec<(String, String)> = Vec::new();
2585        let mut auth_type = String::new();
2586        let mut server_version = String::new();
2587
2588        for (user, pass) in &creds_to_try {
2589            let stream =
2590                match tokio::time::timeout(timeout, tokio::net::TcpStream::connect(&addr)).await {
2591                    Ok(Ok(s)) => s,
2592                    _ => continue,
2593                };
2594            let startup = pg_startup_message(user);
2595            if tokio::time::timeout(timeout, stream.writable())
2596                .await
2597                .is_err()
2598            {
2599                continue;
2600            }
2601            let _ = stream.try_write(&startup);
2602
2603            let response = match pg_read_response(&stream, timeout).await {
2604                Ok(r) => r,
2605                Err(_) => continue,
2606            };
2607
2608            if response.is_empty() || response[0] == b'E' {
2609                continue;
2610            }
2611
2612            if response[0] == b'R' {
2613                if response.len() < 9 {
2614                    continue;
2615                }
2616                let auth_t =
2617                    i32::from_be_bytes([response[5], response[6], response[7], response[8]]);
2618                let auth_name = pg_auth_type_name(auth_t);
2619                if server_version.is_empty() {
2620                    auth_type = auth_name.to_string();
2621                }
2622
2623                if auth_t == 0 {
2624                    found_creds.push((user.to_string(), pass.to_string()));
2625                    tokio::time::timeout(timeout, stream.readable()).await.ok();
2626                    let mut buf2 = vec![0u8; 4096];
2627                    if let Ok(Ok(_)) = tokio::time::timeout(timeout, stream.readable()).await {
2628                        let n = stream.try_read(&mut buf2).unwrap_or(0);
2629                        if n > 0 {
2630                            let text = String::from_utf8_lossy(&buf2[..n.min(4000)]);
2631                            for line in text.split('\x00') {
2632                                if line.starts_with("server_version") {
2633                                    server_version = line
2634                                        .trim_start_matches("server_version")
2635                                        .trim_start_matches('\x00')
2636                                        .to_string();
2637                                }
2638                            }
2639                        }
2640                    }
2641                } else if auth_t == 3 {
2642                    let pass_pkt = format!("p{}\x00", pass);
2643                    let len = (4 + pass_pkt.len()) as u32;
2644                    let mut auth_msg = Vec::new();
2645                    auth_msg.extend_from_slice(&len.to_le_bytes());
2646                    auth_msg.extend_from_slice(pass_pkt.as_bytes());
2647                    if tokio::time::timeout(timeout, stream.writable())
2648                        .await
2649                        .is_ok()
2650                    {
2651                        let _ = stream.try_write(&auth_msg);
2652                    }
2653                    let resp2 = pg_read_response(&stream, timeout).await.unwrap_or_default();
2654                    if resp2.len() > 5 && resp2[0] == b'R' {
2655                        let auth_t2 = i32::from_be_bytes([resp2[5], resp2[6], resp2[7], resp2[8]]);
2656                        if auth_t2 == 0 {
2657                            found_creds.push((user.to_string(), pass.to_string()));
2658                        }
2659                    }
2660                } else if auth_t == 5 {
2661                    let salt = &response[9..13];
2662                    let md5_hash = pg_md5_auth(user, pass, salt);
2663                    let pass_bytes = md5_hash.as_bytes();
2664                    let len = (4 + pass_bytes.len() + 1) as u32;
2665                    let mut auth_msg = Vec::new();
2666                    auth_msg.extend_from_slice(&len.to_le_bytes());
2667                    auth_msg.extend_from_slice(pass_bytes);
2668                    auth_msg.push(0x00);
2669                    if tokio::time::timeout(timeout, stream.writable())
2670                        .await
2671                        .is_ok()
2672                    {
2673                        let _ = stream.try_write(&auth_msg);
2674                    }
2675                    let resp2 = pg_read_response(&stream, timeout).await.unwrap_or_default();
2676                    if resp2.len() > 5 && resp2[0] == b'R' {
2677                        let auth_t2 = i32::from_be_bytes([resp2[5], resp2[6], resp2[7], resp2[8]]);
2678                        if auth_t2 == 0 {
2679                            found_creds.push((user.to_string(), pass.to_string()));
2680                        }
2681                    }
2682                }
2683            } else if response[0] == b'N' || response[0] == b'S' {
2684            }
2685        }
2686
2687        let evidence: Vec<String> = found_creds
2688            .iter()
2689            .map(|(u, p)| format!("postgres/cred:{u}:{p}"))
2690            .collect();
2691
2692        let finding = if !found_creds.is_empty() {
2693            let labels: Vec<String> = found_creds
2694                .iter()
2695                .map(|(u, p)| format!("{u}:{p}"))
2696                .collect();
2697            format!(
2698                "PostgreSQL on tcp/{port}: FOUND {} credential(s): {}",
2699                found_creds.len(),
2700                labels.join(", ")
2701            )
2702        } else {
2703            format!("PostgreSQL on tcp/{port}: detected (auth={auth_type}, version={server_version}), no valid creds found", )
2704        };
2705
2706        Ok(ModuleResult {
2707            success: !found_creds.is_empty(),
2708            finding: Some(finding),
2709            evidence,
2710            data: serde_json::json!({
2711                "host": self.host, "port": port,
2712                "version": server_version,
2713                "auth_type": auth_type,
2714                "found_credentials": found_creds,
2715                "tried_count": creds_to_try.len(),
2716            }),
2717            ..Default::default()
2718        })
2719    }
2720}
2721
2722#[module(
2723    name = "smtp_scanner",
2724    kind = "Scanner",
2725    description = "SMTP service scanner  -  banner grabbing, open relay check, and user enumeration",
2726    author = "ICEBOX"
2727)]
2728pub struct SmtpScanner {
2729    #[option(required = true, help = "Target IP or hostname")]
2730    pub host: String,
2731    #[option(help = "SMTP port (default 25)")]
2732    pub port: u16,
2733    #[option(help = "Timeout in milliseconds (default 5000)")]
2734    pub timeout_ms: u64,
2735    #[option(help = "Check open relay (default true)")]
2736    pub check_relay: bool,
2737    #[option(help = "Check VRFY user enumeration (default true)")]
2738    pub check_vrfy: bool,
2739}
2740
2741async fn smtp_read(stream: &tokio::net::TcpStream, timeout: std::time::Duration) -> String {
2742    let mut all = Vec::new();
2743    let mut buf = vec![0u8; 4096];
2744    for _ in 0..3 {
2745        let n = match tokio::time::timeout(timeout, stream.readable()).await {
2746            Ok(Ok(_)) => match stream.try_read(&mut buf) {
2747                Ok(n) => n,
2748                Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => 0,
2749                Err(_) => break,
2750            },
2751            _ => break,
2752        };
2753        if n == 0 {
2754            break;
2755        }
2756        all.extend_from_slice(&buf[..n]);
2757        if all.len() > 8192 {
2758            break;
2759        }
2760    }
2761    String::from_utf8_lossy(&all).to_string()
2762}
2763
2764async fn smtp_write(
2765    stream: &tokio::net::TcpStream,
2766    cmd: &str,
2767    timeout: std::time::Duration,
2768) -> Result<(), ModuleError> {
2769    match tokio::time::timeout(timeout, stream.writable()).await {
2770        Ok(Ok(_)) => stream
2771            .try_write(cmd.as_bytes())
2772            .map_err(|e| ModuleError::Other(format!("write: {e}")))
2773            .map(|_| ()),
2774        _ => Err(ModuleError::Other("write timeout".into())),
2775    }
2776}
2777
2778#[async_trait]
2779impl Module for SmtpScanner {
2780    fn options_json(&self) -> serde_json::Value {
2781        serde_json::to_value(&SmtpScannerOptions {
2782            host: self.host.clone(),
2783            port: self.port,
2784            timeout_ms: self.timeout_ms,
2785            check_relay: self.check_relay,
2786            check_vrfy: self.check_vrfy,
2787        })
2788        .unwrap_or(serde_json::Value::Null)
2789    }
2790
2791    fn set_option(&mut self, name: &str, value: &str) -> Result<(), ModuleError> {
2792        let mut o = SmtpScannerOptions {
2793            host: self.host.clone(),
2794            port: self.port,
2795            timeout_ms: self.timeout_ms,
2796            check_relay: self.check_relay,
2797            check_vrfy: self.check_vrfy,
2798        };
2799        o.set(name, value)?;
2800        self.host = o.host;
2801        self.port = o.port;
2802        self.timeout_ms = o.timeout_ms;
2803        self.check_relay = o.check_relay;
2804        self.check_vrfy = o.check_vrfy;
2805        Ok(())
2806    }
2807
2808    fn validate(&self) -> Result<(), ModuleError> {
2809        SmtpScannerOptions {
2810            host: self.host.clone(),
2811            port: self.port,
2812            timeout_ms: self.timeout_ms,
2813            check_relay: self.check_relay,
2814            check_vrfy: self.check_vrfy,
2815        }
2816        .validate()
2817    }
2818
2819    async fn run(&self) -> Result<ModuleResult, ModuleError> {
2820        let port = if self.port > 0 { self.port } else { 25 };
2821        let timeout_ms = if self.timeout_ms > 0 {
2822            self.timeout_ms
2823        } else {
2824            5000
2825        };
2826        let timeout = std::time::Duration::from_millis(timeout_ms);
2827        let addr = crate::core::proxy::resolve_dial(&self.host, port);
2828
2829        let stream =
2830            match tokio::time::timeout(timeout, tokio::net::TcpStream::connect(&addr)).await {
2831                Ok(Ok(s)) => s,
2832                Ok(Err(e)) => return Err(ModuleError::Other(format!("connection failed: {e}"))),
2833                Err(_) => return Err(ModuleError::Other("connection timed out".into())),
2834            };
2835
2836        let banner = smtp_read(&stream, timeout).await;
2837        let banner_line = banner.lines().next().unwrap_or("").trim().to_string();
2838
2839        let mut findings = Vec::new();
2840        let mut evidence = Vec::new();
2841        findings.push(format!("banner: {banner_line}"));
2842        evidence.push(format!("smtp/banner:{banner_line}"));
2843
2844        smtp_write(&stream, "EHLO icebox\r\n", timeout).await.ok();
2845        let ehlo_resp = smtp_read(&stream, timeout).await;
2846        let ehlo_lines: Vec<&str> = ehlo_resp.lines().collect();
2847        let ehlo_ok = ehlo_lines
2848            .first()
2849            .map(|l| l.starts_with("250"))
2850            .unwrap_or(false);
2851
2852        if ehlo_ok {
2853            let caps: Vec<String> = ehlo_lines
2854                .iter()
2855                .filter_map(|l| {
2856                    let l = l.strip_prefix("250-").or_else(|| l.strip_prefix("250 "))?;
2857                    Some(l.trim().to_string())
2858                })
2859                .collect();
2860            if !caps.is_empty() {
2861                findings.push(format!("capabilities: {}", caps.join(", ")));
2862            }
2863
2864            if caps.iter().any(|c| c.to_uppercase().contains("AUTH")) {
2865                evidence.push("smtp/auth_available".into());
2866            }
2867
2868            if self.check_relay {
2869                smtp_write(&stream, "MAIL FROM:<test@icebox.local>\r\n", timeout)
2870                    .await
2871                    .ok();
2872                let mfrom = smtp_read(&stream, timeout).await;
2873                if mfrom.starts_with("250") {
2874                    smtp_write(&stream, "RCPT TO:<test@example.com>\r\n", timeout)
2875                        .await
2876                        .ok();
2877                    let rcpt = smtp_read(&stream, timeout).await;
2878                    if rcpt.starts_with("250") {
2879                        findings.push("open relay: YES (RCPT TO succeeded)".into());
2880                        evidence.push("smtp/open_relay".into());
2881                    } else {
2882                        findings.push("open relay: NO (relay denied)".into());
2883                    }
2884                }
2885            }
2886
2887            if self.check_vrfy {
2888                smtp_write(&stream, "VRFY root\r\n", timeout).await.ok();
2889                let vrfy = smtp_read(&stream, timeout).await;
2890                if vrfy.starts_with("250") || vrfy.starts_with("252") {
2891                    findings.push("user enumeration via VRFY: possible".into());
2892                    evidence.push("smtp/vrfy_enabled".into());
2893                }
2894
2895                smtp_write(&stream, "EXPN postmaster\r\n", timeout)
2896                    .await
2897                    .ok();
2898                let expn = smtp_read(&stream, timeout).await;
2899                if expn.starts_with("250") {
2900                    findings.push("user enumeration via EXPN: possible".into());
2901                    evidence.push("smtp/expn_enabled".into());
2902                }
2903            }
2904        } else {
2905            findings.push("EHLO not recognized".into());
2906        }
2907
2908        let _ = smtp_write(&stream, "QUIT\r\n", timeout).await;
2909
2910        Ok(ModuleResult {
2911            success: true,
2912            finding: Some(format!("SMTP on tcp/{port}: {}", findings.join("; "))),
2913            evidence,
2914            data: serde_json::json!({
2915                "host": self.host, "port": port,
2916                "banner": banner_line,
2917                "findings": findings,
2918            }),
2919            ..Default::default()
2920        })
2921    }
2922}