Skip to content
Open
294 changes: 294 additions & 0 deletions docs/adrs/00014-advisory-vulnerability-scores.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,294 @@
# 00014. Replace average scores with structured score lists on advisory/vulnerability entries

Date: 2026-04-02

## Status

APPROVED

## Context

[ADR 00004](00004-advisory-scores.md) outlined the overall design for handling CVSS scores across
advisory and vulnerability API responses. The implementation that followed introduced two interim
patterns that are now being retired:

* **Average score fields** on multiple structs, under two different names:
* `average_score: Option<f64>` and `average_severity: Option<Severity>` (or `Option<String>`
for `AdvisorySummary`) on `AdvisoryDetails`, `AdvisorySummary`, `VulnerabilityDetails`, and
`VulnerabilitySummary` — top-level aggregates across all vulnerabilities/advisories.
* `score: Option<f64>` and `severity: Option<Severity>` on `AdvisoryVulnerabilityHead` and
`VulnerabilityAdvisoryHead` — values sourced from `vulnerability.base_score` /
`base_severity` DB columns, which were themselves derived by averaging CVSS v3 values only,
discarding v2 and v4. `VulnerabilityHead` did not expose these columns directly.

* **Raw CVSS vector strings** (`cvss3_scores: Vec<String>`) on `AdvisoryVulnerabilitySummary` and
`VulnerabilityAdvisorySummary`. These exposed opaque CVSS vector strings (e.g.
`CVSS:3.1/AV:N/AC:L/...`) directly and were filtered to CVSS v3 only, discarding v2 and v4.

## Decision

### Score types

Two structured score types replace the old fields:

#### `Score`

Numeric score, type, and derived severity:

```rust
pub struct Score {
pub r#type: ScoreType, // "2.0" | "3.0" | "3.1" | "4.0"
pub value: f64, // rounded to 1 decimal place
pub severity: Severity, // "none" | "low" | "medium" | "high" | "critical"
}
```

#### `ScoredVector`

Same as `Score` (flattened) plus the raw CVSS vector string:

```rust
pub struct ScoredVector {
#[serde(flatten)]
pub score: Score,
pub vector: String, // e.g. "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"
}
```

### Scores are per (advisory, vulnerability) pair

Each score entry represents a score that a specific advisory asserts for a specific vulnerability.
One advisory can assert multiple scores for the same vulnerability (e.g. one CVSS v2 and one
CVSS v3.1 score). All of them are included in the array.

### Changed endpoints

#### `GET /api/v3/advisory`

Each vulnerability entry in the list previously carried a single `score`/`severity` from
`AdvisoryVulnerabilityHead`. It now carries a `scores` array of `ScoredVector`. The top-level
`average_score` and `average_severity` fields on `AdvisorySummary` are also removed.

Before:
```json
{
"average_score": 6.8,
"average_severity": "medium",
"vulnerabilities": [
{
"identifier": "CVE-2023-1234",
"score": 6.8,
"severity": "medium"
}
]
}
```

After:
```json
{
"vulnerabilities": [
{
"identifier": "CVE-2023-1234",
"scores": [
{ "type": "3.1", "value": 6.8, "severity": "medium", "vector": "CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:C/C:H/I:N/A:N" }
]
}
]
}
```

#### `GET /api/v3/advisory/{id}`

Same per-vulnerability change as the list endpoint. Additionally, the old
`cvss3_scores: Vec<String>` on `AdvisoryVulnerabilitySummary` is replaced by the same `scores`
array already on the flattened `AdvisoryVulnerabilityHead` — so list and detail now expose
scores identically. The top-level `average_score` and `average_severity` fields on
`AdvisoryDetails` are also removed.

Before:
```json
{
"average_score": 6.8,
"average_severity": "medium",
"vulnerabilities": [
{
"identifier": "CVE-2023-1234",
"score": 6.8,
"severity": "medium",
"cvss3_scores": ["CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:C/C:H/I:N/A:N"]
}
]
}
```

After:
```json
{
"vulnerabilities": [
{
"identifier": "CVE-2023-1234",
"scores": [
{ "type": "3.1", "value": 6.8, "severity": "medium", "vector": "CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:C/C:H/I:N/A:N" }
]
}
]
}
```

#### `GET /api/v3/vulnerability/{id}`

Each advisory entry previously carried a `score`/`severity` from `VulnerabilityAdvisoryHead`
and a `cvss3_scores: Vec<String>` from `VulnerabilityAdvisorySummary`. It now carries a
`scores` array of `ScoredVector` entries. The top-level `average_score` and `average_severity`
fields on `VulnerabilityDetails` are also removed.

Before:
```json
{
"average_score": 7.5,
"average_severity": "high",
"advisories": [
{
"identifier": "RHSA-2023:1234",
"score": 7.5,
"severity": "high",
"cvss3_scores": ["CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:H"]
}
]
}
```

After:
```json
{
"base_score": { "type": "3.1", "score": 7.5, "severity": "high" },
"advisories": [
{
"identifier": "RHSA-2023:1234",
"scores": [
{ "type": "3.1", "value": 7.5, "severity": "high", "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:H" }
]
}
]
}
```

#### `GET /api/v3/vulnerability`

The advisory list per vulnerability is removed entirely from list responses. Previously each
item included an `advisories` array (with score/severity per advisory); now only the
vulnerability's own `base_score` is returned. This is a new field on `VulnerabilityHead`; a
new `base_type` DB column is added alongside the existing `base_score`/`base_severity` columns,
and all three are now populated from the best available CVSS score across all versions (v4 >
v3.1 > v3.0 > v2, higher numeric score preferred within the same version) rather than a v3-only
average.

Before:
```json
{
"items": [
{
"identifier": "CVE-2023-1234",
"average_score": 7.5,
"average_severity": "high",
"advisories": [
{ "identifier": "RHSA-2023:1234", "score": 7.5, "severity": "high" }
]
}
]
}
```

After:
```json
{
"items": [
{
"identifier": "CVE-2023-1234",
"base_score": { "type": "3.1", "score": 7.5, "severity": "high" }
}
]
}
```

Per-advisory scores are available via `GET /api/v3/vulnerability/{id}`.

#### `GET /api/v3/purl/{id}`

`PurlStatus` previously carried deprecated `average_severity: Severity` and `average_score: f64`
fields alongside a `scores: Vec<Score>` array. Both deprecated fields are now removed and
`scores` is promoted to `Vec<ScoredVector>`.

Before:
```json
{
"advisories": [{
"status": [{
"average_score": 7.5,
"average_severity": "high",
"scores": [{ "type": "3.1", "value": 7.5, "severity": "high" }]
}]
}]
}
```

After:
```json
{
"advisories": [{
"status": [{
"scores": [{ "type": "3.1", "value": 7.5, "severity": "high", "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:H" }]
}]
}]
}
```

#### `GET /api/v3/sbom/{id}/advisory`

`SbomStatus` carried the same deprecated fields as `PurlStatus`. Both are removed and `scores`
is promoted to `Vec<ScoredVector>`.

Before:
```json
[{
"status": [{
"average_score": 7.5,
"average_severity": "high",
"scores": [{ "type": "3.1", "value": 7.5, "severity": "high" }]
}]
}]
```

After:
```json
[{
"status": [{
"scores": [{ "type": "3.1", "value": 7.5, "severity": "high", "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:H" }]
}]
}]
```

### Batch loading

To avoid N+1 query patterns when assembling list responses, score loading is batched:

* `AdvisorySummary::from_entities` loads all scores for all advisories in the result page with a
single `advisory_id IN (...)` query before the per-advisory loop, then distributes them in
memory.
* `AdvisoryVulnerabilityHead::from_entities` loads all `advisory_vulnerability` join records for
the advisory's vulnerabilities in a single `load_many` call instead of one query per
vulnerability.
* `VulnerabilitySummary::from_entities` no longer loads advisories or their scores at all — only
`vulnerability_description` rows are batch-loaded (one `load_many` for English descriptions).

## Consequences

* Average score fields are removed from all response types. Clients that were consuming them must
migrate to the `scores` array and compute their own aggregation if needed.
* `cvss3_scores: Vec<String>` is gone. All CVSS versions (v2, v3.0, v3.1, v4.0) are now
represented equally — no version is silently discarded.
* Advisory endpoints now expose structured scores with vector strings, allowing clients to display
or validate scores without hitting a separate API.
* Vulnerability list responses are faster and smaller: the advisory join and associated score
loading are eliminated at list level.
11 changes: 1 addition & 10 deletions entity/src/advisory.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::{advisory_vulnerability, cvss3, labels::Labels, organization, vulnerability};
use crate::{advisory_vulnerability, labels::Labels, organization, vulnerability};
use sea_orm::{Condition, entity::prelude::*, sea_query::IntoCondition};
use time::OffsetDateTime;
use trustify_common::id::{Id, IdError, TryFilterForId};
Expand Down Expand Up @@ -38,9 +38,6 @@ pub enum Relation {
to = "super::organization::Column::Id")]
Issuer,

#[sea_orm(has_many = "super::cvss3::Entity")]
Cvss3,

#[sea_orm(has_many = "super::advisory_vulnerability::Entity")]
AdvisoryVulnerability,
}
Expand Down Expand Up @@ -73,12 +70,6 @@ impl Related<advisory_vulnerability::Entity> for Entity {
}
}

impl Related<cvss3::Entity> for Entity {
fn to() -> RelationDef {
Relation::Cvss3.def()
}
}

impl ActiveModelBehavior for ActiveModel {}

impl TryFilterForId for Entity {
Expand Down
11 changes: 1 addition & 10 deletions entity/src/advisory_vulnerability.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::{advisory, cvss3, purl_status, vulnerability};
use crate::{advisory, purl_status, vulnerability};
use sea_orm::{LinkDef, entity::prelude::*};
use time::OffsetDateTime;

Expand Down Expand Up @@ -32,9 +32,6 @@ pub enum Relation {
to = "super::vulnerability::Column::Id")]
Vulnerability,

#[sea_orm(has_many = "super::cvss3::Entity")]
Cvss3,

#[sea_orm(has_many = "super::purl_status::Entity")]
PurlStatus,

Expand All @@ -54,12 +51,6 @@ impl Related<vulnerability::Entity> for Entity {
}
}

impl Related<cvss3::Entity> for Entity {
fn to() -> RelationDef {
Relation::Cvss3.def()
}
}

impl Related<purl_status::Entity> for Entity {
fn to() -> RelationDef {
Relation::PurlStatus.def()
Expand Down
Loading
Loading