diff --git a/pkg/kubelet/images/image_pull_policies.go b/pkg/kubelet/images/image_pull_policies.go new file mode 100644 index 00000000000..fa85788ef9e --- /dev/null +++ b/pkg/kubelet/images/image_pull_policies.go @@ -0,0 +1,171 @@ +/* +Copyright 2025 The Kubernetes 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 images + +import ( + "fmt" + "strings" + + dockerref "github.com/distribution/reference" + + "k8s.io/apimachinery/pkg/util/sets" + kubeletconfiginternal "k8s.io/kubernetes/pkg/kubelet/apis/config" +) + +// ImagePullPolicyEnforcer defines a class of functions implementing a credential +// verification policies for image pulls. These function determines whether the +// implemented policy requires credential verification based on image name, local +// image presence and existence of records about previous image pulls. +// +// `image` is an image name from a Pod's container "image" field. +// `imagePresent` informs whether the `image` is present on the node. +// `imagePulledByKubelet` marks that ImagePulledRecord or ImagePullingIntent records +// for the `image` exist on the node, meaning it was pulled by the kubelet somewhere +// in the past. +type ImagePullPolicyEnforcer interface { + RequireCredentialVerificationForImage(image string, imagePulledByKubelet bool) bool +} + +// ImagePullPolicyEnforcerFunc is a function type that implements the ImagePullPolicyEnforcer interface +type ImagePullPolicyEnforcerFunc func(image string, imagePulledByKubelet bool) bool + +func (e ImagePullPolicyEnforcerFunc) RequireCredentialVerificationForImage(image string, imagePulledByKubelet bool) bool { + return e(image, imagePulledByKubelet) +} + +func NewImagePullCredentialVerificationPolicy(policy kubeletconfiginternal.ImagePullCredentialsVerificationPolicy, imageAllowList []string) (ImagePullPolicyEnforcer, error) { + switch policy { + case kubeletconfiginternal.NeverVerify: + return NeverVerifyImagePullPolicy(), nil + case kubeletconfiginternal.NeverVerifyPreloadedImages: + return NeverVerifyPreloadedPullPolicy(), nil + case kubeletconfiginternal.NeverVerifyAllowlistedImages: + return NewNeverVerifyAllowListedPullPolicy(imageAllowList) + case kubeletconfiginternal.AlwaysVerify: + return AlwaysVerifyImagePullPolicy(), nil + default: + return nil, fmt.Errorf("unknown image pull credential verification policy: %v", policy) + } +} + +func NeverVerifyImagePullPolicy() ImagePullPolicyEnforcerFunc { + return func(image string, imagePulledByKubelet bool) bool { + return false + } +} + +func NeverVerifyPreloadedPullPolicy() ImagePullPolicyEnforcerFunc { + return func(image string, imagePulledByKubelet bool) bool { + return imagePulledByKubelet + } +} + +func AlwaysVerifyImagePullPolicy() ImagePullPolicyEnforcerFunc { + return func(image string, imagePulledByKubelet bool) bool { + return true + } +} + +type NeverVerifyAllowlistedImages struct { + absoluteURLs sets.Set[string] + prefixes []string +} + +func NewNeverVerifyAllowListedPullPolicy(allowList []string) (*NeverVerifyAllowlistedImages, error) { + policy := &NeverVerifyAllowlistedImages{ + absoluteURLs: sets.New[string](), + } + for _, pattern := range allowList { + normalizedPattern, isWildcard, err := getAllowlistImagePattern(pattern) + if err != nil { + return nil, err + } + + if isWildcard { + policy.prefixes = append(policy.prefixes, normalizedPattern) + } else { + policy.absoluteURLs.Insert(normalizedPattern) + } + } + + return policy, nil +} + +func (p *NeverVerifyAllowlistedImages) RequireCredentialVerificationForImage(image string, imagePulledByKubelet bool) bool { + return !p.imageMatches(image) +} + +func (p *NeverVerifyAllowlistedImages) imageMatches(image string) bool { + if p.absoluteURLs.Has(image) { + return true + } + for _, prefix := range p.prefixes { + if strings.HasPrefix(image, prefix) { + return true + } + } + return false +} + +func ValidateAllowlistImagesPatterns(patterns []string) error { + for _, p := range patterns { + if _, _, err := getAllowlistImagePattern(p); err != nil { + return err + } + } + return nil +} + +func getAllowlistImagePattern(pattern string) (string, bool, error) { + if pattern != strings.TrimSpace(pattern) { + return "", false, fmt.Errorf("leading/trailing spaces are not allowed: %s", pattern) + } + + trimmedPattern := pattern + isWildcard := false + if strings.HasSuffix(pattern, "/*") { + isWildcard = true + trimmedPattern = strings.TrimSuffix(trimmedPattern, "*") + } + + if len(trimmedPattern) == 0 { + return "", false, fmt.Errorf("the supplied pattern is too short: %s", pattern) + } + + if strings.ContainsRune(trimmedPattern, '*') { + return "", false, fmt.Errorf("not a valid wildcard pattern, only patterns ending with '/*' are allowed: %s", pattern) + } + + if isWildcard { + if len(trimmedPattern) == 1 { + return "", false, fmt.Errorf("at least registry hostname is required") + } + } else { // not a wildcard + image, err := dockerref.ParseNormalizedNamed(trimmedPattern) + if err != nil { + return "", false, fmt.Errorf("failed to parse as an image name: %w", err) + } + + if trimmedPattern != image.Name() { // image.Name() returns the image name without tag/digest + return "", false, fmt.Errorf("neither tag nor digest is accepted in an image reference: %s", pattern) + } + + return trimmedPattern, false, nil + } + + return trimmedPattern, true, nil +} diff --git a/pkg/kubelet/images/image_pull_policies_test.go b/pkg/kubelet/images/image_pull_policies_test.go new file mode 100644 index 00000000000..718671777cf --- /dev/null +++ b/pkg/kubelet/images/image_pull_policies_test.go @@ -0,0 +1,188 @@ +/* +Copyright 2025 The Kubernetes 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 images + +import ( + "reflect" + "testing" +) + +func TestNeverVerifyPreloadedPullPolicy(t *testing.T) { + tests := []struct { + name string + imageRecordsExist bool + want bool + }{ + { + name: "there are no records about the image being pulled", + imageRecordsExist: false, + want: false, + }, + { + name: "there are records about the image being pulled", + imageRecordsExist: true, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := NeverVerifyPreloadedPullPolicy()("test-image", tt.imageRecordsExist); got != tt.want { + t.Errorf("NeverVerifyPreloadedPullPolicy() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestNewNeverVerifyAllowListedPullPolicy(t *testing.T) { + tests := []struct { + name string + imageRecordsExist bool + allowlist []string + expectedAbsolutes int + expectedWildcards int + want bool + wantErr bool + }{ + { + name: "there are no records about the image being pulled, not in allowlist", + imageRecordsExist: false, + want: true, + allowlist: []string{"test.io/test/image1", "test.io/test/image2", "test.io/test/image3"}, + expectedAbsolutes: 3, + }, + { + name: "there are records about the image being pulled, not in allowlist", + imageRecordsExist: true, + want: true, + allowlist: []string{"test.io/test/image1", "test.io/test/image3", "test.io/test/image2", "test.io/test/image3"}, + expectedAbsolutes: 3, + }, + { + name: "there are no records about the image being pulled, appears in allowlist", + imageRecordsExist: false, + want: false, + allowlist: []string{"test.io/test/image1", "test.io/test/image2", "test.io/test/test-image", "test.io/test/image3"}, + expectedAbsolutes: 4, + }, + { + name: "there are records about the image being pulled, appears in allowlist", + imageRecordsExist: true, + want: false, + allowlist: []string{"test.io/test/image1", "test.io/test/image2", "test.io/test/test-image", "test.io/test/image3"}, + expectedAbsolutes: 4, + }, + { + name: "invalid allowlist pattern - wildcard in the middle", + wantErr: true, + allowlist: []string{"image.repo/pokus*/imagename"}, + }, + { + name: "invalid allowlist pattern - trailing non-segment wildcard middle", + wantErr: true, + allowlist: []string{"image.repo/pokus*"}, + }, + { + name: "invalid allowlist pattern - wildcard path segment in the middle", + wantErr: true, + allowlist: []string{"image.repo/*/imagename"}, + }, + { + name: "invalid allowlist pattern - only wildcard segment", + wantErr: true, + allowlist: []string{"/*"}, + }, + { + name: "invalid allowlist pattern - ends with a '/'", + wantErr: true, + allowlist: []string{"image.repo/"}, + }, + { + name: "invalid allowlist pattern - empty", + wantErr: true, + allowlist: []string{""}, + }, + { + name: "invalid allowlist pattern - asterisk", + wantErr: true, + allowlist: []string{"*"}, + }, + { + name: "invalid allowlist pattern - image with a tag", + wantErr: true, + allowlist: []string{"test.io/test/image1:tagged"}, + }, + { + name: "invalid allowlist pattern - image with a digest", + wantErr: true, + allowlist: []string{"test.io/test/image1@sha256:38a8906435c4dd5f4258899d46621bfd8eea3ad6ff494ee3c2f17ef0321625bd"}, + }, + { + name: "invalid allowlist pattern - trailing whitespace", + wantErr: true, + allowlist: []string{"test.io/test/image1 "}, + }, + { + name: "there are no records about the image being pulled, not in allowlist - different repo wildcard", + imageRecordsExist: false, + want: true, + allowlist: []string{"test.io/test/image1", "test.io/test/image2", "different.repo/test/*"}, + expectedAbsolutes: 2, + expectedWildcards: 1, + }, + { + name: "there are no records about the image being pulled, not in allowlist - matches org wildcard", + imageRecordsExist: false, + want: false, + allowlist: []string{"test.io/test/image1", "test.io/test/image2", "test.io/test/*"}, + expectedAbsolutes: 2, + expectedWildcards: 1, + }, + { + name: "there are no records about the image being pulled, not in allowlist - matches repo wildcard", + imageRecordsExist: false, + want: false, + allowlist: []string{"test.io/test/image1", "test.io/test/image2", "test.io/*"}, + expectedAbsolutes: 2, + expectedWildcards: 1, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + policyEnforcer, err := NewNeverVerifyAllowListedPullPolicy(tt.allowlist) + if tt.wantErr != (err != nil) { + t.Fatalf("wanted error: %t, got: %v", tt.wantErr, err) + } + + if err != nil { + return + } + + if len(policyEnforcer.absoluteURLs) != tt.expectedAbsolutes { + t.Errorf("expected %d of absolute image URLs in the allowlist policy, got %d: %v", tt.expectedAbsolutes, len(policyEnforcer.absoluteURLs), policyEnforcer.absoluteURLs) + } + + if len(policyEnforcer.prefixes) != tt.expectedWildcards { + t.Errorf("expected %d of wildcard image URLs in the allowlist policy, got %d: %v", tt.expectedWildcards, len(policyEnforcer.prefixes), policyEnforcer.prefixes) + } + + got := policyEnforcer.RequireCredentialVerificationForImage("test.io/test/test-image", tt.imageRecordsExist) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("NewNeverVerifyAllowListedPullPolicy() = %v, want %v", got, tt.want) + } + }) + } +}