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
67 changes: 66 additions & 1 deletion .plumber.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ controls:
# Set to false to disable this control
enabled: true

# Version patterns considered forbidden - includes using these will be flagged
# Version patterns considered forbidden: includes using these will be flagged
forbiddenVersions:
- latest
- "~latest"
Expand Down Expand Up @@ -263,3 +263,68 @@ controls:
forbiddenVariables:
- CI_DEBUG_TRACE
- CI_DEBUG_SERVICES

# ===========================================
# Pipeline must not use unsafe variable expansion
# ===========================================
# Detects user-controlled CI variables passed to commands that
# re-interpret their input as shell code. This is OWASP CICD-SEC-1.
#
# GitLab sets CI variables as environment variables. The shell does
# NOT re-parse expanded values for command substitution, so normal
# usage is safe. Only commands that re-interpret arguments as code
# create an injection surface.
#
# Flagged (re-interpretation contexts):
# - eval "$CI_COMMIT_BRANCH"
# - sh -c "$CI_MERGE_REQUEST_TITLE"
# - bash -c "$CI_COMMIT_MESSAGE"
# - dash -c / zsh -c / ksh -c
# - source <(echo "$CI_COMMIT_REF_NAME")
# - envsubst '$CI_COMMIT_MESSAGE' < tpl.sh | sh
# - echo "$CI_COMMIT_BRANCH" | xargs sh
#
# Not flagged (safe — shell doesn't re-parse env var values):
# - echo $CI_COMMIT_BRANCH
# - echo "$CI_COMMIT_MESSAGE"
# - curl -d "$CI_MERGE_REQUEST_TITLE" https://...
# - git checkout $CI_COMMIT_REF_NAME
# - printf '%s' "$CI_COMMIT_MESSAGE"
#
# Not caught (known limitation):
# - sh -c $BRANCH (where BRANCH: $CI_COMMIT_BRANCH in variables:)
# Indirect aliasing is not tracked; only direct variable names.
pipelineMustNotUseUnsafeVariableExpansion:
# Set to false to disable this control
enabled: true

# CI/CD variables whose values come from user input and must not
# appear in shell re-interpretation contexts (eval, sh -c, bash -c, etc.)
dangerousVariables:
- CI_MERGE_REQUEST_TITLE
- CI_MERGE_REQUEST_DESCRIPTION
- CI_COMMIT_MESSAGE
- CI_COMMIT_TITLE
- CI_COMMIT_TAG_MESSAGE
- CI_COMMIT_REF_NAME
- CI_COMMIT_REF_SLUG
- CI_COMMIT_BRANCH
- CI_MERGE_REQUEST_SOURCE_BRANCH_NAME
- CI_EXTERNAL_PULL_REQUEST_SOURCE_BRANCH_NAME

# Script lines
# sh -c "helm upgrade myapp . --set image.tag=$CI_COMMIT_SHA"
# sh -c "terraform workspace select $CI_COMMIT_REF_SLUG"
# sh -c "docker build --build-arg BRANCH=$CI_COMMIT_BRANCH ."
# sh -c "aws s3 sync dist/ s3://my-bucket/$CI_COMMIT_REF_SLUG/"
# bash -c "make deploy BRANCH=$CI_COMMIT_BRANCH"
#
# Allow with patterns:
# (Escape $ as \\$, {} as \\{ \\} in patterns.)
# allowedPatterns:
# - "helm.*--set.*\\$CI_"
# - "terraform workspace select.*\\$CI_"
# - "docker build.*--build-arg.*\\$CI_"
# - "aws s3 sync.*\\$CI_"
# - "make deploy.*\\$CI_"
allowedPatterns: []
49 changes: 46 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ Plumber is a compliance scanner for GitLab. It reads your `.gitlab-ci.yml` and r
- Forbidden version patterns (e.g., `main`, `HEAD`)
- Missing required components or templates
- Debug trace variables (`CI_DEBUG_TRACE`) leaking secrets in job logs
- Unsafe variable injection via `eval`/`sh -c`/`bash -c` (OWASP CICD-SEC-1)

**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.

Expand Down Expand Up @@ -274,7 +275,7 @@ This creates `.plumber.yaml` with sensible [defaults](./.plumber.yaml). Customiz

### Available Controls

Plumber includes 9 compliance controls. Each can be enabled/disabled and customized in [.plumber.yaml](.plumber.yaml):
Plumber includes 10 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 @@ -466,6 +467,47 @@ pipelineMustNotEnableDebugTrace:

</details>

<details>
<summary><b>10. Pipeline must not use unsafe variable expansion</b></summary>

Detects user-controlled CI variables (MR title, commit message, branch name) passed to commands that re-interpret their input as shell code. An attacker can craft a branch name or MR title to inject arbitrary commands: this is [OWASP CICD-SEC-1](https://owasp.org/www-project-top-10-ci-cd-security-risks/).

GitLab sets CI variables as environment variables. The shell does **not** re-parse expanded values for command substitution, so normal usage is safe. Only commands that re-interpret their arguments as code are flagged:

**Flagged**: re-interpretation contexts:
- `eval "$CI_COMMIT_BRANCH"`
- `sh -c "$CI_MERGE_REQUEST_TITLE"` / `bash -c` / `dash -c` / `zsh -c` / `ksh -c`
- `source <(echo "$CI_COMMIT_REF_NAME")`
- `envsubst '$CI_COMMIT_MESSAGE' < tpl.sh | sh`
- `echo "$CI_COMMIT_BRANCH" | xargs sh`

**Not flagged**: safe, shell doesn't re-parse env var values:
- `echo $CI_COMMIT_BRANCH` / `echo "$CI_COMMIT_MESSAGE"`
- `curl -d "$CI_MERGE_REQUEST_TITLE" https://...`
- `git checkout $CI_COMMIT_REF_NAME`
- `printf '%s' "$CI_COMMIT_MESSAGE"`

> **Limitation:** only direct variable names are detected. Indirect aliasing (`variables: { B: $CI_COMMIT_BRANCH }` then `sh -c $B`) is not tracked.

```yaml
pipelineMustNotUseUnsafeVariableExpansion:
enabled: true
dangerousVariables:
- CI_MERGE_REQUEST_TITLE
- CI_MERGE_REQUEST_DESCRIPTION
- CI_COMMIT_MESSAGE
- CI_COMMIT_TITLE
- CI_COMMIT_TAG_MESSAGE
- CI_COMMIT_REF_NAME
- CI_COMMIT_REF_SLUG
- CI_COMMIT_BRANCH
- CI_MERGE_REQUEST_SOURCE_BRANCH_NAME
- CI_EXTERNAL_PULL_REQUEST_SOURCE_BRANCH_NAME
allowedPatterns: []
```

</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 @@ -509,6 +551,7 @@ Controls not selected are reported as **skipped** in the output. The `--controls
| `pipelineMustIncludeTemplate` |
| `pipelineMustNotEnableDebugTrace` |
| `pipelineMustNotIncludeHardcodedJobs` |
| `pipelineMustNotUseUnsafeVariableExpansion` |

</details>

Expand Down Expand Up @@ -646,10 +689,10 @@ brew install plumber
To install a specific version:

```bash
brew install getplumber/plumber/plumber@0.1.51
brew install getplumber/plumber/plumber@0.1.52
```

> **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.
> **Note:** Versioned formulas are keg-only. Use the full path for example `/usr/local/opt/plumber@0.1.52/bin/plumber` or run `brew link plumber@0.1.52` to add it to your PATH.

### Mise

Expand Down
38 changes: 38 additions & 0 deletions cmd/analyze.go
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,11 @@ func runAnalyze(cmd *cobra.Command, args []string) error {
controlCount++
}

if result.VariableInjectionResult != nil && !result.VariableInjectionResult.Skipped {
complianceSum += result.VariableInjectionResult.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 @@ -972,6 +977,39 @@ func outputText(result *control.AnalysisResult, threshold, compliance float64, c
fmt.Println()
}

// Control 10: Pipeline must not use unsafe variable expansion
if result.VariableInjectionResult != nil {
ctrl := controlSummary{
name: "Pipeline must not use unsafe variable expansion",
compliance: result.VariableInjectionResult.Compliance,
issues: len(result.VariableInjectionResult.Issues),
skipped: result.VariableInjectionResult.Skipped,
}
controls = append(controls, ctrl)

printControlHeader("Pipeline must not use unsafe variable expansion", result.VariableInjectionResult.Compliance, result.VariableInjectionResult.Skipped)

if result.VariableInjectionResult.Skipped {
fmt.Printf(" %sStatus: SKIPPED (disabled in configuration)%s\n", colorDim, colorReset)
} else {
fmt.Printf(" Jobs Checked: %d\n", result.VariableInjectionResult.Metrics.JobsChecked)
fmt.Printf(" Script Lines Checked: %d\n", result.VariableInjectionResult.Metrics.TotalScriptLinesChecked)
fmt.Printf(" Unsafe Expansions: %d\n", result.VariableInjectionResult.Metrics.UnsafeExpansionsFound)

if len(result.VariableInjectionResult.Issues) > 0 {
fmt.Printf("\n %sUnsafe Variable Expansions Found:%s\n", colorYellow, colorReset)
for _, issue := range result.VariableInjectionResult.Issues {
if issue.JobName == "(global)" {
fmt.Printf(" %s•%s $%s in global %s: %s\n", colorYellow, colorReset, issue.VariableName, issue.ScriptBlock, issue.ScriptLine)
} else {
fmt.Printf(" %s•%s $%s in job '%s' %s: %s\n", colorYellow, colorReset, issue.VariableName, issue.JobName, issue.ScriptBlock, issue.ScriptLine)
}
}
}
}
fmt.Println()
}

// Summary Section
printSectionHeader("Summary")
fmt.Println()
Expand Down
38 changes: 38 additions & 0 deletions configuration/plumberconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ var validControlSchema = map[string][]string{
"pipelineMustNotEnableDebugTrace": {
"enabled", "forbiddenVariables",
},
"pipelineMustNotUseUnsafeVariableExpansion": {
"enabled", "dangerousVariables", "allowedPatterns",
},
}

// validControlKeys returns the list of known control names.
Expand Down Expand Up @@ -96,6 +99,9 @@ type ControlsConfig struct {

// PipelineMustNotEnableDebugTrace control configuration
PipelineMustNotEnableDebugTrace *DebugTraceControlConfig `yaml:"pipelineMustNotEnableDebugTrace,omitempty"`

// PipelineMustNotUseUnsafeVariableExpansion control configuration
PipelineMustNotUseUnsafeVariableExpansion *VariableInjectionControlConfig `yaml:"pipelineMustNotUseUnsafeVariableExpansion,omitempty"`
}

// ImageForbiddenTagsControlConfig configuration for the forbidden image tags control
Expand Down Expand Up @@ -236,6 +242,20 @@ type DebugTraceControlConfig struct {
ForbiddenVariables []string `yaml:"forbiddenVariables,omitempty"`
}

// VariableInjectionControlConfig configuration for the unsafe variable expansion control
type VariableInjectionControlConfig struct {
// Enabled controls whether this check runs
Enabled *bool `yaml:"enabled,omitempty"`

// DangerousVariables is a list of CI/CD variable names whose values come from user input
// and should not appear in script blocks where shell injection is possible
DangerousVariables []string `yaml:"dangerousVariables,omitempty"`

// AllowedPatterns is a list of regex patterns. Script lines matching any of these
// patterns will not be flagged even if they contain a dangerous variable.
AllowedPatterns []string `yaml:"allowedPatterns,omitempty"`
}

// RequiredTemplatesControlConfig configuration for the required templates control
type RequiredTemplatesControlConfig struct {
// Enabled controls whether this check runs
Expand Down Expand Up @@ -500,6 +520,24 @@ func (c *DebugTraceControlConfig) IsEnabled() bool {
return *c.Enabled
}

// GetPipelineMustNotUseUnsafeVariableExpansionConfig returns the control configuration
// Returns nil if not configured
func (c *PlumberConfig) GetPipelineMustNotUseUnsafeVariableExpansionConfig() *VariableInjectionControlConfig {
if c == nil {
return nil
}
return c.Controls.PipelineMustNotUseUnsafeVariableExpansion
}

// IsEnabled returns whether the control is enabled
// Returns false if not properly configured
func (c *VariableInjectionControlConfig) 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 @@ -327,6 +327,7 @@ func TestValidControlNames(t *testing.T) {
"pipelineMustIncludeTemplate",
"pipelineMustNotEnableDebugTrace",
"pipelineMustNotIncludeHardcodedJobs",
"pipelineMustNotUseUnsafeVariableExpansion",
}

if len(names) != len(expected) {
Expand Down
Loading