v0.3.0 AI Drafted

GitLab Hardening Guide

DevOps Last updated: 2026-09-25

DevOps platform security for CI/CD pipelines, repository access, and runners

View:

Overview

GitLab is used by 50%+ of Fortune 100 with 30,000+ paying customers. Integrated CI/CD pipelines, container registry, and secrets management concentrate attack surface. Runner tokens, project API keys, and OAuth integrations with cloud providers enable code injection and infrastructure access. A compromised GitLab instance provides attackers with source code, CI/CD secrets, and deployment capabilities.

Intended Audience

  • Security engineers hardening GitLab instances
  • DevOps engineers configuring CI/CD security
  • GRC professionals assessing DevSecOps compliance
  • Platform teams managing GitLab infrastructure

How to Use This Guide

  • L1 (Crawl): Essential controls for all organizations
  • L2 (Walk): Enhanced controls for security-sensitive environments
  • L3 (Run): Strictest controls for regulated industries

Scope

This guide covers GitLab security configurations including authentication, CI/CD pipeline security, runner hardening, and third-party integration controls.


Table of Contents

  1. Authentication & Access Controls
  2. CI/CD Pipeline Security
  3. Runner Security
  4. Repository Security
  5. Secrets Management
  6. Monitoring & Detection
  7. AI Assistant Governance (GitLab Duo)
  8. Compliance Quick Reference

1. Authentication & Access Controls

1.1 Enforce SSO with MFA

Profile Level: L1 (Crawl) CIS Controls: 6.3, 6.5 NIST 800-53: IA-2(1)

Description

Require SAML/OIDC SSO with MFA for all GitLab authentication, eliminating password-based access.

Rationale

Why This Matters:

  • GitLab credentials provide access to source code and CI/CD pipelines
  • Compromised accounts can inject malicious code
  • SSO enables centralized access control and MFA enforcement

Attack Prevented: Credential-based account takeover, malicious code injection into source and CI/CD pipelines

Attack Scenario: Malicious .gitlab-ci.yml injects backdoor during build; stolen runner token enables unauthorized deployments.

ClickOps Implementation (Premium/Ultimate)

Step 1: Configure SAML SSO

  1. Navigate to: Group → Settings → SAML SSO
  2. Configure:
    • Identity provider single sign-on URL: Your IdP endpoint
    • Certificate fingerprint: From IdP
  3. Select Enable SAML authentication for this group
  4. Click Save changes

Step 2: Enforce SSO-Only Authentication

  1. Navigate to: Group → Settings → SAML SSO
  2. Enable: Enforce SSO-only authentication for web activity for this group
  3. Enable: Enforce SSO-only authentication for Git and Dependency Proxy activity for this group

Step 3: Disable Password Authentication

  • GitLab.com: In Group → Settings → SAML SSO, select Disable password and passkey authentication for enterprise users (GitLab 17.4 and later)
  • GitLab Self-Managed: Navigate to Admin → Settings → General → Sign-in restrictions and clear Allow password and passkey authentication for the web interface and Allow password authentication for Git over HTTP(S). Confirm an administrator can sign in through SAML first; turning off password sign-in before that is a lockout risk.

Code Implementation (Self-Managed)

The Code Pack prints an Omnibus gitlab.rb SAML block to merge into /etc/gitlab/gitlab.rb. It does not apply to GitLab.com, where SAML is configured per group in the console as above.

Code Pack: Config
hth-gitlab-1.01-configure-saml-sso.sh View source on GitHub ↗
# /etc/gitlab/gitlab.rb

# SAML Configuration
gitlab_rails['omniauth_enabled'] = true
gitlab_rails['omniauth_allow_single_sign_on'] = ['saml']
gitlab_rails['omniauth_block_auto_created_users'] = false
gitlab_rails['omniauth_providers'] = [
  {
    name: 'saml',
    args: {
      assertion_consumer_service_url: 'https://gitlab.example.com/users/auth/saml/callback',
      idp_cert_fingerprint: 'XX:XX:XX...',
      idp_sso_target_url: 'https://idp.example.com/saml/sso',
      issuer: 'https://gitlab.example.com',
      name_identifier_format: 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent'
    }
  }
]

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.1 Logical access controls
NIST 800-53 IA-2(1) MFA for network access

1.2 Implement Granular Project Permissions

Profile Level: L1 (Crawl) NIST 800-53: AC-3, AC-6

Description

Configure project-level access controls using GitLab’s role-based permissions.

Rationale

Why This Matters:

  • GitLab’s role hierarchy (Guest through Owner) limits each user to only the actions their job requires, shrinking the blast radius of any single compromised account
  • Protected branches with a forced merge-request workflow stop unreviewed or malicious code from reaching production branches directly
  • Mandatory multi-approver review with author self-approval blocked prevents one insider or one hijacked account from shipping changes unilaterally

Attack Prevented: Privilege escalation, unauthorized code changes, insider tampering, malicious merge to protected branches

ClickOps Implementation

Step 1: Define Role Strategy

Role Permissions Use Case
Guest View issues, wiki External stakeholders
Reporter Clone, view CI/CD QA, read-only developers
Developer Push to non-protected branches Development team
Maintainer Merge to protected, manage CI/CD Tech leads
Owner Full control Project owners only

Step 2: Configure Protected Branches

  1. Navigate to: Project → Settings → Repository → Protected branches
  2. Protect main and release/*:
    • Allowed to merge: Maintainers
    • Allowed to push: No one (force MR workflow)
    • Require approval from code owners: Enable

Step 3: Enable Required Approvals

  1. Navigate to: Project → Settings → Merge requests
  2. Configure:
    • Approvals required: 2 (minimum)
    • Prevent approval by merge request creator: Enable
    • Prevent editing approval rules in merge requests: Enable

Code Implementation

The Code Pack is a read-only audit: it confirms the default branch is protected, that nobody pushes directly or force-pushes to a protected branch, that Developers cannot merge, and (Premium/Ultimate) that authors cannot approve their own merge requests and at least one approval rule requires two approvals.

Code Pack: API Script
hth-gitlab-1.02-audit-project-permissions.sh View source on GitHub ↗
# 1. The default branch must be covered by a protected-branch rule.
PROJECT=$(gl_get "/projects/${PROJECT_ID}") || {
  fail "1.2 GET /projects/${PROJECT_ID} failed -- check PROJECT_ID and token"; summary; exit 2; }
DEFAULT_BRANCH=$(printf '%s' "${PROJECT}" | jq -r '.default_branch // empty')

RULES=$(gl_get_all "/projects/${PROJECT_ID}/protected_branches") || {
  fail "1.2 Could not list protected branches (HTTP status above; a 403 means the Maintainer role is required)"; summary; exit 2; }

COVERED=0
while IFS= read -r pattern; do
  # GitLab protected-branch names may use * as a wildcard.
  # shellcheck disable=SC2053
  [ -n "${DEFAULT_BRANCH}" ] && [[ "${DEFAULT_BRANCH}" == ${pattern} ]] && COVERED=1
done < <(printf '%s' "${RULES}" | jq -r '.[].name')
if [ -z "${DEFAULT_BRANCH}" ]; then
  warn "1.2 Project has no default branch (empty repository?) -- nothing to protect yet"
elif [ "${COVERED}" -eq 1 ]; then
  pass "1.2 Default branch '${DEFAULT_BRANCH}' is protected"
else
  finding "1.2 Default branch '${DEFAULT_BRANCH}' is NOT protected"
fi

# 2. Each rule: nobody pushes directly, Developers do not merge, no force push.
while IFS= read -r rule; do
  NAME=$(printf '%s' "${rule}" | jq -r '.name')
  PUSHERS=$(printf '%s' "${rule}" | jq '[.push_access_levels[]? | select(.access_level != 0)] | length')
  DEV_MERGE=$(printf '%s' "${rule}" | jq '[.merge_access_levels[]? | select(.access_level == 30)] | length')
  FORCE=$(printf '%s' "${rule}" | jq -r '.allow_force_push')
  CODEOWNER=$(printf '%s' "${rule}" | jq -r '.code_owner_approval_required')
  [ "${PUSHERS}" -gt 0 ] && finding "1.2 '${NAME}': direct push allowed for ${PUSHERS} role/user/group/key entry(ies) -- set Allowed to push to No one"
  [ "${DEV_MERGE}" -gt 0 ] && finding "1.2 '${NAME}': Developers are allowed to merge -- restrict to Maintainers"
  [ "${FORCE}" = "true" ] && finding "1.2 '${NAME}': force push is allowed"
  [ "${CODEOWNER}" = "true" ] || warn "1.2 '${NAME}': code owner approval not required (Premium/Ultimate)"
done < <(printf '%s' "${RULES}" | jq -c '.[]')

# 3. Approval settings (Premium/Ultimate): authors cannot approve their own MRs,
#    and approval rules cannot be edited per merge request. Console labels
#    (Settings > Merge requests): "Prevent approval by merge request creator",
#    "Prevent editing approval rules in merge requests".
if APPROVALS=$(gl_get "/projects/${PROJECT_ID}/approvals"); then
  [ "$(printf '%s' "${APPROVALS}" | jq -r '.merge_requests_author_approval')" = "false" ] \
    && pass "1.2 Prevent approval by merge request creator: enabled" \
    || finding "1.2 Merge request authors can approve their own merge requests"
  [ "$(printf '%s' "${APPROVALS}" | jq -r '.disable_overriding_approvers_per_merge_request')" = "true" ] \
    && pass "1.2 Prevent editing approval rules in merge requests: enabled" \
    || finding "1.2 Approval rules can be edited in individual merge requests"
else
  fail "1.2 Could not read approval settings (HTTP status above; a 403 or 404 here usually means Premium/Ultimate and the Maintainer role are required)"
  STATE_UNKNOWN=1
fi

# 4. At least one approval rule requires two or more approvals.
if APPROVAL_RULES=$(gl_get_all "/projects/${PROJECT_ID}/approval_rules"); then
  MAX_REQUIRED=$(printf '%s' "${APPROVAL_RULES}" | jq '[.[].approvals_required] | max // 0')
  if [ "${MAX_REQUIRED}" -ge 2 ]; then
    pass "1.2 Highest approval rule requires ${MAX_REQUIRED} approvals"
  else
    finding "1.2 No approval rule requires 2 or more approvals (highest: ${MAX_REQUIRED})"
  fi
else
  fail "1.2 Could not list approval rules (HTTP status above; a 403 or 404 here usually means Premium/Ultimate is required)"
  STATE_UNKNOWN=1
fi

1.3 Configure Personal Access Token Policies

Profile Level: L1 (Crawl) NIST 800-53: IA-5

Description

Restrict personal access token (PAT) creation and enforce expiration policies.

Rationale

Why This Matters:

  • Personal access tokens authenticate to the API and Git without MFA, so a leaked long-lived token grants persistent, password-less access to source and pipelines
  • Enforcing a maximum token lifetime guarantees stolen or forgotten tokens expire automatically instead of remaining valid indefinitely
  • Restricting tokens to minimal scopes ensures a leaked read token cannot be used to push code or alter CI/CD configuration

Attack Prevented: Credential theft, token replay, over-privileged token abuse, persistent unauthorized access

ClickOps Implementation

Step 1: Set Token Expiration Limits

Expiration is no longer optional on current GitLab. Every new personal, group, and project access token must have an expiration date; if the creator does not set one, GitLab applies a default of 365 days, and by default an expiry cannot be more than 365 days out. GitLab 17.6 added an extended 400-day maximum behind a feature flag that is disabled by default. Non-expiring tokens are deprecated — on upgrade, existing tokens without an expiration date have one applied automatically. Treat any shorter figure as an organizational policy choice made within that ceiling, not as a platform default.

Self-Managed and Dedicated (Ultimate):

  1. Navigate to: Admin → Settings → General → Account and limit
  2. Configure:
    • Maximum allowable lifetime for access tokens (days): 90 (recommended organizational policy)
  3. Keep the service account token expiration settings enabled — do not use the allowance for non-expiring service account credentials, which reintroduces the exact persistence problem the mandatory expiry removed.

GitLab.com: There is no customer-settable instance lifetime (the Admin area is Self-Managed and Dedicated only). Owners of a top-level group with Premium or Ultimate can instead turn off personal access tokens for the group’s enterprise users: Group → Settings → General → Permissions and group features → Enterprise users → Disable personal access tokens.

Step 2: Restrict Group and Project Access Token Creation

  1. Navigate to: Top-level group → Settings → General → Permissions and group features (Owner role; on GitLab.com the group needs Premium or Ultimate)
  2. Clear: Users can create group access tokens and project access tokens in this group
  3. Existing tokens stay valid until they expire or are revoked

Step 3: Disable API Scope for Non-Essential Tokens

  • Audit tokens with api scope
  • Replace with minimal scopes (read_repository, write_repository)

Source: Personal access tokens

Code Implementation

The API Code Pack is a read-only audit of active personal access tokens that flags tokens with api or write_repository scope or no expiration date. An administrator token lists every user’s tokens; any other token lists only its own, so on GitLab.com, where customers have no administrator role, it audits only the calling user’s tokens.

Code Pack: API Script
hth-gitlab-1.03-configure-personal-access-token-policies.sh View source on GitHub ↗
# List all active personal access tokens and flag risky configurations.
# A failed page stops the audit: an empty list must mean "no tokens", never "the call failed".
info "1.3 Retrieving active personal access tokens..."
PAGE=1
ALL_PATS="[]"
while true; do
  RESPONSE=$(gl_get "/personal_access_tokens?state=active&per_page=100&page=${PAGE}") || {
    fail "1.3 GET /personal_access_tokens (page ${PAGE}) failed -- check GITLAB_URL, token validity and read_api scope"
    increment_failed; summary; exit 2
  }
  COUNT=$(printf '%s' "${RESPONSE}" | jq 'length') || {
    fail "1.3 Unparseable response on page ${PAGE}"
    increment_failed; summary; exit 2
  }
  [ "${COUNT}" -eq 0 ] && break
  ALL_PATS=$(printf '%s %s' "${ALL_PATS}" "${RESPONSE}" | jq -s 'add')
  [ "${COUNT}" -lt 100 ] && break
  PAGE=$((PAGE + 1))
done

TOTAL=$(printf '%s' "${ALL_PATS}" | jq 'length')
info "1.3 Found ${TOTAL} active personal access token(s)"

# Flag tokens with overly broad 'api' scope
API_SCOPE_PATS=$(printf '%s' "${ALL_PATS}" | jq '[.[] | select((.scopes // []) | index("api"))]')
API_SCOPE_COUNT=$(printf '%s' "${API_SCOPE_PATS}" | jq 'length')

if [ "${API_SCOPE_COUNT}" -gt 0 ]; then
  warn "1.3 Found ${API_SCOPE_COUNT} token(s) with full 'api' scope (overly permissive)"
  printf '%s' "${API_SCOPE_PATS}" | jq -r '.[] | "  - \(.name // "unnamed") (user: \(.user_id // "unknown"), created: \(.created_at // "unknown"))"'
fi

# Flag tokens with no expiration date
NO_EXPIRY_PATS=$(printf '%s' "${ALL_PATS}" | jq '[.[] | select(.expires_at == null)]')
NO_EXPIRY_COUNT=$(printf '%s' "${NO_EXPIRY_PATS}" | jq 'length')

if [ "${NO_EXPIRY_COUNT}" -gt 0 ]; then
  warn "1.3 Found ${NO_EXPIRY_COUNT} token(s) with no expiration date"
  printf '%s' "${NO_EXPIRY_PATS}" | jq -r '.[] | "  - \(.name // "unnamed") (user: \(.user_id // "unknown"), scopes: \((.scopes // []) | join(", ")))"'
fi

# Flag tokens with write_repository scope (supply chain risk)
WRITE_REPO_PATS=$(printf '%s' "${ALL_PATS}" | jq '[.[] | select((.scopes // []) | index("write_repository"))]')
WRITE_REPO_COUNT=$(printf '%s' "${WRITE_REPO_PATS}" | jq 'length')

if [ "${WRITE_REPO_COUNT}" -gt 0 ]; then
  warn "1.3 Found ${WRITE_REPO_COUNT} token(s) with 'write_repository' scope"
  printf '%s' "${WRITE_REPO_PATS}" | jq -r '.[] | "  - \(.name // "unnamed") (user: \(.user_id // "unknown"), expires: \(.expires_at // "never"))"'
fi
Code Pack: Sigma Detection Rule
hth-gitlab-1.03-configure-personal-access-token-policies.yml View source on GitHub ↗
detection:
    selection:
        entity_type: 'PersonalAccessToken'
        action: 'create'
    condition: selection
fields:
    - author_name
    - entity_path
    - target_details
    - ip_address
    - created_at

1.4 Enforce Approvals with Merge Request Approval Policies

Profile Level: L2 (Walk)

Framework Control
CIS Controls 16.1
NIST 800-53 CM-3, AU-10

Description

Move approval enforcement out of per-project approval rules and into merge request approval policies, which live in a separate security policy project that only Owners can link. Includes the any_merge_request rule that requires approval whenever a merge request contains unsigned commits. Source: Merge request approval policies.

Rationale

Why This Matters:

  • Project approval rules (control 1.2) are configured in project settings, where any Maintainer can edit or delete them — the same people whose code the rules are meant to gate can turn the gate off
  • Merge request approval policies are defined in a linked security policy project, and only the Owner role can link that project, so the enforcement configuration and the code being enforced sit under different administrators
  • Policies attach at the group level and apply to every project underneath, so a newly created project inherits approval enforcement instead of starting with none
  • The any_merge_request rule type can require approval whenever a merge request contains unsigned commits, which makes the commit-signing control in 4.2 enforceable rather than advisory

Attack Prevented: Approval-rule tampering by a compromised or malicious Maintainer, unilateral merge of attacker-authored code, unsigned and spoofed commits reaching protected branches without review

ClickOps Implementation

Step 1: Create and Link a Security Policy Project

  1. Navigate to: Group → Secure → Policies
  2. Click Edit policy project and create or select a dedicated security policy project
  3. Restrict membership on that project to the security team — its members control enforcement for every project in the group
  4. Confirm only Owners hold the ability to change the linked policy project

Step 2: Create the Merge Request Approval Policy

  1. Navigate to: Group → Secure → Policies → New policy → Merge request approval policy
  2. Set the scope to all projects in the group (or an explicit project list)
  3. Add a rule of type Any merge request targeting protected branches
  4. Set the commit attribute to unsigned commits so the rule triggers when any commit in the merge request is unsigned
  5. Set Approvals required to at least 1 and assign an approver group outside the project’s own Maintainers
  6. Set the policy status to Enabled, select Configure with a merge request, then review and merge the merge request GitLab opens in the security policy project; the policy takes effect only once that merge request is merged

Step 3: Keep Project Rules as Defense in Depth

  1. Leave the project-level approval rules from control 1.2 in place
  2. Treat them as a convenience layer, not the enforcement layer — the policy is what survives a Maintainer with bad intent

Code Implementation

The Code Pack is the policy itself, in GitLab’s policy format: commit it as .gitlab/security-policies/policy.yml in the linked security policy project, merged with any policies already there. It requires an approval from a named group whenever a merge request into a protected branch contains an unsigned commit, and locks the related approval settings.

Code Pack: Config
hth-gitlab-1.04-mr-approval-policy.yml View source on GitHub ↗
approval_policy:
  - name: Require security approval for unsigned commits
    description: >-
      Any merge request into a protected branch that contains an unsigned
      commit needs an approval from outside the project's own Maintainers.
    enabled: true
    policy_scope:
      projects:
        excluding: []
    rules:
      - type: any_merge_request
        branch_type: protected
        commits: unsigned
    actions:
      - type: require_approval
        approvals_required: 1
        group_approvers:
          - your-group/security-approvers
    approval_settings:
      block_branch_modification: true
      prevent_pushing_and_force_pushing: true
      prevent_approval_by_author: true
      prevent_approval_by_commit_author: true
      remove_approvals_with_new_commit: true
    fallback_behavior:
      fail: closed

Validation & Testing

  1. Sign in as a user with the Maintainer role on a covered project and confirm the policy cannot be edited or removed from Secure → Policies
  2. Open a merge request containing at least one unsigned commit against a protected branch and confirm an additional policy-sourced approval requirement appears and blocks merge
  3. Delete a project-level approval rule as a Maintainer and confirm the policy requirement still applies to a new merge request
  4. Review Group → Secure → Policies quarterly to confirm the policy is still enabled and scoped to every project

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC8.1 Change management authorization
NIST 800-53 CM-3 Configuration change control
NIST 800-53 AU-10 Non-repudiation of code authorship

2. CI/CD Pipeline Security

2.1 Protect CI/CD Variables

Profile Level: L1 (Crawl) NIST 800-53: SC-28

Description

Configure CI/CD variables with appropriate protection levels and masking.

Rationale

Why This Matters:

  • CI/CD variables typically hold deployment credentials, API keys, and cloud secrets that grant access far beyond GitLab itself
  • Masking keeps secret values from being printed in job logs, which are visible to anyone who can view the pipeline
  • Marking variables as protected confines them to protected branches, so a feature branch or fork cannot exfiltrate production secrets
  • Environment-scoping prevents a staging pipeline from reading production credentials

Attack Prevented: Secret exposure in logs, credential exfiltration via untrusted branches, cross-environment secret leakage

ClickOps Implementation

Step 1: Configure Variable Protection

  1. Navigate to: Project → Settings → CI/CD → Variables
  2. For each sensitive variable:
    • Protect variable: Enable (only available in protected branches)
    • Mask variable: Enable (hidden in job logs)
    • Expand variable reference: Disable

Step 2: Use Group-Level Variables

  1. Navigate to: Group → Settings → CI/CD → Variables
  2. Define shared secrets at group level
  3. Limit duplication across projects

Step 3: Environment-Scoped Variables

  1. Create separate variables for each environment:
    • PROD_API_KEY (protected)
    • STAGING_API_KEY
  2. Scope to specific environments

Code Implementation

Code Pack: Config
hth-gitlab-2.01-secure-variable-usage.yml View source on GitHub ↗
# .gitlab-ci.yml - Secure variable usage
# Never hardcode secrets in a variables: block; reference protected CI/CD
# variables defined in Settings > CI/CD > Variables instead.

deploy_production:
  stage: deploy
  script:
    - echo "Deploying with protected credentials"
    - ./deploy.sh  # Uses $PROD_API_KEY from CI/CD settings
  environment:
    name: production
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
  # Only run on protected branch with protected variables
Code Pack: API Script
hth-gitlab-2.01-protect-cicd-variables.sh View source on GitHub ↗
# Retrieve all project-level CI/CD variables and check protection settings.
# The endpoint is paginated (20 per page by default), so walk every page --
# auditing only the first page would report a partial scan as a clean one.
VARIABLES="[]"
PAGE=1
while true; do
  RESPONSE=$(gl_get "/projects/${PROJECT_ID}/variables?per_page=100&page=${PAGE}") || {
    fail "2.1 Failed to retrieve CI/CD variables (page ${PAGE}) -- check PROJECT_ID and token permissions (Maintainer role)"
    increment_failed; summary; exit 2
  }
  COUNT=$(printf '%s' "${RESPONSE}" | jq 'length') || {
    fail "2.1 Unparseable response on page ${PAGE}"
    increment_failed; summary; exit 2
  }
  [ "${COUNT}" -eq 0 ] && break
  VARIABLES=$(printf '%s %s' "${VARIABLES}" "${RESPONSE}" | jq -s 'add')
  [ "${COUNT}" -lt 100 ] && break
  PAGE=$((PAGE + 1))
done

VAR_COUNT=$(printf '%s' "${VARIABLES}" | jq 'length')
info "2.1 Found ${VAR_COUNT} CI/CD variable(s)"

printf '%s' "${VARIABLES}" | jq -c '.[]' | while IFS= read -r var; do
  KEY=$(printf '%s' "${var}" | jq -r '.key')
  PROTECTED=$(printf '%s' "${var}" | jq -r '.protected')
  MASKED=$(printf '%s' "${var}" | jq -r '.masked')
  # raw=true means "Expand variable reference" is off, the recommended state
  RAW=$(printf '%s' "${var}" | jq -r '.raw')

  ISSUES=""
  if [ "${PROTECTED}" != "true" ]; then
    ISSUES="${ISSUES} unprotected"
  fi
  if [ "${MASKED}" != "true" ]; then
    ISSUES="${ISSUES} unmasked"
  fi
  if [ "${RAW}" == "false" ]; then
    ISSUES="${ISSUES} expands-references"
  fi

  if [ -n "${ISSUES}" ]; then
    warn "2.1 Variable '${KEY}':${ISSUES}"
  else
    pass "2.1 Variable '${KEY}': protected + masked"
  fi
done

# Summary counts (re-parse for totals since while-loop runs in subshell)
UNPROTECTED=$(printf '%s' "${VARIABLES}" | jq '[.[] | select(.protected != true)] | length')
UNMASKED=$(printf '%s' "${VARIABLES}" | jq '[.[] | select(.masked != true)] | length')
EXPANDED=$(printf '%s' "${VARIABLES}" | jq '[.[] | select(.raw == false)] | length')

info "2.1 Unprotected: ${UNPROTECTED}, Unmasked: ${UNMASKED}, Expands references: ${EXPANDED}"

2.2 Implement Pipeline Security Controls

Profile Level: L1 (Crawl) NIST 800-53: CM-7, SI-7

Description

Restrict pipeline execution and prevent unauthorized CI/CD modifications.

Rationale

Why This Matters:

  • Merge requests from forks carry attacker-authored pipeline code, so keeping those pipelines out of the parent project, or running them there only after a reviewer has read the change, stops poisoned-pipeline attacks on the parent’s variables and runners
  • Limiting the CI/CD job token allowlist to only the projects a pipeline truly needs prevents lateral movement between repositories if a job is compromised
  • Requiring pipelines to succeed and threads to be resolved before merge enforces that security and quality checks actually gate the codebase

Attack Prevented: Poisoned pipeline execution, lateral movement via job tokens, bypass of security gates

ClickOps Implementation

Step 1: Limit CI/CD Job Token Access

  1. Navigate to: Project → Settings → CI/CD → Job token permissions
  2. Confirm This project and any groups and projects in the allowlist is selected, so jobs in other projects cannot use their job token against this one (opening access to All groups and projects is possible only on Self-Managed and Dedicated)
  3. Keep the CI/CD job token allowlist to groups and projects with a working pipeline dependency; control 2.4 scopes each entry

Step 2: Configure Merge Checks

  1. Navigate to: Project → Settings → Merge requests
  2. Under Merge checks, select Pipelines must succeed and leave Skipped pipelines are considered successful cleared
  3. Select All threads must be resolved
  4. Select Save changes

Step 3: Control Who Can Run Pipelines

  1. On a protected branch, only users allowed to merge or push to it can run manual or scheduled pipelines, run manual jobs, or retry and cancel jobs, so restrict Allowed to merge and Allowed to push on your protected branches (control 1.2)
  2. Navigate to: Project → Settings → CI/CD → Variables and set Minimum role to use pipeline variables to no_one_allowed, or to maintainer if a pipeline genuinely needs them
  3. Merge requests from forks run their pipelines in the fork by default. A parent-project member can run one in the parent project, with the parent’s variables and runners, from the merge request’s Pipelines tab after accepting a warning, so read the fork’s changes first. To stop this entirely, set ci_allow_fork_pipelines_to_run_in_parent_project to false through the projects API; there is no console setting for it

Code Implementation

The Code Pack is a read-only audit of the merge gates (pipelines must succeed, skipped pipelines do not count, threads must be resolved), of whether the CI/CD job token allowlist is enforced, of the minimum role for pipeline variables, and (with the Owner role, which the API requires to return it) of whether fork pipelines can run in the parent project.

Code Pack: API Script
hth-gitlab-2.02-audit-pipeline-controls.sh View source on GitHub ↗
PROJECT=$(gl_get "/projects/${PROJECT_ID}") || {
  fail "2.2 GET /projects/${PROJECT_ID} failed -- check PROJECT_ID and token"; summary; exit 2; }

# Merge gates: a merge request cannot merge until its pipeline succeeds (a
# skipped pipeline does not count) and every thread is resolved.
check_setting() {  # <json field> <required value> <description>
  local actual
  actual=$(printf '%s' "${PROJECT}" | jq -r ".$1")
  if [ "${actual}" = "$2" ]; then
    pass "2.2 $3"
  else
    fail "2.2 $3 -- expected $1=$2, found ${actual}"
    FINDINGS=$((FINDINGS + 1))
  fi
}
check_setting only_allow_merge_if_pipeline_succeeds true "Pipelines must succeed before merge"
check_setting allow_merge_on_skipped_pipeline false "Skipped pipelines do not satisfy the pipeline gate"
check_setting only_allow_merge_if_all_discussions_are_resolved true "All threads must be resolved before merge"

# Minimum role to use pipeline variables (Settings > CI/CD > Variables).
VAR_ROLE=$(printf '%s' "${PROJECT}" | jq -r '.ci_pipeline_variables_minimum_override_role // empty')
case "${VAR_ROLE}" in
  no_one_allowed|owner|maintainer)
    pass "2.2 Minimum role to use pipeline variables: ${VAR_ROLE}" ;;
  developer)
    fail "2.2 Developers can run pipelines with pipeline variables -- set the minimum role to no_one_allowed or maintainer"
    FINDINGS=$((FINDINGS + 1)) ;;
  "")
    fail "2.2 ci_pipeline_variables_minimum_override_role was not returned (GitLab 17.1+) -- pipeline variable restriction unknown"
    STATE_UNKNOWN=1 ;;
  *)
    fail "2.2 Unrecognized ci_pipeline_variables_minimum_override_role '${VAR_ROLE}'"
    FINDINGS=$((FINDINGS + 1)) ;;
esac

# Fork pipelines in the parent project (API only; Owner role to read).
FORK_IN_PARENT=$(printf '%s' "${PROJECT}" | jq -r '.ci_allow_fork_pipelines_to_run_in_parent_project | if . == null then empty else tostring end')
case "${FORK_IN_PARENT}" in
  false) pass "2.2 Fork merge request pipelines cannot run in this project" ;;
  true)  warn "2.2 Members can run fork merge request pipelines in this project (after a warning) -- review fork changes first, or set ci_allow_fork_pipelines_to_run_in_parent_project=false" ;;
  *)     info "2.2 ci_allow_fork_pipelines_to_run_in_parent_project not returned (Owner role required) -- fork pipeline setting not audited" ;;
esac

# Job token scope: only allowlisted groups and projects may use a CI/CD job
# token to reach this project.
if SCOPE=$(gl_get "/projects/${PROJECT_ID}/job_token_scope"); then
  if [ "$(printf '%s' "${SCOPE}" | jq -r '.inbound_enabled')" = "true" ]; then
    pass "2.2 CI/CD job token allowlist is enforced for inbound access"
  else
    fail "2.2 CI/CD job token allowlist is off -- jobs in any project can reach this one"
    FINDINGS=$((FINDINGS + 1))
  fi
else
  fail "2.2 Could not read the job token scope (HTTP status above; a 403 means the Maintainer role is required)"
  STATE_UNKNOWN=1
fi

2.3 Harden .gitlab-ci.yml Configuration

Profile Level: L2 (Walk) NIST 800-53: CM-7

Description

Implement secure CI/CD configuration practices. See the Code Pack below for a security-hardened .gitlab-ci.yml example.

Rationale

Why This Matters:

  • The .gitlab-ci.yml file is executable code that runs with pipeline privileges, making it a prime target for supply-chain injection
  • Pinning image and dependency versions, avoiding untrusted includes, and restricting privileged execution reduce the chance a build step is hijacked
  • A hardened pipeline definition limits what a compromised job can reach, containing damage to a single stage rather than the whole environment

Attack Prevented: CI/CD supply-chain injection, malicious build steps, privileged container escape, untrusted include abuse

ClickOps Implementation

Step 1: Edit and Validate in the Pipeline Editor

  1. Navigate to: Project → Build → Pipeline editor
  2. Apply the hardened configuration from the Code Pack below to the project’s .gitlab-ci.yml
  3. Select the Validate tab, then Validate pipeline, and fix any errors before committing
  4. Commit the change through a merge request so the approval rules from control 1.2 apply to it

Code Implementation

Code Pack: Config
hth-gitlab-2.03-hardened-ci-config.yml View source on GitHub ↗
# .gitlab-ci.yml - Security hardened example

default:
  # Use specific image tags, not :latest
  image: ruby:3.2.0-alpine@sha256:abc123...

  # Limit job timeout
  timeout: 30 minutes

  # Run in isolated environment
  tags:
    - docker
    - isolated

# Prevent secret leakage in logs
variables:
  GIT_STRATEGY: clone
  SECURE_LOG_LEVEL: "warn"

# Security scanning stages
stages:
  - test
  - security
  - build
  - deploy

sast:
  stage: security
  allow_failure: false  # Block on security issues

dependency_scanning:
  stage: security
  allow_failure: false

container_scanning:
  stage: security
  allow_failure: false

# Restrict production deployment
deploy_production:
  stage: deploy
  script:
    - ./deploy.sh
  environment:
    name: production
    url: https://prod.company.com
  rules:
    # Only from main branch
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual  # Require manual approval
  # Prevent concurrent deployments
  resource_group: production

2.4 Apply Fine-Grained CI/CD Job Token Permissions

Profile Level: L2 (Walk)

Framework Control
CIS Controls 6.8
NIST 800-53 AC-6

Description

Replace blanket job token inheritance with per-allowlist-entry endpoint scopes, so each project you authorize receives only the specific READ_* or ADMIN_* API permissions it needs. Generally available in GitLab 18.3. Source: Fine-grained permissions for CI/CD job tokens.

Rationale

Why This Matters:

  • Without fine-grained permissions, a CI/CD job token carries the permissions of the user who triggered the pipeline — so a routine job triggered by an Owner can reach everything that Owner can reach in every allowlisted project
  • The allowlist alone (control 2.2) answers “which projects” but not “to do what”; fine-grained permissions add the missing second question by scoping each entry to explicit endpoint groups such as reading packages or reading jobs
  • Job tokens are a primary target in poisoned-pipeline attacks because they are present in the job environment by design; a token limited to read-only endpoints degrades a stolen credential from a lateral-movement primitive into a low-value one
  • Self-managed administrators can enforce the allowlist instance-wide, which closes the gap where individual project Maintainers opt out of scoping entirely

Attack Prevented: Lateral movement between projects using a harvested job token, privilege inheritance from a highly privileged triggering user, unauthorized API writes (member additions, pipeline changes) from a compromised job

ClickOps Implementation

Step 1: Confirm the Allowlist Is Active

  1. Navigate to: Project → Settings → CI/CD → Job token permissions
  2. Confirm inbound access is limited to the CI/CD job token allowlist rather than open access
  3. Remove allowlist entries that no longer have a working pipeline dependency

Step 2: Scope Each Allowlist Entry

  1. For each entry in the CI/CD job token allowlist, open its permissions
  2. Select only the endpoint scopes the consuming pipeline actually calls — for example a read scope for packages or jobs
  3. Avoid granting any ADMIN_* scope unless a pipeline provably needs to write; document the justification for every one you keep
  4. Save and re-run the dependent pipeline to confirm nothing broke

Step 3 (Self-Managed and Dedicated only): Enforce the Allowlist Instance-Wide

  1. Navigate to: Admin → Settings → CI/CD → Job token permissions
  2. Enable: Enable and enforce job token allowlist for all projects
  3. Communicate the change ahead of time — projects relying on unscoped token access will fail until their allowlist entries are configured

Code Implementation

The Code Pack is a read-only audit: it fails when the allowlist is off and lists every allowlisted project and group for review. The REST API does not return each entry’s permission scopes, so review those on the Job token permissions page (Step 2).

Code Pack: API Script
hth-gitlab-2.04-audit-job-token-allowlist.sh View source on GitHub ↗
SCOPE=$(gl_get "/projects/${PROJECT_ID}/job_token_scope") || {
  fail "2.4 Could not read the job token scope (HTTP status above; a 403 means the Maintainer role is required)"; summary; exit 2; }
ALLOW_PROJECTS=$(gl_get_all "/projects/${PROJECT_ID}/job_token_scope/allowlist") || {
  fail "2.4 Could not list the project allowlist"; summary; exit 2; }
# (Not named GROUPS: that is a read-only bash builtin, and assigning to it is silently ignored.)
ALLOW_GROUPS=$(gl_get_all "/projects/${PROJECT_ID}/job_token_scope/groups_allowlist") || {
  fail "2.4 Could not list the group allowlist"; summary; exit 2; }

INBOUND=$(printf '%s' "${SCOPE}" | jq -r '.inbound_enabled')
if [ "${INBOUND}" = "true" ]; then
  pass "2.4 CI/CD job token allowlist is enforced"
else
  fail "2.4 CI/CD job token allowlist is OFF -- a job token from any project can reach this project"
fi

# Every entry is a standing grant; review each one's permission scopes and
# remove entries with no working pipeline dependency.
info "2.4 Allowlisted projects: $(printf '%s' "${ALLOW_PROJECTS}" | jq 'length'), groups: $(printf '%s' "${ALLOW_GROUPS}" | jq 'length')"
printf '%s' "${ALLOW_PROJECTS}" | jq -r '.[] | "  - project: \(.path_with_namespace)"'
printf '%s' "${ALLOW_GROUPS}" | jq -r '.[] | "  - group:   \(.full_path // .name)"'

Validation & Testing

  1. From a pipeline job in an authorized inbound project, call an API endpoint outside the granted scope using the job token and confirm the request is rejected with a 401 or 403
  2. Call an endpoint inside the granted scope and confirm it succeeds, proving the scoping is precise rather than simply broken
  3. Review each project’s Job token permissions page and record any entry still holding an ADMIN_* scope for the next access review
  4. On self-managed, confirm a project that has not configured an allowlist cannot receive inbound job token access once enforcement is enabled

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.3 Least-privilege access to resources
NIST 800-53 AC-6 Least privilege
NIST 800-53 AC-3 Access enforcement

2.5 Enforce Security Scans with Pipeline Execution Policies

Profile Level: L2 (Walk)

Framework Control
CIS Controls 16.1
NIST 800-53 CM-7, SI-7

Description

Inject mandatory CI/CD jobs into every targeted project from a security policy project, so required scans run regardless of what the project’s own .gitlab-ci.yml says. Generally available in GitLab 17.3 (Ultimate). Source: Pipeline execution policies.

Rationale

Why This Matters:

  • Security jobs defined only in a project’s .gitlab-ci.yml can be edited or deleted by anyone who can push that file, meaning the scans gating a release are controlled by the same people whose code they inspect
  • A pipeline execution policy stores the mandatory configuration in a separate security policy project and injects it at pipeline creation, so scans still run when a project’s configuration is empty, broken, or deliberately stripped
  • Compliance pipelines — the older mechanism that attached a pipeline configuration to a compliance framework — are deprecated; migrating now keeps enforcement on a supported path instead of one scheduled for removal
  • A per-project cap of five pipeline execution policies keeps enforcement auditable, so the set of mandatory jobs stays small enough for a reviewer to actually read

Attack Prevented: Removal or bypass of mandatory security scanning, malicious edits to pipeline definitions that disable gates, silent enforcement gaps left behind by deprecated compliance pipelines

ClickOps Implementation

Step 1: Create the Pipeline Execution Policy

  1. Navigate to: Group → Secure → Policies → New policy → Pipeline execution policy
  2. Confirm the linked security policy project is the restricted-membership project from control 1.4
  3. Point the policy at the CI configuration file held in that security policy project — the policy itself is stored under .gitlab/security-policies/policy.yml as a pipeline_execution_policy entry

Step 2: Choose the Injection Strategy

  1. Select inject_policy to add the policy’s jobs alongside the project’s own pipeline — this is the current strategy and the right default for most groups
  2. Do not adopt inject_ci; it is the deprecated predecessor to inject_policy and existing policies using it should be migrated
  3. Select override_project_ci only where the policy’s configuration must fully replace the project’s pipeline, such as tightly regulated deployment repositories

Step 3: Scope and Cap

  1. Set the policy scope to the projects or compliance-framework-labeled projects that must carry the mandatory jobs
  2. Keep the total at or below the limit of five pipeline execution policies per project
  3. Enable the policy, select Configure with a merge request, then review and merge the merge request GitLab opens in the security policy project

Step 4: Migrate Off Compliance Pipelines

  1. Identify compliance frameworks that still specify a pipeline configuration file
  2. Recreate the equivalent jobs as a pipeline execution policy
  3. Clear the pipeline configuration from the compliance framework once the policy is verified, so a single mechanism owns enforcement

Code Implementation

The Code Pack is the policy in GitLab’s policy format, for .gitlab/security-policies/policy.yml in the security policy project. It uses inject_policy, and it stops projects from overriding the policy’s variables or skipping its jobs with [skip ci]. The included CI file holds the mandatory jobs.

Code Pack: Config
hth-gitlab-2.05-pipeline-execution-policy.yml View source on GitHub ↗
pipeline_execution_policy:
  - name: Mandatory security scans
    description: Inject the security team's scan jobs into every pipeline.
    enabled: true
    pipeline_config_strategy: inject_policy
    content:
      include:
        - project: your-group/security-policy-project
          file: ci/mandatory-scans.yml
    policy_scope:
      projects:
        excluding: []
    # Projects cannot override the policy's CI/CD variables, and the
    # [skip ci] directive cannot skip pipelines this policy applies to.
    # (Both are GitLab's defaults; stating them keeps them from drifting.)
    variables_override:
      allowed: false
    skip_ci:
      allowed: false

Validation & Testing

  1. Create a scratch project in scope with a minimal .gitlab-ci.yml, run a pipeline, and confirm the policy-injected jobs appear and execute
  2. Delete every job from the project’s own CI configuration, re-run, and confirm the mandatory jobs still run
  3. As a project Maintainer, attempt to modify the injected jobs and confirm the change does not take effect
  4. Count the pipeline execution policies applying to your most heavily governed project and confirm the total is five or fewer

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC7.1 Detection of configuration deviations
NIST 800-53 CM-7 Least functionality in build configuration
NIST 800-53 SI-7 Software and information integrity

2.6 Pin and Vet CI/CD Catalog Components

Profile Level: L2 (Walk)

Framework Control
CIS Controls 16.4
NIST 800-53 SR-3, CM-7

Description

Treat CI/CD catalog components as third-party dependencies: pin every reference to an immutable version, prefer commit SHAs, and review the component’s source before adoption. Source: CI/CD components.

Rationale

Why This Matters:

  • A catalog component is third-party code that executes inside your pipeline with access to the job token, masked variables, and the runner’s network position — it is a dependency with credentials, not a convenience snippet
  • Referencing a floating version such as latest or a branch name means an upstream change lands in your production pipeline with no review, no approval, and no record of what changed
  • Pinning to a commit SHA makes the resolved content reproducible and blocks a compromised or careless maintainer from swapping the code behind a moving reference; a release tag is the acceptable fallback where SHA pinning is impractical
  • Catalog badges are provenance signals with different meanings: GitLab-Maintained components are maintained by GitLab, GitLab Partner components are published by partners on an as-is basis without GitLab support, and self-managed instances can show a verified-creator badge for namespaces the administrator has verified — none of these is a security audit of the component’s behavior

Attack Prevented: Supply-chain injection through mutable component references, adoption of a look-alike or partner-published component with no support commitment, credential theft by a component that reads job variables it does not need

ClickOps Implementation

Step 1: Review Before Adoption

  1. Navigate to: Search or go to → Explore → CI/CD Catalog and open the component you intend to use
  2. Record its badge — GitLab-Maintained, GitLab Partner (as-is, unsupported by GitLab), or verified creator on self-managed — and treat a partner or unbadged component as requiring deeper review
  3. Open the component’s source project and read its templates: check whether it reads CI/CD variables it does not need, makes outbound network calls, or executes downloaded scripts
  4. Reject or fork any component whose behavior you cannot explain from its source

Step 2: Pin Every Reference

  1. Reference components by commit SHA wherever possible — a 40-character SHA is the only genuinely immutable reference
  2. Where a SHA is impractical, use a published release tag; never reference latest or a branch name in any project that builds or deploys production code
  3. Record approved components and their pinned versions in an internal allowlist so reviewers have something to compare a merge request against

Step 3: Control Upgrades

  1. Treat a version bump as a code change: review the upstream diff between the pinned SHA and the new one before merging
  2. Route component upgrades through the merge request approval policy from control 1.4 so a second person sees the change
  3. Re-review the component’s source at upgrade time, not only at first adoption

Code Implementation

The Code Pack is a read-only audit of the project’s CI configuration file and every include: local file it reaches, at the same ref: it passes commit-SHA references, warns on release tags, and fails on ~latest, partial versions, branch names, and references with no version. It cannot follow a project:, remote: or template: include, a wildcard or variable path, or a child pipeline’s trigger: include:, so it names each one and reports the run as unknown rather than clean.

Code Pack: API Script
hth-gitlab-2.06-audit-component-pinning.sh View source on GitHub ↗
PROJECT=$(gl_get "/projects/${PROJECT_ID}") || {
  fail "2.6 GET /projects/${PROJECT_ID} failed -- check PROJECT_ID and token"; summary; exit 2; }
REF="${REF:-$(printf '%s' "${PROJECT}" | jq -r '.default_branch // empty')}"
CONFIG_PATH=$(printf '%s' "${PROJECT}" | jq -r '.ci_config_path // empty')
CONFIG_PATH="${CONFIG_PATH:-.gitlab-ci.yml}"
case "${CONFIG_PATH}" in
  *@*|*://*) fail "2.6 CI configuration lives outside this project (${CONFIG_PATH}) -- audit that project instead"; summary; exit 2 ;;
esac
ENC_REF=$(jq -rn --arg r "${REF}" '$r | @uri')

read_raw() {  # <repository path> -> file content at REF
  local enc
  enc=$(jq -rn --arg p "$1" '$p | @uri')
  gl_get "/projects/${PROJECT_ID}/repository/files/${enc}/raw?ref=${ENC_REF}"
}

# One "<kind><TAB><value>" line per include entry in a CI file. Kinds: local,
# component, project, remote, template, string (a bare path or URL), flow (an
# inline [..] or {..} include), nested (an indented include:, e.g. trigger:include).
list_includes() {
  awk -v q="'" '
    function indent(s) { match(s, /^ */); return RLENGTH }
    function strip(v) {
      sub(/[ \t]+#.*$/, "", v); gsub(/^[ \t]+|[ \t]+$/, "", v)
      if (substr(v, 1, 1) == "\"" || substr(v, 1, 1) == q) v = substr(v, 2, length(v) - 2)
      return v
    }
    { sub(/\r$/, "") }
    /^[ \t]*(#|$)/ { next }
    /^include:/ {
      v = $0; sub(/^include:[ \t]*/, "", v); v = strip(v)
      if (v == "") { inblk = 1; dash = -1; keycol = -1; next }
      if (v ~ /^[[{]/) print "flow\t" v; else print "string\t" v
      inblk = 0; next
    }
    /^[^ \t-]/ { inblk = 0 }
    !inblk && /^[ \t]+include:/ { v = $0; gsub(/^[ \t]+/, "", v); print "nested\t" v; next }
    inblk {
      ind = indent($0); line = $0; sub(/^ */, "", line)
      if (dash < 0 && keycol < 0) {
        if (line ~ /^-/) { dash = ind; t = line; sub(/^- */, "", t); keycol = ind + length(line) - length(t) }
        else keycol = ind
      }
      entry = ""
      if (dash >= 0 && ind == dash && line ~ /^-/) { entry = line; sub(/^- */, "", entry) }
      else if (ind == keycol) entry = line
      else next
      if (entry ~ /^(local|project|remote|template|component):/) {
        k = entry; sub(/:.*/, "", k); v = entry; sub(/^[a-z]+:[ \t]*/, "", v); print k "\t" strip(v)
      } else if (dash >= 0 && ind == dash && entry != "" && entry !~ /^[A-Za-z_]+:/) {
        print "string\t" strip(entry)
      }
    }'
}

FINDINGS=0; PINNED=0; FILES=0; MAX_FILES=50
QUEUE="${CONFIG_PATH}"   # newline-separated local files still to read
SEEN=""                  # newline-separated local files already read
UNFOLLOWED=""            # newline-separated includes this pack could not follow

scan_components() {  # <file label> <content>
  local ref version
  while IFS= read -r ref; do
    case "${ref}" in */*) ;; *) continue ;; esac   # a component reference always has a path
    version="${ref##*@}"
    if [ "${version}" = "${ref}" ]; then
      fail "2.6 ${ref}: no version ($1)"; FINDINGS=$((FINDINGS + 1))
    elif [[ "${version}" =~ ^[0-9a-f]{40}$ ]]; then
      pass "2.6 ${ref}: pinned to a commit SHA ($1)"; PINNED=$((PINNED + 1))
    elif [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
      warn "2.6 ${ref}: release tag -- acceptable fallback, a commit SHA is immutable ($1)"; PINNED=$((PINNED + 1))
    elif [ "${version}" = "~latest" ]; then
      fail "2.6 ${ref}: ~latest floats to every new release ($1)"; FINDINGS=$((FINDINGS + 1))
    elif [[ "${version}" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then
      fail "2.6 ${ref}: partial version floats to the latest matching release ($1)"; FINDINGS=$((FINDINGS + 1))
    else
      fail "2.6 ${ref}: '${version}' is a branch or other moving reference ($1)"; FINDINGS=$((FINDINGS + 1))
    fi
  done < <(printf '%s\n' "$2" | grep -oE "component:[[:space:]]*[\"']?[^\"'[:space:]#]+" \
            | sed -E "s/^component:[[:space:]]*[\"']?//")
}

while [ -n "${QUEUE}" ]; do
  FILE=$(printf '%s\n' "${QUEUE}" | head -n 1)
  QUEUE=$(printf '%s\n' "${QUEUE}" | sed '1d')
  if printf '%s\n' "${SEEN}" | grep -qxF -- "${FILE}"; then continue; fi
  SEEN=$(printf '%s\n%s' "${SEEN}" "${FILE}")
  FILES=$((FILES + 1))
  if [ "${FILES}" -gt "${MAX_FILES}" ]; then
    UNFOLLOWED=$(printf '%s\n%s' "${UNFOLLOWED}" "more than ${MAX_FILES} local files; stopped at ${FILE}")
    break
  fi
  CONTENT=$(read_raw "${FILE}") || {
    fail "2.6 Could not read ${FILE} at ref '${REF}'"; summary; exit 2; }
  scan_components "${FILE}" "${CONTENT}"
  while IFS=$'\t' read -r kind value; do
    case "${kind}" in
      component) ;;   # audited from the file text by scan_components
      local|string)
        case "${value}" in
          http://*|https://*) UNFOLLOWED=$(printf '%s\n%s' "${UNFOLLOWED}" "remote: ${value} (in ${FILE})") ;;
          *'$'*|*'*'*|'')     UNFOLLOWED=$(printf '%s\n%s' "${UNFOLLOWED}" "local: '${value}' (in ${FILE}; variable, wildcard, or empty path)") ;;
          *)                  QUEUE=$(printf '%s\n%s' "${QUEUE}" "${value#/}") ;;
        esac ;;
      *) UNFOLLOWED=$(printf '%s\n%s' "${UNFOLLOWED}" "${kind}: ${value} (in ${FILE})") ;;
    esac
  done < <(printf '%s\n' "${CONTENT}" | list_includes)
  QUEUE=$(printf '%s\n' "${QUEUE}" | sed '/^$/d')
done

NOT_FOLLOWED=$(printf '%s\n' "${UNFOLLOWED}" | sed '/^$/d' | wc -l | tr -d ' ')
info "2.6 ${FILES} file(s) read at '${REF}': ${PINNED} pinned, ${FINDINGS} floating component reference(s), ${NOT_FOLLOWED} include(s) not followed"
if [ "${NOT_FOLLOWED}" -gt 0 ]; then
  while IFS= read -r item; do
    [ -n "${item}" ] && fail "2.6 Not followed -- components in it were NOT audited: ${item}"
  done < <(printf '%s\n' "${UNFOLLOWED}")
fi

Validation & Testing

  1. Use group-level code search for component include statements and confirm no result resolves to latest or a branch name
  2. Confirm every component reference in projects that deploy to production resolves to a commit SHA
  3. Pick one pinned component and verify the SHA in your configuration matches a commit that actually exists in the upstream source project
  4. Review the approved-component allowlist against what pipelines actually reference each quarter and reconcile the difference

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC8.1 Change authorization for third-party code
NIST 800-53 SR-3 Supply chain controls and processes
NIST 800-53 CM-7 Least functionality in pipeline configuration

3. Runner Security

3.1 Isolate CI/CD Runners

Profile Level: L1 (Crawl) NIST 800-53: SC-7

Description

Deploy isolated runners for different trust levels and environments.

Rationale

Why This Matters:

  • Runners execute arbitrary pipeline code, so a shared runner that touches production is a single point an attacker can use to pivot from any project to sensitive systems
  • Segmenting runners by trust level and environment ensures a compromised low-trust job cannot reach production networks or credentials
  • Ephemeral, single-use runner containers prevent one job from tampering with the environment of the next job on the same host

Attack Prevented: Runner-based lateral movement, cross-job contamination, production network pivot, persistent runner compromise

ClickOps Implementation

Step 1: Plan Runner Tiers

  1. shared-runners – general use, Docker executor, ephemeral containers
  2. group-runners – team-specific, isolated per business unit
  3. project-runners – sensitive projects, dedicated to single project
  4. production-runners – deployment only, network access to production, limited users

Step 2: Create Scoped Runners

  1. Instance runners (Self-Managed and Dedicated): Admin → CI/CD → Runners → Create instance runner
  2. Group runners: Group → Build → Runners → Create group runner
  3. Project runners: Project → Settings → CI/CD → Runners → Create project runner
  4. In the creation form, enter the job Tags the runner serves and leave Run untagged cleared, so only jobs that ask for this tier land on it
  5. Register the host with the runner authentication token the page shows (see the Code Pack)

Step 3: Keep Instance Runners Off Sensitive Projects

  1. Navigate to: Project → Settings → CI/CD → Runners
  2. Turn off Turn on instance runners for this project on projects that must run only on their own runners

Step 4: Restrict Sensitive Runners to Protected Refs

  1. Open the runner’s Edit page and select the Protected checkbox, so it runs jobs only on protected branches and tags

Code Implementation

The Code Pack uses the runner creation workflow: it creates a tagged, locked, protected-refs-only project runner through the API, then registers the host with the runner authentication token. Registration tokens are legacy and not recommended.

Code Pack: CLI Script
hth-gitlab-3.01-isolate-cicd-runners.sh View source on GitHub ↗
# 1. Create a project runner: tagged, not picking up untagged jobs, locked to
#    this project, and running only on protected branches and tags
#    (POST /user/runners, docs.gitlab.com/api/users).
RUNNER_AUTH_TOKEN=$(curl -sf --request POST \
  --header "PRIVATE-TOKEN: ${GITLAB_TOKEN}" \
  --data "runner_type=project_type" \
  --data "project_id=${PROJECT_ID}" \
  --data "description=isolated-security-sensitive" \
  --data "tag_list=isolated,security-sensitive" \
  --data "run_untagged=false" \
  --data "locked=true" \
  --data "access_level=ref_protected" \
  "${GITLAB_URL}/api/v4/user/runners" | jq -er '.token')

# 2. Register this host with the runner authentication token only
gitlab-runner register --non-interactive \
  --url "${GITLAB_URL}" \
  --token "${RUNNER_AUTH_TOKEN}" \
  --executor "docker" \
  --docker-image "alpine:3.24"
[[runners]]
  name = "secure-runner"
  executor = "docker"
  [runners.docker]
    image = "alpine:3.24"
    privileged = false  # Never enable unless absolutely required
    disable_entrypoint_overwrite = true
    volumes = ["/cache"]
    # Drop Linux capabilities from the job container
    cap_drop = ["ALL"]

3.2 Rotate Runner Tokens

Profile Level: L1 (Crawl) NIST 800-53: IA-5(1)

Description

Implement regular runner token rotation to limit exposure from compromised tokens.

Rationale

Why This Matters:

  • A runner registration or authentication token lets anyone register a runner that receives and executes pipeline jobs, including access to CI/CD secrets
  • Regular rotation ensures a leaked token has a short useful lifespan instead of granting indefinite access
  • Resetting tokens immediately on suspected exposure invalidates any rogue runners an attacker may have registered

Attack Prevented: Rogue runner registration, token theft, unauthorized job execution, secret harvesting

ClickOps Implementation

Step 1: Rotate Runner Authentication Tokens Automatically (Self-Managed and Dedicated)

  1. Navigate to: Admin → Settings → CI/CD → Continuous Integration and Deployment
  2. Set a Runners expiration interval and click Save changes
  3. Before the interval expires, each runner requests a new runner authentication token on its own

Step 2: Replace a Runner Whose Token Was Exposed

  1. Delete the runner: Admin → CI/CD → Runners for instance runners, Build → Runners for group runners, or Settings → CI/CD → Runners for project runners
  2. Create a new runner (control 3.1, Step 2) so it is issued a new runner authentication token, and register the host with it
  3. Optionally confirm through the Runners API that the old token was revoked

Legacy runner registration tokens, and their Reset registration token action, only matter while the legacy registration workflow is still enabled. Move off it rather than rotating it.

Code Implementation

The Code Pack rotates one runner’s authentication token in place with gitlab-runner reset-token, then checks that the runner still verifies. Use it for scheduled rotation; for an exposed token, follow Step 2.

Code Pack: CLI Script
hth-gitlab-3.02-rotate-runner-token.sh View source on GitHub ↗
# Rotate the runner's authentication token in place (config.toml is updated)
gitlab-runner reset-token --name "${RUNNER_NAME}"

# Confirm the runner still authenticates with the new token. `verify` exits 0
# even when the check fails, so test its output for "is alive". Capture a
# non-zero exit too, so the output is printed before the script stops.
VERIFY_RC=0
VERIFY_OUT=$(gitlab-runner verify --name "${RUNNER_NAME}" 2>&1) || VERIFY_RC=$?
printf '%s\n' "${VERIFY_OUT}"
case "${VERIFY_RC}:${VERIFY_OUT}" in
  0:*"is alive"*) echo "Runner ${RUNNER_NAME} rotated and verified" ;;
  *) echo "Runner ${RUNNER_NAME} did not verify after rotation (verify exit ${VERIFY_RC})" >&2; exit 1 ;;
esac

4. Repository Security

4.1 Enable Push Rules

Profile Level: L1 (Crawl) NIST 800-53: CM-3

Description

Configure push rules to prevent accidental secret commits and enforce commit hygiene.

Rationale

Why This Matters:

  • Secrets accidentally committed to a repository remain in Git history even after deletion and are frequently harvested by attackers scanning repos
  • Push rules that block secret files and verify author identity stop credential leaks and commit spoofing at the point of push
  • Combining push rules with secret detection in the pipeline provides defense in depth against hardcoded credentials reaching the repository

Attack Prevented: Secret leakage in commits, credential harvesting, commit author spoofing

ClickOps Implementation (Premium/Ultimate)

Push rules are a Premium and Ultimate feature, in both the UI and the API.

Step 1: Configure Project Push Rules

  1. Navigate to: Project → Settings → Repository → Push rules
  2. Enable:
    • Prevent pushing secret files: Enable
    • Reject unsigned commits: Enable (L2)
    • Reject unverified users: Enable (the committer email must match one of the user’s verified email addresses)

Step 2: Configure Secret Detection

See the Code Pack below for the .gitlab-ci.yml secret detection configuration.

Code Implementation

Code Pack: Config
hth-gitlab-4.01-secret-detection.yml View source on GitHub ↗
# .gitlab-ci.yml
secret_detection:
  stage: security
  variables:
    SECRET_DETECTION_HISTORIC_SCAN: "true"
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
Code Pack: API Script
hth-gitlab-4.01-enable-push-rules.sh View source on GitHub ↗
# Configure push rules: L1 enables prevent_secrets and deny_delete_tag;
# L2 additionally enables reject_unsigned_commits for commit signing enforcement.
info "4.1 Configuring push rules..."

PAYLOAD='{
  "prevent_secrets": true,
  "deny_delete_tag": true'

# L2: Add reject_unsigned_commits
if should_apply 2 2>/dev/null; then
  info "4.1 L2: Enabling reject_unsigned_commits (commit signing required)"
  PAYLOAD="${PAYLOAD}"',"reject_unsigned_commits": true'
fi

PAYLOAD="${PAYLOAD}"'}'

if [ -n "${EXISTING}" ] && [ "${EXISTING}" != "null" ]; then
  # Update existing push rules
  RESULT=$(gl_put "/projects/${PROJECT_ID}/push_rule" "${PAYLOAD}" 2>/dev/null) || {
    fail "4.1 Failed to update push rules"
    increment_failed
    summary
    exit 0
  }
else
  # Create new push rules
  RESULT=$(gl_post "/projects/${PROJECT_ID}/push_rule" "${PAYLOAD}" 2>/dev/null) || {
    fail "4.1 Failed to create push rules"
    increment_failed
    summary
    exit 0
  }
fi
Code Pack: Sigma Detection Rule
hth-gitlab-4.01-enable-push-rules.yml View source on GitHub ↗
detection:
    selection:
        entity_type: 'PushRule'
        action:
            - 'create'
            - 'update'
            - 'destroy'
    condition: selection
fields:
    - author_name
    - entity_path
    - target_details
    - ip_address
    - created_at

4.2 Enable Commit Signing

Profile Level: L2 (Walk) NIST 800-53: AU-10

Description

Require GPG or SSH signed commits to verify commit authorship.

Rationale

Why This Matters:

  • Git lets anyone set an arbitrary author name and email, so unsigned commits provide no real proof of who wrote the code
  • Requiring cryptographically signed commits verifies that changes come from a known, key-holding identity rather than an impersonator
  • Rejecting unsigned commits and unverified users blocks an attacker from forging history or attributing malicious code to a trusted developer

Attack Prevented: Commit spoofing, author impersonation, unauthorized code attribution, repository history forgery

ClickOps Implementation (Push Rules: Premium/Ultimate)

Step 1: Configure Signature Requirements

  1. Navigate to: Project → Settings → Repository → Push rules
  2. Enable: Reject unsigned commits
  3. Enable: Reject unverified users

Step 2: User Setup

  1. Navigate to: Avatar → Edit profile → Access → GPG keys
  2. Add GPG public key
  3. Configure git client (see CLI Code Pack below)

Code Implementation

Code Pack: CLI Script
hth-gitlab-4.02-commit-signing.sh View source on GitHub ↗
git config --global commit.gpgsign true
git config --global user.signingkey "${SIGNING_KEY_ID}"

4.3 Enable Secret Push Protection

Profile Level: L1 (Crawl)

Framework Control
CIS Controls 16.12
NIST 800-53 IA-5, SC-28

Description

Block pushes that contain detected secrets at the pre-receive hook, so credentials are rejected before they ever enter repository history. Generally available in GitLab 17.5 (Ultimate). Source: Secret push protection.

Rationale

Why This Matters:

  • Pipeline-based secret detection runs after the commit has been pushed, so by the time it reports a finding the credential is already in history, already replicated to anyone who fetched, and must be treated as compromised
  • Secret push protection evaluates the push at the pre-receive hook and rejects it outright, which means the credential never lands on the server and the developer fixes the commit locally instead of filing an incident
  • Purging a leaked secret from history is disruptive and frequently incomplete — forks, mirrors, clones, and cached views retain it — so prevention at push time is materially cheaper than remediation after the fact
  • The control complements rather than replaces the push rules in 4.1: push rules match file names and patterns you define, while secret push protection matches known credential formats maintained by GitLab

Attack Prevented: Credential leakage into Git history, harvesting of secrets from forks and mirrors after a rushed deletion, costly and error-prone history rewrites following a leak

ClickOps Implementation

Step 1 (Self-Managed): Allow the Feature Instance-Wide

  1. Navigate to: Admin → Settings → Security and compliance
  2. Enable: Allow secret push protection
  3. Save changes — this makes the feature available to projects but does not turn it on for them

Step 2: Enable Per Project

  1. Navigate to: Project → Secure → Security configuration
  2. Enable: Secret push protection
  3. Repeat for every project handling production credentials; start with the repositories whose history a leak would be most expensive to clean

Step 3: Plan the Rollout

  1. Notify developers before enabling — the first rejected push is otherwise reported as a broken remote
  2. Document the remediation path: remove the secret from the commit, rotate the exposed credential regardless, and re-push
  3. Document the skip mechanism (secret_push_protection.skip_all as a push option) and treat every use of it as an event to review, not a routine workaround

Code Implementation

The Code Pack is a read-only audit of the project security settings API (Ultimate): for one project, or every active project in a group and its subgroups, it reports which have secret push protection off. A project whose setting cannot be read is reported as unknown, never as enabled.

Code Pack: API Script
hth-gitlab-4.03-audit-secret-push-protection.sh View source on GitHub ↗
# Collect the projects to check: one project, or every active project in the
# group and its subgroups (all pages).
if [ -n "${GROUP_ID}" ]; then
  PROJECT_IDS=""
  PAGE=1
  while true; do
    RESP=$(gl_get "/groups/${GROUP_ID}/projects?include_subgroups=true&archived=false&per_page=100&page=${PAGE}") || {
      fail "4.3 Could not list projects in group ${GROUP_ID} (page ${PAGE})"; summary; exit 2; }
    COUNT=$(printf '%s' "${RESP}" | jq 'length') || { fail "4.3 Unparseable project list"; summary; exit 2; }
    PROJECT_IDS="${PROJECT_IDS} $(printf '%s' "${RESP}" | jq -r '.[].id' | tr '\n' ' ')"
    [ "${COUNT}" -lt 100 ] && break
    PAGE=$((PAGE + 1))
  done
else
  PROJECT_IDS="${PROJECT_ID}"
fi

ENABLED=0; DISABLED=0; UNKNOWN=0
for PID in ${PROJECT_IDS}; do
  if SETTINGS=$(gl_get "/projects/${PID}/security_settings"); then
    if [ "$(printf '%s' "${SETTINGS}" | jq -r '.secret_push_protection_enabled')" = "true" ]; then
      ENABLED=$((ENABLED + 1))
    else
      DISABLED=$((DISABLED + 1))
      fail "4.3 Project ${PID}: secret push protection is OFF"
    fi
  else
    UNKNOWN=$((UNKNOWN + 1))
    warn "4.3 Project ${PID}: could not read security settings (HTTP status above; a 403 means Ultimate and the Developer role are required)"
  fi
done
info "4.3 Secret push protection: ${ENABLED} enabled, ${DISABLED} disabled, ${UNKNOWN} unknown"

Validation & Testing

  1. In a scratch project with the feature enabled, commit a test value in a recognized credential format (for example a glpat- prefixed token) and push; confirm the push is rejected and the message identifies the detected secret
  2. Confirm the remote history contains no trace of the rejected commit
  3. Push a benign change to the same project and confirm normal pushes are unaffected
  4. Review use of the skip push option periodically and confirm each instance had a documented justification

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.1 Protection of credentials and logical access
NIST 800-53 IA-5 Authenticator management
NIST 800-53 SC-28 Protection of information at rest

5. Secrets Management

5.1 Use External Secrets Management

Profile Level: L2 (Walk) NIST 800-53: SC-28

Description

Integrate with external secrets managers instead of storing secrets in GitLab.

Rationale

Why This Matters:

  • Storing secrets directly in GitLab couples their security to GitLab’s access model and risks exposure through logs, exports, or a platform compromise
  • An external secrets manager like Vault issues short-lived, dynamically generated credentials that are far harder to steal and reuse
  • Centralizing secrets externally provides a single audited place to rotate, revoke, and govern access independent of the CI/CD platform

Attack Prevented: Static secret theft, credential reuse, broad exposure from a platform compromise, unaudited secret access

ClickOps Implementation (Premium/Ultimate)

Step 1: Configure Vault

  1. On the Vault server, enable a JWT authentication method bound to your GitLab instance, and create roles restricted to the projects (and protected refs) that may read each secret path

Step 2: Point GitLab at Vault

  1. Navigate to: Project → Settings → CI/CD → Variables (or the group’s)
  2. Add VAULT_SERVER_URL with your Vault server’s URL
  3. Optionally add VAULT_AUTH_ROLE, VAULT_AUTH_PATH, and VAULT_NAMESPACE

Step 3: Request Secrets in the Pipeline

  1. In .gitlab-ci.yml, declare an ID token with id_tokens and read each secret with secrets: — see the Code Pack below
  2. GitLab authenticates to Vault with that ID token; no Vault token is stored in GitLab

Code Implementation

Code Pack: Config
hth-gitlab-5.01-vault-integration.yml View source on GitHub ↗
# .gitlab-ci.yml
deploy:
  stage: deploy
  id_tokens:
    VAULT_ID_TOKEN:
      aud: https://vault.example.com
  secrets:
    DATABASE_PASSWORD:
      vault: production/db/password@secret
      token: $VAULT_ID_TOKEN
    API_KEY:
      vault: production/api/key@secret
      token: $VAULT_ID_TOKEN
  script:
    - echo "Using secrets from Vault"
    - ./deploy.sh

6. Monitoring & Detection

6.1 Enable Audit Events

Profile Level: L1 (Crawl) NIST 800-53: AU-2, AU-3

Description

Configure comprehensive audit logging for GitLab operations.

Rationale

Why This Matters:

  • Without comprehensive audit logging, malicious actions such as repository deletion, permission changes, or runner registration go undetected
  • Streaming audit events to a SIEM preserves a tamper-resistant record off-platform, surviving attempts to cover tracks inside GitLab
  • Alerting on high-risk events enables fast detection and response to account takeover and privilege abuse before damage spreads

Attack Prevented: Undetected privilege abuse, log tampering, delayed breach detection, repository destruction

ClickOps Implementation

Step 1: Review Group Audit Events (Premium/Ultimate)

  1. Navigate to: Group → Secure → Audit events
  2. You need the Owner role on the group to see every user’s events

Step 2: Stream Audit Events to Your SIEM (Ultimate, top-level group)

  1. Navigate to: Group → Secure → Audit events and select the Streams tab
  2. Select Add streaming destination and choose HTTP endpoint, Google Cloud Logging, or AWS S3
  3. Leave event type filters empty so the destination receives every audit event

Step 3: Alert on Critical Events

  • Repository deletion
  • Protected branch modification
  • Runner registration
  • Admin privilege changes

Code Implementation

The API Code Pack reads the group’s recent audit events and, at L2, lists its audit event streaming destinations (HTTP, Google Cloud Logging, and AWS S3). The Sigma rule alerts when an audit streaming destination or one of its headers is removed.

Code Pack: API Script
hth-gitlab-6.01-enable-audit-events.sh View source on GitHub ↗
# Query group-level audit events and verify audit logging is active.
# GitLab Premium/Ultimate exposes audit events via the REST API.
info "6.1 Retrieving recent audit events..."
AUDIT_EVENTS=$(gl_get "/groups/${GROUP_ID}/audit_events?per_page=20") || {
  fail "6.1 Failed to retrieve audit events (HTTP status above; a 403 means GitLab Premium/Ultimate and the group Owner role are required)"
  increment_failed
  summary
  exit 2
}

EVENT_COUNT=$(printf '%s' "${AUDIT_EVENTS}" | jq 'length')
info "6.1 Retrieved ${EVENT_COUNT} recent audit event(s)"

if [ "${EVENT_COUNT}" -gt 0 ]; then
  # Show recent security-relevant events
  printf '%s' "${AUDIT_EVENTS}" | jq -r '.[] | "  - [\(.created_at)] \(.author.name // .author_id): \(.entity_type)/\(.details.action // .details.custom_message // "event")"'

  # Check for key security event types
  info "6.1 Checking for security-relevant event categories..."
  AUTH_EVENTS=$(printf '%s' "${AUDIT_EVENTS}" | jq '[.[] | select(.details.action // "" | test("auth|login|session"; "i"))] | length')
  PERM_EVENTS=$(printf '%s' "${AUDIT_EVENTS}" | jq '[.[] | select(.details.action // "" | test("permission|role|access"; "i"))] | length')
  REPO_EVENTS=$(printf '%s' "${AUDIT_EVENTS}" | jq '[.[] | select(.details.action // "" | test("push|merge|branch|tag"; "i"))] | length')

  info "6.1 Event breakdown: auth=${AUTH_EVENTS}, permissions=${PERM_EVENTS}, repository=${REPO_EVENTS}"
fi

# Check for audit event streaming destinations (L2; Ultimate, top-level group Owner).
# A failed query is reported as unknown -- never as "no destinations configured".
if should_apply 2 2>/dev/null; then
  info "6.1 L2: Checking external audit event streaming destinations..."
  GROUP_PATH=$(gl_get "/groups/${GROUP_ID}" | jq -r '.full_path // empty') || GROUP_PATH=""
  # externalAuditEventStreamingDestinations covers every category (HTTP, Google
  # Cloud Logging, Amazon S3); the three per-type fields are deprecated in
  # GitLab 18.10 but still hold destinations created the older way.
  QUERY='query($fullPath: ID!) { group(fullPath: $fullPath) {
    externalAuditEventStreamingDestinations { nodes { name category active } }
    externalAuditEventDestinations { nodes { name active } }
    googleCloudLoggingConfigurations { nodes { name active } }
    amazonS3Configurations { nodes { name active } } } }'
  BODY=$(jq -n --arg q "${QUERY}" --arg p "${GROUP_PATH}" '{query: $q, variables: {fullPath: $p}}')
  STREAMS=""
  if [ -n "${GROUP_PATH}" ]; then
    STREAMS=$(gl_graphql "${BODY}") || STREAMS=""
  fi
  if [ -z "${STREAMS}" ] || [ "$(printf '%s' "${STREAMS}" | jq '((.errors // []) | length) == 0 and .data.group != null')" != "true" ]; then
    fail "6.1 L2: Could not read streaming destinations (an HTTP status above is the cause; with none, the query returned no group -- Ultimate and the Owner role on a top-level group are required)"
    STATE_UNKNOWN=1
  else
    # A destination can appear under both the new and a legacy field, so list
    # them rather than sum them.
    DESTS=$(printf '%s' "${STREAMS}" | jq -r '.data.group as $g |
      [ ($g.externalAuditEventStreamingDestinations.nodes // [])[] | "\(.category): \(.name) (active: \(.active))" ] +
      [ ($g.externalAuditEventDestinations.nodes // [])[] | "http: \(.name) (active: \(.active))" ] +
      [ ($g.googleCloudLoggingConfigurations.nodes // [])[] | "gcpLogging: \(.name) (active: \(.active))" ] +
      [ ($g.amazonS3Configurations.nodes // [])[] | "amazonS3: \(.name) (active: \(.active))" ] | unique | .[]')
    if [ -n "${DESTS}" ]; then
      pass "6.1 Audit event streaming destination(s) configured:"
      printf '%s\n' "${DESTS}" | sed 's/^/  - /'
    else
      warn "6.1 No external audit event streaming destinations configured"
      warn "6.1 Configure via Secure > Audit events > Streams to forward to your SIEM"
    fi
  fi
fi
Code Pack: Sigma Detection Rule
hth-gitlab-6.01-enable-audit-events.yml View source on GitHub ↗
detection:
    selection_destination:
        entity_type: 'ExternalAuditEventDestination'
        action: 'destroy'
    selection_header:
        entity_type: 'AuditEventsStreamingHeader'
        action: 'destroy'
    condition: selection_destination or selection_header
fields:
    - author_name
    - entity_path
    - target_details
    - ip_address
    - created_at

7. AI Assistant Governance (GitLab Duo)

7.1 Govern GitLab Duo Availability

Profile Level: L1 (Crawl)

Framework Control
CIS Controls 4.8
NIST 800-53 CM-7, SA-9

Description

Make an explicit decision about where GitLab Duo may operate, using the instance-level availability setting and its group and project cascade, instead of inheriting the on-by-default posture. Source: Turn GitLab Duo on or off.

Rationale

Why This Matters:

  • GitLab Duo is on by default, so an organization that has never discussed it has already granted an AI assistant access to source code, issues, and merge request content across the instance — the absence of a decision is itself a decision
  • The availability setting has three states (always on, off by default, always off) and cascades down the group and project hierarchy, so setting the posture once at the top establishes a default for every future project rather than requiring per-project cleanup forever
  • Duo Core is controlled by its own checkbox in the same configuration, so turning Duo “off” without checking that box can leave functionality enabled that reviewers assumed was disabled
  • Experiment and beta features operate under different terms than generally available features; leaving them off until legal and security have reviewed them prevents proprietary code from flowing through paths nobody evaluated

Attack Prevented: Unreviewed exposure of proprietary source code to AI processing, shadow AI adoption inside individual projects, silent expansion of data handling as new experimental features ship

ClickOps Implementation

Step 1: Set the Instance Posture

  1. Navigate to: Admin → GitLab Duo → Change configuration (Self-Managed). On GitLab.com, where there is no Admin area, use Top-level group → Settings → GitLab Duo → Change configuration
  2. Set availability to the state your organization has actually decided on: Always on, Off by default, or Always off
  3. Prefer Off by default where you intend to allow Duo only in specific groups — it makes enablement an explicit, attributable act
  4. Review the Duo Core checkbox in the same configuration and set it deliberately rather than leaving it at its shipped value

Step 2: Control Experimental Features

  1. In the same configuration, locate the experiment and beta features toggle
  2. Leave it disabled until the data handling terms for those features have been reviewed
  3. Re-review after each GitLab upgrade, since the set of features behind that toggle changes between releases

Step 3: Cascade to Groups and Projects

  1. Navigate to: Group → Settings → General → GitLab Duo features
  2. Enable Duo only for groups whose repositories you are comfortable exposing to AI processing
  3. Confirm the setting at project level for any project that handles regulated or customer-sensitive code
  4. Keep public and fork-accepting projects out of scope by default — see control 7.2 for why

Code Implementation

The Code Pack is a read-only audit of a group’s GitLab Duo availability and experiment-features setting through the Groups API, which returns them to Premium and Ultimate groups. It warns when Duo is on by default, fails when experiment and beta features are on, and reports the result as unknown, never as off, when the API does not return the experiment setting.

Code Pack: API Script
hth-gitlab-7.01-audit-duo-availability.sh View source on GitHub ↗
GROUP=$(gl_get "/groups/${GROUP_ID}") || {
  fail "7.1 GET /groups/${GROUP_ID} failed -- check GROUP_ID and token"; summary; exit 2; }
AVAILABILITY=$(printf '%s' "${GROUP}" | jq -r '.duo_availability // empty')
# (Not `// empty`: jq's // also discards a literal false.)
EXPERIMENTS=$(printf '%s' "${GROUP}" | jq -r '.experiment_features_enabled | if . == null then empty else tostring end')

if [ -z "${AVAILABILITY}" ]; then
  fail "7.1 duo_availability was not returned -- requires Premium/Ultimate and the Owner role"
  summary; exit 2
fi

FINDINGS=0
case "${AVAILABILITY}" in
  never_on)    pass "7.1 GitLab Duo availability: Always off" ;;
  default_off) pass "7.1 GitLab Duo availability: Off by default (enablement is an explicit act)" ;;
  default_on)  warn "7.1 GitLab Duo availability: On by default -- every new project gets Duo; confirm this was a decision, or set Off by default" ;;
  *)           fail "7.1 Unrecognized duo_availability value '${AVAILABILITY}'"; FINDINGS=$((FINDINGS + 1)) ;;
esac

STATE_UNKNOWN=0
if [ "${EXPERIMENTS}" = "true" ]; then
  fail "7.1 Experiment and beta features are ON -- leave them off until their data handling terms are reviewed"
  FINDINGS=$((FINDINGS + 1))
elif [ "${EXPERIMENTS}" = "false" ]; then
  pass "7.1 Experiment and beta features are off"
else
  # Absent is unknown, never "off": a missing field could be hiding a finding.
  fail "7.1 experiment_features_enabled was not returned -- experiment and beta feature posture unknown"
  STATE_UNKNOWN=1
fi

Validation & Testing

  1. Sign in as a standard user in a project where Duo should be unavailable and confirm Duo Chat and code suggestions do not appear
  2. Sign in to a project where Duo is intentionally enabled and confirm it works, proving the cascade is scoped rather than globally broken
  3. Review group-level GitLab Duo settings across the instance and list any group that has overridden the instance default
  4. After each upgrade, revisit Admin → GitLab Duo → Change configuration and confirm availability, Duo Core, and the experiment toggle still match the documented decision

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.1 Authorized access to information assets
NIST 800-53 CM-7 Least functionality
NIST 800-53 SA-9 External information system services

7.2 Treat Repository Content as Untrusted GitLab Duo Input

Profile Level: L2 (Walk)

Framework Control
CIS Controls 16.1
NIST 800-53 SI-10, SA-9

Description

Assume anything Duo reads from a repository may contain instructions written by an attacker, and constrain Duo’s scope and output handling accordingly. Source: Remote prompt injection in GitLab Duo.

Rationale

Why This Matters:

  • Duo composes answers from merge request descriptions, comments, commit messages, and source files — every one of which is attacker-controllable in any project that accepts outside contributions, so untrusted text reaches the assistant by design
  • Legit Security demonstrated this concretely: instructions hidden with Base16 encoding and white-text KaTeX rendering were followed by Duo, and because responses streamed raw HTML, injected image tags caused the contents of private merge request diffs to be sent to an attacker-controlled server
  • GitLab remediated the exfiltration path by blocking rendering of unsafe external HTML in Duo responses, but the underlying problem — that untrusted repository text can become instructions — is a property of how assistants read context, not a single bug that a patch retires
  • Because the residual risk sits in scope and review rather than in the product’s code, the durable controls are limiting which projects Duo can read and keeping a human between Duo’s output and anything that merges

Attack Prevented: Remote prompt injection through merge request and commit content, exfiltration of private source code via markup rendered in assistant responses, attacker-steered code suggestions accepted without review

ClickOps Implementation

Step 1: Scope Duo to Trusted Projects

  1. Using the group and project settings from control 7.1, disable Duo in projects that accept merge requests from outside your organization
  2. Prioritize public projects, community-contribution repositories, and any project where fork pipelines run
  3. Document which groups are in scope so the decision survives staff turnover

Step 2: Stay Patched

  1. Confirm your instance is running a GitLab version that includes the fix blocking unsafe external HTML rendering in Duo responses
  2. On self-managed, treat Duo-related security fixes as a reason to upgrade promptly — they ship with GitLab releases and do not reach you until you upgrade
  3. Track GitLab release announcements for further AI-related security changes

Step 3: Keep a Human in the Loop

  1. Never let Duo output flow into a merge without human approval — the merge request approval policy from control 1.4 is what enforces this structurally
  2. Do not grant automation the ability to act on Duo output without review
  3. Treat Duo summaries of a merge request as a convenience, not as evidence that the merge request was reviewed

Step 4: Train Reviewers

  1. Teach reviewers that hidden content is a real technique: invisible or same-colour text, unusual encodings, and rendering tricks in descriptions and comments
  2. Instruct reviewers to be suspicious when a Duo answer contains links or images they did not expect, and to report rather than click
  3. Add “check for hidden instructions in contributed text” to the review checklist for projects that accept outside contributions

Code Implementation

The Code Pack is a read-only audit: it fails when Duo is on by default for a group that holds public projects, and lists those projects so you can confirm Duo is off in each one’s Settings → General → GitLab Duo (the REST API does not expose that project toggle). It also reports the group’s prompt injection protection level where GitLab returns it (GitLab 18.8 and later).

Code Pack: API Script
hth-gitlab-7.02-audit-duo-untrusted-input.sh View source on GitHub ↗
GROUP=$(gl_get "/groups/${GROUP_ID}") || {
  fail "7.2 GET /groups/${GROUP_ID} failed -- check GROUP_ID and token"; summary; exit 2; }
AVAILABILITY=$(printf '%s' "${GROUP}" | jq -r '.duo_availability // empty')
[ -n "${AVAILABILITY}" ] || {
  fail "7.2 duo_availability was not returned -- requires Premium/Ultimate and the Owner role"; summary; exit 2; }

# Public projects in the group and its subgroups (all pages).
PUBLIC="[]"
PAGE=1
while true; do
  RESP=$(gl_get "/groups/${GROUP_ID}/projects?include_subgroups=true&visibility=public&archived=false&per_page=100&page=${PAGE}") || {
    fail "7.2 Could not list public projects (page ${PAGE})"; summary; exit 2; }
  COUNT=$(printf '%s' "${RESP}" | jq 'length') || { fail "7.2 Unparseable project list"; summary; exit 2; }
  PUBLIC=$(printf '%s %s' "${PUBLIC}" "${RESP}" | jq -s 'add')
  [ "${COUNT}" -lt 100 ] && break
  PAGE=$((PAGE + 1))
done
PUBLIC_COUNT=$(printf '%s' "${PUBLIC}" | jq 'length')

FINDINGS=0
if [ "${AVAILABILITY}" = "never_on" ]; then
  pass "7.2 Duo is Always off for this group -- no project content reaches it"
elif [ "${PUBLIC_COUNT}" -eq 0 ]; then
  pass "7.2 No public projects in this group"
else
  if [ "${AVAILABILITY}" = "default_on" ]; then
    fail "7.2 Duo is On by default and ${PUBLIC_COUNT} public project(s) inherit it"
    FINDINGS=$((FINDINGS + 1))
  fi
  warn "7.2 Confirm GitLab Duo is off in Settings > General > GitLab Duo for each public project:"
  printf '%s' "${PUBLIC}" | jq -r '.[] | "  - \(.path_with_namespace)"'
fi

LEVEL=$(printf '%s' "${GROUP}" | jq -r '.ai_settings.prompt_injection_protection_level // empty')
case "${LEVEL}" in
  interrupt) pass "7.2 Prompt injection protection: interrupt" ;;
  log_only)  warn "7.2 Prompt injection protection: log_only (detections are logged, not stopped)" ;;
  no_checks) warn "7.2 Prompt injection protection: no_checks" ;;
  *)         info "7.2 Prompt injection protection level not returned (Duo Agent Platform not available to this group)" ;;
esac

Validation & Testing

  1. In a scratch project, place text containing hidden instructions in a merge request description, ask Duo to summarize the merge request, and confirm the response neither follows the instructions nor emits external image or link markup
  2. Confirm the instance version in Admin → Overview includes the fix for unsafe external HTML rendering
  3. Confirm Duo is unavailable in at least one representative fork-accepting project, matching the scoping decision from Step 1
  4. Sample recent merges in Duo-enabled projects and confirm each carried a human approval, not an automated one

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.8 Prevention of unauthorized or malicious software behavior
NIST 800-53 SI-10 Information input validation
NIST 800-53 SA-9 External information system services

8. Compliance Quick Reference

SOC 2 Mapping

Control ID GitLab Control Guide Section
CC6.1 SSO enforcement 1.1
CC6.1 Secret push protection 4.3
CC6.2 Project permissions 1.2
CC6.3 Fine-grained job token permissions 2.4
CC6.8 Duo prompt injection controls 7.2
CC7.1 Pipeline execution policies 2.5
CC7.2 Audit events 6.1
CC8.1 Protected branches 1.2
CC8.1 Merge request approval policies 1.4
CC8.1 CI/CD catalog component pinning 2.6

NIST 800-53 Mapping

Control GitLab Control Guide Section
IA-2(1) SSO with MFA 1.1
AC-6 Role-based access 1.2
AC-6 Fine-grained job token permissions 2.4
CM-3 Push rules 4.1
CM-3 Merge request approval policies 1.4
CM-7 Pipeline execution policies 2.5
CM-7 GitLab Duo availability 7.1
SR-3 CI/CD catalog component pinning 2.6
SI-10 Duo untrusted input handling 7.2
IA-5 Secret push protection 4.3
SC-28 CI/CD variable protection 2.1

Appendix A: Edition Compatibility

Control Free Premium Ultimate
SAML SSO ❌ ✅ ✅
Push Rules ❌ ✅ ✅
Audit Events ❌ ✅ ✅
SAST/DAST ❌ ❌ ✅
Compliance Dashboard ❌ ❌ ✅
Fine-grained Job Token Permissions ✅ ✅ ✅
Secret Push Protection ❌ ❌ ✅
Merge Request Approval Policies ❌ ❌ ✅
Pipeline Execution Policies ❌ ❌ ✅

Appendix B: References

Official GitLab Documentation:

API & Developer Tools:

Compliance Frameworks:

Security Incidents:

  • CVE-2023-7028 (Jan 2024): Critical account takeover vulnerability (CVSS 10.0) via password reset emails to unverified addresses; actively exploited in the wild. Patched in GitLab 16.7.2+.
  • Red Hat Consulting GitLab Instance Breach (Sep 2025): Attacker accessed Red Hat’s self-managed GitLab CE instance, exposing consulting data for organizations such as Bank of America, T-Mobile, and U.S. government agencies. GitLab confirmed no breach of its managed SaaS infrastructure.

Community Resources:


Changelog

Date Version Maturity Changes Author
2026-09-25 0.3.0 ai-drafted validate-hth-guide run (offline fix loop; console signed out, 0 surfaces exercised live, maturity unchanged): corrected ClickOps against current GitLab docs in 1.1 (SAML labels, GitLab.com vs Self-Managed password sign-in), 1.2 (current approval setting names), 1.3 (365-day default ceiling, group token-creation restriction, GitLab.com enterprise-user token switch), 1.4 and 2.5 (a policy takes effect only once the merge request from Configure with a merge request is merged), 2.2 (replaced two settings that do not exist with job token permissions, merge checks, the pipeline-variable role and fork-pipeline guidance), 2.4 (CI/CD job token allowlist), 2.6 (Explore → CI/CD Catalog path), 3.2 (runner authentication token rotation), 4.1/4.2 (push rules are Premium/Ultimate; Appendix A; current GPG keys path), 5.1 (Vault via ID tokens and CI/CD variables, not Secure Files), 6.1 (Secure → Audit events, Streams tab) and 7.1 (GitLab.com top-level group path); added ClickOps to 2.3 and 3.1; added read-only API audit packs for 1.2, 2.2, 2.4, 2.6, 4.3, 7.1 and 7.2 and policy-file packs for 1.4 and 2.5; moved 3.1/3.2 packs to runner authentication tokens; fixed the 1.1 pack (config emitter, dropped an obsolete sign-in key), 2.1 (inverted raw check), 3.1 (unknown config.toml key), 5.1 (missing id_tokens) and 6.1 (missed AWS S3 streaming destinations); the 2.6 pack now follows include: local files and reports any include it cannot follow as unknown, and the 7.1 pack reports a missing experiment setting as unknown, with the 2.6 and 7.1 descriptions updated to match; the API packs now name the HTTP status behind a failed call (a 401 as an invalid or expired token rather than a role or tier gate) and treat a redirect as a failed call, not as content; removed a 2.2 reference to a Terraform pack that does not exist and 2.3/4.1 references to a CLI pack that does not exist; the 1.3 description now says that on GitLab.com the pack sees only the calling user’s tokens; the 3.2 pack prints the runner’s verify output when verify fails; fixed the CIS benchmark URL Claude Code (Opus 5.5)
2026-08-08 0.2.1 ai-drafted Cheat-sheet cell repair: added missing Attack Prevented line(s) to §1.1 (no content-facts changed) Claude Code (Fable 5)
2026-08-03 0.2.0 ai-drafted Add fine-grained job token permissions (2.4), pipeline execution policies (2.5), CI/CD catalog component trust (2.6), merge request approval policies (1.4), secret push protection (4.3), and new AI Assistant Governance section (7.1 Duo availability, 7.2 Duo prompt injection); correct 1.3 token expiry to mandatory-expiry model (365-day default, 400-day ceiling); renumber Compliance Quick Reference to 8 Claude Code (Sonnet 5)
2026-06-29 0.1.1 ai-drafted Add cheat-sheet Description and Rationale for all controls Claude Code (Opus 4.8)
2026-02-19 0.1.2 ai-drafted Migrate all remaining inline code to Code Packs (2.1, 2.3, 3.1, 4.1, 4.2, 6.1); zero inline blocks Claude Code (Opus 4.6)
2026-02-19 0.1.1 ai-drafted Migrate inline code to CLI Code Packs (1.1, 3.1, 3.2, 5.1) Claude Code (Opus 4.6)
2025-12-14 0.1.0 ai-drafted Initial GitLab hardening guide Claude Code (Opus 4.5)

Contributing

Found an issue or want to improve this guide?