From d476b59c037de8aed93ea21425590c82bb6e773a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20ROBERT?= Date: Tue, 3 Mar 2026 15:58:12 +0100 Subject: [PATCH 1/2] feat(controls): Add debug trace detection control Add a new control that detects CI/CD pipelines enabling CI_DEBUG_TRACE or CI_DEBUG_SERVICES in global or job-level variables. When enabled, these variables cause GitLab to print ALL environment variables in job logs, including masked secrets like CI_JOB_TOKEN. - Add controlGitlabPipelineDebugTrace with Run() and GetConf() - Add DebugTraceControlConfig in plumberconfig.go with schema validation - Add debug trace section to default .plumber.yaml - Integrate into analyze output, MR comments, and compliance calculation - Add comprehensive unit tests (10 tests, 14 sub-cases) Closes #86 --- .plumber.yaml | 44 ++- cmd/analyze.go | 38 +++ configuration/plumberconfig.go | 34 +++ configuration/plumberconfig_test.go | 1 + control/controlGitlabPipelineDebugTrace.go | 218 ++++++++++++++ .../controlGitlabPipelineDebugTrace_test.go | 274 ++++++++++++++++++ control/mrcomment.go | 19 ++ control/task.go | 16 + control/types.go | 1 + 9 files changed, 633 insertions(+), 12 deletions(-) create mode 100644 control/controlGitlabPipelineDebugTrace.go create mode 100644 control/controlGitlabPipelineDebugTrace_test.go 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/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..2a5b82c 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. @@ -420,6 +421,21 @@ func RunAnalysis(conf *configuration.Configuration) (*AnalysisResult, error) { result.RequiredTemplatesResult = requiredTemplatesResult reportProgress(conf, analysisStepCount, analysisStepCount, "Analysis complete") + // 11. Run Pipeline Must Not Enable Debug Trace control + 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 l.WithFields(logrus.Fields{ "ciValid": result.CiValid, 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:"-"` From ac8fa664a67e453bf6a00571365b7fa7ff597dba Mon Sep 17 00:00:00 2001 From: Joseph Moukarzel Date: Tue, 3 Mar 2026 16:37:39 +0100 Subject: [PATCH 2/2] fix(rebase): Rebase on main and add spinner --- README.md | 23 ++++++++++++++++++++--- control/task.go | 6 ++++-- 2 files changed, 24 insertions(+), 5 deletions(-) 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/control/task.go b/control/task.go index 2a5b82c..cc76698 100644 --- a/control/task.go +++ b/control/task.go @@ -72,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) { @@ -420,8 +420,8 @@ func RunAnalysis(conf *configuration.Configuration) (*AnalysisResult, error) { requiredTemplatesResult := requiredTemplatesConf.Run(pipelineOriginData) result.RequiredTemplatesResult = requiredTemplatesResult - reportProgress(conf, analysisStepCount, analysisStepCount, "Analysis complete") // 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{} @@ -437,6 +437,8 @@ func RunAnalysis(conf *configuration.Configuration) (*AnalysisResult, error) { debugTraceResult := debugTraceConf.Run(pipelineOriginData) result.DebugTraceResult = debugTraceResult + reportProgress(conf, analysisStepCount, analysisStepCount, "Analysis complete") + l.WithFields(logrus.Fields{ "ciValid": result.CiValid, "ciMissing": result.CiMissing,