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)
Sbom::AggregationsFinder#executeruns (existing — produces the 20 representative occurrences).- (new)
vulnerability_ids_by_component_version(group, cv_ids)— joinssbom_occurrences + sbom_occurrences_vulnerabilities, filters bytraversal_ids(group + descendants) andcomponent_version_id IN (cv_ids), returns{ cv_id => [vuln_ids] }. - (new)
Vulnerability.id_in(all_vuln_ids).with_vulnerability_read— batch loads the vulns + their reads (thevulnerability_readpreload fires as a second query). - (new) For each aggregated occurrence, override
association(:vulnerabilities).targetwith 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)
endDatabase
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 againstSbom::Occurrence.vulnerability_ids_by_component_versionandVulnerability.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 onvulnerability_readswe already have to load forvulnerability_count/vulnerabilitiesanyway. - 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
- 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). - After import, run a pipeline so vulnerabilities + SBOM occurrences are ingested.
- 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
- #598208 —
DependencyVulnerabilitiesResolverhas the same MIN(id) limitation for thevulnerabilitiesfield; tracked as a separate follow-up.
Related
- Project-level MR (merged): !233323 (merged)
- Companion MR (Vulnerabilities GraphQL API, merged): !228811 (merged)
- Parent issue: #587647