From 7f8254be7c5e011e737f74e0943b15c974065b62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20ROBERT?= Date: Wed, 4 Mar 2026 16:11:43 +0100 Subject: [PATCH] feat: add structured error codes (PLB-XXXX) with documentation links - Add control/codes.go with 14 error codes (PLB-0101 to PLB-0501) - Add Code and DocURL fields to all issue structs - Display [PLB-XXXX] prefix and docs link in CLI output - Add Codes column to summary table with docs footer - Include error codes and doc links in MR comments Closes #92 --- cmd/analyze.go | 90 +++++-- control/codes.go | 227 ++++++++++++++++++ control/controlGitlabImageMutable.go | 24 +- control/controlGitlabImageUntrusted.go | 10 +- control/controlGitlabPipelineDebugTrace.go | 12 +- ...ontrolGitlabPipelineOriginHardcodedJobs.go | 6 +- .../controlGitlabPipelineOriginOutdated.go | 24 +- ...lGitlabPipelineOriginRequiredComponents.go | 14 +- ...olGitlabPipelineOriginRequiredTemplates.go | 14 +- control/controlGitlabPipelineOriginVersion.go | 24 +- ...bProtectionBranchProtectionNotCompliant.go | 4 + control/mrcomment.go | 28 +-- control/types.go | 6 +- 13 files changed, 403 insertions(+), 80 deletions(-) create mode 100644 control/codes.go diff --git a/cmd/analyze.go b/cmd/analyze.go index 433cebc..6476e68 100644 --- a/cmd/analyze.go +++ b/cmd/analyze.go @@ -606,6 +606,7 @@ type controlSummary struct { compliance float64 issues int skipped bool + codes []string } func printBanner() { @@ -659,11 +660,16 @@ func outputText(result *control.AnalysisResult, threshold, compliance float64, c controlName = "Container images must not use forbidden tags (pinned by digest)" } + imageCodes := []string{string(control.CodeImageForbiddenTag)} + if result.ImageForbiddenTagsResult.MustBePinnedByDigest { + imageCodes = []string{string(control.CodeImageNotPinnedByDigest)} + } ctrl := controlSummary{ name: controlName, compliance: result.ImageForbiddenTagsResult.Compliance, issues: len(result.ImageForbiddenTagsResult.Issues), skipped: result.ImageForbiddenTagsResult.Skipped, + codes: imageCodes, } controls = append(controls, ctrl) @@ -680,7 +686,8 @@ func outputText(result *control.AnalysisResult, threshold, compliance float64, c if len(result.ImageForbiddenTagsResult.Issues) > 0 { fmt.Printf("\n %sImages Not Pinned By Digest Found:%s\n", colorYellow, colorReset) for _, issue := range result.ImageForbiddenTagsResult.Issues { - fmt.Printf(" %s•%s Job '%s' uses image without digest pinning: %s\n", colorYellow, colorReset, issue.Job, issue.Link) + fmt.Printf(" %s•%s [%s] Job '%s' uses image without digest pinning: %s\n", colorYellow, colorReset, issue.Code, issue.Job, issue.Link) + fmt.Printf(" %s↳ docs: %s%s\n", colorDim, issue.DocURL, colorReset) } } } else { @@ -691,7 +698,8 @@ func outputText(result *control.AnalysisResult, threshold, compliance float64, c if len(result.ImageForbiddenTagsResult.Issues) > 0 { fmt.Printf("\n %sForbidden Tags Found:%s\n", colorYellow, colorReset) for _, issue := range result.ImageForbiddenTagsResult.Issues { - fmt.Printf(" %s•%s Job '%s' uses forbidden tag '%s' (image: %s)\n", colorYellow, colorReset, issue.Job, issue.Tag, issue.Link) + fmt.Printf(" %s•%s [%s] Job '%s' uses forbidden tag '%s' (image: %s)\n", colorYellow, colorReset, issue.Code, issue.Job, issue.Tag, issue.Link) + fmt.Printf(" %s↳ docs: %s%s\n", colorDim, issue.DocURL, colorReset) } } } @@ -705,6 +713,7 @@ func outputText(result *control.AnalysisResult, threshold, compliance float64, c compliance: result.ImageAuthorizedSourcesResult.Compliance, issues: len(result.ImageAuthorizedSourcesResult.Issues), skipped: result.ImageAuthorizedSourcesResult.Skipped, + codes: []string{string(control.CodeImageUnauthorizedSource)}, } controls = append(controls, ctrl) @@ -720,7 +729,8 @@ func outputText(result *control.AnalysisResult, threshold, compliance float64, c if len(result.ImageAuthorizedSourcesResult.Issues) > 0 { fmt.Printf("\n %sUnauthorized Images Found:%s\n", colorYellow, colorReset) for _, issue := range result.ImageAuthorizedSourcesResult.Issues { - fmt.Printf(" %s•%s Job '%s' uses unauthorized image: %s\n", colorYellow, colorReset, issue.Job, issue.Link) + fmt.Printf(" %s•%s [%s] Job '%s' uses unauthorized image: %s\n", colorYellow, colorReset, issue.Code, issue.Job, issue.Link) + fmt.Printf(" %s↳ docs: %s%s\n", colorDim, issue.DocURL, colorReset) } } } @@ -734,6 +744,7 @@ func outputText(result *control.AnalysisResult, threshold, compliance float64, c compliance: result.BranchProtectionResult.Compliance, issues: len(result.BranchProtectionResult.Issues), skipped: result.BranchProtectionResult.Skipped, + codes: []string{string(control.CodeBranchUnprotected), string(control.CodeBranchNonCompliant)}, } controls = append(controls, ctrl) @@ -754,9 +765,10 @@ func outputText(result *control.AnalysisResult, threshold, compliance float64, c fmt.Printf("\n %sIssues Found:%s\n", colorYellow, colorReset) for _, issue := range result.BranchProtectionResult.Issues { if issue.Type == "unprotected" { - fmt.Printf(" %s•%s Branch '%s' is not protected\n", colorYellow, colorReset, issue.BranchName) + fmt.Printf(" %s•%s [%s] Branch '%s' is not protected\n", colorYellow, colorReset, issue.Code, issue.BranchName) + fmt.Printf(" %s↳ docs: %s%s\n", colorDim, issue.DocURL, colorReset) } else { - fmt.Printf(" %s•%s Branch '%s' has non-compliant protection settings\n", colorYellow, colorReset, issue.BranchName) + fmt.Printf(" %s•%s [%s] Branch '%s' has non-compliant protection settings\n", colorYellow, colorReset, issue.Code, issue.BranchName) if issue.AllowForcePushDisplay { fmt.Printf(" └─ Force push is allowed (should be disabled)\n") } @@ -769,6 +781,7 @@ func outputText(result *control.AnalysisResult, threshold, compliance float64, c if issue.MinPushAccessLevelDisplay { fmt.Printf(" └─ Push access level is too low (%d, minimum: %d)\n", issue.MinPushAccessLevel, issue.AuthorizedMinPushAccessLevel) } + fmt.Printf(" %s↳ docs: %s%s\n", colorDim, issue.DocURL, colorReset) } } } @@ -783,6 +796,7 @@ func outputText(result *control.AnalysisResult, threshold, compliance float64, c compliance: result.HardcodedJobsResult.Compliance, issues: len(result.HardcodedJobsResult.Issues), skipped: result.HardcodedJobsResult.Skipped, + codes: []string{string(control.CodeJobHardcoded)}, } controls = append(controls, ctrl) @@ -797,7 +811,8 @@ func outputText(result *control.AnalysisResult, threshold, compliance float64, c if len(result.HardcodedJobsResult.Issues) > 0 { fmt.Printf("\n %sHardcoded Jobs Found:%s\n", colorYellow, colorReset) for _, issue := range result.HardcodedJobsResult.Issues { - fmt.Printf(" %s•%s Job '%s' is hardcoded (not from include/component)\n", colorYellow, colorReset, issue.JobName) + fmt.Printf(" %s•%s [%s] Job '%s' is hardcoded (not from include/component)\n", colorYellow, colorReset, issue.Code, issue.JobName) + fmt.Printf(" %s↳ docs: %s%s\n", colorDim, issue.DocURL, colorReset) } } } @@ -811,6 +826,7 @@ func outputText(result *control.AnalysisResult, threshold, compliance float64, c compliance: result.OutdatedIncludesResult.Compliance, issues: len(result.OutdatedIncludesResult.Issues), skipped: result.OutdatedIncludesResult.Skipped, + codes: []string{string(control.CodeIncludeOutdated)}, } controls = append(controls, ctrl) @@ -825,7 +841,8 @@ func outputText(result *control.AnalysisResult, threshold, compliance float64, c if len(result.OutdatedIncludesResult.Issues) > 0 { fmt.Printf("\n %sOutdated Includes Found:%s\n", colorYellow, colorReset) for _, issue := range result.OutdatedIncludesResult.Issues { - fmt.Printf(" %s•%s %s uses version '%s' (latest: %s)\n", colorYellow, colorReset, issue.GitlabIncludeLocation, issue.Version, issue.LatestVersion) + fmt.Printf(" %s•%s [%s] %s uses version '%s' (latest: %s)\n", colorYellow, colorReset, issue.Code, issue.GitlabIncludeLocation, issue.Version, issue.LatestVersion) + fmt.Printf(" %s↳ docs: %s%s\n", colorDim, issue.DocURL, colorReset) } } } @@ -839,6 +856,7 @@ func outputText(result *control.AnalysisResult, threshold, compliance float64, c compliance: result.ForbiddenVersionsIncludesResult.Compliance, issues: len(result.ForbiddenVersionsIncludesResult.Issues), skipped: result.ForbiddenVersionsIncludesResult.Skipped, + codes: []string{string(control.CodeIncludeForbiddenVersion)}, } controls = append(controls, ctrl) @@ -854,7 +872,8 @@ func outputText(result *control.AnalysisResult, threshold, compliance float64, c if len(result.ForbiddenVersionsIncludesResult.Issues) > 0 { fmt.Printf("\n %sForbidden Versions Found:%s\n", colorYellow, colorReset) for _, issue := range result.ForbiddenVersionsIncludesResult.Issues { - fmt.Printf(" %s•%s %s uses forbidden version '%s'\n", colorYellow, colorReset, issue.GitlabIncludeLocation, issue.Version) + fmt.Printf(" %s•%s [%s] %s uses forbidden version '%s'\n", colorYellow, colorReset, issue.Code, issue.GitlabIncludeLocation, issue.Version) + fmt.Printf(" %s↳ docs: %s%s\n", colorDim, issue.DocURL, colorReset) } } } @@ -869,6 +888,7 @@ func outputText(result *control.AnalysisResult, threshold, compliance float64, c compliance: result.RequiredComponentsResult.Compliance, issues: totalComponentIssues, skipped: result.RequiredComponentsResult.Skipped, + codes: []string{string(control.CodeComponentMissing), string(control.CodeComponentOverridden)}, } controls = append(controls, ctrl) @@ -883,17 +903,19 @@ func outputText(result *control.AnalysisResult, threshold, compliance float64, c if len(result.RequiredComponentsResult.Issues) > 0 { fmt.Printf("\n %sMissing Components:%s\n", colorYellow, colorReset) for _, issue := range result.RequiredComponentsResult.Issues { - fmt.Printf(" %s•%s %s (group %d)\n", colorYellow, colorReset, issue.ComponentPath, issue.GroupIndex+1) + fmt.Printf(" %s•%s [%s] %s (group %d)\n", colorYellow, colorReset, issue.Code, issue.ComponentPath, issue.GroupIndex+1) + fmt.Printf(" %s↳ docs: %s%s\n", colorDim, issue.DocURL, colorReset) } } if len(result.RequiredComponentsResult.OverriddenIssues) > 0 { fmt.Printf("\n %sOverridden Components:%s\n", colorYellow, colorReset) for _, issue := range result.RequiredComponentsResult.OverriddenIssues { - fmt.Printf(" %s•%s %s (group %d)\n", colorYellow, colorReset, issue.ComponentPath, issue.GroupIndex+1) + fmt.Printf(" %s•%s [%s] %s (group %d)\n", colorYellow, colorReset, issue.Code, issue.ComponentPath, issue.GroupIndex+1) for _, job := range issue.OverriddenJobs { fmt.Printf(" job %s%s%s overrides: %s\n", colorDim, job.JobName, colorReset, strings.Join(job.OverriddenKeys, ", ")) } + fmt.Printf(" %s↳ docs: %s%s\n", colorDim, issue.DocURL, colorReset) } } } @@ -908,6 +930,7 @@ func outputText(result *control.AnalysisResult, threshold, compliance float64, c compliance: result.RequiredTemplatesResult.Compliance, issues: totalTemplateIssues, skipped: result.RequiredTemplatesResult.Skipped, + codes: []string{string(control.CodeTemplateMissing), string(control.CodeTemplateOverridden)}, } controls = append(controls, ctrl) @@ -922,17 +945,19 @@ func outputText(result *control.AnalysisResult, threshold, compliance float64, c if len(result.RequiredTemplatesResult.Issues) > 0 { fmt.Printf("\n %sMissing Templates:%s\n", colorYellow, colorReset) for _, issue := range result.RequiredTemplatesResult.Issues { - fmt.Printf(" %s•%s %s (group %d)\n", colorYellow, colorReset, issue.TemplatePath, issue.GroupIndex+1) + fmt.Printf(" %s•%s [%s] %s (group %d)\n", colorYellow, colorReset, issue.Code, issue.TemplatePath, issue.GroupIndex+1) + fmt.Printf(" %s↳ docs: %s%s\n", colorDim, issue.DocURL, colorReset) } } if len(result.RequiredTemplatesResult.OverriddenIssues) > 0 { fmt.Printf("\n %sOverridden Templates:%s\n", colorYellow, colorReset) for _, issue := range result.RequiredTemplatesResult.OverriddenIssues { - fmt.Printf(" %s•%s %s (group %d)\n", colorYellow, colorReset, issue.TemplatePath, issue.GroupIndex+1) + fmt.Printf(" %s•%s [%s] %s (group %d)\n", colorYellow, colorReset, issue.Code, issue.TemplatePath, issue.GroupIndex+1) for _, job := range issue.OverriddenJobs { fmt.Printf(" job %s%s%s overrides: %s\n", colorDim, job.JobName, colorReset, strings.Join(job.OverriddenKeys, ", ")) } + fmt.Printf(" %s↳ docs: %s%s\n", colorDim, issue.DocURL, colorReset) } } } @@ -946,6 +971,7 @@ func outputText(result *control.AnalysisResult, threshold, compliance float64, c compliance: result.DebugTraceResult.Compliance, issues: len(result.DebugTraceResult.Issues), skipped: result.DebugTraceResult.Skipped, + codes: []string{string(control.CodeDebugTraceEnabled)}, } controls = append(controls, ctrl) @@ -962,10 +988,11 @@ func outputText(result *control.AnalysisResult, threshold, compliance float64, c for _, issue := range result.DebugTraceResult.Issues { location := issue.Location if location == "global" { - fmt.Printf(" %s•%s %s = \"%s\" (global variables)\n", colorYellow, colorReset, issue.VariableName, issue.Value) + fmt.Printf(" %s•%s [%s] %s = \"%s\" (global variables)\n", colorYellow, colorReset, issue.Code, issue.VariableName, issue.Value) } else { - fmt.Printf(" %s•%s %s = \"%s\" (job '%s')\n", colorYellow, colorReset, issue.VariableName, issue.Value, location) + fmt.Printf(" %s•%s [%s] %s = \"%s\" (job '%s')\n", colorYellow, colorReset, issue.Code, issue.VariableName, issue.Value, location) } + fmt.Printf(" %s↳ docs: %s%s\n", colorDim, issue.DocURL, colorReset) } } } @@ -1022,7 +1049,7 @@ func printSectionHeader(name string) { func printIssuesTable(controls []controlSummary) { fmt.Printf(" %sIssues%s\n", colorBold, colorReset) - // Calculate column widths dynamically based on longest control name + // Calculate column widths dynamically based on longest control name and codes controlWidth := 52 // minimum width for _, ctrl := range controls { needed := len(ctrl.name) + 2 // +2 for padding @@ -1030,27 +1057,39 @@ func printIssuesTable(controls []controlSummary) { controlWidth = needed } } + codesWidth := 22 // enough for "PLB-0101, PLB-0102" + for _, ctrl := range controls { + codesStr := strings.Join(ctrl.codes, ", ") + needed := len(codesStr) + 2 + if needed > codesWidth { + codesWidth = needed + } + } issuesWidth := 10 // Top border - fmt.Printf(" %s╔%s╤%s╗%s\n", + fmt.Printf(" %s╔%s╤%s╤%s╗%s\n", colorCyan, strings.Repeat("═", controlWidth), + strings.Repeat("═", codesWidth), strings.Repeat("═", issuesWidth), colorReset) // Header row - fmt.Printf(" %s║%s %-*s %s│%s %*s %s║%s\n", + fmt.Printf(" %s║%s %-*s %s│%s %-*s %s│%s %*s %s║%s\n", colorCyan, colorReset, controlWidth-2, "Control", colorCyan, colorReset, + codesWidth-2, "Codes", + colorCyan, colorReset, issuesWidth-2, "Issues", colorCyan, colorReset) // Header separator - fmt.Printf(" %s╟%s┼%s╢%s\n", + fmt.Printf(" %s╟%s┼%s┼%s╢%s\n", colorCyan, strings.Repeat("─", controlWidth), + strings.Repeat("─", codesWidth), strings.Repeat("─", issuesWidth), colorReset) @@ -1068,20 +1107,31 @@ func printIssuesTable(controls []controlSummary) { issueColor = colorRed } - fmt.Printf(" %s║%s %-*s %s│%s %s%*s%s %s║%s\n", + codesStr := strings.Join(ctrl.codes, ", ") + if ctrl.skipped { + codesStr = "-" + } + + fmt.Printf(" %s║%s %-*s %s│%s %-*s %s│%s %s%*s%s %s║%s\n", colorCyan, colorReset, controlWidth-2, ctrl.name, colorCyan, colorReset, + codesWidth-2, codesStr, + colorCyan, colorReset, issueColor, issuesWidth-2, issueStr, colorReset, colorCyan, colorReset) } // Bottom border - fmt.Printf(" %s╚%s╧%s╝%s\n", + fmt.Printf(" %s╚%s╧%s╧%s╝%s\n", colorCyan, strings.Repeat("═", controlWidth), + strings.Repeat("═", codesWidth), strings.Repeat("═", issuesWidth), colorReset) + + // Docs footer + fmt.Printf(" %s↳ docs: https://getplumber.io/e/%s\n", colorDim, colorReset) } func printComplianceTable(controls []controlSummary, overallCompliance, threshold float64) { diff --git a/control/codes.go b/control/codes.go new file mode 100644 index 0000000..2f623c7 --- /dev/null +++ b/control/codes.go @@ -0,0 +1,227 @@ +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" +) + +// 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}} '.", + 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", + }, +} + +// 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) +} diff --git a/control/controlGitlabImageMutable.go b/control/controlGitlabImageMutable.go index 055cdab..3106bbc 100644 --- a/control/controlGitlabImageMutable.go +++ b/control/controlGitlabImageMutable.go @@ -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"` } /////////////////////// @@ -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++ @@ -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++ diff --git a/control/controlGitlabImageUntrusted.go b/control/controlGitlabImageUntrusted.go index bfeeb55..960f64c 100644 --- a/control/controlGitlabImageUntrusted.go +++ b/control/controlGitlabImageUntrusted.go @@ -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"` } /////////////////////// @@ -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, diff --git a/control/controlGitlabPipelineDebugTrace.go b/control/controlGitlabPipelineDebugTrace.go index a6953b2..c229324 100644 --- a/control/controlGitlabPipelineDebugTrace.go +++ b/control/controlGitlabPipelineDebugTrace.go @@ -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 } /////////////////////// @@ -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", @@ -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, diff --git a/control/controlGitlabPipelineOriginHardcodedJobs.go b/control/controlGitlabPipelineOriginHardcodedJobs.go index 075f101..a60ed79 100644 --- a/control/controlGitlabPipelineOriginHardcodedJobs.go +++ b/control/controlGitlabPipelineOriginHardcodedJobs.go @@ -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"` } /////////////////////// @@ -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) diff --git a/control/controlGitlabPipelineOriginOutdated.go b/control/controlGitlabPipelineOriginOutdated.go index 4c47311..b8534fc 100644 --- a/control/controlGitlabPipelineOriginOutdated.go +++ b/control/controlGitlabPipelineOriginOutdated.go @@ -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"` } /////////////////////// @@ -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, diff --git a/control/controlGitlabPipelineOriginRequiredComponents.go b/control/controlGitlabPipelineOriginRequiredComponents.go index a425f00..cdef6a1 100644 --- a/control/controlGitlabPipelineOriginRequiredComponents.go +++ b/control/controlGitlabPipelineOriginRequiredComponents.go @@ -99,13 +99,17 @@ type GitlabPipelineRequiredComponentsResult struct { // RequiredComponentIssue represents an issue with a missing required component type RequiredComponentIssue struct { - ComponentPath string `json:"componentPath"` - GroupIndex int `json:"groupIndex"` + Code ErrorCode `json:"code"` + DocURL string `json:"docUrl"` + ComponentPath string `json:"componentPath"` + GroupIndex int `json:"groupIndex"` } // RequiredComponentOverriddenIssue represents an issue where a required component // is imported but its jobs are overridden with forbidden keywords type RequiredComponentOverriddenIssue struct { + Code ErrorCode `json:"code"` + DocURL string `json:"docUrl"` ComponentPath string `json:"componentPath"` GroupIndex int `json:"groupIndex"` OverriddenJobs []utils.OverriddenJobDetail `json:"overriddenJobs"` @@ -188,8 +192,8 @@ func (p *GitlabPipelineRequiredComponentsConf) Run(pipelineOriginData *collector if len(overriddenJobs) > 0 { group.OverriddenOrigins = append(group.OverriddenOrigins, requiredOrigin) - result.OverriddenIssues = append(result.OverriddenIssues, RequiredComponentOverriddenIssue{ - ComponentPath: requiredOrigin, + result.OverriddenIssues = append(result.OverriddenIssues, RequiredComponentOverriddenIssue{ Code: CodeComponentOverridden, + DocURL: CodeComponentOverridden.DocURL(), ComponentPath: requiredOrigin, GroupIndex: groupIdx, OverriddenJobs: overriddenJobs, }) @@ -229,6 +233,8 @@ func (p *GitlabPipelineRequiredComponentsConf) Run(pipelineOriginData *collector // Create issues for missing components for _, missing := range group.MissingOrigins { result.Issues = append(result.Issues, RequiredComponentIssue{ + Code: CodeComponentMissing, + DocURL: CodeComponentMissing.DocURL(), ComponentPath: missing, GroupIndex: i, }) diff --git a/control/controlGitlabPipelineOriginRequiredTemplates.go b/control/controlGitlabPipelineOriginRequiredTemplates.go index 26ab07f..aaea504 100644 --- a/control/controlGitlabPipelineOriginRequiredTemplates.go +++ b/control/controlGitlabPipelineOriginRequiredTemplates.go @@ -102,13 +102,17 @@ type GitlabPipelineRequiredTemplatesResult struct { // RequiredTemplateIssue represents an issue with a missing required template type RequiredTemplateIssue struct { - TemplatePath string `json:"templatePath"` - GroupIndex int `json:"groupIndex"` + Code ErrorCode `json:"code"` + DocURL string `json:"docUrl"` + TemplatePath string `json:"templatePath"` + GroupIndex int `json:"groupIndex"` } // RequiredTemplateOverriddenIssue represents an issue where a required template // is imported but its jobs are overridden with forbidden keywords type RequiredTemplateOverriddenIssue struct { + Code ErrorCode `json:"code"` + DocURL string `json:"docUrl"` TemplatePath string `json:"templatePath"` GroupIndex int `json:"groupIndex"` OverriddenJobs []utils.OverriddenJobDetail `json:"overriddenJobs"` @@ -232,8 +236,8 @@ func (p *GitlabPipelineRequiredTemplatesConf) Run(pipelineOriginData *collector. if len(overriddenJobs) > 0 { group.OverriddenOrigins = append(group.OverriddenOrigins, requiredOrigin) - result.OverriddenIssues = append(result.OverriddenIssues, RequiredTemplateOverriddenIssue{ - TemplatePath: requiredOrigin, + result.OverriddenIssues = append(result.OverriddenIssues, RequiredTemplateOverriddenIssue{ Code: CodeTemplateOverridden, + DocURL: CodeTemplateOverridden.DocURL(), TemplatePath: requiredOrigin, GroupIndex: groupIdx, OverriddenJobs: overriddenJobs, }) @@ -273,6 +277,8 @@ func (p *GitlabPipelineRequiredTemplatesConf) Run(pipelineOriginData *collector. // Create issues for missing templates for _, missing := range group.MissingOrigins { result.Issues = append(result.Issues, RequiredTemplateIssue{ + Code: CodeTemplateMissing, + DocURL: CodeTemplateMissing.DocURL(), TemplatePath: missing, GroupIndex: i, }) diff --git a/control/controlGitlabPipelineOriginVersion.go b/control/controlGitlabPipelineOriginVersion.go index 3d0285a..d465bef 100644 --- a/control/controlGitlabPipelineOriginVersion.go +++ b/control/controlGitlabPipelineOriginVersion.go @@ -88,16 +88,18 @@ type GitlabPipelineIncludesForbiddenVersionResult struct { // GitlabPipelineIncludesForbiddenVersionIssue represents an issue with a forbidden version // Issue data for mutable version usage - PolicyIssueTypeId = [11] type GitlabPipelineIncludesForbiddenVersionIssue struct { - Version string `json:"version"` - LatestVersion string `json:"latestVersion,omitempty"` - 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,omitempty"` + 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"` } /////////////////////// @@ -203,6 +205,8 @@ func (p *GitlabPipelineIncludesForbiddenVersionConf) Run(pipelineOriginData *col // Create issue for forbidden version issue := GitlabPipelineIncludesForbiddenVersionIssue{ + Code: CodeIncludeForbiddenVersion, + DocURL: CodeIncludeForbiddenVersion.DocURL(), Version: origin.Version, LatestVersion: latestVersion, PlumberOriginPath: plumberOriginPath, diff --git a/control/controlGitlabProtectionBranchProtectionNotCompliant.go b/control/controlGitlabProtectionBranchProtectionNotCompliant.go index d844a52..d64856b 100644 --- a/control/controlGitlabProtectionBranchProtectionNotCompliant.go +++ b/control/controlGitlabProtectionBranchProtectionNotCompliant.go @@ -131,6 +131,8 @@ func (c *GitlabBranchProtectionControl) Run( // Create issue for unprotected branch issue := BranchProtectionIssue{ + Code: CodeBranchUnprotected, + DocURL: CodeBranchUnprotected.DocURL(), Type: "unprotected", BranchName: branch.BranchName, } @@ -155,6 +157,8 @@ func (c *GitlabBranchProtectionControl) Run( // Check compliance issues issueData := BranchProtectionIssue{ + Code: CodeBranchNonCompliant, + DocURL: CodeBranchNonCompliant.DocURL(), Type: "non_compliant", BranchName: branch.BranchName, AllowForcePush: branch.AllowForcePush, diff --git a/control/mrcomment.go b/control/mrcomment.go index 06927bf..764e133 100644 --- a/control/mrcomment.go +++ b/control/mrcomment.go @@ -237,12 +237,12 @@ func writeIssueDetails(b *strings.Builder, result *AnalysisResult) { if r.MustBePinnedByDigest { b.WriteString("**Container images must be pinned by digest:**\n") for _, issue := range r.Issues { - fmt.Fprintf(b, "- Job `%s`: image `%s` is not pinned by digest\n", issue.Job, issue.Link) + fmt.Fprintf(b, "- `%s` Job `%s`: image `%s` is not pinned by digest ([docs](%s))\n", issue.Code, issue.Job, issue.Link, issue.DocURL) } } else { b.WriteString("**Container images must not use forbidden tags:**\n") for _, issue := range r.Issues { - fmt.Fprintf(b, "- Job `%s`: image `%s` uses forbidden tag `%s`\n", issue.Job, issue.Link, issue.Tag) + fmt.Fprintf(b, "- `%s` Job `%s`: image `%s` uses forbidden tag `%s` ([docs](%s))\n", issue.Code, issue.Job, issue.Link, issue.Tag, issue.DocURL) } } b.WriteString("\n") @@ -252,7 +252,7 @@ func writeIssueDetails(b *strings.Builder, result *AnalysisResult) { if r := result.ImageAuthorizedSourcesResult; r != nil && !r.Skipped && len(r.Issues) > 0 { b.WriteString("**Container images must come from authorized sources:**\n") for _, issue := range r.Issues { - fmt.Fprintf(b, "- Job `%s`: unauthorized image `%s`\n", issue.Job, issue.Link) + fmt.Fprintf(b, "- `%s` Job `%s`: unauthorized image `%s` ([docs](%s))\n", issue.Code, issue.Job, issue.Link, issue.DocURL) } b.WriteString("\n") } @@ -262,9 +262,9 @@ func writeIssueDetails(b *strings.Builder, result *AnalysisResult) { b.WriteString("**Branch must be protected:**\n") for _, issue := range r.Issues { if issue.Type == "unprotected" { - fmt.Fprintf(b, "- Branch `%s` is not protected\n", issue.BranchName) + fmt.Fprintf(b, "- `%s` Branch `%s` is not protected ([docs](%s))\n", issue.Code, issue.BranchName, issue.DocURL) } else { - fmt.Fprintf(b, "- Branch `%s` has non-compliant protection settings\n", issue.BranchName) + fmt.Fprintf(b, "- `%s` Branch `%s` has non-compliant protection settings ([docs](%s))\n", issue.Code, issue.BranchName, issue.DocURL) } } b.WriteString("\n") @@ -274,7 +274,7 @@ func writeIssueDetails(b *strings.Builder, result *AnalysisResult) { if r := result.HardcodedJobsResult; r != nil && !r.Skipped && len(r.Issues) > 0 { b.WriteString("**Pipeline must not include hardcoded jobs:**\n") for _, issue := range r.Issues { - fmt.Fprintf(b, "- Job `%s` is hardcoded (not from include/component)\n", issue.JobName) + fmt.Fprintf(b, "- `%s` Job `%s` is hardcoded (not from include/component) ([docs](%s))\n", issue.Code, issue.JobName, issue.DocURL) } b.WriteString("\n") } @@ -283,7 +283,7 @@ func writeIssueDetails(b *strings.Builder, result *AnalysisResult) { if r := result.OutdatedIncludesResult; r != nil && !r.Skipped && len(r.Issues) > 0 { b.WriteString("**Includes must be up to date:**\n") for _, issue := range r.Issues { - fmt.Fprintf(b, "- `%s` uses version `%s` (latest: `%s`)\n", issue.GitlabIncludeLocation, issue.Version, issue.LatestVersion) + fmt.Fprintf(b, "- `%s` `%s` uses version `%s` (latest: `%s`) ([docs](%s))\n", issue.Code, issue.GitlabIncludeLocation, issue.Version, issue.LatestVersion, issue.DocURL) } b.WriteString("\n") } @@ -292,7 +292,7 @@ func writeIssueDetails(b *strings.Builder, result *AnalysisResult) { if r := result.ForbiddenVersionsIncludesResult; r != nil && !r.Skipped && len(r.Issues) > 0 { b.WriteString("**Includes must not use forbidden versions:**\n") for _, issue := range r.Issues { - fmt.Fprintf(b, "- `%s` uses forbidden version `%s`\n", issue.GitlabIncludeLocation, issue.Version) + fmt.Fprintf(b, "- `%s` `%s` uses forbidden version `%s` ([docs](%s))\n", issue.Code, issue.GitlabIncludeLocation, issue.Version, issue.DocURL) } b.WriteString("\n") } @@ -301,10 +301,10 @@ func writeIssueDetails(b *strings.Builder, result *AnalysisResult) { if r := result.RequiredComponentsResult; r != nil && !r.Skipped && (len(r.Issues) > 0 || len(r.OverriddenIssues) > 0) { b.WriteString("**Pipeline must include required components:**\n") for _, issue := range r.Issues { - fmt.Fprintf(b, "- Missing component `%s` (group %d)\n", issue.ComponentPath, issue.GroupIndex+1) + fmt.Fprintf(b, "- `%s` Missing component `%s` (group %d) ([docs](%s))\n", issue.Code, issue.ComponentPath, issue.GroupIndex+1, issue.DocURL) } for _, issue := range r.OverriddenIssues { - fmt.Fprintf(b, "- Overridden component `%s` (group %d)\n", issue.ComponentPath, issue.GroupIndex+1) + fmt.Fprintf(b, "- `%s` Overridden component `%s` (group %d) ([docs](%s))\n", issue.Code, issue.ComponentPath, issue.GroupIndex+1, issue.DocURL) for _, job := range issue.OverriddenJobs { fmt.Fprintf(b, " - job `%s` overrides: `%s`\n", job.JobName, strings.Join(job.OverriddenKeys, "`, `")) } @@ -316,10 +316,10 @@ func writeIssueDetails(b *strings.Builder, result *AnalysisResult) { if r := result.RequiredTemplatesResult; r != nil && !r.Skipped && (len(r.Issues) > 0 || len(r.OverriddenIssues) > 0) { b.WriteString("**Pipeline must include required templates:**\n") for _, issue := range r.Issues { - fmt.Fprintf(b, "- Missing template `%s` (group %d)\n", issue.TemplatePath, issue.GroupIndex+1) + fmt.Fprintf(b, "- `%s` Missing template `%s` (group %d) ([docs](%s))\n", issue.Code, issue.TemplatePath, issue.GroupIndex+1, issue.DocURL) } for _, issue := range r.OverriddenIssues { - fmt.Fprintf(b, "- Overridden template `%s` (group %d)\n", issue.TemplatePath, issue.GroupIndex+1) + fmt.Fprintf(b, "- `%s` Overridden template `%s` (group %d) ([docs](%s))\n", issue.Code, issue.TemplatePath, issue.GroupIndex+1, issue.DocURL) for _, job := range issue.OverriddenJobs { fmt.Fprintf(b, " - job `%s` overrides: `%s`\n", job.JobName, strings.Join(job.OverriddenKeys, "`, `")) } @@ -332,9 +332,9 @@ func writeIssueDetails(b *strings.Builder, result *AnalysisResult) { b.WriteString("**Pipeline must not enable debug trace:**\n") for _, issue := range r.Issues { if issue.Location == "global" { - fmt.Fprintf(b, "- `%s` = `%s` in global variables\n", issue.VariableName, issue.Value) + fmt.Fprintf(b, "- `%s` `%s` = `%s` in global variables ([docs](%s))\n", issue.Code, issue.VariableName, issue.Value, issue.DocURL) } else { - fmt.Fprintf(b, "- `%s` = `%s` in job `%s`\n", issue.VariableName, issue.Value, issue.Location) + fmt.Fprintf(b, "- `%s` `%s` = `%s` in job `%s` ([docs](%s))\n", issue.Code, issue.VariableName, issue.Value, issue.Location, issue.DocURL) } } b.WriteString("\n") diff --git a/control/types.go b/control/types.go index cbe93f7..d0f88c5 100644 --- a/control/types.go +++ b/control/types.go @@ -98,8 +98,10 @@ type BranchProtectionMetrics struct { // BranchProtectionIssue represents an issue found by the branch protection control type BranchProtectionIssue struct { - Type string `json:"type"` // "unprotected" or "non_compliant" - BranchName string `json:"branchName"` + Code ErrorCode `json:"code"` + DocURL string `json:"docUrl"` + Type string `json:"type"` // "unprotected" or "non_compliant" + BranchName string `json:"branchName"` AllowForcePush bool `json:"allowForcePush,omitempty"` AllowForcePushDisplay bool `json:"allowForcePushDisplay,omitempty"` CodeOwnerApprovalRequired bool `json:"codeOwnerApprovalRequired,omitempty"`