Add MalwareAdvisoriesFinder for Dependency Firewall malicious enforcement
What & why
Adds the finder that lets the Dependency Firewall (DFW) detect malicious packages inside the monolith — the data-query layer for the malicious signal.
Security::DependencyFirewall::FetchPackageMaliciousService (added in !242988 (merged)) is the enforcement entry point, mirroring FetchPackageVulnerabilitiesService. It delegates to PackageMetadata::MalwareAdvisoriesFinder, which !245899 (merged) introduced as a flag-gated no-op (returns []).
!245899 (merged) has since merged, so the flag and the no-op finder are now on master. This MR targets master and replaces that no-op with the real malware-advisory query, plus the index that serves it.
This is the Rails path that replaces the superseded "Malicious Packages API" direction (a standalone external service): the DFW is part of the monolith, so enforcement is done in-process through a finder. See work item gitlab-org#22692.
What this MR does
PackageMetadata::MalwareAdvisoriesFinder(ee/app/finders/package_metadata/malware_advisories_finder.rb) — replaces the flag-gated no-op (introduced by !245899 (merged)) with the real query. Takes(name:, purl_type:, version:)(matchingFetchPackageMaliciousService's signature) and returns thePackageMetadata::MalwareAffectedPackagerecords (with theirMalwareAdvisoryeager-loaded) that flag the component and whose affected range includesversion. The version is matched client-side viaSemverDialects(Gitlab::VulnerabilityScanning::AdvisoryUtils#build_matcher), exactly asFetchPackageVulnerabilitiesServicedoes, because malware affected ranges are arbitrary version disjunctions rather than SQL-comparable ranges. A blank version matches all ranges (fail-closed). The caller shapes each advisory into its audit/return contract.- Query scopes on
PackageMetadata::MalwareAffectedPackage—for_occurrences/with_advisory, mirroringPackageMetadata::AffectedPackage. - Index migration —
i_pm_malware_affected_packages_on_purl_type_and_package_nameon(purl_type, package_name). The table previously only hadi_pm_malware_affected_packages_unique_for_upsert(leading withpm_malware_advisory_id), which cannot serve this lookup. The abbreviatedi_prefix is used because the fullindex_...name would be 64 chars, over Postgres's 63-char identifier limit. - Flag-gated (flag lives in !245899 (merged)) — the finder's DB lookup stays behind
dependency_firewall_malicious_packages(thewip, default-off, instance-wide flag defined in !245899 (merged)). This MR does not touch the flag definition; it fills in the query the flag guards. With the flag off,#executereturns[]before querying, so the query can be enabled for performance testing and switched off if it causes production issues. - Specs — finder spec + model scope specs (see Testing).
Because the finder does client-side version filtering, #execute returns an Array rather than an ActiveRecord::Relation, so it is added to spec/support/finder_collection_allowlist.yml with a reason (same rationale as the vulnerabilities equivalent being a service).
Database review
The malware tables live on the gitlab_sec_cell_local schema (the sec database).
Migration
Regular schema migration (db/migrate) using add_concurrent_index + disable_ddl_transaction!. Milestone 19.3. It is a regular (not post-deployment) migration because pm_malware_affected_packages is a newly-introduced table that is currently empty, so the concurrent index builds instantly with no locking concern, and the index ships alongside the application code that uses it. Reversible via explicit up/down.
INDEX_NAME = 'i_pm_malware_affected_packages_on_purl_type_and_package_name'
def up
add_concurrent_index :pm_malware_affected_packages, [:purl_type, :package_name], name: INDEX_NAME
end
def down
remove_concurrent_index_by_name :pm_malware_affected_packages, INDEX_NAME
endDDL added to the schema:
CREATE INDEX i_pm_malware_affected_packages_on_purl_type_and_package_name
ON pm_malware_affected_packages USING btree (purl_type, package_name);Migration output (up / down)
# up
sec: == 20260707120000 AddPurlTypePackageNameIndexToPmMalwareAffectedPackages: migrating
sec: -- index_exists?(:pm_malware_affected_packages, [:purl_type, :package_name], {:name=>"i_pm_malware_affected_packages_on_purl_type_and_package_name", :algorithm=>:concurrently})
sec: -- add_index(:pm_malware_affected_packages, [:purl_type, :package_name], {:name=>"i_pm_malware_affected_packages_on_purl_type_and_package_name", :algorithm=>:concurrently})
sec: == 20260707120000 AddPurlTypePackageNameIndexToPmMalwareAffectedPackages: migrated (0.0773s)
# down
sec: == 20260707120000 AddPurlTypePackageNameIndexToPmMalwareAffectedPackages: reverting
sec: -- index_name_exists?(:pm_malware_affected_packages, "i_pm_malware_affected_packages_on_purl_type_and_package_name")
sec: -- remove_index(:pm_malware_affected_packages, {:algorithm=>:concurrently, :name=>"i_pm_malware_affected_packages_on_purl_type_and_package_name"})
sec: == 20260707120000 AddPurlTypePackageNameIndexToPmMalwareAffectedPackages: reverted (0.1239s)Table size and growth
pm_malware_affected_packages is a new table, currently empty on GitLab.com; it is populated by the malware-advisory ingestion feed (separate work). Each malware advisory enumerates its affected package versions as rows keyed by (purl_type, package_name). Expected order of magnitude is comparable to (well below) the public pm_affected_packages table, which carries the analogous index_pm_affected_packages_on_purl_type_and_package_name index for the same access pattern.
Query and plans
When dependency_firewall_malicious_packages is enabled, for a single component (e.g. name: "lodash", purl_type: "npm" → integer 6) MalwareAdvisoriesFinder#execute evaluates for_occurrences(...).with_advisory, which issues the lookup below plus a preload of the matched advisories (when the flag is off, #execute returns [] and neither query runs):
-- affected packages for the component (this is where the new index is used)
WITH occurrences_cte(purl_type, name) AS (VALUES (6, 'lodash'))
SELECT DISTINCT "pm_malware_affected_packages".*
FROM "pm_malware_affected_packages"
INNER JOIN occurrences_cte
ON occurrences_cte.purl_type = pm_malware_affected_packages.purl_type
AND occurrences_cte.name = pm_malware_affected_packages.package_name;
-- advisories preloaded for the matched rows (from includes(:malware_advisory))
SELECT "pm_malware_advisories".*
FROM "pm_malware_advisories"
WHERE "pm_malware_advisories"."id" IN (/* pm_malware_advisory_id values from query 1 */);The (purl_type, package_name) join predicate is served by the new index. This is a single-component point lookup — one (purl_type, package_name) pair per call, with no namespace/project scope to scan and no fan-out, so cost stays bounded regardless of group size. The version filter is deliberately not in SQL: malware affected_range is an arbitrary explicit-version disjunction (=1.0.0 || =1.0.1 || …), not a SQL-comparable range, so version_matches? applies it in Ruby via SemverDialects over the (small) result set — the same approach as FetchPackageVulnerabilitiesService.
The plans below are from a local GDK sec DB seeded with 60,001 affected-package rows across 3,000 advisories (synthetic), inside a rolled-back transaction. They demonstrate the index is chosen and the improvement over the pre-existing unique index; production-scale plans from Database Lab will be attached from the db:gitlabcom-database-testing run before review.
EXPLAIN (ANALYZE, BUFFERS) — with vs. without the new index
########## WITH new index (this MR) ##########
Unique (cost=2.44..2.46 rows=1 width=47) (actual time=0.009..0.009 rows=1 loops=1)
Buffers: shared hit=4
-> Sort (cost=2.44..2.45 rows=1 width=47) (actual time=0.009..0.009 rows=1 loops=1)
-> Index Scan using i_pm_malware_affected_packages_on_purl_type_and_package_name on pm_malware_affected_packages (cost=0.41..2.43 rows=1 width=47) (actual time=0.005..0.005 rows=1 loops=1)
Index Cond: ((purl_type = 6) AND (package_name = 'lodash'::text))
Buffers: shared hit=4
Execution Time: 0.018 ms
########## WITHOUT new index (falls back to i_pm_malware_affected_packages_unique_for_upsert) ##########
Unique (cost=1001.32..1001.34 rows=1 width=47) (actual time=0.780..0.780 rows=1 loops=1)
Buffers: shared hit=402
-> Sort (cost=1001.32..1001.32 rows=1 width=47) (actual time=0.779..0.779 rows=1 loops=1)
-> Index Scan using i_pm_malware_affected_packages_unique_for_upsert on pm_malware_affected_packages (cost=0.29..1001.31 rows=1 width=47) (actual time=0.778..0.778 rows=1 loops=1)
Index Cond: ((purl_type = 6) AND (package_name = 'lodash'::text))
Buffers: shared hit=402
Execution Time: 0.785 msDB Lab query run : https://console.postgres.ai/gitlab/projects/gitlab-production-main/sessions/53890/commands/156226
Without the new index the planner falls back to the unique upsert index (leading column pm_malware_advisory_id), forcing a full index scan filtered on the non-leading (purl_type, package_name) columns: 402 buffers / 0.785 ms vs. 4 buffers / 0.018 ms with the dedicated index — and that gap grows with table size, since the fallback cost scales with the number of distinct advisories.
Database review checklist
-
db/structure.sqlupdated +db/schema_migrations/20260707120000added - Migration is reversible (
up/down), verified viadb:migrate:up:sec/db:migrate:down:sec - Index created with
add_concurrent_index+disable_ddl_transaction!, explicitly named - Raw SQL + query plans included above; application code using the index is in this MR
- Database Lab plans for the
for_occurrenceslookup (theWITH occurrences_cte …query above) attached — the same query run without and with the new index (in Joe: run it,CREATE INDEX i_pm_malware_affected_packages_on_purl_type_and_package_name …, run again), via thedb:gitlabcom-database-testingjob or against thesecdatabase. Note:pm_malware_affected_packagesis empty on GitLab.com, so both Lab plans are near-instant and mainly confirm the index builds and is chosen — the seeded localEXPLAINabove is the with/without comparison that shows the improvement at scale.
Feature flag
dependency_firewall_malicious_packages (defined in the now-merged !245899 (merged); type: wip, default off, separate from dependency_firewall_phase1) gates the finder's DB lookup as a query kill-switch, evaluated instance-wide via Feature.enabled?(:dependency_firewall_malicious_packages, :instance). Enable it to performance-test the query; disable it to stop the query immediately if production performance issues appear. Rollout tracked in #605121.
Testing
26 examples, 0 failures across the finder, model, and service specs:
ee/spec/finders/package_metadata/malware_advisories_finder_spec.rb— match on(name, purl_type)+ in-range version, version out of range, blank version (fail-closed), non-matching name / purl_type,SemverDialects::Errorhandling, and the flag-off short-circuit.ee/spec/models/package_metadata/malware_affected_package_spec.rb— coverage for thefor_occurrencesandwith_advisoryscopes.ee/spec/services/security/dependency_firewall/fetch_package_malicious_service_spec.rb— the service wraps each finder match as{ advisory: … }once the finder is real, and returns[]when the flag is off.- RuboCop clean on all changed Ruby files.
Local test setup
This MR is the data-query layer; the steps below exercise it end to end in GDK through the merged enforcement path (FetchPackageMaliciousService → MalwareAdvisoriesFinder). Steps 1–3 are prerequisites; 4a tests this MR's finder in isolation, 4b tests it inside a real block.
1. Enable the flags + namespace opt-in (Rails console):
project = Project.find_by_full_path('gitlab-org/.../your-project')
Feature.enable(:dependency_firewall_malicious_packages) # this MR's query kill-switch (instance-wide)
Feature.enable(:dependency_firewall_phase1, project) # enforcement gate
project.root_ancestor.namespace_settings.update!(dependency_firewall_enabled: true)2. Seed a malicious advisory flagging npm lodash (all versions):
adv = PackageMetadata::MalwareAdvisory.create!(
advisory_xid: 'GLAM-2099-01-00001', source_xid: :glam, published_date: Date.current,
title: 'Test malicious lodash', description: 'local test',
identifiers: [{ 'type' => 'GLAM', 'name' => 'GLAM-2099-01-00001',
'value' => 'GLAM-2099-01-00001', 'url' => 'https://example.com' }])
PackageMetadata::MalwareAffectedPackage.create!(
malware_advisory: adv, purl_type: :npm, package_name: 'lodash', affected_range: '>=0')
# advisory_xid must match /\AGLAM-\d{4}-\d{2}-\d{5,}\z/3. Add a malicious policy to the project's security-policy project .gitlab/security-policies/policy.yml (syncs into project.security_policies):
dependency_firewall_policy:
- name: Block malicious packages
enabled: true
enforcement_type: enforced
rules:
- type: malicious
denied:
- is_malicious: true4a. Verify the finder directly (this MR's unit):
PackageMetadata::MalwareAdvisoriesFinder.new(name: 'lodash', purl_type: 'npm', version: '4.17.21').execute
# => [#<PackageMetadata::MalwareAffectedPackage ... advisory GLAM-2099-01-00001>]
PackageMetadata::MalwareAdvisoriesFinder.new(name: 'left-pad', purl_type: 'npm', version: '1.0.0').execute
# => []4b. Verify the full enforcement path (finder feeding the block):
E = Security::DependencyFirewall::EnforcementService
E.firewall_check(project: project, pkg_type: 'npm', name: 'lodash', version: '4.17.21',
operation: E::PACKAGE_DOWNLOAD, current_user: User.find_by_username('root'))
# => ServiceResponse(status: :error, reason: :blocked,
# message: "Package 'lodash' violates 'Block malicious packages' policy")Kill-switch off → no query runs, package allowed:
Feature.disable(:dependency_firewall_malicious_packages)
PackageMetadata::MalwareAdvisoriesFinder.new(name: 'lodash', purl_type: 'npm', version: '4.17.21').execute # => []Screenshots
Out of scope
- Populating the data — the malware-advisory ingestion feed is separate work; until it lands the tables are empty and the finder returns
[]. - Surfacing the advisory in the audit event — the finder returns the advisory record so a follow-up can name what flagged a package; wiring that into the audit payload is not part of this MR.
References
- Work item: gitlab-org#22692
- Enforcement MR (malicious rule evaluator): !242988 (merged)
- Feature-flag rollout: #605121
- Superseded direction: gitlab-org#20341
Closes #606161
