Skip to main content

nessus_parser/report/
mod.rs

1//! Types for Nessus `<Report>` data, including hosts and derived findings metadata.
2
3use 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/// Represents the `<Report>` element, which contains the main body of the
18/// scan results.
19///
20/// This struct holds the report's name and a collection of `Host` structs,
21/// each detailing the findings for a single scanned target.
22#[derive(Debug)]
23pub struct Report<'input> {
24    /// The name of the report
25    pub name: Cow<'input, str>,
26    /// A vector of hosts that were scanned and included in the report.
27    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/// Represents a `<ReportHost>` element, containing all information gathered
55/// for a single host.
56#[derive(Debug)]
57pub struct Host<'input> {
58    /// The name of the host, typically its IP address or FQDN.
59    pub name: Cow<'input, str>,
60    /// A collection of metadata and properties discovered about the host.
61    pub properties: HostProperties<'input>,
62    /// A vector of `ReportItem` findings for this host, representing vulnerabilities,
63    /// information gathered, etc.
64    pub items: Vec<Item<'input>>,
65    /// The parsed outcome of the "Ping the remote host" plugin (ID 10180),
66    /// indicating the host's reachability status.
67    pub ping_outcome: Option<PingOutcome>,
68    /// The IP address of the scanner that performed the scan on this host,
69    /// extracted from the "Nessus Scan Information" plugin (ID 19506).
70    pub scanner_ip: Option<IpAddr>,
71    /// A sorted list of ports found to be open on the host.
72    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                        // ignore plugins that don't verify ports
127                        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    // Nessus emits additional lines after "Scanner IP : <addr>", so this split
160    // is expected to succeed and keeps parsing strict to the observed format.
161    let (ip, _) = rest
162        .split_once('\n')
163        .ok_or(FormatError::MissingAttribute("Scanner IP"))?;
164    Ok(ip.parse()?)
165}
166
167/// Represents the `<HostProperties>` element, a collection of key-value tags
168/// containing detailed metadata about a scanned host.
169#[derive(Debug)]
170pub struct HostProperties<'input> {
171    /// The primary IP address of the host (`host-ip`).
172    pub host_ip: IpAddr,
173    /// A timestamp string indicating when the scan of this host began (`HOST_START`).
174    pub host_start: &'input str,
175    /// A timestamp indicating when the scan of this host began (`HOST_START_TIMESTAMP`).
176    pub host_start_timestamp: Timestamp,
177    /// A timestamp string indicating when the scan of this host ended (`HOST_END`).
178    pub host_end: Option<&'input str>,
179    /// A timestamp indicating when the scan of this host ended (`HOST_END_TIMESTAMP`).
180    pub host_end_timestamp: Option<Timestamp>,
181
182    // Apache_sites
183    pub apache_sites: Option<Cow<'input, str>>,
184    // bios-uuid
185    pub bios_uuid: Option<&'input str>,
186    // Credentialed_Scan
187    pub credentialed_scan: Option<bool>,
188    // DDI_Dir_Scanner_Global_Duration
189    pub ddi_dir_scanner_global_duration: Option<u32>,
190    // DDI_Dir_Scanner_Global_Init
191    pub ddi_dir_scanner_global_init: Option<Timestamp>,
192    // dead_host: "1"
193    pub dead_host: Option<bool>,
194    // host-ad-config
195    pub host_ad_config: Option<Cow<'input, str>>,
196    /// The fully qualified domain name of the host (`host-fqdn`).
197    pub host_fqdn: Option<Cow<'input, str>>,
198    // host-fqdns
199    pub host_fqdns: Option<Cow<'input, str>>,
200    // host-rdns
201    pub host_rdns: Option<Cow<'input, str>>,
202    // hostname
203    pub hostname: Option<&'input str>,
204    // ignore_printer: "1"
205    pub ignore_printer: Option<bool>,
206    // IIS_sites
207    pub iis_sites: Option<Cow<'input, str>>,
208    // LastAuthenticatedResults
209    pub last_authenticated_results: Option<Timestamp>,
210    // LastUnauthenticatedResults
211    pub last_unauthenticated_results: Option<Timestamp>,
212    // local-checks-proto
213    pub local_checks_proto: Option<&'input str>,
214    // netbios-name
215    pub netbios_name: Option<Cow<'input, str>>,
216    /// The operating system identified on the host (`operating-system`).
217    pub operating_system: Option<&'input str>,
218    // operating-system-conf
219    pub operating_system_conf: Option<i32>,
220    // operating-system-method
221    pub operating_system_method: Option<&'input str>,
222    // operating-system-unsupported
223    pub operating_system_unsupported: Option<bool>,
224    // os: "linux" | "mac" | "other" | "windows"
225    pub os: Option<&'input str>,
226    // patch-summary-total-cves: Option<u32>
227    pub patch_summary_total_cves: Option<u32>,
228    // policy-used
229    pub policy_used: Option<Cow<'input, str>>,
230    // rexec-login-used
231    pub rexec_login_used: Option<&'input str>,
232    // rlogin-login-used
233    pub rlogin_login_used: Option<&'input str>,
234    // rsh-login-used
235    pub rsh_login_used: Option<&'input str>,
236    // smb-login-used
237    pub smb_login_used: Option<&'input str>,
238    // ssh-login-used
239    pub ssh_login_used: Option<&'input str>,
240    // telnet-login-used
241    pub telnet_login_used: Option<&'input str>,
242    // sinfp-ml-prediction
243    pub sinfp_ml_prediction: Option<Cow<'input, str>>,
244    // sinfp-signature:
245    // > P1:B11113:F0x12:W8192:O0204ffff:M1460:
246    // >    P2:B11113:F0x12:W8192:O0204ffff010303080402080affffffff44454144:M1460:
247    // >    P3:B00000:F0x00:W0:O0:M0
248    // >    P4:190400_7_p=53R
249    pub sinfp_signature: Option<&'input str>,
250    // ssh-fingerprint
251    pub ssh_fingerprint: Option<Cow<'input, str>>,
252    /// The system type, such as "general-purpose", "printer", etc. (`system-type`).
253    // > "unknown" | "general-purpose" | "hypervisor" | "firewall" | "router"
254    // > | "embedded" | "camera" | "switch" | "General" | "load-balancer"
255    // > | "wireless-access-point" | "printer"
256    pub system_type: Option<&'input str>,
257    // wmi-domain
258    pub wmi_domain: Option<&'input str>,
259    /// A list of MAC addresses discovered on the host (`mac-address`).
260    pub mac_address: Vec<MacAddress>,
261    /// A list of Common Platform Enumeration (CPE) strings (`cpe`, `cpe-0`, etc.).
262    // cpe-<u16?>
263    // > cpe:/o:<vendor>:<product?>
264    // > cpe:/o:<vendor>:<product>:<version?> -> <string?>
265    // > x-cpe:/h:<vendor>:<product>:<version>
266    // > x-cpe:/a:<vendor>:<product>:<version?>
267    pub cpe: Vec<Cow<'input, str>>,
268    /// A list of IP addresses representing the hops in a traceroute to the target.
269    // traceroute-hop-<u8>
270    pub traceroute: Vec<Option<IpAddr>>,
271    // netstat-listen-(tcp|udp)(4|6)-<u16>
272    pub netstat_listen: Vec<(&'input str, u16, &'input str)>,
273    // netstat-established-(tcp|udp)(4|6)-<u16>
274    pub netstat_established: Vec<(&'input str, u16, &'input str)>,
275    // patch-summary-txt-<32-hex-digits>
276    pub patch_summary_txt: Vec<(&'input str, Cow<'input, str>)>,
277    // enumerated-ports-<u16>-tcp: "open" (ports out of range)
278    pub enumerated_ports: Vec<(u16, Protocol, &'input str)>,
279    // patch-summary-cve-num-<32-hex-digits>
280    pub patch_summary_cve_num: Vec<(&'input str, u32)>,
281    // patch-summary-cves-<32-hex-digits>
282    pub patch_summary_cves: Vec<(&'input str, Vec<&'input str>)>,
283    // DDI_Dir_Scanner_Port_<u16>_Init
284    pub ddi_dir_scanner_port_init: Vec<(u16, Timestamp)>,
285    // DDI_Dir_Scanner_Port_<u16>_Pass_Start
286    pub ddi_dir_scanner_port_pass_start: Vec<(u16, Timestamp)>,
287    // DDI_Dir_Scanner_Port_<u16>_Duration
288    pub ddi_dir_scanner_port_duration: Vec<(u16, u32)>,
289    // DDI_Dir_Scanner_Port_<u16>_Pass_Timeout
290    pub ddi_dir_scanner_port_pass_timeout: Vec<(u16, Timestamp)>,
291    /// A map to hold any other host properties not explicitly parsed into
292    /// other fields. The key is the tag's `name` attribute.
293    // "MSXX-XXX" | "pd-XXXXXX-X", | "SecurityControls-X"
294    // "TAG" -> "<32 hex digits>"
295    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                    // Intentional: only "1" is true; any other value is interpreted as false.
472                    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                    // Intentional: only "1" is true; any other value is interpreted as false.
479                    ignore_printer = Some(value.as_str() == "1");
480                }
481
482                "mac-address" => {
483                    mac_address.reserve_exact((value.len() + 1) / 18);
484                    // Nessus emits one MAC per line here without trailing blank lines,
485                    // so parsing each split segment directly is intentional.
486                    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}