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
9 changes: 9 additions & 0 deletions .plumber.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,15 @@ gitlab:
# Minimum access level required to push (0=No one, 30=Developer, 40=Maintainer)
minPushAccessLevel: 40
# ===========================================
# Project must have a security policy source
# ===========================================
# Requires the project to link a GitLab security policy project (Settings >
# Security & Compliance > Policies). Set expectedProjectId (numeric) OR
# expectedProjectPath (full path, case-insensitive) to require a specific
# policy project — the ID wins if both are set; leave both unset to require
# only that SOME policy project is linked. REQUIRES GITLAB ULTIMATE.
projectMustHaveSecurityPolicySource:
# ===========================================
# 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 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)
renderSecurityPolicyTierCaveat(result)

printSectionHeader("Summary")
fmt.Println()
Expand Down
35 changes: 35 additions & 0 deletions cmd/legacy_json.go
Comment thread
Joseph94m marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,43 @@ 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.SecurityPolicyTierCaveat && e.ControlName == "projectMustHaveSecurityPolicySource" {
// No policy project is linked, and security policies are an Ultimate
// feature, so we cannot tell a non-Ultimate project (unable to link)
// from an Ultimate project that left it unset. Keyed so a consumer can
// annotate ISSUE-601.
m["tierCaveat"] = map[string]any{
"reason": "no-security-policy-project-linked",
"requiresTier": "ultimate",
"message": securityPolicyTierCaveatMessage,
}
}
}
return block
}

// securityPolicyTierCaveatMessage explains the Ultimate requirement for
// ISSUE-601 when no security policy project is linked. Shared by the terminal
// caveat (render_details.go) and the JSON tierCaveat.
const securityPolicyTierCaveatMessage = "Security policies require GitLab Ultimate, and no policy project is linked. If this project is not on GitLab Ultimate it cannot link one — disable this control. If it is, link the expected security policy project to satisfy the check."

// buildSecurityPolicyProjectBlock emits the legacy JSON block for the
// security-policy-project linkage control (ISSUE-601). The finding is a
// project-level singleton (no file/job); its linkedProjectId / linkedProjectPath
// / expectedProjectId ride in the issue's data, preserved by projectFindings.
func buildSecurityPolicyProjectBlock(c legacyCommon, findings []opaengine.Finding) map[string]any {
return map[string]any{
"issues": projectFindings(findings, "job"),
"metrics": map[string]any{
"projectWithoutExpectedSecurityPolicy": len(findings),
},
"version": "0.1.0",
"ciValid": c.CiValid,
"ciMissing": c.CiMissing,
"skipped": c.Skipped,
}
}

// 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 All @@ -118,6 +151,8 @@ func buildLegacyResult(e control.ControlEntry, result *control.AnalysisResult, p
return "imageAuthorizedSourcesResult", buildImageAuthorizedSourcesBlock(common, result, findings)
case "branchMustBeProtected":
return "branchProtectionResult", buildBranchProtectionBlock(common, result, pc, findings)
case "projectMustHaveSecurityPolicySource":
return "securityPolicyProjectResult", buildSecurityPolicyProjectBlock(common, findings)
case "pipelineMustNotIncludeHardcodedJobs":
return "hardcodedJobsResult", buildHardcodedJobsBlock(common, result, findings)
case "externalRefsMustNotCollide":
Expand Down
18 changes: 18 additions & 0 deletions cmd/render_details.go
Original file line number Diff line number Diff line change
Expand Up @@ -867,10 +867,28 @@ func buildGitLabControlStats(controlName string, result *control.AnalysisResult,
{Label: "Unprotected", Value: fmt.Sprintf("%d", unprotected)},
{Label: "Non-Compliant", Value: fmt.Sprintf("%d", nonCompliant)},
}
case "projectMustHaveSecurityPolicySource":
return []statLine{
{Label: "Security Policy Not Linked", Value: fmt.Sprintf("%d", findingsCount)},
}
}
return nil
}

// renderSecurityPolicyTierCaveat prints a caveat when the security-policy
// control flagged a project with no policy project linked: the feature requires
// GitLab Ultimate, and a non-Ultimate project cannot link one, so the failure
// may be a tier limitation rather than a real misconfiguration (see
// AnalysisResult.SecurityPolicyTierCaveat). No-op otherwise.
func renderSecurityPolicyTierCaveat(result *control.AnalysisResult) {
if result == nil || !result.SecurityPolicyTierCaveat {
return
}
fmt.Println()
fmt.Printf(" %s⚠ Security policies are a GitLab Ultimate feature, and no policy project is linked.%s\n", colorYellow, colorReset)
fmt.Printf(" %s•%s If this project isn't on GitLab Ultimate it can't link one, so disable this control. If it is, link the expected security policy project to satisfy the check.\n", colorYellow, colorReset)
}

// _countScriptLines walks the merged GitLab CI conf and totals every
// script line declared on every job (script, before_script,
// after_script). Used as the "Script Lines Checked" denominator
Expand Down
48 changes: 48 additions & 0 deletions configuration/plumberconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ var validControlSchema = map[string][]string{
"allowForcePush", "codeOwnerApprovalRequired",
"minMergeAccessLevel", "minPushAccessLevel",
},
"projectMustHaveSecurityPolicySource": {
"enabled", "expectedProjectId", "expectedProjectPath",
},
"pipelineMustNotIncludeHardcodedJobs": {
"enabled",
},
Expand Down Expand Up @@ -258,6 +261,11 @@ type ControlsConfig struct {
// BranchMustBeProtected control configuration
BranchMustBeProtected *BranchProtectionControlConfig `yaml:"branchMustBeProtected,omitempty"`

// ProjectMustHaveSecurityPolicySource control configuration (GitLab only).
// Requires the project to link the expected GitLab security policy project
// (ISSUE-601). Requires GitLab Ultimate.
ProjectMustHaveSecurityPolicySource *SecurityPolicyControlConfig `yaml:"projectMustHaveSecurityPolicySource,omitempty"`

// PipelineMustNotIncludeHardcodedJobs control configuration
PipelineMustNotIncludeHardcodedJobs *HardcodedJobsControlConfig `yaml:"pipelineMustNotIncludeHardcodedJobs,omitempty"`

Expand Down Expand Up @@ -405,6 +413,37 @@ type EnabledOnlyControlConfig struct {
Enabled *bool `yaml:"enabled,omitempty"`
}

// SecurityPolicyControlConfig configures the GitLab security-policy-project
// linkage check (ISSUE-601). GitLab-only, requires Ultimate. When
// ExpectedProjectId is set, the linked policy project must be exactly that
// project. When it is unset, any linked policy project passes and the control
// fails only when none is linked.
type SecurityPolicyControlConfig struct {
// Enabled controls whether this check runs.
Enabled *bool `yaml:"enabled,omitempty"`

// ExpectedProjectId is the numeric GitLab project ID the security policy
// project must match. Unset => require only that some policy project is
// linked.
ExpectedProjectId *int `yaml:"expectedProjectId,omitempty"`

// ExpectedProjectPath is the full path (namespace/project) the linked
// security policy project must match — a human-friendly alternative to the
// numeric ID, compared case-insensitively. Ignored when ExpectedProjectId is
// also set (the ID is authoritative). Unset (and no ID) => require only that
// some policy project is linked.
ExpectedProjectPath *string `yaml:"expectedProjectPath,omitempty"`
}

// IsEnabled reports whether the control is enabled. Returns false when the
// wrapper or the field is nil — same convention as every other IsEnabled().
func (c *SecurityPolicyControlConfig) IsEnabled() bool {
if c == nil || c.Enabled == nil {
return false
}
return *c.Enabled
}

// IsEnabled reports whether the control is enabled. Returns false when
// the wrapper or the field is nil — same convention as every other
// IsEnabled() in this package.
Expand Down Expand Up @@ -1131,6 +1170,15 @@ func (c *PlumberConfig) GetBranchMustBeProtectedConfig() *BranchProtectionContro
return c.ControlsFor("gitlab").BranchMustBeProtected
}

// GetProjectMustHaveSecurityPolicySourceConfig returns the GitLab
// security-policy-project linkage control configuration (ISSUE-601), or nil.
func (c *PlumberConfig) GetProjectMustHaveSecurityPolicySourceConfig() *SecurityPolicyControlConfig {
if c == nil {
return nil
}
return c.ControlsFor("gitlab").ProjectMustHaveSecurityPolicySource
}

// IsEnabled returns whether the control is enabled
// Returns false if not properly configured
func (c *BranchProtectionControlConfig) 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 @@ -375,6 +375,7 @@ func TestValidControlNames(t *testing.T) {
"pipelineMustNotOverrideJobVariables",
"pipelineMustNotUseDockerInDocker",
"pipelineMustNotUseUnsafeVariableExpansion",
"projectMustHaveSecurityPolicySource",
"pullRequestTargetMustNotCheckoutHead",
"releaseWorkflowsMustNotRestoreUntrustedCache",
"reusableWorkflowsMustNotInheritSecrets",
Expand Down
1 change: 1 addition & 0 deletions configuration/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const (
var controlsMeta = map[string]ControlMeta{
// Cross-provider (same control name + rego logic, provider-specific values).
"branchMustBeProtected": {Providers: []string{ProviderGitLab, ProviderGitHub}},
"projectMustHaveSecurityPolicySource": {Providers: []string{ProviderGitLab}},
"containerImageMustComeFromAuthorizedSources": {Providers: []string{ProviderGitLab, ProviderGitHub}},
"containerImageMustNotUseForbiddenTags": {Providers: []string{ProviderGitLab, ProviderGitHub}},
"externalRefsMustNotCollide": {Providers: []string{ProviderGitLab, ProviderGitHub}},
Expand Down
2 changes: 2 additions & 0 deletions configuration/v1_to_v2.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ func controlsConfigIsZero(c ControlsConfig) bool {
return c.ContainerImageMustNotUseForbiddenTags == nil &&
c.ContainerImageMustComeFromAuthorizedSources == nil &&
c.BranchMustBeProtected == nil &&
c.ProjectMustHaveSecurityPolicySource == nil &&
c.PipelineMustNotIncludeHardcodedJobs == nil &&
c.IncludesMustBeUpToDate == nil &&
c.IncludesMustNotUseForbiddenVersions == nil &&
Expand All @@ -96,6 +97,7 @@ func controlsConfigEqual(a, b ControlsConfig) bool {
return a.ContainerImageMustNotUseForbiddenTags == b.ContainerImageMustNotUseForbiddenTags &&
a.ContainerImageMustComeFromAuthorizedSources == b.ContainerImageMustComeFromAuthorizedSources &&
a.BranchMustBeProtected == b.BranchMustBeProtected &&
a.ProjectMustHaveSecurityPolicySource == b.ProjectMustHaveSecurityPolicySource &&
a.PipelineMustNotIncludeHardcodedJobs == b.PipelineMustNotIncludeHardcodedJobs &&
a.IncludesMustBeUpToDate == b.IncludesMustBeUpToDate &&
a.IncludesMustNotUseForbiddenVersions == b.IncludesMustNotUseForbiddenVersions &&
Expand Down
8 changes: 8 additions & 0 deletions control/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ func GitLabControls(pc *configuration.PlumberConfig) []ControlEntry {
ControlName: "branchMustBeProtected",
Skipped: c.BranchMustBeProtected == nil || !c.BranchMustBeProtected.IsEnabled(),
})
entries = append(entries, ControlEntry{
DisplayName: "Project must have a security policy source",
ControlName: "projectMustHaveSecurityPolicySource",
Skipped: c.ProjectMustHaveSecurityPolicySource == nil || !c.ProjectMustHaveSecurityPolicySource.IsEnabled(),
})
entries = append(entries, ControlEntry{
DisplayName: "Pipeline must not include hardcoded jobs",
ControlName: "pipelineMustNotIncludeHardcodedJobs",
Expand Down Expand Up @@ -315,6 +320,9 @@ func DisabledControlNames(c *configuration.ControlsConfig) map[string]bool {
if cfg := c.BranchMustBeProtected; cfg == nil || !cfg.IsEnabled() {
out["branchMustBeProtected"] = true
}
if cfg := c.ProjectMustHaveSecurityPolicySource; cfg == nil || !cfg.IsEnabled() {
out["projectMustHaveSecurityPolicySource"] = true
}
if cfg := c.PipelineMustNotIncludeHardcodedJobs; cfg == nil || !cfg.IsEnabled() {
out["pipelineMustNotIncludeHardcodedJobs"] = true
}
Expand Down
15 changes: 13 additions & 2 deletions control/codes.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,8 @@ const (

// Issue codes for workflow-hygiene controls (6xx)
const (
// ISSUE-601: Workflow has no explicit `name:` field
CodeAnonymousDefinition ErrorCode = "ISSUE-601"
// ISSUE-422: Workflow has no explicit `name:` field
CodeAnonymousDefinition ErrorCode = "ISSUE-422"
// ISSUE-418: Workflow has no `concurrency:` block at either level
CodeMissingConcurrency ErrorCode = "ISSUE-418"
// ISSUE-419: Workflow uses a misfeature pattern (shell: cmd, inline pip install curl|sh, …)
Expand All @@ -180,6 +180,8 @@ const (
CodeBranchUnprotected ErrorCode = "ISSUE-501"
// ISSUE-505: Branch has non-compliant protection settings
CodeBranchNonCompliant ErrorCode = "ISSUE-505"
// ISSUE-601: No (or the wrong) GitLab security policy project is linked
CodeSecurityPolicyProjectNotSet ErrorCode = "ISSUE-601"
// ISSUE-803: Job runs with overly broad permissions (write-all)
CodeExcessivePermissions ErrorCode = "ISSUE-803"
)
Expand Down Expand Up @@ -559,6 +561,15 @@ var errorCodeRegistry = map[ErrorCode]ErrorCodeInfo{
DocURL: docsBaseURL + string(CodeBranchNonCompliant),
ControlName: "branchMustBeProtected",
},
CodeSecurityPolicyProjectNotSet: {
Code: CodeSecurityPolicyProjectNotSet,
Severity: SeverityCritical,
Title: "Missing security policy source on project",
Description: "The project does not directly link the expected GitLab security policy project (none is linked, or a different one than the configured expectation). This checks the project's own link only, so a security policy source inherited from a parent group is not detected.",
Remediation: "Link the expected security policy project in Settings > Security & Compliance > Policies (or set it via the API). If your policies are enforced at a parent group and inherited, this project-scoped check will not see them, so link at the project level too or disable this control. Security policies require GitLab Ultimate.",
DocURL: docsBaseURL + string(CodeSecurityPolicyProjectNotSet),
ControlName: "projectMustHaveSecurityPolicySource",
},
CodeTemplateInjection: {
Code: CodeTemplateInjection,
Severity: SeverityCritical,
Expand Down
10 changes: 10 additions & 0 deletions control/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,16 @@ func StatusFor(e ControlEntry, result *AnalysisResult, findingCount int) string
}
return StatusPassed
}
if e.ControlName == "projectMustHaveSecurityPolicySource" {
Comment thread
Joseph94m marked this conversation as resolved.
// Reached only with zero findings (a finding returned Failed above). The
// linkage is read over its own API surface; when it could not be read
// authoritatively (401/403 or the field is unavailable) the control never
// truly evaluated and must not read as a pass.
if result.SecurityPolicyEvaluable {
return StatusPassed
}
return StatusError
}
if result.CiMissing || !result.CiValid {
return StatusError
}
Expand Down
63 changes: 61 additions & 2 deletions control/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,45 @@ const opaEvaluateTimeout = 2 * time.Minute
// the catalog in catalog.go.
const controlBranchMustBeProtected = "branchMustBeProtected"
const controlMutableRemoteExec = "actionsMustNotExecuteMutableRemoteCode"
const controlSecurityPolicy = "projectMustHaveSecurityPolicySource"

// securityPolicyControlEnabled reports whether the security-policy-project
// linkage control (ISSUE-601) is active for this run. It reads the linkage the
// GitLab protection collection fetches, so that collection must run when it is
// enabled even if branchMustBeProtected is not.
func securityPolicyControlEnabled(conf *configuration.Configuration) bool {
if conf == nil || conf.PlumberConfig == nil {
return false
}
c := conf.PlumberConfig.GetProjectMustHaveSecurityPolicySourceConfig()
return c != nil && c.IsEnabled() && shouldRunControl(controlSecurityPolicy, conf)
}

// protectionDataNeeded reports whether any control needs the GitLab protection
// collection this run: branchMustBeProtected or the security-policy control
// (they read the one GitlabProtectionAnalysisData).
func protectionDataNeeded(conf *configuration.Configuration) bool {
if shouldRunControl(controlBranchMustBeProtected, conf) {
if cfg := conf.PlumberConfig.GetBranchMustBeProtectedConfig(); cfg != nil && cfg.IsEnabled() {
return true
}
}
return securityPolicyControlEnabled(conf)
}

// securityPolicyTierCaveatApplies reports whether to surface the conditional
// Ultimate caveat for ISSUE-601: the control ran, the linkage was read
// authoritatively, and NO policy project is linked — the tier-ambiguous case
// (a non-Ultimate project cannot link one, but an Ultimate project may simply
// have left it unset). A wrong-project-linked read is a real misconfiguration
// on a paid tier, not a tier caveat, and a non-authoritative read is
// not-evaluable, so neither triggers it.
func securityPolicyTierCaveatApplies(conf *configuration.Configuration, protectionData *gitlab.GitlabProtectionAnalysisData) bool {
if !securityPolicyControlEnabled(conf) || protectionData == nil || !protectionData.SecurityPolicyKnown {
return false
}
return protectionData.SecurityPolicyProject == nil
}

// shouldScanMutableExec reports whether the collector should fetch and
// scan action source for actionsMustNotExecuteMutableRemoteCode
Expand Down Expand Up @@ -178,6 +217,20 @@ func buildEngineConfig(controls *configuration.ControlsConfig) map[string]any {
}
cfg := map[string]any{}

if c := controls.ProjectMustHaveSecurityPolicySource; c != nil {
// expectedProjectId / expectedProjectPath reach the engine only when set:
// the Rego rule treats their absence as "require any linkage", the id as
// the authoritative match, and the path as a case-insensitive fallback.
entry := map[string]any{}
if c.ExpectedProjectId != nil {
entry["expectedProjectId"] = *c.ExpectedProjectId
}
if c.ExpectedProjectPath != nil {
entry["expectedProjectPath"] = *c.ExpectedProjectPath
}
cfg["projectMustHaveSecurityPolicySource"] = entry
}

if c := controls.ContainerImageMustNotUseForbiddenTags; c != nil {
if len(c.Tags) > 0 {
cfg["imageMutableTag"] = map[string]any{
Expand Down Expand Up @@ -626,8 +679,8 @@ func RunAnalysis(conf *configuration.Configuration) (*AnalysisResult, error) {
// corresponding control — the Rego policy needs the protection
// settings to check every branch against the declared bar.
var protectionData *gitlab.GitlabProtectionAnalysisData
if shouldRunControl(controlBranchMustBeProtected, conf) {
if cfg := conf.PlumberConfig.GetBranchMustBeProtectedConfig(); cfg != nil && cfg.IsEnabled() {
if protectionDataNeeded(conf) {
{
reportProgress(conf, 9, analysisStepCount, "Checking branch protection")
protectionDC := &gitlab.GitlabProtectionDataCollection{}
pData, _, pErr := protectionDC.Run(projectInfo, conf.GitlabToken, conf)
Expand All @@ -651,6 +704,12 @@ func RunAnalysis(conf *configuration.Configuration) (*AnalysisResult, error) {
// docs/REFACTOR_MULTI_PROVIDER.md §8 Phase A).
result.Findings = runRegoEngine(l, conf, project, pipelineOriginData, pipelineImageData, protectionData)
result.ProtectionData = protectionData
// ISSUE-601 is not-evaluable when the linkage could not be read (auth error /
// field unavailable): the collector leaves SecurityPolicyKnown false, so
// StatusFor reports error rather than a false pass. When it WAS read but
// nothing is linked, surface the conditional Ultimate tier caveat.
result.SecurityPolicyEvaluable = protectionData != nil && protectionData.SecurityPolicyKnown
result.SecurityPolicyTierCaveat = securityPolicyTierCaveatApplies(conf, protectionData)

reportProgress(conf, analysisStepCount, analysisStepCount, "Analysis complete")

Expand Down
Loading