diff --git a/modules/fundamental/src/sbom/endpoints/mod.rs b/modules/fundamental/src/sbom/endpoints/mod.rs index 2afdf01ac..dfb6572d4 100644 --- a/modules/fundamental/src/sbom/endpoints/mod.rs +++ b/modules/fundamental/src/sbom/endpoints/mod.rs @@ -16,7 +16,7 @@ use crate::{ sbom::{ model::{ SbomExternalPackageReference, SbomNodeReference, SbomPackage, SbomPackageRelation, - SbomSummary, Which, details::SbomAdvisory, + SbomStatus, SbomSummary, Which, details::SbomAdvisory, }, service::SbomService, }, @@ -69,11 +69,38 @@ pub fn configure( .service(download) .service(label::set) .service(label::update) - .service(get_license_export); + .service(get_license_export) + .service(get_sbom_status); } const CONTENT_TYPE_GZIP: &str = "application/gzip"; +#[utoipa::path( + tag = "sbom", + operation_id = "getSbomStatus", + params( + ("id" = String, Path,), + ), + responses( + (status = 200, description = "sbom status", body = PaginatedResults), + (status = 404, description = "The sbom status could not be found"), + ), +)] +#[get("/v2/sbom/{id}/sbom-status")] +pub async fn get_sbom_status( + fetcher: web::Data, + db: web::Data, + id: web::Path, +) -> actix_web::Result { + let id = Id::from_str(&id).map_err(Error::IdKey)?; + println!("get_sbom_statusget_sbom_statusget_sbom_statusget_sbom_status"); + let result = fetcher + .fetch_sbom_status(id.try_as_uid().unwrap_or_default(), db.as_ref()) + .await?; + + Ok(HttpResponse::Ok().json(result)) +} + #[utoipa::path( tag = "sbom", operation_id = "getLicenseExport", diff --git a/modules/fundamental/src/sbom/endpoints/test.rs b/modules/fundamental/src/sbom/endpoints/test.rs index c24418bc8..12dae4aaa 100644 --- a/modules/fundamental/src/sbom/endpoints/test.rs +++ b/modules/fundamental/src/sbom/endpoints/test.rs @@ -16,6 +16,25 @@ use trustify_test_context::{TrustifyContext, call::CallService, document_bytes}; use urlencoding::encode; use uuid::Uuid; +#[test_context(TrustifyContext)] +#[test(actix_web::test)] +async fn sbom_status(ctx: &TrustifyContext) -> Result<(), anyhow::Error> { + let app = caller(ctx).await?; + let id = ctx + .ingest_document("spdx/OCP-TOOLS-4.11-RHEL-8.json") + .await? + .id + .to_string(); + + let uri = format!("/api/v2/sbom/{id}/sbom-status"); + let req = TestRequest::get().uri(&uri).to_request(); + let sbom: Value = app.call_and_read_body_json(req).await; + assert_eq!(3274, sbom["total_packages"]); + assert_eq!(10, sbom["total_licenses"]); + + Ok(()) +} + #[test_context(TrustifyContext)] #[test(actix_web::test)] async fn license_export(ctx: &TrustifyContext) -> Result<(), anyhow::Error> { diff --git a/modules/fundamental/src/sbom/model/mod.rs b/modules/fundamental/src/sbom/model/mod.rs index b990da4b0..da575681e 100644 --- a/modules/fundamental/src/sbom/model/mod.rs +++ b/modules/fundamental/src/sbom/model/mod.rs @@ -44,6 +44,7 @@ impl SbomHead { db: &C, ) -> Result { let number_of_packages = sbom.find_related(sbom_package::Entity).count(db).await?; + Ok(Self { id: sbom.sbom_id, document_id: sbom.document_id.clone(), @@ -114,6 +115,12 @@ pub struct SbomPackage { pub cpe: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, ToSchema, SimpleObject, Default)] +pub struct SbomStatus { + pub total_packages: usize, + pub total_licenses: usize, +} + #[derive(Copy, Clone, Eq, PartialEq, Debug)] pub enum SbomPackageReference<'a> { Internal(&'a str), diff --git a/modules/fundamental/src/sbom/service/sbom.rs b/modules/fundamental/src/sbom/service/sbom.rs index b943bb1bc..2996c1df0 100644 --- a/modules/fundamental/src/sbom/service/sbom.rs +++ b/modules/fundamental/src/sbom/service/sbom.rs @@ -3,18 +3,21 @@ use crate::{ Error, sbom::model::{ SbomExternalPackageReference, SbomNodeReference, SbomPackage, SbomPackageRelation, - SbomSummary, Which, details::SbomDetails, + SbomStatus, SbomSummary, Which, details::SbomDetails, }, }; use futures_util::{StreamExt, TryStreamExt, stream}; use sea_orm::{ - ColumnTrait, ConnectionTrait, DbErr, EntityTrait, FromQueryResult, IntoSimpleExpr, QueryFilter, - QueryOrder, QueryResult, QuerySelect, RelationTrait, Select, SelectColumns, StreamTrait, - prelude::Uuid, + ColumnTrait, ConnectionTrait, DbErr, EntityTrait, FromQueryResult, IntoSimpleExpr, + PaginatorTrait, QueryFilter, QueryOrder, QueryResult, QuerySelect, RelationTrait, Select, + SelectColumns, StreamTrait, prelude::Uuid, }; -use sea_query::{Expr, JoinType, extension::postgres::PgExpr}; +use sea_query::{Condition, Expr, JoinType, extension::postgres::PgExpr}; use serde_json::Value; -use std::{collections::HashMap, fmt::Debug}; +use std::{ + collections::{HashMap, HashSet}, + fmt::Debug, +}; use tracing::instrument; use trustify_common::{ cpe::Cpe, @@ -27,19 +30,70 @@ use trustify_common::{ model::{Paginated, PaginatedResults}, purl::Purl, }; +use trustify_entity::sbom_package_license::LicenseCategory; use trustify_entity::{ advisory, advisory_vulnerability, base_purl, cpe::{self, CpeDto}, labels::Labels, - organization, package_relates_to_package, + license, organization, package_relates_to_package, qualified_purl::{self, CanonicalPurl}, relationship::Relationship, sbom::{self, SbomNodeLink}, - sbom_node, sbom_package, sbom_package_cpe_ref, sbom_package_purl_ref, source_document, status, - versioned_purl, vulnerability, + sbom_node, sbom_package, sbom_package_cpe_ref, sbom_package_license, sbom_package_purl_ref, + source_document, status, versioned_purl, vulnerability, }; impl SbomService { + #[instrument(skip(self, connection), err(level=tracing::Level::INFO))] + pub async fn fetch_sbom_status( + &self, + sbom_id: Uuid, + connection: &C, + ) -> Result { + let package_count = sbom_package::Entity::find() + .filter(sbom_package::Column::SbomId.eq(sbom_id)) + .count(connection) + .await?; + + let package_count_usize = package_count as usize; + + let result_package_licenses: Vec>> = + sbom_package_license::Entity::find() + .filter(sbom_package_license::Column::SbomId.eq(sbom_id)) + .join( + JoinType::InnerJoin, + sbom_package_license::Relation::License.def(), + ) + .filter( + Condition::all().add( + sbom_package_license::Column::LicenseType.eq(LicenseCategory::Declared), + ), + ) + .select_only() + .column_as(license::Column::SpdxLicenses, "license_ids") + .into_tuple::<(Option>,)>() + .all(connection) + .await? + .into_iter() + .map(|(ids,)| ids) + .collect(); + let unique_licenses: HashSet<_> = result_package_licenses + .into_iter() + .flatten() + .flatten() + .collect(); + let unique_licenses: HashSet<_> = unique_licenses + .iter() + .filter(|l| l.to_string() != *"NOASSERTION") + .collect(); + let result = SbomStatus { + total_packages: package_count_usize, + total_licenses: unique_licenses.len(), + }; + + Ok(result) + } + #[instrument(skip(self, connection), err(level=tracing::Level::INFO))] async fn fetch_sbom( &self, @@ -665,6 +719,22 @@ mod test { use trustify_entity::labels::Labels; use trustify_test_context::TrustifyContext; + #[test_context(TrustifyContext)] + #[test(tokio::test)] + async fn sboms_status(ctx: &TrustifyContext) -> Result<(), anyhow::Error> { + let result = ctx + .ingest_document("spdx/OCP-TOOLS-4.11-RHEL-8.json") + .await?; + + let fetch = SbomService::new(ctx.db.clone()); + let sbom_status = fetch + .fetch_sbom_status(result.id.try_as_uid().unwrap_or_default(), &ctx.db) + .await?; + assert_eq!(3274, sbom_status.total_packages); + assert_eq!(10, sbom_status.total_licenses); + Ok(()) + } + #[test_context(TrustifyContext)] #[test(tokio::test)] async fn all_sboms(ctx: &TrustifyContext) -> Result<(), anyhow::Error> { diff --git a/openapi.yaml b/openapi.yaml index e6508686a..a1a334afb 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1531,6 +1531,26 @@ paths: application/json: schema: $ref: '#/components/schemas/PaginatedResults_SbomPackageRelation' + /api/v2/sbom/{id}/sbom-status: + get: + tags: + - sbom + operationId: getSbomStatus + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: sbom status + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedResults_SbomStatus' + '404': + description: The sbom status could not be found /api/v2/sbom/{key}/download: get: tags: @@ -2776,6 +2796,30 @@ components: type: integer format: int64 minimum: 0 + PaginatedResults_SbomStatus: + type: object + required: + - items + - total + properties: + items: + type: array + items: + type: object + required: + - total_packages + - total_licenses + properties: + total_licenses: + type: integer + minimum: 0 + total_packages: + type: integer + minimum: 0 + total: + type: integer + format: int64 + minimum: 0 PaginatedResults_SbomSummary: type: object required: @@ -3294,30 +3338,17 @@ components: relationship: $ref: '#/components/schemas/Relationship' SbomStatus: - allOf: - - $ref: '#/components/schemas/VulnerabilityHead' - - type: object - required: - - average_severity - - average_score - - status - - packages - properties: - average_score: - type: number - format: double - average_severity: - $ref: '#/components/schemas/Severity' - context: - oneOf: - - type: 'null' - - $ref: '#/components/schemas/StatusContext' - packages: - type: array - items: - $ref: '#/components/schemas/SbomPackage' - status: - type: string + type: object + required: + - total_packages + - total_licenses + properties: + total_licenses: + type: integer + minimum: 0 + total_packages: + type: integer + minimum: 0 SbomSummary: allOf: - $ref: '#/components/schemas/SbomHead'