wazuh_client/
vulnerability.rs

1use reqwest::Method;
2use serde::{Deserialize, Serialize};
3use serde_json::{json, Value};
4use std::fmt;
5use tracing::{debug, info, warn};
6
7use super::indexer_client::WazuhIndexerClient;
8use super::wazuh_client::WazuhApiClient;
9
10mod string_or_number_as_string {
11    use serde::{Deserialize, Deserializer};
12
13    pub fn deserialize<'de, D>(deserializer: D) -> Result<String, D::Error>
14    where
15        D: Deserializer<'de>,
16    {
17        #[derive(Deserialize)]
18        #[serde(untagged)]
19        enum StringOrNumber {
20            Str(String),
21            Num(serde_json::Number),
22        }
23
24        match StringOrNumber::deserialize(deserializer)? {
25            StringOrNumber::Str(s) => Ok(s),
26            StringOrNumber::Num(n) => Ok(n.to_string()),
27        }
28    }
29
30    pub fn deserialize_optional<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
31    where
32        D: Deserializer<'de>,
33    {
34        #[derive(Deserialize)]
35        #[serde(untagged)]
36        enum StringOrNumberOrNull {
37            Str(String),
38            Num(serde_json::Number),
39        }
40
41        let intermediate: Option<StringOrNumberOrNull> = Option::deserialize(deserializer)?;
42
43        match intermediate {
44            Some(StringOrNumberOrNull::Str(s)) => Ok(Some(s)),
45            Some(StringOrNumberOrNull::Num(n)) => Ok(Some(n.to_string())),
46            None => Ok(None),
47        }
48    }
49}
50use super::error::WazuhApiError;
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
53#[serde(rename_all = "PascalCase")]
54pub enum VulnerabilitySeverity {
55    Critical,
56    High,
57    Medium,
58    Low,
59}
60
61impl VulnerabilitySeverity {
62    /// Convert to the format expected by the Wazuh indexer (first letter uppercase, rest lowercase)
63    pub fn to_indexer_format(&self) -> &'static str {
64        match self {
65            VulnerabilitySeverity::Critical => "Critical",
66            VulnerabilitySeverity::High => "High",
67            VulnerabilitySeverity::Medium => "Medium",
68            VulnerabilitySeverity::Low => "Low",
69        }
70    }
71
72    /// Parse from string (case-insensitive)
73    pub fn parse(s: &str) -> Option<Self> {
74        match s.to_lowercase().as_str() {
75            "critical" => Some(VulnerabilitySeverity::Critical),
76            "high" => Some(VulnerabilitySeverity::High),
77            "medium" => Some(VulnerabilitySeverity::Medium),
78            "low" => Some(VulnerabilitySeverity::Low),
79            _ => None,
80        }
81    }
82
83    /// Get all severity levels in order from highest to lowest
84    pub fn all() -> [VulnerabilitySeverity; 4] {
85        [
86            VulnerabilitySeverity::Critical,
87            VulnerabilitySeverity::High,
88            VulnerabilitySeverity::Medium,
89            VulnerabilitySeverity::Low,
90        ]
91    }
92}
93
94impl fmt::Display for VulnerabilitySeverity {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        write!(f, "{}", self.to_indexer_format())
97    }
98}
99
100impl From<VulnerabilitySeverity> for String {
101    fn from(severity: VulnerabilitySeverity) -> Self {
102        severity.to_indexer_format().to_string()
103    }
104}
105
106#[derive(Debug, Clone, Deserialize, Serialize)]
107pub struct Vulnerability {
108    pub cve: String,
109    pub title: String,
110    #[serde(deserialize_with = "deserialize_severity")]
111    pub severity: VulnerabilitySeverity,
112    pub published: Option<String>,
113    pub updated: Option<String>,
114    pub reference: Option<String>,
115    pub description: Option<String>,
116    pub cvss: Option<CvssScore>,
117    pub detection_time: Option<String>,
118    pub agent_id: Option<String>,
119    pub agent_name: Option<String>,
120}
121
122fn deserialize_severity<'de, D>(deserializer: D) -> Result<VulnerabilitySeverity, D::Error>
123where
124    D: serde::Deserializer<'de>,
125{
126    let s = String::deserialize(deserializer)?;
127    VulnerabilitySeverity::parse(&s)
128        .ok_or_else(|| serde::de::Error::custom(format!("Invalid severity: {}", s)))
129}
130
131#[derive(Debug, Clone, Deserialize, Serialize)]
132pub struct CvssScore {
133    pub cvss2: Option<CvssDetails>,
134    pub cvss3: Option<CvssDetails>,
135}
136
137#[derive(Debug, Clone, Deserialize, Serialize)]
138pub struct CvssDetails {
139    pub vector: Option<String>,
140    pub base_score: Option<f64>,
141}
142
143#[derive(Debug, Clone, Deserialize, Serialize)]
144pub struct Package {
145    pub name: String,
146    pub version: String,
147    pub architecture: Option<String>,
148    pub format: Option<String>,
149    pub description: Option<String>,
150    pub size: Option<u64>,
151    pub vendor: Option<String>,
152    pub multiarch: Option<String>,
153    pub source: Option<String>,
154    pub priority: Option<String>,
155    pub scan_id: Option<u64>,
156    pub section: Option<String>,
157    pub agent_id: Option<String>,
158}
159
160#[derive(Debug, Clone, Deserialize, Serialize)]
161pub struct Process {
162    #[serde(deserialize_with = "string_or_number_as_string::deserialize")]
163    pub pid: String,
164    pub name: String,
165    pub state: Option<String>,
166    #[serde(
167        default,
168        deserialize_with = "string_or_number_as_string::deserialize_optional"
169    )]
170    pub ppid: Option<String>,
171    #[serde(
172        default,
173        deserialize_with = "string_or_number_as_string::deserialize_optional"
174    )]
175    pub utime: Option<String>,
176    #[serde(
177        default,
178        deserialize_with = "string_or_number_as_string::deserialize_optional"
179    )]
180    pub stime: Option<String>,
181    pub cmd: Option<String>,
182    pub argvs: Option<String>,
183    pub euser: Option<String>,
184    pub ruser: Option<String>,
185    pub suser: Option<String>,
186    pub egroup: Option<String>,
187    pub rgroup: Option<String>,
188    pub sgroup: Option<String>,
189    pub fgroup: Option<String>,
190    pub priority: Option<i32>,
191    pub nice: Option<i32>,
192    pub size: Option<u64>,
193    pub vm_size: Option<u64>,
194    pub resident: Option<u64>,
195    pub share: Option<u64>,
196    #[serde(
197        default,
198        deserialize_with = "string_or_number_as_string::deserialize_optional"
199    )]
200    pub start_time: Option<String>,
201    #[serde(
202        default,
203        deserialize_with = "string_or_number_as_string::deserialize_optional"
204    )]
205    pub pgrp: Option<String>,
206    #[serde(
207        default,
208        deserialize_with = "string_or_number_as_string::deserialize_optional"
209    )]
210    pub session: Option<String>,
211    pub nlwp: Option<u32>,
212    #[serde(
213        default,
214        deserialize_with = "string_or_number_as_string::deserialize_optional"
215    )]
216    pub tgid: Option<String>,
217    #[serde(
218        default,
219        deserialize_with = "string_or_number_as_string::deserialize_optional"
220    )]
221    pub tty: Option<String>,
222    #[serde(
223        default,
224        deserialize_with = "string_or_number_as_string::deserialize_optional"
225    )]
226    pub processor: Option<String>,
227    pub scan_id: Option<u64>,
228    pub agent_id: Option<String>,
229}
230
231#[derive(Debug, Clone, Deserialize, Serialize)]
232pub struct Port {
233    pub local: PortInfo,
234    pub remote: Option<PortInfo>,
235    pub state: Option<String>,
236    pub protocol: String,
237    pub tx_queue: Option<u32>,
238    pub rx_queue: Option<u32>,
239    pub inode: Option<u64>,
240    pub process: Option<String>,
241    pub scan_id: Option<u64>,
242    pub agent_id: Option<String>,
243    pub pid: Option<u32>,
244}
245
246#[derive(Debug, Clone, Deserialize, Serialize)]
247pub struct VulnerabilitySummaryCounts {
248    pub critical: i32,
249    pub high: i32,
250    pub medium: i32,
251    pub low: i32,
252}
253
254#[derive(Debug, Clone, Deserialize, Serialize)]
255pub struct VulnerabilitySummaryResponseData {
256    pub agent_id: String,
257    pub summary: VulnerabilitySummaryCounts,
258}
259
260#[derive(Debug, Clone, Deserialize, Serialize)]
261pub struct PortInfo {
262    pub ip: Option<String>,
263    pub port: u16,
264}
265
266#[derive(Debug, Clone)]
267pub struct VulnerabilityClient {
268    api_client: WazuhApiClient,
269    indexer_client: WazuhIndexerClient,
270}
271
272impl VulnerabilityClient {
273    pub fn new(api_client: WazuhApiClient, indexer_client: WazuhIndexerClient) -> Self {
274        Self {
275            api_client,
276            indexer_client,
277        }
278    }
279
280    pub async fn get_agent_vulnerabilities(
281        &mut self,
282        agent_id: &str,
283        limit: Option<u32>,
284        offset: Option<u32>,
285        severity: Option<VulnerabilitySeverity>,
286    ) -> Result<Vec<Vulnerability>, WazuhApiError> {
287        debug!(%agent_id, ?severity, "Getting vulnerabilities for agent");
288
289        let size = limit.unwrap_or(100);
290        let from = offset.unwrap_or(0);
291
292        let mut must_clauses = vec![json!({
293            "term": {
294                "agent.id": agent_id
295            }
296        })];
297
298        if let Some(severity) = severity {
299            must_clauses.push(json!({
300                "term": {
301                    "vulnerability.severity": severity.to_indexer_format()
302                }
303            }));
304        }
305
306        let query_body = json!({
307            "size": size,
308            "from": from,
309            "query": {
310                "bool": {
311                    "must": must_clauses
312                }
313            }
314        });
315
316        let endpoint = "/wazuh-states-vulnerabilities*/_search";
317        let response = self
318            .indexer_client
319            .make_indexer_request(Method::POST, endpoint, Some(query_body))
320            .await?;
321
322        let hits = response
323            .get("hits")
324            .and_then(|h| h.get("hits"))
325            .and_then(|h_array| h_array.as_array())
326            .ok_or_else(|| {
327                WazuhApiError::ApiError("Missing 'hits.hits' in vulnerability response".to_string())
328            })?;
329
330        let mut vulnerabilities = Vec::new();
331        for hit in hits {
332            if let Some(source) = hit.get("_source") {
333                if let Ok(vuln) = self.parse_vulnerability_from_source(source, agent_id) {
334                    vulnerabilities.push(vuln);
335                }
336            }
337        }
338
339        info!(%agent_id, "Retrieved {} vulnerabilities", vulnerabilities.len());
340        Ok(vulnerabilities)
341    }
342
343    fn parse_vulnerability_from_source(
344        &self,
345        source: &Value,
346        agent_id: &str,
347    ) -> Result<Vulnerability, WazuhApiError> {
348        let vulnerability_data = source.get("vulnerability").ok_or_else(|| {
349            WazuhApiError::ApiError("Missing vulnerability data in source".to_string())
350        })?;
351
352        let cve = vulnerability_data
353            .get("cve")
354            .and_then(|v| v.as_str())
355            .unwrap_or("Unknown")
356            .to_string();
357
358        let title = vulnerability_data
359            .get("title")
360            .and_then(|v| v.as_str())
361            .unwrap_or("No title available")
362            .to_string();
363
364        let severity = vulnerability_data
365            .get("severity")
366            .and_then(|v| v.as_str())
367            .and_then(VulnerabilitySeverity::parse)
368            .unwrap_or(VulnerabilitySeverity::Low); // Default to Low if parsing fails
369
370        let published = vulnerability_data
371            .get("published")
372            .and_then(|v| v.as_str())
373            .map(|s| s.to_string());
374
375        let updated = vulnerability_data
376            .get("updated")
377            .and_then(|v| v.as_str())
378            .map(|s| s.to_string());
379
380        let reference = vulnerability_data
381            .get("reference")
382            .and_then(|v| v.as_str())
383            .map(|s| s.to_string());
384
385        let description = vulnerability_data
386            .get("description")
387            .and_then(|v| v.as_str())
388            .map(|s| s.to_string());
389
390        let detection_time = source
391            .get("timestamp")
392            .or_else(|| source.get("@timestamp"))
393            .or_else(|| source.get("vulnerability.detection_time"))
394            .and_then(|v| v.as_str())
395            .map(|s| s.to_string());
396
397        let agent_name = source
398            .get("agent")
399            .and_then(|a| a.get("name"))
400            .and_then(|v| v.as_str())
401            .map(|s| s.to_string());
402
403        let cvss = self.parse_cvss_scores(vulnerability_data);
404
405        Ok(Vulnerability {
406            cve,
407            title,
408            severity,
409            published,
410            updated,
411            reference,
412            description,
413            cvss,
414            detection_time,
415            agent_id: Some(agent_id.to_string()),
416            agent_name,
417        })
418    }
419
420    fn parse_cvss_scores(&self, vulnerability_data: &Value) -> Option<CvssScore> {
421        let cvss2 = vulnerability_data
422            .get("cvss2")
423            .map(|cvss2_data| CvssDetails {
424                vector: cvss2_data
425                    .get("vector")
426                    .and_then(|v| v.as_str())
427                    .map(|s| s.to_string()),
428                base_score: cvss2_data.get("base_score").and_then(|v| v.as_f64()),
429            });
430
431        let cvss3 = vulnerability_data
432            .get("cvss3")
433            .map(|cvss3_data| CvssDetails {
434                vector: cvss3_data
435                    .get("vector")
436                    .and_then(|v| v.as_str())
437                    .map(|s| s.to_string()),
438                base_score: cvss3_data.get("base_score").and_then(|v| v.as_f64()),
439            });
440
441        if cvss2.is_some() || cvss3.is_some() {
442            Some(CvssScore { cvss2, cvss3 })
443        } else {
444            None
445        }
446    }
447
448    pub async fn get_critical_vulnerabilities(
449        &mut self,
450        agent_id: &str,
451        limit: Option<u32>,
452    ) -> Result<Vec<Vulnerability>, WazuhApiError> {
453        debug!(%agent_id, "Getting critical vulnerabilities");
454        self.get_agent_vulnerabilities(agent_id, limit, None, Some(VulnerabilitySeverity::Critical))
455            .await
456    }
457
458    pub async fn get_high_vulnerabilities(
459        &mut self,
460        agent_id: &str,
461        limit: Option<u32>,
462    ) -> Result<Vec<Vulnerability>, WazuhApiError> {
463        debug!(%agent_id, "Getting high severity vulnerabilities");
464        self.get_agent_vulnerabilities(agent_id, limit, None, Some(VulnerabilitySeverity::High))
465            .await
466    }
467
468    pub async fn get_medium_vulnerabilities(
469        &mut self,
470        agent_id: &str,
471        limit: Option<u32>,
472    ) -> Result<Vec<Vulnerability>, WazuhApiError> {
473        debug!(%agent_id, "Getting medium severity vulnerabilities");
474        self.get_agent_vulnerabilities(agent_id, limit, None, Some(VulnerabilitySeverity::Medium))
475            .await
476    }
477
478    pub async fn get_low_vulnerabilities(
479        &mut self,
480        agent_id: &str,
481        limit: Option<u32>,
482    ) -> Result<Vec<Vulnerability>, WazuhApiError> {
483        debug!(%agent_id, "Getting low severity vulnerabilities");
484        self.get_agent_vulnerabilities(agent_id, limit, None, Some(VulnerabilitySeverity::Low))
485            .await
486    }
487
488    pub async fn get_agent_packages(
489        &mut self,
490        agent_id: &str,
491        limit: Option<u32>,
492        offset: Option<u32>,
493        search: Option<&str>,
494    ) -> Result<Vec<Package>, WazuhApiError> {
495        debug!(%agent_id, ?search, "Getting packages for agent");
496
497        let mut query_params = Vec::new();
498
499        if let Some(limit) = limit {
500            query_params.push(("limit", limit.to_string()));
501        }
502        if let Some(offset) = offset {
503            query_params.push(("offset", offset.to_string()));
504        }
505        if let Some(search) = search {
506            query_params.push(("search", search.to_string()));
507        }
508
509        let query_params_ref: Vec<(&str, &str)> =
510            query_params.iter().map(|(k, v)| (*k, v.as_str())).collect();
511
512        let endpoint = format!("/syscollector/{}/packages", agent_id);
513        let response = self
514            .api_client
515            .make_request(
516                Method::GET,
517                &endpoint,
518                None,
519                if query_params_ref.is_empty() {
520                    None
521                } else {
522                    Some(&query_params_ref)
523                },
524            )
525            .await?;
526
527        let packages_data = response
528            .get("data")
529            .and_then(|d| d.get("affected_items"))
530            .ok_or_else(|| {
531                WazuhApiError::ApiError(
532                    "Missing 'data.affected_items' in packages response".to_string(),
533                )
534            })?;
535
536        let packages: Vec<Package> = serde_json::from_value(packages_data.clone())?;
537        info!(%agent_id, "Retrieved {} packages", packages.len());
538        Ok(packages)
539    }
540
541    pub async fn get_agent_processes(
542        &mut self,
543        agent_id: &str,
544        limit: Option<u32>,
545        offset: Option<u32>,
546        search: Option<&str>,
547    ) -> Result<Vec<Process>, WazuhApiError> {
548        debug!(%agent_id, ?search, "Getting processes for agent");
549
550        let mut query_params = Vec::new();
551
552        if let Some(limit) = limit {
553            query_params.push(("limit", limit.to_string()));
554        }
555        if let Some(offset) = offset {
556            query_params.push(("offset", offset.to_string()));
557        }
558        if let Some(search) = search {
559            query_params.push(("search", search.to_string()));
560        }
561
562        let query_params_ref: Vec<(&str, &str)> =
563            query_params.iter().map(|(k, v)| (*k, v.as_str())).collect();
564
565        let endpoint = format!("/syscollector/{}/processes", agent_id);
566        let response = self
567            .api_client
568            .make_request(
569                Method::GET,
570                &endpoint,
571                None,
572                if query_params_ref.is_empty() {
573                    None
574                } else {
575                    Some(&query_params_ref)
576                },
577            )
578            .await?;
579
580        let processes_data = response
581            .get("data")
582            .and_then(|d| d.get("affected_items"))
583            .ok_or_else(|| {
584                WazuhApiError::ApiError(
585                    "Missing 'data.affected_items' in processes response".to_string(),
586                )
587            })?;
588
589        let processes: Vec<Process> = serde_json::from_value(processes_data.clone())?;
590        info!(%agent_id, "Retrieved {} processes", processes.len());
591        Ok(processes)
592    }
593
594    pub async fn get_agent_ports(
595        &mut self,
596        agent_id: &str,
597        limit: Option<u32>,
598        offset: Option<u32>,
599        protocol: Option<&str>,
600    ) -> Result<Vec<Port>, WazuhApiError> {
601        debug!(%agent_id, ?protocol, "Getting ports for agent");
602
603        let mut query_params = Vec::new();
604
605        if let Some(limit) = limit {
606            query_params.push(("limit", limit.to_string()));
607        }
608        if let Some(offset) = offset {
609            query_params.push(("offset", offset.to_string()));
610        }
611        if let Some(protocol) = protocol {
612            query_params.push(("protocol", protocol.to_string()));
613        }
614
615        let query_params_ref: Vec<(&str, &str)> =
616            query_params.iter().map(|(k, v)| (*k, v.as_str())).collect();
617
618        let endpoint = format!("/syscollector/{}/ports", agent_id);
619        let response = self
620            .api_client
621            .make_request(
622                Method::GET,
623                &endpoint,
624                None,
625                if query_params_ref.is_empty() {
626                    None
627                } else {
628                    Some(&query_params_ref)
629                },
630            )
631            .await?;
632
633        let ports_data = response
634            .get("data")
635            .and_then(|d| d.get("affected_items"))
636            .ok_or_else(|| {
637                WazuhApiError::ApiError(
638                    "Missing 'data.affected_items' in ports response".to_string(),
639                )
640            })?;
641
642        let ports: Vec<Port> = serde_json::from_value(ports_data.clone())?;
643        info!(%agent_id, "Retrieved {} ports", ports.len());
644        Ok(ports)
645    }
646
647    pub async fn get_vulnerability_summary(
648        &mut self,
649        agent_id: &str,
650    ) -> Result<Option<VulnerabilitySummaryResponseData>, WazuhApiError> {
651        debug!(%agent_id, "Getting vulnerability summary");
652
653        let query_body = json!({
654            "size": 0,
655            "query": {
656                "bool": {
657                    "must": [
658                        {
659                            "term": {
660                                "agent.id": agent_id
661                            }
662                        }
663                    ]
664                }
665            },
666            "aggs": {
667                "severity_counts": {
668                    "terms": {
669                        "field": "vulnerability.severity",
670                        "size": 10
671                    }
672                }
673            }
674        });
675
676        let endpoint = "/wazuh-states-vulnerabilities*/_search";
677        match self
678            .indexer_client
679            .make_indexer_request(Method::POST, endpoint, Some(query_body))
680            .await
681        {
682            Ok(response_value) => {
683                let aggregations = response_value
684                    .get("aggregations")
685                    .and_then(|aggs| aggs.get("severity_counts"))
686                    .and_then(|severity| severity.get("buckets"))
687                    .and_then(|buckets| buckets.as_array());
688
689                if let Some(buckets) = aggregations {
690                    let mut critical = 0;
691                    let mut high = 0;
692                    let mut medium = 0;
693                    let mut low = 0;
694
695                    for bucket in buckets {
696                        if let (Some(key), Some(count)) = (
697                            bucket.get("key").and_then(|k| k.as_str()),
698                            bucket.get("doc_count").and_then(|c| c.as_i64()),
699                        ) {
700                            if let Some(severity) = VulnerabilitySeverity::parse(key) {
701                                match severity {
702                                    VulnerabilitySeverity::Critical => critical = count as i32,
703                                    VulnerabilitySeverity::High => high = count as i32,
704                                    VulnerabilitySeverity::Medium => medium = count as i32,
705                                    VulnerabilitySeverity::Low => low = count as i32,
706                                }
707                            }
708                        }
709                    }
710
711                    let summary = VulnerabilitySummaryResponseData {
712                        agent_id: agent_id.to_string(),
713                        summary: VulnerabilitySummaryCounts {
714                            critical,
715                            high,
716                            medium,
717                            low,
718                        },
719                    };
720
721                    info!(%agent_id, "Retrieved vulnerability summary");
722                    Ok(Some(summary))
723                } else {
724                    info!(%agent_id, "No vulnerability data found for summary");
725                    Ok(None)
726                }
727            }
728            Err(e) => {
729                warn!(%agent_id, "Failed to get vulnerability summary: {}", e);
730                Ok(None)
731            }
732        }
733    }
734
735    pub async fn search_package(
736        &mut self,
737        package_name: &str,
738        agent_ids: Option<&[String]>,
739    ) -> Result<Vec<(String, Vec<Package>)>, WazuhApiError> {
740        debug!(%package_name, "Searching for package across agents");
741
742        let mut results = Vec::new();
743
744        if let Some(agent_ids) = agent_ids {
745            for agent_id in agent_ids {
746                match self
747                    .get_agent_packages(agent_id, None, None, Some(package_name))
748                    .await
749                {
750                    Ok(packages) => {
751                        if !packages.is_empty() {
752                            results.push((agent_id.clone(), packages));
753                        }
754                    }
755                    Err(e) => {
756                        debug!(%agent_id, %package_name, "Failed to get packages: {}", e);
757                    }
758                }
759            }
760        }
761
762        info!(%package_name, "Found package in {} agents", results.len());
763        Ok(results)
764    }
765
766    pub async fn get_agents_with_vulnerability(
767        &mut self,
768        cve: &str,
769        agent_ids: &[String],
770        limit: Option<u32>,
771    ) -> Result<Vec<String>, WazuhApiError> {
772        debug!(%cve, "Finding agents with specific vulnerability");
773
774        let mut affected_agents = Vec::new();
775
776        for agent_id in agent_ids {
777            match self
778                .get_agent_vulnerabilities(agent_id, limit, None, None)
779                .await
780            {
781                Ok(vulnerabilities) => {
782                    if vulnerabilities.iter().any(|v| v.cve == cve) {
783                        affected_agents.push(agent_id.clone());
784                    }
785                }
786                Err(e) => {
787                    debug!(%agent_id, %cve, "Failed to get vulnerabilities: {}", e);
788                }
789            }
790        }
791
792        info!(%cve, "Found {} agents with vulnerability", affected_agents.len());
793        Ok(affected_agents)
794    }
795
796    /// Get vulnerabilities with severity at or above the specified level
797    pub async fn get_vulnerabilities_by_min_severity(
798        &mut self,
799        agent_id: &str,
800        min_severity: VulnerabilitySeverity,
801        limit: Option<u32>,
802        offset: Option<u32>,
803    ) -> Result<Vec<Vulnerability>, WazuhApiError> {
804        debug!(%agent_id, ?min_severity, "Getting vulnerabilities with minimum severity");
805
806        // Get all vulnerabilities and filter by severity level
807        let all_vulnerabilities = self
808            .get_agent_vulnerabilities(agent_id, limit, offset, None)
809            .await?;
810
811        let filtered: Vec<Vulnerability> = all_vulnerabilities
812            .into_iter()
813            .filter(|vuln| match (vuln.severity, min_severity) {
814                (VulnerabilitySeverity::Critical, _) => true,
815                (VulnerabilitySeverity::High, VulnerabilitySeverity::Critical) => false,
816                (VulnerabilitySeverity::High, _) => true,
817                (
818                    VulnerabilitySeverity::Medium,
819                    VulnerabilitySeverity::Critical | VulnerabilitySeverity::High,
820                ) => false,
821                (VulnerabilitySeverity::Medium, _) => true,
822                (VulnerabilitySeverity::Low, VulnerabilitySeverity::Low) => true,
823                (VulnerabilitySeverity::Low, _) => false,
824            })
825            .collect();
826
827        info!(%agent_id, ?min_severity, "Found {} vulnerabilities at or above severity level", filtered.len());
828        Ok(filtered)
829    }
830
831    /// Get vulnerabilities for multiple severity levels
832    pub async fn get_vulnerabilities_by_severities(
833        &mut self,
834        agent_id: &str,
835        severities: &[VulnerabilitySeverity],
836        limit: Option<u32>,
837        offset: Option<u32>,
838    ) -> Result<Vec<Vulnerability>, WazuhApiError> {
839        debug!(%agent_id, ?severities, "Getting vulnerabilities for multiple severity levels");
840
841        let mut all_vulnerabilities = Vec::new();
842
843        for &severity in severities {
844            let vulns = self
845                .get_agent_vulnerabilities(agent_id, limit, offset, Some(severity))
846                .await?;
847            all_vulnerabilities.extend(vulns);
848        }
849
850        // Remove duplicates based on CVE
851        all_vulnerabilities.sort_by(|a, b| a.cve.cmp(&b.cve));
852        all_vulnerabilities.dedup_by(|a, b| a.cve == b.cve);
853
854        info!(%agent_id, ?severities, "Found {} unique vulnerabilities", all_vulnerabilities.len());
855        Ok(all_vulnerabilities)
856    }
857}