From 36a8b22d2eac8b792913e84fe03d90e2b8eba30e Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Thu, 11 Jun 2026 15:30:53 +0100 Subject: [PATCH 1/5] feat(entity): add exploit intelligence entities and migrations --- entity/src/exploit_intelligence_job.rs | 135 +++++++++++ .../src/exploit_intelligence_job_component.rs | 89 +++++++ entity/src/lib.rs | 2 + migration/src/lib.rs | 4 + ...0002200_create_exploit_intelligence_job.rs | 166 +++++++++++++ ...210_add_exploit_intelligence_components.rs | 223 ++++++++++++++++++ 6 files changed, 619 insertions(+) create mode 100644 entity/src/exploit_intelligence_job.rs create mode 100644 entity/src/exploit_intelligence_job_component.rs create mode 100644 migration/src/m0002200_create_exploit_intelligence_job.rs create mode 100644 migration/src/m0002210_add_exploit_intelligence_components.rs diff --git a/entity/src/exploit_intelligence_job.rs b/entity/src/exploit_intelligence_job.rs new file mode 100644 index 000000000..6b4139373 --- /dev/null +++ b/entity/src/exploit_intelligence_job.rs @@ -0,0 +1,135 @@ +/// 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 = "i32", db_type = "Integer")] +pub enum ExploitIntelligenceJobStatus { + /// Job has been created but not yet started. + Pending = 0, + /// Analysis is in progress. + Running = 1, + /// Analysis completed successfully. + Completed = 2, + /// Analysis failed with an error. + Failed = 3, +} + +/// 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 = "i32", db_type = "Integer")] +pub enum Finding { + /// The analysed component is vulnerable to the CVE. + Vulnerable = 0, + /// The analysed component is not vulnerable to the CVE. + NotVulnerable = 1, + /// The analysis was inconclusive. + Uncertain = 2, +} + +#[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. The UNIQUE constraint still holds — PostgreSQL + /// allows multiple NULLs in a unique column. + #[sea_orm(unique)] + pub scan_id: Option, + + /// FK to the SBOM being analysed (nullable). + pub sbom_id: Option, + + /// 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. + pub report_url: Option, + + /// FK to advisory — set when a VEX document is ingested from the result. + pub advisory_id: Option, + + /// Analysis finding for the vulnerability (set on completion). + pub finding: Option, + + /// Error details for failed analyses. + pub error_message: Option, + + /// EI product ID for SPDX multi-component flow (null for CycloneDX single-component). + pub product_id: Option, + + /// Total number of components in the product (null 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..14b45f702 --- /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, + + /// The pkg:oci purl of the component. + pub component_purl: 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..409be9c96 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -63,6 +63,8 @@ mod m0002180_advisory_fk_indexes; mod m0002190_vulnerability_base_score_advisory; mod m0002200_source_document_ingested_index; mod m0002210_sbom_node_name_index; +mod m0002200_create_exploit_intelligence_job; +mod m0002210_add_exploit_intelligence_components; pub trait MigratorExt: Send { fn build_migrations() -> Migrations; @@ -141,6 +143,8 @@ 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) + .normal(m0002210_add_exploit_intelligence_components::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..d0ad0c744 --- /dev/null +++ b/migration/src/m0002200_create_exploit_intelligence_job.rs @@ -0,0 +1,166 @@ +use crate::{Now, UuidV4}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Create the exploit_intelligence_job table + manager + .create_table( + Table::create() + .table(ExploitIntelligenceJob::Table) + .col( + ColumnDef::new(ExploitIntelligenceJob::Id) + .uuid() + .not_null() + .primary_key() + .default(Func::cust(UuidV4)), + ) + .col( + ColumnDef::new(ExploitIntelligenceJob::ScanId) + .string() + .unique_key(), + ) + .col(ColumnDef::new(ExploitIntelligenceJob::SbomId).uuid()) + .col( + ColumnDef::new(ExploitIntelligenceJob::VulnerabilityId) + .string() + .not_null(), + ) + .col( + ColumnDef::new(ExploitIntelligenceJob::Status) + .integer() + .not_null() + .default(0), + ) + .col(ColumnDef::new(ExploitIntelligenceJob::SourceUrl).string()) + .col(ColumnDef::new(ExploitIntelligenceJob::ReportUrl).string()) + .col(ColumnDef::new(ExploitIntelligenceJob::AdvisoryId).uuid()) + .col(ColumnDef::new(ExploitIntelligenceJob::Finding).integer()) + .col(ColumnDef::new(ExploitIntelligenceJob::ErrorMessage).string()) + .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::SetNull), + ) + .foreign_key( + ForeignKey::create() + .from_col(ExploitIntelligenceJob::AdvisoryId) + .to(Advisory::Table, Advisory::Id) + .on_delete(ForeignKeyAction::SetNull), + ) + .to_owned(), + ) + .await?; + + // Create FK indexes for sbom_id and advisory_id per CONVENTIONS.md §Migration Patterns + 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?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + 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?; + + Ok(()) + } +} + +#[allow(clippy::enum_variant_names)] +#[derive(DeriveIden)] +enum Indexes { + IdxExploitIntelligenceJobSbomId, + IdxExploitIntelligenceJobAdvisoryId, +} + +#[derive(DeriveIden)] +enum ExploitIntelligenceJob { + Table, + Id, + ScanId, + SbomId, + VulnerabilityId, + Status, + SourceUrl, + ReportUrl, + AdvisoryId, + Finding, + ErrorMessage, + Created, + Updated, +} + +#[derive(DeriveIden)] +enum Sbom { + Table, + SbomId, +} + +#[derive(DeriveIden)] +enum Advisory { + Table, + Id, +} diff --git a/migration/src/m0002210_add_exploit_intelligence_components.rs b/migration/src/m0002210_add_exploit_intelligence_components.rs new file mode 100644 index 000000000..894fc0b55 --- /dev/null +++ b/migration/src/m0002210_add_exploit_intelligence_components.rs @@ -0,0 +1,223 @@ +use crate::{Now, UuidV4}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Add product_id and total_components columns to exploit_intelligence_job + manager + .alter_table( + Table::alter() + .table(ExploitIntelligenceJob::Table) + .add_column(ColumnDef::new(ExploitIntelligenceJob::ProductId).string()) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(ExploitIntelligenceJob::Table) + .add_column( + ColumnDef::new(ExploitIntelligenceJob::TotalComponents).integer(), + ) + .to_owned(), + ) + .await?; + + // Create the exploit_intelligence_job_component table + manager + .create_table( + Table::create() + .table(ExploitIntelligenceJobComponent::Table) + .col( + ColumnDef::new(ExploitIntelligenceJobComponent::Id) + .uuid() + .not_null() + .primary_key() + .default(Func::cust(UuidV4)), + ) + .col( + ColumnDef::new(ExploitIntelligenceJobComponent::JobId) + .uuid() + .not_null(), + ) + .col( + ColumnDef::new(ExploitIntelligenceJobComponent::ComponentPurl) + .string() + .not_null(), + ) + .col( + ColumnDef::new(ExploitIntelligenceJobComponent::ComponentName) + .string() + .not_null(), + ) + .col( + ColumnDef::new(ExploitIntelligenceJobComponent::ScanId) + .string() + .unique_key(), + ) + .col( + ColumnDef::new(ExploitIntelligenceJobComponent::Status) + .integer() + .not_null() + .default(0), + ) + .col(ColumnDef::new(ExploitIntelligenceJobComponent::Finding).integer()) + .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?; + + // Create FK index on job_id + manager + .create_index( + Index::create() + .if_not_exists() + .table(ExploitIntelligenceJobComponent::Table) + .name(Indexes::IdxEiJobComponentJobId.to_string()) + .col(ExploitIntelligenceJobComponent::JobId) + .to_owned(), + ) + .await?; + + // Create index on scan_id for lookups + manager + .create_index( + Index::create() + .if_not_exists() + .table(ExploitIntelligenceJobComponent::Table) + .name(Indexes::IdxEiJobComponentScanId.to_string()) + .col(ExploitIntelligenceJobComponent::ScanId) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Drop indexes + manager + .drop_index( + Index::drop() + .if_exists() + .table(ExploitIntelligenceJobComponent::Table) + .name(Indexes::IdxEiJobComponentScanId.to_string()) + .to_owned(), + ) + .await?; + + manager + .drop_index( + Index::drop() + .if_exists() + .table(ExploitIntelligenceJobComponent::Table) + .name(Indexes::IdxEiJobComponentJobId.to_string()) + .to_owned(), + ) + .await?; + + // Drop the component table + manager + .drop_table( + Table::drop() + .table(ExploitIntelligenceJobComponent::Table) + .if_exists() + .to_owned(), + ) + .await?; + + // Remove added columns from exploit_intelligence_job + manager + .alter_table( + Table::alter() + .table(ExploitIntelligenceJob::Table) + .drop_column(ExploitIntelligenceJob::TotalComponents) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(ExploitIntelligenceJob::Table) + .drop_column(ExploitIntelligenceJob::ProductId) + .to_owned(), + ) + .await?; + + Ok(()) + } +} + +#[derive(DeriveIden)] +enum Indexes { + IdxEiJobComponentJobId, + IdxEiJobComponentScanId, +} + +#[derive(DeriveIden)] +enum ExploitIntelligenceJob { + Table, + Id, + ProductId, + TotalComponents, +} + +#[derive(DeriveIden)] +enum ExploitIntelligenceJobComponent { + Table, + Id, + JobId, + ComponentPurl, + ComponentName, + ScanId, + Status, + Finding, + ReportUrl, + AdvisoryId, + ErrorMessage, + Created, + Updated, +} + +#[derive(DeriveIden)] +enum Advisory { + Table, + Id, +} From 331c3c9bb937da2d4eeef6df80a334d760140e75 Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Fri, 26 Jun 2026 14:22:53 +0100 Subject: [PATCH 2/5] refactor(migration): combine EI migrations and add missing FK index Merge m0002200 and m0002210 into a single migration that creates both the exploit_intelligence_job and exploit_intelligence_job_component tables together, including product_id/total_components columns upfront. Add the missing advisory_id FK index on the component table per CONVENTIONS.md migration patterns. Co-Authored-By: Claude Opus 4.6 --- migration/src/lib.rs | 2 - ...0002200_create_exploit_intelligence_job.rs | 152 +++++++++++- ...210_add_exploit_intelligence_components.rs | 223 ------------------ 3 files changed, 150 insertions(+), 227 deletions(-) delete mode 100644 migration/src/m0002210_add_exploit_intelligence_components.rs diff --git a/migration/src/lib.rs b/migration/src/lib.rs index 409be9c96..8733cd8db 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -64,7 +64,6 @@ mod m0002190_vulnerability_base_score_advisory; mod m0002200_source_document_ingested_index; mod m0002210_sbom_node_name_index; mod m0002200_create_exploit_intelligence_job; -mod m0002210_add_exploit_intelligence_components; pub trait MigratorExt: Send { fn build_migrations() -> Migrations; @@ -144,7 +143,6 @@ impl MigratorExt for Migrator { .normal(m0002200_source_document_ingested_index::Migration) .normal(m0002210_sbom_node_name_index::Migration) .normal(m0002200_create_exploit_intelligence_job::Migration) - .normal(m0002210_add_exploit_intelligence_components::Migration) } } diff --git a/migration/src/m0002200_create_exploit_intelligence_job.rs b/migration/src/m0002200_create_exploit_intelligence_job.rs index d0ad0c744..62629d14d 100644 --- a/migration/src/m0002200_create_exploit_intelligence_job.rs +++ b/migration/src/m0002200_create_exploit_intelligence_job.rs @@ -7,7 +7,6 @@ pub struct Migration; #[async_trait::async_trait] impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { - // Create the exploit_intelligence_job table manager .create_table( Table::create() @@ -41,6 +40,10 @@ impl MigrationTrait for Migration { .col(ColumnDef::new(ExploitIntelligenceJob::AdvisoryId).uuid()) .col(ColumnDef::new(ExploitIntelligenceJob::Finding).integer()) .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() @@ -69,7 +72,6 @@ impl MigrationTrait for Migration { ) .await?; - // Create FK indexes for sbom_id and advisory_id per CONVENTIONS.md §Migration Patterns manager .create_index( Index::create() @@ -92,10 +94,135 @@ impl MigrationTrait for Migration { ) .await?; + manager + .create_table( + Table::create() + .table(ExploitIntelligenceJobComponent::Table) + .col( + ColumnDef::new(ExploitIntelligenceJobComponent::Id) + .uuid() + .not_null() + .primary_key() + .default(Func::cust(UuidV4)), + ) + .col( + ColumnDef::new(ExploitIntelligenceJobComponent::JobId) + .uuid() + .not_null(), + ) + .col( + ColumnDef::new(ExploitIntelligenceJobComponent::ComponentPurl) + .string() + .not_null(), + ) + .col( + ColumnDef::new(ExploitIntelligenceJobComponent::ComponentName) + .string() + .not_null(), + ) + .col( + ColumnDef::new(ExploitIntelligenceJobComponent::ScanId) + .string() + .unique_key(), + ) + .col( + ColumnDef::new(ExploitIntelligenceJobComponent::Status) + .integer() + .not_null() + .default(0), + ) + .col(ColumnDef::new(ExploitIntelligenceJobComponent::Finding).integer()) + .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() @@ -134,6 +261,8 @@ impl MigrationTrait for Migration { enum Indexes { IdxExploitIntelligenceJobSbomId, IdxExploitIntelligenceJobAdvisoryId, + IdxEiJobComponentJobId, + IdxEiJobComponentAdvisoryId, } #[derive(DeriveIden)] @@ -149,6 +278,25 @@ enum ExploitIntelligenceJob { AdvisoryId, Finding, ErrorMessage, + ProductId, + TotalComponents, + Created, + Updated, +} + +#[derive(DeriveIden)] +enum ExploitIntelligenceJobComponent { + Table, + Id, + JobId, + ComponentPurl, + ComponentName, + ScanId, + Status, + Finding, + ReportUrl, + AdvisoryId, + ErrorMessage, Created, Updated, } diff --git a/migration/src/m0002210_add_exploit_intelligence_components.rs b/migration/src/m0002210_add_exploit_intelligence_components.rs deleted file mode 100644 index 894fc0b55..000000000 --- a/migration/src/m0002210_add_exploit_intelligence_components.rs +++ /dev/null @@ -1,223 +0,0 @@ -use crate::{Now, UuidV4}; -use sea_orm_migration::prelude::*; - -#[derive(DeriveMigrationName)] -pub struct Migration; - -#[async_trait::async_trait] -impl MigrationTrait for Migration { - async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { - // Add product_id and total_components columns to exploit_intelligence_job - manager - .alter_table( - Table::alter() - .table(ExploitIntelligenceJob::Table) - .add_column(ColumnDef::new(ExploitIntelligenceJob::ProductId).string()) - .to_owned(), - ) - .await?; - - manager - .alter_table( - Table::alter() - .table(ExploitIntelligenceJob::Table) - .add_column( - ColumnDef::new(ExploitIntelligenceJob::TotalComponents).integer(), - ) - .to_owned(), - ) - .await?; - - // Create the exploit_intelligence_job_component table - manager - .create_table( - Table::create() - .table(ExploitIntelligenceJobComponent::Table) - .col( - ColumnDef::new(ExploitIntelligenceJobComponent::Id) - .uuid() - .not_null() - .primary_key() - .default(Func::cust(UuidV4)), - ) - .col( - ColumnDef::new(ExploitIntelligenceJobComponent::JobId) - .uuid() - .not_null(), - ) - .col( - ColumnDef::new(ExploitIntelligenceJobComponent::ComponentPurl) - .string() - .not_null(), - ) - .col( - ColumnDef::new(ExploitIntelligenceJobComponent::ComponentName) - .string() - .not_null(), - ) - .col( - ColumnDef::new(ExploitIntelligenceJobComponent::ScanId) - .string() - .unique_key(), - ) - .col( - ColumnDef::new(ExploitIntelligenceJobComponent::Status) - .integer() - .not_null() - .default(0), - ) - .col(ColumnDef::new(ExploitIntelligenceJobComponent::Finding).integer()) - .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?; - - // Create FK index on job_id - manager - .create_index( - Index::create() - .if_not_exists() - .table(ExploitIntelligenceJobComponent::Table) - .name(Indexes::IdxEiJobComponentJobId.to_string()) - .col(ExploitIntelligenceJobComponent::JobId) - .to_owned(), - ) - .await?; - - // Create index on scan_id for lookups - manager - .create_index( - Index::create() - .if_not_exists() - .table(ExploitIntelligenceJobComponent::Table) - .name(Indexes::IdxEiJobComponentScanId.to_string()) - .col(ExploitIntelligenceJobComponent::ScanId) - .to_owned(), - ) - .await?; - - Ok(()) - } - - async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { - // Drop indexes - manager - .drop_index( - Index::drop() - .if_exists() - .table(ExploitIntelligenceJobComponent::Table) - .name(Indexes::IdxEiJobComponentScanId.to_string()) - .to_owned(), - ) - .await?; - - manager - .drop_index( - Index::drop() - .if_exists() - .table(ExploitIntelligenceJobComponent::Table) - .name(Indexes::IdxEiJobComponentJobId.to_string()) - .to_owned(), - ) - .await?; - - // Drop the component table - manager - .drop_table( - Table::drop() - .table(ExploitIntelligenceJobComponent::Table) - .if_exists() - .to_owned(), - ) - .await?; - - // Remove added columns from exploit_intelligence_job - manager - .alter_table( - Table::alter() - .table(ExploitIntelligenceJob::Table) - .drop_column(ExploitIntelligenceJob::TotalComponents) - .to_owned(), - ) - .await?; - - manager - .alter_table( - Table::alter() - .table(ExploitIntelligenceJob::Table) - .drop_column(ExploitIntelligenceJob::ProductId) - .to_owned(), - ) - .await?; - - Ok(()) - } -} - -#[derive(DeriveIden)] -enum Indexes { - IdxEiJobComponentJobId, - IdxEiJobComponentScanId, -} - -#[derive(DeriveIden)] -enum ExploitIntelligenceJob { - Table, - Id, - ProductId, - TotalComponents, -} - -#[derive(DeriveIden)] -enum ExploitIntelligenceJobComponent { - Table, - Id, - JobId, - ComponentPurl, - ComponentName, - ScanId, - Status, - Finding, - ReportUrl, - AdvisoryId, - ErrorMessage, - Created, - Updated, -} - -#[derive(DeriveIden)] -enum Advisory { - Table, - Id, -} From 97c81dc6a48973b48f3d14cfd8397e4db9d68c34 Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Tue, 30 Jun 2026 15:59:05 +0100 Subject: [PATCH 3/5] refactor(entity): rename component_purl to component_ref The column stores both purls (successful components) and container image references (failed/excluded components), so component_ref is more accurate. Co-Authored-By: Claude Opus 4.6 --- entity/src/exploit_intelligence_job_component.rs | 4 ++-- migration/src/m0002200_create_exploit_intelligence_job.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/entity/src/exploit_intelligence_job_component.rs b/entity/src/exploit_intelligence_job_component.rs index 14b45f702..1e489b1f2 100644 --- a/entity/src/exploit_intelligence_job_component.rs +++ b/entity/src/exploit_intelligence_job_component.rs @@ -19,8 +19,8 @@ pub struct Model { /// FK to the parent exploit intelligence job. pub job_id: Uuid, - /// The pkg:oci purl of the component. - pub component_purl: String, + /// Identifier of the component (purl or container image reference). + pub component_ref: String, /// Human-readable component name. pub component_name: String, diff --git a/migration/src/m0002200_create_exploit_intelligence_job.rs b/migration/src/m0002200_create_exploit_intelligence_job.rs index 62629d14d..43abc6dbb 100644 --- a/migration/src/m0002200_create_exploit_intelligence_job.rs +++ b/migration/src/m0002200_create_exploit_intelligence_job.rs @@ -111,7 +111,7 @@ impl MigrationTrait for Migration { .not_null(), ) .col( - ColumnDef::new(ExploitIntelligenceJobComponent::ComponentPurl) + ColumnDef::new(ExploitIntelligenceJobComponent::ComponentRef) .string() .not_null(), ) @@ -289,7 +289,7 @@ enum ExploitIntelligenceJobComponent { Table, Id, JobId, - ComponentPurl, + ComponentRef, ComponentName, ScanId, Status, From 19376fc3b0c9ad0cbb65f9088f0bf19163c4d3e2 Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Wed, 1 Jul 2026 15:18:15 +0100 Subject: [PATCH 4/5] refactor(entity): make sbom_id non-nullable and clarify multi-component docs sbom_id is always provided when creating a job, so make it NOT NULL in both the entity and migration. Change the FK on-delete action from SetNull to Cascade accordingly. Add doc comments to scan_id, report_url, advisory_id, finding, and product_id clarifying which fields stay None in the multi-component SPDX product flow vs the single-component CycloneDX flow. Co-Authored-By: Claude Opus 4.6 --- entity/src/exploit_intelligence_job.rs | 25 ++++++++++++++----- ...0002200_create_exploit_intelligence_job.rs | 8 ++++-- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/entity/src/exploit_intelligence_job.rs b/entity/src/exploit_intelligence_job.rs index 6b4139373..5cc415348 100644 --- a/entity/src/exploit_intelligence_job.rs +++ b/entity/src/exploit_intelligence_job.rs @@ -44,13 +44,13 @@ pub struct Model { /// 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. The UNIQUE constraint still holds — PostgreSQL - /// allows multiple NULLs in a unique column. + /// 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 (nullable). - pub sbom_id: Option, + /// FK to the SBOM being analysed. + pub sbom_id: Uuid, /// CVE identifier (e.g., "CVE-2024-9680"). pub vulnerability_id: String, @@ -62,21 +62,34 @@ pub struct Model { 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 (null for CycloneDX single-component). + /// 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, - /// Total number of components in the product (null for single-component). + /// Number of components in the product (`None` for single-component). pub total_components: Option, /// Timestamp when the job was created. diff --git a/migration/src/m0002200_create_exploit_intelligence_job.rs b/migration/src/m0002200_create_exploit_intelligence_job.rs index 43abc6dbb..d128bc87e 100644 --- a/migration/src/m0002200_create_exploit_intelligence_job.rs +++ b/migration/src/m0002200_create_exploit_intelligence_job.rs @@ -23,7 +23,11 @@ impl MigrationTrait for Migration { .string() .unique_key(), ) - .col(ColumnDef::new(ExploitIntelligenceJob::SbomId).uuid()) + .col( + ColumnDef::new(ExploitIntelligenceJob::SbomId) + .uuid() + .not_null(), + ) .col( ColumnDef::new(ExploitIntelligenceJob::VulnerabilityId) .string() @@ -60,7 +64,7 @@ impl MigrationTrait for Migration { ForeignKey::create() .from_col(ExploitIntelligenceJob::SbomId) .to(Sbom::Table, Sbom::SbomId) - .on_delete(ForeignKeyAction::SetNull), + .on_delete(ForeignKeyAction::Cascade), ) .foreign_key( ForeignKey::create() From 0060d977dbfc212fb738d9d2d5de57fcb56fb8e4 Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Thu, 2 Jul 2026 16:42:35 +0100 Subject: [PATCH 5/5] fix(entity): use V7 UUIDs and PostgreSQL enum types for EI jobs Address PR review feedback: remove gen_random_uuid() defaults so application code generates V7 UUIDs, and convert Status/Finding from integer-backed columns to proper PostgreSQL enum types matching the existing Severity/ScoreType pattern. Co-Authored-By: Claude Opus 4.6 (1M context) --- entity/src/exploit_intelligence_job.rs | 29 ++++-- migration/src/lib.rs | 2 +- ...0002200_create_exploit_intelligence_job.rs | 91 ++++++++++++++----- 3 files changed, 91 insertions(+), 31 deletions(-) diff --git a/entity/src/exploit_intelligence_job.rs b/entity/src/exploit_intelligence_job.rs index 5cc415348..654d47927 100644 --- a/entity/src/exploit_intelligence_job.rs +++ b/entity/src/exploit_intelligence_job.rs @@ -8,30 +8,41 @@ use time::OffsetDateTime; /// Lifecycle status of an Exploit Intelligence analysis job. #[derive(Copy, Clone, Debug, Eq, PartialEq, EnumIter, DeriveActiveEnum)] -#[sea_orm(rs_type = "i32", db_type = "Integer")] +#[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. - Pending = 0, + #[sea_orm(string_value = "pending")] + Pending, /// Analysis is in progress. - Running = 1, + #[sea_orm(string_value = "running")] + Running, /// Analysis completed successfully. - Completed = 2, + #[sea_orm(string_value = "completed")] + Completed, /// Analysis failed with an error. - Failed = 3, + #[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 = "i32", db_type = "Integer")] +#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "ei_finding")] pub enum Finding { /// The analysed component is vulnerable to the CVE. - Vulnerable = 0, + #[sea_orm(string_value = "vulnerable")] + Vulnerable, /// The analysed component is not vulnerable to the CVE. - NotVulnerable = 1, + #[sea_orm(string_value = "not_vulnerable")] + NotVulnerable, /// The analysis was inconclusive. - Uncertain = 2, + #[sea_orm(string_value = "uncertain")] + Uncertain, } #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] diff --git a/migration/src/lib.rs b/migration/src/lib.rs index 8733cd8db..7d59cd08b 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -61,9 +61,9 @@ 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; -mod m0002200_create_exploit_intelligence_job; pub trait MigratorExt: Send { fn build_migrations() -> Migrations; diff --git a/migration/src/m0002200_create_exploit_intelligence_job.rs b/migration/src/m0002200_create_exploit_intelligence_job.rs index d128bc87e..19c053ad2 100644 --- a/migration/src/m0002200_create_exploit_intelligence_job.rs +++ b/migration/src/m0002200_create_exploit_intelligence_job.rs @@ -1,5 +1,9 @@ -use crate::{Now, UuidV4}; +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; @@ -7,6 +11,23 @@ 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() @@ -15,8 +36,7 @@ impl MigrationTrait for Migration { ColumnDef::new(ExploitIntelligenceJob::Id) .uuid() .not_null() - .primary_key() - .default(Func::cust(UuidV4)), + .primary_key(), ) .col( ColumnDef::new(ExploitIntelligenceJob::ScanId) @@ -35,19 +55,17 @@ impl MigrationTrait for Migration { ) .col( ColumnDef::new(ExploitIntelligenceJob::Status) - .integer() + .custom(ExploitIntelligenceJobStatus::Table) .not_null() - .default(0), + .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).integer()) + .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::TotalComponents).integer()) .col( ColumnDef::new(ExploitIntelligenceJob::Created) .timestamp_with_time_zone() @@ -106,8 +124,7 @@ impl MigrationTrait for Migration { ColumnDef::new(ExploitIntelligenceJobComponent::Id) .uuid() .not_null() - .primary_key() - .default(Func::cust(UuidV4)), + .primary_key(), ) .col( ColumnDef::new(ExploitIntelligenceJobComponent::JobId) @@ -131,16 +148,17 @@ impl MigrationTrait for Migration { ) .col( ColumnDef::new(ExploitIntelligenceJobComponent::Status) - .integer() + .custom(ExploitIntelligenceJobStatus::Table) .not_null() - .default(0), + .default("pending"), ) - .col(ColumnDef::new(ExploitIntelligenceJobComponent::Finding).integer()) - .col(ColumnDef::new(ExploitIntelligenceJobComponent::ReportUrl).string()) - .col(ColumnDef::new(ExploitIntelligenceJobComponent::AdvisoryId).uuid()) .col( - ColumnDef::new(ExploitIntelligenceJobComponent::ErrorMessage).string(), + 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() @@ -156,10 +174,7 @@ impl MigrationTrait for Migration { .foreign_key( ForeignKey::create() .from_col(ExploitIntelligenceJobComponent::JobId) - .to( - ExploitIntelligenceJob::Table, - ExploitIntelligenceJob::Id, - ) + .to(ExploitIntelligenceJob::Table, ExploitIntelligenceJob::Id) .on_delete(ForeignKeyAction::Cascade), ) .foreign_key( @@ -256,6 +271,19 @@ impl MigrationTrait for Migration { ) .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(()) } } @@ -305,6 +333,27 @@ enum ExploitIntelligenceJobComponent { 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,