Harden GitLab CI/CD pipelines for supply-chain security — SHA-pin `include:` and CI/CD components, scope the `CI_JOB_TOKEN` allowlist, protect and mask variables, pin job image digests, and use `id_tokens`/OIDC instead of long-lived secrets. Use when adding or auditing a `.gitlab-ci.yml`, before making a GitLab project public, when a supply-chain review flags CI gaps, or when standardizing pipeline hardening across GitLab projects (gitlab.com or self-hosted). GitLab-specific by design — for GitHub Actions use `harden-github-actions`; Forgejo/Gitea Actions are out of scope.
التثبيت
التثبيت باستخدام Codex أو Claude انسخ هذا Prompt والصقه في Codex أو Claude أو مساعد آخر ليراجع صفحة Skill ويثبّتها لك.
Harden GitLab CI/CD pipelines for supply-chain security — SHA-pin `include:` and CI/CD components, scope the `CI_JOB_TOKEN` allowlist, protect and mask variables, pin job image digests, and use `id_tokens`/OIDC instead of long-lived secrets. Use when adding or auditing a `.gitlab-ci.yml`, before making a GitLab project public, when a supply-chain review flags CI gaps, or when standardizing pipeline hardening across GitLab projects (gitlab.com or self-hosted). GitLab-specific by design — for GitHub Actions use `harden-github-actions`; Forgejo/Gitea Actions are out of scope.
metadata
{"author":"Georges Martin <jrjsmrtn@gmail.com>","version":"0.1.25"}
license
MIT
Harden GitLab CI
Harden GitLab CI/CD pipelines against supply-chain attack: pin what runs, minimise what it can reach, and stop long-lived secrets from existing at all.
GitLab-specific by design. Like harden-github-actions, this skill is deliberately bound to one
forge. The controls are properties of the GitLab CI execution model — include: resolution, the
CI_JOB_TOKEN allowlist, project/group variable scoping, id_tokens — not portable concepts in
GitLab syntax. It is a sibling of harden-github-actions, not a translation of it: the two
forges differ in what the risks are, not merely in how they are spelled.
When to Use
When adding a .gitlab-ci.yml to a project (after setup-pre-commit)
When auditing an existing pipeline before making a GitLab project public
When a supply-chain review (or the supply-chain skill) flags CI hardening gaps
When standardising pipeline hardening across GitLab projects
Not for: GitHub Actions (use harden-github-actions) or Forgejo/Gitea Actions. The controls do not carry over — say so rather than approximating.
Required Inputs
Repository — a GitLab project (auto-detected from git remotes). Self-hosted GitLab is a
first-class target, not an exception: authenticate once and every control below is identical.
Project visibility — public/internal or private. Phases 3 and 4 branch on it: public and
internal projects expose pipeline artifacts to unauthenticated users, and fork merge requests are
the main variable-exfiltration route.
Scope — what to run:
audit — report findings against the checklist below; no writes (default)
fix — apply the mechanical fixes (pin includes/components/images), leave judgment calls to the maintainer
full — audit, then fix after confirmation
The GitLab Threat Model — What Actually Differs
Where the risk sits differs from GitHub Actions; the controls are not one-for-one equivalents:
Concern
GitHub Actions
GitLab CI
Third-party code execution
uses: actions/x@ref from the Marketplace
include: + CI/CD Catalog components — same class of risk, different keyword
Ambient credential
GITHUB_TOKEN with repo-wide default scopes
CI_JOB_TOKEN — defaults to own project only; risk is a widened allowlist
Secret exfiltration surface
workflow can read all repo secrets
variable scoping — protected/masked/environment-scoped, plus fork-MR exposure
Provenance / keyless auth
OIDC via id-token: write
id_tokens: with an aud claim
Runner trust
GitHub-hosted vs self-hosted
shared vs group/project runners, privileged Docker, shell executor
GitLab has a marketplace-shaped supply chain: the CI/CD Catalog of components. Assuming
otherwise — that only GitHub has this problem — skips the pinning discipline entirely. GitLab's
own docs (CI/CD components → Best practices) say to "Pin CI/CD components to a specific commit
SHA (preferred) or release version tag to ensure the integrity of the component used in a
pipeline."
Workflow
Phase 1: Detect Context
Confirm the forge is GitLab and the CLI is authenticated:
If the remote is GitHub or Forgejo, stop and point at the right sibling skill.
Locate the pipeline config. glab ci lintdefaults to .gitlab-ci.yml in the current
directory, but a project may relocate it (Settings → CI/CD → General pipelines → CI/CD
configuration file). If it is relocated, cd to its directory or pass its path/URL — a bare
lint would silently validate the wrong file, or nothing.
Validate it parses before changing anything:
glab ci lint # or: glab ci lint <path-or-url>
Inventory the third-party surface — run these and triage every hit (CONFIG = the config path):
# Components/includes on a moving target: ~latest, a partial semver, or a branch
grep -nE '@(~latest|[0-9]+(\.[0-9]+)?$|main|master)'"$CONFIG"# include: project blocks — then confirm each has a `ref:` pinned to a 40-char SHA.# A missing ref: is a finding: it silently defaults to the target project's HEAD.
grep -nA3 'include:'"$CONFIG" | grep -E 'project:|ref:'
grep -nE '^\s*ref:\s*(?![0-9a-f]{40}\s*$)' -P "$CONFIG"# ref: present but not a SHA# Remote includes — no auth, no ref to pin; each one is a trust-boundary decision
grep -nE '^\s*-?\s*remote:'"$CONFIG"# Images/services not pinned by digest (covers `image:` and `- name:` service entries;# the negative lookahead is what keeps already-pinned `foo:1@sha256:…` from false-positiving)
grep -nPE '^\s*-?\s*(image|name):\s*(?!.*@sha256:)'"$CONFIG"# Deprecated JWTs (see Phase 5) and inline secrets
grep -nE 'CI_JOB_JWT'"$CONFIG"# Runner isolation escapes
grep -nE 'privileged:\s*true|executor:\s*shell'"$CONFIG" .gitlab-runner/*.toml 2>/dev/null
Treat every hit as a finding to justify or fix — not as noise to skim.
Phase 2: Pin the Third-Party Supply Chain
This is the highest-value control — the direct analogue of SHA-pinning actions.
CI/CD components (include: component) — pin to a commit SHA:
include:# BAD — a moving target; ~latest re-resolves on every run-component:$CI_SERVER_FQDN/my-org/security-components/secret-detection@~latest# GOOD — immutable-component:$CI_SERVER_FQDN/my-org/security-components/secret-detection@e3262fdd0914fa823210cdb79a8c421e2cef79d8
Version resolution precedence, and why ~latest is unsafe: a commit SHA is exact; a tag
(1.0.0) is mutable unless the project protects its tags — and if a tag and SHA share a name, the
SHA wins; a branch is fully mutable; ~latest/partial semver re-resolves to whatever the Catalog
last published. Prefer SHA; accept a release tag only when the component project protects tags.
include: project — ref: accepts a branch, tag, or commit SHA. Pin it:
include:-project:'my-group/ci-templates'ref:787123b47f14b552955ca2786bc9542ae66fee5b# not `main`file:'/templates/build.yml'
ref: is optional and defaults to the project's HEAD — an unpinned include: project silently
tracks someone else's default branch. Treat a missing ref: as a finding, not a style nit.
include: remote — a public URL fetched over HTTP(S) with no authentication support. It is
the weakest link: whoever controls that URL controls your pipeline, and there is no ref to pin.
Prefer component or project. If a remote include is unavoidable, use a URL that embeds an
immutable revision (a raw file path containing a commit SHA, not /raw/main/), and treat the host as
part of your trust boundary.
include: template and include: local are GitLab-shipped or in-repo — no pinning needed.
Resolving a tag to a SHA — for a component or template project:
# .id is the full commit SHA; :sha accepts a branch or tag name
glab api "projects/my-org%2Fsecurity-components/repository/commits/1.0.0" --jq '.id'
:fullpath also works in place of the URL-encoded path when acting on the current project. For a
non-GitLab-hosted template, git ls-remote <url> refs/tags/<tag> resolves the same thing.
Job images and services — pin by digest, keeping the tag for readability:
# BAD — mutableimage:python:3.13services:-postgres:18
# GOOD — immutable, still readableimage:python:3.13@sha256:<digest>services:-name:postgres:18@sha256:<digest>
Resolve digests with skopeo inspect docker://python:3.13 --format '{{.Digest}}'. This mirrors
setup-container-security's base-image rule; the reasoning and the bump procedure are the same.
Phase 3: Scope the Job Token
CI_JOB_TOKEN is minted per job, scoped to the triggering user's access level, masked in logs, and
revoked when the job ends. By default it reaches only its own project — so the work here is
mostly keeping it that way. But verify the default actually holds before auditing anything else:
First, confirm the allowlist is enforced at all. Under Settings → CI/CD → Job token
permissions, the project may be set to either "This project and any groups and projects in the
allowlist" (restricted — the default) or "All groups and projects" (permissive). GitLab's
warning is unambiguous: "If you disable the CI/CD job token allowlist, jobs from any project can
access your project with a job token… You should only disable this setting for testing or a
similar reason." A project in the permissive mode has no allowlist to audit — that is the
worst case and the finding. Fix it before reviewing entries. (Self-managed admins can force the
restricted mode instance-wide via Enable and enforce job token allowlist for all projects.)
Then audit the allowlist: Settings → CI/CD → Job token permissions → CI/CD job token allowlist.
Every entry is a project that may authenticate into this one. Justify each; remove the rest.
(Limit: 200 entries — an allowlist near that size is a finding in itself.)
Adding to the allowlist grants no new permissions — the user must already have access. It
widens reachability, not authority. Do not treat allowlist membership as an access grant.
Prefer the fine-grained permissions setting (limiting the token to a specific set of REST
endpoints) over all-or-nothing allowlisting.
Public/internal projects: unauthenticated users can fetch artifacts from public pipelines
regardless of the allowlist. If artifacts are sensitive, set feature visibility to
Only project members.
Review the authentication log periodically for unexpected cross-project token use.
Phase 4: Variables and Secrets
Never put secrets in .gitlab-ci.yml. It is readable by anyone with repo access; it holds
non-sensitive configuration only. Secrets live in project/group settings or a secrets manager.
Protected = available only to pipelines on protected branches/tags. Masked = redacted as
[MASKED] in job logs. They solve different problems — set both for real secrets:
# Read the secret into a variable; never inline it or echo itread -rs SECRET_VALUE
glab variable set DEPLOY_TOKEN "$SECRET_VALUE" --masked --protected
glab variable list -F json --jq '.[] | select(.masked==false or .protected==false) | .key'
That second command is the audit: any secret-shaped key it prints is a finding.
Masking has hard constraints — the value must be a single line, no spaces, ≥ 8 characters, and
(with expansion enabled) use only _ : @ - + . ~ = / beyond alphanumerics. A secret that cannot be
masked is a secret that will eventually appear in a log; regenerate it in a maskable format rather
than shipping it unmasked.
Masking is not a security boundary. GitLab's own docs (CI/CD variables → Mask a CI/CD
variable) state it "is not a guaranteed way to prevent malicious users from accessing variable
values" — it defeats accidental logging, not an attacker running code in the job.
The fork/MR path is the real exposure. A merge request that edits .gitlab-ci.yml can
exfiltrate both masked and protected variables — GitLab's guidance is to review every
.gitlab-ci.yml change before merging. For public projects, require approval before running
pipelines on fork MRs.
Use --hidden for variables that should not be readable back in the UI. Set it at creation —
it cannot be added later: "Hiding a variable is only possible when creating a new variable, you
cannot update an existing variable to be hidden." Hidden values must also satisfy the masking
requirements above. Making an existing variable hidden means deleting and recreating it, which in
practice means rotating the secret — plan for that rather than discovering it mid-audit.
Phase 5: Replace Long-Lived Secrets with OIDC (id_tokens)
A static cloud credential in CI is a permanent liability. id_tokens mints a short-lived JWT per
job that a third party can verify:
deploy:id_tokens:DEPLOY_TOKEN:aud:https://deploy.internal.example# set it — services should reject a mismatched audscript:-./authenticate.sh"$DEPLOY_TOKEN"
aud defaults to the GitLab instance domain. Set it explicitly to the consuming service, and
configure that service to reject tokens whose aud does not match — that is what limits the
blast radius if a token leaks.
Claims available for policy include project_path, ref, ref_protected, environment,
runner_id, sha, user_login. Scope the trust policy on the relying side with
ref_protected/environment, not project_path alone.
CI_JOB_JWT / CI_JOB_JWT_V2 are deprecated — they now fail with 401 Unauthorized. Any
pipeline still referencing them is broken, not merely dated.
Phase 6: Runner Trust
Prefer project or group runners over shared runners for jobs that touch secrets.
privileged: true Docker executors on a shared machine give a job the host — flag every use
and demand a justification.
The shell executor on a multi-job machine offers no isolation between jobs; flag it.
Pin runner images by digest (Phase 2).
Phase 7: Verify
glab ci lint # config still parses
glab ci lint --dry-run # simulate creation against a real ref
Re-run the Phase 2 inventory and confirm nothing resolves to a moving target.
Outputs
An audit report: job-token enforcement mode, unpinned includes/components/images/services,
allowlist entries, unmasked/unprotected variables, deprecated CI_JOB_JWT* use,
privileged/shell runners — each with a file:line or settings path.
(fix scope) Pinned include: / component / image: / services: references, with the
resolved SHAs and digests recorded.
(fix scope) glab ci lint re-run and passing after the changes.
A list of judgment calls left to the maintainer — job-token mode, allowlist trimming,
accepted include: remote hosts, runner choice, and fork-MR policy are decisions, not
mechanical fixes.
Any decision worth keeping (an accepted remote include, a retained privileged runner)
recorded as an ADR via setup-adrs.
Validation
Forge confirmed GitLab; GitHub/Forgejo projects redirected to the sibling skill, not approximated
Every include: component pinned to a commit SHA (or a release tag on a tag-protected project) — no ~latest
Every include: project has an explicit ref: pinned to a SHA — no implicit HEAD
Every include: remote justified, and its host treated as part of the trust boundary
Job image: and services: pinned by digest
Job-token access mode confirmed restricted (not "All groups and projects") before allowlist entries were reviewed — a permissive project has no allowlist to audit