Skip to main content

Common Misconfiguration

Exposed GitLab tokens can lead to unauthorized repository access, CI/CD pipeline manipulation, and container registry breaches. 😱

Vulnerable Example

# VULNERABLE - .gitlab-ci.yml with hardcoded tokens
variables:
  # Never hardcode tokens!
  GITLAB_TOKEN: "glpat-xxxxxxxxxxxxxxxxxxxx"
  DEPLOY_TOKEN: "gldt-xxxxxxxxxxxxxxxxxxxx"
  REGISTRY_TOKEN: "glrt-xxxxxxxxxxxxxxxxxxxx"
  RUNNER_TOKEN: "GR1348941xxxxxxxxxxxxxxxxxxxx"

stages:
  - build
  - deploy

build:
  script:
    # Hardcoded project token
    - git clone https://gitlab-ci-token:glpat-xxxxxxxxxxxxxxxxxxxx@gitlab.com/org/private-repo.git
    - docker login -u gitlab-ci-token -p glpat-xxxxxxxxxxxxxxxxxxxx registry.gitlab.com
    
deploy:
  script:
    # Hardcoded deploy token
    - curl --header "PRIVATE-TOKEN: glpat-xxxxxxxxxxxxxxxxxxxx" "https://gitlab.com/api/v4/projects"
// VULNERABLE - Hardcoded GitLab API credentials
const axios = require('axios');

class GitLabClient {
    constructor() {
        // Never hardcode these!
        this.personalAccessToken = 'glpat-xxxxxxxxxxxxxxxxxxxx';
        this.projectAccessToken = 'glpat-yyyy-xxxxxxxxxxxx';
        this.deployToken = {
            username: 'gitlab+deploy-token-1234',
            password: 'gldt-xxxxxxxxxxxxxxxxxxxx'
        };
        this.jobToken = process.env.CI_JOB_TOKEN || 'glctt-xxxxxxxxxxxxxxxxxxxx'; // Fallback is bad
        this.feedToken = 'feed_token_xxxxxxxxxxxxxxxxxxxx';
        
        this.apiUrl = 'https://gitlab.com/api/v4';
    }

    async makeRequest(endpoint) {
        return axios.get(`${this.apiUrl}${endpoint}`, {
            headers: {
                'PRIVATE-TOKEN': this.personalAccessToken
            }
        });
    }
}

Secure Example

# SECURE - .gitlab-ci.yml using CI/CD variables
variables:
  DOCKER_REGISTRY: $CI_REGISTRY       # Predefined variable
  DOCKER_IMAGE: $CI_REGISTRY_IMAGE    # Predefined variable

stages:
  - build
  - test
  - deploy

before_script:
  # Use predefined CI variables for context
  - echo "Running in project ${CI_PROJECT_PATH}"
  - echo "Commit ${CI_COMMIT_SHA} on branch ${CI_COMMIT_REF_NAME}"

build:
  stage: build
  image: docker:latest
  services:
    - docker:dind
  before_script:
    # Use CI_REGISTRY variables for secure Docker registry authentication
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
  script:
    - docker build -t $DOCKER_IMAGE:$CI_COMMIT_SHA .
    - docker push $DOCKER_IMAGE:$CI_COMMIT_SHA
  only: # Limit job execution
    - main
    - develop

deploy_production:
  stage: deploy
  image: alpine:latest # Minimal image
  before_script:
    - apk add --no-cache curl git # Install only needed tools
  script:
    # Use masked & protected CI/CD variables (set in GitLab UI: Settings > CI/CD > Variables)
    # DEPLOY_TRIGGER_TOKEN: Masked, Protected
    # TARGET_PROJECT_ID: Not masked, Protected
    - |
      curl --fail --request POST \
        --form token=$DEPLOY_TRIGGER_TOKEN \
        --form ref=main \
        --form "variables[ENVIRONMENT]=production" \
        "https://gitlab.com/api/v4/projects/${TARGET_PROJECT_ID}/trigger/pipeline"
  environment: # Define GitLab Environment
    name: production
    url: https://app.example.com
  only:
    refs:
      - main # Only run on main branch
  when: manual # Require manual trigger for production deploy

# Using GitLab's dependency proxy (securely caches Docker Hub images)
build_with_proxy:
  stage: build
  # Use the proxy-enabled image name format
  image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/docker:latest 
  services:
    - name: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/docker:dind
      alias: docker
  before_script:
    # Login to proxy using job token credentials
    - docker login -u $CI_DEPENDENCY_PROXY_USER -p $CI_DEPENDENCY_PROXY_PASSWORD $CI_DEPENDENCY_PROXY_SERVER
  script:
    - docker build -t myapp:latest .
// SECURE - Using environment variables and secure token management
const axios = require('axios');

class SecureGitLabClient {
    constructor() {
        this.apiUrl = process.env.GITLAB_API_URL || 'https://gitlab.com/api/v4';
        this.token = null;
        this.tokenHeader = null; // Store which header to use
    }

    async initialize() {
        // Check for different token types in order of preference
        // CI_JOB_TOKEN is preferred as it's short-lived and scoped
        if (process.env.CI_JOB_TOKEN) {
            this.token = process.env.CI_JOB_TOKEN;
            this.tokenHeader = 'JOB-TOKEN';
        } 
        // Use a PAT (Personal, Project, or Group) if outside CI or needing broader access
        else if (process.env.GITLAB_ACCESS_TOKEN) {
            this.token = process.env.GITLAB_ACCESS_TOKEN;
            this.tokenHeader = 'PRIVATE-TOKEN';
        } 
        // Add other auth methods if needed (e.g., OAuth)
        else {
            throw new Error('No GitLab authentication token found (checked CI_JOB_TOKEN, GITLAB_ACCESS_TOKEN)');
        }
        
        // Validate token by making a simple API call
        await this.validateToken();
    }

    async validateToken() {
        try {
            // Fetching user info requires 'read_user' scope for PATs
            // CI_JOB_TOKEN might only allow access within the project scope
            const endpoint = this.tokenHeader === 'JOB-TOKEN' ? `/projects/${process.env.CI_PROJECT_ID}` : '/user';
            const response = await this.makeRequest(endpoint);
            console.log(`GitLab token validated successfully. Type: ${this.tokenHeader}`);
            return true;
        } catch (error) {
            throw new Error(`GitLab token validation failed: ${error.message}`);
        }
    }

    async makeRequest(endpoint, options = {}) {
        const headers = { ...options.headers }; // Copy existing headers if any
        
        // Set appropriate header based on token type
        if (this.tokenHeader) {
            headers[this.tokenHeader] = this.token;
        }
        
        return axios({
            url: `${this.apiUrl}${endpoint}`,
            method: options.method || 'GET', // Default to GET
            headers,
            data: options.data, // Include data for POST/PUT etc.
            ...options // Pass other axios options
        });
    }

    // Example: Fetch only non-sensitive variables
    async getProjectVariables(projectId) {
        const response = await this.makeRequest(`/projects/${projectId}/variables`);
        // Filter out masked variables if needed, although API should hide values
        return response.data.filter(v => !v.masked); 
    }

    // Example: Create a protected and masked variable using the API
    async createProtectedVariable(projectId, key, value, environmentScope = '*') {
        return this.makeRequest(
            `/projects/${projectId}/variables`,
            {
                method: 'POST',
                data: {
                    key: key,
                    value: value,
                    protected: true,       // Only available in protected branches/tags
                    masked: true,          // Hidden in job logs (requires specific format)
                    environment_scope: environmentScope // e.g., 'production', 'staging', '*'
                }
            }
        );
    }
}
# SECURE - Docker example for secure GitLab registry access

# --- Option 1: Using BuildKit Secrets (Token not in final image) ---
# syntax=docker/dockerfile:1.4 
FROM alpine:latest AS builder
ARG GITLAB_USER
# Mount the secret file during this RUN command only
RUN --mount=type=secret,id=gitlab_registry_password \
    apk add --no-cache docker-cli && \
    export GITLAB_PASS=$(cat /run/secrets/gitlab_registry_password) && \
    docker login -u "$GITLAB_USER" -p "$GITLAB_PASS" registry.gitlab.com && \
    # Now pull base images or perform other registry operations
    docker pull registry.gitlab.com/my-group/my-base-image:latest && \
    # Copy needed artifacts
    mkdir /app && cp /path/to/artifact /app/

# Build command: docker build --secret id=gitlab_registry_password,src=./gitlab_pass.txt --build-arg GITLAB_USER=myuser .

# --- Final Stage ---
FROM alpine:latest
COPY --from=builder /app /app
# No credentials remain in this final image

# --- Option 2: Multi-stage build (Simpler but still effective) ---
FROM alpine:latest AS downloader
ARG GITLAB_TOKEN 
RUN apk add --no-cache curl && \
    # Use token to download artifacts securely
    curl -H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
         "https://gitlab.com/api/v4/projects/123/jobs/artifacts/main/download?job=build" \
         -o artifacts.zip && \
    unzip artifacts.zip -d /app

# Build command: docker build --build-arg GITLAB_TOKEN=mysecrettoken ...

# --- Final Stage ---
FROM alpine:latest
COPY --from=downloader /app /app
# No credentials remain in this final image
# SECURE - Kubernetes GitLab integration
# 1. Secret for Docker Registry Auth
apiVersion: v1
kind: Secret
metadata:
  name: gitlab-registry-creds
  namespace: my-app
type: kubernetes.io/dockerconfigjson
data:
  # Create using: 
  # kubectl create secret docker-registry gitlab-registry-creds \
  #   --docker-server=registry.gitlab.com \
  #   --docker-username=<your-gitlab-deploy-token-username> \
  #   --docker-password=<your-gitlab-deploy-token-password> \
  #   --namespace=my-app \
  #   --dry-run=client -o json | jq -r '.data.".dockerconfigjson"'
  .dockerconfigjson: <base64-encoded-docker-config>

# 2. Secret for GitLab API Token (e.g., for syncing)
apiVersion: v1
kind: Secret
metadata:
  name: gitlab-api-token-secret
  namespace: tools 
type: Opaque
stringData:
  # Store the actual token value here, injected via secure means (e.g., Vault, SealedSecrets)
  token: ${GITLAB_API_TOKEN} 

# 3. Example Job using the API token
apiVersion: batch/v1
kind: Job
metadata:
  name: gitlab-repo-sync
  namespace: tools
spec:
  template:
    spec:
      containers:
      - name: sync-script
        image: curlimages/curl:latest # Or your custom image
        command: ["/bin/sh", "-c"]
        args:
          - |
            echo "Fetching projects..."
            curl --fail -H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
                 "https://gitlab.com/api/v4/projects?owned=true" 
            # Add actual sync logic here
        env:
        - name: GITLAB_TOKEN
          valueFrom:
            secretKeyRef:
              name: gitlab-api-token-secret
              key: token
      restartPolicy: Never
  backoffLimit: 1

Detection Patterns

  • GitLab Personal Access Token: `glpat-[0-9a-zA-Z\-\_]{20}`
  • GitLab Project Access Token: `glpat-[0-9a-zA-Z\-\_]{20}`
  • GitLab Group Access Token: `glpat-[0-9a-zA-Z\-\_]{20}`
  • GitLab Deploy Token Password: `gldt-[0-9a-zA-Z\-\_]{20}`
  • GitLab Runner Registration Token: `GR1348941[0-9a-zA-Z\-\_]{20}`
  • GitLab CI/CD Job Token (format): `glc[i|j]t-[0-9a-zA-Z\-\_]{20,}` (Note: $CI_JOB_TOKEN itself is secure in context)
  • GitLab Trigger Token: `gl[p|t]t-[0-9a-zA-Z]{20,}`
  • GitLab Feed Token: `feed_token_[0-9a-zA-Z\-\_]{20,}`

Prevention Best Practices

  1. Use CI/CD Variables: Never hardcode tokens directly in your .gitlab-ci.yml or scripts. Store them in GitLab CI/CD Variables (Settings > CI/CD > Variables). ⚙️
  2. Mask & Protect Variables: For sensitive variables like API keys or deploy tokens, mark them as Masked (hides value in job logs, requires specific format) and Protected (only available on protected branches/tags). This significantly reduces exposure risk.
  3. Prefer Job Tokens ($CI_JOB_TOKEN): Use the automatically available $CI_JOB_TOKEN whenever possible. It’s short-lived (only valid for the job’s duration) and has limited permissions scoped to the project. It’s ideal for accessing the project’s own container registry or package registry.
  4. Implement Token Rotation: Regularly rotate all static tokens (Personal, Project, Group Access Tokens, Deploy Tokens). Define a schedule (e.g., every 90 days) and automate the rotation process if possible using the GitLab API.
  5. Use Project/Group Tokens over Personal: Avoid using Personal Access Tokens (PATs) for automation. PATs are tied to a user account and often have broad permissions. Use Project Access Tokens or Group Access Tokens instead, which are designed for automation and have more granular scope control.
  6. Enforce 2FA: Require Two-Factor Authentication (2FA) for all user accounts, especially those with Maintainer or Owner roles. This prevents account takeover, which could lead to token compromise.
  7. Monitor Audit Events: Regularly review GitLab’s Audit Events (Admin Area or Group/Project Settings) for suspicious activity related to token creation, usage, or CI/CD variable changes.
  8. Use Dependency Proxy: Enable GitLab’s Dependency Proxy to securely cache Docker Hub images. This reduces reliance on external registries and allows you to authenticate using the GitLab job token ($CI_DEPENDENCY_PROXY_USER, $CI_DEPENDENCY_PROXY_PASSWORD).