Skip to main content

nessus_parser/
lib.rs

1//! Parse Nessus v2 (`.nessus`) XML reports into strongly typed Rust structs.
2//!
3//! # Entry Point
4//!
5//! Use [`NessusClientDataV2::parse`] to parse a full Nessus XML document.
6//!
7//! # Example
8//!
9//! ```
10//! use nessus_parser::NessusClientDataV2;
11//!
12//! # const XML: &str = r#"
13//! # <NessusClientData_v2>
14//! #   <Policy>
15//! #     <policyName>example</policyName>
16//! #     <Preferences>
17//! #       <ServerPreferences>
18//! #         <preference><name>whoami</name><value>user</value></preference>
19//! #         <preference><name>scan_description</name><value>demo</value></preference>
20//! #         <preference><name>TARGET</name><value>127.0.0.1</value></preference>
21//! #         <preference><name>port_range</name><value>default</value></preference>
22//! #         <preference><name>scan_start_timestamp</name><value>1</value></preference>
23//! #         <preference><name>plugin_set</name><value>;1;</value></preference>
24//! #         <preference><name>name</name><value>policy</value></preference>
25//! #       </ServerPreferences>
26//! #       <PluginsPreferences/>
27//! #     </Preferences>
28//! #     <FamilySelection/>
29//! #     <IndividualPluginSelection/>
30//! #   </Policy>
31//! # </NessusClientData_v2>
32//! # "#;
33//! #
34//! # fn main() -> Result<(), nessus_parser::error::FormatError> {
35//! # let xml = XML;
36//! let parsed = NessusClientDataV2::parse(&xml)?;
37//!
38//! println!("Policy: {}", parsed.policy.policy_name);
39//! if let Some(report) = parsed.report {
40//!     println!("Hosts: {}", report.hosts.len());
41//! }
42//! # Ok(())
43//! # }
44//! ```
45
46pub mod error;
47pub mod ping;
48pub mod policy;
49pub mod report;
50
51use std::{borrow::Cow, str::FromStr};
52
53use roxmltree::{Document, StringStorage};
54
55use crate::{error::FormatError, policy::Policy, report::Report};
56
57/// Represents the root of a Nessus v2 XML report, corresponding to the
58/// `<NessusClientData_v2>` element.
59///
60/// This is the main entry point for a parsed `.nessus` file. It contains the
61/// scan policy that was used and the report itself, which holds the results
62/// of the scan.
63#[derive(Debug)]
64pub struct NessusClientDataV2<'input> {
65    /// The policy configuration used for the Nessus scan.
66    pub policy: Policy<'input>,
67    /// The results of the scan, containing all discovered hosts and their
68    /// vulnerabilities.
69    pub report: Option<Report<'input>>,
70}
71
72impl<'input> NessusClientDataV2<'input> {
73    /// Parses a string containing a `.nessus` (v2) XML report.
74    ///
75    /// This function is the main entry point for the parser. It takes the entire
76    /// XML content of a `.nessus` file as a string slice and attempts to parse
77    /// it into a structured `NessusClientDataV2` object.
78    ///
79    /// # Errors
80    ///
81    /// Returns a `FormatError` if the input string is not a valid Nessus v2
82    /// report. This can happen for several reasons, including:
83    /// - The XML is malformed.
84    /// - The root element is not `<NessusClientData_v2>`.
85    /// - Required elements or attributes (e.g., `<Policy>`) are missing.
86    /// - Elements that should be unique appear multiple times.
87    /// - Data cannot be converted to the expected type (e.g., a non-integer
88    ///   value for a port number).
89    pub fn parse(xml: &'input str) -> Result<Self, FormatError> {
90        let doc = Document::parse(xml)?;
91
92        let root = doc.root_element();
93
94        if root.tag_name().name() != "NessusClientData_v2" {
95            return Err(FormatError::UnsupportedVersion);
96        }
97
98        let mut policy = None;
99        let mut report = None;
100
101        for child in root.children() {
102            match child.tag_name().name() {
103                "Policy" => {
104                    if policy.is_some() {
105                        return Err(FormatError::RepeatedTag("Policy"));
106                    }
107                    policy = Some(Policy::from_xml_node(child)?);
108                }
109                "Report" => {
110                    if report.is_some() {
111                        return Err(FormatError::RepeatedTag("Report"));
112                    }
113                    report = Some(Report::from_xml_node(child)?);
114                }
115                _ => assert_empty_text(child)?,
116            }
117        }
118
119        let policy = policy.ok_or(FormatError::MissingTag("Policy"))?;
120
121        Ok(Self { policy, report })
122    }
123}
124
125fn assert_empty_text(node: roxmltree::Node<'_, '_>) -> Result<(), FormatError> {
126    let Some(text) = node.text() else {
127        return Err(FormatError::UnexpectedNodeKind);
128    };
129
130    if !text.trim().is_empty() {
131        return Err(FormatError::UnexpectedNode(
132            format!("{}: {text}", node.tag_name().name()).into_boxed_str(),
133        ));
134    }
135
136    Ok(())
137}
138
139trait StringStorageExt<'input> {
140    fn to_str(&self) -> Result<&'input str, FormatError>;
141    fn to_cow(&self) -> Cow<'input, str>;
142}
143
144impl<'input> StringStorageExt<'input> for StringStorage<'input> {
145    fn to_str(&self) -> Result<&'input str, FormatError> {
146        match self {
147            StringStorage::Borrowed(s) => Ok(s),
148            // We reject owned strings here to enforce "no allocation" paths for callers that
149            // expect borrowed XML text. Use `to_cow` when allocation is acceptable.
150            StringStorage::Owned(s) => Err(FormatError::UnexpectedXmlAttribute(s.as_ref().into())),
151        }
152    }
153
154    fn to_cow(&self) -> Cow<'input, str> {
155        match self {
156            StringStorage::Borrowed(s) => Cow::Borrowed(s),
157            StringStorage::Owned(s) => Cow::Owned(String::from(s.as_ref())),
158        }
159    }
160}
161
162/// A utility struct for representing a standard 6-byte MAC address.
163///
164/// It provides functionality for parsing from the common colon-separated
165/// hexadecimal format (e.g., "00:1A:2B:3C:4D:5E") and for displaying
166/// in the same format.
167#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
168pub struct MacAddress {
169    bytes: [u8; 6],
170}
171
172impl MacAddress {
173    /// Returns the six raw bytes of the MAC address.
174    #[must_use]
175    pub const fn bytes(self) -> [u8; 6] {
176        self.bytes
177    }
178}
179
180impl FromStr for MacAddress {
181    type Err = FormatError;
182
183    fn from_str(s: &str) -> Result<Self, Self::Err> {
184        let mut octets = s.split(':');
185
186        let mac_address = Self {
187            bytes: [
188                parse_octet(octets.next().ok_or(FormatError::MacAddressParse)?)?,
189                parse_octet(octets.next().ok_or(FormatError::MacAddressParse)?)?,
190                parse_octet(octets.next().ok_or(FormatError::MacAddressParse)?)?,
191                parse_octet(octets.next().ok_or(FormatError::MacAddressParse)?)?,
192                parse_octet(octets.next().ok_or(FormatError::MacAddressParse)?)?,
193                parse_octet(octets.next().ok_or(FormatError::MacAddressParse)?)?,
194            ],
195        };
196
197        if octets.next().is_some() {
198            Err(FormatError::MacAddressParse)
199        } else {
200            Ok(mac_address)
201        }
202    }
203}
204
205fn parse_octet(input: &str) -> Result<u8, FormatError> {
206    let &[a, b] = input.as_bytes() else {
207        return Err(FormatError::MacAddressParse);
208    };
209
210    Ok((parse_hex_digit(a)? << 4) | parse_hex_digit(b)?)
211}
212
213const fn parse_hex_digit(ch: u8) -> Result<u8, FormatError> {
214    match ch {
215        b'0'..=b'9' => Ok(ch - b'0'),
216        b'A'..=b'F' => Ok((ch - b'A') + 10),
217        b'a'..=b'f' => Ok((ch - b'a') + 10),
218        _ => Err(FormatError::MacAddressParse),
219    }
220}
221
222impl std::fmt::Display for MacAddress {
223    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
224        write!(
225            f,
226            "{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}",
227            self.bytes[0],
228            self.bytes[1],
229            self.bytes[2],
230            self.bytes[3],
231            self.bytes[4],
232            self.bytes[5]
233        )
234    }
235}
236
237impl std::fmt::Debug for MacAddress {
238    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
239        write!(f, "\"{self}\"")
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use std::str::FromStr;
246
247    use super::{FormatError, MacAddress, NessusClientDataV2};
248
249    const MINIMAL_POLICY: &str = r"
250<Policy>
251  <policyName>p</policyName>
252  <Preferences>
253    <ServerPreferences>
254      <preference><name>whoami</name><value>u</value></preference>
255      <preference><name>scan_description</name><value>d</value></preference>
256      <preference><name>TARGET</name><value>127.0.0.1</value></preference>
257      <preference><name>port_range</name><value>default</value></preference>
258      <preference><name>scan_start_timestamp</name><value>1</value></preference>
259      <preference><name>plugin_set</name><value>;1;</value></preference>
260      <preference><name>name</name><value>n</value></preference>
261    </ServerPreferences>
262    <PluginsPreferences/>
263  </Preferences>
264  <FamilySelection/>
265  <IndividualPluginSelection/>
266</Policy>
267";
268
269    #[test]
270    fn parse_allows_missing_report_tag() {
271        let xml = format!("<NessusClientData_v2>{MINIMAL_POLICY}</NessusClientData_v2>");
272        let parsed = NessusClientDataV2::parse(&xml).expect("XML should parse");
273        assert!(parsed.report.is_none());
274    }
275
276    #[test]
277    fn parse_rejects_repeated_policy_tag() {
278        let xml =
279            format!("<NessusClientData_v2>{MINIMAL_POLICY}{MINIMAL_POLICY}</NessusClientData_v2>");
280        let err = NessusClientDataV2::parse(&xml).expect_err("duplicate Policy must fail");
281        assert!(matches!(err, FormatError::RepeatedTag("Policy")));
282    }
283
284    #[test]
285    fn parse_rejects_repeated_report_tag() {
286        let xml = format!(
287            "<NessusClientData_v2>{MINIMAL_POLICY}<Report name=\"r\"/><Report name=\"r2\"/></NessusClientData_v2>"
288        );
289        let err = NessusClientDataV2::parse(&xml).expect_err("duplicate Report must fail");
290        assert!(matches!(err, FormatError::RepeatedTag("Report")));
291    }
292
293    #[test]
294    fn parse_rejects_non_empty_unknown_top_level_text() {
295        let xml = format!(
296            "<NessusClientData_v2>{MINIMAL_POLICY}<extra>boom</extra></NessusClientData_v2>"
297        );
298        let err =
299            NessusClientDataV2::parse(&xml).expect_err("non-empty unknown node text must fail");
300        assert!(matches!(err, FormatError::UnexpectedNode(_)));
301    }
302
303    #[test]
304    fn mac_address_rejects_invalid_inputs() {
305        for input in [
306            "0a:1b:2c:3d:4e",
307            "0a:1b:2c:3d:4e:5f:6a",
308            "0a:1b:2c:3d:4e:zz",
309            "0:1b:2c:3d:4e:5f",
310        ] {
311            let err = MacAddress::from_str(input).expect_err("invalid MAC must fail");
312            assert!(matches!(err, FormatError::MacAddressParse));
313        }
314    }
315}