Skip to content

Update Vulnerabilities GraphQL API filters

What does this MR do and why?

Adds date range filters support for detected_at (before | after) and created_at (before | after) for Vulnerabilities GraphQL, so it can be used in the security.py tool in agentic workflows.

It adds the dates scopes to the Vulnerability:Read models as well.

Queries:


EXPLAIN SELECT "vulnerability_reads"."id", "vulnerability_reads"."vulnerability_id", "vulnerability_reads"."project_id", "vulnerability_reads"."scanner_id", "vulnerability_reads"."report_type", "vulnerability_reads"."severity", "vulnerability_reads"."state", "vulnerability_reads"."has_issues", "vulnerability_reads"."resolved_on_default_branch", "vulnerability_reads"."uuid", "vulnerability_reads"."location_image", "vulnerability_reads"."cluster_agent_id", "vulnerability_reads"."casted_cluster_agent_id", "vulnerability_reads"."dismissal_reason", "vulnerability_reads"."has_merge_request", "vulnerability_reads"."has_remediations", "vulnerability_reads"."owasp_top_10", "vulnerability_reads"."traversal_ids", "vulnerability_reads"."archived", "vulnerability_reads"."identifier_names", "vulnerability_reads"."has_vulnerability_resolution", "vulnerability_reads"."auto_resolved" FROM "vulnerability_reads" INNER JOIN "vulnerabilities" ON "vulnerabilities"."id" = "vulnerability_reads"."vulnerability_id" WHERE "vulnerabilities"."detected_at" >= '2025-07-01 00:00:00' 

EXPLAIN SELECT "vulnerability_reads"."id", "vulnerability_reads"."vulnerability_id", "vulnerability_reads"."project_id", "vulnerability_reads"."scanner_id", "vulnerability_reads"."report_type", "vulnerability_reads"."severity", "vulnerability_reads"."state", "vulnerability_reads"."has_issues", "vulnerability_reads"."resolved_on_default_branch", "vulnerability_reads"."uuid", "vulnerability_reads"."location_image", "vulnerability_reads"."cluster_agent_id", "vulnerability_reads"."casted_cluster_agent_id", "vulnerability_reads"."dismissal_reason", "vulnerability_reads"."has_merge_request", "vulnerability_reads"."has_remediations", "vulnerability_reads"."owasp_top_10", "vulnerability_reads"."traversal_ids", "vulnerability_reads"."archived", "vulnerability_reads"."identifier_names", "vulnerability_reads"."has_vulnerability_resolution", "vulnerability_reads"."auto_resolved" FROM "vulnerability_reads" INNER JOIN "vulnerabilities" ON "vulnerabilities"."id" = "vulnerability_reads"."vulnerability_id" WHERE "vulnerabilities"."detected_at" <= '2025-07-01 00:00:00' 

EXPLAIN SELECT "vulnerability_reads"."id", "vulnerability_reads"."vulnerability_id", "vulnerability_reads"."project_id", "vulnerability_reads"."scanner_id", "vulnerability_reads"."report_type", "vulnerability_reads"."severity", "vulnerability_reads"."state", "vulnerability_reads"."has_issues", "vulnerability_reads"."resolved_on_default_branch", "vulnerability_reads"."uuid", "vulnerability_reads"."location_image", "vulnerability_reads"."cluster_agent_id", "vulnerability_reads"."casted_cluster_agent_id", "vulnerability_reads"."dismissal_reason", "vulnerability_reads"."has_merge_request", "vulnerability_reads"."has_remediations", "vulnerability_reads"."owasp_top_10", "vulnerability_reads"."traversal_ids", "vulnerability_reads"."archived", "vulnerability_reads"."identifier_names", "vulnerability_reads"."has_vulnerability_resolution", "vulnerability_reads"."auto_resolved" FROM "vulnerability_reads" INNER JOIN "vulnerabilities" ON "vulnerabilities"."id" = "vulnerability_reads"."vulnerability_id" WHERE "vulnerabilities"."created_at" >= '2025-07-01 00:00:00' 

EXPLAIN SELECT "vulnerability_reads"."id", "vulnerability_reads"."vulnerability_id", "vulnerability_reads"."project_id", "vulnerability_reads"."scanner_id", "vulnerability_reads"."report_type", "vulnerability_reads"."severity", "vulnerability_reads"."state", "vulnerability_reads"."has_issues", "vulnerability_reads"."resolved_on_default_branch", "vulnerability_reads"."uuid", "vulnerability_reads"."location_image", "vulnerability_reads"."cluster_agent_id", "vulnerability_reads"."casted_cluster_agent_id", "vulnerability_reads"."dismissal_reason", "vulnerability_reads"."has_merge_request", "vulnerability_reads"."has_remediations", "vulnerability_reads"."owasp_top_10", "vulnerability_reads"."traversal_ids", "vulnerability_reads"."archived", "vulnerability_reads"."identifier_names", "vulnerability_reads"."has_vulnerability_resolution", "vulnerability_reads"."auto_resolved" FROM "vulnerability_reads" INNER JOIN "vulnerabilities" ON "vulnerabilities"."id" = "vulnerability_reads"."vulnerability_id" WHERE "vulnerabilities"."created_at" <= '2025-07-01 00:00:00' 
Sample Query Plan

[
  {
    "Plan": {
      "Node Type": "Hash Join",
      "Parallel Aware": false,
      "Async Capable": false,
      "Join Type": "Inner",
      "Startup Cost": 34.86,
      "Total Cost": 88.54,
      "Plan Rows": 838,
      "Plan Width": 151,
      "Actual Startup Time": 0.140,
      "Actual Total Time": 0.459,
      "Actual Rows": 838,
      "Actual Loops": 1,
      "Output": ["vulnerability_reads.id", "vulnerability_reads.vulnerability_id", "vulnerability_reads.project_id", "vulnerability_reads.scanner_id", "vulnerability_reads.report_type", "vulnerability_reads.severity", "vulnerability_reads.state", "vulnerability_reads.has_issues", "vulnerability_reads.resolved_on_default_branch", "vulnerability_reads.uuid", "vulnerability_reads.location_image", "vulnerability_reads.cluster_agent_id", "vulnerability_reads.casted_cluster_agent_id", "vulnerability_reads.dismissal_reason", "vulnerability_reads.has_merge_request", "vulnerability_reads.has_remediations", "vulnerability_reads.owasp_top_10", "vulnerability_reads.traversal_ids", "vulnerability_reads.archived", "vulnerability_reads.identifier_names", "vulnerability_reads.has_vulnerability_resolution", "vulnerability_reads.auto_resolved"],
      "Inner Unique": true,
      "Hash Cond": "(vulnerabilities.id = vulnerability_reads.vulnerability_id)",
      "Shared Hit Blocks": 57,
      "Shared Read Blocks": 0,
      "Shared Dirtied Blocks": 0,
      "Shared Written Blocks": 0,
      "Local Hit Blocks": 0,
      "Local Read Blocks": 0,
      "Local Dirtied Blocks": 0,
      "Local Written Blocks": 0,
      "Temp Read Blocks": 0,
      "Temp Written Blocks": 0,
      "Plans": [
        {
          "Node Type": "Seq Scan",
          "Parent Relationship": "Outer",
          "Parallel Aware": false,
          "Async Capable": false,
          "Relation Name": "vulnerabilities",
          "Schema": "public",
          "Alias": "vulnerabilities",
          "Startup Cost": 0.00,
          "Total Cost": 51.48,
          "Plan Rows": 838,
          "Plan Width": 8,
          "Actual Startup Time": 0.004,
          "Actual Total Time": 0.204,
          "Actual Rows": 838,
          "Actual Loops": 1,
          "Output": ["vulnerabilities.id", "vulnerabilities.project_id", "vulnerabilities.author_id", "vulnerabilities.created_at", "vulnerabilities.updated_at", "vulnerabilities.title", "vulnerabilities.title_html", "vulnerabilities.description", "vulnerabilities.description_html", "vulnerabilities.state", "vulnerabilities.severity", "vulnerabilities.severity_overridden", "vulnerabilities.resolved_by_id", "vulnerabilities.resolved_at", "vulnerabilities.report_type", "vulnerabilities.cached_markdown_version", "vulnerabilities.confirmed_by_id", "vulnerabilities.confirmed_at", "vulnerabilities.dismissed_at", "vulnerabilities.dismissed_by_id", "vulnerabilities.resolved_on_default_branch", "vulnerabilities.present_on_default_branch", "vulnerabilities.detected_at", "vulnerabilities.finding_id", "vulnerabilities.cvss", "vulnerabilities.auto_resolved"],
          "Filter": "(vulnerabilities.detected_at >= '2025-07-01 00:00:00+00'::timestamp with time zone)",
          "Rows Removed by Filter": 0,
          "Shared Hit Blocks": 41,
          "Shared Read Blocks": 0,
          "Shared Dirtied Blocks": 0,
          "Shared Written Blocks": 0,
          "Local Hit Blocks": 0,
          "Local Read Blocks": 0,
          "Local Dirtied Blocks": 0,
          "Local Written Blocks": 0,
          "Temp Read Blocks": 0,
          "Temp Written Blocks": 0
        },
        {
          "Node Type": "Hash",
          "Parent Relationship": "Inner",
          "Parallel Aware": false,
          "Async Capable": false,
          "Startup Cost": 24.38,
          "Total Cost": 24.38,
          "Plan Rows": 838,
          "Plan Width": 151,
          "Actual Startup Time": 0.128,
          "Actual Total Time": 0.128,
          "Actual Rows": 838,
          "Actual Loops": 1,
          "Output": ["vulnerability_reads.id", "vulnerability_reads.vulnerability_id", "vulnerability_reads.project_id", "vulnerability_reads.scanner_id", "vulnerability_reads.report_type", "vulnerability_reads.severity", "vulnerability_reads.state", "vulnerability_reads.has_issues", "vulnerability_reads.resolved_on_default_branch", "vulnerability_reads.uuid", "vulnerability_reads.location_image", "vulnerability_reads.cluster_agent_id", "vulnerability_reads.casted_cluster_agent_id", "vulnerability_reads.dismissal_reason", "vulnerability_reads.has_merge_request", "vulnerability_reads.has_remediations", "vulnerability_reads.owasp_top_10", "vulnerability_reads.traversal_ids", "vulnerability_reads.archived", "vulnerability_reads.identifier_names", "vulnerability_reads.has_vulnerability_resolution", "vulnerability_reads.auto_resolved"],
          "Hash Buckets": 1024,
          "Original Hash Buckets": 1024,
          "Hash Batches": 1,
          "Original Hash Batches": 1,
          "Peak Memory Usage": 114,
          "Shared Hit Blocks": 16,
          "Shared Read Blocks": 0,
          "Shared Dirtied Blocks": 0,
          "Shared Written Blocks": 0,
          "Local Hit Blocks": 0,
          "Local Read Blocks": 0,
          "Local Dirtied Blocks": 0,
          "Local Written Blocks": 0,
          "Temp Read Blocks": 0,
          "Temp Written Blocks": 0,
          "Plans": [
            {
              "Node Type": "Seq Scan",
              "Parent Relationship": "Outer",
              "Parallel Aware": false,
              "Async Capable": false,
              "Relation Name": "vulnerability_reads",
              "Schema": "public",
              "Alias": "vulnerability_reads",
              "Startup Cost": 0.00,
              "Total Cost": 24.38,
              "Plan Rows": 838,
              "Plan Width": 151,
              "Actual Startup Time": 0.002,
              "Actual Total Time": 0.081,
              "Actual Rows": 838,
              "Actual Loops": 1,
              "Output": ["vulnerability_reads.id", "vulnerability_reads.vulnerability_id", "vulnerability_reads.project_id", "vulnerability_reads.scanner_id", "vulnerability_reads.report_type", "vulnerability_reads.severity", "vulnerability_reads.state", "vulnerability_reads.has_issues", "vulnerability_reads.resolved_on_default_branch", "vulnerability_reads.uuid", "vulnerability_reads.location_image", "vulnerability_reads.cluster_agent_id", "vulnerability_reads.casted_cluster_agent_id", "vulnerability_reads.dismissal_reason", "vulnerability_reads.has_merge_request", "vulnerability_reads.has_remediations", "vulnerability_reads.owasp_top_10", "vulnerability_reads.traversal_ids", "vulnerability_reads.archived", "vulnerability_reads.identifier_names", "vulnerability_reads.has_vulnerability_resolution", "vulnerability_reads.auto_resolved"],
              "Shared Hit Blocks": 16,
              "Shared Read Blocks": 0,
              "Shared Dirtied Blocks": 0,
              "Shared Written Blocks": 0,
              "Local Hit Blocks": 0,
              "Local Read Blocks": 0,
              "Local Dirtied Blocks": 0,
              "Local Written Blocks": 0,
              "Temp Read Blocks": 0,
              "Temp Written Blocks": 0
            }
          ]
        }
      ]
    },
    "Planning": {
      "Shared Hit Blocks": 16,
      "Shared Read Blocks": 0,
      "Shared Dirtied Blocks": 0,
      "Shared Written Blocks": 0,
      "Local Hit Blocks": 0,
      "Local Read Blocks": 0,
      "Local Dirtied Blocks": 0,
      "Local Written Blocks": 0,
      "Temp Read Blocks": 0,
      "Temp Written Blocks": 0
    },
    "Planning Time": 0.220,
    "Triggers": [
    ],
    "Execution Time": 0.489
  }
]

References

How to set up and validate locally

  • Seed vulnerabilities by using a script or if there's a vulnerability seeder:

bin/rake 'gitlab:seed:vulnerabilities[group/project]'

script using factories/in rails console

def cleanup_corrupted_vulnerabilities(project)
  puts "Checking for corrupted vulnerabilities..."
  
  corrupted_count = 0
  project.vulnerabilities.includes(:findings).each do |vulnerability|
    if vulnerability.findings.any? { |finding| finding.scanner.nil? }
      puts "  Removing corrupted vulnerability ID #{vulnerability.id}"
      vulnerability.destroy
      corrupted_count += 1
    end
  end
  
  if corrupted_count > 0
    puts "  Removed #{corrupted_count} corrupted vulnerabilities"
  else
    puts "  No corrupted vulnerabilities found"
  end
end

def get_or_create_test_user(project)
  @test_user ||= User.find_by(email: 'vulnerability-test@example.com') || 
                 FactoryBot.create(:user, 
                   email: 'vulnerability-test@example.com',
                   username: "vuln-test-#{SecureRandom.hex(4)}",
                   name: 'Vulnerability Test User'
                 )
end

def create_finding_for_type(report_type, project, date)
  timestamp = Time.current.to_f
  random_suffix = SecureRandom.hex(8)
  
  scanner = FactoryBot.create(:vulnerabilities_scanner, 
    project: project,
    external_id: "#{report_type}_scanner_#{timestamp}_#{random_suffix}",
    name: "#{report_type.humanize} Scanner"
  )
  
  case report_type
  when 'secret_detection'
    # Secret detection trait creates its own identifiers, so we don't pre-create primary_identifier
    primary_identifier = FactoryBot.create(:vulnerabilities_identifier, 
      project: project,
      external_type: 'gitleaks_rule_id',
      external_id: "gitleaks-#{timestamp}-#{random_suffix}",
      name: "Gitleaks rule ID #{random_suffix}",
      fingerprint: SecureRandom.hex(20)
    )
    
    FactoryBot.create(:vulnerabilities_finding,
      project: project,
      scanner: scanner,
      primary_identifier: primary_identifier,
      report_type: report_type,
      name: "Secret detected",
      severity: :critical,
      created_at: date,
      updated_at: date
    )
  when 'dependency_scanning' 
    primary_identifier = FactoryBot.create(:vulnerabilities_identifier, 
      project: project,
      external_id: "#{report_type}-#{timestamp}-#{random_suffix}",
      name: "#{report_type.upcase}-#{random_suffix}"
    )
    
    FactoryBot.create(:vulnerabilities_finding, :with_dependency_scanning_metadata,
      project: project,
      scanner: scanner,
      primary_identifier: primary_identifier,
      report_type: report_type,
      created_at: date,
      updated_at: date
    )
  when 'container_scanning'
    primary_identifier = FactoryBot.create(:vulnerabilities_identifier, 
      project: project,
      external_id: "#{report_type}-#{timestamp}-#{random_suffix}",
      name: "#{report_type.upcase}-#{random_suffix}"
    )
    
    FactoryBot.create(:vulnerabilities_finding, :with_container_scanning_metadata,
      project: project,
      scanner: scanner,
      primary_identifier: primary_identifier,
      report_type: report_type,
      created_at: date,
      updated_at: date
    )
  when 'cluster_image_scanning'
    primary_identifier = FactoryBot.create(:vulnerabilities_identifier, 
      project: project,
      external_id: "#{report_type}-#{timestamp}-#{random_suffix}",
      name: "#{report_type.upcase}-#{random_suffix}"
    )
    
    FactoryBot.create(:vulnerabilities_finding, :with_cluster_image_scanning_scanning_metadata,
      project: project,
      scanner: scanner,
      primary_identifier: primary_identifier,
      report_type: report_type,
      created_at: date,
      updated_at: date
    )
  else
    primary_identifier = FactoryBot.create(:vulnerabilities_identifier, 
      project: project,
      external_id: "#{report_type}-#{timestamp}-#{random_suffix}",
      name: "#{report_type.upcase}-#{random_suffix}"
    )
    
    FactoryBot.create(:vulnerabilities_finding,
      project: project,
      scanner: scanner,
      primary_identifier: primary_identifier,
      report_type: report_type,
      created_at: date,
      updated_at: date
    )
  end
end

def create_vulnerability_statistics(project)
  existing_stat = Vulnerabilities::Statistic.find_by(project: project)
  if existing_stat
    puts "  Updating existing vulnerability statistic"
    existing_stat.touch
  else
    puts "  Creating new vulnerability statistic"
    FactoryBot.create(:vulnerability_statistic, project: project)
  end
  
  puts "  Creating historical statistics..."
  3.times do |i|
    date = i.months.ago.end_of_month
    
    existing_historical = Vulnerabilities::HistoricalStatistic.find_by(project: project, date: date)
    if existing_historical
      puts "    Skipping historical stat for #{date} (already exists)"
      next
    end
    
    critical_count = project.vulnerabilities.where(severity: 'critical').count
    high_count = project.vulnerabilities.where(severity: 'high').count
    medium_count = project.vulnerabilities.where(severity: 'medium').count
    low_count = project.vulnerabilities.where(severity: 'low').count
    
    FactoryBot.create(:vulnerability_historical_statistic,
      project: project,
      date: date,
      total: critical_count + high_count + medium_count + low_count,
      critical: critical_count,
      high: high_count,
      medium: medium_count,
      low: low_count
    )
    puts "    Created historical stat for #{date}"
  end
end

def verify_vulnerabilities(project)
  issues = []
  
  project.vulnerabilities.includes(:findings).each do |vulnerability|
    if vulnerability.findings.empty?
      issues << "Vulnerability #{vulnerability.id} has no findings"
    end
    
    vulnerability.findings.each do |finding|
      if finding.scanner.nil?
        issues << "Finding #{finding.id} has no scanner"
      elsif finding.scanner.project_id != project.id
        issues << "Finding #{finding.id} scanner belongs to wrong project"
      end
      
      if finding.primary_identifier.nil?
        issues << "Finding #{finding.id} has no primary identifier"
      end
    end
  end
  
  if issues.any?
    puts "  ⚠️  Found #{issues.count} data integrity issues:"
    issues.each { |issue| puts "    - #{issue}" }
  else
    puts "  ✓ All vulnerabilities have proper associations"
  end
end

def create_vulnerabilities_for_project!
  project = Project.find_by_full_path('gitlab-duo/test')
  
  unless project
    puts "Project 'gitlab-duo/test' not found!"
    return
  end
  
  puts "Cleaning up existing vulnerabilities for project: #{project.full_path}"
  cleanup_corrupted_vulnerabilities(project)
  
  puts "Cleaning up existing statistics..."
  Vulnerabilities::HistoricalStatistic.where(project: project).destroy_all
  Vulnerabilities::Statistic.where(project: project).destroy_all
  
  puts "Cleaning up existing scanners and identifiers..."
  Vulnerabilities::Scanner.where(project: project).destroy_all
  Vulnerabilities::Identifier.where(project: project).destroy_all
  
  project.vulnerabilities.destroy_all
  
  puts "Creating vulnerabilities for project: #{project.full_path}"
  
  test_user = get_or_create_test_user(project)
  
  report_types = %w[sast dast dependency_scanning container_scanning secret_detection 
                   cluster_image_scanning coverage_fuzzing api_fuzzing generic]
  
  states = %w[detected confirmed dismissed resolved]
  
  report_types.each do |report_type|
    puts "Creating #{report_type} vulnerabilities..."
    
    rand(2..4).times do
      state = states.sample
      random_date = rand(1.month.ago..Time.current)
      severity = %w[critical high medium low info].sample
      
      begin
        finding = create_finding_for_type(report_type, project, random_date)
        
        vulnerability = FactoryBot.create(:vulnerability, state.to_sym,
          project: project,
          author: test_user,
          vulnerability_finding: finding,
          findings: [finding],
          report_type: report_type,
          severity: severity,
          created_at: random_date,
          updated_at: random_date,
          detected_at: random_date
        )
        
        case state
        when 'confirmed'
          vulnerability.update!(confirmed_at: random_date + rand(1..7).days, confirmed_by: test_user)
        when 'dismissed'
          vulnerability.update!(dismissed_at: random_date + rand(1..7).days, dismissed_by: test_user)
        when 'resolved'
          vulnerability.update!(resolved_at: random_date + rand(1..7).days, resolved_by: test_user)
        end
        
        puts "  ✓ Created #{state} #{report_type} vulnerability (ID: #{vulnerability.id}) - #{severity} severity"
        
      rescue => e
        puts "  ✗ Failed to create #{report_type} vulnerability: #{e.message}"
        puts "    #{e.backtrace.first}" if e.backtrace
      end
    end
  end
  
  project.project_setting.update!(has_vulnerabilities: true)
  create_vulnerability_statistics(project)
  
  puts "\n🔍 Verifying data integrity..."
  verify_vulnerabilities(project)
  
  puts "\n✅ Vulnerability creation completed!"
  puts "Total vulnerabilities created: #{project.vulnerabilities.count}"
  puts "Vulnerabilities by state:"
  project.vulnerabilities.group(:state).count.each do |state, count|
    puts "  #{state}: #{count}"
  end
  puts "Vulnerabilities by report type:"
  project.vulnerabilities.group(:report_type).count.each do |type, count|
    puts "  #{type}: #{count}"
  end
end

create_vulnerabilities_for_project!
:
  • Use graphql API with detectedAfter, detectedBefore, createdAfter, createdBefore:

GraphQL API:


query {
  project(fullPath: "group/project") {
    vulnerabilities(
      first: 10
      detectedAfter: "2024-02-01T00:00:00Z"
    ) {
      pageInfo {
        hasNextPage
        endCursor
      }
      nodes {
        id
        title
        reportType
        severity
        state
        detectedAt
      }
    }
  }
}

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

Edited by Andrew Jung

Merge request reports

Loading