11. Security and Compliance (DevSecOps)
A vulnerability found in production costs an incident, a patch under pressure and sometimes a disclosure. The same flaw found in the merge request that introduced it costs a few minutes of the author's time. DevSecOps moves security checks into the pipeline so that every change is scanned, the results are shown where developers already work, and organization-wide rules enforce scanning and approvals whether or not individual projects opt in. This chapter covers GitLab's security scanners, how their findings become managed vulnerabilities, how security and compliance teams enforce policies across many projects, and how to harden the pipeline itself so that it cannot become the attack path.
Tiers at a glance. Most scanners in this chapter run on every tier and produce a downloadable JSON report. The views that make findings usable (merge request widget, vulnerability report, security dashboard, dependency list) and the security policies require Ultimate. Compliance frameworks and audit events start at Premium. Each topic notes its tier.
<a id="111-application-security-testing"></a>
11.1 Application Security Testing
Application security testing examines your code, its dependencies, its container images, its infrastructure definitions and the running application for known weakness patterns. This subchapter covers each scanner GitLab provides: what class of problem it finds, how to enable and tune it, and where it fits in the pipeline.
The scanners differ in what they look at and when they can run:
source code ──► SAST, Secret Detection, IaC Scanning (no build needed, every commit)
manifests ──► Dependency Scanning (lockfiles / dependency graph)
image ──► Container Scanning (after the image is built and pushed)
running app ──► DAST, API Security Testing (after deploy to a test environment)
functions ──► Coverage-Guided Fuzz Testing (instrumented build, long runs)
◄──── static: fast, early, sees the code ────►◄──── dynamic: slower, later, sees real behavior ────►
All GitLab scanners follow the same pattern, which makes them easy to learn once:
- You
includea template (or a CI/CD component) that defines one or more jobs. - Each job runs an analyzer image and writes a report in GitLab's JSON security report format.
- The job declares the file under
artifacts:reports:<type>so GitLab ingests it. - Variables tune the analyzer. You override them globally or on the job that the template defines.
Static Application Security Testing (SAST)
Why it matters. Many serious vulnerabilities are visible in the source code: user input concatenated into a SQL query, a hardcoded cryptographic key, eval on request data, a disabled TLS check. A reviewer may spot them, but reviewers get tired and do not know every dangerous API in every language. SAST reads the code without running it, the way a spell checker reads a document, and flags patterns that are known to lead to vulnerabilities.
What it is. SAST in GitLab is a set of analyzers selected automatically by the languages detected in the repository. The main analyzer is based on Semgrep with GitLab-maintained rules and covers most mainstream languages (Java, JavaScript/TypeScript, Python, Go, C#, Ruby, PHP, C/C++, Kotlin, Swift, Scala and more). A few specialized analyzers handle niche languages. On Ultimate, GitLab Advanced SAST adds cross-function and cross-file taint analysis: it follows untrusted input (a source, such as an HTTP parameter) through the program to a dangerous call (a sink, such as a SQL execute) and reports only paths where the data is not sanitized on the way, which removes many false positives.
Taint analysis (Advanced SAST)
request.args["id"] ──► user_id = ... ──► build_query(user_id) ──► cursor.execute(q)
SOURCE propagation other file SINK
=> SQL injection, with the full path shown
request.args["id"] ──► int(...) ──► cursor.execute(q, (id,))
SOURCE sanitizer parameterized => no finding
Enabling SAST.
# .gitlab-ci.yml
stages:
- build
- test # SAST jobs run in the "test" stage, which must exist
include:
- template: Jobs/SAST.gitlab-ci.yml # adds one job per detected language/analyzer
variables:
SAST_EXCLUDED_PATHS: "spec, test, tests, tmp, node_modules, vendor" # skip non-production code
GITLAB_ADVANCED_SAST_ENABLED: "true" # Ultimate: also run the taint-analysis analyzer
The same scanner is also published as a CI/CD component, which is versioned explicitly and accepts inputs instead of variables:
include:
- component: $CI_SERVER_FQDN/components/sast/sast@3 # pin a major version from the CI/CD Catalog
The template's jobs use rules to run only when matching files exist. A Python-only repository gets semgrep-sast (and gitlab-advanced-sast if enabled), but no jobs for other languages. Each job produces gl-sast-report.json and declares it as artifacts:reports:sast.
Common settings.
| Variable | Purpose |
|---|---|
SAST_EXCLUDED_PATHS | Comma-separated paths or globs whose findings are dropped. Default: spec, test, tests, tmp |
SAST_EXCLUDED_ANALYZERS | Analyzers that should not run, for example "spotbugs" |
GITLAB_ADVANCED_SAST_ENABLED | "true" adds the Advanced SAST job (Ultimate) |
SAST_DISABLED | "true" disables all SAST jobs without removing the include |
SECURE_ANALYZERS_PREFIX | Registry to pull analyzer images from, for offline or mirrored setups |
SECURE_LOG_LEVEL | debug prints which files and rules the analyzer used |
Customizing a template job. Template jobs are ordinary jobs, so you can override keywords by redefining the job with the same name. Configuration is merged, as with extends:
semgrep-sast:
variables:
SAST_ANALYZER_IMAGE_TAG: "5" # pin the analyzer's major version
tags: [security] # run on a dedicated runner pool
Tuning rules. A ruleset file at .gitlab/sast-ruleset.toml disables rules or overrides their severity without forking the analyzer:
# .gitlab/sast-ruleset.toml
[semgrep]
[[semgrep.ruleset]]
disable = true # we use a vetted crypto wrapper; this rule only adds noise
[semgrep.ruleset.identifier]
type = "semgrep_id"
value = "gosec.G401-1"
[[semgrep.ruleset]]
[semgrep.ruleset.identifier]
type = "semgrep_id"
value = "bandit.B608"
[semgrep.ruleset.override]
severity = "Critical" # treat SQL string building as critical in this codebase
What happens on each tier.
| Tier | What you get |
|---|---|
| Free, Premium | The jobs run and gl-sast-report.json can be downloaded from the job |
| Ultimate | Findings in the merge request widget, the vulnerability report, policies, Advanced SAST, ruleset customization from remote sources |
Limitations. SAST sees code, not configuration or runtime. It cannot know that a value is safe because a proxy validates it, and it cannot find business logic flaws such as "users can view other users' invoices". Expect false positives, and plan to tune rules rather than ignoring the scanner wholesale.
Key takeaways
- SAST scans source code without running it and reports dangerous patterns by language. Analyzers are selected automatically.
- Include
Jobs/SAST.gitlab-ci.yml(or the SAST component), make sure ateststage exists, and tune withSAST_*variables. - Advanced SAST (Ultimate) follows tainted data across functions and files, which yields fewer, more precise findings.
- Use
.gitlab/sast-ruleset.tomlto disable or re-rate individual rules instead of disabling SAST. - On Free and Premium you get the JSON report. The MR widget and vulnerability management are Ultimate.
Practice
- (Beginner) Add the SAST template to a small Python or JavaScript project that contains one deliberately unsafe call (for example
subprocess.call(cmd, shell=True)with user input). Downloadgl-sast-report.jsonand find the finding's rule identifier, file and line.- (Intermediate) Add a
.gitlab/sast-ruleset.tomlthat disables one noisy rule and raises the severity of another. Confirm the change in the next report, and exclude thetests/directory withSAST_EXCLUDED_PATHS.- Interview question: "SAST produces 400 findings on a legacy codebase and developers have started ignoring it. What do you do?" Hint: separate new findings in merge requests from existing ones on the default branch, tune or disable noisy rules, taint analysis, and severity thresholds in approval policies.
Secret Detection
Why it matters. A cloud access key committed to a repository is usable by anyone who can read the repository, and deleting the line in a later commit does not help: the key stays in Git history, in forks and clones, and possibly in public search engines. Automated scrapers find leaked keys on public hosts within minutes. Secret detection catches credentials before they spread, and ideally before they are pushed at all.
What it is. GitLab detects secrets at three points:
developer laptop GitLab server pipeline
─────────────────── ───────────────────────── ────────────────────────────────
git push ─────────────► Secret push protection ──accept──► Pipeline secret detection
(Ultimate, blocks push) (all tiers, scans commits/files)
│
MR description, comment ► Client-side detection ▼
(warns before posting) report → MR widget / vuln report
| Method | When it runs | Effect | Tier |
|---|---|---|---|
| Secret push protection | During git push, on the server | Rejects the push if a high-confidence secret is in the new commits | Ultimate |
| Pipeline secret detection | In a CI job | Reports secrets in the repository or its history | All (report); Ultimate (widget, report UI) |
| Client-side secret detection | When writing MR descriptions and comments | Warns the user before the text is posted | All |
Pipeline secret detection uses an analyzer based on Gitleaks with GitLab's rules for API keys, tokens, private keys and cloud credentials:
include:
- template: Jobs/Secret-Detection.gitlab-ci.yml # adds the "secret_detection" job in stage "test"
variables:
SECRET_DETECTION_EXCLUDED_PATHS: "docs/examples" # fixtures with fake keys
secret_detection:
variables:
SECRET_DETECTION_HISTORIC_SCAN: "true" # scan the full history once, then remove
By default the job scans the commits of the current push or merge request, not the whole history. Run a historic scan once when you adopt the scanner, because old leaks are still leaks.
Secret push protection is enabled per project (Secure > Security configuration) or by group/instance administrators. A blocked push looks like this:
$ git push origin feature/payments
remote: PUSH BLOCKED: Secrets detected in code changes
remote: Secret push protection found the following secrets in commit: 4f2a9c1
remote: -- config/settings.py:12 | GitLab personal access token
remote: To push your changes you must remove the identified secrets.
! [remote rejected] feature/payments -> feature/payments (pre-receive hook declined)
To fix it, remove the secret from the commit (amend or interactive rebase), not just from the latest file version. For a confirmed false positive, a push can skip the check, and the skip is recorded in the audit log:
# Option 1: push option (skips the check for this push only)
git push -o secret_push_protection.skip_all origin feature/payments
# Option 2: commit message token in one of the pushed commits
git commit -m "Add test fixture with fake key [skip secret push protection]"
Custom rules. Add organization-specific token formats (for example an internal API key prefix) in .gitlab/secret-detection-ruleset.toml, which extends or replaces the default Gitleaks rules.
Responding to a leaked secret. The order matters:
- Revoke or rotate the credential at its provider. This is the only step that removes the risk.
- Check the provider's logs for use of the credential between the leak and the revocation.
- Remove it from the code and move it to a CI/CD variable or a secrets manager (chapter 5).
- Rewrite history only if needed (for example, a public repository). It is secondary to step 1.
On GitLab.com, GitLab personal access tokens found by secret detection in public projects are revoked automatically, and some partner providers are notified of their leaked tokens.
Key takeaways
- A secret in Git history is exposed even after the line is deleted. Rotation is the fix, not deletion.
- Secret push protection (Ultimate) blocks secrets before they reach the server. Pipeline secret detection (all tiers) reports what got through.
- Include
Jobs/Secret-Detection.gitlab-ci.yml. Run a historic scan once withSECRET_DETECTION_HISTORIC_SCAN. - Skipping push protection is possible but audited. Use it only for confirmed false positives.
Practice
- (Beginner) Add the secret detection template, commit a fake AWS access key pair (use the documented example key
AKIAIOSFODNN7EXAMPLE), and find the finding in the job's report.- (Intermediate) Write a custom rule in
.gitlab/secret-detection-ruleset.tomlfor an internal token format such asacme_live_followed by 32 hex characters, and prove it fires while the default rules keep working.- Interview question: "A developer pushed a production database password to a public repository an hour ago and has already force-pushed it away. Walk me through your response." Hint: what the force push did not undo (clones, forks, caches, scrapers), rotation first, access logs, and the controls that prevent a repeat.
Dependency Scanning
Why it matters. A typical application is mostly third-party code: a web service with 30 direct dependencies easily pulls in 500 transitive ones. When a vulnerability is published in one of them (Log4Shell is the canonical example), you need to know quickly which of your projects use the affected version, including as a transitive dependency nobody chose explicitly. This is called software composition analysis (SCA).
What it is. Dependency scanning reads your project's dependency manifests and lockfiles, builds the list of components with exact versions, and matches them against GitLab's advisory database (built from sources such as the GitHub Advisory Database, NVD and language-specific feeds). It is an Ultimate feature.
package.json package-lock.json advisory database
"express": "^4" express 4.17.1 express < 4.19.2 → CVE-2024-29041
├─ body-parser 1.19.0 ───► body-parser < 1.20.3 → CVE-2024-45590
└─ qs 6.7.0 ...
(exact, transitive versions) => 2 findings, one via a transitive dependency
Lockfiles are the input. A manifest such as package.json lists version ranges. Only a lockfile, or a dependency graph exported by the build tool, says which version is actually installed. Commit lockfiles, and generate a graph for ecosystems that do not have one.
| Ecosystem | Files the scanner uses |
|---|---|
| npm, Yarn, pnpm | package-lock.json, npm-shrinkwrap.json, yarn.lock, pnpm-lock.yaml |
| Python | poetry.lock, Pipfile.lock, uv.lock, pip requirements compiled with pinned versions, or a pipdeptree JSON export |
| Ruby | Gemfile.lock |
| Go | go.mod / go.sum, or a go.graph export |
| Java, Kotlin | Maven or Gradle dependency graph export (for example maven.graph.json, dependencies.lock) |
| PHP, .NET, Conan, Swift... | composer.lock, packages.lock.json, conan.lock, Package.resolved |
Enabling it. GitLab is moving from the older Gemnasium analyzers to an SBOM-based analyzer. Use the newer template on current versions:
include:
- template: Jobs/Dependency-Scanning.v2.gitlab-ci.yml # SBOM-based analyzer, job "dependency-scanning"
# Java needs a dependency graph produced by the build tool before the scan
maven-graph:
stage: build
image: maven:3.9-eclipse-temurin-21
script:
- mvn --batch-mode com.github.ferstl:depgraph-maven-plugin:4.0.3:graph -DgraphFormat=json -DoutputFileName=maven.graph.json
artifacts:
paths: ["**/target/maven.graph.json"] # picked up by the scanner in the test stage
The job produces two reports: a CycloneDX SBOM (artifacts:reports:cyclonedx) listing every component, and a dependency scanning report (artifacts:reports:dependency_scanning) with the vulnerabilities. The SBOM also feeds the dependency list and license compliance (section 11.2).
| Variable | Purpose |
|---|---|
DS_EXCLUDED_PATHS | Paths to skip, for example "docs, examples" |
DS_MAX_DEPTH | How many directory levels to search for lockfiles (monorepos) |
DS_EXCLUDED_ANALYZERS | Disable a specific analyzer (Gemnasium-based template) |
Continuous vulnerability scanning. Advisories are published every day, but your pipelines may not run every day. On the default branch, GitLab compares new advisories against the components recorded in the latest SBOM and creates vulnerabilities without a new pipeline. A service that has not been touched for months still learns that a dependency became vulnerable.
Fixing findings. Most findings are fixed by upgrading. The finding shows the fixed version when one exists. Upgrading a transitive dependency usually means upgrading the direct dependency that pulls it in, or adding an override:
{
"dependencies": { "express": "^4.21.0" },
"overrides": { "qs": "6.13.0" }
}
Key takeaways
- Dependency scanning (Ultimate) matches the exact versions of direct and transitive dependencies against advisory databases.
- Commit lockfiles, and export a dependency graph where the ecosystem has no lockfile (Maven, Gradle).
- The scanner produces a CycloneDX SBOM and a vulnerability report. The SBOM powers the dependency list and license policies.
- Continuous vulnerability scanning flags newly published advisories on the default branch without a pipeline run.
Practice
- (Beginner) Add dependency scanning to a Node.js project that pins an old version of a popular library (for example
lodash@4.17.15). Find the CVE identifiers and the suggested fixed version in the report.- (Intermediate) In a Maven project without a lockfile, add a job that exports the dependency graph and make the scanner find a vulnerable transitive dependency. Then fix it by upgrading the direct dependency, not by pinning the transitive one.
- Interview question: "A critical CVE is announced in a widely used library at 9 a.m. How do you find out by 10 a.m. which of your 300 services are affected?" Hint: SBOMs on default branches, group-level dependency list search, continuous vulnerability scanning, and what happens for repositories without lockfiles.
Container Scanning
Why it matters. Your application code can be perfectly secure and still ship inside an image with an outdated OpenSSL, glibc or curl. The base image you chose last year has accumulated CVEs since then. Container scanning inspects what you actually deploy: every OS package and runtime library in the built image.
What it is. The container scanning job pulls an image and analyzes it with Trivy. It reads the OS package database (apk, dpkg, rpm) and can also find language packages installed inside the image. The job runs on all tiers. The MR widget and vulnerability report need Ultimate.
Dockerfile built image findings
FROM python:3.10-slim ──► layer 1: Debian 12 base ──► libssl3 3.0.11 CVE-2024-... (fixed in 3.0.15)
RUN pip install ... layer 2: Python packages zlib1g ... CVE-2023-...
COPY . /app layer 3: application code (base image issues: fix by bumping FROM)
Enabling it. The job needs an image that already exists in a registry, so it must run after the build job:
stages:
- build
- test
include:
- template: Jobs/Container-Scanning.gitlab-ci.yml # adds the "container_scanning" job in stage "test"
build-image:
stage: build
image: docker:27
services: [docker:27-dind]
script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
- docker build -t "$CI_REGISTRY_IMAGE/$CI_COMMIT_REF_SLUG:$CI_COMMIT_SHA" .
- docker push "$CI_REGISTRY_IMAGE/$CI_COMMIT_REF_SLUG:$CI_COMMIT_SHA"
container_scanning:
variables:
CS_IMAGE: "$CI_REGISTRY_IMAGE/$CI_COMMIT_REF_SLUG:$CI_COMMIT_SHA" # the image just pushed (this is the default)
CS_DOCKERFILE_PATH: "Dockerfile" # enables base image upgrade suggestions
CS_SEVERITY_THRESHOLD: "MEDIUM" # ignore LOW and UNKNOWN findings
| Variable | Purpose |
|---|---|
CS_IMAGE | Image to scan. Defaults to $CI_REGISTRY_IMAGE/$CI_COMMIT_REF_SLUG:$CI_COMMIT_SHA |
CS_REGISTRY_USER, CS_REGISTRY_PASSWORD | Credentials for a registry other than the project's own |
CS_DOCKERFILE_PATH | Lets the scanner suggest a base image fix |
CS_SEVERITY_THRESHOLD | Minimum severity to report |
CS_DISABLE_LANGUAGE_VULNERABILITY_SCAN | Language packages inside the image are skipped by default to avoid duplicates with dependency scanning. Set "false" to scan them |
CS_IGNORE_UNFIXED | "true" hides vulnerabilities that have no fix available yet |
The job also produces a CycloneDX SBOM of the image, so OS packages appear in the dependency list.
Other ways to scan images (Ultimate).
| Method | What triggers it | Use case |
|---|---|---|
Pipeline container_scanning job | Each pipeline | Images you build |
| Container scanning for registry | Pushing a latest tag to the project's container registry | Images built outside GitLab CI, or not rebuilt often |
| Operational container scanning | Schedule, through the GitLab agent for Kubernetes | What is actually running in your clusters |
Reducing findings at the source. Most container findings come from the base image. Prefer small, frequently updated bases (-slim, Alpine, distroless), rebuild images on a schedule even without code changes, and use multi-stage builds so that compilers and build tools never reach the runtime image.
Key takeaways
- Container scanning finds vulnerable OS packages and libraries in the built image, using Trivy.
- The job must run after the image is pushed.
CS_IMAGEdefaults to the image for the current branch and commit. - Set
CS_DOCKERFILE_PATHfor base image upgrade advice, and use thresholds orCS_IGNORE_UNFIXEDto manage noise. - Registry scanning and operational scanning (Ultimate) cover images that are not rebuilt by your pipelines.
Practice
- (Beginner) Build an image
FROM python:3.8(an old base), scan it, and count the findings by severity. Change the base to a current-slimtag and compare.- (Intermediate) Scan an image hosted in an external registry using
CS_REGISTRY_USERandCS_REGISTRY_PASSWORDfrom masked variables, and add a scheduled pipeline that rebuilds and rescans the image weekly.- Interview question: "Container scanning reports 150 vulnerabilities in an image, but the team says most are not exploitable. How do you make the results actionable?" Hint: base image choice, multi-stage builds, unfixed versus fixable findings, severity thresholds, and dismissing with a documented reason.
Infrastructure as Code Scanning
Why it matters. Many cloud breaches are not caused by code bugs but by configuration: a storage bucket readable by everyone, a security group open to 0.0.0.0/0 on port 22, an unencrypted database, a Kubernetes pod running as root. When infrastructure is defined in Terraform, CloudFormation or Kubernetes manifests, these mistakes are visible in the repository before anything is provisioned.
What it is. IaC scanning is a SAST variant for configuration files, run by the KICS analyzer. It runs on all tiers and reports in the SAST format (artifacts:reports:sast), so its findings appear alongside SAST findings.
| Supported formats (examples) | Typical findings |
|---|---|
| Terraform, OpenTofu | Public S3 buckets, missing encryption, open security groups |
| AWS CloudFormation, Azure Resource Manager, Google Deployment Manager | Same classes of issue for each cloud's resources |
| Kubernetes manifests, Helm charts | Privileged containers, missing resource limits, runAsRoot |
| Dockerfile | USER root, latest tags, ADD from URLs |
| Ansible, OpenAPI | Insecure modules, missing authentication definitions |
Enabling it.
include:
- template: Jobs/SAST-IaC.gitlab-ci.yml # adds the "kics-iac-sast" job in stage "test"
variables:
SAST_EXCLUDED_PATHS: "examples, modules/legacy" # IaC scanning honors the SAST exclusion list
An example finding. For this Terraform:
resource "aws_security_group" "web" {
name = "web"
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"] # SSH open to the internet: flagged as High
}
}
resource "aws_s3_bucket_public_access_block" "assets" {
bucket = aws_s3_bucket.assets.id
block_public_acls = false # flagged: allows public ACLs on the bucket
restrict_public_buckets = false
}
KICS reports each issue with the file, line, a description and remediation guidance, for example "Security group allows SSH access from 0.0.0.0/0".
Disabling rules. Use the same .gitlab/sast-ruleset.toml as SAST, with a kics section and KICS query IDs:
[kics]
[[kics.ruleset]]
disable = true # our bastion host is meant to be reachable on 22
[kics.ruleset.identifier]
type = "kics_id"
value = "65905cec-d691-4320-b320-2000436cb696"
For a single resource, prefer an inline KICS annotation in the file (# kics-scan ignore-line) with a comment explaining why. Disabling a rule project-wide hides future mistakes too.
Where IaC scanning fits. It complements terraform plan review (chapter 12): the scanner checks the definition against best practices, and the plan shows what will change. Policy-as-code tools such as OPA/Conftest can enforce organization-specific rules in addition.
Key takeaways
- IaC scanning applies static analysis to Terraform, CloudFormation, Kubernetes, Dockerfiles, Ansible and more, using KICS.
- Include
Jobs/SAST-IaC.gitlab-ci.yml. Findings use the SAST report type. - Misconfigurations caught in the merge request never reach a cloud account.
- Disable rules narrowly (inline annotations, single query IDs) and document why.
Practice
- (Beginner) Add IaC scanning to a repository with a Kubernetes Deployment that runs a privileged container without resource limits. Read the findings and fix both.
- (Intermediate) Scan a Terraform module that creates an S3 bucket. Fix the findings related to encryption, versioning and public access, and suppress one accepted finding with an inline annotation and a justification.
- Interview question: "How is scanning Terraform code different from scanning a live cloud account, and why would you want both?" Hint: drift, resources created by hand, findings before versus after provisioning, and modules from external registries.
Dynamic Application Security Testing (DAST)
Why it matters. Static scanners see code, but some vulnerabilities only appear when the application runs with its real configuration: a missing Content-Security-Policy header, a session cookie without Secure, a reflected XSS created by how a template engine and a framework interact, a verbose stack trace on errors. DAST tests the running application from the outside, the way an attacker would, without access to the source.
What it is. GitLab DAST (Ultimate) uses a browser-based analyzer. It drives a headless Chromium browser to crawl the application, including JavaScript-heavy single-page apps, records the pages and requests it finds, and then checks them.
| Scan mode | What it does | Safe for shared environments? |
|---|---|---|
| Passive (default) | Observes responses during the crawl: headers, cookies, information leaks | Yes, it only browses |
Active (DAST_FULL_SCAN: "true") | Sends attack payloads (XSS, SQL injection, path traversal, ...) to every input | No. It can create data, trigger emails and break things. Use disposable environments only |
Typical pipeline: scan the review app. DAST needs a URL. The standard pattern deploys a review app (chapter 12) and scans it before merge:
stages:
- build
- deploy
- dast # the DAST template uses this stage; it must exist
include:
- template: DAST.gitlab-ci.yml
deploy-review:
stage: deploy
script:
- ./deploy.sh "review-$CI_MERGE_REQUEST_IID"
- echo "DAST_TARGET_URL=https://review-$CI_MERGE_REQUEST_IID.apps.example.com" >> deploy.env
artifacts:
reports:
dotenv: deploy.env # dotenv variables reach later jobs and override YAML variables
environment:
name: review/$CI_MERGE_REQUEST_IID
url: https://review-$CI_MERGE_REQUEST_IID.apps.example.com
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
dast:
needs: [deploy-review] # receive DAST_TARGET_URL from the dotenv report
variables:
DAST_FULL_SCAN: "true" # active scan: acceptable on a throwaway review app
DAST_SCOPE_EXCLUDE_URLS: "https://review-$CI_MERGE_REQUEST_IID.apps.example.com/logout"
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
Authenticated scans. Most of an application sits behind a login. Without authentication, DAST only tests the login page. Give it a dedicated test account:
dast:
variables:
DAST_AUTH_URL: "$DAST_TARGET_URL/login"
DAST_AUTH_USERNAME: "dast-bot@example.com"
DAST_AUTH_USERNAME_FIELD: "css:input[name=email]" # how to find the login form fields
DAST_AUTH_PASSWORD_FIELD: "css:input[name=password]"
DAST_AUTH_SUBMIT_FIELD: "css:button[type=submit]"
# DAST_AUTH_PASSWORD is defined as a masked CI/CD variable in project settings, never in YAML
On-demand scans. Besides pipeline scans, Secure > On-demand scans runs DAST manually or on a schedule using reusable site profiles (target URL, authentication) and scanner profiles (passive/active, timeouts). Active on-demand scans require proving ownership of the site (site validation), so DAST cannot be pointed at someone else's server.
Practical limits. DAST is slow (minutes to hours) and needs a deployed environment, so it rarely runs on every commit of every branch. Run a passive scan on merge requests, an active scan on schedules against a staging environment, and never an active scan against production.
Key takeaways
- DAST (Ultimate) tests the running application from the outside with a browser-based crawler, finding runtime and configuration issues static scanners miss.
- Passive scans are safe. Active scans (
DAST_FULL_SCAN) send attacks and belong only on disposable environments. - Point DAST at review apps through a dotenv report, and configure authentication so it reaches pages behind the login.
- On-demand scans with site and scanner profiles cover scheduled and manual testing outside the pipeline.
Practice
- (Beginner) Run a passive DAST scan against an intentionally vulnerable app (for example OWASP Juice Shop deployed as a review app or service). List the header and cookie findings.
- (Intermediate) Configure an authenticated, active scan with a dedicated test user, exclude the logout URL, and compare the number of pages crawled with and without authentication.
- Interview question: "Why would you run both SAST and DAST, and what kinds of issues does each one miss?" Hint: code visibility versus runtime behavior, configuration and deployment issues, false positive rates, and when each can run in the pipeline.
API Security Testing
Why it matters. Modern applications expose much of their logic through REST and GraphQL APIs consumed by SPAs, mobile apps and partners. A browser crawler cannot discover an API endpoint that no page calls, and it does not know which parameters an endpoint accepts. API security testing starts from a description of the API instead of crawling, so it can test every operation and every parameter.
What it is. API security testing (formerly "DAST API", Ultimate) reads an API definition, sends requests built from it, and mutates parameters, headers and bodies with attack payloads to detect injection, authentication and authorization flaws, and information disclosure.
| Input | Variable | Notes |
|---|---|---|
| OpenAPI v2/v3 spec | APISEC_OPENAPI | Most common. A file in the repo or a URL |
| HAR file | APISEC_HAR | Recorded real traffic, useful when no spec exists |
| Postman collection | APISEC_POSTMAN_COLLECTION | Reuses existing API tests |
| GraphQL | APISEC_GRAPHQL | Uses the schema through introspection or a file |
Enabling it.
stages:
- build
- deploy
- dast # API security testing also runs in "dast"
include:
- template: API-Security.gitlab-ci.yml # adds the "api_security" job
variables:
APISEC_PROFILE: Quick-10 # test intensity: Quick-10, Medium-20, Medium-50, Long-100
APISEC_OPENAPI: openapi/v1.yaml # API definition in the repository
APISEC_TARGET_URL: https://staging-api.example.com
Authentication. APIs usually require a token. A common pattern is a script that fetches a short-lived token and writes it to an overrides file, which the scanner re-reads periodically:
api_security:
variables:
APISEC_OVERRIDES_FILE: api-overrides.json # headers added to every request
APISEC_OVERRIDES_CMD: ./ci/get-api-token.sh # regenerates the file
APISEC_OVERRIDES_INTERVAL: 300 # seconds between regenerations
#!/usr/bin/env sh
# ci/get-api-token.sh: obtain a token for the dedicated test client and write the overrides file
TOKEN=$(curl --silent -X POST "$APISEC_TARGET_URL/oauth/token" \
-d "grant_type=client_credentials&client_id=$API_CLIENT_ID&client_secret=$API_CLIENT_SECRET" | jq -r .access_token)
printf '{"headers": {"Authorization": "Bearer %s"}}' "$TOKEN" > api-overrides.json
Choosing a profile. The profile sets how many checks run and how many variations each sends. Quick-10 fits merge request pipelines. Long-100 belongs in scheduled pipelines against a staging environment. Like active DAST, API security testing sends attack traffic: target dedicated environments and test accounts only.
API fuzzing. A related scanner, web API fuzzing (API-Fuzzing.gitlab-ci.yml, FUZZAPI_* variables), sends random and malformed values instead of known attack payloads. It finds crashes and unhandled errors (500 responses, timeouts) rather than known vulnerability classes.
Key takeaways
- API security testing (Ultimate) tests APIs from their definition (OpenAPI, HAR, Postman, GraphQL), reaching endpoints a crawler cannot find.
- Include
API-Security.gitlab-ci.yml, set the spec andAPISEC_TARGET_URL, and choose a profile that fits the pipeline's time budget. - Use the overrides file and command to supply short-lived authentication tokens.
- It sends attack traffic, so run it only against dedicated, non-production environments.
Practice
- (Beginner) Point API security testing with
Quick-10at a sample API with an OpenAPI spec running as a CI service, and read the findings in the job report.- (Intermediate) Protect the sample API with bearer tokens, write an overrides script that fetches a token, and confirm in the job log that requests are authenticated (no 401 responses).
- Interview question: "Your API has no OpenAPI specification. How would you still test it automatically, and what would you change in the development process?" Hint: HAR recordings, Postman collections, generating specs from code, and treating the spec as a contract.
Coverage-Guided Fuzz Testing
Why it matters. Parsers, decoders, protocol handlers and anything that reads untrusted bytes often fail on inputs nobody thought to test: a truncated header, a length field larger than the buffer, a deeply nested structure. Unit tests check the cases you imagined. Fuzzing generates millions of inputs to find the ones you did not, and coverage guidance makes the search efficient.
What it is. A fuzz target is a function that receives arbitrary bytes and feeds them to the code under test. The fuzzer (libFuzzer, go-fuzz, Jazzer, Atheris, ...) instruments the program to see which code paths each input reaches. Inputs that reach new paths are kept in a corpus and mutated further, so the fuzzer gradually explores deeper code. A crash, sanitizer error or hang is reported with the input that caused it.
┌──────────────── mutate ◄────────────────┐
▼ │
corpus ──► input ──► fuzz target ──► coverage feedback
│ new path? ──► keep input in corpus
▼
crash / ASan error / timeout ──► report + reproducer input
GitLab's coverage-guided fuzz testing (Ultimate) wraps these fuzzers in a CI job, uploads crashes as findings, and stores corpora between runs.
GitLab has announced the deprecation of coverage-guided fuzz testing. Check the deprecation notices for your GitLab version before investing in it. The fuzz targets themselves are standard (libFuzzer and language-native fuzzing) and keep working outside GitLab.
A libFuzzer target in C.
// fuzz_parse_header.c
#include <stdint.h>
#include <stddef.h>
#include "header.h"
// libFuzzer calls this function with each generated input
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
struct header h;
parse_header(data, size, &h); // must never crash or read out of bounds, whatever the input
return 0; // non-zero return values are reserved
}
The CI job. The template provides a hidden job .fuzz_base that downloads the gitlab-cov-fuzz helper and declares the report:
stages:
- build
- test
- fuzz # the fuzz template's jobs use this stage
include:
- template: Coverage-Fuzzing.gitlab-ci.yml
fuzz-header-parser:
extends: .fuzz_base # helper download, report artifacts, REGRESSION variable
image: silkeh/clang:18
variables:
COVFUZZ_SEED_CORPUS: "./fuzz/corpus/header" # example inputs to start mutating from
COVFUZZ_ADDITIONAL_ARGS: "-max_total_time=300"
script:
# -fsanitize=fuzzer links libFuzzer; address adds AddressSanitizer to catch memory errors
- clang -g -O1 -fsanitize=fuzzer,address fuzz_parse_header.c header.c -o fuzz_header
- ./gitlab-cov-fuzz run --regression=$REGRESSION -- ./fuzz_header
Regression versus fuzzing mode. Fuzzing for hours on every merge request is not practical, so the template distinguishes two modes through REGRESSION:
| Mode | When | What it does |
|---|---|---|
Regression (REGRESSION=true) | Merge request and most branch pipelines | Replays the stored corpus and known crashes only. Fast, catches reintroduced bugs |
Fuzzing (REGRESSION=false) | The branch named in COVFUZZ_BRANCH (default: the default branch) | Generates new inputs for the configured time and grows the corpus |
Run long fuzzing sessions on a schedule. Interesting inputs found there become part of the corpus that every merge request replays.
Language-native alternative. Go has built-in fuzzing (go test -fuzz=FuzzParse -fuzztime=60s), and similar tools exist for Rust (cargo fuzz), Java (Jazzer) and Python (Atheris). They can run as ordinary CI jobs with a time limit, independently of the GitLab template.
Key takeaways
- Coverage-guided fuzzing feeds generated inputs to a fuzz target and uses coverage feedback to reach deeper code paths. It excels at parsers and input handling.
- Build targets with sanitizers (
-fsanitize=fuzzer,address) so that memory errors become crashes. - The template runs quick regression replays on merge requests and real fuzzing on the configured branch or schedule.
- The feature is deprecated in GitLab. Write standard fuzz targets so that the investment carries over to other runners.
Practice
- (Beginner) Write a Go fuzz test with
testing.Ffor a function that parseskey=valuepairs, run it locally with-fuzztime=30s, and add the resulting regression inputs to the repository.- (Intermediate) Set up the libFuzzer job above for a small C parser that contains an off-by-one bug. Let fuzzing find the crash, then add the reproducer to the seed corpus and confirm the regression run fails until you fix the bug.
- Interview question: "Which parts of a codebase would you prioritize for fuzzing, and how would you integrate fuzzing into CI without slowing down merge requests?" Hint: trust boundaries and untrusted input, regression versus exploration runs, scheduled pipelines and corpus storage.
<a id="112-vulnerability-management"></a>
11.2 Vulnerability Management
Scanners produce findings. Vulnerability management turns those findings into decisions: which ones block a merge, which ones get fixed first, which ones are accepted risks, and how the overall security posture changes over time. This subchapter covers where GitLab shows scan results (merge requests, project and group reports, dashboards, the dependency list) and the workflow for triaging them. All the views in this subchapter require Ultimate.
A key distinction runs through the whole subchapter:
| Term | Where it lives | Lifecycle |
|---|---|---|
| Finding | A scan report from one pipeline | Exists only for that pipeline. Shown in the MR widget and the pipeline's Security tab |
| Vulnerability | The project's vulnerability record | Created when a finding is detected on the default branch. Has a status, history and comments, and persists across pipelines |
Security Widget in Merge Requests
Why it matters. Developers fix problems most cheaply while the change is still fresh in their mind and not yet merged. If security results live in a separate tool that someone checks weekly, they arrive too late and without context. The merge request widget puts the results of every scanner next to the code review, and shows only what this change introduces.
What it is. The security widget compares the findings of the merge request's latest pipeline with those of the target branch and groups the differences by scanner:
Security scanning detected 3 new potential vulnerabilities [Full report]
─────────────────────────────────────────────────────────────────────────────────────────
▼ SAST 2 new: 1 High, 1 Medium 0 fixed
High SQL injection in app/orders/repository.py:88 (new)
Medium Use of weak hash function MD5 in app/utils/tokens.py:12 (new)
▼ Dependency scanning 1 new: 1 Critical 2 fixed
Critical CVE-2024-XXXXX in jinja2 3.1.2 (new)
▶ Secret detection no new findings
▶ Container scanning no new findings
How the comparison works.
target branch (main) ──●────────● base pipeline: findings A, B, C
\
feature branch ●──● head pipeline: findings A, C, D, E
widget: new = D, E (in head, not in base)
fixed = B (in base, not in head)
A, C are pre-existing and not listed as new
For the comparison to be reliable:
- The target branch needs a recent successful pipeline that ran the same scanners. If
mainnever ran container scanning, every container finding in the MR looks new. - Findings are matched by identity (scanner, rule identifier, location fingerprint), not only by line number, so unrelated line shifts do not create duplicates.
- The widget appears after the MR pipeline finishes and reports are processed.
Acting on a finding. Clicking a finding opens a modal with the description, location, identifiers (CVE, CWE, OWASP), the suggested solution, and actions:
| Action | Effect |
|---|---|
| Dismiss | Records a dismissal reason. The finding is marked dismissed if it reaches the default branch |
| Create issue | Opens an issue linked to the finding, for follow-up work outside this MR |
| Resolve with merge request | For some dependency and container findings, GitLab creates a remediation MR (for example a version bump) |
| Explain / resolve with AI | With GitLab Duo, explains the vulnerability or proposes a code fix in a new MR |
Making findings block the merge. The widget alone is informational. A developer can merge despite a Critical finding. To require approval when the MR introduces vulnerabilities, use a merge request approval policy (section 11.3).
On Free and Premium. The MR widget shows nothing for security scanners, but the reports can still be downloaded from the jobs. Teams sometimes fail the job themselves, for example with jq on the report, as a basic gate:
semgrep-sast:
artifacts:
paths: [gl-sast-report.json] # also keep the report as a regular artifact so later jobs receive it
sast-gate:
stage: .post
image: alpine:3.20
needs: [semgrep-sast] # downloads gl-sast-report.json from the paths artifact
script:
- apk add --no-cache jq
# Count Critical and High findings; exit 1 if any
- COUNT=$(jq '[.vulnerabilities[] | select(.severity == "Critical" or .severity == "High")] | length' gl-sast-report.json)
- echo "Critical/High findings: $COUNT"
- test "$COUNT" -eq 0
This gate fails on all findings, including old ones, because it has no target branch comparison. That is the main limitation compared with the Ultimate widget and policies.
Key takeaways
- The security widget (Ultimate) shows new and fixed findings per scanner by comparing the MR's pipeline with the target branch.
- The target branch must run the same scanners recently, or everything looks new.
- From the widget you can dismiss, create issues, request automatic remediation, or ask GitLab Duo for help.
- The widget does not block merges. Approval policies do.
Practice
- (Beginner) In an Ultimate project with SAST enabled, open an MR that adds an obviously unsafe call and another MR that removes an existing finding. Compare how each appears in the widget (new versus fixed).
- (Intermediate) Enable container scanning only in the MR (not on
main) and observe how all container findings appear as new. Then run a pipeline onmainwith the scanner and rerun the MR pipeline. Explain the difference.- Interview question: "A team complains that the MR security widget shows dozens of 'new' findings for a one-line change. What would you investigate?" Hint: the base pipeline on the target branch, scanner configuration differences between branches, analyzer version changes, and moved files.
Vulnerability Report
Why it matters. Findings in merge requests are temporary. Once code is merged, its vulnerabilities need an owner, a status and a history: who looked at this, what was decided, is it still present? The vulnerability report is the system of record for vulnerabilities on the default branch.
What it is. Secure > Vulnerability report exists at project, group and (through the Security Center) instance level. It lists every vulnerability detected on the default branch, with filters and bulk actions.
Vulnerability report ─ shop/checkout-service
Severity: Critical (2) High (11) Medium (37) Low (20) Info (4)
Filters: Status = Needs triage, Confirmed │ Tool = SAST, Dependency scanning │ Activity = Still detected
Detected Status Severity Description Identifier Tool
2026-10-02 Needs triage Critical CVE-2026-1234 in libxml2 2.9.14 CVE-2026-1234 Container scanning
2026-09-28 Confirmed High SQL injection in orders/repository.py CWE-89 SAST
2026-09-15 Needs triage High Hardcoded AWS secret in legacy/cfg.py AWS key Secret detection
Vulnerability statuses.
| Status | Meaning |
|---|---|
| Needs triage | Default for newly detected vulnerabilities. Nobody has assessed it yet |
| Confirmed | Assessed as a real problem that must be fixed |
| Dismissed | Assessed and not acted on, with a reason: acceptable risk, false positive, mitigating control, used in tests, not applicable |
| Resolved | Fixed and verified |
Separately from the status, the activity shows whether the latest default branch pipeline still detects the vulnerability. "No longer detected" does not mean resolved: the code may have moved, the scanner may have been disabled, or the vulnerable path excluded. Someone should verify before setting Resolved. On Ultimate, a vulnerability management policy can automatically resolve vulnerabilities that are no longer detected, for selected scanners and severities.
The vulnerability details page. Each vulnerability has its own page with:
- Description, location (file and line, image and package, or URL), and identifiers (CVE, CWE, scanner rule)
- For CVEs, exploitability context such as the EPSS score and whether the CVE is in CISA's Known Exploited Vulnerabilities catalog
- Solution or fixed version, links, and evidence (for DAST, the request and response)
- Status history and comments, linked issues, and merge requests that resolve it
- Severity override with a justification, for when the scanner's default severity does not fit your context
Bulk operations and export. Select multiple rows to change status in one action (for example, dismiss all findings in a vendored directory as not applicable). Export produces a CSV for audits or spreadsheets.
Querying through the API. The GraphQL API exposes vulnerabilities for automation and reporting:
query criticalOpenVulns {
group(fullPath: "shop") {
vulnerabilities(severity: [CRITICAL, HIGH], state: [DETECTED, CONFIRMED], first: 50) {
nodes {
title
severity
state # DETECTED is shown as "Needs triage" in the UI
reportType # SAST, DEPENDENCY_SCANNING, CONTAINER_SCANNING, ...
detectedAt
project { fullPath }
identifiers { externalType externalId } # e.g. cve / CVE-2026-1234
}
}
}
}
Key takeaways
- The vulnerability report (Ultimate) is the persistent record of vulnerabilities on the default branch, at project, group and instance level.
- Statuses are Needs triage, Confirmed, Dismissed (with a reason) and Resolved. "No longer detected" is a signal, not a resolution.
- The details page gives location, identifiers, exploitability context, solution and an audit trail of decisions.
- Use filters and bulk actions for triage, and CSV export or GraphQL for reporting.
Practice
- (Beginner) In a project with several vulnerabilities, filter the report to Critical and High that still need triage. Confirm one, and dismiss another as used in tests with a comment.
- (Intermediate) Write a script that runs the GraphQL query above for your group and prints a count of open Critical and High vulnerabilities per project, sorted descending.
- Interview question: "What is the difference between a finding in a merge request and a vulnerability in the vulnerability report, and why does GitLab keep them separate?" Hint: branches that are never merged, the default branch as the source of truth, and where triage decisions are stored.
Security Dashboard
Why it matters. A list of 2,000 vulnerabilities answers "what exactly is wrong", but security leads and engineering managers also need trends: are we getting better or worse, which projects are the riskiest, how fast do we fix critical issues? The security dashboard summarizes the vulnerability data for those questions.
What it is. Secure > Security dashboard at project and group level (Ultimate) shows charts built from the vulnerability report:
| Panel | Question it answers |
|---|---|
| Vulnerabilities over time, by severity | Is our open vulnerability count growing or shrinking? |
| Vulnerabilities by report type / scanner | Where do our problems come from: code, dependencies, images? |
| Project security status (group level) | Which projects carry the most severe open vulnerabilities? |
| Age and remediation metrics (newer versions) | How long do vulnerabilities stay open by severity? |
At group level, projects are graded by the most severe open vulnerability they contain:
| Grade | Most severe open vulnerability |
|---|---|
| F | Critical |
| D | High or Unknown |
| C | Medium |
| B | Low |
| A | None |
Group security dashboard ─ shop
Open vulnerabilities (90 days)
120 ┤ ╭─╮
90 ┤ ╭────╯ ╰──╮
60 ┤───╯ ╰────╮ ← dependency upgrade sprint in September
30 ┤ ╰───────
└──────────────────────────
Jul Aug Sep Oct
Project grades: F: payments-api D: checkout-service, search C: 4 projects A: 12 projects
Related views.
- Security Center (instance level, from the user menu): a personal dashboard where you add the projects you care about across groups.
- Security inventory (group level, recent versions): shows which scanners are enabled in each project, which reveals coverage gaps. A project with grade A and no scanners is not secure, it is unscanned.
- Security configuration (project level, Secure > Security configuration): shows which scanners are enabled for a single project and links to their setup.
Reading the dashboard well. Raw counts can mislead. A rising count after enabling a new scanner reflects better visibility, not worse security. Watch the trend of critical and high vulnerabilities, the time to remediate them, and scanner coverage together.
Key takeaways
- The security dashboard (Ultimate) charts vulnerability counts over time and by severity and scanner, at project and group level.
- Group grades (A to F) rank projects by their most severe open vulnerability.
- Combine the dashboard with scanner coverage (security inventory or configuration). An unscanned project looks clean.
- Interpret trends in context: new scanners and new advisories increase counts without new risk being introduced by developers.
Practice
- (Beginner) Open the group security dashboard and list the projects graded D or F. For one of them, open the vulnerability report and find the vulnerability that determines its grade.
- (Intermediate) Define three metrics for a monthly security review (for example open Critical count, median days to resolve High, percentage of projects with SAST and dependency scanning). Describe which GitLab view or API provides each.
- Interview question: "Leadership asks for one number that shows whether application security is improving. What do you propose, and what are its weaknesses?" Hint: counts versus remediation time, severity weighting, coverage, and how teams can game a single metric.
Dependency List and SBOM
Why it matters. When a new critical CVE is announced, the first question is "do we use this component, and where?" Answering it by grepping hundreds of repositories is slow and misses transitive dependencies and OS packages in images. A software bill of materials (SBOM) is a machine-readable inventory of every component in a piece of software, like an ingredients label on food. Customers and regulations (for example US Executive Order 14028 and the EU Cyber Resilience Act) increasingly require one.
What it is. Dependency scanning and container scanning produce CycloneDX SBOMs (artifacts:reports:cyclonedx). GitLab stores the SBOMs from default branch pipelines and shows them in Secure > Dependency list (Ultimate) at project and group level.
A trimmed CycloneDX document:
{
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"metadata": {
"timestamp": "2026-10-15T09:12:44Z",
"tools": { "components": [{ "name": "gitlab-dependency-scanning" }] }
},
"components": [
{
"name": "jinja2",
"version": "3.1.2",
"purl": "pkg:pypi/jinja2@3.1.2",
"licenses": [{ "license": { "id": "BSD-3-Clause" } }]
},
{
"name": "markupsafe",
"version": "2.1.3",
"purl": "pkg:pypi/markupsafe@2.1.3"
}
],
"dependencies": [
{ "ref": "pkg:pypi/jinja2@3.1.2", "dependsOn": ["pkg:pypi/markupsafe@2.1.3"] }
]
}
The purl (package URL) identifies a component unambiguously across ecosystems (pkg:npm/..., pkg:maven/..., pkg:deb/...). It is what lets tools match components to advisories.
The dependency list.
Dependency list ─ group "shop" [Export ▾]
Search: jinja2
Component Version Packager Location License Vulnerabilities
jinja2 3.1.2 pip checkout-service: poetry.lock BSD-3-Clause 1 High
jinja2 3.1.4 pip search: requirements.txt BSD-3-Clause 0
jinja2 3.1.2 pip reports-job: Pipfile.lock BSD-3-Clause 1 High
(via flask → jinja2) ← dependency path for transitive components
You can filter by component name, packager, license and project, and see the dependency path for transitive components.
Exporting. The dependency list can be exported as a CycloneDX SBOM (project level) or CSV, from the UI or the API:
# Start an export of the project's dependency list as a CycloneDX SBOM
EXPORT_ID=$(curl --silent --request POST --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.example.com/api/v4/projects/12345/dependency_list_exports?export_type=sbom" | jq -r '.id')
# Download it once it is ready (poll the export until has_finished is true)
curl --silent --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.example.com/api/v4/dependency_list_exports/$EXPORT_ID/download" -o sbom.cdx.json
Bringing your own SBOM. Any tool that writes CycloneDX JSON can feed GitLab. For example, an SBOM generated by Syft or by your build tool:
sbom-syft:
stage: test
image:
name: anchore/syft:v1.14.0
entrypoint: [""]
script:
- syft dir:. -o cyclonedx-json=gl-sbom-syft.cdx.json # file name must match the report glob
artifacts:
reports:
cyclonedx: gl-sbom-*.cdx.json
Licenses. Because each component carries its license, GitLab can enforce license rules: a merge request approval policy of type license_finding (section 11.3) requires approval when a change adds a dependency under a denied license such as AGPL in a proprietary product.
Key takeaways
- An SBOM is a machine-readable inventory of components. GitLab produces CycloneDX SBOMs from dependency and container scanning.
- The dependency list (Ultimate) shows components, versions, locations, licenses and vulnerabilities, searchable across a group.
- Export SBOMs from the UI or API for customers and audits. Import third-party CycloneDX output through
artifacts:reports:cyclonedx. - License data in the SBOM powers license approval policies.
Practice
- (Beginner) After running dependency scanning on the default branch, find one transitive dependency in the dependency list and identify which direct dependency brings it in.
- (Intermediate) Export the project's SBOM through the API and write a
jqcommand that lists every component whose license is missing or unknown.- Interview question: "A customer contract requires an SBOM with every release. How would you produce, store and verify it in GitLab CI/CD?" Hint: generate on tag pipelines, attach it to the release, sign or attest it (section 11.4), and keep it consistent with what was actually shipped.
Vulnerability Triage Workflow
Why it matters. Scanners are cheap to enable and generate a lot of output. Without a process, findings pile up, nobody knows which ones matter, developers learn to ignore the security tab, and the one critical issue is lost in the noise. Triage is the routine that turns raw output into prioritized, owned work, like a hospital emergency room that decides who is treated first based on severity, not arrival order.
What it is. A triage workflow defines who reviews new vulnerabilities, how they are prioritized, what decisions are allowed, and how fast each severity must be handled.
Detected on default branch
│
▼
┌─────────────┐ duplicate / false positive / test code
│ Needs triage│ ─────────────────────────────────────────► Dismissed (reason + comment)
└──────┬──────┘
│ real issue
▼
┌─────────────┐ risk accepted by owner, or mitigated elsewhere
│ Confirmed │ ─────────────────────────────────────────► Dismissed (acceptable risk / mitigating control)
└──────┬──────┘
│ issue created, assigned, SLA starts
▼
fix merged → next default branch scan: "No longer detected"
│
▼
verified → Resolved
Step 1: assess the real risk. The scanner's severity is generic. Adjust for your context:
| Question | Raises priority | Lowers priority |
|---|---|---|
| Is the vulnerable code reachable? | Called on a request path | Dead code, test helper, build-time only tool |
| Is it exposed? | Internet-facing service | Internal batch job with no untrusted input |
| Is it being exploited? | In CISA KEV, high EPSS score, public exploit | Theoretical, no known exploit |
| What is the impact? | Customer data, credentials, payment | Non-sensitive demo service |
| Is there a fix? | Patch available, easy upgrade | No fixed version yet (track, mitigate) |
Step 2: decide. Every vulnerability ends in one of a small set of decisions, recorded in GitLab so that the next person does not repeat the analysis:
| Decision | Status in GitLab | Required record |
|---|---|---|
| Fix | Confirmed, then Resolved | Linked issue with assignee and due date |
| Not a real issue | Dismissed: false positive | Comment explaining why, ideally a rule tuning change too |
| Real but accepted | Dismissed: acceptable risk | Owner's approval and a review date |
| Mitigated elsewhere | Dismissed: mitigating control | Which control (WAF rule, network policy, input validation upstream) |
| Test or example code | Dismissed: used in tests | Consider excluding the path from scanning instead |
Step 3: remediation SLAs. Typical targets, measured from detection:
| Severity | Example SLA | Typical gate |
|---|---|---|
| Critical | 7 days (24-72 hours if actively exploited) | Blocks merges that introduce new ones |
| High | 30 days | Blocks merges, or requires security approval |
| Medium | 90 days | Tracked in backlog |
| Low / Info | Best effort | Reviewed periodically |
Automating parts of the workflow.
- Merge request approval policies stop new Critical and High vulnerabilities from being merged without approval (section 11.3), so triage deals mostly with existing debt and new advisories.
- Vulnerability management policies automatically resolve vulnerabilities that are no longer detected by selected scanners.
- Issues created from vulnerabilities carry a link back, and closing the MR that fixes the code can be tracked on the vulnerability page.
- GraphQL mutations let scripts apply consistent decisions, for example dismissing all findings in a generated directory:
mutation dismissGenerated {
vulnerabilityDismiss(input: {
id: "gid://gitlab/Vulnerability/48211"
dismissalReason: NOT_APPLICABLE # ACCEPTABLE_RISK, FALSE_POSITIVE, MITIGATING_CONTROL, USED_IN_TESTS
comment: "Generated protobuf stubs; excluded from scanning in !812"
}) {
vulnerability { state }
errors
}
}
Ownership. Assign triage explicitly: for example, each team triages its own projects weekly, and the security team reviews all Critical vulnerabilities and every acceptable risk dismissal. Unowned vulnerabilities are never triaged.
Key takeaways
- Triage moves each vulnerability from Needs triage to a recorded decision: fix, false positive, accepted risk, mitigated or test code.
- Prioritize by real risk (reachability, exposure, active exploitation, impact), not only by the scanner's severity.
- Define SLAs per severity and track them. Use approval policies so that new debt is blocked at the merge request.
- Record every dismissal with a reason and comment, and turn repeated false positives into rule tuning.
Practice
- (Beginner) Take five vulnerabilities from a project's report and triage them using the risk table above. Record each decision in GitLab with the matching status, reason and comment.
- (Intermediate) Write a script that lists Confirmed vulnerabilities older than their SLA (Critical 7 days, High 30 days) using the GraphQL API, and posts the list to a team channel or issue on a schedule.
- Interview question: "How would you roll out security scanning to 200 existing projects without overwhelming the development teams?" Hint: baseline existing vulnerabilities, block only new ones first, phase in scanners and severities, ownership, and measuring adoption.
<a id="113-security-policies-and-compliance"></a>
11.3 Security Policies and Compliance
Scanners and dashboards help teams that choose to use them. Organizations also need guarantees: every production project runs secret detection, no merge request adds a critical vulnerability without security approval, every pipeline runs the mandatory compliance checks, and every change to these rules is logged. This subchapter covers how GitLab enforces such rules centrally, so that individual projects cannot remove them, and how compliance frameworks and audit events provide the evidence auditors ask for.
Where policies live. Security policies are YAML stored in a dedicated security policy project, linked to a group or project under Secure > Policies. A policy linked at group level applies to every project below it. Developers of the target projects do not need, and usually do not have, write access to the policy project.
security-policies (project) linked to
.gitlab/security-policies/policy.yml ───────────────────► group "shop"
scan_execution_policy: [...] ├── checkout-service ┐
pipeline_execution_policy: [...] ├── payments-api ├─ policies enforced,
approval_policy: [...] └── search ┘ cannot be removed locally
| Policy type | Enforces | Tier |
|---|---|---|
| Scan execution policy | That specific scanners run | Ultimate |
| Pipeline execution policy | That custom CI jobs run (any jobs, not only scanners) | Ultimate |
| Merge request approval policy | That MRs with certain findings or conditions need approval | Ultimate |
| Vulnerability management policy | Automatic status changes, such as resolving no-longer-detected vulnerabilities | Ultimate |
Scan Execution Policies
Why it matters. Adding include: template: Jobs/SAST.gitlab-ci.yml to 300 projects is tedious, and any project can delete the line again. A security team needs to say "every project in this group runs SAST and secret detection" once, and have it hold.
What it is. A scan execution policy injects GitLab scanner jobs into pipelines of the projects in scope, or runs them on a schedule, regardless of what the project's .gitlab-ci.yml contains. Projects without a CI configuration get a pipeline anyway.
# .gitlab/security-policies/policy.yml in the security policy project
scan_execution_policy:
- name: Baseline scanners on every pipeline
description: SAST, secret detection and dependency scanning for all branches
enabled: true
rules:
- type: pipeline # run whenever a pipeline runs...
branch_type: all # ...on any branch (also: default, protected)
actions:
- scan: sast
- scan: secret_detection
- scan: dependency_scanning
skip_ci:
allowed: false # "[skip ci]" cannot be used to bypass the scans
- name: Nightly container scanning
description: Scan the default branch image every night
enabled: true
rules:
- type: schedule
branch_type: default
cadence: "0 2 * * *" # cron syntax, evaluated in UTC by default
actions:
- scan: container_scanning
variables:
CS_IMAGE: "$CI_REGISTRY_IMAGE:latest"
- name: DAST on staging for regulated projects
enabled: true
rules:
- type: pipeline
branches: [main]
actions:
- scan: dast
site_profile: staging-site # on-demand DAST profiles defined in the projects
scanner_profile: passive-scan
policy_scope:
compliance_frameworks:
- id: 3 # only projects labeled with this framework (see below)
Supported scans. sast, sast_iac, secret_detection, dependency_scanning, container_scanning and dast. You can pass variables to each action, and choose the template version (template: latest or the default stable one).
How it behaves in a pipeline.
- Injected jobs have names such as
sast-0orsecret-detection-0and run in theteststage (or a policy-specific stage if the project lacks one). Their configuration cannot be overridden by the project's.gitlab-ci.yml. - If the project already includes the same scanner, the policy's job takes precedence for that scan, so results are not duplicated.
- Variables set in the action override project-defined variables for that scanner, so a project cannot weaken the scan with its own
SAST_EXCLUDED_PATHS.
Scoping. policy_scope limits a group-level policy to projects with certain compliance frameworks, to included or excluded project lists, or to specific groups. Use it to apply heavier scans only where they are needed.
Creating policies. The policy editor under Secure > Policies > New policy offers a form and a YAML mode. Saving a policy opens a merge request in the policy project, so policy changes themselves go through review.
Key takeaways
- Scan execution policies (Ultimate) force GitLab scanners into pipelines or schedules across groups and projects, independent of each project's
.gitlab-ci.yml. - Policies live in a separate security policy project. Changes go through merge requests there.
- Rules choose when to run (
pipelinewith branch filters, orschedulewith a cron cadence). Actions choose which scanners, with optional variables. policy_scopetargets subsets of projects, for example by compliance framework.
Practice
- (Beginner) Create a security policy project, link it to a test group, and add a policy that runs secret detection on all branches. Confirm that a project with no
.gitlab-ci.ymlgets a pipeline with the injected job.- (Intermediate) Add a scheduled policy that runs dependency scanning on the default branch weekly, scoped to projects with a specific compliance framework. Verify it does not run in projects without the framework.
- Interview question: "Why would a security team prefer a scan execution policy over asking every team to include the scanner templates?" Hint: bypass by editing
.gitlab-ci.yml, consistency of settings, new projects, and separation of duties.
Pipeline Execution Policies
Why it matters. Scan execution policies can only add GitLab's own scanners. Organizations often need to enforce other jobs too: a third-party scanner, a license check, recording build evidence in a central system, a deployment freeze check, or a job that verifies a signed commit. Pipeline execution policies enforce any CI configuration across projects.
What it is. A pipeline execution policy points to a CI configuration file stored in another project and merges it into the pipelines of every project in scope. It replaces the older compliance pipelines feature, which is deprecated.
# .gitlab/security-policies/policy.yml
pipeline_execution_policy:
- name: Organization-wide CI controls
description: License check and build evidence for every pipeline
enabled: true
pipeline_config_strategy: inject_policy # add the policy jobs to the project's own pipeline
content:
include:
- project: platform/ci-policies # project holding the enforced CI configuration
file: enforced/policy-ci.yml
ref: v2.4.0 # pin to a tag so changes are deliberate
skip_ci:
allowed: false
policy_scope:
compliance_frameworks:
- id: 3
# platform/ci-policies: enforced/policy-ci.yml
policy-license-check:
stage: .pipeline-policy-pre # reserved stage: runs before every project stage
image: registry.example.com/tools/license-checker:1.8
script:
- license-checker --fail-on "AGPL-3.0-only,SSPL-1.0"
policy-evidence:
stage: .pipeline-policy-post # reserved stage: runs after every project stage
image: curlimages/curl:8.10.1
script:
# Record what was built, from which commit and by whom, in the central compliance service
- >
curl --fail -X POST "$COMPLIANCE_SERVICE_URL/evidence"
-H "Content-Type: application/json"
-d "{\"project\":\"$CI_PROJECT_PATH\",\"sha\":\"$CI_COMMIT_SHA\",\"pipeline\":\"$CI_PIPELINE_URL\",\"user\":\"$GITLAB_USER_LOGIN\"}"
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
Strategies.
pipeline_config_strategy | Effect | Typical use |
|---|---|---|
inject_policy | Policy jobs are added to the project's pipeline. Policy stages can be injected into the project's stage list, plus the reserved .pipeline-policy-pre and .pipeline-policy-post stages | Most cases: enforce extra jobs, keep the project's own CI |
override_project_ci | The policy configuration replaces the project's pipeline. The policy can still include the project's own .gitlab-ci.yml to run it in a controlled way | Strict environments where the platform team owns the pipeline structure |
The older inject_ci strategy is superseded by inject_policy.
Reserved stages. .pipeline-policy-pre always runs first and .pipeline-policy-post always runs last, whatever stages the project defines. A failing job in .pipeline-policy-pre stops the pipeline before any project job runs, which makes it the place for mandatory gates.
Protection against tampering.
- Policy jobs cannot be overridden by the project's configuration. If a project job has the same name as a policy job, the policy job gets a suffix by default (
suffix: on_conflict). Withsuffix: never, the conflict fails the pipeline instead. - Variables defined in pipeline execution policies take the highest precedence, above project, group and pipeline variables.
- The user running the pipeline must be able to read the policy's CI configuration project. Instead of granting every developer access, enable the setting in the configuration project that grants access to pipeline execution policies for linked projects.
Key takeaways
- Pipeline execution policies (Ultimate) enforce arbitrary CI jobs from a central project across many projects, and replace compliance pipelines.
inject_policyadds jobs to the project's pipeline.override_project_cireplaces the project's pipeline..pipeline-policy-preand.pipeline-policy-postrun before and after all project stages. Use the pre stage for mandatory gates.- Pin the included configuration to a tag, and give pipeline users read access to it through the policy access setting.
Practice
- (Beginner) Create a pipeline execution policy that injects a job in
.pipeline-policy-preprintingCI_PROJECT_PATHand the commit author. Confirm it runs in two projects of the group.- (Intermediate) Make the pre-stage job fail when the last commit is not signed (for example by checking the Commits API for the verification status). Test with a signed and an unsigned commit, and try to override the job from the project's
.gitlab-ci.yml.- Interview question: "When would you choose
override_project_cioverinject_policy, and what are the risks for development teams?" Hint: who owns pipeline structure, flexibility versus control, how project jobs can still run, and the impact of a broken policy on every project.
Merge Request Approval Policies
Why it matters. Scanners report problems, but a report does not stop anyone from merging. Organizations need a gate: "a merge request that introduces a new Critical vulnerability needs approval from the security team", or "adding a GPL dependency to a proprietary product needs legal approval". Merge request approval policies (formerly scan result policies) are that gate, enforced centrally.
What it is. An approval policy evaluates each merge request against rules. When a rule matches, the policy requires approvals from specified users, groups or roles, and can enforce merge request settings that projects cannot relax.
# .gitlab/security-policies/policy.yml
approval_policy:
- name: Security approval for new critical or high vulnerabilities
description: Require AppSec approval when an MR introduces Critical/High findings
enabled: true
rules:
- type: scan_finding
branch_type: protected # MRs targeting protected branches
scanners: [sast, secret_detection, dependency_scanning, container_scanning]
vulnerabilities_allowed: 0 # any matching finding triggers the rule
severity_levels: [critical, high]
vulnerability_states: [new_needs_triage, new_dismissed] # new findings in this MR
actions:
- type: require_approval
approvals_required: 1
group_approvers: [shop/appsec] # or user_approvers, role_approvers
- type: send_bot_message
enabled: true # the bot comments on the MR explaining why
approval_settings:
prevent_approval_by_author: true
prevent_approval_by_commit_author: true
remove_approvals_with_new_commit: true
block_branch_modification: true # projects cannot unprotect the branch to bypass
fallback_behavior:
fail: closed # if scan results are missing, require approval
- name: Legal approval for copyleft licenses
enabled: true
rules:
- type: license_finding
branch_type: protected
match_on_inclusion_license: true # match when one of these licenses is added
license_types: ["GNU Affero General Public License v3.0 only", "Server Side Public License v1"]
license_states: [newly_detected]
actions:
- type: require_approval
approvals_required: 1
user_approvers: [legal-reviewer]
- name: Two approvals for every MR to main
enabled: true
rules:
- type: any_merge_request
branches: [main]
commits: any # or "unsigned" to target unsigned commits only
actions:
- type: require_approval
approvals_required: 2
role_approvers: [maintainer]
Rule types.
| Rule type | Matches when |
|---|---|
scan_finding | The MR's pipeline has findings matching scanners, severities, states and count thresholds |
license_finding | Dependencies with matching licenses are added (or exist) according to the SBOM |
any_merge_request | Any MR to the target branches, optionally only when it contains unsigned commits |
Vulnerability states. new_needs_triage and new_dismissed match findings introduced by the MR. detected, confirmed, dismissed and resolved (previously existing) match vulnerabilities already on the default branch, which lets a policy block all merges to a project until its existing critical vulnerabilities are handled. That is strict, so use it carefully.
What developers see.
Merge request !412 ─ Add CSV export
✗ Approval required: 1 of 1 from shop/appsec
Policy "Security approval for new critical or high vulnerabilities" was violated:
Dependency scanning: 1 new Critical (CVE-2026-1234 in libfoo 1.2.0)
[Merge] disabled
If the developer upgrades the dependency and the new pipeline no longer reports the finding, the approval requirement disappears.
Fail open or closed. If a required scanner did not run or its pipeline failed, the policy cannot tell whether vulnerabilities exist. fallback_behavior: fail: closed requires approval in that case (secure default). open lets the MR proceed. Choose closed for production-critical branches.
Key takeaways
- Merge request approval policies (Ultimate) turn scan results, license findings or any MR into required approvals, enforced from a central policy project.
scan_findingrules withnew_*states gate new vulnerabilities. Previously existing states gate on the project's current vulnerability state.approval_settingsenforce MR settings (no self-approval, approvals reset on new commits, no branch unprotection) that projects cannot relax.- Decide what happens when scans are missing with
fallback_behavior. Closed is safer.
Practice
- (Beginner) Create an approval policy that requires one approval from a specific user when an MR introduces a new High or Critical SAST finding. Trigger it with an MR containing a SQL injection, then fix the code and watch the requirement disappear.
- (Intermediate) Add a
license_findingrule that requires approval when a dependency under AGPL-3.0 is added. Test with an MR that adds such a package, and withfallback_behaviorset to closed when dependency scanning fails.- Interview question: "Developers complain that the security approval policy blocks merges for findings they did not introduce. What is likely misconfigured?" Hint:
vulnerability_states(new versus previously existing), the target branch's base pipeline, and scanner coverage on the default branch.
Compliance Frameworks
Why it matters. Not every project needs the same controls. A payment service is subject to PCI DSS, a health data service to HIPAA, an internal wiki to almost nothing. Applying the strictest rules everywhere slows everyone down. Applying them selectively requires knowing which project falls under which regulation, and showing auditors that the right controls are in place.
What it is. A compliance framework is a label, defined at the top-level group, that identifies projects with specific compliance requirements. Frameworks are available on Premium and Ultimate. On Ultimate, they connect to policies and to the Compliance Center.
Top-level group "acme"
Compliance frameworks:
[PCI-DSS] [SOC 2] [Internal-Low]
Projects Frameworks Enforced by policies scoped to the framework
payments-api PCI-DSS, SOC 2 SAST + DAST + 2 approvals + signed commits
checkout-service SOC 2 SAST + secret detection + 1 security approval
team-wiki Internal-Low secret detection only
Using frameworks.
- Create frameworks under the top-level group's Secure > Compliance center > Frameworks (name, description, color). One framework can be the group's default, applied automatically to new projects.
- Apply them to projects from the Compliance Center, project settings or the API. A project can have several frameworks.
- Scope policies with
policy_scope.compliance_frameworks, so that scan execution, pipeline execution and approval policies apply exactly to the labeled projects:
approval_policy:
- name: PCI - two approvals and security sign-off
enabled: true
policy_scope:
compliance_frameworks:
- id: 7 # the PCI-DSS framework's ID
rules:
- type: any_merge_request
branch_type: protected
commits: any
actions:
- type: require_approval
approvals_required: 2
role_approvers: [maintainer]
- Define requirements and controls (Ultimate, recent versions). A framework can contain requirements, each made of controls that GitLab checks automatically, such as "SAST is running", "at least two approvals are required", "default branch is protected", or "author cannot approve". External controls can be reported by your own systems through the API.
The Compliance Center (group level, Ultimate) shows:
| Tab | Shows |
|---|---|
| Status / standards adherence | Which projects pass or fail each requirement and control |
| Violations | Merged MRs that broke rules, such as approval by the author or merge without enough approvals |
| Frameworks | All frameworks, their requirements, and linked policies |
| Projects | Every project with its frameworks, editable in bulk |
Applying a framework through the API. Useful when new projects are created by automation:
mutation labelPaymentsProject {
projectUpdateComplianceFrameworks(input: {
projectId: "gid://gitlab/Project/12345"
complianceFrameworkIds: ["gid://gitlab/ComplianceManagement::Framework/7"]
}) {
errors
}
}
Key takeaways
- Compliance frameworks (Premium+) are labels on projects that identify which regulations or internal standards apply to them.
- Policies scoped by framework apply controls only where they are required.
- The Compliance Center (Ultimate) reports adherence to requirements, violations, and framework coverage across the group.
- Apply frameworks automatically (default framework, API) so that new projects are not unlabeled and unprotected.
Practice
- (Beginner) Create two frameworks, "High" and "Low", apply them to two projects, and filter the Compliance Center's project list by framework.
- (Intermediate) Scope a scan execution policy (SAST and DAST) to "High" and a lighter one (secret detection) to "Low". Verify which jobs each project's pipeline receives.
- Interview question: "An auditor asks you to prove that every change to the payment service in the last quarter was reviewed by someone other than its author. How would you answer with GitLab?" Hint: approval policies and settings, the Compliance Center violations report, merge request approval records, and audit events.
Audit Events
Why it matters. Controls only matter if they stay in place. If someone unprotects main, adds a CI/CD variable with a production key, disables a security policy or grants themselves Maintainer, you need to know who did it and when, both to detect misuse and to prove to auditors that changes are tracked. Audit events are GitLab's tamper-resistant record of such actions, like a building's access log.
What it is. GitLab records audit events for security-relevant actions at instance, group and project level. Viewing them in the UI is a Premium feature (Secure > Audit events). Streaming them to external systems is Ultimate.
Examples of audited actions:
| Area | Examples |
|---|---|
| Access | Member added or removed, access level changed, personal or project access token created or revoked |
| Repository | Protected branch created, changed or removed, force push to a protected branch, push rules changed |
| CI/CD | CI/CD variable created, updated or deleted, runner registered, job token allowlist changed |
| Merge requests | Approval rules changed, MR merged with approval settings overridden |
| Security | Security policy project linked or changed, secret push protection skipped, compliance framework changed |
Reading audit events through the API.
# Group audit events from the last 7 days, printed as "time | author | action"
curl --silent --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.example.com/api/v4/groups/42/audit_events?created_after=$(date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%SZ)&per_page=100" \
| jq -r '.[] | "\(.created_at) | \(.author.name) | \(.details.custom_message // .details.change // .event_name)"'
A single event in the API looks similar to:
{
"id": 912345,
"author_id": 87,
"entity_id": 12345,
"entity_type": "Project",
"event_name": "protected_branch_updated",
"details": {
"author_name": "Alex Doe",
"target_details": "main",
"change": "allow_force_push",
"from": "false",
"to": "true",
"ip_address": "203.0.113.24",
"entity_path": "shop/payments-api"
},
"created_at": "2026-10-14T21:47:03.112Z"
}
Streaming audit events (Ultimate). Copying events to a SIEM (Splunk, Elastic, Datadog, Sentinel) lets security teams correlate them with other logs and alert in real time. Destinations are configured at the top-level group or instance:
| Destination type | Notes |
|---|---|
| HTTP endpoint | GitLab POSTs each event as JSON. A verification token is sent in the X-Gitlab-Event-Streaming-Token header so the receiver can reject forged events |
| AWS S3 | Events are written as objects to a bucket |
| Google Cloud Logging | Events are sent to a log in a GCP project |
You can filter streamed events by type and add custom HTTP headers. A receiver should validate the token, deduplicate on the event ID, and alert on high-risk event names (for example force pushes to protected branches, policy changes, or secret push protection skips).
Good practices.
- Stream to a system outside GitLab. An administrator who can change GitLab should not be able to erase the evidence.
- Alert on a short list of high-signal events rather than on everything.
- Combine audit events with the Compliance Center's violations report for audits.
Key takeaways
- Audit events record security-relevant actions (access, protected refs, CI/CD variables, policies) with author, time, IP and before/after values.
- Viewing them is Premium. Streaming to HTTP, S3 or Google Cloud Logging is Ultimate.
- Verify streamed events with the verification token header, and keep the copy outside GitLab.
- Alert on high-risk events such as protected branch changes, force pushes and policy changes.
Practice
- (Beginner) Change a protected branch setting and add a CI/CD variable in a test project, then find both actions in the project's audit events and in the API output.
- (Intermediate) Write a small HTTP receiver (for example in Python with Flask) that validates the
X-Gitlab-Event-Streaming-Tokenheader and prints only events whose name containsprotected_branchorpolicy. Configure it as a streaming destination.- Interview question: "How would you detect that someone temporarily disabled branch protection on
main, pushed a commit, and re-enabled protection?" Hint: the sequence of audit events, streaming and alerting, and push events on protected branches.
<a id="114-pipeline-security-hardening"></a>
11.4 Pipeline Security Hardening
The previous subchapters secure the application. This one secures the delivery system itself. A CI/CD pipeline holds deployment credentials, runs code from every contributor, and produces the artifacts that reach production, which makes it a prime target: compromising a pipeline is often easier than compromising production directly, and it gives the same result. This subchapter covers the controls that keep untrusted changes away from secrets and runners, limit what a compromised job can reach, and let consumers verify that an artifact really came from your pipeline.
Supply chain attack surface Controls in this subchapter
───────────────────────────────────────────── ──────────────────────────────────────────
1. Malicious change to code or .gitlab-ci.yml ───► Protected branches/tags, fork pipeline rules
2. Stolen or overreaching job credentials ───► CI_JOB_TOKEN allowlists, OIDC, protected variables
3. Compromised or shared build machine ───► Least-privilege runner design
4. Tampered artifact between build and deploy ───► Signing (Sigstore) and provenance (SLSA)
Protected Branches and Tags
Why it matters. In GitLab, who can push to a ref largely decides who can run code with production secrets: protected CI/CD variables, protected runners and protected environments are only available to pipelines on protected refs. If any developer can push to main or create a v* tag, any developer can modify .gitlab-ci.yml on that ref and print the production credentials. Protected branches and tags (introduced in sections 1.2, 3.1 and 5.2) are therefore a security boundary, not only a workflow convenience.
What it is. A protected branch or tag rule restricts who can push, merge, force push, create or delete matching refs. Pipelines on protected refs receive protected resources:
protected ref (main, release/*, v*) unprotected ref (feature/*)
Protected variables available not available
Protected runners can pick up jobs cannot
Protected environments deploy allowed (if user is allowed) deploy blocked
Who controls the code Maintainers via reviewed merges any developer with push access
A hardened configuration.
| Setting | Recommended value | Why |
|---|---|---|
Allowed to push and merge on main | Push: No one. Merge: Maintainers | All changes go through merge requests and their approvals |
| Allowed to force push | Off | Prevents rewriting history to hide or replace commits |
| Code owner approval (Premium) | On for main | Changes to .gitlab-ci.yml, deployment scripts and infra require their owners' approval |
Protected tags v* | Allowed to create: Maintainers (or a release bot) | Tag pipelines publish releases with signing and registry credentials |
| Group-level protected branches (Premium) | Protect main for all projects | New projects are protected from day one |
Pair the branch rules with CODEOWNERS entries for pipeline files, so that a change to CI configuration needs approval from the platform or security team:
# .gitlab/CODEOWNERS
[CI/CD] @acme/platform-team
/.gitlab-ci.yml
/ci/
/deploy/
Setting it through the API. Useful for enforcing the same rules across many projects:
# Protect main: no direct pushes (0), merges by Maintainers (40), no force push, code owner approval
curl --request POST --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.example.com/api/v4/projects/12345/protected_branches" \
--data "name=main&push_access_level=0&merge_access_level=40&allow_force_push=false&code_owner_approval_required=true"
# Protect release tags: only Maintainers (40) can create v* tags
curl --request POST --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.example.com/api/v4/projects/12345/protected_tags" \
--data "name=v*&create_access_level=40"
Push rules (Premium). Settings > Repository > Push rules add server-side checks on every push: reject unsigned commits, require commit author emails from your domain, check that the author is a GitLab user, prevent files matching secret patterns (id_rsa, *.pem), and limit file size. Rejecting unsigned commits on protected branches makes it harder to impersonate another developer in the history.
Common gaps.
- A wildcard such as
release/*protects existing and future branches, butrelease-*is a different pattern. Check that your naming conventions match the rules. - Protecting a branch does not protect a tag with the same name. Protect tags separately.
- Developers who can merge can still change
.gitlab-ci.ymlthrough a merge request. Approvals and code owners are what make that change reviewed.
Key takeaways
- Protected refs are the gate to protected variables, runners and environments. Whoever can push to them can effectively use production secrets.
- Disallow direct pushes and force pushes on
main, protect release tag patterns, and require code owner approval for pipeline files. - Use group-level protection and push rules (Premium) to make secure defaults automatic.
- Protected branches and protected tags are separate rules. Configure both.
Practice
- (Beginner) Protect
mainso that nobody can push directly and only Maintainers can merge. Try a direct push as a Developer and as a Maintainer, and read the error messages.- (Intermediate) Add a
CODEOWNERSsection requiring@platform-teamapproval for.gitlab-ci.ymland enable code owner approval onmain. Open an MR that changes the pipeline and confirm the required approval appears.- Interview question: "A Developer can't see the production deploy key in project settings. Can they still get it? How?" Hint: who can push to which refs, what a pipeline on that ref can read,
.gitlab-ci.ymlchanges, and masked versus protected variables.
CI_JOB_TOKEN Scope and Allowlists
Why it matters. Every job gets a CI_JOB_TOKEN that can call the GitLab API and Git as the user who triggered the pipeline (section 1.2). Without limits, a compromised job in a low-value project, for example a malicious test dependency, could use that token to clone private repositories, download packages or read artifacts from every project the user can access. Scoping the token limits the blast radius to the projects that really need each other.
What it is. The job token is short-lived (valid only while the job runs) and its permissions are the intersection of three things:
what the triggering user can access
∩ what the target project's job token allowlist permits (which source projects/groups)
∩ which API endpoints accept job tokens (and, optionally, fine-grained permissions)
= what CI_JOB_TOKEN can actually do
The inbound allowlist. Each project controls which other projects' job tokens may access it, under Settings > CI/CD > Job token permissions. With "Limit access to this project" enabled (the default for new projects), only the listed projects and groups can use their job tokens against it. A job token always works against its own project.
payments-lib (target) allowlist
"Limit access to this project": ON - group: acme/payments
- project: acme/platform/release-tools
acme/payments/checkout ── CI_JOB_TOKEN ──► pull package from payments-lib ✓ allowed (group listed)
acme/marketing/website ── CI_JOB_TOKEN ──► clone payments-lib ✗ 403 (not listed)
Managing the allowlist through the API.
# Ensure the inbound scope is enforced for payments-lib (project 200)
curl --request PATCH --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.example.com/api/v4/projects/200/job_token_scope" --data "enabled=true"
# Allow every project in group 42 (acme/payments)
curl --request POST --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.example.com/api/v4/projects/200/job_token_scope/groups_allowlist" --data "target_group_id=42"
# Allow one specific project (release tooling, project 315)
curl --request POST --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.example.com/api/v4/projects/200/job_token_scope/allowlist" --data "target_project_id=315"
Building the allowlist without breaking pipelines. The project's job token authentication log lists which projects have accessed it with job tokens. Before enforcing the scope on an existing project, review the log and add each legitimate caller. Recent GitLab versions can populate the allowlist from the log automatically.
Fine-grained permissions. Recent versions let you restrict an allowlist entry to specific resources (for example read packages, read deployments, read or write environments) instead of everything the user can do. Grant only what the calling project needs.
Using the job token correctly.
use-shared-package:
script:
# Authenticate to another project's package registry with the job token, not a personal token
- pip install --index-url "https://gitlab-ci-token:${CI_JOB_TOKEN}@${CI_SERVER_HOST}/api/v4/projects/200/packages/pypi/simple" payments-lib
- Prefer
CI_JOB_TOKENover personal or project access tokens stored as variables. It expires with the job and is not tied to a long-lived secret. - Pushing to a repository with the job token is off by default and must be enabled per project. Leave it off unless a job really needs to push.
- The token cannot do everything a user can do. Only endpoints that document job token support accept it.
Key takeaways
CI_JOB_TOKENacts as the triggering user, limited to the job's lifetime and to projects whose inbound allowlist includes the caller.- Keep "Limit access to this project" enabled, and allowlist specific projects or groups using the authentication log as evidence.
- Use fine-grained permissions where available, and keep repository push access with the job token disabled unless required.
- Replace long-lived access tokens in variables with the job token wherever an endpoint supports it.
Practice
- (Beginner) In project B, try to clone private project A using
CI_JOB_TOKENand observe the 403. Add B to A's allowlist and run the job again.- (Intermediate) For a project used by several others, read its job token authentication log, build the allowlist from it, enable enforcement, and verify that an unlisted project is blocked while listed ones keep working.
- Interview question: "A popular internal library's CI job was compromised through a malicious dependency. Which GitLab settings determine what the attacker could reach with
CI_JOB_TOKEN?" Hint: the triggering user's memberships, inbound allowlists of other projects, endpoint support, job token push access, and the token's lifetime.
Least-Privilege Runner Design
Why it matters. A runner executes whatever a job's script says. On a shared runner, the job from an unreviewed feature branch runs on the same machine, with the same network access and possibly the same cached credentials, as the production deployment job. If one job can escape its container, persist on the host, or use the host's cloud identity, it can tamper with other projects' builds or reach production. Runner design decides how much damage a single malicious job can do.
Principles.
| Principle | Concrete control |
|---|---|
| Separate trust levels | Different runners for untrusted work (MR and feature branch jobs) and trusted work (protected branch deploys) |
| No shared state between jobs | Ephemeral executors: a fresh VM or container per job, no persistent host volumes |
| No privileged containers | Avoid privileged = true and Docker socket mounts. Build images with rootless tools (BuildKit rootless, Kaniko alternatives, Buildah) |
| No ambient credentials | Do not attach cloud IAM roles to runner hosts. Use per-job OIDC tokens (id_tokens, section 5.4) scoped by project and ref |
| Restrict what runs | Allowed images, services and pull policies on the runner |
| Restrict the network | Runners for untrusted jobs cannot reach production networks or internal admin endpoints |
Separating trust levels. Register a deploy runner as protected (it only runs jobs on protected branches and tags), locked to its project or group, and tagged so that only jobs that ask for it are routed there:
# Create a protected, project-locked runner that does not run untagged jobs (returns a runner auth token)
curl --request POST --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.example.com/api/v4/user/runners" \
--data "runner_type=project_type&project_id=12345&description=prod-deployer" \
--data "access_level=ref_protected&locked=true&run_untagged=false&tag_list=prod-deploy"
deploy-production:
stage: deploy
tags: [prod-deploy] # only the protected runner has this tag
environment: production
script: ./deploy.sh
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
A job on a feature branch that adds tags: [prod-deploy] stays pending forever: the runner is protected and refuses it.
Hardening the Docker executor.
# /etc/gitlab-runner/config.toml (excerpt)
concurrent = 4
[[runners]]
name = "untrusted-docker"
executor = "docker"
[runners.docker]
image = "alpine:3.20"
privileged = false # no access to host devices or kernel capabilities
cap_drop = ["NET_RAW"] # drop capabilities jobs do not need
security_opt = ["no-new-privileges"] # setuid binaries cannot escalate privileges
allowed_images = ["registry.example.com/ci/*:*", "docker.io/library/*:*"] # only approved image sources
allowed_services = ["registry.example.com/ci/*:*", "docker.io/library/postgres:*"]
allowed_pull_policies = ["always"] # never reuse a locally cached, possibly tampered image
volumes = ["/cache"] # no host paths such as /var/run/docker.sock
disable_cache = false
network_mode = "ci-isolated" # a Docker network without routes to internal services
Ephemeral runners. With the Docker Autoscaler or instance executors (chapter 6), each VM can be limited to a single job (max_use_count = 1), so nothing a job leaves behind (a modified binary, a background process, a stolen cache) survives to the next one. On Kubernetes, run untrusted jobs in a dedicated namespace with Pod Security Admission set to restricted, network policies, and no service account token mounted.
Shared runners on GitLab.com. GitLab-hosted runners already give each job a fresh, isolated VM. The risk shifts to what your jobs can reach: avoid long-lived cloud keys in variables and use OIDC federation with conditions on project path and ref.
Key takeaways
- A runner is only as trustworthy as the least trusted job it runs. Separate runners for untrusted branches and trusted deployments.
- Register deploy runners as protected, locked and tagged. Feature branch jobs cannot use them.
- Avoid privileged mode, Docker socket mounts and host volumes. Restrict images, services and pull policies.
- Prefer ephemeral, single-use execution environments and per-job OIDC credentials over ambient host credentials.
Practice
- (Beginner) Register a protected runner with a unique tag and show that a job using that tag runs on
mainbut stays pending on a feature branch.- (Intermediate) Configure a Docker runner with
allowed_images,allowed_pull_policiesand no privileged mode. Write jobs that try to use a disallowed image, mount the Docker socket, and rundocker build, and record which fail and why. Then build the image with a rootless builder instead.- Interview question: "Your company uses one pool of self-managed shell-executor runners for all projects, including deployments. What risks do you see, and how would you redesign it?" Hint: persistence between jobs, shared file systems and credentials, untrusted MR code, protected runners, ephemeral executors and OIDC.
Securing Pipelines from Forks
Why it matters. Open source and inner source projects accept merge requests from forks. The person who opens a fork MR controls all of its content, including .gitlab-ci.yml, build scripts, test files and dependency manifests. If that code runs with the parent project's secrets or on its runners, the contributor can steal credentials or abuse infrastructure with a single MR.
What it is. GitLab runs fork MR pipelines in the fork project by default:
contributor/fork (fork) acme/app (parent)
─────────────────────── ─────────────────
MR from fork:feature → acme/app:main
Default: pipeline runs in the FORK
- fork's CI/CD variables (none of the parent's)
- fork's runners (or GitLab.com shared runners, billed to the fork's namespace)
- parent sees results in the MR, but its secrets are never exposed
Optional: a parent member with Developer+ clicks "Run pipeline" in the parent
- runs the FORK'S CODE and the FORK'S .gitlab-ci.yml in the PARENT project
- with the parent's non-protected variables and the parent's runners
- GitLab shows a warning: review the changes first
Running in the parent is useful when tests need the parent's infrastructure, but it is equivalent to executing untrusted code with your project's credentials. Protected variables remain unavailable (the MR ref is not protected), which is why deploy and release secrets must always be protected variables.
Controls.
| Control | How |
|---|---|
| Review before running in the parent | Inspect .gitlab-ci.yml, scripts, dependency changes and test files in the diff before clicking Run pipeline |
| Disable running fork pipelines in the parent | Project setting allowing fork pipelines to run in the parent project (API: ci_allow_fork_pipelines_to_run_in_parent_project). Turn it off if you never need it |
| Keep secrets protected | Only protected variables for credentials. Unprotected variables are readable by fork pipelines run in the parent |
| Exclude sensitive jobs | Rules that never run deploy or publish jobs for fork MRs |
| Isolate runners | Run fork MR jobs on a dedicated, unprivileged runner pool with no network access to internal systems |
| Restrict pipeline variables | Limit who can pass variables when running pipelines (minimum role setting for pipeline variables) |
Excluding sensitive jobs for fork MRs. In an MR pipeline from a fork that runs in the parent, CI_PROJECT_ID is the parent and CI_MERGE_REQUEST_SOURCE_PROJECT_ID is the fork:
.not-from-fork:
rules:
# Fork MR running in the parent: the source project differs from the current project
- if: $CI_MERGE_REQUEST_SOURCE_PROJECT_ID && $CI_MERGE_REQUEST_SOURCE_PROJECT_ID != $CI_PROJECT_ID
when: never
- when: on_success
integration-tests-with-sandbox-creds:
extends: .not-from-fork # never runs for fork MRs, even if a maintainer starts it
script: ./run-integration-tests.sh
This rule protects against a maintainer accidentally running jobs that use sensitive, non-protected variables. It does not protect against a malicious .gitlab-ci.yml in the fork, because the fork's version of the file is the one that runs. Only review and the protected/unprotected boundary protect against that.
Public project settings. For public projects, also check Settings > CI/CD > General pipelines: whether job logs and artifacts are visible to non-members (Project-based pipeline visibility), and that masked variables never appear in logs that anyone can read.
Key takeaways
- Fork MR pipelines run in the fork by default, without the parent's variables or runners. That is the safe default.
- Running a fork MR pipeline in the parent executes untrusted code and CI configuration with the parent's non-protected variables and runners. Review first, or disable the option.
- Store every credential as a protected variable so that MR pipelines, including fork MRs, can never read it.
- Use rules on
CI_MERGE_REQUEST_SOURCE_PROJECT_IDand dedicated runners to reduce the impact of mistakes.
Practice
- (Beginner) Fork a test project, open an MR to the parent, and check in which project the pipeline runs and whether a parent variable is visible to it.
- (Intermediate) As a parent maintainer, run the fork MR's pipeline in the parent project with an unprotected and a protected variable defined. Verify which one the job can print, then add the
.not-from-forkrule to a sensitive job and confirm it is skipped.- Interview question: "An open source project wants to run its expensive GPU integration tests on contributions from forks. How would you allow that safely?" Hint: maintainer review before running, isolated runners without credentials, protected variables, and what the contributor controls in the MR.
Artifact Signing with Sigstore
Why it matters. Between build and deployment, an artifact passes through registries, caches and mirrors. If an attacker replaces app:1.4.0 in a registry with a backdoored image, the deploy job pulls it without noticing, because a tag name proves nothing about content. A signature binds an artifact's exact digest to an identity, so deployers can verify "this image was built by the acme/app pipeline on main" before running it.
What it is. Sigstore is an open source signing ecosystem that removes the hardest part of signing: managing private keys. Its tool cosign supports keyless signing:
1. GitLab job requests an OIDC ID token (id_tokens, aud: sigstore)
└─ token contains claims: project path, ref, pipeline, CI config path...
2. cosign sends the token to Fulcio (Sigstore CA)
└─ Fulcio issues a short-lived (10 min) certificate whose identity is the job's CI config URI
3. cosign signs the artifact digest with an ephemeral key, discards the key
4. Signature + certificate are recorded in Rekor (public transparency log)
└─ and the signature is stored next to the image in the registry
Verification: check the signature, the certificate chain, the Rekor entry,
and that the identity matches the expected project, ref and issuer
Nothing long-lived needs protecting: the identity is the pipeline itself, attested by GitLab's OIDC issuer.
Signing a container image. Keyless signing with the public Sigstore instance works on GitLab.com:
build-and-sign:
stage: build
image: docker:27
services: [docker:27-dind]
variables:
IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
COSIGN_YES: "true" # do not prompt to confirm upload to the transparency log
id_tokens:
SIGSTORE_ID_TOKEN: # cosign reads this variable automatically
aud: sigstore
before_script:
- apk add --no-cache cosign
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
script:
- docker build --pull -t "$IMAGE" .
- docker push "$IMAGE"
# Sign the immutable digest, never the mutable tag
- IMAGE_DIGEST="$(docker inspect --format='{{index .RepoDigests 0}}' "$IMAGE")"
- cosign sign "$IMAGE_DIGEST" --annotations "com.gitlab.ci.pipeline.url=$CI_PIPELINE_URL"
- echo "IMAGE_DIGEST=$IMAGE_DIGEST" >> build.env
artifacts:
reports:
dotenv: build.env
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
Verifying before deployment. Verification must check who signed, not only that someone did:
verify-and-deploy:
stage: deploy
image: alpine:3.20
needs: [build-and-sign] # receives IMAGE_DIGEST from the dotenv report
before_script:
- apk add --no-cache cosign
script:
# The identity is the CI config URI: <server>/<project path>//<config path>@<ref>
- >
cosign verify "$IMAGE_DIGEST"
--certificate-identity "https://gitlab.com/acme/app//.gitlab-ci.yml@refs/heads/main"
--certificate-oidc-issuer "https://gitlab.com"
- ./deploy.sh "$IMAGE_DIGEST" # deploy by digest, the exact bytes that were verified
In Kubernetes, an admission controller such as the Sigstore policy-controller or Kyverno can enforce the same check for every pod, so unsigned images cannot run even if someone bypasses the pipeline.
Signing other artifacts. cosign sign-blob signs files such as release tarballs or SBOMs and writes a bundle to publish next to them:
cosign sign-blob dist/app-1.4.0.tar.gz --bundle dist/app-1.4.0.tar.gz.sigstore.json
cosign verify-blob dist/app-1.4.0.tar.gz --bundle dist/app-1.4.0.tar.gz.sigstore.json \
--certificate-identity "https://gitlab.com/acme/app//.gitlab-ci.yml@refs/tags/v1.4.0" \
--certificate-oidc-issuer "https://gitlab.com"
Caveats.
| Topic | Detail |
|---|---|
| Self-managed GitLab | The public Fulcio instance trusts GitLab.com's issuer, not yours. Run a private Sigstore stack, or use key-based signing (cosign generate-key-pair, key in a KMS or protected variable) |
| Privacy | Rekor is public. Keyless signing in a private project publishes its path, ref and pipeline URLs in the log. Use a private Sigstore instance or key-based signing without the transparency log if that is unacceptable |
| What a signature proves | Who built the artifact, not that it is safe. A signed backdoor is still a backdoor if the source or pipeline was compromised. Pair signing with protected refs and provenance |
Key takeaways
- Signing binds an artifact digest to an identity. Verification before deployment detects tampering in registries and caches.
- Keyless signing uses a GitLab OIDC token (
id_tokenswithaud: sigstore), a short-lived Fulcio certificate and the Rekor transparency log. No private key to manage. - Always sign and deploy by digest, and verify the exact certificate identity and issuer, not just any valid signature.
- On self-managed GitLab or for private metadata, use a private Sigstore instance or key-based signing.
Practice
- (Beginner) On GitLab.com, sign an image with keyless cosign in a pipeline and verify it locally with
cosign verify, using the correct certificate identity for your project and branch.- (Intermediate) Add a verification step to the deploy job, then push a different image to the same tag manually and show that verification by the tag's digest fails. Explain why deploying by tag would have missed it.
- Interview question: "Why is
cosign verifywith only--certificate-oidc-issuer https://gitlab.comnot enough to trust an image?" Hint: who else can obtain a certificate from that issuer, and which claims (project, ref, config path) the identity must pin.
SLSA Provenance and Attestation
Why it matters. A signature says who produced an artifact. Consumers and auditors increasingly ask how: from which source commit, by which build system, with which parameters and dependencies? If a release binary cannot be traced to a reviewed commit and a trusted pipeline, nobody can tell whether it contains code that never went through review, which is what happened in several major supply chain attacks such as SolarWinds. Provenance is the verifiable record of how an artifact was built.
What it is. SLSA (Supply-chain Levels for Software Artifacts, pronounced "salsa") is a framework that defines increasing levels of build integrity:
| SLSA build level | Requirement | Protects against |
|---|---|---|
| Build L0 | None | Nothing |
| Build L1 | Provenance exists, describing how the artifact was built | Mistakes, unknown build origins |
| Build L2 | Builds run on a hosted build platform that generates and signs the provenance | Tampering after the build, forged provenance by individuals |
| Build L3 | Hardened build platform: builds are isolated from each other, and signing material is unreachable by the build's own steps | A compromised build step forging provenance, cross-build contamination |
Provenance is expressed as an in-toto attestation: a statement with a subject (the artifact's name and digest) and a predicate (the SLSA provenance describing the build).
Generating provenance with GitLab Runner. The runner can produce provenance for a job's artifacts. Set RUNNER_GENERATE_ARTIFACTS_METADATA:
build-release:
stage: build
image: golang:1.23
variables:
RUNNER_GENERATE_ARTIFACTS_METADATA: "true" # runner writes SLSA provenance for the artifacts
script:
- CGO_ENABLED=0 go build -trimpath -o dist/app ./cmd/app
artifacts:
paths:
- dist/app # provenance covers these files
rules:
- if: $CI_COMMIT_TAG
The runner uploads an extra file, artifacts-metadata.json (named after artifacts:name), alongside the artifacts. Simplified:
{
"_type": "https://in-toto.io/Statement/v1",
"subject": [
{ "name": "dist/app", "digest": { "sha256": "9f2c1e...a41b" } }
],
"predicateType": "https://slsa.dev/provenance/v1",
"predicate": {
"buildDefinition": {
"buildType": "https://gitlab.com/gitlab-org/gitlab-runner/...",
"externalParameters": {
"source": "https://gitlab.com/acme/app",
"entryPoint": "build-release"
},
"internalParameters": { "name": "saas-linux-small-amd64", "executor": "docker+machine" },
"resolvedDependencies": [
{ "uri": "https://gitlab.com/acme/app", "digest": { "sha256": "<commit sha>" } }
]
},
"runDetails": {
"builder": { "id": "https://gitlab.com/acme/app/-/runners/12270859" },
"metadata": {
"invocationID": "8148375512",
"startedOn": "2026-10-15T08:01:12Z",
"finishedOn": "2026-10-15T08:03:40Z"
}
}
}
}
The file on its own is unsigned, which corresponds to SLSA Build L1. To reach L2, the provenance must be signed by the build platform, so that a consumer can verify it was not edited after the fact.
Signing the attestation. cosign can sign the provenance as an in-toto attestation for the binary, using the same keyless identity as in the previous topic, and write a bundle to publish next to the release:
attest-release:
stage: release
image: alpine:3.20
needs: [build-release] # provides dist/app and artifacts-metadata.json
id_tokens:
SIGSTORE_ID_TOKEN:
aud: sigstore
variables:
COSIGN_YES: "true"
script:
- apk add --no-cache cosign jq
# cosign builds the in-toto statement itself; pass only the SLSA predicate
- jq '.predicate' artifacts-metadata.json > provenance-predicate.json
- >
cosign attest-blob dist/app
--type slsaprovenance1
--predicate provenance-predicate.json
--bundle dist/app.provenance.sigstore.json
artifacts:
paths: [dist/app.provenance.sigstore.json] # publish with the release (section 8.4)
rules:
- if: $CI_COMMIT_TAG
For container images the equivalent is cosign attest <image digest> --type slsaprovenance1 --predicate ..., which stores the attestation in the registry next to the image.
Consumers verify the attestation's signature and identity, then check its content, for example that externalParameters.source is the expected repository and that the commit in resolvedDependencies is a reviewed commit on a protected ref:
cosign verify-blob-attestation dist/app \
--bundle dist/app.provenance.sigstore.json \
--type slsaprovenance1 \
--certificate-identity "https://gitlab.com/acme/app//.gitlab-ci.yml@refs/tags/v1.4.0" \
--certificate-oidc-issuer "https://gitlab.com"
Recent GitLab versions are adding built-in attestation and verification support (including through the glab CLI and CI/CD components). Check the documentation for your version before building your own tooling.
Getting closer to Build L3 with GitLab.
- Generate provenance on the platform side (the runner), not with a script that the build itself controls.
- Run release builds on ephemeral, isolated runners (previous topics), so one build cannot influence another.
- Keep signing identities out of reach of the build steps where possible, for example by signing in a separate job, on a protected runner, that only consumes the build's output digest.
- Build only from protected tags or branches, with reviewed changes, so that the provenance points to code that went through review.
Key takeaways
- Provenance records how an artifact was built: source, commit, builder, entry point and parameters. SLSA defines build levels for how trustworthy that record is.
RUNNER_GENERATE_ARTIFACTS_METADATA: "true"makes GitLab Runner produce SLSA v1 provenance for job artifacts (Build L1).- Sign the provenance (for example
cosign attest) and attach it to the artifact to approach Build L2. Verify both the signature and the content. - Higher levels depend on isolated, ephemeral builds and signing that the build steps cannot tamper with.
Practice
- (Beginner) Enable
RUNNER_GENERATE_ARTIFACTS_METADATAin a build job, downloadartifacts-metadata.json, and check that the subject's SHA-256 matchessha256sumof the artifact.- (Intermediate) Attach a signed SLSA provenance attestation to an image with
cosign attest, then write a verification script that fails unless the provenance's source repository and commit match the expected values.- Interview question: "Explain the difference between signing an artifact and providing SLSA provenance for it. Which supply chain attacks does each one stop?" Hint: identity versus build process, registry tampering versus a compromised build step, and what happens when the source itself was malicious.
<a id="12-deployment-and-environments"></a>