Securing a GitLab CI pipeline is not just a matter of masking variables. Hardening covers the whole chain: the permissions of the job token, runner isolation, dependency pinning, the restriction of includes and the configuration at the instance level. This guide details 10 concrete measures, ranked from the simplest to the most structural, with the commands to check each point on your KVM instance.
What you will learn
- Restrict the permissions of the
CI_JOB_TOKENto the strict minimum - Isolate runners by trust level (shared, group, project)
- Disable the
privilegedmode and configure executors securely - Pin Docker images and includes by hash or exact version
- Restrict the images allowed at the runner level
- Audit the configuration with
plumberand API scripts
Prerequisites
This lesson assumes that you have read the seven attacks these measures
close, that you know what a runner is and what its config.toml is for,
and that you have already built an image in a pipeline. The Developer role is
not enough to apply the measures: the fine-grained job token permissions
(measure 1) require the Maintainer or Owner role on the target project,
separating runners (measure 3) goes through Administration > Runners, hence
an instance administrator account, and measures 2 and 6 edit the
config.toml, which requires access to the machine that hosts the runner.
As for tools, you need curl and jq for the API calls (with a token in
$GITLAB_API_TOKEN), Docker and its buildx plugin to read an image's
digest and run plumber, openssl to compute the fingerprint of a remote
include, and gitlab-runner on the host. The final lab also requires
DSOXLab. Upstream lessons:
- 7 attacks on your pipelines: the risks each measure closes
- Runners: shared, group and project runners, registration
- GitLab registries: building an image without a daemon (measure 2)
- Templates and include:
the
include:syntax locked down by measure 5
In which context?
You have a self-hosted GitLab CE instance on KVM with registered runners. Your pipelines work, the secrets are protected (previous guide), but you have not yet restricted the default permissions. A developer can still trigger a pipeline that does Docker-in-Docker in privileged mode, reference any image without verification, or include a remote YAML file that has not been audited.
Measure 1: restrict the scope of the CI_JOB_TOKEN
The CI_JOB_TOKEN is an ephemeral token created automatically for each job. The
GitLab documentation states two things that govern everything else: "The
token receives the same access level as the user that triggered the pipeline"
and "The token is valid only while the job is running".
What can the token reach by default, measured
On a recent instance, a job cannot clone a neighboring private project, but it clones an internal project without difficulty. This is the nuance that decides the real risk, and it cannot be guessed.
Measured on September 20, 2026 on a freshly installed GitLab CE 19.4.0, with three projects in the same group and a runner with a shell executor:
| Target project | Visibility | Result of git clone with CI_JOB_TOKEN |
|---|---|---|
cible-privee | private | denied, HTTP 403 |
cible-interne | internal | succeeded |
The denial carries an unambiguous message, which you need to know how to recognize in a job trace:
remote: Authentication by CI/CD job token not allowed from appelant to project #2.fatal: unable to access 'http://…/equipe-audit/cible-privee.git/': The requested URL returned error: 403Two settings explain this denial, both observed on the new instance:
- the inbound allowlist of a newly created project contains a single entry, the project itself, and not its group;
- at the instance level,
enforce_ci_inbound_job_token_scope_enabledistrue.
The lateral movement scenario therefore remains intact for internal projects. On an enterprise instance, internal visibility is common, precisely because it looks harmless: it exposes nothing to the Internet. Yet it exposes the repository to every job on the instance.
mouvement-lateral: script: # ❌ Counter-example: still succeeds today if the target is "internal" - git clone "http://gitlab-ci-token:${CI_JOB_TOKEN}@gitlab.example.com/org/autre-projet.git"The fix
The restriction is configured in the target project, not in the calling project: it is the repository to protect that decides who is allowed to reach it with a job token.
-
Check the inbound allowlist of the target project
The right entry point is not the project object, it is the dedicated endpoint. A healthy project shows only itself there:
Read the inbound allowlist curl --header "PRIVATE-TOKEN: $GITLAB_API_TOKEN" \"https://gitlab.example.com/api/v4/projects/$PROJECT_ID/job_token_scope/allowlist" \| jq -r '.[].path_with_namespace'In the interface, the same list is read under Settings > CI/CD > Job token permissions.
-
Handle the case of internal projects, which escape this list
An internal project remains cloneable without appearing on any list. Two remedies, depending on what you want to preserve: switch the project to private, or restrict only the repository feature to members.
Restrict the repository to members, keeping the project internal curl --header "PRIVATE-TOKEN: $GITLAB_API_TOKEN" \--header "Content-Type: application/json" \--request PUT "https://gitlab.example.com/api/v4/projects/$PROJECT_ID" \--data '{"repository_access_level":"private","merge_requests_access_level":"private","builds_access_level":"private"}'The three values go down together, and this is not excess zeal: the API refuses the isolated change with
cannot have higher visibility level than repository access level. Merge requests and pipelines cannot be more visible than the repository they depend on.After applying it, the same job fails with a different message from the first one, which lets you tell the two causes apart in a trace:
remote: You are not allowed to download code from this project. -
Reduce what the token can do, not just where it can go
Since GitLab 18.3, fine-grained permissions for job tokens are available as a stable feature, after an experiment in 17.10 and a beta in 18.0. They allow a project on the list to call only a subset of REST API endpoints, instead of opening everything the token can do.
They are enabled per project, then set entry by entry in Settings > CI/CD > Job token permissions > CI/CD job token allowlist. The Maintainer or Owner role is required. Permissions are named by resource and by level:
Level Example names Read READ_JOBS,READ_PACKAGES,READ_BADGESRead and write ADMIN_PACKAGES,ADMIN_DEPLOYMENTS,ADMIN_BADGESA project that only downloads a package needs only
READ_PACKAGES: leaving itADMIN_PACKAGESalso lets it publish one, and therefore replace a dependency that other pipelines consume. -
Leave the generic token when the destination is a third party
To talk to an external service, a single-destination identity token is better than a token that opens the GitLab API:
.gitlab-ci.yml: one token per destination default:id_tokens:VAULT_TOKEN:aud: https://vault.example.com
Measure 2: disable privileged mode on runners
A Docker runner in privileged mode gives full root access to the host. It is the most direct container escape vector.
[[runners]] name = "shared-runner" [runners.docker] privileged = true[[runners]] name = "shared-runner" [runners.docker] privileged = false cap_drop = ["ALL"] security_opt = ["no-new-privileges:true"]What about building images?
Docker-in-Docker requires privileged = true: on a hardened runner, it no
longer starts. You then need a daemonless builder, which reads the
Dockerfile without starting a Docker daemon. The two usual candidates do not
behave the same on a runner with a Docker executor. One more factor comes
into play: the cap_drop = ["ALL"] of the AFTER configuration removes all
the Linux capabilities from the container. A capability is one of the
small privileges, such as changing the owner of a file (CHOWN), into which
the kernel splits root's powers. This cap_drop therefore blocks kaniko, and
Buildah poses an additional problem:
- kaniko (in French) builds without
privilegedand without any seccomp or AppArmor setting (Docker's default profiles that filter system calls and access to resources). But withcap_drop = ["ALL"], it fails onchown /etc/gshadow: operation not permitted: for the Dockerfile of the running project of the labs, it needs at least theCHOWN,DAC_OVERRIDEandFOWNERcapabilities, given back bycap_add. A Dockerfile that switches user may require others. - Buildah (in French) fails with
the default seccomp and AppArmor profiles
(
unshare(CLONE_NEWUSER): Operation not permitted), and it additionally needs these profiles relaxed (security_opt), a real loss of protection. On Kubernetes or OpenShift, follow GitLab's rootless Buildah tutorial instead.
The practical consequence is a runner dedicated to builds, separate from the runners that run the tests (measure 3), with just the capabilities that kaniko needs:
[[runners]] name = "build-runner" [runners.docker] privileged = false cap_drop = ["ALL"] cap_add = ["CHOWN", "DAC_OVERRIDE", "FOWNER"] security_opt = ["no-new-privileges:true"]Watch out for the registry the image comes from. The original repository
GoogleContainerTools/kaniko has been archived since June 3, 2025, and the
gcr.io/kaniko-project registry froze at v1.24.0: everything pulled from
there is mid-2025 code, which will no longer receive any fix. Development
continues on active forks, and that is where you should go:
build: image: name: ghcr.io/osscontainertools/kaniko:v1.28.6-debug@sha256:7ab44d0131c3051b6dea128067e258c7afb5c4815082a93b9cf6c624f2140bf0 entrypoint: [""] before_script: - mkdir -p /kaniko/.docker - echo "{\"auths\":{\"${CI_REGISTRY}\":{\"auth\":\"$(printf "%s:%s" "${CI_REGISTRY_USER}" "${CI_REGISTRY_PASSWORD}" | base64 | tr -d '\n')\"}}}" > /kaniko/.docker/config.json script: - /kaniko/executor --context "$CI_PROJECT_DIR" --destination "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"entrypoint: [""] is not decorative: without it, GitLab runs the image's
entrypoint instead of your script, and the job fails with no useful
message. The before_script gives kaniko the registry credentials:
without config.json, the push fails on UNAUTHORIZED: authentication required.
Measure 3: separate runners by trust level
Do not share the same runners between public/fork projects and internal projects that deploy to production.
| Level | Runner | Tags | Usage |
|---|---|---|---|
| Public/Fork | Dedicated runner, unprotected | public, untrusted | External MRs, contribution CI |
| Internal | Group runner | internal | Build, tests, quality |
| Deployment | Project runner, protected | deploy, protected | Staging/production deployment |
[[runners]] name = "deploy-runner" limit = 1 [runners.docker] privileged = false allowed_images = ["registry.example.com/deploy/*"] allowed_services = []-
Register a protected runner dedicated to deployment
Register a protected runner gitlab-runner register \--non-interactive \--url "https://gitlab.example.com" \--token "glrt-xxxxxxxxxxxx" \--executor docker \--docker-image "alpine:3.21@sha256:ce64758a109eb420d874a118f87920e625e12d3634e03b4a5573fd9f6e5d3507" \--tag-list "deploy,protected" \--run-untagged=falseThe runner's default image is pinned too, by its index digest, as in measure 4: it is the one used by any job that does not declare an
image:, and therefore the one you forget to check.--run-untagged=falsecompletes the restriction: without it, this deployment runner would pick up any job without a tag. -
Mark the runner as protected in the UI
Administration > Runners → select the runner → check Protected. A protected runner only runs jobs on protected branches/tags.
-
Force the tags in deployment jobs
.gitlab-ci.yml: job restricted to the protected runner deploy:production:tags:- deploy- protectedrules:- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCHscript:- ansible-playbook deploy-prod.yml
Measure 4: pin Docker images by digest
Referencing an image by tag (python:3.12) exposes you to tag poisoning: a
tag silently moved to malicious content. This is the vector of the March
2026 incidents. On March 19, stolen credentials were used to rewrite 76 of
the 77 tags of the aquasecurity/trivy-action action to a credential
stealer, then, on March 22, to publish malicious trivy 0.69.5 and 0.69.6
images on Docker Hub (GHSA-69fq-xp46-6x23,
CVE-2026-33634). On March 23, the tags of Checkmarx/kics-github-action
carried a malicious payload for almost four hours, according to
Checkmarx's bulletin.
A pipeline pinned by digest pulled none of it.
test: # ❌ Counter-example: a bare tag, which the registry can move again at any time image: python:3.12-slimtest: image: python:3.12-slim@sha256:2f17fc044b579bab302c2e8054d3a686e2cb9a83de48e70534b94cd8ebbe06a9Which digest to pin: the index one, not a platform one
A popular image is an index that points to one manifest per architecture, and the two have different digests. Pinning the wrong one silently freezes your pipeline on a single architecture.
The command seen everywhere returns the wrong one:
# ❌ Returns the digest of the FIRST manifest of the index, so one platformdocker manifest inspect python:3.12-slim | jq -r '.manifests[0].digest'Measured on September 20, 2026 on python:3.12-slim, the two values differ:
| What you get | Value | What it pins |
|---|---|---|
.manifests[0].digest | sha256:44ff437b… | linux/amd64 only |
| Index digest | sha256:2f17fc04… | the tag, all architectures |
The right command reads the index itself:
docker buildx imagetools inspect python:3.12-slim | awk '/^Digest:/ {print $2}'The first digest is not wrong, it is narrower: an arm64 runner that
pulls this reference gets the amd64 image, and the error only shows up
when the first binary runs. On a homogeneous fleet, nobody sees it for
months; the day an arm64 runner arrives, the pipeline breaks without the
file having changed.
Measure 5: lock down remote includes
An include: remote: loads a YAML file from an external URL at every pipeline run. If this URL is compromised, all your pipelines are too.
include: # ❌ Counter-example: the URL is reread at every pipeline, with no verification at all - remote: https://example.com/templates/deploy.ymlinclude: - project: 'my-org/ci-templates' ref: '787123b47f14b552955ca2786bc9542ae66fee5b' # v2.1.0 file: '/templates/deploy.yml'Pin to the commit SHA, not to the tag. ref accepts all three forms, and
they do not offer the same guarantee:
Value of ref | What can change under your feet |
|---|---|
| Omitted | The worst case. The documentation is explicit: "Defaults to the HEAD of the project when not specified". Every git push to the default branch changes what your pipeline runs. |
ref: main | Same, on every commit pushed to this branch |
ref: 'v2.1.0' | A tag can be moved again: git tag -f followed by push --force changes the content without changing the reference |
ref: '787123b4…' | Nothing. A commit SHA is immutable |
This is the same rule that this site applies to GitHub actions and container images, and for the same reason: a mutable reference cancels the pinning it is supposed to provide. The tag keeps its place as a comment, so that the reader knows which version it is.
When the remote include is imposed: integrity
Since GitLab 17.9, an include: remote: can carry the fingerprint of the
expected file, and the pipeline fails if the content has changed. The
documentation is direct: "If integrity does not match the actual content, the remote file
is not processed and the pipeline fails". It is the answer for the case where
the external URL is imposed and you cannot bring the file in-house.
include: - remote: 'https://gitlab.com/exemple/ci-templates/-/raw/v2.1.0/deploy.yml' integrity: 'sha256-L3/GAoKaw0Arw6hDCKeKQlV1QPEgHYxGBHsH4zG1IY8='The format is not the one you would expect: it is a base64-encoded
SHA-256, prefixed with sha256-, and not the hexadecimal representation
that sha256sum returns. The conversion takes one command:
curl -fsSL https://gitlab.com/exemple/ci-templates/-/raw/v2.1.0/deploy.yml \ | openssl dgst -sha256 -binary | openssl base64 -A \ | sed 's/^/sha256-/'This protection does not exempt you from auditing the content: it guarantees that the file has not changed since you read it, not that it was sound that day.
Measure 6: restrict the allowed images at the runner level
The runner's config.toml lets you limit the images that pipelines can use:
[[runners]] [runners.docker] allowed_images = [ "registry.example.com/*", "python:3.12-*", "node:20-*", "alpine:3.*" ] allowed_services = [ "postgres:16-*", "redis:7-*" ]Any image not listed will make the job fail with image is not allowed. It is the most effective defense against the use of compromised images.
The default is the worst case, and it is silent. The runner documentation
is explicit: "If not present, all images are allowed (equivalent to
["**"])". A missing key is therefore not a neutral key, it is a blanket
authorization. The check is not to verify that the value is right, but that the
key exists.
Restricting the privilege without removing it
When a runner really has to run in privileged mode, for example for Docker-in-Docker, two keys let you limit who gets this privilege instead of granting it to every allowed image:
[[runners]] [runners.docker] privileged = true allowed_images = ["registry.example.com/*", "docker:28.*"] allowed_privileged_images = ["docker:28.*"] allowed_services = ["docker:28.*-dind"] allowed_privileged_services = ["docker:28.*-dind"]allowed_privileged_images is a subset of allowed_images: an image
absent from the first list still runs, but without privilege. The
result is a runner that accepts Docker-in-Docker for the image that
needs it and refuses the privilege to all the others, whereas
privileged = true alone grants it to each one.
Measure 7: limit fork pipelines
A merge request coming from a fork brings its own .gitlab-ci.yml: its
pipeline therefore runs arbitrary code. The
GitLab documentation
sets the default behavior: this pipeline is created and run in the fork,
not in the parent project. It touches neither the variables nor the protected
runners of the parent, but it runs on the instance runners open to the
fork, hence the separation by trust level of measure 3.
-
Only run a fork pipeline in the parent project after a review
A member of the parent project can run this pipeline in the parent, with its variables and its runners, from the Pipelines tab of the merge request (Run pipeline). GitLab then shows a warning to accept: only do it after reading the fork's changes,
.gitlab-ci.ymlincluded. -
Forbid this execution on sensitive projects
The documentation offers no checkbox: the setting goes through the projects API, with the attribute
ci_allow_fork_pipelines_to_run_in_parent_project.Forbid fork pipelines in the parent project curl --request PUT \--header "PRIVATE-TOKEN: $GITLAB_API_TOKEN" \--data "ci_allow_fork_pipelines_to_run_in_parent_project=false" \"https://gitlab.example.com/api/v4/projects/$PROJECT_ID"Pipelines already created keep running: the setting only applies to the following ones.
-
Check via the API
Check the fork pipeline configuration curl --header "PRIVATE-TOKEN: $GITLAB_API_TOKEN" \"https://gitlab.example.com/api/v4/projects/$PROJECT_ID" \| jq '{ci_allow_fork_pipelines_to_run_in_parent_project}'
Measure 8: enable branch protection by default
Protected branches are GitLab's central mechanism for controlling who can push, merge and trigger pipelines on critical branches.
curl --request POST \ --header "PRIVATE-TOKEN: $GITLAB_API_TOKEN" \ --data "name=main&push_access_level=40&merge_access_level=30&allow_force_push=false" \ "https://gitlab.example.com/api/v4/projects/$PROJECT_ID/protected_branches"| Access level | Value | Who can act |
|---|---|---|
| No access | 0 | Nobody |
| Developer | 30 | Developers and above |
| Maintainer | 40 | Maintainers and above |
| Admin | 60 | Administrators only |
Measure 9: enforce merge requests with approval
A deployment pipeline must never be triggered by a direct push. Require an MR with at least one approval.
deploy:production: rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" when: never - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH script: - ansible-playbook deploy-prod.ymlWhat you apply therefore depends on your edition:
| Edition | What really blocks an unreviewed merge |
|---|---|
| Free / CE | The protected branches of measure 8, which decide who can merge, plus the "Pipelines must succeed" setting, available in Free. Approval remains a social signal, not a lock. |
| Premium / Ultimate | The approval rules: required number, exclusion of the author and committers, per-file-path rules. |
On CE, real protection therefore comes from the combination of measures 8
and 9: only Maintainers merge to main, the pipeline must pass, and the
deployment job only triggers on the default branch. Relying on approval alone
would give a false sense of control.
Measure 10: audit the configuration with plumber
plumber is an open source tool that analyzes the
security configuration of your pipelines and your projects, on GitLab
as well as on GitHub: images not pinned by digest, includes on a mutable
reference, unprotected branches, Docker-in-Docker, unprotected CI/CD
variables (control disabled by default, to enable in .plumber.yaml). It
reads neither the runners' config.toml nor the fork settings: measures 2
and 7 still have to be checked by hand. The code is published at
github.com/getplumber/plumber.
docker run --rm \ -e GITLAB_TOKEN="$GITLAB_API_TOKEN" \ getplumber/plumber:0.6.1@sha256:ddb9ef26800d66a594f92774e79f5bb6965acb4a6c7bd9c10bff25cb4f48a454 analyze \ --gitlab-url "https://gitlab.example.com" \ --project "my-org/my-project"On a lab project whose images remain on tags, version 0.6.1 returns
three attack paths, one per image, and a Plumber Score out of 100. The
word unverified indicates that the level of the path rests on a fact that
Plumber could not establish with certainty. Excerpt of the real output:
MED Attack path 3 (unverified) Entry docker.io/docker:27 (mutable image tag) └──▶ runs in job `docker-build` (unverified) └─▶ reaches secrets Plumber could not list and a step that publishes Fix pin the image by digest
Plumber Score 82 / 100 Status: FAILED (gate blocks at 100 pts)The details of how to read the output, compute the score and set the threshold are in the
Plumber guide. Integrate plumber
into your CI pipeline for a continuous audit. The image has the plumber
binary as its entry point: a job that runs a script: must therefore empty it
with entrypoint: [""], as the official component does.
security-audit: stage: test image: name: getplumber/plumber:0.6.1@sha256:ddb9ef26800d66a594f92774e79f5bb6965acb4a6c7bd9c10bff25cb4f48a454 entrypoint: [""] variables: GITLAB_TOKEN: $GITLAB_API_TOKEN script: - plumber analyze --gitlab-url $CI_SERVER_URL --project $CI_PROJECT_PATH rules: - if: $CI_PIPELINE_SOURCE == "schedule"Hardening checklist
Each line points to a precise place where the real state can be checked, in
the interface, in the runner's config.toml or by a search in the repository.
A measure that you cannot check this way is not applied, it is only
assumed. Go back over this list after every runner or project you add.
| # | Measure | Verifiable by |
|---|---|---|
| 1 | Inbound allowlist reduced to the project, internal targets handled | GET /projects/:id/job_token_scope/allowlist |
| 2 | privileged mode disabled | runner config.toml |
| 3 | Runners separated by trust | Administration > Runners (tags + protected) |
| 4 | Images pinned by digest | Grep @sha256: in .gitlab-ci.yml |
| 5 | Includes pinned to a commit SHA, not a tag | grep -A2 'include:' .gitlab-ci.yml, a ref: of 40 hexadecimal characters |
| 6 | Allowed images restricted | config.toml → allowed_images |
| 7 | Fork pipelines forbidden in the parent project | Projects API, ci_allow_fork_pipelines_to_run_in_parent_project set to false |
| 8 | Critical branches protected | Settings > Repository > Protected branches |
| 9 | Controlled merge: protected branches on CE, approval rules on Premium | Settings > Repository > Protected branches, and Settings > Merge requests |
| 10 | Regular plumber audit | Weekly pipeline schedule |
Troubleshooting
Hardening always breaks something on the first pass, and that is expected: a pipeline that fails after a restriction reveals an implicit dependency that nobody had documented. The symptoms below match the measures of this guide, in the order in which they usually appear.
| Symptom | Likely cause | Solution |
|---|---|---|
Job fails with image is not allowed | Image not in the runner's allowed_images | Add the image to the allowlist in config.toml |
| Deployment job does not start | Protected runner + unprotected branch | Check that the branch is in the protected list |
Authentication by CI/CD job token not allowed from X to project #N | The calling project is not on the target's inbound allowlist | Add the calling project in the target's Settings > CI/CD > Job token permissions |
You are not allowed to download code from this project | The target's repository feature is restricted to members | Add the account that triggers the pipeline as a member, or reopen repository_access_level knowingly |
Docker build fails after disabling privileged | DinD requires privileged = true | Migrate to kaniko from a maintained fork, not from gcr.io, on a dedicated build runner |
kaniko: chown /etc/gshadow: operation not permitted | Runner with cap_drop = ["ALL"] | Give back CHOWN, DAC_OVERRIDE and FOWNER through cap_add on the build runner |
Buildah: unshare(CLONE_NEWUSER): Operation not permitted | Default seccomp and AppArmor profiles | Prefer kaniko, or relax security_opt on a dedicated build runner |
| plumber cannot connect | Insufficient token | Use a token with the read_api and read_repository scopes (api only for the MR comment or the badge) |
Hands-on lab
The lab "Harden the runner, and close what was wide open" replays measures 2 and 6 of this guide on a GitLab CE forge of your own (a GitLab server you host yourself), set up on a virtual machine with its runner. Its starting pipeline runs in privileged mode, accepts any image and keeps every Linux capability: two attacks launched against the runner succeed there, and the pipeline shows it in its logs.
You harden the runner configuration, run the pipeline again, and prove the
two attacks are blocked while the legitimate job still passes. Grading is
taken on the runner configuration and on what the job logs show, never
on a file in the repository: an allowed_images key written without
reloading the runner does not count. Docker-in-Docker no longer works on
a hardened runner, and the lab says so without practising any workaround.
The lab starts with dsoxlab run gl-25-durcir-le-pipeline, after
dsoxlab provision which prepares the virtual machine and its runner. The
DSOXLab installation and its prerequisites are described in
the DSOXLab documentation.
Key takeaways
- Hardening covers the whole chain: token, runner, images, includes, branches, approvals
- The
CI_JOB_TOKENdoes not cross the boundary of a private project on a recent instance, but it clones an internal project without being on any list. This is where lateral movement plays out - An internal project is protected by restricting the repository feature to members, which forces you to lower merge requests and pipelines as well
- The
privilegedmode is the simplest container escape. To build images, kaniko pulled from a maintained fork runs without privilege on a dedicated build runner that keepsCHOWN,DAC_OVERRIDEandFOWNER; Buildah additionally requires relaxed seccomp and AppArmor profiles - Pinning by digest (not by tag) is the only protection against tag poisoning on Docker images
- An
include: project:is pinned to a commit SHA: an omittedref:meansHEAD, and a tag can be moved again. When the external URL is imposed,integritylocks the content in the absence of the source - In the free edition, MR approval is optional and blocks nothing: it is the protected branches that really control merging. Required approval rules are Premium
- plumber on a pipeline schedule detects configuration regressions
Sources
GitLab settings change from one major version to the next, in particular the labels of the Settings screens. Check the pages below for your instance's version before applying a measure.
- CI/CD job token, GitLab Docs
- GitLab Runner advanced configuration, GitLab Docs
- Protected branches, GitLab Docs
- Merge request approvals, GitLab Docs, for the Free and Premium tiers
- Fine-grained permissions for CI/CD job tokens, GitLab Docs
include:integrityandinclude:project:ref, GitLab Docs- plumber, CI/CD security issue detector, getplumber project
- kaniko, original repository archived on June 3, 2025, Google, and the maintained fork osscontainertools/kaniko
- Trivy ecosystem supply chain was briefly compromised, GHSA-69fq-xp46-6x23, Aqua Security, CVE-2026-33634
- Update: Ongoing Checkmarx Supply Chain Security Incident, Checkmarx, for
kics-github-action - OWASP Top 10 CI/CD, CICD-SEC-07: Insecure System Configuration, OWASP
Test your knowledge
Ten questions on the points most often misconfigured: job token scope,
privileged mode, image pinning and remote includes.
Knowledge check
Check what you have learned with this interactive quiz
How it works
- The timer starts when you click Start
- Multiple choice, true/false and short answer questions
- You can move between questions
- Detailed results are shown at the end
Starts the quiz and the timer
Review
(0/0)Score by domain
A domain groups several guides and gets several questions, so its score is more reliable than a per-topic score, which often rests on a single question.
Summary
Strengths
No topic passed at 80% on at least two questions yet.
Weak areas
No topic below the pass mark.
Recommended next
Hints before you try again?
Continue the course: GitLab CI: manage secrets without exposing them
A new full quiz with random questions
Work again only on the questions you missed