Safeguard
Product•

Add Safeguard to GitLab CI: SBOM, Scan and Merge Request Policy Gate

Add one GitLab CI job that runs safeguard ci scan: each push gets an SBOM and a scan, and a policy gate or severity threshold can fail the merge request.

Safeguard Security Team
41 min read

To add Safeguard to GitLab CI, store a Safeguard secret key with the scans:trigger and scans:read scopes as a masked CI/CD variable named SAFEGUARD_API_KEY, then add one job to .gitlab-ci.yml that installs the Safeguard CLI and runs safeguard ci scan --wait. Every push and merge request then becomes a new version of its Safeguard project with a fresh SBOM and vulnerability scan. Add --fail-on critical or --fail-on-gate <gate> and turn on "Pipelines must succeed" in GitLab, and a merge request that breaks your policy cannot merge.

This post is the pipeline half of the GitLab story. It covers the job itself, where the scan runs, how SBOMs are produced and kept, how to gate on a policy instead of a bare severity, how to scan the container images your pipeline builds, and how scheduled pipelines fit in. If you have not connected GitLab to Safeguard yet, start with connecting GitLab to Safeguard: projects, merge request checks and fix merge requests, which covers tokens, scopes and fix merge requests in depth. Every YAML snippet and command below is copied from, or assembled only from, Safeguard's and GitLab's own docs as fetched on 25 October 2026. We did not run them for this post, so treat each one as a starting point to test on a branch.

Key takeaways

  • The Safeguard CLI (GA) is the whole integration. There is no plugin, extension or marketplace component to install in GitLab: one shell job on a Linux or macOS runner with bash and curl runs safeguard ci scan.
  • SBOM generation and analysis (GA) happens on every scanned push. Each push becomes a new project version named after the branch and short commit, such as main-a1b2c3d, and its SBOM can be exported from Safeguard in SPDX and CycloneDX formats.
  • Software composition analysis and vulnerability management (GA) run on that SBOM. SAST (GA) also runs in the default mode, but the docs say SAST findings are not yet part of the --fail-on threshold.
  • Policies and gates (GA) turn findings into a pass or fail. Each policy has one Action: Block, Warn or Notify. --fail-on-gate fails the job only when a gate is Failed, Error or Not evaluated.
  • Container image scanning in CI (GA) is a separate step after your image build, recorded per environment, such as prod-1.4.2.
  • Reachability analysis and prioritisation (GA) and autonomous remediation (GA, ships switched off with every category set to Manual) work on the same project versions the pipeline creates.
  • Attestation and signing (GA) exists, but the docs say there is no CLI command for signing yet, so this post does not add a signing step to GitLab CI.

Why put the scan in the pipeline at all

A scheduled scan reports a vulnerable dependency days after it merged; a merge request scan tells the author while the change is still open, when it is cheapest to fix. The standards say the same thing more formally.

What NIST SP 800-218 asks for

NIST SP 800-218, the Secure Software Development Framework (SSDF) version 1.1, published in February 2022, has several tasks that a pipeline scan maps onto directly:

  • PO.3.3 asks you to configure tools to generate artifacts of their support of secure software development practices. A scan result tied to a commit, and an SBOM kept per version, are exactly those artifacts.
  • PO.4.1 asks you to define criteria for software security checks and track them throughout the lifecycle. A written policy evaluated by a gate is a criterion you can show an auditor.
  • PS.3.2 asks you to collect, safeguard, maintain and share provenance data for all components of each software release, and it names the SBOM as an example.
  • PW.4.4 asks you to verify that acquired open source and third party components comply with your requirements throughout their life cycles. One of its examples is building automatic detection of known vulnerabilities into the toolchain.
  • RV.1.1 asks you to gather information about potential vulnerabilities in the software and the third party components it uses, and to monitor vulnerability databases.

The SSDF also maps its tasks to Executive Order 14028. Its mapping table ties section 4e(vii), which concerns providing a purchaser an SBOM, to PS.3.2. Supplier questionnaires still cite it. Note that OMB memo M-22-18, which once turned SSDF attestation into a federal procurement requirement, has since been rescinded by M-26-05; cite current guidance when a customer asks.

What CISA and the SBOM formats add

CISA's SBOM page describes an SBOM as a nested inventory, a list of ingredients that make up software components, and it now carries the 2026 update to the minimum elements for an SBOM, which replaces the 2021 NTIA minimum elements as the reference baseline. The two formats Safeguard exports are both formal standards: SPDX is an international open standard published as ISO/IEC 5962:2021, and CycloneDX, an OWASP project, is standardised by Ecma International as ECMA-424. For the full story of what a good SBOM contains, see SBOM generation and analysis in Safeguard, from NTIA minimum elements to the CRA.

What the OWASP CI/CD risks warn about

The OWASP Top 10 CI/CD Security Risks cover both what the scan catches and how to add it safely.

  • CICD-SEC-1, Insufficient Flow Control Mechanisms. Code that can reach a protected branch without a passing check. A required pipeline with a gate is the control.
  • CICD-SEC-3, Dependency Chain Abuse. The scan's job is to show what came in through the dependency chain.
  • CICD-SEC-4, Poisoned Pipeline Execution. A merge request can change .gitlab-ci.yml itself. Treat any secret available to merge request pipelines as reachable by the merge request author.
  • CICD-SEC-6, Insufficient Credential Hygiene. The Safeguard key is a credential. Scope it, lock it to a project and rotate it.

What one safeguard ci scan job does

Safeguard's CI/CD docs describe the integration as a single step that runs safeguard ci scan. Here is what that step does, in order:

  1. It reads SAFEGUARD_API_KEY and detects the repository, branch and commit from GitLab CI/CD, one of the CI systems the CLI recognises.
  2. It finds the Safeguard project for that repository, or creates it on the first run.
  3. It starts a scan. In the default mode Safeguard clones the repository and runs SAST, SBOM generation and dependency vulnerability matching on its side. With --local, the scan runs on the GitLab runner and only the results are uploaded.
  4. It records the scan as a new version of the project, named <branch>-<commit>, for example main-a1b2c3d. On a merge request the source branch is scanned and its head commit names the version.
  5. With --wait, it waits for the scan to finish, up to --timeout, which defaults to 30 minutes.
  6. With --fail-on <severity>, it exits non-zero when a dependency vulnerability at or above that severity is found. With --fail-on-gate <gate>, it waits for the gate's policies to be evaluated on the new data and exits non-zero unless the gate is Passed or Warning.

A non-zero exit fails the job and the pipeline; with "Pipelines must succeed" on, the merge request cannot merge.

Before you start

Check these five things first. Each one maps to a failure the docs list.

CheckWhy it matters
A Safeguard secret key with scans:trigger and scans:readWithout both scopes the job fails with MISSING_SCOPE
The key locked to one project, if one key serves one pipelineLimits what a leaked key can do; a key locked to another project fails with PROJECT_NOT_ALLOWED
A Linux or macOS GitLab runner with bash and curl, or an image that can install themThe CLI installs and runs in a plain shell step
A test stage in your pipeline, or an explicit stage: on the jobThe docs say a job in a stage your pipeline does not define is missing from the pipeline
The repository connected to Safeguard as a GitLab integration, or public on gitlab.com, unless you use --localThe default mode clones the repository on Safeguard's side; an upload-only project fails with PROJECT_NOT_RESCANNABLE

The CI/CD overview also says the GitLab card under Integrations › Source Control has a Set up CI/CD button that opens the setup with your branches and severity filled in, and a project's own Set up CI/CD button does the same with that project's id.

Choose where the scan runs

This is the decision that matters most. Safeguard's docs describe four ways to scan from a GitLab pipeline.

OptionCommandNeedsWhat it scansCan it fail the job on findings?
On Safeguard (default)safeguard ci scanThe repository connected as an integration, or public on gitlab.comSAST, SBOM, dependency vulnerabilitiesYes, with --wait plus --fail-on or --fail-on-gate
On the runner, uploadedsafeguard ci scan --localA checkout in the job; no integrationSBOM, dependency vulnerabilitiesThe CI docs list --fail-on and --fail-on-gate as safeguard ci scan flags; confirm with --help for --local
On the runner, kept localsafeguard scan --dir . --output out/The scanner binaries on the PATH or in TOOLS_DIRSBOM and vulnerability matching, written as JSON filesNo; it reports without failing, so you gate on the files it writes
A built container imagesafeguard ci scan --image <ref> --environment <name>The image in the local Docker daemon, or registry credentialsThe image's packages and their vulnerabilitiesYes, with --wait and --fail-on

Some guidance on picking:

  • GitLab.com, repository already connected: use the default, which adds SAST to the SBOM and dependency scan.
  • Self-managed GitLab behind a firewall: use --local. The CI/CD overview says only the results leave the runner, so it also works for self-hosted and firewalled Git servers. It reads dependencies from lockfiles and manifests in the checkout, such as package-lock.json, go.sum, requirements.txt and pom.xml. You lose SAST in this mode.
  • Raw results as GitLab job artifacts, or images built in the pipeline: add those jobs alongside the gate job, not instead of it.

If you are unsure whether to let Safeguard clone your code at all, choosing between a repo connection, the CLI and the local runner walks through the trade.

Step by step: the merge request job

Step 1: Create the key and store it in GitLab

Create a secret key in Safeguard under Settings › Developer with the scopes scans:trigger and scans:read. Lock it to the project if the pipeline only scans one project.

In GitLab, go to Settings › CI/CD › Variables and add it as SAFEGUARD_API_KEY, with Mask variable on. Safeguard's GitLab page says to leave Protect variable off if unprotected branches or merge requests should be scanned. GitLab's own docs explain why: a protected variable is only available in pipelines that run on protected branches or protected tags, so a merge request from a feature branch never sees it and the job fails with AUTHENTICATION_REQUIRED. GitLab's docs also note that from GitLab 18.3 the default visibility for a new variable is Masked.

An unprotected variable is visible to every pipeline in the project, including one started by a merge request that edits .gitlab-ci.yml. That is the CICD-SEC-4 problem in practice. The key's narrow scopes and project lock are what keep the blast radius small: someone who extracts it can trigger and read scans for one project, nothing more. Rotate it on a schedule.

One point that confuses people: the CLI usage page says the CLI has no API key variable and a workstation signs in once with a device code. That describes the interactive commands. The CI/CD docs say safeguard ci scan reads SAFEGUARD_API_KEY, so use the variable in CI and device sign-in on laptops.

Step 2: Add the job

Safeguard's GitLab page offers two forms: an included job template, and a plain job written out in full under "Without the template". We use the plain job here because every line sits in your own repository and nothing is fetched from outside at pipeline time, The include line for the template is on the GitLab CI/CD docs page if you prefer it.

This is the plain job, exactly as the docs show it:

safeguard:
  stage: test
  image: alpine:3.20
  variables:
    SAFEGUARD_INSTALL_DIR: /usr/local/bin
  before_script:
    - apk add --no-cache bash curl ca-certificates
    - curl -fsSL https://cli.safeguard.sh/install | bash
  script:
    - safeguard ci scan --wait --fail-on critical
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"

Line by line:

  • stage: test puts the job in the test stage. If your pipeline has no test stage, change it.
  • image: alpine:3.20 plus apk add gives a small Linux image with what the installer needs.
  • SAFEGUARD_INSTALL_DIR tells the installer where to put the binary. The installation docs say that without it the shell installer tries /usr/local/bin and falls back to ~/.local/bin.
  • The curl line downloads and runs the installer. The installation docs say it resolves the current release, verifies the archive against the release's published checksums.txt, and on macOS and Linux refuses to install if the checksums file is missing. The docs invite you to read it first.
  • safeguard ci scan --wait --fail-on critical triggers the scan, waits for it, and fails on a critical dependency vulnerability.
  • The two rules run the job on merge request pipelines and on pushes to main. GitLab's docs say the merge_request_event pipeline source is a merge request pipeline, which runs on the contents of the source branch.

If your default branch is not main, change the second rule, or compare against GitLab's predefined $CI_DEFAULT_BRANCH.

Step 3: Pin the CLI version

A plain install always gets the newest release. The CI/CD overview recommends pinning a version so a CLI release does not change a build under you. The installation docs show the form:

curl -fsSL https://cli.safeguard.sh/install | SAFEGUARD_VERSION=<version> bash

Swap that in for the install line in before_script. Every published release is listed at cli.safeguard.sh/releases. Move the pin forward deliberately, on a branch, like any build tool.

Step 4: Make the job a required check

A failed job only blocks a merge if GitLab is told to wait for it. In the project, go to Settings › Merge requests, scroll to Merge checks, and select Pipelines must succeed. GitLab's docs say merge request pipelines have the highest priority for this setting, so the merge request pipeline that runs your Safeguard job is the one that counts.

Pair it with merge request approval rules: the pipeline checks the policy, approvals check that a person reviewed the change.

Step 5: Choose the branches

The plain job decides branches with rules. If you use the template instead, the GitLab page lists two variables for this:

Template variableDefaultWhat it does
SAFEGUARD_BRANCH_PATTERN/.*/A regular expression; pushes to matching branches are scanned, for example /^(main|release\/.*)$/
SAFEGUARD_SCAN_MERGE_REQUESTS"true"Set to "false" to skip merge request pipelines

The same page lists the template's other variables: SAFEGUARD_FAIL_ON (empty means no severity gate), SAFEGUARD_TIMEOUT (default 30m), SAFEGUARD_PROJECT (the project name to scan into), SAFEGUARD_LOCAL ("true" scans on the runner) and SAFEGUARD_VERSION (default latest). They map one to one onto the flags and installer variables used in the plain job.

Scan every merge request but push-scan only the branches you release from; each scanned push creates a version, so scanning both doubles the versions for little gain.

Step 6: Push and read the first result

Push the change on a branch and open a merge request. The first run creates the Safeguard project if needed, and the project shows a new version named after the branch and commit, with its SBOM and findings. We do not reproduce job log output because the docs publish no sample and we did not run the job; the documented exit behaviour is in the gate section below.

SBOM generation: what the pipeline produces and where it lives

In Safeguard, per version

With the default mode or --local, the SBOM is stored in Safeguard as the new project version. Safeguard's Explore SBOM docs describe what you can see per version: an Info tab with project details and build information, a Dependencies tab with tree and table views, a Provenance tab, Vulnerabilities, Mitigations, Security Posture, Code Quality, Suppliers and Licenses, and Findings. From there the SBOM exports to SPDX (JSON, XML, RDF), CycloneDX (JSON, XML), a formatted PDF report, raw JSON and other formats.

That gives you one SBOM per commit that went through the pipeline, which is what PS.3.2 asks for: provenance data for each release, kept and shareable. For handing SBOMs to customers in a controlled way, see publish versus share: distributing SBOMs to customers from Safeguard Portal.

Inside GitLab, as job artifacts

Some teams also want the scan output attached to the pipeline itself, so a reviewer can download it from the job page without logging into anything else. The CI/CD overview has a section for exactly this, called "Scanning on the build machine instead". It says safeguard scan runs fully on the runner and writes files you can keep as artifacts, and that it reports without failing the build, so you gate on the files it writes. The docs show this pair of commands:

safeguard scan --dir . --output out/
jq -e '[.data.matches[].vulnerability.severity | ascii_downcase]
       | index("critical") | not' out/*/vuln.json

The first line builds an SBOM for the checkout, matches it against vulnerability databases and writes JSON under out/. The CLI usage page says results include vuln.json, license.json and collection.json, among others, in a folder per scan. The second line uses jq to exit non-zero if any match has critical severity.

Wrapped in a GitLab job with GitLab's artifacts keyword, it looks like this. This job is assembled from the two documented commands above plus GitLab's documented artifacts:paths, artifacts:when and artifacts:expire_in keywords. It is not a Safeguard docs snippet, and we have not run it:

safeguard-artifacts:
  stage: test
  image: alpine:3.20
  variables:
    SAFEGUARD_INSTALL_DIR: /usr/local/bin
  before_script:
    - apk add --no-cache bash curl ca-certificates jq
    - curl -fsSL https://cli.safeguard.sh/install | bash
  script:
    - safeguard scan --dir . --output out/
  artifacts:
    when: always
    paths:
      - out/
    expire_in: 1 week
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"

Three things to know before you add it:

  1. The scanner binaries. The docs say safeguard scan calls two open source scanners. The interactive terminal downloads them the first time it runs, but a fresh CI container has never opened the terminal, so put both on the PATH or point TOOLS_DIR at a directory that holds them. The CLI usage page names them.
  2. The exit codes. safeguard scan exits 0 when the scan ran, whatever it found; 1 when the command itself failed; and 2 when results were written but the vulnerability scan did not run. That last code exists so a script never mistakes a skipped scan for a clean pass. Do not mask it with || true.
  3. No upload. The CLI usage page says these results stay on the machine unless you scan from the interactive terminal while signed in. So this job does not replace the safeguard ci scan job; it complements it. Keep the gate on the ci scan job.

artifacts:when: always matters because you want the files even when a later line fails. GitLab's docs say that without expire_in, the instance's default expiration applies, and that artifacts from the most recent successful pipeline on each ref can be kept regardless. Set it to what your retention policy requires.

Validating SBOMs you produce or receive

If your build also produces its own SBOM, or you receive one from a supplier, the CLI has a validator. The usage docs say safeguard validate checks CycloneDX (JSON and XML), SPDX (JSON, YAML, tag-value and RDF) and VEX (CSAF and OpenVEX) documents against their specifications:

safeguard validate sbom.json
safeguard validate ./sboms --recursive
safeguard validate sbom.json --strict -o report.json

--strict treats warnings as errors and --fail-fast stops at the first error. A validation step before you publish an SBOM catches a malformed file before a customer does. To scan an SBOM you already have, the same CLI takes safeguard scan --sbom sbom.json, with --format auto|spdx|cyclonedx. For supplier SBOMs specifically, see the ingest and share VEX and CSAF guide.

The policy gate: from a severity threshold to a real policy

--fail-on critical is a sensible first gate. It is also blunt. It cannot express "block a copyleft license in a product we distribute", "warn on components older than two years" or "block only when the EPSS score is high". For those you want a policy gate.

How policies and gates fit together

Safeguard's Policies and Gates docs describe policies as rules that evaluate your SBOMs and generate findings, and gates as checkpoints that enforce policies. You create a policy under Settings › Policies with Create Policy, define its conditions in the visual builder or in YAML, set its severity and action, then save and enable it. The docs list these condition types, with examples:

ConditionExample from the docs
Vulnerability severityNo critical CVEs
CVSS scoreNo vulnerabilities above 7.0
EPSS scoreNo actively exploited CVEs
LicenseNo GPL licenses
Component ageNo components older than 2 years
MaintenanceNo abandoned packages

CISA's Known Exploited Vulnerabilities catalogue is part of how Safeguard enriches and prioritises findings, but it is not in the documented list of gate conditions. If "block anything on KEV" is the rule you want, check with your Safeguard contact how to express it before you depend on it. The policy creation in plain English (GA) that Safeguard offers is covered in plain English security policies to release gates.

What each action does to the gate

Each policy has exactly one Action. The policy outcomes docs define what a failed policy does:

ActionWhen the policy failsEffect on the merge request job using --fail-on-gate
BlockThe gate failsThe job exits non-zero and the merge request is blocked
WarnThe gate still passes, with a warning naming the policyThe job exits 0 and prints the warning
NotifyThe gate still passes, and Safeguard notifies your configured channelsThe job exits 0 and lists the notified policy

Notify goes to the in-app bell, your connected chat integrations and email according to each person's preferences. The docs say Notify does not repeat the same failure on every evaluation: you are notified when a policy goes from passing to failing, and again when a failing policy finds new matching issues.

How the gate's outcome is decided

The gate's outcome follows its linked policies in a fixed order, which the docs spell out:

  1. No policies linked: Not evaluated.
  2. Any Block policy failed: Failed.
  3. Otherwise, any policy could not be evaluated: Error, with the reason.
  4. Otherwise, any policy not evaluated yet: Not evaluated.
  5. Otherwise, any Warn policy failed: Warning, and the gate passes.
  6. Otherwise: Passed, with failed Notify policies listed as notified.

Two rules from the same page protect you from a false green: an Error is never counted as a pass, and a gate that has never been evaluated shows Not evaluated, never Passed. One more detail: new policies have no threshold, so any match fails the policy. Policies created earlier with a threshold above zero keep it.

Switch the job to the gate

Change the script line:

  script:
    - safeguard ci scan --wait --fail-on-gate <gate name or id>

The docs say this waits for the scan to finish and for the gate's policies to be evaluated on the new data, then:

Gate outcomeExitWhat is printed
Passed0Notified policies, if any
Warning0The warnings and the policies that raised them
Failednon-zeroThe Block policies that failed
Errornon-zeroA message saying why
Not evaluated, or not evaluated before --timeoutnon-zeroA message saying why

You can pass all instead of a gate name to check every gate. --fail-on and --fail-on-gate can be combined, so you can keep a hard severity floor while you build up policies.

There is one prerequisite. The docs say policy outcomes are turned on per workspace by Safeguard. If your workspace does not show an Outcome column on the Gates and Policies tables, ask your Safeguard contact to turn it on. Until then, --fail-on-gate has no outcomes to read, so start with --fail-on.

A rollout order that does not stall every merge request

Turning on a strict gate on day one usually blocks every open merge request on findings that were already in main. A staged rollout avoids that:

  1. Report only. Run safeguard ci scan --wait with no fail flags; the docs say dropping --fail-on reports without failing.
  2. A severity floor. Add --fail-on critical, and clear what it catches on main first.
  3. Warn policies. Link Warn policies to a gate and switch to --fail-on-gate. Nothing blocks yet, but every merge request shows the warnings.
  4. Block. Flip trusted policies to Block one at a time, and keep Notify for what only a security reviewer needs to see.

A gate whose policies moved from Warn to Block on a known date is also the tracked criterion PO.4.1 asks for.

When a gate blocks: exceptions

Sometimes a finding is real but accepted: no fix exists yet, or the vulnerable function is never called. The Policies and Gates docs describe the exception flow: click Request Exception, select the policy and finding, provide a business justification, set an expiration date and submit. Approvers are notified, decide, and the decision is recorded in an audit trail. Set short expiry dates. An exception that never expires is a policy you quietly turned off. To decide which findings deserve that conversation at all, prioritise with reachability, EPSS and KEV.

Scan the container images your pipeline builds

A repository scan sees your declared dependencies. It does not see the operating system packages in the base image you ship. If your GitLab pipeline builds an image, add a second Safeguard step after the build.

The container images docs show the core command:

safeguard ci scan --image "ghcr.io/acme/api:$TAG" --environment prod --wait --fail-on critical

The docs explain the pieces:

  • --image <ref> names the image and implies an on-runner scan.
  • --environment <name> names the environment the build is for, using letters, digits and dashes. Each environment's build is its own version in Safeguard, for example prod-1.4.2 or staging-1.4.2. A latest or missing tag uses the build's commit, as in dev-<commit>.
  • The image is read from the local Docker daemon when the step runs on the machine that built it. Otherwise it is pulled from a registry using SAFEGUARD_REGISTRY_USERNAME and SAFEGUARD_REGISTRY_PASSWORD. The docs say that on GitLab's own registry you use $CI_REGISTRY_USER and $CI_REGISTRY_PASSWORD.
  • Only the results leave the runner, and no repository integration is needed.
  • --project <name> records the scan under another project instead of the image repository.

The docs show a full example for one CI system only, not for GitLab. Here is the same step as a GitLab job, assembled from the documented command and variables plus GitLab's predefined $CI_REGISTRY_IMAGE and $CI_COMMIT_SHORT_SHA variables. It assumes an earlier job built and pushed the image to the project's container registry. We have not run it:

safeguard-image-prod:
  stage: test
  image: alpine:3.20
  variables:
    SAFEGUARD_INSTALL_DIR: /usr/local/bin
    SAFEGUARD_REGISTRY_USERNAME: $CI_REGISTRY_USER
    SAFEGUARD_REGISTRY_PASSWORD: $CI_REGISTRY_PASSWORD
  before_script:
    - apk add --no-cache bash curl ca-certificates
    - curl -fsSL https://cli.safeguard.sh/install | bash
  script:
    - safeguard ci scan --image "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" --environment prod --wait --fail-on critical
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

Put the image job in a stage after the build stage in your own pipeline, and duplicate it per environment with a different --environment and fail flag. The docs suggest failing only where it matters: --fail-on critical in production and no --fail-on in development. The container images page also says every registry card under Integrations has a Set up CI/CD button that produces these steps for your CI, image and environments, which is a good way to check the job above against what the app generates for you.

If the job reports reading image …, the docs say the image could not be pulled, so check the registry credentials. For the per environment view in depth, see tracking container images per environment in Safeguard, and for what the image findings mean, scanning container images in Safeguard.

Self-managed and firewalled GitLab

The default mode needs Safeguard to clone the repository. That works for GitLab.com and for a self-managed GitLab with a public address connected as an integration. It does not work for a GitLab server that Safeguard cannot reach.

For those, add --local to the scan command:

  script:
    - safeguard ci scan --local --wait --fail-on critical

The CI/CD overview documents safeguard ci scan --local and lists --wait, --fail-on and --fail-on-gate as flags of safeguard ci scan. It does not show --local combined with the fail flags in one example, so run safeguard ci scan --help on your pinned version to confirm before you make the job required. With the template, the equivalent is SAFEGUARD_LOCAL: "true".

What changes with --local:

  • The scan reads lockfiles and manifests in the job's checkout, so commit your lockfiles; a manifest alone gives a less exact dependency picture.
  • SAST does not run, because the docs list only SBOM and dependency vulnerabilities for this mode.
  • The source code stays on your runner. Only results go to Safeguard. For a firewalled environment, that is usually the whole point.
  • The runner still needs outbound access to Safeguard to upload results, and to cli.safeguard.sh to install the CLI unless you mirror it. The installation docs describe SAFEGUARD_SKIP_CHECKSUM=1 as an explicit opt-out for an air-gapped mirror that does not carry the checksums file; prefer mirroring the checksums file instead.

Scheduled pipelines and continuous scanning

A merge request scan answers "is this change safe today". A vulnerability disclosed next month changes the answer for code that already merged. There are two ways to cover that.

The first is Safeguard's own continuous scanning (GA). The Generate SBOM docs say Safeguard performs daily continuous scanning of all your projects to detect new vulnerabilities as they are disclosed, with additional triggers on push, on a schedule or on release. For projects connected as integrations, new findings on code that has not changed show up in Safeguard without any pipeline running.

The second is a GitLab scheduled pipeline. GitLab's docs describe pipeline schedules that run pipelines at regular intervals based on cron patterns, independently of code changes. A rule such as $CI_PIPELINE_SOURCE == "schedule" lets a job run only in scheduled pipelines. A nightly scheduled gate on main turns a newly disclosed vulnerability into a failed pipeline; use a Notify policy instead if a red pipeline would only be ignored.

A complete .gitlab-ci.yml example

Here is a full pipeline that combines the pieces: a merge request gate, an artifacts job, an image scan on the default branch and a nightly scheduled gate. It is assembled from the documented Safeguard commands and variables above and GitLab's documented keywords. It is not a Safeguard docs snippet, the build job is a placeholder for your own, and we have not run it. Replace <gate name or id> with your gate before use.

stages:
  - build
  - test

.safeguard-cli:
  image: alpine:3.20
  variables:
    SAFEGUARD_INSTALL_DIR: /usr/local/bin
  before_script:
    - apk add --no-cache bash curl ca-certificates jq
    - curl -fsSL https://cli.safeguard.sh/install | bash

build-image:
  stage: build
  script:
    - echo "your existing image build and push to $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"

safeguard-gate:
  extends: .safeguard-cli
  stage: test
  script:
    - safeguard ci scan --wait --fail-on critical --fail-on-gate <gate name or id>
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
    - if: $CI_PIPELINE_SOURCE == "schedule"

safeguard-artifacts:
  extends: .safeguard-cli
  stage: test
  script:
    - safeguard scan --dir . --output out/
  artifacts:
    when: always
    paths:
      - out/
    expire_in: 1 week
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"

safeguard-image-prod:
  extends: .safeguard-cli
  stage: test
  variables:
    SAFEGUARD_REGISTRY_USERNAME: $CI_REGISTRY_USER
    SAFEGUARD_REGISTRY_PASSWORD: $CI_REGISTRY_PASSWORD
  script:
    - safeguard ci scan --image "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" --environment prod --wait --fail-on critical
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

The hidden .safeguard-cli job is a GitLab extends base so the install lines appear once; pin SAFEGUARD_VERSION there. Check the merged configuration in GitLab's pipeline editor before relying on it, drop safeguard-artifacts if you do not need raw files in GitLab, and move the image job to wherever your images are actually built.

Troubleshooting the GitLab job

These come from Safeguard's GitLab and CI/CD overview pages.

Symptom or messageCauseFix
The job is missing from the pipelineThe branch does not match SAFEGUARD_BRANCH_PATTERN, or the pipeline has no test stageFix the pattern or rules, or set stage:
AUTHENTICATION_REQUIRED on a merge request or branchThe variable is protected, so GitLab passes it only to protected branchesTurn Protect variable off, and keep the key narrowly scoped
MISSING_SCOPEThe key lacks scans:trigger or scans:readCreate a key with both
PROJECT_NOT_ALLOWEDThe key is locked to another projectUse the right key, or relock it
PROJECT_AMBIGUOUS or INTEGRATION_AMBIGUOUSMore than one project or integration matches the repositoryPass --project-id, or --integration-id on the first run
PROJECT_NOT_RESCANNABLEThe project has no repository Safeguard can clone, such as an upload projectConnect the repository under Integrations, or use --local
The step never runs on fork merge requestsThe CI does not pass secrets to fork pipelinesExpected; decide how you handle forks before relying on the check

For anything else, safeguard doctor checks the environment, sign-in, tools and policy; it exits 0 whenever the checks ran, so read its lines, not its exit code.

Decision table: what the pipeline covers and what you still own

RequirementWhat Safeguard does in the GitLab pipelineWhat you still own
SSDF PS.3.2, an SBOM per releaseEvery scanned push stores a versioned SBOM, exportable as SPDX or CycloneDXDeciding which versions are releases, and sharing the right SBOM with each customer
SSDF PW.4.4, automated detection of known vulnerabilities in componentsSCA and vulnerability matching on every merge request and default branch pushFixing or accepting each finding
SSDF PO.4.1, defined security criteriaPolicies with Block, Warn and Notify, evaluated by gatesWriting the policies and owning their changes
SSDF PO.3.3, tool artifactsVersions, findings and gate outcomes in Safeguard; optional JSON artifacts in GitLabArtifact retention periods in GitLab
OWASP CICD-SEC-1, flow controlA job that exits non-zero on a failed gateTurning on Pipelines must succeed and approval rules
OWASP CICD-SEC-6, credential hygieneNarrow key scopes and a project lockVariable settings, rotation and who can edit .gitlab-ci.yml
SSDF RV.1.1, monitoring for new vulnerabilitiesDaily continuous scanning of projectsActing on new findings, and scheduled pipelines if you want them to fail
Container base image exposureImage scan per environment after the buildChoosing and rebuilding base images

Where attestation and signing fit

Attestation and signing (GA) is a separate capability. Safeguard's docs say its attestations are signed with Safeguard's own key, are not written to a public transparency log, and are verified with cosign against Safeguard's published public key. The same page says there is no CLI command for signing yet, so this pipeline generates, scans and gates, and does not sign anything.

What Safeguard does not do

  • It does not gate on SAST findings yet. The CI/CD overview says SAST findings are scanned and shown in Safeguard but are not part of --fail-on yet. If you need a code-level gate today, review SAST findings in Safeguard and treat the CI gate as a dependency gate.
  • It does not make GitLab enforce anything. The job exits non-zero; GitLab decides what that means. If Pipelines must succeed is off, a failed Safeguard job is just a red icon.
  • It does not sign artifacts from the pipeline. There is no CLI signing command yet, so do not plan an attestation step around the CLI.
  • It does not see what is not in the checkout or the image. Dependencies fetched at runtime and hosts not built as images are outside this pipeline.
  • It does not turn on policy outcomes for you. Safeguard enables them per workspace; until then use --fail-on.
  • It does not merge fixes on its own in the setup described here. Autonomous remediation (GA) can open fix merge requests, but it ships switched off with every category set to Manual. See autonomous remediation in Safeguard.

Common mistakes

  • Protecting the variable. The key disappears from merge request pipelines and every merge request fails with AUTHENTICATION_REQUIRED. Leave it unprotected, masked and narrowly scoped.
  • Forgetting Pipelines must succeed. The gate fails and the merge request merges anyway. Turn the setting on in the same change that adds the job.
  • Starting with Block on everything. Every open merge request stalls on old findings and the team turns the job off. Report first, then a severity floor, then Warn, then Block.
  • Using || true to quiet the job. It also hides exit code 2 from safeguard scan and real failures from ci scan. Use Warn policies to make things non-blocking instead.
  • Not pinning the CLI. Pin SAFEGUARD_VERSION and bump it on purpose.
  • Expecting --fail-on-gate to work before policy outcomes are on. The gate shows Not evaluated and the job fails for that reason, not because of a finding.
  • Missing lockfiles with --local. The on-runner scan reads what is in the checkout. Commit lockfiles.
  • One key for every project. A leaked key then reaches every project. Lock each key to one project where you can.

FAQ

How do I add Safeguard to GitLab CI?

Create a Safeguard secret key with scans:trigger and scans:read, add it in GitLab under Settings › CI/CD › Variables as a masked, unprotected SAFEGUARD_API_KEY, and add a job that installs the CLI and runs safeguard ci scan --wait. The plain job from Safeguard's GitLab docs is reproduced in Step 2 above.

Is there a Safeguard GitLab plugin or CI/CD component?

No. Safeguard's CI/CD docs say there is no plugin to install. The CLI runs in a plain shell step. The docs also offer an included job template, which still runs the same CLI.

Does Safeguard generate an SBOM in GitLab CI?

Yes. Each scanned push produces an SBOM stored in Safeguard as a project version named after the branch and commit, exportable as SPDX or CycloneDX. If you also want files in GitLab, a safeguard scan --dir . --output out/ job writes JSON results you can keep as job artifacts.

How do I block a merge request on critical vulnerabilities?

Run safeguard ci scan --wait --fail-on critical on merge request pipelines and turn on Pipelines must succeed under Settings › Merge requests.

How do I fail a GitLab pipeline on a Safeguard policy?

Create the policy under Settings › Policies with the Block action, link it to a gate, and use safeguard ci scan --wait --fail-on-gate <gate name or id>. Policy outcomes must be turned on for your workspace first.

Why does my Safeguard job fail with AUTHENTICATION_REQUIRED on merge requests?

The SAFEGUARD_API_KEY variable is probably protected. GitLab only passes protected variables to pipelines on protected branches or tags, so feature branch merge request pipelines do not get it.

Can I use Safeguard with self-managed GitLab behind a firewall?

Yes, with safeguard ci scan --local, or SAFEGUARD_LOCAL: "true" in the template. The scan runs on your runner, reads lockfiles and manifests, and uploads only the results. SAST does not run in that mode.

Does Safeguard scan Docker images built in GitLab CI?

Yes. Add a step after the build that runs safeguard ci scan --image <ref> --environment <name> --wait, with $CI_REGISTRY_USER and $CI_REGISTRY_PASSWORD as the registry credentials if the image is not in the local daemon.

How often does Safeguard rescan code that has not changed?

The Generate SBOM docs say Safeguard runs daily continuous scanning of all projects, with optional triggers on push, on a schedule and on release. A GitLab scheduled pipeline is a second option if you want a failed pipeline as the signal.

When a gate does block a merge request, the next question is how fast the fix can land. The block below is our standard summary of the remediation options that sit behind the gate.

Fix it without waiting for upstream

Whatever the vulnerability, most of the risk sits in two gaps. The first is the time between a fix being published and that fix actually running in your production builds. The second, and worse, is the time before any fix exists at all, while a maintainer works out a patch and a release schedule. Waiting through both gaps is a habit, not a requirement. Safeguard gives teams three ways to close them.

1. Autonomous remediation: pull requests, not tickets

When a fixed version exists, Safeguard's Griffin models open a tested, policy gated pull request that moves the affected dependency to the safe version. You can run it on one repository or in bulk across every project that carries the vulnerable version. Remediation categories include dependencies, licenses, security posture and findings, and each one is set separately. Reachability analysis ranks the vulnerable code paths your application actually calls first, so the first pull requests you merge are the ones that matter most. Autonomous remediation ships switched off, with every category set to Manual, so your team decides how much runs on its own and what still needs a human approval.

2. Gold Open Source: the safe version in seconds, free

gold.safeguard.sh is free and needs no login. Search any CVE or package to see affected and fixed versions, CVSS, EPSS, CISA KEV status and a recommended safe version, or use the "Am I affected?" checker. The same data is available through a free read API, README status badges and RSS threat feeds.

3. Gold Registry: start from components that are already fixed

Gold Registry provides hardened, drop in replacements for upstream packages and container images. Every Gold artifact is held continuously to zero exploitable critical or high CVEs and a completed license check, with signed build provenance (signed with Safeguard's key and verifiable with cosign, SLSA Build Level 3 as the design target) and a public evidence page you can hand to an auditor. An artifact loses its Gold badge if it regresses. Teams can point their own package directory at the registry so hardened equivalents resolve without changing how developers install anything.

When upstream has no fix yet, or will not ship one, Safeguard first works to get the fix accepted upstream. If the issue is severe or upstream will not move, Safeguard builds a fork, fixes the vulnerability there and publishes a hardened Gold build, so you are not stuck on a maintainer's release schedule. You can also request a hardened, compatibility tested build of a specific package. Ask which packages it covers before you plan around it.

Your situationWhat to use
A fixed version existsAutonomous remediation opens the upgrade pull request; Gold Open Source confirms the safe version
You need to know today whether you are affectedGold Open Source lookup and "Am I affected?" checker, free and without login
You want vulnerable versions to stop shippingSafeguard policies and gates in CI
Upstream has no fix yet, or will not ship oneA custom Gold request; fork and patch
You want to start clean on new projectsPull hardened components and images from Gold Registry

One honest limit: when the vulnerable code lives in a vendor appliance, a hosted service or an operating system runtime you do not build yourself, the fix still has to come from that vendor or distribution. The steps above apply to the open source dependencies and container images you build and ship, which is where most teams carry the largest and least tracked share of their exposure.

See how Safeguard would help

This demo is a real CLI session: install, open the interactive terminal with safeguard, ask it to fix the lodash CVE in a repository, and watch it read the finding and advisory, edit package.json to lodash 4.17.21, run npm install and npm test, and print a summary without pushing anything. It asks before any command that changes something. That is the local version of what happens after your GitLab gate fails: the developer fixes the dependency on their branch, pushes, and the merge request pipeline runs the gate again.

Click through it at your own pace, or open it full screen.

If your team cannot let Safeguard clone code, the local runner is the workstation side of the same idea as --local in CI: scanning happens on your machine and only results go to Safeguard. This demo sets up the runner on your own machine, approves it with its device code, and runs a scan from the terminal.

Click through it at your own pace, or open it full screen.

Once gates are running, people ask "why did that merge request fail?" from wherever they work. Safeguard is listed in the Claude connector directory, and the policy outcomes docs say Safeguard's MCP server has read-only tools for gate outcomes, policy outcomes and evaluation history when policy outcomes are on and your MCP access settings allow them. This demo shows connecting Claude to Safeguard with OAuth and choosing which tools Claude can use; install it from the connector directory rather than setting up a custom server.

Click through it at your own pace, or open it full screen.

To try it yourself, download Safeguard and keep the Safeguard docs open next to your .gitlab-ci.yml.

Quick check

Your GitLab merge request pipelines fail with AUTHENTICATION_REQUIRED, but pipelines on main pass. What is the most likely cause?

The SAFEGUARD_API_KEY CI/CD variable is protected. GitLab passes protected variables only to pipelines on protected branches or tags, so merge request pipelines from feature branches never receive it. Turn Protect variable off, keep Mask variable on, and keep the key scoped to scans:trigger and scans:read and locked to one project.

A Warn policy fails on a merge request whose Safeguard job gates on that policy's gate. Does the job fail?

No. A failed Warn policy leaves the gate at Warning, which counts as passing. The job exits 0 and prints the warning naming the policy. Only a failed Block policy, an Error, or a gate that is Not evaluated makes the job exit non-zero.

Sources

Last reviewed: 25 October 2026. Product capabilities change; check docs.safeguard.sh for the current state.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.