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
23 changes: 22 additions & 1 deletion .plumber.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -452,4 +452,25 @@ controls:
# URLs that are trusted and should not trigger findings.
# Supports wildcards (e.g., https://internal-artifacts.example.com/*).
trustedUrls: []
# - https://internal-artifacts.example.com/*
# - https://internal-artifacts.example.com/*

# ── Pipeline must not use Docker-in-Docker ──────────────────────
#
# Detects CI/CD jobs that use Docker-in-Docker (dind) services.
# Running a Docker daemon inside a CI container on shared runners
# in privileged mode enables container escape, lateral movement,
# and access to secrets from other jobs on the same runner.
#
# This is a well-known attack vector documented in GitLab CI
# security best practices.
#
# Best practice: Use Kaniko or Buildah for container image builds
# instead of Docker-in-Docker.
pipelineMustNotUseDockerInDocker:
# Set to false to disable this control
enabled: true

# When true, also flags insecure daemon configuration
# (DOCKER_TLS_CERTDIR="" or DOCKER_HOST tcp://...:2375)
# in jobs that use a DinD service.
detectInsecureDaemon: true
27 changes: 24 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ Plumber is a compliance scanner for GitLab. It reads your `.gitlab-ci.yml` and r
- Debug trace variables (`CI_DEBUG_TRACE`) leaking secrets in job logs
- Unsafe variable injection via `eval`/`sh -c`/`bash -c` (OWASP CICD-SEC-1)
- Weakened security jobs (`allow_failure: true`, `when: manual`, `rules: [{when: never}]`) on SAST, Secret Detection, and other scanners (OWASP CICD-SEC-4)
- Docker-in-Docker (dind) services enabling container escape on shared runners

**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 CI configuration file** (`.gitlab-ci.yml` by default, or a [custom path](#custom-ci-configuration-file-path)) allowing you to validate changes before pushing.

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

### Available Controls

Plumber includes 13 compliance controls. Each can be enabled/disabled and customized in [.plumber.yaml](.plumber.yaml):
Plumber includes 14 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 @@ -674,6 +675,25 @@ Add any variable name you want to protect to the `variables` list. Variables are

</details>

<details>
<summary><b>14. Pipeline must not use Docker-in-Docker</b></summary>

Detects CI/CD jobs that use Docker-in-Docker (dind) services. Running a Docker daemon inside a CI container on shared runners in privileged mode enables container escape, lateral movement, and access to secrets from other jobs on the same runner.

When `detectInsecureDaemon` is enabled (default: true), the control also flags jobs where TLS is disabled (`DOCKER_TLS_CERTDIR=""`) or the Docker host uses the plaintext port (`tcp://docker:2375`).

**Configuration:**

```yaml
pipelineMustNotUseDockerInDocker:
enabled: true
detectInsecureDaemon: true
```

Consider using [Kaniko](https://github.com/GoogleContainerTools/kaniko) or [Buildah](https://github.com/containers/buildah) as safer alternatives for building container images in CI/CD.

</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 @@ -719,6 +739,7 @@ Controls not selected are reported as **skipped** in the output. The `--controls
| `pipelineMustNotExecuteUnverifiedScripts` |
| `pipelineMustNotIncludeHardcodedJobs` |
| `pipelineMustNotOverrideJobVariables` |
| `pipelineMustNotUseDockerInDocker` |
| `pipelineMustNotUseUnsafeVariableExpansion` |
| `securityJobsMustNotBeWeakened` |

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

```bash
brew install getplumber/plumber/plumber@0.1.76
brew install getplumber/plumber/plumber@0.1.77
```

> **Note:** Versioned formulas are keg-only. Use the full path for example `/usr/local/opt/plumber@0.1.76/bin/plumber` or run `brew link plumber@0.1.76` to add it to your PATH.
> **Note:** Versioned formulas are keg-only. Use the full path for example `/usr/local/opt/plumber@0.1.77/bin/plumber` or run `brew link plumber@0.1.77` to add it to your PATH.

### Mise

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

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

// Control 14: Pipeline must not use Docker-in-Docker
if result.DockerInDockerResult != nil {
ctrl := controlSummary{
name: "Pipeline must not use Docker-in-Docker",
compliance: result.DockerInDockerResult.Compliance,
issues: len(result.DockerInDockerResult.Issues),
skipped: result.DockerInDockerResult.Skipped,
codes: []string{string(control.CodeDockerInDockerUsage), string(control.CodeDockerInDockerInsecure)},
}
controls = append(controls, ctrl)

printControlHeader("Pipeline must not use Docker-in-Docker", result.DockerInDockerResult.Compliance, result.DockerInDockerResult.Skipped)

if result.DockerInDockerResult.Skipped {
fmt.Printf(" %sStatus: SKIPPED (disabled in configuration)%s\n", colorDim, colorReset)
} else {
fmt.Printf(" Jobs Checked: %d\n", result.DockerInDockerResult.Metrics.TotalJobsChecked)
fmt.Printf(" DinD Services Found: %d\n", result.DockerInDockerResult.Metrics.DindServicesFound)
fmt.Printf(" Insecure Daemon Config: %d\n", result.DockerInDockerResult.Metrics.InsecureDaemonFound)

if len(result.DockerInDockerResult.Issues) > 0 {
fmt.Printf("\n %sDocker-in-Docker Issues Found:%s\n", colorYellow, colorReset)
for _, issue := range result.DockerInDockerResult.Issues {
if issue.Code == control.CodeDockerInDockerUsage {
fmt.Printf(" %s•%s [%s] Job '%s' uses DinD service: %s\n", colorYellow, colorReset, issue.Code, issue.JobName, issue.ServiceImage)
fmt.Printf(" %sConsider using Kaniko or Buildah instead%s\n", colorDim, colorReset)
} else {
fmt.Printf(" %s•%s [%s] Job '%s': %s\n", colorYellow, colorReset, issue.Code, issue.JobName, issue.Detail)
}
fmt.Printf(" %s↳ docs: %s%s\n", colorDim, issue.DocURL, colorReset)
}
}
}
fmt.Println()
}

// Summary Section
printSectionHeader("Summary")
fmt.Println()
Expand Down
44 changes: 44 additions & 0 deletions configuration/plumberconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ var validControlSchema = map[string][]string{
"pipelineMustNotOverrideJobVariables": {
"enabled", "variables",
},
"pipelineMustNotUseDockerInDocker": {
"enabled", "detectInsecureDaemon",
},
}

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

// PipelineMustNotOverrideJobVariables control configuration
PipelineMustNotOverrideJobVariables *JobVariablesOverrideControlConfig `yaml:"pipelineMustNotOverrideJobVariables,omitempty"`

// PipelineMustNotUseDockerInDocker control configuration
PipelineMustNotUseDockerInDocker *DockerInDockerControlConfig `yaml:"pipelineMustNotUseDockerInDocker,omitempty"`
}

// ImageForbiddenTagsControlConfig configuration for the forbidden image tags control
Expand Down Expand Up @@ -337,6 +343,17 @@ type JobVariablesOverrideControlConfig struct {
Variables []string `yaml:"variables,omitempty"`
}

// DockerInDockerControlConfig configuration for the Docker-in-Docker detection control
type DockerInDockerControlConfig struct {
// Enabled controls whether this check runs
Enabled *bool `yaml:"enabled,omitempty"`

// DetectInsecureDaemon when true, also flags insecure daemon configuration
// (DOCKER_TLS_CERTDIR="" or DOCKER_HOST pointing to non-TLS port 2375)
// in jobs that use a DinD service.
DetectInsecureDaemon *bool `yaml:"detectInsecureDaemon,omitempty"`
}

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

// GetPipelineMustNotUseDockerInDockerConfig returns the control configuration
// Returns nil if not configured
func (c *PlumberConfig) GetPipelineMustNotUseDockerInDockerConfig() *DockerInDockerControlConfig {
if c == nil {
return nil
}
return c.Controls.PipelineMustNotUseDockerInDocker
}

// IsEnabled returns whether the control is enabled
// Returns false if not properly configured
func (c *DockerInDockerControlConfig) IsEnabled() bool {
if c == nil || c.Enabled == nil {
return false
}
return *c.Enabled
}

// IsDetectInsecureDaemonEnabled returns whether insecure daemon detection is enabled.
// Defaults to true when the field is nil.
func (c *DockerInDockerControlConfig) IsDetectInsecureDaemonEnabled() bool {
if c == nil || c.DetectInsecureDaemon == nil {
return true
}
return *c.DetectInsecureDaemon
}

// 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 @@ -329,6 +329,7 @@ func TestValidControlNames(t *testing.T) {
"pipelineMustNotExecuteUnverifiedScripts",
"pipelineMustNotIncludeHardcodedJobs",
"pipelineMustNotOverrideJobVariables",
"pipelineMustNotUseDockerInDocker",
"pipelineMustNotUseUnsafeVariableExpansion",
"securityJobsMustNotBeWeakened",
}
Expand Down
21 changes: 21 additions & 0 deletions control/codes.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ const (
CodeSecurityJobWeakened ErrorCode = "ISSUE-410"
// ISSUE-411: Pipeline downloads and executes a script without integrity verification (curl|bash, wget|sh)
CodeUnverifiedScriptExecution ErrorCode = "ISSUE-411"
// ISSUE-412: CI/CD job uses a Docker-in-Docker (dind) service
CodeDockerInDockerUsage ErrorCode = "ISSUE-412"
// ISSUE-413: CI/CD job uses Docker-in-Docker with insecure daemon configuration
CodeDockerInDockerInsecure ErrorCode = "ISSUE-413"
)

// Issue codes for access and authorization controls (5xx)
Expand Down Expand Up @@ -201,6 +205,23 @@ var errorCodeRegistry = map[ErrorCode]ErrorCodeInfo{
ControlName: "pipelineMustNotExecuteUnverifiedScripts",
},

CodeDockerInDockerUsage: {
Code: CodeDockerInDockerUsage,
Title: "Docker-in-Docker service detected",
Description: "A CI/CD job uses a Docker-in-Docker (dind) service. On shared runners running in privileged mode, this enables container escape, lateral movement, and access to secrets from other jobs on the same runner.",
Remediation: "Replace Docker-in-Docker with a safer alternative such as Kaniko or Buildah for building container images. These tools do not require privileged mode and avoid the security risks of running a Docker daemon inside a CI container.",
DocURL: docsBaseURL + string(CodeDockerInDockerUsage),
ControlName: "pipelineMustNotUseDockerInDocker",
},
CodeDockerInDockerInsecure: {
Code: CodeDockerInDockerInsecure,
Title: "Docker-in-Docker with insecure daemon configuration",
Description: "A CI/CD job uses Docker-in-Docker with an insecure daemon configuration. Setting DOCKER_TLS_CERTDIR to an empty string or using DOCKER_HOST with tcp://...:2375 disables TLS encryption between the CI job and the Docker daemon, allowing network-level eavesdropping and command injection.",
Remediation: "If Docker-in-Docker is required, ensure TLS is enabled: do not set DOCKER_TLS_CERTDIR to an empty string, and use tcp://docker:2376 (TLS) instead of tcp://docker:2375 (plaintext). Prefer Kaniko or Buildah to avoid this pattern entirely.",
DocURL: docsBaseURL + string(CodeDockerInDockerInsecure),
ControlName: "pipelineMustNotUseDockerInDocker",
},

// Access and authorization controls (5xx)
CodeBranchUnprotected: {
Code: CodeBranchUnprotected,
Expand Down
Loading
Loading