Add malware advisory ingestion pipeline
What does this MR do and why?
Wave-1 ingestion pipeline for the malware advisory sync (epic gitlab-org&20876). It parses GLAM v3 NDJSON records into value objects and upserts them into the malware advisory tables (pm_malware_advisories, pm_malware_affected_packages).
Changes
MalwareAdvisoryDataObject+MalwareAdvisoryDataObjectFabricator— wrap a parsed GLAM v3 record (one advisory plus its affected package(s)).MalwareAdvisoryIngestionService— orchestrates advisory then affected-package ingestion in a single transaction. The malware tables live on the sec database (SecApplicationRecord), unlike the otherpm_tables (gitlab_pm), so the wrapping transaction runs on that connection to avoid a cross-database write.Ingestion::MalwareAdvisory::MalwareAdvisoryIngestionTaskandMalwareAffectedPackageIngestionTask— use validation-awarebulk_upsert!, mirroring the publicPackageMetadata::Advisoryingestion: each record is validated individually, invalid records are logged viaGitlab::ErrorTrackingand skipped, so a single malformed record never fails the whole batch. Both tasks also de-duplicate on their upsert conflict key so a batch that repeats a record can't hitON CONFLICT DO UPDATE ... cannot affect row a second time.- Feature-flag gated —
MalwareAdvisoryIngestionServiceskips the entire upsert unless theingest_malware_advisoriesflag is enabled (type: wip, default off, #604584). With it off, the sync still fetches and parses from PDS but writes nothing topm_malware_*, so fetch/parse can be validated before enabling DB writes (pairs withsync_malware_advisories, #604583). - Specs for both tasks and the orchestrating service (including the flag-off "no writes" path).
Why bulk_upsert! instead of upsert_all
The POC used upsert_all, which bypasses ActiveRecord validations and sends the chunk in one statement — so a single bad record (e.g. an over-limit affected_range) raised PG::CheckViolation and dropped the entire batch. This MR adopts the public-advisory pattern: per-record validate → log → skip, then bulk_upsert! the valid records.
Event publishing deferred
The POC published an IngestedMalwareAdvisoryEvent per recent advisory. Event publishing is only needed for the downstream SBOM / CVS scanning integration, not for the sync itself, so it is removed here and deferred to #606613, under epic gitlab-org&21156.
Local testing
Ran the ingestion service directly against the sec database on a GDK, feeding two GLAM v3 records through PackageMetadata::MalwareAdvisoryIngestionService.execute:
run 1: advisories +2, affected +2
run 2 (idempotent): advisories +2, affected +2 # re-run creates no duplicates
advisory[0] urls=["https://example.test/GLAM-90001"] withdrawn_date=nil
advisory[1] withdrawn_date=2025-05-01 # withdrawn captured
advisory[0] affected=[["pkg-GLAM-2099-01-90001", ">=0"]]- Advisories and their affected packages are upserted together in one sec-DB transaction.
- Re-running is idempotent (upsert, no duplicate rows).
urlsandwithdrawn_dateare persisted.
The full pipeline (PDS / offline sync → this ingestion) was also validated end-to-end against the GLAM v3 npm example dataset via the sync MRs — 212,988 (full) / 211,316 (delta) advisories, 0 records dropped.
Specs
bundle exec rspec \
ee/spec/services/package_metadata/ingestion/malware_advisory/ \
ee/spec/services/package_metadata/malware_advisory_ingestion_service_spec.rb
# 10 examples, 0 failuresMR acceptance checklist
- Ingestion is resilient: invalid records are logged and skipped, and the batch continues.
- Unit specs for both tasks and the service (validate / skip / log, advisory id map).
- Idempotent upsert verified locally;
urls/withdrawn_datepersisted. - Malware schema (#589555 (closed)) is merged to
master; the specs are green in CI.
Related to #602431.
Database review — upsert query plans
This MR introduces the malware advisory upserts (bulk_upsert! in the two ingestion tasks). bulk_upsert! batches at BulkInsertSafe::DEFAULT_BATCH_SIZE = 500, so the database sees 500-row INSERT … ON CONFLICT … DO UPDATE upserts, one per malware table (both on the sec database).
1. pm_malware_advisories
INSERT INTO "pm_malware_advisories"
("advisory_xid", "source_xid", "title", "description", "published_date",
"withdrawn_date", "identifiers", "urls", "created_at", "updated_at")
VALUES (...), (...) -- 500 value rows
ON CONFLICT ("advisory_xid", "source_xid") DO UPDATE SET
"title" = excluded."title", "description" = excluded."description",
"published_date" = excluded."published_date", "withdrawn_date" = excluded."withdrawn_date",
"identifiers" = excluded."identifiers", "urls" = excluded."urls",
"updated_at" = excluded."updated_at"
RETURNING "advisory_xid", "id";Database Lab (gitlab-production-sec clone, 500-row batch):
- INSERT path (
/allbootstrap — new rows): postgres.ai plan - UPDATE-on-conflict path (
/deltare-sync — existing rows): postgres.ai plan
Conflict arbiter index i_pm_malware_advisories_xid_source — no sequential scan.
2. pm_malware_affected_packages
INSERT INTO "pm_malware_affected_packages"
("pm_malware_advisory_id", "purl_type", "package_name", "affected_range", "created_at", "updated_at")
VALUES (...), (...) -- 500 value rows
ON CONFLICT ("pm_malware_advisory_id", "purl_type", "package_name") DO UPDATE SET
"affected_range" = excluded."affected_range", "updated_at" = excluded."updated_at";Database Lab (gitlab-production-sec clone, 500-row batch):
- INSERT path: postgres.ai plan
- UPDATE-on-conflict path: postgres.ai plan
Conflict arbiter index i_pm_malware_affected_packages_unique_for_upsert — no sequential scan. Because pm_malware_advisory_id is an FK, the Database Lab batch sourced its ids from existing advisories (ORDER BY random() LIMIT 500), so the linked plans include a small sample node ahead of the upsert; the upsert/conflict cost is the relevant part.