

TL;DR – The flaw lets an unauthenticated attacker inject arbitrary commands into any GitLab CI job that uses the default CI variables (
CI_PROJECT_DIR,CI_JOB_TOKEN, etc.). The following guide shows how to audit your GitLab projects for risky CI patterns, harden them, and detect exploitation attempts via the GitLab API.
| Item | Why you need it | Minimum version |
|---|---|---|
| GitLab account (self‑managed or SaaS) with Maintainer or Owner rights on the projects you want to audit | Needed to read CI configuration via the API | Any (tested on 15.+ and 16.+) |
Personal Access Token (PAT) with api scope (or read_api, read_repository) | Authenticates API calls | – |
| Python 3.9+ (for the Python example) | Runs the audit script | 3.9+ |
| Node.js 18+ (for the JS/TS example) | Runs the audit script | 18+ |
Git (optional) – to clone a repo locally if you prefer to inspect .gitlab-ci.yml directly | – | – |
| IDE / Text editor (VS Code, PyCharm, etc.) | For editing the samples | – |
Security note – Store the PAT only in a secret manager or environment variable; never commit it to source control.
# Create a virtual environment (recommended)
python3 -m venv .venv
source .venv/bin/activate # on Windows: .venv\Scripts\activate
# Install the official GitLab Python client and helpers
pip install --upgrade python-gitlab python-dotenv
# Initialise a new Node project (if you don't have one)
npm init -y
# Install GitBeaker (a typed GitLab API wrapper) and dotenv
npm install @gitbeaker/node dotenv
# If you prefer TypeScript, also install typings:
npm install --save-dev typescript @types/node ts-node
npx tsc --init # creates a basic tsconfig.json
The core idea is to scan every project’s .gitlab-ci.yml for patterns that could be abused by the flaw:
| Risky pattern | Why it’s dangerous |
|---|---|
script: that directly expands a CI variable without quoting (e.g., script: - echo $CI_JOB_TOKEN) | An attacker who can control the variable (via the flaw) can inject arbitrary shell commands. |
variables: that are marked protected: false and used in unsafe contexts | Unprotected variables can be set by any user with access to the repo (including forged merge requests). |
rules: or only/except that rely on $CI_COMMIT_REF_NAME without validation | Ref‑name injection can change which jobs run. |
Use of needs: with artifacts: true from an untrusted job | Could pull in malicious artifacts. |
Below are complete, copy‑paste‑ready scripts that:
.gitlab-ci.yml.Feel free to adapt the reporting mechanism (e.g., push to a SIEM, create a GitLab issue, etc.).
# audit_gitlab_ci.py
"""
Audit GitLab CI configuration for patterns that could be exploited by the
CVSS‑10 GitLab flaw (unauthenticated CI variable injection).
Requirements:
pip install python-gitlab python-dotenv pyyaml
"""
import os
import sys
import json
import yaml
import gitlab
from dotenv import load_dotenv
from typing import List, Dict, Any
# ----------------------------------------------------------------------
# Load environment variables (GITLAB_URL, GITLAB_TOKEN)
# ----------------------------------------------------------------------
load_dotenv() # reads .env file if present
GITLAB_URL = os.getenv("GITLAB_URL")
GITLAB_TOKEN = os.getenv("GITLAB_TOKEN")
if not GITLAB_URL or not GITLAB_TOKEN:
sys.exit("❌ Please set GITLAB_URL and GITLAB_TOKEN in your environment or .env file")
# ----------------------------------------------------------------------
# Initialise GitLab client
# ----------------------------------------------------------------------
gl = gitlab.Gitlab(GITLAB_URL, private_token=GITLAB_TOKEN, api_version=4)
try:
gl.auth() # raises GitlabAuthenticationError on failure
except Exception as e:
sys.exit(f"❌ Authentication failed: {e}")
# ----------------------------------------------------------------------
# Helper: load .gitlab-ci.yml from a project's default branch
# ----------------------------------------------------------------------
def fetch_ci_yaml(project: gitlab.v4.objects.Project) -> Dict[str, Any] | None:
try:
# Get the default branch (usually main/master)
default_branch = project.default_branch
# GitLab API: repository/files?ref=<branch>&file_path=.gitlab-ci.yml
file_info = project.files.get(file_path=".gitlab-ci.yml", ref=default_branch)
# file_info.decode() returns bytes; decode to str
raw_yaml = file_info.decode().decode("utf-8")
return yaml.safe_load(raw_yaml)
except gitlab.exceptions.GitlabGetError as e:
if e.response_code == 404:
# No CI file – that's fine, just skip
return None
print(f"⚠️ Unable to fetch CI for {project.path_with_namespace}: {e}")
return None
except yaml.YAMLError as ye:
print(f"⚠️ Invalid YAML in {project.path_with_namespace}: {ye}")
return None
# ----------------------------------------------------------------------
# Risk detection heuristics
# ----------------------------------------------------------------------
def is_risky_script(script: List[str]) -> bool:
"""
Detects unquoted variable expansion in a script line.
Simple heuristic: looks for $VAR or ${VAR} not inside quotes.
"""
import re
pattern = re.compile(r'(?<!["\'])\$\{?[A-Z0-9_]+\}?(?!["\'])')
for line in script:
if pattern.search(line):
return True
return False
def audit_ci_config(ci: Dict[str, Any]) -> List[Dict[str, Any]]:
"""
Returns a list of findings. Each finding is a dict with:
- location: e.g., "job:build.script"
- description: human readable explanation
- severity: "high"/"medium"/"low"
"""
findings = []
# 1️⃣ Check global variables that are not protected
variables = ci.get("variables", {})
if isinstance(variables, dict):
for name, spec in variables.items():
if isinstance(spec, dict) and not spec.get("protected", True):
findings.append({
"location": f"variables.{name}",
"description": f"Variable '{name}' is not protected – can be set by any user with repo access.",
"severity": "medium"
})
# 2️⃣ Iterate over jobs (top-level keys that are dicts and not reserved)
reserved_keys = {"stages", "variables", "default", "include", "workflow"}
for job_name, job_spec in ci.items():
if job_name in reserved_keys or not isinstance(job_spec, dict):
continue
# ---- script ----
script = job_spec.get("script")
if isinstance(script, str):
script = [script]
if isinstance(script, list) and is_risky_script(script):
findings.append({
"location": f"job:{job_name}.script",
"description": "Script contains unquoted CI variable expansion – vulnerable to injection.",
"severity": "high"
})
# ---- rules / only / except ----
for cond_key in ("rules", "only", "except"):
cond = job_spec.get(cond_key)
if cond:
# Very simple check: if any condition uses $CI_COMMIT_REF_NAME without regex anchoring
if isinstance(cond, list):
for item in cond:
if isinstance(item, str) and "$CI_COMMIT_REF_NAME" in item and not item.startswith("/") and not item.endswith("/"):
findings.append({
"location": f"job:{job_name}.{cond_key}",
"description": f"Condition uses $CI_COMMIT_REF_NAME without anchoring – possible ref‑name injection.",
"severity": "medium"
})
elif isinstance(item, dict) and "if" in item and "$CI_COMMIT_REF_NAME" in item["if"]:
findings.append({
"location": f"job:{job_name}.{cond_key}.if",
"description": "Conditional 'if' references $CI_COMMIT_REF_NAME – review for injection risk.",
"severity": "medium"
})
# ---- needs with artifacts from untrusted jobs ----
needs = job_spec.get("needs")
if isinstance(needs, list):
for need in needs:
if isinstance(need, dict) and need.get("artifacts", True) and not need.get("job"):
findings.append({
"location": f"job:{job_name}.needs",
"description": "Need pulls artifacts from an unspecified job – could be spoofed.",
"severity": "low"
})
return findings
# ----------------------------------------------------------------------
# Main routine – iterate over projects (or a specific group)
# ----------------------------------------------------------------------
def main():
# Optionally limit to a group: gl.groups.get(<group_id>).projects.list(...)
projects = gl.projects.list(get_all=True, membership=True) # projects you can see
report = {}
for proj in projects:
print(f"🔍 Scanning {proj.path_with_namespace}...")
ci_yaml = fetch_ci_yaml(proj)
if ci_yaml is None:
continue # no CI file or error already logged
findings = audit_ci_config(ci_yaml)
if findings:
report[proj.path_with_namespace] = findings
print(f" ⚠️ {len(findings)} issue(s) found")
else:
print(" ✅ No risky patterns detected")
# Write report
out_file = "gitlab_ci_audit_report.json"
with open(out_file, "w", encoding="utf-8") as f:
json.dump(report, f, indent=2, ensure_ascii=False)
print(f"\n📝 Report written to {out_file}")
if __name__ == "__main__":
main()
GITLAB_TOKEN.membership=True). Change to a specific group if you prefer..gitlab-ci.yml from the default branch via the Repository Files API.script: (high risk).$CI_COMMIT_REF_NAME usage in rules/only/except.needs that pull artifacts.gitlab_ci_audit_report.json) that can be ingested by ticketing systems or SIEMs.Tip: If you have thousands of projects, add pagination (
per_page=100) and/or cache the CI YAML to avoid hitting API rate limits.
// audit-gitlab-ci.ts
/*
* Audit GitLab CI for risky patterns that could be abused by the CVSS‑10 flaw.
*
* Prerequisites:
* npm i @gitbeaker/node dotenv yamljs
* (Optional for TS) npm i -D typescript @types/node ts-node
*
* Create a .env file:
* GITLAB_URL=https://gitlab.com
* GITLAB_TOKEN=your_personal_access_token
*/
import * as dotenv from "dotenv";
import { Gitlab } from "@gitbeaker/node";
import * as YAML from "yamljs";
import { readFileSync } from "fs";
dotenv.config();
const GITLAB_URL = process.env.GITLAB_URL ?? "";
const GITLAB_TOKEN = process.env.GITLAB_TOKEN ?? "";
if (!GITLAB_URL || !GITLAB_TOKEN) {
console.error("❌ Please set GITLAB_URL and GITLAB_TOKEN in your environment or .env");
process.exit(1);
}
const api = new Gitlab({
host: GITLAB_URL,
token: GITLAB_TOKEN,
});
// ---------------------------------------------------------------------
// Helper: fetch .gitlab-ci.yml from a project's default branch
// ---------------------------------------------------------------------
async function fetchCiYaml(projectId: number): Promise<any | null> {
try {
const repository = await api.Repositories.show(projectId);
const defaultBranch = repository.default_branch;
const file = await api.RepositoryFiles.show(projectId, {
file_path: ".gitlab-ci.yml",
ref: defaultBranch,
});
const content = Buffer.from(file.content, "base64").toString("utf-8");
return YAML.parse(content);
} catch (err: any) {
if (err.response?.status === 404) {
// No CI file – ignore
return null;
}
console.warn(`⚠️ Unable to get CI for project ${projectId}: ${err.message}`);
return null;
}
}
// ---------------------------------------------------------------------
// Risk detection (mirrors the Python version)
// ---------------------------------------------------------------------
function isRiskyScript(script: string[]): boolean {
const unquotedVar = /(?<!["'])\$\{?[A-Z0-9_]+\}?(?!["'])/;
return script.some((line) => unquotedVar.test(line));
}
function auditCiConfig(ci: any): Array<{ location: string; description: string; severity: string }> {
const findings: Array<{ location: string; description: string; severity: string }> = [];
// 1️⃣ Unprotected variables
const variables = ci.variables ?? {};
if (typeof variables === "object") {
for (const [name, spec] of Object.entries(variables)) {
if (typeof spec === "object" && !(spec.protected ?? true)) {
findings.push({
location: `variables.${name}`,
description: `Variable '${name}' is not protected – any user with repo access can set it.`,
severity: "medium",
});
}
}
}
// 2️⃣ Scan jobs
const reserved = new Set(["stages", "variables", "default", "include", "workflow"]);
for (const [jobName, jobSpec] of Object.entries(ci)) {
if (reserved.has(jobName) || typeof jobSpec !== "object") continue;
// ---- script ----
let script = jobSpec.script ?? [];
if (typeof script === "string") script = [script];
if (Array.isArray(script) && isRiskyScript(script)) {
findings.push({
location: `job:${jobName}.script`,
description: "Script contains unquoted CI variable expansion – vulnerable to injection.",
severity: "high",
});
}
// ---- rules / only / except ----
for (const condKey of ["rules", "only", "except"]) {
const cond = jobSpec[condKey];
if (!cond) continue;
if (Array.isArray(cond)) {
for (const item of cond) {
if (typeof item === "string" && item.includes("$CI_COMMIT_REF_NAME") && !/^\/.*\/$/.test(item)) {
findings.push({
location: `job:${jobName}.${condKey}`,
description: `Condition uses $CI_COMMIT_REF_NAME without anchoring – possible ref‑name injection.`,
severity: "medium",
});
}
}
} else if (typeof cond === "object" && cond.if && cond.if.includes("$CI_COMMIT_REF_NAME")) {
findings.push({
location: `job:${jobName}.${condKey}.if`,
description: "Conditional 'if' references $CI_COMMIT_REF_NAME – review for injection risk.",
severity: "medium",
});
}
}
// ---- needs with artifacts ----
const needs = jobSpec.needs ?? [];
if (Array.isArray(needs)) {
for (const need of needs) {
if (typeof need === "object" && need.artifacts !== false && !need.job) {
findings.push({
location: `job:${jobName}.needs`,
description: "Need pulls artifacts from an unspecified job – could be spoofed.",
severity: "low",
});
}
}
}
}
return findings;
}
// ---------------------------------------------------------------------
// Main: list projects you have access to and audit each
// ---------------------------------------------------------------------
async function main() {
// Get all projects where the token holder has at least Developer access
const projects = await api.Projects.all({ membership: true, per_page: 100 });
const report: Record<string, any[]> = {};
for (const proj of projects) {
console.log(`🔍 Scanning ${proj.path_with_namespace} (ID ${proj.id})…`);
const ci = await fetchCiYaml(proj.id);
if (!ci) {
console.log(" ℹ️ No .gitlab-ci.yml found");
continue;
}
const findings = auditCiConfig(ci);
if (findings.length) {
report[proj.path_with_namespace] = findings;
console.log(` ⚠️ ${findings.length} issue(s) found`);
} else {
console.log(" ✅ No risky patterns detected");
}
}
const outPath = "gitlab_ci_audit_report.json";
require("fs").writeFileSync(outPath, JSON.stringify(report, null, 2));
console.log(`\n📝 Report written to ${outPath}`);
}
main().catch((err) => {
console.error("❌ Fatal error:", err);
process.exit(1);
});
# If you saved the file as audit-gitlab-ci.ts
npx ts-node audit-gitlab-ci.ts # (requires ts-node)
# Or compile first:
tsc audit-gitlab-ci.ts
node audit-gitlab-ci.js
The script produces the same gitlab_ci_audit_report.json as the Python version.
| Variable | Description | Example |
|---|---|---|
GITLAB_URL | Base URL of your GitLab instance (include https://). | https://gitlab.com or https://gitlab.example.com |
GITLAB_TOKEN | Personal Access Token with api scope (or at least read_api, read_repository). | glpat-XXXXXXXXXXXXXXXXXXXX |
GITLAB_GROUP_ID (optional) | If you want to limit the audit to a specific group, set this and adjust the script to use api.Groups.show(groupId) then api.GroupProjects.all(groupId, ...). | 42 |
OUTPUT_FILE (optional) | Path for the JSON report. Default: gitlab_ci_audit_report.json. | ./reports/ci-audit-$(date +%F).json |
Best practice: Store these values in a secret manager (AWS Secrets Manager, HashiCorp Vault, GitHub/GitLab CI variables) and inject them at runtime. Never hard‑code them.
.gitlab-ci.ymlvariables:
# ✅ Safe – protected, can only be set by Maintainers+
DEPLOY_KEY:
value: $DEPLOY_KEY
protected: true
# ❌ Dangerous – left unprotected
DEBUG_MODE:
value: "false"
# protected omitted → defaults to false
build:
script:
# ✅ Safe – the variable is inside quotes, so the shell treats it as a literal string
- echo "Deploying with key: $DEPLOY_KEY"
# ❌ Risky – unquoted expansion allows injection
- echo Deploying with key: $DEPLOY_KEY
test:
only:
# ✅ Safe – anchored regex, only matches exactly "main" or "release/*"
- /^main$/
- /^release\/.*$/
# ❌ Risky – plain string with variable could be tampered
only:
- $CI_COMMIT_REF_NAME
job_a:
script: ...
artifacts:
paths:
- dist/
job_b:
needs:
- job: job_a
artifacts: true # explicit – OK
- job: unknown_job # ❌ unspecified job – could be spoofed
| Symptom | Likely cause | Fix |
|---|---|---|
401 Unauthorized when calling the API | Token missing, expired, or lacks api scope. | Regenerate a PAT with api scope, ensure GITLAB_TOKEN is set correctly. |
404 Project Not Found | The token does not have at least Reporter access to the project. | Invite the token’s user to the project or use a token from an account with higher permissions. |
yaml.js: Scanner error | The retrieved .gitlab-ci.yml is not valid YAML (maybe it’s a template with CI includes). | Use GitLab’s CI lint endpoint (/ci/lint) to validate first, or skip files that fail to parse. |
Too many requests (429) | Hitting GitLab API rate limit (usually 10 req/s per token). | Add a delay (setTimeout/time.sleep) between requests, or increase per_page and paginate less frequently. |
| Report shows many false positives | Heuristics are intentionally broad to catch edge cases. | Tune the regexes or add whitelists for known safe patterns in your organization. |
| Script crashes on Windows | Line‑ending issues when reading the .env file. | Use dotenv package (already included) which handles CRLF/LF automatically. |
Before you rely on this audit in a CI/CD pipeline or security dashboard, verify the following:
| ✅ Item | Why it matters |
|---|---|
Token least‑privilege – Use a token scoped only to read_api and read_repository. | Limits damage if the token is leaked. |
Secret storage – Keep GITLAB_TOKEN in a secret manager or CI protected variable, never in plain text. | |
API rate‑limit handling – Implement retry‑with‑backoff (exponential) for 429 responses. | |
| Logging & alerting – Ship the JSON report to a SIEM, Slack channel, or ticketing system with severity‑based routing. | |
| Baseline comparison – Store the first clean report and diff against subsequent runs to spot new risky patterns automatically. | |
Automated remediation – Optionally create a merge request that adds protected: true to risky variables or quotes unsafe script lines. | |
| Coverage verification – Ensure the script iterates over all projects you own (including subgroups). Test with a known vulnerable repo to confirm detection. | |
Version pinning – Lock dependencies (python-gitlab==4.11.0, @gitbeaker/node@5.0.0) to avoid breaking changes. | |
Documentation – Add a SECURITY.md to your repo explaining how to run the audit and interpret findings. | |
| Legal/Compliance – Verify that scanning projects you don’t own is allowed under your organization’s policy. |
Copy the Python or JavaScript/TypeScript snippets, configure your environment variables, and run the audit. The generated report will highlight the exact places where the CVSS‑10 GitLab flaw could be abused, letting you harden your CI pipelines before an attacker gets a chance to strike.
Stay safe, keep your supply chain sealed, and happy coding! 🚀
Source: Dark Reading
Follow ICARAX for more AI insights and tutorials.
