diff --git a/backend/crates/application/src/test_fakes.rs b/backend/crates/application/src/test_fakes.rs index 3c69d41..8bb58c7 100644 --- a/backend/crates/application/src/test_fakes.rs +++ b/backend/crates/application/src/test_fakes.rs @@ -595,17 +595,18 @@ impl FindingRepository for MemFindings { self.0.lock().unwrap().push(finding.clone()); Ok(()) } - async fn touch( + async fn refresh( &self, - ids: &[Uuid], + updates: &[(Uuid, RawFinding)], last_seen: chrono::DateTime, ) -> Result<(), DomainError> { - self.0 - .lock() - .unwrap() - .iter_mut() - .filter(|f| ids.contains(&f.id)) - .for_each(|f| f.last_seen = last_seen); + let mut v = self.0.lock().unwrap(); + for (id, raw) in updates { + if let Some(f) = v.iter_mut().find(|f| f.id == *id) { + f.raw = raw.clone(); + f.last_seen = last_seen; + } + } Ok(()) } async fn set_status(&self, id: Uuid, status: FindingStatus) -> Result<(), DomainError> { diff --git a/backend/crates/application/src/tests/vuln_tests.rs b/backend/crates/application/src/tests/vuln_tests.rs index d44746c..2cc85c0 100644 --- a/backend/crates/application/src/tests/vuln_tests.rs +++ b/backend/crates/application/src/tests/vuln_tests.rs @@ -363,3 +363,48 @@ async fn targets_are_reported_with_their_scope_and_open_count() { // an image without findings is not listed assert!(targets.iter().all(|t| t.target != PG)); } + +#[tokio::test] +async fn rescan_refreshes_scanner_details_of_findings_that_are_still_present() { + let mut first = raw("CVE-1", "openssl", "3.0.1", Severity::Medium, None); + first.source = String::new(); + let scanner = FakeScanner::default().with("os", Ok(vec![first])); + let (f, svc) = fixture(scanner); + svc.scan(&VecLog::default()).await.unwrap(); + let before = f.findings.0.lock().unwrap()[0].clone(); + svc.set_status(before.id, FindingStatus::Acknowledged) + .await + .unwrap(); + + // the scanner now rates it higher, knows a fix and reports the package source + let mut updated = raw( + "CVE-1", + "openssl", + "3.0.1", + Severity::Critical, + Some("3.0.2"), + ); + updated.source = "debian".into(); + updated.title = "openssl: corrected title".into(); + *f.results.lock().unwrap() = + std::collections::HashMap::from([("os".to_string(), Ok(vec![updated]))]); + let report = svc.scan(&VecLog::default()).await.unwrap(); + assert!( + report.new_findings.is_empty(), + "same finding, not a new one" + ); + + let after = f.findings.0.lock().unwrap()[0].clone(); + assert_eq!(after.id, before.id); + assert_eq!(after.raw.severity, Severity::Critical); + assert_eq!(after.raw.fixed_version.as_deref(), Some("3.0.2")); + assert_eq!(after.raw.source, "debian"); + assert_eq!(after.raw.title, "openssl: corrected title"); + assert_eq!(after.first_seen, before.first_seen, "first_seen is kept"); + assert!(after.last_seen > before.last_seen); + assert_eq!( + after.status, + FindingStatus::Acknowledged, + "the user's decision is kept" + ); +} diff --git a/backend/crates/application/src/vuln_service.rs b/backend/crates/application/src/vuln_service.rs index 4f35898..97bf1ac 100644 --- a/backend/crates/application/src/vuln_service.rs +++ b/backend/crates/application/src/vuln_service.rs @@ -146,7 +146,7 @@ impl VulnerabilityService { continue; } match existing.iter().find(|f| f.raw.key() == r.key()) { - Some(f) => still_present.push(f.id), + Some(f) => still_present.push((f.id, r)), None => { let f = Finding { id: Uuid::new_v4(), @@ -162,9 +162,12 @@ impl VulnerabilityService { } } } - self.findings.touch(&still_present, now).await?; + self.findings.refresh(&still_present, now).await?; let mut fixed = 0; - for f in existing.iter().filter(|f| !still_present.contains(&f.id)) { + for f in existing + .iter() + .filter(|f| !still_present.iter().any(|(id, _)| *id == f.id)) + { self.findings.set_status(f.id, FindingStatus::Fixed).await?; fixed += 1; } diff --git a/backend/crates/domain/src/ports.rs b/backend/crates/domain/src/ports.rs index 2fdf67c..b1fb214 100644 --- a/backend/crates/domain/src/ports.rs +++ b/backend/crates/domain/src/ports.rs @@ -140,9 +140,11 @@ pub trait FindingRepository: Send + Sync { /// Open and acknowledged findings of one target. async fn active_by_target(&self, target: &str) -> Result, DomainError>; async fn insert(&self, finding: &Finding) -> Result<(), DomainError>; - async fn touch( + /// Mark findings as seen again and update the details the scanner may have changed + /// (severity, fix version, title, source). + async fn refresh( &self, - ids: &[Uuid], + updates: &[(Uuid, RawFinding)], last_seen: chrono::DateTime, ) -> Result<(), DomainError>; async fn set_status(&self, id: Uuid, status: FindingStatus) -> Result<(), DomainError>; diff --git a/backend/crates/infrastructure/src/sqlite.rs b/backend/crates/infrastructure/src/sqlite.rs index cb3cca1..1557da4 100644 --- a/backend/crates/infrastructure/src/sqlite.rs +++ b/backend/crates/infrastructure/src/sqlite.rs @@ -574,14 +574,25 @@ impl domain::ports::FindingRepository for SqliteFindings { .map(|_| ()) .map_err(storage) } - async fn touch(&self, ids: &[Uuid], last_seen: DateTime) -> Result<(), DomainError> { - for id in ids { - sqlx::query("UPDATE findings SET last_seen = ? WHERE id = ?") - .bind(last_seen.to_rfc3339()) - .bind(id) - .execute(&self.0) - .await - .map_err(storage)?; + async fn refresh( + &self, + updates: &[(Uuid, RawFinding)], + last_seen: DateTime, + ) -> Result<(), DomainError> { + for (id, raw) in updates { + sqlx::query( + "UPDATE findings SET last_seen = ?, severity = ?, fixed_version = ?, title = ?, url = ?, source = ? WHERE id = ?", + ) + .bind(last_seen.to_rfc3339()) + .bind(raw.severity.as_str()) + .bind(&raw.fixed_version) + .bind(&raw.title) + .bind(&raw.url) + .bind(&raw.source) + .bind(id) + .execute(&self.0) + .await + .map_err(storage)?; } Ok(()) } @@ -752,8 +763,16 @@ mod finding_tests { assert_eq!(repo.counts(Some(TargetKind::Os)).await.unwrap().critical, 1); let later = Utc::now() + chrono::Duration::hours(1); - repo.touch(&[a.id], later).await.unwrap(); - assert!(repo.get(a.id).await.unwrap().unwrap().last_seen > a.last_seen); + let fixed = RawFinding { + fixed_version: Some("2.0".into()), + source: "gobinary".into(), + ..a.raw.clone() + }; + repo.refresh(&[(a.id, fixed)], later).await.unwrap(); + let refreshed = repo.get(a.id).await.unwrap().unwrap(); + assert!(refreshed.last_seen > a.last_seen); + assert_eq!(refreshed.raw.fixed_version.as_deref(), Some("2.0")); + assert_eq!(refreshed.raw.source, "gobinary"); assert_eq!( repo.set_status(Uuid::new_v4(), FindingStatus::Open) .await