Add malware filter to vulnerability GraphQL APIs

Summary

Part of gitlab-org/gitlab#587647 — Phase 1 backend requirement for the Malicious Package UI Representation and Filters epic.

Adds a malware: Boolean filter argument to:

  • project.vulnerabilities GraphQL query
  • group.vulnerabilities GraphQL query
  • vulnerabilitySeveritiesCount GraphQL query

The filter uses Elasticsearch to query the identifier_names field, matching vulnerabilities with GLAM-* prefix identifiers.

Depends on: !228811 (merged) (merged) — introduces the MalwareDetection concern with sscs_addon_active_for? and the MALWARE_PACKAGE_IDENTIFIER_PREFIX constant.

Filter Behavior

Parameter Value Expected Result
true Returns only malware vulnerabilities (GLAM-* identifier present)
false Returns only non-malware vulnerabilities
null/omitted Returns all vulnerabilities (current behavior)

Approach

Gating

The filter requires both conditions to be met:

  1. SSCS add-on active — checked via Vulnerability.sscs_addon_active_for?(vulnerable) from the MalwareDetection concern, which delegates to vulnerable.sscs_malware_detection_feature_flag_enabled? (WIP feature flag). Accepts both Project and Group.

  2. Filter feature flag enabled — at least one of:

    • malicious_vulnerability_filter_group (scoped to group) — checked when vulnerable is a Group
    • malicious_vulnerability_filter_project (scoped to project) — checked when vulnerable is a Project

ES Query

VulnerabilityFilters.by_malware builds an ES prefix query against identifier_names.keyword using the downcased MALWARE_PACKAGE_IDENTIFIER_PREFIX constant (glam-) as the single source of truth for GLAM identifier detection.

  • malware: truemust: [prefix query]
  • malware: falsemust_not: [prefix query]

Integration with existing infrastructure

The filter is wired into the existing advanced vulnerability management ES pipeline:

  1. VulnerabilitiesResolver / VulnerabilitySeveritiesCountResolver — new malware argument
  2. VulnerabilityFilterable — added to ADVANCED_FILTERS, validated by validate_malware!
  3. BaseFinder#initialize_search_params — passes malware param to ES
  4. VulnerabilityQueryBuilder — includes :by_malware in QUERY_COMPONENTS
  5. VulnerabilityFilters.by_malware — builds the ES query

Elasticsearch

ES query details

The by_malware filter generates an ES query against the identifier_names.keyword field. The identifier_names field is a denormalized array on the vulnerability ES document, indexed from vulnerability_reads.identifier_names at ingestion time. The glam- prefix is derived from Vulnerabilities::MalwareDetection::MALWARE_PACKAGE_IDENTIFIER_PREFIX.downcase.

When malware: true — return only malware vulnerabilities

A prefix query is added to the filter array, matching documents where any identifier_names entry starts with glam-:

{
  "query": {
    "bool": {
      "filter": [
        { "prefix": { "traversal_ids": { "value": "95-" } } },
        {
          "bool": {
            "_name": "filters:malware",
            "must": [
              {
                "prefix": {
                  "identifier_names.keyword": {
                    "value": "glam-"
                  }
                }
              }
            ]
          }
        }
      ]
    }
  }
}

When malware: false — return non-malware vulnerabilities

The GLAM prefix exclusion is added to the outer must_not of the query bool (not nested in a sub-bool inside filter). This ensures ES correctly returns all documents that don't match the GLAM prefix — including those where identifier_names is empty or absent:

{
  "query": {
    "bool": {
      "filter": [
        { "prefix": { "traversal_ids": { "value": "95-" } } }
      ],
      "must_not": [
        {
          "prefix": {
            "identifier_names.keyword": {
              "value": "glam-"
            }
          }
        }
      ]
    }
  }
}

Key design decision: For malware: false, the exclusion is placed in must_not at the outer bool level rather than as a nested bool inside filter. A bool with only must_not inside filter does not reliably match documents where the field is absent, leading to incorrect counts. The outer must_not approach ensures true + false = total for all severity levels.

No new ES index migrations are required — the identifier_names field is already indexed and available.

Feature Flags

Feature flag details
Flag Type Scope Purpose
sscs_malware_detection WIP group Gates add-on availability (from !228811 (merged))
malicious_vulnerability_filter_group WIP group Gates filter rollout at group level
malicious_vulnerability_filter_project WIP project Gates filter rollout at project level

Local testing

Setup steps, queries, and verification

Prerequisites

  • Elasticsearch must be running and configured in your GDK
  • Advanced vulnerability management must be enabled

Setup

  1. Import the project https://gitlab.com/gitlab-org/govern/threat-insights-demos/verification-projects/bala-test-group/test-malicious-dependency-badge into your GDK. This project stubs the dependency scanner report with GLAM identifier vulnerabilities.
  2. After import, run a pipeline on the project to populate vulnerabilities and dependencies.
  3. All three feature flags (sscs_malware_detection, malicious_vulnerability_filter_group, malicious_vulnerability_filter_project) are WIP type and enabled by default. If any were previously disabled, re-enable:
    # In rails console
    Feature.enable(:sscs_malware_detection)
    Feature.enable(:malicious_vulnerability_filter_group)
    Feature.enable(:malicious_vulnerability_filter_project)
  4. Ensure vulnerabilities are indexed in Elasticsearch:
    # In rails console
    project = Project.find_by_full_path('bala-test-group/test-malicious-dependency-badge')
    project.vulnerabilities.find_each do |v|
      ElasticAssociationIndexerWorker.perform_async(v.class.name, v.id, ['search_index_vulnerability_reads'])
    end
  5. Wait for indexing to complete, then verify:
    Vulnerability.__elasticsearch__.refresh_index!

Vulnerabilities GraphQL — project level

Query: malware: true
{
  project(fullPath: "bala-test-group/test-malicious-dependency-badge") {
    vulnerabilities(malware: true) {
      nodes {
        id
        title
        severity
        malware
      }
    }
  }
}
Query: malware: false
{
  project(fullPath: "bala-test-group/test-malicious-dependency-badge") {
    vulnerabilities(malware: false) {
      nodes {
        id
        title
        severity
        malware
      }
    }
  }
}
Filter Value Expected Result Screenshot
malware: true Only vulnerabilities with GLAM-* identifiers
malware: false Only vulnerabilities without GLAM-* identifiers
omitted All vulnerabilities

Vulnerabilities GraphQL — group level

Query: malware: true
{
  group(fullPath: "bala-test-group") {
    vulnerabilities(malware: true) {
      nodes {
        id
        title
        severity
        malware
      }
    }
  }
}
Filter Value Expected Result Screenshot
malware: true Only vulnerabilities with GLAM-* identifiers
malware: false Only vulnerabilities without GLAM-* identifiers

Severity counts with malware filter — project level

Query
{
  project(fullPath: "bala-test-group/test-malicious-dependency-badge") {
    vulnerabilitySeveritiesCount(malware: true) {
      critical
      high
      medium
      low
    }
  }
}
Filter Value Expected Result Screenshot
malware: true Severity counts for malware vulnerabilities only
malware: false Severity counts excluding malware vulnerabilities

Severity counts with malware filter — group level

Query
{
  group(fullPath: "bala-test-group") {
    vulnerabilitySeveritiesCount(malware: true) {
      critical
      high
      medium
      low
    }
  }
}
Filter Value Expected Result Screenshot
malware: true Severity counts for malware vulnerabilities only
malware: false Severity counts excluding malware vulnerabilities

Feature flag gating verification

  1. Disable the filter feature flag:
    group = Group.find_by_full_path('bala-test-group')
    Feature.disable(:malicious_vulnerability_filter_group, group)
    Feature.disable(:malicious_vulnerability_filter_project)
  2. Run any query with malware: true — expect error: "The malware filter is not available."
  3. Re-enable:
    Feature.enable(:malicious_vulnerability_filter_group, group)
  4. Run the same query — expect success

Files changed

12 files changed
File Change
ee/app/graphql/resolvers/vulnerabilities_resolver.rb Add malware argument
ee/app/graphql/resolvers/vulnerability_severities_count_resolver.rb Add malware argument
ee/app/graphql/resolvers/vulnerability_filterable.rb Add :malware to ADVANCED_FILTERS, add validate_malware!
ee/app/models/concerns/vulnerabilities/malware_detection.rb Update sscs_addon_active_for? to accept Project or Group; move MALWARE_PACKAGE_IDENTIFIER_PREFIX constant here
ee/app/models/ee/vulnerability.rb Update has_glam_identifier? to use fully qualified constant from concern
ee/lib/search/advanced_finders/security/vulnerability/base_finder.rb Pass malware param to ES
ee/lib/search/elastic/vulnerability_filters.rb Add by_malware filter method using concern constant
ee/lib/search/elastic/vulnerability_query_builder.rb Add :by_malware to QUERY_COMPONENTS
config/feature_flags/wip/malicious_vulnerability_filter_group.yml New — WIP FF
config/feature_flags/wip/malicious_vulnerability_filter_project.yml New — WIP FF
doc/api/graphql/reference/_index.md Regenerated GraphQL reference docs
public/-/graphql/introspection_result.json Regenerated introspection schema
Edited by Bala Kumar

Merge request reports

Loading