POC: Add database schema for malware advisories and SBOM vulnerability engine integration for CI & CVS
Summary
This is a proof-of-concept MR for the database schema and SBOM vulnerability engine integration proposed in #589555 (closed).
It introduces two new tables — pm_malware_advisories and pm_malware_affected_packages — modelled after the existing pm_advisories / pm_affected_packages pair, and wires them into the existing SBOM vulnerability scanning engine with minimal new code.
The exported advisory format this schema is based on is defined in #587162 (comment 3064070424).
For a full example of how the exported JSON maps to the DB columns, see the JSON to DB Mapping comment.
Database Schema
pm_malware_advisories
Stores one row per malware advisory ingested from GLAM.
| Column | Type | Constraints | Notes |
|---|---|---|---|
id |
bigint |
PK | |
advisory_xid |
text |
NOT NULL, max 36 | e.g. GLAM-2025-04-00005 |
source_xid |
smallint |
NOT NULL | enum — initially only glam: 0 |
title |
text |
max 256 | |
description |
text |
max 8192 | |
published_date |
date |
NOT NULL | |
withdrawn_date |
date |
nullable | presence = advisory was retracted |
identifiers |
jsonb |
NOT NULL, default [] |
array of {type, name, value, url} |
urls |
text[] |
NOT NULL, default [] |
optional reference URLs from the GLAM v3 schema |
created_at |
datetime |
NOT NULL | |
updated_at |
datetime |
NOT NULL |
Indexes:
- Unique on
(advisory_xid, source_xid)— used for upsert on sync
pm_malware_affected_packages
One row per (advisory, purl_type, package_name) combination. Holds the version range used for matching against sbom_occurrences.
| Column | Type | Constraints | Notes |
|---|---|---|---|
id |
bigint |
PK | |
pm_malware_advisory_id |
bigint |
NOT NULL, FK → pm_malware_advisories.id CASCADE |
|
purl_type |
smallint |
NOT NULL | same enum as pm_affected_packages |
package_name |
text |
NOT NULL, max 256 | |
affected_range |
text |
NOT NULL, max 512 | semver NPM-style, e.g. >=0, >=1.0.0 <2.0.0 |
created_at |
datetime |
NOT NULL | |
updated_at |
datetime |
NOT NULL |
Indexes:
- Unique on
(pm_malware_advisory_id, purl_type, package_name)— used for upsert on sync
identifiers JSON schema
Each element in the identifiers jsonb array conforms to:
{
"type": "GLAM",
"name": "GLAM-2025-04-00005",
"value": "GLAM-2025-04-00005",
"url": "https://gitlab.com/gitlab-org/secure/vulnerability-research/pocs/malwares/-/blob/main/advisories/2025/04/00/GLAD-2025-04-00005.json"
}Secondary identifiers (e.g. MAL-) can be appended as additional array elements.
Entity Relationship
pm_malware_advisories
id PK
advisory_xid (unique with source_xid)
source_xid
title
description
published_date
withdrawn_date
identifiers (jsonb)
urls (text[])
|
| 1 : N
|
pm_malware_affected_packages
id PK
pm_malware_advisory_id FK
purl_type
package_name
affected_rangeKey Design Decisions vs pm_advisories
pm_advisories |
pm_malware_advisories |
Reason | |
|---|---|---|---|
cvss_v2 / cvss_v3 |
Malware is always critical; severity set in Rails | ||
solution / fixed_versions |
No "upgrade to fix" concept for malware | ||
distro_version |
Ecosystem-level only, never OS-distro specific | ||
withdrawn_date |
Replaces hard-deletion; triggers downstream resolution | ||
| Identifiers | CVE, Gemnasium… | GLAM, MAL… | Different identifier namespace |
source_xid enum |
glad, trivy-db |
glam |
Separate enum, extensible |
JSON to DB Mapping Example
The following shows how a PMDB exported advisory JSON maps to the two database tables.
Exported PMDB advisory JSON (input to Rails sync)
{
"malware_advisory": {
"id": "GLAM-2025-04-00005",
"source": "glam",
"title": "Malicious code in @baywa-re-lusy/nsemea-address-lib-poc (npm)",
"description": "\n---\n_-= Per source ....",
"published_date": "2025-04-29",
"identifiers": [
{
"type": "GLAM",
"name": "GLAM-2025-04-00005",
"value": "GLAM-2025-04-00005",
"url": "https://gitlab.com/gitlab-org/secure/vulnerability-research/pocs/malwares/-/blob/main/advisories/2025/04/00/GLAD-2025-04-00005.json"
},
{
"type": "MAL",
"name": "MAL-2025-3512",
"value": "MAL-2025-3512",
"url": ""
}
],
"urls": [
"https://github.com/advisories/GHSA-xxxx-yyyy-zzzz"
]
},
"packages": [
{
"name": "@baywa-re-lusy/nsemea-address-lib-poc",
"purl_type": "npm",
"affected_range": ">=0"
}
]
}Field mapping to DB columns
| JSON field | DB table | DB column | Stored value |
|---|---|---|---|
malware_advisory.id |
pm_malware_advisories |
advisory_xid |
"GLAM-2025-04-00005" |
malware_advisory.source |
pm_malware_advisories |
source_xid |
0 (enum glam) |
malware_advisory.title |
pm_malware_advisories |
title |
"Malicious code in ..." |
malware_advisory.description |
pm_malware_advisories |
description |
"\n---\n_-= Per source ..." |
malware_advisory.published_date |
pm_malware_advisories |
published_date |
2025-04-29 |
malware_advisory.withdrawn (absent) |
pm_malware_advisories |
withdrawn_date |
NULL |
malware_advisory.identifiers |
pm_malware_advisories |
identifiers |
jsonb array (both GLAM + MAL objects) |
malware_advisory.urls (optional) |
pm_malware_advisories |
urls |
text[] of reference URLs; empty array when the field is absent |
packages[0].name |
pm_malware_affected_packages |
package_name |
"@baywa-re-lusy/nsemea-address-lib-poc" |
packages[0].purl_type |
pm_malware_affected_packages |
purl_type |
5 (enum npm) |
packages[0].affected_range |
pm_malware_affected_packages |
affected_range |
">=0" |
Note on source_xid: The source field in the JSON ("glam") maps to the source_xid smallint enum defined in the AR model as { glam: 0 }. The integer 0 is stored in the DB; Rails exposes it as the symbol :glam / string "glam" in application code. This is intentionally a separate enum from pm_advisories (which uses glad: 1, trivy-db: 2) since malware advisories come from a different source pipeline. The source_xid value is also forwarded to the Advisory value object in MalwareAdvisoryScanner and used downstream for Snowplow telemetry and error tracking, the same way "glad" is used for regular advisories.
SBOM Vulnerability Engine Integration
The integration reuses the entire existing scanning engine unchanged. Only a thin adapter layer is added.
How pm_advisories maps to sbom_occurrences (existing flow)
The existing flow for pm_advisories works as follows:
- A new advisory is synced into
pm_advisories+pm_affected_packages. PackageMetadata::IngestedAdvisoryEventis published with theadvisory_id.PackageMetadata::GlobalAdvisoryScanWorker(subscribed via EventStore) picks it up.- It calls
PackageMetadata::AdvisoryScanService→Gitlab::VulnerabilityScanning::AdvisoryScanner. - For each
pm_affected_package,Sbom::PossiblyAffectedOccurrencesFinderqueriessbom_occurrencesby(purl_type, package_name)— matching viaSbom::Component(libraries) orSbom::SourcePackage(OS packages). - For each matching occurrence,
AdvisoryUtils#occurrence_is_affected?checks whetheroccurrence.versionfalls withinaffected_rangeusingSemverDialects. - If affected,
BuildFindingMapService+CreateVulnerabilityServicecreate the vulnerability.
New malware advisory flow (this MR)
The malware flow is identical, with a parallel set of thin adapter classes:
| Existing (pm_advisories) | New (pm_malware_advisories) |
|---|---|
IngestedAdvisoryEvent |
IngestedMalwareAdvisoryEvent |
GlobalAdvisoryScanWorker |
GlobalMalwareAdvisoryScanWorker |
AdvisoryScanService |
MalwareAdvisoryScanService |
AdvisoryScanner |
MalwareAdvisoryScanner |
PossiblyAffectedOccurrencesFinder, AdvisoryUtils#occurrence_is_affected?, BuildFindingMapService, and CreateVulnerabilityService are reused without modification.
The only behavioural difference in MalwareAdvisoryScanner vs AdvisoryScanner:
- Passes
distro: niltooccurrence_is_affected?(malware is never OS-distro specific). - No CVSS, no solution text — the
Advisoryvalue object is built withcvss_v2: nil,cvss_v3: nil,solution: nil.
Flow Diagram
flowchart TD
A[GLAM advisory synced\ninto pm_malware_advisories\n+ pm_malware_affected_packages] --> B[IngestedMalwareAdvisoryEvent\npublished with advisory_id]
B --> C[GlobalMalwareAdvisoryScanWorker\nhandle_event]
C --> AD{Malware protection\nadd-on available?}
AD -- No --> AE[Skip & stop\nadd-on not active]
AD -- Yes --> D{Advisory found?}
D -- No --> E[Log error & stop]
D -- Yes --> F[MalwareAdvisoryScanService.execute]
F --> G[MalwareAdvisoryScanner.execute]
G --> H[For each\npm_malware_affected_package]
H --> I[Build Advisory value object\nxid, title, description,\nidentifiers, source_xid\ncvss=nil, solution=nil]
H --> J[PossiblyAffectedOccurrencesFinder\npurl_type + package_name]
J --> K[Lookup Sbom::Component\nor Sbom::SourcePackage]
K --> L[Fetch sbom_occurrences\nin batches of 100]
L --> DED{ExistingDsVulnerabilityChecker\ncovered? - active DS vuln\nalready exists?}
DED -- Yes --> SKIP[Skip occurrence\navoid duplicate]
DED -- No --> M{occurrence_is_affected?\nSemverDialects range check\ndistro=nil}
M -- No --> N[Skip occurrence]
M -- Yes --> O[PossiblyAffectedComponent\n.from_sbom_occurrence]
O --> P[BuildFindingMapService.execute\nadvisory + component + pipeline\n+ project + scanner]
P --> Q[CreateVulnerabilityService.execute\nbulk upsert vulnerabilities]
style AE fill:#f90,color:#fff
style E fill:#f66,color:#fff
style Q fill:#0a0,color:#fff
style N fill:#aaa,color:#fff
style SKIP fill:#aaa,color:#fffTODO: The add-on availability check in
GlobalMalwareAdvisoryScanWorkeris not yet implemented. See the TODO comment in the worker for details.
Deduplication: How Duplicate Vulnerabilities Are Avoided
GlobalMalwareAdvisoryScanWorker and GlobalAdvisoryScanWorker run independently via the EventStore with no sequencing guarantee. A malware advisory could be ingested before or after a regular advisory for the same package, risking duplicate vulnerabilities.
MalwareAdvisoryScanner handles this via ExistingDsVulnerabilityChecker, an inner class that issues a single CTE SQL query per (project, batch) joining vulnerability_occurrences on (name, version, file_path, project_id), filtered to active and unresolved dependency_scanning vulnerabilities. Results are stored in a Set for O(1) per-occurrence lookups — no N+1 queries.
# MalwareAdvisoryScanner#bulk_vulnerability_ingestion
ds_checker = ExistingDsVulnerabilityChecker.new(project_occurrences, project)
project_occurrences.filter_map do |occurrence|
next if ds_checker.covered?(occurrence) # ← skip if regular DS vuln exists
next unless affected_occurrence?(...)
...
endThe with_resolution(false) filter ensures that if a regular advisory vulnerability was resolved or dismissed, a malware vulnerability can still be created for the same package.
| Scenario | Result |
|---|---|
Regular advisory ingested before malware advisory (GlobalAdvisoryScanWorker ran first) |
ds_checker.covered? → true → skipped |
| Regular advisory vulnerability exists from a previous pipeline run | ds_checker.covered? → true → skipped |
| Regular advisory vulnerability was resolved or dismissed | with_resolution(false) → false → malware vuln created |
| No regular advisory covers the package | ds_checker.covered? → false → malware vuln created normally |
NOTE:
ExistingDsVulnerabilityCheckerwill be refactored into a sharedGitlab::VulnerabilityScanning::ExistingVulnerabilityCheckerclass (accepting areport_type:parameter) when the malware CI pipeline integration MR lands on top of this one.
Files Changed
Database
db/migrate/20260309000001_create_pm_malware_advisories.rbdb/migrate/20260309000002_create_pm_malware_affected_packages.rbdb/docs/pm_malware_advisories.ymldb/docs/pm_malware_affected_packages.yml
Models
ee/app/models/package_metadata/malware_advisory.rbee/app/models/package_metadata/malware_affected_package.rbee/app/validators/json_schemas/pm_malware_advisory_identifiers.json
SBOM Engine Integration
app/events/package_metadata/ingested_malware_advisory_event.rbee/lib/gitlab/vulnerability_scanning/malware_advisory_scanner.rbee/app/services/package_metadata/malware_advisory_scan_service.rbee/app/workers/package_metadata/global_malware_advisory_scan_worker.rbee/lib/ee/gitlab/event_store.rb(wires worker to event)
Tests & Factories
ee/spec/factories/package_metadata/pm_malware_advisories.rbee/spec/factories/package_metadata/pm_malware_affected_packages.rbee/spec/models/package_metadata/malware_advisory_spec.rbee/spec/models/package_metadata/malware_affected_package_spec.rbee/spec/lib/gitlab/vulnerability_scanning/malware_advisory_scanner_spec.rbee/spec/services/package_metadata/malware_advisory_scan_service_spec.rbee/spec/workers/package_metadata/global_malware_advisory_scan_worker_spec.rb
Closes #589555 (closed)