Integrate malware advisories on GitLab PostGres with SBOM and CVS engine
Integrate Malware advisories on GitLab PostGres (tracked in [epic](https://gitlab.com/groups/gitlab-org/-/work_items/20876)) with GitLab rails SBOM ingestion and CVS engine.
After this integration only, UI (tracked in [epic](https://gitlab.com/groups/gitlab-org/-/work_items/18456)) can filter by malicious filter on vulnerability reports and dependency list.
---
## Two integration paths
Malware advisories become vulnerabilities by two independent routes. Both are needed, because they cover different gaps.
| Path | Trigger | Covers |
|---|---|---|
| **SBOM / CI** | A dependency scanning job posts SBOM components to the `sbom_scans` API during a pipeline | Any project that runs a pipeline. Matches against all advisories with no age filter |
| **CVS** | An advisory is ingested; a worker sweeps already-ingested `sbom_occurrences` | Projects whose code has not changed since their last pipeline |
The SBOM path catches all advisories against changed code; CVS catches new advisories against static code. Neither is sufficient alone. The SBOM/CI integration lands first, then CVS. Work common to both is Wave 0.
## Already on master
The data layer is complete, so what remains is integration only.
- `pm_malware_advisories` and `pm_malware_affected_packages` on the sec DB — [!242951](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/242951) and [!242954](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/242954).
- `PackageMetadata::MalwareAdvisory` and `PackageMetadata::MalwareAffectedPackage` models, including the `for_occurrences` CTE-join scope and `with_advisory` — this is exactly the query surface the SBOM path needs, so no new query work is required.
- `PackageMetadata::MalwareAdvisoriesFinder`, used today by the Dependency Firewall.
- Sync and ingestion, feature-flag gated — [epic &20876](https://gitlab.com/groups/gitlab-org/-/epics/20876).
- Engine pieces reused unmodified: `AdvisoryUtils#occurrence_is_affected?`, `Sbom::PossiblyAffectedOccurrencesFinder`, `BuildFindingMapService`, `CreateVulnerabilityService`.
**Not on master:** any malware branch in `SecurityReportBuilder`, the CVS worker / scan service / scanner, and the advisory value-object builder.
## Settled design points
**Not add-on gated.** The SBOM and CVS integration for malicious advisories is a general **Ultimate** feature. It is not tied to an add-on. The proof of concept branched on "malware protection add-on available?" and left that check as a TODO; that branch is dropped rather than implemented. Note this is separate from the malware advisory **sync**, which has its own availability rules including running on Premium with the Dependency Firewall add-on ([#606640](https://gitlab.com/gitlab-org/gitlab/-/work_items/606640)) — data being synced on an instance does not imply vulnerabilities should be produced there.
**Default branch only for GA.** Both integrations target the default-branch case for GA. Multi-context support (VAC, `vulnerabilities_across_contexts`) is post-GA and tracked in Wave 4 below. This is a real saving in the CVS scanner, where the per-occurrence-ref path is genuinely extra code; it saves nothing in the SBOM/CI path, which inherits context attribution from the generic ingestion pipeline and needs no malware-specific work either way. The one exception is the UUID version: `SecurityReportBuilder` passes `tracked_context: nil`, so it emits context-unaware UUIDs. That is correct on the default branch and shared with public advisories, and is tracked in [#612169](https://gitlab.com/gitlab-org/gitlab/-/work_items/612169). The deduplication concern that used to sit here is resolved: [#594791](https://gitlab.com/gitlab-org/gitlab/-/work_items/594791) chose to keep both findings, so no check is built pre-GA and there is nothing to make context-aware later.
**Report type and identifier.** Malware findings use the `dependency_scanning` report type and are identified by a GLAM identifier. `Sbom::Occurrence#malware_status` and the `Dependency` REST entity already derive malware status from the presence of a GLAM identifier, so this is fixed by existing code rather than open for decision.
## Work breakdown & structure
Following the POC ([!226519](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/226519), closed), the integration is decomposed into the issues below, grouped by dependency wave. Everything within a wave can be worked in parallel; `is_blocked_by` links are set on the issues.
**Issue status:** ✅ done · 🔄 in progress · 🚫 blocked · 📅 scheduled · ⏭️ not required · ❓ scope to confirm
### Wave 0 — foundations (%19.3)
Shared by both integration paths. The overlap decision and the value object are both closed; what remains is the withdrawn-advisory exclusion.
| Issue | Status | Scope | MR |
|---|---|---|---|
| [#612090](https://gitlab.com/gitlab-org/gitlab/-/work_items/612090) | 📅 | Exclude advisories with a non-null `withdrawn_date` from the shared query surface, so neither path can match a retracted advisory | — |
| ~~[#612089](https://gitlab.com/gitlab-org/gitlab/-/work_items/612089)~~ | ✅ | `Advisory.from_malware_advisory` — value object with no CVSS, no solution, `source_xid` of `glam`, GLAM/MAL identifiers. Renamed from `from_malware_affected_package` during implementation: nothing is read from the affected package. **Severity resolved:** `FindingBuilder` derived severity from `cvss_scores.first&.severity`, so nil CVSS yielded `unknown`; the value object now takes an optional `severity:` that the builder prefers, rather than synthesizing a fake CVSS vector | [!249736](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/249736) (merged `b73c1cb660c4`) |
| ~~[#594791](https://gitlab.com/gitlab-org/gitlab/-/work_items/594791)~~ | ✅ | **Decided: Option C — keep both findings.** Confirmed with the team in Slack, issue closed. Plain dedup was ruled out on evidence: `malware_status` derives from a `GLAM-`/`MAL-` identifier, so suppressing the GLAM finding reports `malware = false` on real malware (verified on the fixture for `chalk@5.6.1`). True duplicates are only 458 of 215,111 GLAM npm packages (0.213%). No suppression or merge check is built pre-GA. Cross-source correlation is reframed as its own feature, also covering third-party findings ingestion — deliberately not tracked on this epic, and **not yet raised anywhere** | — |
### Wave 1 — SBOM / CI integration (%19.4)
| Issue | Status | Scope | Blocked by | MR |
|---|---|---|---|---|
| [#612091](https://gitlab.com/gitlab-org/gitlab/-/work_items/612091) | 🔄 | Match SBOM components against malware advisories in `SecurityReportBuilder`, FF-gated. One builder serves both `ProcessSbomScanService` (the `sbom_scans` API) and `Ci::JobArtifact#build_security_report` (SBOM-only jobs), so both entry points need coverage | [#612089](https://gitlab.com/gitlab-org/gitlab/-/work_items/612089), [#612090](https://gitlab.com/gitlab-org/gitlab/-/work_items/612090), [#612092](https://gitlab.com/gitlab-org/gitlab/-/work_items/612092) | [!249853](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/249853) (open, stacked on [!249736](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/249736)) |
| [#612093](https://gitlab.com/gitlab-org/gitlab/-/work_items/612093) | 📅 | Feature flag rollout for the SBOM/CI path. Do not enable before [#612092](https://gitlab.com/gitlab-org/gitlab/-/work_items/612092) ships, or a cached scan result can mask the new findings during verification | — | — |
| [#616888](https://gitlab.com/gitlab-org/gitlab/-/work_items/616888) | 📅 | `find_cached_scan` selects a scan to reuse without applying `SbomScan.not_expired`, so the `EXPIRED_AGE` bound is enforced only by `DestroyExpiredSbomScansWorker` (daily, `0 2 * * *`) rather than at read time. In normal operation a scan stays reusable for roughly a day past expiry; wider if the reaper falls behind, since its self-rescheduling chain stops on a zero-destroy batch or a storage error. Found while verifying [#612092](https://gitlab.com/gitlab-org/gitlab/-/work_items/612092). Pre-existing and affects public-advisory findings equally, so **not a malware GA blocker** — first step is confirming whether scan age is meant to be a reuse criterion at all, since the class comment lists three and omits it | — | — |
| ~~[#612092](https://gitlab.com/gitlab-org/gitlab/-/work_items/612092)~~ | ✅ | Invalidate cached SBOM scan results on malware sync. `SbomScanResultCachingService#advisories_synced_since?` checked only `Checkpoint.for_advisories`, so a malware-only sync left a cached result looking fresh and the new findings were silently missing. Adds a `for_finding_advisories` scope. Exposure is bounded by `SbomScan::EXPIRED_AGE` (2 days) **plus reaper lag** — `find_cached_scan` never applies `not_expired`, so the age bound is enforced by a daily cron rather than at read time ([#616888](https://gitlab.com/gitlab-org/gitlab/-/work_items/616888)). A correctness gap rather than an indefinite one | — | [!250273](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/250273) (merged `7d71acf8815e`) |
### Wave 2 — CVS integration (%19.4)
| Issue | Status | Scope | Blocked by | MR |
|---|---|---|---|---|
| [#606613](https://gitlab.com/gitlab-org/gitlab/-/work_items/606613) | 🔄 | Publish `IngestedMalwareAdvisoryEvent` from ingestion. Dropped from the sync work because it exists only to feed this epic | — | [!251402](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/251402) (in review) |
| [#612094](https://gitlab.com/gitlab-org/gitlab/-/work_items/612094) | 🔄 | `GlobalMalwareAdvisoryScanWorker`, `MalwareAdvisoryScanService`, `MalwareAdvisoryScanner`, plus CVS telemetry. Mirrors `AdvisoryScanner` with `distro: nil` and no CVSS/solution | [#606613](https://gitlab.com/gitlab-org/gitlab/-/work_items/606613), [#612089](https://gitlab.com/gitlab-org/gitlab/-/work_items/612089) | [!251424](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/251424) (in review, stacked on [!251402](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/251402)) |
| [#612097](https://gitlab.com/gitlab-org/gitlab/-/work_items/612097) | 🔄 | Post-deploy migration resetting the malware `pm_checkpoints` rows (`data_type = 4`, `gitlab_pm`) so the next sync re-drives the full `/all` snapshot. **Needed because ingestion is enabled in %19.3 while the scanner ships later:** delta sync only publishes events as advisories *arrive*, and rows already in the table are never re-upserted, so even advisories inside the 14-day window are permanently skipped simply because they arrived before any consumer existed. The reset re-upserts everything and publishes events for what is still in window, aligning CVS with its own policy at enablement. **A same-named migration already shipped in %19.3** (`20260813000002_reset_malware_advisory_checkpoints.rb`, merged for the normalization work) but does not count: it ran before the scanner or event publication existed, so it burned the `/all` snapshot with no consumer. A second reset is required in %19.4, under a different class name. Must ship in a release where the scanner is already enabled | — | [!251462](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/251462) (**Draft**, and blocked by [!251424](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/251424) via an MR dependency — must not merge until the scanner is enabled) |
| [#612095](https://gitlab.com/gitlab-org/gitlab/-/work_items/612095) | 📅 | Define the licensing and enablement gate. The POC's add-on check is dropped (see settled design points above); what remains is confirming no explicit Ultimate check is needed in the scanner, and deciding whether malware reuses the per-project `cvs_for_dependency_scanning_enabled` setting or gets its own. **Recommendation posted: reuse `cvs_for_dependency_scanning_enabled`** — it already defaults to `true`, matches the same `sbom_occurrences` surface, and errs toward giving an opted-out customer less rather than surprising them with new `critical` findings. Awaiting team confirmation; [!251424](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/251424) already implements it this way, confined to `MalwareAdvisoryScanner#cvs_enabled_for_project?` | — | — |
| [#612098](https://gitlab.com/gitlab-org/gitlab/-/work_items/612098) | 📅 | Feature flag rollout for the CVS path. Must not be enabled before [#612097](https://gitlab.com/gitlab-org/gitlab/-/work_items/612097) is applied | [#612097](https://gitlab.com/gitlab-org/gitlab/-/work_items/612097) | — |
| ~~[#612096](https://gitlab.com/gitlab-org/gitlab/-/work_items/612096)~~ | ✅ | **Decided: reuse `14.days`, no malware-specific handling.** Confirmed with the team in Slack. The gate compares `ingestion_run_time − published_date`, and the delta poll runs every 5 minutes, so a newly published advisory sits far inside the window — it does not bite in steady state. The back-catalogue is not CVS-scanned, which is accepted because the SBOM/CI path has no age filter. The filter itself is implemented as part of [#606613](https://gitlab.com/gitlab-org/gitlab/-/work_items/606613) | — | — |
### Wave 3 — lifecycle & resolution (%19.4)
| Issue | Status | Scope | MR |
|---|---|---|---|
| [#612099](https://gitlab.com/gitlab-org/gitlab/-/work_items/612099) | 📅 | Resolve vulnerabilities already created when their advisory is later withdrawn | — |
| ~~[#594793](https://gitlab.com/gitlab-org/gitlab/-/work_items/594793)~~ | ⏭️ | ~~Skip `MarkAsResolved` on GLAM vulnerabilities for up to 30 days after add-on expiry~~ — **closed, no longer applicable.** The integration is not add-on gated: malware sync and malicious vulnerability management ship to all Ultimate customers, so there is no add-on expiry to grace. Withdrawn-advisory resolution is covered by [#612099](https://gitlab.com/gitlab-org/gitlab/-/work_items/612099); loss of Ultimate entitlement follows the standard licensing paths | — |
### Wave 4 — VAC support (post-GA)
Multi-context support, deliberately excluded from GA. Everything above assumes the default branch.
| Issue | Status | Scope | Relates to | MR |
|---|---|---|---|---|
| [#616781](https://gitlab.com/gitlab-org/gitlab/-/work_items/616781) | 🔄 | `SecurityReportBuilder` passed `tracked_context: nil`, so SBOM/CI findings always got context-unaware UUIDs. Correct on the default branch, wrong on a tracked non-default ref once VAC is on, where the analyzer parser and the CVS scanner both emit context-aware ones. Shared with public advisories, so not malware-specific — one change fixes both. Gated on `sbom_scan_context_aware_uuids` plus `Security::VAC.enabled?`. Code lands in %19.4; flag rollout is post-GA because enabling it rewrites the UUIDs of findings already stored against tracked refs ([rollout](https://gitlab.com/gitlab-org/gitlab/-/work_items/616783)) | [#612169](https://gitlab.com/gitlab-org/gitlab/-/work_items/612169) | [!250563](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/250563) (in review) |
| [#612168](https://gitlab.com/gitlab-org/gitlab/-/work_items/612168) | 📅 | Per-occurrence-ref malware findings in the CVS scanner — the `build_finding_maps_per_occurrence_ref` branch that `AdvisoryScanner` already has, gated on `Security::VAC.enabled?`. This is the only part of the VAC deferral that saves meaningful pre-GA time | [#612094](https://gitlab.com/gitlab-org/gitlab/-/work_items/612094) | — |
| [#612169](https://gitlab.com/gitlab-org/gitlab/-/work_items/612169) | 📅 | Malware findings from non-default-branch pipelines in the SBOM/CI path. Context *tagging* is free — ingestion assigns it from the pipeline — but context-aware UUIDs are not: `build_finding` passes `tracked_context: nil`, so this path emits v1 UUIDs where the analyzer parser and the CVS scanner emit v2. A real change rather than verification: thread `report.tracked_context` through. Shared with public advisories, so still no malware-specific work | [#612091](https://gitlab.com/gitlab-org/gitlab/-/work_items/612091) | — |
| ~~[#612170](https://gitlab.com/gitlab-org/gitlab/-/work_items/612170)~~ | ⏭️ | ~~Make GLAM/GLAD overlap handling context-aware~~ — **closed, no longer needed.** [#594791](https://gitlab.com/gitlab-org/gitlab/-/work_items/594791) chose Option C, so there is no suppression or merge check to make context-aware. If cross-source correlation is built as its own feature, context-awareness belongs in it from the start | — | — |
> **The dedup deferral is resolved, not deferred.** [#594791](https://gitlab.com/gitlab-org/gitlab/-/work_items/594791) chose to keep both findings, so no suppression or merge check is built pre-GA and there is nothing to make context-aware later. Cross-source correlation is now framed as its own feature rather than deferred work on this epic, and would also cover the duplication that third-party findings ingestion will introduce.
### Completed
| Issue | Delivered by |
|---|---|
| ~~[#596810](https://gitlab.com/gitlab-org/gitlab/-/work_items/596810)~~ | `pm_malware_advisories` table — [!242951](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/242951) (merged) |
| ~~[#596811](https://gitlab.com/gitlab-org/gitlab/-/work_items/596811)~~ | `pm_malware_affected_packages` table — [!242954](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/242954) (merged) |
| ~~[#596494](https://gitlab.com/gitlab-org/gitlab/-/work_items/596494)~~ | POC spike — [!226519](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/226519) (closed); outcome carried into this breakdown |
### The enablement gap, and what closes it
Advisory ingestion is enabled in %19.3 while the CVS scanner ships later, and CVS is entirely event-driven with no cron re-scan. So by the time the scanner is live, the advisory tables already hold a month or more of rows that arrived before any consumer existed. Delta sync never revisits them.
This splits into two parts, handled differently:
- **Advisories inside the 14-day window.** These are in CVS scope by policy but would be skipped purely because of arrival timing. The checkpoint-reset migration ([#612097](https://gitlab.com/gitlab-org/gitlab/-/work_items/612097)) re-drives the snapshot so they publish events and get scanned.
- **Advisories older than 14 days.** Out of CVS scope by policy, following the decision on [#612096](https://gitlab.com/gitlab-org/gitlab/-/work_items/612096). Covered instead by the SBOM/CI path ([#612091](https://gitlab.com/gitlab-org/gitlab/-/work_items/612091)), which applies **no age filter** — any project running a dependency scanning job is matched against the full advisory set.
The residual exposure is therefore a project whose code has not changed since before the scanner shipped, and whose malicious dependency was flagged more than 14 days before the reset ran, so it neither re-runs a pipeline nor falls inside the reset window.
### Out of scope (other epics)
- **Advisory sync into Postgres** — [epic &20876](https://gitlab.com/groups/gitlab-org/-/epics/20876).
- **PDS service** — PMDB ingestion [epic &20538](https://gitlab.com/groups/gitlab-org/-/epics/20538).
- **UI filtering** on vulnerability reports and the dependency list — [epic &18456](https://gitlab.com/groups/gitlab-org/-/epics/18456).
- **Dependency Firewall** request-time malicious lookup, which already consumes this data independently of the SBOM and CVS paths.
## Issue status
```glql
display: table
fields: title, state, labels("workflow::*"), milestone, assignee, updated
sort: milestone asc
query: epic = &21156
```
epic