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 mergeableapprovals_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  => false

This 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-resolutionmvn 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/canceling block; running/pending/created/manual/skipped do 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_removed path), no DS baseline (previously mergeable, now blocks via scan_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.status self-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
Edited by Alan (Maciej) Paruszewski

Merge request reports

Loading