DependencyVulnerabilitiesResolver returns incomplete vulnerabilities for group-level aggregated dependencies

Everyone can contribute. Help move this issue forward while earning points, leveling up and collecting rewards.

Summary

DependencyVulnerabilitiesResolver uses object.id to batch-load vulnerabilities for each dependency. For group-level aggregated dependencies, object.id is MIN(id) from AggregationsFinder#execute, so only vulnerabilities linked to one representative occurrence are returned — not all vulnerabilities across the group for that component.

This means the vulnerabilities field on DependencyAggregationType may show an incomplete list of vulnerabilities at group level.

Affected surface

Surface Level Vulnerabilities list exposed? Uses MIN(id)? Affected?
GraphQL vulnerabilities field Project Yes No (real ID) No
GraphQL vulnerabilities field Group Yes (DependencyVulnerabilitiesResolver) Yes (MIN(id)) Yes
REST API vulnerabilities array Project Yes (include_vulnerabilities) No (real ID) No
REST API vulnerabilities array Group No (not rendered) N/A No
Grape API vulnerabilities array Project Yes No (real ID) No
Grape API vulnerabilities array Group N/A (no group endpoint) N/A No

Only the group-level GraphQL vulnerabilities field is affected. All other surfaces either use real occurrence IDs (project level) or don't expose the vulnerabilities list (group REST API).

Root cause

DependencyVulnerabilitiesResolver (line 16) batches by object.id:

BatchLoader::GraphQL.for(object.id).batch do |ids, loader|
  occurrence_vulnerabilities = ::Sbom::OccurrencesVulnerability
    .for_occurrence_ids(ids)
    ...
end

For aggregated dependencies, object.id is MIN(outer_occurrences.id)::bigint AS id from AggregationsFinder#execute. This is a single representative occurrence — not all occurrences of that component across the group.

Proposed fix

When the parent object is an aggregated dependency, the resolver should batch by component_version_id instead of id, querying across all occurrences in the group's namespace:

def resolve(**_args)
  return [] unless object

  if aggregated_dependency?
    resolve_for_aggregation
  else
    resolve_for_occurrence
  end
end

def resolve_for_aggregation
  BatchLoader::GraphQL.for(object.component_version_id).batch do |cv_ids, loader|
    # Load all distinct vulnerabilities across the group for these component versions
    ...
  end
end

Performance considerations

Production data shows the vulnerability count per component at group level:

p50 p90 p95 p99 Max
3 21 42 195 399,399

The max (399K) is an extreme outlier (linux-libc-dev across 32 projects). The fix should include pagination or a cap to avoid loading excessive data.

Edited by 🤖 GitLab Bot 🤖