diff --git a/api/admin/settings.go b/api/admin/settings.go index ee0d4cd4c..d7b65e22b 100644 --- a/api/admin/settings.go +++ b/api/admin/settings.go @@ -178,6 +178,38 @@ func UpdateSettings(c *gin.Context) { l.Infof("platform admin: updating starlark exec limit to %d", *input.StarlarkExecLimit) } + + if input.BlockedImages != nil { + for _, restriction := range input.GetBlockedImages() { + if restriction.GetImage() == "" { + retErr := fmt.Errorf("blocked image entry missing image pattern") + + util.HandleError(c, http.StatusBadRequest, retErr) + + return + } + } + + _s.SetBlockedImages(input.GetBlockedImages()) + + l.Infof("platform admin: updating blocked images to: %v", input.GetBlockedImages()) + } + + if input.WarnImages != nil { + for _, restriction := range input.GetWarnImages() { + if restriction.GetImage() == "" { + retErr := fmt.Errorf("warn image entry missing image pattern") + + util.HandleError(c, http.StatusBadRequest, retErr) + + return + } + } + + _s.SetWarnImages(input.GetWarnImages()) + + l.Infof("platform admin: updating warn images to: %v", input.GetWarnImages()) + } } if input.Queue != nil { diff --git a/api/build/compile_publish.go b/api/build/compile_publish.go index 7a6c7c894..02b95b786 100644 --- a/api/build/compile_publish.go +++ b/api/build/compile_publish.go @@ -346,6 +346,26 @@ func CompileAndPublish( "repo": r.GetName(), "repo_id": r.GetID(), }).Info("pipeline created") + } else { + // reset the pipeline warnings if the compiled pipeline returned any + // + // The pipeline warnings can change at any time due to the image registry since + // new images could be added to the blocked or warning list. To account for this, + // we update the pipeline warnings to match what was compiled with the latest + // results. In general, this shouldn't be called often since we create a new + // pipeline record for every new commit so this covers scenarios where this + // isn't the case such as restarting a build. + if len(compiled.GetWarnings()) > 0 { + pipeline.SetWarnings(compiled.GetWarnings()) + + // send API call to update the pipeline + pipeline, err = database.UpdatePipeline(ctx, pipeline) + if err != nil { + retErr := fmt.Errorf("%s: failed to update pipeline for %s: %w", baseErr, r.GetFullName(), err) + + return nil, nil, http.StatusInternalServerError, retErr + } + } } b.SetPipelineID(pipeline.GetID()) diff --git a/api/types/settings/compiler.go b/api/types/settings/compiler.go index 3a945c637..93db2fd09 100644 --- a/api/types/settings/compiler.go +++ b/api/types/settings/compiler.go @@ -4,14 +4,128 @@ package settings import "fmt" +// ImageRestriction represents a container image pattern that is either +// blocked or warned about when used in a pipeline. +type ImageRestriction struct { + Image *string `json:"image,omitempty" yaml:"image,omitempty"` + Reason *string `json:"reason,omitempty" yaml:"reason,omitempty"` +} + +// GetImage returns the Image field. +// +// When the provided ImageRestriction type is nil, or the field within +// the type is nil, it returns the zero value for the field. +func (ir *ImageRestriction) GetImage() string { + if ir == nil || ir.Image == nil { + return "" + } + + return *ir.Image +} + +// GetReason returns the Reason field. +// +// When the provided ImageRestriction type is nil, or the field within +// the type is nil, it returns the zero value for the field. +func (ir *ImageRestriction) GetReason() string { + if ir == nil || ir.Reason == nil { + return "" + } + + return *ir.Reason +} + +// SetImage sets the Image field. +// +// When the provided ImageRestriction type is nil, it +// will set nothing and immediately return. +func (ir *ImageRestriction) SetImage(v string) { + if ir == nil { + return + } + + ir.Image = &v +} + +// SetReason sets the Reason field. +// +// When the provided ImageRestriction type is nil, it +// will set nothing and immediately return. +func (ir *ImageRestriction) SetReason(v string) { + if ir == nil { + return + } + + ir.Reason = &v +} + +// String implements the Stringer interface for the ImageRestriction type. +func (ir *ImageRestriction) String() string { + return fmt.Sprintf(`{ + Image: %s, + Reason: %s, +}`, + ir.GetImage(), + ir.GetReason(), + ) +} + type Compiler struct { - CloneImage *string `json:"clone_image,omitempty" yaml:"clone_image,omitempty"` - TemplateDepth *int `json:"template_depth,omitempty" yaml:"template_depth,omitempty"` - StarlarkExecLimit *int64 `json:"starlark_exec_limit,omitempty" yaml:"starlark_exec_limit,omitempty"` + CloneImage *string `json:"clone_image,omitempty" yaml:"clone_image,omitempty"` + TemplateDepth *int `json:"template_depth,omitempty" yaml:"template_depth,omitempty"` + StarlarkExecLimit *int64 `json:"starlark_exec_limit,omitempty" yaml:"starlark_exec_limit,omitempty"` + BlockedImages *[]ImageRestriction `json:"blocked_images,omitempty" yaml:"blocked_images,omitempty"` + WarnImages *[]ImageRestriction `json:"warn_images,omitempty" yaml:"warn_images,omitempty"` +} + +// GetBlockedImages returns the BlockedImages field. +// +// When the provided Compiler type is nil, or the field within +// the type is nil, it returns the zero value for the field. +func (cs *Compiler) GetBlockedImages() []ImageRestriction { + if cs == nil || cs.BlockedImages == nil { + return []ImageRestriction{} + } + + return *cs.BlockedImages } -// GetCloneImage returns the CloneImage field. +// GetWarnImages returns the WarnImages field. // +// When the provided Compiler type is nil, or the field within +// the type is nil, it returns the zero value for the field. +func (cs *Compiler) GetWarnImages() []ImageRestriction { + if cs == nil || cs.WarnImages == nil { + return []ImageRestriction{} + } + + return *cs.WarnImages +} + +// SetBlockedImages sets the BlockedImages field. +// +// When the provided Compiler type is nil, it +// will set nothing and immediately return. +func (cs *Compiler) SetBlockedImages(v []ImageRestriction) { + if cs == nil { + return + } + + cs.BlockedImages = &v +} + +// SetWarnImages sets the WarnImages field. +// +// When the provided Compiler type is nil, it +// will set nothing and immediately return. +func (cs *Compiler) SetWarnImages(v []ImageRestriction) { + if cs == nil { + return + } + + cs.WarnImages = &v +} + // When the provided Compiler type is nil, or the field within // the type is nil, it returns the zero value for the field. func (cs *Compiler) GetCloneImage() string { @@ -94,10 +208,14 @@ func (cs *Compiler) String() string { CloneImage: %s, TemplateDepth: %d, StarlarkExecLimit: %d, + BlockedImages: %v, + WarnImages: %v, }`, cs.GetCloneImage(), cs.GetTemplateDepth(), cs.GetStarlarkExecLimit(), + cs.GetBlockedImages(), + cs.GetWarnImages(), ) } @@ -107,6 +225,8 @@ func CompilerMockEmpty() Compiler { cs.SetCloneImage("") cs.SetTemplateDepth(0) cs.SetStarlarkExecLimit(0) + cs.SetBlockedImages(nil) + cs.SetWarnImages(nil) return cs } diff --git a/api/types/settings/compiler_test.go b/api/types/settings/compiler_test.go index a479fc8be..881629f45 100644 --- a/api/types/settings/compiler_test.go +++ b/api/types/settings/compiler_test.go @@ -8,6 +8,46 @@ import ( "testing" ) +func TestTypes_ImageRestriction_String(t *testing.T) { + // setup types + blocked := ImageRestriction{ + Image: new("docker.io/blocked/image:latest"), + Reason: new("this image is blocked"), + } + + warning := ImageRestriction{ + Image: new("docker.io/deprecated/image:latest"), + Reason: new("this image is deprecated"), + } + + wantBlocked := fmt.Sprintf(`{ + Image: %s, + Reason: %s, +}`, + blocked.GetImage(), + blocked.GetReason(), + ) + + wantWarning := fmt.Sprintf(`{ + Image: %s, + Reason: %s, +}`, + warning.GetImage(), + warning.GetReason(), + ) + + // run test + gotBlocked := blocked.String() + if !reflect.DeepEqual(gotBlocked, wantBlocked) { + t.Errorf("String is %v, want %v", gotBlocked, wantBlocked) + } + + gotWarning := warning.String() + if !reflect.DeepEqual(gotWarning, wantWarning) { + t.Errorf("String is %v, want %v", gotWarning, wantWarning) + } +} + func TestTypes_Compiler_Getters(t *testing.T) { // setup tests tests := []struct { @@ -37,6 +77,14 @@ func TestTypes_Compiler_Getters(t *testing.T) { if !reflect.DeepEqual(test.compiler.GetStarlarkExecLimit(), test.want.GetStarlarkExecLimit()) { t.Errorf("GetStarlarkExecLimit is %v, want %v", test.compiler.GetStarlarkExecLimit(), test.want.GetStarlarkExecLimit()) } + + if !reflect.DeepEqual(test.compiler.GetBlockedImages(), test.want.GetBlockedImages()) { + t.Errorf("GetBlockedImages is %v, want %v", test.compiler.GetBlockedImages(), test.want.GetBlockedImages()) + } + + if !reflect.DeepEqual(test.compiler.GetWarnImages(), test.want.GetWarnImages()) { + t.Errorf("GetWarnImages is %v, want %v", test.compiler.GetWarnImages(), test.want.GetWarnImages()) + } } } @@ -78,6 +126,18 @@ func TestTypes_Compiler_Setters(t *testing.T) { if !reflect.DeepEqual(test.compiler.GetStarlarkExecLimit(), test.want.GetStarlarkExecLimit()) { t.Errorf("SetStarlarkExecLimit is %v, want %v", test.compiler.GetStarlarkExecLimit(), test.want.GetStarlarkExecLimit()) } + + test.compiler.SetBlockedImages(test.want.GetBlockedImages()) + + if !reflect.DeepEqual(test.compiler.GetBlockedImages(), test.want.GetBlockedImages()) { + t.Errorf("SetBlockedImages is %v, want %v", test.compiler.GetBlockedImages(), test.want.GetBlockedImages()) + } + + test.compiler.SetWarnImages(test.want.GetWarnImages()) + + if !reflect.DeepEqual(test.compiler.GetWarnImages(), test.want.GetWarnImages()) { + t.Errorf("SetWarnImages is %v, want %v", test.compiler.GetWarnImages(), test.want.GetWarnImages()) + } } } @@ -89,10 +149,14 @@ func TestTypes_Compiler_String(t *testing.T) { CloneImage: %s, TemplateDepth: %d, StarlarkExecLimit: %d, + BlockedImages: %v, + WarnImages: %v, }`, cs.GetCloneImage(), cs.GetTemplateDepth(), cs.GetStarlarkExecLimit(), + cs.GetBlockedImages(), + cs.GetWarnImages(), ) // run test @@ -111,6 +175,12 @@ func testCompilerSettings() *Compiler { cs.SetCloneImage("target/vela-git-slim:latest") cs.SetTemplateDepth(1) cs.SetStarlarkExecLimit(100) + cs.SetBlockedImages([]ImageRestriction{ + {Image: new("docker.io/blocked/image:latest"), Reason: new("this image is blocked")}, + }) + cs.SetWarnImages([]ImageRestriction{ + {Image: new("docker.io/deprecated/image:latest"), Reason: new("this image is deprecated")}, + }) return cs } diff --git a/compiler/native/compile.go b/compiler/native/compile.go index 931d93776..205e10bc5 100644 --- a/compiler/native/compile.go +++ b/compiler/native/compile.go @@ -415,6 +415,16 @@ func (c *Client) compileSteps(ctx context.Context, p *yaml.Build, _pipeline *api return nil, _pipeline, err } + // check image restrictions (blocked → error, warned → warning) + imageWarnings, err := c.checkImageRestrictions(build) + if err != nil { + return nil, _pipeline, err + } + + if len(imageWarnings) > 0 { + _pipeline.SetWarnings(append(_pipeline.GetWarnings(), imageWarnings...)) + } + return build, _pipeline, nil } @@ -517,6 +527,16 @@ func (c *Client) compileStages(ctx context.Context, p *yaml.Build, _pipeline *ap return nil, _pipeline, err } + // check image restrictions (blocked → error, warned → warning) + imageWarnings, err := c.checkImageRestrictions(build) + if err != nil { + return nil, _pipeline, err + } + + if len(imageWarnings) > 0 { + _pipeline.SetWarnings(append(_pipeline.GetWarnings(), imageWarnings...)) + } + return build, _pipeline, nil } diff --git a/compiler/native/native.go b/compiler/native/native.go index 4952b1a29..97eb47561 100644 --- a/compiler/native/native.go +++ b/compiler/native/native.go @@ -109,6 +109,9 @@ func FromCLICommand(ctx context.Context, cmd *cli.Command) (*Client, error) { c.UsePrivateGithub = true } + c.SetBlockedImages(nil) + c.SetWarnImages(nil) + c.TemplateCache = make(map[string][]byte) return c, nil @@ -140,6 +143,8 @@ func (c *Client) Duplicate() compiler.Engine { cc.CloneImage = c.CloneImage cc.TemplateDepth = c.TemplateDepth cc.StarlarkExecLimit = c.StarlarkExecLimit + cc.BlockedImages = c.BlockedImages + cc.WarnImages = c.WarnImages cc.TemplateCache = make(map[string][]byte) return cc diff --git a/compiler/native/parse.go b/compiler/native/parse.go index 229172f77..75e4519e2 100644 --- a/compiler/native/parse.go +++ b/compiler/native/parse.go @@ -26,6 +26,8 @@ func (c *Client) ParseRaw(v any) (string, error) { return ParseReaderRaw(v) case string: // check if string is path to file + // + //nolint:gosec // ignore false positive _, err := os.Stat(v) if err == nil { // parse string as path to yaml configuration @@ -86,6 +88,8 @@ func (c *Client) Parse(v any, pipelineType string, template *yaml.Template) (*ya return ParseReader(v) case string: // check if string is path to file + // + //nolint:gosec // ignore false positive _, err := os.Stat(v) if err == nil { // parse string as path to yaml configuration @@ -140,6 +144,8 @@ func ParseFileRaw(f *os.File) (string, error) { // ParsePath converts a file path into a yaml configuration. func ParsePath(p string) (*yaml.Build, []byte, []string, error) { // open the file for reading + // + //nolint:gosec // ignore false positive f, err := os.Open(p) if err != nil { return nil, nil, nil, fmt.Errorf("unable to open yaml file %s: %w", p, err) @@ -153,6 +159,8 @@ func ParsePath(p string) (*yaml.Build, []byte, []string, error) { // ParsePathRaw converts a file path into a yaml configuration. func ParsePathRaw(p string) (string, error) { // open the file for reading + // + //nolint:gosec // ignore false positive f, err := os.Open(p) if err != nil { return "", fmt.Errorf("unable to open yaml file %s: %w", p, err) diff --git a/compiler/native/settings.go b/compiler/native/settings.go index 1cecab3e2..cf6db8484 100644 --- a/compiler/native/settings.go +++ b/compiler/native/settings.go @@ -17,5 +17,7 @@ func (c *Client) SetSettings(s *settings.Platform) { c.SetCloneImage(s.GetCloneImage()) c.SetTemplateDepth(s.GetTemplateDepth()) c.SetStarlarkExecLimit(s.GetStarlarkExecLimit()) + c.SetBlockedImages(s.GetBlockedImages()) + c.SetWarnImages(s.GetWarnImages()) } } diff --git a/compiler/native/validate.go b/compiler/native/validate.go index 3212f151c..1eaccdd0e 100644 --- a/compiler/native/validate.go +++ b/compiler/native/validate.go @@ -4,6 +4,7 @@ package native import ( "fmt" + "path/filepath" "slices" "github.com/hashicorp/go-multierror" @@ -11,6 +12,7 @@ import ( "github.com/go-vela/server/compiler/types/pipeline" "github.com/go-vela/server/compiler/types/yaml" "github.com/go-vela/server/constants" + "github.com/go-vela/server/internal/image" ) // ValidateYAML verifies the yaml configuration is valid. @@ -247,3 +249,79 @@ func validatePipelineContainers(s pipeline.ContainerSlice, reportCount, gitToken return nil } + +// checkImageRestrictions inspects every container in the compiled pipeline against +// the platform's blocked and warn image lists. Blocked images cause compilation to +// fail. Warned images produce non-fatal warning strings that are surfaced on the +// build's Pipeline tab. +func (c *Client) checkImageRestrictions(p *pipeline.Build) ([]string, error) { + var ( + result error + warnings []string + ) + + // collect all containers from the steps and services + containers := append(p.Steps, p.Services...) + + // collect all containers from the secrets + for _, s := range p.Secrets { + if !s.Origin.Empty() { + containers = append(containers, s.Origin) + } + } + + // collect all containers from the stages + for _, stage := range p.Stages { + containers = append(containers, stage.Steps...) + } + + for _, ctn := range containers { + // skip injected init and clone containers + if ctn.Name == constants.CloneName || ctn.Name == constants.InitName { + continue + } + + for _, restriction := range c.GetBlockedImages() { + if matchesImagePattern(restriction.GetImage(), ctn.Image) { + result = multierror.Append(result, + fmt.Errorf("image %s for container %s is blocked: %s", ctn.Image, ctn.Name, restriction.GetReason()), + ) + } + } + + for _, restriction := range c.GetWarnImages() { + if matchesImagePattern(restriction.GetImage(), ctn.Image) { + warnings = append(warnings, + fmt.Sprintf("image %s for container %s has warning: %s", ctn.Image, ctn.Name, restriction.GetReason()), + ) + } + } + } + + return warnings, result +} + +// matchesImagePattern reports whether the provided image matches the given pattern. +// Patterns support glob wildcards via filepath.Match (e.g. "index.docker.io/org/*"). +// Both the raw image and its normalized (fully-qualified) form are tested so that +// patterns can omit the registry prefix or tag. +func matchesImagePattern(pattern, img string) bool { + if pattern == "" || img == "" { + return false + } + + // direct match against the image as provided + if ok, err := filepath.Match(pattern, img); err == nil && ok { + return true + } + + // match against the normalized, fully-qualified image reference + normalized, err := image.ParseWithError(img) + if err == nil && normalized != img { + if ok, err := filepath.Match(pattern, normalized); err == nil && ok { + return true + } + } + + return false +} diff --git a/compiler/native/validate_test.go b/compiler/native/validate_test.go index f782d2e2b..e2e35bb68 100644 --- a/compiler/native/validate_test.go +++ b/compiler/native/validate_test.go @@ -7,6 +7,7 @@ import ( "fmt" "testing" + "github.com/go-vela/server/api/types/settings" "github.com/go-vela/server/compiler/types/pipeline" "github.com/go-vela/server/compiler/types/raw" "github.com/go-vela/server/compiler/types/yaml" @@ -812,3 +813,196 @@ func TestNative_Validate_Secrets_SecretOriginNameConflict(t *testing.T) { t.Errorf("Validate should have returned err") } } + +func TestNative_CheckImageRestrictions_BlockedImage(t *testing.T) { + compiler, err := FromCLICommand(context.Background(), testCommand(t, "http://foo.example.com")) + if err != nil { + t.Errorf("Unable to create new compiler: %v", err) + } + + compiler.Compiler = settings.Compiler{ + BlockedImages: &[]settings.ImageRestriction{ + {Image: new("docker.io/blocked/image:latest"), Reason: new("this image is not allowed")}, + }, + } + + p := &pipeline.Build{ + Steps: pipeline.ContainerSlice{ + {Name: "blocked-step", Image: "blocked/image:latest"}, + {Name: "allowed-step", Image: "alpine:latest"}, + }, + } + + warnings, err := compiler.checkImageRestrictions(p) + if err == nil { + t.Errorf("checkImageRestrictions should have returned err for blocked image") + } + + if len(warnings) != 0 { + t.Errorf("checkImageRestrictions should not have returned warnings, got: %v", warnings) + } +} + +func TestNative_CheckImageRestrictions_WarnImage(t *testing.T) { + compiler, err := FromCLICommand(context.Background(), testCommand(t, "http://foo.example.com")) + if err != nil { + t.Errorf("Unable to create new compiler: %v", err) + } + + compiler.Compiler = settings.Compiler{ + WarnImages: &[]settings.ImageRestriction{ + {Image: new("docker.io/deprecated/image:latest"), Reason: new("this image is deprecated")}, + }, + } + + p := &pipeline.Build{ + Steps: pipeline.ContainerSlice{ + {Name: "warn-step", Image: "deprecated/image:latest"}, + {Name: "fine-step", Image: "alpine:latest"}, + }, + } + + warnings, err := compiler.checkImageRestrictions(p) + if err != nil { + t.Errorf("checkImageRestrictions returned unexpected err: %v", err) + } + + if len(warnings) != 1 { + t.Errorf("checkImageRestrictions should have returned 1 warning, got: %d", len(warnings)) + } +} + +func TestNative_CheckImageRestrictions_WildcardPattern(t *testing.T) { + compiler, err := FromCLICommand(context.Background(), testCommand(t, "http://foo.example.com")) + if err != nil { + t.Errorf("Unable to create new compiler: %v", err) + } + + compiler.Compiler = settings.Compiler{ + BlockedImages: &[]settings.ImageRestriction{ + {Image: new("docker.io/blocked/*"), Reason: new("entire namespace is blocked")}, + }, + } + + p := &pipeline.Build{ + Steps: pipeline.ContainerSlice{ + {Name: "step-a", Image: "blocked/image-one:latest"}, + {Name: "step-b", Image: "blocked/image-two:v2"}, + {Name: "step-c", Image: "allowed/image:latest"}, + }, + } + + _, err = compiler.checkImageRestrictions(p) + if err == nil { + t.Errorf("checkImageRestrictions should have returned err for wildcard blocked images") + } +} + +func TestNative_CheckImageRestrictions_NoMatch(t *testing.T) { + compiler, err := FromCLICommand(context.Background(), testCommand(t, "http://foo.example.com")) + if err != nil { + t.Errorf("Unable to create new compiler: %v", err) + } + + compiler.Compiler = settings.Compiler{ + BlockedImages: &[]settings.ImageRestriction{ + {Image: new("docker.io/blocked/image:latest"), Reason: new("this image is not allowed")}, + }, + WarnImages: &[]settings.ImageRestriction{ + {Image: new("docker.io/deprecated/image:latest"), Reason: new("this image is deprecated")}, + }, + } + + p := &pipeline.Build{ + Steps: pipeline.ContainerSlice{ + {Name: "fine-step", Image: "alpine:latest"}, + }, + } + + warnings, err := compiler.checkImageRestrictions(p) + if err != nil { + t.Errorf("checkImageRestrictions returned unexpected err: %v", err) + } + + if len(warnings) != 0 { + t.Errorf("checkImageRestrictions should not have returned warnings, got: %v", warnings) + } +} + +func TestNative_CheckImageRestrictions_EmptyLists(t *testing.T) { + compiler, err := FromCLICommand(context.Background(), testCommand(t, "http://foo.example.com")) + if err != nil { + t.Errorf("Unable to create new compiler: %v", err) + } + + p := &pipeline.Build{ + Steps: pipeline.ContainerSlice{ + {Name: "step", Image: "alpine:latest"}, + }, + } + + warnings, err := compiler.checkImageRestrictions(p) + if err != nil { + t.Errorf("checkImageRestrictions returned unexpected err: %v", err) + } + + if len(warnings) != 0 { + t.Errorf("checkImageRestrictions should not have returned warnings, got: %v", warnings) + } +} + +func TestNative_MatchesImagePattern(t *testing.T) { + tests := []struct { + name string + pattern string + image string + want bool + }{ + { + name: "exact match normalized", + pattern: "docker.io/library/alpine:latest", + image: "alpine:latest", + want: true, + }, + { + name: "wildcard tag", + pattern: "docker.io/org/image:*", + image: "org/image:v1.2.3", + want: true, + }, + { + name: "wildcard org", + pattern: "docker.io/blocked/*", + image: "blocked/tool:latest", + want: true, + }, + { + name: "no match", + pattern: "docker.io/blocked/image:latest", + image: "allowed/image:latest", + want: false, + }, + { + name: "empty pattern", + pattern: "", + image: "alpine:latest", + want: false, + }, + { + name: "empty image", + pattern: "alpine:latest", + image: "", + want: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := matchesImagePattern(test.pattern, test.image) + + if got != test.want { + t.Errorf("matchesImagePattern(%q, %q) = %v, want %v", test.pattern, test.image, got, test.want) + } + }) + } +} diff --git a/database/settings/create_test.go b/database/settings/create_test.go index 696491ec0..76800c550 100644 --- a/database/settings/create_test.go +++ b/database/settings/create_test.go @@ -8,6 +8,8 @@ import ( "testing" "github.com/DATA-DOG/go-sqlmock" + + "github.com/go-vela/server/api/types/settings" ) func TestSettings_Engine_CreateSettings(t *testing.T) { @@ -17,6 +19,12 @@ func TestSettings_Engine_CreateSettings(t *testing.T) { _settings.SetCloneImage("target/vela-git-slim:latest") _settings.SetTemplateDepth(10) _settings.SetStarlarkExecLimit(100) + _settings.SetBlockedImages([]settings.ImageRestriction{ + {Image: new("docker.io/blocked/image:latest"), Reason: new("this image is blocked")}, + }) + _settings.SetWarnImages([]settings.ImageRestriction{ + {Image: new("docker.io/deprecated/image:latest"), Reason: new("this image is deprecated")}, + }) _settings.SetRoutes([]string{"vela"}) _settings.SetRepoRoleMap(map[string]string{"admin": "admin", "triage": "read"}) _settings.SetOrgRoleMap(map[string]string{"admin": "admin", "member": "read"}) @@ -41,7 +49,7 @@ func TestSettings_Engine_CreateSettings(t *testing.T) { // ensure the mock expects the query _mock.ExpectQuery(`INSERT INTO "settings" ("compiler","queue","scm","repo_allowlist","schedule_allowlist","max_dashboard_repos","queue_restart_limit","enable_repo_secrets","enable_org_secrets","enable_shared_secrets","created_at","updated_at","updated_by","id") VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) RETURNING "id"`). - WithArgs(`{"clone_image":{"String":"target/vela-git-slim:latest","Valid":true},"template_depth":{"Int64":10,"Valid":true},"starlark_exec_limit":{"Int64":100,"Valid":true}}`, + WithArgs(`{"clone_image":{"String":"target/vela-git-slim:latest","Valid":true},"template_depth":{"Int64":10,"Valid":true},"starlark_exec_limit":{"Int64":100,"Valid":true},"blocked_images":[{"image":"docker.io/blocked/image:latest","reason":"this image is blocked"}],"warn_images":[{"image":"docker.io/deprecated/image:latest","reason":"this image is deprecated"}]}`, `{"routes":["vela"]}`, `{"repo_role_map":{"admin":"admin","triage":"read"},"org_role_map":{"admin":"admin","member":"read"},"team_role_map":{"admin":"admin"}}`, `{"octocat/hello-world"}`, `{"*"}`, 10, 30, true, true, true, 1, 1, ``, 1). WillReturnRows(_rows) diff --git a/database/settings/table.go b/database/settings/table.go index d746bec20..e2c66d12d 100644 --- a/database/settings/table.go +++ b/database/settings/table.go @@ -17,7 +17,7 @@ settings ( id SERIAL PRIMARY KEY, compiler JSON DEFAULT NULL, queue JSON DEFAULT NULL, - scm JSON DEFAULT NULL, + scm JSON DEFAULT NULL, repo_allowlist VARCHAR(1000), schedule_allowlist VARCHAR(1000), max_dashboard_repos INTEGER, diff --git a/database/settings/update_test.go b/database/settings/update_test.go index 7c7fa23d3..61e4743a7 100644 --- a/database/settings/update_test.go +++ b/database/settings/update_test.go @@ -9,6 +9,7 @@ import ( "github.com/DATA-DOG/go-sqlmock" + "github.com/go-vela/server/api/types/settings" "github.com/go-vela/server/database/testutils" ) @@ -19,6 +20,12 @@ func TestSettings_Engine_UpdateSettings(t *testing.T) { _settings.SetCloneImage("target/vela-git-slim:latest") _settings.SetTemplateDepth(10) _settings.SetStarlarkExecLimit(100) + _settings.SetBlockedImages([]settings.ImageRestriction{ + {Image: new("docker.io/blocked/image:latest"), Reason: new("this image is blocked")}, + }) + _settings.SetWarnImages([]settings.ImageRestriction{ + {Image: new("docker.io/deprecated/image:latest"), Reason: new("this image is deprecated")}, + }) _settings.SetRoutes([]string{"vela", "large"}) _settings.SetRepoRoleMap(map[string]string{"admin": "admin", "triage": "read"}) _settings.SetOrgRoleMap(map[string]string{"admin": "admin", "member": "read"}) @@ -40,7 +47,7 @@ func TestSettings_Engine_UpdateSettings(t *testing.T) { // ensure the mock expects the query _mock.ExpectExec(`UPDATE "settings" SET "compiler"=$1,"queue"=$2,"scm"=$3,"repo_allowlist"=$4,"schedule_allowlist"=$5,"max_dashboard_repos"=$6,"queue_restart_limit"=$7,"enable_repo_secrets"=$8,"enable_org_secrets"=$9,"enable_shared_secrets"=$10,"created_at"=$11,"updated_at"=$12,"updated_by"=$13 WHERE "id" = $14`). - WithArgs(`{"clone_image":{"String":"target/vela-git-slim:latest","Valid":true},"template_depth":{"Int64":10,"Valid":true},"starlark_exec_limit":{"Int64":100,"Valid":true}}`, + WithArgs(`{"clone_image":{"String":"target/vela-git-slim:latest","Valid":true},"template_depth":{"Int64":10,"Valid":true},"starlark_exec_limit":{"Int64":100,"Valid":true},"blocked_images":[{"image":"docker.io/blocked/image:latest","reason":"this image is blocked"}],"warn_images":[{"image":"docker.io/deprecated/image:latest","reason":"this image is deprecated"}]}`, `{"routes":["vela","large"]}`, `{"repo_role_map":{"admin":"admin","triage":"read"},"org_role_map":{"admin":"admin","member":"read"},"team_role_map":{"admin":"admin"}}`, `{"octocat/hello-world"}`, `{"*"}`, 10, 30, true, true, true, 1, testutils.AnyArgument{}, "octocat", 1). WillReturnResult(sqlmock.NewResult(1, 1)) diff --git a/database/types/settings.go b/database/types/settings.go index 209b93735..3a2c091a2 100644 --- a/database/types/settings.go +++ b/database/types/settings.go @@ -44,9 +44,11 @@ type ( // Compiler is the database representation of compiler settings. Compiler struct { - CloneImage sql.NullString `json:"clone_image" sql:"clone_image"` - TemplateDepth sql.NullInt64 `json:"template_depth" sql:"template_depth"` - StarlarkExecLimit sql.NullInt64 `json:"starlark_exec_limit" sql:"starlark_exec_limit"` + CloneImage sql.NullString `json:"clone_image" sql:"clone_image"` + TemplateDepth sql.NullInt64 `json:"template_depth" sql:"template_depth"` + StarlarkExecLimit sql.NullInt64 `json:"starlark_exec_limit" sql:"starlark_exec_limit"` + BlockedImages ImageRestrictionJSON `json:"blocked_images" sql:"blocked_images"` + WarnImages ImageRestrictionJSON `json:"warn_images" sql:"warn_images"` } // Queue is the database representation of queue settings. @@ -60,8 +62,28 @@ type ( OrgRoleMap map[string]string `json:"org_role_map" sql:"org_role_map"` TeamRoleMap map[string]string `json:"team_role_map" sql:"team_role_map"` } + + ImageRestrictionJSON []settings.ImageRestriction ) +// Value - Implementation of valuer for database/sql for ImageRestrictionJSON. +func (i ImageRestrictionJSON) Value() (driver.Value, error) { + valueString, err := json.Marshal(i) + return string(valueString), err +} + +// Scan - Implement the database/sql scanner interface for ImageRestrictionJSON. +func (i *ImageRestrictionJSON) Scan(value any) error { + switch v := value.(type) { + case []byte: + return json.Unmarshal(v, &i) + case string: + return json.Unmarshal([]byte(v), &i) + default: + return fmt.Errorf("wrong type for repos: %T", v) + } +} + // Value - Implementation of valuer for database/sql for Compiler. func (r Compiler) Value() (driver.Value, error) { valueString, err := json.Marshal(r) @@ -178,6 +200,8 @@ func (ps *Platform) ToAPI() *settings.Platform { psAPI.SetCloneImage(ps.CloneImage.String) psAPI.SetTemplateDepth(int(ps.TemplateDepth.Int64)) psAPI.SetStarlarkExecLimit(ps.StarlarkExecLimit.Int64) + psAPI.SetBlockedImages(ps.BlockedImages) + psAPI.SetWarnImages(ps.WarnImages) psAPI.Queue = new(settings.Queue) psAPI.SetRoutes(ps.Routes) @@ -262,6 +286,8 @@ func SettingsFromAPI(s *settings.Platform) *Platform { CloneImage: sql.NullString{String: s.GetCloneImage(), Valid: true}, TemplateDepth: sql.NullInt64{Int64: int64(s.GetTemplateDepth()), Valid: true}, StarlarkExecLimit: sql.NullInt64{Int64: s.GetStarlarkExecLimit(), Valid: true}, + BlockedImages: s.GetBlockedImages(), + WarnImages: s.GetWarnImages(), }, Queue: Queue{ Routes: pq.StringArray(s.GetRoutes()), diff --git a/database/types/settings_test.go b/database/types/settings_test.go index eacc05397..a15ba77c2 100644 --- a/database/types/settings_test.go +++ b/database/types/settings_test.go @@ -66,6 +66,12 @@ func TestTypes_Platform_ToAPI(t *testing.T) { want.SetCloneImage("target/vela-git-slim:latest") want.SetTemplateDepth(10) want.SetStarlarkExecLimit(100) + want.SetBlockedImages([]api.ImageRestriction{ + {Image: new("docker.io/blocked/image:latest"), Reason: new("this image is blocked")}, + }) + want.SetWarnImages([]api.ImageRestriction{ + {Image: new("docker.io/deprecated/image:latest"), Reason: new("this image is deprecated")}, + }) want.Queue = new(api.Queue) want.SetRoutes([]string{"vela"}) @@ -210,6 +216,12 @@ func TestTypes_Platform_PlatformFromAPI(t *testing.T) { s.SetCloneImage("target/vela-git-slim:latest") s.SetTemplateDepth(10) s.SetStarlarkExecLimit(100) + s.SetBlockedImages([]api.ImageRestriction{ + {Image: new("docker.io/blocked/image:latest"), Reason: new("this image is blocked")}, + }) + s.SetWarnImages([]api.ImageRestriction{ + {Image: new("docker.io/deprecated/image:latest"), Reason: new("this image is deprecated")}, + }) s.Queue = new(api.Queue) s.SetRoutes([]string{"vela"}) @@ -246,6 +258,12 @@ func testPlatform() *Platform { CloneImage: sql.NullString{String: "target/vela-git-slim:latest", Valid: true}, TemplateDepth: sql.NullInt64{Int64: 10, Valid: true}, StarlarkExecLimit: sql.NullInt64{Int64: 100, Valid: true}, + BlockedImages: []api.ImageRestriction{ + {Image: new("docker.io/blocked/image:latest"), Reason: new("this image is blocked")}, + }, + WarnImages: []api.ImageRestriction{ + {Image: new("docker.io/deprecated/image:latest"), Reason: new("this image is deprecated")}, + }, }, Queue: Queue{ Routes: []string{"vela"}, diff --git a/router/middleware/compiler_test.go b/router/middleware/compiler_test.go index c3bcf09f5..76e172122 100644 --- a/router/middleware/compiler_test.go +++ b/router/middleware/compiler_test.go @@ -34,6 +34,18 @@ func TestMiddleware_CompilerNative(t *testing.T) { want, _ := native.FromCLICommand(context.Background(), c) want.SetCloneImage(wantCloneImage) + want.SetBlockedImages([]settings.ImageRestriction{ + { + Image: new("docker.io/blocked/image:latest"), + Reason: new("this image is blocked"), + }, + }) + want.SetWarnImages([]settings.ImageRestriction{ + { + Image: new("docker.io/deprecated/image:latest"), + Reason: new("this image is deprecated"), + }, + }) var got compiler.Engine @@ -48,9 +60,24 @@ func TestMiddleware_CompilerNative(t *testing.T) { engine.Use(func() gin.HandlerFunc { return func(c *gin.Context) { s := settings.Platform{ - Compiler: &settings.Compiler{}, + Compiler: &settings.Compiler{ + BlockedImages: &[]settings.ImageRestriction{ + { + Image: new("docker.io/blocked/image:latest"), + Reason: new("this image is blocked"), + }, + }, + WarnImages: &[]settings.ImageRestriction{ + { + Image: new("docker.io/deprecated/image:latest"), + Reason: new("this image is deprecated"), + }, + }, + }, } s.SetCloneImage(wantCloneImage) + s.SetBlockedImages(*want.BlockedImages) + s.SetWarnImages(*want.WarnImages) sMiddleware.ToContext(c, &s) diff --git a/scm/github/authentication.go b/scm/github/authentication.go index 7e8ac05de..8ab9a0d8c 100644 --- a/scm/github/authentication.go +++ b/scm/github/authentication.go @@ -43,6 +43,8 @@ func (c *Client) Login(_ context.Context, w http.ResponseWriter, r *http.Request } // pass through the redirect if it exists + // + //nolint:gosec // ignore false positive redirect := r.FormValue("redirect_uri") if len(redirect) > 0 { c.OAuth.RedirectURL = redirect @@ -60,18 +62,24 @@ func (c *Client) Authenticate(ctx context.Context, _ http.ResponseWriter, r *htt c.Logger.Trace("authenticating user") // get the OAuth code + // + //nolint:gosec // ignore false positive code := r.FormValue("code") if len(code) == 0 { return nil, nil } // verify the OAuth state + // + //nolint:gosec // ignore false positive state := r.FormValue("state") if state != oAuthState { return nil, fmt.Errorf("unexpected oauth state: want %s but got %s", oAuthState, state) } // pass through the redirect if it exists + // + //nolint:gosec // ignore false positive redirect := r.FormValue("redirect_uri") if len(redirect) > 0 { c.OAuth.RedirectURL = redirect