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
54 changes: 54 additions & 0 deletions .plumber.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,60 @@ 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
# ===========================================
# MR approval settings must be compliant
# ===========================================
# Checks the project's MR approval settings against the expectations
# below (unset/false = not checked; behaviorWhenCommitIsAdded is a
# minimum on keep_approvals < remove_approvals_by_code_owners <
# remove_all_approvals). REQUIRES GITLAB PREMIUM OR ULTIMATE: on Free
# the settings do not exist and the API answers with defaults, so this
# fires. A genuine 401/403 reports not-evaluable.
mergeRequestApprovalSettingsMustBeCompliant:
enabled: true
preventApprovalByAuthor: true
preventApprovalsByCommitters: true
preventEditingApprovalRulesInMR: true
requireReAuthToApprove: false
behaviorWhenCommitIsAdded: remove_all_approvals
# ===========================================
# MR settings must be compliant
# ===========================================
# Requires the project's merge-request/merge settings (Settings > Merge
# requests) to match the values below exactly. Every field is optional:
# remove any you don't want enforced. mergeMethod is one of merge, ff,
# rebase_merge; squashOption is one of never, always, default_on,
# default_off. mergePipelinesEnabled and mergeTrainsEnabled are GitLab
# Premium/Ultimate (always false on Free, so drop them there).
mergeRequestSettingsMustBeCompliant:
enabled: false
mergeMethod: ff # linear history, no merge commits
squashOption: default_on # squash to one commit by default
mergePipelinesEnabled: true # run the pipeline on the merged result
mergeTrainsEnabled: false
allowMergeOnSkippedPipeline: false # never merge when CI was skipped
resolveOutdatedDiffDiscussions: true # auto-resolve stale review threads
printingMergeRequestLinkEnabled: true
removeSourceBranchAfterMerge: true # clean up the branch after merge
# ===========================================
# 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) and approval settings (author/committer approval, per-MR overrides, re-authentication, approval reset)
- unverified script execution (`curl | bash`, `base64 -d | bash`, etc.)
- Docker-in-Docker
- weakened security jobs
Expand Down
3 changes: 3 additions & 0 deletions cmd/analyze_shared.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,9 @@ 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)
renderMRApprovalSettingsTierCaveat(result)
renderMRSettingsPremiumCaveat(result)

printSectionHeader("Summary")
fmt.Println()
Expand Down
171 changes: 170 additions & 1 deletion cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/AlecAivazis/survey/v2"
"github.com/getplumber/plumber/configuration"
defaultconfig "github.com/getplumber/plumber/defaultConfig"
"github.com/getplumber/plumber/internal/ir"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"golang.org/x/term"
Expand All @@ -27,7 +28,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 +196,20 @@ type initWizardState struct {
BranchMinMergeAccessLevel string
BranchMinPushAccessLevel string

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

// mergeRequestApprovalSettingsMustBeCompliant (GitLab-only, catAccess).
MRApprovalSettingsEnabled bool
MRApprovalSettingsPreventAuthor bool
MRApprovalSettingsPreventCommitters bool
MRApprovalSettingsPreventEditing bool
MRApprovalSettingsRequireReAuth bool
MRApprovalSettingsBehavior string

// pipelineMustNotEnableDebugTrace
DebugForbiddenVariablesMultiline string

Expand Down Expand Up @@ -490,6 +505,70 @@ 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 err := survey.AskOne(&survey.Confirm{
Message: "Check MR approval settings against expectations? (GitLab)",
Help: "Compares the project's approval settings (author/committer approval, per-MR overrides, re-auth, approval reset) to the expectations you pick next. Ships off by default.",
Default: defaultMRApprovalSettingsEnabled(),
}, &st.MRApprovalSettingsEnabled); err != nil {
return err
}
if st.MRApprovalSettingsEnabled {
if err := survey.AskOne(&survey.Confirm{
Message: "Expect that MR authors cannot approve their own merge requests? (GitLab)",
Default: defaultMRApprovalSettingsBool(func(c *configuration.MRApprovalSettingsControlConfig) *bool { return c.PreventApprovalByAuthor }),
}, &st.MRApprovalSettingsPreventAuthor); err != nil {
return err
}
if err := survey.AskOne(&survey.Confirm{
Message: "Expect that users who committed to an MR cannot approve it? (GitLab)",
Default: defaultMRApprovalSettingsBool(func(c *configuration.MRApprovalSettingsControlConfig) *bool { return c.PreventApprovalsByCommitters }),
}, &st.MRApprovalSettingsPreventCommitters); err != nil {
return err
}
if err := survey.AskOne(&survey.Confirm{
Message: "Expect that approval rules cannot be edited per merge request? (GitLab)",
Default: defaultMRApprovalSettingsBool(func(c *configuration.MRApprovalSettingsControlConfig) *bool { return c.PreventEditingApprovalRulesInMR }),
}, &st.MRApprovalSettingsPreventEditing); err != nil {
return err
}
if err := survey.AskOne(&survey.Confirm{
Message: "Expect re-authentication to approve? (GitLab)",
Help: "Strict: every approval re-prompts credentials. Answering no leaves this setting unchecked.",
Default: defaultMRApprovalSettingsBool(func(c *configuration.MRApprovalSettingsControlConfig) *bool { return c.RequireReAuthToApprove }),
}, &st.MRApprovalSettingsRequireReAuth); err != nil {
return err
}
if err := survey.AskOne(&survey.Select{
Message: "Minimum required behavior when a commit is added to an open MR (GitLab)",
Help: "A project below the chosen rung is flagged: keep_approvals (weakest, effectively unchecked) < remove_approvals_by_code_owners < remove_all_approvals.",
Options: mrApprovalBehaviorOptions(),
Default: defaultMRApprovalSettingsBehavior(),
}, &st.MRApprovalSettingsBehavior); err != nil {
return err
}
}
}
if hasProvider(st, "github") {
if err := survey.AskOne(&survey.Confirm{
Expand Down Expand Up @@ -773,6 +852,70 @@ 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
}

// defaultMRApprovalSettings* source the wizard's prompt defaults for the
// merge-request approval-settings control from the shipped default, so the
// prompts and the zero-config baseline cannot drift.
func defaultMRApprovalSettingsEnabled() bool {
if c := defaultGitLabControls().MergeRequestApprovalSettingsMustBeCompliant; c != nil {
return c.IsEnabled()
}
return false
}

// defaultMRApprovalSettingsBool reads one optional boolean expectation from
// the shipped default via the field selector; an unset field defaults the
// prompt to false ("not checked").
func defaultMRApprovalSettingsBool(field func(*configuration.MRApprovalSettingsControlConfig) *bool) bool {
if c := defaultGitLabControls().MergeRequestApprovalSettingsMustBeCompliant; c != nil {
if v := field(c); v != nil {
return *v
}
}
return false
}

func defaultMRApprovalSettingsBehavior() string {
if c := defaultGitLabControls().MergeRequestApprovalSettingsMustBeCompliant; c != nil && c.BehaviorWhenCommitIsAdded != nil {
return *c.BehaviorWhenCommitIsAdded
}
return ir.MRApprovalBehaviorKeepApprovals
}

// mrApprovalBehaviorOptions is the behavior ladder in strictness order, from
// the IR constants the projection emits (the same values config validation
// accepts).
func mrApprovalBehaviorOptions() []string {
return []string{
ir.MRApprovalBehaviorKeepApprovals,
ir.MRApprovalBehaviorRemoveCodeOwnerApprovals,
ir.MRApprovalBehaviorRemoveAllApprovals,
}
}

// 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 +1152,32 @@ func (st *initWizardState) applyAccessControls(gl, gh *configuration.ProviderCon
MinMergeAccessLevel: intPtrInit(parseIntInit(st.BranchMinMergeAccessLevel, 30)),
MinPushAccessLevel: intPtrInit(parseIntInit(st.BranchMinPushAccessLevel, 40)),
}
if st.MRApprovalMinEnabled {
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 st.MRApprovalSettingsEnabled {
// Every expectation is emitted, answered value included: a false
// boolean means "not checked" (same as unset) but keeps the full
// configuration surface visible in the generated file.
behavior := st.MRApprovalSettingsBehavior
if behavior == "" {
behavior = defaultMRApprovalSettingsBehavior()
}
gl.Controls.MergeRequestApprovalSettingsMustBeCompliant = &configuration.MRApprovalSettingsControlConfig{
Enabled: boolPtrInit(true),
PreventApprovalByAuthor: boolPtrInit(st.MRApprovalSettingsPreventAuthor),
PreventApprovalsByCommitters: boolPtrInit(st.MRApprovalSettingsPreventCommitters),
PreventEditingApprovalRulesInMR: boolPtrInit(st.MRApprovalSettingsPreventEditing),
RequireReAuthToApprove: boolPtrInit(st.MRApprovalSettingsRequireReAuth),
BehaviorWhenCommitIsAdded: &behavior,
}
}
}
if gh != nil {
// GitHub branch-protection ignores access-level fields.
Expand Down
Loading