Block MR when required scan job fails without report
What does this MR do?
Fixes the security gap in #604648, where an MR approval policy with fallback_behavior: fail: closed does not block the merge request when one required scanner's CI job fails and produces no report artifact while another scanner in the same pipeline succeeds.
Approach
Report-presence routing is decided pipeline-wide, so a single succeeding scanner makes UnenforceablePolicyRulesNotificationService defer to UpdateApprovalsService, whose existing guards only ever compare scan types that have an ingested Security::Scan row. A job that fails before uploading any artifact never gets such a row, so when the target pipeline also has no baseline for that scan type there is no diff for either guard to detect and the rule silently passes.
This adds enforce_scan_job_success! in UpdateApprovalsService, which detects — from the CI config artifacts:reports: declaration — a required scanner whose job terminated without succeeding, and blocks through the existing fallback-aware error! path (so fail: closed blocks, fail: open passes). Scoped strictly to the rule's evaluated scanners, so a scanner the policy does not list never triggers a block.
Only failed, canceled and canceling jobs count. Non-terminal statuses (created/pending/running) would block prematurely on a scan that is still in progress — and several entry points into this service fire before the pipeline completes — while manual/skipped jobs are a deliberate configuration choice rather than a scan that ran without producing a verdict.
Gated behind the approval_policies_block_on_failed_scan_job feature flag (default off).
Known limitation — split resolution/analyzer job topologies
This guard detects the failure from the job that declares artifacts:reports:. It therefore cannot see a failure in an upstream resolution or helper job that declares no security report of its own.
The Dependency Scanning v2 unified analyzer (include: template: Jobs/Dependency-Scanning.v2.gitlab-ci.yml, no per-language override) compiles into exactly that shape:
| job | stage | artifacts:reports: |
|---|---|---|
dependency-scanning:maven-resolution |
.pre |
none — only artifacts:paths: ['**/maven.graph.json'] |
dependency-scanning |
test |
cyclonedx, dependency_scanning |
When dependency resolution fails but the analyzer still runs and uploads a degraded report, the observed state is:
dependency-scanning:maven-resolution failed artifacts.reports = nil
dependency-scanning success artifacts.reports = {cyclonedx, dependency_scanning}
fake_secret_detection success artifacts.reports = {secret_detection}Confirmed on a real GDK pipeline using the real v2 template and the real dependency-scanning:2 analyzer image: the merge request stays mergeable — approvals_required=0, approvals_left=0, no violations. All three guards are blind in this topology, because dependency_scanning ends up with a succeeded Security::Scan row (so enforce_scans_presence! and enforce_scan_completion! also see nothing missing), and the only failed job declares no security report at all:
failed jobs in pipeline => [["dependency-scanning:maven-resolution", "failed"]]
of those, declaring ANY security report => falseThis is a structural limit of job-status detection rather than a regression: the underlying query returns [] both with this MR's predicate (failed_or_canceled) and with the original one (without_status(:success)), because the report-declaring job never fails.
Update (2026-07-29): !244051 (closed) (scan.status self-reporting) was expected to close this gap, but its premise does not hold for Dependency Scanning v2. Confirmed via the analyzer's own source (reporter/report.go) and a real, unmodified GDK run using a genuinely-failing Maven resolution: Scan.Status is hardcoded to "success" in the DS v2 analyzer and is never set to "failure". Separately, most real degradation is invisible even at the job-status level — an unresolvable Maven dependency typically logs a warning and still exits BUILD SUCCESS rather than failing the resolution job. There is currently no reliable GitLab-side signal (job status or report content) to close this gap safely; it needs a fix on the Dependency Scanning analyzer side (failing the job on partial/structural resolution errors, or genuinely setting scan.status: "failure", a field their own report/v7 package already defines but never uses in the v2 main.go) rather than in gitlab-org/gitlab. Tracking as a cross-team follow-up rather than in this MR or !244051 (closed).
Reproduction note: a merely unresolvable Maven dependency is not enough to fail dependency-scanning:maven-resolution — mvn dependency:tree logs The POM for … is missing, no dependency information available and still reports BUILD SUCCESS (also with -o). The repro above forced the resolution failure by overriding only that job's script, leaving the template's job name, stage and artifacts configuration untouched.
Testing
- Unit coverage in
ee/spec/services/security/scan_result_policies/update_approvals_service_spec.rb, including a table-driven job-status matrix (failed/canceled/cancelingblock;running/pending/created/manual/skippeddo not), a retried-and-succeeded job, and multiple failed scanners. - Verified end to end in GDK across four topologies with the flag on and off: DS baseline present on the target branch (blocks via the pre-existing
scan_removedpath), no DS baseline (previously mergeable, now blocks viascan_not_succeeded), no target pipeline, and the real DS v2 analyzer. With the flag off, all cases retain today's behaviour.
No changelog entry: the change sits behind a feature flag that is disabled by default (guidance).
References
- #604648
- !244051 (closed) —
scan.statusself-reporting; does not fix the Dependency Scanning v2 gap above (see Known limitation, updated 2026-07-29) — its premise may still apply to SAST-family analyzers, but not DS v2