From 99f3d675c72854e1b68787e2a217cd609c4159fc Mon Sep 17 00:00:00 2001 From: Dejan Bosanac Date: Thu, 24 Oct 2024 12:52:47 +0200 Subject: [PATCH] feat: csaf correlation - correlate vulnerability to purls and sboms --- Cargo.lock | 1 + entity/src/cpe.rs | 12 + entity/src/product.rs | 11 + entity/src/product_status.rs | 14 +- migration/src/lib.rs | 2 + .../src/m0000641_update_product_status.rs | 72 ++++++ modules/fundamental/Cargo.toml | 1 + .../model/details/vulnerability_advisory.rs | 219 +++++++++++++++++- .../src/service/advisory/csaf/creator.rs | 36 ++- .../service/advisory/csaf/product_status.rs | 34 +-- 10 files changed, 332 insertions(+), 70 deletions(-) create mode 100644 migration/src/m0000641_update_product_status.rs diff --git a/Cargo.lock b/Cargo.lock index ce8b2811b..ae17a052a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8520,6 +8520,7 @@ dependencies = [ "itertools 0.13.0", "jsonpath-rust", "langchain-rust", + "lenient_semver", "log", "osv", "packageurl", diff --git a/entity/src/cpe.rs b/entity/src/cpe.rs index 74161b89a..00202c95f 100644 --- a/entity/src/cpe.rs +++ b/entity/src/cpe.rs @@ -28,6 +28,12 @@ pub enum Relation { to = "super::sbom_package_cpe_ref::Column::CpeId" )] SbomPackage, + #[sea_orm( + belongs_to = "super::product::Entity", + from = "Column::Product", + to = "super::product::Column::CpeKey" + )] + Product, } impl Related for Entity { @@ -36,6 +42,12 @@ impl Related for Entity { } } +impl Related for Entity { + fn to() -> RelationDef { + Relation::Product.def() + } +} + impl ActiveModelBehavior for ActiveModel {} impl Display for Model { diff --git a/entity/src/product.rs b/entity/src/product.rs index 2d6b77cec..2a04894e8 100644 --- a/entity/src/product.rs +++ b/entity/src/product.rs @@ -21,6 +21,11 @@ pub enum Relation { Vendor, #[sea_orm(has_many = "super::product_version::Entity")] ProductVersion, + #[sea_orm( + has_many = "super::cpe::Entity" + from = "Column::CpeKey" + to = "super::cpe::Column::Product")] + Cpe, } impl Related for Entity { @@ -35,4 +40,10 @@ impl Related for Entity { } } +impl Related for Entity { + fn to() -> RelationDef { + Relation::Cpe.def() + } +} + impl ActiveModelBehavior for ActiveModel {} diff --git a/entity/src/product_status.rs b/entity/src/product_status.rs index 324edc5c3..4139b4d2b 100644 --- a/entity/src/product_status.rs +++ b/entity/src/product_status.rs @@ -8,7 +8,7 @@ pub struct Model { pub advisory_id: Uuid, pub vulnerability_id: String, pub status_id: Uuid, - pub base_purl_id: Option, + pub component: Option, pub product_version_range_id: Uuid, pub context_cpe_id: Option, } @@ -21,12 +21,6 @@ pub enum Relation { )] ProductVersionRange, - #[sea_orm(belongs_to = "super::base_purl::Entity", - from = "Column::BasePurlId" - to = "super::base_purl::Column::Id" - )] - BasePurl, - #[sea_orm(belongs_to = "super::vulnerability::Entity", from = "Column::VulnerabilityId" to = "super::vulnerability::Column::Id" @@ -64,12 +58,6 @@ impl Related for Entity { } } -impl Related for Entity { - fn to() -> RelationDef { - Relation::BasePurl.def() - } -} - impl Related for Entity { fn to() -> RelationDef { Relation::Vulnerability.def() diff --git a/migration/src/lib.rs b/migration/src/lib.rs index c925deb42..e9889ab48 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -81,6 +81,7 @@ mod m0000625_alter_qualified_purl_purl_column; mod m0000630_create_product_version_range; mod m0000631_alter_product_cpe_key; mod m0000640_create_product_status; +mod m0000641_update_product_status; mod m0000650_alter_advisory_tracking; mod m0000660_purl_id_indexes; mod m0000670_version_cmp; @@ -174,6 +175,7 @@ impl MigratorTrait for Migrator { Box::new(m0000630_create_product_version_range::Migration), Box::new(m0000631_alter_product_cpe_key::Migration), Box::new(m0000640_create_product_status::Migration), + Box::new(m0000641_update_product_status::Migration), Box::new(m0000650_alter_advisory_tracking::Migration), Box::new(m0000660_purl_id_indexes::Migration), Box::new(m0000670_version_cmp::Migration), diff --git a/migration/src/m0000641_update_product_status.rs b/migration/src/m0000641_update_product_status.rs new file mode 100644 index 000000000..3722c2fd5 --- /dev/null +++ b/migration/src/m0000641_update_product_status.rs @@ -0,0 +1,72 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +#[allow(deprecated)] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(ProductStatus::Table) + .drop_column(ProductStatus::BasePurlId) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(ProductStatus::Table) + .add_column(ColumnDef::new(ProductStatus::Component).string()) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(ProductStatus::Table) + .drop_column(ProductStatus::Component) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(ProductStatus::Table) + .add_column(ColumnDef::new(ProductStatus::BasePurlId).uuid()) + .add_foreign_key( + TableForeignKey::new() + .from_tbl(ProductStatus::Table) + .from_col(ProductStatus::BasePurlId) + .to_tbl(BasePurl::Table) + .to_col(BasePurl::Id), + ) + .to_owned(), + ) + .await?; + + Ok(()) + } +} + +#[derive(DeriveIden)] +enum ProductStatus { + Table, + BasePurlId, + Component, +} + +#[derive(DeriveIden)] +enum BasePurl { + Table, + Id, +} diff --git a/modules/fundamental/Cargo.toml b/modules/fundamental/Cargo.toml index 37917ec20..461158e07 100644 --- a/modules/fundamental/Cargo.toml +++ b/modules/fundamental/Cargo.toml @@ -22,6 +22,7 @@ base64 = { workspace = true } cpe = { workspace = true } futures-util = { workspace = true } itertools = { workspace = true } +lenient_semver = { workspace = true } langchain-rust = { workspace = true } log = { workspace = true } sea-orm = { workspace = true } diff --git a/modules/fundamental/src/vulnerability/model/details/vulnerability_advisory.rs b/modules/fundamental/src/vulnerability/model/details/vulnerability_advisory.rs index e123b4556..dd1e41d85 100644 --- a/modules/fundamental/src/vulnerability/model/details/vulnerability_advisory.rs +++ b/modules/fundamental/src/vulnerability/model/details/vulnerability_advisory.rs @@ -2,12 +2,13 @@ use crate::purl::model::details::purl::StatusContext; use crate::purl::model::PurlHead; use crate::sbom::model::SbomHead; use crate::{advisory::model::AdvisoryHead, purl::model::BasePurlHead, Error}; +use cpe::cpe::Cpe; use cpe::uri::OwnedUri; use sea_orm::{ ColumnTrait, DbErr, EntityTrait, FromQueryResult, IntoIdentity, LoaderTrait, ModelTrait, - QueryFilter, QueryResult, QuerySelect, RelationTrait, Select, + QueryFilter, QueryOrder, QueryResult, QuerySelect, RelationTrait, Select, }; -use sea_query::{Asterisk, Expr, Func, IntoCondition, JoinType, SimpleExpr}; +use sea_query::{Asterisk, Expr, Func, IntoCondition, JoinType, NullOrdering, SimpleExpr}; use serde::{Deserialize, Serialize}; use std::collections::hash_map::Entry; use std::collections::{HashMap, HashSet}; @@ -17,7 +18,7 @@ use trustify_common::memo::Memo; use trustify_common::{cpe::CpeCompare, db::ConnectionOrTransaction, purl::Purl}; use trustify_cvss::cvss3::severity::Severity; use trustify_cvss::{cvss3::score::Score, cvss3::Cvss3Base}; -use trustify_entity as entity; +use trustify_entity::{self as entity}; use utoipa::ToSchema; use uuid::Uuid; @@ -213,6 +214,50 @@ impl VulnerabilityAdvisorySummary { .group_by(entity::sbom_package::Column::NodeId) .group_by(entity::cpe::Column::Id); + let product_status_query = entity::product_status::Entity::find() + .join( + JoinType::LeftJoin, + entity::product_status::Relation::ContextCpe.def(), + ) + .join( + JoinType::Join, + entity::product_status::Relation::Status.def(), + ) + .join( + JoinType::LeftJoin, + entity::product_status::Relation::ProductVersionRange.def(), + ) + .join( + JoinType::LeftJoin, + entity::product_version_range::Relation::VersionRange.def(), + ) + .join(JoinType::LeftJoin, entity::cpe::Relation::Product.def()) + .join( + JoinType::LeftJoin, + entity::product::Relation::ProductVersion.def(), + ) + .join( + JoinType::LeftJoin, + entity::product_version::Relation::Sbom.def(), + ) + .join(JoinType::LeftJoin, entity::sbom::Relation::Node.def()) + .filter(entity::product_status::Column::VulnerabilityId.eq(&vulnerability.id)) + .filter(SimpleExpr::FunctionCall( + Func::cust(VersionMatches) + .arg(Expr::col(( + entity::product_version::Entity, + entity::product_version::Column::Version, + ))) + .arg(Expr::col((entity::version_range::Entity, Asterisk))), + )) + .distinct_on([(entity::cpe::Entity, entity::cpe::Column::Id)]) + .order_by_desc(entity::cpe::Column::Id) + .order_by_with_nulls( + entity::product_version::Column::SbomId, + sea_orm::Order::Desc, + NullOrdering::Last, + ); + let vuln_purl_statuses = purl_status_query .try_into_multi_model::()? .all(tx) @@ -223,6 +268,11 @@ impl VulnerabilityAdvisorySummary { .all(tx) .await?; + let vuln_product_statuses = product_status_query + .try_into_multi_model::()? + .all(tx) + .await?; + let mut summaries = Vec::new(); for advisory_vulnerability in advisory_vulnerabilities { @@ -248,8 +298,18 @@ impl VulnerabilityAdvisorySummary { ) .await?, cvss3_scores, - purls: VulnerabilityAdvisoryStatus::from_models(purl_statuses, tx).await?, - sboms: VulnerabilitySbomStatus::from_models(sbom_statuses, tx).await?, + purls: VulnerabilityAdvisoryStatus::from_models( + purl_statuses, + vuln_product_statuses.iter(), + tx, + ) + .await?, + sboms: VulnerabilitySbomStatus::from_models( + sbom_statuses, + vuln_product_statuses.iter(), + tx, + ) + .await?, }); } @@ -375,13 +435,19 @@ pub struct VulnerabilityAdvisoryStatus { } impl VulnerabilityAdvisoryStatus { - async fn from_models<'i, I: Iterator>( - models: I, + async fn from_models< + 'i, + 'j, + I: Iterator, + J: Iterator, + >( + purls: I, + products: J, _tx: &ConnectionOrTransaction<'_>, ) -> Result>, Error> { let mut statuses = HashMap::new(); - for each in models { + for each in purls { let context = each.cpe.as_ref().and_then(|cpe| { let cpe: Result = cpe.try_into(); cpe.ok().map(|cpe| StatusContext::Cpe(cpe.to_string())) @@ -398,10 +464,56 @@ impl VulnerabilityAdvisoryStatus { }); } + for product in products { + if let Some(component) = &product.product_status.component { + let status_entry = statuses + .entry(product.status.slug.clone()) + .or_insert(vec![]); + // TODO: If we have an SBOM in the result, we should try to locate the real purl + // in that SBOM by the component name + let purl = generic_purl(component); + // This is creating a fake Purl that does not exists in the database + // Maybe we should model this differently, e.g. introduce component instead of purl + // in case there's no SBOM ot purl can't be found + let base_purl = BasePurlHead { + uuid: Default::default(), + purl, + }; + let context = StatusContext::Cpe(product.cpe.to_string()); + status_entry.push(VulnerabilityAdvisoryStatus { + base_purl, + version: "*".to_string(), + context: Some(context), + }); + } + } + Ok(statuses) } } +/// Parse purl from generic identifiers +fn generic_purl(name: &str) -> Purl { + // try to extract at least name and optionally namespace + // usually separate by / + // e.g. io.quarkus/quarkus-vertx-http + let parts = name.split('/').collect::>(); + + let (namespace, name) = if parts.len() >= 2 { + (Some(parts[0]), parts[1]) + } else { + (None, parts[0]) + }; + + Purl { + ty: "generic".to_string(), + namespace: namespace.map(|s| s.to_string()), + name: name.to_string(), + version: None, + qualifiers: Default::default(), + } +} + #[derive(Debug)] struct SbomStatusCatcher { //purl_status: entity::purl_status::Model, @@ -447,6 +559,53 @@ impl FromQueryResultMultiModel for SbomStatusCatcher { } } +#[derive(Debug)] +struct ProductStatusCatcher { + product_status: entity::product_status::Model, + cpe: entity::cpe::Model, + status: entity::status::Model, + product_version: Option, + sbom: Option, + sbom_node: Option, +} + +impl FromQueryResult for ProductStatusCatcher { + fn from_query_result(res: &QueryResult, _pre: &str) -> Result { + Ok(Self { + product_status: Self::from_query_result_multi_model( + res, + "", + entity::product_status::Entity, + )?, + cpe: Self::from_query_result_multi_model(res, "", entity::cpe::Entity)?, + status: Self::from_query_result_multi_model(res, "", entity::status::Entity)?, + product_version: Self::from_query_result_multi_model_optional( + res, + "", + entity::product_version::Entity, + )?, + sbom: Self::from_query_result_multi_model_optional(res, "", entity::sbom::Entity)?, + sbom_node: Self::from_query_result_multi_model_optional( + res, + "", + entity::sbom_node::Entity, + )?, + }) + } +} + +impl FromQueryResultMultiModel for ProductStatusCatcher { + fn try_into_multi_model(select: Select) -> Result, DbErr> { + select + .try_model_columns(entity::product_status::Entity)? + .try_model_columns(entity::cpe::Entity)? + .try_model_columns(entity::status::Entity)? + .try_model_columns(entity::product_version::Entity)? + .try_model_columns(entity::sbom::Entity)? + .try_model_columns(entity::sbom_node::Entity) + } +} + #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] pub struct VulnerabilitySbomStatus { #[serde(flatten)] @@ -460,12 +619,14 @@ pub struct VulnerabilitySbomStatus { } impl VulnerabilitySbomStatus { - async fn from_models<'i, I>( + async fn from_models<'i, 'j, I, J>( sbom_purl_status: I, + products: J, tx: &ConnectionOrTransaction<'_>, ) -> Result, Error> where I: Iterator + Clone, + J: Iterator + Clone, { let mut sboms = HashMap::new(); @@ -486,6 +647,46 @@ impl VulnerabilitySbomStatus { } } + for product in products.clone() { + if let Some(sbom) = &product.sbom { + let advisory_cpe = (&product.cpe).try_into().ok(); + let entry = VulnerabilitySbomStatus { + head: SbomHead::from_entity(sbom, product.sbom_node.clone(), tx).await?, + version: product + .product_version + .as_ref() + .map(|pv| pv.version.clone()), + cpe: advisory_cpe, + status: vec![product.status.slug.clone()].into_iter().collect(), + }; + sboms + .entry(&sbom.sbom_id) + .and_modify(|value| { + match &value.cpe { + Some(cpe) => { + // This is where the matching logic for multiple advisory entries lives + // At the moment we try to use the biggest range we have. + // For example: if we have entries for 2.0, 2.7 and 2.13 we will choose the last one + if let Some(version) = &product.cpe.version { + let version_semver = lenient_semver::parse(version); + let current_version = &cpe.version().clone().to_string(); + let current_semver = lenient_semver::parse(current_version); + + if match (&version_semver, ¤t_semver) { + (Ok(v1), Ok(v2)) => v1 > v2, + _ => false, + } { + *value = entry.clone() + } + } + } + None => *value = entry.clone(), + } + }) + .or_insert(entry); + } + } + 'status: for advisory_status in sbom_purl_status { if let Some(sbom_status) = sboms.get_mut(&advisory_status.sbom.sbom_id) { match (&advisory_status.context_cpe, &sbom_status.cpe) { diff --git a/modules/ingestor/src/service/advisory/csaf/creator.rs b/modules/ingestor/src/service/advisory/csaf/creator.rs index 4b6eafb0b..19742b36f 100644 --- a/modules/ingestor/src/service/advisory/csaf/creator.rs +++ b/modules/ingestor/src/service/advisory/csaf/creator.rs @@ -146,22 +146,33 @@ impl<'a> StatusCreator<'a> { None => None, }; - // If there are no components associated to this product - // Ingest product status - if product.packages.is_empty() { - if let Some(range) = &product_version_range { + if let Some(range) = &product_version_range { + let components = if product.components.is_empty() { + // If there are no components associated to this product, ingest just a product status + vec![None] + } else { + product + .components + .iter() + .map(|c| Some(c.to_string())) + .collect() + }; + + for component in components { let base_product = product_status::ActiveModel { id: Default::default(), product_version_range_id: Set(range.id), advisory_id: Set(self.advisory_id), vulnerability_id: Set(self.vulnerability_id.clone()), - base_purl_id: Set(None), + component: Set(component), context_cpe_id: Set(product.cpe.as_ref().map(Cpe::uuid)), status_id: Set(status_id.id), }; + if let Some(cpe) = &product.cpe { cpes.add(cpe.clone()); } + product_statuses.push(base_product); } } @@ -189,21 +200,6 @@ impl<'a> StatusCreator<'a> { }; self.entries.insert(purl_status); - - // Ingest matching product status - if let Some(ver) = &product_version_range { - let product_status = product_status::ActiveModel { - id: Default::default(), - product_version_range_id: Set(ver.id), - advisory_id: Set(self.advisory_id), - vulnerability_id: Set(self.vulnerability_id.clone()), - base_purl_id: Set(Some(purl.package_uuid())), - context_cpe_id: Set(product.cpe.as_ref().map(Cpe::uuid)), - status_id: Set(status_id.id), - }; - - product_statuses.push(product_status); - } } } diff --git a/modules/ingestor/src/service/advisory/csaf/product_status.rs b/modules/ingestor/src/service/advisory/csaf/product_status.rs index 5da548064..2c240eed4 100644 --- a/modules/ingestor/src/service/advisory/csaf/product_status.rs +++ b/modules/ingestor/src/service/advisory/csaf/product_status.rs @@ -13,6 +13,7 @@ pub struct ProductStatus { pub cpe: Option, pub status: &'static str, pub packages: Vec, + pub components: Vec, } impl ProductStatus { @@ -30,17 +31,16 @@ impl ProductStatus { } // Get component/package info BranchCategory::ProductVersion => { - let purl = match branch.product.clone() { + match branch.product.clone() { Some(full_name) => match full_name.product_identification_helper { Some(id_helper) => match id_helper.purl { - Some(purl) => Purl::from(purl), - None => ProductStatus::generic_purl(&branch.name), + Some(purl) => self.packages.push(purl.into()), + None => self.components.push(branch.name.clone()), }, - None => ProductStatus::generic_purl(&full_name.product_id.0), + None => self.components.push(full_name.product_id.0), }, - None => ProductStatus::generic_purl(&branch.name), + None => self.components.push(branch.name.clone()), }; - self.packages.push(purl); } // For everything else, for now see if we can get any purls _ => { @@ -52,28 +52,6 @@ impl ProductStatus { } } - /// Parse purl from generic identifiers - fn generic_purl(name: &str) -> Purl { - // try to extract at least name and optionally namespace - // usually separate by / - // e.g. io.quarkus/quarkus-vertx-http - let parts = name.split('/').collect::>(); - - let (namespace, name) = if parts.len() >= 2 { - (Some(parts[0]), parts[1]) - } else { - (None, parts[0]) - }; - - Purl { - ty: "generic".to_string(), - namespace: namespace.map(|s| s.to_string()), - name: name.to_string(), - version: None, - qualifiers: Default::default(), - } - } - /// Parse cpe or purl from product identifier helper pub fn set_version(&mut self, full_name: Option) { self.version = full_name.clone().and_then(|full_name| {