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/types/settings/compiler.go b/api/types/settings/compiler.go index 3a945c637..ef30cb1eb 100644 --- a/api/types/settings/compiler.go +++ b/api/types/settings/compiler.go @@ -4,14 +4,117 @@ 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 +} + 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"` } -// GetCloneImage returns the CloneImage field. +// 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 +} + +// 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 +197,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(), ) } diff --git a/api/types/settings/compiler_test.go b/api/types/settings/compiler_test.go index a479fc8be..3ad0b2bf6 100644 --- a/api/types/settings/compiler_test.go +++ b/api/types/settings/compiler_test.go @@ -37,6 +37,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 +86,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 +109,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 +135,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/settings.go b/compiler/native/settings.go index 1cecab3e2..127eb0258 100644 --- a/compiler/native/settings.go +++ b/compiler/native/settings.go @@ -17,5 +17,11 @@ func (c *Client) SetSettings(s *settings.Platform) { c.SetCloneImage(s.GetCloneImage()) c.SetTemplateDepth(s.GetTemplateDepth()) c.SetStarlarkExecLimit(s.GetStarlarkExecLimit()) + + // copy pointer fields directly to preserve nil vs empty-slice distinction + if s.Compiler != nil { + c.Compiler.BlockedImages = s.Compiler.BlockedImages + c.Compiler.WarnImages = s.Compiler.WarnImages + } } } diff --git a/compiler/native/validate.go b/compiler/native/validate.go index 3212f151c..1361ba5e7 100644 --- a/compiler/native/validate.go +++ b/compiler/native/validate.go @@ -4,13 +4,16 @@ package native import ( "fmt" + "path/filepath" "slices" "github.com/hashicorp/go-multierror" + "github.com/go-vela/server/api/types/settings" "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 +250,89 @@ 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: steps, services, and secret origins + containers := make(pipeline.ContainerSlice, 0, len(p.Steps)+len(p.Services)) + containers = append(containers, p.Steps...) + containers = append(containers, p.Services...) + + for _, s := range p.Secrets { + if !s.Origin.Empty() { + containers = append(containers, s.Origin) + } + } + + 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 %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 %s: %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 +} + +// imageRestrictionsFromSettings is a helper used in tests to build a Compiler +// settings value containing image restriction lists. +func imageRestrictionsFromSettings(blocked, warned []settings.ImageRestriction) settings.Compiler { + cs := settings.Compiler{} + cs.SetBlockedImages(blocked) + cs.SetWarnImages(warned) + + return cs +} diff --git a/compiler/native/validate_test.go b/compiler/native/validate_test.go index f782d2e2b..d0f7f05fc 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,199 @@ 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 = imageRestrictionsFromSettings( + []settings.ImageRestriction{ + {Image: new("docker.io/blocked/image:latest"), Reason: new("this image is not allowed")}, + }, + nil, + ) + + 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 = imageRestrictionsFromSettings( + nil, + []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 = imageRestrictionsFromSettings( + []settings.ImageRestriction{ + {Image: new("docker.io/blocked/*"), Reason: new("entire org is blocked")}, + }, + nil, + ) + + 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 = imageRestrictionsFromSettings( + []settings.ImageRestriction{ + {Image: new("docker.io/blocked/image:latest"), Reason: new("blocked")}, + }, + []settings.ImageRestriction{ + {Image: new("docker.io/deprecated/image:latest"), Reason: new("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/types/settings.go b/database/types/settings.go index 209b93735..3cb0b016c 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 []settings.ImageRestriction `json:"blocked_images,omitempty" sql:"blocked_images"` + WarnImages []settings.ImageRestriction `json:"warn_images,omitempty" sql:"warn_images"` } // Queue is the database representation of queue settings. @@ -179,6 +181,14 @@ func (ps *Platform) ToAPI() *settings.Platform { psAPI.SetTemplateDepth(int(ps.TemplateDepth.Int64)) psAPI.SetStarlarkExecLimit(ps.StarlarkExecLimit.Int64) + if len(ps.BlockedImages) > 0 { + psAPI.SetBlockedImages(ps.BlockedImages) + } + + if len(ps.WarnImages) > 0 { + psAPI.SetWarnImages(ps.WarnImages) + } + psAPI.Queue = new(settings.Queue) psAPI.SetRoutes(ps.Routes) @@ -262,6 +272,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"},