diff --git a/entity/src/exploit_intelligence_job.rs b/entity/src/exploit_intelligence_job.rs new file mode 100644 index 000000000..654d47927 --- /dev/null +++ b/entity/src/exploit_intelligence_job.rs @@ -0,0 +1,159 @@ +/// Entity model for tracking Exploit Intelligence analysis jobs. +/// +/// Each row represents a single analysis request submitted to the Exploit Intelligence +/// service, tracking its lifecycle from submission through completion. The result may +/// link to an ingested VEX advisory or report an error. +use sea_orm::entity::prelude::*; +use time::OffsetDateTime; + +/// Lifecycle status of an Exploit Intelligence analysis job. +#[derive(Copy, Clone, Debug, Eq, PartialEq, EnumIter, DeriveActiveEnum)] +#[sea_orm( + rs_type = "String", + db_type = "Enum", + enum_name = "exploit_intelligence_job_status" +)] +pub enum ExploitIntelligenceJobStatus { + /// Job has been created but not yet started. + #[sea_orm(string_value = "pending")] + Pending, + /// Analysis is in progress. + #[sea_orm(string_value = "running")] + Running, + /// Analysis completed successfully. + #[sea_orm(string_value = "completed")] + Completed, + /// Analysis failed with an error. + #[sea_orm(string_value = "failed")] + Failed, +} + +/// Analysis finding from the Exploit Intelligence service. +/// +/// Represents the outcome of a vulnerability analysis for a given SBOM/CVE pair. +#[derive(Copy, Clone, Debug, Eq, PartialEq, EnumIter, DeriveActiveEnum)] +#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "ei_finding")] +pub enum Finding { + /// The analysed component is vulnerable to the CVE. + #[sea_orm(string_value = "vulnerable")] + Vulnerable, + /// The analysed component is not vulnerable to the CVE. + #[sea_orm(string_value = "not_vulnerable")] + NotVulnerable, + /// The analysis was inconclusive. + #[sea_orm(string_value = "uncertain")] + Uncertain, +} + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "exploit_intelligence_job")] +pub struct Model { + /// The database internal ID. + #[sea_orm(primary_key)] + pub id: Uuid, + + /// Correlation ID from the Exploit Intelligence service (unique, nullable). + /// + /// Initially `None` when the job is created; populated once the EI service + /// returns a scan identifier. For multi-component SPDX jobs this stays `None` + /// because individual scan IDs live on the component records instead. + #[sea_orm(unique)] + pub scan_id: Option, + + /// FK to the SBOM being analysed. + pub sbom_id: Uuid, + + /// CVE identifier (e.g., "CVE-2024-9680"). + pub vulnerability_id: String, + + /// Current lifecycle status of the job. + pub status: ExploitIntelligenceJobStatus, + + /// Git repository URL for source code analysis. + pub source_url: Option, + + /// Link to the EI human-readable justification report. + /// + /// Set for single-component (CycloneDX) jobs. For multi-component SPDX jobs + /// this stays `None` — per-component report URLs live on the component records. + pub report_url: Option, + + /// FK to advisory — set when a VEX document is ingested from the result. + /// + /// Set for single-component (CycloneDX) jobs. For multi-component SPDX jobs + /// this stays `None` — each component carries its own advisory link. + pub advisory_id: Option, + + /// Analysis finding for the vulnerability (set on completion). + /// + /// For single-component jobs this is the direct EI result. For multi-component + /// SPDX jobs this is an aggregate: `Vulnerable` if any component is vulnerable, + /// `NotVulnerable` if all are not, `Uncertain` otherwise. + pub finding: Option, + + /// Error details for failed analyses. + pub error_message: Option, + + /// EI product ID for SPDX multi-component flow (`None` for single-component). + /// + /// When set, per-component results are tracked via the related + /// [`exploit_intelligence_job_component`](super::exploit_intelligence_job_component) records. + pub product_id: Option, + + /// Number of components in the product (`None` for single-component). + pub total_components: Option, + + /// Timestamp when the job was created. + pub created: OffsetDateTime, + + /// Timestamp when the job was last updated. + pub updated: OffsetDateTime, +} + +/// Foreign-key relationships for the exploit intelligence job entity. +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + /// Link to the SBOM that was analysed. + #[sea_orm( + belongs_to = "super::sbom::Entity", + from = "Column::SbomId", + to = "super::sbom::Column::SbomId" + )] + Sbom, + + /// Link to the advisory ingested from the analysis result. + #[sea_orm( + belongs_to = "super::advisory::Entity", + from = "Column::AdvisoryId", + to = "super::advisory::Column::Id" + )] + Advisory, + + /// Link to the per-component analysis results (multi-component SPDX flow). + #[sea_orm(has_many = "super::exploit_intelligence_job_component::Entity")] + Components, +} + +/// Navigate from an exploit intelligence job to its SBOM. +impl Related for Entity { + fn to() -> RelationDef { + Relation::Sbom.def() + } +} + +/// Navigate from an exploit intelligence job to its advisory. +impl Related for Entity { + fn to() -> RelationDef { + Relation::Advisory.def() + } +} + +/// Navigate from an exploit intelligence job to its components. +impl Related for Entity { + fn to() -> RelationDef { + Relation::Components.def() + } +} + +/// Default active-model behaviour (no custom hooks). +impl ActiveModelBehavior for ActiveModel {} diff --git a/entity/src/exploit_intelligence_job_component.rs b/entity/src/exploit_intelligence_job_component.rs new file mode 100644 index 000000000..1e489b1f2 --- /dev/null +++ b/entity/src/exploit_intelligence_job_component.rs @@ -0,0 +1,89 @@ +/// Entity model for tracking per-component analysis results within a multi-component +/// Exploit Intelligence product job. +/// +/// Each row represents one container image component within an SPDX product SBOM, +/// independently analysed by the Exploit Intelligence service. The parent +/// `exploit_intelligence_job` holds product-level metadata while this entity holds +/// the per-component scan results. +use super::exploit_intelligence_job::{ExploitIntelligenceJobStatus, Finding}; +use sea_orm::entity::prelude::*; +use time::OffsetDateTime; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "exploit_intelligence_job_component")] +pub struct Model { + /// The database internal ID. + #[sea_orm(primary_key)] + pub id: Uuid, + + /// FK to the parent exploit intelligence job. + pub job_id: Uuid, + + /// Identifier of the component (purl or container image reference). + pub component_ref: String, + + /// Human-readable component name. + pub component_name: String, + + /// EI scan ID for this component's report (unique, nullable). + #[sea_orm(unique)] + pub scan_id: Option, + + /// Current lifecycle status of this component's analysis. + pub status: ExploitIntelligenceJobStatus, + + /// Analysis finding for the component (set on completion). + pub finding: Option, + + /// Link to the EI human-readable justification report. + pub report_url: Option, + + /// FK to advisory — set when a VEX document is ingested from the result. + pub advisory_id: Option, + + /// Error details for failed analyses. + pub error_message: Option, + + /// Timestamp when the component record was created. + pub created: OffsetDateTime, + + /// Timestamp when the component record was last updated. + pub updated: OffsetDateTime, +} + +/// Foreign-key relationships for the exploit intelligence job component entity. +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + /// Link to the parent exploit intelligence job. + #[sea_orm( + belongs_to = "super::exploit_intelligence_job::Entity", + from = "Column::JobId", + to = "super::exploit_intelligence_job::Column::Id" + )] + Job, + + /// Link to the advisory ingested from the analysis result. + #[sea_orm( + belongs_to = "super::advisory::Entity", + from = "Column::AdvisoryId", + to = "super::advisory::Column::Id" + )] + Advisory, +} + +/// Navigate from a component to its parent job. +impl Related for Entity { + fn to() -> RelationDef { + Relation::Job.def() + } +} + +/// Navigate from a component to its advisory. +impl Related for Entity { + fn to() -> RelationDef { + Relation::Advisory.def() + } +} + +/// Default active-model behaviour (no custom hooks). +impl ActiveModelBehavior for ActiveModel {} diff --git a/entity/src/lib.rs b/entity/src/lib.rs index eb0cdad58..846ec9034 100644 --- a/entity/src/lib.rs +++ b/entity/src/lib.rs @@ -4,6 +4,8 @@ pub mod advisory_vulnerability_score; pub mod base_purl; pub mod cpe; pub mod expanded_license; +pub mod exploit_intelligence_job; +pub mod exploit_intelligence_job_component; pub mod importer; pub mod importer_report; pub mod labels; diff --git a/migration/src/lib.rs b/migration/src/lib.rs index 051d32875..7d59cd08b 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -61,6 +61,7 @@ mod m0002160_fix_ref_fk; mod m0002170_drop_cvss_tables; mod m0002180_advisory_fk_indexes; mod m0002190_vulnerability_base_score_advisory; +mod m0002200_create_exploit_intelligence_job; mod m0002200_source_document_ingested_index; mod m0002210_sbom_node_name_index; @@ -141,6 +142,7 @@ impl MigratorExt for Migrator { .normal(m0002130_sbom_ancestor::Migration) .normal(m0002200_source_document_ingested_index::Migration) .normal(m0002210_sbom_node_name_index::Migration) + .normal(m0002200_create_exploit_intelligence_job::Migration) } } diff --git a/migration/src/m0002200_create_exploit_intelligence_job.rs b/migration/src/m0002200_create_exploit_intelligence_job.rs new file mode 100644 index 000000000..19c053ad2 --- /dev/null +++ b/migration/src/m0002200_create_exploit_intelligence_job.rs @@ -0,0 +1,367 @@ +use crate::Now; +use sea_orm::sea_query::extension::postgres::Type; +use sea_orm_migration::prelude::*; +use trustify_common::db::create_enum_if_not_exists; + +use strum::VariantNames; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + create_enum_if_not_exists( + manager, + ExploitIntelligenceJobStatus::Table, + ExploitIntelligenceJobStatus::VARIANTS + .iter() + .skip(1) + .copied(), + ) + .await?; + + create_enum_if_not_exists( + manager, + EiFinding::Table, + EiFinding::VARIANTS.iter().skip(1).copied(), + ) + .await?; + + manager + .create_table( + Table::create() + .table(ExploitIntelligenceJob::Table) + .col( + ColumnDef::new(ExploitIntelligenceJob::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(ExploitIntelligenceJob::ScanId) + .string() + .unique_key(), + ) + .col( + ColumnDef::new(ExploitIntelligenceJob::SbomId) + .uuid() + .not_null(), + ) + .col( + ColumnDef::new(ExploitIntelligenceJob::VulnerabilityId) + .string() + .not_null(), + ) + .col( + ColumnDef::new(ExploitIntelligenceJob::Status) + .custom(ExploitIntelligenceJobStatus::Table) + .not_null() + .default("pending"), + ) + .col(ColumnDef::new(ExploitIntelligenceJob::SourceUrl).string()) + .col(ColumnDef::new(ExploitIntelligenceJob::ReportUrl).string()) + .col(ColumnDef::new(ExploitIntelligenceJob::AdvisoryId).uuid()) + .col(ColumnDef::new(ExploitIntelligenceJob::Finding).custom(EiFinding::Table)) + .col(ColumnDef::new(ExploitIntelligenceJob::ErrorMessage).string()) + .col(ColumnDef::new(ExploitIntelligenceJob::ProductId).string()) + .col(ColumnDef::new(ExploitIntelligenceJob::TotalComponents).integer()) + .col( + ColumnDef::new(ExploitIntelligenceJob::Created) + .timestamp_with_time_zone() + .not_null() + .default(Func::cust(Now)), + ) + .col( + ColumnDef::new(ExploitIntelligenceJob::Updated) + .timestamp_with_time_zone() + .not_null() + .default(Func::cust(Now)), + ) + .foreign_key( + ForeignKey::create() + .from_col(ExploitIntelligenceJob::SbomId) + .to(Sbom::Table, Sbom::SbomId) + .on_delete(ForeignKeyAction::Cascade), + ) + .foreign_key( + ForeignKey::create() + .from_col(ExploitIntelligenceJob::AdvisoryId) + .to(Advisory::Table, Advisory::Id) + .on_delete(ForeignKeyAction::SetNull), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .if_not_exists() + .table(ExploitIntelligenceJob::Table) + .name(Indexes::IdxExploitIntelligenceJobSbomId.to_string()) + .col(ExploitIntelligenceJob::SbomId) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .if_not_exists() + .table(ExploitIntelligenceJob::Table) + .name(Indexes::IdxExploitIntelligenceJobAdvisoryId.to_string()) + .col(ExploitIntelligenceJob::AdvisoryId) + .to_owned(), + ) + .await?; + + manager + .create_table( + Table::create() + .table(ExploitIntelligenceJobComponent::Table) + .col( + ColumnDef::new(ExploitIntelligenceJobComponent::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(ExploitIntelligenceJobComponent::JobId) + .uuid() + .not_null(), + ) + .col( + ColumnDef::new(ExploitIntelligenceJobComponent::ComponentRef) + .string() + .not_null(), + ) + .col( + ColumnDef::new(ExploitIntelligenceJobComponent::ComponentName) + .string() + .not_null(), + ) + .col( + ColumnDef::new(ExploitIntelligenceJobComponent::ScanId) + .string() + .unique_key(), + ) + .col( + ColumnDef::new(ExploitIntelligenceJobComponent::Status) + .custom(ExploitIntelligenceJobStatus::Table) + .not_null() + .default("pending"), + ) + .col( + ColumnDef::new(ExploitIntelligenceJobComponent::Finding) + .custom(EiFinding::Table), + ) + .col(ColumnDef::new(ExploitIntelligenceJobComponent::ReportUrl).string()) + .col(ColumnDef::new(ExploitIntelligenceJobComponent::AdvisoryId).uuid()) + .col(ColumnDef::new(ExploitIntelligenceJobComponent::ErrorMessage).string()) + .col( + ColumnDef::new(ExploitIntelligenceJobComponent::Created) + .timestamp_with_time_zone() + .not_null() + .default(Func::cust(Now)), + ) + .col( + ColumnDef::new(ExploitIntelligenceJobComponent::Updated) + .timestamp_with_time_zone() + .not_null() + .default(Func::cust(Now)), + ) + .foreign_key( + ForeignKey::create() + .from_col(ExploitIntelligenceJobComponent::JobId) + .to(ExploitIntelligenceJob::Table, ExploitIntelligenceJob::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .foreign_key( + ForeignKey::create() + .from_col(ExploitIntelligenceJobComponent::AdvisoryId) + .to(Advisory::Table, Advisory::Id) + .on_delete(ForeignKeyAction::SetNull), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .if_not_exists() + .table(ExploitIntelligenceJobComponent::Table) + .name(Indexes::IdxEiJobComponentJobId.to_string()) + .col(ExploitIntelligenceJobComponent::JobId) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .if_not_exists() + .table(ExploitIntelligenceJobComponent::Table) + .name(Indexes::IdxEiJobComponentAdvisoryId.to_string()) + .col(ExploitIntelligenceJobComponent::AdvisoryId) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_index( + Index::drop() + .if_exists() + .table(ExploitIntelligenceJobComponent::Table) + .name(Indexes::IdxEiJobComponentAdvisoryId.to_string()) + .to_owned(), + ) + .await?; + + manager + .drop_index( + Index::drop() + .if_exists() + .table(ExploitIntelligenceJobComponent::Table) + .name(Indexes::IdxEiJobComponentJobId.to_string()) + .to_owned(), + ) + .await?; + + manager + .drop_table( + Table::drop() + .table(ExploitIntelligenceJobComponent::Table) + .if_exists() + .to_owned(), + ) + .await?; + + manager + .drop_index( + Index::drop() + .if_exists() + .table(ExploitIntelligenceJob::Table) + .name(Indexes::IdxExploitIntelligenceJobAdvisoryId.to_string()) + .to_owned(), + ) + .await?; + + manager + .drop_index( + Index::drop() + .if_exists() + .table(ExploitIntelligenceJob::Table) + .name(Indexes::IdxExploitIntelligenceJobSbomId.to_string()) + .to_owned(), + ) + .await?; + + manager + .drop_table( + Table::drop() + .table(ExploitIntelligenceJob::Table) + .if_exists() + .to_owned(), + ) + .await?; + + manager + .drop_type(Type::drop().if_exists().name(EiFinding::Table).to_owned()) + .await?; + + manager + .drop_type( + Type::drop() + .if_exists() + .name(ExploitIntelligenceJobStatus::Table) + .to_owned(), + ) + .await?; + + Ok(()) + } +} + +#[allow(clippy::enum_variant_names)] +#[derive(DeriveIden)] +enum Indexes { + IdxExploitIntelligenceJobSbomId, + IdxExploitIntelligenceJobAdvisoryId, + IdxEiJobComponentJobId, + IdxEiJobComponentAdvisoryId, +} + +#[derive(DeriveIden)] +enum ExploitIntelligenceJob { + Table, + Id, + ScanId, + SbomId, + VulnerabilityId, + Status, + SourceUrl, + ReportUrl, + AdvisoryId, + Finding, + ErrorMessage, + ProductId, + TotalComponents, + Created, + Updated, +} + +#[derive(DeriveIden)] +enum ExploitIntelligenceJobComponent { + Table, + Id, + JobId, + ComponentRef, + ComponentName, + ScanId, + Status, + Finding, + ReportUrl, + AdvisoryId, + ErrorMessage, + Created, + Updated, +} + +#[derive(DeriveIden, strum::VariantNames, strum::Display, Clone)] +#[strum(serialize_all = "lowercase")] +#[allow(unused)] +enum ExploitIntelligenceJobStatus { + Table, + Pending, + Running, + Completed, + Failed, +} + +#[derive(DeriveIden, strum::VariantNames, strum::Display, Clone)] +#[strum(serialize_all = "lowercase")] +#[allow(unused)] +enum EiFinding { + Table, + Vulnerable, + NotVulnerable, + Uncertain, +} + +#[derive(DeriveIden)] +enum Sbom { + Table, + SbomId, +} + +#[derive(DeriveIden)] +enum Advisory { + Table, + Id, +}