Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

GitLab CI: harden your pipelines in 10 measures

By updated

Improve this page

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_TOKEN to the strict minimum
  • Isolate runners by trust level (shared, group, project)
  • Disable the privileged mode 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 plumber and 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:

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 projectVisibilityResult of git clone with CI_JOB_TOKEN
cible-priveeprivatedenied, HTTP 403
cible-interneinternalsucceeded

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: 403

Two 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_enabled is true.

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.

.gitlab-ci.yml: what a compromised job tries first
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.

  1. 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.

  2. 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.
  3. 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:

    LevelExample names
    ReadREAD_JOBS, READ_PACKAGES, READ_BADGES
    Read and writeADMIN_PACKAGES, ADMIN_DEPLOYMENTS, ADMIN_BADGES

    A project that only downloads a package needs only READ_PACKAGES: leaving it ADMIN_PACKAGES also lets it publish one, and therefore replace a dependency that other pipelines consume.

  4. 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.

config.toml: BEFORE (dangerous)
[[runners]]
name = "shared-runner"
[runners.docker]
privileged = true
config.toml: AFTER (secure)
[[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 privileged and without any seccomp or AppArmor setting (Docker's default profiles that filter system calls and access to resources). But with cap_drop = ["ALL"], it fails on chown /etc/gshadow: operation not permitted: for the Dockerfile of the running project of the labs, it needs at least the CHOWN, DAC_OVERRIDE and FOWNER capabilities, given back by cap_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:

config.toml: build runner for kaniko
[[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:

.gitlab-ci.yml: build without privileged, from a maintained fork
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.

LevelRunnerTagsUsage
Public/ForkDedicated runner, unprotectedpublic, untrustedExternal MRs, contribution CI
InternalGroup runnerinternalBuild, tests, quality
DeploymentProject runner, protecteddeploy, protectedStaging/production deployment
config.toml: isolated deployment runner
[[runners]]
name = "deploy-runner"
limit = 1
[runners.docker]
privileged = false
allowed_images = ["registry.example.com/deploy/*"]
allowed_services = []
  1. 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=false

    The 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=false completes the restriction: without it, this deployment runner would pick up any job without a tag.

  2. 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.

  3. Force the tags in deployment jobs

    .gitlab-ci.yml: job restricted to the protected runner
    deploy:production:
    tags:
    - deploy
    - protected
    rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
    script:
    - 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.

.gitlab-ci.yml: BEFORE (mutable tag)
test:
# ❌ Counter-example: a bare tag, which the registry can move again at any time
image: python:3.12-slim
.gitlab-ci.yml: AFTER (immutable digest)
test:
image: python:3.12-slim@sha256:2f17fc044b579bab302c2e8054d3a686e2cb9a83de48e70534b94cd8ebbe06a9

Which 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:

Terminal window
# ❌ Returns the digest of the FIRST manifest of the index, so one platform
docker 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 getValueWhat it pins
.manifests[0].digestsha256:44ff437b…linux/amd64 only
Index digestsha256:2f17fc04…the tag, all architectures

The right command reads the index itself:

Get the index digest, the one the tag references
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.

.gitlab-ci.yml: BEFORE (unverified include)
include:
# ❌ Counter-example: the URL is reread at every pipeline, with no verification at all
- remote: https://example.com/templates/deploy.yml
.gitlab-ci.yml: AFTER (include from a project, pinned to a commit)
include:
- 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 refWhat can change under your feet
OmittedThe 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: mainSame, 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.

.gitlab-ci.yml: remote include locked by fingerprint
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:

Compute the fingerprint expected by integrity
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:

config.toml: image allowlist
[[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:

config.toml: privilege reserved for a single 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.

  1. 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.yml included.

  2. 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.

  3. 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.

Protect the main branch via the API
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 levelValueWho can act
No access0Nobody
Developer30Developers and above
Maintainer40Maintainers and above
Admin60Administrators 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.

.gitlab-ci.yml: deploy only via a merged MR
deploy:production:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
when: never
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
script:
- ansible-playbook deploy-prod.yml

What you apply therefore depends on your edition:

EditionWhat really blocks an unreviewed merge
Free / CEThe 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 / UltimateThe 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.

Analyze a GitLab project with 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:

Observed result (excerpt)
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.

.gitlab-ci.yml: continuous audit with plumber
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.

#MeasureVerifiable by
1Inbound allowlist reduced to the project, internal targets handledGET /projects/:id/job_token_scope/allowlist
2privileged mode disabledrunner config.toml
3Runners separated by trustAdministration > Runners (tags + protected)
4Images pinned by digestGrep @sha256: in .gitlab-ci.yml
5Includes pinned to a commit SHA, not a taggrep -A2 'include:' .gitlab-ci.yml, a ref: of 40 hexadecimal characters
6Allowed images restrictedconfig.toml → allowed_images
7Fork pipelines forbidden in the parent projectProjects API, ci_allow_fork_pipelines_to_run_in_parent_project set to false
8Critical branches protectedSettings > Repository > Protected branches
9Controlled merge: protected branches on CE, approval rules on PremiumSettings > Repository > Protected branches, and Settings > Merge requests
10Regular plumber auditWeekly 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.

SymptomLikely causeSolution
Job fails with image is not allowedImage not in the runner's allowed_imagesAdd the image to the allowlist in config.toml
Deployment job does not startProtected runner + unprotected branchCheck that the branch is in the protected list
Authentication by CI/CD job token not allowed from X to project #NThe calling project is not on the target's inbound allowlistAdd the calling project in the target's Settings > CI/CD > Job token permissions
You are not allowed to download code from this projectThe target's repository feature is restricted to membersAdd the account that triggers the pipeline as a member, or reopen repository_access_level knowingly
Docker build fails after disabling privilegedDinD requires privileged = trueMigrate to kaniko from a maintained fork, not from gcr.io, on a dedicated build runner
kaniko: chown /etc/gshadow: operation not permittedRunner with cap_drop = ["ALL"]Give back CHOWN, DAC_OVERRIDE and FOWNER through cap_add on the build runner
Buildah: unshare(CLONE_NEWUSER): Operation not permittedDefault seccomp and AppArmor profilesPrefer kaniko, or relax security_opt on a dedicated build runner
plumber cannot connectInsufficient tokenUse 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_TOKEN does 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 privileged mode is the simplest container escape. To build images, kaniko pulled from a maintained fork runs without privilege on a dedicated build runner that keeps CHOWN, DAC_OVERRIDE and FOWNER; 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 omitted ref: means HEAD, and a tag can be moved again. When the external URL is imposed, integrity locks 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.

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

10 questions
5 min
70% to pass

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

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn