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, ) } } diff --git a/modules/fundamental/src/purl/model/details/purl.rs b/modules/fundamental/src/purl/model/details/purl.rs index 6ac0bf8ec..db165b26d 100644 --- a/modules/fundamental/src/purl/model/details/purl.rs +++ b/modules/fundamental/src/purl/model/details/purl.rs @@ -11,7 +11,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, Condition, Expr, Func, IntoIden, JoinType, SimpleExpr, UnionType, +}; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, hash_map::Entry}; use trustify_common::{ @@ -23,9 +25,9 @@ use trustify_common::{ use trustify_cvss::cvss3::{Cvss3Base, score::Score, severity::Severity}; use trustify_entity::{ advisory, base_purl, cpe, cvss3, license, organization, product, product_status, - product_version, product_version_range, purl_status, qualified_purl, sbom, sbom_package, - sbom_package_license, sbom_package_purl_ref, status, version_range, versioned_purl, - vulnerability, + product_version, product_version_range, purl_status, qualified_purl, sbom, sbom_describing_cpe, + sbom_package, sbom_package_license, sbom_package_purl_ref, status, version_range, + versioned_purl, vulnerability, }; use trustify_module_ingestor::common::{Deprecation, DeprecationForExt}; use utoipa::ToSchema; @@ -147,6 +149,83 @@ impl PurlDetails { } } +/// Build the two subqueries needed for CPE context filtering: +/// 1. `allowed_cpe_ids` — CPE IDs from the describing CPEs of SBOMs +/// containing the given PURL, plus generalized (major-version-only) +/// variants. +/// 2. `sbom_has_cpes` — EXISTS subquery that checks whether any SBOM +/// containing the PURL has describing CPEs at all. +/// +/// The returned pair is used in a three-way filter: +/// context_cpe_id IS NULL +/// OR context_cpe_id IN (allowed_cpe_ids) +/// OR NOT EXISTS (sbom_has_cpes) +fn cpe_context_subqueries( + qualified_purl_id: Uuid, +) -> (sea_query::SelectStatement, sea_query::SelectStatement) { + let sbom_ids = sbom_package_purl_ref::Entity::find() + .select_only() + .column(sbom_package_purl_ref::Column::SbomId) + .filter(sbom_package_purl_ref::Column::QualifiedPurlId.eq(qualified_purl_id)) + .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)), + )), + ), + ) + .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.clone()), + ) + .to_owned(); + + 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.clone())) + .into_query(); + 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) + .and_where(sbom_describing_cpe::Column::SbomId.in_subquery(sbom_ids)) + .to_owned(); + + (allowed_cpe_ids, sbom_has_cpes) +} + async fn get_product_statuses_for_purl( tx: &C, qualified_package_id: Uuid, @@ -162,6 +241,11 @@ async fn get_product_statuses_for_purl( .column(sbom::Column::SbomId) .into_query(); + // CPE context filtering — only return product statuses whose + // context CPE matches the describing CPEs of SBOMs containing this + // PURL. Prevents wrong-product false positives (TC-5630). + let (allowed_cpe_ids, sbom_has_cpes) = cpe_context_subqueries(qualified_package_id); + // Main query to get product statuses let product_statuses_query = product_status::Entity::find() .join(JoinType::Join, product_status::Relation::ContextCpe.def()) @@ -182,6 +266,17 @@ async fn get_product_statuses_for_purl( product_status::Relation::Vulnerability.def(), ) .filter(product_version::Column::SbomId.in_subquery(sbom_ids_query)) + // NOTE: no version_matches filter here. The version_range in + // product_status refers to the *product* version (e.g. Quarkus + // 2.x), not the *package* version (e.g. keycloak-core 18.0.6). + // Product applicability is validated through the product_version + // → SBOM join chain above. + .filter( + Condition::any() + .add(product_status::Column::ContextCpeId.is_null()) + .add(product_status::Column::ContextCpeId.in_subquery(allowed_cpe_ids)) + .add(Expr::exists(sbom_has_cpes).not()), + ) .filter(Expr::col(product_status::Column::Package).eq(purl_name).or( namespace_name.map_or(Expr::value(false), |ns| { Expr::col(product_status::Column::Package).eq(format!("{ns}/{purl_name}")) diff --git a/modules/importer/src/runner/clearly_defined/mod.rs b/modules/importer/src/runner/clearly_defined/mod.rs index f42393416..2b28e3f3e 100644 --- a/modules/importer/src/runner/clearly_defined/mod.rs +++ b/modules/importer/src/runner/clearly_defined/mod.rs @@ -56,10 +56,10 @@ impl super::ImportRunner { } Err(err) => Err(ScannerError::Normal { err: err.into(), - output: RunOutput { + output: Box::new(RunOutput { report: report.lock().await.clone().build(), continuation: None, - }, + }), }), } } diff --git a/modules/importer/src/runner/clearly_defined_curation/mod.rs b/modules/importer/src/runner/clearly_defined_curation/mod.rs index d8dac2c71..d80584b8d 100644 --- a/modules/importer/src/runner/clearly_defined_curation/mod.rs +++ b/modules/importer/src/runner/clearly_defined_curation/mod.rs @@ -130,10 +130,10 @@ impl super::ImportRunner { } .map_err(|err| ScannerError::Normal { err: err.into(), - output: RunOutput { + output: Box::new(RunOutput { report: report.lock().clone().build(), continuation: None, - }, + }), })?; // extract the report diff --git a/modules/importer/src/runner/csaf/mod.rs b/modules/importer/src/runner/csaf/mod.rs index 3adfb257b..08a3abbc8 100644 --- a/modules/importer/src/runner/csaf/mod.rs +++ b/modules/importer/src/runner/csaf/mod.rs @@ -109,10 +109,10 @@ impl super::ImportRunner { // further processing, like storing the marker .map_err(|err| ScannerError::Normal { err: err.into(), - output: RunOutput { + output: Box::new(RunOutput { report: report.lock().clone().build(), continuation: None, - }, + }), })?; Ok(match Arc::try_unwrap(report) { diff --git a/modules/importer/src/runner/cve/mod.rs b/modules/importer/src/runner/cve/mod.rs index f2c87d74b..a0e78a009 100644 --- a/modules/importer/src/runner/cve/mod.rs +++ b/modules/importer/src/runner/cve/mod.rs @@ -126,10 +126,10 @@ impl super::ImportRunner { } .map_err(|err| ScannerError::Normal { err: err.into(), - output: RunOutput { + output: Box::new(RunOutput { report: report.lock().clone().build(), continuation: None, - }, + }), })?; // extract the report diff --git a/modules/importer/src/runner/cwe/mod.rs b/modules/importer/src/runner/cwe/mod.rs index 2cecbf904..14750c8eb 100644 --- a/modules/importer/src/runner/cwe/mod.rs +++ b/modules/importer/src/runner/cwe/mod.rs @@ -53,10 +53,10 @@ impl super::ImportRunner { } Err(err) => Err(ScannerError::Normal { err: err.into(), - output: RunOutput { + output: Box::new(RunOutput { report: report.lock().await.clone().build(), continuation: None, - }, + }), }), } } diff --git a/modules/importer/src/runner/quay/mod.rs b/modules/importer/src/runner/quay/mod.rs index 3f73fadfa..d31b682b1 100644 --- a/modules/importer/src/runner/quay/mod.rs +++ b/modules/importer/src/runner/quay/mod.rs @@ -49,10 +49,10 @@ impl super::ImportRunner { } Err(err) => Err(ScannerError::Normal { err: err.into(), - output: RunOutput { + output: Box::new(RunOutput { report: report.lock().await.clone().build(), continuation: None, - }, + }), }), } } diff --git a/modules/importer/src/runner/report.rs b/modules/importer/src/runner/report.rs index 4654930a3..c3cac72ee 100644 --- a/modules/importer/src/runner/report.rs +++ b/modules/importer/src/runner/report.rs @@ -194,7 +194,7 @@ pub enum ScannerError { Normal { #[source] err: anyhow::Error, - output: RunOutput, + output: Box, }, } @@ -207,7 +207,7 @@ impl SplitScannerError for Result { fn split(self) -> anyhow::Result<(RunOutput, anyhow::Result<()>)> { match self { Ok(output) => Ok((output, Ok(()))), - Err(ScannerError::Normal { err, output }) => Ok((output, Err(err))), + Err(ScannerError::Normal { err, output }) => Ok((*output, Err(err))), Err(ScannerError::Critical(err)) => Err(err), } } diff --git a/modules/importer/src/runner/sbom/mod.rs b/modules/importer/src/runner/sbom/mod.rs index 858eec6ec..2ae9e426c 100644 --- a/modules/importer/src/runner/sbom/mod.rs +++ b/modules/importer/src/runner/sbom/mod.rs @@ -113,10 +113,10 @@ impl super::ImportRunner { // further processing, like storing the marker .map_err(|err| ScannerError::Normal { err: err.into(), - output: RunOutput { + output: Box::new(RunOutput { report: report.lock().clone().build(), continuation: None, - }, + }), })?; Ok(match Arc::try_unwrap(report) { diff --git a/modules/importer/src/server/mod.rs b/modules/importer/src/server/mod.rs index 6390698e7..8d9c28c9f 100644 --- a/modules/importer/src/server/mod.rs +++ b/modules/importer/src/server/mod.rs @@ -164,13 +164,13 @@ async fn import( report, continuation, }) => (None, Some(report), continuation), - Err(ScannerError::Normal { - err, - output: RunOutput { + Err(ScannerError::Normal { err, output }) => { + let RunOutput { report, continuation, - }, - }) => (Some(err.to_string()), Some(report), continuation), + } = *output; + (Some(err.to_string()), Some(report), continuation) + } Err(ScannerError::Critical(err)) => (Some(err.to_string()), None, None), }; diff --git a/test-context/Cargo.toml b/test-context/Cargo.toml index fe00dee55..987f19d27 100644 --- a/test-context/Cargo.toml +++ b/test-context/Cargo.toml @@ -23,7 +23,7 @@ git2 = { workspace = true } log = { workspace = true } peak_alloc = { workspace = true } postgresql_embedded = { workspace = true } -reqwest = { workspace = true } +reqwest = { workspace = true, features = ["stream"] } serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } diff --git a/xtask/src/openapi.rs b/xtask/src/openapi.rs index 4b31b48d9..4cf7c724a 100644 --- a/xtask/src/openapi.rs +++ b/xtask/src/openapi.rs @@ -57,7 +57,7 @@ pub async fn generate_openapi(base: Option<&Path>) -> anyhow::Result<()> { // write - println!("Writing openapi to {:?}", &path); + println!("Writing openapi to {path:?}"); fs::write(path, doc).context("Failed to write openapi spec")?;