nessus-parser 0.3.0

A parser for `.nessus` (v2) XML reports
Documentation
//! Parse Nessus v2 (`.nessus`) XML reports into strongly typed Rust structs.
//!
//! # Entry Point
//!
//! Use [`NessusClientDataV2::parse`] to parse a full Nessus XML document.
//!
//! # Example
//!
//! ```
//! use nessus_parser::NessusClientDataV2;
//!
//! # const XML: &str = r#"
//! # <NessusClientData_v2>
//! #   <Policy>
//! #     <policyName>example</policyName>
//! #     <Preferences>
//! #       <ServerPreferences>
//! #         <preference><name>whoami</name><value>user</value></preference>
//! #         <preference><name>scan_description</name><value>demo</value></preference>
//! #         <preference><name>TARGET</name><value>127.0.0.1</value></preference>
//! #         <preference><name>port_range</name><value>default</value></preference>
//! #         <preference><name>scan_start_timestamp</name><value>1</value></preference>
//! #         <preference><name>plugin_set</name><value>;1;</value></preference>
//! #         <preference><name>name</name><value>policy</value></preference>
//! #       </ServerPreferences>
//! #       <PluginsPreferences/>
//! #     </Preferences>
//! #     <FamilySelection/>
//! #     <IndividualPluginSelection/>
//! #   </Policy>
//! # </NessusClientData_v2>
//! # "#;
//! #
//! # fn main() -> Result<(), nessus_parser::error::FormatError> {
//! # let xml = XML;
//! let parsed = NessusClientDataV2::parse(&xml)?;
//!
//! println!("Policy: {}", parsed.policy.policy_name);
//! if let Some(report) = parsed.report {
//!     println!("Hosts: {}", report.hosts.len());
//! }
//! # Ok(())
//! # }
//! ```

pub mod error;
pub mod ping;
pub mod policy;
pub mod report;

use std::{borrow::Cow, str::FromStr};

use roxmltree::{Document, StringStorage};

use crate::{error::FormatError, policy::Policy, report::Report};

/// Represents the root of a Nessus v2 XML report, corresponding to the
/// `<NessusClientData_v2>` element.
///
/// This is the main entry point for a parsed `.nessus` file. It contains the
/// scan policy that was used and the report itself, which holds the results
/// of the scan.
#[derive(Debug)]
pub struct NessusClientDataV2<'input> {
    /// The policy configuration used for the Nessus scan.
    pub policy: Policy<'input>,
    /// The results of the scan, containing all discovered hosts and their
    /// vulnerabilities.
    pub report: Option<Report<'input>>,
}

impl<'input> NessusClientDataV2<'input> {
    /// Parses a string containing a `.nessus` (v2) XML report.
    ///
    /// This function is the main entry point for the parser. It takes the entire
    /// XML content of a `.nessus` file as a string slice and attempts to parse
    /// it into a structured `NessusClientDataV2` object.
    ///
    /// # Errors
    ///
    /// Returns a `FormatError` if the input string is not a valid Nessus v2
    /// report. This can happen for several reasons, including:
    /// - The XML is malformed.
    /// - The root element is not `<NessusClientData_v2>`.
    /// - Required elements or attributes (e.g., `<Policy>`) are missing.
    /// - Elements that should be unique appear multiple times.
    /// - Data cannot be converted to the expected type (e.g., a non-integer
    ///   value for a port number).
    pub fn parse(xml: &'input str) -> Result<Self, FormatError> {
        let doc = Document::parse(xml)?;

        let root = doc.root_element();

        if root.tag_name().name() != "NessusClientData_v2" {
            return Err(FormatError::UnsupportedVersion);
        }

        let mut policy = None;
        let mut report = None;

        for child in root.children() {
            match child.tag_name().name() {
                "Policy" => {
                    if policy.is_some() {
                        return Err(FormatError::RepeatedTag("Policy"));
                    }
                    policy = Some(Policy::from_xml_node(child)?);
                }
                "Report" => {
                    if report.is_some() {
                        return Err(FormatError::RepeatedTag("Report"));
                    }
                    report = Some(Report::from_xml_node(child)?);
                }
                _ => assert_empty_text(child)?,
            }
        }

        let policy = policy.ok_or(FormatError::MissingTag("Policy"))?;

        Ok(Self { policy, report })
    }
}

fn assert_empty_text(node: roxmltree::Node<'_, '_>) -> Result<(), FormatError> {
    let Some(text) = node.text() else {
        return Err(FormatError::UnexpectedNodeKind);
    };

    if !text.trim().is_empty() {
        return Err(FormatError::UnexpectedNode(
            format!("{}: {text}", node.tag_name().name()).into_boxed_str(),
        ));
    }

    Ok(())
}

trait StringStorageExt<'input> {
    fn to_str(&self) -> Result<&'input str, FormatError>;
    fn to_cow(&self) -> Cow<'input, str>;
}

impl<'input> StringStorageExt<'input> for StringStorage<'input> {
    fn to_str(&self) -> Result<&'input str, FormatError> {
        match self {
            StringStorage::Borrowed(s) => Ok(s),
            // We reject owned strings here to enforce "no allocation" paths for callers that
            // expect borrowed XML text. Use `to_cow` when allocation is acceptable.
            StringStorage::Owned(s) => Err(FormatError::UnexpectedXmlAttribute(s.as_ref().into())),
        }
    }

    fn to_cow(&self) -> Cow<'input, str> {
        match self {
            StringStorage::Borrowed(s) => Cow::Borrowed(s),
            StringStorage::Owned(s) => Cow::Owned(String::from(s.as_ref())),
        }
    }
}

/// A utility struct for representing a standard 6-byte MAC address.
///
/// It provides functionality for parsing from the common colon-separated
/// hexadecimal format (e.g., "00:1A:2B:3C:4D:5E") and for displaying
/// in the same format.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct MacAddress {
    bytes: [u8; 6],
}

impl MacAddress {
    /// Returns the six raw bytes of the MAC address.
    #[must_use]
    pub const fn bytes(self) -> [u8; 6] {
        self.bytes
    }
}

impl FromStr for MacAddress {
    type Err = FormatError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let mut octets = s.split(':');

        let mac_address = Self {
            bytes: [
                parse_octet(octets.next().ok_or(FormatError::MacAddressParse)?)?,
                parse_octet(octets.next().ok_or(FormatError::MacAddressParse)?)?,
                parse_octet(octets.next().ok_or(FormatError::MacAddressParse)?)?,
                parse_octet(octets.next().ok_or(FormatError::MacAddressParse)?)?,
                parse_octet(octets.next().ok_or(FormatError::MacAddressParse)?)?,
                parse_octet(octets.next().ok_or(FormatError::MacAddressParse)?)?,
            ],
        };

        if octets.next().is_some() {
            Err(FormatError::MacAddressParse)
        } else {
            Ok(mac_address)
        }
    }
}

fn parse_octet(input: &str) -> Result<u8, FormatError> {
    let &[a, b] = input.as_bytes() else {
        return Err(FormatError::MacAddressParse);
    };

    Ok((parse_hex_digit(a)? << 4) | parse_hex_digit(b)?)
}

const fn parse_hex_digit(ch: u8) -> Result<u8, FormatError> {
    match ch {
        b'0'..=b'9' => Ok(ch - b'0'),
        b'A'..=b'F' => Ok((ch - b'A') + 10),
        b'a'..=b'f' => Ok((ch - b'a') + 10),
        _ => Err(FormatError::MacAddressParse),
    }
}

impl std::fmt::Display for MacAddress {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        write!(
            f,
            "{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}",
            self.bytes[0],
            self.bytes[1],
            self.bytes[2],
            self.bytes[3],
            self.bytes[4],
            self.bytes[5]
        )
    }
}

impl std::fmt::Debug for MacAddress {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "\"{self}\"")
    }
}

#[cfg(test)]
mod tests {
    use std::str::FromStr;

    use super::{FormatError, MacAddress, NessusClientDataV2};

    const MINIMAL_POLICY: &str = r"
<Policy>
  <policyName>p</policyName>
  <Preferences>
    <ServerPreferences>
      <preference><name>whoami</name><value>u</value></preference>
      <preference><name>scan_description</name><value>d</value></preference>
      <preference><name>TARGET</name><value>127.0.0.1</value></preference>
      <preference><name>port_range</name><value>default</value></preference>
      <preference><name>scan_start_timestamp</name><value>1</value></preference>
      <preference><name>plugin_set</name><value>;1;</value></preference>
      <preference><name>name</name><value>n</value></preference>
    </ServerPreferences>
    <PluginsPreferences/>
  </Preferences>
  <FamilySelection/>
  <IndividualPluginSelection/>
</Policy>
";

    #[test]
    fn parse_allows_missing_report_tag() {
        let xml = format!("<NessusClientData_v2>{MINIMAL_POLICY}</NessusClientData_v2>");
        let parsed = NessusClientDataV2::parse(&xml).expect("XML should parse");
        assert!(parsed.report.is_none());
    }

    #[test]
    fn parse_rejects_repeated_policy_tag() {
        let xml =
            format!("<NessusClientData_v2>{MINIMAL_POLICY}{MINIMAL_POLICY}</NessusClientData_v2>");
        let err = NessusClientDataV2::parse(&xml).expect_err("duplicate Policy must fail");
        assert!(matches!(err, FormatError::RepeatedTag("Policy")));
    }

    #[test]
    fn parse_rejects_repeated_report_tag() {
        let xml = format!(
            "<NessusClientData_v2>{MINIMAL_POLICY}<Report name=\"r\"/><Report name=\"r2\"/></NessusClientData_v2>"
        );
        let err = NessusClientDataV2::parse(&xml).expect_err("duplicate Report must fail");
        assert!(matches!(err, FormatError::RepeatedTag("Report")));
    }

    #[test]
    fn parse_rejects_non_empty_unknown_top_level_text() {
        let xml = format!(
            "<NessusClientData_v2>{MINIMAL_POLICY}<extra>boom</extra></NessusClientData_v2>"
        );
        let err =
            NessusClientDataV2::parse(&xml).expect_err("non-empty unknown node text must fail");
        assert!(matches!(err, FormatError::UnexpectedNode(_)));
    }

    #[test]
    fn mac_address_rejects_invalid_inputs() {
        for input in [
            "0a:1b:2c:3d:4e",
            "0a:1b:2c:3d:4e:5f:6a",
            "0a:1b:2c:3d:4e:zz",
            "0:1b:2c:3d:4e:5f",
        ] {
            let err = MacAddress::from_str(input).expect_err("invalid MAC must fail");
            assert!(matches!(err, FormatError::MacAddressParse));
        }
    }
}