From project-orchestration-skills
Hardens GitLab CI/CD pipelines for supply-chain security: SHA-pin includes, scope CI_JOB_TOKEN, protect variables, pin image digests, use OIDC. Use when adding/auditing .gitlab-ci.yml or before making a project public.
How this skill is triggered — by the user, by Claude, or both
Slash command
/project-orchestration-skills:harden-gitlab-ciThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
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.
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, theCI_JOB_TOKENallowlist, project/group variable scoping,id_tokens— not portable concepts in GitLab syntax. It is a sibling ofharden-github-actions, not a translation of it: the two forges differ in what the risks are, not merely in how they are spelled.
.gitlab-ci.yml to a project (after setup-pre-commit)supply-chain skill) flags CI hardening gapsNot for: GitHub Actions (use harden-github-actions) or Forgejo/Gitea Actions. The controls do not carry over — say so rather than approximating.
audit — report findings against the checklist below; no writes (default)fix — apply the mechanical fixes (pin includes/components/images), leave judgment calls to the maintainerfull — audit, then fix after confirmationWhere 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."
Confirm the forge is GitLab and the CLI is authenticated:
git remote -v
glab auth status # self-hosted: glab auth login --hostname gitlab.internal.example
If the remote is GitHub or Forgejo, stop and point at the right sibling skill.
Locate the pipeline config. glab ci lint defaults 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.
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 — mutable
image: python:3.13
services:
- postgres:18
# GOOD — immutable, still readable
image: 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.
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:
.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.[MASKED] in job logs. They solve different problems — set both for real secrets:
# Read the secret into a variable; never inline it or echo it
read -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._ : @ - + . ~ = / 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..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.--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.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 aud
script:
- ./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.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.privileged: true Docker executors on a shared machine give a job the host — flag every use
and demand a justification.shell executor on a multi-job machine offers no isolation between jobs; flag it.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.
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.include: remote hosts, runner choice, and fork-MR policy are decisions, not
mechanical fixes.remote include, a retained privileged runner)
recorded as an ADR via setup-adrs.include: component pinned to a commit SHA (or a release tag on a tag-protected project) — no ~latestinclude: project has an explicit ref: pinned to a SHA — no implicit HEADinclude: remote justified, and its host treated as part of the trust boundaryimage: and services: pinned by digestCI_JOB_TOKEN allowlist reviewed entry-by-entry; fine-grained permissions preferred.gitlab-ci.yml; every secret variable both masked and protectedid_tokens with an explicit aud used instead of long-lived credentials; no CI_JOB_JWT*privileged / shell executors justified or removedglab ci lint passes after changesharden-github-actions — the sibling for GitHub Actions. Same intent, different execution model; neither is a translation of the othersetup-adrs — record the decisions this surfaces: an accepted include: remote host, retained allowlist entries, a justified privileged runnersetup-container-security — base-image digest pinning and the skopeo bump procedure this reuses for job imagessetup-pre-commit — local quality gates; this skill hardens the CI that enforces themvalidate-quality-config — reads CI to confirm parity with local hooks; it does not check pinning, permissions, or provenance. Complementarysupply-chain (project-maintenance-skills) — the audit that flags CI hardening gaps; this skill closes themwrapup-sprint — release provenance for ships-artifacts projects, which consumes the OIDC path established herenpx claudepluginhub jrjsmrtn/jrjsmrtn-skills --plugin project-orchestration-skillsHardens GitHub Actions CI/CD workflows against supply-chain attacks by SHA-pinning actions, enforcing least-privilege token permissions, verifying toolchain installs, and adding OpenSSF Scorecard/SLSA provenance.
Generates production-ready GitLab CI/CD pipelines (.gitlab-ci.yml), stages, and jobs following best practices; validates syntax and compliance for builds, deploys, and scans.
Hardens GitHub Actions workflows against supply chain attacks, credential theft, and privilege escalation with SHA pinning, token minimization, script injection prevention, and required reviewers for changes.