Runner Security, Isolation Boundaries, Privileged Containers, Fork Pipelines, Untrusted Code, and Threat Modeling: Guided Hands-On Workflow and Core Operations
Build a completely local runner-routing and threat-surface simulation, prove a fork-like untrusted job cannot reach protected/privileged capacity, compare safe and dangerous executor boundaries, and capture an auditable evidence packet.
Learning objectives
- Build a local runner-policy simulator with explicit runner tags, protection, privilege, worker lifetime, network and credential properties.
- Model fork, same-project protected-ref and ordinary review jobs without using a real GitLab token or runner.
- Prove an attacker-like fork job cannot reach a protected deploy or privileged image-builder pool.
- Compare a non-privileged container boundary with privileged/socket-backed designs without executing unsafe configurations.
-
Capture evidence that could later be correlated to
CI_PIPELINE_SOURCE,CI_COMMIT_SHA, pipeline/job and runner IDs.
1. Scenario and safety boundary
You maintain three conceptual pools: a general untrusted Docker pool, a protected release pool, and a rare privileged image-build pool. The exercise does not register a runner, create a cloud instance, mount a Docker socket, or expose a real secret. It models eligibility locally and emits JSON evidence.
2. Create the disposable workspace
mkdir -p glci-ch31-lab/evidence
cd glci-ch31-lab
python3 --version
printf 'lab=chapter31-runner-security\n' > evidence/preflight.txt
The only side effects are local text/code files under
glci-ch31-lab.
3. Define the runner-policy evaluator
The policy separates routing properties from trust properties. A job must first match tags. A protected runner then rejects anything except the simulated trusted protected-ref class. Untrusted jobs are also forbidden from privileged capacity or a pool that carries privileged credentials.
from dataclasses import dataclass
from typing import FrozenSet
import json, sys
@dataclass(frozen=True)
class Runner:
name: str
tags: FrozenSet[str]
protected: bool
privileged: bool
ephemeral: bool
network: str
credentials: str
RUNNERS = [
Runner('untrusted-docker', frozenset({'untrusted','docker'}), False, False, True,
'internet-egress-only', 'none'),
Runner('protected-release', frozenset({'protected','deploy'}), True, False, True,
'deployment-egress-only', 'short-lived-oidc'),
Runner('protected-image-builder', frozenset({'protected','image-build'}), True, True, True,
'registry-egress-only', 'registry-push-only'),
]
def classify(job):
same_project = job['source_project_id'] == job['target_project_id']
if job.get('pipeline_source') == 'merge_request_event' and not same_project:
return 'untrusted-fork'
if job.get('ref_protected') and same_project:
return 'trusted-protected-ref'
return 'untrusted-or-review'
def eligible(job, runner):
required = set(job.get('required_tags', []))
if not required.issubset(runner.tags):
return False, 'required tags do not match runner tags'
trust = classify(job)
if runner.protected and trust != 'trusted-protected-ref':
return False, 'protected runner rejects this trust class'
if trust.startswith('untrusted') and runner.privileged:
return False, 'untrusted workload cannot use privileged capacity'
if trust.startswith('untrusted') and runner.credentials != 'none':
return False, 'untrusted workload cannot receive privileged credentials'
return True, 'eligible under simulated policy'
def decide(job):
decisions=[]
for r in RUNNERS:
ok, reason = eligible(job, r)
decisions.append({
'runner': r.name,
'eligible': ok,
'reason': reason,
'protected': r.protected,
'privileged': r.privileged,
'ephemeral': r.ephemeral,
'network': r.network,
'credentials': r.credentials,
})
return {'job_id':job['job_id'], 'trust_class':classify(job), 'decisions':decisions}
if __name__ == '__main__':
with open(sys.argv[1], encoding='utf-8') as f:
job=json.load(f)
print(json.dumps(decide(job), indent=2, sort_keys=True))
4. Create three synthetic jobs
The fork deploy request deliberately asks for sensitive tags. The protected main job asks for the same deploy pool but has same-project/protected-ref evidence. The fork test asks only for the untrusted pool.
{
"job_id": "fork-mr-101",
"pipeline_source": "merge_request_event",
"source_project_id": 2202,
"target_project_id": 1101,
"ref_protected": false,
"required_tags": ["protected", "deploy"]
}
{
"job_id": "protected-main-202",
"pipeline_source": "push",
"source_project_id": 1101,
"target_project_id": 1101,
"ref_protected": true,
"required_tags": ["protected", "deploy"]
}
{
"job_id": "fork-test-303",
"pipeline_source": "merge_request_event",
"source_project_id": 2202,
"target_project_id": 1101,
"ref_protected": false,
"required_tags": ["untrusted", "docker"]
}
Save them as fork-deploy.json,
trusted-deploy.json and fork-test.json.
5. Predict, then evaluate
Before running anything, write two predictions: (1) the fork deploy request will match the tag names of the protected deploy pool but still be denied by trust policy; (2) the protected main job will be eligible for the protected release pool and not the untrusted pool because the tags differ.
python3 runner_policy.py fork-deploy.json > evidence/fork-deploy-decision.json
python3 runner_policy.py trusted-deploy.json > evidence/trusted-deploy-decision.json
python3 runner_policy.py fork-test.json > evidence/fork-test-decision.json
python3 -m json.tool evidence/fork-deploy-decision.json
python3 -m json.tool evidence/trusted-deploy-decision.json
Expected fork evidence contains
trust_class: untrusted-fork and a denial reason for
protected-release. This is the core lesson: capability
tags can match while authorization still denies the job.
6. Map the simulation to current GitLab behavior
| Simulation field | Real GitLab evidence/control | Important boundary |
|---|---|---|
pipeline_source |
CI_PIPELINE_SOURCE |
MR, push, schedule and API pipelines have different trust contexts. |
| source/target project IDs | MR predefined variables/API metadata | Fork versus same-project is a trust fact, not a branch-name convention. |
ref_protected |
CI_COMMIT_REF_PROTECTED plus branch/tag
protection settings
|
Protected ref narrows who controls the ref. |
| required tags | Job tags / CI_JOB_TAGS |
Routing requirement, not sufficient authorization. |
| runner protected flag | Runner setting in GitLab | Protected runner rejects ordinary unprotected refs; MR protected-resource rules add further conditions. |
| runner tags | Runner configuration / CI_RUNNER_TAGS |
Capability advertisement. |
| privilege/network/credentials | Runner manager/executor/host configuration | Mostly outside repository YAML; must be inventoried independently. |
7. Optional: inspect a hardened non-privileged container
If Docker is already installed on a disposable workstation, this optional command demonstrates a reduced container boundary. It does not grant privilege or mount host sockets:
docker run --rm --read-only \
--cap-drop ALL \
--security-opt no-new-privileges \
--network none \
alpine:3.22 sh -c 'id; cat /proc/1/status | grep -E "^(Name|CapEff|NoNewPrivs):"'
Interpret the evidence rather than treating flags as a perfect sandbox. The container still shares the host kernel. A production runner also needs safe volumes, cache policy, image provenance, resource limits and a network design.
8. Compare dangerous designs without executing them
The following snippets are threat-model examples only. Do not apply them to a shared or untrusted runner.
# DANGEROUS for untrusted jobs: host-impacting privilege
[[runners]]
executor = "docker"
[runners.docker]
privileged = true
# ALSO DANGEROUS for untrusted jobs: host Docker daemon control
[[runners]]
executor = "docker"
[runners.docker]
privileged = false
volumes = ["/var/run/docker.sock:/var/run/docker.sock"]
GitLab's documentation warns that privileged mode can lead to host root/container breakout, and that Docker socket binding effectively disables the container security boundary by giving the job control of the host daemon.
9. Repository configuration can request pools, but cannot make them secure
stages: [test, release]
default:
image: alpine:3.22
untrusted_tests:
stage: test
tags: [untrusted, docker]
script:
- printf 'source=%s sha=%s\n' "$CI_PIPELINE_SOURCE" "$CI_COMMIT_SHA"
- printf 'job_tags=%s runner_tags=%s\n' "$CI_JOB_TAGS" "$CI_RUNNER_TAGS"
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH'
protected_release:
stage: release
tags: [protected, deploy]
script:
- printf 'release candidate sha=%s\n' "$CI_COMMIT_SHA"
rules:
- if: '$CI_COMMIT_REF_PROTECTED == "true" && $CI_PIPELINE_SOURCE == "push"'
when: manual
- when: never
This YAML narrows job creation and requests specific tags. It does not configure the runner's protected flag, Docker privilege, network, host mounts or credentials. Those are runner/platform state and must be verified separately.
10. Fork pipeline routing: understand the parent-project trap
A normal fork MR pipeline runs in the fork project and uses fork resources. A parent-project member can intentionally run the fork MR in the parent, which uses the fork branch's CI configuration but parent resources/settings/variables and the member's permissions. That is exactly why the parent UI shows a warning. The safe decision is not “forks are blocked”; it is “fork code stays on an untrusted pool unless a reviewer intentionally and safely changes the trust context.”
11. Before/after evidence checklist
| Moment | Capture | Do not capture |
|---|---|---|
| Before routing | Job source/project/ref/SHA, compiled rules, requested tags | Token values or secret-variable values |
| After runner assignment | Runner ID, scope/protection/tags/version/executor, worker ID | Runner authentication token |
| During job | Non-secret logs, image identity, network-policy decision, artifact metadata | Cloud keys, Docker auth JSON, OIDC token body in artifacts |
| After job | Cleanup/destruction proof, manager logs, retained incident evidence | Reused worker state presented as proof of cleanliness |
12. Challenge: pick the causal control layer
A fork MR job asks for [protected, deploy]. The job is
created successfully, but remains pending. The protected runner is
online and advertises both tags. Which layer should you investigate
first?
Answer after reasoning: runner eligibility/trust state, not shell commands or runner capacity. A pending job can be correct behavior if protected-resource rules reject the fork context. Preserve the pipeline/job IDs and trust metadata; do not “fix” it by unprotecting the runner.
13. Evidence packet
runner_policy.pySHA-256 digest.- Three input JSON files and their digests.
- Three decision JSON outputs.
- Your two predictions and whether the outputs matched.
-
Runner baseline assumption: GitLab Runner
19.3.2; no real runner registered. -
A mapping note for
CI_PIPELINE_SOURCE,CI_COMMIT_SHA, protected ref, job tags, runner tags/protection and executor. - Limitations: local model does not call GitLab scheduling APIs or emulate every role/protected-branch rule.
sha256sum runner_policy.py fork-deploy.json trusted-deploy.json fork-test.json > evidence/input-sha256.txt
cat evidence/input-sha256.txt
14. Cleanup
cd ..
rm -rf glci-ch31-lab
printf 'cleanup=local-lab-removed\n'
No runner, token, registry, cloud resource or production system was created. If you later test on GitLab, delete only the exact disposable project/runner created for that exercise and preserve incident evidence first.
Knowledge check
Why does the fork deploy case still fail even though its requested tags match the protected release runner?
The simulated protected-runner trust rule rejects the untrusted fork class. Tags advertise capabilities; the protection/trust decision is a separate eligibility condition.
What does the optional hardened Docker command prove?
Only that the local container was launched without added capabilities/network and with a read-only filesystem/no-new-privileges. It does not prove perfect isolation or a production runner policy.
Why is rules not enough to secure a protected
release runner?
Repository YAML can be attacker-controlled in some contexts. Runner protection/scope, host configuration, credentials and network controls live outside the job and must enforce the boundary independently.
A fork MR is run in the parent project. Whose resources matter?
The pipeline is created in the parent, uses the fork branch CI configuration, but parent CI/CD settings/resources/variables and the triggering parent member permissions. Review before running.
What should you do when a pending untrusted job cannot match a protected runner?
Treat the denial as potentially correct. Verify trust/eligibility evidence rather than weakening protection just to make the job run.
Version and compatibility note
GitLab and GitLab Runner evolve continuously. Treat version-sensitive YAML, runner/executor behavior, APIs, security features, policy controls, tiers, and deprecations as assumptions to verify against the current official GitLab documentation before production use. Preserve the exact project/ref/SHA, compiled configuration, pipeline/job IDs, runner/tool versions, and external target evidence used for any reproducible lab or incident record.
Official references and version notes
Documentation verification date: 2026-09-12. The
current stable GitLab Runner patch used as this chapter's
reproducibility baseline is 19.3.2 (tagged 2026-09-10).
GitLab Runner security guidance treats self-managed runners as
remote-code-execution infrastructure, rates Shell executor as high
risk for untrusted builds, warns that privileged containers and
Docker-socket binding can collapse host isolation, and states that
GIT_STRATEGY: fetch on a shared environment is
appropriate only when all users are trusted. Since GitLab 18.1,
same-project merge-request pipelines can be allowed to use protected
variables/runners only when both source and target branches are
protected, the triggering user has suitable target-branch access,
and both branches belong to the same project; fork merge-request
pipelines cannot access those protected resources. The mandatory
exercises are local simulations: they require no runner registration
token, real secret, privileged container, Docker socket, cloud
account, or production network access. The local evaluator
intentionally simplifies GitLab scheduling; it is a teaching model
for causal boundaries, not a replacement for GitLab authorization
behavior.
- Security for self-managed runners — official reference.
- Configure runners — official reference.
- Runner executors — official reference.
- Shell executor — official reference.
- Docker executor — official reference.
- Use Docker to build Docker images — official reference.
- Merge request pipelines and forks — official reference.
- CI/CD pipelines and protected runner behavior — official reference.
- Pipeline types — official reference.
- CI/CD variables — official reference.
- Predefined CI/CD variables — official reference.
- Advanced Runner configuration — official reference.
- Docker Autoscaler executor — official reference.
- GitLab Runner tags — official reference.
- GitLab 19.3 release notes — official reference.
Current assumptions used in this chapter: Version-sensitive YAML, Runner/executor behavior, APIs, security features, policy controls, tiers, and deprecations must be verified against the exact GitLab, GitLab Runner, tool, and external-system versions used in production. Preserve the exact project/ref/SHA, compiled configuration, pipeline/job IDs, runner/tool versions, and external target evidence used for reproducible work.
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.