Skip to content
Merged
80 changes: 74 additions & 6 deletions modules/fundamental/src/purl/model/details/purl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ 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::{
Alias, Asterisk, ColumnRef, Expr, Func, IntoIden, JoinType, SimpleExpr, UnionType,
};
use sea_query::{Asterisk, ColumnRef, Expr, Func, IntoIden, JoinType, SimpleExpr};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, hash_map::Entry};
use trustify_common::{
Expand All @@ -28,8 +30,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;
Expand Down Expand Up @@ -84,6 +86,66 @@ 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 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)),
)),
),
)
.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)
.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)
Expand All @@ -93,6 +155,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(),
Expand Down
11 changes: 4 additions & 7 deletions modules/fundamental/src/sbom/model/raw_sql.rs
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
)
Expand Down
4 changes: 2 additions & 2 deletions modules/fundamental/src/vulnerability/service/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -560,7 +560,7 @@ GROUP BY
};

let purl_status_sql = Self::build_vulnerabilities_query_string(
r#"'advisory_id', purl_status.advisory_id"#,
r#"'advisory_id', purl_status.advisory_id, 'context_cpe', purl_status.context_cpe_id"#,
r#" remediation_purl_status rps
JOIN remediation r ON r.id = rps.remediation_id
WHERE rps.purl_status_id = purl_status.id
Expand All @@ -586,7 +586,7 @@ GROUP BY
p.into(),
purl.name.clone().into(),
purl.ty.clone().into(),
version.into(),
version.clone().into(),
],
);

Expand Down
2 changes: 1 addition & 1 deletion modules/fundamental/tests/dataset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ async fn ingest(ctx: TrustifyContext) -> anyhow::Result<()> {
assert!(ubi_details.is_some());
let ubi_details = ubi_details.unwrap();
let ubi_advisories = ubi_details.advisories;
assert_eq!(ubi_advisories.len(), 1);
assert_eq!(ubi_advisories.len(), 3);
assert!(
ubi_advisories
.iter()
Expand Down
64 changes: 61 additions & 3 deletions modules/fundamental/tests/vuln/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ async fn issue_1840(ctx: &TrustifyContext) -> Result<(), anyhow::Error> {
.filter(|status| status.purl_status.status == "affected")
.collect();

assert_eq!(status_entries.len(), 2);
assert_eq!(status_entries.len(), 4);
Comment thread
rh-jfuller marked this conversation as resolved.
let json = serde_json::to_value(status_entries).expect("must serialize");
assert!(
json.contains_subset(json!([{
Expand All @@ -80,7 +80,6 @@ async fn issue_1840(ctx: &TrustifyContext) -> Result<(), anyhow::Error> {
},
"scores": [{"type": "3.1", "value": 5.3, "severity": "medium", "vector": "CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:N/A:N"}],
"status": "affected",
"context": null,
"version_range": {
"version_scheme_id": "rpm",
"high_version": "3.7.6-23.el9_3.4",
Expand All @@ -102,7 +101,6 @@ async fn issue_1840(ctx: &TrustifyContext) -> Result<(), anyhow::Error> {
},
"scores": [{"type": "3.1", "value": 5.3, "severity": "medium", "vector": "CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:N/A:N"}],
"status": "affected",
"context": null,
"version_range": {
"version_scheme_id": "rpm",
"high_version": "3.8.3-4.el9_4",
Expand All @@ -116,3 +114,63 @@ async fn issue_1840(ctx: &TrustifyContext) -> Result<(), anyhow::Error> {

Ok(())
}

/// Proves that `version_matches` filtering works on the purl_status path:
/// a version ABOVE the advisory's affected range must not be reported as affected.
#[test_context(TrustifyContext)]
#[test(tokio::test)]
async fn version_filtering(ctx: &TrustifyContext) -> Result<(), anyhow::Error> {
ctx.ingest_dataset(Dataset::DS3).await?;

let service = VulnerabilityService::new(PaginationCache::for_test());

// Version BELOW the fix threshold — known affected by CVE-2024-28834
// (3.7.6-23.el9 < 3.7.6-23.el9_3.4, the fix version)
let affected_result = service
.analyze_purls_v3(["pkg:rpm/redhat/gnutls@3.7.6-23.el9?arch=aarch64"], &ctx.db)
.await?;

let affected_entry = &affected_result["pkg:rpm/redhat/gnutls@3.7.6-23.el9?arch=aarch64"];
let affected_count = affected_entry
.details
.iter()
.flat_map(|d| &d.purl_statuses)
.filter(|s| {
s.purl_status.status == "affected"
&& s.purl_status.vulnerability.identifier == "CVE-2024-28834"
})
.count();
assert!(
affected_count > 0,
"affected version must have 'affected' statuses for CVE-2024-28834"
);

// Version ABOVE the fix threshold — not affected
// (3.8.3-5.el9 > 3.8.3-4.el9_4, the highest fix range, so version_matches returns false)
let fixed_result = service
.analyze_purls_v3(["pkg:rpm/redhat/gnutls@3.8.3-5.el9?arch=aarch64"], &ctx.db)
.await?;

let fixed_entry = fixed_result.get("pkg:rpm/redhat/gnutls@3.8.3-5.el9?arch=aarch64");
let fixed_affected_count = fixed_entry
.map(|entry| {
entry
.details
.iter()
.flat_map(|d| &d.purl_statuses)
.filter(|s| {
s.purl_status.status == "affected"
&& s.purl_status.vulnerability.identifier == "CVE-2024-28834"
})
.count()
})
.unwrap_or(0);

assert_eq!(
fixed_affected_count, 0,
"fixed version must not have 'affected' statuses for CVE-2024-28834 \
(version_matches should filter them out)"
);

Ok(())
}
Loading