diff --git a/.plumber.yaml b/.plumber.yaml index 54d4d379..8a7b4131 100644 --- a/.plumber.yaml +++ b/.plumber.yaml @@ -313,6 +313,63 @@ gitlab: # required: templates/go/go AND templates/trivy/trivy AND templates/iso27001/iso27001 requiredGroups: [] # =========================================== + # CI/CD components must come from authorized sources + # =========================================== + # Detects `include: component:` references that are not trusted. + # Components run arbitrary code with the job's full context + # (variables, secrets, CI_JOB_TOKEN) — the GitLab analogue of a + # GitHub Actions "pwn request". + # + # A source is trusted when it matches trustedComponents, OR lives + # under this project's own root namespace on the same GitLab + # instance (trustSameGroupComponents), OR is hosted on the same + # GitLab instance at all (trustSameInstanceComponents — on by + # default). Both are derived dynamically from the pipeline at scan + # time, in CI or locally — no environment variables involved. + # + # Best practice: keep components scoped to your own project/group + componentMustComeFromAuthorizedSources: + # Set to false to disable this control + enabled: true + # Trust components under this project's own root namespace + trustSameGroupComponents: true + # Trust any component on the same GitLab instance, regardless of + # namespace. (defaults to true when self-hosted, false on gitlab.com) + trustSameInstanceComponents: true + # Additional trusted component source URLs and patterns (supports wildcards) + trustedComponents: [] + # =========================================== + # GitLab Functions must come from authorized sources + # =========================================== + # Detects `run:` step function references (the `func:` keyword, or + # the deprecated `step:` alias) that are not trusted. Functions run + # arbitrary code with the job's full context, the same supply-chain + # exposure as CI/CD components. Trust is evaluated the same way + # regardless of reference form — deprecated forms are not a free + # pass; deprecation is tracked separately (see the Deprecated stat + # in `plumber analyze` output) and doesn't affect this control. + # + # A reference is trusted when it matches trustedFunctions (patterns + # may reference $CI_* variables — a pattern is rejected if the + # pipeline redefines one of those variables itself; both $VAR and + # ${VAR} notation are accepted and normalized identically), OR its + # host matches the scanned GitLab instance and its path after that + # host starts with this project's own root namespace + # (trustSameGroupFunctions). + # + # Best practice: keep functions scoped to your own project/group + functionMustComeFromAuthorizedSources: + # Set to false to disable this control + enabled: true + # Trust functions under this project's own root namespace + trustSameGroupFunctions: true + # Additional trusted function source URLs and patterns (supports + # wildcards). Both $VAR and ${VAR} notation are listed below since + # pipeline authors write either form. + trustedFunctions: + - $CI_TEMPLATE_REGISTRY_HOST/$CI_PROJECT_PATH/* + - ${CI_TEMPLATE_REGISTRY_HOST}/${CI_PROJECT_PATH}/* + # =========================================== # Pipeline must not enable debug trace # =========================================== # Detects CI/CD pipelines that set CI_DEBUG_TRACE or CI_DEBUG_SERVICES diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fd94ef13..2b9e691b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -688,6 +688,7 @@ The wizard is a **separate code path** from `.plumber.yaml`: it builds a curated - [ ] **`README.md`** — usually nothing to do. The README no longer carries a control count, per-control `
` blocks, or a "Valid control names" table; its `## Controls` section is a short prose summary that links to the website catalog. Touch it only if your control introduces a category that summary does not already cover. - [ ] **`docs/GITHUB_ISSUES.md`** (GitHub controls only) — add a TOC row under the right severity-category section (1xx/2xx/3xx/4xx/5xx/6xx), then a detailed `## ISSUE-XXX — ` section with: severity + control name banner, threat model paragraph, bad/good YAML examples, FP guards if any, config snippet. Reference: the ISSUE-411 section we added during the Megalodon work. +- [ ] **`docs/GITLAB_ISSUES.md`** (GitLab controls only) — same shape as the GitHub catalog above: a TOC row under the right severity-category section, then a detailed `## ISSUE-XXX — ` section (severity + control name banner, threat model paragraph, bad/good YAML examples, config snippet). Reference: the ISSUE-414/ISSUE-415 sections added for the authorized-sources work. - [ ] **`docs/PBOM.md`** — only if you added PBOM enrichment (§11). Document both the JSON field and the CycloneDX property. - [ ] **`docs/scoring.md`** — only if your control's severity or contribution changes the score formula. Adding a new control at an existing severity does not require an update. - [ ] **Website — `getplumber.io/src/data/issues.ts`**. Each `ISSUE-XXX` entry has up to two sub-blocks keyed by provider: diff --git a/cmd/init.go b/cmd/init.go index 3743aacf..a096dfac 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -31,14 +31,16 @@ const ( catVariables = "Variable security (debug trace, unsafe expansion)" // GitLab-applicable composition checks (existing). - compHardcoded = "Disallow hardcoded jobs (use includes/components)" - compUpToDate = "Require catalog includes to be up to date" - compForbidden = "Forbid mutable include refs (latest, main, HEAD, …)" - compRefCollision = "Flag include refs that resolve to both a tag and a branch" - compSecurity = "Detect weakened security scanning jobs" - compScripts = "Detect unverified script execution (curl|bash, base64|bash, |sh, …)" - compJobVars = "Detect sensitive variables overridden in pipeline YAML" - compDinD = "Detect Docker-in-Docker (dind) usage" + compHardcoded = "Disallow hardcoded jobs (use includes/components)" + compUpToDate = "Require catalog includes to be up to date" + compForbidden = "Forbid mutable include refs (latest, main, HEAD, …)" + compRefCollision = "Flag include refs that resolve to both a tag and a branch" + compAuthorizedComponents = "Restrict CI/CD components to authorized sources" + compAuthorizedFunctions = "Restrict GitLab Functions to authorized sources" + compSecurity = "Detect weakened security scanning jobs" + compScripts = "Detect unverified script execution (curl|bash, base64|bash, |sh, …)" + compJobVars = "Detect sensitive variables overridden in pipeline YAML" + compDinD = "Detect Docker-in-Docker (dind) usage" // GitHub-applicable composition checks (new). The cross-provider ones // (security jobs, DinD) reuse compSecurity / compDinD above. @@ -157,6 +159,15 @@ type initWizardState struct { ForbiddenVersionsMultiline string DefaultBranchIsForbiddenVersion bool + // componentMustComeFromAuthorizedSources (when compAuthorizedComponents selected) + TrustedComponentsMultiline string + TrustSameGroupComponentsEnabled bool + TrustSameInstanceComponentsEnabled bool + + // functionMustComeFromAuthorizedSources (when compAuthorizedFunctions selected) + TrustedFunctionsMultiline string + TrustSameGroupFunctionsEnabled bool + // securityJobsMustNotBeWeakened (when compSecurity selected). All // three sub-toggles are tracked per provider: GitLab ships them off // (its security templates trip allow_failure / rules / when:manual) @@ -379,6 +390,44 @@ func (st *initWizardState) askCompositionFirstHalf() error { return err } } + if compSelected(st, compAuthorizedComponents) && hasProvider(st, "gitlab") { + fmt.Fprintf(os.Stderr, "\n › Authorized component sources (GitLab)\n") + if err := survey.AskOne(&survey.Confirm{ + Message: "Trust CI/CD components under this project's own root namespace?", + Default: true, + }, &st.TrustSameGroupComponentsEnabled); err != nil { + return err + } + if err := survey.AskOne(&survey.Confirm{ + Message: "Trust CI/CD components hosted on the same GitLab instance, any namespace?", + Default: true, + }, &st.TrustSameInstanceComponentsEnabled); err != nil { + return err + } + if err := survey.AskOne(&survey.Multiline{ + Message: "Additional trusted component source URL patterns (one per line)", + Help: "Supports wildcards. Leave empty to rely only on the namespace/instance trust above.", + Default: strings.Join(defaultTrustedComponents(), "\n"), + }, &st.TrustedComponentsMultiline); err != nil { + return err + } + } + if compSelected(st, compAuthorizedFunctions) && hasProvider(st, "gitlab") { + fmt.Fprintf(os.Stderr, "\n › Authorized function sources (GitLab)\n") + if err := survey.AskOne(&survey.Confirm{ + Message: "Trust GitLab Functions under this project's own root namespace?", + Default: true, + }, &st.TrustSameGroupFunctionsEnabled); err != nil { + return err + } + if err := survey.AskOne(&survey.Multiline{ + Message: "Additional trusted function source URL patterns (one per line)", + Help: "Supports wildcards. Leave empty to rely only on the namespace trust above.", + Default: strings.Join(defaultTrustedFunctions(), "\n"), + }, &st.TrustedFunctionsMultiline); err != nil { + return err + } + } if compSelected(st, compSecurity) { if err := st.askSecurityJobQuestions(); err != nil { return err @@ -719,7 +768,7 @@ func compositionOptionsForProviders(providers []string) []string { } var out []string if hasGitLab { - out = append(out, compHardcoded, compUpToDate, compForbidden, compRefCollision) + out = append(out, compHardcoded, compUpToDate, compForbidden, compRefCollision, compAuthorizedComponents, compAuthorizedFunctions) } out = append(out, compSecurity, compDinD) if hasGitLab { @@ -909,6 +958,24 @@ func defaultJobOverrideVariables() []string { return nil } +// defaultTrustedComponents mirrors the .plumber.yaml default for +// gitlab.controls.componentMustComeFromAuthorizedSources.trustedComponents. +func defaultTrustedComponents() []string { + if c := defaultGitLabControls().ComponentMustComeFromAuthorizedSources; c != nil { + return c.TrustedComponents + } + return nil +} + +// defaultTrustedFunctions mirrors the .plumber.yaml default for +// gitlab.controls.functionMustComeFromAuthorizedSources.trustedFunctions. +func defaultTrustedFunctions() []string { + if c := defaultGitLabControls().FunctionMustComeFromAuthorizedSources; c != nil { + return c.TrustedFunctions + } + return nil +} + func defaultSecurityJobPatterns() []string { if c := defaultGitLabControls().SecurityJobsMustNotBeWeakened; c != nil { return c.SecurityJobPatterns @@ -1116,6 +1183,29 @@ func (st *initWizardState) toPlumberConfig() *configuration.PlumberConfig { Variables: vars, } } + if compSelected(st, compAuthorizedComponents) { + comps := parseLinesInit(st.TrustedComponentsMultiline) + if len(comps) == 0 { + comps = defaultTrustedComponents() + } + gl.Controls.ComponentMustComeFromAuthorizedSources = &configuration.ComponentAuthorizedSourcesControlConfig{ + Enabled: boolPtrInit(true), + TrustSameGroupComponents: boolPtrInit(st.TrustSameGroupComponentsEnabled), + TrustSameInstanceComponents: boolPtrInit(st.TrustSameInstanceComponentsEnabled), + TrustedComponents: comps, + } + } + if compSelected(st, compAuthorizedFunctions) { + funcs := parseLinesInit(st.TrustedFunctionsMultiline) + if len(funcs) == 0 { + funcs = defaultTrustedFunctions() + } + gl.Controls.FunctionMustComeFromAuthorizedSources = &configuration.FunctionAuthorizedSourcesControlConfig{ + Enabled: boolPtrInit(true), + TrustSameGroupFunctions: boolPtrInit(st.TrustSameGroupFunctionsEnabled), + TrustedFunctions: funcs, + } + } if e := strings.TrimSpace(st.RequiredComponentsExpr); e != "" { gl.Controls.PipelineMustIncludeComponent = &configuration.RequiredComponentsControlConfig{ diff --git a/cmd/init_test.go b/cmd/init_test.go index fe689d8c..68f02e3e 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -208,10 +208,15 @@ func starterWizardConfig() *configuration.PlumberConfig { TrustedURLsText: strings.Join(defaultTrustedURLs(), "\n"), AuthorizedActionsUsePlumberList: true, CompositionChoices: []string{ - compHardcoded, compUpToDate, compForbidden, compRefCollision, compSecurity, compScripts, compJobVars, compDinD, + compHardcoded, compUpToDate, compForbidden, compRefCollision, compAuthorizedComponents, compAuthorizedFunctions, compSecurity, compScripts, compJobVars, compDinD, compActionPin, compAuthorizedActions, compDangerousTriggers, compPRTargetHead, compDeclarePermissions, compReusableSecrets, compOverprovSecrets, compTemplateInjection, compEnvInjection, compWriteAllPerms, compRefConfusion, compArchivedActions, compKnownCVEs, compImpostorCommit, compMutableRemoteExec, compCachePoisoning, compDebugTraceGitHub, }, + TrustSameGroupComponentsEnabled: true, + TrustSameInstanceComponentsEnabled: true, + TrustedComponentsMultiline: strings.Join(defaultTrustedComponents(), "\n"), + TrustSameGroupFunctionsEnabled: true, + TrustedFunctionsMultiline: strings.Join(defaultTrustedFunctions(), "\n"), ActionPinTrustedOwnersMultiline: strings.Join(defaultGitHubTrustedActionOwners(), "\n"), SecurityJobPatternsGitHubMultiline: strings.Join(defaultGitHubSecurityJobPatterns(), "\n"), ForbiddenVersionsMultiline: strings.Join(defaultForbiddenVersions(), "\n"), @@ -265,6 +270,54 @@ func TestStarterGitHubControlsMatchEmbeddedDefault(t *testing.T) { } } +// enabledGitLabControlKeys parses a .plumber.yaml document and returns the +// set of gitlab.controls. keys whose block has enabled: true. +func enabledGitLabControlKeys(t *testing.T, doc []byte) map[string]bool { + t.Helper() + var root map[string]interface{} + if err := yaml.Unmarshal(doc, &root); err != nil { + t.Fatalf("unmarshal: %v", err) + } + out := map[string]bool{} + gl, _ := root["gitlab"].(map[interface{}]interface{}) + if gl == nil { + return out + } + controls, _ := gl["controls"].(map[interface{}]interface{}) + for name, block := range controls { + m, ok := block.(map[interface{}]interface{}) + if !ok { + continue + } + if enabled, _ := m["enabled"].(bool); enabled { + out[name.(string)] = true + } + } + return out +} + +// Every GitLab control enabled in the embedded default must also be emitted +// (and enabled) when the wizard defaults are accepted. This is the GitLab-side +// twin of TestStarterGitHubControlsMatchEmbeddedDefault — the durable +// regression guard against `config init` drifting behind the shipped control +// set again. +func TestStarterGitLabControlsMatchEmbeddedDefault(t *testing.T) { + wantKeys := enabledGitLabControlKeys(t, defaultconfig.Get()) + if len(wantKeys) == 0 { + t.Fatal("embedded default exposed no enabled gitlab controls; test wiring is wrong") + } + starterBytes, err := yaml.Marshal(starterWizardConfig()) + if err != nil { + t.Fatalf("marshal starter: %v", err) + } + gotKeys := enabledGitLabControlKeys(t, starterBytes) + for k := range wantKeys { + if !gotKeys[k] { + t.Errorf("config init starter omits gitlab control %q that ships enabled in the embedded default", k) + } + } +} + func TestStarterPlumberConfigValidate(t *testing.T) { cfg := starterWizardConfig() if err := cfg.Validate(); err != nil { diff --git a/cmd/render_details.go b/cmd/render_details.go index 6c7e48a6..b9c9e751 100644 --- a/cmd/render_details.go +++ b/cmd/render_details.go @@ -669,6 +669,49 @@ func buildGitLabControlStats(controlName string, result *control.AnalysisResult, {Label: "Authorized", Value: fmt.Sprintf("%d", authorized)}, {Label: "Unauthorized", Value: fmt.Sprintf("%d", unauthorized)}, } + case "componentMustComeFromAuthorizedSources": + total := 0 + if result.GitLabPipeline != nil { + for _, inc := range result.GitLabPipeline.Includes { + if inc.Kind == "component" { + total++ + } + } + } + unauthorized := findingsCount + authorized := total - unauthorized + if authorized < 0 { + authorized = 0 + } + return []statLine{ + {Label: "Total Components", Value: fmt.Sprintf("%d", total)}, + {Label: "Authorized", Value: fmt.Sprintf("%d", authorized)}, + {Label: "Unauthorized", Value: fmt.Sprintf("%d", unauthorized)}, + } + case "functionMustComeFromAuthorizedSources": + total := 0 + deprecated := 0 + if result.GitLabPipeline != nil { + for _, job := range result.GitLabPipeline.Jobs { + total += len(job.Functions) + for _, fn := range job.Functions { + if fn.Deprecated { + deprecated++ + } + } + } + } + unauthorized := findingsCount + authorized := total - unauthorized + if authorized < 0 { + authorized = 0 + } + return []statLine{ + {Label: "Total Functions", Value: fmt.Sprintf("%d", total)}, + {Label: "Authorized", Value: fmt.Sprintf("%d", authorized)}, + {Label: "Unauthorized", Value: fmt.Sprintf("%d", unauthorized)}, + {Label: "Deprecated", Value: fmt.Sprintf("%d", deprecated)}, + } case "pipelineMustNotIncludeHardcodedJobs": total := uint(0) hardcoded := uint(0) diff --git a/configuration/plumberconfig.go b/configuration/plumberconfig.go index bbaaaefe..7447e0c4 100644 --- a/configuration/plumberconfig.go +++ b/configuration/plumberconfig.go @@ -54,6 +54,12 @@ var validControlSchema = map[string][]string{ "pipelineMustIncludeTemplate": { "enabled", "required", "requiredGroups", }, + "componentMustComeFromAuthorizedSources": { + "enabled", "trustedComponents", "trustSameGroupComponents", "trustSameInstanceComponents", + }, + "functionMustComeFromAuthorizedSources": { + "enabled", "trustedFunctions", "trustSameGroupFunctions", + }, "pipelineMustNotEnableDebugTrace": { "enabled", "forbiddenVariables", }, @@ -281,6 +287,12 @@ type ControlsConfig struct { // PipelineMustIncludeTemplate control configuration PipelineMustIncludeTemplate *RequiredTemplatesControlConfig `yaml:"pipelineMustIncludeTemplate,omitempty"` + // ComponentMustComeFromAuthorizedSources control configuration + ComponentMustComeFromAuthorizedSources *ComponentAuthorizedSourcesControlConfig `yaml:"componentMustComeFromAuthorizedSources,omitempty"` + + // FunctionMustComeFromAuthorizedSources control configuration + FunctionMustComeFromAuthorizedSources *FunctionAuthorizedSourcesControlConfig `yaml:"functionMustComeFromAuthorizedSources,omitempty"` + // PipelineMustNotEnableDebugTrace control configuration PipelineMustNotEnableDebugTrace *DebugTraceControlConfig `yaml:"pipelineMustNotEnableDebugTrace,omitempty"` @@ -601,6 +613,52 @@ type ImageAuthorizedSourcesControlConfig struct { IncludePlumberDefaults *bool `yaml:"includePlumberDefaults,omitempty"` } +// ComponentAuthorizedSourcesControlConfig configuration for the +// authorized GitLab CI/CD component sources control (ISSUE-414). +type ComponentAuthorizedSourcesControlConfig struct { + // Enabled controls whether this check runs + Enabled *bool `yaml:"enabled,omitempty"` + + // TrustedComponents is a manual allowlist of trusted component + // source URLs/patterns (supports wildcards), matched against the + // server-side-resolved include: component: source, on top of the + // dynamic trust options below. + TrustedComponents []string `yaml:"trustedComponents,omitempty"` + + // TrustSameGroupComponents trusts components hosted under the + // scanned project's root namespace (top-level group) on the same + // GitLab instance. Derived dynamically from the pipeline's own + // projectPath at scan time — no environment variables involved. + // Defaults to true when unset. + TrustSameGroupComponents *bool `yaml:"trustSameGroupComponents,omitempty"` + + // TrustSameInstanceComponents trusts any component hosted on the + // same GitLab instance as the scanned project, regardless of + // namespace. Defaults to false on gitlab.com (a multi-tenant SaaS + // host — same-instance is not a trust boundary there) and true on a + // self-hosted instance (already inside the org's trust boundary). + TrustSameInstanceComponents *bool `yaml:"trustSameInstanceComponents,omitempty"` +} + +// FunctionAuthorizedSourcesControlConfig configuration for the +// authorized GitLab CI/CD function sources control (ISSUE-415). +type FunctionAuthorizedSourcesControlConfig struct { + // Enabled controls whether this check runs + Enabled *bool `yaml:"enabled,omitempty"` + + // TrustedFunctions is a manual allowlist of trusted function source + // URLs/patterns (supports wildcards), matched as literal text + // against the pipeline's func: reference (only $VAR / ${VAR} + // notation is normalized — no variable resolution is performed). + TrustedFunctions []string `yaml:"trustedFunctions,omitempty"` + + // TrustSameGroupFunctions trusts function references whose host + // matches the scanned GitLab instance and whose path after that + // host starts with the scanned project's root namespace. Defaults + // to true when unset. + TrustSameGroupFunctions *bool `yaml:"trustSameGroupFunctions,omitempty"` +} + // BranchProtectionControlConfig configuration for the branch protection control type BranchProtectionControlConfig struct { // Enabled controls whether this check runs @@ -1122,6 +1180,42 @@ func (c *ImageAuthorizedSourcesControlConfig) IsIncludePlumberDefaults() bool { return *c.IncludePlumberDefaults } +// IsEnabled returns whether the control is enabled +// Returns false if not properly configured +func (c *ComponentAuthorizedSourcesControlConfig) IsEnabled() bool { + if c == nil || c.Enabled == nil { + return false + } + return *c.Enabled +} + +// IsEnabled returns whether the control is enabled +// Returns false if not properly configured +func (c *FunctionAuthorizedSourcesControlConfig) IsEnabled() bool { + if c == nil || c.Enabled == nil { + return false + } + return *c.Enabled +} + +// GetComponentMustComeFromAuthorizedSourcesConfig returns the control configuration +// Returns nil if not configured +func (c *PlumberConfig) GetComponentMustComeFromAuthorizedSourcesConfig() *ComponentAuthorizedSourcesControlConfig { + if c == nil { + return nil + } + return c.ControlsFor("gitlab").ComponentMustComeFromAuthorizedSources +} + +// GetFunctionMustComeFromAuthorizedSourcesConfig returns the control configuration +// Returns nil if not configured +func (c *PlumberConfig) GetFunctionMustComeFromAuthorizedSourcesConfig() *FunctionAuthorizedSourcesControlConfig { + if c == nil { + return nil + } + return c.ControlsFor("gitlab").FunctionMustComeFromAuthorizedSources +} + // GetBranchMustBeProtectedConfig returns the control configuration // Returns nil if not configured func (c *PlumberConfig) GetBranchMustBeProtectedConfig() *BranchProtectionControlConfig { diff --git a/configuration/plumberconfig_test.go b/configuration/plumberconfig_test.go index 42700797..43ec2e5a 100644 --- a/configuration/plumberconfig_test.go +++ b/configuration/plumberconfig_test.go @@ -361,9 +361,11 @@ func TestValidControlNames(t *testing.T) { "actionsMustNotCarryKnownCVEs", "actionsMustNotExecuteMutableRemoteCode", "branchMustBeProtected", + "componentMustComeFromAuthorizedSources", "containerImageMustComeFromAuthorizedSources", "containerImageMustNotUseForbiddenTags", "externalRefsMustNotCollide", + "functionMustComeFromAuthorizedSources", "githubActionMustComeFromAuthorizedSources", "includesMustBeUpToDate", "includesMustNotUseForbiddenVersions", diff --git a/configuration/registry.go b/configuration/registry.go index 888043f1..029a52c7 100644 --- a/configuration/registry.go +++ b/configuration/registry.go @@ -46,6 +46,11 @@ var controlsMeta = map[string]ControlMeta{ "pipelineMustNotUseUnsafeVariableExpansion": {Providers: []string{ProviderGitLab, ProviderGitHub}}, "securityJobsMustNotBeWeakened": {Providers: []string{ProviderGitLab, ProviderGitHub}}, + // GitLab-only. Components and Functions are GitLab CI/CD-specific + // concepts with no GitHub Actions equivalent. + "componentMustComeFromAuthorizedSources": {Providers: []string{ProviderGitLab}}, + "functionMustComeFromAuthorizedSources": {Providers: []string{ProviderGitLab}}, + // GitHub-only. "actionPinCommentsMustMatchSha": {Providers: []string{ProviderGitHub}}, "actionPinsMustNotBeStale": {Providers: []string{ProviderGitHub}}, diff --git a/control/cache_poisoning_contract_test.go b/control/cache_poisoning_contract_test.go index f7d9abae..f4687bb4 100644 --- a/control/cache_poisoning_contract_test.go +++ b/control/cache_poisoning_contract_test.go @@ -23,7 +23,7 @@ func TestCachePoisoningConfigContract(t *testing.T) { if err := yaml.Unmarshal(defaultconfig.Get(), &conf); err != nil { t.Fatalf("unmarshal embedded default: %v", err) } - engineCfg := buildEngineConfig(conf.ControlsFor("github")) + engineCfg := buildEngineConfig(conf.ControlsFor("github"), "") if _, ok := engineCfg["cachePoisoning"]; !ok { t.Fatal("buildEngineConfig did not project a cachePoisoning block from the embedded default") } diff --git a/control/catalog.go b/control/catalog.go index 67d8c6fe..31f92ea7 100644 --- a/control/catalog.go +++ b/control/catalog.go @@ -29,7 +29,7 @@ func GitLabControls(pc *configuration.PlumberConfig) []ControlEntry { return nil } c := pc.ControlsFor("gitlab") - entries := make([]ControlEntry, 0, 15) + entries := make([]ControlEntry, 0, 16) // Container images must not use forbidden tags cfgForbiddenTags := c.ContainerImageMustNotUseForbiddenTags @@ -82,6 +82,16 @@ func GitLabControls(pc *configuration.PlumberConfig) []ControlEntry { ControlName: "pipelineMustIncludeTemplate", Skipped: c.PipelineMustIncludeTemplate == nil || !c.PipelineMustIncludeTemplate.IsEnabled(), }) + entries = append(entries, ControlEntry{ + DisplayName: "Components must come from authorized sources", + ControlName: "componentMustComeFromAuthorizedSources", + Skipped: c.ComponentMustComeFromAuthorizedSources == nil || !c.ComponentMustComeFromAuthorizedSources.IsEnabled(), + }) + entries = append(entries, ControlEntry{ + DisplayName: "Functions must come from authorized sources", + ControlName: "functionMustComeFromAuthorizedSources", + Skipped: c.FunctionMustComeFromAuthorizedSources == nil || !c.FunctionMustComeFromAuthorizedSources.IsEnabled(), + }) entries = append(entries, ControlEntry{ DisplayName: "Pipeline must not enable debug trace", ControlName: "pipelineMustNotEnableDebugTrace", @@ -333,6 +343,12 @@ func DisabledControlNames(c *configuration.ControlsConfig) map[string]bool { if cfg := c.PipelineMustIncludeTemplate; cfg == nil || !cfg.IsEnabled() { out["pipelineMustIncludeTemplate"] = true } + if cfg := c.ComponentMustComeFromAuthorizedSources; cfg == nil || !cfg.IsEnabled() { + out["componentMustComeFromAuthorizedSources"] = true + } + if cfg := c.FunctionMustComeFromAuthorizedSources; cfg == nil || !cfg.IsEnabled() { + out["functionMustComeFromAuthorizedSources"] = true + } if cfg := c.PipelineMustNotEnableDebugTrace; cfg == nil || !cfg.IsEnabled() { out["pipelineMustNotEnableDebugTrace"] = true } diff --git a/control/codes.go b/control/codes.go index d3dabe56..d1e3b8b1 100644 --- a/control/codes.go +++ b/control/codes.go @@ -140,6 +140,10 @@ const ( CodeDockerInDockerUsage ErrorCode = "ISSUE-412" // ISSUE-413: CI/CD job uses Docker-in-Docker with insecure daemon configuration CodeDockerInDockerInsecure ErrorCode = "ISSUE-413" + // ISSUE-414: GitLab CI/CD component comes from an unauthorized source + CodeComponentUnauthorizedSource ErrorCode = "ISSUE-414" + // ISSUE-415: GitLab CI/CD function comes from an unauthorized source, or uses a deprecated reference form + CodeFunctionUnauthorizedSource ErrorCode = "ISSUE-415" // ISSUE-802: Job reaches a dangerous trigger (workflow_run, issue_comment, // pull_request_review*, discussion*, gollum, fork) AND checks out fork // content. pull_request_target is owned by ISSUE-804. @@ -539,6 +543,24 @@ var errorCodeRegistry = map[ErrorCode]ErrorCodeInfo{ DocURL: docsBaseURL + string(CodeDockerInDockerInsecure), ControlName: "pipelineMustNotUseDockerInDocker", }, + CodeComponentUnauthorizedSource: { + Code: CodeComponentUnauthorizedSource, + Severity: SeverityHigh, + Title: "Untrusted GitLab CI/CD component source", + Description: "A GitLab CI/CD component is included from a source that is not trusted: not on an explicit allowlist, not under the scanned project's own namespace, and not on the same GitLab instance (when that trust is enabled). Components run arbitrary code with the job's full context (variables, secrets, CI_JOB_TOKEN), so an untrusted source increases supply chain attack risk.", + Remediation: "Include components only from a trusted source: add it to .plumber.yaml under componentMustComeFromAuthorizedSources.trustedComponents, or rely on trustSameGroupComponents / trustSameInstanceComponents if it already lives in your own namespace or instance.", + DocURL: docsBaseURL + string(CodeComponentUnauthorizedSource), + ControlName: "componentMustComeFromAuthorizedSources", + }, + CodeFunctionUnauthorizedSource: { + Code: CodeFunctionUnauthorizedSource, + Severity: SeverityHigh, + Title: "Untrusted GitLab CI/CD function reference", + Description: "A GitLab CI/CD function (run:/func: step) is referenced from a source that is not trusted: not on an explicit allowlist and not under the scanned project's own namespace (when that trust is enabled). Functions run arbitrary code with the job's full context, the same supply chain exposure as CI/CD components.", + Remediation: "Reference functions only from a trusted source: add it to .plumber.yaml under functionMustComeFromAuthorizedSources.trustedFunctions, or rely on trustSameGroupFunctions if it already lives in your own namespace.", + DocURL: docsBaseURL + string(CodeFunctionUnauthorizedSource), + ControlName: "functionMustComeFromAuthorizedSources", + }, // Access and authorization controls (5xx) CodeBranchUnprotected: { diff --git a/control/mrcomment.go b/control/mrcomment.go index dabb87e0..6e59f77b 100644 --- a/control/mrcomment.go +++ b/control/mrcomment.go @@ -257,6 +257,8 @@ func writeIssueDetails(b *strings.Builder, result *AnalysisResult) { {"includesMustNotUseForbiddenVersions", "Includes must not use forbidden versions"}, {"pipelineMustIncludeComponent", "Pipeline must include required components"}, {"pipelineMustIncludeTemplate", "Pipeline must include required templates"}, + {"componentMustComeFromAuthorizedSources", "Components must come from authorized sources"}, + {"functionMustComeFromAuthorizedSources", "Functions must come from authorized sources"}, {"pipelineMustNotEnableDebugTrace", "Pipeline must not enable debug trace"}, {"pipelineMustNotUseUnsafeVariableExpansion", "Pipeline must not use unsafe variable expansion"}, {"pipelineMustNotOverrideJobVariables", "Pipeline must not override job variables"}, diff --git a/control/task.go b/control/task.go index e1035120..8cb12499 100644 --- a/control/task.go +++ b/control/task.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "strings" "time" "github.com/getplumber/plumber/configuration" @@ -107,7 +108,9 @@ func clearProgressLine(conf *configuration.Configuration) { const analysisStepCount = 18 // runRegoEngine invokes the experimental Rego/OPA rule engine on the -// GitLab collector outputs and returns the aggregated findings. The +// GitLab collector outputs and returns the aggregated findings plus the +// normalized pipeline they were evaluated against (retained by the +// caller on AnalysisResult.GitLabPipeline for stats rendering). The // legacy Go controls always run and remain authoritative until parity // is reached (see phases 2+). On any failure the returned slice is nil // and the error is logged at Warn level so the overall analysis still @@ -119,7 +122,7 @@ func runRegoEngine( originData *gitlab.GitlabPipelineOriginData, imageData *gitlab.GitlabPipelineImageData, protectionData *gitlab.GitlabProtectionAnalysisData, -) []opaengine.Finding { +) ([]opaengine.Finding, *ir.NormalizedPipeline) { pipeline := gitlab.ToNormalizedPipeline( conf.ProjectPath, project.DefaultBranch, @@ -128,7 +131,7 @@ func runRegoEngine( imageData, protectionData, ) - return evaluatePolicies(l, conf, "gitlab", pipeline) + return evaluatePolicies(l, conf, "gitlab", pipeline), pipeline } // evaluatePolicies loads the embedded Rego policies and evaluates them @@ -155,7 +158,7 @@ func evaluatePolicies(l *logrus.Entry, conf *configuration.Configuration, provid controls := conf.PlumberConfig.ControlsFor(provider) ctx, cancel := context.WithTimeout(context.Background(), opaEvaluateTimeout) defer cancel() - findings, err := engine.Evaluate(ctx, pipeline, buildEngineConfig(controls)) + findings, err := engine.Evaluate(ctx, pipeline, buildEngineConfig(controls, conf.GitlabURL)) if err != nil { l.WithError(err).Warn("Rego/OPA engine evaluation failed") return empty @@ -171,8 +174,10 @@ func evaluatePolicies(l *logrus.Entry, conf *configuration.Configuration, provid // buildEngineConfig projects the relevant bits of the user's .plumber.yaml // onto a Rego-friendly map. Policies read it as `input.config..`. // Only the sections consumed by already-ported policies are included; -// additional entries land with each new policy. -func buildEngineConfig(controls *configuration.ControlsConfig) map[string]any { +// additional entries land with each new policy. gitlabURL is +// conf.GitlabURL — only used to derive the scanned GitLab instance's host +// for componentAuthorizedSources; callers scoped to GitHub may pass "". +func buildEngineConfig(controls *configuration.ControlsConfig, gitlabURL string) map[string]any { if controls == nil { return nil } @@ -324,6 +329,57 @@ func buildEngineConfig(controls *configuration.ControlsConfig) map[string]any { } } + // componentAuthorizedSources: no environment-variable resolution. + // Trust is either an explicit trustedComponents allowlist pattern, or + // derived dynamically from the scanned project's own namespace/instance + // via trustSameGroupComponents / trustSameInstanceComponents — modeled + // on githubActionMustComeFromAuthorizedSources's trustSameOrgActions, + // which reads input.pipeline.projectPath instead of trusting Plumber's + // own process environment. + if c := controls.ComponentMustComeFromAuthorizedSources; c != nil && c.IsEnabled() { + trustSameGroup := true + if c.TrustSameGroupComponents != nil { + trustSameGroup = *c.TrustSameGroupComponents + } + + trustSameInstance := true + if c.TrustSameInstanceComponents != nil { + trustSameInstance = *c.TrustSameInstanceComponents + } + + if isGitlabSaaS(gitlabURL) { + trustSameInstance = false + } + + entry := map[string]any{ + "trustSameGroupComponents": trustSameGroup, + "trustSameInstanceComponents": trustSameInstance, + "instanceHost": gitlabInstanceHost(gitlabURL), + } + if len(c.TrustedComponents) > 0 { + entry["trustedComponents"] = c.TrustedComponents + } + cfg["componentAuthorizedSources"] = entry + } + + // functionAuthorizedSources: same dynamic same-namespace model as + // componentAuthorizedSources — same-group trust is host-bound via + // instanceHost for a literal same-instance ref. + if c := controls.FunctionMustComeFromAuthorizedSources; c != nil && c.IsEnabled() { + trustSameGroup := true + if c.TrustSameGroupFunctions != nil { + trustSameGroup = *c.TrustSameGroupFunctions + } + entry := map[string]any{ + "trustSameGroupFunctions": trustSameGroup, + "instanceHost": gitlabInstanceHost(gitlabURL), + } + if len(c.TrustedFunctions) > 0 { + entry["trustedFunctions"] = c.TrustedFunctions + } + cfg["functionAuthorizedSources"] = entry + } + if c := controls.ActionsMustBePinnedByCommitSha; c != nil && c.IsEnabled() { entry := map[string]any{} if len(c.TrustedOwners) > 0 { @@ -386,6 +442,23 @@ func toAnyGroups(groups [][]string) []any { return out } +// gitlabInstanceHost strips the scheme (and any trailing slash) from a +// GitLab base URL, e.g. "https://gitlab.com" -> "gitlab.com". +func gitlabInstanceHost(gitlabURL string) string { + host := gitlabURL + if i := strings.Index(host, "://"); i >= 0 { + host = host[i+3:] + } + return strings.TrimSuffix(host, "/") +} + +// isGitlabSaaS reports whether gitlabURL points at gitlab.com, the +// multi-tenant SaaS instance — as opposed to a self-hosted instance, +// which is already inside the scanning org's trust boundary. +func isGitlabSaaS(gitlabURL string) bool { + return gitlabInstanceHost(gitlabURL) == "gitlab.com" +} + // RunAnalysis executes the complete pipeline analysis for a GitLab project func RunAnalysis(conf *configuration.Configuration) (*AnalysisResult, error) { l := l.WithFields(logrus.Fields{ @@ -399,9 +472,9 @@ func RunAnalysis(conf *configuration.Configuration) (*AnalysisResult, error) { ProjectPath: conf.ProjectPath, } - /////////////////////// + // ///////////////////// // Fetch Project Info from GitLab - /////////////////////// + // ///////////////////// reportProgress(conf, 1, analysisStepCount, "Fetching project information") l.Info("Fetching project information from GitLab") project, err := gitlab.FetchProjectDetails(conf.ProjectPath, conf.GitlabToken, conf.GitlabURL, conf) @@ -472,9 +545,9 @@ func RunAnalysis(conf *configuration.Configuration) (*AnalysisResult, error) { result.HeadCommitSha = projectInfo.LatestHeadCommitSha } - /////////////////////// + // ///////////////////// // Resolve CI config source (local file vs remote) - /////////////////////// + // ///////////////////// // Priority: // 1. If --branch is defined: use remote file on that branch @@ -517,9 +590,9 @@ func RunAnalysis(conf *configuration.Configuration) (*AnalysisResult, error) { result.CIConfigSource = "local" } - /////////////////////// + // ///////////////////// // Run Data Collections - /////////////////////// + // ///////////////////// // 1. Run Pipeline Origin data collection reportProgress(conf, 2, analysisStepCount, "Collecting pipeline origins") @@ -649,7 +722,7 @@ func RunAnalysis(conf *configuration.Configuration) (*AnalysisResult, error) { // Rego/OPA rule engine evaluation — the single authoritative // compliance path (the legacy Go controls were retired in // docs/REFACTOR_MULTI_PROVIDER.md §8 Phase A). - result.Findings = runRegoEngine(l, conf, project, pipelineOriginData, pipelineImageData, protectionData) + result.Findings, result.GitLabPipeline = runRegoEngine(l, conf, project, pipelineOriginData, pipelineImageData, protectionData) result.ProtectionData = protectionData reportProgress(conf, analysisStepCount, analysisStepCount, "Analysis complete") diff --git a/control/task_test.go b/control/task_test.go new file mode 100644 index 00000000..f651deb3 --- /dev/null +++ b/control/task_test.go @@ -0,0 +1,48 @@ +package control + +import "testing" + +func TestGitlabInstanceHost(t *testing.T) { + cases := []struct { + name string + gitlabURL string + want string + }{ + {name: "https_gitlab_com", gitlabURL: "https://gitlab.com", want: "gitlab.com"}, + {name: "trailing_slash_stripped", gitlabURL: "https://gitlab.com/", want: "gitlab.com"}, + {name: "http_scheme", gitlabURL: "http://gitlab.example.com", want: "gitlab.example.com"}, + {name: "port_preserved", gitlabURL: "https://gitlab.example.com:8443", want: "gitlab.example.com:8443"}, + {name: "no_scheme_passthrough", gitlabURL: "gitlab.com", want: "gitlab.com"}, + {name: "empty", gitlabURL: "", want: ""}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := gitlabInstanceHost(tc.gitlabURL); got != tc.want { + t.Errorf("gitlabInstanceHost(%q) = %q, want %q", tc.gitlabURL, got, tc.want) + } + }) + } +} + +func TestIsGitlabSaaS(t *testing.T) { + cases := []struct { + name string + gitlabURL string + want bool + }{ + {name: "https_gitlab_com", gitlabURL: "https://gitlab.com", want: true}, + {name: "trailing_slash", gitlabURL: "https://gitlab.com/", want: true}, + {name: "self_hosted", gitlabURL: "https://gitlab.example.com", want: false}, + {name: "http_scheme", gitlabURL: "http://gitlab.com", want: true}, + {name: "empty", gitlabURL: "", want: false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := isGitlabSaaS(tc.gitlabURL); got != tc.want { + t.Errorf("isGitlabSaaS(%q) = %v, want %v", tc.gitlabURL, got, tc.want) + } + }) + } +} diff --git a/control/types.go b/control/types.go index 898f7a4d..184e474b 100644 --- a/control/types.go +++ b/control/types.go @@ -71,6 +71,15 @@ type AnalysisResult struct { // Nil on the GitLab path. GitHubPipeline *ir.NormalizedPipeline `json:"-"` + // GitLabPipeline is the normalized IR built for the Rego engine on + // the GitLab path, retained so the stats renderer can compute + // Total/Authorized/Unauthorized(/Deprecated) denominators for + // componentMustComeFromAuthorizedSources and + // functionMustComeFromAuthorizedSources directly from the pipeline + // (Includes/Jobs[].Functions) without a dedicated metrics + // collector. Nil on the GitHub path. + GitLabPipeline *ir.NormalizedPipeline `json:"-"` + // Warnings holds non-fatal "could not verify" messages from the run, // e.g. a known-CVE check skipped because an action's pinned commit // could not be resolved to a version (tag list blocked by an org IP diff --git a/defaultConfig/.plumber.yaml b/defaultConfig/.plumber.yaml index 9c5fcb4d..cb6ab24c 100644 --- a/defaultConfig/.plumber.yaml +++ b/defaultConfig/.plumber.yaml @@ -369,6 +369,63 @@ gitlab: # required: templates/go/go AND templates/trivy/trivy AND templates/iso27001/iso27001 requiredGroups: [] # =========================================== + # CI/CD components must come from authorized sources + # =========================================== + # Detects `include: component:` references that are not trusted. + # Components run arbitrary code with the job's full context + # (variables, secrets, CI_JOB_TOKEN) — the GitLab analogue of a + # GitHub Actions "pwn request". + # + # A source is trusted when it matches trustedComponents, OR lives + # under this project's own root namespace on the same GitLab + # instance (trustSameGroupComponents), OR is hosted on the same + # GitLab instance at all (trustSameInstanceComponents — on by + # default). Both are derived dynamically from the pipeline at scan + # time, in CI or locally — no environment variables involved. + # + # Best practice: keep components scoped to your own project/group + componentMustComeFromAuthorizedSources: + # Set to false to disable this control + enabled: true + # Trust components under this project's own root namespace + trustSameGroupComponents: true + # Trust any component on the same GitLab instance, regardless of + # namespace. (defaults to true when self-hosted, false on gitlab.com) + trustSameInstanceComponents: true + # Additional trusted component source URLs and patterns (supports wildcards) + trustedComponents: [] + # =========================================== + # GitLab Functions must come from authorized sources + # =========================================== + # Detects `run:` step function references (the `func:` keyword, or + # the deprecated `step:` alias) that are not trusted. Functions run + # arbitrary code with the job's full context, the same supply-chain + # exposure as CI/CD components. Trust is evaluated the same way + # regardless of reference form — deprecated forms are not a free + # pass; deprecation is tracked separately (see the Deprecated stat + # in `plumber analyze` output) and doesn't affect this control. + # + # A reference is trusted when it matches trustedFunctions (patterns + # may reference $CI_* variables — a pattern is rejected if the + # pipeline redefines one of those variables itself; both $VAR and + # ${VAR} notation are accepted and normalized identically), OR its + # host matches the scanned GitLab instance and its path after that + # host starts with this project's own root namespace + # (trustSameGroupFunctions). + # + # Best practice: keep functions scoped to your own project/group + functionMustComeFromAuthorizedSources: + # Set to false to disable this control + enabled: true + # Trust functions under this project's own root namespace + trustSameGroupFunctions: true + # Additional trusted function source URLs and patterns (supports + # wildcards). Both $VAR and ${VAR} notation are listed below since + # pipeline authors write either form. + trustedFunctions: + - $CI_TEMPLATE_REGISTRY_HOST/$CI_PROJECT_PATH/* + - ${CI_TEMPLATE_REGISTRY_HOST}/${CI_PROJECT_PATH}/* + # =========================================== # Pipeline must not enable debug trace # =========================================== # Detects CI/CD pipelines that set CI_DEBUG_TRACE or CI_DEBUG_SERVICES diff --git a/docs/GITLAB_ISSUES.md b/docs/GITLAB_ISSUES.md new file mode 100644 index 00000000..4e8c4edc --- /dev/null +++ b/docs/GITLAB_ISSUES.md @@ -0,0 +1,144 @@ +# GitLab CI/CD rule catalog + +Reference for rules Plumber runs against GitLab CI/CD pipelines. Each +entry gives the trigger, the risk, and a compilable **before / after** +remediation so you can drop the fix in without reading the upstream +docs. This catalog is seeded incrementally as rules are documented; +absence from this file does not mean a rule doesn't exist — see +[`control/codes.go`](../control/codes.go) for the full list. + +## Table of contents + +### Pipeline composition — `4xx` + +| Code | Name | Severity | +| :--- | :--- | :--- | +| [ISSUE-414](#issue-414--component-authorized-sources) | `component-authorized-sources` | high | +| [ISSUE-415](#issue-415--function-authorized-sources) | `function-authorized-sources` | high | + +### Run / output conventions + +- Every finding prints a clickable `↳ at :` — `Ctrl+click` + in a VS Code terminal opens the exact job. +- Severity counts drive the **Plumber score** (A–E). +- To turn a rule off, either disable its `ControlName` in + `.plumber.yaml` or pass `--skip-controls `. The + mapping lives in [`control/codes.go`](../control/codes.go). + +--- + +## ISSUE-414 — `component-authorized-sources` + +**Severity:** `high` • **Control:** `componentMustComeFromAuthorizedSources` + +An `include: component:` reference is pulled from a source that isn't +trusted. Components run arbitrary code with the job's full context +(variables, secrets, `CI_JOB_TOKEN`) — the GitLab analogue of a +GitHub Actions "pwn request" — so an unvetted source is a direct +supply-chain entry point. + +A source is **trusted** when any of these hold: + +- it matches an explicit `trustedComponents` allowlist pattern + (wildcards supported, `$VAR`/`${VAR}` notation both accepted); +- `trustSameGroupComponents` (default `true`) and the component lives + under the scanned project's own root namespace, on the same GitLab + instance; +- `trustSameInstanceComponents` (default `true` on a self-hosted + instance, `false` on gitlab.com) and the component is hosted on the + scanned instance at all, regardless of namespace — a self-hosted + instance is already inside the org's trust boundary the way + gitlab.com, a multi-tenant SaaS host, is not. + +```yaml +# ❌ before — component from an untrusted external namespace +include: + - component: gitlab.com/attacker/evil-components/backdoor@1.0.0 + +build: + script: + - echo build +``` + +```yaml +# ✅ after — trusted: lives under the project's own namespace +include: + - component: $CI_SERVER_FQDN/$CI_PROJECT_PATH/secret-detection@1.0.0 + +build: + script: + - echo build +``` + +**Config.** + +```yaml +componentMustComeFromAuthorizedSources: + enabled: true + # Trust components under this project's own root namespace + trustSameGroupComponents: true + # Trust any component on the same GitLab instance, regardless of + # namespace (defaults to true when self-hosted, false on gitlab.com) + trustSameInstanceComponents: true + # Additional trusted component source URLs and patterns (wildcards supported) + trustedComponents: [] +``` + +--- + +## ISSUE-415 — `function-authorized-sources` + +**Severity:** `high` • **Control:** `functionMustComeFromAuthorizedSources` + +A `run:` step function reference (`func:`, or the deprecated `step:` +alias) is pulled from a source that isn't trusted. Functions +(docs.gitlab.com/ci/functions) run arbitrary code with the job's full +context — the same supply-chain exposure as CI/CD components above. +Trust is evaluated identically regardless of which key form is used; +a deprecated reference form (`step:` instead of `func:`, or a +deprecated git-repository ref) is tracked separately as a terminal +stat, not as a violation of this rule. + +A reference is **trusted** when any of these hold: + +- it matches an explicit `trustedFunctions` allowlist pattern + (wildcards supported, `$VAR`/`${VAR}` notation both accepted); +- `trustSameGroupFunctions` (default `true`) and the function is + hosted on the scanned GitLab instance, under the project's own + root namespace. + +Local (relative/absolute filesystem path) function references are +same-repo and always out of scope. + +```yaml +# ❌ before — function from an untrusted external namespace +build: + run: + - name: say_hi + func: registry.gitlab.com/attacker/evil/backdoor:1 +``` + +```yaml +# ✅ after — trusted: lives under the project's own namespace +build: + run: + - name: say_hi + func: $CI_TEMPLATE_REGISTRY_HOST/$CI_PROJECT_PATH/echo:1 + inputs: + message: "Hi Sally!" +``` + +**Config.** + +```yaml +functionMustComeFromAuthorizedSources: + enabled: true + # Trust functions under this project's own root namespace + trustSameGroupFunctions: true + # Additional trusted function source URLs and patterns (wildcards + # supported). Both $VAR and ${VAR} notation are listed below since + # pipeline authors write either form. + trustedFunctions: + - $CI_TEMPLATE_REGISTRY_HOST/$CI_PROJECT_PATH/* + - ${CI_TEMPLATE_REGISTRY_HOST}/${CI_PROJECT_PATH}/* +``` diff --git a/finding/identity/declarations.go b/finding/identity/declarations.go index a03bf30b..39431603 100644 --- a/finding/identity/declarations.go +++ b/finding/identity/declarations.go @@ -143,6 +143,10 @@ var declarations = map[string][]string{ "ISSUE-412": {"file", "job", "serviceImage"}, // Docker-in-Docker with an insecure daemon: one finding per job, keyed on the job (detail was prose; the finding is one-per-job so it needs no discriminator). "ISSUE-413": {"file", "job"}, + // Untrusted CI/CD component source: keyed on the component path, like ISSUE-408/409; an include is not a job, so job stays a deterministic empty pair (assertNoJob pins that). + "ISSUE-414": {"file", "job", "componentPath"}, + // Untrusted `run:` step function source: keyed on the function ref (link); step discriminates two steps in one job referencing the same function. + "ISSUE-415": {"file", "job", "link", "step"}, // Required action/workflow missing: keyed on the required action. "ISSUE-417": {"file", "job", "requiredAction"}, // Workflow has no `concurrency:` block: one finding per workflow file, keyed on the file (benched, not yet live: declaration provisional, revisit on unbench). diff --git a/finding/identity/identity_test.go b/finding/identity/identity_test.go index 76ccdc35..0f469bdb 100644 --- a/finding/identity/identity_test.go +++ b/finding/identity/identity_test.go @@ -353,6 +353,8 @@ func TestDeclarations_EveryCodeFingerprintIsPinned(t *testing.T) { "ISSUE-411": "00a582d1c7ce3b61", "ISSUE-412": "a7e4aba60a34a04a", "ISSUE-413": "1388ae7203cc3eb8", + "ISSUE-414": "c1cbb69708f90ad4", + "ISSUE-415": "014ae9977401ed15", "ISSUE-417": "9be7e2421e979408", "ISSUE-418": "b7d95784db1eafc2", "ISSUE-419": "6147e752a7f298fb", diff --git a/gitlab/gitlab_ir.go b/gitlab/gitlab_ir.go index 4c5d8f7f..695edde7 100644 --- a/gitlab/gitlab_ir.go +++ b/gitlab/gitlab_ir.go @@ -619,6 +619,9 @@ func enrichFromMergedConf(job *ir.Job, name string, conf *GitlabCIConf) { if rules := extractGitLabRules(parsed.Rules); len(rules) > 0 { job.Rules = rules } + if fns := extractGitLabRunSteps(parsed.Run); len(fns) > 0 { + job.Functions = fns + } } // extractGitLabRules normalises the polymorphic `rules:` block into a @@ -693,6 +696,73 @@ func extractGitLabServices(v any) []ir.Image { return out } +// extractGitLabRunSteps normalizes the polymorphic `run:` block (list of +// {name, func, step, inputs, env} maps) into a flat list of ir.Function +// entries. `func:` is the current keyword; `step:` is the deprecated +// alias GitLab renamed away from (docs.gitlab.com/ci/functions) — using +// it marks the function Deprecated regardless of its trust status. Ref +// is stored exactly as parsed, with no variable substitution. +func extractGitLabRunSteps(v any) []ir.Function { + list, ok := v.([]any) + if !ok { + return nil + } + out := make([]ir.Function, 0, len(list)) + for _, item := range list { + m, ok := item.(map[any]any) + if !ok { + continue + } + name, _ := m["name"].(string) + ref, _ := m["func"].(string) + usedDeprecatedKey := false + if ref == "" { + if step, ok := m["step"].(string); ok && step != "" { + ref = step + usedDeprecatedKey = true + } + } + if ref == "" { + continue + } + kind, deprecatedForm := classifyFunctionRef(ref) + out = append(out, ir.Function{ + Name: name, + Ref: ref, + Kind: kind, + Deprecated: usedDeprecatedKey || deprecatedForm, + }) + } + return out +} + +// classifyFunctionRef reports the reference form of a GitLab Function +// `func:`/`step:` value. "local" refs (relative or absolute filesystem +// paths) are same-repo and carry no supply-chain concern. "oci" refs +// (registry/path:tag or a @sha256: digest) are the supported form. +// Anything else containing "@" is the deprecated git-repository +// loading form (host/path@ref, no OCI tag) — GitLab plans to remove +// support for it in favor of OCI registry refs. +func classifyFunctionRef(ref string) (kind string, deprecated bool) { + switch { + case strings.HasPrefix(ref, "./"), strings.HasPrefix(ref, "../"), strings.HasPrefix(ref, "/"): + return "local", false + case strings.Contains(ref, "@sha256:"): + return "oci", false + } + last := ref + if i := strings.LastIndex(ref, "/"); i >= 0 { + last = ref[i+1:] + } + if strings.Contains(last, ":") { + return "oci", false + } + if strings.Contains(ref, "@") { + return "git", true + } + return "oci", false +} + // extractGitLabVariables collapses the YAML-typed variables map into a // string→string map so Rego sees everything as plain strings. func extractGitLabVariables(v map[string]any) map[string]string { diff --git a/gitlab/gitlab_ir_test.go b/gitlab/gitlab_ir_test.go index e018aa51..2b9ed938 100644 --- a/gitlab/gitlab_ir_test.go +++ b/gitlab/gitlab_ir_test.go @@ -79,3 +79,51 @@ func TestToNormalizedPipeline_NilJobInMap(t *testing.T) { t.Fatalf("expected valid job kept, got %q", pipeline.Jobs[0].Name) } } + +func TestClassifyFunctionRef(t *testing.T) { + cases := []struct { + name string + ref string + expectedKind string + expectedDeprec bool + }{ + {"oci_registry_tag", "registry.gitlab.com/gitlab-org/ci-cd/runner-tools/example/echo:1", "oci", false}, + {"oci_digest", "registry.gitlab.com/gitlab-org/example/echo@sha256:abcd1234", "oci", false}, + {"oci_no_slash", "echo:1", "oci", false}, + {"local_relative", "./path/to/my-function", "local", false}, + {"local_relative_parent", "../shared/my-function", "local", false}, + {"local_absolute", "/opt/gitlab-functions/my-function", "local", false}, + {"git_deprecated", "gitlab.com/funcs/my-git-repo@v1.0.0", "git", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + kind, deprecated := classifyFunctionRef(tc.ref) + if kind != tc.expectedKind || deprecated != tc.expectedDeprec { + t.Fatalf("classifyFunctionRef(%q) = (%q, %v), want (%q, %v)", tc.ref, kind, deprecated, tc.expectedKind, tc.expectedDeprec) + } + }) + } +} + +func TestExtractGitLabRunSteps(t *testing.T) { + steps := []any{ + map[any]any{ + "name": "say_hi", + "func": "registry.gitlab.com/gitlab-org/ci-cd/runner-tools/example/echo:1", + }, + map[any]any{ + "name": "legacy", + "step": "registry.gitlab.com/gitlab-org/example/legacy:1", + }, + } + fns := extractGitLabRunSteps(steps) + if len(fns) != 2 { + t.Fatalf("expected 2 functions, got %d (%+v)", len(fns), fns) + } + if fns[0].Name != "say_hi" || fns[0].Kind != "oci" || fns[0].Deprecated { + t.Fatalf("unexpected func: entry: %+v", fns[0]) + } + if fns[1].Name != "legacy" || fns[1].Ref != "registry.gitlab.com/gitlab-org/example/legacy:1" || !fns[1].Deprecated { + t.Fatalf("unexpected step: entry: %+v", fns[1]) + } +} diff --git a/gitlab/models.go b/gitlab/models.go index ba264354..7f3a85e9 100644 --- a/gitlab/models.go +++ b/gitlab/models.go @@ -233,6 +233,10 @@ type GitlabJob struct { When interface{} `yaml:"when,omitempty"` AllowFailure interface{} `yaml:"allow_failure,omitempty"` Extends interface{} `yaml:"extends,omitempty"` + // Run holds the `run:` keyword (docs.gitlab.com/ci/functions), an + // alternative to `script:` where each step invokes a GitLab + // Function via `func:` (or the deprecated `step:` alias). + Run interface{} `yaml:"run,omitempty"` } type Image struct { diff --git a/internal/ir/pipeline.go b/internal/ir/pipeline.go index 46fb91a7..42d0bd6e 100644 --- a/internal/ir/pipeline.go +++ b/internal/ir/pipeline.go @@ -149,6 +149,12 @@ type Job struct { OriginFile string `json:"originFile,omitempty"` OriginLine int `json:"originLine,omitempty"` OriginKind string `json:"originKind,omitempty"` + // Functions lists this job's `run:`-step function references + // (docs.gitlab.com/ci/functions, GitLab CI only). Kind classifies + // the reference form so policies can skip same-repo file + // references (no supply-chain concern) and flag deprecated forms + // independently of trust. + Functions []Function `json:"functions,omitempty"` // Overridden is true when the job inherits from an upstream // component or template but the project locally redefined some of // its keys. Lets policies distinguish "user-authored override" from @@ -368,6 +374,22 @@ type Include struct { OverriddenJobs []OverriddenJob `json:"overriddenJobs,omitempty"` } +// Function references a GitLab CI/CD Function (docs.gitlab.com/ci/functions) +// — a job's `run:` step's `func:` (or deprecated `step:`) reference. Kind +// classifies the reference form: "oci" (registry/path:tag or @sha256: +// digest — the supported form), "local" (relative/absolute filesystem +// path — same-repo, no supply-chain concern), or "git" (the deprecated +// git-repository loading form, host/path@ref with no OCI tag). +type Function struct { + Name string `json:"name,omitempty"` + Ref string `json:"ref"` + Kind string `json:"kind"` + // Deprecated is true when the step used the legacy `step:` key + // (renamed to `func:`) or Kind is "git" (deprecated git-repository + // loading, superseded by OCI registry refs). + Deprecated bool `json:"deprecated,omitempty"` +} + // OverriddenJob captures a single job whose inherited definition was // locally overridden. Keys is the list of CI/CD fields (script, image, // rules, …) whose values were redefined in the pipeline configuration. diff --git a/policies/component_authorized_sources.rego b/policies/component_authorized_sources.rego new file mode 100644 index 00000000..9b632703 --- /dev/null +++ b/policies/component_authorized_sources.rego @@ -0,0 +1,97 @@ +# component-authorized-sources (ISSUE-414) — flag `include: component:` +# references pulled from a source that is not trusted by +# componentMustComeFromAuthorizedSources. Components run arbitrary code +# with the job's full context (variables, secrets, CI_JOB_TOKEN) — the +# GitLab analogue of a GitHub Actions "pwn request". +# +# GitLab resolves $VAR server-side before +# Plumber ever fetches the merged CI config, so inc.source already +# carries the literal resolved host+path — trust here can safely compare +# against the real host. +# +# A source is trusted when it matches an explicit trustedComponents +# allowlist pattern, or (trustSameGroupComponents, default true) it lives +# under the scanned project's root namespace on the same instance, or +# (trustSameInstanceComponents, default false on gitlab.com / true on a +# self-hosted instance — see buildEngineConfig) it's hosted on the +# scanned GitLab instance at all, regardless of namespace — a self-hosted +# instance is already inside the org's trust boundary the way gitlab.com, +# a multi-tenant SaaS host, is not. Modeled on +# action_authorized_sources.rego's trustSameOrgActions. +package component_authorized_sources + +import rego.v1 + +deny contains finding if { + input.config.componentAuthorizedSources + some i + inc := input.pipeline.includes[i] + inc.kind == "component" + inc.source != "" + not _is_authorized(inc.source) + finding := { + "code": "ISSUE-414", + "severity": "high", + "message": sprintf("component %q comes from untrusted source: %s", [object.get(inc, "componentName", inc.source), inc.source]), + # No "job": an include is not a CI job, and the job field is a hashed + # identity segment (finding/identity). componentPath names what this + # finding is about and is the subject key the identity recipe selects, + # matching the other component controls (ISSUE-408/409). + "componentPath": inc.source, + "link": inc.source, + "status": "unauthorized", + "file": object.get(inc, "originFile", ""), + "line": object.get(inc, "originLine", 0), + "componentName": object.get(inc, "componentName", ""), + "gitlabIncludeLocation": inc.source, + } +} + +_is_authorized(source) if _in_allowlist(source) + +_is_authorized(source) if _is_same_group(source) + +_is_authorized(source) if _is_same_instance(source) + +_in_allowlist(source) if { + pattern := input.config.componentAuthorizedSources.trustedComponents[_] + glob.match(_normalize_var(pattern), null, _normalize_var(source)) +} + +_is_same_group(source) if { + object.get(input.config.componentAuthorizedSources, "trustSameGroupComponents", true) == true + instanceHost := object.get(input.config.componentAuthorizedSources, "instanceHost", "") + instanceHost != "" + startswith(source, sprintf("%s/", [instanceHost])) + root := _root_namespace(object.get(input.pipeline, "projectPath", "")) + root != "" + path := _path_after_host(source) + startswith(path, sprintf("%s/", [root])) +} + +_is_same_instance(source) if { + object.get(input.config.componentAuthorizedSources, "trustSameInstanceComponents", false) == true + instanceHost := object.get(input.config.componentAuthorizedSources, "instanceHost", "") + instanceHost != "" + startswith(source, sprintf("%s/", [instanceHost])) +} + +_root_namespace(projectPath) := parts[0] if { + projectPath != "" + parts := split(projectPath, "/") + count(parts) > 0 + parts[0] != "" +} else := "" + +# _path_after_host drops the first "/"-delimited segment of source +# (the registry/instance host). +_path_after_host(source) := path if { + idx := indexof(source, "/") + idx >= 0 + path := substring(source, idx+1, -1) +} else := "" + +# _normalize_var rewrites `${VAR}` references to `$VAR` so trustedComponents +# patterns and the actual source compare equal regardless of notation. +# Mirrors image_authorized_sources.rego's helper of the same name. +_normalize_var(s) := regex.replace(s, `\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}`, `$$$1`) diff --git a/policies/function_authorized_sources.rego b/policies/function_authorized_sources.rego new file mode 100644 index 00000000..454cc0a6 --- /dev/null +++ b/policies/function_authorized_sources.rego @@ -0,0 +1,150 @@ +# function-authorized-sources (ISSUE-415) — flag `run:` step function +# references (docs.gitlab.com/ci/functions) pulled from a source that is +# not trusted by functionMustComeFromAuthorizedSources. Functions run +# arbitrary code with the job's full context, the same supply-chain +# exposure as CI/CD components. Trust is evaluated identically for every +# reference form — a deprecated form is NOT a free pass; deprecation is +# tracked separately (ir.Function.Deprecated, surfaced as a terminal stat) +# and carries no weight here. +# +# A reference is trusted when it matches an explicit trustedFunctions +# allowlist pattern, or (trustSameGroupFunctions, default true) the ref is +# hosted on the scanned GitLab instance (instanceHost) and its path starts +# with the project's own root namespace (top-level group) — a same-namespace +# check that only looks at the path after an unvalidated host segment would +# trust any registry that happens to name a top-level path after the +# victim's namespace (ISSUE-415 hardening). +# +# Allowlist patterns may themselves reference GitLab predefined CI/CD +# variables (e.g. the shipped defaults `$CI_TEMPLATE_REGISTRY_HOST/ +# $CI_PROJECT_PATH/*` and its `${VAR}` equivalent — both notations are +# shipped since pipeline authors write either form, and _normalize_var +# treats them identically). GitLab predefined variables have the lowest +# precedence, so a pipeline that redefines one of them in its own +# `variables:` block could make Plumber trust pattern text that resolves to +# an attacker registry at runtime — _in_allowlist guards against this by +# rejecting a pattern match if any `$CI_*` variable referenced by that +# pattern is redefined in the pipeline's globalVariables/localGlobalVariables +# (ISSUE-415 hardening). +# +# "local" (relative/absolute filesystem path) references are same-repo +# and out of scope entirely, mirroring how `include: local` is out of +# scope for component-authorized-sources. +package function_authorized_sources + +import rego.v1 + +deny contains finding if { + input.config.functionAuthorizedSources + some i, j + job := input.pipeline.jobs[i] + fn := job.functions[j] + fn.kind != "local" + not _is_authorized(fn) + finding := { + "code": "ISSUE-415", + "severity": "high", + "message": sprintf("job %q uses function %q from untrusted source: %s", [job.name, _fn_name(fn), fn.ref]), + "job": job.name, + # step discriminates two `run:` steps in one job that reference the + # same function (same code/file/job/link would otherwise collide to + # one fingerprint). Empty when the author set no step `name:`, in + # which case the identity recipe skips the segment (finding/identity). + "step": object.get(fn, "name", ""), + "file": object.get(job, "originFile", ""), + "line": object.get(job, "originLine", 0), + "link": fn.ref, + "status": "unauthorized", + } +} + +# fn.name is omitted from the JSON payload entirely when empty +# (omitempty), so object.get's default only kicks in when the pipeline +# author didn't set a step `name:` — never compares against "". +_fn_name(fn) := object.get(fn, "name", fn.ref) + +_is_authorized(fn) if _in_allowlist(fn.ref) + +_is_authorized(fn) if _is_same_group(fn.ref) + +_in_allowlist(ref) if { + pattern := input.config.functionAuthorizedSources.trustedFunctions[_] + glob.match(_normalize_var(pattern), null, _normalize_var(ref)) + not _pattern_redefined(pattern) +} + +# _pattern_redefined guards trustedFunctions patterns that reference +# GitLab predefined CI/CD variables (e.g. $CI_PROJECT_PATH) — those +# variables have the lowest precedence, so a pipeline that redefines one +# in its own `variables:` block could make an otherwise-safe pattern +# match text that resolves to an attacker-controlled source at runtime. +# Every `/`-delimited segment of the pattern that starts with $CI is +# checked independently; if ANY of those variables is redefined, the +# pattern cannot authorize the ref (ISSUE-415 hardening). +_pattern_redefined(pattern) if { + segment := split(_normalize_var(pattern), "/")[_] + startswith(segment, "$CI") + _pipeline_defines_var(_segment_var_name(segment)) +} + +# _segment_var_name extracts the bare variable name (no $) from a +# normalized "$CI_..." path segment, e.g. "$CI_PROJECT_PATH" -> +# "CI_PROJECT_PATH". +_segment_var_name(segment) := name if { + m := regex.find_all_string_submatch_n(`^\$([A-Za-z_][A-Za-z0-9_]*)`, segment, 1) + count(m) > 0 + name := m[0][1] +} + +# _is_same_group trusts a function ref hosted on the scanned GitLab +# instance whose path starts with the project's own root namespace — see +# _matches_own_namespace. +_is_same_group(ref) if { + object.get(input.config.functionAuthorizedSources, "trustSameGroupFunctions", true) == true + _matches_own_namespace(ref) +} + +# _matches_own_namespace mirrors component_authorized_sources.rego's +# _is_same_group — the ref must be hosted on the scanned GitLab instance +# AND its path (after the host) must start with the project's root +# namespace. Checking the path alone, with an unvalidated host segment +# dropped, let an attacker-controlled registry claim any path it wanted +# (ISSUE-415 hardening). +_matches_own_namespace(ref) if { + instanceHost := object.get(input.config.functionAuthorizedSources, "instanceHost", "") + instanceHost != "" + startswith(ref, sprintf("%s/", [instanceHost])) + root := _root_namespace(object.get(input.pipeline, "projectPath", "")) + root != "" + path := _path_after_host(ref) + startswith(path, sprintf("%s/", [root])) +} + +# _pipeline_defines_var reports whether the pipeline redefines a GitLab +# predefined CI/CD variable itself — checked both against the merged +# view (globalVariables) and the project-authored-only view +# (localGlobalVariables), since either could shadow the predefined value +# at runtime. Used by _pattern_redefined above. +_pipeline_defines_var(name) if object.get(input.pipeline, "globalVariables", {})[name] + +_pipeline_defines_var(name) if object.get(input.pipeline, "localGlobalVariables", {})[name] + +_root_namespace(projectPath) := parts[0] if { + projectPath != "" + parts := split(projectPath, "/") + count(parts) > 0 + parts[0] != "" +} else := "" + +# _path_after_host drops the first "/"-delimited segment of ref (the +# registry host, validated separately by the namespace branch above). +_path_after_host(ref) := path if { + idx := indexof(ref, "/") + idx >= 0 + path := substring(ref, idx+1, -1) +} else := "" + +# _normalize_var rewrites `${VAR}` references to `$VAR` so trustedFunctions +# patterns and the actual ref compare equal regardless of notation. +# Mirrors image_authorized_sources.rego's helper of the same name. +_normalize_var(s) := regex.replace(s, `\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}`, `$$$1`) diff --git a/policies/rules_test.go b/policies/rules_test.go index 4dfdcd8b..c14eafe7 100644 --- a/policies/rules_test.go +++ b/policies/rules_test.go @@ -1727,6 +1727,407 @@ func TestImageRepoIdentityValue(t *testing.T) { } } +// TestIssue414_ComponentAuthorizedSources exercises +// component_authorized_sources.rego directly against hand-built +// ir.Include entries — the lightweight parseGitLabCI test parser used +// by runGitLabPolicyCases does not parse `include:` (same situation +// ISSUE-101 hit for job.image.Registry). +func TestIssue414_ComponentAuthorizedSources(t *testing.T) { + engine := opaengine.New() + if err := engine.LoadFromFSFiltered(policies.FS, nil); err != nil { + t.Fatalf("load embedded policies: %v", err) + } + + cases := []struct { + name string + inc ir.Include + projectPath string + cfg map[string]any + expected bool + }{ + { + name: "trusted_allowlist", + inc: ir.Include{Kind: "component", Source: "gitlab.example.com/my-group/my-project/ci-component"}, + cfg: map[string]any{ + "componentAuthorizedSources": map[string]any{ + "trustedComponents": []string{"gitlab.example.com/my-group/my-project/*"}, + }, + }, + expected: false, + }, + { + name: "untrusted_external", + inc: ir.Include{Kind: "component", Source: "gitlab.example.com/attacker/evil-component"}, + cfg: map[string]any{ + "componentAuthorizedSources": map[string]any{ + "trustedComponents": []string{"gitlab.example.com/my-group/my-project/*"}, + }, + }, + expected: true, + }, + { + name: "trust_same_group_root_namespace", + inc: ir.Include{Kind: "component", Source: "gitlab.example.com/my-group/other-project/ci-component"}, + projectPath: "my-group/my-project", + cfg: map[string]any{ + "componentAuthorizedSources": map[string]any{ + "trustSameGroupComponents": true, + "instanceHost": "gitlab.example.com", + }, + }, + expected: false, + }, + { + // A different root namespace (top-level group) must NOT be + // trusted, even on the same instance. + name: "trust_same_group_different_root_namespace", + inc: ir.Include{Kind: "component", Source: "gitlab.example.com/other-group/x/ci-component"}, + projectPath: "my-group/my-project", + cfg: map[string]any{ + "componentAuthorizedSources": map[string]any{ + "trustSameGroupComponents": true, + "instanceHost": "gitlab.example.com", + }, + }, + expected: true, + }, + { + name: "trust_same_group_disabled", + inc: ir.Include{Kind: "component", Source: "gitlab.example.com/my-group/other-project/ci-component"}, + projectPath: "my-group/my-project", + cfg: map[string]any{ + "componentAuthorizedSources": map[string]any{ + "trustSameGroupComponents": false, + "instanceHost": "gitlab.example.com", + }, + }, + expected: true, + }, + { + name: "trust_same_instance_any_namespace", + inc: ir.Include{Kind: "component", Source: "gitlab.example.com/other-group/x/ci-component"}, + projectPath: "my-group/my-project", + cfg: map[string]any{ + "componentAuthorizedSources": map[string]any{ + "trustSameGroupComponents": false, + "trustSameInstanceComponents": true, + "instanceHost": "gitlab.example.com", + }, + }, + expected: false, + }, + { + // A matching namespace path on a DIFFERENT instance host must + // not be trusted — host is checked, not just the path text. + name: "different_instance_host_not_trusted", + inc: ir.Include{Kind: "component", Source: "gitlab.com/my-group/my-project/ci-component"}, + projectPath: "my-group/my-project", + cfg: map[string]any{ + "componentAuthorizedSources": map[string]any{ + "trustSameGroupComponents": true, + "trustSameInstanceComponents": true, + "instanceHost": "gitlab.example.com", + }, + }, + expected: true, + }, + { + name: "notation_normalization", + inc: ir.Include{Kind: "component", Source: "$CI_SERVER_FQDN/my-group/my-project/ci-component"}, + cfg: map[string]any{ + "componentAuthorizedSources": map[string]any{ + "trustedComponents": []string{"${CI_SERVER_FQDN}/my-group/my-project/*"}, + }, + }, + expected: false, + }, + { + name: "non_component_kind_ignored", + inc: ir.Include{Kind: "local", Source: "anything/untrusted"}, + cfg: map[string]any{ + "componentAuthorizedSources": map[string]any{ + "trustedComponents": []string{"gitlab.example.com/my-group/my-project/*"}, + }, + }, + expected: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + pipeline := &ir.NormalizedPipeline{ + Provider: ir.ProviderGitLab, + ProjectPath: tc.projectPath, + Includes: []ir.Include{tc.inc}, + } + findings, err := engine.Evaluate(context.Background(), pipeline, tc.cfg) + if err != nil { + t.Fatalf("evaluate: %v", err) + } + found := false + for _, f := range findings { + if f.Code == "ISSUE-414" { + found = true + } + } + if found != tc.expected { + t.Fatalf("%s: expected violation=%v, got %v (findings=%+v)", tc.name, tc.expected, found, findings) + } + }) + } + + // Identity pins (finding/identity): an untrusted component identifies on + // componentPath, like the other component controls (ISSUE-408/409), and + // never puts its source in the job field. + t.Run("identity_pins", func(t *testing.T) { + pipeline := &ir.NormalizedPipeline{ + Provider: ir.ProviderGitLab, + ProjectPath: "my-group/my-project", + Includes: []ir.Include{{Kind: "component", Source: "gitlab.example.com/attacker/evil-component"}}, + } + cfg := map[string]any{ + "componentAuthorizedSources": map[string]any{ + "trustedComponents": []string{"gitlab.example.com/my-group/my-project/*"}, + }, + } + findings, err := engine.Evaluate(context.Background(), pipeline, cfg) + if err != nil { + t.Fatalf("evaluate: %v", err) + } + assertSubjectKey(t, findings, "ISSUE-414", "componentPath", + []string{"gitlab.example.com/attacker/evil-component"}) + assertNoJob(t, findings, "ISSUE-414") + }) +} + +// TestIssue415_FunctionAuthorizedSources exercises +// function_authorized_sources.rego directly against hand-built +// ir.Function entries, mirroring TestIssue414_ComponentAuthorizedSources +// (the lightweight test parser doesn't parse `run:` either). +func TestIssue415_FunctionAuthorizedSources(t *testing.T) { + engine := opaengine.New() + if err := engine.LoadFromFSFiltered(policies.FS, nil); err != nil { + t.Fatalf("load embedded policies: %v", err) + } + + cases := []struct { + name string + fn ir.Function + projectPath string + globalVars map[string]string + cfg map[string]any + expectFinding bool + }{ + { + name: "authorized_allowlist", + fn: ir.Function{Name: "say_hi", Ref: "registry.gitlab.com/my-group/my-project/echo:1", Kind: "oci"}, + cfg: map[string]any{ + "functionAuthorizedSources": map[string]any{ + "trustedFunctions": []string{"registry.gitlab.com/my-group/my-project/*"}, + }, + }, + expectFinding: false, + }, + { + name: "unauthorized_oci", + fn: ir.Function{Name: "say_hi", Ref: "registry.gitlab.com/attacker/evil:1", Kind: "oci"}, + cfg: map[string]any{"functionAuthorizedSources": map[string]any{}}, + expectFinding: true, + }, + { + // Regression for PR #387 blocking issue #1: the legacy + // git-repository loading form (deprecated) must NOT bypass + // the trust check when the reference is also untrusted. + name: "deprecated_and_untrusted_still_flagged", + fn: ir.Function{Name: "say_hi", Ref: "gitlab.com/attacker/evil@v1.0.0", Kind: "git", Deprecated: true}, + projectPath: "my-group/my-project", + cfg: map[string]any{"functionAuthorizedSources": map[string]any{"trustSameGroupFunctions": true}}, + expectFinding: true, + }, + { + // Deprecated but otherwise trusted (same-group, same host): + // deprecation carries no weight in this control. + name: "deprecated_but_trusted_not_flagged", + fn: ir.Function{Name: "say_hi", Ref: "gitlab.com/my-group/my-project@v1.0.0", Kind: "git", Deprecated: true}, + projectPath: "my-group/my-project", + cfg: map[string]any{"functionAuthorizedSources": map[string]any{"trustSameGroupFunctions": true, "instanceHost": "gitlab.com"}}, + expectFinding: false, + }, + { + name: "local_ref_excluded", + fn: ir.Function{Name: "say_hi", Ref: "./funcs/release/dry-run.yml", Kind: "local"}, + cfg: map[string]any{"functionAuthorizedSources": map[string]any{}}, + expectFinding: false, + }, + { + // Same-group trust requires both the host AND the path to + // match — not the path alone. + name: "same_group_host_and_path_match_trusted", + fn: ir.Function{Name: "say_hi", Ref: "registry.gitlab.com/my-group/my-project/echo:1", Kind: "oci"}, + projectPath: "my-group/my-project", + cfg: map[string]any{"functionAuthorizedSources": map[string]any{"trustSameGroupFunctions": true, "instanceHost": "registry.gitlab.com"}}, + expectFinding: false, + }, + { + name: "different_root_namespace_untrusted", + fn: ir.Function{Name: "say_hi", Ref: "registry.gitlab.com/other-group/x/echo:1", Kind: "oci"}, + projectPath: "my-group/my-project", + cfg: map[string]any{"functionAuthorizedSources": map[string]any{"trustSameGroupFunctions": true, "instanceHost": "registry.gitlab.com"}}, + expectFinding: true, + }, + { + // ISSUE-415 hardening regression: an attacker-controlled + // registry that names a top-level path segment after the + // victim's own root namespace must NOT be trusted just + // because the path (ignoring host) matches. + name: "same_group_different_host_untrusted", + fn: ir.Function{Name: "say_hi", Ref: "registry.evil.example/my-group/whatever:1", Kind: "oci"}, + projectPath: "my-group/my-project", + cfg: map[string]any{"functionAuthorizedSources": map[string]any{"trustSameGroupFunctions": true, "instanceHost": "gitlab.example.com"}}, + expectFinding: true, + }, + { + // ISSUE-415 hardening regression: the default trustedFunctions + // pattern must glob-match the FULL ref against + // $CI_TEMPLATE_REGISTRY_HOST/$CI_PROJECT_PATH/, not just the + // path after an attacker-controlled host. + name: "ci_project_path_idiom_wrong_host_untrusted", + fn: ir.Function{Name: "say_hi", Ref: "registry.evil.example/$CI_PROJECT_PATH/backdoor:1", Kind: "oci"}, + projectPath: "my-group/my-project", + cfg: map[string]any{"functionAuthorizedSources": map[string]any{ + "trustSameGroupFunctions": true, + "trustedFunctions": []string{"$CI_TEMPLATE_REGISTRY_HOST/$CI_PROJECT_PATH/*"}, + }}, + expectFinding: true, + }, + { + name: "default_trusted_functions_pattern_matches", + fn: ir.Function{Name: "say_hi", Ref: "$CI_TEMPLATE_REGISTRY_HOST/$CI_PROJECT_PATH/echo:1", Kind: "oci"}, + cfg: map[string]any{"functionAuthorizedSources": map[string]any{ + "trustSameGroupFunctions": true, + "trustedFunctions": []string{"$CI_TEMPLATE_REGISTRY_HOST/$CI_PROJECT_PATH/*"}, + }}, + expectFinding: false, + }, + { + // Regression for PR #387 blocking issue #3's shadowing + // example: the pipeline redefines CI_TEMPLATE_REGISTRY_HOST + // itself, so the $CI_TEMPLATE_REGISTRY_HOST/$CI_PROJECT_PATH/* + // trustedFunctions pattern must NOT authorize the ref — at + // runtime the literal text would resolve from the attacker's + // redefined value, GitLab predefined variables having the + // lowest precedence. + name: "trusted_functions_pattern_untrusted_when_ci_var_redefined", + fn: ir.Function{Name: "x", Ref: "$CI_TEMPLATE_REGISTRY_HOST/$CI_PROJECT_PATH/echo:1", Kind: "oci"}, + globalVars: map[string]string{"CI_TEMPLATE_REGISTRY_HOST": "registry.evil.example"}, + cfg: map[string]any{"functionAuthorizedSources": map[string]any{ + "trustSameGroupFunctions": true, + "trustedFunctions": []string{"$CI_TEMPLATE_REGISTRY_HOST/$CI_PROJECT_PATH/*"}, + }}, + expectFinding: true, + }, + { + // The redefinition guard is generic — it isn't hardcoded to + // CI_TEMPLATE_REGISTRY_HOST/CI_PROJECT_PATH. A custom pattern + // referencing any other $CI_* variable must also be rejected + // when that variable is redefined by the pipeline. + name: "custom_pattern_untrusted_when_ci_var_redefined", + fn: ir.Function{Name: "x", Ref: "$CI_SERVER_HOST/mygroup/echo:1", Kind: "oci"}, + globalVars: map[string]string{"CI_SERVER_HOST": "registry.evil.example"}, + cfg: map[string]any{"functionAuthorizedSources": map[string]any{ + "trustedFunctions": []string{"$CI_SERVER_HOST/mygroup/*"}, + }}, + expectFinding: true, + }, + { + // No variable resolution happens for this control — the + // ref carries the SAME literal variable text (a common + // idiom for the project's own namespace); only notation + // ($VAR vs ${VAR}) is normalized, never resolved to a real + // value. + name: "notation_normalization_same_literal_vars", + fn: ir.Function{Name: "say_hi", Ref: "${CI_TEMPLATE_REGISTRY_HOST}/${CI_PROJECT_PATH}/echo:1", Kind: "oci"}, + cfg: map[string]any{"functionAuthorizedSources": map[string]any{ + "trustSameGroupFunctions": true, + "trustedFunctions": []string{"$CI_TEMPLATE_REGISTRY_HOST/$CI_PROJECT_PATH/*"}, + }}, + expectFinding: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + pipeline := &ir.NormalizedPipeline{ + Provider: ir.ProviderGitLab, + ProjectPath: tc.projectPath, + GlobalVariables: tc.globalVars, + Jobs: []ir.Job{{Name: "build", Functions: []ir.Function{tc.fn}}}, + } + findings, err := engine.Evaluate(context.Background(), pipeline, tc.cfg) + if err != nil { + t.Fatalf("evaluate: %v", err) + } + found := false + for _, f := range findings { + if f.Code == "ISSUE-415" { + found = true + } + } + if found != tc.expectFinding { + t.Fatalf("%s: expected finding=%v, got %v (findings=%+v)", tc.name, tc.expectFinding, found, findings) + } + }) + } + + // Identity pins (finding/identity): an untrusted function identifies on + // link (the ref), scoped by the real CI job in the job field, with the + // step name as the discriminator between two steps of one job that + // reference the same function. + t.Run("identity_pins", func(t *testing.T) { + pipeline := &ir.NormalizedPipeline{ + Provider: ir.ProviderGitLab, + ProjectPath: "my-group/my-project", + Jobs: []ir.Job{{ + Name: "build", + OriginFile: ".gitlab-ci.yml", + OriginLine: 12, + Functions: []ir.Function{ + {Name: "step_a", Ref: "registry.evil.example/x/backdoor:1", Kind: "oci"}, + {Name: "step_b", Ref: "registry.evil.example/x/backdoor:1", Kind: "oci"}, + }, + }}, + } + cfg := map[string]any{"functionAuthorizedSources": map[string]any{}} + findings, err := engine.Evaluate(context.Background(), pipeline, cfg) + if err != nil { + t.Fatalf("evaluate: %v", err) + } + opaengine.StampFingerprints(findings, "") + assertSubjectKey(t, findings, "ISSUE-415", "link", + []string{"registry.evil.example/x/backdoor:1", "registry.evil.example/x/backdoor:1"}) + fingerprints := map[string]string{} + for _, f := range findings { + if f.Code != "ISSUE-415" { + continue + } + if f.Job != "build" { + t.Fatalf("ISSUE-415: job = %q, want the real CI job name \"build\"", f.Job) + } + if f.File != ".gitlab-ci.yml" { + t.Fatalf("ISSUE-415: file = %q, want the job's origin file", f.File) + } + step, _ := f.Data["step"].(string) + if prev, dup := fingerprints[f.Fingerprint]; dup { + t.Fatalf("ISSUE-415: steps %q and %q collide on fingerprint %s", prev, step, f.Fingerprint) + } + fingerprints[f.Fingerprint] = step + } + if len(fingerprints) != 2 { + t.Fatalf("ISSUE-415: want 2 distinctly-fingerprinted findings, got %d", len(fingerprints)) + } + }) +} + // TestIssue410_SecurityJobsWeakened flags SAST-like jobs with // allow_failure: true or when: manual. func TestIssue410_SecurityJobsWeakened(t *testing.T) { diff --git a/policies/testdata/ISSUE-414/gitlab/clean_own_namespace.gitlab-ci.yml b/policies/testdata/ISSUE-414/gitlab/clean_own_namespace.gitlab-ci.yml new file mode 100644 index 00000000..5586f1f1 --- /dev/null +++ b/policies/testdata/ISSUE-414/gitlab/clean_own_namespace.gitlab-ci.yml @@ -0,0 +1,6 @@ +include: + - component: $CI_SERVER_FQDN/$CI_PROJECT_PATH/secret-detection@1.0.0 + +build: + script: + - echo build diff --git a/policies/testdata/ISSUE-414/gitlab/violation_untrusted.gitlab-ci.yml b/policies/testdata/ISSUE-414/gitlab/violation_untrusted.gitlab-ci.yml new file mode 100644 index 00000000..6f07e104 --- /dev/null +++ b/policies/testdata/ISSUE-414/gitlab/violation_untrusted.gitlab-ci.yml @@ -0,0 +1,6 @@ +include: + - component: gitlab.com/attacker/evil-components/backdoor@1.0.0 + +build: + script: + - echo build diff --git a/policies/testdata/ISSUE-415/gitlab/clean_own_namespace.gitlab-ci.yml b/policies/testdata/ISSUE-415/gitlab/clean_own_namespace.gitlab-ci.yml new file mode 100644 index 00000000..534c1614 --- /dev/null +++ b/policies/testdata/ISSUE-415/gitlab/clean_own_namespace.gitlab-ci.yml @@ -0,0 +1,6 @@ +build: + run: + - name: say_hi + func: $CI_TEMPLATE_REGISTRY_HOST/$CI_PROJECT_PATH/echo:1 + inputs: + message: "Hi Sally!" diff --git a/policies/testdata/ISSUE-415/gitlab/deprecated_git_ref.gitlab-ci.yml b/policies/testdata/ISSUE-415/gitlab/deprecated_git_ref.gitlab-ci.yml new file mode 100644 index 00000000..439234e3 --- /dev/null +++ b/policies/testdata/ISSUE-415/gitlab/deprecated_git_ref.gitlab-ci.yml @@ -0,0 +1,4 @@ +build: + run: + - name: say_hi + func: gitlab.com/funcs/my-git-repo@v1.0.0 diff --git a/policies/testdata/ISSUE-415/gitlab/deprecated_step_keyword.gitlab-ci.yml b/policies/testdata/ISSUE-415/gitlab/deprecated_step_keyword.gitlab-ci.yml new file mode 100644 index 00000000..6e09442a --- /dev/null +++ b/policies/testdata/ISSUE-415/gitlab/deprecated_step_keyword.gitlab-ci.yml @@ -0,0 +1,4 @@ +build: + run: + - name: say_hi + step: $CI_TEMPLATE_REGISTRY_HOST/$CI_PROJECT_PATH/echo:1 diff --git a/policies/testdata/ISSUE-415/gitlab/violation_untrusted.gitlab-ci.yml b/policies/testdata/ISSUE-415/gitlab/violation_untrusted.gitlab-ci.yml new file mode 100644 index 00000000..3a164789 --- /dev/null +++ b/policies/testdata/ISSUE-415/gitlab/violation_untrusted.gitlab-ci.yml @@ -0,0 +1,4 @@ +build: + run: + - name: say_hi + func: registry.gitlab.com/attacker/evil/backdoor:1