GitLab CI includes pinned to SHA

Checks whether external include: entries in the GitLab CI configuration are pinned to a full commit SHA instead of a branch, tag, or version.
category
security
weight
8
applies to
GitLab repositories only

How Plumb checks this

What is inspected, and how the result is decided.

A GitLab CI pipeline can pull configuration from outside the repository through include: entries: files from other projects, CI/CD components, and remote URLs. When such an include points at a branch, a tag, or a component version such as 1.2 or ~latest, whoever controls the source can change what the pipeline runs without touching this repository. This is the same class of attack seen against tag-pinned GitHub Actions in the tj-actions/changed-files incident of March 2025. A full 40-character commit SHA cannot be changed after the fact.

Plumb reads the project's CI configuration file (.gitlab-ci.yml, or the custom path the project sets) and classifies each include. local: files from the same repository and GitLab-bundled template: includes are skipped, because they are covered by the repository's own review controls or ship with GitLab itself. A project: include is pinned when its ref is a full SHA. A component: include is pinned when its version after @ is a full SHA. A remote: URL has no pinning mechanism and always counts as unpinned.

The check passes when every external include is pinned. If more than 80 percent are pinned it warns and awards half credit. Otherwise it fails. It is not applicable when there is no CI configuration or no external includes, and it only runs for repositories hosted on GitLab.

Doing it well

Why this matters

GitLab CI lets a pipeline pull configuration from outside the repository. An include: entry can load a file from another project, a CI/CD component from the catalog, or a file from any URL. Whatever those sources contain becomes part of your pipeline, running with your variables and your deploy credentials.

If the include points at a branch or a tag, the code it loads can change without anyone touching your repository. Whoever controls the other project can push new code to that branch, or move the tag, and your next pipeline runs it. This is the same weakness that was exploited against GitHub Actions in the tj-actions/changed-files compromise of March 2025, where version tags were rewritten to point at code that leaked secrets.

A full commit SHA cannot be moved. Pinning your includes to one means the configuration you reviewed is exactly what runs.

What good looks like

include:
  # A file from another project, pinned to a commit.
  - project: my-group/ci-templates
    ref: 9f3c2b7d1e4a6f8c0b2d4e6f8a0c2e4f6a8b0c2d # v3.1.0
    file: /templates/php.yml

  # A CI/CD component, pinned to a commit.
  - component: gitlab.example.com/my-group/components/php-test@b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0 # 1.4.0
    inputs:
      php_version: '8.5'

  # Files in this repository and GitLab's own templates need no pinning.
  - local: /.gitlab/ci/build.yml
  - template: Security/SAST.gitlab-ci.yml

How to do it

Pin project includes

  1. Open the project you include from and find the commit behind the branch or tag you currently use.
  2. Copy its full 40-character SHA.
  3. Set ref: to that SHA. Add a comment with the tag name so readers know which version it is.

Pin components

A component reference ends with @ and a version. Replace the version with the full SHA of the commit that release was made from. You can find it on the component project's tags page.

Replace remote URL includes

An include: remote: entry loads a file from a URL, and a URL has no way to pin a specific revision. Move the file into a project you control and include it with project: and a pinned ref:, or copy it into your own repository and use local:.

Keep pins up to date

Pinned includes do not update themselves. Renovate supports GitLab and will open merge requests that move a pinned SHA when a new version is published, so you still get updates, now as reviewable changes.

Things to watch for

  • ref: main or ref: v1. Both are moving targets. Only a full commit SHA is fixed.
  • ~latest on components. This always resolves to the newest release, whatever it contains.
  • A pin with no version comment. Future readers cannot tell what a bare SHA is. Leave a comment with the tag.
  • Custom CI config paths. If your project sets a custom CI/CD configuration file, the includes to pin live there rather than in .gitlab-ci.yml.

Further reading