Skip to content

feat: include all scores in PurlStatus and deprecate average_severity/average_score#2166

Merged
Strum355 merged 1 commit intoguacsec:mainfrom
Strum355:nsc/v3-analyze-scores
Jan 29, 2026
Merged

feat: include all scores in PurlStatus and deprecate average_severity/average_score#2166
Strum355 merged 1 commit intoguacsec:mainfrom
Strum355:nsc/v3-analyze-scores

Conversation

@Strum355
Copy link
Copy Markdown
Member

@Strum355 Strum355 commented Dec 4, 2025

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:

  • Add a scores list to PurlStatus and propagate it through the v3 /vulnerability/analyze API so callers receive all CVSS scores.
  • Introduce dedicated v3 analysis response types that return purl-centric status details without flattening vulnerability heads.

Enhancements:

  • Swap v2 and v3 analysis response model wiring so v2 retains the original head-based structure while v3 uses the new PurlStatus-only details.
  • Adjust vulnerability service helpers and endpoints to use the new v3 analysis types and scoring aggregation logic.

Tests:

  • Extend advisory and vulnerability tests to cover the new scores field on PurlStatus and the updated v3 analysis response shape.

@Strum355 Strum355 requested a review from dejanb December 4, 2025 16:46
@sourcery-ai
Copy link
Copy Markdown
Contributor

sourcery-ai Bot commented Dec 4, 2025

Reviewer's Guide

Expose 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 scores

sequenceDiagram
    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
Loading

Updated class diagram for analysis models and PurlStatus with scores

classDiagram
    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
Loading

File-Level Changes

Change Details Files
Introduce a v3 analysis response model that returns PurlStatus collections (including full CVSS scores) and rewire the service layer to distinguish v2 vs v3 processing.
  • Rename analyze_purls to analyze_purls_v3 returning AnalysisResponseV3, and add format_response_v3 plus row_to_vuln_v3 to build v3-style results
  • Change v2 formatting helpers to use AnalysisResponse/AnalysisResult/AnalysisDetails from the v2 module while v3 uses new AnalysisResponseV3/AnalysisResultV3/AnalysisDetailsV3 types
  • Stop aggregating CVSS scores into a single CvssScore before building PurlStatus; instead, keep Veccvss3::Model per advisory/vulnerability and pass those to PurlStatus::from_head_v2
modules/fundamental/src/vulnerability/service/mod.rs
modules/fundamental/src/vulnerability/model/analyze.rs
modules/fundamental/src/vulnerability/model/v2.rs
modules/fundamental/src/vulnerability/service/test.rs
modules/fundamental/tests/vuln/mod.rs
Extend PurlStatus to carry all raw CVSS scores while still computing and exposing average severity/score.
  • Add a scores: Vec field to PurlStatus and document it as all CVSS scores associated with the vulnerability
  • Update PurlStatus::from_vuln to compute Cvss3Score from related cvss3 records, derive average severity/score from that, and map all CVSS models to Score via TryFrom, collecting them into scores
  • Update PurlStatus::from_head_v2 to accept a slice of cvss3::Model instead of a pre-aggregated Score, compute the average via Cvss3Score::from_iter, and build the scores Vec similarly
modules/fundamental/src/purl/model/details/purl.rs
Update HTTP endpoints, OpenAPI schema, and tests to reflect the new v3 response and the added scores field on PurlStatus.
  • Swap v2/v3 response schema references in openapi.yaml so /api/v2/vulnerability/analyze uses AnalysisResponse (v2 model) and /api/v3/vulnerability/analyze uses AnalysisResponseV3 (PurlStatus-based model) and add AnalysisDetailsV3/AnalysisResultV3/AnalysisResponseV3 schemas
  • Adjust utoipa annotations and handlers so v2 analyze returns v2::AnalysisResponse and v3 analyze returns AnalysisResponseV3 using analyze_purls_v3
  • Update integration tests (CSAF/OSV reingest, delete, and vulnerability tests) to assert the new scores field is present and correctly populated in PurlStatus instances and to follow the new v3 result structure when traversing entries
openapi.yaml
modules/fundamental/src/vulnerability/endpoints/mod.rs
modules/fundamental/tests/advisory/csaf/reingest.rs
modules/fundamental/tests/advisory/csaf/delete.rs
modules/fundamental/tests/advisory/osv/reingest.rs
modules/fundamental/tests/vuln/mod.rs

Possibly linked issues

  • #(unassigned): ADR-4 demands all scores; this PR adds a scores array to PurlStatus and v3 analysis responses.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

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

Hey there - I've reviewed your changes - here's some feedback:

  • 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_froms them; consider adding a TryFrom<&cvss3::Model> implementation for Score so 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>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +340 to +344
let all_scores = cvss3
.iter()
.cloned()
.filter_map(|cvss3| Score::try_from(cvss3).ok())
.collect();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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
Copy link
Copy Markdown

codecov Bot commented Dec 4, 2025

Codecov Report

❌ Patch coverage is 94.87179% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.75%. Comparing base (a93901a) to head (0b80929).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
modules/fundamental/src/purl/model/details/purl.rs 95.00% 0 Missing and 1 partial ⚠️
...les/fundamental/src/vulnerability/endpoints/mod.rs 0.00% 1 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread modules/fundamental/src/purl/model/details/purl.rs Outdated
Comment thread modules/fundamental/src/purl/model/details/purl.rs Outdated
@Strum355 Strum355 force-pushed the nsc/v3-analyze-scores branch from d32a3d9 to ff91aeb Compare December 5, 2025 11:53
@Strum355 Strum355 force-pushed the nsc/v3-analyze-scores branch from ff91aeb to d5b0d65 Compare December 15, 2025 12:32
Copy link
Copy Markdown
Contributor

@carlosthe19916 carlosthe19916 left a comment

Choose a reason for hiding this comment

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

@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?

@Strum355
Copy link
Copy Markdown
Member Author

Strum355 commented Jan 2, 2026

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

pub struct AnalysisDetails {
#[serde(flatten)]
pub head: VulnerabilityHead,
, this does mean that each entry in purl_status will have the same values for vulnerability

@carlosthe19916
Copy link
Copy Markdown
Contributor

this does mean that each entry in purl_status will have the same values for 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 :)

@Strum355 Strum355 force-pushed the nsc/v3-analyze-scores branch from d5b0d65 to 24547af Compare January 5, 2026 13:01
@Strum355
Copy link
Copy Markdown
Member Author

Strum355 commented Jan 5, 2026

@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 :)

@ctron @dejanb I opted to remove it from the top level AnalysisDetails, lmk if you feel strongly otherwise

@PhilipCattanach
Copy link
Copy Markdown

@ctron @dejanb - Can you respond to @Strum355 please so we can progress this PR?
Thanks,
Phil

@dejanb
Copy link
Copy Markdown
Contributor

dejanb commented Jan 20, 2026

@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 :)

@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.

@dejanb
Copy link
Copy Markdown
Contributor

dejanb commented Jan 20, 2026

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.

@dejanb
Copy link
Copy Markdown
Contributor

dejanb commented Jan 21, 2026

Here are some comments and this we should do next:

  • the changes to /v2/purl/ endpoint are additive so I don't think we need to bump a version to v3, so the current approach looks OK
  • Before approving, I would look into sourcery suggestion about cloning and error handling
  • Also, there are no any tests that actually trying to verify if the scores are returned as expected

For the future steps:

  • We need to deprecate average_score and average_severity. It can be don in this or the follow-up PR
  • We need to look into using new database schema introduced with docs: ADR for re-processing of documents #1913
  • We need to see if further changes to PurlStatus are OK to introduce now, like returning whole vectors as well

@Strum355
Copy link
Copy Markdown
Member Author

@sourcery-ai review

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 3 issues, and left some high level feedback:

  • 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., AnalysisResponseAnalysisResponseV3, row_to_vulnrow_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>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread modules/fundamental/src/vulnerability/service/mod.rs
Comment thread modules/fundamental/tests/advisory/csaf/delete.rs
Comment thread modules/fundamental/src/vulnerability/service/test.rs
@Strum355 Strum355 force-pushed the nsc/v3-analyze-scores branch from 24547af to 0b80929 Compare January 28, 2026 16:57
@Strum355 Strum355 changed the title feat: include all scores in PurlStatus feat: include all scores in PurlStatus and deprecate average_severity/average_score Jan 28, 2026
Copy link
Copy Markdown
Contributor

@dejanb dejanb left a comment

Choose a reason for hiding this comment

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

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?

@Strum355
Copy link
Copy Markdown
Member Author

@carlosthe19916 this PR is also blocked on your "changes requested" review btw, GH won't let me merge otherwise 🙂

@carlosthe19916 carlosthe19916 requested review from carlosthe19916 and removed request for carlosthe19916 January 29, 2026 11:53
@carlosthe19916
Copy link
Copy Markdown
Contributor

@Strum355 sorry for that. I approved the PR

@ruromero
Copy link
Copy Markdown
Contributor

@dejanb @Strum355 changes look good to me

@Strum355 Strum355 added this pull request to the merge queue Jan 29, 2026
Merged via the queue into guacsec:main with commit 015cb9e Jan 29, 2026
8 checks passed
@Strum355 Strum355 deleted the nsc/v3-analyze-scores branch January 29, 2026 15:47
@github-project-automation github-project-automation Bot moved this to Done in Trustify Jan 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

7 participants