GitLab CI provider
Parses .gitlab-ci.yml on disk, no GitLab API token, no runner install.
Works against the file in a detached clone or a merged-result pipeline
export.
Producer workflow
# --gitlab-path auto-detected when .gitlab-ci.yml exists at cwd.
pipeline_check --pipeline gitlab
# …or pass it explicitly (file or directory).
pipeline_check --pipeline gitlab --gitlab-path ci/
What it covers
52 checks · 12 have an autofix patch (--fix).
| Check | Title | Severity | Fix |
|---|---|---|---|
| GL-001 | Image not pinned to specific version or digest | HIGH | 🔧 fix |
| GL-002 | Script injection via untrusted commit/MR context | HIGH | |
| GL-003 | Variables contain literal secret values | CRITICAL | |
| GL-004 | Deploy job lacks manual approval or environment gate | MEDIUM | |
| GL-005 | include: pulls remote / project without pinned ref | HIGH | |
| GL-006 | Artifacts not signed | MEDIUM | |
| GL-007 | SBOM not produced | MEDIUM | |
| GL-008 | Credential-shaped literal in pipeline body | CRITICAL | 🔧 fix |
| GL-009 | Image pinned to version tag rather than sha256 digest | LOW | |
| GL-010 | Multi-project pipeline ingests upstream artifact unverified | CRITICAL | |
| GL-011 | include: local file pulled in MR-triggered pipeline | HIGH | |
| GL-012 | Cache key derives from MR-controlled CI variable | MEDIUM | |
| GL-013 | AWS auth uses long-lived access keys | MEDIUM | 🔧 fix |
| GL-014 | Self-managed runner without ephemeral tag | MEDIUM | |
| GL-015 | Job has no timeout, unbounded build |
MEDIUM | 🔧 fix |
| GL-016 | Remote script piped to shell interpreter | HIGH | 🔧 fix |
| GL-017 | Docker run with insecure flags (privileged/host mount) | CRITICAL | 🔧 fix |
| GL-018 | Package install from insecure source | HIGH | 🔧 fix |
| GL-019 | No vulnerability scanning step | MEDIUM | |
| GL-020 | CI_JOB_TOKEN written to persistent storage | CRITICAL | 🔧 fix |
| GL-021 | Package install without lockfile enforcement | MEDIUM | 🔧 fix |
| GL-022 | Dependency update command bypasses lockfile pins | MEDIUM | 🔧 fix |
| GL-023 | TLS / certificate verification bypass | HIGH | 🔧 fix |
| GL-024 | No SLSA provenance attestation produced | MEDIUM | |
| GL-025 | Pipeline contains indicators of malicious activity | CRITICAL | |
| GL-026 | Dangerous shell idiom (eval, sh -c variable, backtick exec) | HIGH | |
| GL-027 | Package install bypasses registry integrity (git / path / tarball source) | MEDIUM | |
| GL-028 | services: image not pinned | HIGH | |
| GL-029 | Manual deploy job defaults to allow_failure: true | MEDIUM | |
| GL-030 | trigger: include: pulls child pipeline without pinned ref | HIGH | |
| GL-031 | id_tokens: missing audience pin or environment binding | HIGH | |
| GL-032 | tags: interpolates untrusted CI variable | HIGH | 🔧 fix |
| GL-033 | Global before_script / after_script propagates taint to every job | HIGH | |
| GL-034 | npm install without registry-signature verification step | MEDIUM | |
| GL-035 | pip install without --require-hashes verification |
MEDIUM | |
| GL-036 | Secret-named variable echoed / printed in a script block | HIGH | |
| GL-037 | Pipeline disables Go module checksum / sum-db verification | HIGH | |
| GL-038 | CI_DEBUG_TRACE / debug logging dumps secrets to the job log | HIGH | |
| GL-039 | Docker-in-Docker service exposes an unauthenticated daemon | HIGH | |
| GL-040 | CI_JOB_TOKEN used for cross-project / remote access | HIGH | |
| GL-041 | IaC apply on an untrusted merge-request trigger | CRITICAL | |
| GL-042 | include: component pulls a CI/CD component without a pinned version | HIGH | |
| GL-043 | GitLab native security scanner explicitly disabled | MEDIUM | |
| GL-044 | Automatic production deployment on a merge-request pipeline | CRITICAL | |
| GL-045 | ML model loaded with trust_remote_code (code execution) | HIGH | |
| GL-046 | AI model pulled without a pinned revision | MEDIUM | |
| GL-047 | Unsafe deserialization of a fetched artifact (pickle RCE) | HIGH | |
| GL-048 | Untrusted MR/commit context reaches an agentic AI CLI (prompt injection) | HIGH | |
| GL-049 | Agentic CLI output lands without human review | HIGH | |
| GL-050 | Package-publish job relies on a long-lived registry token | HIGH | |
| TAINT-004 | Untrusted input flows across jobs via dotenv artifact | HIGH | |
| TAINT-008 | Untrusted input flows via GitLab extends: template inheritance |
HIGH |
GL-001: Image not pinned to specific version or digest
Floating tags (latest or major-only) can be silently swapped under the job. Every image: reference should pin a specific version tag or digest.
Recommended action
Reference images by @sha256:<digest> or at minimum a full immutable version tag (e.g. python:3.12.1-slim). Avoid :latest and bare tags like :3.
GL-002: Script injection via untrusted commit/MR context
CI_COMMIT_MESSAGE / CI_COMMIT_REF_NAME / CI_MERGE_REQUEST_TITLE and friends are populated from SCM event metadata the attacker controls. Interpolating them into a shell body executes the crafted content as part of the build.
Recommended action
Read these values into intermediate variables: entries or shell variables and quote them defensively ("$BRANCH"). Never inline $CI_COMMIT_MESSAGE / $CI_MERGE_REQUEST_TITLE into a shell command.
GL-003: Variables contain literal secret values
Scans variables: at the top level and on each job for entries whose KEY looks credential-shaped and whose VALUE is a literal string (not a $VAR reference). AWS access keys are detected by value pattern regardless of key name.
Recommended action
Store credentials as protected + masked CI/CD variables in project or group settings, and reference them by name from the YAML. For cloud access prefer short-lived OIDC tokens.
GL-004: Deploy job lacks manual approval or environment gate
A job whose stage or name contains deploy / release / publish / promote should either require manual approval or declare an environment: binding. Otherwise any push to the trigger branch ships to the target.
Recommended action
Add when: manual (optionally with rules: for protected branches) or bind the job to an environment: with a deployment tier so approvals and audit are enforced by GitLab's environment controls.
GL-005: include: pulls remote / project without pinned ref
Cross-project and remote includes can be silently re-pointed. Branch-name refs (main/master/develop/head/trunk) are treated as unpinned; tag and SHA refs are considered safe.
Recommended action
Pin include: project: entries with ref: set to a tag or commit SHA. Avoid include: remote: for untrusted URLs; mirror the content into a trusted project and pin it.
GL-006: Artifacts not signed
Unsigned artifacts can't be verified downstream, so a tampered build is indistinguishable from a legitimate one. Pass when any of cosign / sigstore / slsa-* / notation-sign appears in the pipeline text.
Recommended action
Add a job that runs cosign sign (keyless OIDC with GitLab's id_tokens works out of the box) or notation sign. Publish the signature next to the artifact and verify it on consume.
GL-007: SBOM not produced
Without an SBOM, downstream consumers can't audit the dependency set shipped in the artifact. Passes when CycloneDX / syft / anchore / spdx-sbom-generator / sbom-tool / Trivy-SBOM appears in the pipeline body.
Recommended action
Add an SBOM step, syft . -o cyclonedx-json, Trivy with --format cyclonedx, or GitLab's built-in CycloneDX dependency-scanning template. Attach the SBOM as a pipeline artifact.
GL-008: Credential-shaped literal in pipeline body
Complements GL-003 (which looks at variables: block keys). GL-008 scans every string in the pipeline against the cross-provider credential-pattern catalog, catches secrets pasted into script: bodies or environment blocks where the name-based detector can't see them.
Known false-positive modes
- Test fixtures and documentation blobs sometimes embed credential-shaped strings (JWT samples, vendor example keys). Well-known vendor example tokens (
AKIAIOSFODNN7EXAMPLE, Stripesk_test_docs keys) are suppressed via theVENDOR_EXAMPLE_TOKENSallowlist. Defaults to LOW confidence.
Recommended action
Rotate the exposed credential immediately. Move the value to a protected + masked CI/CD variable and reference it by name. For cloud access prefer short-lived OIDC tokens.
GL-009: Image pinned to version tag rather than sha256 digest
GL-001 fails floating tags at HIGH; GL-009 is the stricter tier. Even immutable-looking version tags (python:3.12.1) can be repointed by registry operators. Digest pins are the only tamper-evident form.
Recommended action
Resolve each image to its current digest (docker buildx imagetools inspect <ref> prints it) and replace the tag with @sha256:<digest>. Automate refreshes with Renovate.
GL-010: Multi-project pipeline ingests upstream artifact unverified
needs: { project: ..., artifacts: true } pulls artifacts from another project's pipeline. If that upstream project accepts MR pipelines, the artifact may have been built by attacker-controlled code.
Recommended action
Add a verification step before consuming the artifact: cosign verify-attestation, sha256sum -c, or gpg --verify against a manifest signed by the upstream project's release key. Only consume artifacts produced by upstream pipelines whose origin you can trust.
GL-011: include: local file pulled in MR-triggered pipeline
include: local: '<path>' resolves from the current pipeline's checked-out tree. On an MR pipeline the tree is the MR source branch, the MR author controls the included YAML content.
Recommended action
Move the included template into a separate, read-only project and reference it via include: project: ... ref: <sha-or-tag>. That way the included content is fixed at MR creation time and not editable from the MR branch.
GL-012: Cache key derives from MR-controlled CI variable
GitLab caches restore by key prefix. When the key includes an MR-controlled variable, an attacker can poison a cache entry that a later default-branch pipeline restores.
Recommended action
Build the cache key from values the MR can't control: lockfile contents (files: [Cargo.lock]), the job name, and $CI_PROJECT_NAMESPACE. Never reference $CI_MERGE_REQUEST_* or $CI_COMMIT_BRANCH from a cache key namespace.
GL-013: AWS auth uses long-lived access keys
Long-lived AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY values in CI/CD variables can't be rotated on a fine-grained schedule. GitLab supports OIDC via id_tokens: for short-lived credential injection.
Recommended action
Use GitLab CI/CD OIDC with id_tokens: to obtain short-lived AWS credentials via sts:AssumeRoleWithWebIdentity. Remove static AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY from CI/CD variables.
GL-014: Self-managed runner without ephemeral tag
Self-managed runners that don't tear down between jobs leak filesystem and process state. The check looks for an ephemeral tag on any job whose tags: list doesn't match SaaS-only runner names.
Recommended action
Register the runner with --executor docker + --docker-pull-policy always so containers are fresh per job, and add an ephemeral tag. Alternatively use the GitLab Runner Operator with autoscaling.
GL-015: Job has no timeout, unbounded build
Without an explicit timeout, the job runs until the instance-level default (typically 60 minutes). Explicit timeouts cap blast radius and the window during which a compromised script has access to CI/CD variables.
Recommended action
Add timeout: to each job (e.g. timeout: 30 minutes), sized to the 95th percentile of historical runtime. GitLab's default is 60 minutes (or the instance admin setting).
GL-016: Remote script piped to shell interpreter
Detects curl | bash, wget | sh, and similar patterns that pipe remote content directly into a shell interpreter inside a pipeline. An attacker who controls the remote endpoint (or poisons DNS / CDN) gains arbitrary code execution in the CI runner.
Known false-positive modes
- Established vendor installers (get.docker.com, sh.rustup.rs, bun.sh/install, awscli.amazonaws.com, cli.github.com, ...) ship via HTTPS from their own CDN and are idiomatic. This rule defaults to LOW confidence so CI gates can ignore them with --min-confidence MEDIUM; the finding still surfaces so teams that want cryptographic verification can audit.
Recommended action
Download the script to a file, verify its checksum, then execute it. Or vendor the script into the repository.
GL-017: Docker run with insecure flags (privileged/host mount)
Flags like --privileged, --cap-add, --net=host, or host-root volume mounts (-v /:/) in a pipeline give the container full access to the CI runner, enabling container escape and lateral movement.
Recommended action
Remove --privileged and --cap-add flags. Use minimal volume mounts. Prefer rootless containers.
GL-018: Package install from insecure source
Detects package-manager invocations that use plain HTTP registries (--index-url http://, --registry=http://) or disable TLS verification (--trusted-host, --no-verify) in a pipeline. These patterns allow man-in-the-middle injection of malicious packages.
Recommended action
Use HTTPS registry URLs. Remove --trusted-host and --no-verify flags. Pin to a private registry with TLS.
GL-019: No vulnerability scanning step
Without a vulnerability scanning step, known-vulnerable dependencies ship to production undetected. The check recognizes trivy, grype, snyk, npm audit, yarn audit, safety check, pip-audit, osv-scanner, and govulncheck.
Recommended action
Add a vulnerability scanning step, trivy, grype, snyk test, npm audit, pip-audit, or osv-scanner. Publish results so vulnerabilities surface before deployment.
GL-020: CI_JOB_TOKEN written to persistent storage
Detects patterns where CI_JOB_TOKEN is redirected to a file, piped through tee, or appended to dotenv/artifact paths. Persisted tokens survive the job boundary and can be read by later stages, downloaded artifacts, or cache entries, turning a scoped credential into a long-lived one.
Recommended action
Never write CI_JOB_TOKEN to files, artifacts, or dotenv reports. Use the token inline in the command that needs it and let GitLab revoke it automatically when the job finishes.
GL-021: Package install without lockfile enforcement
Detects package-manager install commands that do not enforce a lockfile or hash verification. Without lockfile enforcement the resolver pulls whatever version is currently latest, exactly the window a supply-chain attacker exploits.
Recommended action
Use lockfile-enforcing install commands: npm ci instead of npm install, pip install --require-hashes -r requirements.txt, yarn install --frozen-lockfile, bundle install --frozen, and go install tool@v1.2.3.
GL-022: Dependency update command bypasses lockfile pins
Detects pip install --upgrade, npm update, yarn upgrade, bundle update, cargo update, go get -u, and composer update. These commands bypass lockfile pins and pull whatever version is currently latest. Tooling upgrades (pip install --upgrade pip) are exempted.
Known false-positive modes
- Common build-tool bootstrapping idioms (
pip install --upgrade pip,pip install --upgrade setuptools wheel virtualenv) and security-tool installs (pip install --upgrade pip-audit / cyclonedx-bom / semgrep) are exempted by theDEP_UPDATE_REtooling allowlist. Other tooling-upgrade idioms not yet on the list can still trip the rule. Defaults to MEDIUM confidence so CI gates can require--min-confidence HIGHto ignore.
Recommended action
Remove dependency-update commands from CI. Use lockfile-pinned install commands (npm ci, pip install -r requirements.txt) and update dependencies via a dedicated PR workflow (e.g. Dependabot, Renovate).
GL-023: TLS / certificate verification bypass
Detects patterns that disable TLS certificate verification: git config http.sslVerify false, NODE_TLS_REJECT_UNAUTHORIZED=0, npm config set strict-ssl false, curl -k, wget --no-check-certificate, PYTHONHTTPSVERIFY=0, and GOINSECURE=. Disabling TLS verification allows MITM injection of malicious packages, repositories, or build tools.
Recommended action
Remove TLS verification bypasses. Fix certificate issues at the source (install CA certificates, configure proper trust stores) instead of disabling verification.
GL-024: No SLSA provenance attestation produced
cosign sign and cosign attest look similar but mean different things: the first binds identity to bytes; the second binds a structured claim (builder, source, inputs) to the artifact. SLSA Build L3 verifiers check the latter.
Recommended action
Add a job that runs cosign attest against a provenance.intoto.jsonl statement, or adopt a SLSA-aware builder (the SLSA project ships GitLab templates). Signing the artifact (GL-006) isn't enough for SLSA L3, the attestation describes how the build ran.
GL-025: Pipeline contains indicators of malicious activity
Fires on concrete indicators (reverse shells, base64-decoded execution, miner binaries, Discord/Telegram webhooks, webhook.site callbacks, env | curl credential dumps, history -c audit erasure). Orthogonal to GL-003 (curl pipe) and GL-017 (Docker insecure flags). Those flag risky defaults; this flags evidence.
Known false-positive modes
- Security-training repositories, CTF challenges, and red-team exercise pipelines legitimately contain reverse-shell strings or exfil domains as literals. Matches inside YAML keys / HCL attributes whose names contain
example,fixture,sample,demo, ortestare auto-suppressed; bare lines in a production pipeline still fire. - Defaults to LOW confidence. Filter with
--min-confidence MEDIUMto ignore all matches; the rule still surfaces the hit for teams that want to spot-check.
Recommended action
Treat as a potential compromise. Identify the MR that added the matching job(s), rotate any credentials the pipeline can reach, and audit recent runs for outbound traffic to the matched hosts. A legitimate red-team exercise should be time-bounded via .pipelinecheckignore with expires:.
GL-026: Dangerous shell idiom (eval, sh -c variable, backtick exec)
eval, sh -c "$X", and `$X` all re-parse the variable's value as shell syntax. Once a CI variable feeds into one of these idioms, any ;, &&, |, backtick, or $() in the value executes, even if the variable's source is currently trusted, future refactors may expose it.
Known false-positive modes
eval "$(ssh-agent -s)"and similareval "$(<literal-tool>)"bootstrap idioms are intentionally NOT flagged, the substituted command is literal, only its output is eval'd.
Recommended action
Replace eval "$VAR" / sh -c "$VAR" / backtick exec of variables with direct command invocation. If the command must be dynamic, pass arguments as array members or validate the input against an allow-list at the boundary.
GL-027: Package install bypasses registry integrity (git / path / tarball source)
Complements GL-021 (missing lockfile flag). Git URL installs without a commit pin, local-path installs, and direct tarball URLs all bypass the registry integrity controls the lockfile relies on, an attacker who can move a branch head, drop a sibling checkout, or change a served tarball can substitute code into the build.
Recommended action
Pin git dependencies to a commit SHA (pip install git+https://…/repo@<sha>, cargo install --git … --rev <sha>). Publish private packages to an internal registry instead of installing from a filesystem path or tarball URL.
GL-028: services: image not pinned
services: entries (top-level or per-job) can be either a string (redis:7) or a dict ({name: redis:7, alias: cache}). Both forms are normalized via image_ref-style extraction and evaluated with the same floating-tag regex GL-001 uses for image:.
Recommended action
Pin every services: entry the same way image: is pinned, prefer @sha256:<digest>, or at minimum a full immutable version tag (postgres:16.2-alpine). Avoid :latest and bare tags like :16.
GL-029: Manual deploy job defaults to allow_failure: true
This is the most common GitLab deployment gotcha: a manual deploy job looks like a gate in the UI, but the pipeline reports success on the first run because the job is marked allow_failure by default. Downstream jobs (and the overall pipeline status) proceed as though the human approved.
Recommended action
Add allow_failure: false to every deploy-like when: manual job. GitLab defaults allow_failure to true for manual jobs, which makes the pipeline report success whether or not the operator clicks, exactly the opposite of the gate you meant to add.
GL-030: trigger: include: pulls child pipeline without pinned ref
GL-005 only audits top-level include:. Parent-child and multi-project pipelines that load YAML via the job-level trigger: include: slot slip through. Branch refs (main/master/develop/head/trunk) count as unpinned.
Recommended action
Pin trigger: include: project: entries with ref: set to a tag or commit SHA. Avoid trigger: include: remote: for untrusted URLs; mirror the content into a trusted project and pin it there.
GL-031: id_tokens: missing audience pin or environment binding
Pairs with IAM-008. IAM-008 verifies the cloud-side trust policy pins audience + subject; this rule verifies the GitLab-side workflow can't request a token without an audience claim or without a deployment gate.
Recommended action
For every job that declares an id_tokens: block, pin a non-wildcard aud: (a literal string the consumer trusts) AND bind the job to a protected environment:. Audience pinning prevents token replay against unintended consumers; the environment binding gates which refs can drive the assume-role on the consumer side.
GL-032: tags: interpolates untrusted CI variable
GL-014 catches self-managed runners that aren't ephemeral; this rule catches the upstream targeting choice. When tags: is computed from an attacker-controllable CI variable, the operator (or anyone who can craft a PR title / branch name / commit message that the workflow consumes) picks where the job runs, including any privileged tag the instance exposes (deploy-prod, signer, hsm …). The rule reuses the same untrusted-context catalog as GL-002 (CI_COMMIT_MESSAGE, CI_COMMIT_REF_NAME, CI_MERGE_REQUEST_TITLE and friends) so the two rules stay in lockstep.
Known false-positive modes
- Workflows that intentionally select runners by environment via a vetted
variables:block (RUNNER_TAG: deploy-prod) referencing a build-time-set value are out of scope, the rule only matches the curated untrusted-predefined-variable catalog. Static custom variables ($DEPLOY_FLEETdefined inside the workflow file) are intentionally not flagged.
Recommended action
Hard-code tags: to a specific runner tag list. If runner selection has to be parameterized, validate the candidate value against an explicit allowlist in a job rules: block before the job runs, and never accept a $CI_COMMIT_* / $CI_MERGE_REQUEST_* field as a tag value directly.
GL-033: Global before_script / after_script propagates taint to every job
GL-002 catches injection in per-job before_script: / script: / after_script:, but its scanner walks iter_jobs which deliberately skips top-level keywords (before_script, after_script, default, image, services, variables, stages, workflow, include, ...). That means a tainted $CI_COMMIT_TITLE interpolation in a document-root before_script: or default.before_script: evades GL-002 entirely, even though it propagates to every job in the pipeline.
GL-033 closes that gap. It scans:
before_script:at document rootafter_script:at document rootdefault.before_script:(the modern form)default.after_script:
for direct interpolation of the same attacker-controllable predefined variables tracked by GL-002 (CI_COMMIT_TITLE / CI_COMMIT_MESSAGE / CI_COMMIT_REF_NAME / CI_MERGE_REQUEST_TITLE / CI_MERGE_REQUEST_SOURCE_BRANCH_NAME / etc.). The detection mirrors GL-002's has_direct_taint helper so the quote-aware semantics are identical.
Known false-positive modes
- Some self-hosted GitLab installations build a diagnostic banner into the global
before_scriptthatechos commit metadata for log-correlation purposes. Suppress per pipeline file rather than globally, the rule is checking propagation reach, not intent.
Recommended action
Move any setup logic that touches commit / MR metadata out of the document-root before_script: (and default.before_script: / default.after_script:) and into a dedicated job that opts in via extends: or that runs on a known-safe trigger only. The global before-script runs verbatim before every job in the pipeline (including child pipelines launched by trigger:include:); a single unquoted $CI_COMMIT_TITLE interpolation there is, in effect, that injection in N jobs at once. Quote the value defensively (branch="$CI_COMMIT_REF_NAME") and copy it into a job-local variable before any further use.
GL-034: npm install without registry-signature verification step
Fires once per pipeline file when:
- Some job's
before_script:/script:/after_script:runs an npm or pnpm install verb (npm ci,npm install,npm i,pnpm install,pnpm i,pnpm ci); - No job anywhere in the pipeline runs
npm audit signaturesorpnpm audit signatures.
Yarn / Bun-only pipelines pass silently because the audit signatures primitive is npm-CLI-specific (Yarn Berry's yarn npm audit does not yet verify registry trusted-publisher records). Pairs with the per-package lockfile rules NPM-002 / NPM-006: NPM-002 / NPM-006 verify what the lockfile pinned, GL-034 verifies the lockfile pinned what the maintainer actually signed.
Known false-positive modes
- Pipelines that build against a private registry without trusted-publisher records (legacy Artifactory, self-hosted Verdaccio without sigstore) cannot run
audit signaturesmeaningfully. Suppress on the specific pipeline with a rationale that names the private registry.
Seen in the wild
- Shai-Hulud npm worm (2026) / TanStack / axios patch-release compromises rode the gap between lockfile-pinned integrity and registry-signed-publisher provenance.
npm audit signaturesis the gate that consumes trusted-publisher records.
Recommended action
Add an npm audit signatures step (or pnpm audit signatures) after the install. Lockfile pinning only guarantees the bytes installed match what the lockfile recorded; audit signatures is what verifies those bytes were signed by the maintainer the registry recognizes as the package's trusted publisher. Run it as a separate script line after npm ci and before any code from node_modules/ executes.
GL-035: pip install without --require-hashes verification
Fires once per pipeline file when:
- Some job's
before_script:/script:/after_script:runs a realpip install(pip install,pip3 install,python -m pip install) that isn't a tooling-bootstrap exempted by the allowlist; - No job uses
--require-hashesAND no job uses a lockfile-consuming manager (uv sync/uv pip sync,poetry install,pipenv install --deploy/pipenv sync).
Tooling-bootstrap allowlist (same as GHA-060).
Known false-positive modes
- Pipelines that build against a private index without SHA-256 hash records (legacy DevPI, self-hosted simple indexes without per-file hashes) cannot run
--require-hashesmeaningfully. Suppress on the specific pipeline with a rationale that names the private index.
Seen in the wild
- PyPI maintainer-account compromises (ctx 2022, requests-darwin-lite 2024) shipped malicious sdists / wheels under existing version pins;
--require-hasheswould have refused the swap.
Recommended action
Pin every dependency with a SHA-256 hash and install with pip install -r requirements.txt --require-hashes, or migrate to a manager that hash-pins by default: uv sync, poetry install, pipenv install --deploy. Hash-pinned install is the PyPI equivalent of npm's lockfile-integrity guarantee: it refuses to install any tarball whose SHA-256 doesn't match a recorded entry.
GL-036: Secret-named variable echoed / printed in a script block
Detects three shapes in script:, before_script:, and after_script: blocks:
echo/printf/cat/teeof a variable whose name matches common secret patterns (PASSWORD, TOKEN, API_KEY, SECRET, CREDENTIAL, etc.).printenv/envcommands that dump the entire environment (which includes CI/CD variables that may hold secrets).set -x(shell trace) enabled alongside any reference to a secret-named variable.
Recommended action
Don't print secret values in CI scripts. GitLab's log masking only covers variables explicitly marked as masked in the UI, and only when the full value appears as a contiguous string. Base64-encoded, URL-encoded, or partial substrings bypass the mask. Log a boolean instead ([ -n "$X" ] && echo set || echo unset). Avoid set -x when secret-bound variables are in scope.
GL-037: Pipeline disables Go module checksum / sum-db verification
Walks the global and per-job variables: maps and every script: / before_script: / after_script: body (for inline export GOSUMDB=off assignments) and flags the Go integrity-disabling settings via the shared _primitives/go_insecure_env detector: GOFLAGS with -insecure, GOSUMDB=off, truthy GONOSUMCHECK, any GOINSECURE, and a broad GOPRIVATE / GONOSUMDB glob.
Scoped GOPRIVATE and GOPROXY=off / direct (still checksum-verified) are not flagged. The CI-variable face of the verification-bypass surface GOMOD-001 warns about; the GitLab sibling of GHA-110 / CC-033.
Known false-positive modes
- A pipeline that builds only against an internal module proxy on a trusted network may set a scoped
GOINSECUREfor one internal host deliberately. Suppress per pipeline with a rationale; a TLS-terminating internal proxy that preserves checksum verification is the safer path.
Seen in the wild
- Verification-bypass class: a runner told to skip the Go checksum database / sum file can be served a substituted module without
go mod verifycatching it, the same gap GOMOD-001 flags from thego.sumside.
Recommended action
Remove the Go toolchain variables that turn off module integrity verification so go build keeps checking every downloaded module against go.sum and the checksum transparency database. Drop GOFLAGS=-insecure (plain HTTP fetch, TLS off), GOSUMDB=off / legacy GONOSUMCHECK (checksum DB / sum check off), and any GOINSECURE; scope GOPRIVATE / GONOSUMDB to the exact internal namespace (corp.example.com/team/*) rather than a broad * or whole public host. This is the CI-variable twin of GOMOD-001, a committed go.sum is moot if the runner ignores it. For private modules, prefer a trusted internal GOPROXY that still enforces checksums over disabling verification.
GL-038: CI_DEBUG_TRACE / debug logging dumps secrets to the job log
Fires when CI_DEBUG_TRACE or CI_DEBUG_SERVICES is set to a truthy value ("true", 1, ...) in the global variables: block or any job's variables: block. Both the bare scalar form (CI_DEBUG_TRACE: "true") and the typed form (CI_DEBUG_TRACE: {value: "true"}) are matched. It inverts a logging / visibility control into a secret-exfiltration channel: the job trace itself leaks every secret in scope, masking and all.
Recommended action
Remove CI_DEBUG_TRACE / CI_DEBUG_SERVICES (or set them to false) anywhere they ship in the repo. Debug trace expands the entire environment, including masked CI/CD variables and protected secrets, into the job log, where anyone with Reporter access (or the trace API) can read it. GitLab's log masking does not cover the debug dump. If you need a one-off debug run, enable it transiently from the pipeline UI on a job with no secrets in scope rather than committing it to .gitlab-ci.yml.
GL-039: Docker-in-Docker service exposes an unauthenticated daemon
Fires when a job (or the global config) runs a docker:*-dind service AND disables daemon authentication, either via DOCKER_TLS_CERTDIR: "" (reverts to the plaintext 2375 socket) or by exposing / pointing at tcp://...:2375 in the service command: or DOCKER_HOST. Global services: / variables: are merged into each job before the check. The unauthenticated daemon is the container-escape vector behind the untagged shared-runner + privileged-dind anti-pattern.
Recommended action
Keep TLS on the dind daemon: drop DOCKER_TLS_CERTDIR: "" (let it default to /certs) and talk to the daemon over the TLS port 2376, not the plaintext 2375. Never expose the daemon with --host=tcp://0.0.0.0:2375. On a shared / untagged runner an unauthenticated daemon socket is reachable by every other tenant's job, which means container escape and cross-tenant compromise; pin the job to a dedicated, ephemeral runner via tags: as well.
GL-040: CI_JOB_TOKEN used for cross-project / remote access
Fires on the two documented cross-project job-token idioms in a script: / before_script: / after_script: block: a gitlab-ci-token:$CI_JOB_TOKEN@<host> clone URL, or a JOB-TOKEN: $CI_JOB_TOKEN request header. Defaults to MEDIUM confidence because a same-project pull uses the same idiom; the finding flags the access surface so the target's inbound allowlist gets reviewed, it can't see the server-side allowlist from the pipeline YAML.
Recommended action
A job authenticates to a GitLab endpoint with the ambient CI_JOB_TOKEN (a gitlab-ci-token:$CI_JOB_TOKEN@ clone URL or a JOB-TOKEN: $CI_JOB_TOKEN API header). The job token is minted automatically for every pipeline, so if the TARGET project's inbound job-token allowlist is disabled (the pre-hardening default), any project that can run a pipeline can reach it (GitLab #243703 / CVE-2024-8641). Restrict the target's CI/CD > Token Access inbound allowlist to the specific projects that need it, or use a scoped deploy token / project access token with least privilege instead of the ambient job token.
GL-041: IaC apply on an untrusted merge-request trigger
Fires when a job runs an unattended IaC apply (terraform/terragrunt apply or destroy, aws cloudformation deploy/create-stack/update-stack/execute-change-set, cdk deploy, pulumi up, sam deploy) AND the job is reachable on a merge-request pipeline (its own rules: admit merge_request_event, its legacy only: includes merge_requests, or it inherits a workflow: that admits MR pipelines). Applying an MR author's IaC is the plan/apply-on-untrusted-input RCE class. GL-004 already flags this as an ungated deploy; GL-041 names the apply-RCE shape and raises it to CRITICAL when the trigger is merge-request reach.
Recommended action
Never run terraform apply (or cloudformation deploy / cdk deploy / pulumi up / sam deploy) in a job reachable from a merge-request pipeline. Apply executes the MR branch's IaC, so an external data source, a local-exec provisioner, or a hijacked provider runs arbitrary code on the runner with whatever cloud credentials (often an OIDC role via id_tokens:) the apply uses. On merge requests run a read-only plan and post it for review; gate the apply on a protected branch (if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH) with when: manual or an environment: so it runs against merged, reviewed code.
GL-042: include: component pulls a CI/CD component without a pinned version
GitLab CI/CD components are third-party pipeline code merged into the consumer's pipeline before any job runs. The version in include: component: <host>/<path>@<version> resolves like a dependency ref: ~latest (the latest release), a branch, and a floating major / minor are all mutable; a full X.Y.Z tag or a 40-char commit SHA are pinned. Fires when the @<version> is missing, ~latest, branch-shaped, or a partial version. The same supply-chain class as GL-005 (remote / project includes) and GL-030 (trigger includes), through the newer component surface those two rules don't inspect.
Recommended action
Pin every include: component: to an immutable version: a 40-character commit SHA, or a full release tag (X.Y.Z) on a component project that enforces tag protection. A mutable version (~latest, a branch like main, or a floating major / minor like 1 / 1.2) lets whoever controls the component project re-point that reference and ship arbitrary pipeline code into every consumer's next pipeline, running with the consumer's CI_JOB_TOKEN and CI/CD variables. Bump pins in reviewable MRs (Renovate's GitLab CI/CD component updater supports this).
GL-043: GitLab native security scanner explicitly disabled
Fires when a *_DISABLED variable for a GitLab-managed scanner (SAST, Secret Detection, Dependency Scanning, Container Scanning, DAST) is set to a truthy value ("true" / "1" / "yes") at the top level or on a job. Both the plain scalar and the typed {value:, description:} variable form are read. Disabling a scanner pipeline-wide silently drops the finding stream the rest of your supply-chain controls assume exists.
Known false-positive modes
- A pipeline that runs the scanner through a dedicated security pipeline (e.g. a scheduled
secret_detectionjob) and disables the auto-included template here to avoid a duplicate run. Suppress with a rationale that names the other pipeline.
Recommended action
Remove the *_DISABLED: "true" CI/CD variable so GitLab's managed scanner runs again, or scope the opt-out narrowly with rules: instead of disabling it pipeline-wide. Each of SAST_DISABLED, SECRET_DETECTION_DISABLED, DEPENDENCY_SCANNING_DISABLED, CONTAINER_SCANNING_DISABLED, and DAST_DISABLED turns off a security control that would otherwise gate the pipeline. If a scanner is noisy, tune it (SAST_EXCLUDED_PATHS, ruleset overrides) rather than switching it off, and keep the opt-out in code review via the pipeline file rather than a hidden project variable.
GL-044: Automatic production deployment on a merge-request pipeline
Fires when a job reachable on a merge-request pipeline (its rules: admit merge_request_event, its legacy only: includes merge_requests, or it inherits a workflow: that admits MR pipelines) binds a production-tier environment: (a name matching production / prod) AND is not gated by when: manual. GL-004 treats any environment: as sufficient gating, so it misses an automatic production deploy on an MR; GL-044 names that shape and raises it to CRITICAL. Review-app, test, and staging environments don't fire (only the production tier), manual-approval jobs are out of scope (GitLab's accepted gate), and an environment: action: of stop / prepare / verify / access (no deploy) is excluded.
Known false-positive modes
- A repo that deploys per-MR preview apps to an environment it happens to have named
production. Rename it to a review / preview tier, or suppress with a rationale. A production environment under a custom name (notproduction/prod) can't be recognized from the YAML and won't fire.
Recommended action
Don't run a job bound to a production environment: automatically on a merge-request pipeline. A merge-request pipeline runs the MR branch's code, so this ships unreviewed (and on fork MRs, untrusted) changes to production on every MR with the production environment's scoped credentials, before review or merge. Deploy to an ephemeral review-app environment on MRs; gate the production environment: job behind when: manual and a protected-branch rule (if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH) so it runs against merged, reviewed code.
GL-045: ML model loaded with trust_remote_code (code execution)
Fires on trust_remote_code=True / --trust-remote-code in a job's script / before_script / after_script. The transformers / huggingface_hub loader executes the model repo's own Python at load time, so an untrusted or unpinned model is arbitrary code execution in CI with the job's CI_JOB_TOKEN and secrets.
Recommended action
Load models with trust_remote_code=False (the library default). If a model genuinely needs custom code, vet it and pin an exact revision (a commit SHA, not a tag or branch), run the load in a job scoped to no production secrets, and prefer safetensors weights over pickle.
GL-046: AI model pulled without a pinned revision
Fires on a job script / before_script / after_script that fetches a model by a mutable registry reference and supplies no revision pin. Detected fetch forms: from_pretrained("org/model"), hf_hub_download / snapshot_download with a org/model repo id, and huggingface-cli download org/model / hf download org/model.
Does NOT fire when a revision is pinned in the same command (revision='<sha>' / --revision <sha>), when the reference is a local path (./model, /models/x) or a variable / ${{ }} interpolation (the value can't be judged statically), or on a bare single-segment canonical hub name (bert-base-uncased) that has no org/ namespace, since those are first-party and the org-scoped third-party models are the higher-risk surface.
Known false-positive modes
- A team that re-pulls its own group's model on every pipeline may treat the latest revision as intentional. The right fix is still to pin the revision (it makes an upstream compromise visible); if a rolling pull is genuinely wanted, suppress on the specific job with a rationale naming the model and who controls it.
Recommended action
Pin the model to an immutable revision. Pass an exact commit revision= to from_pretrained / hf_hub_download / snapshot_download (a 40-char commit SHA, not a branch or a tag, both of which the owner can move), or --revision <sha> to huggingface-cli download. A pinned revision is what makes a swapped-weights or swapped-loader-code attack show up as a diff in your repo instead of silently landing on the next build. Pair with trust_remote_code=False (GL-045) and prefer safetensors weights over pickle.
GL-047: Unsafe deserialization of a fetched artifact (pickle RCE)
Fires per job (across script / before_script / after_script) in two shapes. (A) Explicit unsafe opt-in, always: weights_only=False on a load, or allow_pickle=True on numpy.load / np.load. (B) Fetch + unpickle, only when both appear in the same job: a remote fetch (curl / wget / hf_hub_download / snapshot_download / huggingface-cli download / hf download / requests.get / urlretrieve / urlopen) alongside a pickle-backed loader (torch.load / pickle.load / pickle.loads / joblib.load).
Does NOT fire when the job takes the safe path (weights_only=True, or safetensors via safe_open / load_file), nor on a bare torch.load / pickle.load with no remote fetch in the same job (a load of a locally produced, trusted artifact).
Known false-positive modes
- A job that downloads a non-pickle file for one purpose and separately unpickles a trusted local file for another would match shape B by co-location. Split the two concerns into separate jobs, or suppress on the specific job with a rationale naming the artifact's verified source.
Recommended action
Don't deserialize a downloaded artifact through pickle. Load weights with safetensors, or pass weights_only=True to torch.load (the PyTorch 2.6+ default) so only tensors, not arbitrary Python, are unpickled. Drop allow_pickle=True from numpy.load. If a pickle / joblib artifact is unavoidable, pin and verify its source (a pinned model revision, a checksum, or a signature) and load it in a job scoped to no production secrets, not one carrying the CI_JOB_TOKEN and pipeline credentials.
GL-048: Untrusted MR/commit context reaches an agentic AI CLI (prompt injection)
The AI analog of GL-002 (script injection). Fires when a job script line invokes an agentic CLI (claude / gemini / cursor-agent / aider / openhands / goose / q chat) AND attacker-controllable GitLab context reaches that line, either a predefined untrusted variable interpolated directly ($CI_MERGE_REQUEST_TITLE) or a variables: entry whose value carries one. Unlike a shell, an LLM ingests a quoted / variable-routed value as prompt text, so the GL-002 mitigation (route through a quoted variable) does not apply, which is why this is a separate rule.
Recommended action
Do not place attacker-controllable context (MR / commit / branch-name metadata) in an agentic CLI's prompt. A quoted variables: entry does NOT sanitize a prompt the way it does a shell command, the model still reads the value. If the agent must see MR content, run it with no write-scoped CI_JOB_TOKEN and no tool / shell access on a job gated to no production secrets, and treat its output as untrusted.
GL-049: Agentic CLI output lands without human review
Fires when one job both invokes an agentic CLI (claude / gemini / cursor-agent / aider / openhands / goose / q chat) and, in the same job, lands the result with no review gate. The landing command is one of: a glab mr merge with an auto / non-interactive flag (--auto-merge / --yes / -y / --when-pipeline-succeeds), a git push carrying the merge_request.merge_when_pipeline_succeeds push option, or a plain git push (the GitLab idiom for committing straight to a branch).
Does NOT fire when the agent only opens a merge request for review (glab mr create with no merge), nor on a push / auto-merge job that does not run an agent (ordinary formatting / generated-file bots). The agent-plus-auto-land coupling is the signal. A git push --dry-run is ignored.
Known false-positive modes
- A job that runs an agent for a read-only task (triage, labeling) but also pushes an unrelated generated file would match by co-location. Split the agent and the push into separate jobs, or suppress on the job with a rationale noting the agent does not write the pushed paths.
Recommended action
Don't let an agentic CLI's output reach a branch or a merge without a human review gate. Have the agent open a normal merge request (glab mr create with no auto-merge) so a person reviews the diff before it lands; drop glab mr merge --auto-merge / --yes and the merge_request.merge_when_pipeline_succeeds push option from the agent's job, and don't pair the agent with a git push straight to a protected branch. If the agent's prompt can be influenced by untrusted input (an MR title / description, a fetched page), treat the committed result as attacker-controlled.
GL-050: Package-publish job relies on a long-lived registry token
Fires when a job's script: (or before_script: / after_script:) runs a package-publish verb AND the job, its variables:, or the pipeline's top-level variables: reference a long-lived external-registry token. Publish verbs covered: npm / pnpm / yarn publish, twine upload, poetry publish, uv publish, gem push, cargo publish. Long-lived secrets: NPM_TOKEN, NODE_AUTH_TOKEN, NPM_AUTH_TOKEN, PYPI_TOKEN, TWINE_PASSWORD, POETRY_PYPI_TOKEN, RUBYGEMS_API_KEY, GEM_HOST_API_KEY, CARGO_REGISTRY_TOKEN.
GitLab's built-in ${CI_JOB_TOKEN} is deliberately excluded: it is the per-job, automatically-expiring token used to publish to the project's own GitLab Package Registry (the native path), not a long-lived external credential. A publish job that uses OIDC (id_tokens:) and references no long-lived token does not match. The GitHub Actions analog is GHA-050; the cloud-credentials side is GL-013 (long-lived AWS keys) / GL-031 (OIDC trust).
Known false-positive modes
- A private / internal registry that genuinely can't do OIDC (self-hosted Artifactory / Nexus without an OIDC broker) requires a static token. Gate that publish job behind a protected environment with required approvers and suppress this rule with a rationale naming the registry.
- First-publish bootstrap of a new package (npm and PyPI both require an initial manual publish before trusted publishing can be wired). The rule fires; suppress on the specific job until the trusted-publisher record is in place.
Seen in the wild
- Shai-Hulud npm worm (2025-2026): the worm scraped
NPM_TOKENfrom the runner env /~/.npmrcand used it tonpm publishpatched versions of other packages the maintainer's account owned. OIDC trusted publishing turns that step into a no-op: the token doesn't survive the job. - npm 'Our plan for a more secure npm supply chain' (2025-09-22): npm will disallow token-based publishing by default and expand OIDC trusted publishing, with GitLab named as a supported provider.
Recommended action
Publish to public registries with GitLab OIDC trusted publishing instead of a long-lived registry token. Concretely:
- npm: configure an
id_tokens:block withaud: https://registry.npmjs.orgon the publish job and runnpm publish(npm CLI >= 11.5.1 exchanges the OIDC token for a short-lived upload token); drop the${NPM_TOKEN}/${NODE_AUTH_TOKEN}.npmrcline. npm's September 2025 plan disallows token-based publishing by default and lists GitLab as a supported OIDC provider. - PyPI: use PyPI trusted publishing (the GitLab OIDC provider) rather than a
${TWINE_PASSWORD}/${PYPI_TOKEN}. - Publishing to the GitLab Package Registry of the same project already uses the built-in, per-job
${CI_JOB_TOKEN}(which this rule does not flag); reserve long-lived tokens for registries that genuinely can't do OIDC, and protect those jobs with a protected environment / branch rule.
A long-lived NPM_TOKEN in a publish job is the fuel a Shai-Hulud-shaped worm needs: once scraped from the job env or a .npmrc it can publish more compromised packages on the project's behalf. An OIDC token expires in minutes and is scoped to the job that requested it. The GitHub Actions analog is GHA-050.
TAINT-004: Untrusted input flows across jobs via dotenv artifact
Detection is a two-pass walk over the pipeline. Pass 1 looks for jobs whose scripts write KEY=value to a file declared under artifacts.reports.dotenv: and whose value interpolates an attacker-controllable GitLab predefined variable (the UNTRUSTED_VAR_RE vocabulary GL-002 already uses). Pass 2 walks every job with a needs: / dependencies: link to a producer and looks for $KEY references in scripts that match a tainted leak.
v1 limitations: extends: job-template inheritance and cross-pipeline include: are not yet tracked. The dotenv path matching is literal (./taint.env and taint.env are treated as the same path), no glob expansion is performed.
Known false-positive modes
- If the producer job runs a sanitizer between the tainted source interpolation and the dotenv write (
echo "$CI_COMMIT_TITLE" | tr -dc 'a-zA-Z0-9 ' > taint.env), the consumer is no longer exploitable but TAINT-004 still fires. Suppress via ignore-file scoped to the consumer job's pipeline file when this is the deliberate shape; the sanitizer is then load-bearing and any future regression in it would re-expose the consumer.
Recommended action
Sanitize the value at the producer job before it lands in the dotenv file. The canonical safe pattern is to copy the $CI_COMMIT_* / $CI_MERGE_REQUEST_* source into an intermediate shell variable, run a sanitizer (tr -dc 'a-zA-Z0-9 ' is enough for a freeform title), and only then write the cleaned value to dotenv. The consuming job should still treat the auto-imported variable as tainted, reference it quoted ("$TITLE") and never inline into a command without re-quoting. Removing the dotenv entirely is the strongest fix; if the value genuinely needs to flow downstream, validate the sanitizer is doing what you think before relying on it.
TAINT-008: Untrusted input flows via GitLab extends: template inheritance
Two-pass walk over the pipeline doc. Pass 1 builds a universe of every job-shaped entry (hidden templates included, top-level keywords excluded), resolves each non-hidden job's extends: chain transitively, and gathers tainted variables (any $CI_COMMIT_* / $CI_MERGE_REQUEST_* interpolation in the link's variables: block). Pass 2 walks the consuming job's before_script: / script: / after_script: for unquoted $<name> references matching an inherited tainted variable. Cycles in the extends chain are broken via a visited set; unresolvable extends entries are silently dropped.
v1 limitations: include: cross-pipeline file inclusion isn't tracked yet (would need cross-document analysis like the GHA --resolve-remote flow). extends: chains that pull templates from include-d files are partial: in-doc links resolve, external links are treated as missing.
Known false-positive modes
- If the consuming job sanitizes the inherited variable before referencing it (
CLEAN=$(echo "$TITLE" | tr -dc 'a-zA-Z0-9 '); echo $CLEAN), the rule still fires on the original$TITLEreference even though the sanitized value is what reaches the shell. Suppress via ignore-file scoped to the consuming job's name when the sanitizer is audited and load-bearing.
Recommended action
Move the tainted-source interpolation out of the template's variables: block. The canonical safe pattern is to receive the source value through $CI_* directly in the consuming job's script (or a dedicated sanitizer step) and never copy it into a shared variable a downstream job can interpolate unquoted. If the inheritance is genuinely needed, sanitize at the boundary (TITLE_SAFE: '$(echo "$CI_COMMIT_TITLE" | tr -dc "a-zA-Z0-9 ")') and have the extending job reference the cleaned variable. Removing the extends: propagation is the strongest fix; if the value genuinely needs to flow downstream, validate the sanitizer is doing what you think before relying on it.
Adding a new GitLab CI check
- Create a new module at
pipeline_check/core/checks/gitlab/rules/glNNN_<name>.pyexporting a top-levelRULE = Rule(...)and acheck(path, doc) -> Findingfunction. The orchestrator auto-discoversRULEand callscheckwith the parsed YAML document. - Add a mapping for the new ID in
pipeline_check/core/standards/data/owasp_cicd_top_10.py(and any other standard that applies). - Drop unsafe/safe snippets at
tests/fixtures/per_check/gitlab/GL-NNN.{unsafe,safe}.ymland add aCheckCaseentry intests/test_per_check_real_examples.py::CASES. - Regenerate this doc: