1use std::{borrow::Cow, collections::HashMap, str::FromStr};
4
5use jiff::civil::Date;
6use roxmltree::Node;
7
8use crate::{StringStorageExt, error::FormatError};
9
10#[derive(Debug, PartialEq, Eq, Clone, Copy, PartialOrd, Ord)]
12pub enum Protocol {
13 Tcp,
15 Udp,
17 Icmp,
19}
20
21impl Protocol {
22 #[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#[derive(Debug, PartialEq, Eq, Clone, Copy)]
48pub enum PluginType {
49 Summary,
51 Remote,
53 Combined,
55 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#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)]
75pub enum Level {
76 None = 0,
78 Low = 1,
80 Medium = 2,
82 High = 3,
84 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#[derive(Debug)]
115pub struct Item<'input> {
116 pub plugin_id: u32,
118 pub plugin_name: Cow<'input, str>,
120 pub port: u16,
122 pub protocol: Protocol,
124 pub svc_name: &'input str,
126 pub severity: Level,
128 pub plugin_family: &'input str,
130 pub plugin_output: Option<Cow<'input, str>>,
132
133 pub solution: Cow<'input, str>,
135 pub script_version: &'input str,
138 pub risk_factor: Level,
140 pub plugin_type: PluginType,
142 pub plugin_publication_date: jiff::civil::Date,
144 pub plugin_modification_date: jiff::civil::Date,
146 pub fname: &'input str,
148 pub description: Cow<'input, str>,
150 pub exploit_available: bool,
152 pub exploited_by_nessus: bool,
154 pub exploitability_ease: Option<&'input str>,
157
158 pub agent: Option<&'input str>,
160
161 pub cvss_vector: Option<&'input str>,
163 pub cvss_temporal_vector: Option<&'input str>,
165 pub cvss3_vector: Option<&'input str>,
167 pub cvss3_temporal_vector: Option<&'input str>,
169 pub cvss4_vector: Option<&'input str>,
171 pub cvss4_threat_vector: Option<&'input str>,
173
174 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 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 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 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}