Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions entity/src/vulnerability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ pub struct Model {
pub cwes: Option<Vec<String>>,
pub base_score: Option<f64>,
pub base_severity: Option<Severity>,
/// Generated column for sorting vulnerability IDs with proper numeric ordering
/// This is a STORED generated column in the database and should not be set during insert/update
/// Nullable to support LEFT JOIN queries where the vulnerability may not exist
pub id_sort_key: Option<String>,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
Expand Down
2 changes: 2 additions & 0 deletions migration/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ mod m0002040_create_crypto;
mod m0002050_add_sbom_revision;
mod m0002060_add_sbom_group_field;
mod m0002070_alter_sbom_group_restrict_parent;
mod m0002080_vulnerability_id_sort_index;

pub trait MigratorExt: Send {
fn build_migrations() -> Migrations;
Expand Down Expand Up @@ -108,6 +109,7 @@ impl MigratorExt for Migrator {
.normal(m0002050_add_sbom_revision::Migration)
.normal(m0002060_add_sbom_group_field::Migration)
.normal(m0002070_alter_sbom_group_restrict_parent::Migration)
.normal(m0002080_vulnerability_id_sort_index::Migration)
}
}

Expand Down
61 changes: 61 additions & 0 deletions migration/src/m0002080_vulnerability_id_sort_index.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
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> {
// Add a generated column that stores the normalized sort key
// This pads numeric segments with zeros to achieve numeric sorting:
// 1. First, prepend 19 zeros to each numeric segment
// 2. Then, keep only the 19 right-most digits for each numeric segment
// The number 19 is used as that is the largest segment defined in the CVE ID spec.
//
// Using a STORED generated column means:
// - The value is computed once when the row is inserted/updated
// - It's physically stored in the table
// - Indexes on this column work like regular column indexes
// - PostgreSQL automatically computes values for all existing rows during migration
// (this may take some time on large tables, but is a one-time operation)
manager
.get_connection()
.execute_unprepared(
"ALTER TABLE vulnerability
ADD COLUMN id_sort_key TEXT GENERATED ALWAYS AS (
REGEXP_REPLACE(
REGEXP_REPLACE(id, '\\y([0-9]+)\\y', '0000000000000000000\\1', 'g'),
'\\y([0-9]+)([0-9]{19})\\y',
'\\2',
'g'
)
) STORED",
)
.await?;

// Create an index on the generated column for efficient sorting
manager
.get_connection()
.execute_unprepared(
"CREATE INDEX vulnerability_id_sort_key_idx ON vulnerability (id_sort_key)",
)
.await?;

Ok(())
}

async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.get_connection()
.execute_unprepared("DROP INDEX IF EXISTS vulnerability_id_sort_key_idx")
.await?;

manager
.get_connection()
.execute_unprepared("ALTER TABLE vulnerability DROP COLUMN IF EXISTS id_sort_key")
.await?;

Ok(())
}
}
1 change: 1 addition & 0 deletions modules/fundamental/src/purl/model/details/purl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ impl PurlAdvisory {
cwes: None,
base_severity: None,
base_score: None,
id_sort_key: None, // Fallback only; normally loaded from database
});

if let Some(advisory) = advisory {
Expand Down
18 changes: 17 additions & 1 deletion modules/fundamental/src/vulnerability/service/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,21 @@ impl VulnerabilityService {
connection: &C,
) -> Result<PaginatedResults<VulnerabilitySummary>, Error> {
let limiter = vulnerability::Entity::find()
.filtering_with(search, Columns::from_entity::<vulnerability::Entity>())?
.filtering_with(
search,
Columns::from_entity::<vulnerability::Entity>().translator(|field, order, _value| {
// When sorting by 'id', translate to use the id_sort_key column
// This is a generated column in the database that pads numeric segments
// with zeros to achieve proper numeric sorting while maintaining
// alphabetical ordering between different prefixes (ABC-, CVE-, GHSA-, etc.)
// The column is indexed for efficient sorting.
if field == "id" && (order == "asc" || order == "desc") {
Some(format!("id_sort_key:{}", order))
} else {
None
}
}),
)?
.limiting(connection, paginated.offset, paginated.limit);

let total = limiter.total().await?;
Expand Down Expand Up @@ -444,6 +458,7 @@ SELECT
vulnerability.modified,
vulnerability.withdrawn,
vulnerability.cwes,
vulnerability.id_sort_key,
jsonb_agg(
DISTINCT jsonb_build_object(
'status', status.slug,
Expand Down Expand Up @@ -493,6 +508,7 @@ GROUP BY
vulnerability.modified,
vulnerability.withdrawn,
vulnerability.cwes,
vulnerability.id_sort_key,
requested_purl
"#
);
Expand Down
76 changes: 76 additions & 0 deletions modules/fundamental/src/vulnerability/service/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,82 @@ async fn vulnerability_queries(ctx: &TrustifyContext) -> Result<(), anyhow::Erro
Ok(())
}

#[test_context(TrustifyContext)]
#[test(tokio::test)]
async fn vulnerability_numeric_sorting(ctx: &TrustifyContext) -> Result<(), anyhow::Error> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's good to have that. I'm not happy about the repetition.

I think we could rewrite this as:

for id in [
   "ID1",
   "ID2",
] {
    ctx.graph.ingest_vulnerability(id, (), &ctx.db).await?;
}

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

let service = VulnerabilityService::new();

// Test various OSV ID formats, including edge cases, to ensure generic numeric sorting works
for id in [
"CVE-2024-40000",
"CVE-2024-10288000",
"GHSA-r9p9-mrjm-926w",
"GHSA-vp9c-fpxx-744v",
"GO-2024-268",
"GO-2024-1234",
"RUSTSEC-2019-0033",
"RUSTSEC-2024-0001",
"ALPINE-12345",
"ALPINE-6789",
"PYSEC-2021-1234",
"PYSEC-2024-5678",
"OSV-2020-111",
"OSV-2020-58",
"ABC-xxxx-yyyy",
"12345",
"NOPE",
] {
ctx.graph.ingest_vulnerability(id, (), &ctx.db).await?;
}

const EXPECTED_ASC: &[&str] = &[
"12345",
"ABC-xxxx-yyyy",
"ALPINE-6789",
"ALPINE-12345",
"CVE-2024-40000",
"CVE-2024-10288000",
"GHSA-r9p9-mrjm-926w",
"GHSA-vp9c-fpxx-744v",
"GO-2024-268",
"GO-2024-1234",
"NOPE",
"OSV-2020-58",
"OSV-2020-111",
"PYSEC-2021-1234",
"PYSEC-2024-5678",
"RUSTSEC-2019-0033",
"RUSTSEC-2024-0001",
];

// Test ascending sort
let vulns = service
.fetch_vulnerabilities(
q("").sort("id:asc"),
Paginated::default(),
Default::default(),
&ctx.db,
)
.await?;
let ids: Vec<_> = vulns.items.iter().map(|v| v.head.identifier.as_str()).collect();
assert_eq!(EXPECTED_ASC, ids);

// Test descending sort
let vulns = service
.fetch_vulnerabilities(
q("").sort("id:desc"),
Paginated::default(),
Default::default(),
&ctx.db,
)
.await?;
let ids: Vec<_> = vulns.items.iter().map(|v| v.head.identifier.as_str()).collect();
let expected_desc: Vec<_> = EXPECTED_ASC.iter().rev().copied().collect();
assert_eq!(expected_desc, ids);

Ok(())
}

#[test_context(TrustifyContext)]
#[test(tokio::test)]
async fn analyze_purls(ctx: &TrustifyContext) -> Result<(), anyhow::Error> {
Expand Down
3 changes: 2 additions & 1 deletion modules/ingestor/src/graph/vulnerability/creator.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use crate::graph::{error::Error, vulnerability::VulnerabilityInformation};
use sea_orm::{ActiveValue::Set, ConnectionTrait, EntityTrait};
use sea_orm::{ActiveValue::{NotSet, Set}, ConnectionTrait, EntityTrait};
use sea_query::OnConflict;
use std::collections::BTreeMap;
use tracing::instrument;
Expand Down Expand Up @@ -54,6 +54,7 @@ impl VulnerabilityCreator {
cwes: Set(info.cwes),
base_score: Set(info.base_score),
base_severity: Set(info.base_severity),
id_sort_key: NotSet,
}
}

Expand Down
5 changes: 3 additions & 2 deletions modules/ingestor/src/graph/vulnerability/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ pub mod creator;
use crate::common::{Deprecation, DeprecationExt};
use crate::{graph::Graph, graph::advisory::AdvisoryContext, graph::error::Error};
use sea_orm::{
ActiveValue::Set, ColumnTrait, ConnectionTrait, EntityTrait, ModelTrait, QueryFilter,
QuerySelect, RelationTrait,
ActiveValue::{NotSet, Set},
ColumnTrait, ConnectionTrait, EntityTrait, ModelTrait, QueryFilter, QuerySelect, RelationTrait,
};
use sea_query::{JoinType, OnConflict};
use std::fmt::{Debug, Formatter};
Expand Down Expand Up @@ -97,6 +97,7 @@ impl Graph {
cwes: Set(information.cwes),
base_score: Set(information.base_score),
base_severity: Set(information.base_severity),
id_sort_key: NotSet,
};

let result = vulnerability::Entity::insert(entity)
Expand Down