Skip to content
Draft
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
57 changes: 57 additions & 0 deletions .plumber.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,63 @@ gitlab:
# required: templates/go/go AND templates/trivy/trivy AND templates/iso27001/iso27001
requiredGroups: []
# ===========================================
# CI/CD components must come from authorized sources
# ===========================================
# Detects `include: component:` references that are not trusted.
# Components run arbitrary code with the job's full context
# (variables, secrets, CI_JOB_TOKEN) — the GitLab analogue of a
# GitHub Actions "pwn request".
#
# A source is trusted when it matches trustedComponents, OR lives
# under this project's own root namespace on the same GitLab
# instance (trustSameGroupComponents), OR is hosted on the same
# GitLab instance at all (trustSameInstanceComponents — on by
# default). Both are derived dynamically from the pipeline at scan
# time, in CI or locally — no environment variables involved.
#
# Best practice: keep components scoped to your own project/group
componentMustComeFromAuthorizedSources:
# Set to false to disable this control
enabled: true
# Trust components under this project's own root namespace
trustSameGroupComponents: true
# Trust any component on the same GitLab instance, regardless of
# namespace. (defaults to true when self-hosted, false on gitlab.com)
trustSameInstanceComponents: true
# Additional trusted component source URLs and patterns (supports wildcards)
trustedComponents: []
# ===========================================
# GitLab Functions must come from authorized sources
# ===========================================
# Detects `run:` step function references (the `func:` keyword, or
# the deprecated `step:` alias) that are not trusted. Functions run
# arbitrary code with the job's full context, the same supply-chain
# exposure as CI/CD components. Trust is evaluated the same way
# regardless of reference form — deprecated forms are not a free
# pass; deprecation is tracked separately (see the Deprecated stat
# in `plumber analyze` output) and doesn't affect this control.
#
# A reference is trusted when it matches trustedFunctions (patterns
# may reference $CI_* variables — a pattern is rejected if the
# pipeline redefines one of those variables itself; both $VAR and
# ${VAR} notation are accepted and normalized identically), OR its
# host matches the scanned GitLab instance and its path after that
# host starts with this project's own root namespace
# (trustSameGroupFunctions).
#
# Best practice: keep functions scoped to your own project/group
functionMustComeFromAuthorizedSources:
# Set to false to disable this control
enabled: true
# Trust functions under this project's own root namespace
trustSameGroupFunctions: true
# Additional trusted function source URLs and patterns (supports
# wildcards). Both $VAR and ${VAR} notation are listed below since
# pipeline authors write either form.
trustedFunctions:
- $CI_TEMPLATE_REGISTRY_HOST/$CI_PROJECT_PATH/*
- ${CI_TEMPLATE_REGISTRY_HOST}/${CI_PROJECT_PATH}/*
# ===========================================
# Pipeline must not enable debug trace
# ===========================================
# Detects CI/CD pipelines that set CI_DEBUG_TRACE or CI_DEBUG_SERVICES
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,7 @@ The wizard is a **separate code path** from `.plumber.yaml`: it builds a curated

- [ ] **`README.md`** — usually nothing to do. The README no longer carries a control count, per-control `<details>` blocks, or a "Valid control names" table; its `## Controls` section is a short prose summary that links to the website catalog. Touch it only if your control introduces a category that summary does not already cover.
- [ ] **`docs/GITHUB_ISSUES.md`** (GitHub controls only) — add a TOC row under the right severity-category section (1xx/2xx/3xx/4xx/5xx/6xx), then a detailed `## ISSUE-XXX — <rule-name>` section with: severity + control name banner, threat model paragraph, bad/good YAML examples, FP guards if any, config snippet. Reference: the ISSUE-411 section we added during the Megalodon work.
- [ ] **`docs/GITLAB_ISSUES.md`** (GitLab controls only) — same shape as the GitHub catalog above: a TOC row under the right severity-category section, then a detailed `## ISSUE-XXX — <rule-name>` section (severity + control name banner, threat model paragraph, bad/good YAML examples, config snippet). Reference: the ISSUE-414/ISSUE-415 sections added for the authorized-sources work.
- [ ] **`docs/PBOM.md`** — only if you added PBOM enrichment (§11). Document both the JSON field and the CycloneDX property.
- [ ] **`docs/scoring.md`** — only if your control's severity or contribution changes the score formula. Adding a new control at an existing severity does not require an update.
- [ ] **Website — `getplumber.io/src/data/issues.ts`**. Each `ISSUE-XXX` entry has up to two sub-blocks keyed by provider:
Expand Down
108 changes: 99 additions & 9 deletions cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,16 @@ const (
catVariables = "Variable security (debug trace, unsafe expansion)"

// GitLab-applicable composition checks (existing).
compHardcoded = "Disallow hardcoded jobs (use includes/components)"
compUpToDate = "Require catalog includes to be up to date"
compForbidden = "Forbid mutable include refs (latest, main, HEAD, …)"
compRefCollision = "Flag include refs that resolve to both a tag and a branch"
compSecurity = "Detect weakened security scanning jobs"
compScripts = "Detect unverified script execution (curl|bash, base64|bash, |sh, …)"
compJobVars = "Detect sensitive variables overridden in pipeline YAML"
compDinD = "Detect Docker-in-Docker (dind) usage"
compHardcoded = "Disallow hardcoded jobs (use includes/components)"
compUpToDate = "Require catalog includes to be up to date"
compForbidden = "Forbid mutable include refs (latest, main, HEAD, …)"
compRefCollision = "Flag include refs that resolve to both a tag and a branch"
compAuthorizedComponents = "Restrict CI/CD components to authorized sources"
compAuthorizedFunctions = "Restrict GitLab Functions to authorized sources"
compSecurity = "Detect weakened security scanning jobs"
compScripts = "Detect unverified script execution (curl|bash, base64|bash, |sh, …)"
compJobVars = "Detect sensitive variables overridden in pipeline YAML"
compDinD = "Detect Docker-in-Docker (dind) usage"

// GitHub-applicable composition checks (new). The cross-provider ones
// (security jobs, DinD) reuse compSecurity / compDinD above.
Expand Down Expand Up @@ -157,6 +159,15 @@ type initWizardState struct {
ForbiddenVersionsMultiline string
DefaultBranchIsForbiddenVersion bool

// componentMustComeFromAuthorizedSources (when compAuthorizedComponents selected)
TrustedComponentsMultiline string
TrustSameGroupComponentsEnabled bool
TrustSameInstanceComponentsEnabled bool

// functionMustComeFromAuthorizedSources (when compAuthorizedFunctions selected)
TrustedFunctionsMultiline string
TrustSameGroupFunctionsEnabled bool

// securityJobsMustNotBeWeakened (when compSecurity selected). All
// three sub-toggles are tracked per provider: GitLab ships them off
// (its security templates trip allow_failure / rules / when:manual)
Expand Down Expand Up @@ -379,6 +390,44 @@ func (st *initWizardState) askCompositionFirstHalf() error {
return err
}
}
if compSelected(st, compAuthorizedComponents) && hasProvider(st, "gitlab") {
fmt.Fprintf(os.Stderr, "\n › Authorized component sources (GitLab)\n")
if err := survey.AskOne(&survey.Confirm{
Message: "Trust CI/CD components under this project's own root namespace?",
Default: true,
}, &st.TrustSameGroupComponentsEnabled); err != nil {
return err
}
if err := survey.AskOne(&survey.Confirm{
Message: "Trust CI/CD components hosted on the same GitLab instance, any namespace?",
Default: true,
}, &st.TrustSameInstanceComponentsEnabled); err != nil {
return err
}
if err := survey.AskOne(&survey.Multiline{
Message: "Additional trusted component source URL patterns (one per line)",
Help: "Supports wildcards. Leave empty to rely only on the namespace/instance trust above.",
Default: strings.Join(defaultTrustedComponents(), "\n"),
}, &st.TrustedComponentsMultiline); err != nil {
return err
}
}
if compSelected(st, compAuthorizedFunctions) && hasProvider(st, "gitlab") {
fmt.Fprintf(os.Stderr, "\n › Authorized function sources (GitLab)\n")
if err := survey.AskOne(&survey.Confirm{
Message: "Trust GitLab Functions under this project's own root namespace?",
Default: true,
}, &st.TrustSameGroupFunctionsEnabled); err != nil {
return err
}
if err := survey.AskOne(&survey.Multiline{
Message: "Additional trusted function source URL patterns (one per line)",
Help: "Supports wildcards. Leave empty to rely only on the namespace trust above.",
Default: strings.Join(defaultTrustedFunctions(), "\n"),
}, &st.TrustedFunctionsMultiline); err != nil {
return err
}
}
if compSelected(st, compSecurity) {
if err := st.askSecurityJobQuestions(); err != nil {
return err
Expand Down Expand Up @@ -719,7 +768,7 @@ func compositionOptionsForProviders(providers []string) []string {
}
var out []string
if hasGitLab {
out = append(out, compHardcoded, compUpToDate, compForbidden, compRefCollision)
out = append(out, compHardcoded, compUpToDate, compForbidden, compRefCollision, compAuthorizedComponents, compAuthorizedFunctions)
}
out = append(out, compSecurity, compDinD)
if hasGitLab {
Expand Down Expand Up @@ -909,6 +958,24 @@ func defaultJobOverrideVariables() []string {
return nil
}

// defaultTrustedComponents mirrors the .plumber.yaml default for
// gitlab.controls.componentMustComeFromAuthorizedSources.trustedComponents.
func defaultTrustedComponents() []string {
if c := defaultGitLabControls().ComponentMustComeFromAuthorizedSources; c != nil {
return c.TrustedComponents
}
return nil
}

// defaultTrustedFunctions mirrors the .plumber.yaml default for
// gitlab.controls.functionMustComeFromAuthorizedSources.trustedFunctions.
func defaultTrustedFunctions() []string {
if c := defaultGitLabControls().FunctionMustComeFromAuthorizedSources; c != nil {
return c.TrustedFunctions
}
return nil
}

func defaultSecurityJobPatterns() []string {
if c := defaultGitLabControls().SecurityJobsMustNotBeWeakened; c != nil {
return c.SecurityJobPatterns
Expand Down Expand Up @@ -1116,6 +1183,29 @@ func (st *initWizardState) toPlumberConfig() *configuration.PlumberConfig {
Variables: vars,
}
}
if compSelected(st, compAuthorizedComponents) {
comps := parseLinesInit(st.TrustedComponentsMultiline)
if len(comps) == 0 {
comps = defaultTrustedComponents()
}
gl.Controls.ComponentMustComeFromAuthorizedSources = &configuration.ComponentAuthorizedSourcesControlConfig{
Enabled: boolPtrInit(true),
TrustSameGroupComponents: boolPtrInit(st.TrustSameGroupComponentsEnabled),
TrustSameInstanceComponents: boolPtrInit(st.TrustSameInstanceComponentsEnabled),
TrustedComponents: comps,
}
}
if compSelected(st, compAuthorizedFunctions) {
funcs := parseLinesInit(st.TrustedFunctionsMultiline)
if len(funcs) == 0 {
funcs = defaultTrustedFunctions()
}
gl.Controls.FunctionMustComeFromAuthorizedSources = &configuration.FunctionAuthorizedSourcesControlConfig{
Enabled: boolPtrInit(true),
TrustSameGroupFunctions: boolPtrInit(st.TrustSameGroupFunctionsEnabled),
TrustedFunctions: funcs,
}
}

if e := strings.TrimSpace(st.RequiredComponentsExpr); e != "" {
gl.Controls.PipelineMustIncludeComponent = &configuration.RequiredComponentsControlConfig{
Expand Down
55 changes: 54 additions & 1 deletion cmd/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,10 +208,15 @@ func starterWizardConfig() *configuration.PlumberConfig {
TrustedURLsText: strings.Join(defaultTrustedURLs(), "\n"),
AuthorizedActionsUsePlumberList: true,
CompositionChoices: []string{
compHardcoded, compUpToDate, compForbidden, compRefCollision, compSecurity, compScripts, compJobVars, compDinD,
compHardcoded, compUpToDate, compForbidden, compRefCollision, compAuthorizedComponents, compAuthorizedFunctions, compSecurity, compScripts, compJobVars, compDinD,
compActionPin, compAuthorizedActions, compDangerousTriggers, compPRTargetHead, compDeclarePermissions, compReusableSecrets, compOverprovSecrets, compTemplateInjection,
compEnvInjection, compWriteAllPerms, compRefConfusion, compArchivedActions, compKnownCVEs, compImpostorCommit, compMutableRemoteExec, compCachePoisoning, compDebugTraceGitHub,
},
TrustSameGroupComponentsEnabled: true,
TrustSameInstanceComponentsEnabled: true,
TrustedComponentsMultiline: strings.Join(defaultTrustedComponents(), "\n"),
TrustSameGroupFunctionsEnabled: true,
TrustedFunctionsMultiline: strings.Join(defaultTrustedFunctions(), "\n"),
ActionPinTrustedOwnersMultiline: strings.Join(defaultGitHubTrustedActionOwners(), "\n"),
SecurityJobPatternsGitHubMultiline: strings.Join(defaultGitHubSecurityJobPatterns(), "\n"),
ForbiddenVersionsMultiline: strings.Join(defaultForbiddenVersions(), "\n"),
Expand Down Expand Up @@ -265,6 +270,54 @@ func TestStarterGitHubControlsMatchEmbeddedDefault(t *testing.T) {
}
}

// enabledGitLabControlKeys parses a .plumber.yaml document and returns the
// set of gitlab.controls.<name> keys whose block has enabled: true.
func enabledGitLabControlKeys(t *testing.T, doc []byte) map[string]bool {
t.Helper()
var root map[string]interface{}
if err := yaml.Unmarshal(doc, &root); err != nil {
t.Fatalf("unmarshal: %v", err)
}
out := map[string]bool{}
gl, _ := root["gitlab"].(map[interface{}]interface{})
if gl == nil {
return out
}
controls, _ := gl["controls"].(map[interface{}]interface{})
for name, block := range controls {
m, ok := block.(map[interface{}]interface{})
if !ok {
continue
}
if enabled, _ := m["enabled"].(bool); enabled {
out[name.(string)] = true
}
}
return out
}

// Every GitLab control enabled in the embedded default must also be emitted
// (and enabled) when the wizard defaults are accepted. This is the GitLab-side
// twin of TestStarterGitHubControlsMatchEmbeddedDefault — the durable
// regression guard against `config init` drifting behind the shipped control
// set again.
func TestStarterGitLabControlsMatchEmbeddedDefault(t *testing.T) {
wantKeys := enabledGitLabControlKeys(t, defaultconfig.Get())
if len(wantKeys) == 0 {
t.Fatal("embedded default exposed no enabled gitlab controls; test wiring is wrong")
}
starterBytes, err := yaml.Marshal(starterWizardConfig())
if err != nil {
t.Fatalf("marshal starter: %v", err)
}
gotKeys := enabledGitLabControlKeys(t, starterBytes)
for k := range wantKeys {
if !gotKeys[k] {
t.Errorf("config init starter omits gitlab control %q that ships enabled in the embedded default", k)
}
}
}

func TestStarterPlumberConfigValidate(t *testing.T) {
cfg := starterWizardConfig()
if err := cfg.Validate(); err != nil {
Expand Down
43 changes: 43 additions & 0 deletions cmd/render_details.go
Original file line number Diff line number Diff line change
Expand Up @@ -669,6 +669,49 @@ func buildGitLabControlStats(controlName string, result *control.AnalysisResult,
{Label: "Authorized", Value: fmt.Sprintf("%d", authorized)},
{Label: "Unauthorized", Value: fmt.Sprintf("%d", unauthorized)},
}
case "componentMustComeFromAuthorizedSources":
total := 0
if result.GitLabPipeline != nil {
for _, inc := range result.GitLabPipeline.Includes {
if inc.Kind == "component" {
total++
}
}
}
unauthorized := findingsCount
authorized := total - unauthorized
if authorized < 0 {
authorized = 0
}
return []statLine{
{Label: "Total Components", Value: fmt.Sprintf("%d", total)},
{Label: "Authorized", Value: fmt.Sprintf("%d", authorized)},
{Label: "Unauthorized", Value: fmt.Sprintf("%d", unauthorized)},
}
case "functionMustComeFromAuthorizedSources":
total := 0
deprecated := 0
if result.GitLabPipeline != nil {
for _, job := range result.GitLabPipeline.Jobs {
total += len(job.Functions)
for _, fn := range job.Functions {
if fn.Deprecated {
deprecated++
}
}
}
}
unauthorized := findingsCount
authorized := total - unauthorized
if authorized < 0 {
authorized = 0
}
return []statLine{
{Label: "Total Functions", Value: fmt.Sprintf("%d", total)},
{Label: "Authorized", Value: fmt.Sprintf("%d", authorized)},
{Label: "Unauthorized", Value: fmt.Sprintf("%d", unauthorized)},
{Label: "Deprecated", Value: fmt.Sprintf("%d", deprecated)},
}
case "pipelineMustNotIncludeHardcodedJobs":
total := uint(0)
hardcoded := uint(0)
Expand Down
Loading