From 81669e1e9f74919fcaf82a10e8d37e8049a590b2 Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Thu, 16 Jul 2026 17:09:39 +0200 Subject: [PATCH 1/5] fix(purl): filter purl_statuses by SBOM describing CPEs to prevent cross-product matches The purl_status query in PurlDetails was returning entries with context_cpe_id values from unrelated products (e.g., RPM/RHEL CPE contexts for Maven packages). This happened because context_cpe_id was never validated against the SBOM's own describing CPEs. Add a three-way filter matching the pattern already used in raw_sql.rs (batch_severity_counts_sql): allow purl_status rows where context_cpe_id is NULL, or matches a CPE from the SBOM's describing CPEs (via sbom_describing_cpe), or when the SBOM has no describing CPEs at all (fallback). Co-Authored-By: Claude Opus 4.6 --- .../src/purl/model/details/purl.rs | 34 ++++++++++++++++--- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/modules/fundamental/src/purl/model/details/purl.rs b/modules/fundamental/src/purl/model/details/purl.rs index 60e5d8fb8..5b526d844 100644 --- a/modules/fundamental/src/purl/model/details/purl.rs +++ b/modules/fundamental/src/purl/model/details/purl.rs @@ -12,9 +12,9 @@ use crate::{ vulnerability::model::VulnerabilityHead, }; use sea_orm::{ - ColumnTrait, ConnectionTrait, DbErr, EntityTrait, FromQueryResult, LoaderTrait, ModelTrait, - QueryFilter, QueryOrder, QueryResult, QuerySelect, QueryTrait, RelationTrait, Select, - SelectColumns, + ColumnTrait, Condition, ConnectionTrait, DbErr, EntityTrait, FromQueryResult, LoaderTrait, + ModelTrait, QueryFilter, QueryOrder, QueryResult, QuerySelect, QueryTrait, RelationTrait, + Select, SelectColumns, }; use sea_query::{Asterisk, ColumnRef, Expr, Func, IntoIden, JoinType, SimpleExpr}; use serde::{Deserialize, Serialize}; @@ -28,8 +28,8 @@ use trustify_common::{ use trustify_entity::{ advisory, advisory_vulnerability_score, base_purl, cpe, license, organization, product, product_status, product_version, product_version_range, purl_status, qualified_purl, sbom, - sbom_license_expanded, sbom_node, sbom_node_purl_ref, sbom_package_license, status, - version_range, versioned_purl, vulnerability, + sbom_describing_cpe, sbom_license_expanded, sbom_node, sbom_node_purl_ref, + sbom_package_license, status, version_range, versioned_purl, vulnerability, }; use trustify_module_ingestor::common::{Deprecation, DeprecationForExt}; use utoipa::ToSchema; @@ -84,6 +84,24 @@ impl PurlDetails { .ok_or(Error::Data("underlying package missing".to_string()))? }; + let sbom_ids_for_purl = sbom_node_purl_ref::Entity::find() + .select_only() + .column(sbom_node_purl_ref::Column::SbomId) + .filter(sbom_node_purl_ref::Column::QualifiedPurlId.eq(qualified_package.id)) + .into_query(); + + let allowed_cpe_ids = sbom_describing_cpe::Entity::find() + .select_only() + .column(sbom_describing_cpe::Column::CpeId) + .filter(sbom_describing_cpe::Column::SbomId.in_subquery(sbom_ids_for_purl.clone())) + .into_query(); + + let sbom_has_cpes = sea_query::Query::select() + .expr(Expr::value(1i32)) + .from(sbom_describing_cpe::Entity) + .and_where(sbom_describing_cpe::Column::SbomId.in_subquery(sbom_ids_for_purl)) + .to_owned(); + let purl_statuses = purl_status::Entity::find() .filter(purl_status::Column::BasePurlId.eq(package.id)) .left_join(version_range::Entity) @@ -93,6 +111,12 @@ impl PurlDetails { .arg(Expr::value(package_version.version.clone())) .arg(Expr::col((version_range::Entity, Asterisk))), )) + .filter( + Condition::any() + .add(purl_status::Column::ContextCpeId.is_null()) + .add(purl_status::Column::ContextCpeId.in_subquery(allowed_cpe_ids)) + .add(Expr::exists(sbom_has_cpes).not()), + ) .distinct_on([ColumnRef::TableColumn( purl_status::Entity.into_iden(), purl_status::Column::Id.into_iden(), From 7bac37ef47c8e183009e362f5fee1ae09b493f0f Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Fri, 17 Jul 2026 11:18:14 +0200 Subject: [PATCH 2/5] fix(vulnerability): filter context_cpe_id in build_query for /vulnerability/analyze The build_query() function in the vulnerability service returns advisory entries with RPM/RHEL CPE contexts for non-RPM packages because context_cpe_id is never validated against the SBOM's describing CPEs. Add the three-way context_cpe_id filter to both purl_status and product_status sub-queries in build_query(): 1. context_cpe_id IS NULL (advisory applies globally) 2. context_cpe_id matches an SBOM describing CPE for the purl 3. The purl has no SBOM with describing CPEs (fallback) For purl_status, base_purl.id is available in the FROM clause so the correlated subquery joins through versioned_purl directly. For product_status, base_purl is not in scope, so a product_bp_condition variable is built using Statement::from_sql_and_values to match by name/type/namespace, with an explicit JOIN to base_purl in the subquery. This complements commit 81669e1e which applied the same fix to the purl details endpoint (purl.rs). Fixes: https://github.com/guacsec/trustify/issues/2524 Co-Authored-By: Claude Opus 4.6 --- .../src/vulnerability/service/mod.rs | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/modules/fundamental/src/vulnerability/service/mod.rs b/modules/fundamental/src/vulnerability/service/mod.rs index 5ab6acb01..30aecfda9 100644 --- a/modules/fundamental/src/vulnerability/service/mod.rs +++ b/modules/fundamental/src/vulnerability/service/mod.rs @@ -575,6 +575,25 @@ GROUP BY AND base_purl.name = $2 AND base_purl.type = $3 AND version_matches($4, version_range.*) = TRUE + AND ( + purl_status.context_cpe_id IS NULL + OR purl_status.context_cpe_id IN ( + SELECT sdc.cpe_id + FROM sbom_describing_cpe sdc + JOIN sbom_node_purl_ref snpr ON snpr.sbom_id = sdc.sbom_id + JOIN qualified_purl qp ON snpr.qualified_purl_id = qp.id + JOIN versioned_purl vp ON qp.versioned_purl_id = vp.id + WHERE vp.base_purl_id = base_purl.id + ) + OR NOT EXISTS ( + SELECT 1 + FROM sbom_describing_cpe sdc + JOIN sbom_node_purl_ref snpr ON snpr.sbom_id = sdc.sbom_id + JOIN qualified_purl qp ON snpr.qualified_purl_id = qp.id + JOIN versioned_purl vp ON qp.versioned_purl_id = vp.id + WHERE vp.base_purl_id = base_purl.id + ) + ) "#).as_str(), "r.data", ); @@ -612,6 +631,23 @@ GROUP BY } }; + let product_bp_condition = match &purl.namespace { + Some(namespace) => { + Statement::from_sql_and_values( + connection.get_database_backend(), + "bp.name = $1 AND bp.type = $2 AND bp.namespace = $3", + [purl.name.clone().into(), purl.ty.clone().into(), namespace.into()], + ).to_string() + } + None => { + Statement::from_sql_and_values( + connection.get_database_backend(), + "bp.name = $1 AND bp.type = $2 AND bp.namespace IS NULL", + [purl.name.clone().into(), purl.ty.clone().into()], + ).to_string() + } + }; + let product_status_sql = Self::build_vulnerabilities_query_string( r#" 'advisory_id', product_status.advisory_id, 'context_cpe', cpe.id @@ -629,6 +665,27 @@ GROUP BY "#, format!(r#" {package_condition} AND product_status.package IS NOT NULL + AND ( + product_status.context_cpe_id IS NULL + OR product_status.context_cpe_id IN ( + SELECT sdc.cpe_id + FROM sbom_describing_cpe sdc + JOIN sbom_node_purl_ref snpr ON snpr.sbom_id = sdc.sbom_id + JOIN qualified_purl qp ON snpr.qualified_purl_id = qp.id + JOIN versioned_purl vp ON qp.versioned_purl_id = vp.id + JOIN base_purl bp ON vp.base_purl_id = bp.id + WHERE {product_bp_condition} + ) + OR NOT EXISTS ( + SELECT 1 + FROM sbom_describing_cpe sdc + JOIN sbom_node_purl_ref snpr ON snpr.sbom_id = sdc.sbom_id + JOIN qualified_purl qp ON snpr.qualified_purl_id = qp.id + JOIN versioned_purl vp ON qp.versioned_purl_id = vp.id + JOIN base_purl bp ON vp.base_purl_id = bp.id + WHERE {product_bp_condition} + ) + ) "#).as_str(), r#" jsonb_set(r.data, '{product_ids}', COALESCE( From e25a50bc8d71724117e1ae6407803e57c3867a8f Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Fri, 17 Jul 2026 13:58:36 +0200 Subject: [PATCH 3/5] fix(purl): use generalized CPE matching for context_cpe_id filter The purl details endpoint filtered purl_statuses using exact CPE ID matching against SBOM describing CPEs. Advisory context CPEs use different version granularity than SBOM describing CPEs (e.g., version=8 vs version=8.8), causing all entries to be filtered out. Add a UNION with generalized CPE matching that expands allowed CPE IDs to include those sharing (vendor, product, split_part(version, '.', 1)) with relaxed edition, matching the pattern already used in raw_sql.rs. This completes the fix across all three affected code paths (raw_sql.rs, build_query, and purl details). Refs: TC-5171 Co-Authored-By: Claude Opus 4.6 --- .../src/purl/model/details/purl.rs | 53 ++++++++++++++++++- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/modules/fundamental/src/purl/model/details/purl.rs b/modules/fundamental/src/purl/model/details/purl.rs index 5b526d844..4563ec207 100644 --- a/modules/fundamental/src/purl/model/details/purl.rs +++ b/modules/fundamental/src/purl/model/details/purl.rs @@ -16,7 +16,9 @@ use sea_orm::{ ModelTrait, QueryFilter, QueryOrder, QueryResult, QuerySelect, QueryTrait, RelationTrait, Select, SelectColumns, }; -use sea_query::{Asterisk, ColumnRef, Expr, Func, IntoIden, JoinType, SimpleExpr}; +use sea_query::{ + Alias, Asterisk, ColumnRef, Expr, Func, IntoIden, JoinType, SimpleExpr, UnionType, +}; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, hash_map::Entry}; use trustify_common::{ @@ -90,12 +92,59 @@ impl PurlDetails { .filter(sbom_node_purl_ref::Column::QualifiedPurlId.eq(qualified_package.id)) .into_query(); - let allowed_cpe_ids = sbom_describing_cpe::Entity::find() + let mut allowed_cpe_ids = sbom_describing_cpe::Entity::find() .select_only() .column(sbom_describing_cpe::Column::CpeId) .filter(sbom_describing_cpe::Column::SbomId.in_subquery(sbom_ids_for_purl.clone())) .into_query(); + let c = Alias::new("c"); + let sc = Alias::new("sc"); + let sdc = Alias::new("sdc"); + let generalized_cpe_ids = sea_query::Query::select() + .expr(Expr::col((c.clone(), cpe::Column::Id))) + .from_as(cpe::Entity, c.clone()) + .join_as( + JoinType::InnerJoin, + cpe::Entity, + sc.clone(), + Condition::all() + .add( + Expr::col((c.clone(), cpe::Column::Vendor)) + .equals((sc.clone(), cpe::Column::Vendor)), + ) + .add( + Expr::col((c.clone(), cpe::Column::Product)) + .equals((sc.clone(), cpe::Column::Product)), + ) + .add( + Expr::col((c.clone(), cpe::Column::Version)).eq(SimpleExpr::FunctionCall( + Func::cust(Alias::new("split_part")) + .arg(Expr::col((sc.clone(), cpe::Column::Version))) + .arg(Expr::value(".")) + .arg(Expr::value(1i32)), + )), + ) + .add( + Condition::any() + .add(Expr::col((c.clone(), cpe::Column::Edition)).is_null()) + .add(Expr::col((c.clone(), cpe::Column::Edition)).eq("*")), + ), + ) + .join_as( + JoinType::InnerJoin, + sbom_describing_cpe::Entity, + sdc.clone(), + Expr::col((sdc.clone(), sbom_describing_cpe::Column::CpeId)) + .equals((sc.clone(), cpe::Column::Id)), + ) + .and_where( + Expr::col((sdc.clone(), sbom_describing_cpe::Column::SbomId)) + .in_subquery(sbom_ids_for_purl.clone()), + ) + .to_owned(); + allowed_cpe_ids.union(UnionType::Distinct, generalized_cpe_ids); + let sbom_has_cpes = sea_query::Query::select() .expr(Expr::value(1i32)) .from(sbom_describing_cpe::Entity) From ab6f6c1ce5d17f6ee9ce4cc8feec39676cebc31f Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Fri, 17 Jul 2026 14:34:10 +0200 Subject: [PATCH 4/5] fix(vulnerability): use generalized CPE matching in analyze endpoint The build_query() function for /vulnerability/analyze filtered purl_statuses and product_statuses using exact CPE ID matching. Advisory context CPEs use different version granularity than SBOM describing CPEs, causing all entries to be filtered out. Add UNION with generalized CPE matching to both the purl_status and product_status subqueries, expanding allowed CPE IDs to include those sharing (vendor, product, split_part(version, '.', 1)) with relaxed edition. Refs: TC-5171 Co-Authored-By: Claude Opus 4.6 --- .../src/vulnerability/service/mod.rs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/modules/fundamental/src/vulnerability/service/mod.rs b/modules/fundamental/src/vulnerability/service/mod.rs index 30aecfda9..0e4ae2718 100644 --- a/modules/fundamental/src/vulnerability/service/mod.rs +++ b/modules/fundamental/src/vulnerability/service/mod.rs @@ -584,6 +584,20 @@ GROUP BY JOIN qualified_purl qp ON snpr.qualified_purl_id = qp.id JOIN versioned_purl vp ON qp.versioned_purl_id = vp.id WHERE vp.base_purl_id = base_purl.id + + UNION + + SELECT c.id + FROM cpe c + JOIN cpe sc ON c.vendor = sc.vendor + AND c.product = sc.product + AND c.version = split_part(sc.version, '.', 1) + AND (c.edition IS NULL OR c.edition = '*') + JOIN sbom_describing_cpe sdc ON sdc.cpe_id = sc.id + JOIN sbom_node_purl_ref snpr ON snpr.sbom_id = sdc.sbom_id + JOIN qualified_purl qp ON snpr.qualified_purl_id = qp.id + JOIN versioned_purl vp ON qp.versioned_purl_id = vp.id + WHERE vp.base_purl_id = base_purl.id ) OR NOT EXISTS ( SELECT 1 @@ -675,6 +689,21 @@ GROUP BY JOIN versioned_purl vp ON qp.versioned_purl_id = vp.id JOIN base_purl bp ON vp.base_purl_id = bp.id WHERE {product_bp_condition} + + UNION + + SELECT c.id + FROM cpe c + JOIN cpe sc ON c.vendor = sc.vendor + AND c.product = sc.product + AND c.version = split_part(sc.version, '.', 1) + AND (c.edition IS NULL OR c.edition = '*') + JOIN sbom_describing_cpe sdc ON sdc.cpe_id = sc.id + JOIN sbom_node_purl_ref snpr ON snpr.sbom_id = sdc.sbom_id + JOIN qualified_purl qp ON snpr.qualified_purl_id = qp.id + JOIN versioned_purl vp ON qp.versioned_purl_id = vp.id + JOIN base_purl bp ON vp.base_purl_id = bp.id + WHERE {product_bp_condition} ) OR NOT EXISTS ( SELECT 1 From 643dbb27c05551d0fce123e84af63654de938800 Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Fri, 17 Jul 2026 16:40:35 +0200 Subject: [PATCH 5/5] fix: remove edition constraint from generalized CPE matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The edition constraint (edition IS NULL OR edition = '*') in the generalized CPE matching was too strict — it excluded legitimate same-product entries with specific editions (e.g. baseos when SBOM describes appstream). All CPEs sharing the same vendor, product, and major version should be included regardless of edition. Co-Authored-By: Claude Opus 4.6 --- modules/fundamental/src/purl/model/details/purl.rs | 5 ----- modules/fundamental/src/sbom/model/raw_sql.rs | 11 ++++------- modules/fundamental/src/vulnerability/service/mod.rs | 2 -- 3 files changed, 4 insertions(+), 14 deletions(-) diff --git a/modules/fundamental/src/purl/model/details/purl.rs b/modules/fundamental/src/purl/model/details/purl.rs index 4563ec207..80e939b9f 100644 --- a/modules/fundamental/src/purl/model/details/purl.rs +++ b/modules/fundamental/src/purl/model/details/purl.rs @@ -124,11 +124,6 @@ impl PurlDetails { .arg(Expr::value(".")) .arg(Expr::value(1i32)), )), - ) - .add( - Condition::any() - .add(Expr::col((c.clone(), cpe::Column::Edition)).is_null()) - .add(Expr::col((c.clone(), cpe::Column::Edition)).eq("*")), ), ) .join_as( diff --git a/modules/fundamental/src/sbom/model/raw_sql.rs b/modules/fundamental/src/sbom/model/raw_sql.rs index 51beb5680..ca7e022ba 100644 --- a/modules/fundamental/src/sbom/model/raw_sql.rs +++ b/modules/fundamental/src/sbom/model/raw_sql.rs @@ -1,8 +1,8 @@ /// This constant is a SQL subquery that filters the context_cpe_id /// based on the given sbom_id. It reads from the materialized /// sbom_describing_cpe table instead of computing the join at query time. -/// The generalized CPE logic expands matches to include CPEs without edition -/// and with major-version-only matching. +/// The generalized CPE logic expands matches to include all CPEs sharing +/// the same vendor, product, and major version. pub const CONTEXT_CPE_FILTER_SQL: &str = r#" ( context_cpe_id IS NULL OR @@ -16,8 +16,7 @@ pub const CONTEXT_CPE_FILTER_SQL: &str = r#" generalized_cpes AS ( SELECT * FROM cpe - WHERE (edition IS NULL OR edition = '*') - AND (vendor, product, version) IN ( + WHERE (vendor, product, version) IN ( SELECT vendor, product, split_part(version, '.', 1) FROM filtered_cpes ) @@ -86,7 +85,6 @@ pub fn batch_severity_counts_sql() -> &'static str { JOIN cpe c ON c.vendor = sc.vendor AND c.product = sc.product AND c.version = split_part(sc.version, '.', 1) - AND (c.edition IS NULL OR c.edition = '*') ), sbom_allowed_cpes AS ( SELECT sbom_id, id AS cpe_id FROM sbom_cpes @@ -218,8 +216,7 @@ pub fn product_advisory_info_sql() -> String { generalized_cpes AS ( SELECT * FROM cpe - WHERE (edition IS NULL OR edition = '*') - AND (vendor, product, version) IN ( + WHERE (vendor, product, version) IN ( SELECT vendor, product, split_part(version, '.', 1) FROM filtered_cpes ) diff --git a/modules/fundamental/src/vulnerability/service/mod.rs b/modules/fundamental/src/vulnerability/service/mod.rs index 0e4ae2718..007c5aa7d 100644 --- a/modules/fundamental/src/vulnerability/service/mod.rs +++ b/modules/fundamental/src/vulnerability/service/mod.rs @@ -592,7 +592,6 @@ GROUP BY JOIN cpe sc ON c.vendor = sc.vendor AND c.product = sc.product AND c.version = split_part(sc.version, '.', 1) - AND (c.edition IS NULL OR c.edition = '*') JOIN sbom_describing_cpe sdc ON sdc.cpe_id = sc.id JOIN sbom_node_purl_ref snpr ON snpr.sbom_id = sdc.sbom_id JOIN qualified_purl qp ON snpr.qualified_purl_id = qp.id @@ -697,7 +696,6 @@ GROUP BY JOIN cpe sc ON c.vendor = sc.vendor AND c.product = sc.product AND c.version = split_part(sc.version, '.', 1) - AND (c.edition IS NULL OR c.edition = '*') JOIN sbom_describing_cpe sdc ON sdc.cpe_id = sc.id JOIN sbom_node_purl_ref snpr ON snpr.sbom_id = sdc.sbom_id JOIN qualified_purl qp ON snpr.qualified_purl_id = qp.id