feat: include all scores in PurlStatus and deprecate average_severity/average_score#2166
Conversation
Reviewer's GuideExpose all underlying CVSS scores on PurlStatus and wire them through a new v3 vulnerability analysis response model, while repointing the existing v2 API to the older, head-based model and updating OpenAPI and tests accordingly. Sequence diagram for v3 vulnerability analyze with full CVSS scoressequenceDiagram
actor Client
participant API as VulnerabilityEndpoints
participant Service as VulnerabilityService
participant DB as Database
participant Model as PurlStatus
Client->>API: POST /api/v3/vulnerability/analyze (AnalysisRequest)
API->>Service: analyze_purls_v3(purls, connection)
Service->>DB: build_query(purls)
DB-->>Service: purls_with_vulnerabilities, cvss3_scores, warnings, descriptions_map, advisories_map
Service->>Service: format_response(purls_with_vulnerabilities,...)
loop for each cvss3_score
Service->>Service: group scores into cvss3_map[(advisory_id, vulnerability_id)] as Vec<cvss3::Model>
end
loop for each row in purls_with_vulnerabilities
Service->>Service: row_to_vuln_v3(row, connection, descriptions_map, cvss3_map, advisories_map)
Service->>Service: get scores = cvss3_map[(advisory_id, vulnerability_id)]
Service->>Model: PurlStatus::from_head(head, advisory_head, vuln_head, status, version_range, cpe, scores)
Model->>Model: compute Cvss3Score average from scores
Model->>Model: map scores to Vec<Score> for all_scores
Model-->>Service: PurlStatus with scores, average_score, average_severity
end
Service-->>API: AnalysisResponseV3(BTreeMap<String, AnalysisResultV3>)
API-->>Client: 200 OK with AnalysisResponseV3 JSON
Updated class diagram for analysis models and PurlStatus with scoresclassDiagram
class AnalysisRequest {
+Vec~String~ purls
}
class AnalysisResponseV3 {
+BTreeMap~String, AnalysisResultV3~ 0
}
class AnalysisResultV3 {
+Vec~AnalysisDetailsV3~ details
+Vec~String~ warnings
}
class AnalysisDetailsV3 {
+Vec~PurlStatus~ purl_statuses
}
class AnalysisResponse {
+BTreeMap~String, AnalysisResult~ 0
}
class AnalysisResult {
+Vec~AnalysisDetails~ details
+Vec~String~ warnings
}
class AnalysisDetails {
+VulnerabilityHead head
+BTreeMap~String, AnalysisAdvisory~ status
}
class AnalysisAdvisory {
+AdvisoryHead advisory
+Vec~PurlStatus~ purl_statuses
}
class PurlStatus {
+VulnerabilityHead vulnerability
+AdvisoryHead advisory
+Vec~Score~ scores
+Severity average_severity
+f64 average_score
+String status
+StatusContext context
+VersionRange version_range
+from_head(vuln_head, advisory_head, vuln, status, version_range, cpe, scores)
+from_vuln(vuln, advisory, status, cpe, tx)
}
class Score {
+f64 value()
+Severity severity()
}
class Cvss3Score {
+f64 value()
+Severity severity()
+from_iter(iterator)
}
class Cvss3Base {
}
class cvss3_Model {
}
class VulnerabilityHead {
}
class AdvisoryHead {
}
class StatusContext {
}
class VersionRange {
}
AnalysisResponseV3 --> AnalysisResultV3 : maps_to
AnalysisResultV3 --> AnalysisDetailsV3 : contains
AnalysisDetailsV3 --> PurlStatus : contains
AnalysisResponse --> AnalysisResult : maps_to
AnalysisResult --> AnalysisDetails : contains
AnalysisDetails --> VulnerabilityHead : flattens
AnalysisDetails --> AnalysisAdvisory : status_map
AnalysisAdvisory --> AdvisoryHead : has
AnalysisAdvisory --> PurlStatus : contains
PurlStatus --> VulnerabilityHead : has
PurlStatus --> AdvisoryHead : has
PurlStatus --> Score : has
PurlStatus --> StatusContext : optional
PurlStatus --> VersionRange : optional
PurlStatus --> Cvss3Score : computes_average
Cvss3Score --> Cvss3Base : from_iter
Cvss3Base --> cvss3_Model : from_model
Score --> cvss3_Model : try_from
AnalysisRequest --> AnalysisResponseV3 : used_by_v3_endpoint
AnalysisRequest --> AnalysisResponse : used_by_v2_endpoint
File-Level Changes
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
Hey there - I've reviewed your changes - here's some feedback:
- In
VulnerabilityService, when populatingcvss3_map, you can avoid the extra clone by insertingscoredirectly into theHashMapifcvss3_scoresisn’t reused later (i.e., replacelet score_clone = score.clone(); ... .push(score_clone);with.push(score);). - The construction of
all_scoresinPurlStatus::from_vulnerability/from_headclones the CVSS3 models and thentry_froms them; consider adding aTryFrom<&cvss3::Model>implementation forScoreso you can map over references and avoid cloning.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- In `VulnerabilityService`, when populating `cvss3_map`, you can avoid the extra clone by inserting `score` directly into the `HashMap` if `cvss3_scores` isn’t reused later (i.e., replace `let score_clone = score.clone(); ... .push(score_clone);` with `.push(score);`).
- The construction of `all_scores` in `PurlStatus::from_vulnerability`/`from_head` clones the CVSS3 models and then `try_from`s them; consider adding a `TryFrom<&cvss3::Model>` implementation for `Score` so you can map over references and avoid cloning.
## Individual Comments
### Comment 1
<location> `modules/fundamental/src/purl/model/details/purl.rs:340-344` </location>
<code_context>
let cvss3 = vuln.find_related(cvss3::Entity).all(tx).await?;
- let average_score = Score::from_iter(cvss3.iter().map(Cvss3Base::from));
+ let average_score = Cvss3Score::from_iter(cvss3.iter().map(Cvss3Base::from));
+ let all_scores = cvss3
+ .iter()
+ .cloned()
+ .filter_map(|cvss3| Score::try_from(cvss3).ok())
+ .collect();
+
Ok(Self {
</code_context>
<issue_to_address>
**issue:** Consider whether silently dropping scores that fail `Score::try_from` is acceptable.
This `filter_map` will drop any CVSS3 values that fail `Score::try_from`, so `scores` may exclude items that contributed to `average_score`. If conversion failures are expected in real data, consider logging them or propagating an error instead of skipping them silently.
</issue_to_address>
### Comment 2
<location> `modules/fundamental/tests/advisory/csaf/reingest.rs:164-175` </location>
<code_context>
},
average_severity: Severity::Medium,
average_score: 5.3f64,
+ scores: vec![
+ Score {
+ severity: Severity::Medium,
+ value: 5.3,
</code_context>
<issue_to_address>
**suggestion (testing):** Use distinct scores in the expectation to prove that all individual scores (not just count) are preserved
The new expectations only verify that `scores` contains multiple entries, not that distinct CVSS scores are preserved. Please make the entries differ (e.g., one V3.1 and one V2/V3.0, or with different `value`s) so the test would fail if only a single or averaged score were returned.
```suggestion
scores: vec![
Score {
severity: Severity::Medium,
value: 5.3,
r#type: ScoreType::V3_1,
},
Score {
severity: Severity::Medium,
value: 7.5,
r#type: ScoreType::V3_0,
}
],
```
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| let all_scores = cvss3 | ||
| .iter() | ||
| .cloned() | ||
| .filter_map(|cvss3| Score::try_from(cvss3).ok()) | ||
| .collect(); |
There was a problem hiding this comment.
issue: Consider whether silently dropping scores that fail Score::try_from is acceptable.
This filter_map will drop any CVSS3 values that fail Score::try_from, so scores may exclude items that contributed to average_score. If conversion failures are expected in real data, consider logging them or propagating an error instead of skipping them silently.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #2166 +/- ##
==========================================
+ Coverage 68.74% 68.75% +0.01%
==========================================
Files 397 397
Lines 22322 22337 +15
Branches 22322 22337 +15
==========================================
+ Hits 15345 15358 +13
+ Misses 6072 6068 -4
- Partials 905 911 +6 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
d32a3d9 to
ff91aeb
Compare
ff91aeb to
d5b0d65
Compare
carlosthe19916
left a comment
There was a problem hiding this comment.
@Strum355 my apologies for the delayed review.
I tested this PR and it works fine. Just a minor thing to change: The current response of /api/v3/vulnerability/analyze has the following shape:
{
"purl1": {
details: [
...vulnerability fields here, // this not needed
purl_statuses: [{
vulnerability: {...vulnerability fields here},
advisory: {},
scores: []
}]
]
}
}
notice that the field purl.details contains a set of fields that belong to a Vulnerability, while at the same time the vulnerability fields are already present inside the purl_statuses field.
Could we clean up the duplicate vulnerability fields?
Thanks for the feedback! I had noticed the duplication as well, but Im not sure on which side we want to remove it from. While I am leaning towards removing it from trustify/modules/fundamental/src/vulnerability/model/analyze.rs Lines 18 to 20 in 53e6492 vulnerability
|
@Strum355 this wouldn't be a problem for the client side (UI). I'll let you take the final decision on regards of which side the fields should be removed from :) |
d5b0d65 to
24547af
Compare
@ctron @dejanb I opted to remove it from the top level AnalysisDetails, lmk if you feel strongly otherwise |
I think that's the right approach as changing PurlStatus would change other API definitions that we don't want to do at this moment. |
Scratch that. I just see that we are changing the PurlStatus, let me take a deeper look at this, especially in light of #1913 where we are staring to save scores in different table. Maybe we should tackle all this in one go. |
|
Here are some comments and this we should do next:
For the future steps:
|
|
@sourcery-ai review |
There was a problem hiding this comment.
Hey - I've found 3 issues, and left some high level feedback:
- The new
PurlStatus::from_entityandPurlStatus::from_headimplementations contain very similar logic for derivingaverage_score,average_severity, andscoresfrom CVSS models; consider extracting a small helper that takes an iterator ofcvss3::Modeland returns(Cvss3Score, Vec<Score>)to avoid duplication and keep the two constructors in sync. - The v2/v3 type and endpoint remapping (e.g.,
AnalysisResponse↔AnalysisResponseV3,row_to_vuln→row_to_vuln_v3) is somewhat non-obvious when reading the code; adding brief inline comments where the swap happens (service methods and endpoint handlers) would make the versioning and the rationale for the new shapes much clearer to future readers.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- The new `PurlStatus::from_entity` and `PurlStatus::from_head` implementations contain very similar logic for deriving `average_score`, `average_severity`, and `scores` from CVSS models; consider extracting a small helper that takes an iterator of `cvss3::Model` and returns `(Cvss3Score, Vec<Score>)` to avoid duplication and keep the two constructors in sync.
- The v2/v3 type and endpoint remapping (e.g., `AnalysisResponse` ↔ `AnalysisResponseV3`, `row_to_vuln` → `row_to_vuln_v3`) is somewhat non-obvious when reading the code; adding brief inline comments where the swap happens (service methods and endpoint handlers) would make the versioning and the rationale for the new shapes much clearer to future readers.
## Individual Comments
### Comment 1
<location> `modules/fundamental/src/vulnerability/service/mod.rs:284-291` </location>
<code_context>
} = data;
- let mut cvss3_map: HashMap<(Uuid, String), Vec<Cvss3Base>> = HashMap::new();
+ let mut cvss3_map: HashMap<(Uuid, String), Vec<cvss3::Model>> = HashMap::new();
for score in cvss3_scores {
- let converted_score = Cvss3Base::from(score.clone());
+ let score_clone = score.clone();
cvss3_map
.entry((score.advisory_id, score.vulnerability_id))
</code_context>
<issue_to_address>
**suggestion (performance):** Avoid the extra clone when inserting into `cvss3_map`.
In this loop, `score` is already owned, so you can push it directly instead of cloning:
```rust
for score in cvss3_scores {
cvss3_map
.entry((score.advisory_id, score.vulnerability_id))
.or_default()
.push(score);
}
```
```suggestion
let mut cvss3_map: HashMap<(Uuid, String), Vec<cvss3::Model>> = HashMap::new();
for score in cvss3_scores {
cvss3_map
.entry((score.advisory_id, score.vulnerability_id))
.or_default()
.push(score);
}
```
</issue_to_address>
### Comment 2
<location> `modules/fundamental/tests/advisory/csaf/delete.rs:157-161` </location>
<code_context>
},
average_severity: Severity::Medium,
average_score: 5.3f64,
+ scores: vec![Score {
+ severity: Severity::Medium,
+ value: 5.3,
+ r#type: ScoreType::V3_1,
+ }],
status: "affected".to_string(),
context: Some(StatusContext::Cpe(
</code_context>
<issue_to_address>
**suggestion (testing):** Consider asserting that `average_score` and `average_severity` align with the single entry in `scores`.
Right now the test doesn’t check that the averages are actually derived from `scores`. To make this explicit, add assertions like `assert_eq!(status.average_score, status.scores[0].value)` and `assert_eq!(status.average_severity, status.scores[0].severity)` (or equivalent in your style) so the relationship remains guaranteed if the implementation changes.
Suggested implementation:
```rust
average_severity: Severity::Medium,
average_score: 5.3f64,
scores: vec![Score {
severity: Severity::Medium,
value: 5.3,
r#type: ScoreType::V3_1,
}],
status: "affected".to_string(),
context: Some(StatusContext::Cpe(
```
You should add explicit assertions after you have access to the `status` object in the test (for example, after something like `let status = &vuln.status[0];` or equivalent). The key is that `status` must be the value whose fields are being verified against the literal you showed.
Add the following assertions in that place:
```rust
assert_eq!(status.average_score, status.scores[0].value);
assert_eq!(status.average_severity, status.scores[0].severity);
```
If the test uses a different variable name or indexing (e.g. `let status = &advisory.status[0];` or `let status = &result[0];`), adjust `status` accordingly.
If `scores` can be empty in other scenarios, and this test doesn’t already guarantee a single entry, you may also want to guard with:
```rust
assert_eq!(status.scores.len(), 1);
```
before indexing `status.scores[0]`.
</issue_to_address>
### Comment 3
<location> `modules/fundamental/src/vulnerability/service/test.rs:571-572` </location>
<code_context>
// test empty request
- let result = service.analyze_purls(Vec::<&str>::new(), &ctx.db).await?;
+ let result = service
+ .analyze_purls_v3(Vec::<&str>::new(), &ctx.db)
+ .await?;
assert!(result.is_empty());
</code_context>
<issue_to_address>
**suggestion (testing):** Extend `analyze_purls` tests to assert presence and consistency of `scores` and averages in the v3 response.
These tests now exercise `analyze_purls_v3`, but only validate errors, warnings, and basic vulnerability presence. To cover the new v3 scoring fields, please also:
- Use at least one PURL with multiple CVSS scores.
- Assert that the corresponding `PurlStatus` entries have non-empty `scores` with the expected count and (severity/type/value).
- Check that `average_score` and `average_severity` match the aggregation of those scores.
This will better validate the full DB → service → v3 model pipeline, beyond the advisory-only tests.
Suggested implementation:
```rust
// test some unsuitable PURLs
// ... existing unsuitable PURL tests ...
// test v3 scoring fields with multiple CVSS scores
{
// PURL known in test fixtures to have multiple CVSS scores
// (adjust this to a real test fixture PURL that has >1 CVSS entry)
let purl = "pkg:cargo/example-with-multi-cvss@1.2.3";
let mut result = service
.analyze_purls_v3(vec![purl], &ctx.db)
.await
.expect("analyze_purls_v3 should succeed for valid PURL");
// result is expected to be a non-empty collection keyed by PURL
let status = result
.remove(purl)
.expect("expected status entry for requested PURL");
// scores should be present and have more than one entry
assert!(
!status.scores.is_empty(),
"expected non-empty scores for multi-CVSS PURL"
);
assert!(
status.scores.len() >= 2,
"expected at least two CVSS scores for multi-CVSS PURL"
);
// capture score values and severities for aggregation checks
let cvss_scores: Vec<f64> = status
.scores
.iter()
.map(|s| {
// ensure type and severity are populated for each score
assert!(
!s.score_type.is_empty(),
"score_type should be populated for each score"
);
assert!(
!s.severity.is_empty(),
"severity should be populated for each score"
);
s.value
})
.collect();
let expected_average =
cvss_scores.iter().sum::<f64>() / (cvss_scores.len() as f64);
// average_score must be present and equal to the mean of the scores (within tolerance)
let avg_score = status
.average_score
.expect("average_score should be populated for v3 response with scores");
let epsilon = 0.0001;
assert!(
(avg_score - expected_average).abs() < epsilon,
"average_score ({avg_score}) did not match expected mean ({expected_average})"
);
// average_severity should be present and consistent with the individual severities
let avg_severity = status
.average_severity
.clone()
.expect("average_severity should be populated for v3 response with scores");
assert!(
!avg_severity.is_empty(),
"average_severity should not be empty"
);
}
```
To integrate this with your actual codebase you will likely need to:
1. Replace the placeholder PURL `"pkg:cargo/example-with-multi-cvss@1.2.3"` with a real test fixture PURL that is known (from your seed data/fixtures) to have multiple CVSS scores.
2. Adjust field names/types on `status` and `status.scores` if they differ:
- If `scores` lives under a nested field (e.g. `status.vulnerability.scores`), update the access accordingly.
- If the score struct uses different names than `score_type`, `severity`, or `value`, update those field accesses.
- If `average_score` / `average_severity` are not `Option<...>` or have different names, adjust the `.expect(...)` and type annotations to match.
3. If `analyze_purls_v3` returns a collection not keyed directly by PURL (e.g. a `Vec<PurlStatus>`), replace the `remove(purl)` logic with appropriate lookup/filtering for the status corresponding to `purl`.
4. If your tests are organized into a specific `#[tokio::test]` or helper function, ensure this new block is placed inside the same async context where `service` and `ctx` are in scope, or refactor into its own test function using the same setup helpers as the surrounding tests.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
24547af to
0b80929
Compare
dejanb
left a comment
There was a problem hiding this comment.
This looks good to me.
@ruromero did you get a chance to give it a test?
@PhilipCattanach @ptomanRH is there a need to backport this to 0.4 branch? Are there any minor releases planned?
|
@carlosthe19916 this PR is also blocked on your "changes requested" review btw, GH won't let me merge otherwise 🙂 |
|
@Strum355 sorry for that. I approved the PR |
UI needs the full list of scores to adopt /api/v3/vulnerability/analyze endpoint, which was included in the v2 version in the AnalysisAdvisory struct
Summary by Sourcery
Expose full CVSS score lists per vulnerability in PurlStatus and update v2/v3 analysis response models accordingly.
New Features:
Enhancements:
Tests: