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
96 changes: 74 additions & 22 deletions cmd/analyze.go

Large diffs are not rendered by default.

237 changes: 237 additions & 0 deletions control/codes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
package control

// docsBaseURL is the base URL for Plumber error code documentation.
// Each error code has a short URL that redirects to the full documentation page.
const docsBaseURL = "https://getplumber.io/e/"

// ErrorCode represents a unique Plumber error code (PLB-XXXX format).
type ErrorCode string

// Error codes for container image controls (PLB-01xx)
const (
// PLB-0101: Container image uses a forbidden tag (e.g., latest, dev)
CodeImageForbiddenTag ErrorCode = "PLB-0101"
// PLB-0102: Container image is not pinned by digest
CodeImageNotPinnedByDigest ErrorCode = "PLB-0102"
// PLB-0103: Container image comes from an unauthorized registry
CodeImageUnauthorizedSource ErrorCode = "PLB-0103"
)

// Error codes for branch protection controls (PLB-02xx)
const (
// PLB-0201: Branch is not protected
CodeBranchUnprotected ErrorCode = "PLB-0201"
// PLB-0202: Branch has non-compliant protection settings
CodeBranchNonCompliant ErrorCode = "PLB-0202"
)

// Error codes for pipeline origin controls (PLB-03xx)
const (
// PLB-0301: Job is hardcoded (not sourced from include/component)
CodeJobHardcoded ErrorCode = "PLB-0301"
// PLB-0302: Include uses an outdated version
CodeIncludeOutdated ErrorCode = "PLB-0302"
// PLB-0303: Include uses a forbidden version
CodeIncludeForbiddenVersion ErrorCode = "PLB-0303"
)

// Error codes for required includes controls (PLB-04xx)
const (
// PLB-0401: Required component is missing from the pipeline
CodeComponentMissing ErrorCode = "PLB-0401"
// PLB-0402: Required component jobs are overridden
CodeComponentOverridden ErrorCode = "PLB-0402"
// PLB-0403: Required template is missing from the pipeline
CodeTemplateMissing ErrorCode = "PLB-0403"
// PLB-0404: Required template jobs are overridden
CodeTemplateOverridden ErrorCode = "PLB-0404"
)

// Error codes for security controls (PLB-05xx)
const (
// PLB-0501: Pipeline enables CI debug trace (CI_DEBUG_TRACE or CI_DEBUG_SERVICES)
CodeDebugTraceEnabled ErrorCode = "PLB-0501"
// PLB-0502: Unsafe variable expansion in shell re-interpretation context (eval, sh -c, etc.)
CodeUnsafeVariableExpansion ErrorCode = "PLB-0502"
)

// ErrorCodeInfo provides metadata about an error code.
type ErrorCodeInfo struct {
// Code is the unique error code (e.g., PLB-0101).
Code ErrorCode `json:"code"`
// Title is a short human-readable title.
Title string `json:"title"`
// Description explains what the issue is.
Description string `json:"description"`
// Remediation provides guidance on how to fix the issue.
Remediation string `json:"remediation"`
// DocURL is a direct link to the documentation for this error.
DocURL string `json:"docUrl"`
// ControlName is the .plumber.yaml control key this code belongs to.
ControlName string `json:"controlName"`
}

// errorCodeRegistry maps error codes to their metadata.
var errorCodeRegistry = map[ErrorCode]ErrorCodeInfo{
// Container image controls
CodeImageForbiddenTag: {
Code: CodeImageForbiddenTag,
Title: "Forbidden image tag",
Description: "A container image in the pipeline uses a tag that is forbidden by the configuration (e.g., 'latest', 'dev'). Mutable tags make builds non-reproducible because the underlying image can change without notice.",
Remediation: "Pin the image to a specific immutable version tag (e.g., 'python:3.12.1' instead of 'python:latest'). Configure forbidden tags in .plumber.yaml under containerImageMustNotUseForbiddenTags.forbiddenTags.",
DocURL: docsBaseURL + string(CodeImageForbiddenTag),
ControlName: "containerImageMustNotUseForbiddenTags",
},
CodeImageNotPinnedByDigest: {
Code: CodeImageNotPinnedByDigest,
Title: "Image not pinned by digest",
Description: "A container image in the pipeline is not pinned by its SHA256 digest. Without digest pinning, a tag can be reassigned to a different image, introducing supply chain risks.",
Remediation: "Pin the image using its digest: 'image: registry.example.com/myimage@sha256:abc123...'. You can find the digest with 'docker inspect --format={{.RepoDigests}} <image>'.",
DocURL: docsBaseURL + string(CodeImageNotPinnedByDigest),
ControlName: "containerImageMustNotUseForbiddenTags",
},
CodeImageUnauthorizedSource: {
Code: CodeImageUnauthorizedSource,
Title: "Unauthorized image source",
Description: "A container image is pulled from a registry that is not listed in the authorized sources. Using untrusted registries increases supply chain attack risk.",
Remediation: "Use images from an authorized registry configured in .plumber.yaml under containerImageMustComeFromAuthorizedSources.authorizedSources, or add the registry to the authorized list.",
DocURL: docsBaseURL + string(CodeImageUnauthorizedSource),
ControlName: "containerImageMustComeFromAuthorizedSources",
},

// Branch protection controls
CodeBranchUnprotected: {
Code: CodeBranchUnprotected,
Title: "Branch not protected",
Description: "A branch that should be protected according to the configuration has no protection rules. Unprotected branches allow direct pushes and force pushes, bypassing code review.",
Remediation: "Enable branch protection in GitLab: Settings > Repository > Protected Branches. Add the branch with appropriate access levels for push and merge.",
DocURL: docsBaseURL + string(CodeBranchUnprotected),
ControlName: "branchMustBeProtected",
},
CodeBranchNonCompliant: {
Code: CodeBranchNonCompliant,
Title: "Non-compliant branch protection",
Description: "A protected branch does not meet the required protection settings (e.g., force push allowed, access levels too permissive, code owner approval not required).",
Remediation: "Update branch protection settings in GitLab: Settings > Repository > Protected Branches. Ensure force push is disabled, access levels meet the minimum, and code owner approval is required per your .plumber.yaml configuration.",
DocURL: docsBaseURL + string(CodeBranchNonCompliant),
ControlName: "branchMustBeProtected",
},

// Pipeline origin controls
CodeJobHardcoded: {
Code: CodeJobHardcoded,
Title: "Hardcoded job",
Description: "A job in the pipeline is defined directly in the CI configuration instead of being sourced from a CI/CD component or include. Hardcoded jobs bypass governance and standardization.",
Remediation: "Replace the hardcoded job with a CI/CD component or an include from an approved catalog. Use 'include:' or 'component:' directives in your .gitlab-ci.yml.",
DocURL: docsBaseURL + string(CodeJobHardcoded),
ControlName: "pipelineMustNotIncludeHardcodedJobs",
},
CodeIncludeOutdated: {
Code: CodeIncludeOutdated,
Title: "Outdated include version",
Description: "An included CI/CD component or template is not using the latest available version. Outdated versions may miss security patches, bug fixes, or improvements.",
Remediation: "Update the include to use the latest version. Check the component/template repository for the latest release and update the version reference in your .gitlab-ci.yml.",
DocURL: docsBaseURL + string(CodeIncludeOutdated),
ControlName: "includesMustBeUpToDate",
},
CodeIncludeForbiddenVersion: {
Code: CodeIncludeForbiddenVersion,
Title: "Forbidden include version",
Description: "An included CI/CD component or template uses a version that is explicitly forbidden (e.g., a mutable branch reference like 'main' instead of a tagged version).",
Remediation: "Replace the forbidden version with an authorized version format. Use semantic version tags (e.g., '1.2.3' or '~latest') instead of branch names or mutable references as configured in .plumber.yaml.",
DocURL: docsBaseURL + string(CodeIncludeForbiddenVersion),
ControlName: "includesMustNotUseForbiddenVersions",
},

// Required includes controls
CodeComponentMissing: {
Code: CodeComponentMissing,
Title: "Required component missing",
Description: "A CI/CD component required by the configuration is not included in the pipeline. This means a mandatory compliance check or security scan is missing.",
Remediation: "Add the required component to your .gitlab-ci.yml using 'include:' with the component path specified in your .plumber.yaml under pipelineMustIncludeComponent.",
DocURL: docsBaseURL + string(CodeComponentMissing),
ControlName: "pipelineMustIncludeComponent",
},
CodeComponentOverridden: {
Code: CodeComponentOverridden,
Title: "Required component overridden",
Description: "A required CI/CD component is included but some of its job keys are overridden locally, which may alter the intended behavior of the compliance check.",
Remediation: "Remove the local overrides on the component's jobs. If customization is needed, check if the component provides input variables for configuration instead of overriding job keys directly.",
DocURL: docsBaseURL + string(CodeComponentOverridden),
ControlName: "pipelineMustIncludeComponent",
},
CodeTemplateMissing: {
Code: CodeTemplateMissing,
Title: "Required template missing",
Description: "A CI/CD template required by the configuration is not included in the pipeline. This means a mandatory workflow step is missing.",
Remediation: "Add the required template to your .gitlab-ci.yml using 'include:' with the template path specified in your .plumber.yaml under pipelineMustIncludeTemplate.",
DocURL: docsBaseURL + string(CodeTemplateMissing),
ControlName: "pipelineMustIncludeTemplate",
},
CodeTemplateOverridden: {
Code: CodeTemplateOverridden,
Title: "Required template overridden",
Description: "A required CI/CD template is included but some of its job keys are overridden locally, which may alter the intended behavior.",
Remediation: "Remove the local overrides on the template's jobs. If customization is needed, check if the template provides variables for configuration instead of overriding job keys directly.",
DocURL: docsBaseURL + string(CodeTemplateOverridden),
ControlName: "pipelineMustIncludeTemplate",
},

// Security controls
CodeDebugTraceEnabled: {
Code: CodeDebugTraceEnabled,
Title: "Debug trace enabled",
Description: "The pipeline has CI_DEBUG_TRACE or CI_DEBUG_SERVICES enabled, which exposes all secret variables in the job log output. This is a critical security risk in production pipelines.",
Remediation: "Remove or set CI_DEBUG_TRACE and CI_DEBUG_SERVICES to 'false' in your .gitlab-ci.yml variables section. These should only be used temporarily for debugging and never committed.",
DocURL: docsBaseURL + string(CodeDebugTraceEnabled),
ControlName: "pipelineMustNotEnableDebugTrace",
},
CodeUnsafeVariableExpansion: {
Code: CodeUnsafeVariableExpansion,
Title: "Unsafe variable expansion",
Description: "A dangerous CI variable is expanded in a shell re-interpretation context (eval, sh -c, bash -c, source, etc.). The expanded value is executed as code, enabling command injection if the variable is user-controlled.",
Remediation: "Avoid passing variables to commands that re-interpret input as shell code. Use the variable in a safe context (e.g. echo, env) or sanitize/allowlist values. Configure dangerousVariables and allowedPatterns in .plumber.yaml under pipelineMustNotUseUnsafeVariableExpansion.",
DocURL: docsBaseURL + string(CodeUnsafeVariableExpansion),
ControlName: "pipelineMustNotUseUnsafeVariableExpansion",
},
}

// LookupCode returns the ErrorCodeInfo for a given error code, or nil if not found.
func LookupCode(code ErrorCode) *ErrorCodeInfo {
info, ok := errorCodeRegistry[code]
if !ok {
return nil
}
return &info
}

// AllCodes returns all registered error codes sorted by code.
func AllCodes() []ErrorCodeInfo {
codes := make([]ErrorCodeInfo, 0, len(errorCodeRegistry))
for _, info := range errorCodeRegistry {
codes = append(codes, info)
}
// Sort by code for deterministic output
for i := 0; i < len(codes); i++ {
for j := i + 1; j < len(codes); j++ {
if codes[i].Code > codes[j].Code {
codes[i], codes[j] = codes[j], codes[i]
}
}
}
return codes
}

// DocURL returns the documentation URL for a given error code.
func (c ErrorCode) DocURL() string {
info := LookupCode(c)
if info == nil {
return docsBaseURL
}
return info.DocURL
}

// String returns the string representation of an error code.
func (c ErrorCode) String() string {
return string(c)
}
24 changes: 15 additions & 9 deletions control/controlGitlabImageMutable.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,11 @@ type GitlabImageForbiddenTagsResult struct {

// GitlabPipelineImageIssueTag represents an issue with an image using a mutable tag
type GitlabPipelineImageIssueTag struct {
Link string `json:"link"`
Tag string `json:"tag"`
Job string `json:"job"`
Code ErrorCode `json:"code"`
DocURL string `json:"docUrl"`
Link string `json:"link"`
Tag string `json:"tag"`
Job string `json:"job"`
}

///////////////////////
Expand Down Expand Up @@ -157,9 +159,11 @@ func (p *GitlabImageForbiddenTagsConf) Run(pipelineImageData *collector.GitlabPi

// Not pinned by digest — flag it
issue := GitlabPipelineImageIssueTag{
Link: image.Link,
Tag: image.Tag,
Job: image.Job,
Code: CodeImageNotPinnedByDigest,
DocURL: CodeImageNotPinnedByDigest.DocURL(),
Link: image.Link,
Tag: image.Tag,
Job: image.Job,
}
result.Issues = append(result.Issues, issue)
result.Metrics.NotPinnedByDigest++
Expand All @@ -171,9 +175,11 @@ func (p *GitlabImageForbiddenTagsConf) Run(pipelineImageData *collector.GitlabPi

if isForbiddenTag {
issue := GitlabPipelineImageIssueTag{
Link: image.Link,
Tag: image.Tag,
Job: image.Job,
Code: CodeImageForbiddenTag,
DocURL: CodeImageForbiddenTag.DocURL(),
Link: image.Link,
Tag: image.Tag,
Job: image.Job,
}
result.Issues = append(result.Issues, issue)
result.Metrics.UsingForbiddenTags++
Expand Down
10 changes: 7 additions & 3 deletions control/controlGitlabImageUntrusted.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,11 @@ type GitlabImageAuthorizedSourcesResult struct {

// GitlabPipelineImageIssueUnauthorized represents an issue with an unauthorized image source
type GitlabPipelineImageIssueUnauthorized struct {
Link string `json:"link"`
Status string `json:"status"`
Job string `json:"job"`
Code ErrorCode `json:"code"`
DocURL string `json:"docUrl"`
Link string `json:"link"`
Status string `json:"status"`
Job string `json:"job"`
}

///////////////////////
Expand Down Expand Up @@ -225,6 +227,8 @@ func (p *GitlabImageAuthorizedSourcesConf) Run(pipelineImageData *collector.Gitl
result.Metrics.Unauthorized++
// Add issue for unauthorized images
issue := GitlabPipelineImageIssueUnauthorized{
Code: CodeImageUnauthorizedSource,
DocURL: CodeImageUnauthorizedSource.DocURL(),
Link: image.Link,
Status: status,
Job: image.Job,
Expand Down
12 changes: 9 additions & 3 deletions control/controlGitlabPipelineDebugTrace.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,11 @@ type GitlabPipelineDebugTraceResult struct {

// GitlabPipelineDebugTraceIssue represents a forbidden debug variable found in the CI config
type GitlabPipelineDebugTraceIssue struct {
VariableName string `json:"variableName"`
Value string `json:"value"`
Location string `json:"location"` // "global" or job name
Code ErrorCode `json:"code"`
DocURL string `json:"docUrl"`
VariableName string `json:"variableName"`
Value string `json:"value"`
Location string `json:"location"` // "global" or job name
}

///////////////////////
Expand Down Expand Up @@ -146,6 +148,8 @@ func (p *GitlabPipelineDebugTraceConf) Run(pipelineOriginData *collector.GitlabP
result.Metrics.TotalVariablesChecked++
if forbiddenSet[strings.ToUpper(key)] && isTrueValue(value) {
result.Issues = append(result.Issues, GitlabPipelineDebugTraceIssue{
Code: CodeDebugTraceEnabled,
DocURL: CodeDebugTraceEnabled.DocURL(),
VariableName: key,
Value: value,
Location: "global",
Expand Down Expand Up @@ -181,6 +185,8 @@ func (p *GitlabPipelineDebugTraceConf) Run(pipelineOriginData *collector.GitlabP
result.Metrics.TotalVariablesChecked++
if forbiddenSet[strings.ToUpper(key)] && isTrueValue(value) {
result.Issues = append(result.Issues, GitlabPipelineDebugTraceIssue{
Code: CodeDebugTraceEnabled,
DocURL: CodeDebugTraceEnabled.DocURL(),
VariableName: key,
Value: value,
Location: jobName,
Expand Down
6 changes: 5 additions & 1 deletion control/controlGitlabPipelineOriginHardcodedJobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ type GitlabPipelineHardcodedJobsResult struct {

// GitlabPipelineHardcodedJobIssue represents an issue with a hardcoded job
type GitlabPipelineHardcodedJobIssue struct {
JobName string `json:"jobName"`
Code ErrorCode `json:"code"`
DocURL string `json:"docUrl"`
JobName string `json:"jobName"`
}

///////////////////////
Expand Down Expand Up @@ -117,6 +119,8 @@ func (p *GitlabPipelineHardcodedJobsConf) Run(pipelineOriginData *collector.Gitl
l.WithField("jobName", jobName).Debug("Found hardcoded job")

issue := GitlabPipelineHardcodedJobIssue{
Code: CodeJobHardcoded,
DocURL: CodeJobHardcoded.DocURL(),
JobName: jobName,
}
result.Issues = append(result.Issues, issue)
Expand Down
24 changes: 14 additions & 10 deletions control/controlGitlabPipelineOriginOutdated.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,16 +75,18 @@ type GitlabPipelineIncludesOutdatedResult struct {
// GitlabPipelineIncludesOutdatedIssue represents an issue with an outdated include
// Issue data for outdated origin - PolicyIssueTypeId = [10]
type GitlabPipelineIncludesOutdatedIssue struct {
Version string `json:"version"`
LatestVersion string `json:"latestVersion"`
PlumberOriginPath string `json:"plumberOriginPath,omitempty"`
GitlabIncludeLocation string `json:"gitlabIncludeLocation"`
GitlabIncludeType string `json:"gitlabIncludeType"`
GitlabIncludeProject string `json:"gitlabIncludeProject,omitempty"`
Nested bool `json:"nested"`
ComponentName string `json:"componentName,omitempty"`
PlumberTemplateName string `json:"plumberTemplateName,omitempty"`
OriginHash uint64 `json:"originHash"`
Code ErrorCode `json:"code"`
DocURL string `json:"docUrl"`
Version string `json:"version"`
LatestVersion string `json:"latestVersion"`
PlumberOriginPath string `json:"plumberOriginPath,omitempty"`
GitlabIncludeLocation string `json:"gitlabIncludeLocation"`
GitlabIncludeType string `json:"gitlabIncludeType"`
GitlabIncludeProject string `json:"gitlabIncludeProject,omitempty"`
Nested bool `json:"nested"`
ComponentName string `json:"componentName,omitempty"`
PlumberTemplateName string `json:"plumberTemplateName,omitempty"`
OriginHash uint64 `json:"originHash"`
}

///////////////////////
Expand Down Expand Up @@ -184,6 +186,8 @@ func (p *GitlabPipelineIncludesOutdatedConf) Run(pipelineOriginData *collector.G

// Create issue for outdated origin
issue := GitlabPipelineIncludesOutdatedIssue{
Code: CodeIncludeOutdated,
DocURL: CodeIncludeOutdated.DocURL(),
Version: origin.Version,
LatestVersion: latestVersion,
PlumberOriginPath: plumberOriginPath,
Expand Down
Loading