diff --git a/.plumber.yaml b/.plumber.yaml
index f14d39d..17fe60f 100644
--- a/.plumber.yaml
+++ b/.plumber.yaml
@@ -21,7 +21,7 @@ controls:
containerImageMustNotUseForbiddenTags:
# Set to false to disable this control
enabled: true
-
+
# Tags considered "forbidden" - images using these will be flagged
tags:
- latest
@@ -54,21 +54,21 @@ controls:
containerImageMustComeFromAuthorizedSources:
# Set to false to disable this control
enabled: true
-
+
# Trust official Docker Hub images (e.g., nginx, alpine, python)
# These are images without a username prefix on Docker Hub
trustDockerHubOfficialImages: true
-
+
# Trusted registry URLs and patterns (supports wildcards)
# Images matching these patterns will be considered trusted
trustedUrls:
# Docker Hub official images (if trustDockerHubOfficialImages is false)
# - docker.io/library/*
-
+
# Common CI/CD tool images
- docker.io/docker:*
- gcr.io/kaniko-project/*
-
+
# GitLab registry patterns
- $CI_REGISTRY_IMAGE:*
- $CI_REGISTRY_IMAGE/*
@@ -79,7 +79,7 @@ controls:
# Security products
- registry.gitlab.com/security-products/*
-
+
# Add your organization's trusted registries below:
# - your-registry.example.com/*
# - ghcr.io/your-org/*
@@ -95,10 +95,10 @@ controls:
branchMustBeProtected:
# Set to false to disable this control
enabled: true
-
+
# Require the default branch to be protected
defaultMustBeProtected: true
-
+
# Branch name patterns that must be protected (supports wildcards)
namePatterns:
- main
@@ -109,16 +109,16 @@ controls:
# Add your organization's protected branch patterns below:
# - develop
# - staging
-
+
# When false, force push must be disabled on protected branches
allowForcePush: false
-
+
# Require code owner approval for changes
codeOwnerApprovalRequired: false
-
+
# Minimum access level required to merge (0=No one, 30=Developer, 40=Maintainer)
minMergeAccessLevel: 30
-
+
# Minimum access level required to push (0=No one, 30=Developer, 40=Maintainer)
minPushAccessLevel: 40
@@ -243,3 +243,23 @@ controls:
enabled: false
# required: templates/go/go AND templates/trivy/trivy AND templates/iso27001/iso27001
requiredGroups: []
+
+ # ===========================================
+ # Pipeline must not enable debug trace
+ # ===========================================
+ # Detects CI/CD pipelines that set CI_DEBUG_TRACE or CI_DEBUG_SERVICES
+ # to "true" in global or job-level variables.
+ #
+ # When CI_DEBUG_TRACE is enabled, GitLab prints ALL environment variables
+ # in the job logs, including masked secrets like CI_JOB_TOKEN and any
+ # custom CI/CD variables. This is a critical security risk.
+ #
+ # Best practice: Never enable CI_DEBUG_TRACE in committed CI configuration
+ pipelineMustNotEnableDebugTrace:
+ # Set to false to disable this control
+ enabled: true
+
+ # CI/CD variable names that must not be set to "true"
+ forbiddenVariables:
+ - CI_DEBUG_TRACE
+ - CI_DEBUG_SERVICES
diff --git a/README.md b/README.md
index febd79d..ae4f96d 100644
--- a/README.md
+++ b/README.md
@@ -35,6 +35,7 @@ Plumber is a compliance scanner for GitLab. It reads your `.gitlab-ci.yml` and r
- Outdated includes/templates
- Forbidden version patterns (e.g., `main`, `HEAD`)
- Missing required components or templates
+- Debug trace variables (`CI_DEBUG_TRACE`) leaking secrets in job logs
**How does it work?** Plumber connects to your GitLab instance via API, analyzes your pipeline configuration, and reports any issues it finds. You define what's allowed in a config file (`.plumber.yaml`), and Plumber tells you if your project complies. When running locally from your git repo, Plumber uses your **local `.gitlab-ci.yml`** allowing you to validate changes before pushing.
@@ -273,7 +274,7 @@ This creates `.plumber.yaml` with sensible [defaults](./.plumber.yaml). Customiz
### Available Controls
-Plumber includes 8 compliance controls. Each can be enabled/disabled and customized in [.plumber.yaml](.plumber.yaml):
+Plumber includes 9 compliance controls. Each can be enabled/disabled and customized in [.plumber.yaml](.plumber.yaml):
1. Container images must not use forbidden tags
@@ -450,6 +451,21 @@ pipelineMustIncludeTemplate:
+
+9. Pipeline must not enable debug trace
+
+Detects CI/CD pipelines that set `CI_DEBUG_TRACE` or `CI_DEBUG_SERVICES` to `"true"` in global or job-level variables. When enabled, GitLab prints ALL environment variables in job logs, including masked secrets like `CI_JOB_TOKEN`.
+
+```yaml
+pipelineMustNotEnableDebugTrace:
+ enabled: true
+ forbiddenVariables:
+ - CI_DEBUG_TRACE
+ - CI_DEBUG_SERVICES
+```
+
+
+
### Selective Control Execution
You can run or skip specific controls using their YAML key names from `.plumber.yaml`. This is useful for iterative debugging or targeted CI checks.
@@ -491,6 +507,7 @@ Controls not selected are reported as **skipped** in the output. The `--controls
| `includesMustNotUseForbiddenVersions` |
| `pipelineMustIncludeComponent` |
| `pipelineMustIncludeTemplate` |
+| `pipelineMustNotEnableDebugTrace` |
| `pipelineMustNotIncludeHardcodedJobs` |
@@ -629,10 +646,10 @@ brew install plumber
To install a specific version:
```bash
-brew install getplumber/plumber/plumber@0.1.49
+brew install getplumber/plumber/plumber@0.1.51
```
-> **Note:** Versioned formulas are keg-only. Use the full path for example `/usr/local/opt/plumber@0.1.49/bin/plumber` or run `brew link plumber@0.1.49` to add it to your PATH.
+> **Note:** Versioned formulas are keg-only. Use the full path for example `/usr/local/opt/plumber@0.1.51/bin/plumber` or run `brew link plumber@0.1.51` to add it to your PATH.
### Mise
diff --git a/cmd/analyze.go b/cmd/analyze.go
index 7d2e65b..433cebc 100644
--- a/cmd/analyze.go
+++ b/cmd/analyze.go
@@ -293,6 +293,11 @@ func runAnalyze(cmd *cobra.Command, args []string) error {
controlCount++
}
+ if result.DebugTraceResult != nil && !result.DebugTraceResult.Skipped {
+ complianceSum += result.DebugTraceResult.Compliance
+ controlCount++
+ }
+
// Calculate average compliance
// If no controls ran (e.g., data collection failed), compliance is 0% - we can't verify anything
var compliance float64 = 0
@@ -934,6 +939,39 @@ func outputText(result *control.AnalysisResult, threshold, compliance float64, c
fmt.Println()
}
+ // Control 9: Pipeline must not enable debug trace
+ if result.DebugTraceResult != nil {
+ ctrl := controlSummary{
+ name: "Pipeline must not enable debug trace",
+ compliance: result.DebugTraceResult.Compliance,
+ issues: len(result.DebugTraceResult.Issues),
+ skipped: result.DebugTraceResult.Skipped,
+ }
+ controls = append(controls, ctrl)
+
+ printControlHeader("Pipeline must not enable debug trace", result.DebugTraceResult.Compliance, result.DebugTraceResult.Skipped)
+
+ if result.DebugTraceResult.Skipped {
+ fmt.Printf(" %sStatus: SKIPPED (disabled in configuration)%s\n", colorDim, colorReset)
+ } else {
+ fmt.Printf(" Variables Checked: %d\n", result.DebugTraceResult.Metrics.TotalVariablesChecked)
+ fmt.Printf(" Forbidden Found: %d\n", result.DebugTraceResult.Metrics.ForbiddenFound)
+
+ if len(result.DebugTraceResult.Issues) > 0 {
+ fmt.Printf("\n %sForbidden Debug Variables Found:%s\n", colorYellow, colorReset)
+ for _, issue := range result.DebugTraceResult.Issues {
+ location := issue.Location
+ if location == "global" {
+ fmt.Printf(" %s•%s %s = \"%s\" (global variables)\n", colorYellow, colorReset, issue.VariableName, issue.Value)
+ } else {
+ fmt.Printf(" %s•%s %s = \"%s\" (job '%s')\n", colorYellow, colorReset, issue.VariableName, issue.Value, location)
+ }
+ }
+ }
+ }
+ fmt.Println()
+ }
+
// Summary Section
printSectionHeader("Summary")
fmt.Println()
diff --git a/configuration/plumberconfig.go b/configuration/plumberconfig.go
index 81e6d83..0a979c4 100644
--- a/configuration/plumberconfig.go
+++ b/configuration/plumberconfig.go
@@ -38,6 +38,9 @@ var validControlSchema = map[string][]string{
"pipelineMustIncludeTemplate": {
"enabled", "required", "requiredGroups",
},
+ "pipelineMustNotEnableDebugTrace": {
+ "enabled", "forbiddenVariables",
+ },
}
// validControlKeys returns the list of known control names.
@@ -90,6 +93,9 @@ type ControlsConfig struct {
// PipelineMustIncludeTemplate control configuration
PipelineMustIncludeTemplate *RequiredTemplatesControlConfig `yaml:"pipelineMustIncludeTemplate,omitempty"`
+
+ // PipelineMustNotEnableDebugTrace control configuration
+ PipelineMustNotEnableDebugTrace *DebugTraceControlConfig `yaml:"pipelineMustNotEnableDebugTrace,omitempty"`
}
// ImageForbiddenTagsControlConfig configuration for the forbidden image tags control
@@ -220,6 +226,16 @@ func (c *RequiredComponentsControlConfig) GetResolvedRequiredGroups() ([][]strin
return c.RequiredGroups, nil
}
+// DebugTraceControlConfig configuration for the debug trace detection control
+type DebugTraceControlConfig struct {
+ // Enabled controls whether this check runs
+ Enabled *bool `yaml:"enabled,omitempty"`
+
+ // ForbiddenVariables is a list of CI/CD variable names that must not be set to "true"
+ // Defaults: CI_DEBUG_TRACE, CI_DEBUG_SERVICES
+ ForbiddenVariables []string `yaml:"forbiddenVariables,omitempty"`
+}
+
// RequiredTemplatesControlConfig configuration for the required templates control
type RequiredTemplatesControlConfig struct {
// Enabled controls whether this check runs
@@ -466,6 +482,24 @@ func (c *PlumberConfig) GetPipelineMustIncludeTemplateConfig() *RequiredTemplate
return c.Controls.PipelineMustIncludeTemplate
}
+// GetPipelineMustNotEnableDebugTraceConfig returns the control configuration
+// Returns nil if not configured
+func (c *PlumberConfig) GetPipelineMustNotEnableDebugTraceConfig() *DebugTraceControlConfig {
+ if c == nil {
+ return nil
+ }
+ return c.Controls.PipelineMustNotEnableDebugTrace
+}
+
+// IsEnabled returns whether the control is enabled
+// Returns false if not properly configured
+func (c *DebugTraceControlConfig) IsEnabled() bool {
+ if c == nil || c.Enabled == nil {
+ return false
+ }
+ return *c.Enabled
+}
+
// IsEnabled returns whether the control is enabled
// Returns false if not properly configured
func (c *RequiredTemplatesControlConfig) IsEnabled() bool {
diff --git a/configuration/plumberconfig_test.go b/configuration/plumberconfig_test.go
index c3bbbb7..bf7ca0c 100644
--- a/configuration/plumberconfig_test.go
+++ b/configuration/plumberconfig_test.go
@@ -325,6 +325,7 @@ func TestValidControlNames(t *testing.T) {
"includesMustNotUseForbiddenVersions",
"pipelineMustIncludeComponent",
"pipelineMustIncludeTemplate",
+ "pipelineMustNotEnableDebugTrace",
"pipelineMustNotIncludeHardcodedJobs",
}
diff --git a/control/controlGitlabPipelineDebugTrace.go b/control/controlGitlabPipelineDebugTrace.go
new file mode 100644
index 0000000..a6953b2
--- /dev/null
+++ b/control/controlGitlabPipelineDebugTrace.go
@@ -0,0 +1,218 @@
+package control
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/getplumber/plumber/collector"
+ "github.com/getplumber/plumber/configuration"
+ "github.com/getplumber/plumber/gitlab"
+ "github.com/sirupsen/logrus"
+)
+
+const ControlTypeGitlabPipelineDebugTraceVersion = "0.1.0"
+
+//////////////////
+// Control conf //
+//////////////////
+
+// GitlabPipelineDebugTraceConf holds the configuration for debug trace detection
+type GitlabPipelineDebugTraceConf struct {
+ // Enabled controls whether this check runs
+ Enabled bool `json:"enabled"`
+
+ // ForbiddenVariables is a list of CI/CD variable names that must not be set to "true"
+ ForbiddenVariables []string `json:"forbiddenVariables"`
+}
+
+// GetConf loads configuration from PlumberConfig
+// If config is nil or the control section is missing, the control is disabled (skipped).
+func (p *GitlabPipelineDebugTraceConf) GetConf(plumberConfig *configuration.PlumberConfig) error {
+ if plumberConfig == nil {
+ p.Enabled = false
+ return nil
+ }
+
+ debugTraceConfig := plumberConfig.GetPipelineMustNotEnableDebugTraceConfig()
+ if debugTraceConfig == nil {
+ l.Debug("pipelineMustNotEnableDebugTrace control configuration is missing from .plumber.yaml file, skipping")
+ p.Enabled = false
+ return nil
+ }
+
+ if debugTraceConfig.Enabled == nil {
+ return fmt.Errorf("pipelineMustNotEnableDebugTrace.enabled field is required in .plumber.yaml config file")
+ }
+
+ p.Enabled = debugTraceConfig.IsEnabled()
+ p.ForbiddenVariables = debugTraceConfig.ForbiddenVariables
+
+ l.WithFields(logrus.Fields{
+ "enabled": p.Enabled,
+ "forbiddenVariables": p.ForbiddenVariables,
+ }).Debug("pipelineMustNotEnableDebugTrace control configuration loaded from .plumber.yaml file")
+
+ return nil
+}
+
+////////////////////////////
+// Control data & metrics //
+////////////////////////////
+
+// GitlabPipelineDebugTraceMetrics holds metrics about debug trace detection
+type GitlabPipelineDebugTraceMetrics struct {
+ TotalVariablesChecked uint `json:"totalVariablesChecked"`
+ ForbiddenFound uint `json:"forbiddenFound"`
+}
+
+// GitlabPipelineDebugTraceResult holds the result of the debug trace control
+type GitlabPipelineDebugTraceResult struct {
+ Issues []GitlabPipelineDebugTraceIssue `json:"issues"`
+ Metrics GitlabPipelineDebugTraceMetrics `json:"metrics"`
+ Compliance float64 `json:"compliance"`
+ Version string `json:"version"`
+ CiValid bool `json:"ciValid"`
+ CiMissing bool `json:"ciMissing"`
+ Skipped bool `json:"skipped"`
+ Error string `json:"error,omitempty"`
+}
+
+////////////////////
+// Control issues //
+////////////////////
+
+// GitlabPipelineDebugTraceIssue represents a forbidden debug variable found in the CI config
+type GitlabPipelineDebugTraceIssue struct {
+ VariableName string `json:"variableName"`
+ Value string `json:"value"`
+ Location string `json:"location"` // "global" or job name
+}
+
+///////////////////////
+// Control functions //
+///////////////////////
+
+// Run executes the debug trace detection control
+func (p *GitlabPipelineDebugTraceConf) Run(pipelineOriginData *collector.GitlabPipelineOriginData) *GitlabPipelineDebugTraceResult {
+ l := l.WithFields(logrus.Fields{
+ "control": "GitlabPipelineDebugTrace",
+ "controlVersion": ControlTypeGitlabPipelineDebugTraceVersion,
+ })
+ l.Info("Start debug trace detection control")
+
+ result := &GitlabPipelineDebugTraceResult{
+ Issues: []GitlabPipelineDebugTraceIssue{},
+ Metrics: GitlabPipelineDebugTraceMetrics{},
+ Compliance: 100.0,
+ Version: ControlTypeGitlabPipelineDebugTraceVersion,
+ CiValid: pipelineOriginData.CiValid,
+ CiMissing: pipelineOriginData.CiMissing,
+ Skipped: false,
+ }
+
+ if !p.Enabled {
+ l.Info("Debug trace detection control is disabled, skipping")
+ result.Skipped = true
+ return result
+ }
+
+ if len(p.ForbiddenVariables) == 0 {
+ l.Info("No forbidden variables configured, skipping")
+ result.Skipped = true
+ return result
+ }
+
+ // Use merged conf to check variables after all includes are resolved
+ mergedConf := pipelineOriginData.MergedConf
+ if mergedConf == nil {
+ l.Warn("Merged CI configuration not available, cannot check variables")
+ result.Compliance = 0
+ result.Error = "merged CI configuration not available"
+ return result
+ }
+
+ // Build a set of forbidden variable names (case-insensitive)
+ forbiddenSet := make(map[string]bool, len(p.ForbiddenVariables))
+ for _, v := range p.ForbiddenVariables {
+ forbiddenSet[strings.ToUpper(v)] = true
+ }
+
+ // Check global variables
+ globalVars, err := gitlab.ParseGlobalVariables(mergedConf)
+ if err != nil {
+ l.WithError(err).Warn("Unable to parse global variables")
+ } else {
+ for key, value := range globalVars {
+ result.Metrics.TotalVariablesChecked++
+ if forbiddenSet[strings.ToUpper(key)] && isTrueValue(value) {
+ result.Issues = append(result.Issues, GitlabPipelineDebugTraceIssue{
+ VariableName: key,
+ Value: value,
+ Location: "global",
+ })
+ result.Metrics.ForbiddenFound++
+ l.WithFields(logrus.Fields{
+ "variable": key,
+ "value": value,
+ "location": "global",
+ }).Debug("Forbidden debug variable found in global variables")
+ }
+ }
+ }
+
+ // Check per-job variables
+ for jobName, jobContent := range mergedConf.GitlabJobs {
+ job, err := gitlab.ParseGitlabCIJob(jobContent)
+ if err != nil {
+ l.WithError(err).WithField("job", jobName).Debug("Unable to parse job, skipping")
+ continue
+ }
+ if job == nil {
+ continue
+ }
+
+ jobVars, err := gitlab.ParseJobVariables(job)
+ if err != nil {
+ l.WithError(err).WithField("job", jobName).Debug("Unable to parse job variables, skipping")
+ continue
+ }
+
+ for key, value := range jobVars {
+ result.Metrics.TotalVariablesChecked++
+ if forbiddenSet[strings.ToUpper(key)] && isTrueValue(value) {
+ result.Issues = append(result.Issues, GitlabPipelineDebugTraceIssue{
+ VariableName: key,
+ Value: value,
+ Location: jobName,
+ })
+ result.Metrics.ForbiddenFound++
+ l.WithFields(logrus.Fields{
+ "variable": key,
+ "value": value,
+ "location": jobName,
+ }).Debug("Forbidden debug variable found in job variables")
+ }
+ }
+ }
+
+ // Calculate compliance
+ if len(result.Issues) > 0 {
+ result.Compliance = 0.0
+ l.WithField("issuesCount", len(result.Issues)).Info("Forbidden debug variables found, setting compliance to 0")
+ }
+
+ l.WithFields(logrus.Fields{
+ "totalChecked": result.Metrics.TotalVariablesChecked,
+ "forbiddenFound": result.Metrics.ForbiddenFound,
+ "compliance": result.Compliance,
+ }).Info("Debug trace detection control completed")
+
+ return result
+}
+
+// isTrueValue checks if a variable value is truthy
+// GitLab considers "true", "1", "yes" as truthy for CI_DEBUG_TRACE
+func isTrueValue(value string) bool {
+ v := strings.ToLower(strings.TrimSpace(value))
+ return v == "true" || v == "1" || v == "yes"
+}
diff --git a/control/controlGitlabPipelineDebugTrace_test.go b/control/controlGitlabPipelineDebugTrace_test.go
new file mode 100644
index 0000000..ac82c1b
--- /dev/null
+++ b/control/controlGitlabPipelineDebugTrace_test.go
@@ -0,0 +1,274 @@
+package control
+
+import (
+ "testing"
+
+ "github.com/getplumber/plumber/collector"
+ "github.com/getplumber/plumber/gitlab"
+)
+
+// helper to build a GitlabPipelineOriginData with global variables and optional jobs
+func buildPipelineOriginDataWithVars(globalVars map[string]interface{}, jobs map[string]interface{}) *collector.GitlabPipelineOriginData {
+ mergedConf := &gitlab.GitlabCIConf{
+ GlobalVariables: globalVars,
+ GitlabJobs: jobs,
+ }
+ return &collector.GitlabPipelineOriginData{
+ MergedConf: mergedConf,
+ CiValid: true,
+ CiMissing: false,
+ }
+}
+
+func TestDebugTrace_Disabled(t *testing.T) {
+ conf := &GitlabPipelineDebugTraceConf{
+ Enabled: false,
+ ForbiddenVariables: []string{"CI_DEBUG_TRACE"},
+ }
+ data := buildPipelineOriginDataWithVars(
+ map[string]interface{}{"CI_DEBUG_TRACE": "true"},
+ nil,
+ )
+
+ result := conf.Run(data)
+
+ if !result.Skipped {
+ t.Fatal("expected control to be skipped when disabled")
+ }
+ if result.Compliance != 100.0 {
+ t.Fatalf("expected compliance 100 when skipped, got %v", result.Compliance)
+ }
+}
+
+func TestDebugTrace_NoForbiddenVariablesConfigured(t *testing.T) {
+ conf := &GitlabPipelineDebugTraceConf{
+ Enabled: true,
+ ForbiddenVariables: []string{},
+ }
+ data := buildPipelineOriginDataWithVars(
+ map[string]interface{}{"CI_DEBUG_TRACE": "true"},
+ nil,
+ )
+
+ result := conf.Run(data)
+
+ if !result.Skipped {
+ t.Fatal("expected control to be skipped when no forbidden variables configured")
+ }
+}
+
+func TestDebugTrace_NilMergedConf(t *testing.T) {
+ conf := &GitlabPipelineDebugTraceConf{
+ Enabled: true,
+ ForbiddenVariables: []string{"CI_DEBUG_TRACE"},
+ }
+ data := &collector.GitlabPipelineOriginData{
+ MergedConf: nil,
+ CiValid: true,
+ CiMissing: false,
+ }
+
+ result := conf.Run(data)
+
+ if result.Skipped {
+ t.Fatal("expected control not to be skipped")
+ }
+ if result.Compliance != 0 {
+ t.Fatalf("expected compliance 0 when merged conf unavailable, got %v", result.Compliance)
+ }
+ if result.Error == "" {
+ t.Fatal("expected error message when merged conf unavailable")
+ }
+}
+
+func TestDebugTrace_GlobalVarTrue(t *testing.T) {
+ conf := &GitlabPipelineDebugTraceConf{
+ Enabled: true,
+ ForbiddenVariables: []string{"CI_DEBUG_TRACE"},
+ }
+ data := buildPipelineOriginDataWithVars(
+ map[string]interface{}{"CI_DEBUG_TRACE": "true"},
+ nil,
+ )
+
+ result := conf.Run(data)
+
+ if result.Skipped {
+ t.Fatal("expected control to run")
+ }
+ if result.Compliance != 0.0 {
+ t.Fatalf("expected compliance 0, got %v", result.Compliance)
+ }
+ if len(result.Issues) != 1 {
+ t.Fatalf("expected 1 issue, got %d", len(result.Issues))
+ }
+ issue := result.Issues[0]
+ if issue.VariableName != "CI_DEBUG_TRACE" {
+ t.Fatalf("expected variable CI_DEBUG_TRACE, got %s", issue.VariableName)
+ }
+ if issue.Location != "global" {
+ t.Fatalf("expected location 'global', got %s", issue.Location)
+ }
+ if result.Metrics.ForbiddenFound != 1 {
+ t.Fatalf("expected ForbiddenFound 1, got %d", result.Metrics.ForbiddenFound)
+ }
+}
+
+func TestDebugTrace_GlobalVarFalse(t *testing.T) {
+ conf := &GitlabPipelineDebugTraceConf{
+ Enabled: true,
+ ForbiddenVariables: []string{"CI_DEBUG_TRACE"},
+ }
+ data := buildPipelineOriginDataWithVars(
+ map[string]interface{}{"CI_DEBUG_TRACE": "false"},
+ nil,
+ )
+
+ result := conf.Run(data)
+
+ if result.Compliance != 100.0 {
+ t.Fatalf("expected compliance 100 when value is false, got %v", result.Compliance)
+ }
+ if len(result.Issues) != 0 {
+ t.Fatalf("expected no issues, got %d", len(result.Issues))
+ }
+}
+
+func TestDebugTrace_JobVarTrue(t *testing.T) {
+ conf := &GitlabPipelineDebugTraceConf{
+ Enabled: true,
+ ForbiddenVariables: []string{"CI_DEBUG_TRACE"},
+ }
+
+ // Build job content as YAML-like map (how GitlabJobs stores parsed CI jobs)
+ jobContent := map[interface{}]interface{}{
+ "script": "echo hello",
+ "variables": map[interface{}]interface{}{
+ "CI_DEBUG_TRACE": "true",
+ },
+ }
+ data := buildPipelineOriginDataWithVars(
+ nil,
+ map[string]interface{}{"build": jobContent},
+ )
+
+ result := conf.Run(data)
+
+ if result.Compliance != 0.0 {
+ t.Fatalf("expected compliance 0, got %v", result.Compliance)
+ }
+ if len(result.Issues) != 1 {
+ t.Fatalf("expected 1 issue, got %d", len(result.Issues))
+ }
+ if result.Issues[0].Location != "build" {
+ t.Fatalf("expected location 'build', got %s", result.Issues[0].Location)
+ }
+}
+
+func TestDebugTrace_MultipleVarsGlobalAndJob(t *testing.T) {
+ conf := &GitlabPipelineDebugTraceConf{
+ Enabled: true,
+ ForbiddenVariables: []string{"CI_DEBUG_TRACE", "CI_DEBUG_SERVICES"},
+ }
+
+ jobContent := map[interface{}]interface{}{
+ "script": "echo test",
+ "variables": map[interface{}]interface{}{
+ "CI_DEBUG_SERVICES": "true",
+ },
+ }
+ data := buildPipelineOriginDataWithVars(
+ map[string]interface{}{"CI_DEBUG_TRACE": "true"},
+ map[string]interface{}{"test-job": jobContent},
+ )
+
+ result := conf.Run(data)
+
+ if result.Compliance != 0.0 {
+ t.Fatalf("expected compliance 0, got %v", result.Compliance)
+ }
+ if len(result.Issues) != 2 {
+ t.Fatalf("expected 2 issues, got %d", len(result.Issues))
+ }
+ if result.Metrics.ForbiddenFound != 2 {
+ t.Fatalf("expected ForbiddenFound 2, got %d", result.Metrics.ForbiddenFound)
+ }
+}
+
+func TestDebugTrace_CaseInsensitiveVariableMatch(t *testing.T) {
+ conf := &GitlabPipelineDebugTraceConf{
+ Enabled: true,
+ ForbiddenVariables: []string{"ci_debug_trace"},
+ }
+ data := buildPipelineOriginDataWithVars(
+ map[string]interface{}{"CI_DEBUG_TRACE": "true"},
+ nil,
+ )
+
+ result := conf.Run(data)
+
+ if len(result.Issues) != 1 {
+ t.Fatalf("expected case-insensitive match to find 1 issue, got %d", len(result.Issues))
+ }
+}
+
+func TestDebugTrace_NoIssuesCleanConfig(t *testing.T) {
+ conf := &GitlabPipelineDebugTraceConf{
+ Enabled: true,
+ ForbiddenVariables: []string{"CI_DEBUG_TRACE", "CI_DEBUG_SERVICES"},
+ }
+
+ jobContent := map[interface{}]interface{}{
+ "script": "echo hello",
+ "variables": map[interface{}]interface{}{
+ "MY_VAR": "hello",
+ },
+ }
+ data := buildPipelineOriginDataWithVars(
+ map[string]interface{}{"SOME_VAR": "value"},
+ map[string]interface{}{"build": jobContent},
+ )
+
+ result := conf.Run(data)
+
+ if result.Compliance != 100.0 {
+ t.Fatalf("expected compliance 100, got %v", result.Compliance)
+ }
+ if len(result.Issues) != 0 {
+ t.Fatalf("expected no issues, got %d", len(result.Issues))
+ }
+ if result.Metrics.TotalVariablesChecked < 2 {
+ t.Fatalf("expected at least 2 variables checked, got %d", result.Metrics.TotalVariablesChecked)
+ }
+}
+
+func TestIsTrueValue(t *testing.T) {
+ tests := []struct {
+ input string
+ want bool
+ }{
+ {"true", true},
+ {"TRUE", true},
+ {"True", true},
+ {" true ", true},
+ {"1", true},
+ {"yes", true},
+ {"YES", true},
+ {"Yes", true},
+ {"false", false},
+ {"0", false},
+ {"no", false},
+ {"", false},
+ {"random", false},
+ {"truthy", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ got := isTrueValue(tt.input)
+ if got != tt.want {
+ t.Fatalf("isTrueValue(%q) = %v, want %v", tt.input, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/control/mrcomment.go b/control/mrcomment.go
index 840f35e..06927bf 100644
--- a/control/mrcomment.go
+++ b/control/mrcomment.go
@@ -186,6 +186,12 @@ func generateMRComment(result *AnalysisResult, compliance, threshold float64) st
totalIssues += issueCount
}
}
+ if r := result.DebugTraceResult; r != nil {
+ controls = append(controls, controlEntry{"Pipeline must not enable debug trace", r.Compliance, len(r.Issues), r.Skipped})
+ if !r.Skipped {
+ totalIssues += len(r.Issues)
+ }
+ }
// Controls summary table
b.WriteString("### Controls\n\n")
@@ -320,4 +326,17 @@ func writeIssueDetails(b *strings.Builder, result *AnalysisResult) {
}
b.WriteString("\n")
}
+
+ // Debug trace
+ if r := result.DebugTraceResult; r != nil && !r.Skipped && len(r.Issues) > 0 {
+ b.WriteString("**Pipeline must not enable debug trace:**\n")
+ for _, issue := range r.Issues {
+ if issue.Location == "global" {
+ fmt.Fprintf(b, "- `%s` = `%s` in global variables\n", issue.VariableName, issue.Value)
+ } else {
+ fmt.Fprintf(b, "- `%s` = `%s` in job `%s`\n", issue.VariableName, issue.Value, issue.Location)
+ }
+ }
+ b.WriteString("\n")
+ }
}
diff --git a/control/task.go b/control/task.go
index f8acf99..cc76698 100644
--- a/control/task.go
+++ b/control/task.go
@@ -21,6 +21,7 @@ const (
controlIncludesMustNotUseForbiddenVersions = "includesMustNotUseForbiddenVersions"
controlPipelineMustIncludeComponent = "pipelineMustIncludeComponent"
controlPipelineMustIncludeTemplate = "pipelineMustIncludeTemplate"
+ controlPipelineMustNotEnableDebugTrace = "pipelineMustNotEnableDebugTrace"
)
// shouldRunControl applies --controls / --skip-controls filtering for a control.
@@ -71,7 +72,7 @@ func clearProgressLine(conf *configuration.Configuration) {
}
// analysisStepCount is the total number of progress steps reported during analysis.
-const analysisStepCount = 12
+const analysisStepCount = 13
// RunAnalysis executes the complete pipeline analysis for a GitLab project
func RunAnalysis(conf *configuration.Configuration) (*AnalysisResult, error) {
@@ -419,6 +420,23 @@ func RunAnalysis(conf *configuration.Configuration) (*AnalysisResult, error) {
requiredTemplatesResult := requiredTemplatesConf.Run(pipelineOriginData)
result.RequiredTemplatesResult = requiredTemplatesResult
+ // 11. Run Pipeline Must Not Enable Debug Trace control
+ reportProgress(conf, 12, analysisStepCount, "Checking debug trace variables")
+ l.Info("Running Pipeline Must Not Enable Debug Trace control")
+
+ debugTraceConf := &GitlabPipelineDebugTraceConf{}
+ if shouldRunControl(controlPipelineMustNotEnableDebugTrace, conf) {
+ if err := debugTraceConf.GetConf(conf.PlumberConfig); err != nil {
+ l.WithError(err).Error("Failed to load DebugTrace config from .plumber.yaml file")
+ return result, fmt.Errorf("invalid configuration: %w", err)
+ }
+ } else {
+ debugTraceConf.Enabled = false
+ }
+
+ debugTraceResult := debugTraceConf.Run(pipelineOriginData)
+ result.DebugTraceResult = debugTraceResult
+
reportProgress(conf, analysisStepCount, analysisStepCount, "Analysis complete")
l.WithFields(logrus.Fields{
diff --git a/control/types.go b/control/types.go
index b75c419..cbe93f7 100644
--- a/control/types.go
+++ b/control/types.go
@@ -35,6 +35,7 @@ type AnalysisResult struct {
ForbiddenVersionsIncludesResult *GitlabPipelineIncludesForbiddenVersionResult `json:"forbiddenVersionsIncludesResult,omitempty"`
RequiredComponentsResult *GitlabPipelineRequiredComponentsResult `json:"requiredComponentsResult,omitempty"`
RequiredTemplatesResult *GitlabPipelineRequiredTemplatesResult `json:"requiredTemplatesResult,omitempty"`
+ DebugTraceResult *GitlabPipelineDebugTraceResult `json:"debugTraceResult,omitempty"`
// Raw collected data (not included in JSON output, used for PBOM generation)
PipelineImageData *collector.GitlabPipelineImageData `json:"-"`