1use crate::config::{Config, OssIndexConfig, StoreConfig};
7use crate::ecosystem::normalize_package_key;
8use crate::error::{AdvisoryError, Result};
9use crate::models::{Advisory, Enrichment, Event, RangeType, Severity};
10use crate::purl::Purl;
11use crate::sources::epss::EpssSource;
12use crate::sources::kev::KevSource;
13use crate::sources::ossindex::OssIndexSource;
14use crate::sources::{AdvisorySource, ghsa::GHSASource, nvd::NVDSource, osv::OSVSource};
15use crate::store::{AdvisoryStore, DragonflyStore, EnrichmentData, HealthStatus, OssIndexCache};
16use std::cmp::Ordering;
17use std::collections::HashMap;
18use std::sync::Arc;
19use tracing::{debug, error, info, warn};
20
21#[derive(Debug, Clone, Default)]
23pub struct MatchOptions {
24 pub min_cvss: Option<f64>,
26 pub min_epss: Option<f64>,
28 pub kev_only: bool,
30 pub min_severity: Option<Severity>,
32 pub include_enrichment: bool,
34 pub cwe_ids: Option<Vec<String>>,
37}
38
39#[derive(Debug, Clone, Default)]
41pub struct SyncStats {
42 pub total_sources: usize,
44 pub successful_sources: usize,
46 pub failed_sources: usize,
48 pub total_advisories_synced: usize,
50 pub errors: HashMap<String, String>,
52}
53
54pub trait SyncObserver: Send + Sync {
56 fn on_sync_start(&self);
58
59 fn on_source_start(&self, source_name: &str);
61
62 fn on_source_success(&self, source_name: &str, count: usize);
64
65 fn on_source_error(&self, source_name: &str, error: &crate::error::AdvisoryError);
67
68 fn on_sync_complete(&self, stats: &SyncStats);
70}
71
72pub struct TracingSyncObserver;
74
75impl SyncObserver for TracingSyncObserver {
76 fn on_sync_start(&self) {
77 info!("Starting full vulnerability sync...");
78 }
79
80 fn on_source_start(&self, source_name: &str) {
81 debug!("Syncing {}...", source_name);
82 }
83
84 fn on_source_success(&self, source_name: &str, count: usize) {
85 if count > 0 {
86 info!(
87 "Successfully synced {} advisories from {}",
88 count, source_name
89 );
90 } else {
91 debug!(
92 "Successfully synced {} advisories from {}",
93 count, source_name
94 );
95 }
96 }
97
98 fn on_source_error(&self, source_name: &str, error: &crate::error::AdvisoryError) {
99 error!("Failed to sync {}: {}", source_name, error);
100 }
101
102 fn on_sync_complete(&self, _stats: &SyncStats) {
103 info!("Sync completed.");
104 }
105}
106
107impl MatchOptions {
108 pub fn with_enrichment() -> Self {
110 Self {
111 include_enrichment: true,
112 ..Default::default()
113 }
114 }
115
116 pub fn high_severity() -> Self {
118 Self {
119 min_severity: Some(Severity::High),
120 include_enrichment: true,
121 ..Default::default()
122 }
123 }
124
125 pub fn exploited_only() -> Self {
127 Self {
128 kev_only: true,
129 include_enrichment: true,
130 ..Default::default()
131 }
132 }
133
134 pub fn with_cwes(cwe_ids: Vec<String>) -> Self {
147 Self {
148 cwe_ids: Some(cwe_ids),
149 include_enrichment: true,
150 ..Default::default()
151 }
152 }
153}
154
155#[derive(Debug, Clone, Hash, PartialEq, Eq)]
157pub struct PackageKey {
158 pub ecosystem: String,
160 pub name: String,
162 pub version: Option<String>,
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168pub enum BatchFailureStage {
169 StoreLookup,
171 OssIndex,
173}
174
175#[derive(Debug, Clone)]
177pub struct BatchFailure {
178 pub package: PackageKey,
180 pub stage: BatchFailureStage,
182 pub retryable: bool,
184 pub error: String,
186}
187
188#[derive(Debug, Clone, Default)]
190pub struct BatchSummary {
191 pub total: usize,
193 pub succeeded: usize,
195 pub failed: usize,
197 pub range_translation_statuses: HashMap<String, usize>,
199}
200
201#[derive(Debug, Clone)]
203pub struct BatchOutcome<T> {
204 pub successes: HashMap<PackageKey, T>,
206 pub failures: Vec<BatchFailure>,
208 pub summary: BatchSummary,
210}
211
212impl<T> BatchOutcome<T> {
213 fn from_parts(
214 successes: HashMap<PackageKey, T>,
215 failures: Vec<BatchFailure>,
216 total: usize,
217 ) -> Self {
218 use std::collections::HashSet;
219
220 let failed_packages: HashSet<_> = failures
221 .iter()
222 .map(|failure| failure.package.clone())
223 .collect();
224 Self {
225 summary: BatchSummary {
226 total,
227 succeeded: successes.len(),
228 failed: failed_packages.len(),
229 range_translation_statuses: HashMap::new(),
230 },
231 successes,
232 failures,
233 }
234 }
235}
236
237impl PackageKey {
238 pub fn new(ecosystem: impl Into<String>, name: impl Into<String>) -> Self {
240 let (ecosystem, name) = normalize_package_key(&ecosystem.into(), &name.into());
241 Self {
242 ecosystem,
243 name,
244 version: None,
245 }
246 }
247
248 pub fn with_version(
250 ecosystem: impl Into<String>,
251 name: impl Into<String>,
252 version: impl Into<String>,
253 ) -> Self {
254 let (ecosystem, name) = normalize_package_key(&ecosystem.into(), &name.into());
255 Self {
256 ecosystem,
257 name,
258 version: Some(version.into()),
259 }
260 }
261}
262
263pub struct VulnerabilityManagerBuilder {
265 redis_url: Option<String>,
266 store_config: StoreConfig,
267 sources: Vec<Arc<dyn AdvisorySource + Send + Sync>>,
268 custom_store: Option<Arc<dyn AdvisoryStore + Send + Sync>>,
269 ossindex_source: Option<OssIndexSource>,
270 observer: Option<Arc<dyn SyncObserver>>,
271}
272
273impl Default for VulnerabilityManagerBuilder {
274 fn default() -> Self {
275 Self::new()
276 }
277}
278
279impl VulnerabilityManagerBuilder {
280 pub fn new() -> Self {
282 Self {
283 redis_url: None,
284 store_config: StoreConfig::default(),
285 sources: Vec::new(),
286 custom_store: None,
287 ossindex_source: None,
288 observer: None,
289 }
290 }
291
292 pub fn redis_url(mut self, url: impl Into<String>) -> Self {
294 self.redis_url = Some(url.into());
295 self
296 }
297
298 pub fn store_config(mut self, config: StoreConfig) -> Self {
300 self.store_config = config;
301 self
302 }
303
304 pub fn store(mut self, store: Arc<dyn AdvisoryStore + Send + Sync>) -> Self {
306 self.custom_store = Some(store);
307 self
308 }
309
310 pub fn add_source(mut self, source: Arc<dyn AdvisorySource + Send + Sync>) -> Self {
312 self.sources.push(source);
313 self
314 }
315
316 pub fn with_ghsa(mut self, token: impl Into<String>) -> Self {
318 self.sources.push(Arc::new(GHSASource::new(token.into())));
319 self
320 }
321
322 pub fn with_nvd(mut self, api_key: Option<String>) -> Self {
324 self.sources.push(Arc::new(NVDSource::new(api_key)));
325 self
326 }
327
328 pub fn with_osv(mut self, ecosystems: Vec<String>) -> Self {
330 self.sources.push(Arc::new(OSVSource::new(ecosystems)));
331 self
332 }
333
334 pub fn with_osv_defaults(self) -> Self {
336 self.with_osv(vec![
337 "npm".to_string(),
338 "PyPI".to_string(),
339 "Maven".to_string(),
340 "crates.io".to_string(),
341 "Go".to_string(),
342 "Packagist".to_string(),
343 "RubyGems".to_string(),
344 "NuGet".to_string(),
345 ])
346 }
347
348 pub fn with_ossindex(mut self, config: Option<OssIndexConfig>) -> Self {
353 match OssIndexSource::new(config) {
354 Ok(source) => {
355 self.ossindex_source = Some(source);
356 }
357 Err(e) => {
358 warn!("Failed to configure OSS Index source: {}", e);
359 }
360 }
361 self
362 }
363
364 pub fn with_observer(mut self, observer: Arc<dyn SyncObserver>) -> Self {
366 self.observer = Some(observer);
367 self
368 }
369
370 pub fn build(self) -> Result<VulnerabilityManager> {
372 let store: Arc<dyn AdvisoryStore + Send + Sync> = match self.custom_store {
373 Some(s) => s,
374 None => {
375 let url = self.redis_url.ok_or_else(|| {
376 AdvisoryError::config("Redis URL is required. Use .redis_url() or .store()")
377 })?;
378 Arc::new(DragonflyStore::with_config(&url, self.store_config)?)
379 }
380 };
381
382 if self.sources.is_empty() {
383 warn!("No sources configured. Use .with_ghsa(), .with_nvd(), or .with_osv()");
384 }
385
386 Ok(VulnerabilityManager {
387 store,
388 sources: self.sources,
389 kev_source: KevSource::new(),
390 epss_source: EpssSource::new(),
391 ossindex_source: self.ossindex_source,
392 observer: self
393 .observer
394 .unwrap_or_else(|| Arc::new(TracingSyncObserver)),
395 })
396 }
397}
398
399pub struct VulnerabilityManager {
401 store: Arc<dyn AdvisoryStore + Send + Sync>,
402 sources: Vec<Arc<dyn AdvisorySource + Send + Sync>>,
403 kev_source: KevSource,
404 epss_source: EpssSource,
405 ossindex_source: Option<OssIndexSource>,
406 observer: Arc<dyn SyncObserver>,
407}
408
409impl VulnerabilityManager {
410 fn collect_range_translation_statuses(
411 advisories_by_package: &HashMap<PackageKey, Vec<Advisory>>,
412 ) -> HashMap<String, usize> {
413 let mut counters = HashMap::new();
414
415 for advisories in advisories_by_package.values() {
416 for advisory in advisories {
417 for affected in &advisory.affected {
418 let Some(database_specific) = &affected.database_specific else {
419 continue;
420 };
421 let Some(status) = database_specific
422 .get("range_translation")
423 .and_then(|translation| translation.get("status"))
424 .and_then(|status| status.as_str())
425 else {
426 continue;
427 };
428
429 *counters.entry(status.to_string()).or_insert(0) += 1;
430 }
431 }
432 }
433
434 counters
435 }
436
437 pub async fn new(config: Config) -> Result<Self> {
441 let mut builder = VulnerabilityManagerBuilder::new()
442 .redis_url(&config.redis_url)
443 .store_config(config.store.clone());
444
445 builder = builder.with_osv_defaults();
447
448 builder = builder.with_nvd(config.nvd_api_key.clone());
450
451 if let Some(token) = &config.ghsa_token {
453 builder = builder.with_ghsa(token.clone());
454 }
455
456 if config.ossindex.is_some() {
458 builder = builder.with_ossindex(config.ossindex.clone());
459 }
460
461 builder.build()
462 }
463
464 pub fn builder() -> VulnerabilityManagerBuilder {
466 VulnerabilityManagerBuilder::new()
467 }
468
469 pub fn store(&self) -> &Arc<dyn AdvisoryStore + Send + Sync> {
471 &self.store
472 }
473
474 pub async fn health_check(&self) -> Result<HealthStatus> {
476 self.store.health_check().await
477 }
478
479 pub async fn sync_all(&self) -> Result<SyncStats> {
481 self.observer.on_sync_start();
482
483 let mut handles = Vec::new();
484 let mut stats = SyncStats {
485 total_sources: self.sources.len(),
486 ..Default::default()
487 };
488
489 for source in &self.sources {
490 let source = source.clone();
491 let store = self.store.clone();
492 let observer = self.observer.clone();
493
494 let handle = tokio::spawn(async move {
495 observer.on_source_start(source.name());
496
497 let last_sync = match store.last_sync(source.name()).await {
498 Ok(Some(ts)) => match chrono::DateTime::parse_from_rfc3339(&ts) {
499 Ok(dt) => Some(dt.with_timezone(&chrono::Utc)),
500 Err(_) => None,
501 },
502 _ => None,
503 };
504
505 match source.fetch(last_sync).await {
506 Ok(advisories) => {
507 if !advisories.is_empty() {
508 match store.upsert_batch(&advisories, source.name()).await {
509 Ok(_) => {
510 observer.on_source_success(source.name(), advisories.len());
511 if let Err(e) = store.update_sync_timestamp(source.name()).await
513 {
514 let err = AdvisoryError::source_fetch(
515 source.name(),
516 format!("Failed to update timestamp: {}", e),
517 );
518 observer.on_source_error(source.name(), &err);
519 }
522 Ok((source.name().to_string(), advisories.len()))
523 }
524 Err(e) => {
525 observer.on_source_error(source.name(), &e);
527 Err((source.name().to_string(), e.to_string()))
528 }
529 }
530 } else {
531 observer.on_source_success(source.name(), 0);
532 if let Err(e) = store.update_sync_timestamp(source.name()).await {
534 let err = AdvisoryError::source_fetch(
535 source.name(),
536 format!("Failed to update timestamp: {}", e),
537 );
538 observer.on_source_error(source.name(), &err);
539 }
540 Ok((source.name().to_string(), 0))
541 }
542 }
543 Err(e) => {
544 observer.on_source_error(source.name(), &e);
545 Err((source.name().to_string(), e.to_string()))
546 }
547 }
548 });
549 handles.push(handle);
550 }
551
552 for handle in handles {
554 match handle.await {
555 Ok(result) => match result {
556 Ok((_, count)) => {
557 stats.successful_sources += 1;
558 stats.total_advisories_synced += count;
559 }
560 Err((name, error)) => {
561 stats.failed_sources += 1;
562 stats.errors.insert(name, error);
563 }
564 },
565 Err(e) => {
566 error!("Task join error: {}", e);
568 stats.failed_sources += 1;
569 stats
570 .errors
571 .insert("unknown".to_string(), format!("Task join error: {}", e));
572 }
573 }
574 }
575
576 self.observer.on_sync_complete(&stats);
577 Ok(stats)
578 }
579
580 pub async fn reset_sync(&self, source: &str) -> Result<()> {
584 self.store.reset_sync_timestamp(source).await
585 }
586
587 pub async fn reset_all_syncs(&self) -> Result<()> {
589 for source in &self.sources {
590 self.store.reset_sync_timestamp(source.name()).await?;
591 }
592 Ok(())
593 }
594
595 pub async fn sync_enrichment(&self) -> Result<()> {
597 self.sync_enrichment_with_cves(&[]).await
598 }
599
600 pub async fn sync_enrichment_with_cves(&self, extra_cves: &[String]) -> Result<()> {
602 debug!("Syncing enrichment data (KEV, EPSS)...");
603
604 let mut enrichment: HashMap<String, EnrichmentData> = HashMap::new();
605
606 match self.kev_source.fetch_catalog().await {
608 Ok(kev_entries) => {
609 debug!("Processing {} KEV entries", kev_entries.len());
610 for (cve_id, entry) in kev_entries {
611 let data = enrichment
612 .entry(cve_id.clone())
613 .or_insert_with(|| EnrichmentData {
614 epss_score: None,
615 epss_percentile: None,
616 is_kev: false,
617 kev_due_date: None,
618 kev_date_added: None,
619 kev_ransomware: None,
620 updated_at: String::new(),
621 });
622
623 data.is_kev = true;
624 data.kev_due_date = entry.due_date_utc().map(|d| d.to_rfc3339());
625 data.kev_date_added = entry.date_added_utc().map(|d| d.to_rfc3339());
626 data.kev_ransomware = Some(entry.is_ransomware_related());
627 }
628 }
629 Err(e) => {
630 error!("Failed to fetch KEV catalog: {}", e);
631 }
632 }
633
634 let epss_targets = Self::collect_enrichment_targets(&enrichment, extra_cves);
636 if !epss_targets.is_empty() {
637 match self
638 .epss_source
639 .fetch_scores_batch(&epss_targets, 200)
640 .await
641 {
642 Ok(scores) => {
643 Self::merge_epss_scores(&mut enrichment, scores);
644 }
645 Err(e) => {
646 warn!("Failed to fetch EPSS scores: {}", e);
647 }
648 }
649 }
650
651 if !enrichment.is_empty() {
653 let now = chrono::Utc::now().to_rfc3339();
654 for (cve_id, mut data) in enrichment {
655 if data.updated_at.is_empty() {
656 data.updated_at = now.clone();
657 }
658 if let Err(e) = self.store.store_enrichment(&cve_id, &data).await {
659 debug!("Failed to store enrichment for {}: {}", cve_id, e);
660 }
661 }
662 }
663
664 Ok(())
665 }
666
667 fn collect_enrichment_targets(
669 current: &HashMap<String, EnrichmentData>,
670 extra: &[String],
671 ) -> Vec<String> {
672 let mut set: std::collections::HashSet<String> = current.keys().cloned().collect();
673 for c in extra {
674 set.insert(c.clone());
675 }
676 set.into_iter().collect()
677 }
678
679 fn merge_epss_scores(
681 enrichment: &mut HashMap<String, EnrichmentData>,
682 scores: HashMap<String, crate::sources::epss::EpssScore>,
683 ) {
684 for (cve_id, score) in scores {
685 let data = enrichment
686 .entry(cve_id.clone())
687 .or_insert_with(|| EnrichmentData {
688 epss_score: None,
689 epss_percentile: None,
690 is_kev: false,
691 kev_due_date: None,
692 kev_date_added: None,
693 kev_ransomware: None,
694 updated_at: String::new(),
695 });
696
697 data.epss_score = Some(score.epss);
698 data.epss_percentile = Some(score.percentile);
699 if let Some(date) = score.date_utc() {
700 data.updated_at = date.to_rfc3339();
701 }
702 }
703 }
704
705 pub async fn query(&self, ecosystem: &str, package: &str) -> Result<Vec<Advisory>> {
707 let (ecosystem, package) = normalize_package_key(ecosystem, package);
708 let advisories = self.store.get_by_package(&ecosystem, &package).await?;
709 Ok(crate::aggregator::ReportAggregator::aggregate(advisories))
710 }
711
712 pub async fn query_enriched(&self, ecosystem: &str, package: &str) -> Result<Vec<Advisory>> {
714 let mut advisories = self.query(ecosystem, package).await?;
715 self.enrich_advisories(&mut advisories).await?;
716 Ok(advisories)
717 }
718
719 pub async fn query_batch(
723 &self,
724 packages: &[PackageKey],
725 ) -> Result<BatchOutcome<Vec<Advisory>>> {
726 use futures_util::future::join_all;
727
728 let tasks: Vec<_> = packages
729 .iter()
730 .map(|pkg| {
731 let pkg = pkg.clone();
732 let ecosystem = pkg.ecosystem.clone();
733 let name = pkg.name.clone();
734 let version = pkg.version.clone();
735 let store = self.store.clone();
736
737 async move {
738 let advisories = if let Some(ver) = &version {
739 match store.get_by_package(&ecosystem, &name).await {
741 Ok(all) => {
742 let aggregated =
743 crate::aggregator::ReportAggregator::aggregate(all);
744 Ok(Self::filter_by_version(aggregated, &ecosystem, &name, ver))
745 }
746 Err(e) => Err(e),
747 }
748 } else {
749 match store.get_by_package(&ecosystem, &name).await {
750 Ok(all) => Ok(crate::aggregator::ReportAggregator::aggregate(all)),
751 Err(e) => Err(e),
752 }
753 };
754 (pkg, advisories)
755 }
756 })
757 .collect();
758
759 let results: Vec<_> = join_all(tasks).await;
760
761 let mut successes = HashMap::new();
762 let mut failures = Vec::new();
763 for (pkg, result) in results {
764 match result {
765 Ok(advisories) => {
766 successes.insert(pkg, advisories);
767 }
768 Err(e) => {
769 failures.push(BatchFailure {
770 package: pkg,
771 stage: BatchFailureStage::StoreLookup,
772 retryable: e.is_retryable(),
773 error: e.to_string(),
774 });
775 }
776 }
777 }
778
779 let mut outcome = BatchOutcome::from_parts(successes, failures, packages.len());
780 outcome.summary.range_translation_statuses =
781 Self::collect_range_translation_statuses(&outcome.successes);
782 Ok(outcome)
783 }
784
785 fn filter_by_version(
787 advisories: Vec<Advisory>,
788 ecosystem: &str,
789 package: &str,
790 version: &str,
791 ) -> Vec<Advisory> {
792 let (ecosystem, package) = normalize_package_key(ecosystem, package);
793 advisories
794 .into_iter()
795 .filter(|advisory| {
796 for affected in &advisory.affected {
797 let (affected_ecosystem, affected_package) =
798 normalize_package_key(&affected.package.ecosystem, &affected.package.name);
799 if affected_package != package || affected_ecosystem != ecosystem {
800 continue;
801 }
802
803 if affected.versions.contains(&version.to_string()) {
805 return true;
806 }
807
808 for range in &affected.ranges {
810 match range.range_type {
811 RangeType::Semver => {
812 if Self::matches_semver_range(version, &range.events) {
813 return true;
814 }
815 }
816 RangeType::Ecosystem => {
817 if Self::matches_ecosystem_range(version, &range.events) {
818 return true;
819 }
820 }
821 RangeType::Git => {
822 if Self::matches_git_range(version, &range.events) {
823 return true;
824 }
825 }
826 }
827 }
828 }
829 false
830 })
831 .collect()
832 }
833
834 pub async fn matches(
836 &self,
837 ecosystem: &str,
838 package: &str,
839 version: &str,
840 ) -> Result<Vec<Advisory>> {
841 self.matches_with_options(ecosystem, package, version, &MatchOptions::default())
842 .await
843 }
844
845 pub async fn matches_with_options(
847 &self,
848 ecosystem: &str,
849 package: &str,
850 version: &str,
851 options: &MatchOptions,
852 ) -> Result<Vec<Advisory>> {
853 let advisories = self.query(ecosystem, package).await?;
854 let mut matched = Vec::new();
855
856 for mut advisory in advisories {
857 let mut is_vulnerable = false;
858 for affected in &advisory.affected {
859 if affected.package.name != package || affected.package.ecosystem != ecosystem {
860 continue;
861 }
862
863 if affected.versions.contains(&version.to_string()) {
865 is_vulnerable = true;
866 break;
867 }
868
869 for range in &affected.ranges {
871 match range.range_type {
872 RangeType::Semver => {
873 if Self::matches_semver_range(version, &range.events) {
874 is_vulnerable = true;
875 break;
876 }
877 }
878 RangeType::Ecosystem => {
879 if Self::matches_ecosystem_range(version, &range.events) {
880 is_vulnerable = true;
881 break;
882 }
883 }
884 RangeType::Git => {
885 if Self::matches_git_range(version, &range.events) {
886 is_vulnerable = true;
887 break;
888 }
889 }
890 }
891 }
892 if is_vulnerable {
893 break;
894 }
895 }
896
897 if is_vulnerable {
898 if options.include_enrichment {
900 self.enrich_advisory(&mut advisory).await?;
901 }
902
903 if self.advisory_passes_filters(&advisory, options) {
905 matched.push(advisory);
906 }
907 }
908 }
909
910 Ok(matched)
911 }
912
913 fn matches_semver_range(version: &str, events: &[Event]) -> bool {
917 let Ok(v) = semver::Version::parse(version) else {
918 return false;
919 };
920
921 #[derive(Default)]
922 struct Interval {
923 start: Option<semver::Version>,
924 end: Option<semver::Version>,
925 end_inclusive: bool,
926 }
927
928 let mut intervals: Vec<Interval> = Vec::new();
929 let mut current_start: Option<semver::Version> = None;
930
931 for event in events {
932 match event {
933 Event::Introduced(ver) => {
934 if let Ok(parsed) = semver::Version::parse(ver) {
935 current_start = Some(parsed);
936 } else if ver == "0" {
937 current_start = Some(semver::Version::new(0, 0, 0));
938 }
939 }
940 Event::Fixed(ver) => {
941 let end = semver::Version::parse(ver).ok();
942 intervals.push(Interval {
943 start: current_start.clone(),
944 end,
945 end_inclusive: false,
946 });
947 current_start = None;
948 }
949 Event::LastAffected(ver) => {
950 let end = semver::Version::parse(ver).ok();
951 intervals.push(Interval {
952 start: current_start.clone(),
953 end,
954 end_inclusive: true,
955 });
956 current_start = None;
957 }
958 Event::Limit(ver) => {
959 let end = semver::Version::parse(ver).ok();
961 intervals.push(Interval {
962 start: current_start.clone(),
963 end,
964 end_inclusive: false,
965 });
966 current_start = None;
967 }
968 }
969 }
970
971 if current_start.is_some() {
973 intervals.push(Interval {
974 start: current_start,
975 end: None,
976 end_inclusive: false,
977 });
978 }
979
980 intervals.into_iter().any(|interval| {
981 if let Some(start) = &interval.start {
982 if v < *start {
983 return false;
984 }
985 }
986
987 match (&interval.end, interval.end_inclusive) {
988 (Some(end), true) => v <= *end,
989 (Some(end), false) => v < *end,
990 (None, _) => true,
991 }
992 })
993 }
994
995 fn matches_ecosystem_range(version: &str, events: &[Event]) -> bool {
998 if events.iter().all(|e| match e {
1000 Event::Introduced(v) | Event::Fixed(v) | Event::LastAffected(v) | Event::Limit(v) => {
1001 semver::Version::parse(v).is_ok() || v == "0"
1002 }
1003 }) {
1004 return Self::matches_semver_range(version, events);
1005 }
1006
1007 let version_parts = match Self::parse_dotted(version) {
1008 Some(p) => p,
1009 None => return false,
1010 };
1011
1012 #[derive(Default)]
1013 struct Interval {
1014 start: Option<Vec<u64>>,
1015 end: Option<Vec<u64>>,
1016 end_inclusive: bool,
1017 }
1018
1019 let mut intervals: Vec<Interval> = Vec::new();
1020 let mut current_start: Option<Vec<u64>> = None;
1021
1022 for event in events {
1023 match event {
1024 Event::Introduced(ver) => {
1025 current_start = Self::parse_dotted(ver);
1026 }
1027 Event::Fixed(ver) => {
1028 intervals.push(Interval {
1029 start: current_start.clone(),
1030 end: Self::parse_dotted(ver),
1031 end_inclusive: false,
1032 });
1033 current_start = None;
1034 }
1035 Event::LastAffected(ver) => {
1036 intervals.push(Interval {
1037 start: current_start.clone(),
1038 end: Self::parse_dotted(ver),
1039 end_inclusive: true,
1040 });
1041 current_start = None;
1042 }
1043 Event::Limit(ver) => {
1044 intervals.push(Interval {
1045 start: current_start.clone(),
1046 end: Self::parse_dotted(ver),
1047 end_inclusive: false,
1048 });
1049 current_start = None;
1050 }
1051 }
1052 }
1053
1054 if current_start.is_some() {
1055 intervals.push(Interval {
1056 start: current_start,
1057 end: None,
1058 end_inclusive: false,
1059 });
1060 }
1061
1062 intervals.into_iter().any(|interval| {
1063 if let Some(start) = &interval.start {
1064 if Self::cmp_dotted(&version_parts, start) == Ordering::Less {
1065 return false;
1066 }
1067 }
1068
1069 match (&interval.end, interval.end_inclusive) {
1070 (Some(end), true) => Self::cmp_dotted(&version_parts, end) != Ordering::Greater,
1071 (Some(end), false) => Self::cmp_dotted(&version_parts, end) == Ordering::Less,
1072 (None, _) => true,
1073 }
1074 })
1075 }
1076
1077 fn normalize_git_revision(value: &str) -> String {
1078 value
1079 .trim()
1080 .trim_start_matches("refs/")
1081 .trim_start_matches("heads/")
1082 .trim_start_matches("tags/")
1083 .to_ascii_lowercase()
1084 }
1085
1086 fn matches_git_range(version: &str, events: &[Event]) -> bool {
1091 let target = Self::normalize_git_revision(version);
1092 if target.is_empty() {
1093 return false;
1094 }
1095
1096 let mut has_unbounded_start = false;
1097 let mut has_closing_boundary = false;
1098
1099 for event in events {
1100 match event {
1101 Event::Introduced(commit) => {
1102 let normalized = Self::normalize_git_revision(commit);
1103 if normalized == "0" || normalized == "*" {
1104 has_unbounded_start = true;
1105 continue;
1106 }
1107 if normalized == target {
1108 return true;
1109 }
1110 }
1111 Event::LastAffected(commit) => {
1112 if Self::normalize_git_revision(commit) == target {
1113 return true;
1114 }
1115 }
1116 Event::Fixed(commit) | Event::Limit(commit) => {
1117 has_closing_boundary = true;
1118 if Self::normalize_git_revision(commit) == target {
1119 return false;
1120 }
1121 }
1122 }
1123 }
1124
1125 has_unbounded_start && !has_closing_boundary
1126 }
1127
1128 fn parse_dotted(v: &str) -> Option<Vec<u64>> {
1130 let mut parts = Vec::new();
1131 for chunk in v.split(|c: char| !c.is_ascii_digit()) {
1132 if chunk.is_empty() {
1133 continue;
1134 }
1135 let Ok(num) = chunk.parse::<u64>() else {
1136 return None;
1137 };
1138 parts.push(num);
1139 }
1140 if parts.is_empty() { None } else { Some(parts) }
1141 }
1142
1143 fn cmp_dotted(a: &[u64], b: &[u64]) -> Ordering {
1145 let max_len = a.len().max(b.len());
1146 for i in 0..max_len {
1147 let ai = *a.get(i).unwrap_or(&0);
1148 let bi = *b.get(i).unwrap_or(&0);
1149 match ai.cmp(&bi) {
1150 Ordering::Equal => continue,
1151 ord => return ord,
1152 }
1153 }
1154 Ordering::Equal
1155 }
1156
1157 async fn enrich_advisory(&self, advisory: &mut Advisory) -> Result<()> {
1159 let cve_ids = Self::extract_cve_ids(advisory);
1161
1162 if cve_ids.is_empty() {
1163 return Ok(());
1164 }
1165
1166 for cve_id in &cve_ids {
1168 if let Ok(Some(data)) = self.store.get_enrichment(cve_id).await {
1169 let enrichment = advisory.enrichment.get_or_insert_with(Enrichment::default);
1170 enrichment.epss_score = data.epss_score.or(enrichment.epss_score);
1171 enrichment.epss_percentile = data.epss_percentile.or(enrichment.epss_percentile);
1172 enrichment.is_kev = enrichment.is_kev || data.is_kev;
1173 if data.kev_due_date.is_some() {
1174 enrichment.kev_due_date = data
1175 .kev_due_date
1176 .and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok())
1177 .map(|d| d.with_timezone(&chrono::Utc));
1178 }
1179 if data.kev_ransomware.is_some() {
1180 enrichment.kev_ransomware = data.kev_ransomware;
1181 }
1182 }
1183 }
1184
1185 Ok(())
1186 }
1187
1188 async fn enrich_advisories(&self, advisories: &mut [Advisory]) -> Result<()> {
1190 for advisory in advisories.iter_mut() {
1191 self.enrich_advisory(advisory).await?;
1192 }
1193 Ok(())
1194 }
1195
1196 fn extract_cve_ids(advisory: &Advisory) -> Vec<String> {
1198 let mut cve_ids = Vec::new();
1199
1200 if advisory.id.starts_with("CVE-") {
1201 cve_ids.push(advisory.id.clone());
1202 }
1203
1204 if let Some(aliases) = &advisory.aliases {
1205 for alias in aliases {
1206 if alias.starts_with("CVE-") && !cve_ids.contains(alias) {
1207 cve_ids.push(alias.clone());
1208 }
1209 }
1210 }
1211
1212 cve_ids
1213 }
1214
1215 fn advisory_passes_filters(&self, advisory: &Advisory, options: &MatchOptions) -> bool {
1217 if options.kev_only {
1219 let is_kev = advisory
1220 .enrichment
1221 .as_ref()
1222 .map(|e| e.is_kev)
1223 .unwrap_or(false);
1224 if !is_kev {
1225 return false;
1226 }
1227 }
1228
1229 if let Some(min_cvss) = options.min_cvss {
1231 let cvss = advisory
1232 .enrichment
1233 .as_ref()
1234 .and_then(|e| e.cvss_v3_score)
1235 .unwrap_or(0.0);
1236 if cvss < min_cvss {
1237 return false;
1238 }
1239 }
1240
1241 if let Some(min_epss) = options.min_epss {
1243 let epss = advisory
1244 .enrichment
1245 .as_ref()
1246 .and_then(|e| e.epss_score)
1247 .unwrap_or(0.0);
1248 if epss < min_epss {
1249 return false;
1250 }
1251 }
1252
1253 if let Some(min_severity) = &options.min_severity {
1255 let severity = advisory
1256 .enrichment
1257 .as_ref()
1258 .and_then(|e| e.cvss_v3_severity)
1259 .unwrap_or(Severity::None);
1260 if severity < *min_severity {
1261 return false;
1262 }
1263 }
1264
1265 if let Some(ref filter_cwes) = options.cwe_ids {
1267 if !filter_cwes.is_empty() {
1268 let advisory_cwes = Self::extract_cwes_from_advisory(advisory);
1269 let normalized_filter: Vec<String> = filter_cwes
1271 .iter()
1272 .map(|c| Self::normalize_cwe_id(c))
1273 .collect();
1274 let normalized_advisory: Vec<String> = advisory_cwes
1275 .iter()
1276 .map(|c| Self::normalize_cwe_id(c))
1277 .collect();
1278 let has_match = normalized_filter
1280 .iter()
1281 .any(|cwe| normalized_advisory.iter().any(|ac| ac == cwe));
1282 if !has_match {
1283 return false;
1284 }
1285 }
1286 }
1287
1288 true
1289 }
1290
1291 fn normalize_cwe_id(cwe: &str) -> String {
1298 let trimmed = cwe.trim();
1299 let upper = trimmed.to_uppercase();
1300
1301 if upper.starts_with("CWE-") {
1302 upper
1303 } else {
1304 format!("CWE-{}", trimmed)
1305 }
1306 }
1307
1308 fn extract_cwes_from_advisory(advisory: &Advisory) -> Vec<String> {
1312 let mut cwes = Vec::new();
1313
1314 if let Some(ref db_specific) = advisory.database_specific {
1316 if let Some(cwe_ids) = db_specific.get("cwe_ids") {
1317 if let Some(arr) = cwe_ids.as_array() {
1318 for cwe in arr {
1319 if let Some(s) = cwe.as_str() {
1320 cwes.push(s.to_string());
1321 }
1322 }
1323 }
1324 }
1325 }
1326
1327 cwes
1328 }
1329
1330 pub async fn fetch_epss_scores(&self, cve_ids: &[&str]) -> Result<HashMap<String, f64>> {
1332 let scores = self.epss_source.fetch_scores(cve_ids).await?;
1333 Ok(scores.into_iter().map(|(k, v)| (k, v.epss)).collect())
1334 }
1335
1336 pub async fn is_kev(&self, cve_id: &str) -> Result<bool> {
1338 if let Some(data) = self.store.get_enrichment(cve_id).await? {
1340 return Ok(data.is_kev);
1341 }
1342
1343 let entry = self.kev_source.is_kev(cve_id).await?;
1345 Ok(entry.is_some())
1346 }
1347
1348 pub async fn query_ossindex(&self, purls: &[String]) -> Result<Vec<Advisory>> {
1386 let source = self.ossindex_source.as_ref().ok_or_else(|| {
1387 AdvisoryError::config("OSS Index not configured. Use .with_ossindex() in builder.")
1388 })?;
1389
1390 let mut cached_advisories = Vec::new();
1392 let mut cache_misses = Vec::new();
1393
1394 for purl in purls {
1395 let cache_key = Purl::cache_key_from_str(purl);
1396 match self.store.get_ossindex_cache(&cache_key).await {
1397 Ok(Some(cache)) if !cache.is_expired() => {
1398 debug!("OSS Index cache hit for {}", purl);
1399 cached_advisories.extend(cache.advisories);
1400 }
1401 _ => {
1402 debug!("OSS Index cache miss for {}", purl);
1403 cache_misses.push(purl.clone());
1404 }
1405 }
1406 }
1407
1408 if !cache_misses.is_empty() {
1410 debug!("Querying OSS Index for {} cache misses", cache_misses.len());
1411 let fresh_advisories = source.query_advisories(&cache_misses).await.map_err(|e| {
1412 AdvisoryError::SourceFetch {
1413 source_name: "ossindex".to_string(),
1414 message: e.to_string(),
1415 }
1416 })?;
1417
1418 let advisory_map = Self::group_advisories_by_purl(&cache_misses, &fresh_advisories);
1420
1421 for (purl, advisories) in &advisory_map {
1423 let cache_key = Purl::cache_key_from_str(purl);
1424 let cache = OssIndexCache::new(advisories.clone());
1425 if let Err(e) = self.store.store_ossindex_cache(&cache_key, &cache).await {
1426 debug!("Failed to cache OSS Index result for {}: {}", purl, e);
1427 }
1428 }
1429
1430 for advisories in advisory_map.into_values() {
1432 cached_advisories.extend(advisories);
1433 }
1434 }
1435
1436 Ok(cached_advisories)
1437 }
1438
1439 pub async fn query_batch_with_ossindex(
1452 &self,
1453 packages: &[PackageKey],
1454 ) -> Result<BatchOutcome<Vec<Advisory>>> {
1455 let mut successes: HashMap<PackageKey, Vec<Advisory>> = HashMap::new();
1456 let mut failures: Vec<BatchFailure> = Vec::new();
1457
1458 let (with_version, without_version): (Vec<_>, Vec<_>) =
1460 packages.iter().partition(|p| p.version.is_some());
1461
1462 if !with_version.is_empty() && self.ossindex_source.is_some() {
1464 let purls: Vec<String> = with_version
1465 .iter()
1466 .map(|p| {
1467 Purl::new(&p.ecosystem, &p.name)
1468 .with_version(p.version.as_ref().unwrap())
1469 .to_string()
1470 })
1471 .collect();
1472
1473 match self.query_ossindex(&purls).await {
1474 Ok(advisories) => {
1475 for pkg in &with_version {
1477 let pkg_advisories: Vec<_> = advisories
1478 .iter()
1479 .filter(|a| {
1480 a.affected.iter().any(|aff| {
1481 aff.package.ecosystem.eq_ignore_ascii_case(&pkg.ecosystem)
1482 && aff.package.name == pkg.name
1483 })
1484 })
1485 .cloned()
1486 .collect();
1487 successes.insert((*pkg).clone(), pkg_advisories);
1488 }
1489 }
1490 Err(e) => {
1491 warn!("OSS Index query failed, falling back to local store: {}", e);
1492 for pkg in &with_version {
1494 failures.push(BatchFailure {
1495 package: (*pkg).clone(),
1496 stage: BatchFailureStage::OssIndex,
1497 retryable: e.is_retryable(),
1498 error: e.to_string(),
1499 });
1500 let advisories = if let Some(version) = &pkg.version {
1501 self.matches(&pkg.ecosystem, &pkg.name, version).await
1502 } else {
1503 self.query(&pkg.ecosystem, &pkg.name).await
1504 };
1505
1506 match advisories {
1507 Ok(advisories) => {
1508 successes.insert((*pkg).clone(), advisories);
1509 }
1510 Err(fallback_err) => {
1511 failures.push(BatchFailure {
1512 package: (*pkg).clone(),
1513 stage: BatchFailureStage::StoreLookup,
1514 retryable: fallback_err.is_retryable(),
1515 error: fallback_err.to_string(),
1516 });
1517 }
1518 }
1519 }
1520 }
1521 }
1522 }
1523
1524 for pkg in &without_version {
1526 match self.query(&pkg.ecosystem, &pkg.name).await {
1527 Ok(advisories) => {
1528 successes.insert((*pkg).clone(), advisories);
1529 }
1530 Err(e) => {
1531 failures.push(BatchFailure {
1532 package: (*pkg).clone(),
1533 stage: BatchFailureStage::StoreLookup,
1534 retryable: e.is_retryable(),
1535 error: e.to_string(),
1536 });
1537 }
1538 }
1539 }
1540
1541 let mut outcome = BatchOutcome::from_parts(successes, failures, packages.len());
1542 outcome.summary.range_translation_statuses =
1543 Self::collect_range_translation_statuses(&outcome.successes);
1544 Ok(outcome)
1545 }
1546
1547 pub async fn invalidate_ossindex_cache(&self, purls: &[String]) -> Result<()> {
1551 for purl in purls {
1552 let cache_key = Purl::cache_key_from_str(purl);
1553 self.store.invalidate_ossindex_cache(&cache_key).await?;
1554 }
1555 Ok(())
1556 }
1557
1558 pub async fn invalidate_all_ossindex_cache(&self) -> Result<()> {
1560 self.store.invalidate_all_ossindex_cache().await?;
1561 Ok(())
1562 }
1563
1564 pub async fn suggest_remediation(
1593 &self,
1594 ecosystem: &str,
1595 package: &str,
1596 current_version: &str,
1597 ) -> Result<crate::remediation::Remediation> {
1598 let advisories = self.matches(ecosystem, package, current_version).await?;
1600
1601 let remediation = crate::remediation::build_remediation(
1603 ecosystem,
1604 package,
1605 current_version,
1606 &advisories,
1607 None, Self::matches_semver_range,
1609 );
1610
1611 Ok(remediation)
1612 }
1613
1614 pub async fn suggest_remediation_with_registry(
1642 &self,
1643 ecosystem: &str,
1644 package: &str,
1645 current_version: &str,
1646 registry: &dyn crate::version_registry::VersionRegistry,
1647 ) -> Result<crate::remediation::Remediation> {
1648 let advisories = self.matches(ecosystem, package, current_version).await?;
1650
1651 let available_versions = match registry.get_versions(ecosystem, package).await {
1653 Ok(versions) => Some(versions),
1654 Err(e) => {
1655 warn!(
1656 "Failed to fetch versions from registry, using advisory data only: {}",
1657 e
1658 );
1659 None
1660 }
1661 };
1662
1663 let remediation = crate::remediation::build_remediation(
1665 ecosystem,
1666 package,
1667 current_version,
1668 &advisories,
1669 available_versions.as_deref(),
1670 Self::matches_semver_range,
1671 );
1672
1673 Ok(remediation)
1674 }
1675
1676 fn group_advisories_by_purl(
1678 purls: &[String],
1679 advisories: &[Advisory],
1680 ) -> HashMap<String, Vec<Advisory>> {
1681 let mut map: HashMap<String, Vec<Advisory>> = HashMap::new();
1682
1683 for purl in purls {
1685 map.insert(purl.clone(), Vec::new());
1686 }
1687
1688 for advisory in advisories {
1689 for affected in &advisory.affected {
1690 for purl in purls {
1691 let Ok(parsed) = Purl::parse(purl) else {
1692 continue;
1693 };
1694
1695 let affected_eco = affected.package.ecosystem.to_lowercase();
1697 let purl_eco = parsed.purl_type.to_lowercase();
1698 let purl_eco_alt = parsed.ecosystem().to_lowercase();
1699 if affected_eco != purl_eco && affected_eco != purl_eco_alt {
1700 continue;
1701 }
1702
1703 if parsed.name != affected.package.name {
1704 continue;
1705 }
1706
1707 if let Some(ver) = parsed.version.as_deref() {
1708 let version_matches = affected.versions.contains(&ver.to_string())
1710 || affected.ranges.iter().any(|r| {
1711 matches!(r.range_type, RangeType::Semver | RangeType::Ecosystem)
1712 && Self::matches_semver_range(ver, &r.events)
1713 });
1714
1715 if !version_matches {
1716 continue;
1717 }
1718 }
1719
1720 map.entry(purl.clone()).or_default().push(advisory.clone());
1721 break;
1722 }
1723 }
1724 }
1725
1726 map
1727 }
1728}
1729
1730#[cfg(test)]
1731mod tests {
1732 use super::*;
1733 use crate::models::{Advisory, Enrichment, Severity};
1734
1735 fn create_advisory_with_cwes(id: &str, cwes: Option<Vec<&str>>) -> Advisory {
1737 let database_specific = cwes.map(|cwe_list| {
1738 serde_json::json!({
1739 "cwe_ids": cwe_list
1740 })
1741 });
1742
1743 Advisory {
1744 id: id.to_string(),
1745 summary: Some("Test advisory".to_string()),
1746 details: None,
1747 affected: vec![],
1748 references: vec![],
1749 published: None,
1750 modified: None,
1751 aliases: None,
1752 database_specific,
1753 enrichment: None,
1754 }
1755 }
1756
1757 fn create_advisory_with_enrichment(id: &str, severity: Severity, is_kev: bool) -> Advisory {
1759 Advisory {
1760 id: id.to_string(),
1761 summary: Some("Test advisory".to_string()),
1762 details: None,
1763 affected: vec![],
1764 references: vec![],
1765 published: None,
1766 modified: None,
1767 aliases: None,
1768 database_specific: None,
1769 enrichment: Some(Enrichment {
1770 cvss_v3_severity: Some(severity),
1771 is_kev,
1772 ..Default::default()
1773 }),
1774 }
1775 }
1776
1777 #[test]
1778 fn test_match_options_default() {
1779 let options = MatchOptions::default();
1780 assert!(options.cwe_ids.is_none());
1781 assert!(options.min_cvss.is_none());
1782 assert!(!options.kev_only);
1783 }
1784
1785 #[test]
1786 fn test_match_options_with_cwes() {
1787 let options = MatchOptions::with_cwes(vec!["CWE-79".to_string(), "CWE-89".to_string()]);
1788 assert!(options.cwe_ids.is_some());
1789 let cwes = options.cwe_ids.unwrap();
1790 assert_eq!(cwes.len(), 2);
1791 assert!(cwes.contains(&"CWE-79".to_string()));
1792 assert!(cwes.contains(&"CWE-89".to_string()));
1793 assert!(options.include_enrichment);
1794 }
1795
1796 #[test]
1797 fn test_extract_cwes_from_advisory_with_cwes() {
1798 let advisory = create_advisory_with_cwes("CVE-2024-1234", Some(vec!["CWE-79", "CWE-89"]));
1799 let cwes = VulnerabilityManager::extract_cwes_from_advisory(&advisory);
1800 assert_eq!(cwes.len(), 2);
1801 assert!(cwes.contains(&"CWE-79".to_string()));
1802 assert!(cwes.contains(&"CWE-89".to_string()));
1803 }
1804
1805 #[test]
1806 fn test_extract_cwes_from_advisory_no_cwes() {
1807 let advisory = create_advisory_with_cwes("CVE-2024-1234", None);
1808 let cwes = VulnerabilityManager::extract_cwes_from_advisory(&advisory);
1809 assert!(cwes.is_empty());
1810 }
1811
1812 #[test]
1813 fn test_extract_cwes_from_advisory_empty_cwes() {
1814 let advisory = create_advisory_with_cwes("CVE-2024-1234", Some(vec![]));
1815 let cwes = VulnerabilityManager::extract_cwes_from_advisory(&advisory);
1816 assert!(cwes.is_empty());
1817 }
1818
1819 #[test]
1820 fn test_cwe_filter_case_insensitive() {
1821 let advisory = create_advisory_with_cwes("CVE-2024-1234", Some(vec!["cwe-79"]));
1823
1824 let options = MatchOptions::with_cwes(vec!["CWE-79".to_string()]);
1826
1827 let advisory_cwes = VulnerabilityManager::extract_cwes_from_advisory(&advisory);
1829
1830 let filter_cwes = options.cwe_ids.as_ref().unwrap();
1832 let has_match = filter_cwes
1833 .iter()
1834 .any(|cwe| advisory_cwes.iter().any(|ac| ac.eq_ignore_ascii_case(cwe)));
1835 assert!(has_match, "CWE matching should be case-insensitive");
1836 }
1837
1838 #[test]
1839 fn test_cwe_filter_no_match() {
1840 let advisory = create_advisory_with_cwes("CVE-2024-1234", Some(vec!["CWE-79"]));
1841
1842 let options = MatchOptions::with_cwes(vec!["CWE-89".to_string()]);
1844
1845 let advisory_cwes = VulnerabilityManager::extract_cwes_from_advisory(&advisory);
1846 let filter_cwes = options.cwe_ids.as_ref().unwrap();
1847 let has_match = filter_cwes
1848 .iter()
1849 .any(|cwe| advisory_cwes.iter().any(|ac| ac.eq_ignore_ascii_case(cwe)));
1850 assert!(!has_match, "Should not match when CWEs don't overlap");
1851 }
1852
1853 #[test]
1854 fn test_cwe_filter_partial_match() {
1855 let advisory =
1857 create_advisory_with_cwes("CVE-2024-1234", Some(vec!["CWE-79", "CWE-352", "CWE-94"]));
1858
1859 let options = MatchOptions::with_cwes(vec!["CWE-89".to_string(), "CWE-79".to_string()]);
1860
1861 let advisory_cwes = VulnerabilityManager::extract_cwes_from_advisory(&advisory);
1862 let filter_cwes = options.cwe_ids.as_ref().unwrap();
1863 let has_match = filter_cwes
1864 .iter()
1865 .any(|cwe| advisory_cwes.iter().any(|ac| ac.eq_ignore_ascii_case(cwe)));
1866 assert!(has_match, "Should match when at least one CWE overlaps");
1867 }
1868
1869 #[test]
1870 fn test_match_options_empty_cwe_list() {
1871 let options = MatchOptions {
1873 cwe_ids: Some(vec![]),
1874 ..Default::default()
1875 };
1876
1877 assert!(options.cwe_ids.as_ref().is_none_or(|v| v.is_empty()));
1879 }
1880
1881 #[test]
1882 fn test_match_options_combined_filters() {
1883 let options = MatchOptions {
1885 cwe_ids: Some(vec!["CWE-79".to_string()]),
1886 min_severity: Some(Severity::High),
1887 kev_only: true,
1888 include_enrichment: true,
1889 ..Default::default()
1890 };
1891
1892 assert!(options.cwe_ids.is_some());
1893 assert_eq!(options.min_severity, Some(Severity::High));
1894 assert!(options.kev_only);
1895 }
1896
1897 #[test]
1898 fn test_normalize_cwe_id_with_prefix() {
1899 assert_eq!(VulnerabilityManager::normalize_cwe_id("CWE-79"), "CWE-79");
1900 assert_eq!(VulnerabilityManager::normalize_cwe_id("cwe-79"), "CWE-79");
1901 assert_eq!(VulnerabilityManager::normalize_cwe_id("Cwe-89"), "CWE-89");
1902 }
1903
1904 #[test]
1905 fn test_normalize_cwe_id_bare_number() {
1906 assert_eq!(VulnerabilityManager::normalize_cwe_id("79"), "CWE-79");
1907 assert_eq!(VulnerabilityManager::normalize_cwe_id("89"), "CWE-89");
1908 assert_eq!(VulnerabilityManager::normalize_cwe_id("352"), "CWE-352");
1909 }
1910
1911 #[test]
1912 fn test_normalize_cwe_id_with_whitespace() {
1913 assert_eq!(VulnerabilityManager::normalize_cwe_id(" CWE-79 "), "CWE-79");
1914 assert_eq!(VulnerabilityManager::normalize_cwe_id(" 79 "), "CWE-79");
1915 }
1916
1917 #[test]
1918 fn test_cwe_filter_bare_id_matches_prefixed() {
1919 let advisory = create_advisory_with_cwes("CVE-2024-1234", Some(vec!["CWE-79"]));
1921 let advisory_cwes = VulnerabilityManager::extract_cwes_from_advisory(&advisory);
1922
1923 let filter_cwes = ["79".to_string()];
1924 let normalized_filter: Vec<String> = filter_cwes
1925 .iter()
1926 .map(|c| VulnerabilityManager::normalize_cwe_id(c))
1927 .collect();
1928 let normalized_advisory: Vec<String> = advisory_cwes
1929 .iter()
1930 .map(|c| VulnerabilityManager::normalize_cwe_id(c))
1931 .collect();
1932
1933 let has_match = normalized_filter
1934 .iter()
1935 .any(|cwe| normalized_advisory.iter().any(|ac| ac == cwe));
1936 assert!(has_match, "Bare '79' should match 'CWE-79'");
1937 }
1938
1939 #[test]
1940 fn test_cwe_filter_prefixed_matches_bare() {
1941 let advisory = create_advisory_with_cwes("CVE-2024-1234", Some(vec!["79"]));
1943 let advisory_cwes = VulnerabilityManager::extract_cwes_from_advisory(&advisory);
1944
1945 let filter_cwes = ["CWE-79".to_string()];
1946 let normalized_filter: Vec<String> = filter_cwes
1947 .iter()
1948 .map(|c| VulnerabilityManager::normalize_cwe_id(c))
1949 .collect();
1950 let normalized_advisory: Vec<String> = advisory_cwes
1951 .iter()
1952 .map(|c| VulnerabilityManager::normalize_cwe_id(c))
1953 .collect();
1954
1955 let has_match = normalized_filter
1956 .iter()
1957 .any(|cwe| normalized_advisory.iter().any(|ac| ac == cwe));
1958 assert!(has_match, "'CWE-79' should match bare '79'");
1959 }
1960
1961 #[test]
1962 fn test_cwe_filter_with_enrichment_severity() {
1963 let mut advisory = create_advisory_with_enrichment("CVE-2024-1234", Severity::High, false);
1965
1966 let mut db_specific = serde_json::Map::new();
1968 db_specific.insert(
1969 "cwe_ids".to_string(),
1970 serde_json::json!(["CWE-79", "CWE-89"]),
1971 );
1972 advisory.database_specific = Some(serde_json::Value::Object(db_specific));
1973
1974 assert!(advisory.enrichment.is_some());
1976 assert_eq!(
1977 advisory.enrichment.as_ref().unwrap().cvss_v3_severity,
1978 Some(Severity::High)
1979 );
1980
1981 let cwes = VulnerabilityManager::extract_cwes_from_advisory(&advisory);
1983 assert_eq!(cwes, vec!["CWE-79", "CWE-89"]);
1984 }
1985
1986 #[test]
1987 fn test_cwe_filter_with_enrichment_kev() {
1988 let mut advisory =
1990 create_advisory_with_enrichment("CVE-2024-5678", Severity::Critical, true);
1991
1992 let mut db_specific = serde_json::Map::new();
1994 db_specific.insert("cwe_ids".to_string(), serde_json::json!(["CWE-78"]));
1995 advisory.database_specific = Some(serde_json::Value::Object(db_specific));
1996
1997 assert!(advisory.enrichment.as_ref().unwrap().is_kev);
1999
2000 let cwes = VulnerabilityManager::extract_cwes_from_advisory(&advisory);
2002 assert_eq!(cwes, vec!["CWE-78"]);
2003
2004 let normalized: Vec<String> = cwes
2006 .iter()
2007 .map(|c| VulnerabilityManager::normalize_cwe_id(c))
2008 .collect();
2009 assert_eq!(normalized, vec!["CWE-78"]);
2010 }
2011
2012 #[test]
2013 fn test_matches_git_range_exact_boundary_commits() {
2014 let events = vec![
2015 Event::Introduced("abc123".to_string()),
2016 Event::Fixed("def456".to_string()),
2017 ];
2018
2019 assert!(VulnerabilityManager::matches_git_range("abc123", &events));
2020 assert!(!VulnerabilityManager::matches_git_range("def456", &events));
2021 }
2022
2023 #[test]
2024 fn test_matches_git_range_unbounded_introduced() {
2025 let events = vec![Event::Introduced("0".to_string())];
2026 assert!(VulnerabilityManager::matches_git_range("deadbeef", &events));
2027
2028 let closed_events = vec![
2029 Event::Introduced("0".to_string()),
2030 Event::Limit("ffff".to_string()),
2031 ];
2032 assert!(!VulnerabilityManager::matches_git_range(
2033 "deadbeef",
2034 &closed_events
2035 ));
2036 }
2037}