1use std::{borrow::Cow, collections::HashMap, net::IpAddr};
2
3use jiff::Timestamp;
4use roxmltree::{Node, StringStorage};
5
6use crate::{
7 MacAddress, StringStorageExt, assert_empty_text,
8 error::FormatError,
9 ping::PingOutcome,
10 report::item::{Item, PluginType, Protocol},
11};
12
13pub mod item;
14
15#[derive(Debug)]
21pub struct Report<'input> {
22 pub name: Cow<'input, str>,
24 pub hosts: Vec<Host<'input>>,
26}
27
28impl<'input> Report<'input> {
29 pub(crate) fn from_xml_node(node: Node<'_, 'input>) -> Result<Self, FormatError> {
30 let name = node
31 .attributes()
32 .find(|a| a.name() == "name")
33 .ok_or(FormatError::MissingAttribute("name"))?
34 .value_storage()
35 .to_cow();
36
37 let mut hosts = vec![];
38
39 for child in node.children() {
40 match child.tag_name().name() {
41 "ReportHost" => {
42 hosts.push(Host::from_xml_node(child)?);
43 }
44 _ => assert_empty_text(child)?,
45 }
46 }
47
48 Ok(Self { name, hosts })
49 }
50}
51
52#[derive(Debug)]
55pub struct Host<'input> {
56 pub name: Cow<'input, str>,
58 pub properties: HostProperties<'input>,
60 pub items: Vec<Item<'input>>,
63 pub ping_outcome: Option<PingOutcome>,
66 pub scanner_ip: Option<IpAddr>,
69 pub open_ports: Vec<(u16, Protocol)>,
71}
72
73impl<'input> Host<'input> {
74 fn from_xml_node(node: Node<'_, 'input>) -> Result<Self, FormatError> {
75 const PING_THE_REMOTE_HOST_ID: u32 = 10180;
76 const NESSUS_SCAN_INFORMATION_ID: u32 = 19506;
77
78 let name = node
79 .attributes()
80 .find(|a| a.name() == "name")
81 .ok_or(FormatError::MissingAttribute("name"))?
82 .value_storage()
83 .to_cow();
84
85 let mut host_properties = None;
86 let mut items = vec![];
87
88 let mut ping_outcome = None;
89 let mut scanner_ip = None;
90
91 let mut open_ports = vec![];
92
93 for child in node.children() {
94 match child.tag_name().name() {
95 "HostProperties" => {
96 if host_properties.is_some() {
97 return Err(FormatError::RepeatedTag("HostProperties"));
98 }
99 host_properties = Some(HostProperties::from_xml_node(child)?);
100 }
101 "ReportItem" => {
102 let item = Item::from_xml_node(child)?;
103 match item.plugin_id {
104 PING_THE_REMOTE_HOST_ID => {
105 let plugin_output = item
106 .plugin_output
107 .as_ref()
108 .ok_or(FormatError::MissingPluginOutput)?;
109 if ping_outcome.is_some() {
110 return Err(FormatError::RepeatedTag("Ping the remote host"));
111 }
112 ping_outcome = Some(PingOutcome::from_plugin_output(plugin_output)?);
113 }
114 NESSUS_SCAN_INFORMATION_ID => {
115 let plugin_output = item
116 .plugin_output
117 .as_ref()
118 .ok_or(FormatError::MissingPluginOutput)?;
119 if scanner_ip.is_some() {
120 return Err(FormatError::RepeatedTag("Nessus Scan Information"));
121 }
122 scanner_ip = Some(parse_scanner_ip(plugin_output)?);
123 }
124 10736 | 11111 | 14272 | 14274 => {}
126 _ => {
127 if item.port != 0 && item.plugin_type != PluginType::Local {
128 open_ports.push((item.port, item.protocol));
129 }
130 }
131 }
132
133 items.push(item);
134 }
135 _ => assert_empty_text(child)?,
136 }
137 }
138
139 open_ports.sort_unstable();
140 open_ports.dedup();
141
142 Ok(Self {
143 name,
144 properties: host_properties.ok_or(FormatError::MissingTag("HostProperties"))?,
145 items,
146 ping_outcome,
147 scanner_ip,
148 open_ports,
149 })
150 }
151}
152
153fn parse_scanner_ip(plugin_output: &str) -> Result<IpAddr, FormatError> {
154 let (_, rest) = plugin_output
155 .split_once("Scanner IP : ")
156 .ok_or(FormatError::MissingAttribute("Scanner IP"))?;
157 let (ip, _) = rest
158 .split_once('\n')
159 .ok_or(FormatError::MissingAttribute("Scanner IP"))?;
160 Ok(ip.parse()?)
161}
162
163#[derive(Debug)]
166pub struct HostProperties<'input> {
167 pub host_ip: IpAddr,
169 pub host_start: &'input str,
171 pub host_start_timestamp: Timestamp,
173 pub host_end: Option<&'input str>,
175 pub host_end_timestamp: Option<Timestamp>,
177
178 pub apache_sites: Option<Cow<'input, str>>,
180 pub bios_uuid: Option<&'input str>,
182 pub credentialed_scan: Option<bool>,
184 pub ddi_dir_scanner_global_duration: Option<u32>,
186 pub ddi_dir_scanner_global_init: Option<Timestamp>,
188 pub dead_host: Option<bool>,
190 pub host_ad_config: Option<Cow<'input, str>>,
192 pub host_fqdn: Option<Cow<'input, str>>,
194 pub host_fqdns: Option<Cow<'input, str>>,
196 pub host_rdns: Option<Cow<'input, str>>,
198 pub hostname: Option<&'input str>,
200 pub ignore_printer: Option<bool>,
202 pub iis_sites: Option<Cow<'input, str>>,
204 pub last_authenticated_results: Option<Timestamp>,
206 pub last_unauthenticated_results: Option<Timestamp>,
208 pub local_checks_proto: Option<&'input str>,
210 pub netbios_name: Option<Cow<'input, str>>,
212 pub operating_system: Option<&'input str>,
214 pub operating_system_conf: Option<i32>,
216 pub operating_system_method: Option<&'input str>,
218 pub operating_system_unsupported: Option<bool>,
220 pub os: Option<&'input str>,
222 pub patch_summary_total_cves: Option<u32>,
224 pub policy_used: Option<Cow<'input, str>>,
226 pub rexec_login_used: Option<&'input str>,
228 pub rlogin_login_used: Option<&'input str>,
230 pub rsh_login_used: Option<&'input str>,
232 pub smb_login_used: Option<&'input str>,
234 pub ssh_login_used: Option<&'input str>,
236 pub telnet_login_used: Option<&'input str>,
238 pub sinfp_ml_prediction: Option<Cow<'input, str>>,
240 pub sinfp_signature: Option<&'input str>,
246 pub ssh_fingerprint: Option<Cow<'input, str>>,
248 pub system_type: Option<&'input str>,
253 pub wmi_domain: Option<&'input str>,
255 pub mac_address: Vec<MacAddress>,
257 pub cpe: Vec<Cow<'input, str>>,
264 pub traceroute: Vec<Option<IpAddr>>,
267 pub netstat_listen: Vec<(&'input str, u16, &'input str)>,
269 pub netstat_established: Vec<(&'input str, u16, &'input str)>,
271 pub patch_summary_txt: Vec<(&'input str, Cow<'input, str>)>,
273 pub enumerated_ports: Vec<(u16, Protocol, &'input str)>,
275 pub patch_summary_cve_num: Vec<(&'input str, u32)>,
277 pub patch_summary_cves: Vec<(&'input str, Vec<&'input str>)>,
279 pub ddi_dir_scanner_port_init: Vec<(u16, Timestamp)>,
281 pub ddi_dir_scanner_port_pass_start: Vec<(u16, Timestamp)>,
283 pub ddi_dir_scanner_port_duration: Vec<(u16, u32)>,
285 pub ddi_dir_scanner_port_pass_timeout: Vec<(u16, Timestamp)>,
287 pub others: HashMap<&'input str, Vec<Cow<'input, str>>>,
292}
293
294impl<'input> HostProperties<'input> {
295 #[expect(clippy::too_many_lines)]
296 fn from_xml_node(node: Node<'_, 'input>) -> Result<Self, FormatError> {
297 let mut host_ip = None;
298 let mut host_start = None;
299 let mut host_start_timestamp = None;
300 let mut host_end = None;
301 let mut host_end_timestamp = None;
302 let mut apache_sites = None;
303 let mut bios_uuid = None;
304 let mut credentialed_scan = None;
305 let mut ddi_dir_scanner_global_duration = None;
306 let mut ddi_dir_scanner_global_init = None;
307 let mut dead_host = None;
308 let mut host_ad_config = None;
309 let mut host_fqdn = None;
310 let mut host_fqdns = None;
311 let mut host_rdns = None;
312 let mut hostname = None;
313 let mut ignore_printer = None;
314 let mut iis_sites = None;
315 let mut last_authenticated_results = None;
316 let mut last_unauthenticated_results = None;
317 let mut local_checks_proto = None;
318 let mut netbios_name = None;
319 let mut operating_system = None;
320 let mut operating_system_conf = None;
321 let mut operating_system_method = None;
322 let mut operating_system_unsupported = None;
323 let mut os = None;
324 let mut patch_summary_total_cves = None;
325 let mut policy_used = None;
326 let mut rexec_login_used = None;
327 let mut rlogin_login_used = None;
328 let mut rsh_login_used = None;
329 let mut smb_login_used = None;
330 let mut ssh_login_used = None;
331 let mut telnet_login_used = None;
332 let mut sinfp_ml_prediction = None;
333 let mut sinfp_signature = None;
334 let mut ssh_fingerprint = None;
335 let mut system_type = None;
336 let mut wmi_domain = None;
337
338 let mut cpe = vec![];
339 let mut traceroute = vec![];
340 let mut mac_address = vec![];
341 let mut netstat_listen = vec![];
342 let mut netstat_established = vec![];
343 let mut patch_summary_txt = vec![];
344 let mut enumerated_ports = vec![];
345 let mut patch_summary_cve_num = vec![];
346 let mut patch_summary_cves = vec![];
347 let mut ddi_dir_scanner_port_init = vec![];
348 let mut ddi_dir_scanner_port_pass_start = vec![];
349 let mut ddi_dir_scanner_port_duration = vec![];
350 let mut ddi_dir_scanner_port_pass_timeout = vec![];
351
352 let mut others: HashMap<_, Vec<_>> = HashMap::new();
353
354 for child in node.children() {
355 if child.tag_name().name() != "tag" {
356 assert_empty_text(child)?;
357 continue;
358 }
359
360 let (name, Some(value)) = get_tag_name_value(child)? else {
361 continue;
362 };
363
364 match name {
365 "host-ip" => parse_value(&mut host_ip, "host-ip", value)?,
366 "Credentialed_Scan" => {
367 parse_value(&mut credentialed_scan, "Credentialed_Scan", value)?;
368 }
369 "DDI_Dir_Scanner_Global_Duration" => parse_value(
370 &mut ddi_dir_scanner_global_duration,
371 "DDI_Dir_Scanner_Global_Duration",
372 value,
373 )?,
374 "operating-system-conf" => {
375 parse_value(&mut operating_system_conf, "operating-system-conf", value)?;
376 }
377 "operating-system-unsupported" => parse_value(
378 &mut operating_system_unsupported,
379 "operating-system-unsupported",
380 value,
381 )?,
382 "patch-summary-total-cves" => parse_value(
383 &mut patch_summary_total_cves,
384 "patch-summary-total-cves",
385 value,
386 )?,
387
388 "HOST_START" => str_value(&mut host_start, "HOST_START", value)?,
389 "HOST_END" => str_value(&mut host_end, "HOST_END", value)?,
390 "bios-uuid" => str_value(&mut bios_uuid, "bios-uuid", value)?,
391 "hostname" => str_value(&mut hostname, "hostname", value)?,
392 "local-checks-proto" => {
393 str_value(&mut local_checks_proto, "local-checks-proto", value)?;
394 }
395 "operating-system" => str_value(&mut operating_system, "operating-system", value)?,
396 "operating-system-method" => str_value(
397 &mut operating_system_method,
398 "operating-system-method",
399 value,
400 )?,
401 "os" => str_value(&mut os, "os", value)?,
402 "rexec-login-used" => str_value(&mut rexec_login_used, "rexec-login-used", value)?,
403 "rlogin-login-used" => {
404 str_value(&mut rlogin_login_used, "rlogin-login-used", value)?;
405 }
406 "rsh-login-used" => str_value(&mut rsh_login_used, "rsh-login-used", value)?,
407 "smb-login-used" => str_value(&mut smb_login_used, "smb-login-used", value)?,
408 "ssh-login-used" => str_value(&mut ssh_login_used, "ssh-login-used", value)?,
409 "telnet-login-used" => {
410 str_value(&mut telnet_login_used, "telnet-login-used", value)?;
411 }
412 "sinfp-signature" => str_value(&mut sinfp_signature, "sinfp-signature", value)?,
413 "system-type" => str_value(&mut system_type, "system-type", value)?,
414 "wmi-domain" => str_value(&mut wmi_domain, "wmi-domain", value)?,
415
416 "Apache_sites" => cow_value(&mut apache_sites, "Apache_sites", value)?,
417 "host-ad-config" => cow_value(&mut host_ad_config, "host-ad-config", value)?,
418 "host-fqdn" => cow_value(&mut host_fqdn, "host-fqdn", value)?,
419 "host-fqdns" => cow_value(&mut host_fqdns, "host-fqdns", value)?,
420 "host-rdns" => cow_value(&mut host_rdns, "host-rdns", value)?,
421 "IIS_sites" => cow_value(&mut iis_sites, "IIS_sites", value)?,
422 "netbios-name" => cow_value(&mut netbios_name, "netbios-name", value)?,
423 "policy-used" => cow_value(&mut policy_used, "policy-used", value)?,
424 "sinfp-ml-prediction" => {
425 cow_value(&mut sinfp_ml_prediction, "sinfp-ml-prediction", value)?;
426 }
427 "ssh-fingerprint" => cow_value(&mut ssh_fingerprint, "ssh-fingerprint", value)?,
428
429 "HOST_START_TIMESTAMP" => {
430 if host_start_timestamp.is_some() {
431 return Err(FormatError::RepeatedTag("HOST_START_TIMESTAMP"));
432 }
433 host_start_timestamp = Some(Timestamp::from_second(value.parse::<i64>()?)?);
434 }
435 "HOST_END_TIMESTAMP" => {
436 if host_end_timestamp.is_some() {
437 return Err(FormatError::RepeatedTag("HOST_END_TIMESTAMP"));
438 }
439 host_end_timestamp = Some(Timestamp::from_second(value.parse::<i64>()?)?);
440 }
441 "DDI_Dir_Scanner_Global_Init" => {
442 if ddi_dir_scanner_global_init.is_some() {
443 return Err(FormatError::RepeatedTag("DDI_Dir_Scanner_Global_Init"));
444 }
445 ddi_dir_scanner_global_init =
446 Some(Timestamp::from_second(value.parse::<i64>()?)?);
447 }
448 "LastAuthenticatedResults" => {
449 if last_authenticated_results.is_some() {
450 return Err(FormatError::RepeatedTag("LastAuthenticatedResults"));
451 }
452 last_authenticated_results =
453 Some(Timestamp::from_second(value.parse::<i64>()?)?);
454 }
455 "LastUnauthenticatedResults" => {
456 if last_unauthenticated_results.is_some() {
457 return Err(FormatError::RepeatedTag("LastUnauthenticatedResults"));
458 }
459 last_unauthenticated_results =
460 Some(Timestamp::from_second(value.parse::<i64>()?)?);
461 }
462
463 "dead_host" => {
464 if dead_host.is_some() {
465 return Err(FormatError::RepeatedTag("dead_host"));
466 }
467 dead_host = Some(value.as_str() == "1");
468 }
469 "ignore_printer" => {
470 if ignore_printer.is_some() {
471 return Err(FormatError::RepeatedTag("ignore_printer"));
472 }
473 ignore_printer = Some(value.as_str() == "1");
474 }
475
476 "mac-address" => {
477 mac_address.reserve_exact((value.len() + 1) / 18);
478 for mac in value.split('\n') {
479 mac_address.push(mac.parse()?);
480 }
481 }
482
483 "cpe" => cpe.push((0, value.to_cow())),
484
485 other_name => {
486 if let Some(cpe_n) = other_name.strip_prefix("cpe-") {
487 cpe.push((cpe_n.parse::<u16>()?, value.to_cow()));
488 } else if let Some(port_and_suffix) =
489 other_name.strip_prefix("DDI_Dir_Scanner_Port_")
490 && let Some((port, suffix)) = port_and_suffix.split_once('_')
491 {
492 let port = port.parse()?;
493 match suffix {
494 "Duration" => {
495 ddi_dir_scanner_port_duration.push((port, value.parse()?));
496 }
497 "Init" => ddi_dir_scanner_port_init
498 .push((port, Timestamp::from_second(value.parse::<i64>()?)?)),
499 "Pass_Start" => {
500 ddi_dir_scanner_port_pass_start
501 .push((port, Timestamp::from_second(value.parse::<i64>()?)?));
502 }
503 "Pass_Timeout" => {
504 ddi_dir_scanner_port_pass_timeout
505 .push((port, Timestamp::from_second(value.parse::<i64>()?)?));
506 }
507 _ => {
508 return Err(FormatError::UnexpectedText(other_name.into()));
509 }
510 }
511 } else if let Some(port_and_protocol) =
512 other_name.strip_prefix("enumerated-ports-")
513 && let Some((port, protocol)) = port_and_protocol.split_once('-')
514 {
515 enumerated_ports.push((port.parse()?, protocol.parse()?, value.to_str()?));
516 } else if let Some(hex_str) = other_name.strip_prefix("patch-summary-cve-num-")
517 {
518 patch_summary_cve_num.push((hex_str, value.parse()?));
519 } else if let Some(hex_str) = other_name.strip_prefix("patch-summary-cves-") {
520 patch_summary_cves.push((hex_str, value.to_str()?.split(", ").collect()));
521 } else if let Some(hex_str) = other_name.strip_prefix("patch-summary-txt-") {
522 patch_summary_txt.push((hex_str, value.to_cow()));
523 } else if let Some(hop_num) = other_name.strip_prefix("traceroute-hop-") {
524 traceroute.push((hop_num.parse::<u8>()?, value.parse().ok()));
525 } else if let Some(netstat_info) = other_name.strip_prefix("netstat-")
526 && let Some((mode, rest)) = netstat_info.split_once('-')
527 && let Some((protocol, num)) = rest.split_once('-')
528 {
529 let num = num.parse()?;
530 let value = value.to_str()?;
531 match mode {
532 "listen" => netstat_listen.push((protocol, num, value)),
533 "established" => netstat_established.push((protocol, num, value)),
534 _ => {
535 return Err(FormatError::UnexpectedText(other_name.into()));
536 }
537 }
538 } else {
539 others.entry(other_name).or_default().push(value.to_cow());
540 }
541 }
542 }
543 }
544
545 cpe.sort_unstable();
546 traceroute.sort_unstable();
547 netstat_listen.sort_unstable();
548 netstat_established.sort_unstable();
549
550 let cpe = cpe.into_iter().map(|(_, cpe)| cpe).collect();
551 let traceroute = traceroute.into_iter().map(|(_, ip)| ip).collect();
552
553 Ok(Self {
554 host_ip: host_ip.ok_or(FormatError::MissingTag("host-ip"))?,
555 host_start: host_start.ok_or(FormatError::MissingTag("HOST_START"))?,
556 host_start_timestamp: host_start_timestamp
557 .ok_or(FormatError::MissingTag("HOST_START_TIMESTAMP"))?,
558 host_end,
559 host_end_timestamp,
560 apache_sites,
561 bios_uuid,
562 credentialed_scan,
563 ddi_dir_scanner_global_duration,
564 ddi_dir_scanner_global_init,
565 dead_host,
566 host_ad_config,
567 host_fqdn,
568 host_fqdns,
569 host_rdns,
570 hostname,
571 ignore_printer,
572 iis_sites,
573 last_authenticated_results,
574 last_unauthenticated_results,
575 local_checks_proto,
576 mac_address,
577 netbios_name,
578 operating_system,
579 operating_system_conf,
580 operating_system_method,
581 operating_system_unsupported,
582 os,
583 patch_summary_total_cves,
584 policy_used,
585 rexec_login_used,
586 rlogin_login_used,
587 rsh_login_used,
588 smb_login_used,
589 ssh_login_used,
590 telnet_login_used,
591 sinfp_ml_prediction,
592 sinfp_signature,
593 ssh_fingerprint,
594 system_type,
595 wmi_domain,
596 others,
597 cpe,
598 traceroute,
599 netstat_listen,
600 netstat_established,
601 patch_summary_txt,
602 enumerated_ports,
603 patch_summary_cve_num,
604 patch_summary_cves,
605 ddi_dir_scanner_port_init,
606 ddi_dir_scanner_port_pass_start,
607 ddi_dir_scanner_port_duration,
608 ddi_dir_scanner_port_pass_timeout,
609 })
610 }
611}
612
613fn parse_value<T>(
614 output: &mut Option<T>,
615 tag_name: &'static str,
616 value: &StringStorage,
617) -> Result<(), FormatError>
618where
619 T: std::str::FromStr,
620 FormatError: From<T::Err>,
621{
622 if output.is_some() {
623 return Err(FormatError::RepeatedTag(tag_name));
624 }
625 *output = Some(value.parse()?);
626
627 Ok(())
628}
629
630fn str_value<'input>(
631 output: &mut Option<&'input str>,
632 tag_name: &'static str,
633 value: &StringStorage<'input>,
634) -> Result<(), FormatError> {
635 if output.is_some() {
636 return Err(FormatError::RepeatedTag(tag_name));
637 }
638 *output = Some(value.to_str()?);
639
640 Ok(())
641}
642
643fn cow_value<'input>(
644 output: &mut Option<Cow<'input, str>>,
645 tag_name: &'static str,
646 value: &StringStorage<'input>,
647) -> Result<(), FormatError> {
648 if output.is_some() {
649 return Err(FormatError::RepeatedTag(tag_name));
650 }
651 *output = Some(value.to_cow());
652
653 Ok(())
654}
655
656fn get_tag_name_value<'input, 'a>(
657 child: Node<'a, 'input>,
658) -> Result<(&'input str, Option<&'a StringStorage<'input>>), FormatError> {
659 let name = child
660 .attributes()
661 .find(|a| a.name() == "name")
662 .ok_or(FormatError::MissingAttribute("name"))?
663 .value_storage()
664 .to_str()?;
665
666 let value = child.text_storage();
667
668 Ok((name, value))
669}