GitLab CI/CD Validator Comprehensive toolkit for validating, linting, testing, and securing configurations. Trigger Phrases Use this skill when requests include intent like: - "Validate this " - "Why is this GitLab pipeline failing?" - "Run a security review for our GitLab CI" - "Check pipeline best practices" - "Lint GitLab CI config before merge" Setup And Prerequisites (Run First) All commands below assume repository root as current working directory. Use one canonical command path for orchestration: Optional local execution tooling (for ): Quick Start Commands Deterministic Validation Wor…

):\n self.issues.append(BestPracticeIssue(\n 'warning',\n line,\n f\"Image '{image_value}' has no version tag in {context}\",\n 'image-no-version',\n \"Pin to specific version (e.g., 'node:18-alpine')\"\n ))\n\n # Check global image\n if 'image' in self.config:\n check_image(self.config['image'], 'global image', 'image')\n\n # Check default image\n if 'default' in self.config and 'image' in self.config['default']:\n check_image(self.config['default']['image'], 'default image', 'default')\n\n # Check job images\n for job_name, job in self.config.items():\n if not self._is_job(job_name) or job_name.startswith('.'):\n continue\n\n if 'image' in job:\n check_image(job['image'], f\"job '{job_name}'\", job_name)\n\n # Check service images\n if 'services' in job:\n services = job['services']\n if isinstance(services, list):\n for service in services:\n check_image(service, f\"job '{job_name}' services\", job_name)\n\n def _check_dag_opportunities(self):\n \"\"\"Check for DAG optimization opportunities\"\"\"\n\n # Find jobs that could use 'needs'\n jobs_by_stage = defaultdict(list)\n\n for job_name, job in self.config.items():\n if not self._is_job(job_name) or job_name.startswith('.'):\n continue\n\n stage = job.get('stage', 'test')\n jobs_by_stage[stage].append((job_name, job))\n\n # Check if multiple stages exist without needs\n stages = self.config.get('stages', ['build', 'test', 'deploy'])\n\n if len(stages) > 2:\n for job_name, job in self.config.items():\n if not self._is_job(job_name) or job_name.startswith('.'):\n continue\n\n if 'needs' not in job and 'trigger' not in job:\n stage = job.get('stage', 'test')\n stage_index = stages.index(stage) if stage in stages else 0\n\n if stage_index > 0: # Not in first stage\n line = self._get_line(job_name)\n self.issues.append(BestPracticeIssue(\n 'suggestion',\n line,\n f\"Job '{job_name}' in stage '{stage}' might benefit from 'needs'\",\n 'dag-optimization',\n \"Use 'needs' to create a DAG and run jobs as soon as dependencies complete\"\n ))\n break # Only suggest once per file\n\n def _check_parallel_usage(self):\n \"\"\"Check for parallel execution opportunities\"\"\"\n\n for job_name, job in self.config.items():\n if not self._is_job(job_name) or job_name.startswith('.'):\n continue\n\n # Check for test jobs that might benefit from parallelization\n if 'test' in job_name.lower() and 'parallel' not in job:\n script = job.get('script', [])\n if isinstance(script, str):\n script = [script]\n\n # Look for test commands\n test_commands = ['npm test', 'pytest', 'go test', 'rspec', 'jest']\n has_tests = any(\n any(cmd in str(line) for cmd in test_commands)\n for line in script\n )\n\n if has_tests:\n line = self._get_line(job_name)\n self.issues.append(BestPracticeIssue(\n 'suggestion',\n line,\n f\"Test job '{job_name}' might benefit from parallelization\",\n 'parallel-opportunity',\n \"Consider using 'parallel: N' to split tests across multiple runners\"\n ))\n break # Only suggest once per file\n\n def _check_resource_optimization(self):\n \"\"\"Check for resource optimization opportunities\"\"\"\n\n for job_name, job in self.config.items():\n if not self._is_job(job_name) or job_name.startswith('.'):\n continue\n\n # Check for resource_group on deployment jobs\n is_deployment = any(keyword in job_name.lower() for keyword in ['deploy', 'release'])\n\n if is_deployment and 'resource_group' not in job:\n environment = job.get('environment')\n if environment:\n line = self._get_line(job_name)\n self.issues.append(BestPracticeIssue(\n 'suggestion',\n line,\n f\"Deployment job '{job_name}' should use resource_group\",\n 'missing-resource-group',\n \"Add 'resource_group' to prevent concurrent deployments to same environment\"\n ))\n\n def _check_environment_configuration(self):\n \"\"\"Check environment configuration best practices\"\"\"\n\n for job_name, job in self.config.items():\n if not self._is_job(job_name) or job_name.startswith('.'):\n continue\n\n if 'environment' in job:\n line = self._get_line(job_name)\n environment = job['environment']\n\n if isinstance(environment, dict):\n # Check for URL\n if 'url' not in environment:\n self.issues.append(BestPracticeIssue(\n 'suggestion',\n line,\n f\"Environment in job '{job_name}' missing 'url'\",\n 'environment-no-url',\n \"Add 'url' to make environment easily accessible from GitLab UI\"\n ))\n\n # Check for on_stop on non-production environments\n env_name = environment.get('name', '')\n is_dynamic = 'review' in env_name.lower() or '

GitLab CI/CD Validator Comprehensive toolkit for validating, linting, testing, and securing configurations. Trigger Phrases Use this skill when requests include intent like: - "Validate this " - "Why is this GitLab pipeline failing?" - "Run a security review for our GitLab CI" - "Check pipeline best practices" - "Lint GitLab CI config before merge" Setup And Prerequisites (Run First) All commands below assume repository root as current working directory. Use one canonical command path for orchestration: Optional local execution tooling (for ): Quick Start Commands Deterministic Validation Wor…

in env_name\n\n if is_dynamic and 'on_stop' not in environment:\n self.issues.append(BestPracticeIssue(\n 'suggestion',\n line,\n f\"Dynamic environment in job '{job_name}' missing 'on_stop'\",\n 'environment-no-stop',\n \"Add 'on_stop' and 'auto_stop_in' for automatic cleanup of review apps\"\n ))\n\n def _check_extends_usage(self):\n \"\"\"Check for extends usage opportunities\"\"\"\n\n # Find jobs with similar configuration\n job_configs = {}\n\n for job_name, job in self.config.items():\n if not self._is_job(job_name) or job_name.startswith('.'):\n continue\n\n # Create a signature of common keywords\n signature = tuple(sorted([k for k in job.keys() if k not in ['script', 'stage', 'environment']]))\n if signature:\n if signature not in job_configs:\n job_configs[signature] = []\n job_configs[signature].append(job_name)\n\n # Find duplicate configurations\n for signature, jobs in job_configs.items():\n if len(jobs) >= 3: # If 3 or more jobs share configuration\n # Check if they're already using extends\n using_extends = any('extends' in self.config[job] for job in jobs)\n\n if not using_extends:\n line = self._get_line(jobs[0])\n self.issues.append(BestPracticeIssue(\n 'suggestion',\n line,\n f\"Jobs {', '.join(jobs[:3])} share similar configuration\",\n 'extends-opportunity',\n \"Consider creating a template job (starting with '.') and use 'extends'\"\n ))\n break # Only suggest once\n\n def _check_missing_tags(self):\n \"\"\"Check for jobs missing tags (important for self-hosted runners)\"\"\"\n\n # Check if any job has tags defined (indicates self-hosted runners)\n has_tags_in_pipeline = False\n for job_name, job in self.config.items():\n if self._is_job(job_name) and 'tags' in job:\n has_tags_in_pipeline = True\n break\n\n # If some jobs have tags, warn about jobs without tags\n if has_tags_in_pipeline:\n for job_name, job in self.config.items():\n if not self._is_job(job_name):\n continue\n\n if 'tags' not in job:\n # Skip if job extends a template (might inherit tags)\n if 'extends' in job:\n continue\n\n line = self._get_line(job_name)\n self.issues.append(BestPracticeIssue(\n 'warning',\n line,\n f\"Job '{job_name}' missing 'tags' keyword\",\n 'missing-tags',\n \"Add 'tags' to ensure job runs on appropriate runners in self-hosted environment\"\n ))\n\n def _check_dependency_proxy_usage(self):\n \"\"\"Check for CI_DEPENDENCY_PROXY usage to avoid Docker Hub rate limits\"\"\"\n\n has_docker_images = False\n uses_dependency_proxy = False\n\n # Check if we have Docker images from Docker Hub\n for job_name, job in self.config.items():\n if not self._is_job(job_name):\n continue\n\n # Check image\n if 'image' in job:\n image = job['image']\n if isinstance(image, str):\n # Check if it's from Docker Hub (no registry prefix or docker.io)\n if not any(reg in image for reg in ['gcr.io', 'ghcr.io', 'registry.gitlab.com', 'quay.io']):\n if '/' not in image or image.startswith('docker.io/') or image.count('/') == 1:\n has_docker_images = True\n\n # Check if using dependency proxy\n if '$CI_DEPENDENCY_PROXY' in image or '${CI_DEPENDENCY_PROXY' in image:\n uses_dependency_proxy = True\n\n # Check services\n if 'services' in job:\n services = job['services']\n if isinstance(services, list):\n for service in services:\n if isinstance(service, str):\n if not any(reg in service for reg in ['gcr.io', 'ghcr.io', 'registry.gitlab.com', 'quay.io']):\n if '/' not in service or service.startswith('docker.io/') or service.count('/') == 1:\n has_docker_images = True\n\n if '$CI_DEPENDENCY_PROXY' in service or '${CI_DEPENDENCY_PROXY' in service:\n uses_dependency_proxy = True\n\n # If we have Docker Hub images but not using dependency proxy, suggest it\n if has_docker_images and not uses_dependency_proxy:\n self.issues.append(BestPracticeIssue(\n 'suggestion',\n 0,\n \"Pipeline uses Docker Hub images without dependency proxy\",\n 'no-dependency-proxy',\n \"Use $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX to avoid Docker Hub rate limits. \"\n \"Example: image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/node:16\"\n ))\n\n def _check_coverage_regex(self):\n \"\"\"Check for coverage regex on test jobs\"\"\"\n\n for job_name, job in self.config.items():\n if not self._is_job(job_name):\n continue\n\n # Heuristic: if job name contains 'test' or stage is 'test'\n is_test_job = (\n 'test' in job_name.lower() or\n job.get('stage') == 'test' or\n 'pytest' in str(job.get('script', [])) or\n 'jest' in str(job.get('script', [])) or\n 'npm test' in str(job.get('script', [])) or\n 'go test' in str(job.get('script', []))\n )\n\n if is_test_job:\n # Check if coverage is configured\n has_coverage = 'coverage' in job\n\n # Check if artifacts have coverage reports\n has_coverage_report = False\n if 'artifacts' in job and isinstance(job['artifacts'], dict):\n reports = job['artifacts'].get('reports', {})\n if isinstance(reports, dict) and 'coverage_report' in reports:\n has_coverage_report = True\n\n if not has_coverage and not has_coverage_report:\n line = self._get_line(job_name)\n self.issues.append(BestPracticeIssue(\n 'suggestion',\n line,\n f\"Test job '{job_name}' missing coverage configuration\",\n 'missing-coverage',\n \"Add 'coverage' regex or 'artifacts:reports:coverage_report' to track code coverage\"\n ))\n\n\ndef main():\n \"\"\"Main entry point\"\"\"\n\n if len(sys.argv) \u003c 2:\n print(\"Usage: check_best_practices.py \u003cgitlab-ci.yml> [--json]\", file=sys.stderr)\n sys.exit(1)\n\n file_path = sys.argv[1]\n json_output = '--json' in sys.argv\n\n checker = BestPracticesChecker(file_path)\n issues = checker.check()\n\n # Group by severity\n by_severity = defaultdict(list)\n for issue in issues:\n by_severity[issue.severity].append(issue)\n\n if json_output:\n # Output JSON format\n result = {\n 'validator': 'best_practices',\n 'file': file_path,\n 'success': len(issues) == 0,\n 'issues': [issue.to_dict() for issue in issues],\n 'summary': {\n 'warnings': len(by_severity.get('warning', [])),\n 'suggestions': len(by_severity.get('suggestion', []))\n }\n }\n print(json.dumps(result, indent=2))\n else:\n # Output formatted text\n if issues:\n print(f\"\\n{'='*80}\")\n print(f\"Best Practices Check for: {file_path}\")\n print(f\"{'='*80}\\n\")\n\n # Print warnings first, then suggestions\n for severity in ['warning', 'suggestion']:\n if severity in by_severity:\n print(f\"\\n{severity.upper()}S ({len(by_severity[severity])}):\")\n print(\"-\" * 80)\n for issue in by_severity[severity]:\n print(f\" {issue}\\n\")\n\n print(f\"{'='*80}\")\n print(f\"Summary: {len(by_severity.get('warning', []))} warnings, \"\n f\"{len(by_severity.get('suggestion', []))} suggestions\")\n print(f\"{'='*80}\\n\")\n\n print(\"✓ Best practices check completed\")\n else:\n print(f\"✓ No best practice issues found in {file_path}\")\n\n # Exit with 1 if issues were found (to trigger WARNINGS display in shell script)\n sys.exit(1 if issues else 0)\n\n\nif __name__ == '__main__':\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":32327,"content_sha256":"4cf879e58dafa82bae71d085fdf8949e83f98cde133dfcc2a545dd280162e245"},{"filename":"scripts/check_security.py","content":"#!/usr/bin/env python3\n\"\"\"\nGitLab CI/CD Security Scanner\n\nThis script scans GitLab CI/CD YAML files for security issues:\n- Hardcoded secrets and credentials\n- Unmasked sensitive variables\n- Insecure Docker image usage\n- Dangerous script patterns\n- Secrets in logs\n- Insecure dependency installation\n- Command injection vulnerabilities\n- Unpinned external resources\n\"\"\"\n\nimport sys\nimport yaml\nimport re\nimport json\nfrom pathlib import Path\nfrom typing import Dict, List, Any, Pattern\nfrom collections import defaultdict\n\n\nclass SecurityIssue:\n \"\"\"Represents a security issue\"\"\"\n\n def __init__(self, severity: str, line: int, message: str, rule: str, remediation: str = \"\"):\n self.severity = severity # 'critical', 'high', 'medium', 'low'\n self.line = line\n self.message = message\n self.rule = rule\n self.remediation = remediation\n\n def __str__(self):\n result = f\"{self.severity.upper()}: Line {self.line}: {self.message} [{self.rule}]\"\n if self.remediation:\n result += f\"\\n 🔒 Remediation: {self.remediation}\"\n return result\n\n def to_dict(self) -> Dict[str, Any]:\n \"\"\"Convert to dictionary for JSON output\"\"\"\n result = {\n 'severity': self.severity,\n 'line': self.line,\n 'message': self.message,\n 'rule': self.rule\n }\n if self.remediation:\n result['remediation'] = self.remediation\n return result\n\n\nclass SecurityScanner:\n \"\"\"Scans GitLab CI/CD files for security issues\"\"\"\n\n # Patterns for detecting hardcoded secrets\n SECRET_PATTERNS = [\n (re.compile(r'(?i)(password|passwd|pwd)\\s*[:=]\\s*[\"\\']?[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{};:,.\u003c>?/\\\\|`~]{8,}[\"\\']?'), 'password'),\n (re.compile(r'(?i)(api[_-]?key|apikey)\\s*[:=]\\s*[\"\\']?[a-zA-Z0-9_\\-]{16,}[\"\\']?'), 'api-key'),\n (re.compile(r'(?i)(secret|token)\\s*[:=]\\s*[\"\\']?[a-zA-Z0-9_\\-]{16,}[\"\\']?'), 'secret-token'),\n (re.compile(r'(?i)(aws_access_key_id|aws_secret_access_key)\\s*[:=]\\s*[\"\\']?[A-Z0-9]{16,}[\"\\']?'), 'aws-credentials'),\n (re.compile(r'(?i)bearer\\s+[a-zA-Z0-9_\\-\\.]{20,}'), 'bearer-token'),\n (re.compile(r'(?i)authorization:\\s*(basic|bearer)\\s+[a-zA-Z0-9_\\-\\.=]+'), 'auth-header'),\n (re.compile(r'-----BEGIN\\s+(RSA\\s+)?PRIVATE\\s+KEY-----'), 'private-key'),\n (re.compile(r'(?i)(client_secret|client_id)\\s*[:=]\\s*[\"\\']?[a-zA-Z0-9_\\-]{16,}[\"\\']?'), 'oauth-credentials'),\n (re.compile(r'(?i)(database_url|db_url|connection_string)\\s*[:=]\\s*[\"\\']?[a-zA-Z0-9]+://[^\"\\'\\s]+[\"\\']?'), 'connection-string'),\n ]\n\n # Dangerous script patterns\n DANGEROUS_PATTERNS = [\n (re.compile(r'curl\\s+[^|]*\\|\\s*(bash|sh)'), 'curl-pipe-bash', 'Download and verify scripts before execution'),\n (re.compile(r'wget\\s+[^|]*\\|\\s*(bash|sh)'), 'wget-pipe-bash', 'Download and verify scripts before execution'),\n (re.compile(r'eval\\s+\\

GitLab CI/CD Validator Comprehensive toolkit for validating, linting, testing, and securing configurations. Trigger Phrases Use this skill when requests include intent like: - "Validate this " - "Why is this GitLab pipeline failing?" - "Run a security review for our GitLab CI" - "Check pipeline best practices" - "Lint GitLab CI config before merge" Setup And Prerequisites (Run First) All commands below assume repository root as current working directory. Use one canonical command path for orchestration: Optional local execution tooling (for ): Quick Start Commands Deterministic Validation Wor…

), 'eval-variable', 'Avoid using eval with variables to prevent code injection'),\n (re.compile(r'\\$\\{.*?\\}.*?\\|.*?(bash|sh)'), 'variable-pipe-shell', 'Validate input before piping to shell'),\n (re.compile(r'chmod\\s+777'), 'chmod-777', 'Avoid overly permissive file permissions'),\n (re.compile(r'--no-verify'), 'skip-verification', 'Do not skip verification checks'),\n (re.compile(r'--insecure|-k\\s'), 'insecure-ssl', 'Do not disable SSL/TLS verification'),\n ]\n\n # Sensitive variable hints for secret exposure checks. Keep this specific to\n # high-risk key families to avoid broad KEY false positives.\n SECRET_VAR_HINTS = (\n 'PASSWORD', 'PASSWD', 'PWD', 'SECRET', 'TOKEN', 'CREDENTIAL',\n 'API_KEY', 'APIKEY', 'PRIVATE_KEY', 'SSH_PRIVATE_KEY',\n 'SSH_KEY', 'GPG_KEY', 'SIGNING_KEY', 'ACCESS_KEY', 'SECRET_KEY'\n )\n\n # Common non-secret variables that include \"KEY\" in their names.\n NON_SECRET_KEY_EXCEPTIONS = {'CACHE_KEY', 'KEY_FILE', 'PUBLIC_KEY'}\n\n # Capture variable references like $VAR or ${VAR}.\n VAR_REF_PATTERN = re.compile(r'\\$\\{?([A-Za-z_][A-Za-z0-9_]*)\\}?')\n\n # Patterns that might leak secrets in logs\n ECHO_SECRET_PATTERNS = [\n re.compile(r'\\becho\\b', re.IGNORECASE),\n re.compile(r'\\bprint\\b', re.IGNORECASE),\n re.compile(r'console\\.log\\b', re.IGNORECASE),\n ]\n\n def __init__(self, file_path: str):\n self.file_path = Path(file_path)\n self.issues: List[SecurityIssue] = []\n self.config: Dict[str, Any] = {}\n self.raw_content: str = \"\"\n self.line_map: Dict[str, int] = {}\n\n def scan(self) -> List[SecurityIssue]:\n \"\"\"Run all security scans\"\"\"\n\n try:\n with open(self.file_path, 'r') as f:\n self.raw_content = f.read()\n self.config = yaml.safe_load(self.raw_content)\n self._build_line_map()\n except Exception as e:\n print(f\"Error loading file: {e}\", file=sys.stderr)\n return []\n\n if not isinstance(self.config, dict):\n return []\n\n # Run all security checks\n self._check_hardcoded_secrets()\n self._check_dangerous_scripts()\n self._check_secret_exposure()\n self._check_image_security()\n self._check_dependency_security()\n self._check_variable_security()\n self._check_include_security()\n self._check_artifact_security()\n self._check_git_strategy_security()\n\n return self.issues\n\n def _build_line_map(self):\n \"\"\"Build line number map\"\"\"\n lines = self.raw_content.split('\\n')\n current_line = 1\n\n for line in lines:\n match = re.match(r'^(\\s*)([a-zA-Z0-9_-]+):', line)\n if match:\n key = match.group(2)\n self.line_map[key] = current_line\n current_line += 1\n\n def _get_line(self, key: str) -> int:\n \"\"\"Get line number for a key\"\"\"\n return self.line_map.get(key, 0)\n\n def _find_line_for_text(self, text: str) -> int:\n \"\"\"Find line number for specific text\"\"\"\n lines = self.raw_content.split('\\n')\n for i, line in enumerate(lines, 1):\n if text in line:\n return i\n return 0\n\n def _is_job(self, key: str) -> bool:\n \"\"\"Check if a key represents a job\"\"\"\n global_keywords = {\n 'default', 'include', 'stages', 'variables', 'workflow', 'spec'\n }\n return key not in global_keywords and isinstance(self.config.get(key), dict)\n\n def _check_hardcoded_secrets(self):\n \"\"\"Check for hardcoded secrets\"\"\"\n\n # Check in raw content for better detection\n lines = self.raw_content.split('\\n')\n\n # Pattern to match variable references: $VAR, ${VAR}, $CI_VAR, etc.\n var_reference_pattern = re.compile(r'\\$\\{?[A-Z_][A-Z0-9_]*\\}?')\n\n for line_num, line in enumerate(lines, 1):\n # Skip comments\n if line.strip().startswith('#'):\n continue\n\n for pattern, secret_type in self.SECRET_PATTERNS:\n match = pattern.search(line)\n if match:\n # Get the matched value (after the key/operator)\n matched_text = match.group(0)\n\n # Extract the value part (everything after : or =)\n value_match = re.search(r'[:=]\\s*(.+)

GitLab CI/CD Validator Comprehensive toolkit for validating, linting, testing, and securing configurations. Trigger Phrases Use this skill when requests include intent like: - "Validate this " - "Why is this GitLab pipeline failing?" - "Run a security review for our GitLab CI" - "Check pipeline best practices" - "Lint GitLab CI config before merge" Setup And Prerequisites (Run First) All commands below assume repository root as current working directory. Use one canonical command path for orchestration: Optional local execution tooling (for ): Quick Start Commands Deterministic Validation Wor…

, matched_text)\n if value_match:\n value_part = value_match.group(1).strip()\n\n # Check if the value contains variable references\n # If it's a variable reference or contains variables, skip it\n if var_reference_pattern.search(value_part):\n continue\n\n # Check if value looks like a placeholder (all caps, contains underscores, etc.)\n if value_part.replace('\"', '').replace(\"'\", '').replace('_', '').replace('-', '').isupper():\n continue\n\n # This looks like a real hardcoded secret\n self.issues.append(SecurityIssue(\n 'critical',\n line_num,\n f\"Potential hardcoded {secret_type} detected\",\n f'hardcoded-{secret_type}',\n \"Use CI/CD variables or secrets manager instead of hardcoding credentials\"\n ))\n else:\n # If we can't extract the value, check the whole match\n if not var_reference_pattern.search(matched_text):\n self.issues.append(SecurityIssue(\n 'critical',\n line_num,\n f\"Potential hardcoded {secret_type} detected\",\n f'hardcoded-{secret_type}',\n \"Use CI/CD variables or secrets manager instead of hardcoding credentials\"\n ))\n\n def _check_dangerous_scripts(self):\n \"\"\"Check for dangerous script patterns\"\"\"\n\n for job_name, job in self.config.items():\n if not self._is_job(job_name):\n continue\n\n # Check all script sections\n for script_key in ['script', 'before_script', 'after_script']:\n if script_key not in job:\n continue\n\n script = job[script_key]\n if isinstance(script, str):\n script = [script]\n\n for cmd in script:\n cmd_str = str(cmd)\n\n for pattern, rule_id, remediation in self.DANGEROUS_PATTERNS:\n if pattern.search(cmd_str):\n line = self._find_line_for_text(cmd_str[:50])\n self.issues.append(SecurityIssue(\n 'high',\n line,\n f\"Dangerous script pattern in job '{job_name}': {rule_id}\",\n rule_id,\n remediation\n ))\n\n @classmethod\n def _looks_sensitive_var(cls, var_name: str) -> bool:\n \"\"\"Return True when a variable name suggests sensitive data.\"\"\"\n normalized = var_name.upper()\n if normalized in cls.NON_SECRET_KEY_EXCEPTIONS:\n return False\n return any(hint in normalized for hint in cls.SECRET_VAR_HINTS)\n\n def _check_secret_exposure(self):\n \"\"\"Check for potential secret exposure in logs\"\"\"\n\n for job_name, job in self.config.items():\n if not self._is_job(job_name):\n continue\n\n line = self._get_line(job_name)\n\n # Check script sections for echo/print of secrets\n for script_key in ['script', 'before_script', 'after_script']:\n if script_key not in job:\n continue\n\n script = job[script_key]\n if isinstance(script, str):\n script = [script]\n\n for cmd in script:\n cmd_str = str(cmd)\n\n if not any(pattern.search(cmd_str) for pattern in self.ECHO_SECRET_PATTERNS):\n continue\n\n for var_name in self.VAR_REF_PATTERN.findall(cmd_str):\n if not self._looks_sensitive_var(var_name):\n continue\n self.issues.append(SecurityIssue(\n 'high',\n line,\n f\"Job '{job_name}' may expose secrets in logs\",\n 'secret-in-logs',\n \"Avoid printing secret variables; ensure they are masked in CI/CD settings\"\n ))\n break\n\n # Check for debug flags that might expose secrets\n if 'variables' in job:\n variables = job['variables']\n if isinstance(variables, dict):\n if variables.get('CI_DEBUG_TRACE') == 'true':\n self.issues.append(SecurityIssue(\n 'medium',\n line,\n f\"Job '{job_name}' has CI_DEBUG_TRACE enabled\",\n 'debug-trace-enabled',\n \"Debug trace may expose sensitive information; use only for troubleshooting\"\n ))\n\n def _check_image_security(self):\n \"\"\"Check Docker image security\"\"\"\n\n def check_image(image_value, context, line):\n if not isinstance(image_value, str):\n if isinstance(image_value, dict):\n image_value = image_value.get('name', '')\n else:\n return\n\n is_digest_pinned = '@sha256:' in image_value\n\n # Digest-pinned images skip tag checks, but still go through trust checks.\n if not is_digest_pinned:\n # Check for :latest tag (security risk due to unpredictability)\n if ':latest' in image_value:\n self.issues.append(SecurityIssue(\n 'medium',\n line,\n f\"Using ':latest' tag in {context} is a security risk\",\n 'image-latest-tag',\n \"Pin to specific version or SHA digest to ensure consistent, verified images\"\n ))\n elif not image_value.startswith('

GitLab CI/CD Validator Comprehensive toolkit for validating, linting, testing, and securing configurations. Trigger Phrases Use this skill when requests include intent like: - "Validate this " - "Why is this GitLab pipeline failing?" - "Run a security review for our GitLab CI" - "Check pipeline best practices" - "Lint GitLab CI config before merge" Setup And Prerequisites (Run First) All commands below assume repository root as current working directory. Use one canonical command path for orchestration: Optional local execution tooling (for ): Quick Start Commands Deterministic Validation Wor…

):\n # Check for image with no tag at all (implicit :latest).\n # Examine the last path component (after any registry/org prefix)\n # to distinguish 'registry:5000/image' (port colon) from 'image:1.0' (tag colon).\n last_component = image_value.rsplit('/', 1)[-1]\n if ':' not in last_component:\n self.issues.append(SecurityIssue(\n 'medium',\n line,\n f\"Image in {context} has no version tag (implicitly uses ':latest')\",\n 'image-no-tag',\n \"Pin to a specific version tag (e.g., ubuntu:22.04) or SHA digest\"\n ))\n\n # Check for variables in image names (potential injection)\n if '

GitLab CI/CD Validator Comprehensive toolkit for validating, linting, testing, and securing configurations. Trigger Phrases Use this skill when requests include intent like: - "Validate this " - "Why is this GitLab pipeline failing?" - "Run a security review for our GitLab CI" - "Check pipeline best practices" - "Lint GitLab CI config before merge" Setup And Prerequisites (Run First) All commands below assume repository root as current working directory. Use one canonical command path for orchestration: Optional local execution tooling (for ): Quick Start Commands Deterministic Validation Wor…

in image_value and not is_digest_pinned:\n self.issues.append(SecurityIssue(\n 'medium',\n line,\n f\"Using variable in image name in {context} without SHA pinning\",\n 'image-variable-no-digest',\n \"When using variables for images, ensure they resolve to SHA digests\"\n ))\n\n # Warn about unverified registries\n if not any(registry in image_value for registry in [\n 'docker.io', 'gcr.io', 'registry.gitlab.com', 'ghcr.io', 'quay.io'\n ]) and '/' in image_value:\n if not image_value.startswith('

GitLab CI/CD Validator Comprehensive toolkit for validating, linting, testing, and securing configurations. Trigger Phrases Use this skill when requests include intent like: - "Validate this " - "Why is this GitLab pipeline failing?" - "Run a security review for our GitLab CI" - "Check pipeline best practices" - "Lint GitLab CI config before merge" Setup And Prerequisites (Run First) All commands below assume repository root as current working directory. Use one canonical command path for orchestration: Optional local execution tooling (for ): Quick Start Commands Deterministic Validation Wor…

):\n self.issues.append(SecurityIssue(\n 'low',\n line,\n f\"Using image from unverified registry in {context}\",\n 'image-unknown-registry',\n \"Ensure the registry is trusted and uses secure authentication\"\n ))\n\n # Check global and default images\n if 'image' in self.config:\n check_image(self.config['image'], 'global image', self._get_line('image'))\n\n if 'default' in self.config and 'image' in self.config['default']:\n check_image(self.config['default']['image'], 'default image', self._get_line('default'))\n\n # Check job images and services\n for job_name, job in self.config.items():\n if not self._is_job(job_name):\n continue\n\n line = self._get_line(job_name)\n\n if 'image' in job:\n check_image(job['image'], f\"job '{job_name}'\", line)\n\n if 'services' in job:\n services = job['services']\n if isinstance(services, list):\n for service in services:\n check_image(service, f\"job '{job_name}' services\", line)\n\n def _check_dependency_security(self):\n \"\"\"Check dependency installation security\"\"\"\n\n insecure_install_patterns = [\n (r'npm\\s+install(?!\\s+--ignore-scripts)', 'npm-without-ignore-scripts',\n 'Use npm ci or npm install --ignore-scripts to prevent arbitrary script execution'),\n (r'pip\\s+install(?!.*--require-hashes)', 'pip-without-hashes',\n 'Use pip install --require-hashes for verified dependency installation'),\n (r'gem\\s+install(?!.*--trust-policy)', 'gem-without-trust-policy',\n 'Use gem install with --trust-policy to verify gem signatures'),\n ]\n\n for job_name, job in self.config.items():\n if not self._is_job(job_name):\n continue\n\n script = job.get('script', [])\n if isinstance(script, str):\n script = [script]\n\n for cmd in script:\n cmd_str = str(cmd)\n\n for pattern, rule_id, remediation in insecure_install_patterns:\n if re.search(pattern, cmd_str):\n line = self._find_line_for_text(cmd_str[:50])\n self.issues.append(SecurityIssue(\n 'medium',\n line,\n f\"Insecure dependency installation in job '{job_name}'\",\n rule_id,\n remediation\n ))\n\n def _check_variable_security(self):\n \"\"\"Check variable security\"\"\"\n\n # Check global variables\n if 'variables' in self.config:\n self._check_variable_dict('global', self.config['variables'], self._get_line('variables'))\n\n # Check job variables\n for job_name, job in self.config.items():\n if not self._is_job(job_name):\n continue\n\n if 'variables' in job:\n self._check_variable_dict(f\"job '{job_name}'\", job['variables'], self._get_line(job_name))\n\n def _check_variable_dict(self, context: str, variables: Any, line: int):\n \"\"\"Check a variables dictionary for security issues\"\"\"\n\n if not isinstance(variables, dict):\n return\n\n sensitive_var_patterns = [\n 'PASSWORD', 'SECRET', 'TOKEN', 'KEY', 'CREDENTIAL',\n 'API_KEY', 'APIKEY', 'AUTH', 'PRIVATE'\n ]\n\n for var_name, var_value in variables.items():\n # Check if sensitive variable name has a static value (might be hardcoded)\n if any(pattern in var_name.upper() for pattern in sensitive_var_patterns):\n if isinstance(var_value, str) and not var_value.startswith('

GitLab CI/CD Validator Comprehensive toolkit for validating, linting, testing, and securing configurations. Trigger Phrases Use this skill when requests include intent like: - "Validate this " - "Why is this GitLab pipeline failing?" - "Run a security review for our GitLab CI" - "Check pipeline best practices" - "Lint GitLab CI config before merge" Setup And Prerequisites (Run First) All commands below assume repository root as current working directory. Use one canonical command path for orchestration: Optional local execution tooling (for ): Quick Start Commands Deterministic Validation Wor…

):\n # Check if it looks like an actual secret (not a placeholder)\n if len(var_value) > 8 and not var_value.isupper():\n self.issues.append(SecurityIssue(\n 'critical',\n line,\n f\"Sensitive variable '{var_name}' in {context} appears to have hardcoded value\",\n 'variable-hardcoded-secret',\n \"Use CI/CD variables with masking enabled or secrets manager\"\n ))\n\n def _check_include_security(self):\n \"\"\"Check include security for all types: component, project, remote, local, template\"\"\"\n\n if 'include' not in self.config:\n return\n\n includes = self.config['include']\n if not isinstance(includes, list):\n includes = [includes]\n\n line = self._get_line('include')\n\n for i, inc in enumerate(includes):\n # Handle string includes (shorthand for local)\n if isinstance(inc, str):\n self._check_local_include_security(inc, line, i+1)\n continue\n\n if not isinstance(inc, dict):\n continue\n\n # Check component includes (GitLab 16.x+)\n if 'component' in inc:\n self._check_component_include_security(inc, line, i+1)\n\n # Check remote includes\n if 'remote' in inc:\n self._check_remote_include_security(inc, line, i+1)\n\n # Check project includes\n if 'project' in inc:\n self._check_project_include_security(inc, line, i+1)\n\n # Check local includes\n if 'local' in inc:\n self._check_local_include_security(inc['local'], line, i+1)\n\n # Check template includes\n if 'template' in inc:\n self._check_template_include_security(inc, line, i+1)\n\n def _check_component_include_security(self, inc: Dict[str, Any], line: int, item_num: int):\n \"\"\"Check security for component includes\"\"\"\n\n component = inc.get('component', '')\n\n # Check for ~latest version (not recommended for production)\n if '@~latest' in component:\n self.issues.append(SecurityIssue(\n 'medium',\n line,\n f\"Include item #{item_num}: Component uses '~latest' version which may include breaking changes\",\n 'include-component-latest-version',\n \"Pin to specific semantic version (e.g., @1.2.3) for production stability\"\n ))\n\n # Check for external/untrusted component sources\n if 'gitlab.com' in component and '$CI_SERVER_FQDN' not in component:\n # Component from gitlab.com (public) - ensure it's from verified sources\n if '/components/' not in component:\n self.issues.append(SecurityIssue(\n 'medium',\n line,\n f\"Include item #{item_num}: Component from external source - ensure it's from a trusted organization\",\n 'include-component-external-source',\n \"Verify the component source and consider mirroring to your own GitLab instance\"\n ))\n\n # Check if inputs contain sensitive data (should use variables instead)\n if 'inputs' in inc:\n inputs = inc['inputs']\n if isinstance(inputs, dict):\n for key, value in inputs.items():\n if isinstance(value, str):\n # Check for hardcoded secrets in inputs\n sensitive_patterns = ['password', 'token', 'secret', 'key', 'credential']\n if any(pattern in key.lower() for pattern in sensitive_patterns):\n # Check if value is hardcoded (not a variable reference)\n if not value.startswith('

GitLab CI/CD Validator Comprehensive toolkit for validating, linting, testing, and securing configurations. Trigger Phrases Use this skill when requests include intent like: - "Validate this " - "Why is this GitLab pipeline failing?" - "Run a security review for our GitLab CI" - "Check pipeline best practices" - "Lint GitLab CI config before merge" Setup And Prerequisites (Run First) All commands below assume repository root as current working directory. Use one canonical command path for orchestration: Optional local execution tooling (for ): Quick Start Commands Deterministic Validation Wor…

):\n self.issues.append(SecurityIssue(\n 'critical',\n line,\n f\"Include item #{item_num}: Component input '{key}' may contain hardcoded sensitive data\",\n 'include-component-hardcoded-input',\n \"Use CI/CD variables ($VARIABLE_NAME) instead of hardcoded values\"\n ))\n\n def _check_remote_include_security(self, inc: Dict[str, Any], line: int, item_num: int):\n \"\"\"Check security for remote includes\"\"\"\n\n remote = inc.get('remote', '')\n\n # Always warn about remote includes - they're not verified\n self.issues.append(SecurityIssue(\n 'high',\n line,\n f\"Include item #{item_num}: Remote include from '{remote}' has no integrity verification\",\n 'include-remote-unverified',\n \"Store remote files locally or verify their integrity. Use project includes with pinned refs instead\"\n ))\n\n # Check for http:// (insecure)\n if remote.startswith('http://'):\n self.issues.append(SecurityIssue(\n 'critical',\n line,\n f\"Include item #{item_num}: Remote include uses insecure HTTP protocol\",\n 'include-remote-insecure-http',\n \"Use HTTPS for remote includes to prevent man-in-the-middle attacks\"\n ))\n\n # Check for githubusercontent.com raw links (commonly used but not recommended)\n if 'raw.githubusercontent.com' in remote:\n self.issues.append(SecurityIssue(\n 'medium',\n line,\n f\"Include item #{item_num}: Including from GitHub raw content without verification\",\n 'include-remote-github-raw',\n \"Consider using GitLab's project include with pinned ref for better security\"\n ))\n\n def _check_project_include_security(self, inc: Dict[str, Any], line: int, item_num: int):\n \"\"\"Check security for project includes\"\"\"\n\n # Check project includes without specific ref\n if 'ref' not in inc:\n self.issues.append(SecurityIssue(\n 'medium',\n line,\n f\"Include item #{item_num}: Project include without pinned ref\",\n 'include-project-unpinned',\n \"Pin includes to specific commit SHA or protected tag for reproducibility\"\n ))\n else:\n ref = inc['ref']\n # Check for branch names instead of commits/tags\n if isinstance(ref, str):\n # Check if it's a commit SHA (40 hex chars) or version tag\n is_sha = re.match(r'^[0-9a-f]{40}

GitLab CI/CD Validator Comprehensive toolkit for validating, linting, testing, and securing configurations. Trigger Phrases Use this skill when requests include intent like: - "Validate this " - "Why is this GitLab pipeline failing?" - "Run a security review for our GitLab CI" - "Check pipeline best practices" - "Lint GitLab CI config before merge" Setup And Prerequisites (Run First) All commands below assume repository root as current working directory. Use one canonical command path for orchestration: Optional local execution tooling (for ): Quick Start Commands Deterministic Validation Wor…

, ref)\n is_version_tag = re.match(r'^v?\\d+\\.\\d+', ref)\n\n if not is_sha and not is_version_tag:\n # Check for common branch names\n if ref in ['main', 'master', 'develop', 'dev', 'staging', 'production']:\n self.issues.append(SecurityIssue(\n 'medium',\n line,\n f\"Include item #{item_num}: Uses branch name '{ref}' instead of commit SHA\",\n 'include-project-branch-ref',\n \"Pin to specific commit SHA for reproducibility and security\"\n ))\n\n # Check for cross-project includes (may have different security contexts)\n project = inc.get('project', '')\n if '/' in project:\n # Check if it's from a different group\n self.issues.append(SecurityIssue(\n 'low',\n line,\n f\"Include item #{item_num}: Cross-project include from '{project}' - ensure appropriate access controls\",\n 'include-project-cross-project',\n \"Verify that the included project has appropriate security controls and access restrictions\"\n ))\n\n def _check_local_include_security(self, local_path: str, line: int, item_num: int):\n \"\"\"Check security for local includes\"\"\"\n\n if not isinstance(local_path, str):\n return\n\n # Check for path traversal attempts\n if '..' in local_path:\n self.issues.append(SecurityIssue(\n 'high',\n line,\n f\"Include item #{item_num}: Local path '{local_path}' contains '..' (path traversal)\",\n 'include-local-path-traversal',\n \"Use absolute paths starting with '/' or relative paths without '..'\"\n ))\n\n # Note: Absolute paths starting with / are normal in GitLab CI local includes\n # They are relative to the repository root, so no additional warning needed\n\n def _check_template_include_security(self, inc: Dict[str, Any], line: int, item_num: int):\n \"\"\"Check security for template includes\"\"\"\n\n template = inc.get('template', '')\n\n # GitLab templates are generally safe, but check for suspicious patterns\n # Templates from GitLab are in /lib/gitlab/ci/templates/\n\n # Warn about Auto-DevOps if enabled without review\n if 'Auto-DevOps' in template:\n self.issues.append(SecurityIssue(\n 'low',\n line,\n f\"Include item #{item_num}: Auto-DevOps template includes default security scanning\",\n 'include-template-auto-devops',\n \"Review Auto-DevOps template configuration to ensure it matches your security requirements\"\n ))\n\n # Check for deprecated templates (Security/ templates are deprecated, Jobs/ are current)\n deprecated_templates = [\n 'Security/SAST.gitlab-ci.yml',\n 'Security/Secret-Detection.gitlab-ci.yml',\n 'Security/Dependency-Scanning.gitlab-ci.yml',\n 'Security/License-Scanning.gitlab-ci.yml',\n 'Security/Container-Scanning.gitlab-ci.yml',\n 'Security/DAST.gitlab-ci.yml',\n ]\n for deprecated in deprecated_templates:\n if template == deprecated:\n self.issues.append(SecurityIssue(\n 'medium',\n line,\n f\"Include item #{item_num}: Template '{template}' is deprecated\",\n 'include-template-deprecated',\n \"Use Jobs/ templates instead (e.g., Jobs/SAST.gitlab-ci.yml)\"\n ))\n\n def _check_artifact_security(self):\n \"\"\"Check artifact security\"\"\"\n\n for job_name, job in self.config.items():\n if not self._is_job(job_name):\n continue\n\n if 'artifacts' not in job:\n continue\n\n line = self._get_line(job_name)\n artifacts = job['artifacts']\n\n if not isinstance(artifacts, dict):\n continue\n\n # Check for overly broad artifact paths\n if 'paths' in artifacts:\n paths = artifacts['paths']\n if isinstance(paths, list):\n for path in paths:\n # Warn about including entire directories\n if path in ['/', '.', './', '*', '**']:\n self.issues.append(SecurityIssue(\n 'high',\n line,\n f\"Job '{job_name}' includes overly broad artifact path '{path}'\",\n 'artifact-broad-path',\n \"Specify explicit paths to avoid exposing sensitive files\"\n ))\n\n # Warn about including sensitive files/directories.\n # Uses component-aware matching so compound report filenames\n # like 'secrets-report.json' are not treated as credential leaks.\n if self._is_sensitive_artifact_path(path):\n self.issues.append(SecurityIssue(\n 'high',\n line,\n f\"Job '{job_name}' may include sensitive files in artifacts: '{path}'\",\n 'artifact-sensitive-path',\n \"Exclude sensitive directories from artifacts\"\n ))\n\n\n def _is_sensitive_artifact_path(self, path: str) -> bool:\n \"\"\"Return True if an artifact path is genuinely sensitive.\n\n Uses component-aware matching so compound report filenames like\n 'secrets-report.json' or 'gl-secret-detection-report.json' are not\n mistakenly flagged as credential leaks.\n \"\"\"\n path_lower = path.strip('/').lower()\n\n # .git directory — always sensitive\n if '.git' in path_lower:\n return True\n\n # .env file or prefixed variants (.env.local, .env.production, etc.)\n if re.search(r'(^|/)\\.env($|[./])', path_lower):\n return True\n\n # 'secrets' and 'credentials' — only flag as a standalone path component\n # (directory name, bare filename, or filename stem), not as part of a\n # compound name like 'secrets-report.json' or 'credentials-backup.tar'.\n for word in ('secrets', 'credentials'):\n if re.search(rf'(^|/){re.escape(word)}(/|$|\\.)', path_lower):\n return True\n\n return False\n\n def _check_git_strategy_security(self):\n \"\"\"Check Git strategy security\"\"\"\n\n # Check global variables for GIT_STRATEGY\n if 'variables' in self.config:\n self._check_git_strategy_in_variables('global', self.config['variables'], self._get_line('variables'))\n\n # Check default section\n if 'default' in self.config:\n default = self.config['default']\n if isinstance(default, dict) and 'variables' in default:\n self._check_git_strategy_in_variables('default', default['variables'], self._get_line('default'))\n\n # Check per-job Git strategies\n for job_name, job in self.config.items():\n if not self._is_job(job_name):\n continue\n\n line = self._get_line(job_name)\n\n # Check job variables\n if 'variables' in job:\n self._check_git_strategy_in_variables(f\"job '{job_name}'\", job['variables'], line)\n\n def _check_git_strategy_in_variables(self, context: str, variables: Any, line: int):\n \"\"\"Check GIT_STRATEGY variable for security implications\"\"\"\n\n if not isinstance(variables, dict):\n return\n\n if 'GIT_STRATEGY' in variables:\n strategy = variables['GIT_STRATEGY']\n\n # Warn about 'none' strategy with external scripts\n if strategy == 'none':\n self.issues.append(SecurityIssue(\n 'medium',\n line,\n f\"Git strategy 'none' in {context} may execute untrusted code\",\n 'git-strategy-none',\n \"Strategy 'none' skips repository cloning; ensure scripts come from trusted sources\"\n ))\n\n # Warn about 'fetch' without depth limit\n if strategy == 'fetch':\n if 'GIT_DEPTH' not in variables:\n self.issues.append(SecurityIssue(\n 'low',\n line,\n f\"Git strategy 'fetch' in {context} without GIT_DEPTH may be inefficient\",\n 'git-strategy-fetch-no-depth',\n \"Consider setting GIT_DEPTH to limit history and improve performance\"\n ))\n\ndef main():\n \"\"\"Main entry point\"\"\"\n\n if len(sys.argv) \u003c 2:\n print(\"Usage: check_security.py \u003cgitlab-ci.yml> [--json]\", file=sys.stderr)\n sys.exit(1)\n\n file_path = sys.argv[1]\n json_output = '--json' in sys.argv\n\n scanner = SecurityScanner(file_path)\n issues = scanner.scan()\n\n # Group by severity\n by_severity = defaultdict(list)\n for issue in issues:\n by_severity[issue.severity].append(issue)\n\n # Determine if scan passed (no critical or high issues)\n has_critical_or_high = bool(by_severity.get('critical') or by_severity.get('high'))\n\n if json_output:\n # Output JSON format\n result = {\n 'validator': 'security',\n 'file': file_path,\n 'success': not has_critical_or_high,\n 'issues': [issue.to_dict() for issue in issues],\n 'summary': {\n 'critical': len(by_severity.get('critical', [])),\n 'high': len(by_severity.get('high', [])),\n 'medium': len(by_severity.get('medium', [])),\n 'low': len(by_severity.get('low', []))\n }\n }\n print(json.dumps(result, indent=2))\n else:\n # Output formatted text\n if issues:\n print(f\"\\n{'='*80}\")\n print(f\"Security Scan for: {file_path}\")\n print(f\"{'='*80}\\n\")\n\n # Print in severity order\n for severity in ['critical', 'high', 'medium', 'low']:\n if severity in by_severity:\n print(f\"\\n{severity.upper()} SEVERITY ({len(by_severity[severity])}):\")\n print(\"-\" * 80)\n for issue in by_severity[severity]:\n print(f\" {issue}\\n\")\n\n print(f\"{'='*80}\")\n print(f\"Summary: \"\n f\"{len(by_severity.get('critical', []))} critical, \"\n f\"{len(by_severity.get('high', []))} high, \"\n f\"{len(by_severity.get('medium', []))} medium, \"\n f\"{len(by_severity.get('low', []))} low\")\n print(f\"{'='*80}\\n\")\n\n # Exit with error if critical or high issues found\n if has_critical_or_high:\n print(\"❌ Security scan found critical or high severity issues\")\n else:\n print(\"⚠️ Security scan found medium/low severity issues\")\n else:\n print(f\"✓ No security issues found in {file_path}\")\n\n sys.exit(1 if has_critical_or_high else 0)\n\n\nif __name__ == '__main__':\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":37464,"content_sha256":"a3676facc617091b4e373879c5053cca9ae8c38f0cd3a8ca34ff9caf18d035c6"},{"filename":"scripts/install_tools.sh","content":"#!/usr/bin/env bash\n\n#\n# GitLab CI Validator - Tool Installation Script\n#\n# This script installs the required tools for local pipeline testing:\n# - gitlab-ci-local: For local GitLab CI pipeline execution (similar to act for GitHub Actions)\n#\n# Usage: bash scripts/install_tools.sh\n#\n\nset -euo pipefail\n\n# Colors for output\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nBLUE='\\033[0;34m'\nNC='\\033[0m' # No Color\n\n# Script directory\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nTOOLS_DIR=\"$SCRIPT_DIR/.tools\"\n\n# Create tools directory if it doesn't exist\nmkdir -p \"$TOOLS_DIR\"\n\necho -e \"${BLUE}════════════════════════════════════════════════════════════════════════════════${NC}\"\necho -e \"${BLUE} GitLab CI Validator - Tool Installation${NC}\"\necho -e \"${BLUE}════════════════════════════════════════════════════════════════════════════════${NC}\"\necho \"\"\n\n# Function to check if a command exists\ncommand_exists() {\n command -v \"$1\" &> /dev/null\n}\n\n# Function to get OS type\nget_os() {\n case \"$(uname -s)\" in\n Darwin*) echo \"darwin\" ;;\n Linux*) echo \"linux\" ;;\n *) echo \"unknown\" ;;\n esac\n}\n\n# Function to get architecture\nget_arch() {\n case \"$(uname -m)\" in\n x86_64) echo \"x64\" ;;\n aarch64) echo \"arm64\" ;;\n arm64) echo \"arm64\" ;;\n *) echo \"unknown\" ;;\n esac\n}\n\n#\n# Install gitlab-ci-local\n#\ninstall_gitlab_ci_local() {\n echo -e \"${BLUE}[1/1]${NC} Checking for gitlab-ci-local...\"\n\n # Check if already installed globally\n if command_exists gitlab-ci-local; then\n CURRENT_VERSION=$(gitlab-ci-local --version 2>/dev/null || echo \"unknown\")\n echo -e \"${GREEN}✓${NC} gitlab-ci-local is already installed: $CURRENT_VERSION\"\n echo \"\"\n return 0\n fi\n\n # Check if installed in tools directory\n if [ -f \"$TOOLS_DIR/gitlab-ci-local\" ]; then\n CURRENT_VERSION=$(\"$TOOLS_DIR/gitlab-ci-local\" --version 2>/dev/null || echo \"unknown\")\n echo -e \"${GREEN}✓${NC} gitlab-ci-local is installed in .tools: $CURRENT_VERSION\"\n echo \"\"\n return 0\n fi\n\n echo -e \"${YELLOW}→${NC} gitlab-ci-local not found. Installing...\"\n echo \"\"\n\n # Check for Node.js (required for gitlab-ci-local)\n if ! command_exists node; then\n echo -e \"${RED}✗${NC} Node.js is not installed but is required for gitlab-ci-local\"\n echo \"\"\n echo \"Please install Node.js first:\"\n echo \" - macOS: brew install node\"\n echo \" - Linux: Install from https://nodejs.org/ or use your package manager\"\n echo \"\"\n echo \"After installing Node.js, run this script again.\"\n return 1\n fi\n\n NODE_VERSION=$(node --version 2>/dev/null || echo \"unknown\")\n echo -e \"${GREEN}✓${NC} Node.js is installed: $NODE_VERSION\"\n\n # Install gitlab-ci-local using npm\n echo \"\"\n echo -e \"${YELLOW}→${NC} Installing gitlab-ci-local via npm...\"\n echo \" Note: This requires Docker to be installed for pipeline execution\"\n echo \"\"\n\n # Try to install globally if user has permissions\n if npm install -g gitlab-ci-local 2>/dev/null; then\n INSTALLED_VERSION=$(gitlab-ci-local --version 2>/dev/null || echo \"unknown\")\n echo \"\"\n echo -e \"${GREEN}✓${NC} gitlab-ci-local installed successfully: $INSTALLED_VERSION\"\n echo \" Location: $(which gitlab-ci-local)\"\n else\n echo -e \"${YELLOW}⚠${NC} Could not install globally. Trying local installation...\"\n echo \"\"\n\n # Install locally in project\n cd \"$SCRIPT_DIR/..\"\n if npm install --save-dev gitlab-ci-local 2>/dev/null; then\n echo \"\"\n echo -e \"${GREEN}✓${NC} gitlab-ci-local installed locally in node_modules\"\n echo \" Use: npx gitlab-ci-local --help\"\n else\n echo -e \"${RED}✗${NC} Failed to install gitlab-ci-local\"\n echo \"\"\n echo \"Manual installation options:\"\n echo \" 1. Global install: npm install -g gitlab-ci-local\"\n echo \" 2. Project install: npm install --save-dev gitlab-ci-local\"\n echo \"\"\n echo \"For more information: https://github.com/firecow/gitlab-ci-local\"\n return 1\n fi\n fi\n\n echo \"\"\n}\n\n#\n# Verify Docker installation (required for gitlab-ci-local)\n#\ncheck_docker() {\n echo -e \"${BLUE}Checking Docker installation...${NC}\"\n echo \"\"\n\n if command_exists docker; then\n DOCKER_VERSION=$(docker --version 2>/dev/null || echo \"unknown\")\n echo -e \"${GREEN}✓${NC} Docker is installed: $DOCKER_VERSION\"\n\n # Check if Docker daemon is running\n if docker ps &> /dev/null; then\n echo -e \"${GREEN}✓${NC} Docker daemon is running\"\n else\n echo -e \"${YELLOW}⚠${NC} Docker is installed but daemon is not running\"\n echo \" Start Docker Desktop or run: sudo systemctl start docker\"\n fi\n else\n echo -e \"${YELLOW}⚠${NC} Docker is not installed\"\n echo \"\"\n echo \"Docker is required for gitlab-ci-local to execute pipelines locally.\"\n echo \"\"\n echo \"Installation options:\"\n echo \" - macOS: Install Docker Desktop from https://www.docker.com/products/docker-desktop\"\n echo \" - Linux: Install Docker Engine from https://docs.docker.com/engine/install/\"\n echo \"\"\n fi\n\n echo \"\"\n}\n\n#\n# Main installation process\n#\nmain() {\n # Install gitlab-ci-local\n if ! install_gitlab_ci_local; then\n echo -e \"${RED}✗ Installation failed${NC}\"\n exit 1\n fi\n\n # Check Docker\n check_docker\n\n # Summary\n echo -e \"${BLUE}════════════════════════════════════════════════════════════════════════════════${NC}\"\n echo -e \"${BLUE} Installation Summary${NC}\"\n echo -e \"${BLUE}════════════════════════════════════════════════════════════════════════════════${NC}\"\n echo \"\"\n\n # gitlab-ci-local status\n if command_exists gitlab-ci-local; then\n echo -e \"${GREEN}✓${NC} gitlab-ci-local: $(gitlab-ci-local --version)\"\n elif [ -f \"$TOOLS_DIR/gitlab-ci-local\" ]; then\n echo -e \"${GREEN}✓${NC} gitlab-ci-local: installed in .tools\"\n elif command_exists npx && npx --no-install gitlab-ci-local --version &> /dev/null; then\n echo -e \"${GREEN}✓${NC} gitlab-ci-local: installed locally (use npx gitlab-ci-local)\"\n else\n echo -e \"${YELLOW}⚠${NC} gitlab-ci-local: not installed\"\n fi\n\n # Docker status\n if command_exists docker && docker ps &> /dev/null; then\n echo -e \"${GREEN}✓${NC} Docker: running\"\n elif command_exists docker; then\n echo -e \"${YELLOW}⚠${NC} Docker: installed but not running\"\n else\n echo -e \"${YELLOW}⚠${NC} Docker: not installed\"\n fi\n\n echo \"\"\n echo -e \"${BLUE}════════════════════════════════════════════════════════════════════════════════${NC}\"\n echo \"\"\n\n # Usage information\n echo -e \"${GREEN}✓ Installation complete!${NC}\"\n echo \"\"\n echo \"Next steps:\"\n echo \" 1. Ensure Docker is running\"\n echo \" 2. Test with: gitlab-ci-local --help\"\n echo \" 3. Run local pipeline: gitlab-ci-local\"\n echo \" 4. Validate with: bash scripts/validate_gitlab_ci.sh --test-only .gitlab-ci.yml\"\n echo \"\"\n echo \"For more information:\"\n echo \" - gitlab-ci-local: https://github.com/firecow/gitlab-ci-local\"\n echo \" - GitLab CI Docs: https://docs.gitlab.com/ci/\"\n echo \"\"\n}\n\n# Run main installation\nmain\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":8292,"content_sha256":"87e68904eaaebe4cc3184b4c6c937820db0060456f0a0c88a174e468043673c2"},{"filename":"scripts/python_wrapper.sh","content":"#!/bin/bash\n# Wrapper script that handles PyYAML dependency\n# Creates a persistent venv if PyYAML is not available\n\nset -e\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nVENV_DIR=\"$SCRIPT_DIR/../.venv\"\n\n# Check if we have arguments\nif [ $# -lt 2 ]; then\n echo \"Usage: python_wrapper.sh \u003cpython-script> \u003cargs...>\" >&2\n exit 1\nfi\n\nPYTHON_SCRIPT=\"$1\"\nshift # Remove first argument, rest are passed to the Python script\n\n# Try to run with system Python first\nif python3 -c \"import yaml\" 2>/dev/null; then\n # PyYAML is available in system, run directly\n python3 \"$PYTHON_SCRIPT\" \"$@\"\n exit $?\nfi\n\n# PyYAML not available in system, check for venv\nif [ ! -d \"$VENV_DIR\" ]; then\n # Create persistent venv\n echo \"PyYAML not found. Creating persistent virtual environment...\" >&2\n python3 -m venv \"$VENV_DIR\" >&2\n source \"$VENV_DIR/bin/activate\" >&2\n pip install --quiet pyyaml >&2\n echo \"Virtual environment created at $VENV_DIR\" >&2\n echo \"\" >&2\nelse\n # Use existing venv\n source \"$VENV_DIR/bin/activate\" >&2\nfi\n\n# Run the script with venv Python\npython3 \"$PYTHON_SCRIPT\" \"$@\"\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":1125,"content_sha256":"e7477d8db21b34e2602a984a4c9077a898187b8092e715c8943d13c5829e8b35"},{"filename":"scripts/validate_gitlab_ci.sh","content":"#!/usr/bin/env bash\n\n#\n# GitLab CI/CD Validator - Main Orchestrator Script\n#\n# This script runs all validation checks on GitLab CI/CD configuration files:\n# 1. Syntax validation (YAML + GitLab CI schema)\n# 2. Best practices check\n# 3. Security scanning\n#\n# Usage: validate_gitlab_ci.sh \u003cgitlab-ci.yml> [options]\n#\n# Options:\n# --syntax-only Run only syntax validation\n# --best-practices Run only best practices check\n# --security-only Run only security scan\n# --no-best-practices Skip best practices check\n# --no-security Skip security scan\n# --strict Exit with error on any warnings\n# --json Output results in JSON format\n#\n\nset -euo pipefail\n\n# Colors for output\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nBLUE='\\033[0;34m'\nNC='\\033[0m' # No Color\n\n# Script directory\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n\n# Default options\nRUN_SYNTAX=true\nRUN_BEST_PRACTICES=true\nRUN_SECURITY=true\nRUN_TEST=false\nSTRICT_MODE=false\nJSON_OUTPUT=false\n\n# Parse arguments\nFILE_PATH=\"\"\n\nusage() {\n echo \"Usage: $0 \u003cgitlab-ci.yml> [options]\"\n echo \"\"\n echo \"Options:\"\n echo \" --syntax-only Run only syntax validation\"\n echo \" --best-practices Run only best practices check\"\n echo \" --security-only Run only security scan\"\n echo \" --test-only Run only local pipeline testing with gitlab-ci-local\"\n echo \" --no-best-practices Skip best practices check\"\n echo \" --no-security Skip security scan\"\n echo \" --strict Exit with error on any warnings\"\n echo \" --json Output results in JSON format\"\n echo \" -h, --help Show this help message\"\n echo \"\"\n echo \"Examples:\"\n echo \" $0 .gitlab-ci.yml\"\n echo \" $0 .gitlab-ci.yml --syntax-only\"\n echo \" $0 .gitlab-ci.yml --no-security\"\n echo \" $0 .gitlab-ci.yml --strict\"\n echo \" $0 .gitlab-ci.yml --test-only\"\n exit 1\n}\n\nif [ $# -eq 0 ]; then\n usage\nfi\n\n# Parse command line arguments\nwhile [[ $# -gt 0 ]]; do\n case $1 in\n -h|--help)\n usage\n ;;\n --syntax-only)\n RUN_BEST_PRACTICES=false\n RUN_SECURITY=false\n shift\n ;;\n --best-practices)\n RUN_SYNTAX=false\n RUN_SECURITY=false\n shift\n ;;\n --security-only)\n RUN_SYNTAX=false\n RUN_BEST_PRACTICES=false\n shift\n ;;\n --test-only)\n RUN_SYNTAX=false\n RUN_BEST_PRACTICES=false\n RUN_SECURITY=false\n RUN_TEST=true\n shift\n ;;\n --no-best-practices)\n RUN_BEST_PRACTICES=false\n shift\n ;;\n --no-security)\n RUN_SECURITY=false\n shift\n ;;\n --strict)\n STRICT_MODE=true\n shift\n ;;\n --json)\n JSON_OUTPUT=true\n shift\n ;;\n -*)\n echo \"Unknown option: $1\"\n usage\n ;;\n *)\n if [ -z \"$FILE_PATH\" ]; then\n FILE_PATH=\"$1\"\n else\n echo \"Error: Multiple file paths provided\"\n usage\n fi\n shift\n ;;\n esac\ndone\n\n# Validate file path\nif [ -z \"$FILE_PATH\" ]; then\n echo \"Error: No file path provided\"\n usage\nfi\n\nif [ ! -f \"$FILE_PATH\" ]; then\n echo \"Error: File not found: $FILE_PATH\"\n exit 1\nfi\n\n# Check for Python (PyYAML is handled by wrapper script)\nif ! command -v python3 &> /dev/null; then\n echo \"Error: python3 is required but not installed\"\n echo \"Install with: brew install python3\"\n exit 1\nfi\n\n# Python wrapper script for handling venv\nPYTHON_WRAPPER=\"$SCRIPT_DIR/python_wrapper.sh\"\n\n# Create temporary files for JSON output (if needed)\nTEMP_DIR=$(mktemp -d 2>/dev/null || mktemp -d -t 'gitlab-ci-validator')\nTEMP_SYNTAX_JSON=\"$TEMP_DIR/syntax.json\"\nTEMP_BEST_PRACTICES_JSON=\"$TEMP_DIR/best-practices.json\"\nTEMP_SECURITY_JSON=\"$TEMP_DIR/security.json\"\n\n# Cleanup function for temp files\ncleanup_temp_files() {\n rm -rf \"$TEMP_DIR\"\n}\n\n# Register cleanup on exit\ntrap cleanup_temp_files EXIT\n\n# Results tracking\nSYNTAX_RESULT=0\nBEST_PRACTICES_RESULT=0\nSECURITY_RESULT=0\nTOTAL_ERRORS=0\nTOTAL_WARNINGS=0\n\n# Print header\nif [ \"$JSON_OUTPUT\" = false ]; then\n echo \"\"\n echo \"════════════════════════════════════════════════════════════════════════════════\"\n echo \" GitLab CI/CD Validator\"\n echo \"════════════════════════════════════════════════════════════════════════════════\"\n echo \"\"\n echo \"File: $FILE_PATH\"\n echo \"\"\nfi\n\n# Run syntax validation\nif [ \"$RUN_SYNTAX\" = true ]; then\n if [ \"$JSON_OUTPUT\" = false ]; then\n echo -e \"${BLUE}[1/3]${NC} Running syntax validation...\"\n echo \"\"\n fi\n\n if [ \"$JSON_OUTPUT\" = true ]; then\n # Run with JSON output and capture (use set +e to allow non-zero exit)\n set +e\n bash \"$PYTHON_WRAPPER\" \"$SCRIPT_DIR/validate_syntax.py\" \"$FILE_PATH\" --json > \"$TEMP_SYNTAX_JSON\"\n SYNTAX_RESULT=$?\n set -e\n else\n set +e\n bash \"$PYTHON_WRAPPER\" \"$SCRIPT_DIR/validate_syntax.py\" \"$FILE_PATH\"\n SYNTAX_RESULT=$?\n set -e\n if [ $SYNTAX_RESULT -eq 0 ]; then\n echo -e \"${GREEN}✓${NC} Syntax validation passed\"\n else\n echo -e \"${RED}✗${NC} Syntax validation failed\"\n fi\n echo \"\"\n fi\n\n if [ $SYNTAX_RESULT -ne 0 ]; then\n TOTAL_ERRORS=$((TOTAL_ERRORS + 1))\n fi\nfi\n\n# Run best practices check\nif [ \"$RUN_BEST_PRACTICES\" = true ]; then\n if [ \"$JSON_OUTPUT\" = false ]; then\n echo -e \"${BLUE}[2/3]${NC} Running best practices check...\"\n echo \"\"\n fi\n\n if [ \"$JSON_OUTPUT\" = true ]; then\n # Run with JSON output and capture (use set +e to allow non-zero exit)\n set +e\n bash \"$PYTHON_WRAPPER\" \"$SCRIPT_DIR/check_best_practices.py\" \"$FILE_PATH\" --json > \"$TEMP_BEST_PRACTICES_JSON\"\n BEST_PRACTICES_RESULT=$?\n set -e\n else\n set +e\n bash \"$PYTHON_WRAPPER\" \"$SCRIPT_DIR/check_best_practices.py\" \"$FILE_PATH\"\n BEST_PRACTICES_RESULT=$?\n set -e\n if [ $BEST_PRACTICES_RESULT -eq 0 ]; then\n echo -e \"${GREEN}✓${NC} Best practices check completed\"\n else\n echo -e \"${YELLOW}⚠${NC} Best practices check found issues\"\n fi\n echo \"\"\n fi\n\n if [ $BEST_PRACTICES_RESULT -ne 0 ] && [ \"$STRICT_MODE\" = true ]; then\n TOTAL_WARNINGS=$((TOTAL_WARNINGS + 1))\n fi\nfi\n\n# Run security scan\nif [ \"$RUN_SECURITY\" = true ]; then\n if [ \"$JSON_OUTPUT\" = false ]; then\n echo -e \"${BLUE}[3/3]${NC} Running security scan...\"\n echo \"\"\n fi\n\n if [ \"$JSON_OUTPUT\" = true ]; then\n # Run with JSON output and capture (use set +e to allow non-zero exit)\n set +e\n bash \"$PYTHON_WRAPPER\" \"$SCRIPT_DIR/check_security.py\" \"$FILE_PATH\" --json > \"$TEMP_SECURITY_JSON\"\n SECURITY_RESULT=$?\n set -e\n else\n set +e\n bash \"$PYTHON_WRAPPER\" \"$SCRIPT_DIR/check_security.py\" \"$FILE_PATH\"\n SECURITY_RESULT=$?\n set -e\n if [ $SECURITY_RESULT -eq 0 ]; then\n echo -e \"${GREEN}✓${NC} Security scan passed\"\n else\n echo -e \"${RED}✗${NC} Security scan found issues\"\n fi\n echo \"\"\n fi\n\n if [ $SECURITY_RESULT -ne 0 ]; then\n TOTAL_ERRORS=$((TOTAL_ERRORS + 1))\n fi\nfi\n\n# Run local pipeline testing with gitlab-ci-local\nTEST_RESULT=0\nif [ \"$RUN_TEST\" = true ]; then\n if [ \"$JSON_OUTPUT\" = false ]; then\n echo -e \"${BLUE}[TEST]${NC} Running local pipeline testing with gitlab-ci-local...\"\n echo \"\"\n fi\n\n # Check if gitlab-ci-local is installed\n if command -v gitlab-ci-local &> /dev/null; then\n GITLAB_CI_LOCAL_VERSION=$(gitlab-ci-local --version 2>/dev/null || echo \"unknown\")\n echo -e \"${GREEN}✓${NC} gitlab-ci-local found: $GITLAB_CI_LOCAL_VERSION\"\n echo \"\"\n\n # Get the directory containing the config file for proper execution\n CONFIG_DIR=$(dirname \"$FILE_PATH\")\n CONFIG_FILE=$(basename \"$FILE_PATH\")\n\n echo \"Listing jobs in pipeline:\"\n echo \"─────────────────────────────────────────────────────────────────────────────────\"\n\n # Run gitlab-ci-local to list jobs\n set +e\n if [ \"$CONFIG_DIR\" != \".\" ]; then\n (cd \"$CONFIG_DIR\" && gitlab-ci-local --file \"$CONFIG_FILE\" --list 2>&1)\n else\n gitlab-ci-local --file \"$FILE_PATH\" --list 2>&1\n fi\n TEST_RESULT=$?\n set -e\n\n echo \"\"\n echo \"─────────────────────────────────────────────────────────────────────────────────\"\n\n if [ $TEST_RESULT -eq 0 ]; then\n echo -e \"${GREEN}✓${NC} Pipeline structure validated successfully\"\n echo \"\"\n echo \"To run the full pipeline locally, use:\"\n echo \" gitlab-ci-local --file $FILE_PATH\"\n echo \"\"\n echo \"To run a specific job:\"\n echo \" gitlab-ci-local --file $FILE_PATH \u003cjob-name>\"\n else\n echo -e \"${YELLOW}⚠${NC} gitlab-ci-local encountered issues\"\n echo \"This may be due to missing variables or external dependencies.\"\n fi\n elif command -v npx &> /dev/null && npx --no-install gitlab-ci-local --version &> /dev/null 2>&1; then\n # Try with npx (local installation)\n echo -e \"${GREEN}✓${NC} gitlab-ci-local found via npx\"\n echo \"\"\n\n echo \"Listing jobs in pipeline:\"\n echo \"─────────────────────────────────────────────────────────────────────────────────\"\n\n set +e\n npx gitlab-ci-local --file \"$FILE_PATH\" --list 2>&1\n TEST_RESULT=$?\n set -e\n\n echo \"\"\n echo \"─────────────────────────────────────────────────────────────────────────────────\"\n else\n echo -e \"${YELLOW}⚠${NC} gitlab-ci-local is not installed\"\n echo \"\"\n echo \"To install gitlab-ci-local, run:\"\n echo \" bash $SCRIPT_DIR/install_tools.sh\"\n echo \"\"\n echo \"Or install manually:\"\n echo \" npm install -g gitlab-ci-local\"\n echo \"\"\n echo \"Requirements:\"\n echo \" - Node.js (for gitlab-ci-local)\"\n echo \" - Docker (for running jobs locally)\"\n TEST_RESULT=1\n fi\n echo \"\"\nfi\n\n# Print summary\nif [ \"$JSON_OUTPUT\" = true ]; then\n # Combine JSON results from all validators\n echo \"{\"\n echo \" \\\"file\\\": \\\"$FILE_PATH\\\",\"\n echo \" \\\"validators\\\": [\"\n\n FIRST=true\n if [ \"$RUN_SYNTAX\" = true ] && [ -f \"$TEMP_SYNTAX_JSON\" ]; then\n cat \"$TEMP_SYNTAX_JSON\"\n FIRST=false\n fi\n\n if [ \"$RUN_BEST_PRACTICES\" = true ] && [ -f \"$TEMP_BEST_PRACTICES_JSON\" ]; then\n if [ \"$FIRST\" = false ]; then echo \",\"; fi\n cat \"$TEMP_BEST_PRACTICES_JSON\"\n FIRST=false\n fi\n\n if [ \"$RUN_SECURITY\" = true ] && [ -f \"$TEMP_SECURITY_JSON\" ]; then\n if [ \"$FIRST\" = false ]; then echo \",\"; fi\n cat \"$TEMP_SECURITY_JSON\"\n fi\n\n echo \" ],\"\n echo \" \\\"summary\\\": {\"\n\n # Generate summary with proper null handling for skipped validators\n SUMMARY_PARTS=()\n\n if [ \"$RUN_SYNTAX\" = true ]; then\n if [ $SYNTAX_RESULT -eq 0 ]; then\n SUMMARY_PARTS+=(\"\\\"syntax_validation\\\": \\\"PASSED\\\"\")\n else\n SUMMARY_PARTS+=(\"\\\"syntax_validation\\\": \\\"FAILED\\\"\")\n fi\n else\n SUMMARY_PARTS+=(\"\\\"syntax_validation\\\": null\")\n fi\n\n if [ \"$RUN_BEST_PRACTICES\" = true ]; then\n if [ $BEST_PRACTICES_RESULT -eq 0 ]; then\n SUMMARY_PARTS+=(\"\\\"best_practices\\\": \\\"PASSED\\\"\")\n else\n SUMMARY_PARTS+=(\"\\\"best_practices\\\": \\\"WARNINGS\\\"\")\n fi\n else\n SUMMARY_PARTS+=(\"\\\"best_practices\\\": null\")\n fi\n\n if [ \"$RUN_SECURITY\" = true ]; then\n if [ $SECURITY_RESULT -eq 0 ]; then\n SUMMARY_PARTS+=(\"\\\"security_scan\\\": \\\"PASSED\\\"\")\n else\n SUMMARY_PARTS+=(\"\\\"security_scan\\\": \\\"FAILED\\\"\")\n fi\n else\n SUMMARY_PARTS+=(\"\\\"security_scan\\\": null\")\n fi\n\n # Join array elements with commas\n FIRST_SUMMARY=true\n for part in \"${SUMMARY_PARTS[@]}\"; do\n if [ \"$FIRST_SUMMARY\" = true ]; then\n echo -n \" $part\"\n FIRST_SUMMARY=false\n else\n echo \",\"\n echo -n \" $part\"\n fi\n done\n echo \"\"\n\n echo \" }\"\n echo \"}\"\n\n # Temporary files are automatically cleaned up by trap EXIT\nelse\n echo \"════════════════════════════════════════════════════════════════════════════════\"\n echo \" Validation Summary\"\n echo \"════════════════════════════════════════════════════════════════════════════════\"\n echo \"\"\n\n # Syntax validation\n if [ \"$RUN_SYNTAX\" = true ]; then\n if [ $SYNTAX_RESULT -eq 0 ]; then\n echo -e \"Syntax Validation: ${GREEN}PASSED${NC}\"\n else\n echo -e \"Syntax Validation: ${RED}FAILED${NC}\"\n fi\n fi\n\n # Best practices\n if [ \"$RUN_BEST_PRACTICES\" = true ]; then\n if [ $BEST_PRACTICES_RESULT -eq 0 ]; then\n echo -e \"Best Practices: ${GREEN}PASSED${NC}\"\n else\n echo -e \"Best Practices: ${YELLOW}WARNINGS${NC}\"\n fi\n fi\n\n # Security scan\n if [ \"$RUN_SECURITY\" = true ]; then\n if [ $SECURITY_RESULT -eq 0 ]; then\n echo -e \"Security Scan: ${GREEN}PASSED${NC}\"\n else\n echo -e \"Security Scan: ${RED}FAILED${NC}\"\n fi\n fi\n\n # Local testing\n if [ \"$RUN_TEST\" = true ]; then\n if [ $TEST_RESULT -eq 0 ]; then\n echo -e \"Local Testing: ${GREEN}PASSED${NC}\"\n else\n echo -e \"Local Testing: ${YELLOW}WARNINGS${NC}\"\n fi\n fi\n\n echo \"\"\n echo \"════════════════════════════════════════════════════════════════════════════════\"\n echo \"\"\nfi\n\n# Determine exit code\nEXIT_CODE=0\n\nif [ $SYNTAX_RESULT -ne 0 ] || [ $SECURITY_RESULT -ne 0 ]; then\n EXIT_CODE=1\nfi\n\nif [ \"$STRICT_MODE\" = true ] && [ $BEST_PRACTICES_RESULT -ne 0 ]; then\n EXIT_CODE=1\nfi\n\n# Final message\nif [ \"$JSON_OUTPUT\" = false ]; then\n if [ $EXIT_CODE -eq 0 ]; then\n echo -e \"${GREEN}✓ All validation checks passed${NC}\"\n else\n echo -e \"${RED}✗ Validation failed with errors${NC}\"\n fi\n echo \"\"\nfi\n\nexit $EXIT_CODE\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":16004,"content_sha256":"322de62c16db0ee9a21c678ce5ff0b4695ac443f0a1bf9f34517d65199abe910"},{"filename":"scripts/validate_syntax.py","content":"#!/usr/bin/env python3\n\"\"\"\nGitLab CI/CD Syntax Validator\n\nThis script validates GitLab CI/CD YAML files for:\n- Valid YAML syntax\n- GitLab CI schema compliance\n- Required fields and structure\n- Job naming conventions\n- Stage references\n- Dependency references\n\"\"\"\n\nimport sys\nimport yaml\nimport re\nimport json\nfrom pathlib import Path\nfrom typing import Dict, List, Any, Tuple, Set\nfrom collections import defaultdict\n\n\nclass ValidationError:\n \"\"\"Represents a validation error or warning\"\"\"\n\n def __init__(self, severity: str, line: int, message: str, rule: str):\n self.severity = severity # 'error', 'warning', 'info'\n self.line = line\n self.message = message\n self.rule = rule\n\n def __str__(self):\n return f\"{self.severity.upper()}: Line {self.line}: {self.message} [{self.rule}]\"\n\n def to_dict(self) -> Dict[str, Any]:\n \"\"\"Convert to dictionary for JSON output\"\"\"\n return {\n 'severity': self.severity,\n 'line': self.line,\n 'message': self.message,\n 'rule': self.rule\n }\n\n\nclass GitLabCIValidator:\n \"\"\"Validates GitLab CI/CD configuration files\"\"\"\n\n # Reserved keywords that cannot be used as job names\n RESERVED_KEYWORDS = {\n 'image', 'services', 'stages', 'types', 'before_script',\n 'after_script', 'variables', 'cache', 'include', 'pages',\n 'default', 'workflow', 'spec'\n }\n\n # Global keywords that can appear at the top level\n GLOBAL_KEYWORDS = {\n 'default', 'include', 'stages', 'variables', 'workflow',\n 'spec', 'pages'\n }\n\n # Valid job keywords\n JOB_KEYWORDS = {\n 'script', 'image', 'services', 'before_script', 'after_script',\n 'stage', 'only', 'except', 'rules', 'tags', 'allow_failure',\n 'when', 'dependencies', 'needs', 'artifacts', 'cache',\n 'environment', 'coverage', 'retry', 'timeout', 'parallel',\n 'trigger', 'include', 'extends', 'variables', 'interruptible',\n 'resource_group', 'release', 'secrets', 'identity',\n 'manual_confirmation', 'inherit', 'pages', 'dast_configuration',\n 'run', 'hooks', 'id_tokens'\n }\n\n # Valid when values\n VALID_WHEN_VALUES = {\n 'on_success', 'on_failure', 'always', 'manual', 'delayed', 'never'\n }\n\n def __init__(self, file_path: str):\n self.file_path = Path(file_path)\n self.errors: List[ValidationError] = []\n self.config: Dict[str, Any] = {}\n self.line_map: Dict[Any, int] = {}\n\n def validate(self) -> Tuple[bool, List[ValidationError]]:\n \"\"\"Run all validations and return results\"\"\"\n\n # Step 1: Load and parse YAML\n if not self._load_yaml():\n return False, self.errors\n\n # Step 2: Validate structure\n self._validate_structure()\n\n # Step 3: Validate stages\n self._validate_stages()\n\n # Step 4: Validate jobs\n self._validate_jobs()\n\n # Step 5: Validate dependencies\n self._validate_dependencies()\n\n # Step 6: Validate rules and conditions\n self._validate_rules()\n\n # Step 7: Validate GitLab CI limits\n self._validate_gitlab_limits()\n\n # Step 8: Validate extends relationships\n self._validate_extends_relationships()\n\n # Step 9: Validate include configurations\n self._validate_includes()\n\n # Determine if validation passed (no errors, warnings are ok)\n has_errors = any(e.severity == 'error' for e in self.errors)\n return not has_errors, self.errors\n\n def _load_yaml(self) -> bool:\n \"\"\"Load and parse YAML file\"\"\"\n try:\n with open(self.file_path, 'r') as f:\n content = f.read()\n\n # Parse YAML\n self.config = yaml.safe_load(content)\n\n if self.config is None:\n self.errors.append(ValidationError(\n 'error', 1, 'Empty or invalid YAML file', 'yaml-empty'\n ))\n return False\n\n if not isinstance(self.config, dict):\n self.errors.append(ValidationError(\n 'error', 1, 'Root must be a dictionary/object', 'yaml-invalid-root'\n ))\n return False\n\n # Build line number map (approximate - YAML doesn't provide exact line numbers)\n self._build_line_map(content)\n\n return True\n\n except yaml.YAMLError as e:\n line = getattr(e, 'problem_mark', None)\n line_num = line.line + 1 if line else 1\n self.errors.append(ValidationError(\n 'error', line_num, f'YAML syntax error: {str(e)}', 'yaml-syntax'\n ))\n return False\n except FileNotFoundError:\n self.errors.append(ValidationError(\n 'error', 0, f'File not found: {self.file_path}', 'file-not-found'\n ))\n return False\n except Exception as e:\n self.errors.append(ValidationError(\n 'error', 0, f'Error reading file: {str(e)}', 'file-read-error'\n ))\n return False\n\n def _build_line_map(self, content: str):\n \"\"\"Build enhanced line number map for error reporting\"\"\"\n lines = content.split('\\n')\n current_line = 1\n\n for line in lines:\n # Extract key from line if it looks like a YAML key (with any indentation)\n match = re.match(r'^(\\s*)([a-zA-Z0-9_-]+):', line)\n if match:\n indent_level = len(match.group(1))\n key = match.group(2)\n\n # Store both the base key and indented versions for better lookups\n self.line_map[key] = current_line\n\n # Also store with indent prefix for nested keys\n indent_key = f\"{indent_level}:{key}\"\n self.line_map[indent_key] = current_line\n\n current_line += 1\n\n def _get_line(self, key: str) -> int:\n \"\"\"Get approximate line number for a key\"\"\"\n return self.line_map.get(key, 0)\n\n def _find_line_for_text(self, text: str) -> int:\n \"\"\"Find line number for specific text in file\"\"\"\n if not hasattr(self, '_file_content'):\n try:\n with open(self.file_path, 'r') as f:\n self._file_content = f.read().split('\\n')\n except:\n return 0\n\n for i, line in enumerate(self._file_content, 1):\n if text in line:\n return i\n return 0\n\n def _validate_structure(self):\n \"\"\"Validate overall structure\"\"\"\n\n # Check for common typos in global keywords\n common_typos = {\n 'stage': 'stages',\n 'include_': 'include',\n 'variable': 'variables'\n }\n\n for typo, correct in common_typos.items():\n if typo in self.config and correct not in self.config:\n self.errors.append(ValidationError(\n 'warning',\n self._get_line(typo),\n f\"Did you mean '{correct}' instead of '{typo}'?\",\n 'structure-typo'\n ))\n\n def _validate_stages(self):\n \"\"\"Validate stages configuration\"\"\"\n\n if 'stages' in self.config:\n stages = self.config['stages']\n\n if not isinstance(stages, list):\n self.errors.append(ValidationError(\n 'error',\n self._get_line('stages'),\n \"'stages' must be a list\",\n 'stages-not-list'\n ))\n return\n\n if not stages:\n self.errors.append(ValidationError(\n 'warning',\n self._get_line('stages'),\n \"Empty 'stages' list - using default stages\",\n 'stages-empty'\n ))\n\n # Check for duplicate stages\n seen = set()\n for stage in stages:\n if not isinstance(stage, str):\n self.errors.append(ValidationError(\n 'error',\n self._get_line('stages'),\n f\"Stage name must be a string, got {type(stage).__name__}\",\n 'stage-invalid-type'\n ))\n continue\n\n if stage in seen:\n self.errors.append(ValidationError(\n 'warning',\n self._get_line('stages'),\n f\"Duplicate stage '{stage}'\",\n 'stage-duplicate'\n ))\n seen.add(stage)\n\n def _validate_jobs(self):\n \"\"\"Validate all jobs\"\"\"\n\n defined_stages = set(self.config.get('stages', []))\n default_stages = {'build', 'test', 'deploy'}\n # .pre and .post are always valid GitLab reserved stages regardless of the stages list\n reserved_stages = {'.pre', '.post'}\n valid_stages = (defined_stages or default_stages) | reserved_stages\n\n for key, value in self.config.items():\n # Skip global keywords and hidden jobs\n if key in self.GLOBAL_KEYWORDS or key.startswith('.'):\n continue\n\n # This should be a job\n if not isinstance(value, dict):\n self.errors.append(ValidationError(\n 'error',\n self._get_line(key),\n f\"Job '{key}' must be a dictionary\",\n 'job-not-dict'\n ))\n continue\n\n self._validate_job(key, value, valid_stages)\n\n def _validate_job(self, job_name: str, job: Dict[str, Any], valid_stages: Set[str]):\n \"\"\"Validate a single job\"\"\"\n\n line = self._get_line(job_name)\n\n # Check for reserved keywords used as job names\n if job_name in self.RESERVED_KEYWORDS:\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"'{job_name}' is a reserved keyword and cannot be used as a job name\",\n 'job-reserved-keyword'\n ))\n\n # Check job name format\n if not re.match(r'^[a-zA-Z0-9:_. -]+

GitLab CI/CD Validator Comprehensive toolkit for validating, linting, testing, and securing configurations. Trigger Phrases Use this skill when requests include intent like: - "Validate this " - "Why is this GitLab pipeline failing?" - "Run a security review for our GitLab CI" - "Check pipeline best practices" - "Lint GitLab CI config before merge" Setup And Prerequisites (Run First) All commands below assume repository root as current working directory. Use one canonical command path for orchestration: Optional local execution tooling (for ): Quick Start Commands Deterministic Validation Wor…

, job_name):\n self.errors.append(ValidationError(\n 'warning',\n line,\n f\"Job name '{job_name}' contains unusual characters\",\n 'job-name-format'\n ))\n\n # Check for 'script' keyword (required unless it's a trigger/include job)\n has_script = 'script' in job\n has_trigger = 'trigger' in job\n has_extends = 'extends' in job\n\n if not has_script and not has_trigger and not has_extends:\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Job '{job_name}' must have 'script', 'trigger', or 'extends' keyword\",\n 'job-missing-script'\n ))\n\n # Validate 'stage' reference\n if 'stage' in job:\n stage = job['stage']\n if not isinstance(stage, str):\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Job '{job_name}': 'stage' must be a string\",\n 'job-stage-invalid-type'\n ))\n elif stage not in valid_stages:\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Job '{job_name}': references undefined stage '{stage}'\",\n 'job-stage-undefined'\n ))\n\n # Validate 'when' keyword\n if 'when' in job:\n when = job['when']\n if when not in self.VALID_WHEN_VALUES:\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Job '{job_name}': invalid 'when' value '{when}'. \"\n f\"Must be one of: {', '.join(sorted(self.VALID_WHEN_VALUES))}\",\n 'job-when-invalid'\n ))\n\n # Check for mixing 'rules' with 'only'/'except'\n has_rules = 'rules' in job\n has_only = 'only' in job\n has_except = 'except' in job\n\n if has_rules and (has_only or has_except):\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Job '{job_name}': cannot use 'rules' with 'only'/'except'\",\n 'job-rules-conflict'\n ))\n\n # Warn about deprecated only/except\n if has_only or has_except:\n self.errors.append(ValidationError(\n 'warning',\n line,\n f\"Job '{job_name}': 'only'/'except' are deprecated, use 'rules' instead\",\n 'job-deprecated-only-except'\n ))\n\n # Validate unknown keywords\n for keyword in job.keys():\n if keyword not in self.JOB_KEYWORDS:\n self.errors.append(ValidationError(\n 'warning',\n line,\n f\"Job '{job_name}': unknown keyword '{keyword}'\",\n 'job-unknown-keyword'\n ))\n\n # Validate script format\n if 'script' in job:\n script = job['script']\n if not isinstance(script, (str, list)):\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Job '{job_name}': 'script' must be a string or list\",\n 'job-script-invalid-type'\n ))\n elif isinstance(script, list):\n for i, cmd in enumerate(script):\n if not isinstance(cmd, str):\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Job '{job_name}': script command #{i+1} must be a string\",\n 'job-script-item-invalid'\n ))\n\n # Validate artifacts\n if 'artifacts' in job:\n self._validate_artifacts(job_name, job['artifacts'], line)\n\n # Validate cache\n if 'cache' in job:\n self._validate_cache(job_name, job['cache'], line)\n\n # Validate parallel\n if 'parallel' in job:\n self._validate_parallel(job_name, job['parallel'], line)\n\n # Validate hooks\n if 'hooks' in job:\n self._validate_hooks(job_name, job['hooks'], line)\n\n # Validate manual_confirmation\n if 'manual_confirmation' in job:\n self._validate_manual_confirmation(job_name, job['manual_confirmation'], line)\n\n def _validate_artifacts(self, job_name: str, artifacts: Any, line: int):\n \"\"\"Validate artifacts configuration\"\"\"\n\n if not isinstance(artifacts, dict):\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Job '{job_name}': 'artifacts' must be a dictionary\",\n 'artifacts-not-dict'\n ))\n return\n\n valid_artifact_keywords = {\n 'paths', 'exclude', 'expire_in', 'expose_as', 'name',\n 'untracked', 'when', 'reports', 'public'\n }\n\n for keyword in artifacts.keys():\n if keyword not in valid_artifact_keywords:\n self.errors.append(ValidationError(\n 'warning',\n line,\n f\"Job '{job_name}': unknown artifacts keyword '{keyword}'\",\n 'artifacts-unknown-keyword'\n ))\n\n # Check for 'paths' (commonly required)\n if 'paths' not in artifacts and 'reports' not in artifacts:\n self.errors.append(ValidationError(\n 'warning',\n line,\n f\"Job '{job_name}': artifacts should have 'paths' or 'reports'\",\n 'artifacts-no-paths'\n ))\n\n def _validate_cache(self, job_name: str, cache: Any, line: int):\n \"\"\"Validate cache configuration\"\"\"\n\n if not isinstance(cache, (dict, list)):\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Job '{job_name}': 'cache' must be a dictionary or list\",\n 'cache-invalid-type'\n ))\n return\n\n caches = [cache] if isinstance(cache, dict) else cache\n\n for cache_item in caches:\n if not isinstance(cache_item, dict):\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Job '{job_name}': cache item must be a dictionary\",\n 'cache-item-not-dict'\n ))\n continue\n\n valid_cache_keywords = {\n 'paths', 'key', 'untracked', 'policy', 'when', 'fallback_keys'\n }\n\n for keyword in cache_item.keys():\n if keyword not in valid_cache_keywords:\n self.errors.append(ValidationError(\n 'warning',\n line,\n f\"Job '{job_name}': unknown cache keyword '{keyword}'\",\n 'cache-unknown-keyword'\n ))\n\n # Validate policy\n if 'policy' in cache_item:\n policy = cache_item['policy']\n valid_policies = {'pull', 'push', 'pull-push'}\n if policy not in valid_policies:\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Job '{job_name}': invalid cache policy '{policy}'. \"\n f\"Must be one of: {', '.join(sorted(valid_policies))}\",\n 'cache-invalid-policy'\n ))\n\n def _validate_parallel(self, job_name: str, parallel: Any, line: int):\n \"\"\"Validate parallel configuration\"\"\"\n\n # parallel can be an integer or a dict with matrix\n if isinstance(parallel, int):\n if parallel \u003c 2 or parallel > 200:\n self.errors.append(ValidationError(\n 'warning',\n line,\n f\"Job '{job_name}': parallel value {parallel} should be between 2 and 200\",\n 'parallel-invalid-range'\n ))\n elif isinstance(parallel, dict):\n if 'matrix' in parallel:\n matrix = parallel['matrix']\n if not isinstance(matrix, list):\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Job '{job_name}': parallel:matrix must be a list\",\n 'parallel-matrix-not-list'\n ))\n else:\n # Validate matrix items\n for i, matrix_item in enumerate(matrix):\n if not isinstance(matrix_item, dict):\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Job '{job_name}': parallel:matrix item #{i+1} must be a dictionary\",\n 'parallel-matrix-item-invalid'\n ))\n continue\n\n # Each matrix item should have at least one variable with a list of values\n for var_name, var_values in matrix_item.items():\n if not isinstance(var_values, list):\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Job '{job_name}': parallel:matrix variable '{var_name}' must have a list of values\",\n 'parallel-matrix-var-not-list'\n ))\n elif not var_values:\n self.errors.append(ValidationError(\n 'warning',\n line,\n f\"Job '{job_name}': parallel:matrix variable '{var_name}' has empty values list\",\n 'parallel-matrix-var-empty'\n ))\n else:\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Job '{job_name}': parallel must be an integer or have 'matrix' key\",\n 'parallel-invalid-type'\n ))\n else:\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Job '{job_name}': parallel must be an integer or dictionary\",\n 'parallel-invalid-type'\n ))\n\n def _validate_hooks(self, job_name: str, hooks: Any, line: int):\n \"\"\"Validate hooks configuration\"\"\"\n\n if not isinstance(hooks, dict):\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Job '{job_name}': 'hooks' must be a dictionary\",\n 'hooks-not-dict'\n ))\n return\n\n valid_hook_keywords = {'pre_get_sources_script'}\n\n for keyword in hooks.keys():\n if keyword not in valid_hook_keywords:\n self.errors.append(ValidationError(\n 'warning',\n line,\n f\"Job '{job_name}': unknown hooks keyword '{keyword}'\",\n 'hooks-unknown-keyword'\n ))\n\n # Validate pre_get_sources_script\n if 'pre_get_sources_script' in hooks:\n script = hooks['pre_get_sources_script']\n if not isinstance(script, (str, list)):\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Job '{job_name}': hooks:pre_get_sources_script must be a string or list\",\n 'hooks-script-invalid-type'\n ))\n elif isinstance(script, list):\n for i, cmd in enumerate(script):\n if not isinstance(cmd, str):\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Job '{job_name}': hooks:pre_get_sources_script command #{i+1} must be a string\",\n 'hooks-script-item-invalid'\n ))\n\n def _validate_manual_confirmation(self, job_name: str, manual_confirmation: Any, line: int):\n \"\"\"Validate manual_confirmation configuration\"\"\"\n\n if not isinstance(manual_confirmation, str):\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Job '{job_name}': 'manual_confirmation' must be a string\",\n 'manual-confirmation-invalid-type'\n ))\n return\n\n # Check if job has when: manual (required for manual_confirmation)\n job = self.config.get(job_name, {})\n if job.get('when') != 'manual':\n self.errors.append(ValidationError(\n 'warning',\n line,\n f\"Job '{job_name}': 'manual_confirmation' requires 'when: manual'\",\n 'manual-confirmation-no-manual-when'\n ))\n\n def _validate_dependencies(self):\n \"\"\"Validate job dependencies\"\"\"\n\n # Collect all job names\n all_jobs = {\n key for key in self.config.keys()\n if key not in self.GLOBAL_KEYWORDS and isinstance(self.config[key], dict)\n }\n\n for job_name, job in self.config.items():\n if job_name in self.GLOBAL_KEYWORDS or not isinstance(job, dict):\n continue\n\n line = self._get_line(job_name)\n\n # Validate 'dependencies'\n if 'dependencies' in job:\n deps = job['dependencies']\n if not isinstance(deps, list):\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Job '{job_name}': 'dependencies' must be a list\",\n 'dependencies-not-list'\n ))\n else:\n for dep in deps:\n if dep not in all_jobs:\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Job '{job_name}': references undefined job '{dep}' in dependencies\",\n 'dependencies-undefined-job'\n ))\n\n # Validate 'needs'\n if 'needs' in job:\n needs = job['needs']\n if isinstance(needs, list):\n for need in needs:\n if isinstance(need, str):\n if need not in all_jobs:\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Job '{job_name}': references undefined job '{need}' in needs\",\n 'needs-undefined-job'\n ))\n elif isinstance(need, dict):\n if 'job' in need and need['job'] not in all_jobs:\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Job '{job_name}': references undefined job '{need['job']}' in needs\",\n 'needs-undefined-job'\n ))\n elif not isinstance(needs, dict):\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Job '{job_name}': 'needs' must be a list or dictionary\",\n 'needs-invalid-type'\n ))\n\n # Validate 'extends'\n if 'extends' in job:\n extends = job['extends']\n extends_list = [extends] if isinstance(extends, str) else extends\n\n if isinstance(extends_list, list):\n for ext in extends_list:\n # Hidden jobs (templates) should start with '.'\n if not ext.startswith('.') and ext not in all_jobs:\n self.errors.append(ValidationError(\n 'warning',\n line,\n f\"Job '{job_name}': extends '{ext}' which is not defined. \"\n \"Template jobs should start with '.'\",\n 'extends-undefined'\n ))\n\n # Check for circular dependencies in 'needs'\n self._check_circular_dependencies(all_jobs)\n\n def _check_circular_dependencies(self, all_jobs: Set[str]):\n \"\"\"Check for circular dependencies in 'needs'\"\"\"\n\n def get_job_needs(job_name: str) -> Set[str]:\n \"\"\"Get the set of jobs that this job needs\"\"\"\n job = self.config.get(job_name, {})\n needs = job.get('needs', [])\n\n if isinstance(needs, dict):\n return set()\n\n if not isinstance(needs, list):\n return set()\n\n result = set()\n for need in needs:\n if isinstance(need, str):\n result.add(need)\n elif isinstance(need, dict) and 'job' in need:\n result.add(need['job'])\n\n return result\n\n def has_cycle(job_name: str, visited: Set[str], path: Set[str]) -> List[str]:\n \"\"\"Check for cycles using DFS. Returns cycle path if found.\"\"\"\n if job_name in path:\n # Found a cycle\n return [job_name]\n\n if job_name in visited:\n return []\n\n visited.add(job_name)\n path.add(job_name)\n\n for needed_job in get_job_needs(job_name):\n if needed_job not in all_jobs:\n continue # Skip undefined jobs (already reported)\n\n cycle = has_cycle(needed_job, visited, path)\n if cycle:\n cycle.append(job_name)\n return cycle\n\n path.remove(job_name)\n return []\n\n visited = set()\n for job_name in all_jobs:\n if job_name not in visited:\n cycle = has_cycle(job_name, visited, set())\n if cycle:\n # Reverse to get correct order\n cycle.reverse()\n cycle_str = ' -> '.join(cycle)\n self.errors.append(ValidationError(\n 'error',\n self._get_line(cycle[0]),\n f\"Circular dependency detected: {cycle_str}\",\n 'circular-dependency'\n ))\n break # Only report first cycle found\n\n def _validate_rules(self):\n \"\"\"Validate rules and conditions\"\"\"\n\n for job_name, job in self.config.items():\n if job_name in self.GLOBAL_KEYWORDS or not isinstance(job, dict):\n continue\n\n if 'rules' not in job:\n continue\n\n line = self._get_line(job_name)\n rules = job['rules']\n\n if not isinstance(rules, list):\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Job '{job_name}': 'rules' must be a list\",\n 'rules-not-list'\n ))\n continue\n\n for i, rule in enumerate(rules):\n if not isinstance(rule, dict):\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Job '{job_name}': rule #{i+1} must be a dictionary\",\n 'rule-not-dict'\n ))\n continue\n\n valid_rule_keywords = {\n 'if', 'changes', 'exists', 'when', 'allow_failure',\n 'variables', 'needs', 'start_in'\n }\n\n for keyword in rule.keys():\n if keyword not in valid_rule_keywords:\n self.errors.append(ValidationError(\n 'warning',\n line,\n f\"Job '{job_name}': unknown rule keyword '{keyword}'\",\n 'rule-unknown-keyword'\n ))\n\n # Validate 'when' in rules\n if 'when' in rule:\n when = rule['when']\n if when not in self.VALID_WHEN_VALUES:\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Job '{job_name}': invalid 'when' value in rule: '{when}'\",\n 'rule-when-invalid'\n ))\n\n def _validate_gitlab_limits(self):\n \"\"\"Validate GitLab CI/CD limits and constraints\"\"\"\n\n # GitLab CI/CD limits (as of GitLab 15.x+)\n MAX_JOBS = 500 # Maximum number of jobs per pipeline\n MAX_JOB_NAME_LENGTH = 255 # Maximum job name length\n MAX_NEEDS = 50 # Maximum needs dependencies per job\n\n # Count all jobs (excluding global keywords and hidden templates)\n all_jobs = {\n key: value for key, value in self.config.items()\n if key not in self.GLOBAL_KEYWORDS and isinstance(value, dict)\n }\n\n job_count = len(all_jobs)\n\n # Check total job count\n if job_count > MAX_JOBS:\n self.errors.append(ValidationError(\n 'error',\n 1,\n f\"Total job count ({job_count}) exceeds GitLab limit of {MAX_JOBS} jobs per pipeline\",\n 'gitlab-limit-max-jobs'\n ))\n elif job_count > MAX_JOBS * 0.8: # Warn at 80%\n self.errors.append(ValidationError(\n 'warning',\n 1,\n f\"Total job count ({job_count}) is approaching GitLab limit of {MAX_JOBS} jobs (>80%)\",\n 'gitlab-limit-max-jobs-warning'\n ))\n\n # Check individual job constraints\n for job_name, job in all_jobs.items():\n line = self._get_line(job_name)\n\n # Check job name length\n if len(job_name) > MAX_JOB_NAME_LENGTH:\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Job name '{job_name}' exceeds maximum length of {MAX_JOB_NAME_LENGTH} characters (current: {len(job_name)})\",\n 'gitlab-limit-job-name-length'\n ))\n\n # Check needs dependencies count\n if 'needs' in job:\n needs = job['needs']\n needs_count = 0\n\n if isinstance(needs, list):\n needs_count = len(needs)\n elif isinstance(needs, dict):\n # When needs is a dict, it can contain multiple jobs\n if 'job' in needs:\n needs_count = 1\n elif 'pipeline' in needs or 'project' in needs:\n needs_count = 1\n\n if needs_count > MAX_NEEDS:\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Job '{job_name}' has {needs_count} needs dependencies, exceeding GitLab limit of {MAX_NEEDS}\",\n 'gitlab-limit-max-needs'\n ))\n\n def _validate_extends_relationships(self):\n \"\"\"Validate extends relationships for circular references and depth\"\"\"\n\n MAX_EXTENDS_DEPTH = 11 # GitLab limit for extends chain depth\n\n # Collect all jobs and templates\n all_jobs = {\n key: value for key, value in self.config.items()\n if isinstance(value, dict)\n }\n\n def get_extends_list(job_name: str) -> List[str]:\n \"\"\"Get the list of templates/jobs this job extends\"\"\"\n job = self.config.get(job_name, {})\n extends = job.get('extends', [])\n\n if isinstance(extends, str):\n return [extends]\n elif isinstance(extends, list):\n return extends\n return []\n\n def check_circular_extends(job_name: str, visited: Set[str], path: Set[str]) -> List[str]:\n \"\"\"Check for circular extends using DFS. Returns cycle path if found.\"\"\"\n if job_name in path:\n # Found a cycle\n return [job_name]\n\n if job_name in visited:\n return []\n\n visited.add(job_name)\n path.add(job_name)\n\n for extended_job in get_extends_list(job_name):\n if extended_job not in all_jobs:\n continue # Skip undefined templates (already reported)\n\n cycle = check_circular_extends(extended_job, visited, path)\n if cycle:\n cycle.append(job_name)\n return cycle\n\n path.remove(job_name)\n return []\n\n def get_extends_depth(job_name: str, visited: Set[str] = None) -> int:\n \"\"\"Calculate the extends chain depth for a job\"\"\"\n if visited is None:\n visited = set()\n\n if job_name in visited:\n # Circular reference (already reported)\n return 0\n\n visited.add(job_name)\n\n extends_list = get_extends_list(job_name)\n if not extends_list:\n return 0\n\n max_depth = 0\n for extended_job in extends_list:\n if extended_job in all_jobs:\n depth = get_extends_depth(extended_job, visited.copy())\n max_depth = max(max_depth, depth)\n\n return max_depth + 1\n\n # Check for circular extends\n visited = set()\n for job_name in all_jobs.keys():\n if job_name not in visited:\n cycle = check_circular_extends(job_name, visited, set())\n if cycle:\n # Reverse to get correct order\n cycle.reverse()\n cycle_str = ' -> '.join(cycle)\n line = self._get_line(cycle[0])\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Circular extends detected: {cycle_str}\",\n 'circular-extends'\n ))\n break # Only report first cycle found\n\n # Check extends depth\n for job_name in all_jobs.keys():\n # Skip hidden templates (they're meant to be extended)\n if job_name.startswith('.'):\n continue\n\n depth = get_extends_depth(job_name)\n if depth > MAX_EXTENDS_DEPTH:\n line = self._get_line(job_name)\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Job '{job_name}' has extends chain depth of {depth}, exceeding GitLab limit of {MAX_EXTENDS_DEPTH}\",\n 'gitlab-limit-extends-depth'\n ))\n elif depth > MAX_EXTENDS_DEPTH * 0.8: # Warn at 80%\n line = self._get_line(job_name)\n self.errors.append(ValidationError(\n 'warning',\n line,\n f\"Job '{job_name}' has extends chain depth of {depth}, approaching GitLab limit of {MAX_EXTENDS_DEPTH} (>80%)\",\n 'gitlab-limit-extends-depth-warning'\n ))\n\n def _validate_includes(self):\n \"\"\"Validate include configurations including components, project, local, remote, and template\"\"\"\n\n if 'include' not in self.config:\n return\n\n includes = self.config['include']\n line = self._get_line('include')\n\n # Normalize to list\n if not isinstance(includes, list):\n includes = [includes]\n\n # GitLab 18.5+ limit: max 100 components per project\n MAX_COMPONENTS_PER_PROJECT = 100\n component_count = 0\n\n for i, inc in enumerate(includes):\n if inc is None:\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Include item #{i+1} is null\",\n 'include-null-item'\n ))\n continue\n\n # Include can be a string (shorthand for local file) or dict\n if isinstance(inc, str):\n # Shorthand for local file\n self._validate_local_include(inc, line, i+1)\n continue\n\n if not isinstance(inc, dict):\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Include item #{i+1} must be a string or dictionary, got {type(inc).__name__}\",\n 'include-invalid-type'\n ))\n continue\n\n # Determine include type\n include_types = []\n if 'component' in inc:\n include_types.append('component')\n if 'local' in inc:\n include_types.append('local')\n if 'remote' in inc:\n include_types.append('remote')\n if 'template' in inc:\n include_types.append('template')\n if 'project' in inc:\n include_types.append('project')\n if 'file' in inc and 'project' not in inc:\n # 'file' alone is invalid, must be with 'project'\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Include item #{i+1}: 'file' must be used with 'project'\",\n 'include-file-without-project'\n ))\n\n # Check that exactly one include type is specified\n if len(include_types) == 0:\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Include item #{i+1}: must specify one of: component, local, remote, template, or project\",\n 'include-no-type'\n ))\n continue\n elif len(include_types) > 1:\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Include item #{i+1}: cannot specify multiple include types: {', '.join(include_types)}\",\n 'include-multiple-types'\n ))\n continue\n\n # Validate based on type\n include_type = include_types[0]\n\n if include_type == 'component':\n component_count += 1\n self._validate_component_include(inc, line, i+1)\n elif include_type == 'local':\n self._validate_local_include(inc['local'], line, i+1)\n elif include_type == 'remote':\n self._validate_remote_include(inc, line, i+1)\n elif include_type == 'template':\n self._validate_template_include(inc, line, i+1)\n elif include_type == 'project':\n self._validate_project_include(inc, line, i+1)\n\n # Check component count limit\n if component_count > MAX_COMPONENTS_PER_PROJECT:\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Total component count ({component_count}) exceeds GitLab limit of {MAX_COMPONENTS_PER_PROJECT} components per project\",\n 'include-component-limit-exceeded'\n ))\n elif component_count > MAX_COMPONENTS_PER_PROJECT * 0.8: # Warn at 80%\n self.errors.append(ValidationError(\n 'warning',\n line,\n f\"Total component count ({component_count}) is approaching GitLab limit of {MAX_COMPONENTS_PER_PROJECT} components (>80%)\",\n 'include-component-limit-warning'\n ))\n\n def _validate_component_include(self, inc: Dict[str, Any], line: int, item_num: int):\n \"\"\"Validate include:component syntax (GitLab 16.x+)\"\"\"\n\n component = inc.get('component')\n\n if not isinstance(component, str):\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Include item #{item_num}: 'component' must be a string\",\n 'include-component-invalid-type'\n ))\n return\n\n # Component format: \u003cfqdn>/\u003cpath>@\u003cversion>\n # Examples:\n # - $CI_SERVER_FQDN/components/docker/[email protected]\n # - gitlab.com/components/docker/build@~latest\n # - $CI_SERVER_FQDN/org/project/[email protected]\n\n # Check for @ separator (version is required)\n if '@' not in component:\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Include item #{item_num}: component '{component}' must specify a version with '@version'\",\n 'include-component-no-version'\n ))\n return\n\n # Split into path and version\n component_parts = component.rsplit('@', 1)\n if len(component_parts) != 2:\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Include item #{item_num}: invalid component format '{component}'. Expected: \u003cfqdn>/\u003cpath>@\u003cversion>\",\n 'include-component-invalid-format'\n ))\n return\n\n component_path, version = component_parts\n\n # Validate component path format\n # Should have at least: \u003cfqdn>/\u003corg>/\u003cproject>\n # Can be variable like $CI_SERVER_FQDN or literal domain\n if component_path.startswith('

GitLab CI/CD Validator Comprehensive toolkit for validating, linting, testing, and securing configurations. Trigger Phrases Use this skill when requests include intent like: - "Validate this " - "Why is this GitLab pipeline failing?" - "Run a security review for our GitLab CI" - "Check pipeline best practices" - "Lint GitLab CI config before merge" Setup And Prerequisites (Run First) All commands below assume repository root as current working directory. Use one canonical command path for orchestration: Optional local execution tooling (for ): Quick Start Commands Deterministic Validation Wor…

):\n # Variable reference - check it's a valid variable name\n var_match = re.match(r'^\\$\\{?[A-Z_][A-Z0-9_]*\\}?', component_path)\n if not var_match:\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Include item #{item_num}: invalid variable in component path '{component_path}'\",\n 'include-component-invalid-variable'\n ))\n return\n # Extract the rest after variable\n remaining_path = component_path[var_match.end():]\n if not remaining_path.startswith('/'):\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Include item #{item_num}: component path must have '/' after variable: '{component_path}'\",\n 'include-component-missing-slash'\n ))\n else:\n # Literal domain/path\n # Should match: domain.com/org/project or similar\n if '/' not in component_path:\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Include item #{item_num}: component path must include organization and project: '{component_path}'\",\n 'include-component-incomplete-path'\n ))\n\n # Validate version format\n # Can be: 1.0.0, ~latest, ~1.0, 1, etc.\n if version == '~latest':\n # Valid: ~latest for absolute latest version\n pass\n elif version.startswith('~'):\n # Partial semantic version like ~1.0 (matches latest 1.0.x)\n version_pattern = version[1:] # Remove ~\n # Should be numeric with optional dots\n if not re.match(r'^\\d+(\\.\\d+)*

GitLab CI/CD Validator Comprehensive toolkit for validating, linting, testing, and securing configurations. Trigger Phrases Use this skill when requests include intent like: - "Validate this " - "Why is this GitLab pipeline failing?" - "Run a security review for our GitLab CI" - "Check pipeline best practices" - "Lint GitLab CI config before merge" Setup And Prerequisites (Run First) All commands below assume repository root as current working directory. Use one canonical command path for orchestration: Optional local execution tooling (for ): Quick Start Commands Deterministic Validation Wor…

, version_pattern):\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Include item #{item_num}: invalid version pattern '{version}'. Expected: ~latest, ~1.0, or semantic version\",\n 'include-component-invalid-version-pattern'\n ))\n else:\n # Semantic version: 1.0.0, 1.0, or 1\n if not re.match(r'^\\d+(\\.\\d+){0,2}

GitLab CI/CD Validator Comprehensive toolkit for validating, linting, testing, and securing configurations. Trigger Phrases Use this skill when requests include intent like: - "Validate this " - "Why is this GitLab pipeline failing?" - "Run a security review for our GitLab CI" - "Check pipeline best practices" - "Lint GitLab CI config before merge" Setup And Prerequisites (Run First) All commands below assume repository root as current working directory. Use one canonical command path for orchestration: Optional local execution tooling (for ): Quick Start Commands Deterministic Validation Wor…

, version):\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Include item #{item_num}: invalid semantic version '{version}'. Expected: X.Y.Z, X.Y, or X\",\n 'include-component-invalid-semver'\n ))\n\n # Validate inputs if present\n if 'inputs' in inc:\n inputs = inc['inputs']\n if not isinstance(inputs, dict):\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Include item #{item_num}: 'inputs' must be a dictionary\",\n 'include-component-inputs-invalid-type'\n ))\n\n # Check for invalid keywords with component\n valid_component_keywords = {'component', 'inputs', 'rules'}\n for keyword in inc.keys():\n if keyword not in valid_component_keywords:\n self.errors.append(ValidationError(\n 'warning',\n line,\n f\"Include item #{item_num}: unknown keyword '{keyword}' for component include\",\n 'include-component-unknown-keyword'\n ))\n\n def _validate_local_include(self, local_path: Any, line: int, item_num: int):\n \"\"\"Validate include:local syntax\"\"\"\n\n if not isinstance(local_path, str):\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Include item #{item_num}: 'local' must be a string\",\n 'include-local-invalid-type'\n ))\n return\n\n # Local path should start with / for absolute or ./ for relative\n if not local_path.startswith(('/','.')):\n self.errors.append(ValidationError(\n 'warning',\n line,\n f\"Include item #{item_num}: local path '{local_path}' should start with '/' or './'\",\n 'include-local-path-format'\n ))\n\n # Should end with .yml or .yaml\n if not local_path.endswith(('.yml', '.yaml')):\n self.errors.append(ValidationError(\n 'warning',\n line,\n f\"Include item #{item_num}: local path '{local_path}' should end with .yml or .yaml\",\n 'include-local-file-extension'\n ))\n\n def _validate_remote_include(self, inc: Dict[str, Any], line: int, item_num: int):\n \"\"\"Validate include:remote syntax\"\"\"\n\n remote = inc.get('remote')\n\n if not isinstance(remote, str):\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Include item #{item_num}: 'remote' must be a string URL\",\n 'include-remote-invalid-type'\n ))\n return\n\n # Should be a valid URL\n if not remote.startswith(('http://', 'https://')):\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Include item #{item_num}: remote URL must start with http:// or https://\",\n 'include-remote-invalid-url'\n ))\n\n # Check for valid keywords with remote\n valid_remote_keywords = {'remote', 'rules'}\n for keyword in inc.keys():\n if keyword not in valid_remote_keywords:\n self.errors.append(ValidationError(\n 'warning',\n line,\n f\"Include item #{item_num}: unknown keyword '{keyword}' for remote include\",\n 'include-remote-unknown-keyword'\n ))\n\n def _validate_template_include(self, inc: Dict[str, Any], line: int, item_num: int):\n \"\"\"Validate include:template syntax\"\"\"\n\n template = inc.get('template')\n\n if not isinstance(template, str):\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Include item #{item_num}: 'template' must be a string\",\n 'include-template-invalid-type'\n ))\n return\n\n # Template should end with .yml or .yaml\n if not template.endswith(('.yml', '.yaml', '.gitlab-ci.yml')):\n self.errors.append(ValidationError(\n 'warning',\n line,\n f\"Include item #{item_num}: template '{template}' should end with .yml or .yaml\",\n 'include-template-file-extension'\n ))\n\n # Common GitLab templates: Auto-DevOps.gitlab-ci.yml, Jobs/*.gitlab-ci.yml, Security/*.gitlab-ci.yml\n # These are in /lib/gitlab/ci/templates/\n # Just validate the format, don't check if template exists (that requires API access)\n\n # Check for valid keywords with template\n valid_template_keywords = {'template', 'rules'}\n for keyword in inc.keys():\n if keyword not in valid_template_keywords:\n self.errors.append(ValidationError(\n 'warning',\n line,\n f\"Include item #{item_num}: unknown keyword '{keyword}' for template include\",\n 'include-template-unknown-keyword'\n ))\n\n def _validate_project_include(self, inc: Dict[str, Any], line: int, item_num: int):\n \"\"\"Validate include:project syntax\"\"\"\n\n project = inc.get('project')\n\n if not isinstance(project, str):\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Include item #{item_num}: 'project' must be a string\",\n 'include-project-invalid-type'\n ))\n return\n\n # Project format should be: group/project or group/subgroup/project\n if '/' not in project:\n self.errors.append(ValidationError(\n 'warning',\n line,\n f\"Include item #{item_num}: project '{project}' should include group/project format\",\n 'include-project-format'\n ))\n\n # 'file' is required with 'project'\n if 'file' not in inc:\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Include item #{item_num}: 'file' is required when using 'project'\",\n 'include-project-missing-file'\n ))\n else:\n file_val = inc['file']\n # file can be a string or list of strings\n if isinstance(file_val, str):\n files = [file_val]\n elif isinstance(file_val, list):\n files = file_val\n else:\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Include item #{item_num}: 'file' must be a string or list of strings\",\n 'include-project-file-invalid-type'\n ))\n files = []\n\n # Validate each file path\n for file_path in files:\n if not isinstance(file_path, str):\n self.errors.append(ValidationError(\n 'error',\n line,\n f\"Include item #{item_num}: file path must be a string\",\n 'include-project-file-item-invalid'\n ))\n continue\n\n # File should start with / or ./\n if not file_path.startswith(('/', './')):\n self.errors.append(ValidationError(\n 'warning',\n line,\n f\"Include item #{item_num}: file path '{file_path}' should start with '/' or './'\",\n 'include-project-file-path-format'\n ))\n\n # Should end with .yml or .yaml\n if not file_path.endswith(('.yml', '.yaml')):\n self.errors.append(ValidationError(\n 'warning',\n line,\n f\"Include item #{item_num}: file path '{file_path}' should end with .yml or .yaml\",\n 'include-project-file-extension'\n ))\n\n # 'ref' is recommended for reproducibility (commit SHA, tag, or branch)\n if 'ref' not in inc:\n self.errors.append(ValidationError(\n 'warning',\n line,\n f\"Include item #{item_num}: consider specifying 'ref' (commit SHA, tag, or branch) for reproducibility\",\n 'include-project-no-ref'\n ))\n\n # Check for valid keywords with project\n valid_project_keywords = {'project', 'file', 'ref', 'rules'}\n for keyword in inc.keys():\n if keyword not in valid_project_keywords:\n self.errors.append(ValidationError(\n 'warning',\n line,\n f\"Include item #{item_num}: unknown keyword '{keyword}' for project include\",\n 'include-project-unknown-keyword'\n ))\n\n\ndef main():\n \"\"\"Main entry point\"\"\"\n\n if len(sys.argv) \u003c 2:\n print(\"Usage: validate_syntax.py \u003cgitlab-ci.yml> [--json]\", file=sys.stderr)\n sys.exit(1)\n\n file_path = sys.argv[1]\n json_output = '--json' in sys.argv\n\n validator = GitLabCIValidator(file_path)\n success, errors = validator.validate()\n\n # Group by severity\n by_severity = defaultdict(list)\n for error in errors:\n by_severity[error.severity].append(error)\n\n if json_output:\n # Output JSON format\n result = {\n 'validator': 'syntax',\n 'file': file_path,\n 'success': success,\n 'issues': [error.to_dict() for error in errors],\n 'summary': {\n 'errors': len(by_severity.get('error', [])),\n 'warnings': len(by_severity.get('warning', [])),\n 'info': len(by_severity.get('info', []))\n }\n }\n print(json.dumps(result, indent=2))\n else:\n # Output formatted text\n if errors:\n print(f\"\\n{'='*80}\")\n print(f\"Validation Results for: {file_path}\")\n print(f\"{'='*80}\\n\")\n\n # Print errors first, then warnings, then info\n for severity in ['error', 'warning', 'info']:\n if severity in by_severity:\n print(f\"\\n{severity.upper()}S ({len(by_severity[severity])}):\")\n print(\"-\" * 80)\n for error in by_severity[severity]:\n print(f\" {error}\")\n\n print(f\"\\n{'='*80}\")\n print(f\"Summary: {len(by_severity['error'])} errors, \"\n f\"{len(by_severity.get('warning', []))} warnings, \"\n f\"{len(by_severity.get('info', []))} info\")\n print(f\"{'='*80}\\n\")\n\n if success:\n print(f\"✓ Syntax validation passed for {file_path}\")\n else:\n print(f\"✗ Syntax validation failed for {file_path}\")\n\n sys.exit(0 if success else 1)\n\n\nif __name__ == '__main__':\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":58166,"content_sha256":"deab5ec04e005c43e04565b3d3d12fa53b9ded87dd07ba6bf88e0f661c9f156f"},{"filename":"tests/test_validators.py","content":"#!/usr/bin/env python3\n\"\"\"Regression tests for gitlab-ci-validator bug fixes and gap coverage.\n\nEach test class focuses on one bug or gap fix:\n - TestBug1StartIn : 'start_in' accepted as a valid rule keyword\n - TestBug2FallbackKeys : 'fallback_keys' accepted as a valid cache keyword\n - TestGap1ImageNoTag : images without a version tag are detected\n - TestGap2EchoSecrets : prefixed / brace-wrapped secret variables are detected\n - TestGap3ArtifactPaths : security-report filenames not false-flagged as sensitive\n\"\"\"\n\nimport json\nimport os\nimport subprocess\nimport sys\nimport tempfile\nimport textwrap\nimport unittest\nfrom pathlib import Path\n\n\nSKILL_DIR = Path(__file__).resolve().parent.parent\nSYNTAX_VALIDATOR = SKILL_DIR / \"scripts\" / \"validate_syntax.py\"\nSECURITY_CHECKER = SKILL_DIR / \"scripts\" / \"check_security.py\"\n\n\n# ---------------------------------------------------------------------------\n# Helpers\n# ---------------------------------------------------------------------------\n\ndef _run_syntax(yaml_text: str) -> tuple[subprocess.CompletedProcess, dict]:\n \"\"\"Write yaml_text to a temp file and run validate_syntax.py --json.\"\"\"\n with tempfile.NamedTemporaryFile(\n mode=\"w\", suffix=\".yml\", delete=False\n ) as f:\n f.write(textwrap.dedent(yaml_text).strip() + \"\\n\")\n path = f.name\n try:\n proc = subprocess.run(\n [sys.executable, str(SYNTAX_VALIDATOR), path, \"--json\"],\n capture_output=True,\n text=True,\n check=False,\n )\n result = json.loads(proc.stdout)\n finally:\n Path(path).unlink(missing_ok=True)\n return proc, result\n\n\ndef _run_security(yaml_text: str) -> tuple[subprocess.CompletedProcess, dict]:\n \"\"\"Write yaml_text to a temp file and run check_security.py --json.\"\"\"\n with tempfile.NamedTemporaryFile(\n mode=\"w\", suffix=\".yml\", delete=False\n ) as f:\n f.write(textwrap.dedent(yaml_text).strip() + \"\\n\")\n path = f.name\n try:\n proc = subprocess.run(\n [sys.executable, str(SECURITY_CHECKER), path, \"--json\"],\n capture_output=True,\n text=True,\n check=False,\n )\n result = json.loads(proc.stdout)\n finally:\n Path(path).unlink(missing_ok=True)\n return proc, result\n\n\ndef _issue_rules(result: dict) -> list[str]:\n \"\"\"Return list of rule IDs from a JSON result.\"\"\"\n return [issue[\"rule\"] for issue in result.get(\"issues\", [])]\n\n\ndef _issue_messages(result: dict) -> list[str]:\n \"\"\"Return list of messages from a JSON result.\"\"\"\n return [issue[\"message\"] for issue in result.get(\"issues\", [])]\n\n\n# ---------------------------------------------------------------------------\n# Bug 1 — 'start_in' is a valid rule keyword (required for when: delayed)\n# ---------------------------------------------------------------------------\n\nclass TestBug1StartIn(unittest.TestCase):\n \"\"\"'start_in' must not be flagged as an unknown rule keyword.\"\"\"\n\n _PIPELINE = \"\"\"\n stages:\n - deploy\n\n delayed-deploy:\n stage: deploy\n image: alpine:3.18\n script:\n - echo \"deploying after delay\"\n rules:\n - when: delayed\n start_in: 5 minutes\n \"\"\"\n\n def test_start_in_not_flagged_as_unknown_rule_keyword(self):\n \"\"\"'start_in' alongside 'when: delayed' must produce no unknown-rule-keyword error.\"\"\"\n _, result = _run_syntax(self._PIPELINE)\n unknown_kw_errors = [\n issue for issue in result.get(\"issues\", [])\n if issue[\"rule\"] == \"rule-unknown-keyword\"\n and \"start_in\" in issue[\"message\"]\n ]\n self.assertEqual(\n unknown_kw_errors,\n [],\n f\"Unexpected 'start_in' keyword errors: {unknown_kw_errors}\",\n )\n\n def test_pipeline_with_only_start_in_rule_passes(self):\n \"\"\"A pipeline whose only rule keyword is start_in (with when: delayed) must be valid.\"\"\"\n _, result = _run_syntax(self._PIPELINE)\n errors = [i for i in result.get(\"issues\", []) if i[\"severity\"] == \"error\"]\n self.assertEqual(\n errors,\n [],\n f\"Unexpected errors in delayed-job pipeline: {errors}\",\n )\n\n\n# ---------------------------------------------------------------------------\n# Bug 2 — 'fallback_keys' is a valid cache keyword (GitLab ≥ 15.3)\n# ---------------------------------------------------------------------------\n\nclass TestBug2FallbackKeys(unittest.TestCase):\n \"\"\"'fallback_keys' must not be flagged as an unknown cache keyword.\"\"\"\n\n _PIPELINE = \"\"\"\n stages:\n - build\n\n build-job:\n stage: build\n image: alpine:3.18\n script:\n - make build\n cache:\n key: $CI_COMMIT_REF_SLUG\n fallback_keys:\n - $CI_DEFAULT_BRANCH\n paths:\n - .cache/\n \"\"\"\n\n def test_fallback_keys_not_flagged_as_unknown_cache_keyword(self):\n \"\"\"'fallback_keys' in cache must produce no unknown-cache-keyword error.\"\"\"\n _, result = _run_syntax(self._PIPELINE)\n unknown_kw_errors = [\n issue for issue in result.get(\"issues\", [])\n if issue[\"rule\"] == \"cache-unknown-keyword\"\n and \"fallback_keys\" in issue[\"message\"]\n ]\n self.assertEqual(\n unknown_kw_errors,\n [],\n f\"Unexpected 'fallback_keys' keyword errors: {unknown_kw_errors}\",\n )\n\n def test_pipeline_with_fallback_keys_passes(self):\n \"\"\"A pipeline using fallback_keys in cache must not have any errors.\"\"\"\n _, result = _run_syntax(self._PIPELINE)\n errors = [i for i in result.get(\"issues\", []) if i[\"severity\"] == \"error\"]\n self.assertEqual(\n errors,\n [],\n f\"Unexpected errors in fallback_keys pipeline: {errors}\",\n )\n\n\n# ---------------------------------------------------------------------------\n# Gap 1 — Images without a version tag are flagged (implicit :latest)\n# ---------------------------------------------------------------------------\n\nclass TestGap1ImageNoTag(unittest.TestCase):\n \"\"\"Security checker must detect images that carry no version tag.\"\"\"\n\n def test_image_with_no_tag_is_flagged(self):\n \"\"\"'ubuntu' (no tag) must produce an image-no-tag security warning.\"\"\"\n _, result = _run_security(\"\"\"\n build:\n image: ubuntu\n script:\n - echo hello\n \"\"\")\n self.assertIn(\n \"image-no-tag\",\n _issue_rules(result),\n \"Expected 'image-no-tag' issue for untagged image 'ubuntu'\",\n )\n\n def test_image_with_registry_and_no_tag_is_flagged(self):\n \"\"\"'registry.example.com/myapp' (no tag) must produce an image-no-tag warning.\"\"\"\n _, result = _run_security(\"\"\"\n build:\n image: registry.example.com/myapp\n script:\n - echo hello\n \"\"\")\n self.assertIn(\n \"image-no-tag\",\n _issue_rules(result),\n \"Expected 'image-no-tag' for registry image without tag\",\n )\n\n def test_image_pinned_by_digest_is_not_flagged(self):\n \"\"\"An image pinned via SHA256 digest must not produce any tag-related issue.\"\"\"\n _, result = _run_security(\"\"\"\n build:\n image: ubuntu@sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abc1\n script:\n - echo hello\n \"\"\")\n tag_issues = [\n rule for rule in _issue_rules(result)\n if rule in (\"image-no-tag\", \"image-latest-tag\")\n ]\n self.assertEqual(\n tag_issues,\n [],\n f\"Digest-pinned image must not produce tag issues, got: {tag_issues}\",\n )\n\n def test_digest_pinned_image_keeps_unknown_registry_check(self):\n \"\"\"Digest pinning must not suppress unknown-registry warnings.\"\"\"\n _, result = _run_security(\"\"\"\n build:\n image: registry.internal.example/team/app@sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abc1\n script:\n - echo hello\n \"\"\")\n rules = _issue_rules(result)\n self.assertIn(\n \"image-unknown-registry\",\n rules,\n \"Expected unknown-registry warning even when image is digest pinned\",\n )\n self.assertNotIn(\"image-no-tag\", rules)\n self.assertNotIn(\"image-latest-tag\", rules)\n\n def test_image_with_explicit_tag_is_not_flagged(self):\n \"\"\"'ubuntu:22.04' (explicit tag) must not produce any image-no-tag issue.\"\"\"\n _, result = _run_security(\"\"\"\n build:\n image: ubuntu:22.04\n script:\n - echo hello\n \"\"\")\n self.assertNotIn(\n \"image-no-tag\",\n _issue_rules(result),\n \"Explicitly tagged image must not produce 'image-no-tag'\",\n )\n\n def test_image_variable_reference_is_not_flagged(self):\n \"\"\"An image set via a CI variable ($IMAGE) must not produce a false 'image-no-tag'.\"\"\"\n _, result = _run_security(\"\"\"\n build:\n image: $MY_CUSTOM_IMAGE\n script:\n - echo hello\n \"\"\")\n self.assertNotIn(\n \"image-no-tag\",\n _issue_rules(result),\n \"Variable-reference image must not produce 'image-no-tag'\",\n )\n\n\n# ---------------------------------------------------------------------------\n# Gap 2 — Prefixed/brace-wrapped secret variables are detected in echo\n# ---------------------------------------------------------------------------\n\nclass TestGap2EchoSecrets(unittest.TestCase):\n \"\"\"Security checker must detect secret-variable echoes regardless of name prefix.\"\"\"\n\n def _secret_warning_rules(self, result: dict) -> list[str]:\n return [\n i[\"rule\"] for i in result.get(\"issues\", [])\n if \"echo\" in i[\"message\"].lower() or \"print\" in i[\"message\"].lower()\n or \"secret\" in i[\"message\"].lower() or \"password\" in i[\"message\"].lower()\n ]\n\n def test_echo_db_password_is_detected(self):\n \"\"\"'echo $DB_PASSWORD' must be flagged as echoing a secret.\"\"\"\n _, result = _run_security(\"\"\"\n build:\n image: alpine:3.18\n script:\n - echo $DB_PASSWORD\n \"\"\")\n self.assertIn(\n \"secret-in-logs\",\n _issue_rules(result),\n \"Expected secret-in-logs for 'echo $DB_PASSWORD'\",\n )\n\n def test_echo_my_secret_is_detected(self):\n \"\"\"'echo $MY_SECRET' (prefixed variable) must be flagged.\"\"\"\n _, result = _run_security(\"\"\"\n build:\n image: alpine:3.18\n script:\n - echo $MY_SECRET\n \"\"\")\n self.assertIn(\n \"secret-in-logs\",\n _issue_rules(result),\n \"Expected secret-in-logs for 'echo $MY_SECRET'\",\n )\n\n def test_echo_brace_wrapped_token_is_detected(self):\n \"\"\"'echo ${API_TOKEN}' (brace-wrapped) must be flagged.\"\"\"\n _, result = _run_security(\"\"\"\n build:\n image: alpine:3.18\n script:\n - echo ${API_TOKEN}\n \"\"\")\n self.assertIn(\n \"secret-in-logs\",\n _issue_rules(result),\n \"Expected secret-in-logs for 'echo ${API_TOKEN}'\",\n )\n\n def test_echo_app_password_is_detected(self):\n \"\"\"'echo $APP_PASSWORD' must be flagged.\"\"\"\n _, result = _run_security(\"\"\"\n build:\n image: alpine:3.18\n script:\n - echo $APP_PASSWORD\n \"\"\")\n self.assertIn(\n \"secret-in-logs\",\n _issue_rules(result),\n \"Expected secret-in-logs for 'echo $APP_PASSWORD'\",\n )\n\n def test_echo_ssh_private_key_is_detected(self):\n \"\"\"'echo $SSH_PRIVATE_KEY' must be flagged.\"\"\"\n _, result = _run_security(\"\"\"\n build:\n image: alpine:3.18\n script:\n - echo $SSH_PRIVATE_KEY\n \"\"\")\n self.assertIn(\n \"secret-in-logs\",\n _issue_rules(result),\n \"Expected secret-in-logs for 'echo $SSH_PRIVATE_KEY'\",\n )\n\n def test_echo_signing_key_is_detected(self):\n \"\"\"'echo ${SIGNING_KEY}' must be flagged.\"\"\"\n _, result = _run_security(\"\"\"\n build:\n image: alpine:3.18\n script:\n - echo ${SIGNING_KEY}\n \"\"\")\n self.assertIn(\n \"secret-in-logs\",\n _issue_rules(result),\n \"Expected secret-in-logs for 'echo ${SIGNING_KEY}'\",\n )\n\n def test_echo_plain_var_is_not_flagged(self):\n \"\"\"'echo $BUILD_VERSION' (non-secret variable) must not produce a false positive.\"\"\"\n _, result = _run_security(\"\"\"\n build:\n image: alpine:3.18\n script:\n - echo $BUILD_VERSION\n \"\"\")\n self.assertNotIn(\n \"secret-in-logs\",\n _issue_rules(result),\n \"Non-secret variable must not trigger secret-in-logs\",\n )\n\n def test_echo_cache_key_is_not_flagged(self):\n \"\"\"'echo $CACHE_KEY' should not be treated as a secret by key-name matching.\"\"\"\n _, result = _run_security(\"\"\"\n build:\n image: alpine:3.18\n script:\n - echo $CACHE_KEY\n \"\"\")\n self.assertNotIn(\n \"secret-in-logs\",\n _issue_rules(result),\n \"CACHE_KEY must stay excluded from secret-in-logs detection\",\n )\n\n\n# ---------------------------------------------------------------------------\n# Gap 3 — Security-scan report artifact paths are not false-flagged\n# ---------------------------------------------------------------------------\n\nclass TestGap3ArtifactPaths(unittest.TestCase):\n \"\"\"artifact-sensitive-path must use component-aware matching.\"\"\"\n\n def test_secrets_report_json_is_not_flagged(self):\n \"\"\"'secrets-report.json' is a standard scan output and must not be flagged.\"\"\"\n _, result = _run_security(\"\"\"\n security-scan:\n image: alpine:3.18\n script:\n - run-scan\n artifacts:\n paths:\n - secrets-report.json\n \"\"\")\n artifact_issues = [\n i for i in result.get(\"issues\", [])\n if i[\"rule\"] == \"artifact-sensitive-path\"\n ]\n self.assertEqual(\n artifact_issues,\n [],\n f\"'secrets-report.json' must not be flagged as sensitive: {artifact_issues}\",\n )\n\n def test_gl_secret_detection_report_is_not_flagged(self):\n \"\"\"'gl-secret-detection-report.json' (GitLab built-in output) must not be flagged.\"\"\"\n _, result = _run_security(\"\"\"\n secret-detection:\n image: alpine:3.18\n script:\n - run-secret-detection\n artifacts:\n paths:\n - gl-secret-detection-report.json\n \"\"\")\n artifact_issues = [\n i for i in result.get(\"issues\", [])\n if i[\"rule\"] == \"artifact-sensitive-path\"\n ]\n self.assertEqual(\n artifact_issues,\n [],\n f\"'gl-secret-detection-report.json' must not be flagged: {artifact_issues}\",\n )\n\n def test_secrets_directory_is_flagged(self):\n \"\"\"A bare 'secrets/' directory in artifacts must still be flagged.\"\"\"\n _, result = _run_security(\"\"\"\n bad-job:\n image: alpine:3.18\n script:\n - make certs\n artifacts:\n paths:\n - secrets/\n \"\"\")\n artifact_issues = [\n i for i in result.get(\"issues\", [])\n if i[\"rule\"] == \"artifact-sensitive-path\"\n ]\n self.assertNotEqual(\n artifact_issues,\n [],\n \"A 'secrets/' directory must be flagged as sensitive\",\n )\n\n def test_credentials_directory_is_flagged(self):\n \"\"\"A bare 'credentials/' directory in artifacts must be flagged.\"\"\"\n _, result = _run_security(\"\"\"\n bad-job:\n image: alpine:3.18\n script:\n - generate-credentials\n artifacts:\n paths:\n - credentials/\n \"\"\")\n artifact_issues = [\n i for i in result.get(\"issues\", [])\n if i[\"rule\"] == \"artifact-sensitive-path\"\n ]\n self.assertNotEqual(\n artifact_issues,\n [],\n \"A 'credentials/' directory must be flagged as sensitive\",\n )\n\n def test_nested_secrets_file_is_flagged(self):\n \"\"\"A file directly inside a secrets/ directory must be flagged.\"\"\"\n _, result = _run_security(\"\"\"\n bad-job:\n image: alpine:3.18\n script:\n - make certs\n artifacts:\n paths:\n - config/secrets/private.key\n \"\"\")\n artifact_issues = [\n i for i in result.get(\"issues\", [])\n if i[\"rule\"] == \"artifact-sensitive-path\"\n ]\n self.assertNotEqual(\n artifact_issues,\n [],\n \"'config/secrets/private.key' must be flagged as sensitive\",\n )\n\n def test_dot_env_file_is_flagged(self):\n \"\"\"'.env' artifact must still be caught by the component-aware check.\"\"\"\n _, result = _run_security(\"\"\"\n bad-job:\n image: alpine:3.18\n script:\n - env > .env\n artifacts:\n paths:\n - .env\n \"\"\")\n artifact_issues = [\n i for i in result.get(\"issues\", [])\n if i[\"rule\"] == \"artifact-sensitive-path\"\n ]\n self.assertNotEqual(\n artifact_issues,\n [],\n \"'.env' artifact must be flagged as sensitive\",\n )\n\n\n# ---------------------------------------------------------------------------\n# Bug 3 — .pre and .post are always valid stages even when stages: is defined\n# ---------------------------------------------------------------------------\n\nclass TestBug3PrePostStages(unittest.TestCase):\n \"\"\".pre and .post must be valid stages regardless of the stages: list.\"\"\"\n\n _PIPELINE_WITH_POST = \"\"\"\n stages:\n - build\n - deploy\n\n build-job:\n stage: build\n image: alpine:3.18\n script:\n - make build\n\n cleanup:\n stage: .post\n image: alpine:3.18\n script:\n - echo \"cleanup\"\n when: always\n \"\"\"\n\n _PIPELINE_WITH_PRE = \"\"\"\n stages:\n - build\n\n setup:\n stage: .pre\n image: alpine:3.18\n script:\n - echo \"setup\"\n\n build-job:\n stage: build\n image: alpine:3.18\n script:\n - make build\n \"\"\"\n\n def test_post_stage_not_flagged_when_stages_defined(self):\n \"\"\"'.post' must not be flagged as undefined when a stages: list is present.\"\"\"\n _, result = _run_syntax(self._PIPELINE_WITH_POST)\n post_errors = [\n issue for issue in result.get(\"issues\", [])\n if issue[\"rule\"] == \"job-stage-undefined\" and \".post\" in issue[\"message\"]\n ]\n self.assertEqual(\n post_errors,\n [],\n f\"'.post' stage must never raise job-stage-undefined: {post_errors}\",\n )\n\n def test_post_stage_pipeline_passes(self):\n \"\"\"A pipeline using .post with a custom stages: list must have no errors.\"\"\"\n _, result = _run_syntax(self._PIPELINE_WITH_POST)\n errors = [i for i in result.get(\"issues\", []) if i[\"severity\"] == \"error\"]\n self.assertEqual(\n errors,\n [],\n f\"Unexpected errors for pipeline with .post stage: {errors}\",\n )\n\n def test_pre_stage_not_flagged_when_stages_defined(self):\n \"\"\"'.pre' must not be flagged as undefined when a stages: list is present.\"\"\"\n _, result = _run_syntax(self._PIPELINE_WITH_PRE)\n pre_errors = [\n issue for issue in result.get(\"issues\", [])\n if issue[\"rule\"] == \"job-stage-undefined\" and \".pre\" in issue[\"message\"]\n ]\n self.assertEqual(\n pre_errors,\n [],\n f\"'.pre' stage must never raise job-stage-undefined: {pre_errors}\",\n )\n\n\n# ---------------------------------------------------------------------------\n# Bug 4 — docker-build template: echo with colon-space must parse as string\n# ---------------------------------------------------------------------------\n\nclass TestBug4DockerBuildTemplate(unittest.TestCase):\n \"\"\"The docker-build template's echo command must be parsed as a string, not a dict.\"\"\"\n\n # Reproduces the exact pattern from docker-build.yml:\n # an echo command containing ': ' that YAML previously parsed as a mapping.\n _PIPELINE = \"\"\"\n stages:\n - build\n\n build-docker-dind:\n stage: build\n image: docker:24-dind\n services:\n - docker:24-dind\n script:\n - 'echo \"Building Docker image: ${IMAGE_NAME}:${IMAGE_TAG}\"'\n - docker build --tag myapp:latest .\n \"\"\"\n\n def test_quoted_echo_with_colon_not_flagged(self):\n \"\"\"A YAML-quoted echo command containing ': ' must produce no script-item errors.\"\"\"\n _, result = _run_syntax(self._PIPELINE)\n script_errors = [\n issue for issue in result.get(\"issues\", [])\n if issue[\"rule\"] == \"job-script-item-invalid\"\n ]\n self.assertEqual(\n script_errors,\n [],\n f\"Quoted echo command must not raise job-script-item-invalid: {script_errors}\",\n )\n\n def test_unquoted_echo_with_colon_is_flagged(self):\n \"\"\"An unquoted echo with ': ' inside must raise a script-item error (YAML dict).\"\"\"\n bad_pipeline = \"\"\"\n stages:\n - build\n\n build-docker-dind:\n stage: build\n image: docker:24-dind\n script:\n - echo \"Building Docker image: ${IMAGE_NAME}:${IMAGE_TAG}\"\n - docker build --tag myapp:latest .\n \"\"\"\n _, result = _run_syntax(bad_pipeline)\n script_errors = [\n issue for issue in result.get(\"issues\", [])\n if issue[\"rule\"] == \"job-script-item-invalid\"\n ]\n self.assertNotEqual(\n script_errors,\n [],\n \"An unquoted echo with colon-space must be caught as job-script-item-invalid\",\n )\n\n\nif __name__ == \"__main__\":\n unittest.main(verbosity=2)\n","content_type":"text/x-python; charset=utf-8","language":"python","size":23005,"content_sha256":"161a4d9d5f1bd8fc06b7969e6d954fe973f18fb34f5bc5c6df9511e5a90ef586"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"GitLab CI/CD Validator","type":"text"}]},{"type":"paragraph","content":[{"text":"Comprehensive toolkit for validating, linting, testing, and securing ","type":"text"},{"text":".gitlab-ci.yml","type":"text","marks":[{"type":"code_inline"}]},{"text":" configurations.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Trigger Phrases","type":"text"}]},{"type":"paragraph","content":[{"text":"Use this skill when requests include intent like:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"\"Validate this ","type":"text"},{"text":".gitlab-ci.yml","type":"text","marks":[{"type":"code_inline"}]},{"text":"\"","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"\"Why is this GitLab pipeline failing?\"","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"\"Run a security review for our GitLab CI\"","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"\"Check pipeline best practices\"","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"\"Lint GitLab CI config before merge\"","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Setup And Prerequisites (Run First)","type":"text"}]},{"type":"paragraph","content":[{"text":"All commands below assume repository root as current working directory.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# Ensure validator scripts are executable\nchmod +x devops-skills-plugin/skills/gitlab-ci-validator/scripts/*.sh \\\n devops-skills-plugin/skills/gitlab-ci-validator/scripts/*.py\n\n# Required runtime\npython3 --version","type":"text"}]},{"type":"paragraph","content":[{"text":"Use one canonical command path for orchestration:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"VALIDATOR=\"bash devops-skills-plugin/skills/gitlab-ci-validator/scripts/validate_gitlab_ci.sh\"","type":"text"}]},{"type":"paragraph","content":[{"text":"Optional local execution tooling (for ","type":"text"},{"text":"--test-only","type":"text","marks":[{"type":"code_inline"}]},{"text":"):","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"bash devops-skills-plugin/skills/gitlab-ci-validator/scripts/install_tools.sh","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Quick Start Commands","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# 1) Full validation (syntax + best practices + security)\n$VALIDATOR .gitlab-ci.yml\n\n# 2) Syntax and schema only (required first gate)\n$VALIDATOR .gitlab-ci.yml --syntax-only\n\n# 3) Best-practices only (recommended)\n$VALIDATOR .gitlab-ci.yml --best-practices\n\n# 4) Security only (required before merge)\n$VALIDATOR .gitlab-ci.yml --security-only\n\n# 5) Optional local pipeline structure test (needs gitlab-ci-local + Docker)\n$VALIDATOR .gitlab-ci.yml --test-only\n\n# 6) Strict mode (treat best-practice warnings as failure)\n$VALIDATOR .gitlab-ci.yml --strict","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Deterministic Validation Workflow","type":"text"}]},{"type":"paragraph","content":[{"text":"Follow these gates in order:","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Run Quick Start command ","type":"text"},{"text":"2","type":"text","marks":[{"type":"code_inline"}]},{"text":" (","type":"text"},{"text":"--syntax-only","type":"text","marks":[{"type":"code_inline"}]},{"text":").","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"If syntax fails, stop and fix errors before continuing.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Run Quick Start command ","type":"text"},{"text":"3","type":"text","marks":[{"type":"code_inline"}]},{"text":" (","type":"text"},{"text":"--best-practices","type":"text","marks":[{"type":"code_inline"}]},{"text":") and apply relevant improvements.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Run Quick Start command ","type":"text"},{"text":"4","type":"text","marks":[{"type":"code_inline"}]},{"text":" (","type":"text"},{"text":"--security-only","type":"text","marks":[{"type":"code_inline"}]},{"text":") and fix all ","type":"text"},{"text":"critical","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"high","type":"text","marks":[{"type":"code_inline"}]},{"text":" findings before merge.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Optionally run Quick Start command ","type":"text"},{"text":"5","type":"text","marks":[{"type":"code_inline"}]},{"text":" (","type":"text"},{"text":"--test-only","type":"text","marks":[{"type":"code_inline"}]},{"text":") for local execution checks.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Run Quick Start command ","type":"text"},{"text":"6","type":"text","marks":[{"type":"code_inline"}]},{"text":" (","type":"text"},{"text":"--strict","type":"text","marks":[{"type":"code_inline"}]},{"text":") for final merge gate.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Required gates: syntax + security. Recommended gate: best practices. Optional gate: local execution test.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Rule Severity Rationale And Documentation Links","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Severity Model","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"critical","type":"text","marks":[{"type":"code_inline"}]},{"text":": Direct credential/secret exposure or high-confidence compromise path. Block merge.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"high","type":"text","marks":[{"type":"code_inline"}]},{"text":": Exploitable unsafe behavior or strong security regression. Fix before merge.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"medium","type":"text","marks":[{"type":"code_inline"}]},{"text":": Security hardening gap with realistic risk. Track and fix soon.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"low","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"suggestion","type":"text","marks":[{"type":"code_inline"}]},{"text":": Optimization or maintainability improvement.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Rule Classes And Why They Matter","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Syntax rules (","type":"text"},{"text":"yaml-syntax","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"job-stage-undefined","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"dependencies-undefined-job","type":"text","marks":[{"type":"code_inline"}]},{"text":"): prevent pipeline parse and dependency failures.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Best-practice rules (","type":"text"},{"text":"cache-missing","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"artifact-no-expiration","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"dag-optimization","type":"text","marks":[{"type":"code_inline"}]},{"text":"): reduce runtime cost and improve pipeline throughput.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Security rules (","type":"text"},{"text":"hardcoded-password","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"curl-pipe-bash","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"include-remote-unverified","type":"text","marks":[{"type":"code_inline"}]},{"text":"): reduce credential leaks and supply-chain risk.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"References","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Local syntax reference: ","type":"text"},{"text":"devops-skills-plugin/skills/gitlab-ci-validator/docs/gitlab-ci-reference.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Local best practices: ","type":"text"},{"text":"devops-skills-plugin/skills/gitlab-ci-validator/docs/best-practices.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Local common issues: ","type":"text"},{"text":"devops-skills-plugin/skills/gitlab-ci-validator/docs/common-issues.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"GitLab CI YAML reference: https://docs.gitlab.com/ee/ci/yaml/","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"GitLab CI/CD components: https://docs.gitlab.com/ee/ci/components/","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"GitLab pipeline security guidance: https://docs.gitlab.com/ee/ci/pipelines/settings.html","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Fallbacks For Tool Or Environment Constraints","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Missing ","type":"text"},{"text":"python3","type":"text","marks":[{"type":"code_inline"}]},{"text":":","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Behavior: validator cannot run.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Fallback: install Python 3 and rerun.","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Missing ","type":"text"},{"text":"PyYAML","type":"text","marks":[{"type":"code_inline"}]},{"text":":","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Behavior: ","type":"text"},{"text":"python_wrapper.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" auto-creates ","type":"text"},{"text":".venv","type":"text","marks":[{"type":"code_inline"}]},{"text":" and installs ","type":"text"},{"text":"pyyaml","type":"text","marks":[{"type":"code_inline"}]},{"text":" when possible.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Fallback in restricted/offline environments: pre-install ","type":"text"},{"text":"pyyaml","type":"text","marks":[{"type":"code_inline"}]},{"text":" from an internal mirror, then rerun.","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Missing ","type":"text"},{"text":"gitlab-ci-local","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"node","type":"text","marks":[{"type":"code_inline"}]},{"text":", or ","type":"text"},{"text":"docker","type":"text","marks":[{"type":"code_inline"}]},{"text":":","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Behavior: ","type":"text"},{"text":"--test-only","type":"text","marks":[{"type":"code_inline"}]},{"text":" reports warning/failure.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Fallback: skip local execution testing and continue with syntax/best-practice/security gates.","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"No execute permission on scripts:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Behavior: shell permission errors.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Fallback: rerun the setup ","type":"text"},{"text":"chmod","type":"text","marks":[{"type":"code_inline"}]},{"text":" command from the Setup section.","type":"text"}]}]}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Examples","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Example 1: New Pipeline Validation","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"$VALIDATOR examples/basic-pipeline.gitlab-ci.yml --syntax-only\n$VALIDATOR examples/basic-pipeline.gitlab-ci.yml --security-only","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Example 2: Pre-Merge Hard Gate","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"$VALIDATOR .gitlab-ci.yml --strict","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Example 3: CI Integration","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"yaml"},"content":[{"text":"stages:\n - validate\n\nvalidate_gitlab_ci:\n stage: validate\n script:\n - chmod +x devops-skills-plugin/skills/gitlab-ci-validator/scripts/*.sh devops-skills-plugin/skills/gitlab-ci-validator/scripts/*.py\n - bash devops-skills-plugin/skills/gitlab-ci-validator/scripts/validate_gitlab_ci.sh .gitlab-ci.yml --strict","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Individual Validators (Advanced)","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# Syntax validator (via wrapper for PyYAML fallback)\nbash devops-skills-plugin/skills/gitlab-ci-validator/scripts/python_wrapper.sh \\\n devops-skills-plugin/skills/gitlab-ci-validator/scripts/validate_syntax.py .gitlab-ci.yml\n\n# Best-practices validator\nbash devops-skills-plugin/skills/gitlab-ci-validator/scripts/python_wrapper.sh \\\n devops-skills-plugin/skills/gitlab-ci-validator/scripts/check_best_practices.py .gitlab-ci.yml\n\n# Security validator\nbash devops-skills-plugin/skills/gitlab-ci-validator/scripts/python_wrapper.sh \\\n devops-skills-plugin/skills/gitlab-ci-validator/scripts/check_security.py .gitlab-ci.yml","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Done Criteria","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Frontmatter ","type":"text"},{"text":"name","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"description","type":"text","marks":[{"type":"code_inline"}]},{"text":" unchanged.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"One canonical orchestrator path is used consistently.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Setup and ","type":"text"},{"text":"chmod","type":"text","marks":[{"type":"code_inline"}]},{"text":" prerequisites appear before workflow/use examples.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Quick-start and workflow are non-duplicative (workflow references quick-start gates).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Severity rationale and rule-to-doc references are explicit.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Fallback behavior is documented for missing tools and constrained environments.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Examples are executable from repository root.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Notes","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"This skill validates configuration and static patterns; it does not execute production pipelines.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Use ","type":"text"},{"text":"gitlab-ci-local","type":"text","marks":[{"type":"code_inline"}]},{"text":" or GitLab CI Lint for runtime behavior confirmation.","type":"text"}]}]}]},{"type":"hr","attrs":{"markup":"---"}}]},"metadata":{"date":"2026-06-05","name":"gitlab-ci-validator","author":"@skillopedia","source":{"stars":224,"repo_name":"cc-devops-skills","origin_url":"https://github.com/akin-ozer/cc-devops-skills/blob/HEAD/devops-skills-plugin/skills/gitlab-ci-validator/SKILL.md","repo_owner":"akin-ozer","body_sha256":"7ec5f69f05c4c60f9163f508b6cf857e754f3bd6541209e4fee59e56feb3d758","cluster_key":"20452a7e5c58a5337bce5ed5db98022431b605d1d1c1f9c7d945ff7a511e9a48","clean_bundle":{"format":"clean-skill-bundle-v1","source":"akin-ozer/cc-devops-skills/devops-skills-plugin/skills/gitlab-ci-validator/SKILL.md","attachments":[{"id":"25b97d5b-a950-5be6-8e6d-58dab9a57449","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/25b97d5b-a950-5be6-8e6d-58dab9a57449/attachment","path":".gitignore","size":143,"sha256":"5f20a390c438e013995a8e2198849751c24e64b42b9cabac6559f37e10684e99","contentType":"text/plain; charset=utf-8"},{"id":"35c27563-963e-59d2-93bd-b735b940fe36","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/35c27563-963e-59d2-93bd-b735b940fe36/attachment.md","path":"docs/best-practices.md","size":12721,"sha256":"411b4dc26fe027ca74813f4e2a3ba3ba7c1efa590164d57eee9b83978ad9af86","contentType":"text/markdown; charset=utf-8"},{"id":"0e3a5425-23e9-5674-bf58-34f2d65a4fda","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/0e3a5425-23e9-5674-bf58-34f2d65a4fda/attachment.md","path":"docs/common-issues.md","size":15447,"sha256":"d3e065c0d65c85781d50880c5d9d7f4b5959e16c66aeffe50565572088544dcc","contentType":"text/markdown; charset=utf-8"},{"id":"28f3d2da-563d-57ae-b859-3e534d04ca2f","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/28f3d2da-563d-57ae-b859-3e534d04ca2f/attachment.md","path":"docs/gitlab-ci-reference.md","size":12993,"sha256":"4e8a58ceac80dfb5e971404fcffcfc84d3eee24fd1ab26ae3b8ba520979332f0","contentType":"text/markdown; charset=utf-8"},{"id":"39c34ba0-030b-5347-a676-b5e3a99d6e55","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/39c34ba0-030b-5347-a676-b5e3a99d6e55/attachment.yml","path":"examples/.gitlab-ci-local/expanded-gitlab-ci.yml","size":8107,"sha256":"c547678cf54c1e40ee56caf7db5cb0b8565f1cee84641758c948932b6f435a57","contentType":"application/yaml; charset=utf-8"},{"id":"b7140eb1-6dd9-5a72-8c01-c851aae4effa","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b7140eb1-6dd9-5a72-8c01-c851aae4effa/attachment.yml","path":"examples/.gitlab-ci-local/includes/gitlab.com/gitlab-org/gitlab/-/raw/HEAD/lib/gitlab/ci/templates/Jobs/Dependency-Scanning.gitlab-ci.yml","size":10855,"sha256":"824cf971384d4d57a3d27c34c16104350a19b919051f9fda9ad96123a2017f5d","contentType":"application/yaml; charset=utf-8"},{"id":"8d4691db-e2b5-5894-aaa5-edf796f43ac2","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/8d4691db-e2b5-5894-aaa5-edf796f43ac2/attachment.yml","path":"examples/.gitlab-ci-local/includes/gitlab.com/gitlab-org/gitlab/-/raw/HEAD/lib/gitlab/ci/templates/Jobs/SAST.gitlab-ci.yml","size":17824,"sha256":"6b3aaa7f0ce5dfe945339a85a994b1d7a7d51807f1a64a4778a656612fd1f4b7","contentType":"application/yaml; charset=utf-8"},{"id":"0707ecd7-b211-5b35-9432-fd2a6ad12c68","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/0707ecd7-b211-5b35-9432-fd2a6ad12c68/attachment.yml","path":"examples/.gitlab-ci-local/includes/gitlab.com/gitlab-org/gitlab/-/raw/HEAD/lib/gitlab/ci/templates/Jobs/Secret-Detection.gitlab-ci.yml","size":2218,"sha256":"b7a2304559db295c3b7ee33c43dc7249932164dd8d0607a0ed2e371ad6e710e0","contentType":"application/yaml; charset=utf-8"},{"id":"41a864ca-b895-5f1a-9e61-48bd1f120647","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/41a864ca-b895-5f1a-9e61-48bd1f120647/attachment.yml","path":"examples/basic-pipeline.gitlab-ci.yml","size":2366,"sha256":"5f6d8e4b0d651e0189fdf83ea81fcaf0a9c3c62e3b6249cffc92f503e505aedc","contentType":"application/yaml; charset=utf-8"},{"id":"d57a8197-4e15-5b1a-9a78-d9b06231fcc3","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/d57a8197-4e15-5b1a-9a78-d9b06231fcc3/attachment.yml","path":"examples/complex-workflow.gitlab-ci.yml","size":12477,"sha256":"cda6b6db76044cf9addcc5a7bc9c2eaace4a86bc02e500cb68a886a2b1d311b8","contentType":"application/yaml; charset=utf-8"},{"id":"83ecf590-d5c2-5739-8556-db4a68bdf512","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/83ecf590-d5c2-5739-8556-db4a68bdf512/attachment.yml","path":"examples/component-pipeline.gitlab-ci.yml","size":4687,"sha256":"1f0c341bbc07e82835bbedfb34612fbf2ec0c0c88438217dd35f6761b2b23176","contentType":"application/yaml; charset=utf-8"},{"id":"7bfff4f3-577c-597a-b6ee-b629f6d9052f","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/7bfff4f3-577c-597a-b6ee-b629f6d9052f/attachment.yml","path":"examples/docker-build.gitlab-ci.yml","size":3662,"sha256":"6b99327179a9dadbe8a371fde52b95e2b530a62031b34c1b79e2dbef148d8e0b","contentType":"application/yaml; charset=utf-8"},{"id":"c5dad3bf-42eb-5d30-b871-10c171a8ca46","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/c5dad3bf-42eb-5d30-b871-10c171a8ca46/attachment.yml","path":"examples/multi-stage.gitlab-ci.yml","size":7813,"sha256":"781bb1a84fd04e1169fc26ebaebbaa46e68e9586f938411b1baf8603973f9da2","contentType":"application/yaml; charset=utf-8"},{"id":"b5a6df53-8311-55c1-9b80-7f2dc128d076","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b5a6df53-8311-55c1-9b80-7f2dc128d076/attachment.py","path":"scripts/check_best_practices.py","size":32327,"sha256":"4cf879e58dafa82bae71d085fdf8949e83f98cde133dfcc2a545dd280162e245","contentType":"text/x-python; charset=utf-8"},{"id":"a1679645-cbd1-56b1-a121-de0f6d58eb67","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a1679645-cbd1-56b1-a121-de0f6d58eb67/attachment.py","path":"scripts/check_security.py","size":37464,"sha256":"a3676facc617091b4e373879c5053cca9ae8c38f0cd3a8ca34ff9caf18d035c6","contentType":"text/x-python; charset=utf-8"},{"id":"1dd0c144-d2db-5031-bed9-fc6c59e8fcb1","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/1dd0c144-d2db-5031-bed9-fc6c59e8fcb1/attachment.sh","path":"scripts/install_tools.sh","size":8292,"sha256":"87e68904eaaebe4cc3184b4c6c937820db0060456f0a0c88a174e468043673c2","contentType":"application/x-sh; charset=utf-8"},{"id":"d7e09e94-e282-5267-8fe2-aa398254f0ae","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/d7e09e94-e282-5267-8fe2-aa398254f0ae/attachment.sh","path":"scripts/python_wrapper.sh","size":1125,"sha256":"e7477d8db21b34e2602a984a4c9077a898187b8092e715c8943d13c5829e8b35","contentType":"application/x-sh; charset=utf-8"},{"id":"bf9b1cdf-e296-57bf-b859-91e4ad5e32bb","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/bf9b1cdf-e296-57bf-b859-91e4ad5e32bb/attachment.sh","path":"scripts/validate_gitlab_ci.sh","size":16004,"sha256":"322de62c16db0ee9a21c678ce5ff0b4695ac443f0a1bf9f34517d65199abe910","contentType":"application/x-sh; charset=utf-8"},{"id":"93b6d8ec-044c-58e3-8f9e-0d69ac07676c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/93b6d8ec-044c-58e3-8f9e-0d69ac07676c/attachment.py","path":"scripts/validate_syntax.py","size":58166,"sha256":"deab5ec04e005c43e04565b3d3d12fa53b9ded87dd07ba6bf88e0f661c9f156f","contentType":"text/x-python; charset=utf-8"},{"id":"40507de4-69c3-5e5a-b133-f6432ffa47bc","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/40507de4-69c3-5e5a-b133-f6432ffa47bc/attachment.py","path":"tests/test_validators.py","size":23005,"sha256":"161a4d9d5f1bd8fc06b7969e6d954fe973f18fb34f5bc5c6df9511e5a90ef586","contentType":"text/x-python; charset=utf-8"}],"bundle_sha256":"9ca62e0a1797e420a8eea54e71589475cc40d10225e80d204e66c1296db8ab9e","attachment_count":20,"text_attachments":19,"attachment_storage":"skillopedia-attachments-v1","binary_attachments":1,"excluded_attachments":[]},"cluster_size":1,"skill_md_path":"devops-skills-plugin/skills/gitlab-ci-validator/SKILL.md","import_metadata":{"date":"2026-06-05","author":"@skillopedia","version":"v1","category":"security","category_label":"Security"},"exact_dupes_collapsed_into_this":0},"version":"v1","category":"security","import_tag":"clean-skills-v1","description":"Validate, lint, audit, or fix .gitlab-ci.yml pipelines, stages, and jobs."}},"renderedAt":1782986528413}

GitLab CI/CD Validator Comprehensive toolkit for validating, linting, testing, and securing configurations. Trigger Phrases Use this skill when requests include intent like: - "Validate this " - "Why is this GitLab pipeline failing?" - "Run a security review for our GitLab CI" - "Check pipeline best practices" - "Lint GitLab CI config before merge" Setup And Prerequisites (Run First) All commands below assume repository root as current working directory. Use one canonical command path for orchestration: Optional local execution tooling (for ): Quick Start Commands Deterministic Validation Wor…