// Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package linter import ( "fmt" "codeberg.org/6543/xyaml" "go.uber.org/multierr" "go.woodpecker-ci.org/woodpecker/v2/pipeline/errors" errorTypes "go.woodpecker-ci.org/woodpecker/v2/pipeline/errors/types" "go.woodpecker-ci.org/woodpecker/v2/pipeline/frontend/yaml/linter/schema" "go.woodpecker-ci.org/woodpecker/v2/pipeline/frontend/yaml/types" "go.woodpecker-ci.org/woodpecker/v2/pipeline/frontend/yaml/utils" "go.woodpecker-ci.org/woodpecker/v2/shared/constant" ) // A Linter lints a pipeline configuration. type Linter struct { trusted TrustedConfiguration privilegedPlugins *[]string trustedClonePlugins *[]string } type TrustedConfiguration struct { Network bool Volumes bool Security bool } // New creates a new Linter with options. func New(opts ...Option) *Linter { linter := new(Linter) for _, opt := range opts { opt(linter) } return linter } type WorkflowConfig struct { // File is the path to the configuration file. File string // RawConfig is the raw configuration. RawConfig string // Config is the parsed configuration. Workflow *types.Workflow } // Lint lints the configuration. func (l *Linter) Lint(configs []*WorkflowConfig) error { var linterErr error for _, config := range configs { if err := l.lintFile(config); err != nil { linterErr = multierr.Append(linterErr, err) } } return linterErr } func (l *Linter) lintFile(config *WorkflowConfig) error { var linterErr error if len(config.Workflow.Steps.ContainerList) == 0 { linterErr = multierr.Append(linterErr, newLinterError("Invalid or missing steps section", config.File, "steps", false)) } if err := l.lintCloneSteps(config); err != nil { linterErr = multierr.Append(linterErr, err) } if err := l.lintContainers(config, "clone"); err != nil { linterErr = multierr.Append(linterErr, err) } if err := l.lintContainers(config, "steps"); err != nil { linterErr = multierr.Append(linterErr, err) } if err := l.lintContainers(config, "services"); err != nil { linterErr = multierr.Append(linterErr, err) } if err := l.lintSchema(config); err != nil { linterErr = multierr.Append(linterErr, err) } if err := l.lintDeprecations(config); err != nil { linterErr = multierr.Append(linterErr, err) } if err := l.lintBadHabits(config); err != nil { linterErr = multierr.Append(linterErr, err) } return linterErr } func (l *Linter) lintCloneSteps(config *WorkflowConfig) error { if len(config.Workflow.Clone.ContainerList) == 0 { return nil } trustedClonePlugins := constant.TrustedClonePlugins if l.trustedClonePlugins != nil { trustedClonePlugins = *l.trustedClonePlugins } var linterErr error for _, container := range config.Workflow.Clone.ContainerList { if !utils.MatchImageDynamic(container.Image, trustedClonePlugins...) { linterErr = multierr.Append(linterErr, newLinterError( "Specified clone image does not match allow list, netrc will not be injected", config.File, fmt.Sprintf("clone.%s", container.Name), true), ) } } return linterErr } func (l *Linter) lintContainers(config *WorkflowConfig, area string) error { var linterErr error var containers []*types.Container switch area { case "clone": containers = config.Workflow.Clone.ContainerList case "steps": containers = config.Workflow.Steps.ContainerList case "services": containers = config.Workflow.Services.ContainerList } for _, container := range containers { if err := l.lintImage(config, container, area); err != nil { linterErr = multierr.Append(linterErr, err) } if err := l.lintTrusted(config, container, area); err != nil { linterErr = multierr.Append(linterErr, err) } if err := l.lintSettings(config, container, area); err != nil { linterErr = multierr.Append(linterErr, err) } if err := l.lintPrivilegedPlugins(config, container, area); err != nil { linterErr = multierr.Append(linterErr, err) } } return linterErr } func (l *Linter) lintImage(config *WorkflowConfig, c *types.Container, area string) error { if len(c.Image) == 0 { return newLinterError("Invalid or missing image", config.File, fmt.Sprintf("%s.%s", area, c.Name), false) } return nil } func (l *Linter) lintPrivilegedPlugins(config *WorkflowConfig, c *types.Container, area string) error { // lint for conflicts of https://github.com/woodpecker-ci/woodpecker/pull/3918 if utils.MatchImage(c.Image, "plugins/docker", "plugins/gcr", "plugins/ecr", "woodpeckerci/plugin-docker-buildx") { msg := fmt.Sprintf("The formerly privileged plugin '%s' is no longer privileged by default, if required, add it to WOODPECKER_PLUGINS_PRIVILEGED", c.Image) // check first if user did not add them back if l.privilegedPlugins != nil && !utils.MatchImageDynamic(c.Image, *l.privilegedPlugins...) { return newLinterError(msg, config.File, fmt.Sprintf("%s.%s", area, c.Name), false) } else if l.privilegedPlugins == nil { // if linter has no info of current privileged plugins, it's just a warning return newLinterError(msg, config.File, fmt.Sprintf("%s.%s", area, c.Name), true) } } return nil } func (l *Linter) lintSettings(config *WorkflowConfig, c *types.Container, field string) error { if len(c.Settings) == 0 { return nil } if len(c.Commands) != 0 { return newLinterError("Cannot configure both commands and settings", config.File, fmt.Sprintf("%s.%s", field, c.Name), false) } if len(c.Entrypoint) != 0 { return newLinterError("Cannot configure both entrypoint and settings", config.File, fmt.Sprintf("%s.%s", field, c.Name), false) } if len(c.Environment) != 0 { return newLinterError("Should not configure both environment and settings", config.File, fmt.Sprintf("%s.%s", field, c.Name), true) } if len(c.Secrets) != 0 { return newLinterError("Should not configure both secrets and settings", config.File, fmt.Sprintf("%s.%s", field, c.Name), true) } return nil } func (l *Linter) lintTrusted(config *WorkflowConfig, c *types.Container, area string) error { yamlPath := fmt.Sprintf("%s.%s", area, c.Name) errors := []string{} if !l.trusted.Security { if c.Privileged { errors = append(errors, "Insufficient privileges to use privileged mode") } } if !l.trusted.Network { if len(c.DNS) != 0 { errors = append(errors, "Insufficient privileges to use custom dns") } if len(c.DNSSearch) != 0 { errors = append(errors, "Insufficient privileges to use dns_search") } if len(c.ExtraHosts) != 0 { errors = append(errors, "Insufficient privileges to use extra_hosts") } if len(c.NetworkMode) != 0 { errors = append(errors, "Insufficient privileges to use network_mode") } } if !l.trusted.Volumes { if len(c.Devices) != 0 { errors = append(errors, "Insufficient privileges to use devices") } if len(c.Volumes.Volumes) != 0 { errors = append(errors, "Insufficient privileges to use volumes") } if len(c.Tmpfs) != 0 { errors = append(errors, "Insufficient privileges to use tmpfs") } } if len(errors) > 0 { var err error for _, e := range errors { err = multierr.Append(err, newLinterError(e, config.File, yamlPath, false)) } return err } return nil } func (l *Linter) lintSchema(config *WorkflowConfig) error { var linterErr error schemaErrors, err := schema.LintString(config.RawConfig) if err != nil { for _, schemaError := range schemaErrors { linterErr = multierr.Append(linterErr, newLinterError( schemaError.Description(), config.File, schemaError.Field(), true, // TODO: let pipelines fail if the schema is invalid )) } } return linterErr } func (l *Linter) lintDeprecations(config *WorkflowConfig) (err error) { parsed := new(types.Workflow) err = xyaml.Unmarshal([]byte(config.RawConfig), parsed) if err != nil { return err } for _, container := range parsed.Steps.ContainerList { if len(container.Secrets) > 0 { err = multierr.Append(err, &errorTypes.PipelineError{ Type: errorTypes.PipelineErrorTypeDeprecation, Message: "Secrets are deprecated, use environment with from_secret", Data: errors.DeprecationErrorData{ File: config.File, Field: fmt.Sprintf("steps.%s.secrets", container.Name), Docs: "https://woodpecker-ci.org/docs/usage/secrets#usage", }, IsWarning: true, }) } } return nil } func (l *Linter) lintBadHabits(config *WorkflowConfig) (err error) { parsed := new(types.Workflow) err = xyaml.Unmarshal([]byte(config.RawConfig), parsed) if err != nil { return err } rootEventFilters := len(parsed.When.Constraints) > 0 for _, c := range parsed.When.Constraints { if len(c.Event) == 0 { rootEventFilters = false break } } if !rootEventFilters { // root whens do not necessarily have an event filter, check steps for _, step := range parsed.Steps.ContainerList { var field string if len(step.When.Constraints) == 0 { field = fmt.Sprintf("steps.%s", step.Name) } else { stepEventIndex := -1 for i, c := range step.When.Constraints { if len(c.Event) == 0 { stepEventIndex = i break } } if stepEventIndex > -1 { field = fmt.Sprintf("steps.%s.when[%d]", step.Name, stepEventIndex) } } if field != "" { err = multierr.Append(err, &errorTypes.PipelineError{ Type: errorTypes.PipelineErrorTypeBadHabit, Message: "Please set an event filter for all steps or the whole workflow on all items of the when block", Data: errors.BadHabitErrorData{ File: config.File, Field: field, Docs: "https://woodpecker-ci.org/docs/usage/linter#event-filter-for-all-steps", }, IsWarning: true, }) } } } return }