Fix vulnerability statistics inflation from non-default tracked branches

Summary

When vulnerabilities_across_contexts is enabled, non-default branch vulnerabilities inflate vulnerability_statistics and vulnerability_historical_statistics. This fixes all three statistics update paths to only count default-branch vulnerabilities.

Changes

1. Full recount (Statistics::AdjustmentService)

Add a LEFT JOIN to security_project_tracked_contexts in STATS_SQL with filter WHERE tracked_context_id IS NULL OR is_default = TRUE.

2. Pipeline ingestion (IngestVulnerabilityStatistics)

Switch execute and insert_values to use default_branch_severity_counts instead of severity_counts.

3. Individual state changes (StatisticsUpdateService)

Add non_default_branch_vulnerability? guard that skips statistics updates for non-default branch vulnerabilities. Legacy data (NULL tracked context) continues to update normally.

Design decisions

  • No feature flag gate — the filter is a no-op when only default branch data exists, so it's safe unconditionally.
  • Option A from historical stats analysis — scope to default branch now, implement per-branch statistics (Option B) when multi-branch tracking is fully rolled out.

Query plan

The modified STATS_SQL adds a LEFT JOIN on security_project_tracked_contexts (joined on PK id). The table has at most 2 rows per project.

Raw SQL (subquery)

SELECT
  vulnerability_reads.project_id AS project_id,
  COUNT(*) AS total,
  COUNT(*) FILTER (WHERE severity = 0) as info,
  COUNT(*) FILTER (WHERE severity = 1) as unknown,
  COUNT(*) FILTER (WHERE severity = 2) as low,
  COUNT(*) FILTER (WHERE severity = 3) as medium,
  COUNT(*) FILTER (WHERE severity = 4) as high,
  COUNT(*) FILTER (WHERE severity = 5) as critical
FROM vulnerability_reads
LEFT JOIN security_project_tracked_contexts
  ON vulnerability_reads.security_project_tracked_context_id = security_project_tracked_contexts.id
WHERE
  vulnerability_reads.project_id IN (1, 2, 3) AND
  vulnerability_reads.state IN (1, 4) AND
  (vulnerability_reads.security_project_tracked_context_id IS NULL
    OR security_project_tracked_contexts.is_default = TRUE)
GROUP BY vulnerability_reads.project_id;

Query plan

 GroupAggregate  (cost=0.30..4.39 rows=1 width=64)
   Group Key: vulnerability_reads.project_id
   ->  Nested Loop Left Join  (cost=0.30..4.34 rows=1 width=10)
         Filter: ((vulnerability_reads.security_project_tracked_context_id IS NULL) OR security_project_tracked_contexts.is_default)
         ->  Index Scan using index_vulnerability_reads_common_finder_query on vulnerability_reads  (cost=0.15..2.17 rows=1 width=18)
               Index Cond: ((project_id = ANY ('{1,2,3}'::bigint[])) AND (state = ANY ('{1,4}'::integer[])))
         ->  Index Scan using security_project_tracked_contexts_pkey on security_project_tracked_contexts  (cost=0.15..2.17 rows=1 width=9)
               Index Cond: (id = vulnerability_reads.security_project_tracked_context_id)
 Planning Time: 17.029 ms
 Execution Time: 0.050 ms

All index scans — no sequential scans. The join uses index_vulnerability_reads_common_finder_query for the (project_id, state) filter, and security_project_tracked_contexts_pkey for the LEFT JOIN.

References

Edited by Adrien Narinesingh

Merge request reports

Loading