Refresh scanner details of findings that are seen again
A rescan only touched the timestamp of findings it had seen before, so a newly published fix version, a changed severity and the package source never reached existing rows. The repository now updates those fields while keeping first_seen and the status the user set. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@ -595,17 +595,18 @@ impl FindingRepository for MemFindings {
|
|||||||
self.0.lock().unwrap().push(finding.clone());
|
self.0.lock().unwrap().push(finding.clone());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
async fn touch(
|
async fn refresh(
|
||||||
&self,
|
&self,
|
||||||
ids: &[Uuid],
|
updates: &[(Uuid, RawFinding)],
|
||||||
last_seen: chrono::DateTime<Utc>,
|
last_seen: chrono::DateTime<Utc>,
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
self.0
|
let mut v = self.0.lock().unwrap();
|
||||||
.lock()
|
for (id, raw) in updates {
|
||||||
.unwrap()
|
if let Some(f) = v.iter_mut().find(|f| f.id == *id) {
|
||||||
.iter_mut()
|
f.raw = raw.clone();
|
||||||
.filter(|f| ids.contains(&f.id))
|
f.last_seen = last_seen;
|
||||||
.for_each(|f| f.last_seen = last_seen);
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
async fn set_status(&self, id: Uuid, status: FindingStatus) -> Result<(), DomainError> {
|
async fn set_status(&self, id: Uuid, status: FindingStatus) -> Result<(), DomainError> {
|
||||||
|
|||||||
@ -363,3 +363,48 @@ async fn targets_are_reported_with_their_scope_and_open_count() {
|
|||||||
// an image without findings is not listed
|
// an image without findings is not listed
|
||||||
assert!(targets.iter().all(|t| t.target != PG));
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -146,7 +146,7 @@ impl VulnerabilityService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
match existing.iter().find(|f| f.raw.key() == r.key()) {
|
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 => {
|
None => {
|
||||||
let f = Finding {
|
let f = Finding {
|
||||||
id: Uuid::new_v4(),
|
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;
|
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?;
|
self.findings.set_status(f.id, FindingStatus::Fixed).await?;
|
||||||
fixed += 1;
|
fixed += 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -140,9 +140,11 @@ pub trait FindingRepository: Send + Sync {
|
|||||||
/// Open and acknowledged findings of one target.
|
/// Open and acknowledged findings of one target.
|
||||||
async fn active_by_target(&self, target: &str) -> Result<Vec<Finding>, DomainError>;
|
async fn active_by_target(&self, target: &str) -> Result<Vec<Finding>, DomainError>;
|
||||||
async fn insert(&self, finding: &Finding) -> 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,
|
&self,
|
||||||
ids: &[Uuid],
|
updates: &[(Uuid, RawFinding)],
|
||||||
last_seen: chrono::DateTime<chrono::Utc>,
|
last_seen: chrono::DateTime<chrono::Utc>,
|
||||||
) -> Result<(), DomainError>;
|
) -> Result<(), DomainError>;
|
||||||
async fn set_status(&self, id: Uuid, status: FindingStatus) -> Result<(), DomainError>;
|
async fn set_status(&self, id: Uuid, status: FindingStatus) -> Result<(), DomainError>;
|
||||||
|
|||||||
@ -574,14 +574,25 @@ impl domain::ports::FindingRepository for SqliteFindings {
|
|||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
.map_err(storage)
|
.map_err(storage)
|
||||||
}
|
}
|
||||||
async fn touch(&self, ids: &[Uuid], last_seen: DateTime<Utc>) -> Result<(), DomainError> {
|
async fn refresh(
|
||||||
for id in ids {
|
&self,
|
||||||
sqlx::query("UPDATE findings SET last_seen = ? WHERE id = ?")
|
updates: &[(Uuid, RawFinding)],
|
||||||
.bind(last_seen.to_rfc3339())
|
last_seen: DateTime<Utc>,
|
||||||
.bind(id)
|
) -> Result<(), DomainError> {
|
||||||
.execute(&self.0)
|
for (id, raw) in updates {
|
||||||
.await
|
sqlx::query(
|
||||||
.map_err(storage)?;
|
"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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@ -752,8 +763,16 @@ mod finding_tests {
|
|||||||
assert_eq!(repo.counts(Some(TargetKind::Os)).await.unwrap().critical, 1);
|
assert_eq!(repo.counts(Some(TargetKind::Os)).await.unwrap().critical, 1);
|
||||||
|
|
||||||
let later = Utc::now() + chrono::Duration::hours(1);
|
let later = Utc::now() + chrono::Duration::hours(1);
|
||||||
repo.touch(&[a.id], later).await.unwrap();
|
let fixed = RawFinding {
|
||||||
assert!(repo.get(a.id).await.unwrap().unwrap().last_seen > a.last_seen);
|
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!(
|
assert_eq!(
|
||||||
repo.set_status(Uuid::new_v4(), FindingStatus::Open)
|
repo.set_status(Uuid::new_v4(), FindingStatus::Open)
|
||||||
.await
|
.await
|
||||||
|
|||||||
Reference in New Issue
Block a user