Skip to content
Open
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
19 changes: 19 additions & 0 deletions .plumber.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,25 @@ gitlab:
# Minimum access level required to push (0=No one, 30=Developer, 40=Maintainer)
minPushAccessLevel: 40
# ===========================================
# MR approval rules must require a minimum number of approvals
# ===========================================
# Flags approval rules covering all protected branches that require fewer
# approvals than the minimum below. GitLab Premium/Ultimate feature; on
# Free the approvals API returns no rules, so it passes vacuously. A
# genuine 401/403 reports not-evaluable.
mergeRequestApprovalRulesMustRequireMinimumApprovals:
enabled: true
minimumRequiredApprovals: 1
# ===========================================
# MR approval rules must cover all protected branches
# ===========================================
# Flags a project where no approval rule applies to all protected branches
# (explicit "all protected branches" target only). GitLab Premium/Ultimate
# feature; on Free the approvals API returns empty, so this fires. A genuine
# 401/403 reports not-evaluable.
mergeRequestApprovalRulesMustCoverAllProtectedBranches:
enabled: true
# ===========================================
# Pipeline must not include hardcoded jobs
# ===========================================
# Detects CI/CD jobs that are defined directly in the .gitlab-ci.yml file
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,7 @@ Plumber ships controls for:

- container image pinning and authorized sources
- branch protection
- GitLab merge request approval rules (minimum approvals, coverage of all protected branches)
- unverified script execution (`curl | bash`, `base64 -d | bash`, etc.)
- Docker-in-Docker
- weakened security jobs
Expand Down
1 change: 1 addition & 0 deletions cmd/analyze_shared.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ func outputTextWithProvider(p provider.Provider, result *control.AnalysisResult,
// real) and drop the green stat blocks (#220).
renderFindingGroups(filterGroupsForDegraded(groups, result.DataCollectionDegraded))
renderWarnings(result.Warnings)
renderApprovalRulesTierCaveat(result)

printSectionHeader("Summary")
fmt.Println()
Expand Down
63 changes: 62 additions & 1 deletion cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const (

catImages = "Container image security (tags, trusted registries)"
catComposition = "Pipeline composition (includes, scripts, security jobs, DinD)"
catAccess = "Access control (branch protection)"
catAccess = "Access control (branch protection, MR approvals)"
catVariables = "Variable security (debug trace, unsafe expansion)"

// GitLab-applicable composition checks (existing).
Expand Down Expand Up @@ -195,6 +195,12 @@ type initWizardState struct {
BranchMinMergeAccessLevel string
BranchMinPushAccessLevel string

// mergeRequestApprovalRulesMustRequireMinimumApprovals /
// ...MustCoverAllProtectedBranches (GitLab-only, catAccess).
MRApprovalMinEnabled bool
MRApprovalMinCount string
MRApprovalCoverAllEnabled bool

// pipelineMustNotEnableDebugTrace
DebugForbiddenVariablesMultiline string

Expand Down Expand Up @@ -490,6 +496,28 @@ func (st *initWizardState) askAccessQuestions() error {
}, &st.BranchMinPushAccessLevel); err != nil {
return err
}
if err := survey.AskOne(&survey.Confirm{
Message: "Flag MR approval rules that require fewer than a minimum number of approvals? (GitLab)",
Help: "Checks approval rules covering all protected branches against a minimum. Requires a token that can read approval rules (a premium feature). Ships off by default.",
Default: defaultMRApprovalMinEnabled(),
}, &st.MRApprovalMinEnabled); err != nil {
return err
}
if st.MRApprovalMinEnabled {
if err := survey.AskOne(&survey.Input{
Message: "Minimum approvals a rule covering all protected branches must require (GitLab)",
Default: fmt.Sprintf("%d", defaultMRApprovalMinCount()),
}, &st.MRApprovalMinCount); err != nil {
return err
}
}
if err := survey.AskOne(&survey.Confirm{
Message: "Flag projects where no MR approval rule covers all protected branches? (GitLab)",
Help: "A protected branch with no covering approval rule can be merged with no required approval. Ships off by default.",
Default: defaultMRApprovalCoverAllEnabled(),
}, &st.MRApprovalCoverAllEnabled); err != nil {
return err
}
}
if hasProvider(st, "github") {
if err := survey.AskOne(&survey.Confirm{
Expand Down Expand Up @@ -773,6 +801,30 @@ var embeddedDefault = sync.OnceValue(func() *configuration.PlumberConfig {
func defaultGitLabControls() configuration.ControlsConfig { return embeddedDefault().GitLab.Controls }
func defaultGitHubControls() configuration.ControlsConfig { return embeddedDefault().GitHub.Controls }

// defaultMRApproval* source the wizard's Confirm/Input defaults for the
// merge-request approval-rule controls from the shipped default, so the
// prompts and the zero-config baseline cannot drift.
func defaultMRApprovalMinEnabled() bool {
if c := defaultGitLabControls().MergeRequestApprovalRulesMustRequireMinimumApprovals; c != nil {
return c.IsEnabled()
}
return false
}

func defaultMRApprovalMinCount() int {
if c := defaultGitLabControls().MergeRequestApprovalRulesMustRequireMinimumApprovals; c != nil && c.MinimumRequiredApprovals != nil {
return *c.MinimumRequiredApprovals
}
return 1
}

func defaultMRApprovalCoverAllEnabled() bool {
if c := defaultGitLabControls().MergeRequestApprovalRulesMustCoverAllProtectedBranches; c != nil {
return c.IsEnabled()
}
return false
}

// defaultForbiddenTags is the CSV prompt default for forbidden image tags,
// sourced from the GitLab containerImageMustNotUseForbiddenTags default.
func defaultForbiddenTags() string {
Expand Down Expand Up @@ -1009,6 +1061,15 @@ func (st *initWizardState) applyAccessControls(gl, gh *configuration.ProviderCon
MinMergeAccessLevel: intPtrInit(parseIntInit(st.BranchMinMergeAccessLevel, 30)),
MinPushAccessLevel: intPtrInit(parseIntInit(st.BranchMinPushAccessLevel, 40)),
}
if st.MRApprovalMinEnabled {
Comment thread
Joseph94m marked this conversation as resolved.
gl.Controls.MergeRequestApprovalRulesMustRequireMinimumApprovals = &configuration.MRApprovalRulesMinApprovalsControlConfig{
Enabled: boolPtrInit(true),
MinimumRequiredApprovals: intPtrInit(parseIntInit(st.MRApprovalMinCount, defaultMRApprovalMinCount())),
}
}
if st.MRApprovalCoverAllEnabled {
gl.Controls.MergeRequestApprovalRulesMustCoverAllProtectedBranches = &configuration.EnabledOnlyControlConfig{Enabled: boolPtrInit(true)}
}
}
if gh != nil {
// GitHub branch-protection ignores access-level fields.
Expand Down
59 changes: 59 additions & 0 deletions cmd/init_mr_approval_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package cmd

import (
"testing"

"github.com/getplumber/plumber/configuration"
)

// applyAccessControls maps the three MR-approval wizard answers onto GitLab
// config. A field mix-up (wrong parse target, inverted Enabled, or one control
// writing the other's field) would silently emit a wrong .plumber.yaml from
// `plumber config init` with no drift guard, since both controls ship disabled.
// This pins the mapping.
func TestApplyAccessControls_MRApprovalMapping(t *testing.T) {
t.Run("min-approvals: enabled with the parsed count", func(t *testing.T) {
gl := &configuration.ProviderConfig{}
(&initWizardState{MRApprovalMinEnabled: true, MRApprovalMinCount: "3"}).applyAccessControls(gl, nil)
c := gl.Controls.MergeRequestApprovalRulesMustRequireMinimumApprovals
if c == nil || !c.IsEnabled() {
t.Fatal("min-approvals control should be enabled")
}
if c.MinimumRequiredApprovals == nil || *c.MinimumRequiredApprovals != 3 {
t.Fatalf("MinimumRequiredApprovals = %v, want 3", c.MinimumRequiredApprovals)
}
if gl.Controls.MergeRequestApprovalRulesMustCoverAllProtectedBranches != nil {
t.Fatal("cover-all must stay unset when only min-approvals was chosen (field mix-up)")
}
})
t.Run("cover-all: enabled, min-approvals untouched", func(t *testing.T) {
gl := &configuration.ProviderConfig{}
(&initWizardState{MRApprovalCoverAllEnabled: true}).applyAccessControls(gl, nil)
if c := gl.Controls.MergeRequestApprovalRulesMustCoverAllProtectedBranches; c == nil || !c.IsEnabled() {
t.Fatal("cover-all control should be enabled")
}
if gl.Controls.MergeRequestApprovalRulesMustRequireMinimumApprovals != nil {
t.Fatal("min-approvals must stay unset when only cover-all was chosen (field mix-up)")
}
})
t.Run("neither approval control set when neither chosen", func(t *testing.T) {
gl := &configuration.ProviderConfig{}
(&initWizardState{}).applyAccessControls(gl, nil)
if gl.Controls.MergeRequestApprovalRulesMustRequireMinimumApprovals != nil || gl.Controls.MergeRequestApprovalRulesMustCoverAllProtectedBranches != nil {
t.Fatal("no approval controls should be set when neither was chosen")
}
})
// The wizard defaults must come from the embedded shipped default (both ship
// disabled), so the prompt defaults and the zero-config baseline cannot drift.
t.Run("defaults sourced from the embedded shipped default", func(t *testing.T) {
if defaultMRApprovalMinEnabled() {
t.Error("defaultMRApprovalMinEnabled should be false (ships disabled)")
}
if defaultMRApprovalCoverAllEnabled() {
t.Error("defaultMRApprovalCoverAllEnabled should be false (ships disabled)")
}
if got := defaultMRApprovalMinCount(); got < 1 {
t.Errorf("defaultMRApprovalMinCount = %d, want >= 1 (the shipped minimum)", got)
}
})
}
58 changes: 58 additions & 0 deletions cmd/legacy_json.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,32 @@ func _withControlMeta(block any, e control.ControlEntry, result *control.Analysi
if m, ok := block.(map[string]any); ok {
m["controlName"] = e.ControlName
m["status"] = control.StatusFor(e, result, findingCount)
if result != nil && result.ApprovalRulesTierCaveat && isApprovalRuleControl(e.ControlName) {
// Structured so a consumer can key on it: the approvals API returned
// no rules, which on GitLab Free means the feature is unavailable
// (the API 200-empties) rather than a real misconfiguration.
m["tierCaveat"] = map[string]any{
"reason": "no-approval-rules-returned",
"requiresTier": "premium_or_ultimate",
"message": approvalRulesTierCaveatMessage,
}
}
}
return block
}

// approvalRulesTierCaveatMessage explains the Premium/Ultimate requirement for
// the MR approval-rule controls when the approvals API returned no rules.
// Shared by the terminal caveat (render_details.go) and the JSON tierCaveat.
const approvalRulesTierCaveatMessage = "MR approval rules are a GitLab Premium/Ultimate feature. Disable these controls if you don't have GitLab Premium or Ultimate."

// isApprovalRuleControl reports whether a control name is one of the two
// GitLab MR approval-rule controls the tier caveat applies to.
func isApprovalRuleControl(name string) bool {
return name == "mergeRequestApprovalRulesMustRequireMinimumApprovals" ||
name == "mergeRequestApprovalRulesMustCoverAllProtectedBranches"
}

// buildLegacyResult routes a control entry to its legacy JSON
// builder and returns the (jsonKey, block) pair.
func buildLegacyResult(e control.ControlEntry, result *control.AnalysisResult, pc *configuration.PlumberConfig, findings []opaengine.Finding) (string, any) {
Expand Down Expand Up @@ -144,10 +166,46 @@ func buildLegacyResult(e control.ControlEntry, result *control.AnalysisResult, p
return "jobVariablesOverrideResult", buildJobVariablesOverrideBlock(common, result, findings)
case "pipelineMustNotUseDockerInDocker":
return "dockerInDockerResult", buildDockerInDockerBlock(common, result, findings)
case "mergeRequestApprovalRulesMustRequireMinimumApprovals":
return "mrApprovalRulesMinApprovalsResult", buildMRApprovalRulesMinApprovalsBlock(common, findings)
case "mergeRequestApprovalRulesMustCoverAllProtectedBranches":
return "mrApprovalRulesCoverAllBranchesResult", buildMRApprovalRulesCoverAllBranchesBlock(common, findings)
}
return "", nil
}

// buildMRApprovalRulesMinApprovalsBlock and
// buildMRApprovalRulesCoverAllBranchesBlock emit the legacy JSON blocks for the
// merge-request approval-rule controls. The findings are settings-level (no
// file/job), so each issue carries the approval-rule identity (approvalRuleId)
// plus the ruleName / approvalsRequired / minApprovalsRequired data that
// projectFindings preserves from f.Data.
func buildMRApprovalRulesMinApprovalsBlock(c legacyCommon, findings []opaengine.Finding) map[string]any {
return map[string]any{
"issues": projectFindings(findings, "job"),
"metrics": map[string]any{
"rulesBelowMinimum": len(findings),
},
"version": "0.1.0",
"ciValid": c.CiValid,
"ciMissing": c.CiMissing,
"skipped": c.Skipped,
}
}

func buildMRApprovalRulesCoverAllBranchesBlock(c legacyCommon, findings []opaengine.Finding) map[string]any {
return map[string]any{
"issues": projectFindings(findings, "job"),
"metrics": map[string]any{
"allProtectedBranchesRuleMissing": len(findings),
},
"version": "0.1.0",
"ciValid": c.CiValid,
"ciMissing": c.CiMissing,
"skipped": c.Skipped,
}
}

// legacyCommon carries the bookkeeping fields shared by every
// `*Result` block: ciValid, ciMissing, skipped.
type legacyCommon struct {
Expand Down
119 changes: 119 additions & 0 deletions cmd/legacy_json_gitlab_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package cmd

import (
"testing"

"github.com/getplumber/plumber/control"
opaengine "github.com/getplumber/plumber/internal/engine/opa"
)

// TestMRApprovalRulesJSONBlocks locks the results.json detail blocks for the
// two GitLab merge-request approval-rule controls (ISSUE-502/504). Before the
// per-control wiring, buildLegacyResult would return ("", nil) and the blocks
// would be dropped entirely — a dashboard would see the score deficit in
// plumberScore.codeLosses with no per-control detail. Mirrors the GitHub
// TestPullRequestTargetHeadCheckoutJSONBlock, the guard for the same bug class.
func TestMRApprovalRulesJSONBlocks(t *testing.T) {
result := &control.AnalysisResult{CiValid: true}

// ISSUE-502: minimum-approvals — one failing rule, keyed on the stable ID.
minEntry := control.ControlEntry{
DisplayName: "MR approval rules must require a minimum number of approvals",
ControlName: "mergeRequestApprovalRulesMustRequireMinimumApprovals",
}
minFindings := []opaengine.Finding{{
Code: "ISSUE-502",
Data: map[string]any{
"approvalRuleId": "42",
"ruleName": "Security",
"approvalsRequired": 1,
"minApprovalsRequired": 2,
},
}}

name, block := buildLegacyResult(minEntry, result, nil, minFindings)
if name != "mrApprovalRulesMinApprovalsResult" {
t.Fatalf("502 block name = %q, want mrApprovalRulesMinApprovalsResult (dispatch dropped the block)", name)
}
m, ok := block.(map[string]any)
if !ok {
t.Fatalf("502 block is %T, want map[string]any", block)
}
issues, ok := m["issues"].([]map[string]any)
if !ok || len(issues) != 1 {
t.Fatalf("502 issues = %v, want exactly 1 entry", m["issues"])
}
if issues[0]["code"] != "ISSUE-502" {
t.Errorf("502 issue code = %v, want ISSUE-502", issues[0]["code"])
}
// The rule identity (approvalRuleId) must survive into the issue block, so a
// platform can group the finding across runs; the renameable name is data.
if issues[0]["approvalRuleId"] != "42" {
t.Errorf("502 issue must carry approvalRuleId=42, got %v", issues[0]["approvalRuleId"])
}
if metrics, ok := m["metrics"].(map[string]any); !ok || metrics["rulesBelowMinimum"] != 1 {
t.Errorf("502 metrics.rulesBelowMinimum = %v, want 1", m["metrics"])
}

// ISSUE-504: cover-all — singleton finding.
coverEntry := control.ControlEntry{
DisplayName: "MR approval rules must cover all protected branches",
ControlName: "mergeRequestApprovalRulesMustCoverAllProtectedBranches",
}
coverFindings := []opaengine.Finding{{
Code: "ISSUE-504",
Data: map[string]any{"totalRules": 2},
}}

name, block = buildLegacyResult(coverEntry, result, nil, coverFindings)
if name != "mrApprovalRulesCoverAllBranchesResult" {
t.Fatalf("504 block name = %q, want mrApprovalRulesCoverAllBranchesResult (dispatch dropped the block)", name)
}
m, ok = block.(map[string]any)
if !ok {
t.Fatalf("504 block is %T, want map[string]any", block)
}
if issues, ok := m["issues"].([]map[string]any); !ok || len(issues) != 1 || issues[0]["code"] != "ISSUE-504" {
t.Fatalf("504 issues = %v, want exactly 1 ISSUE-504 entry", m["issues"])
}
if metrics, ok := m["metrics"].(map[string]any); !ok || metrics["allProtectedBranchesRuleMissing"] != 1 {
t.Errorf("504 metrics.allProtectedBranchesRuleMissing = %v, want 1", m["metrics"])
}

// A clean run (no findings) still returns a block, with an empty issues list
// — the control is present and evaluated, not absent.
if _, clean := buildLegacyResult(minEntry, result, nil, nil); clean == nil {
t.Errorf("502 clean run returned a nil block; the control must still appear")
}
}

// TestMRApprovalRulesTierCaveatJSON pins the structured Premium/Ultimate caveat
// stamped onto the approval-rule blocks when the run flagged the ambiguous
// zero-rules case (GitLab Free returns an empty list). It must attach only to
// the two approval-rule controls, and only when the run set the flag.
func TestMRApprovalRulesTierCaveatJSON(t *testing.T) {
flagged := &control.AnalysisResult{CiValid: true, ApprovalRulesTierCaveat: true}
approval := control.ControlEntry{ControlName: "mergeRequestApprovalRulesMustCoverAllProtectedBranches"}

block := _withControlMeta(map[string]any{"issues": []map[string]any{}}, approval, flagged, 0)
m := block.(map[string]any)
tc, ok := m["tierCaveat"].(map[string]any)
if !ok {
t.Fatalf("expected a tierCaveat on the approval-rule block, got %v", m)
}
if tc["reason"] != "no-approval-rules-returned" || tc["requiresTier"] != "premium_or_ultimate" {
t.Errorf("tierCaveat shape mismatch: %v", tc)
}

// A non-approval control must NOT get the caveat, even when the flag is set.
other := _withControlMeta(map[string]any{}, control.ControlEntry{ControlName: "branchMustBeProtected"}, flagged, 0)
if _, present := other.(map[string]any)["tierCaveat"]; present {
t.Errorf("tierCaveat leaked onto a non-approval control")
}

// Flag not set (rules were present, or a premium project): no caveat.
clean := &control.AnalysisResult{CiValid: true}
if _, present := _withControlMeta(map[string]any{}, approval, clean, 0).(map[string]any)["tierCaveat"]; present {
t.Errorf("tierCaveat present when the run did not flag it")
}
}
Loading