Draft: Add malware field to Dependency GraphQL and Controller (group-level)

Summary

Part of gitlab-org/gitlab#587647. Builds on the merged project-level work in !233323 (merged).

Adds group-level malware support to:

Surface Endpoint / Type
GraphQL DependencyAggregationType via DependencyInterface
Controller JSON GET /groups/:id/-/dependencies.json

Gated behind the dependency_malware_field_group WIP feature flag (separate from the project-level dependency_malware_field_project flag).

Approach

Problem

Sbom::AggregationsFinder#execute collapses occurrences per component_version_id and returns a single representative via MIN(id). Standard Rails preloading (preload(vulnerabilities: [:vulnerability_read])) therefore only loads vulnerabilities for that one representative — missing GLAM vulnerabilities that may exist on any of the other occurrences of the same component across the group.

Solution

Batch-load vulnerabilities across all occurrences of the page's component versions in the group, then attach them to each aggregated occurrence by setting association(:vulnerabilities).target directly. The downstream malware_status then uses the same in-memory Vulnerability.malware_status_for(vulnerabilities, malware_vulnerable) path as project-level — but with the complete group-wide vulnerability set.

malware_vulnerable is set to the group, so the SSCS add-on FF check (sscs_malware_detection_feature_flag_enabled?) is evaluated at the group level.

Per-page query flow (when malware is requested)

  1. Sbom::AggregationsFinder#execute runs (existing — produces the 20 representative occurrences).
  2. (new) vulnerability_ids_by_component_version(group, cv_ids) — joins sbom_occurrences + sbom_occurrences_vulnerabilities, filters by traversal_ids (group + descendants) and component_version_id IN (cv_ids), returns { cv_id => [vuln_ids] }.
  3. (new) Vulnerability.id_in(all_vuln_ids).with_vulnerability_read — batch loads the vulns + their reads (the vulnerability_read preload fires as a second query).
  4. (new) For each aggregated occurrence, override association(:vulnerabilities).target with the cross-group set.

Feature flag gating

Surface How gated
GraphQL (DependencyAggregationType) Overrides malware_field_enabled? on the type — Feature.enabled?(:dependency_malware_field_group, object.malware_vulnerable, type: :wip)
REST controller (DependencyEntity) malware_field_enabled? branches on group? — group requests use dependency_malware_field_group, project requests fall through to the merged project-level guard

Preloads (resolver)

The base DependencyInterfaceResolver#preloads includes a malware: entry that does the standard MIN(id)-bound preload. The aggregation resolver excludes it because the cross-group batch above replaces the data anyway:

def preloads
  # malware: excluded - vulnerabilities batch-loaded across group via component_version_id (not MIN(id))
  super.except(:packager, :location, :malware)
end

Database

Queries and query plans

The subquery forms below are for postgres.ai pasting (where IDs aren't known in advance). At runtime, Rails emits flat IN (id1, id2, …) lists — captured locally against Sbom::Occurrence.vulnerability_ids_by_component_version and Vulnerability.id_in(...).with_vulnerability_read.

1. vulnerability_ids_by_component_version (new — group-scoped cross-occurrence aggregation)

Called from both the GraphQL resolver (DependencyAggregationResolver#preload_vulnerabilities_across_group) and the controller (Groups::DependenciesController#preload_vulnerabilities_across_group!) when the malware field is requested. Runs once per page, regardless of page size.

EXPLAIN (ANALYZE, BUFFERS)
SELECT "sbom_occurrences"."component_version_id",
       array_agg(DISTINCT "sbom_occurrences_vulnerabilities"."vulnerability_id")
FROM "sbom_occurrences"
INNER JOIN "sbom_occurrences_vulnerabilities"
  ON "sbom_occurrences_vulnerabilities"."sbom_occurrence_id" = "sbom_occurrences"."id"
WHERE sbom_occurrences.traversal_ids >= ARRAY[<group_id>]
  AND ARRAY[<group_id> + 1] > sbom_occurrences.traversal_ids
  AND "sbom_occurrences"."component_version_id" IN (
    -- 20 cv_ids returned by the AggregationsFinder page
  )
GROUP BY "sbom_occurrences"."component_version_id";

Indexes:

  • sbom_occurrences: index_sbom_occurrences_on_component_version_id ((component_version_id)) is the most selective — page-bounded to ≤20 component_version_ids. PG filters traversal_ids post-index.
  • sbom_occurrences_vulnerabilities: i_sbom_occ_vulns_on_occ_id_vuln_id_and_project_id ((sbom_occurrence_id, vulnerability_id, project_id)) — leading column matches the join condition.

Bounded by: 20 component_version_ids per page × total occurrences across the group sharing those component_versions. For typical groups: low-hundreds of rows. For the worst-case group on the heaviest components: a few thousand rows (see performance data below).

Plan: to be captured during database review

2. Batch load vulnerabilities (new)

EXPLAIN (ANALYZE, BUFFERS)
SELECT "vulnerabilities".*
FROM "vulnerabilities"
WHERE "vulnerabilities"."id" IN (
  -- vulnerability_ids gathered from #1
);

Index: vulnerabilities_pkey.

Bounded by: all distinct vulnerability_ids referenced from the cross-group occurrences in #1 (closed).

Plan: to be captured during database review

3. Batch load vulnerability_reads (new — fires from with_vulnerability_read preload)

Rails emits WHERE vulnerability_id IN (?, ?, …) directly with the IDs from #2 (closed). Subquery form for postgres.ai:

EXPLAIN (ANALYZE, BUFFERS)
SELECT "vulnerability_reads".*
FROM "vulnerability_reads"
WHERE "vulnerability_reads"."vulnerability_id" IN (
  -- same vulnerability_ids as #2
);

Index: index_vulnerability_reads_on_vulnerability_id (unique on vulnerability_id).

Bounded by: same as #2 (closed).

Plan: to be captured during database review

4. with_vulnerabilities_and_reads scope on the controller relation (re-added)

The group controller chains .with_vulnerabilities_and_reads onto the AggregationsFinder relation so that for non-malware requests, the MIN(id)-bound vulnerabilities are still preloaded for the regular vulnerability_count/vulnerabilities field. This emits the standard two-step Rails preload — sbom_occurrences_vulnerabilities keyed by sbom_occurrence_id IN (…) then vulnerability_reads keyed by vulnerability_id IN (…) — identical in shape to project-level preloading.

This scope was originally added in !233323 (merged) and then removed during review as unused; this MR is its legitimate consumer.

Query summary

# Query Index Bounded by
1 vulnerability_ids_by_component_version (sbom_occurrences ⋈ sbom_occurrences_vulnerabilities) index_sbom_occurrences_on_component_version_id + i_sbom_occ_vulns_on_occ_id_vuln_id_and_project_id 20 cv_ids × occurrences-per-cv in group
2 vulnerabilities by ID vulnerabilities_pkey Distinct vuln_ids from #1 (closed)
3 vulnerability_reads by vulnerability_id index_vulnerability_reads_on_vulnerability_id (unique) Same as #2 (closed)
4 with_vulnerabilities_and_reads MIN(id) preload (non-malware path) Standard preload — same shape as project-level 20 occurrences per page

Total new queries per malware-requested page: 3 (#1 + #2 + #3). All batch lookups on indexed columns, no N+1.

Production performance data

Based on production clone analysis on the largest group (namespace 9970).

Page type #1 join Vulns loaded (#2+#3) In-memory scan Total est.
Typical < 100 ms < 1 K < 1 ms < 200 ms
p99 components (~195 vulns each) ~ 1 s ~ 3.8 K < 1 ms ~ 1–2 s
p100 worst case (top 20 heaviest components) ~ 3.2 s ~ 53 K < 1 ms ~ 4–5 s

The p100 worst case is the page consisting of the 20 heaviest components in namespace 9970 (e.g. jackson-databind ~12 K vulns, linux-libc-dev, kernel-headers). This is a single extreme group — typical groups stay sub-200 ms.

The in-memory has_glam_identifier? check uses String#start_with?(*PREFIXES) (no .upcase allocation) and short-circuits on the first GLAM match — production GLAM prevalence is 38 / 227.9 M reads (~0%), so for groups without GLAM data the loop runs to completion but at < 1 ms even for 53 K vulns.

Considered-and-rejected alternatives

  • EXISTS subquery that pushes the GLAM check into SQL (EXISTS (SELECT 1 FROM unnest(vr.identifier_names) WHERE name LIKE 'GLAM-%')). Evaluated on the project-level MR (postgres.ai session 51761) — runs an Index Scan per vulnerability_read, scaling linearly with vuln count. For the group-level case it would be strictly worse than this MR's approach because the Ruby loop operates on vulnerability_reads we already have to load for vulnerability_count/vulnerabilities anyway.
  • Per-occurrence preload(:vulnerabilities) — incorrect; only loads vulnerabilities for the MIN(id) representative.

Follow-up paths if rollout surfaces a hot group: (a) denormalized Sbom::Occurrence#has_malware_identifier boolean updated at ingest, or (b) GIN index on vulnerability_reads.identifier_names. Both non-blocking for this MR.

Feature flags

Flag Type Scope Purpose
dependency_malware_field_group WIP group Gates the new malware field on group-level dependency surfaces
dependency_malware_field_project WIP project Pre-existing from !233323 (merged) — gates project-level surfaces
sscs_malware_detection WIP project / group Pre-existing — gates whether the SSCS add-on is considered active. Drives the true/false/null value semantics.

All three are WIP-type flags and off by default. Both dependency_malware_field_group and sscs_malware_detection must be enabled locally before testing the group surfaces.

Local testing

Setup, queries, and verification

Setup

  1. Import a project under a group from https://gitlab.com/gitlab-org/govern/threat-insights-demos/verification-projects/bala-test-group/ that stubs GLAM vulnerabilities (e.g. test-malicious-dependency-badge).
  2. After import, run a pipeline so vulnerabilities + SBOM occurrences are ingested.
  3. Enable required feature flags at the group level:
    # In rails console
    group = Group.find_by_full_path('bala-test-group')
    
    Feature.enable(:dependency_malware_field_group, group)
    Feature.enable(:sscs_malware_detection, group)

Value matrix

Pre-requisite: sscs_malware_detection is enabled. Only the dependency_malware_field_group flag varies.

GLAM identifier present in group dependency_malware_field_group GraphQL malware (DependencyAggregation) REST / Controller malware
Yes Yes true true
Yes No null field omitted
No Yes false false
No No null field omitted

GraphQL query

{
  group(fullPath: "bala-test-group") {
    dependencies {
      nodes {
        id
        name
        componentVersion { version }
        packager
        malware
      }
    }
  }
}

Controller JSON

curl --header "PRIVATE-TOKEN: <token>" \
  "http://localhost:3000/groups/bala-test-group/-/dependencies.json" \
  | jq '[.dependencies[] | {name, version, malware}]'

Toggling the field FF

group = Group.find_by_full_path('bala-test-group')

Feature.disable(:dependency_malware_field_group, group)
Feature.enable(:dependency_malware_field_group, group)

After each toggle, re-issue the request and verify against the matrix above.

Files changed
File Change
config/feature_flags/wip/dependency_malware_field_group.yml New — WIP FF
ee/app/controllers/groups/dependencies_controller.rb Add prepare_malware_vulnerable! + preload_vulnerabilities_across_group!; chain with_vulnerabilities_and_reads for the non-malware path
ee/app/graphql/resolvers/sbom/dependency_aggregation_resolver.rb Exclude :malware from base preloads; add preload_vulnerabilities_across_group when malware is selected
ee/app/graphql/types/sbom/dependency_aggregation_type.rb Override malware_field_enabled? for the group-level FF
ee/app/models/ee/vulnerability.rb Add with_vulnerability_read scope
ee/app/models/sbom/occurrence.rb Add vulnerability_ids_by_component_version class method; re-add with_vulnerabilities_and_reads scope
ee/app/serializers/dependency_entity.rb Branch malware_field_enabled? on group? to use the group FF, preserving the merged project guard
ee/spec/serializers/dependency_entity_spec.rb Group-level coverage for malware_field_enabled?

Follow-up

  • #598208DependencyVulnerabilitiesResolver has the same MIN(id) limitation for the vulnerabilities field; tracked as a separate follow-up.
Edited by Bala Kumar

Merge request reports

Loading