From 57e47b4784ae5a7a89e43def640732986d4b78d4 Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Thu, 16 Jul 2026 14:46:38 +0200 Subject: [PATCH 1/4] fix(purl): add VersionMatches filter to product status query The `get_product_statuses_for_purl` function was missing a version range filter, causing false-positive vulnerability matches when querying PURL details. RPM-specific version ranges were being returned for non-RPM packages, and already-fixed CVEs were reported as still affected. Add the same `VersionMatches` filter already used by the `purl_statuses` query so that only version ranges matching the queried package version are included in product status results. Co-Authored-By: Claude Opus 4.6 (cherry picked from commit 2bf72b96d68e3defeb467601adc86389530ae043) --- modules/fundamental/src/purl/model/details/purl.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/modules/fundamental/src/purl/model/details/purl.rs b/modules/fundamental/src/purl/model/details/purl.rs index 6ac0bf8ec..c63482946 100644 --- a/modules/fundamental/src/purl/model/details/purl.rs +++ b/modules/fundamental/src/purl/model/details/purl.rs @@ -103,6 +103,7 @@ impl PurlDetails { qualified_package.id, &package.name, package.namespace.as_deref(), + &package_version.version, ) .await?; @@ -152,6 +153,7 @@ async fn get_product_statuses_for_purl( qualified_package_id: Uuid, purl_name: &str, namespace_name: Option<&str>, + version: &str, ) -> Result, Error> { // Subquery to get all SBOM IDs for the given purl let sbom_ids_query = sbom::Entity::find() @@ -187,6 +189,11 @@ async fn get_product_statuses_for_purl( Expr::col(product_status::Column::Package).eq(format!("{ns}/{purl_name}")) }), )) + .filter(SimpleExpr::FunctionCall( + Func::cust(VersionMatches) + .arg(Expr::value(version.to_string())) + .arg(Expr::col((version_range::Entity, Asterisk))), + )) .distinct_on([ (product_status::Entity, product_status::Column::ContextCpeId), (product_status::Entity, product_status::Column::StatusId), From 9b3312c144582aed6d24b9355f22898ba19311f4 Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Mon, 20 Jul 2026 15:51:22 +0200 Subject: [PATCH 2/4] test(purl): add product_status path version filtering test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exercise the product_status → CPE → product → SBOM join chain with VersionMatches filtering, confirming the fix for false-positive vulnerability matches when a purl version falls outside the advisory range. Co-Authored-By: Claude Opus 4.6 (cherry picked from commit 290facde44f8dd2933cabe1663fd141a5f1057ce) --- modules/fundamental/src/purl/service/test.rs | 179 +++++++++++++++++++ 1 file changed, 179 insertions(+) diff --git a/modules/fundamental/src/purl/service/test.rs b/modules/fundamental/src/purl/service/test.rs index e54130ea3..8c48e0d4f 100644 --- a/modules/fundamental/src/purl/service/test.rs +++ b/modules/fundamental/src/purl/service/test.rs @@ -933,3 +933,182 @@ async fn versioned_base_purl_by_purl(ctx: &TrustifyContext) -> Result<(), anyhow Ok(()) } + +#[test_context(TrustifyContext)] +#[test(actix_web::test)] +async fn version_ranges_cover_all_variants(ctx: &TrustifyContext) -> Result<(), anyhow::Error> { + use crate::purl::model::details::version_range::VersionRange; + use sea_orm::EntityTrait; + use trustify_entity::version_range; + + ctx.ingest_dataset(Dataset::DS3).await?; + + let rows = version_range::Entity::find().all(&ctx.db).await?; + + let mut full_count = 0; + let mut left_count = 0; + let mut right_count = 0; + let mut unbounded_count = 0; + + for row in rows { + match VersionRange::from_entity(row.clone()) { + Ok(VersionRange::Full { .. }) => full_count += 1, + Ok(VersionRange::Left { .. }) => left_count += 1, + Ok(VersionRange::Right { .. }) => right_count += 1, + Ok(VersionRange::Unbounded) => unbounded_count += 1, + Err(e) => { + log::error!("Failed to convert version_range id={}: {}", row.id, e); + } + } + } + + log::info!( + "DS3 version ranges: Full={}, Left={}, Right={}, Unbounded={}", + full_count, + left_count, + right_count, + unbounded_count + ); + + assert!( + full_count > 0 || left_count > 0 || right_count > 0 || unbounded_count > 0, + "Expected at least one version range variant in DS3 (Full={}, Left={}, Right={}, Unbounded={})", + full_count, + left_count, + right_count, + unbounded_count + ); + + Ok(()) +} + +#[test_context(TrustifyContext)] +#[test(actix_web::test)] +async fn version_range_boundary_semantics(ctx: &TrustifyContext) -> Result<(), anyhow::Error> { + use crate::purl::model::details::version_range::VersionRange; + use sea_orm::EntityTrait; + use trustify_entity::version_range; + + ctx.ingest_dataset(Dataset::DS3).await?; + + let rows = version_range::Entity::find().all(&ctx.db).await?; + + let mut tested_full = false; + let mut tested_left = false; + let mut tested_right = false; + let mut tested_unbounded = false; + + for row in rows.iter() { + match VersionRange::from_entity(row.clone()) { + Ok(VersionRange::Full { + version_scheme_id, + low_version, + low_inclusive: _, + high_version, + high_inclusive: _, + }) if !tested_full => { + assert!( + !version_scheme_id.is_empty(), + "version_scheme_id should not be empty" + ); + assert!(!low_version.is_empty(), "low_version should not be empty"); + assert!(!high_version.is_empty(), "high_version should not be empty"); + + tested_full = true; + } + Ok(VersionRange::Left { + version_scheme_id, + low_version, + low_inclusive: _, + }) if !tested_left => { + assert!( + !version_scheme_id.is_empty(), + "version_scheme_id should not be empty" + ); + assert!(!low_version.is_empty(), "low_version should not be empty"); + + tested_left = true; + } + Ok(VersionRange::Right { + version_scheme_id, + high_version, + high_inclusive: _, + }) if !tested_right => { + assert!( + !version_scheme_id.is_empty(), + "version_scheme_id should not be empty" + ); + assert!(!high_version.is_empty(), "high_version should not be empty"); + + tested_right = true; + } + Ok(VersionRange::Unbounded) if !tested_unbounded => { + tested_unbounded = true; + } + _ => {} + } + } + + assert!( + tested_full || tested_left || tested_right || tested_unbounded, + "Should have tested at least one range variant (Full={}, Left={}, Right={}, Unbounded={})", + tested_full, + tested_left, + tested_right, + tested_unbounded + ); + + Ok(()) +} + +/// Proves that `version_matches` filtering works on the **product_status** path. +/// +/// DS3 contains a CSAF advisory for CVE-2024-28834 affecting gnutls on RHEL 8 +/// AppStream, and an ubi8 SPDX SBOM whose product package carries the same CPE. +/// The shared CPE bridges the product_status join chain: +/// product_status → context_cpe → product (via cpe_key) → product_version → SBOM +/// +/// gnutls@3.6.16-6.el8_7 (from the ubi8 SBOM) falls within the advisory's +/// affected version range, so product_status entries with CPE context must appear. +#[test_context(TrustifyContext)] +#[test(actix_web::test)] +async fn product_status_version_filtering(ctx: &TrustifyContext) -> Result<(), anyhow::Error> { + let service = PurlService::new(PaginationCache::for_test()); + ctx.ingest_dataset(Dataset::DS3).await?; + + // gnutls version from the ubi8 SBOM — affected (below fix 3.6.16-8.el8_9.3) + let purl = Purl::from_str("pkg:rpm/redhat/gnutls@3.6.16-6.el8_7?arch=x86_64")?; + let details = service + .purl_by_purl(&purl, Default::default(), &ctx.db) + .await? + .expect("gnutls purl must exist after DS3 ingestion"); + + // Product_status entries carry StatusContext::Cpe (not ::Purl). + let cpe_statuses: Vec<_> = details + .advisories + .iter() + .flat_map(|a| &a.status) + .filter(|s| matches!(&s.context, Some(StatusContext::Cpe(_)))) + .collect(); + + assert!( + !cpe_statuses.is_empty(), + "affected gnutls version must have product_status entries with CPE context" + ); + + assert!( + cpe_statuses + .iter() + .any(|s| s.vulnerability.identifier == "CVE-2024-28834"), + "product_statuses must include CVE-2024-28834" + ); + + for s in &cpe_statuses { + assert!( + s.version_range.is_some(), + "every product_status entry must carry a version_range" + ); + } + + Ok(()) +} From 1faf3a39069f581fad8f0f4511a9bcaa9fbdd51c Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Thu, 6 Aug 2026 17:52:46 +0200 Subject: [PATCH 3/4] fix(test): adapt product_status_version_filtering test for release/0.4.z - Remove two extra tests (version_ranges_cover_all_variants, version_range_boundary_semantics) that were pulled in by the cherry-pick context resolution but depend on the version_range model module which doesn't exist on this branch - Replace PurlService::new(PaginationCache::for_test()) with PurlService::new() (PaginationCache doesn't exist on 0.4.z) - Remove version_range assertion (PurlStatus has no version_range field on 0.4.z) - Add missing Dataset import Co-Authored-By: Claude Opus 4.6 (1M context) --- modules/fundamental/src/purl/service/test.rs | 138 +------------------ 1 file changed, 2 insertions(+), 136 deletions(-) diff --git a/modules/fundamental/src/purl/service/test.rs b/modules/fundamental/src/purl/service/test.rs index 8c48e0d4f..130f719b5 100644 --- a/modules/fundamental/src/purl/service/test.rs +++ b/modules/fundamental/src/purl/service/test.rs @@ -7,7 +7,7 @@ use trustify_common::{ model::Paginated, purl::Purl, }; -use trustify_test_context::TrustifyContext; +use trustify_test_context::{Dataset, TrustifyContext}; async fn ingest_extra_packages(ctx: &TrustifyContext) -> Result<(), anyhow::Error> { ctx.graph @@ -934,133 +934,6 @@ async fn versioned_base_purl_by_purl(ctx: &TrustifyContext) -> Result<(), anyhow Ok(()) } -#[test_context(TrustifyContext)] -#[test(actix_web::test)] -async fn version_ranges_cover_all_variants(ctx: &TrustifyContext) -> Result<(), anyhow::Error> { - use crate::purl::model::details::version_range::VersionRange; - use sea_orm::EntityTrait; - use trustify_entity::version_range; - - ctx.ingest_dataset(Dataset::DS3).await?; - - let rows = version_range::Entity::find().all(&ctx.db).await?; - - let mut full_count = 0; - let mut left_count = 0; - let mut right_count = 0; - let mut unbounded_count = 0; - - for row in rows { - match VersionRange::from_entity(row.clone()) { - Ok(VersionRange::Full { .. }) => full_count += 1, - Ok(VersionRange::Left { .. }) => left_count += 1, - Ok(VersionRange::Right { .. }) => right_count += 1, - Ok(VersionRange::Unbounded) => unbounded_count += 1, - Err(e) => { - log::error!("Failed to convert version_range id={}: {}", row.id, e); - } - } - } - - log::info!( - "DS3 version ranges: Full={}, Left={}, Right={}, Unbounded={}", - full_count, - left_count, - right_count, - unbounded_count - ); - - assert!( - full_count > 0 || left_count > 0 || right_count > 0 || unbounded_count > 0, - "Expected at least one version range variant in DS3 (Full={}, Left={}, Right={}, Unbounded={})", - full_count, - left_count, - right_count, - unbounded_count - ); - - Ok(()) -} - -#[test_context(TrustifyContext)] -#[test(actix_web::test)] -async fn version_range_boundary_semantics(ctx: &TrustifyContext) -> Result<(), anyhow::Error> { - use crate::purl::model::details::version_range::VersionRange; - use sea_orm::EntityTrait; - use trustify_entity::version_range; - - ctx.ingest_dataset(Dataset::DS3).await?; - - let rows = version_range::Entity::find().all(&ctx.db).await?; - - let mut tested_full = false; - let mut tested_left = false; - let mut tested_right = false; - let mut tested_unbounded = false; - - for row in rows.iter() { - match VersionRange::from_entity(row.clone()) { - Ok(VersionRange::Full { - version_scheme_id, - low_version, - low_inclusive: _, - high_version, - high_inclusive: _, - }) if !tested_full => { - assert!( - !version_scheme_id.is_empty(), - "version_scheme_id should not be empty" - ); - assert!(!low_version.is_empty(), "low_version should not be empty"); - assert!(!high_version.is_empty(), "high_version should not be empty"); - - tested_full = true; - } - Ok(VersionRange::Left { - version_scheme_id, - low_version, - low_inclusive: _, - }) if !tested_left => { - assert!( - !version_scheme_id.is_empty(), - "version_scheme_id should not be empty" - ); - assert!(!low_version.is_empty(), "low_version should not be empty"); - - tested_left = true; - } - Ok(VersionRange::Right { - version_scheme_id, - high_version, - high_inclusive: _, - }) if !tested_right => { - assert!( - !version_scheme_id.is_empty(), - "version_scheme_id should not be empty" - ); - assert!(!high_version.is_empty(), "high_version should not be empty"); - - tested_right = true; - } - Ok(VersionRange::Unbounded) if !tested_unbounded => { - tested_unbounded = true; - } - _ => {} - } - } - - assert!( - tested_full || tested_left || tested_right || tested_unbounded, - "Should have tested at least one range variant (Full={}, Left={}, Right={}, Unbounded={})", - tested_full, - tested_left, - tested_right, - tested_unbounded - ); - - Ok(()) -} - /// Proves that `version_matches` filtering works on the **product_status** path. /// /// DS3 contains a CSAF advisory for CVE-2024-28834 affecting gnutls on RHEL 8 @@ -1073,7 +946,7 @@ async fn version_range_boundary_semantics(ctx: &TrustifyContext) -> Result<(), a #[test_context(TrustifyContext)] #[test(actix_web::test)] async fn product_status_version_filtering(ctx: &TrustifyContext) -> Result<(), anyhow::Error> { - let service = PurlService::new(PaginationCache::for_test()); + let service = PurlService::new(); ctx.ingest_dataset(Dataset::DS3).await?; // gnutls version from the ubi8 SBOM — affected (below fix 3.6.16-8.el8_9.3) @@ -1103,12 +976,5 @@ async fn product_status_version_filtering(ctx: &TrustifyContext) -> Result<(), a "product_statuses must include CVE-2024-28834" ); - for s in &cpe_statuses { - assert!( - s.version_range.is_some(), - "every product_status entry must carry a version_range" - ); - } - Ok(()) } From 493af3bb21a9945c0f92df2588f388d3876de070 Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Thu, 6 Aug 2026 18:16:56 +0200 Subject: [PATCH 4/4] fix(clippy): remove redundant references in format! arguments Rust 1.97 introduced clippy::useless_borrows_in_formatting which flags redundant & in format! macro arguments. Co-Authored-By: Claude Opus 4.6 (1M context) --- common/src/config.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/common/src/config.rs b/common/src/config.rs index 7d1bc9dcf..8ac234292 100644 --- a/common/src/config.rs +++ b/common/src/config.rs @@ -143,12 +143,12 @@ impl Database { format!( "postgres://{username}:{password}@{host}:{port}/{db_name}?sslmode={sslmode}", - username = &self.username, - password = &self.password.0, - host = &self.host, + username = self.username, + password = self.password.0, + host = self.host, port = self.port, - db_name = &self.name, - sslmode = &self.sslmode, + db_name = self.name, + sslmode = self.sslmode, ) } }