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— newpreload(:vulnerability_read)scope, used to readidentifier_nameswithout N+1 queries.
GraphQL-specific:
Types::Sbom::DependencyAggregationType#malwareis overridden to use aBatchLoader::GraphQLkeyed on the group. It runs the preloader once over the nodes on the current page, then returnsoccurrence.malware_statusfor each.Resolvers::Sbom::DependencyAggregationResolver#resolve_with_lookaheadstamps the queried group intocontext[:sbom_dependency_group].DependencyAggregationType#malware_field_enabled?checksdependency_malware_detectionwith 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:
dependenciesreturns an unpaginated relation.resolve_with_lookaheadappliesoffset_paginationafterwards. Loading insidedependenciesreads every aggregation in the whole group, not just the requested page.- The connection then re-queries with
LIMIT/OFFSETand 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 msQuery 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 msQuery 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 msHow 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.
-
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) -
Enable the flag for the group:
Feature.enable(:dependency_malware_detection, group). You can also do this athttp://gdk.test:3000/rails/features, using actor formatGroup:<id>. -
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.
Related
- Parent issue: #587647 (closed)
- REST half of this split, must merge first: !251901 (merged)
- Superseded, to be closed unmerged: !233324 (closed)
- Project-level work already merged: !233323 (merged)
- Flag consolidation: !251948 (merged)
- Flag consolidation that reshaped this area: !249360 (merged) and !249418 (merged)
- Dependency list pagination fix: !251956