Skip to main content

nessus_parser/report/
item.rs

1//! Types for Nessus `<ReportItem>` findings and their normalized fields.
2
3use std::{borrow::Cow, collections::HashMap, str::FromStr};
4
5use jiff::civil::Date;
6use roxmltree::Node;
7
8use crate::{StringStorageExt, error::FormatError};
9
10/// Network transport protocol attached to a report item.
11#[derive(Debug, PartialEq, Eq, Clone, Copy, PartialOrd, Ord)]
12pub enum Protocol {
13    /// TCP.
14    Tcp,
15    /// UDP.
16    Udp,
17    /// ICMP.
18    Icmp,
19}
20
21impl Protocol {
22    /// Returns the lowercase protocol name used in Nessus XML.
23    #[must_use]
24    pub const fn as_str(self) -> &'static str {
25        match self {
26            Self::Tcp => "tcp",
27            Self::Udp => "udp",
28            Self::Icmp => "icmp",
29        }
30    }
31}
32
33impl FromStr for Protocol {
34    type Err = FormatError;
35
36    fn from_str(s: &str) -> Result<Self, Self::Err> {
37        match s {
38            "tcp" => Ok(Self::Tcp),
39            "udp" => Ok(Self::Udp),
40            "icmp" => Ok(Self::Icmp),
41            other => Err(FormatError::UnexpectedProtocol(other.into())),
42        }
43    }
44}
45
46/// Execution context/type of a Nessus plugin finding.
47#[derive(Debug, PartialEq, Eq, Clone, Copy)]
48pub enum PluginType {
49    /// Summary-level plugin output.
50    Summary,
51    /// Remote network check.
52    Remote,
53    /// Combined local+remote behavior.
54    Combined,
55    /// Local check on the scanned host.
56    Local,
57}
58
59impl FromStr for PluginType {
60    type Err = FormatError;
61
62    fn from_str(s: &str) -> Result<Self, Self::Err> {
63        match s {
64            "summary" => Ok(Self::Summary),
65            "remote" => Ok(Self::Remote),
66            "combined" => Ok(Self::Combined),
67            "local" => Ok(Self::Local),
68            other => Err(FormatError::UnexpectedPluginType(other.into())),
69        }
70    }
71}
72
73/// Normalized severity/risk level.
74#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)]
75pub enum Level {
76    /// Informational / no severity.
77    None = 0,
78    /// Low severity.
79    Low = 1,
80    /// Medium severity.
81    Medium = 2,
82    /// High severity.
83    High = 3,
84    /// Critical severity.
85    Critical = 4,
86}
87
88impl Level {
89    fn from_int(int: &str) -> Result<Self, FormatError> {
90        match int {
91            "0" => Ok(Self::None),
92            "1" => Ok(Self::Low),
93            "2" => Ok(Self::Medium),
94            "3" => Ok(Self::High),
95            "4" => Ok(Self::Critical),
96            other => Err(FormatError::UnexpectedLevel(other.into())),
97        }
98    }
99
100    fn from_text(s: &str) -> Result<Self, FormatError> {
101        match s {
102            "None" => Ok(Self::None),
103            "Low" => Ok(Self::Low),
104            "Medium" => Ok(Self::Medium),
105            "High" => Ok(Self::High),
106            "Critical" => Ok(Self::Critical),
107            other => Err(FormatError::UnexpectedLevel(other.into())),
108        }
109    }
110}
111
112/// Represents a `<ReportItem>` element, which is a single finding or piece of
113/// information reported by a Nessus plugin for a specific host and port.
114#[derive(Debug)]
115pub struct Item<'input> {
116    /// The unique identifier of the Nessus plugin that generated this item.
117    pub plugin_id: u32,
118    /// The name of the plugin.
119    pub plugin_name: Cow<'input, str>,
120    /// The port number associated with this finding.
121    pub port: u16,
122    /// The protocol associated with the port (TCP, UDP, ICMP).
123    pub protocol: Protocol,
124    /// The service name discovered on the port (e.g., "www", "general").
125    pub svc_name: &'input str,
126    /// The severity level of the finding.
127    pub severity: Level,
128    /// The family that the plugin belongs to (e.g., "Windows", "CGI abuses").
129    pub plugin_family: &'input str,
130    /// The raw output from the plugin, which often contains detailed evidence.
131    pub plugin_output: Option<Cow<'input, str>>,
132
133    /// The suggested solution or remediation for the finding.
134    pub solution: Cow<'input, str>,
135    /// The version of the plugin script.
136    // $Revision: 1.234 $ | 1.234
137    pub script_version: &'input str,
138    /// The risk factor associated with the finding (e.g., "High", "None").
139    pub risk_factor: Level,
140    /// The type of the plugin (e.g., remote, local).
141    pub plugin_type: PluginType,
142    /// The date when the plugin was first published.
143    pub plugin_publication_date: jiff::civil::Date,
144    /// The date when the plugin was last modified.
145    pub plugin_modification_date: jiff::civil::Date,
146    /// The filename of the plugin script (e.g., "example.nasl").
147    pub fname: &'input str,
148    /// A detailed description of the vulnerability or finding.
149    pub description: Cow<'input, str>,
150    /// A boolean indicating if a known exploit for the vulnerability exists.
151    pub exploit_available: bool,
152    /// A boolean indicating if the vulnerability was exploited by Nessus during the scan.
153    pub exploited_by_nessus: bool,
154    /// A description of how easy it is to exploit the vulnerability.
155    // "Exploits are available" | "No known exploits are available" | "No exploit is required"
156    pub exploitability_ease: Option<&'input str>,
157
158    /// The agent type the plugin is applicable to ("all", "unix", "windows").
159    pub agent: Option<&'input str>,
160
161    /// The `CVSSv2` vector string.
162    pub cvss_vector: Option<&'input str>,
163    /// The `CVSSv2` temporal vector string.
164    pub cvss_temporal_vector: Option<&'input str>,
165    /// The `CVSSv3` vector string.
166    pub cvss3_vector: Option<&'input str>,
167    /// The `CVSSv3` temporal vector string.
168    pub cvss3_temporal_vector: Option<&'input str>,
169    /// The `CVSSv4` vector string.
170    pub cvss4_vector: Option<&'input str>,
171    /// The `CVSSv4` threat vector string.
172    pub cvss4_threat_vector: Option<&'input str>,
173
174    /// A map to hold any other elements from the `ReportItem` not explicitly
175    /// parsed into other fields. The key is the XML tag name.
176    pub others: HashMap<&'input str, Vec<Cow<'input, str>>>,
177}
178
179impl<'input> Item<'input> {
180    #[expect(clippy::too_many_lines, clippy::similar_names)]
181    pub(crate) fn from_xml_node(node: Node<'_, 'input>) -> Result<Self, FormatError> {
182        let mut plugin_id = None;
183        let mut plugin_name = None;
184        let mut port = None;
185        let mut protocol = None;
186        let mut svc_name = None;
187        let mut severity = None;
188        let mut plugin_family = None;
189
190        for attribute in node.attributes() {
191            match attribute.name() {
192                "pluginID" => {
193                    if plugin_id.is_some() {
194                        return Err(FormatError::RepeatedTag("pluginID"));
195                    }
196                    plugin_id = Some(attribute.value_storage().parse()?);
197                }
198                "pluginName" => {
199                    if plugin_name.is_some() {
200                        return Err(FormatError::RepeatedTag("pluginName"));
201                    }
202                    plugin_name = Some(attribute.value_storage().to_cow());
203                }
204                "port" => {
205                    if port.is_some() {
206                        return Err(FormatError::RepeatedTag("port"));
207                    }
208                    port = Some(attribute.value().parse()?);
209                }
210                "protocol" => {
211                    if protocol.is_some() {
212                        return Err(FormatError::RepeatedTag("protocol"));
213                    }
214                    protocol = Some(attribute.value_storage().parse()?);
215                }
216                "svc_name" => {
217                    if svc_name.is_some() {
218                        return Err(FormatError::RepeatedTag("svc_name"));
219                    }
220                    svc_name = Some(attribute.value_storage().to_str()?);
221                }
222                "severity" => {
223                    if severity.is_some() {
224                        return Err(FormatError::RepeatedTag("severity"));
225                    }
226                    severity = Some(Level::from_int(attribute.value())?);
227                }
228                "pluginFamily" => {
229                    if plugin_family.is_some() {
230                        return Err(FormatError::RepeatedTag("pluginFamily"));
231                    }
232                    plugin_family = Some(attribute.value_storage().to_str()?);
233                }
234
235                // Intentional: we fail fast on unknown attributes to keep the parser strict.
236                other => return Err(FormatError::UnexpectedXmlAttribute(other.into())),
237            }
238        }
239
240        let mut plugin_output = None;
241
242        let mut solution = None;
243        let mut script_version = None;
244        let mut risk_factor = None;
245        let mut plugin_type = None;
246        let mut plugin_publication_date = None;
247        let mut plugin_modification_date = None;
248        let mut fname = None;
249        let mut description = None;
250
251        let mut agent = None;
252        let mut cvss_vector = None;
253        let mut cvss3_vector = None;
254        let mut cvss_temporal_vector = None;
255        let mut cvss3_temporal_vector = None;
256        let mut cvss4_vector = None;
257        let mut cvss4_threat_vector = None;
258        let mut exploitability_ease = None;
259        let mut exploit_available = None;
260        let mut exploited_by_nessus = None;
261
262        let mut others: HashMap<_, Vec<_>> = HashMap::new();
263
264        for child in node.children() {
265            if child.is_text() {
266                if let Some(text) = child.text()
267                    && !text.trim().is_empty()
268                {
269                    return Err(FormatError::UnexpectedText(text.into()));
270                }
271                continue;
272            }
273
274            let name = child.tag_name().name();
275            if let Some(value) = child.text_storage() {
276                match name {
277                    "plugin_output" => {
278                        if plugin_output.is_some() {
279                            return Err(FormatError::RepeatedTag("plugin_output"));
280                        }
281                        plugin_output = Some(value.to_cow());
282                    }
283                    "solution" => {
284                        if solution.is_some() {
285                            return Err(FormatError::RepeatedTag("solution"));
286                        }
287                        solution = Some(value.to_cow());
288                    }
289                    "description" => {
290                        if description.is_some() {
291                            return Err(FormatError::RepeatedTag("description"));
292                        }
293                        description = Some(value.to_cow());
294                    }
295
296                    "script_version" => {
297                        if script_version.is_some() {
298                            return Err(FormatError::RepeatedTag("script_version"));
299                        }
300                        script_version = Some(value.to_str()?);
301                    }
302                    "risk_factor" => {
303                        if risk_factor.is_some() {
304                            return Err(FormatError::RepeatedTag("risk_factor"));
305                        }
306                        risk_factor = Some(Level::from_text(value.as_str())?);
307                    }
308                    "plugin_type" => {
309                        if plugin_type.is_some() {
310                            return Err(FormatError::RepeatedTag("plugin_type"));
311                        }
312                        plugin_type = Some(value.parse()?);
313                    }
314                    "plugin_publication_date" => {
315                        if plugin_publication_date.is_some() {
316                            return Err(FormatError::RepeatedTag("plugin_publication_date"));
317                        }
318                        plugin_publication_date = Some(Date::strptime("%Y/%m/%d", value.as_str())?);
319                    }
320                    "plugin_modification_date" => {
321                        if plugin_modification_date.is_some() {
322                            return Err(FormatError::RepeatedTag("plugin_modification_date"));
323                        }
324                        plugin_modification_date =
325                            Some(Date::strptime("%Y/%m/%d", value.as_str())?);
326                    }
327                    "fname" => {
328                        if fname.is_some() {
329                            return Err(FormatError::RepeatedTag("fname"));
330                        }
331                        fname = Some(value.to_str()?);
332                    }
333
334                    "agent" => {
335                        if agent.is_some() {
336                            return Err(FormatError::RepeatedTag("agent"));
337                        }
338                        agent = Some(value.to_str()?);
339                    }
340                    "cvss_vector" => {
341                        if cvss_vector.is_some() {
342                            return Err(FormatError::RepeatedTag("cvss_vector"));
343                        }
344                        cvss_vector = Some(value.to_str()?);
345                    }
346                    "cvss3_vector" => {
347                        if cvss3_vector.is_some() {
348                            return Err(FormatError::RepeatedTag("cvss3_vector"));
349                        }
350                        cvss3_vector = Some(value.to_str()?);
351                    }
352                    "cvss_temporal_vector" => {
353                        if cvss_temporal_vector.is_some() {
354                            return Err(FormatError::RepeatedTag("cvss_temporal_vector"));
355                        }
356                        cvss_temporal_vector = Some(value.to_str()?);
357                    }
358                    "cvss3_temporal_vector" => {
359                        if cvss3_temporal_vector.is_some() {
360                            return Err(FormatError::RepeatedTag("cvss3_temporal_vector"));
361                        }
362                        cvss3_temporal_vector = Some(value.to_str()?);
363                    }
364                    "cvss4_vector" => {
365                        if cvss4_vector.is_some() {
366                            return Err(FormatError::RepeatedTag("cvss4_vector"));
367                        }
368                        cvss4_vector = Some(value.to_str()?);
369                    }
370                    "cvss4_threat_vector" => {
371                        if cvss4_threat_vector.is_some() {
372                            return Err(FormatError::RepeatedTag("cvss4_threat_vector"));
373                        }
374                        cvss4_threat_vector = Some(value.to_str()?);
375                    }
376                    "exploitability_ease" => {
377                        if exploitability_ease.is_some() {
378                            return Err(FormatError::RepeatedTag("exploitability_ease"));
379                        }
380                        exploitability_ease = Some(value.to_str()?);
381                    }
382                    "exploit_available" => {
383                        if exploit_available.is_some() {
384                            return Err(FormatError::RepeatedTag("exploit_available"));
385                        }
386                        // Intentional: any non-"true" value (including invalid strings) is treated as false.
387                        exploit_available = Some(value.as_str() == "true");
388                    }
389                    "exploited_by_nessus" => {
390                        if exploited_by_nessus.is_some() {
391                            return Err(FormatError::RepeatedTag("exploited_by_nessus"));
392                        }
393                        // Intentional: any non-"true" value (including invalid strings) is treated as false.
394                        exploited_by_nessus = Some(value.as_str() == "true");
395                    }
396
397                    _ => others.entry(name).or_default().push(value.to_cow()),
398                }
399            } else {
400                return Err(FormatError::UnexpectedNode(name.into()));
401            }
402        }
403
404        Ok(Self {
405            plugin_id: plugin_id.ok_or(FormatError::MissingAttribute("pluginID"))?,
406            plugin_name: plugin_name.ok_or(FormatError::MissingAttribute("pluginName"))?,
407            port: port.ok_or(FormatError::MissingAttribute("port"))?,
408            protocol: protocol.ok_or(FormatError::MissingAttribute("protocol"))?,
409            svc_name: svc_name.ok_or(FormatError::MissingAttribute("svc_name"))?,
410            severity: severity.ok_or(FormatError::MissingAttribute("severity"))?,
411            plugin_family: plugin_family.ok_or(FormatError::MissingAttribute("pluginFamily"))?,
412            solution: solution.ok_or(FormatError::MissingTag("solution"))?,
413            script_version: script_version.ok_or(FormatError::MissingTag("script_version"))?,
414            risk_factor: risk_factor.ok_or(FormatError::MissingTag("risk_factor"))?,
415            plugin_type: plugin_type.ok_or(FormatError::MissingTag("plugin_type"))?,
416            plugin_publication_date: plugin_publication_date
417                .ok_or(FormatError::MissingTag("plugin_publication_date"))?,
418            plugin_modification_date: plugin_modification_date
419                .ok_or(FormatError::MissingTag("plugin_modification_date"))?,
420            fname: fname.ok_or(FormatError::MissingTag("fname"))?,
421            description: description.ok_or(FormatError::MissingTag("description"))?,
422            plugin_output,
423            agent,
424            cvss_vector,
425            cvss3_vector,
426            cvss_temporal_vector,
427            cvss3_temporal_vector,
428            cvss4_vector,
429            cvss4_threat_vector,
430            exploitability_ease,
431            exploit_available: exploit_available == Some(true),
432            exploited_by_nessus: exploited_by_nessus == Some(true),
433            others,
434        })
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use roxmltree::Document;
441
442    use crate::error::FormatError;
443
444    use super::{Item, Level, PluginType, Protocol};
445
446    fn parse_item(xml: &str) -> Result<Item<'_>, FormatError> {
447        let doc = Document::parse(xml).expect("test XML should parse");
448        let node = doc.root_element();
449        Item::from_xml_node(node)
450    }
451
452    fn minimal_item_xml(extra_attributes: &str, extra_children: &str) -> String {
453        format!(
454            r#"<ReportItem pluginID="1" pluginName="x" port="80" protocol="tcp" svc_name="www" severity="2" pluginFamily="General" {extra_attributes}>
455  <solution>fix</solution>
456  <script_version>1.0</script_version>
457  <risk_factor>Medium</risk_factor>
458  <plugin_type>remote</plugin_type>
459  <plugin_publication_date>2024/01/01</plugin_publication_date>
460  <plugin_modification_date>2024/01/02</plugin_modification_date>
461  <fname>x.nasl</fname>
462  <description>desc</description>
463  {extra_children}
464</ReportItem>"#
465        )
466    }
467
468    #[test]
469    fn protocol_and_plugin_type_parsing_cover_invalid_values() {
470        assert!(matches!("tcp".parse(), Ok(Protocol::Tcp)));
471        assert!(matches!(
472            "not-proto".parse::<Protocol>(),
473            Err(FormatError::UnexpectedProtocol(_))
474        ));
475        assert!(matches!("local".parse(), Ok(PluginType::Local)));
476        assert!(matches!(
477            "bad".parse::<PluginType>(),
478            Err(FormatError::UnexpectedPluginType(_))
479        ));
480    }
481
482    #[test]
483    fn item_rejects_unknown_attribute() {
484        let xml = minimal_item_xml(r#"bad="x""#, "");
485        let err = parse_item(&xml).expect_err("must fail");
486        assert!(matches!(err, FormatError::UnexpectedXmlAttribute(_)));
487    }
488
489    #[test]
490    fn item_rejects_non_empty_text_node() {
491        let xml = minimal_item_xml("", "hello");
492        let err = parse_item(&xml).expect_err("must fail");
493        assert!(matches!(err, FormatError::UnexpectedText(_)));
494    }
495
496    #[test]
497    fn item_rejects_missing_required_fields() {
498        let xml = r#"<ReportItem pluginID="1" pluginName="x" port="80" protocol="tcp" svc_name="www" severity="2" pluginFamily="General"></ReportItem>"#;
499        let err = parse_item(xml).expect_err("must fail");
500        assert!(matches!(err, FormatError::MissingTag("solution")));
501    }
502
503    #[test]
504    fn item_rejects_repeated_child_tag() {
505        let xml = minimal_item_xml("", "<solution>again</solution>");
506        let err = parse_item(&xml).expect_err("must fail");
507        assert!(matches!(err, FormatError::RepeatedTag("solution")));
508    }
509
510    #[test]
511    fn item_coerces_exploit_booleans() {
512        let xml = minimal_item_xml(
513            "",
514            r"
515  <exploit_available>true</exploit_available>
516  <exploited_by_nessus>not-true</exploited_by_nessus>
517",
518        );
519        let item = parse_item(&xml).expect("must parse");
520        assert!(item.exploit_available);
521        assert!(!item.exploited_by_nessus);
522    }
523
524    #[test]
525    fn level_invalid_values_fail() {
526        let xml = minimal_item_xml("", "").replace(
527            "<risk_factor>Medium</risk_factor>",
528            "<risk_factor>NotALevel</risk_factor>",
529        );
530        let err = parse_item(&xml).expect_err("must fail");
531        assert!(matches!(err, FormatError::UnexpectedLevel(_)));
532
533        assert!(matches!(
534            Level::from_int("9"),
535            Err(FormatError::UnexpectedLevel(_))
536        ));
537        assert!(matches!(
538            Level::from_text("NotALevel"),
539            Err(FormatError::UnexpectedLevel(_))
540        ));
541    }
542}