nessus_parser/report/
item.rs

1use std::{borrow::Cow, collections::HashMap, str::FromStr};
2
3use jiff::civil::Date;
4use roxmltree::Node;
5
6use crate::{StringStorageExt, error::FormatError};
7
8#[derive(Debug, PartialEq, Eq, Clone, Copy, PartialOrd, Ord)]
9pub enum Protocol {
10    Tcp,
11    Udp,
12    Icmp,
13}
14
15impl Protocol {
16    #[must_use]
17    pub const fn as_str(self) -> &'static str {
18        match self {
19            Self::Tcp => "tcp",
20            Self::Udp => "udp",
21            Self::Icmp => "icmp",
22        }
23    }
24}
25
26impl FromStr for Protocol {
27    type Err = FormatError;
28
29    fn from_str(s: &str) -> Result<Self, Self::Err> {
30        match s {
31            "tcp" => Ok(Self::Tcp),
32            "udp" => Ok(Self::Udp),
33            "icmp" => Ok(Self::Icmp),
34            other => Err(FormatError::UnexpectedProtocol(other.into())),
35        }
36    }
37}
38
39#[derive(Debug, PartialEq, Eq, Clone, Copy)]
40pub enum PluginType {
41    Summary,
42    Remote,
43    Combined,
44    Local,
45}
46
47impl FromStr for PluginType {
48    type Err = FormatError;
49
50    fn from_str(s: &str) -> Result<Self, Self::Err> {
51        match s {
52            "summary" => Ok(Self::Summary),
53            "remote" => Ok(Self::Remote),
54            "combined" => Ok(Self::Combined),
55            "local" => Ok(Self::Local),
56            other => Err(FormatError::UnexpectedPluginType(other.into())),
57        }
58    }
59}
60
61#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)]
62pub enum Level {
63    None = 0,
64    Low = 1,
65    Medium = 2,
66    High = 3,
67    Critical = 4,
68}
69
70impl Level {
71    fn from_int(int: &str) -> Result<Self, FormatError> {
72        match int {
73            "0" => Ok(Self::None),
74            "1" => Ok(Self::Low),
75            "2" => Ok(Self::Medium),
76            "3" => Ok(Self::High),
77            "4" => Ok(Self::Critical),
78            other => Err(FormatError::UnexpectedLevel(other.into())),
79        }
80    }
81
82    fn from_text(s: &str) -> Result<Self, FormatError> {
83        match s {
84            "None" => Ok(Self::None),
85            "Low" => Ok(Self::Low),
86            "Medium" => Ok(Self::Medium),
87            "High" => Ok(Self::High),
88            "Critical" => Ok(Self::Critical),
89            other => Err(FormatError::UnexpectedLevel(other.into())),
90        }
91    }
92}
93
94/// Represents a `<ReportItem>` element, which is a single finding or piece of
95/// information reported by a Nessus plugin for a specific host and port.
96#[derive(Debug)]
97pub struct Item<'input> {
98    /// The unique identifier of the Nessus plugin that generated this item.
99    pub plugin_id: u32,
100    /// The name of the plugin.
101    pub plugin_name: Cow<'input, str>,
102    /// The port number associated with this finding.
103    pub port: u16,
104    /// The protocol associated with the port (TCP, UDP, ICMP).
105    pub protocol: Protocol,
106    /// The service name discovered on the port (e.g., "www", "general").
107    pub svc_name: &'input str,
108    /// The severity level of the finding.
109    pub severity: Level,
110    /// The family that the plugin belongs to (e.g., "Windows", "CGI abuses").
111    pub plugin_family: &'input str,
112    /// The raw output from the plugin, which often contains detailed evidence.
113    pub plugin_output: Option<Cow<'input, str>>,
114
115    /// The suggested solution or remediation for the finding.
116    pub solution: Cow<'input, str>,
117    /// The version of the plugin script.
118    // $Revision: 1.234 $ | 1.234
119    pub script_version: &'input str,
120    /// The risk factor associated with the finding (e.g., "High", "None").
121    pub risk_factor: Level,
122    /// The type of the plugin (e.g., remote, local).
123    pub plugin_type: PluginType,
124    /// The date when the plugin was first published.
125    pub plugin_publication_date: jiff::civil::Date,
126    /// The date when the plugin was last modified.
127    pub plugin_modification_date: jiff::civil::Date,
128    /// The filename of the plugin script (e.g., "example.nasl").
129    pub fname: &'input str,
130    /// A detailed description of the vulnerability or finding.
131    pub description: Cow<'input, str>,
132    /// A boolean indicating if a known exploit for the vulnerability exists.
133    pub exploit_available: bool,
134    /// A boolean indicating if the vulnerability was exploited by Nessus during the scan.
135    pub exploited_by_nessus: bool,
136    /// A description of how easy it is to exploit the vulnerability.
137    // "Exploits are available" | "No known exploits are available" | "No exploit is required"
138    pub exploitability_ease: Option<&'input str>,
139
140    /// The agent type the plugin is applicable to ("all", "unix", "windows").
141    pub agent: Option<&'input str>,
142
143    /// The CVSSv2 vector string.
144    pub cvss_vector: Option<&'input str>,
145    /// The CVSSv2 temporal vector string.
146    pub cvss_temporal_vector: Option<&'input str>,
147    /// The CVSSv3 vector string.
148    pub cvss3_vector: Option<&'input str>,
149    /// The CVSSv3 temporal vector string.
150    pub cvss3_temporal_vector: Option<&'input str>,
151    /// The CVSSv4 vector string.
152    pub cvss4_vector: Option<&'input str>,
153    /// The CVSSv4 threat vector string.
154    pub cvss4_threat_vector: Option<&'input str>,
155
156    /// A map to hold any other elements from the ReportItem not explicitly
157    /// parsed into other fields. The key is the XML tag name.
158    pub others: HashMap<&'input str, Vec<Cow<'input, str>>>,
159}
160
161impl<'input> Item<'input> {
162    #[expect(clippy::too_many_lines, clippy::similar_names)]
163    pub(crate) fn from_xml_node(node: Node<'_, 'input>) -> Result<Self, FormatError> {
164        let mut plugin_id = None;
165        let mut plugin_name = None;
166        let mut port = None;
167        let mut protocol = None;
168        let mut svc_name = None;
169        let mut severity = None;
170        let mut plugin_family = None;
171
172        for attribute in node.attributes() {
173            match attribute.name() {
174                "pluginID" => {
175                    if plugin_id.is_some() {
176                        return Err(FormatError::RepeatedTag("pluginID"));
177                    }
178                    plugin_id = Some(attribute.value_storage().parse()?);
179                }
180                "pluginName" => {
181                    if plugin_name.is_some() {
182                        return Err(FormatError::RepeatedTag("pluginName"));
183                    }
184                    plugin_name = Some(attribute.value_storage().to_cow());
185                }
186                "port" => {
187                    if port.is_some() {
188                        return Err(FormatError::RepeatedTag("port"));
189                    }
190                    port = Some(attribute.value().parse()?);
191                }
192                "protocol" => {
193                    if protocol.is_some() {
194                        return Err(FormatError::RepeatedTag("protocol"));
195                    }
196                    protocol = Some(attribute.value_storage().parse()?);
197                }
198                "svc_name" => {
199                    if svc_name.is_some() {
200                        return Err(FormatError::RepeatedTag("svc_name"));
201                    }
202                    svc_name = Some(attribute.value_storage().to_str()?);
203                }
204                "severity" => {
205                    if severity.is_some() {
206                        return Err(FormatError::RepeatedTag("severity"));
207                    }
208                    severity = Some(Level::from_int(attribute.value())?);
209                }
210                "pluginFamily" => {
211                    if plugin_family.is_some() {
212                        return Err(FormatError::RepeatedTag("pluginFamily"));
213                    }
214                    plugin_family = Some(attribute.value_storage().to_str()?);
215                }
216
217                other => return Err(FormatError::UnexpectedXmlAttribute(other.into())),
218            }
219        }
220
221        let mut plugin_output = None;
222
223        let mut solution = None;
224        let mut script_version = None;
225        let mut risk_factor = None;
226        let mut plugin_type = None;
227        let mut plugin_publication_date = None;
228        let mut plugin_modification_date = None;
229        let mut fname = None;
230        let mut description = None;
231
232        let mut agent = None;
233        let mut cvss_vector = None;
234        let mut cvss3_vector = None;
235        let mut cvss_temporal_vector = None;
236        let mut cvss3_temporal_vector = None;
237        let mut cvss4_vector = None;
238        let mut cvss4_threat_vector = None;
239        let mut exploitability_ease = None;
240        let mut exploit_available = None;
241        let mut exploited_by_nessus = None;
242
243        let mut others: HashMap<_, Vec<_>> = HashMap::new();
244
245        for child in node.children() {
246            if child.is_text() {
247                if let Some(text) = child.text()
248                    && !text.trim().is_empty()
249                {
250                    return Err(FormatError::UnexpectedText(text.into()));
251                }
252                continue;
253            }
254
255            let name = child.tag_name().name();
256            if let Some(value) = child.text_storage() {
257                match name {
258                    "plugin_output" => {
259                        if plugin_output.is_some() {
260                            return Err(FormatError::RepeatedTag("plugin_output"));
261                        }
262                        plugin_output = Some(value.to_cow());
263                    }
264                    "solution" => {
265                        if solution.is_some() {
266                            return Err(FormatError::RepeatedTag("solution"));
267                        }
268                        solution = Some(value.to_cow());
269                    }
270                    "description" => {
271                        if description.is_some() {
272                            return Err(FormatError::RepeatedTag("description"));
273                        }
274                        description = Some(value.to_cow());
275                    }
276
277                    "script_version" => {
278                        if script_version.is_some() {
279                            return Err(FormatError::RepeatedTag("script_version"));
280                        }
281                        script_version = Some(value.to_str()?);
282                    }
283                    "risk_factor" => {
284                        if risk_factor.is_some() {
285                            return Err(FormatError::RepeatedTag("risk_factor"));
286                        }
287                        risk_factor = Some(Level::from_text(value.as_str())?);
288                    }
289                    "plugin_type" => {
290                        if plugin_type.is_some() {
291                            return Err(FormatError::RepeatedTag("plugin_type"));
292                        }
293                        plugin_type = Some(value.parse()?);
294                    }
295                    "plugin_publication_date" => {
296                        if plugin_publication_date.is_some() {
297                            return Err(FormatError::RepeatedTag("plugin_publication_date"));
298                        }
299                        plugin_publication_date = Some(Date::strptime("%Y/%m/%d", value.as_str())?);
300                    }
301                    "plugin_modification_date" => {
302                        if plugin_modification_date.is_some() {
303                            return Err(FormatError::RepeatedTag("plugin_modification_date"));
304                        }
305                        plugin_modification_date =
306                            Some(Date::strptime("%Y/%m/%d", value.as_str())?);
307                    }
308                    "fname" => {
309                        if fname.is_some() {
310                            return Err(FormatError::RepeatedTag("fname"));
311                        }
312                        fname = Some(value.to_str()?);
313                    }
314
315                    "agent" => {
316                        if agent.is_some() {
317                            return Err(FormatError::RepeatedTag("agent"));
318                        }
319                        agent = Some(value.to_str()?);
320                    }
321                    "cvss_vector" => {
322                        if cvss_vector.is_some() {
323                            return Err(FormatError::RepeatedTag("cvss_vector"));
324                        }
325                        cvss_vector = Some(value.to_str()?);
326                    }
327                    "cvss3_vector" => {
328                        if cvss3_vector.is_some() {
329                            return Err(FormatError::RepeatedTag("cvss3_vector"));
330                        }
331                        cvss3_vector = Some(value.to_str()?);
332                    }
333                    "cvss_temporal_vector" => {
334                        if cvss_temporal_vector.is_some() {
335                            return Err(FormatError::RepeatedTag("cvss_temporal_vector"));
336                        }
337                        cvss_temporal_vector = Some(value.to_str()?);
338                    }
339                    "cvss3_temporal_vector" => {
340                        if cvss3_temporal_vector.is_some() {
341                            return Err(FormatError::RepeatedTag("cvss3_temporal_vector"));
342                        }
343                        cvss3_temporal_vector = Some(value.to_str()?);
344                    }
345                    "cvss4_vector" => {
346                        if cvss4_vector.is_some() {
347                            return Err(FormatError::RepeatedTag("cvss4_vector"));
348                        }
349                        cvss4_vector = Some(value.to_str()?);
350                    }
351                    "cvss4_threat_vector" => {
352                        if cvss4_threat_vector.is_some() {
353                            return Err(FormatError::RepeatedTag("cvss4_threat_vector"));
354                        }
355                        cvss4_threat_vector = Some(value.to_str()?);
356                    }
357                    "exploitability_ease" => {
358                        if exploitability_ease.is_some() {
359                            return Err(FormatError::RepeatedTag("exploitability_ease"));
360                        }
361                        exploitability_ease = Some(value.to_str()?);
362                    }
363                    "exploit_available" => {
364                        if exploit_available.is_some() {
365                            return Err(FormatError::RepeatedTag("exploit_available"));
366                        }
367                        exploit_available = Some(value.as_str() == "true");
368                    }
369                    "exploited_by_nessus" => {
370                        if exploited_by_nessus.is_some() {
371                            return Err(FormatError::RepeatedTag("exploited_by_nessus"));
372                        }
373                        exploited_by_nessus = Some(value.as_str() == "true");
374                    }
375
376                    _ => others.entry(name).or_default().push(value.to_cow()),
377                }
378            }
379        }
380
381        Ok(Self {
382            plugin_id: plugin_id.ok_or(FormatError::MissingAttribute("pluginID"))?,
383            plugin_name: plugin_name.ok_or(FormatError::MissingAttribute("pluginName"))?,
384            port: port.ok_or(FormatError::MissingAttribute("port"))?,
385            protocol: protocol.ok_or(FormatError::MissingAttribute("protocol"))?,
386            svc_name: svc_name.ok_or(FormatError::MissingAttribute("svc_name"))?,
387            severity: severity.ok_or(FormatError::MissingAttribute("severity"))?,
388            plugin_family: plugin_family.ok_or(FormatError::MissingAttribute("pluginFamily"))?,
389            solution: solution.ok_or(FormatError::MissingTag("solution"))?,
390            script_version: script_version.ok_or(FormatError::MissingTag("script_version"))?,
391            risk_factor: risk_factor.ok_or(FormatError::MissingTag("risk_factor"))?,
392            plugin_type: plugin_type.ok_or(FormatError::MissingTag("plugin_type"))?,
393            plugin_publication_date: plugin_publication_date
394                .ok_or(FormatError::MissingTag("plugin_publication_date"))?,
395            plugin_modification_date: plugin_modification_date
396                .ok_or(FormatError::MissingTag("plugin_modification_date"))?,
397            fname: fname.ok_or(FormatError::MissingTag("fname"))?,
398            description: description.ok_or(FormatError::MissingTag("description"))?,
399            plugin_output,
400            agent,
401            cvss_vector,
402            cvss3_vector,
403            cvss_temporal_vector,
404            cvss3_temporal_vector,
405            cvss4_vector,
406            cvss4_threat_vector,
407            exploitability_ease,
408            exploit_available: exploit_available == Some(true),
409            exploited_by_nessus: exploited_by_nessus == Some(true),
410            others,
411        })
412    }
413}