glsec

Rules / GL051

GL051 Unconstrained CI/CD input interpolated into sensitive CI keyword

Severity: warn; error for an unconstrained job input in a deploy-like job
OWASP: CICD-SEC-4 – Poisoned Pipeline Execution
CWE: CWE-829 – Inclusion of Functionality from Untrusted Control Sphere

Risk

GitLab CI Catalog components accept caller-supplied values via spec:inputs. When an input is interpolated into a sensitive keyword — image:, services:, script:, before_script:, after_script:, a run: step script, or environment:name: — without a constraint GitLab enforces, any consumer of the component can supply an arbitrary value.

This differs from GL002 (predefined GitLab variables): here the tainted value originates from whoever calls the component, not from the commit author, making the attack surface wider and the trust model distinct.

Without constraints, an attacker can:

Trigger example

spec:
  inputs:
    deploy_image:
      description: "Image to deploy"
      # no regex, no options, no number/boolean type
---
deploy:
  image: $[[ inputs.deploy_image ]]   # unconstrained caller input
  script:
    - ./deploy.sh

Safe alternatives

# Allowlist with options:
spec:
  inputs:
    environment:
      options: [staging, production]
---
deploy:
  script:
    - ./deploy.sh $[[ inputs.environment ]]
# Pattern constraint with regex:
spec:
  inputs:
    image_tag:
      regex: '^[a-zA-Z0-9._-]+$'
---
deploy:
  image: myapp:$[[ inputs.image_tag ]]
  script:
    - ./deploy.sh

What counts as constrained

Notes

Job inputs (${{ job.inputs.NAME }})

Since GitLab 18.10, a job can declare its own inputs:. Anyone who can run the job manually, or retry it with Retry job with modified values, sets their values. That includes Developers, also on protected-branch jobs that hold protected variables. GitLab substitutes ${{ job.inputs.NAME }} as text into script, before_script, after_script, image, services, cache and artifacts before the shell runs, so quoting in the script does not help.

GL051 flags an unconstrained job input referenced in any of those keywords, including inside a larger expression (${{ job.inputs.suite | upper }}). The constraints are the same as above: options:, regex:, or type: number/boolean. A default: is mandatory for job inputs and constrains nothing. Severity is error in a deploy-like job and warn otherwise.

deploy:
  when: manual
  inputs:
    version:
      default: v1.0.0            # free text: flagged
    target_env:
      default: staging
      options: [staging, production]
  script:
    - ./deploy.sh --env ${{ job.inputs.target_env }} --version ${{ job.inputs.version }}

Fix: regex: ^v[0-9]+\.[0-9]+\.[0-9]+$ on version.

An image given entirely by a job input (image: ${{ job.inputs.img }}) is also checked by GL001 against the input's options:, or its default: when there are none.

See also

Enable the ShellCheck integration to surface additional shell issues in these script blocks.

Documentation for glsec 1.23.0. Source of this page · Rule implementation