Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion .plumber.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -392,4 +392,27 @@ controls:
# semgrep-sast:
# when: manual
whenMustNotBeManual:
enabled: true
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/*
32 changes: 29 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):

<details>
<summary><b>1. Container images must not use forbidden tags</b></summary>
Expand Down Expand Up @@ -596,6 +596,31 @@ securityJobsMustNotBeWeakened:

</details>

<details>
<summary><b>12. Pipeline must not execute unverified scripts</b></summary>

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.

</details>

### 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.
Expand Down Expand Up @@ -638,6 +663,7 @@ Controls not selected are reported as **skipped** in the output. The `--controls
| `pipelineMustIncludeComponent` |
| `pipelineMustIncludeTemplate` |
| `pipelineMustNotEnableDebugTrace` |
| `pipelineMustNotExecuteUnverifiedScripts` |
| `pipelineMustNotIncludeHardcodedJobs` |
| `pipelineMustNotUseUnsafeVariableExpansion` |
| `securityJobsMustNotBeWeakened` |
Expand Down Expand Up @@ -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

Expand Down
40 changes: 40 additions & 0 deletions cmd/analyze.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
34 changes: 34 additions & 0 deletions configuration/plumberconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions configuration/plumberconfig_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,7 @@ func TestValidControlNames(t *testing.T) {
"pipelineMustIncludeComponent",
"pipelineMustIncludeTemplate",
"pipelineMustNotEnableDebugTrace",
"pipelineMustNotExecuteUnverifiedScripts",
"pipelineMustNotIncludeHardcodedJobs",
"pipelineMustNotUseUnsafeVariableExpansion",
"securityJobsMustNotBeWeakened",
Expand Down
10 changes: 10 additions & 0 deletions control/codes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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: {
Expand Down
Loading
Loading