GitLab Hardening Guide
DevOps platform security for CI/CD pipelines, repository access, and runners
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
- Authentication & Access Controls
- CI/CD Pipeline Security
- Runner Security
- Repository Security
- Secrets Management
- Monitoring & Detection
- AI Assistant Governance (GitLab Duo)
- 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 (GitLab.com Premium/Ultimate)
Step 1: Configure SAML SSO
- Navigate to: Group → Settings → SAML SSO
- Configure:
- Identity provider SSO URL: Your IdP endpoint
- Certificate fingerprint: From IdP
- Enforce SSO: Enable
- Click Save changes
Step 2: Enforce Group-Managed Accounts
- Navigate to: Group → Settings → SAML SSO
- Enable: Enforce SSO-only authentication for web activity
- Enable: Enforce SSO-only authentication for Git and Dependency Proxy activity
Step 3: Disable Password Authentication
- Navigate to: Admin → Settings → General → Sign-in restrictions
- Disable: Password authentication enabled for web interface
- Disable: Password authentication enabled for Git over HTTP(S)
Code Implementation
Code Pack: Config
# /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.company.com/users/auth/saml/callback',
idp_cert_fingerprint: 'XX:XX:XX...',
idp_sso_target_url: 'https://idp.company.com/saml/sso',
issuer: 'https://gitlab.company.com',
name_identifier_format: 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent'
}
}
]
# Disable password authentication
gitlab_rails['gitlab_signin_enabled'] = false
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
- Navigate to: Project → Settings → Repository → Protected branches
- Protect
mainandrelease/*:- Allowed to merge: Maintainers
- Allowed to push: No one (force MR workflow)
- Require approval from code owners: Enable
Step 3: Enable Required Approvals
- Navigate to: Project → Settings → Merge requests
- Configure:
- Approvals required: 2 (minimum)
- Prevent approval by author: Enable
- Prevent editing approval rules: Enable
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 the platform ceiling is 400 days (GitLab 17.6 and later). 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 400-day ceiling, not as a platform default.
- Navigate to: Admin → Settings → General → Account and limit
- Configure:
- Maximum allowable lifetime for access tokens: 90 days (recommended organizational policy; the platform hard ceiling is 400 days)
- Limit project access token creation: Enable
- 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.
Step 2: Disable API Scope for Non-Essential Tokens
- Audit tokens with
apiscope - Replace with minimal scopes (read_repository, write_repository)
Source: Personal access tokens
Code Pack: API Script
# List all active personal access tokens and flag risky configurations
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}" 2>/dev/null) || break
COUNT=$(echo "${RESPONSE}" | jq 'length' 2>/dev/null || echo "0")
[ "${COUNT}" -eq 0 ] && break
ALL_PATS=$(echo "${ALL_PATS} ${RESPONSE}" | jq -s 'add')
PAGE=$((PAGE + 1))
done
TOTAL=$(echo "${ALL_PATS}" | jq 'length' 2>/dev/null || echo "0")
info "1.3 Found ${TOTAL} active personal access token(s)"
# Flag tokens with overly broad 'api' scope
API_SCOPE_PATS=$(echo "${ALL_PATS}" | jq '[.[] | select(.scopes | index("api"))]' 2>/dev/null || echo "[]")
API_SCOPE_COUNT=$(echo "${API_SCOPE_PATS}" | jq 'length' 2>/dev/null || echo "0")
if [ "${API_SCOPE_COUNT}" -gt 0 ]; then
warn "1.3 Found ${API_SCOPE_COUNT} token(s) with full 'api' scope (overly permissive)"
echo "${API_SCOPE_PATS}" | jq -r '.[] | " - \(.name // "unnamed") (user: \(.user_id // "unknown"), created: \(.created_at // "unknown"))"' 2>/dev/null || true
fi
# Flag tokens with no expiration date
NO_EXPIRY_PATS=$(echo "${ALL_PATS}" | jq '[.[] | select(.expires_at == null)]' 2>/dev/null || echo "[]")
NO_EXPIRY_COUNT=$(echo "${NO_EXPIRY_PATS}" | jq 'length' 2>/dev/null || echo "0")
if [ "${NO_EXPIRY_COUNT}" -gt 0 ]; then
warn "1.3 Found ${NO_EXPIRY_COUNT} token(s) with no expiration date"
echo "${NO_EXPIRY_PATS}" | jq -r '.[] | " - \(.name // "unnamed") (user: \(.user_id // "unknown"), scopes: \(.scopes | join(", ")))"' 2>/dev/null || true
fi
# Flag tokens with write_repository scope (supply chain risk)
WRITE_REPO_PATS=$(echo "${ALL_PATS}" | jq '[.[] | select(.scopes | index("write_repository"))]' 2>/dev/null || echo "[]")
WRITE_REPO_COUNT=$(echo "${WRITE_REPO_PATS}" | jq 'length' 2>/dev/null || echo "0")
if [ "${WRITE_REPO_COUNT}" -gt 0 ]; then
warn "1.3 Found ${WRITE_REPO_COUNT} token(s) with 'write_repository' scope"
echo "${WRITE_REPO_PATS}" | jq -r '.[] | " - \(.name // "unnamed") (user: \(.user_id // "unknown"), expires: \(.expires_at // "never"))"' 2>/dev/null || true
fi
Code Pack: Sigma Detection Rule
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_requestrule 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
- Navigate to: Group → Secure → Policies
- Click Edit policy project and create or select a dedicated security policy project
- Restrict membership on that project to the security team — its members control enforcement for every project in the group
- Confirm only Owners hold the ability to change the linked policy project
Step 2: Create the Merge Request Approval Policy
- Navigate to: Group → Secure → Policies → New policy → Merge request approval policy
- Set the scope to all projects in the group (or an explicit project list)
- Add a rule of type Any merge request targeting protected branches
- Set the commit attribute to unsigned commits so the rule triggers when any commit in the merge request is unsigned
- Set Approvals required to at least 1 and assign an approver group outside the project’s own Maintainers
- Set the policy status to Enabled and save
Step 3: Keep Project Rules as Defense in Depth
- Leave the project-level approval rules from control 1.2 in place
- Treat them as a convenience layer, not the enforcement layer — the policy is what survives a Maintainer with bad intent
Validation & Testing
- 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
- 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
- Delete a project-level approval rule as a Maintainer and confirm the policy requirement still applies to a new merge request
- 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
- Navigate to: Project → Settings → CI/CD → Variables
- 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
- Navigate to: Group → Settings → CI/CD → Variables
- Define shared secrets at group level
- Limit duplication across projects
Step 3: Environment-Scoped Variables
- Create separate variables for each environment:
PROD_API_KEY(protected)STAGING_API_KEY
- Scope to specific environments
Code Implementation
Code Pack: Config
# .gitlab-ci.yml - Secure variable usage
variables:
# Never hardcode secrets
# Reference protected CI/CD variables
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
# Retrieve all project-level CI/CD variables and check protection settings
VARIABLES=$(gl_get "/projects/${PROJECT_ID}/variables" 2>/dev/null) || {
fail "2.1 Failed to retrieve CI/CD variables -- check PROJECT_ID and token permissions"
increment_failed
summary
exit 0
}
VAR_COUNT=$(echo "${VARIABLES}" | jq 'length' 2>/dev/null || echo "0")
info "2.1 Found ${VAR_COUNT} CI/CD variable(s)"
UNPROTECTED=0
UNMASKED=0
RAW_EXPOSED=0
echo "${VARIABLES}" | jq -c '.[]' 2>/dev/null | while IFS= read -r var; do
KEY=$(echo "${var}" | jq -r '.key')
PROTECTED=$(echo "${var}" | jq -r '.protected')
MASKED=$(echo "${var}" | jq -r '.masked')
RAW=$(echo "${var}" | jq -r '.raw // false')
ISSUES=""
if [ "${PROTECTED}" != "true" ]; then
ISSUES="${ISSUES} unprotected"
fi
if [ "${MASKED}" != "true" ]; then
ISSUES="${ISSUES} unmasked"
fi
if [ "${RAW}" == "true" ]; then
ISSUES="${ISSUES} raw-exposed"
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=$(echo "${VARIABLES}" | jq '[.[] | select(.protected != true)] | length' 2>/dev/null || echo "0")
UNMASKED=$(echo "${VARIABLES}" | jq '[.[] | select(.masked != true)] | length' 2>/dev/null || echo "0")
RAW_EXPOSED=$(echo "${VARIABLES}" | jq '[.[] | select(.raw == true)] | length' 2>/dev/null || echo "0")
info "2.1 Unprotected: ${UNPROTECTED}, Unmasked: ${UNMASKED}, Raw-exposed: ${RAW_EXPOSED}"
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:
- Fork-based merge requests run attacker-authored pipeline code, so requiring approval before they execute stops poisoned-pipeline attacks
- Limiting the CI/CD job token scope to only the projects a pipeline truly needs prevents lateral movement between repositories if a job is compromised
- Requiring pipelines to succeed and discussions to resolve 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: Require Pipeline Approval for Forks
- Navigate to: Project → Settings → CI/CD → General pipelines
- Enable: Protect CI/CD variables in pipeline subscriptions
- Enable: CI/CD job token scope: Limit access to necessary projects
Step 2: Configure Merge Request Pipelines
- Navigate to: Project → Settings → Merge requests
- Enable: Pipelines must succeed before merge
- Enable: All discussions must be resolved
Step 3: Limit Who Can Run Pipelines
- Navigate to: Project → Settings → CI/CD
- Configure: Who can run pipelines on protected branches
- Restrict manual job triggers
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 CLI 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
Code Pack: Config
# .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
- Navigate to: Project → Settings → CI/CD → Job token permissions
- Confirm inbound access is limited to an explicit list of authorized groups and projects rather than open access
- Remove allowlist entries that no longer have a working pipeline dependency
Step 2: Scope Each Allowlist Entry
- For each entry in the authorized groups and projects list, open its permissions
- Select only the endpoint scopes the consuming pipeline actually calls — for example a read scope for packages or jobs
- Avoid granting any
ADMIN_*scope unless a pipeline provably needs to write; document the justification for every one you keep - Save and re-run the dependent pipeline to confirm nothing broke
Step 3 (Self-Managed): Enforce the Allowlist Instance-Wide
- Navigate to: Admin → Settings → CI/CD → Job token permissions
- Enable: Enable and enforce job token allowlist for all projects
- Communicate the change ahead of time — projects relying on unscoped token access will fail until their allowlist entries are configured
Validation & Testing
- 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
- Call an endpoint inside the granted scope and confirm it succeeds, proving the scoping is precise rather than simply broken
- Review each project’s Job token permissions page and record any entry still holding an
ADMIN_*scope for the next access review - 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.ymlcan 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
- Navigate to: Group → Secure → Policies → New policy → Pipeline execution policy
- Confirm the linked security policy project is the restricted-membership project from control 1.4
- Point the policy at the CI configuration file held in that security policy project — the policy itself is stored under
.gitlab/security-policies/policy.ymlas apipeline_execution_policyentry
Step 2: Choose the Injection Strategy
- Select
inject_policyto add the policy’s jobs alongside the project’s own pipeline — this is the current strategy and the right default for most groups - Do not adopt
inject_ci; it is the deprecated predecessor toinject_policyand existing policies using it should be migrated - Select
override_project_cionly where the policy’s configuration must fully replace the project’s pipeline, such as tightly regulated deployment repositories
Step 3: Scope and Cap
- Set the policy scope to the projects or compliance-framework-labeled projects that must carry the mandatory jobs
- Keep the total at or below the limit of five pipeline execution policies per project
- Enable the policy and save
Step 4: Migrate Off Compliance Pipelines
- Identify compliance frameworks that still specify a pipeline configuration file
- Recreate the equivalent jobs as a pipeline execution policy
- Clear the pipeline configuration from the compliance framework once the policy is verified, so a single mechanism owns enforcement
Validation & Testing
- Create a scratch project in scope with a minimal
.gitlab-ci.yml, run a pipeline, and confirm the policy-injected jobs appear and execute - Delete every job from the project’s own CI configuration, re-run, and confirm the mandatory jobs still run
- As a project Maintainer, attempt to modify the injected jobs and confirm the change does not take effect
- 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
latestor 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
- Navigate to the CI/CD Catalog and open the component you intend to use
- 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
- 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
- Reject or fork any component whose behavior you cannot explain from its source
Step 2: Pin Every Reference
- Reference components by commit SHA wherever possible — a 40-character SHA is the only genuinely immutable reference
- Where a SHA is impractical, use a published release tag; never reference
latestor a branch name in any project that builds or deploys production code - 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
- Treat a version bump as a code change: review the upstream diff between the pinned SHA and the new one before merging
- Route component upgrades through the merge request approval policy from control 1.4 so a second person sees the change
- Re-review the component’s source at upgrade time, not only at first adoption
Validation & Testing
- Use group-level code search for component include statements and confirm no result resolves to
latestor a branch name - Confirm every component reference in projects that deploy to production resolves to a commit SHA
- Pick one pinned component and verify the SHA in your configuration matches a commit that actually exists in the upstream source project
- 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
Implementation
Step 1: Create Runner Tiers
- shared-runners – general use, Docker executor, ephemeral containers
- group-runners – team-specific, isolated per business unit
- project-runners – sensitive projects, dedicated to single project
- production-runners – deployment only, network access to production, limited users
Code Pack: CLI Script
# Register runner with specific tags
gitlab-runner register \
--url "https://gitlab.company.com" \
--registration-token "${RUNNER_TOKEN}" \
--executor "docker" \
--docker-image "alpine:3.18" \
--tag-list "isolated,security-sensitive" \
--run-untagged="false" \
--locked="true"
[[runners]]
name = "secure-runner"
executor = "docker"
[runners.docker]
image = "alpine:3.18"
privileged = false # Never enable unless absolutely required
disable_entrypoint_overwrite = true
volumes = ["/cache"]
# Limit network access
network_mode = "bridge"
# Read-only root filesystem
read_only = true
# Drop capabilities
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: Reset Runner Token
- Navigate to: Admin → CI/CD → Runners → [Runner]
- Click Reset registration token
- Update runner configuration with new token
Code Pack: CLI Script
# Reset project runner token
curl -X POST -H "PRIVATE-TOKEN: ${ADMIN_TOKEN}" \
"https://gitlab.company.com/api/v4/projects/${PROJECT_ID}/runners/reset_registration_token"
# Re-register runner
gitlab-runner unregister --all-runners
gitlab-runner register --non-interactive \
--url "https://gitlab.company.com" \
--registration-token "${NEW_TOKEN}" \
--executor "docker"
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
Step 1: Configure Project Push Rules
- Navigate to: Project → Settings → Repository → Push rules
- Enable:
- Prevent pushing secret files: Enable
- Reject unsigned commits: Enable (L2)
- Check author email against verified: Enable
Step 2: Configure Secret Detection
See the CLI Code Pack below for the .gitlab-ci.yml secret detection configuration.
Code Pack: Config
# .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
# 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
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
Step 1: Configure Signature Requirements
- Navigate to: Project → Settings → Repository → Push rules
- Enable: Reject unsigned commits
- Enable: Reject unverified users
Step 2: User Setup
- Navigate to: User Settings → GPG Keys
- Add GPG public key
- Configure git client (see CLI Code Pack below)
Code Pack: CLI Script
git config --global commit.gpgsign true
git config --global user.signingkey YOUR_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
- Navigate to: Admin → Settings → Security and compliance
- Enable: Allow secret push protection
- Save changes — this makes the feature available to projects but does not turn it on for them
Step 2: Enable Per Project
- Navigate to: Project → Secure → Security configuration
- Enable: Secret push protection
- 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
- Notify developers before enabling — the first rejected push is otherwise reported as a broken remote
- Document the remediation path: remove the secret from the commit, rotate the exposed credential regardless, and re-push
- Document the skip mechanism (
secret_push_protection.skip_allas a push option) and treat every use of it as an event to review, not a routine workaround
Validation & Testing
- 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 - Confirm the remote history contains no trace of the rejected commit
- Push a benign change to the same project and confirm normal pushes are unaffected
- 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
HashiCorp Vault Integration
Code Pack: Config
# .gitlab-ci.yml
deploy:
stage: deploy
secrets:
DATABASE_PASSWORD:
vault: production/db/password@secret
API_KEY:
vault: production/api/key@secret
script:
- echo "Using secrets from Vault"
- ./deploy.sh
Step 1: Configure Vault Integration
- Navigate to: Project → Settings → CI/CD → Secure Files
- Configure JWT authentication with Vault
- Map CI/CD variables to Vault paths
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: Configure Audit Event Streaming
- Navigate to: Group → Security & Compliance → Audit events
- Enable streaming to SIEM
- Configure: All event types
Step 2: Alert on Critical Events
- Repository deletion
- Protected branch modification
- Runner registration
- Admin privilege changes
Detection Queries
See the DB Code Pack below for SQL queries that detect unusual repository cloning and pipeline variable modifications.
Code Pack: API Script
# 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" 2>/dev/null) || {
fail "6.1 Failed to retrieve audit events -- requires GitLab Premium/Ultimate and admin token"
increment_failed
summary
exit 0
}
EVENT_COUNT=$(echo "${AUDIT_EVENTS}" | jq 'length' 2>/dev/null || echo "0")
info "6.1 Retrieved ${EVENT_COUNT} recent audit event(s)"
if [ "${EVENT_COUNT}" -gt 0 ]; then
# Show recent security-relevant events
echo "${AUDIT_EVENTS}" | jq -r '.[] | " - [\(.created_at)] \(.author.name // .author_id): \(.entity_type)/\(.details.action // .details.custom_message // "event")"' 2>/dev/null || true
# Check for key security event types
info "6.1 Checking for security-relevant event categories..."
AUTH_EVENTS=$(echo "${AUDIT_EVENTS}" | jq '[.[] | select(.details.action // "" | test("auth|login|session"; "i"))] | length' 2>/dev/null || echo "0")
PERM_EVENTS=$(echo "${AUDIT_EVENTS}" | jq '[.[] | select(.details.action // "" | test("permission|role|access"; "i"))] | length' 2>/dev/null || echo "0")
REPO_EVENTS=$(echo "${AUDIT_EVENTS}" | jq '[.[] | select(.details.action // "" | test("push|merge|branch|tag"; "i"))] | length' 2>/dev/null || echo "0")
info "6.1 Event breakdown: auth=${AUTH_EVENTS}, permissions=${PERM_EVENTS}, repository=${REPO_EVENTS}"
fi
# Check for audit event streaming destinations (L2)
if should_apply 2 2>/dev/null; then
info "6.1 L2: Checking external audit event streaming destinations..."
STREAM_DESTS=$(gl_get "/groups/${GROUP_ID}/audit_events/streaming/destinations" 2>/dev/null || echo "[]")
DEST_COUNT=$(echo "${STREAM_DESTS}" | jq 'length' 2>/dev/null || echo "0")
if [ "${DEST_COUNT}" -gt 0 ]; then
pass "6.1 Found ${DEST_COUNT} audit event streaming destination(s)"
echo "${STREAM_DESTS}" | jq -r '.[] | " - \(.destination_url // "unknown") (verification: \(.verification_token | if . then "set" else "unset" end))"' 2>/dev/null || true
else
warn "6.1 No external audit event streaming destinations configured"
warn "6.1 Configure via Settings > General > Audit events > Streaming to forward to your SIEM"
fi
fi
Code Pack: Sigma Detection Rule
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
- Navigate to: Admin → GitLab Duo → Change configuration
- Set availability to the state your organization has actually decided on: Always on, Off by default, or Always off
- Prefer Off by default where you intend to allow Duo only in specific groups — it makes enablement an explicit, attributable act
- 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
- In the same configuration, locate the experiment and beta features toggle
- Leave it disabled until the data handling terms for those features have been reviewed
- Re-review after each GitLab upgrade, since the set of features behind that toggle changes between releases
Step 3: Cascade to Groups and Projects
- Navigate to: Group → Settings → General → GitLab Duo features
- Enable Duo only for groups whose repositories you are comfortable exposing to AI processing
- Confirm the setting at project level for any project that handles regulated or customer-sensitive code
- Keep public and fork-accepting projects out of scope by default — see control 7.2 for why
Validation & Testing
- Sign in as a standard user in a project where Duo should be unavailable and confirm Duo Chat and code suggestions do not appear
- Sign in to a project where Duo is intentionally enabled and confirm it works, proving the cascade is scoped rather than globally broken
- Review group-level GitLab Duo settings across the instance and list any group that has overridden the instance default
- 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
- Using the group and project settings from control 7.1, disable Duo in projects that accept merge requests from outside your organization
- Prioritize public projects, community-contribution repositories, and any project where fork pipelines run
- Document which groups are in scope so the decision survives staff turnover
Step 2: Stay Patched
- Confirm your instance is running a GitLab version that includes the fix blocking unsafe external HTML rendering in Duo responses
- 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
- Track GitLab release announcements for further AI-related security changes
Step 3: Keep a Human in the Loop
- 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
- Do not grant automation the ability to act on Duo output without review
- Treat Duo summaries of a merge request as a convenience, not as evidence that the merge request was reviewed
Step 4: Train Reviewers
- Teach reviewers that hidden content is a real technique: invisible or same-colour text, unusual encodings, and rendering tricks in descriptions and comments
- Instruct reviewers to be suspicious when a Duo answer contains links or images they did not expect, and to report rather than click
- Add “check for hidden instructions in contributed text” to the review checklist for projects that accept outside contributions
Validation & Testing
- 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
- Confirm the instance version in Admin → Overview includes the fix for unsafe external HTML rendering
- Confirm Duo is unavailable in at least one representative fork-accepting project, matching the scoping decision from Step 1
- 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 | Basic | ✅ | ✅ |
| 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:
- SOC 2 Type II, SOC 3, ISO/IEC 27001:2022, ISO 27017, ISO 27018, PCI DSS (SAQ D) – via Trust Center
- External Audits, Certifications, and Attestations
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-08-08 | 0.2.1 | draft | 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 | draft | 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 | draft | Add cheat-sheet Description and Rationale for all controls | Claude Code (Opus 4.8) |
| 2026-02-19 | 0.1.2 | draft | 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 | draft | 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 | draft | Initial GitLab hardening guide | Claude Code (Opus 4.5) |
Contributing
Found an issue or want to improve this guide?
- Report outdated information: Open an issue with tag
content-outdated - Propose new controls: Open an issue with tag
new-control - Submit improvements: See Contributing Guide