Draft: Report malware on the group dependency GraphQL API

What does this MR do and why?

This MR adds group-level malware reporting to the GraphQL dependency surface (Query.group.dependencyAggregations). It is the GraphQL half of a two-part split. The REST half, !251901 (merged), adds the same capability to the controller/REST surface and must merge before this one.

Parent issue: #587647 (closed)

The problem

Sbom::AggregationsFinder collapses each component version to one representative occurrence, chosen by MIN(id). A normal preload of vulnerabilities only ever sees that one occurrence. If a GLAM (malware) finding sits on a different occurrence of the same component version elsewhere in the group, DependencyAggregation.malware never reports it. A group can be using a malicious package version in one project and see no warning, because the representative row for that component version happens to belong to a different, clean project.

The fix

Shared core, identical to the one in !251901 (merged):

  • Sbom::MalwareVulnerabilitiesPreloader (ee/app/models/sbom/malware_vulnerabilities_preloader.rb) — new. Looks up malware status across every occurrence of a component version in scope, not just the representative one.
  • Sbom::Occurrence.vulnerability_ids_by_component_version(namespace, component_version_ids) — new grouped query backing the preloader.
  • Vulnerability.with_vulnerability_read — new preload(:vulnerability_read) scope, used to read identifier_names without N+1 queries.

GraphQL-specific:

  • Types::Sbom::DependencyAggregationType#malware is overridden to use a BatchLoader::GraphQL keyed on the group. It runs the preloader once over the nodes on the current page, then returns occurrence.malware_status for each.
  • Resolvers::Sbom::DependencyAggregationResolver#resolve_with_lookahead stamps the queried group into context[:sbom_dependency_group].
  • DependencyAggregationType#malware_field_enabled? checks dependency_malware_detection with that group as actor, and returns false (so the field resolves to null) when the group is absent from context.

Why a BatchLoader rather than preloading in the resolver

This is a deliberate departure from the superseded !233324 (closed), which called occurrences.load inside DependencyAggregationResolver#dependencies. That approach does not work, for two reasons:

  • dependencies returns an unpaginated relation. resolve_with_lookahead applies offset_pagination afterwards. Loading inside dependencies reads every aggregation in the whole group, not just the requested page.
  • The connection then re-queries with LIMIT/OFFSET and yields fresh record instances for the page, so whatever was preloaded in the first step is discarded anyway.

Batching from the type sidesteps both problems: BatchLoader::GraphQL collects the nodes GraphQL actually resolves fields for, runs the preloader once against exactly those, and does it after pagination has already picked the page.

Why the group has to come through context

An aggregated occurrence has no way to identify which group was queried, and the type needs the group both to scope the malware lookup and to pick the feature flag actor. context[:group] already exists on this type but is never written anywhere in the codebase today, so it is always nil. Reusing it here would silently switch on the currently-dead apply_group_license_overrides path, which is out of scope for this MR. A distinct context[:sbom_dependency_group] key is used instead to avoid that side effect.

Feature flag

One flag, dependency_malware_detection — the same one the dependency list UI already reads. There is no separate backend field flag: !251948 (merged) removes dependency_malware_field_project and folds the field gating into this one.

Flag Type Default Gates
dependency_malware_detection beta off Backend malware field, plus the UI badge and filter token

The type resolves the group from context[:sbom_dependency_group] and calls Group#dependency_malware_detection_feature_flag_enabled?, which walks up the namespace hierarchy. The field resolves to null when the group is absent from context or the flag is off.

Database review

The three queries below are identical to those reviewed in !251901 (merged) — same SQL, same indexes, same bounds. They are listed here again for completeness. They run once per rendered page, only when the malware field is selected in the GraphQL query and the flag is enabled for the group.

Query 1 — grouped vulnerability ids per component version, fired by the preloader:

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 >= '{22}' AND '{23}' > sbom_occurrences.traversal_ids)
  AND "sbom_occurrences"."archived" = FALSE
  AND "sbom_occurrences"."component_version_id" IN (1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20)
GROUP BY "sbom_occurrences"."component_version_id"

Index: index_unarchived_occurrences_on_version_id_and_traversal_ids, a partial btree on (component_version_id, traversal_ids) WHERE (archived = false). The archived = false predicate is what makes the partial index eligible. The join side uses i_sbom_occ_vulns_on_occ_id_vuln_id_and_project_id (btree (sbom_occurrence_id, vulnerability_id, project_id)) as an Index Only Scan. Bounded by at most 20 component version ids per page (Sbom::AggregationsFinder::MAX_PAGE_SIZE), times however many occurrences in the group share those component versions.

Query 2 — hydrate vulnerabilities:

SELECT "vulnerabilities".* FROM "vulnerabilities" WHERE "vulnerabilities"."id" IN (1, 2, 3)

Index: vulnerabilities_pkey. Bounded by the distinct vulnerability ids returned by query 1.

Query 3 — fired by the with_vulnerability_read preload:

SELECT "vulnerability_reads".* FROM "vulnerability_reads" WHERE "vulnerability_reads"."vulnerability_id" IN (1, 2, 3)

Index: index_vulnerability_reads_on_vulnerability_id (unique). Bounded the same as query 2.

EXPLAIN (ANALYZE, BUFFERS) plans

These plans come from a local GDK database with 81 sbom_occurrences rows and 1607 vulnerabilities rows. They show index selection and query shape, not production cost. Production plans will be captured on postgres.ai during database review.

Query 1 plan:

GroupAggregate  (cost=4.91..4.99 rows=1 width=40) (actual time=0.040..0.040 rows=0 loops=1)
  Group Key: sbom_occurrences.component_version_id
  Buffers: shared hit=3 read=1
  ->  Incremental Sort  (cost=4.91..4.97 rows=3 width=16) (actual time=0.037..0.038 rows=0 loops=1)
        Sort Key: sbom_occurrences.component_version_id, sbom_occurrences_vulnerabilities.vulnerability_id
        Presorted Key: sbom_occurrences.component_version_id
        Full-sort Groups: 1  Sort Method: quicksort  Average Memory: 25kB  Peak Memory: 25kB
        Buffers: shared hit=3 read=1
        ->  Nested Loop  (cost=0.29..4.88 rows=3 width=16) (actual time=0.026..0.026 rows=0 loops=1)
              Buffers: shared read=1
              ->  Index Scan using index_unarchived_occurrences_on_version_id_and_traversal_ids on sbom_occurrences  (cost=0.14..2.17 rows=1 width=16) (actual time=0.024..0.024 rows=0 loops=1)
                    Index Cond: ((component_version_id = ANY ('{1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20}'::bigint[])) AND (traversal_ids >= '{22}'::bigint[]) AND (traversal_ids < '{23}'::bigint[]))
                    Buffers: shared read=1
              ->  Index Only Scan using i_sbom_occ_vulns_on_occ_id_vuln_id_and_project_id on sbom_occurrences_vulnerabilities  (cost=0.14..2.67 rows=5 width=16) (never executed)
                    Index Cond: (sbom_occurrence_id = sbom_occurrences.id)
                    Heap Fetches: 0
Planning Time: 192.429 ms
Execution Time: 0.069 ms

Query 2 plan:

Index Scan using vulnerabilities_pkey on vulnerabilities  (cost=0.28..4.89 rows=3 width=356) (actual time=5.039..5.040 rows=3 loops=1)
  Index Cond: (id = ANY ('{1,2,3}'::bigint[]))
  Buffers: shared read=3
Planning Time: 81.813 ms
Execution Time: 5.056 ms

Query 3 plan:

Index Scan using index_vulnerability_reads_on_vulnerability_id on vulnerability_reads  (cost=0.28..5.44 rows=3 width=255) (actual time=9.489..9.490 rows=3 loops=1)
  Index Cond: (vulnerability_id = ANY ('{1,2,3}'::bigint[]))
  Buffers: shared read=3
Planning Time: 165.168 ms
Execution Time: 9.507 ms

How to set up and validate locally

Local testing steps

Scenario: a group with two projects, both holding an Sbom::Occurrence of the same component version. Only the second project's occurrence has an associated vulnerability whose vulnerability_read.identifier_names starts with GLAM-. The aggregation picks the first project's occurrence as the representative, so the finding was invisible before this MR.

  1. In rails console, build the data (or use the seeded demo group https://gitlab.com/gitlab-org/govern/threat-insights-demos/verification-projects/bala-test-group/ instead of hand-building):

    group     = Group.find_by_full_path('your-group')
    project_a = create(:project, group: group)   # or use existing projects
    project_b = create(:project, group: group)
    
    cv = Sbom::ComponentVersion.first
    Sbom::Occurrence.create!(project: project_a, component_version: cv, ...)
    occurrence_b = Sbom::Occurrence.create!(project: project_b, component_version: cv, ...)
    
    vuln = Vulnerability.find(<some vuln in project_b>)
    vuln.vulnerability_read.update!(identifier_names: ['GLAM-2026-0001'])
    Sbom::OccurrencesVulnerability.create!(occurrence: occurrence_b, vulnerability: vuln)
  2. Enable the flag for the group: Feature.enable(:dependency_malware_detection, group). You can also do this at http://gdk.test:3000/rails/features, using actor format Group:<id>.

  3. Run this query at http://gdk.test:3000/-/graphql-explorer:

    {
      group(fullPath: "your-group") {
        dependencyAggregations {
          nodes {
            name
            version
            malware
          }
        }
      }
    }

BEFORE (on master), the node for the shared component returns "malware": false, because only the representative occurrence in project_a is inspected and it carries no GLAM finding:

{
  "name": "some-package",
  "version": "1.2.3",
  "malware": false
}

AFTER (this MR), the same node returns "malware": true, because the lookup covers every occurrence of that component version in the group:

{
  "name": "some-package",
  "version": "1.2.3",
  "malware": true
}

With the flag disabled again (Feature.disable(:dependency_malware_detection, group)), malware resolves to null for every node.

Verification matrix:

GLAM finding somewhere in group Flag enabled for group malware value
yes yes true
no yes false
yes no null
no no null

Merge order

!251901 (merged) must merge first. Both target master and carry the same shared model and preloader changes (Sbom::MalwareVulnerabilitiesPreloader, Sbom::Occurrence.vulnerability_ids_by_component_version, Vulnerability.with_vulnerability_read), so once it merges this branch rebases, the duplicated hunks drop out, and only the GraphQL-specific changes remain.

!251948 (merged) is independent of both and can merge in any order.

Edited by Bala Kumar

Merge request reports

Loading
Loading