1use std::{borrow::Cow, collections::HashMap, net::IpAddr};
4
5use jiff::Timestamp;
6use roxmltree::{Node, StringStorage};
7
8use crate::{
9 MacAddress, StringStorageExt, assert_empty_text,
10 error::FormatError,
11 ping::PingOutcome,
12 report::item::{Item, PluginType, Protocol},
13};
14
15pub mod item;
16
17#[derive(Debug)]
23pub struct Report<'input> {
24 pub name: Cow<'input, str>,
26 pub hosts: Vec<Host<'input>>,
28}
29
30impl<'input> Report<'input> {
31 pub(crate) fn from_xml_node(node: Node<'_, 'input>) -> Result<Self, FormatError> {
32 let name = node
33 .attributes()
34 .find(|a| a.name() == "name")
35 .ok_or(FormatError::MissingAttribute("name"))?
36 .value_storage()
37 .to_cow();
38
39 let mut hosts = vec![];
40
41 for child in node.children() {
42 match child.tag_name().name() {
43 "ReportHost" => {
44 hosts.push(Host::from_xml_node(child)?);
45 }
46 _ => assert_empty_text(child)?,
47 }
48 }
49
50 Ok(Self { name, hosts })
51 }
52}
53
54#[derive(Debug)]
57pub struct Host<'input> {
58 pub name: Cow<'input, str>,
60 pub properties: HostProperties<'input>,
62 pub items: Vec<Item<'input>>,
65 pub ping_outcome: Option<PingOutcome>,
68 pub scanner_ip: Option<IpAddr>,
71 pub open_ports: Vec<(u16, Protocol)>,
73}
74
75impl<'input> Host<'input> {
76 fn from_xml_node(node: Node<'_, 'input>) -> Result<Self, FormatError> {
77 const PING_THE_REMOTE_HOST_ID: u32 = 10180;
78 const NESSUS_SCAN_INFORMATION_ID: u32 = 19506;
79
80 let name = node
81 .attributes()
82 .find(|a| a.name() == "name")
83 .ok_or(FormatError::MissingAttribute("name"))?
84 .value_storage()
85 .to_cow();
86
87 let mut host_properties = None;
88 let mut items = vec![];
89
90 let mut ping_outcome = None;
91 let mut scanner_ip = None;
92
93 let mut open_ports = vec![];
94
95 for child in node.children() {
96 match child.tag_name().name() {
97 "HostProperties" => {
98 if host_properties.is_some() {
99 return Err(FormatError::RepeatedTag("HostProperties"));
100 }
101 host_properties = Some(HostProperties::from_xml_node(child)?);
102 }
103 "ReportItem" => {
104 let item = Item::from_xml_node(child)?;
105 match item.plugin_id {
106 PING_THE_REMOTE_HOST_ID => {
107 let plugin_output = item
108 .plugin_output
109 .as_ref()
110 .ok_or(FormatError::MissingPluginOutput)?;
111 if ping_outcome.is_some() {
112 return Err(FormatError::RepeatedTag("Ping the remote host"));
113 }
114 ping_outcome = Some(PingOutcome::from_plugin_output(plugin_output)?);
115 }
116 NESSUS_SCAN_INFORMATION_ID => {
117 let plugin_output = item
118 .plugin_output
119 .as_ref()
120 .ok_or(FormatError::MissingPluginOutput)?;
121 if scanner_ip.is_some() {
122 return Err(FormatError::RepeatedTag("Nessus Scan Information"));
123 }
124 scanner_ip = Some(parse_scanner_ip(plugin_output)?);
125 }
126 10736 | 11111 | 14272 | 14274 => {}
128 _ => {
129 if item.port != 0 && item.plugin_type != PluginType::Local {
130 open_ports.push((item.port, item.protocol));
131 }
132 }
133 }
134
135 items.push(item);
136 }
137 _ => assert_empty_text(child)?,
138 }
139 }
140
141 open_ports.sort_unstable();
142 open_ports.dedup();
143
144 Ok(Self {
145 name,
146 properties: host_properties.ok_or(FormatError::MissingTag("HostProperties"))?,
147 items,
148 ping_outcome,
149 scanner_ip,
150 open_ports,
151 })
152 }
153}
154
155fn parse_scanner_ip(plugin_output: &str) -> Result<IpAddr, FormatError> {
156 let (_, rest) = plugin_output
157 .split_once("Scanner IP : ")
158 .ok_or(FormatError::MissingAttribute("Scanner IP"))?;
159 let (ip, _) = rest
162 .split_once('\n')
163 .ok_or(FormatError::MissingAttribute("Scanner IP"))?;
164 Ok(ip.parse()?)
165}
166
167#[derive(Debug)]
170pub struct HostProperties<'input> {
171 pub host_ip: IpAddr,
173 pub host_start: &'input str,
175 pub host_start_timestamp: Timestamp,
177 pub host_end: Option<&'input str>,
179 pub host_end_timestamp: Option<Timestamp>,
181
182 pub apache_sites: Option<Cow<'input, str>>,
184 pub bios_uuid: Option<&'input str>,
186 pub credentialed_scan: Option<bool>,
188 pub ddi_dir_scanner_global_duration: Option<u32>,
190 pub ddi_dir_scanner_global_init: Option<Timestamp>,
192 pub dead_host: Option<bool>,
194 pub host_ad_config: Option<Cow<'input, str>>,
196 pub host_fqdn: Option<Cow<'input, str>>,
198 pub host_fqdns: Option<Cow<'input, str>>,
200 pub host_rdns: Option<Cow<'input, str>>,
202 pub hostname: Option<&'input str>,
204 pub ignore_printer: Option<bool>,
206 pub iis_sites: Option<Cow<'input, str>>,
208 pub last_authenticated_results: Option<Timestamp>,
210 pub last_unauthenticated_results: Option<Timestamp>,
212 pub local_checks_proto: Option<&'input str>,
214 pub netbios_name: Option<Cow<'input, str>>,
216 pub operating_system: Option<&'input str>,
218 pub operating_system_conf: Option<i32>,
220 pub operating_system_method: Option<&'input str>,
222 pub operating_system_unsupported: Option<bool>,
224 pub os: Option<&'input str>,
226 pub patch_summary_total_cves: Option<u32>,
228 pub policy_used: Option<Cow<'input, str>>,
230 pub rexec_login_used: Option<&'input str>,
232 pub rlogin_login_used: Option<&'input str>,
234 pub rsh_login_used: Option<&'input str>,
236 pub smb_login_used: Option<&'input str>,
238 pub ssh_login_used: Option<&'input str>,
240 pub telnet_login_used: Option<&'input str>,
242 pub sinfp_ml_prediction: Option<Cow<'input, str>>,
244 pub sinfp_signature: Option<&'input str>,
250 pub ssh_fingerprint: Option<Cow<'input, str>>,
252 pub system_type: Option<&'input str>,
257 pub wmi_domain: Option<&'input str>,
259 pub mac_address: Vec<MacAddress>,
261 pub cpe: Vec<Cow<'input, str>>,
268 pub traceroute: Vec<Option<IpAddr>>,
271 pub netstat_listen: Vec<(&'input str, u16, &'input str)>,
273 pub netstat_established: Vec<(&'input str, u16, &'input str)>,
275 pub patch_summary_txt: Vec<(&'input str, Cow<'input, str>)>,
277 pub enumerated_ports: Vec<(u16, Protocol, &'input str)>,
279 pub patch_summary_cve_num: Vec<(&'input str, u32)>,
281 pub patch_summary_cves: Vec<(&'input str, Vec<&'input str>)>,
283 pub ddi_dir_scanner_port_init: Vec<(u16, Timestamp)>,
285 pub ddi_dir_scanner_port_pass_start: Vec<(u16, Timestamp)>,
287 pub ddi_dir_scanner_port_duration: Vec<(u16, u32)>,
289 pub ddi_dir_scanner_port_pass_timeout: Vec<(u16, Timestamp)>,
291 pub others: HashMap<&'input str, Vec<Cow<'input, str>>>,
296}
297
298impl<'input> HostProperties<'input> {
299 #[expect(clippy::too_many_lines)]
300 fn from_xml_node(node: Node<'_, 'input>) -> Result<Self, FormatError> {
301 let mut host_ip = None;
302 let mut host_start = None;
303 let mut host_start_timestamp = None;
304 let mut host_end = None;
305 let mut host_end_timestamp = None;
306 let mut apache_sites = None;
307 let mut bios_uuid = None;
308 let mut credentialed_scan = None;
309 let mut ddi_dir_scanner_global_duration = None;
310 let mut ddi_dir_scanner_global_init = None;
311 let mut dead_host = None;
312 let mut host_ad_config = None;
313 let mut host_fqdn = None;
314 let mut host_fqdns = None;
315 let mut host_rdns = None;
316 let mut hostname = None;
317 let mut ignore_printer = None;
318 let mut iis_sites = None;
319 let mut last_authenticated_results = None;
320 let mut last_unauthenticated_results = None;
321 let mut local_checks_proto = None;
322 let mut netbios_name = None;
323 let mut operating_system = None;
324 let mut operating_system_conf = None;
325 let mut operating_system_method = None;
326 let mut operating_system_unsupported = None;
327 let mut os = None;
328 let mut patch_summary_total_cves = None;
329 let mut policy_used = None;
330 let mut rexec_login_used = None;
331 let mut rlogin_login_used = None;
332 let mut rsh_login_used = None;
333 let mut smb_login_used = None;
334 let mut ssh_login_used = None;
335 let mut telnet_login_used = None;
336 let mut sinfp_ml_prediction = None;
337 let mut sinfp_signature = None;
338 let mut ssh_fingerprint = None;
339 let mut system_type = None;
340 let mut wmi_domain = None;
341
342 let mut cpe = vec![];
343 let mut traceroute = vec![];
344 let mut mac_address = vec![];
345 let mut netstat_listen = vec![];
346 let mut netstat_established = vec![];
347 let mut patch_summary_txt = vec![];
348 let mut enumerated_ports = vec![];
349 let mut patch_summary_cve_num = vec![];
350 let mut patch_summary_cves = vec![];
351 let mut ddi_dir_scanner_port_init = vec![];
352 let mut ddi_dir_scanner_port_pass_start = vec![];
353 let mut ddi_dir_scanner_port_duration = vec![];
354 let mut ddi_dir_scanner_port_pass_timeout = vec![];
355
356 let mut others: HashMap<_, Vec<_>> = HashMap::new();
357
358 for child in node.children() {
359 if child.tag_name().name() != "tag" {
360 assert_empty_text(child)?;
361 continue;
362 }
363
364 let (name, Some(value)) = get_tag_name_value(child)? else {
365 continue;
366 };
367
368 match name {
369 "host-ip" => parse_value(&mut host_ip, "host-ip", value)?,
370 "Credentialed_Scan" => {
371 parse_value(&mut credentialed_scan, "Credentialed_Scan", value)?;
372 }
373 "DDI_Dir_Scanner_Global_Duration" => parse_value(
374 &mut ddi_dir_scanner_global_duration,
375 "DDI_Dir_Scanner_Global_Duration",
376 value,
377 )?,
378 "operating-system-conf" => {
379 parse_value(&mut operating_system_conf, "operating-system-conf", value)?;
380 }
381 "operating-system-unsupported" => parse_value(
382 &mut operating_system_unsupported,
383 "operating-system-unsupported",
384 value,
385 )?,
386 "patch-summary-total-cves" => parse_value(
387 &mut patch_summary_total_cves,
388 "patch-summary-total-cves",
389 value,
390 )?,
391
392 "HOST_START" => str_value(&mut host_start, "HOST_START", value)?,
393 "HOST_END" => str_value(&mut host_end, "HOST_END", value)?,
394 "bios-uuid" => str_value(&mut bios_uuid, "bios-uuid", value)?,
395 "hostname" => str_value(&mut hostname, "hostname", value)?,
396 "local-checks-proto" => {
397 str_value(&mut local_checks_proto, "local-checks-proto", value)?;
398 }
399 "operating-system" => str_value(&mut operating_system, "operating-system", value)?,
400 "operating-system-method" => str_value(
401 &mut operating_system_method,
402 "operating-system-method",
403 value,
404 )?,
405 "os" => str_value(&mut os, "os", value)?,
406 "rexec-login-used" => str_value(&mut rexec_login_used, "rexec-login-used", value)?,
407 "rlogin-login-used" => {
408 str_value(&mut rlogin_login_used, "rlogin-login-used", value)?;
409 }
410 "rsh-login-used" => str_value(&mut rsh_login_used, "rsh-login-used", value)?,
411 "smb-login-used" => str_value(&mut smb_login_used, "smb-login-used", value)?,
412 "ssh-login-used" => str_value(&mut ssh_login_used, "ssh-login-used", value)?,
413 "telnet-login-used" => {
414 str_value(&mut telnet_login_used, "telnet-login-used", value)?;
415 }
416 "sinfp-signature" => str_value(&mut sinfp_signature, "sinfp-signature", value)?,
417 "system-type" => str_value(&mut system_type, "system-type", value)?,
418 "wmi-domain" => str_value(&mut wmi_domain, "wmi-domain", value)?,
419
420 "Apache_sites" => cow_value(&mut apache_sites, "Apache_sites", value)?,
421 "host-ad-config" => cow_value(&mut host_ad_config, "host-ad-config", value)?,
422 "host-fqdn" => cow_value(&mut host_fqdn, "host-fqdn", value)?,
423 "host-fqdns" => cow_value(&mut host_fqdns, "host-fqdns", value)?,
424 "host-rdns" => cow_value(&mut host_rdns, "host-rdns", value)?,
425 "IIS_sites" => cow_value(&mut iis_sites, "IIS_sites", value)?,
426 "netbios-name" => cow_value(&mut netbios_name, "netbios-name", value)?,
427 "policy-used" => cow_value(&mut policy_used, "policy-used", value)?,
428 "sinfp-ml-prediction" => {
429 cow_value(&mut sinfp_ml_prediction, "sinfp-ml-prediction", value)?;
430 }
431 "ssh-fingerprint" => cow_value(&mut ssh_fingerprint, "ssh-fingerprint", value)?,
432
433 "HOST_START_TIMESTAMP" => {
434 if host_start_timestamp.is_some() {
435 return Err(FormatError::RepeatedTag("HOST_START_TIMESTAMP"));
436 }
437 host_start_timestamp = Some(Timestamp::from_second(value.parse::<i64>()?)?);
438 }
439 "HOST_END_TIMESTAMP" => {
440 if host_end_timestamp.is_some() {
441 return Err(FormatError::RepeatedTag("HOST_END_TIMESTAMP"));
442 }
443 host_end_timestamp = Some(Timestamp::from_second(value.parse::<i64>()?)?);
444 }
445 "DDI_Dir_Scanner_Global_Init" => {
446 if ddi_dir_scanner_global_init.is_some() {
447 return Err(FormatError::RepeatedTag("DDI_Dir_Scanner_Global_Init"));
448 }
449 ddi_dir_scanner_global_init =
450 Some(Timestamp::from_second(value.parse::<i64>()?)?);
451 }
452 "LastAuthenticatedResults" => {
453 if last_authenticated_results.is_some() {
454 return Err(FormatError::RepeatedTag("LastAuthenticatedResults"));
455 }
456 last_authenticated_results =
457 Some(Timestamp::from_second(value.parse::<i64>()?)?);
458 }
459 "LastUnauthenticatedResults" => {
460 if last_unauthenticated_results.is_some() {
461 return Err(FormatError::RepeatedTag("LastUnauthenticatedResults"));
462 }
463 last_unauthenticated_results =
464 Some(Timestamp::from_second(value.parse::<i64>()?)?);
465 }
466
467 "dead_host" => {
468 if dead_host.is_some() {
469 return Err(FormatError::RepeatedTag("dead_host"));
470 }
471 dead_host = Some(value.as_str() == "1");
473 }
474 "ignore_printer" => {
475 if ignore_printer.is_some() {
476 return Err(FormatError::RepeatedTag("ignore_printer"));
477 }
478 ignore_printer = Some(value.as_str() == "1");
480 }
481
482 "mac-address" => {
483 mac_address.reserve_exact((value.len() + 1) / 18);
484 for mac in value.split('\n') {
487 mac_address.push(mac.parse()?);
488 }
489 }
490
491 "cpe" => cpe.push((0, value.to_cow())),
492
493 other_name => {
494 if let Some(cpe_n) = other_name.strip_prefix("cpe-") {
495 cpe.push((cpe_n.parse::<u16>()?, value.to_cow()));
496 } else if let Some(port_and_suffix) =
497 other_name.strip_prefix("DDI_Dir_Scanner_Port_")
498 && let Some((port, suffix)) = port_and_suffix.split_once('_')
499 {
500 let port = port.parse()?;
501 match suffix {
502 "Duration" => {
503 ddi_dir_scanner_port_duration.push((port, value.parse()?));
504 }
505 "Init" => ddi_dir_scanner_port_init
506 .push((port, Timestamp::from_second(value.parse::<i64>()?)?)),
507 "Pass_Start" => {
508 ddi_dir_scanner_port_pass_start
509 .push((port, Timestamp::from_second(value.parse::<i64>()?)?));
510 }
511 "Pass_Timeout" => {
512 ddi_dir_scanner_port_pass_timeout
513 .push((port, Timestamp::from_second(value.parse::<i64>()?)?));
514 }
515 _ => {
516 return Err(FormatError::UnexpectedText(other_name.into()));
517 }
518 }
519 } else if let Some(port_and_protocol) =
520 other_name.strip_prefix("enumerated-ports-")
521 && let Some((port, protocol)) = port_and_protocol.split_once('-')
522 {
523 enumerated_ports.push((port.parse()?, protocol.parse()?, value.to_str()?));
524 } else if let Some(hex_str) = other_name.strip_prefix("patch-summary-cve-num-")
525 {
526 patch_summary_cve_num.push((hex_str, value.parse()?));
527 } else if let Some(hex_str) = other_name.strip_prefix("patch-summary-cves-") {
528 patch_summary_cves.push((hex_str, value.to_str()?.split(", ").collect()));
529 } else if let Some(hex_str) = other_name.strip_prefix("patch-summary-txt-") {
530 patch_summary_txt.push((hex_str, value.to_cow()));
531 } else if let Some(hop_num) = other_name.strip_prefix("traceroute-hop-") {
532 traceroute.push((hop_num.parse::<u8>()?, value.parse().ok()));
533 } else if let Some(netstat_info) = other_name.strip_prefix("netstat-")
534 && let Some((mode, rest)) = netstat_info.split_once('-')
535 && let Some((protocol, num)) = rest.split_once('-')
536 {
537 let num = num.parse()?;
538 let value = value.to_str()?;
539 match mode {
540 "listen" => netstat_listen.push((protocol, num, value)),
541 "established" => netstat_established.push((protocol, num, value)),
542 _ => {
543 return Err(FormatError::UnexpectedText(other_name.into()));
544 }
545 }
546 } else {
547 others.entry(other_name).or_default().push(value.to_cow());
548 }
549 }
550 }
551 }
552
553 cpe.sort_unstable();
554 traceroute.sort_unstable();
555 netstat_listen.sort_unstable();
556 netstat_established.sort_unstable();
557
558 let cpe = cpe.into_iter().map(|(_, cpe)| cpe).collect();
559 let traceroute = traceroute.into_iter().map(|(_, ip)| ip).collect();
560
561 Ok(Self {
562 host_ip: host_ip.ok_or(FormatError::MissingTag("host-ip"))?,
563 host_start: host_start.ok_or(FormatError::MissingTag("HOST_START"))?,
564 host_start_timestamp: host_start_timestamp
565 .ok_or(FormatError::MissingTag("HOST_START_TIMESTAMP"))?,
566 host_end,
567 host_end_timestamp,
568 apache_sites,
569 bios_uuid,
570 credentialed_scan,
571 ddi_dir_scanner_global_duration,
572 ddi_dir_scanner_global_init,
573 dead_host,
574 host_ad_config,
575 host_fqdn,
576 host_fqdns,
577 host_rdns,
578 hostname,
579 ignore_printer,
580 iis_sites,
581 last_authenticated_results,
582 last_unauthenticated_results,
583 local_checks_proto,
584 mac_address,
585 netbios_name,
586 operating_system,
587 operating_system_conf,
588 operating_system_method,
589 operating_system_unsupported,
590 os,
591 patch_summary_total_cves,
592 policy_used,
593 rexec_login_used,
594 rlogin_login_used,
595 rsh_login_used,
596 smb_login_used,
597 ssh_login_used,
598 telnet_login_used,
599 sinfp_ml_prediction,
600 sinfp_signature,
601 ssh_fingerprint,
602 system_type,
603 wmi_domain,
604 others,
605 cpe,
606 traceroute,
607 netstat_listen,
608 netstat_established,
609 patch_summary_txt,
610 enumerated_ports,
611 patch_summary_cve_num,
612 patch_summary_cves,
613 ddi_dir_scanner_port_init,
614 ddi_dir_scanner_port_pass_start,
615 ddi_dir_scanner_port_duration,
616 ddi_dir_scanner_port_pass_timeout,
617 })
618 }
619}
620
621fn parse_value<T>(
622 output: &mut Option<T>,
623 tag_name: &'static str,
624 value: &StringStorage,
625) -> Result<(), FormatError>
626where
627 T: std::str::FromStr,
628 FormatError: From<T::Err>,
629{
630 if output.is_some() {
631 return Err(FormatError::RepeatedTag(tag_name));
632 }
633 *output = Some(value.parse()?);
634
635 Ok(())
636}
637
638fn str_value<'input>(
639 output: &mut Option<&'input str>,
640 tag_name: &'static str,
641 value: &StringStorage<'input>,
642) -> Result<(), FormatError> {
643 if output.is_some() {
644 return Err(FormatError::RepeatedTag(tag_name));
645 }
646 *output = Some(value.to_str()?);
647
648 Ok(())
649}
650
651fn cow_value<'input>(
652 output: &mut Option<Cow<'input, str>>,
653 tag_name: &'static str,
654 value: &StringStorage<'input>,
655) -> Result<(), FormatError> {
656 if output.is_some() {
657 return Err(FormatError::RepeatedTag(tag_name));
658 }
659 *output = Some(value.to_cow());
660
661 Ok(())
662}
663
664fn get_tag_name_value<'input, 'a>(
665 child: Node<'a, 'input>,
666) -> Result<(&'input str, Option<&'a StringStorage<'input>>), FormatError> {
667 let name = child
668 .attributes()
669 .find(|a| a.name() == "name")
670 .ok_or(FormatError::MissingAttribute("name"))?
671 .value_storage()
672 .to_str()?;
673
674 let value = child.text_storage();
675
676 Ok((name, value))
677}
678
679#[cfg(test)]
680mod tests {
681 use roxmltree::Document;
682
683 use crate::error::FormatError;
684
685 use super::{Host, item::Protocol, parse_scanner_ip};
686
687 fn minimal_report_item(plugin_id: u32, port: u16, protocol: &str, plugin_type: &str) -> String {
688 format!(
689 r#"<ReportItem pluginID="{plugin_id}" pluginName="x" port="{port}" protocol="{protocol}" svc_name="svc" severity="2" pluginFamily="General">
690 <solution>fix</solution>
691 <script_version>1.0</script_version>
692 <risk_factor>Medium</risk_factor>
693 <plugin_type>{plugin_type}</plugin_type>
694 <plugin_publication_date>2024/01/01</plugin_publication_date>
695 <plugin_modification_date>2024/01/02</plugin_modification_date>
696 <fname>x.nasl</fname>
697 <description>desc</description>
698</ReportItem>"#
699 )
700 }
701
702 fn ping_item() -> String {
703 r#"<ReportItem pluginID="10180" pluginName="Ping the remote host" port="0" protocol="tcp" svc_name="general" severity="0" pluginFamily="General">
704 <solution>n/a</solution>
705 <script_version>1.0</script_version>
706 <risk_factor>None</risk_factor>
707 <plugin_type>summary</plugin_type>
708 <plugin_publication_date>2024/01/01</plugin_publication_date>
709 <plugin_modification_date>2024/01/02</plugin_modification_date>
710 <fname>ping.nasl</fname>
711 <description>desc</description>
712 <plugin_output>The remote host is up
713The remote host replied to an ICMP echo packet</plugin_output>
714</ReportItem>"#
715 .to_owned()
716 }
717
718 fn scanner_info_item(ip: &str) -> String {
719 format!(
720 r#"<ReportItem pluginID="19506" pluginName="Nessus Scan Information" port="0" protocol="tcp" svc_name="general" severity="0" pluginFamily="General">
721 <solution>n/a</solution>
722 <script_version>1.0</script_version>
723 <risk_factor>None</risk_factor>
724 <plugin_type>summary</plugin_type>
725 <plugin_publication_date>2024/01/01</plugin_publication_date>
726 <plugin_modification_date>2024/01/02</plugin_modification_date>
727 <fname>scan_info.nasl</fname>
728 <description>desc</description>
729 <plugin_output>Header
730Scanner IP : {ip}
731Tail</plugin_output>
732</ReportItem>"#
733 )
734 }
735
736 fn host_xml(items: &[String], extra_tags: &str) -> String {
737 let items_xml = items.join("\n");
738 format!(
739 r#"<ReportHost name="127.0.0.1">
740 <HostProperties>
741 <tag name="host-ip">127.0.0.1</tag>
742 <tag name="HOST_START">start</tag>
743 <tag name="HOST_START_TIMESTAMP">1</tag>
744 {extra_tags}
745 </HostProperties>
746 {items_xml}
747</ReportHost>"#
748 )
749 }
750
751 fn parse_host(xml: &str) -> Result<Host<'_>, FormatError> {
752 let doc = Document::parse(xml).expect("test XML should parse");
753 Host::from_xml_node(doc.root_element())
754 }
755
756 #[test]
757 fn parse_scanner_ip_handles_expected_and_error_paths() {
758 let ok = r"Information about this scan :
759
760Nessus version : 10.8.4
761Nessus build : 20028
762Plugin feed version : 202507040825
763Scanner edition used : Nessus
764Scanner OS : LINUX
765Scanner distribution : ubuntu1604-x86-64
766Scan type : Normal
767Scan name : anonymized scan from 192.0.2.0/24 (redacted)
768Scan policy used : Host discovery ++
769Scanner IP : 192.0.2.82
770Port scanner(s) : nessus_syn_scanner
771Port range : all
772Ping RTT : 76.193 ms
773Thorough tests : no
774Experimental tests : no
775Scan for Unpatched Vulnerabilities : no
776Plugin debugging enabled : no
777Paranoia level : 1
778Report verbosity : 1
779Safe checks : yes
780Optimize the test : yes
781Credentialed checks : no
782Patch management checks : None
783Display superseded patches : yes (supersedence plugin did not launch)
784CGI scanning : disabled
785Web application tests : disabled
786Max hosts : 100
787Max checks : 5
788Recv timeout : 5
789Backports : None
790Allow post-scan editing : Yes
791Nessus Plugin Signature Checking : Enabled
792Audit File Signature Checking : Disabled
793Scan Start Date : 2025/7/16 10:04 -05 (UTC -05:00)
794Scan duration : 362 sec
795Scan for malware : no
796";
797 assert_eq!(
798 parse_scanner_ip(ok).expect("must parse").to_string(),
799 "192.0.2.82"
800 );
801
802 let missing_prefix = "Header\nNo scanner here";
803 assert!(matches!(
804 parse_scanner_ip(missing_prefix),
805 Err(FormatError::MissingAttribute("Scanner IP"))
806 ));
807
808 let missing_newline = "Scanner IP : 10.0.0.1";
809 assert!(matches!(
810 parse_scanner_ip(missing_newline),
811 Err(FormatError::MissingAttribute("Scanner IP"))
812 ));
813
814 let invalid_ip = "Header\nScanner IP : 999.999.999.999\nFooter";
815 assert!(matches!(
816 parse_scanner_ip(invalid_ip),
817 Err(FormatError::IpAddrParse(_))
818 ));
819 }
820
821 #[test]
822 fn host_open_ports_are_filtered_sorted_and_deduped() {
823 let items = vec![
824 ping_item(),
825 scanner_info_item("10.0.0.2"),
826 minimal_report_item(123, 443, "tcp", "remote"),
827 minimal_report_item(124, 80, "tcp", "remote"),
828 minimal_report_item(124, 80, "tcp", "remote"),
829 minimal_report_item(125, 53, "udp", "local"),
830 minimal_report_item(11111, 22, "tcp", "remote"),
831 minimal_report_item(126, 0, "tcp", "remote"),
832 ];
833 let xml = host_xml(&items, "");
834 let host = parse_host(&xml).expect("must parse");
835
836 assert_eq!(
837 host.open_ports,
838 vec![(80, Protocol::Tcp), (443, Protocol::Tcp)]
839 );
840 assert!(host.ping_outcome.is_some());
841 assert!(host.scanner_ip.is_some());
842 }
843
844 #[test]
845 fn host_allows_missing_special_plugins() {
846 let items = vec![
847 minimal_report_item(123, 443, "tcp", "remote"),
848 minimal_report_item(124, 53, "udp", "remote"),
849 ];
850 let xml = host_xml(&items, "");
851 let host = parse_host(&xml).expect("must parse");
852
853 assert!(host.ping_outcome.is_none());
854 assert!(host.scanner_ip.is_none());
855 assert_eq!(
856 host.open_ports,
857 vec![(53, Protocol::Udp), (443, Protocol::Tcp)]
858 );
859 }
860
861 #[test]
862 fn host_rejects_repeated_special_plugins() {
863 let items = vec![
864 ping_item(),
865 ping_item(),
866 scanner_info_item("10.0.0.2"),
867 minimal_report_item(123, 443, "tcp", "remote"),
868 ];
869 let xml = host_xml(&items, "");
870 let err = parse_host(&xml).expect_err("must fail");
871 assert!(matches!(
872 err,
873 FormatError::RepeatedTag("Ping the remote host")
874 ));
875 }
876
877 #[test]
878 fn host_properties_bool_and_pattern_tags_parse() {
879 let items = vec![ping_item(), scanner_info_item("10.0.0.2")];
880 let extra = r#"
881 <tag name="dead_host">0</tag>
882 <tag name="ignore_printer">1</tag>
883 <tag name="enumerated-ports-8080-tcp">open</tag>
884 <tag name="traceroute-hop-1">8.8.8.8</tag>
885 <tag name="traceroute-hop-2">not-an-ip</tag>
886 <tag name="cpe-1">cpe:/o:test:os</tag>
887"#;
888 let xml = host_xml(&items, extra);
889 let host = parse_host(&xml).expect("must parse");
890 assert_eq!(host.properties.dead_host, Some(false));
891 assert_eq!(host.properties.ignore_printer, Some(true));
892 assert_eq!(host.properties.enumerated_ports.len(), 1);
893 assert_eq!(host.properties.traceroute.len(), 2);
894 assert_eq!(host.properties.cpe.len(), 1);
895 }
896}