From ba2e3613e5746a671230fc4ae9e29ff21241a93f Mon Sep 17 00:00:00 2001 From: Terry Howe Date: Tue, 28 Oct 2025 11:39:39 -0600 Subject: [PATCH 1/3] feature: add policy.json support Signed-off-by: Terry Howe --- .../internal/configuration/evaluator.go | 126 +++ .../internal/configuration/example_test.go | 182 ++++ .../remote/internal/configuration/policy.go | 191 ++++ .../internal/configuration/policy_test.go | 885 ++++++++++++++++++ .../internal/configuration/requirement.go | 339 +++++++ .../configuration/requirement_test.go | 580 ++++++++++++ registry/remote/repository.go | 44 + registry/remote/repository_policy_test.go | 229 +++++ 8 files changed, 2576 insertions(+) create mode 100644 registry/remote/internal/configuration/evaluator.go create mode 100644 registry/remote/internal/configuration/example_test.go create mode 100644 registry/remote/internal/configuration/policy.go create mode 100644 registry/remote/internal/configuration/policy_test.go create mode 100644 registry/remote/internal/configuration/requirement.go create mode 100644 registry/remote/internal/configuration/requirement_test.go create mode 100644 registry/remote/repository_policy_test.go diff --git a/registry/remote/internal/configuration/evaluator.go b/registry/remote/internal/configuration/evaluator.go new file mode 100644 index 000000000..b0205a008 --- /dev/null +++ b/registry/remote/internal/configuration/evaluator.go @@ -0,0 +1,126 @@ +/* +Copyright The ORAS 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 configuration + +import ( + "context" + "fmt" +) + +// ImageReference represents a reference to an image +type ImageReference struct { + // Transport is the transport type (e.g., "docker") + Transport TransportName + // Scope is the scope within the transport (e.g., "docker.io/library/nginx") + Scope string + // Reference is the full reference (e.g., "docker.io/library/nginx:latest") + Reference string +} + +// Evaluator evaluates policy requirements against image references +type Evaluator struct { + policy *Policy +} + +// NewEvaluator creates a new policy evaluator +func NewEvaluator(policy *Policy) (*Evaluator, error) { + if policy == nil { + return nil, fmt.Errorf("policy cannot be nil") + } + + if err := policy.Validate(); err != nil { + return nil, fmt.Errorf("invalid policy: %w", err) + } + + return &Evaluator{ + policy: policy, + }, nil +} + +// IsImageAllowed determines if an image is allowed by the policy +func (e *Evaluator) IsImageAllowed(ctx context.Context, image ImageReference) (bool, error) { + reqs := e.policy.GetRequirementsForImage(image.Transport, image.Scope) + + if len(reqs) == 0 { + // No requirements means reject by default for safety + return false, fmt.Errorf("no policy requirements found for %s:%s", image.Transport, image.Scope) + } + + // All requirements must be satisfied + for _, req := range reqs { + allowed, err := e.evaluateRequirement(ctx, req, image) + if err != nil { + return false, fmt.Errorf("failed to evaluate requirement %s: %w", req.Type(), err) + } + if !allowed { + return false, nil + } + } + + return true, nil +} + +// evaluateRequirement evaluates a single policy requirement +func (e *Evaluator) evaluateRequirement(ctx context.Context, req PolicyRequirement, image ImageReference) (bool, error) { + switch r := req.(type) { + case *InsecureAcceptAnything: + return e.evaluateInsecureAcceptAnything(ctx, r, image) + case *Reject: + return e.evaluateReject(ctx, r, image) + case *PRSignedBy: + return e.evaluateSignedBy(ctx, r, image) + case *PRSigstoreSigned: + return e.evaluateSigstoreSigned(ctx, r, image) + default: + return false, fmt.Errorf("unknown requirement type: %T", req) + } +} + +// evaluateInsecureAcceptAnything always accepts the image +func (e *Evaluator) evaluateInsecureAcceptAnything(ctx context.Context, req *InsecureAcceptAnything, image ImageReference) (bool, error) { + return true, nil +} + +// evaluateReject always rejects the image +func (e *Evaluator) evaluateReject(ctx context.Context, req *Reject, image ImageReference) (bool, error) { + return false, nil +} + +// evaluateSignedBy evaluates a signedBy requirement +// Note: This is a placeholder implementation. Full signature verification +// would require integration with GPG/signing libraries. +func (e *Evaluator) evaluateSignedBy(ctx context.Context, req *PRSignedBy, image ImageReference) (bool, error) { + // TODO: Implement actual signature verification https://github.com/oras-project/oras-go/issues/1029 + return false, fmt.Errorf("signedBy verification not yet implemented") +} + +// evaluateSigstoreSigned evaluates a sigstoreSigned requirement +// Note: This is a placeholder implementation. Full signature verification +// would require integration with sigstore libraries. +func (e *Evaluator) evaluateSigstoreSigned(ctx context.Context, req *PRSigstoreSigned, image ImageReference) (bool, error) { + // TODO: Implement actual sigstore verification https://github.com/oras-project/oras-go/issues/1029 + return false, fmt.Errorf("sigstoreSigned verification not yet implemented") +} + +// ShouldAcceptImage is a convenience function that returns true if the image is allowed +func ShouldAcceptImage(ctx context.Context, policy *Policy, image ImageReference) (bool, error) { + evaluator, err := NewEvaluator(policy) + if err != nil { + return false, err + } + + return evaluator.IsImageAllowed(ctx, image) +} diff --git a/registry/remote/internal/configuration/example_test.go b/registry/remote/internal/configuration/example_test.go new file mode 100644 index 000000000..0e05b44c9 --- /dev/null +++ b/registry/remote/internal/configuration/example_test.go @@ -0,0 +1,182 @@ +/* +Copyright The ORAS 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 configuration_test + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + + "github.com/oras-project/oras-go/v3/registry/remote/internal/configuration" +) + +// ExamplePolicy_basic demonstrates creating a basic policy +func ExamplePolicy_basic() { + // Create a policy that rejects everything by default + p := &configuration.Policy{ + Default: configuration.PolicyRequirements{&configuration.Reject{}}, + } + + // Add a transport-specific policy for docker that accepts anything + p.Transports = map[configuration.TransportName]configuration.TransportScopes{ + configuration.TransportDocker: { + "": configuration.PolicyRequirements{&configuration.InsecureAcceptAnything{}}, + }, + } + + fmt.Println("Policy created with default reject and docker accept") + // Output: Policy created with default reject and docker accept +} + +// ExamplePolicy_signedBy demonstrates creating a policy with signature verification +func ExamplePolicy_signedBy() { + p := &configuration.Policy{ + Default: configuration.PolicyRequirements{&configuration.Reject{}}, + Transports: map[configuration.TransportName]configuration.TransportScopes{ + configuration.TransportDocker: { + "docker.io/myorg": configuration.PolicyRequirements{ + &configuration.PRSignedBy{ + KeyType: "GPGKeys", + KeyPath: "/path/to/trusted-key.gpg", + SignedIdentity: &configuration.SignedIdentity{ + Type: configuration.MatchRepository, + }, + }, + }, + }, + }, + } + _ = p + + fmt.Println("Policy requires GPG signatures for docker.io/myorg") + // Output: Policy requires GPG signatures for docker.io/myorg +} + +// ExampleLoadPolicy demonstrates loading a policy from a file +func ExampleLoadPolicy() { + // Create a temporary policy file + tmpDir := os.TempDir() + policyPath := filepath.Join(tmpDir, "example-policy.json") + + // Create and save a policy + p := &configuration.Policy{ + Default: configuration.PolicyRequirements{&configuration.Reject{}}, + Transports: map[configuration.TransportName]configuration.TransportScopes{ + configuration.TransportDocker: { + "": configuration.PolicyRequirements{&configuration.InsecureAcceptAnything{}}, + }, + }, + } + + if err := configuration.SavePolicy(p, policyPath); err != nil { + log.Fatalf("Failed to save policy: %v", err) + } + defer os.Remove(policyPath) + + // Load the policy + loaded, err := configuration.LoadPolicy(policyPath) + if err != nil { + log.Fatalf("Failed to load policy: %v", err) + } + + fmt.Printf("Loaded policy with %d default requirements\n", len(loaded.Default)) + // Output: Loaded policy with 1 default requirements +} + +// ExampleEvaluator_IsImageAllowed demonstrates evaluating a policy +func ExampleEvaluator_IsImageAllowed() { + // Create a permissive policy for testing + p := &configuration.Policy{ + Default: configuration.PolicyRequirements{&configuration.InsecureAcceptAnything{}}, + } + + // Create an evaluator + evaluator, err := configuration.NewEvaluator(p) + if err != nil { + log.Fatalf("Failed to create evaluator: %v", err) + } + + // Check if an image is allowed + image := configuration.ImageReference{ + Transport: configuration.TransportDocker, + Scope: "docker.io/library/nginx", + Reference: "docker.io/library/nginx:latest", + } + + allowed, err := evaluator.IsImageAllowed(context.Background(), image) + if err != nil { + log.Fatalf("Failed to evaluate policy: %v", err) + } + + fmt.Printf("Image allowed: %v\n", allowed) + // Output: Image allowed: true +} + +// ExamplePolicy_GetRequirementsForImage demonstrates getting requirements for a specific image +func ExamplePolicy_GetRequirementsForImage() { + p := &configuration.Policy{ + Default: configuration.PolicyRequirements{&configuration.Reject{}}, + Transports: map[configuration.TransportName]configuration.TransportScopes{ + configuration.TransportDocker: { + "": configuration.PolicyRequirements{&configuration.InsecureAcceptAnything{}}, + "docker.io/library/nginx": configuration.PolicyRequirements{&configuration.Reject{}}, + }, + }, + } + + // Get requirements for nginx specifically + nginxReqs := p.GetRequirementsForImage(configuration.TransportDocker, "docker.io/library/nginx") + fmt.Printf("Nginx requirements: %s\n", nginxReqs[0].Type()) + + // Get requirements for other docker images + otherReqs := p.GetRequirementsForImage(configuration.TransportDocker, "docker.io/library/alpine") + fmt.Printf("Other docker requirements: %s\n", otherReqs[0].Type()) + + // Output: + // Nginx requirements: reject + // Other docker requirements: insecureAcceptAnything +} + +// ExamplePolicy_sigstore demonstrates creating a sigstore-based policy +func ExamplePolicy_sigstore() { + p := &configuration.Policy{ + Default: configuration.PolicyRequirements{&configuration.Reject{}}, + Transports: map[configuration.TransportName]configuration.TransportScopes{ + configuration.TransportDocker: { + "docker.io/myorg": configuration.PolicyRequirements{ + &configuration.PRSigstoreSigned{ + KeyPath: "/path/to/cosign.pub", + Fulcio: &configuration.FulcioConfig{ + CAPath: "/path/to/fulcio-ca.pem", + OIDCIssuer: "https://oauth2.sigstore.dev/auth", + SubjectEmail: "user@example.com", + }, + RekorPublicKeyPath: "/path/to/rekor.pub", + SignedIdentity: &configuration.SignedIdentity{ + Type: configuration.MatchRepository, + }, + }, + }, + }, + }, + } + _ = p + + fmt.Println("Policy requires sigstore signatures for docker.io/myorg") + // Output: Policy requires sigstore signatures for docker.io/myorg +} diff --git a/registry/remote/internal/configuration/policy.go b/registry/remote/internal/configuration/policy.go new file mode 100644 index 000000000..05d50d529 --- /dev/null +++ b/registry/remote/internal/configuration/policy.go @@ -0,0 +1,191 @@ +/* +Copyright The ORAS 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 configuration implements support for containers-policy.json format +// for OCI image signature verification policies. +// +// Reference: https://man.archlinux.org/man/containers-policy.json.5.en +package configuration + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +const ( + // PolicyConfUserDir is the user-level configuration directory for policy.json + PolicyConfUserDir = ".config/containers" + // PolicyConfFileName is the name of the policy configuration file + PolicyConfFileName = "policy.json" + // PolicyConfSystemPath is the system-wide policy.json path + PolicyConfSystemPath = "/etc/containers/policy.json" +) + +// TransportName represents a supported transport type +type TransportName string + +const ( + // TransportDocker represents the docker transport + TransportDocker TransportName = "docker" + // TransportAtomic represents the atomic transport + TransportAtomic TransportName = "atomic" + // TransportContainersStorage represents the containers-storage transport + TransportContainersStorage TransportName = "containers-storage" + // TransportDir represents the dir transport + TransportDir TransportName = "dir" + // TransportDockerArchive represents the docker-archive transport + TransportDockerArchive TransportName = "docker-archive" + // TransportDockerDaemon represents the docker-daemon transport + TransportDockerDaemon TransportName = "docker-daemon" + // TransportOCI represents the oci transport + TransportOCI TransportName = "oci" + // TransportOCIArchive represents the oci-archive transport + TransportOCIArchive TransportName = "oci-archive" + // TransportSIF represents the sif transport + TransportSIF TransportName = "sif" + // TransportTarball represents the tarball transport + TransportTarball TransportName = "tarball" +) + +// Policy represents a containers-policy.json configuration +type Policy struct { + // Default is the default policy requirement + Default PolicyRequirements `json:"default"` + // Transports contains transport-specific policy scopes + Transports map[TransportName]TransportScopes `json:"transports,omitempty"` +} + +// TransportScopes represents scopes within a transport +type TransportScopes map[string]PolicyRequirements + +// PolicyRequirements is a list of policy requirements +type PolicyRequirements []PolicyRequirement + +// PolicyRequirement represents a single policy requirement +type PolicyRequirement interface { + // Type returns the type of requirement + Type() string + // Validate validates the requirement configuration + Validate() error +} + +// GetDefaultPolicyPath returns the default path to policy.json. +// It checks $HOME/.config/containers/policy.json first, then falls back to +// /etc/containers/policy.json. +func GetDefaultPolicyPath() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get user home directory: %w", err) + } + + // Try user-specific path first + userPath := filepath.Join(homeDir, PolicyConfUserDir, PolicyConfFileName) + if _, err := os.Stat(userPath); err == nil { + return userPath, nil + } + + // Fall back to system-wide path + return PolicyConfSystemPath, nil +} + +// LoadPolicy loads a policy from the specified file path +func LoadPolicy(path string) (*Policy, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read policy file %s: %w", path, err) + } + + var policy Policy + if err := json.Unmarshal(data, &policy); err != nil { + return nil, fmt.Errorf("failed to parse policy file %s: %w", path, err) + } + + return &policy, nil +} + +// LoadDefaultPolicy loads the policy from the default location +func LoadDefaultPolicy() (*Policy, error) { + path, err := GetDefaultPolicyPath() + if err != nil { + return nil, err + } + + return LoadPolicy(path) +} + +// SavePolicy saves a policy to the specified file path +func SavePolicy(policy *Policy, path string) error { + data, err := json.MarshalIndent(policy, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal policy: %w", err) + } + + // Ensure directory exists + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", dir, err) + } + + if err := os.WriteFile(path, data, 0644); err != nil { + return fmt.Errorf("failed to write policy file %s: %w", path, err) + } + + return nil +} + +// Validate validates the policy configuration +func (p *Policy) Validate() error { + // Validate default requirements + for _, req := range p.Default { + if err := req.Validate(); err != nil { + return fmt.Errorf("invalid default requirement: %w", err) + } + } + + // Validate transport-specific requirements + for transport, scopes := range p.Transports { + for scope, reqs := range scopes { + for _, req := range reqs { + if err := req.Validate(); err != nil { + return fmt.Errorf("invalid requirement for transport %s scope %s: %w", transport, scope, err) + } + } + } + } + + return nil +} + +// GetRequirementsForImage returns the policy requirements for a given transport and scope. +// It follows the precedence rules: specific scope > transport default > global default. +func (p *Policy) GetRequirementsForImage(transport TransportName, scope string) PolicyRequirements { + // Check for transport-specific scope + if transportScopes, ok := p.Transports[transport]; ok { + // Try exact scope match first + if reqs, ok := transportScopes[scope]; ok { + return reqs + } + + // Try transport default (empty scope) + if reqs, ok := transportScopes[""]; ok { + return reqs + } + } + + // Fall back to global default + return p.Default +} diff --git a/registry/remote/internal/configuration/policy_test.go b/registry/remote/internal/configuration/policy_test.go new file mode 100644 index 000000000..d1d409259 --- /dev/null +++ b/registry/remote/internal/configuration/policy_test.go @@ -0,0 +1,885 @@ +/* +Copyright The ORAS 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 configuration + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestPolicy_GetRequirementsForImage(t *testing.T) { + tests := []struct { + name string + policy *Policy + transport TransportName + scope string + wantType string + }{ + { + name: "global default", + policy: &Policy{ + Default: PolicyRequirements{&Reject{}}, + }, + transport: TransportDocker, + scope: "docker.io/library/nginx", + wantType: TypeReject, + }, + { + name: "transport default", + policy: &Policy{ + Default: PolicyRequirements{&Reject{}}, + Transports: map[TransportName]TransportScopes{ + TransportDocker: { + "": PolicyRequirements{&InsecureAcceptAnything{}}, + }, + }, + }, + transport: TransportDocker, + scope: "docker.io/library/nginx", + wantType: TypeInsecureAcceptAnything, + }, + { + name: "specific scope", + policy: &Policy{ + Default: PolicyRequirements{&Reject{}}, + Transports: map[TransportName]TransportScopes{ + TransportDocker: { + "": PolicyRequirements{&InsecureAcceptAnything{}}, + "docker.io/library/nginx": PolicyRequirements{&Reject{}}, + }, + }, + }, + transport: TransportDocker, + scope: "docker.io/library/nginx", + wantType: TypeReject, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reqs := tt.policy.GetRequirementsForImage(tt.transport, tt.scope) + if len(reqs) == 0 { + t.Fatal("expected requirements, got none") + } + if reqs[0].Type() != tt.wantType { + t.Errorf("got type %s, want %s", reqs[0].Type(), tt.wantType) + } + }) + } +} + +func TestPolicy_JSONMarshalUnmarshal(t *testing.T) { + policy := &Policy{ + Default: PolicyRequirements{&Reject{}}, + Transports: map[TransportName]TransportScopes{ + TransportDocker: { + "": PolicyRequirements{&InsecureAcceptAnything{}}, + "docker.io/library/nginx": PolicyRequirements{ + &PRSignedBy{ + KeyType: "GPGKeys", + KeyPath: "/path/to/key.gpg", + SignedIdentity: &SignedIdentity{ + Type: MatchExact, + }, + }, + }, + }, + }, + } + + // Marshal + data, err := json.MarshalIndent(policy, "", " ") + if err != nil { + t.Fatalf("failed to marshal policy: %v", err) + } + + // Unmarshal + var unmarshaled Policy + if err := json.Unmarshal(data, &unmarshaled); err != nil { + t.Fatalf("failed to unmarshal policy: %v", err) + } + + // Verify + if len(unmarshaled.Default) != 1 || unmarshaled.Default[0].Type() != TypeReject { + t.Error("default requirement not preserved") + } + + dockerScopes := unmarshaled.Transports[TransportDocker] + if len(dockerScopes[""]) != 1 || dockerScopes[""][0].Type() != TypeInsecureAcceptAnything { + t.Error("docker default requirement not preserved") + } + + nginxReqs := dockerScopes["docker.io/library/nginx"] + if len(nginxReqs) != 1 || nginxReqs[0].Type() != TypeSignedBy { + t.Error("nginx-specific requirement not preserved") + } +} + +func TestPolicy_SaveAndLoad(t *testing.T) { + tmpDir := t.TempDir() + policyPath := filepath.Join(tmpDir, "policy.json") + + original := &Policy{ + Default: PolicyRequirements{&Reject{}}, + Transports: map[TransportName]TransportScopes{ + TransportDocker: { + "": PolicyRequirements{&InsecureAcceptAnything{}}, + }, + }, + } + + // Save + if err := SavePolicy(original, policyPath); err != nil { + t.Fatalf("failed to save policy: %v", err) + } + + // Load + loaded, err := LoadPolicy(policyPath) + if err != nil { + t.Fatalf("failed to load policy: %v", err) + } + + // Verify + if len(loaded.Default) != 1 || loaded.Default[0].Type() != TypeReject { + t.Error("loaded policy default not correct") + } + + if len(loaded.Transports[TransportDocker][""]) != 1 { + t.Error("loaded policy docker transport not correct") + } +} + +func TestPolicy_Validate(t *testing.T) { + tests := []struct { + name string + policy *Policy + wantErr bool + }{ + { + name: "valid policy", + policy: &Policy{ + Default: PolicyRequirements{&Reject{}}, + }, + wantErr: false, + }, + { + name: "invalid signedBy requirement", + policy: &Policy{ + Default: PolicyRequirements{ + &PRSignedBy{ + // Missing KeyType + KeyPath: "/path/to/key.gpg", + }, + }, + }, + wantErr: true, + }, + { + name: "invalid signed identity", + policy: &Policy{ + Default: PolicyRequirements{ + &PRSignedBy{ + KeyType: "GPGKeys", + KeyPath: "/path/to/key.gpg", + SignedIdentity: &SignedIdentity{ + Type: ExactReference, + // Missing DockerReference + }, + }, + }, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.policy.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestEvaluator_IsImageAllowed(t *testing.T) { + tests := []struct { + name string + policy *Policy + image ImageReference + wantResult bool + wantErr bool + }{ + { + name: "insecure accept anything", + policy: &Policy{ + Default: PolicyRequirements{&InsecureAcceptAnything{}}, + }, + image: ImageReference{ + Transport: TransportDocker, + Scope: "docker.io/library/nginx", + Reference: "docker.io/library/nginx:latest", + }, + wantResult: true, + wantErr: false, + }, + { + name: "reject", + policy: &Policy{ + Default: PolicyRequirements{&Reject{}}, + }, + image: ImageReference{ + Transport: TransportDocker, + Scope: "docker.io/library/nginx", + Reference: "docker.io/library/nginx:latest", + }, + wantResult: false, + wantErr: false, + }, + { + name: "signedBy not implemented", + policy: &Policy{ + Default: PolicyRequirements{ + &PRSignedBy{ + KeyType: "GPGKeys", + KeyPath: "/path/to/key.gpg", + }, + }, + }, + image: ImageReference{ + Transport: TransportDocker, + Scope: "docker.io/library/nginx", + Reference: "docker.io/library/nginx:latest", + }, + wantResult: false, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + evaluator, err := NewEvaluator(tt.policy) + if err != nil { + t.Fatalf("failed to create evaluator: %v", err) + } + + result, err := evaluator.IsImageAllowed(context.Background(), tt.image) + if (err != nil) != tt.wantErr { + t.Errorf("IsImageAllowed() error = %v, wantErr %v", err, tt.wantErr) + return + } + if result != tt.wantResult { + t.Errorf("IsImageAllowed() = %v, want %v", result, tt.wantResult) + } + }) + } +} + +func TestRequirement_Validation(t *testing.T) { + tests := []struct { + name string + req PolicyRequirement + wantErr bool + }{ + { + name: "insecure accept anything valid", + req: &InsecureAcceptAnything{}, + wantErr: false, + }, + { + name: "reject valid", + req: &Reject{}, + wantErr: false, + }, + { + name: "signedBy valid", + req: &PRSignedBy{ + KeyType: "GPGKeys", + KeyPath: "/path/to/key.gpg", + }, + wantErr: false, + }, + { + name: "signedBy missing keyType", + req: &PRSignedBy{ + KeyPath: "/path/to/key.gpg", + }, + wantErr: true, + }, + { + name: "signedBy missing key source", + req: &PRSignedBy{ + KeyType: "GPGKeys", + }, + wantErr: true, + }, + { + name: "sigstoreSigned valid", + req: &PRSigstoreSigned{ + KeyPath: "/path/to/key.pub", + }, + wantErr: false, + }, + { + name: "sigstoreSigned missing verification method", + req: &PRSigstoreSigned{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.req.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestGetDefaultPolicyPath(t *testing.T) { + path, err := GetDefaultPolicyPath() + if err != nil { + t.Fatalf("GetDefaultPolicyPath() error = %v", err) + } + + // Should return either user path or system path + homeDir, _ := os.UserHomeDir() + userPath := filepath.Join(homeDir, PolicyConfUserDir, PolicyConfFileName) + systemPath := PolicyConfSystemPath + + if path != userPath && path != systemPath { + t.Errorf("GetDefaultPolicyPath() = %v, want %v or %v", path, userPath, systemPath) + } +} + +// Test LoadDefaultPolicy +func TestLoadDefaultPolicy(t *testing.T) { + // Create a temporary policy file in the home directory + homeDir, err := os.UserHomeDir() + if err != nil { + t.Skip("Cannot get home directory") + } + + userPolicyDir := filepath.Join(homeDir, PolicyConfUserDir) + userPolicyPath := filepath.Join(userPolicyDir, PolicyConfFileName) + + // Clean up any existing test policy + defer os.Remove(userPolicyPath) + + // Create policy directory + if err := os.MkdirAll(userPolicyDir, 0755); err != nil { + t.Fatalf("failed to create policy directory: %v", err) + } + + // Create a test policy + testPolicy := &Policy{ + Default: PolicyRequirements{&InsecureAcceptAnything{}}, + } + if err := SavePolicy(testPolicy, userPolicyPath); err != nil { + t.Fatalf("failed to save test policy: %v", err) + } + + // Test LoadDefaultPolicy + loaded, err := LoadDefaultPolicy() + if err != nil { + t.Errorf("LoadDefaultPolicy() error = %v", err) + } + if loaded == nil { + t.Error("LoadDefaultPolicy() returned nil policy") + } +} + +// Test ShouldAcceptImage convenience function +func TestShouldAcceptImage(t *testing.T) { + tests := []struct { + name string + policy *Policy + image ImageReference + wantResult bool + wantErr bool + }{ + { + name: "accept image", + policy: &Policy{ + Default: PolicyRequirements{&InsecureAcceptAnything{}}, + }, + image: ImageReference{ + Transport: TransportDocker, + Scope: "docker.io/library/nginx", + Reference: "docker.io/library/nginx:latest", + }, + wantResult: true, + wantErr: false, + }, + { + name: "reject image", + policy: &Policy{ + Default: PolicyRequirements{&Reject{}}, + }, + image: ImageReference{ + Transport: TransportDocker, + Scope: "docker.io/library/nginx", + Reference: "docker.io/library/nginx:latest", + }, + wantResult: false, + wantErr: false, + }, + { + name: "nil policy", + policy: nil, + image: ImageReference{ + Transport: TransportDocker, + Scope: "docker.io/library/nginx", + Reference: "docker.io/library/nginx:latest", + }, + wantResult: false, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ShouldAcceptImage(context.Background(), tt.policy, tt.image) + if (err != nil) != tt.wantErr { + t.Errorf("ShouldAcceptImage() error = %v, wantErr %v", err, tt.wantErr) + return + } + if result != tt.wantResult { + t.Errorf("ShouldAcceptImage() = %v, want %v", result, tt.wantResult) + } + }) + } +} + +// Test NewEvaluator with invalid policy +func TestNewEvaluator_Invalid(t *testing.T) { + tests := []struct { + name string + policy *Policy + wantErr bool + }{ + { + name: "nil policy", + policy: nil, + wantErr: true, + }, + { + name: "invalid policy - missing keyType", + policy: &Policy{ + Default: PolicyRequirements{ + &PRSignedBy{ + KeyPath: "/path/to/key", + }, + }, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := NewEvaluator(tt.policy) + if (err != nil) != tt.wantErr { + t.Errorf("NewEvaluator() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +// Test evaluateSigstoreSigned +func TestEvaluator_SigstoreSigned(t *testing.T) { + policy := &Policy{ + Default: PolicyRequirements{ + &PRSigstoreSigned{ + KeyPath: "/path/to/key.pub", + }, + }, + } + + evaluator, err := NewEvaluator(policy) + if err != nil { + t.Fatalf("failed to create evaluator: %v", err) + } + + image := ImageReference{ + Transport: TransportDocker, + Scope: "docker.io/library/nginx", + Reference: "docker.io/library/nginx:latest", + } + + // Should return error as sigstore verification is not implemented + allowed, err := evaluator.IsImageAllowed(context.Background(), image) + if err == nil { + t.Error("expected error for unimplemented sigstore verification") + } + if allowed { + t.Error("should not allow image when verification fails") + } +} + +// Test Policy validation with transport-specific requirements +func TestPolicy_ValidateTransportScopes(t *testing.T) { + tests := []struct { + name string + policy *Policy + wantErr bool + }{ + { + name: "valid transport scopes", + policy: &Policy{ + Default: PolicyRequirements{&Reject{}}, + Transports: map[TransportName]TransportScopes{ + TransportDocker: { + "docker.io": PolicyRequirements{&InsecureAcceptAnything{}}, + }, + }, + }, + wantErr: false, + }, + { + name: "invalid transport requirement", + policy: &Policy{ + Default: PolicyRequirements{&Reject{}}, + Transports: map[TransportName]TransportScopes{ + TransportDocker: { + "docker.io": PolicyRequirements{ + &PRSignedBy{ + // Missing KeyType + KeyPath: "/path/to/key", + }, + }, + }, + }, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.policy.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +// Test LoadPolicy with non-existent file +func TestLoadPolicy_NonExistent(t *testing.T) { + _, err := LoadPolicy("/nonexistent/path/policy.json") + if err == nil { + t.Error("LoadPolicy() should fail for non-existent file") + } +} + +// Test LoadPolicy with invalid JSON +func TestLoadPolicy_InvalidJSON(t *testing.T) { + tmpDir := t.TempDir() + policyPath := filepath.Join(tmpDir, "policy.json") + + // Write invalid JSON + if err := os.WriteFile(policyPath, []byte("invalid json {"), 0644); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + _, err := LoadPolicy(policyPath) + if err == nil { + t.Error("LoadPolicy() should fail for invalid JSON") + } +} + +// Test SavePolicy with invalid policy +func TestSavePolicy_ErrorCases(t *testing.T) { + tmpDir := t.TempDir() + + // Test with read-only directory + readOnlyDir := filepath.Join(tmpDir, "readonly") + if err := os.MkdirAll(readOnlyDir, 0555); err != nil { + t.Fatalf("failed to create read-only directory: %v", err) + } + defer os.Chmod(readOnlyDir, 0755) // Restore permissions for cleanup + + policy := &Policy{ + Default: PolicyRequirements{&Reject{}}, + } + + policyPath := filepath.Join(readOnlyDir, "policy.json") + err := SavePolicy(policy, policyPath) + if err == nil { + t.Error("SavePolicy() should fail for read-only directory") + } +} + +// Test GetDefaultPolicyPath when home directory doesn't exist +func TestGetDefaultPolicyPath_NoUserPolicy(t *testing.T) { + // This test ensures the function returns a path even if user policy doesn't exist + path, err := GetDefaultPolicyPath() + if err != nil { + t.Errorf("GetDefaultPolicyPath() error = %v", err) + } + if path == "" { + t.Error("GetDefaultPolicyPath() returned empty path") + } + // Should return either user path or system path + if path != PolicyConfSystemPath { + // If not system path, check it's a valid user path + if !filepath.IsAbs(path) { + t.Errorf("GetDefaultPolicyPath() returned non-absolute path: %s", path) + } + } +} + +// Test multiple requirements in a single policy +func TestPolicy_MultipleRequirements(t *testing.T) { + policy := &Policy{ + Default: PolicyRequirements{ + &InsecureAcceptAnything{}, + &InsecureAcceptAnything{}, // Multiple requirements - all must pass + }, + } + + evaluator, err := NewEvaluator(policy) + if err != nil { + t.Fatalf("failed to create evaluator: %v", err) + } + + image := ImageReference{ + Transport: TransportDocker, + Scope: "docker.io/library/nginx", + Reference: "docker.io/library/nginx:latest", + } + + allowed, err := evaluator.IsImageAllowed(context.Background(), image) + if err != nil { + t.Errorf("IsImageAllowed() error = %v", err) + } + if !allowed { + t.Error("IsImageAllowed() should allow image when all requirements pass") + } +} + +// Test no requirements in policy +func TestEvaluator_NoRequirements(t *testing.T) { + policy := &Policy{ + Default: PolicyRequirements{}, + } + + evaluator, err := NewEvaluator(policy) + if err != nil { + t.Fatalf("failed to create evaluator: %v", err) + } + + image := ImageReference{ + Transport: TransportDocker, + Scope: "docker.io/library/nginx", + Reference: "docker.io/library/nginx:latest", + } + + _, err = evaluator.IsImageAllowed(context.Background(), image) + if err == nil { + t.Error("IsImageAllowed() should fail when no requirements are defined") + } +} + +// Test all transport types +func TestPolicy_AllTransports(t *testing.T) { + transports := []TransportName{ + TransportDocker, + TransportAtomic, + TransportContainersStorage, + TransportDir, + TransportDockerArchive, + TransportDockerDaemon, + TransportOCI, + TransportOCIArchive, + TransportSIF, + TransportTarball, + } + + for _, transport := range transports { + t.Run(string(transport), func(t *testing.T) { + policy := &Policy{ + Default: PolicyRequirements{&Reject{}}, + Transports: map[TransportName]TransportScopes{ + transport: { + "": PolicyRequirements{&InsecureAcceptAnything{}}, + }, + }, + } + + if err := policy.Validate(); err != nil { + t.Errorf("policy validation failed for transport %s: %v", transport, err) + } + + reqs := policy.GetRequirementsForImage(transport, "test/scope") + if len(reqs) == 0 { + t.Errorf("no requirements found for transport %s", transport) + } + if reqs[0].Type() != TypeInsecureAcceptAnything { + t.Errorf("wrong requirement type for transport %s", transport) + } + }) + } +} + +// Test JSON round-trip with all requirement types +func TestPolicyRequirements_JSONRoundTrip(t *testing.T) { + tests := []struct { + name string + reqs PolicyRequirements + }{ + { + name: "insecure accept anything", + reqs: PolicyRequirements{&InsecureAcceptAnything{}}, + }, + { + name: "reject", + reqs: PolicyRequirements{&Reject{}}, + }, + { + name: "signedBy with all fields", + reqs: PolicyRequirements{ + &PRSignedBy{ + KeyType: "GPGKeys", + KeyPath: "/path/to/key.gpg", + KeyData: "key data", + KeyPaths: []string{"/path1", "/path2"}, + KeyDatas: []SignedByKeyData{ + {KeyPath: "/path/key1.gpg"}, + {KeyData: "inline key data"}, + }, + SignedIdentity: &SignedIdentity{ + Type: MatchExact, + }, + }, + }, + }, + { + name: "sigstoreSigned with fulcio", + reqs: PolicyRequirements{ + &PRSigstoreSigned{ + KeyPath: "/path/to/key.pub", + KeyData: []byte("key data"), + KeyDatas: []SigstoreKeyData{ + {PublicKeyFile: "/path/key.pub"}, + {PublicKeyData: []byte("inline key")}, + }, + Fulcio: &FulcioConfig{ + CAPath: "/path/ca.pem", + CAData: []byte("ca data"), + OIDCIssuer: "https://oauth.example.com", + SubjectEmail: "user@example.com", + }, + RekorPublicKeyPath: "/path/rekor.pub", + RekorPublicKeyData: []byte("rekor key"), + SignedIdentity: &SignedIdentity{ + Type: MatchRepository, + }, + }, + }, + }, + { + name: "mixed requirements", + reqs: PolicyRequirements{ + &InsecureAcceptAnything{}, + &Reject{}, + &PRSignedBy{ + KeyType: "GPGKeys", + KeyPath: "/key.gpg", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Marshal + data, err := json.Marshal(tt.reqs) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + + // Unmarshal + var unmarshaled PolicyRequirements + if err := json.Unmarshal(data, &unmarshaled); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + // Verify count + if len(unmarshaled) != len(tt.reqs) { + t.Errorf("requirement count mismatch: got %d, want %d", len(unmarshaled), len(tt.reqs)) + } + + // Verify types + for i := range tt.reqs { + if unmarshaled[i].Type() != tt.reqs[i].Type() { + t.Errorf("requirement[%d] type mismatch: got %s, want %s", + i, unmarshaled[i].Type(), tt.reqs[i].Type()) + } + } + }) + } +} + +// Test UnmarshalJSON with invalid data +func TestPolicyRequirements_UnmarshalJSON_Errors(t *testing.T) { + tests := []struct { + name string + data string + wantErr bool + }{ + { + name: "not an array", + data: `{"type": "reject"}`, + wantErr: true, + }, + { + name: "missing type field", + data: `[{"keyType": "GPGKeys"}]`, + wantErr: true, + }, + { + name: "unknown type", + data: `[{"type": "unknownType"}]`, + wantErr: true, + }, + { + name: "invalid signedBy", + data: `[{"type": "signedBy", "keyType": 123}]`, + wantErr: true, + }, + { + name: "invalid sigstoreSigned", + data: `[{"type": "sigstoreSigned", "keyPath": 123}]`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var reqs PolicyRequirements + err := json.Unmarshal([]byte(tt.data), &reqs) + if (err != nil) != tt.wantErr { + t.Errorf("UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/registry/remote/internal/configuration/requirement.go b/registry/remote/internal/configuration/requirement.go new file mode 100644 index 000000000..58309fa15 --- /dev/null +++ b/registry/remote/internal/configuration/requirement.go @@ -0,0 +1,339 @@ +/* +Copyright The ORAS 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 configuration + +import ( + "encoding/json" + "fmt" +) + +const ( + // TypeInsecureAcceptAnything accepts any image without verification + TypeInsecureAcceptAnything = "insecureAcceptAnything" + // TypeReject rejects all images + TypeReject = "reject" + // TypeSignedBy requires simple signing verification + TypeSignedBy = "signedBy" + // TypeSigstoreSigned requires sigstore signature verification + TypeSigstoreSigned = "sigstoreSigned" +) + +// InsecureAcceptAnything accepts any image without verification +type InsecureAcceptAnything struct{} + +// Type returns the requirement type +func (r *InsecureAcceptAnything) Type() string { + return TypeInsecureAcceptAnything +} + +// Validate validates the requirement +func (r *InsecureAcceptAnything) Validate() error { + return nil +} + +// Reject rejects all images +type Reject struct{} + +// Type returns the requirement type +func (r *Reject) Type() string { + return TypeReject +} + +// Validate validates the requirement +func (r *Reject) Validate() error { + return nil +} + +// IdentityMatchType represents the type of identity matching +type IdentityMatchType string + +const ( + // MatchExact matches the exact identity + MatchExact IdentityMatchType = "matchExact" + // MatchRepoDigestOrExact matches repository digest or exact + MatchRepoDigestOrExact IdentityMatchType = "matchRepoDigestOrExact" + // MatchRepository matches the repository + MatchRepository IdentityMatchType = "matchRepository" + // ExactReference matches exact reference + ExactReference IdentityMatchType = "exactReference" + // ExactRepository matches exact repository + ExactRepository IdentityMatchType = "exactRepository" + // RemapIdentity remaps identity + RemapIdentity IdentityMatchType = "remapIdentity" +) + +// SignedByKeyData represents GPG key data for signature verification +type SignedByKeyData struct { + // KeyPath is the path to the GPG key file + KeyPath string `json:"keyPath,omitempty"` + // KeyData is the inline GPG key data + KeyData string `json:"keyData,omitempty"` +} + +// PRSignedBy represents a simple signing policy requirement +type PRSignedBy struct { + // KeyType specifies the type of key (e.g., "GPGKeys") + KeyType string `json:"keyType"` + // KeyPath is the path to the key file + KeyPath string `json:"keyPath,omitempty"` + // KeyData is inline key data + KeyData string `json:"keyData,omitempty"` + // KeyPaths is a list of key paths (alternative to KeyPath) + KeyPaths []string `json:"keyPaths,omitempty"` + // KeyDatas is a list of inline key data (alternative to KeyData) + KeyDatas []SignedByKeyData `json:"keyDatas,omitempty"` + // SignedIdentity specifies the identity matching rules + SignedIdentity *SignedIdentity `json:"signedIdentity,omitempty"` +} + +// Type returns the requirement type +func (r *PRSignedBy) Type() string { + return TypeSignedBy +} + +// Validate validates the requirement +func (r *PRSignedBy) Validate() error { + if r.KeyType == "" { + return fmt.Errorf("keyType is required") + } + + // Validate that at least one key source is provided + hasKey := r.KeyPath != "" || r.KeyData != "" || len(r.KeyPaths) > 0 || len(r.KeyDatas) > 0 + if !hasKey { + return fmt.Errorf("at least one key source (keyPath, keyData, keyPaths, or keyDatas) must be specified") + } + + return validateSignedIdentity(r.SignedIdentity) +} + +// SignedIdentity represents identity matching rules +type SignedIdentity struct { + // Type is the identity match type + Type IdentityMatchType `json:"type"` + // DockerReference is used for certain match types + DockerReference string `json:"dockerReference,omitempty"` + // DockerRepository is used for certain match types + DockerRepository string `json:"dockerRepository,omitempty"` + // Prefix is used for remapIdentity + Prefix string `json:"prefix,omitempty"` + // SignedPrefix is used for remapIdentity + SignedPrefix string `json:"signedPrefix,omitempty"` +} + +// Validate validates the signed identity configuration +func (si *SignedIdentity) Validate() error { + switch si.Type { + case MatchExact, MatchRepoDigestOrExact: + // No additional fields required + return nil + case MatchRepository: + // No additional fields required + return nil + case ExactReference: + if si.DockerReference == "" { + return fmt.Errorf("dockerReference is required for exactReference type") + } + return nil + case ExactRepository: + if si.DockerRepository == "" { + return fmt.Errorf("dockerRepository is required for exactRepository type") + } + return nil + case RemapIdentity: + if si.Prefix == "" || si.SignedPrefix == "" { + return fmt.Errorf("both prefix and signedPrefix are required for remapIdentity type") + } + return nil + default: + return fmt.Errorf("unknown identity match type: %s", si.Type) + } +} + +// validateSignedIdentity validates a SignedIdentity if it's not nil +func validateSignedIdentity(si *SignedIdentity) error { + if si != nil { + if err := si.Validate(); err != nil { + return fmt.Errorf("invalid signedIdentity: %w", err) + } + } + return nil +} + +// SigstoreKeyData represents a sigstore public key +type SigstoreKeyData struct { + // PublicKeyFile is the path to the public key file + PublicKeyFile string `json:"publicKeyFile,omitempty"` + // PublicKeyData is inline public key data + PublicKeyData []byte `json:"publicKeyData,omitempty"` +} + +// PRSigstoreSigned represents a sigstore signature policy requirement +type PRSigstoreSigned struct { + // KeyPath is the path to the public key + KeyPath string `json:"keyPath,omitempty"` + // KeyData is inline public key data + KeyData []byte `json:"keyData,omitempty"` + // KeyDatas is a list of key data + KeyDatas []SigstoreKeyData `json:"keyDatas,omitempty"` + // Fulcio specifies Fulcio certificate verification + Fulcio *FulcioConfig `json:"fulcio,omitempty"` + // RekorPublicKeyPath is the path to the Rekor public key + RekorPublicKeyPath string `json:"rekorPublicKeyPath,omitempty"` + // RekorPublicKeyData is inline Rekor public key data + RekorPublicKeyData []byte `json:"rekorPublicKeyData,omitempty"` + // SignedIdentity specifies the identity matching rules + SignedIdentity *SignedIdentity `json:"signedIdentity,omitempty"` +} + +// Type returns the requirement type +func (r *PRSigstoreSigned) Type() string { + return TypeSigstoreSigned +} + +// Validate validates the requirement +func (r *PRSigstoreSigned) Validate() error { + // Validate that at least one verification method is provided + hasKey := r.KeyPath != "" || len(r.KeyData) > 0 || len(r.KeyDatas) > 0 || r.Fulcio != nil + if !hasKey { + return fmt.Errorf("at least one verification method must be specified") + } + + if r.Fulcio != nil { + if err := r.Fulcio.Validate(); err != nil { + return fmt.Errorf("invalid fulcio config: %w", err) + } + } + + return validateSignedIdentity(r.SignedIdentity) +} + +// FulcioConfig represents Fulcio certificate verification configuration +type FulcioConfig struct { + // CAPath is the path to the Fulcio CA certificate + CAPath string `json:"caPath,omitempty"` + // CAData is inline CA certificate data + CAData []byte `json:"caData,omitempty"` + // OIDCIssuer is the OIDC issuer URL + OIDCIssuer string `json:"oidcIssuer,omitempty"` + // SubjectEmail is the subject email to verify + SubjectEmail string `json:"subjectEmail,omitempty"` +} + +// Validate validates the Fulcio configuration +func (fc *FulcioConfig) Validate() error { + // At least CA path or data should be provided + if fc.CAPath == "" && len(fc.CAData) == 0 { + return fmt.Errorf("either caPath or caData must be specified") + } + + return nil +} + +// UnmarshalJSON implements custom JSON unmarshaling for PolicyRequirements +func (pr *PolicyRequirements) UnmarshalJSON(data []byte) error { + var raw []json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + *pr = make([]PolicyRequirement, 0, len(raw)) + + for i, rawReq := range raw { + var typeCheck struct { + Type string `json:"type"` + } + if err := json.Unmarshal(rawReq, &typeCheck); err != nil { + return fmt.Errorf("requirement %d: failed to determine type: %w", i, err) + } + + var req PolicyRequirement + switch typeCheck.Type { + case TypeInsecureAcceptAnything: + req = &InsecureAcceptAnything{} + case TypeReject: + req = &Reject{} + case TypeSignedBy: + var signedBy PRSignedBy + if err := json.Unmarshal(rawReq, &signedBy); err != nil { + return fmt.Errorf("requirement %d: failed to unmarshal signedBy: %w", i, err) + } + req = &signedBy + case TypeSigstoreSigned: + var sigstoreSigned PRSigstoreSigned + if err := json.Unmarshal(rawReq, &sigstoreSigned); err != nil { + return fmt.Errorf("requirement %d: failed to unmarshal sigstoreSigned: %w", i, err) + } + req = &sigstoreSigned + default: + return fmt.Errorf("requirement %d: unknown type %q", i, typeCheck.Type) + } + + *pr = append(*pr, req) + } + + return nil +} + +// marshalWithType marshals a value and adds a "type" field to the resulting JSON +func marshalWithType(typeName string, v interface{}) ([]byte, error) { + // First marshal the value + data, err := json.Marshal(v) + if err != nil { + return nil, err + } + + // Unmarshal to map to add type field + var m map[string]interface{} + if err := json.Unmarshal(data, &m); err != nil { + return nil, err + } + + // Add type field + m["type"] = typeName + + // Marshal back with type field included + return json.Marshal(m) +} + +// MarshalJSON implements custom JSON marshaling for PolicyRequirements +func (pr PolicyRequirements) MarshalJSON() ([]byte, error) { + raw := make([]json.RawMessage, 0, len(pr)) + + for _, req := range pr { + var data []byte + var err error + + switch r := req.(type) { + case *InsecureAcceptAnything: + data, err = json.Marshal(map[string]string{"type": TypeInsecureAcceptAnything}) + case *Reject: + data, err = json.Marshal(map[string]string{"type": TypeReject}) + case *PRSignedBy: + data, err = marshalWithType(TypeSignedBy, r) + case *PRSigstoreSigned: + data, err = marshalWithType(TypeSigstoreSigned, r) + default: + return nil, fmt.Errorf("unknown requirement type: %T", req) + } + + if err != nil { + return nil, err + } + raw = append(raw, data) + } + + return json.Marshal(raw) +} diff --git a/registry/remote/internal/configuration/requirement_test.go b/registry/remote/internal/configuration/requirement_test.go new file mode 100644 index 000000000..6017855ca --- /dev/null +++ b/registry/remote/internal/configuration/requirement_test.go @@ -0,0 +1,580 @@ +/* +Copyright The ORAS 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 configuration + +import ( + "testing" +) + +// Test SignedIdentity validation for all match types +func TestSignedIdentity_ValidateAllTypes(t *testing.T) { + tests := []struct { + name string + identity *SignedIdentity + wantErr bool + }{ + { + name: "matchExact valid", + identity: &SignedIdentity{ + Type: MatchExact, + }, + wantErr: false, + }, + { + name: "matchRepoDigestOrExact valid", + identity: &SignedIdentity{ + Type: MatchRepoDigestOrExact, + }, + wantErr: false, + }, + { + name: "matchRepository valid", + identity: &SignedIdentity{ + Type: MatchRepository, + }, + wantErr: false, + }, + { + name: "exactReference valid", + identity: &SignedIdentity{ + Type: ExactReference, + DockerReference: "docker.io/library/nginx:latest", + }, + wantErr: false, + }, + { + name: "exactReference missing dockerReference", + identity: &SignedIdentity{ + Type: ExactReference, + }, + wantErr: true, + }, + { + name: "exactRepository valid", + identity: &SignedIdentity{ + Type: ExactRepository, + DockerRepository: "docker.io/library/nginx", + }, + wantErr: false, + }, + { + name: "exactRepository missing dockerRepository", + identity: &SignedIdentity{ + Type: ExactRepository, + }, + wantErr: true, + }, + { + name: "remapIdentity valid", + identity: &SignedIdentity{ + Type: RemapIdentity, + Prefix: "docker.io/", + SignedPrefix: "quay.io/", + }, + wantErr: false, + }, + { + name: "remapIdentity missing prefix", + identity: &SignedIdentity{ + Type: RemapIdentity, + SignedPrefix: "quay.io/", + }, + wantErr: true, + }, + { + name: "remapIdentity missing signedPrefix", + identity: &SignedIdentity{ + Type: RemapIdentity, + Prefix: "docker.io/", + }, + wantErr: true, + }, + { + name: "unknown match type", + identity: &SignedIdentity{ + Type: "unknownType", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.identity.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +// Test PRSignedBy validation with various key source combinations +func TestPRSignedBy_ValidateKeySources(t *testing.T) { + tests := []struct { + name string + req *PRSignedBy + wantErr bool + }{ + { + name: "valid with keyPath", + req: &PRSignedBy{ + KeyType: "GPGKeys", + KeyPath: "/path/to/key.gpg", + }, + wantErr: false, + }, + { + name: "valid with keyData", + req: &PRSignedBy{ + KeyType: "GPGKeys", + KeyData: "inline key data", + }, + wantErr: false, + }, + { + name: "valid with keyPaths", + req: &PRSignedBy{ + KeyType: "GPGKeys", + KeyPaths: []string{"/path1.gpg", "/path2.gpg"}, + }, + wantErr: false, + }, + { + name: "valid with keyDatas", + req: &PRSignedBy{ + KeyType: "GPGKeys", + KeyDatas: []SignedByKeyData{ + {KeyPath: "/path/key.gpg"}, + }, + }, + wantErr: false, + }, + { + name: "valid with multiple key sources", + req: &PRSignedBy{ + KeyType: "GPGKeys", + KeyPath: "/path/key.gpg", + KeyData: "inline data", + KeyPaths: []string{"/another.gpg"}, + KeyDatas: []SignedByKeyData{{KeyData: "more data"}}, + }, + wantErr: false, + }, + { + name: "missing keyType", + req: &PRSignedBy{ + KeyPath: "/path/to/key.gpg", + }, + wantErr: true, + }, + { + name: "missing all key sources", + req: &PRSignedBy{ + KeyType: "GPGKeys", + }, + wantErr: true, + }, + { + name: "valid with signed identity", + req: &PRSignedBy{ + KeyType: "GPGKeys", + KeyPath: "/path/to/key.gpg", + SignedIdentity: &SignedIdentity{ + Type: MatchRepository, + }, + }, + wantErr: false, + }, + { + name: "invalid signed identity", + req: &PRSignedBy{ + KeyType: "GPGKeys", + KeyPath: "/path/to/key.gpg", + SignedIdentity: &SignedIdentity{ + Type: ExactReference, + // Missing DockerReference + }, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.req.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +// Test PRSigstoreSigned validation +func TestPRSigstoreSigned_Validate(t *testing.T) { + tests := []struct { + name string + req *PRSigstoreSigned + wantErr bool + }{ + { + name: "valid with keyPath", + req: &PRSigstoreSigned{ + KeyPath: "/path/to/key.pub", + }, + wantErr: false, + }, + { + name: "valid with keyData", + req: &PRSigstoreSigned{ + KeyData: []byte("inline key data"), + }, + wantErr: false, + }, + { + name: "valid with keyDatas", + req: &PRSigstoreSigned{ + KeyDatas: []SigstoreKeyData{ + {PublicKeyFile: "/path/key.pub"}, + }, + }, + wantErr: false, + }, + { + name: "valid with fulcio", + req: &PRSigstoreSigned{ + Fulcio: &FulcioConfig{ + CAPath: "/path/ca.pem", + }, + }, + wantErr: false, + }, + { + name: "missing verification method", + req: &PRSigstoreSigned{ + RekorPublicKeyPath: "/path/rekor.pub", + }, + wantErr: true, + }, + { + name: "invalid fulcio config", + req: &PRSigstoreSigned{ + Fulcio: &FulcioConfig{ + // Missing both CAPath and CAData + OIDCIssuer: "https://oauth.example.com", + }, + }, + wantErr: true, + }, + { + name: "invalid signed identity", + req: &PRSigstoreSigned{ + KeyPath: "/path/key.pub", + SignedIdentity: &SignedIdentity{ + Type: ExactRepository, + // Missing DockerRepository + }, + }, + wantErr: true, + }, + { + name: "valid with all optional fields", + req: &PRSigstoreSigned{ + KeyPath: "/path/key.pub", + KeyData: []byte("key data"), + KeyDatas: []SigstoreKeyData{{PublicKeyFile: "/key.pub"}}, + RekorPublicKeyPath: "/path/rekor.pub", + RekorPublicKeyData: []byte("rekor key"), + SignedIdentity: &SignedIdentity{ + Type: MatchExact, + }, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.req.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +// Test PRSigstoreSigned Type method +func TestPRSigstoreSigned_Type(t *testing.T) { + req := &PRSigstoreSigned{ + KeyPath: "/path/key.pub", + } + if req.Type() != TypeSigstoreSigned { + t.Errorf("Type() = %v, want %v", req.Type(), TypeSigstoreSigned) + } +} + +// Test FulcioConfig validation +func TestFulcioConfig_Validate(t *testing.T) { + tests := []struct { + name string + config *FulcioConfig + wantErr bool + }{ + { + name: "valid with CAPath", + config: &FulcioConfig{ + CAPath: "/path/ca.pem", + }, + wantErr: false, + }, + { + name: "valid with CAData", + config: &FulcioConfig{ + CAData: []byte("ca certificate data"), + }, + wantErr: false, + }, + { + name: "valid with both CAPath and CAData", + config: &FulcioConfig{ + CAPath: "/path/ca.pem", + CAData: []byte("ca data"), + }, + wantErr: false, + }, + { + name: "valid with all fields", + config: &FulcioConfig{ + CAPath: "/path/ca.pem", + CAData: []byte("ca data"), + OIDCIssuer: "https://oauth.example.com", + SubjectEmail: "user@example.com", + }, + wantErr: false, + }, + { + name: "missing both CAPath and CAData", + config: &FulcioConfig{}, + wantErr: true, + }, + { + name: "missing CA with other fields", + config: &FulcioConfig{ + OIDCIssuer: "https://oauth.example.com", + SubjectEmail: "user@example.com", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +// Test all IdentityMatchType constants +func TestIdentityMatchType_Constants(t *testing.T) { + types := []IdentityMatchType{ + MatchExact, + MatchRepoDigestOrExact, + MatchRepository, + ExactReference, + ExactRepository, + RemapIdentity, + } + + for _, matchType := range types { + t.Run(string(matchType), func(t *testing.T) { + identity := &SignedIdentity{Type: matchType} + + // Add required fields based on type + switch matchType { + case ExactReference: + identity.DockerReference = "docker.io/library/nginx:latest" + case ExactRepository: + identity.DockerRepository = "docker.io/library/nginx" + case RemapIdentity: + identity.Prefix = "docker.io/" + identity.SignedPrefix = "quay.io/" + } + + err := identity.Validate() + if err != nil { + t.Errorf("Validate() failed for valid %s: %v", matchType, err) + } + }) + } +} + +// Test requirement type constants +func TestRequirementType_Constants(t *testing.T) { + tests := []struct { + req PolicyRequirement + wantType string + }{ + {&InsecureAcceptAnything{}, TypeInsecureAcceptAnything}, + {&Reject{}, TypeReject}, + {&PRSignedBy{KeyType: "GPGKeys", KeyPath: "/key"}, TypeSignedBy}, + {&PRSigstoreSigned{KeyPath: "/key.pub"}, TypeSigstoreSigned}, + } + + for _, tt := range tests { + t.Run(tt.wantType, func(t *testing.T) { + if tt.req.Type() != tt.wantType { + t.Errorf("Type() = %v, want %v", tt.req.Type(), tt.wantType) + } + }) + } +} + +// Test KeyData structures +func TestSignedByKeyData_Structure(t *testing.T) { + keyData := SignedByKeyData{ + KeyPath: "/path/to/key.gpg", + KeyData: "inline key data", + } + + if keyData.KeyPath != "/path/to/key.gpg" { + t.Errorf("KeyPath not preserved") + } + if keyData.KeyData != "inline key data" { + t.Errorf("KeyData not preserved") + } +} + +func TestSigstoreKeyData_Structure(t *testing.T) { + keyData := SigstoreKeyData{ + PublicKeyFile: "/path/to/key.pub", + PublicKeyData: []byte("inline key data"), + } + + if keyData.PublicKeyFile != "/path/to/key.pub" { + t.Errorf("PublicKeyFile not preserved") + } + if string(keyData.PublicKeyData) != "inline key data" { + t.Errorf("PublicKeyData not preserved") + } +} + +// Test edge case: empty string fields +func TestSignedIdentity_EmptyFields(t *testing.T) { + tests := []struct { + name string + identity *SignedIdentity + wantErr bool + }{ + { + name: "exactReference with empty dockerReference", + identity: &SignedIdentity{ + Type: ExactReference, + DockerReference: "", + }, + wantErr: true, + }, + { + name: "exactRepository with empty dockerRepository", + identity: &SignedIdentity{ + Type: ExactRepository, + DockerRepository: "", + }, + wantErr: true, + }, + { + name: "remapIdentity with empty prefix", + identity: &SignedIdentity{ + Type: RemapIdentity, + Prefix: "", + SignedPrefix: "quay.io/", + }, + wantErr: true, + }, + { + name: "remapIdentity with empty signedPrefix", + identity: &SignedIdentity{ + Type: RemapIdentity, + Prefix: "docker.io/", + SignedPrefix: "", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.identity.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +// Test complex PRSignedBy with SignedByKeyData variations +func TestPRSignedBy_KeyDatasVariations(t *testing.T) { + tests := []struct { + name string + keyData []SignedByKeyData + wantErr bool + }{ + { + name: "keyData with path", + keyData: []SignedByKeyData{ + {KeyPath: "/path/key1.gpg"}, + }, + wantErr: false, + }, + { + name: "keyData with data", + keyData: []SignedByKeyData{ + {KeyData: "inline key"}, + }, + wantErr: false, + }, + { + name: "keyData with both", + keyData: []SignedByKeyData{ + {KeyPath: "/path/key.gpg", KeyData: "inline key"}, + }, + wantErr: false, + }, + { + name: "multiple keyDatas", + keyData: []SignedByKeyData{ + {KeyPath: "/path/key1.gpg"}, + {KeyData: "inline key"}, + {KeyPath: "/path/key2.gpg", KeyData: "more data"}, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := &PRSignedBy{ + KeyType: "GPGKeys", + KeyDatas: tt.keyData, + } + err := req.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/registry/remote/repository.go b/registry/remote/repository.go index 21cdb7224..422853cce 100644 --- a/registry/remote/repository.go +++ b/registry/remote/repository.go @@ -43,6 +43,7 @@ import ( "github.com/oras-project/oras-go/v3/registry" "github.com/oras-project/oras-go/v3/registry/remote/auth" "github.com/oras-project/oras-go/v3/registry/remote/errcode" + "github.com/oras-project/oras-go/v3/registry/remote/internal/configuration" "github.com/oras-project/oras-go/v3/registry/remote/internal/errutil" ) @@ -151,6 +152,11 @@ type Repository struct { // - https://www.rfc-editor.org/rfc/rfc7234#section-5.5 HandleWarning func(warning Warning) + // Policy is an optional policy evaluator for allow/deny decisions. + // If nil, no policy enforcement is performed. + // Reference: https://man.archlinux.org/man/containers-policy.json.5.en + Policy *configuration.Evaluator + // NOTE: Must keep fields in sync with clone(). // referrersState represents that if the repository supports Referrers API. @@ -207,6 +213,7 @@ func (r *Repository) clone() *Repository { MaxMetadataBytes: r.MaxMetadataBytes, SkipReferrersGC: r.SkipReferrersGC, HandleWarning: r.HandleWarning, + Policy: r.Policy, } } @@ -277,13 +284,47 @@ func (r *Repository) blobStore(desc ocispec.Descriptor) registry.BlobStore { return r.Blobs() } +// checkPolicy validates the repository access against the configured policy. +// If no policy is configured (Policy is nil), this is a no-op. +func (r *Repository) checkPolicy(ctx context.Context, reference string) error { + if r.Policy == nil { + return nil + } + + ref := r.Reference.String() + if reference != "" { + ref = reference + } + + imageRef := configuration.ImageReference{ + Transport: configuration.TransportDocker, + Scope: r.Reference.Repository, + Reference: ref, + } + + allowed, err := r.Policy.IsImageAllowed(ctx, imageRef) + if err != nil { + return fmt.Errorf("policy check failed: %w", err) + } + if !allowed { + return fmt.Errorf("access denied by policy for %s", ref) + } + return nil +} + // Fetch fetches the content identified by the descriptor. func (r *Repository) Fetch(ctx context.Context, target ocispec.Descriptor) (io.ReadCloser, error) { + if err := r.checkPolicy(ctx, ""); err != nil { + return nil, err + } return r.blobStore(target).Fetch(ctx, target) } // Push pushes the content, matching the expected descriptor. func (r *Repository) Push(ctx context.Context, expected ocispec.Descriptor, content io.Reader) error { + if err := r.checkPolicy(ctx, ""); err != nil { + return err + } return r.blobStore(expected).Push(ctx, expected, content) } @@ -324,6 +365,9 @@ func (r *Repository) Manifests() registry.ManifestStore { // Resolve resolves a reference to a manifest descriptor. // See also `ManifestMediaTypes`. func (r *Repository) Resolve(ctx context.Context, reference string) (ocispec.Descriptor, error) { + if err := r.checkPolicy(ctx, reference); err != nil { + return ocispec.Descriptor{}, err + } return r.Manifests().Resolve(ctx, reference) } diff --git a/registry/remote/repository_policy_test.go b/registry/remote/repository_policy_test.go new file mode 100644 index 000000000..6ceac86a4 --- /dev/null +++ b/registry/remote/repository_policy_test.go @@ -0,0 +1,229 @@ +/* +Copyright The ORAS 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 remote + +import ( + "context" + "io" + "strings" + "testing" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/oras-project/oras-go/v3/registry" + "github.com/oras-project/oras-go/v3/registry/remote/internal/configuration" +) + +var testReference = registry.Reference{ + Registry: "localhost:5000", + Repository: "test/repo", +} + +func TestRepository_PolicyEnforcement(t *testing.T) { + repo := &Repository{ + Reference: testReference, + } + + // Test without policy - should work + t.Run("no policy", func(t *testing.T) { + err := repo.checkPolicy(context.Background(), "") + if err != nil { + t.Errorf("checkPolicy() without policy should not error, got: %v", err) + } + }) + + // Test with reject policy + t.Run("reject policy", func(t *testing.T) { + pol := &configuration.Policy{ + Default: configuration.PolicyRequirements{&configuration.Reject{}}, + } + evaluator, err := configuration.NewEvaluator(pol) + if err != nil { + t.Fatalf("failed to create evaluator: %v", err) + } + repo.Policy = evaluator + + err = repo.checkPolicy(context.Background(), "") + if err == nil { + t.Error("checkPolicy() with reject policy should error, got nil") + } + if !strings.Contains(err.Error(), "access denied") { + t.Errorf("error should mention access denied, got: %v", err) + } + }) + + // Test with accept policy + t.Run("accept policy", func(t *testing.T) { + pol := &configuration.Policy{ + Default: configuration.PolicyRequirements{&configuration.InsecureAcceptAnything{}}, + } + evaluator, err := configuration.NewEvaluator(pol) + if err != nil { + t.Fatalf("failed to create evaluator: %v", err) + } + repo.Policy = evaluator + + err = repo.checkPolicy(context.Background(), "") + if err != nil { + t.Errorf("checkPolicy() with accept policy should not error, got: %v", err) + } + }) +} + +func TestRepository_Clone_Policy(t *testing.T) { + pol := &configuration.Policy{ + Default: configuration.PolicyRequirements{&configuration.InsecureAcceptAnything{}}, + } + evaluator, err := configuration.NewEvaluator(pol) + if err != nil { + t.Fatalf("failed to create evaluator: %v", err) + } + + original := &Repository{ + Reference: testReference, + Policy: evaluator, + } + + cloned := original.clone() + + if cloned.Policy != original.Policy { + t.Error("cloned repository should have the same policy evaluator") + } +} + +func TestRepository_Fetch_PolicyCheck(t *testing.T) { + // This test verifies that Fetch calls checkPolicy + // We use a reject policy to ensure Fetch fails before attempting network calls + pol := &configuration.Policy{ + Default: configuration.PolicyRequirements{&configuration.Reject{}}, + } + evaluator, err := configuration.NewEvaluator(pol) + if err != nil { + t.Fatalf("failed to create evaluator: %v", err) + } + + repo := &Repository{ + Reference: testReference, + Policy: evaluator, + } + + desc := ocispec.Descriptor{ + MediaType: ocispec.MediaTypeImageManifest, + Digest: "sha256:0000000000000000000000000000000000000000000000000000000000000000", + Size: 1234, + } + + _, err = repo.Fetch(context.Background(), desc) + if err == nil { + t.Error("Fetch() should fail due to reject policy") + } + if !strings.Contains(err.Error(), "access denied") { + t.Errorf("Fetch() error should mention access denied, got: %v", err) + } +} + +func TestRepository_Push_PolicyCheck(t *testing.T) { + // This test verifies that Push calls checkPolicy + pol := &configuration.Policy{ + Default: configuration.PolicyRequirements{&configuration.Reject{}}, + } + evaluator, err := configuration.NewEvaluator(pol) + if err != nil { + t.Fatalf("failed to create evaluator: %v", err) + } + + repo := &Repository{ + Reference: testReference, + Policy: evaluator, + } + + desc := ocispec.Descriptor{ + MediaType: ocispec.MediaTypeImageManifest, + Digest: "sha256:0000000000000000000000000000000000000000000000000000000000000000", + Size: 1234, + } + + content := strings.NewReader("test content") + + err = repo.Push(context.Background(), desc, content) + if err == nil { + t.Error("Push() should fail due to reject policy") + } + if !strings.Contains(err.Error(), "access denied") { + t.Errorf("Push() error should mention access denied, got: %v", err) + } +} + +func TestRepository_Resolve_PolicyCheck(t *testing.T) { + // This test verifies that Resolve calls checkPolicy + pol := &configuration.Policy{ + Default: configuration.PolicyRequirements{&configuration.Reject{}}, + } + evaluator, err := configuration.NewEvaluator(pol) + if err != nil { + t.Fatalf("failed to create evaluator: %v", err) + } + + repo := &Repository{ + Reference: testReference, + Policy: evaluator, + } + + _, err = repo.Resolve(context.Background(), "latest") + if err == nil { + t.Error("Resolve() should fail due to reject policy") + } + if !strings.Contains(err.Error(), "access denied") { + t.Errorf("Resolve() error should mention access denied, got: %v", err) + } +} + +func TestRepository_ScopeSpecificPolicy(t *testing.T) { + // Test that scope-specific policies work correctly + pol := &configuration.Policy{ + Default: configuration.PolicyRequirements{&configuration.Reject{}}, + Transports: map[configuration.TransportName]configuration.TransportScopes{ + configuration.TransportDocker: { + // Allow all docker repositories + "": configuration.PolicyRequirements{&configuration.InsecureAcceptAnything{}}, + }, + }, + } + + evaluator, err := configuration.NewEvaluator(pol) + if err != nil { + t.Fatalf("failed to create evaluator: %v", err) + } + + repo := &Repository{ + Reference: testReference, + Policy: evaluator, + } + + // Since the policy allows docker transport, checkPolicy should succeed + err = repo.checkPolicy(context.Background(), "") + if err != nil { + t.Errorf("checkPolicy() should succeed for allowed docker transport, got: %v", err) + } +} + +// mockReadCloser is a simple mock for testing +type mockReadCloser struct { + io.Reader +} + +func (m *mockReadCloser) Close() error { + return nil +} From ae07713ea54f6360c554295646f83c68c7fd9eab Mon Sep 17 00:00:00 2001 From: Terry Howe Date: Tue, 24 Mar 2026 17:03:11 +0100 Subject: [PATCH 2/3] refactor(policy): move to public package with cross-platform support Move policy code from internal/configuration to registry/remote/policy as a public package so SDK users can create and manage policies. Add platform-specific default path handling so GetDefaultPolicyPath only falls back to /etc/containers/policy.json on Linux. Use interfaces for signature verification instead of direct implementation. Signed-off-by: Terry Howe --- .../configuration => policy}/evaluator.go | 69 ++++- registry/remote/policy/evaluator_test.go | 256 ++++++++++++++++++ .../configuration => policy}/example_test.go | 92 +++---- .../configuration => policy}/policy.go | 116 +++++--- registry/remote/policy/policy_linux.go | 19 ++ registry/remote/policy/policy_other.go | 22 ++ .../configuration => policy}/policy_test.go | 177 ++++++------ .../configuration => policy}/requirement.go | 42 +-- .../requirement_test.go | 62 ++--- registry/remote/policy/transport.go | 42 +++ registry/remote/repository.go | 12 +- registry/remote/repository_policy_test.go | 60 ++-- 12 files changed, 703 insertions(+), 266 deletions(-) rename registry/remote/{internal/configuration => policy}/evaluator.go (64%) create mode 100644 registry/remote/policy/evaluator_test.go rename registry/remote/{internal/configuration => policy}/example_test.go (56%) rename registry/remote/{internal/configuration => policy}/policy.go (59%) create mode 100644 registry/remote/policy/policy_linux.go create mode 100644 registry/remote/policy/policy_other.go rename registry/remote/{internal/configuration => policy}/policy_test.go (83%) rename registry/remote/{internal/configuration => policy}/requirement.go (90%) rename registry/remote/{internal/configuration => policy}/requirement_test.go (91%) create mode 100644 registry/remote/policy/transport.go diff --git a/registry/remote/internal/configuration/evaluator.go b/registry/remote/policy/evaluator.go similarity index 64% rename from registry/remote/internal/configuration/evaluator.go rename to registry/remote/policy/evaluator.go index b0205a008..8ef125c58 100644 --- a/registry/remote/internal/configuration/evaluator.go +++ b/registry/remote/policy/evaluator.go @@ -13,11 +13,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -package configuration +package policy import ( "context" "fmt" + + "github.com/oras-project/oras-go/v3/errdef" ) // ImageReference represents a reference to an image @@ -30,13 +32,48 @@ type ImageReference struct { Reference string } +// SignedByVerifier verifies GPG/simple signing signatures. +// Implementations should verify that the image is signed with a valid key +// as specified in the PRSignedBy requirement. +type SignedByVerifier interface { + Verify(ctx context.Context, req *PRSignedBy, image ImageReference) (bool, error) +} + +// SigstoreVerifier verifies sigstore signatures. +// Implementations should verify that the image is signed with valid sigstore +// signatures as specified in the PRSigstoreSigned requirement. +type SigstoreVerifier interface { + Verify(ctx context.Context, req *PRSigstoreSigned, image ImageReference) (bool, error) +} + // Evaluator evaluates policy requirements against image references type Evaluator struct { - policy *Policy + policy *Policy + signedByVerifier SignedByVerifier + sigstoreVerifier SigstoreVerifier +} + +// EvaluatorOption configures an Evaluator +type EvaluatorOption func(*Evaluator) + +// WithSignedByVerifier sets the verifier for PRSignedBy requirements. +// If not set, evaluating PRSignedBy requirements will return ErrUnsupported. +func WithSignedByVerifier(v SignedByVerifier) EvaluatorOption { + return func(e *Evaluator) { + e.signedByVerifier = v + } +} + +// WithSigstoreVerifier sets the verifier for PRSigstoreSigned requirements. +// If not set, evaluating PRSigstoreSigned requirements will return ErrUnsupported. +func WithSigstoreVerifier(v SigstoreVerifier) EvaluatorOption { + return func(e *Evaluator) { + e.sigstoreVerifier = v + } } // NewEvaluator creates a new policy evaluator -func NewEvaluator(policy *Policy) (*Evaluator, error) { +func NewEvaluator(policy *Policy, opts ...EvaluatorOption) (*Evaluator, error) { if policy == nil { return nil, fmt.Errorf("policy cannot be nil") } @@ -45,9 +82,15 @@ func NewEvaluator(policy *Policy) (*Evaluator, error) { return nil, fmt.Errorf("invalid policy: %w", err) } - return &Evaluator{ + e := &Evaluator{ policy: policy, - }, nil + } + + for _, opt := range opts { + opt(e) + } + + return e, nil } // IsImageAllowed determines if an image is allowed by the policy @@ -100,19 +143,19 @@ func (e *Evaluator) evaluateReject(ctx context.Context, req *Reject, image Image } // evaluateSignedBy evaluates a signedBy requirement -// Note: This is a placeholder implementation. Full signature verification -// would require integration with GPG/signing libraries. func (e *Evaluator) evaluateSignedBy(ctx context.Context, req *PRSignedBy, image ImageReference) (bool, error) { - // TODO: Implement actual signature verification https://github.com/oras-project/oras-go/issues/1029 - return false, fmt.Errorf("signedBy verification not yet implemented") + if e.signedByVerifier == nil { + return false, fmt.Errorf("signedBy verification requires a SignedByVerifier: %w", errdef.ErrUnsupported) + } + return e.signedByVerifier.Verify(ctx, req, image) } // evaluateSigstoreSigned evaluates a sigstoreSigned requirement -// Note: This is a placeholder implementation. Full signature verification -// would require integration with sigstore libraries. func (e *Evaluator) evaluateSigstoreSigned(ctx context.Context, req *PRSigstoreSigned, image ImageReference) (bool, error) { - // TODO: Implement actual sigstore verification https://github.com/oras-project/oras-go/issues/1029 - return false, fmt.Errorf("sigstoreSigned verification not yet implemented") + if e.sigstoreVerifier == nil { + return false, fmt.Errorf("sigstoreSigned verification requires a SigstoreVerifier: %w", errdef.ErrUnsupported) + } + return e.sigstoreVerifier.Verify(ctx, req, image) } // ShouldAcceptImage is a convenience function that returns true if the image is allowed diff --git a/registry/remote/policy/evaluator_test.go b/registry/remote/policy/evaluator_test.go new file mode 100644 index 000000000..a65dbba03 --- /dev/null +++ b/registry/remote/policy/evaluator_test.go @@ -0,0 +1,256 @@ +/* +Copyright The ORAS 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 policy + +import ( + "context" + "errors" + "testing" + + "github.com/oras-project/oras-go/v3/errdef" +) + +// mockSignedByVerifier is a mock implementation of SignedByVerifier for testing +type mockSignedByVerifier struct { + result bool + err error +} + +func (m *mockSignedByVerifier) Verify(ctx context.Context, req *PRSignedBy, image ImageReference) (bool, error) { + return m.result, m.err +} + +// mockSigstoreVerifier is a mock implementation of SigstoreVerifier for testing +type mockSigstoreVerifier struct { + result bool + err error +} + +func (m *mockSigstoreVerifier) Verify(ctx context.Context, req *PRSigstoreSigned, image ImageReference) (bool, error) { + return m.result, m.err +} + +func TestEvaluator_WithSignedByVerifier(t *testing.T) { + policy := &Policy{ + Default: PolicyRequirements{ + &PRSignedBy{ + KeyType: "GPGKeys", + KeyPath: "/path/to/key.gpg", + }, + }, + } + + image := ImageReference{ + Transport: TransportNameDocker, + Scope: "docker.io/library/nginx", + Reference: "docker.io/library/nginx:latest", + } + + tests := []struct { + name string + verifier *mockSignedByVerifier + wantResult bool + wantErr bool + }{ + { + name: "verifier returns true", + verifier: &mockSignedByVerifier{result: true, err: nil}, + wantResult: true, + wantErr: false, + }, + { + name: "verifier returns false", + verifier: &mockSignedByVerifier{result: false, err: nil}, + wantResult: false, + wantErr: false, + }, + { + name: "verifier returns error", + verifier: &mockSignedByVerifier{result: false, err: errors.New("verification failed")}, + wantResult: false, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + evaluator, err := NewEvaluator(policy, WithSignedByVerifier(tt.verifier)) + if err != nil { + t.Fatalf("NewEvaluator() error = %v", err) + } + + result, err := evaluator.IsImageAllowed(context.Background(), image) + if (err != nil) != tt.wantErr { + t.Errorf("IsImageAllowed() error = %v, wantErr %v", err, tt.wantErr) + return + } + if result != tt.wantResult { + t.Errorf("IsImageAllowed() = %v, want %v", result, tt.wantResult) + } + }) + } +} + +func TestEvaluator_WithSigstoreVerifier(t *testing.T) { + policy := &Policy{ + Default: PolicyRequirements{ + &PRSigstoreSigned{ + KeyPath: "/path/to/key.pub", + }, + }, + } + + image := ImageReference{ + Transport: TransportNameDocker, + Scope: "docker.io/library/nginx", + Reference: "docker.io/library/nginx:latest", + } + + tests := []struct { + name string + verifier *mockSigstoreVerifier + wantResult bool + wantErr bool + }{ + { + name: "verifier returns true", + verifier: &mockSigstoreVerifier{result: true, err: nil}, + wantResult: true, + wantErr: false, + }, + { + name: "verifier returns false", + verifier: &mockSigstoreVerifier{result: false, err: nil}, + wantResult: false, + wantErr: false, + }, + { + name: "verifier returns error", + verifier: &mockSigstoreVerifier{result: false, err: errors.New("verification failed")}, + wantResult: false, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + evaluator, err := NewEvaluator(policy, WithSigstoreVerifier(tt.verifier)) + if err != nil { + t.Fatalf("NewEvaluator() error = %v", err) + } + + result, err := evaluator.IsImageAllowed(context.Background(), image) + if (err != nil) != tt.wantErr { + t.Errorf("IsImageAllowed() error = %v, wantErr %v", err, tt.wantErr) + return + } + if result != tt.wantResult { + t.Errorf("IsImageAllowed() = %v, want %v", result, tt.wantResult) + } + }) + } +} + +func TestEvaluator_NoVerifier_ReturnsUnsupported(t *testing.T) { + tests := []struct { + name string + policy *Policy + }{ + { + name: "signedBy without verifier", + policy: &Policy{ + Default: PolicyRequirements{ + &PRSignedBy{ + KeyType: "GPGKeys", + KeyPath: "/path/to/key.gpg", + }, + }, + }, + }, + { + name: "sigstoreSigned without verifier", + policy: &Policy{ + Default: PolicyRequirements{ + &PRSigstoreSigned{ + KeyPath: "/path/to/key.pub", + }, + }, + }, + }, + } + + image := ImageReference{ + Transport: TransportNameDocker, + Scope: "docker.io/library/nginx", + Reference: "docker.io/library/nginx:latest", + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + evaluator, err := NewEvaluator(tt.policy) + if err != nil { + t.Fatalf("NewEvaluator() error = %v", err) + } + + _, err = evaluator.IsImageAllowed(context.Background(), image) + if err == nil { + t.Error("IsImageAllowed() should return error when no verifier is set") + } + if !errors.Is(err, errdef.ErrUnsupported) { + t.Errorf("IsImageAllowed() error should wrap ErrUnsupported, got: %v", err) + } + }) + } +} + +func TestEvaluator_WithBothVerifiers(t *testing.T) { + policy := &Policy{ + Default: PolicyRequirements{ + &PRSignedBy{ + KeyType: "GPGKeys", + KeyPath: "/path/to/key.gpg", + }, + &PRSigstoreSigned{ + KeyPath: "/path/to/key.pub", + }, + }, + } + + image := ImageReference{ + Transport: TransportNameDocker, + Scope: "docker.io/library/nginx", + Reference: "docker.io/library/nginx:latest", + } + + signedByVerifier := &mockSignedByVerifier{result: true, err: nil} + sigstoreVerifier := &mockSigstoreVerifier{result: true, err: nil} + + evaluator, err := NewEvaluator(policy, + WithSignedByVerifier(signedByVerifier), + WithSigstoreVerifier(sigstoreVerifier), + ) + if err != nil { + t.Fatalf("NewEvaluator() error = %v", err) + } + + result, err := evaluator.IsImageAllowed(context.Background(), image) + if err != nil { + t.Errorf("IsImageAllowed() error = %v", err) + } + if !result { + t.Error("IsImageAllowed() should return true when all verifiers pass") + } +} diff --git a/registry/remote/internal/configuration/example_test.go b/registry/remote/policy/example_test.go similarity index 56% rename from registry/remote/internal/configuration/example_test.go rename to registry/remote/policy/example_test.go index 0e05b44c9..dbc59b550 100644 --- a/registry/remote/internal/configuration/example_test.go +++ b/registry/remote/policy/example_test.go @@ -13,7 +13,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package configuration_test +package policy_test import ( "context" @@ -22,20 +22,20 @@ import ( "os" "path/filepath" - "github.com/oras-project/oras-go/v3/registry/remote/internal/configuration" + "github.com/oras-project/oras-go/v3/registry/remote/policy" ) // ExamplePolicy_basic demonstrates creating a basic policy func ExamplePolicy_basic() { // Create a policy that rejects everything by default - p := &configuration.Policy{ - Default: configuration.PolicyRequirements{&configuration.Reject{}}, + p := &policy.Policy{ + Default: policy.PolicyRequirements{&policy.Reject{}}, } // Add a transport-specific policy for docker that accepts anything - p.Transports = map[configuration.TransportName]configuration.TransportScopes{ - configuration.TransportDocker: { - "": configuration.PolicyRequirements{&configuration.InsecureAcceptAnything{}}, + p.Transports = map[policy.TransportName]policy.TransportScopes{ + policy.TransportNameDocker: { + "": policy.PolicyRequirements{&policy.InsecureAcceptAnything{}}, }, } @@ -45,16 +45,16 @@ func ExamplePolicy_basic() { // ExamplePolicy_signedBy demonstrates creating a policy with signature verification func ExamplePolicy_signedBy() { - p := &configuration.Policy{ - Default: configuration.PolicyRequirements{&configuration.Reject{}}, - Transports: map[configuration.TransportName]configuration.TransportScopes{ - configuration.TransportDocker: { - "docker.io/myorg": configuration.PolicyRequirements{ - &configuration.PRSignedBy{ + p := &policy.Policy{ + Default: policy.PolicyRequirements{&policy.Reject{}}, + Transports: map[policy.TransportName]policy.TransportScopes{ + policy.TransportNameDocker: { + "docker.io/myorg": policy.PolicyRequirements{ + &policy.PRSignedBy{ KeyType: "GPGKeys", KeyPath: "/path/to/trusted-key.gpg", - SignedIdentity: &configuration.SignedIdentity{ - Type: configuration.MatchRepository, + SignedIdentity: &policy.SignedIdentity{ + Type: policy.IdentityMatchRepository, }, }, }, @@ -67,29 +67,29 @@ func ExamplePolicy_signedBy() { // Output: Policy requires GPG signatures for docker.io/myorg } -// ExampleLoadPolicy demonstrates loading a policy from a file -func ExampleLoadPolicy() { +// ExampleLoad demonstrates loading a policy from a file +func ExampleLoad() { // Create a temporary policy file tmpDir := os.TempDir() policyPath := filepath.Join(tmpDir, "example-policy.json") // Create and save a policy - p := &configuration.Policy{ - Default: configuration.PolicyRequirements{&configuration.Reject{}}, - Transports: map[configuration.TransportName]configuration.TransportScopes{ - configuration.TransportDocker: { - "": configuration.PolicyRequirements{&configuration.InsecureAcceptAnything{}}, + p := &policy.Policy{ + Default: policy.PolicyRequirements{&policy.Reject{}}, + Transports: map[policy.TransportName]policy.TransportScopes{ + policy.TransportNameDocker: { + "": policy.PolicyRequirements{&policy.InsecureAcceptAnything{}}, }, }, } - if err := configuration.SavePolicy(p, policyPath); err != nil { + if err := p.Save(policyPath); err != nil { log.Fatalf("Failed to save policy: %v", err) } defer os.Remove(policyPath) // Load the policy - loaded, err := configuration.LoadPolicy(policyPath) + loaded, err := policy.Load(policyPath) if err != nil { log.Fatalf("Failed to load policy: %v", err) } @@ -101,19 +101,19 @@ func ExampleLoadPolicy() { // ExampleEvaluator_IsImageAllowed demonstrates evaluating a policy func ExampleEvaluator_IsImageAllowed() { // Create a permissive policy for testing - p := &configuration.Policy{ - Default: configuration.PolicyRequirements{&configuration.InsecureAcceptAnything{}}, + p := &policy.Policy{ + Default: policy.PolicyRequirements{&policy.InsecureAcceptAnything{}}, } // Create an evaluator - evaluator, err := configuration.NewEvaluator(p) + evaluator, err := policy.NewEvaluator(p) if err != nil { log.Fatalf("Failed to create evaluator: %v", err) } // Check if an image is allowed - image := configuration.ImageReference{ - Transport: configuration.TransportDocker, + image := policy.ImageReference{ + Transport: policy.TransportNameDocker, Scope: "docker.io/library/nginx", Reference: "docker.io/library/nginx:latest", } @@ -129,22 +129,22 @@ func ExampleEvaluator_IsImageAllowed() { // ExamplePolicy_GetRequirementsForImage demonstrates getting requirements for a specific image func ExamplePolicy_GetRequirementsForImage() { - p := &configuration.Policy{ - Default: configuration.PolicyRequirements{&configuration.Reject{}}, - Transports: map[configuration.TransportName]configuration.TransportScopes{ - configuration.TransportDocker: { - "": configuration.PolicyRequirements{&configuration.InsecureAcceptAnything{}}, - "docker.io/library/nginx": configuration.PolicyRequirements{&configuration.Reject{}}, + p := &policy.Policy{ + Default: policy.PolicyRequirements{&policy.Reject{}}, + Transports: map[policy.TransportName]policy.TransportScopes{ + policy.TransportNameDocker: { + "": policy.PolicyRequirements{&policy.InsecureAcceptAnything{}}, + "docker.io/library/nginx": policy.PolicyRequirements{&policy.Reject{}}, }, }, } // Get requirements for nginx specifically - nginxReqs := p.GetRequirementsForImage(configuration.TransportDocker, "docker.io/library/nginx") + nginxReqs := p.GetRequirementsForImage(policy.TransportNameDocker, "docker.io/library/nginx") fmt.Printf("Nginx requirements: %s\n", nginxReqs[0].Type()) // Get requirements for other docker images - otherReqs := p.GetRequirementsForImage(configuration.TransportDocker, "docker.io/library/alpine") + otherReqs := p.GetRequirementsForImage(policy.TransportNameDocker, "docker.io/library/alpine") fmt.Printf("Other docker requirements: %s\n", otherReqs[0].Type()) // Output: @@ -154,21 +154,21 @@ func ExamplePolicy_GetRequirementsForImage() { // ExamplePolicy_sigstore demonstrates creating a sigstore-based policy func ExamplePolicy_sigstore() { - p := &configuration.Policy{ - Default: configuration.PolicyRequirements{&configuration.Reject{}}, - Transports: map[configuration.TransportName]configuration.TransportScopes{ - configuration.TransportDocker: { - "docker.io/myorg": configuration.PolicyRequirements{ - &configuration.PRSigstoreSigned{ + p := &policy.Policy{ + Default: policy.PolicyRequirements{&policy.Reject{}}, + Transports: map[policy.TransportName]policy.TransportScopes{ + policy.TransportNameDocker: { + "docker.io/myorg": policy.PolicyRequirements{ + &policy.PRSigstoreSigned{ KeyPath: "/path/to/cosign.pub", - Fulcio: &configuration.FulcioConfig{ + Fulcio: &policy.FulcioConfig{ CAPath: "/path/to/fulcio-ca.pem", OIDCIssuer: "https://oauth2.sigstore.dev/auth", SubjectEmail: "user@example.com", }, RekorPublicKeyPath: "/path/to/rekor.pub", - SignedIdentity: &configuration.SignedIdentity{ - Type: configuration.MatchRepository, + SignedIdentity: &policy.SignedIdentity{ + Type: policy.IdentityMatchRepository, }, }, }, diff --git a/registry/remote/internal/configuration/policy.go b/registry/remote/policy/policy.go similarity index 59% rename from registry/remote/internal/configuration/policy.go rename to registry/remote/policy/policy.go index 05d50d529..ead588556 100644 --- a/registry/remote/internal/configuration/policy.go +++ b/registry/remote/policy/policy.go @@ -13,11 +13,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -// Package configuration implements support for containers-policy.json format +// Package policy implements support for containers-policy.json format // for OCI image signature verification policies. // // Reference: https://man.archlinux.org/man/containers-policy.json.5.en -package configuration +package policy import ( "encoding/json" @@ -31,36 +31,12 @@ const ( PolicyConfUserDir = ".config/containers" // PolicyConfFileName is the name of the policy configuration file PolicyConfFileName = "policy.json" - // PolicyConfSystemPath is the system-wide policy.json path + // PolicyConfSystemPath is the system-wide policy.json path (Linux only). + // On non-Linux platforms, this path is not used by default. + // Use [Load] or [LoadPolicy] with an explicit path for cross-platform usage. PolicyConfSystemPath = "/etc/containers/policy.json" ) -// TransportName represents a supported transport type -type TransportName string - -const ( - // TransportDocker represents the docker transport - TransportDocker TransportName = "docker" - // TransportAtomic represents the atomic transport - TransportAtomic TransportName = "atomic" - // TransportContainersStorage represents the containers-storage transport - TransportContainersStorage TransportName = "containers-storage" - // TransportDir represents the dir transport - TransportDir TransportName = "dir" - // TransportDockerArchive represents the docker-archive transport - TransportDockerArchive TransportName = "docker-archive" - // TransportDockerDaemon represents the docker-daemon transport - TransportDockerDaemon TransportName = "docker-daemon" - // TransportOCI represents the oci transport - TransportOCI TransportName = "oci" - // TransportOCIArchive represents the oci-archive transport - TransportOCIArchive TransportName = "oci-archive" - // TransportSIF represents the sif transport - TransportSIF TransportName = "sif" - // TransportTarball represents the tarball transport - TransportTarball TransportName = "tarball" -) - // Policy represents a containers-policy.json configuration type Policy struct { // Default is the default policy requirement @@ -83,26 +59,84 @@ type PolicyRequirement interface { Validate() error } +// NewPolicy creates a new empty Policy. +// Use this for programmatic policy construction without a file. +func NewPolicy() *Policy { + return &Policy{ + Default: make(PolicyRequirements, 0), + Transports: make(map[TransportName]TransportScopes), + } +} + +// NewInsecureAcceptAnythingPolicy creates a policy that accepts all images. +// This is useful for testing or development environments. +func NewInsecureAcceptAnythingPolicy() *Policy { + return &Policy{ + Default: PolicyRequirements{&InsecureAcceptAnything{}}, + } +} + +// NewRejectAllPolicy creates a policy that rejects all images. +// This is a safe default that requires explicit configuration to allow images. +func NewRejectAllPolicy() *Policy { + return &Policy{ + Default: PolicyRequirements{&Reject{}}, + } +} + +// SetDefault sets the default policy requirements. +func (p *Policy) SetDefault(reqs ...PolicyRequirement) *Policy { + p.Default = reqs + return p +} + +// SetTransportScope sets the policy requirements for a specific transport and scope. +func (p *Policy) SetTransportScope(transport TransportName, scope string, reqs ...PolicyRequirement) *Policy { + if p.Transports == nil { + p.Transports = make(map[TransportName]TransportScopes) + } + if p.Transports[transport] == nil { + p.Transports[transport] = make(TransportScopes) + } + p.Transports[transport][scope] = reqs + return p +} + // GetDefaultPolicyPath returns the default path to policy.json. // It checks $HOME/.config/containers/policy.json first, then falls back to -// /etc/containers/policy.json. +// the system-wide path. +// +// On Linux, the system-wide path is /etc/containers/policy.json. +// On other platforms (macOS, Windows, etc.), only the user-level path +// ($HOME/.config/containers/policy.json) is checked, since the system-wide +// path is Linux-specific. Use [Load] or [LoadPolicy] with an explicit path +// for cross-platform usage. func GetDefaultPolicyPath() (string, error) { homeDir, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("failed to get user home directory: %w", err) } - // Try user-specific path first + // Try user-specific path first (works on all platforms) userPath := filepath.Join(homeDir, PolicyConfUserDir, PolicyConfFileName) if _, err := os.Stat(userPath); err == nil { return userPath, nil } - // Fall back to system-wide path - return PolicyConfSystemPath, nil + // Fall back to system-wide path (Linux only) + if systemPolicyPath != "" { + return systemPolicyPath, nil + } + + return "", fmt.Errorf("no policy.json found at %s and no system-wide default is available on this platform", userPath) +} + +// Load loads a policy from the specified file path. +func Load(path string) (*Policy, error) { + return LoadPolicy(path) } -// LoadPolicy loads a policy from the specified file path +// LoadPolicy loads a policy from the specified file path. func LoadPolicy(path string) (*Policy, error) { data, err := os.ReadFile(path) if err != nil { @@ -117,8 +151,10 @@ func LoadPolicy(path string) (*Policy, error) { return &policy, nil } -// LoadDefaultPolicy loads the policy from the default location -func LoadDefaultPolicy() (*Policy, error) { +// LoadDefault loads the policy from the default location. +// On non-Linux platforms, this only checks the user-level path. +// See [GetDefaultPolicyPath] for details on path resolution. +func LoadDefault() (*Policy, error) { path, err := GetDefaultPolicyPath() if err != nil { return nil, err @@ -127,9 +163,9 @@ func LoadDefaultPolicy() (*Policy, error) { return LoadPolicy(path) } -// SavePolicy saves a policy to the specified file path -func SavePolicy(policy *Policy, path string) error { - data, err := json.MarshalIndent(policy, "", " ") +// Save saves a policy to the specified file path. +func (p *Policy) Save(path string) error { + data, err := json.MarshalIndent(p, "", " ") if err != nil { return fmt.Errorf("failed to marshal policy: %w", err) } @@ -147,7 +183,7 @@ func SavePolicy(policy *Policy, path string) error { return nil } -// Validate validates the policy configuration +// Validate validates the policy configuration. func (p *Policy) Validate() error { // Validate default requirements for _, req := range p.Default { diff --git a/registry/remote/policy/policy_linux.go b/registry/remote/policy/policy_linux.go new file mode 100644 index 000000000..3c5f2eee9 --- /dev/null +++ b/registry/remote/policy/policy_linux.go @@ -0,0 +1,19 @@ +/* +Copyright The ORAS 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 policy + +// systemPolicyPath is the system-wide policy.json path on Linux. +const systemPolicyPath = "/etc/containers/policy.json" diff --git a/registry/remote/policy/policy_other.go b/registry/remote/policy/policy_other.go new file mode 100644 index 000000000..5ecddf710 --- /dev/null +++ b/registry/remote/policy/policy_other.go @@ -0,0 +1,22 @@ +/* +Copyright The ORAS 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. +*/ + +//go:build !linux + +package policy + +// systemPolicyPath is empty on non-Linux platforms since the system-wide +// /etc/containers/policy.json path is Linux-specific. +const systemPolicyPath = "" diff --git a/registry/remote/internal/configuration/policy_test.go b/registry/remote/policy/policy_test.go similarity index 83% rename from registry/remote/internal/configuration/policy_test.go rename to registry/remote/policy/policy_test.go index d1d409259..be3993c23 100644 --- a/registry/remote/internal/configuration/policy_test.go +++ b/registry/remote/policy/policy_test.go @@ -13,7 +13,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package configuration +package policy import ( "context" @@ -36,7 +36,7 @@ func TestPolicy_GetRequirementsForImage(t *testing.T) { policy: &Policy{ Default: PolicyRequirements{&Reject{}}, }, - transport: TransportDocker, + transport: TransportNameDocker, scope: "docker.io/library/nginx", wantType: TypeReject, }, @@ -45,12 +45,12 @@ func TestPolicy_GetRequirementsForImage(t *testing.T) { policy: &Policy{ Default: PolicyRequirements{&Reject{}}, Transports: map[TransportName]TransportScopes{ - TransportDocker: { + TransportNameDocker: { "": PolicyRequirements{&InsecureAcceptAnything{}}, }, }, }, - transport: TransportDocker, + transport: TransportNameDocker, scope: "docker.io/library/nginx", wantType: TypeInsecureAcceptAnything, }, @@ -59,13 +59,13 @@ func TestPolicy_GetRequirementsForImage(t *testing.T) { policy: &Policy{ Default: PolicyRequirements{&Reject{}}, Transports: map[TransportName]TransportScopes{ - TransportDocker: { + TransportNameDocker: { "": PolicyRequirements{&InsecureAcceptAnything{}}, "docker.io/library/nginx": PolicyRequirements{&Reject{}}, }, }, }, - transport: TransportDocker, + transport: TransportNameDocker, scope: "docker.io/library/nginx", wantType: TypeReject, }, @@ -88,14 +88,14 @@ func TestPolicy_JSONMarshalUnmarshal(t *testing.T) { policy := &Policy{ Default: PolicyRequirements{&Reject{}}, Transports: map[TransportName]TransportScopes{ - TransportDocker: { + TransportNameDocker: { "": PolicyRequirements{&InsecureAcceptAnything{}}, "docker.io/library/nginx": PolicyRequirements{ &PRSignedBy{ KeyType: "GPGKeys", KeyPath: "/path/to/key.gpg", SignedIdentity: &SignedIdentity{ - Type: MatchExact, + Type: IdentityMatchExact, }, }, }, @@ -120,7 +120,7 @@ func TestPolicy_JSONMarshalUnmarshal(t *testing.T) { t.Error("default requirement not preserved") } - dockerScopes := unmarshaled.Transports[TransportDocker] + dockerScopes := unmarshaled.Transports[TransportNameDocker] if len(dockerScopes[""]) != 1 || dockerScopes[""][0].Type() != TypeInsecureAcceptAnything { t.Error("docker default requirement not preserved") } @@ -138,19 +138,19 @@ func TestPolicy_SaveAndLoad(t *testing.T) { original := &Policy{ Default: PolicyRequirements{&Reject{}}, Transports: map[TransportName]TransportScopes{ - TransportDocker: { + TransportNameDocker: { "": PolicyRequirements{&InsecureAcceptAnything{}}, }, }, } // Save - if err := SavePolicy(original, policyPath); err != nil { + if err := original.Save(policyPath); err != nil { t.Fatalf("failed to save policy: %v", err) } // Load - loaded, err := LoadPolicy(policyPath) + loaded, err := Load(policyPath) if err != nil { t.Fatalf("failed to load policy: %v", err) } @@ -160,7 +160,7 @@ func TestPolicy_SaveAndLoad(t *testing.T) { t.Error("loaded policy default not correct") } - if len(loaded.Transports[TransportDocker][""]) != 1 { + if len(loaded.Transports[TransportNameDocker][""]) != 1 { t.Error("loaded policy docker transport not correct") } } @@ -198,7 +198,7 @@ func TestPolicy_Validate(t *testing.T) { KeyType: "GPGKeys", KeyPath: "/path/to/key.gpg", SignedIdentity: &SignedIdentity{ - Type: ExactReference, + Type: IdentityMatchExactReference, // Missing DockerReference }, }, @@ -232,7 +232,7 @@ func TestEvaluator_IsImageAllowed(t *testing.T) { Default: PolicyRequirements{&InsecureAcceptAnything{}}, }, image: ImageReference{ - Transport: TransportDocker, + Transport: TransportNameDocker, Scope: "docker.io/library/nginx", Reference: "docker.io/library/nginx:latest", }, @@ -245,7 +245,7 @@ func TestEvaluator_IsImageAllowed(t *testing.T) { Default: PolicyRequirements{&Reject{}}, }, image: ImageReference{ - Transport: TransportDocker, + Transport: TransportNameDocker, Scope: "docker.io/library/nginx", Reference: "docker.io/library/nginx:latest", }, @@ -263,7 +263,7 @@ func TestEvaluator_IsImageAllowed(t *testing.T) { }, }, image: ImageReference{ - Transport: TransportDocker, + Transport: TransportNameDocker, Scope: "docker.io/library/nginx", Reference: "docker.io/library/nginx:latest", }, @@ -355,22 +355,36 @@ func TestRequirement_Validation(t *testing.T) { func TestGetDefaultPolicyPath(t *testing.T) { path, err := GetDefaultPolicyPath() - if err != nil { - t.Fatalf("GetDefaultPolicyPath() error = %v", err) - } - // Should return either user path or system path homeDir, _ := os.UserHomeDir() userPath := filepath.Join(homeDir, PolicyConfUserDir, PolicyConfFileName) - systemPath := PolicyConfSystemPath - if path != userPath && path != systemPath { - t.Errorf("GetDefaultPolicyPath() = %v, want %v or %v", path, userPath, systemPath) + if _, statErr := os.Stat(userPath); statErr == nil { + // User policy file exists — should succeed with that path + if err != nil { + t.Fatalf("GetDefaultPolicyPath() error = %v", err) + } + if path != userPath { + t.Errorf("GetDefaultPolicyPath() = %v, want %v", path, userPath) + } + } else if systemPolicyPath != "" { + // No user policy but system path available (Linux) + if err != nil { + t.Fatalf("GetDefaultPolicyPath() error = %v", err) + } + if path != PolicyConfSystemPath { + t.Errorf("GetDefaultPolicyPath() = %v, want %v", path, PolicyConfSystemPath) + } + } else { + // No user policy and no system path (non-Linux) — should error + if err == nil { + t.Error("GetDefaultPolicyPath() should error on non-Linux without user policy") + } } } -// Test LoadDefaultPolicy -func TestLoadDefaultPolicy(t *testing.T) { +// Test LoadDefault +func TestLoadDefault(t *testing.T) { // Create a temporary policy file in the home directory homeDir, err := os.UserHomeDir() if err != nil { @@ -392,17 +406,17 @@ func TestLoadDefaultPolicy(t *testing.T) { testPolicy := &Policy{ Default: PolicyRequirements{&InsecureAcceptAnything{}}, } - if err := SavePolicy(testPolicy, userPolicyPath); err != nil { + if err := testPolicy.Save(userPolicyPath); err != nil { t.Fatalf("failed to save test policy: %v", err) } - // Test LoadDefaultPolicy - loaded, err := LoadDefaultPolicy() + // Test LoadDefault + loaded, err := LoadDefault() if err != nil { - t.Errorf("LoadDefaultPolicy() error = %v", err) + t.Errorf("LoadDefault() error = %v", err) } if loaded == nil { - t.Error("LoadDefaultPolicy() returned nil policy") + t.Error("LoadDefault() returned nil policy") } } @@ -421,7 +435,7 @@ func TestShouldAcceptImage(t *testing.T) { Default: PolicyRequirements{&InsecureAcceptAnything{}}, }, image: ImageReference{ - Transport: TransportDocker, + Transport: TransportNameDocker, Scope: "docker.io/library/nginx", Reference: "docker.io/library/nginx:latest", }, @@ -434,7 +448,7 @@ func TestShouldAcceptImage(t *testing.T) { Default: PolicyRequirements{&Reject{}}, }, image: ImageReference{ - Transport: TransportDocker, + Transport: TransportNameDocker, Scope: "docker.io/library/nginx", Reference: "docker.io/library/nginx:latest", }, @@ -445,7 +459,7 @@ func TestShouldAcceptImage(t *testing.T) { name: "nil policy", policy: nil, image: ImageReference{ - Transport: TransportDocker, + Transport: TransportNameDocker, Scope: "docker.io/library/nginx", Reference: "docker.io/library/nginx:latest", }, @@ -519,7 +533,7 @@ func TestEvaluator_SigstoreSigned(t *testing.T) { } image := ImageReference{ - Transport: TransportDocker, + Transport: TransportNameDocker, Scope: "docker.io/library/nginx", Reference: "docker.io/library/nginx:latest", } @@ -546,7 +560,7 @@ func TestPolicy_ValidateTransportScopes(t *testing.T) { policy: &Policy{ Default: PolicyRequirements{&Reject{}}, Transports: map[TransportName]TransportScopes{ - TransportDocker: { + TransportNameDocker: { "docker.io": PolicyRequirements{&InsecureAcceptAnything{}}, }, }, @@ -558,7 +572,7 @@ func TestPolicy_ValidateTransportScopes(t *testing.T) { policy: &Policy{ Default: PolicyRequirements{&Reject{}}, Transports: map[TransportName]TransportScopes{ - TransportDocker: { + TransportNameDocker: { "docker.io": PolicyRequirements{ &PRSignedBy{ // Missing KeyType @@ -582,16 +596,16 @@ func TestPolicy_ValidateTransportScopes(t *testing.T) { } } -// Test LoadPolicy with non-existent file -func TestLoadPolicy_NonExistent(t *testing.T) { - _, err := LoadPolicy("/nonexistent/path/policy.json") +// Test Load with non-existent file +func TestLoad_NonExistent(t *testing.T) { + _, err := Load("/nonexistent/path/policy.json") if err == nil { - t.Error("LoadPolicy() should fail for non-existent file") + t.Error("Load() should fail for non-existent file") } } -// Test LoadPolicy with invalid JSON -func TestLoadPolicy_InvalidJSON(t *testing.T) { +// Test Load with invalid JSON +func TestLoad_InvalidJSON(t *testing.T) { tmpDir := t.TempDir() policyPath := filepath.Join(tmpDir, "policy.json") @@ -600,14 +614,14 @@ func TestLoadPolicy_InvalidJSON(t *testing.T) { t.Fatalf("failed to write test file: %v", err) } - _, err := LoadPolicy(policyPath) + _, err := Load(policyPath) if err == nil { - t.Error("LoadPolicy() should fail for invalid JSON") + t.Error("Load() should fail for invalid JSON") } } -// Test SavePolicy with invalid policy -func TestSavePolicy_ErrorCases(t *testing.T) { +// Test Policy.Save with invalid path +func TestPolicy_Save_ErrorCases(t *testing.T) { tmpDir := t.TempDir() // Test with read-only directory @@ -622,27 +636,38 @@ func TestSavePolicy_ErrorCases(t *testing.T) { } policyPath := filepath.Join(readOnlyDir, "policy.json") - err := SavePolicy(policy, policyPath) + err := policy.Save(policyPath) if err == nil { - t.Error("SavePolicy() should fail for read-only directory") + t.Error("Save() should fail for read-only directory") } } -// Test GetDefaultPolicyPath when home directory doesn't exist +// Test GetDefaultPolicyPath when user policy doesn't exist func TestGetDefaultPolicyPath_NoUserPolicy(t *testing.T) { - // This test ensures the function returns a path even if user policy doesn't exist path, err := GetDefaultPolicyPath() - if err != nil { - t.Errorf("GetDefaultPolicyPath() error = %v", err) - } - if path == "" { - t.Error("GetDefaultPolicyPath() returned empty path") - } - // Should return either user path or system path - if path != PolicyConfSystemPath { - // If not system path, check it's a valid user path - if !filepath.IsAbs(path) { - t.Errorf("GetDefaultPolicyPath() returned non-absolute path: %s", path) + + if systemPolicyPath != "" { + // On Linux, should fall back to system path + if err != nil { + t.Errorf("GetDefaultPolicyPath() error = %v", err) + } + if path == "" { + t.Error("GetDefaultPolicyPath() returned empty path") + } + if path != PolicyConfSystemPath { + if !filepath.IsAbs(path) { + t.Errorf("GetDefaultPolicyPath() returned non-absolute path: %s", path) + } + } + } else { + // On non-Linux without user policy, should return an error + homeDir, _ := os.UserHomeDir() + userPath := filepath.Join(homeDir, PolicyConfUserDir, PolicyConfFileName) + if _, statErr := os.Stat(userPath); statErr != nil { + // User policy doesn't exist either — expect error + if err == nil { + t.Error("GetDefaultPolicyPath() should error on non-Linux without user policy") + } } } } @@ -662,7 +687,7 @@ func TestPolicy_MultipleRequirements(t *testing.T) { } image := ImageReference{ - Transport: TransportDocker, + Transport: TransportNameDocker, Scope: "docker.io/library/nginx", Reference: "docker.io/library/nginx:latest", } @@ -688,7 +713,7 @@ func TestEvaluator_NoRequirements(t *testing.T) { } image := ImageReference{ - Transport: TransportDocker, + Transport: TransportNameDocker, Scope: "docker.io/library/nginx", Reference: "docker.io/library/nginx:latest", } @@ -702,16 +727,16 @@ func TestEvaluator_NoRequirements(t *testing.T) { // Test all transport types func TestPolicy_AllTransports(t *testing.T) { transports := []TransportName{ - TransportDocker, - TransportAtomic, - TransportContainersStorage, - TransportDir, - TransportDockerArchive, - TransportDockerDaemon, - TransportOCI, - TransportOCIArchive, - TransportSIF, - TransportTarball, + TransportNameDocker, + TransportNameAtomic, + TransportNameContainersStorage, + TransportNameDir, + TransportNameDockerArchive, + TransportNameDockerDaemon, + TransportNameOCI, + TransportNameOCIArchive, + TransportNameSIF, + TransportNameTarball, } for _, transport := range transports { @@ -767,7 +792,7 @@ func TestPolicyRequirements_JSONRoundTrip(t *testing.T) { {KeyData: "inline key data"}, }, SignedIdentity: &SignedIdentity{ - Type: MatchExact, + Type: IdentityMatchExact, }, }, }, @@ -791,7 +816,7 @@ func TestPolicyRequirements_JSONRoundTrip(t *testing.T) { RekorPublicKeyPath: "/path/rekor.pub", RekorPublicKeyData: []byte("rekor key"), SignedIdentity: &SignedIdentity{ - Type: MatchRepository, + Type: IdentityMatchRepository, }, }, }, diff --git a/registry/remote/internal/configuration/requirement.go b/registry/remote/policy/requirement.go similarity index 90% rename from registry/remote/internal/configuration/requirement.go rename to registry/remote/policy/requirement.go index 58309fa15..15f39b1dc 100644 --- a/registry/remote/internal/configuration/requirement.go +++ b/registry/remote/policy/requirement.go @@ -13,7 +13,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package configuration +package policy import ( "encoding/json" @@ -57,22 +57,22 @@ func (r *Reject) Validate() error { return nil } -// IdentityMatchType represents the type of identity matching -type IdentityMatchType string +// IdentityMatch represents the type of identity matching +type IdentityMatch string const ( - // MatchExact matches the exact identity - MatchExact IdentityMatchType = "matchExact" - // MatchRepoDigestOrExact matches repository digest or exact - MatchRepoDigestOrExact IdentityMatchType = "matchRepoDigestOrExact" - // MatchRepository matches the repository - MatchRepository IdentityMatchType = "matchRepository" - // ExactReference matches exact reference - ExactReference IdentityMatchType = "exactReference" - // ExactRepository matches exact repository - ExactRepository IdentityMatchType = "exactRepository" - // RemapIdentity remaps identity - RemapIdentity IdentityMatchType = "remapIdentity" + // IdentityMatchExact matches the exact identity + IdentityMatchExact IdentityMatch = "matchExact" + // IdentityMatchRepoDigestOrExact matches repository digest or exact + IdentityMatchRepoDigestOrExact IdentityMatch = "matchRepoDigestOrExact" + // IdentityMatchRepository matches the repository + IdentityMatchRepository IdentityMatch = "matchRepository" + // IdentityMatchExactReference matches exact reference + IdentityMatchExactReference IdentityMatch = "exactReference" + // IdentityMatchExactRepository matches exact repository + IdentityMatchExactRepository IdentityMatch = "exactRepository" + // IdentityMatchRemap remaps identity + IdentityMatchRemap IdentityMatch = "remapIdentity" ) // SignedByKeyData represents GPG key data for signature verification @@ -122,7 +122,7 @@ func (r *PRSignedBy) Validate() error { // SignedIdentity represents identity matching rules type SignedIdentity struct { // Type is the identity match type - Type IdentityMatchType `json:"type"` + Type IdentityMatch `json:"type"` // DockerReference is used for certain match types DockerReference string `json:"dockerReference,omitempty"` // DockerRepository is used for certain match types @@ -136,23 +136,23 @@ type SignedIdentity struct { // Validate validates the signed identity configuration func (si *SignedIdentity) Validate() error { switch si.Type { - case MatchExact, MatchRepoDigestOrExact: + case IdentityMatchExact, IdentityMatchRepoDigestOrExact: // No additional fields required return nil - case MatchRepository: + case IdentityMatchRepository: // No additional fields required return nil - case ExactReference: + case IdentityMatchExactReference: if si.DockerReference == "" { return fmt.Errorf("dockerReference is required for exactReference type") } return nil - case ExactRepository: + case IdentityMatchExactRepository: if si.DockerRepository == "" { return fmt.Errorf("dockerRepository is required for exactRepository type") } return nil - case RemapIdentity: + case IdentityMatchRemap: if si.Prefix == "" || si.SignedPrefix == "" { return fmt.Errorf("both prefix and signedPrefix are required for remapIdentity type") } diff --git a/registry/remote/internal/configuration/requirement_test.go b/registry/remote/policy/requirement_test.go similarity index 91% rename from registry/remote/internal/configuration/requirement_test.go rename to registry/remote/policy/requirement_test.go index 6017855ca..9616247ce 100644 --- a/registry/remote/internal/configuration/requirement_test.go +++ b/registry/remote/policy/requirement_test.go @@ -13,7 +13,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package configuration +package policy import ( "testing" @@ -29,28 +29,28 @@ func TestSignedIdentity_ValidateAllTypes(t *testing.T) { { name: "matchExact valid", identity: &SignedIdentity{ - Type: MatchExact, + Type: IdentityMatchExact, }, wantErr: false, }, { name: "matchRepoDigestOrExact valid", identity: &SignedIdentity{ - Type: MatchRepoDigestOrExact, + Type: IdentityMatchRepoDigestOrExact, }, wantErr: false, }, { name: "matchRepository valid", identity: &SignedIdentity{ - Type: MatchRepository, + Type: IdentityMatchRepository, }, wantErr: false, }, { name: "exactReference valid", identity: &SignedIdentity{ - Type: ExactReference, + Type: IdentityMatchExactReference, DockerReference: "docker.io/library/nginx:latest", }, wantErr: false, @@ -58,14 +58,14 @@ func TestSignedIdentity_ValidateAllTypes(t *testing.T) { { name: "exactReference missing dockerReference", identity: &SignedIdentity{ - Type: ExactReference, + Type: IdentityMatchExactReference, }, wantErr: true, }, { name: "exactRepository valid", identity: &SignedIdentity{ - Type: ExactRepository, + Type: IdentityMatchExactRepository, DockerRepository: "docker.io/library/nginx", }, wantErr: false, @@ -73,14 +73,14 @@ func TestSignedIdentity_ValidateAllTypes(t *testing.T) { { name: "exactRepository missing dockerRepository", identity: &SignedIdentity{ - Type: ExactRepository, + Type: IdentityMatchExactRepository, }, wantErr: true, }, { name: "remapIdentity valid", identity: &SignedIdentity{ - Type: RemapIdentity, + Type: IdentityMatchRemap, Prefix: "docker.io/", SignedPrefix: "quay.io/", }, @@ -89,7 +89,7 @@ func TestSignedIdentity_ValidateAllTypes(t *testing.T) { { name: "remapIdentity missing prefix", identity: &SignedIdentity{ - Type: RemapIdentity, + Type: IdentityMatchRemap, SignedPrefix: "quay.io/", }, wantErr: true, @@ -97,7 +97,7 @@ func TestSignedIdentity_ValidateAllTypes(t *testing.T) { { name: "remapIdentity missing signedPrefix", identity: &SignedIdentity{ - Type: RemapIdentity, + Type: IdentityMatchRemap, Prefix: "docker.io/", }, wantErr: true, @@ -193,7 +193,7 @@ func TestPRSignedBy_ValidateKeySources(t *testing.T) { KeyType: "GPGKeys", KeyPath: "/path/to/key.gpg", SignedIdentity: &SignedIdentity{ - Type: MatchRepository, + Type: IdentityMatchRepository, }, }, wantErr: false, @@ -204,7 +204,7 @@ func TestPRSignedBy_ValidateKeySources(t *testing.T) { KeyType: "GPGKeys", KeyPath: "/path/to/key.gpg", SignedIdentity: &SignedIdentity{ - Type: ExactReference, + Type: IdentityMatchExactReference, // Missing DockerReference }, }, @@ -283,7 +283,7 @@ func TestPRSigstoreSigned_Validate(t *testing.T) { req: &PRSigstoreSigned{ KeyPath: "/path/key.pub", SignedIdentity: &SignedIdentity{ - Type: ExactRepository, + Type: IdentityMatchExactRepository, // Missing DockerRepository }, }, @@ -298,7 +298,7 @@ func TestPRSigstoreSigned_Validate(t *testing.T) { RekorPublicKeyPath: "/path/rekor.pub", RekorPublicKeyData: []byte("rekor key"), SignedIdentity: &SignedIdentity{ - Type: MatchExact, + Type: IdentityMatchExact, }, }, wantErr: false, @@ -389,15 +389,15 @@ func TestFulcioConfig_Validate(t *testing.T) { } } -// Test all IdentityMatchType constants -func TestIdentityMatchType_Constants(t *testing.T) { - types := []IdentityMatchType{ - MatchExact, - MatchRepoDigestOrExact, - MatchRepository, - ExactReference, - ExactRepository, - RemapIdentity, +// Test all IdentityMatch constants +func TestIdentityMatch_Constants(t *testing.T) { + types := []IdentityMatch{ + IdentityMatchExact, + IdentityMatchRepoDigestOrExact, + IdentityMatchRepository, + IdentityMatchExactReference, + IdentityMatchExactRepository, + IdentityMatchRemap, } for _, matchType := range types { @@ -406,11 +406,11 @@ func TestIdentityMatchType_Constants(t *testing.T) { // Add required fields based on type switch matchType { - case ExactReference: + case IdentityMatchExactReference: identity.DockerReference = "docker.io/library/nginx:latest" - case ExactRepository: + case IdentityMatchExactRepository: identity.DockerRepository = "docker.io/library/nginx" - case RemapIdentity: + case IdentityMatchRemap: identity.Prefix = "docker.io/" identity.SignedPrefix = "quay.io/" } @@ -483,7 +483,7 @@ func TestSignedIdentity_EmptyFields(t *testing.T) { { name: "exactReference with empty dockerReference", identity: &SignedIdentity{ - Type: ExactReference, + Type: IdentityMatchExactReference, DockerReference: "", }, wantErr: true, @@ -491,7 +491,7 @@ func TestSignedIdentity_EmptyFields(t *testing.T) { { name: "exactRepository with empty dockerRepository", identity: &SignedIdentity{ - Type: ExactRepository, + Type: IdentityMatchExactRepository, DockerRepository: "", }, wantErr: true, @@ -499,7 +499,7 @@ func TestSignedIdentity_EmptyFields(t *testing.T) { { name: "remapIdentity with empty prefix", identity: &SignedIdentity{ - Type: RemapIdentity, + Type: IdentityMatchRemap, Prefix: "", SignedPrefix: "quay.io/", }, @@ -508,7 +508,7 @@ func TestSignedIdentity_EmptyFields(t *testing.T) { { name: "remapIdentity with empty signedPrefix", identity: &SignedIdentity{ - Type: RemapIdentity, + Type: IdentityMatchRemap, Prefix: "docker.io/", SignedPrefix: "", }, diff --git a/registry/remote/policy/transport.go b/registry/remote/policy/transport.go new file mode 100644 index 000000000..b018c0ff9 --- /dev/null +++ b/registry/remote/policy/transport.go @@ -0,0 +1,42 @@ +/* +Copyright The ORAS 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 policy + +// TransportName represents a supported transport type +type TransportName string + +const ( + // TransportNameDocker represents the docker transport + TransportNameDocker TransportName = "docker" + // TransportNameAtomic represents the atomic transport + TransportNameAtomic TransportName = "atomic" + // TransportNameContainersStorage represents the containers-storage transport + TransportNameContainersStorage TransportName = "containers-storage" + // TransportNameDir represents the dir transport + TransportNameDir TransportName = "dir" + // TransportNameDockerArchive represents the docker-archive transport + TransportNameDockerArchive TransportName = "docker-archive" + // TransportNameDockerDaemon represents the docker-daemon transport + TransportNameDockerDaemon TransportName = "docker-daemon" + // TransportNameOCI represents the oci transport + TransportNameOCI TransportName = "oci" + // TransportNameOCIArchive represents the oci-archive transport + TransportNameOCIArchive TransportName = "oci-archive" + // TransportNameSIF represents the sif transport + TransportNameSIF TransportName = "sif" + // TransportNameTarball represents the tarball transport + TransportNameTarball TransportName = "tarball" +) diff --git a/registry/remote/repository.go b/registry/remote/repository.go index 422853cce..e7b1b8ad8 100644 --- a/registry/remote/repository.go +++ b/registry/remote/repository.go @@ -43,7 +43,7 @@ import ( "github.com/oras-project/oras-go/v3/registry" "github.com/oras-project/oras-go/v3/registry/remote/auth" "github.com/oras-project/oras-go/v3/registry/remote/errcode" - "github.com/oras-project/oras-go/v3/registry/remote/internal/configuration" + "github.com/oras-project/oras-go/v3/registry/remote/policy" "github.com/oras-project/oras-go/v3/registry/remote/internal/errutil" ) @@ -154,8 +154,12 @@ type Repository struct { // Policy is an optional policy evaluator for allow/deny decisions. // If nil, no policy enforcement is performed. + // Policies can be loaded from a file via [policy.Load] or constructed + // programmatically via [policy.NewPolicy]. The default file-based loading + // ([policy.LoadDefault]) uses platform-specific paths; see the policy + // package documentation for cross-platform details. // Reference: https://man.archlinux.org/man/containers-policy.json.5.en - Policy *configuration.Evaluator + Policy *policy.Evaluator // NOTE: Must keep fields in sync with clone(). @@ -296,8 +300,8 @@ func (r *Repository) checkPolicy(ctx context.Context, reference string) error { ref = reference } - imageRef := configuration.ImageReference{ - Transport: configuration.TransportDocker, + imageRef := policy.ImageReference{ + Transport: policy.TransportNameDocker, Scope: r.Reference.Repository, Reference: ref, } diff --git a/registry/remote/repository_policy_test.go b/registry/remote/repository_policy_test.go index 6ceac86a4..6f9b15d6b 100644 --- a/registry/remote/repository_policy_test.go +++ b/registry/remote/repository_policy_test.go @@ -17,13 +17,12 @@ package remote import ( "context" - "io" "strings" "testing" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/oras-project/oras-go/v3/registry" - "github.com/oras-project/oras-go/v3/registry/remote/internal/configuration" + "github.com/oras-project/oras-go/v3/registry/remote/policy" ) var testReference = registry.Reference{ @@ -46,10 +45,10 @@ func TestRepository_PolicyEnforcement(t *testing.T) { // Test with reject policy t.Run("reject policy", func(t *testing.T) { - pol := &configuration.Policy{ - Default: configuration.PolicyRequirements{&configuration.Reject{}}, + pol := &policy.Policy{ + Default: policy.PolicyRequirements{&policy.Reject{}}, } - evaluator, err := configuration.NewEvaluator(pol) + evaluator, err := policy.NewEvaluator(pol) if err != nil { t.Fatalf("failed to create evaluator: %v", err) } @@ -66,10 +65,10 @@ func TestRepository_PolicyEnforcement(t *testing.T) { // Test with accept policy t.Run("accept policy", func(t *testing.T) { - pol := &configuration.Policy{ - Default: configuration.PolicyRequirements{&configuration.InsecureAcceptAnything{}}, + pol := &policy.Policy{ + Default: policy.PolicyRequirements{&policy.InsecureAcceptAnything{}}, } - evaluator, err := configuration.NewEvaluator(pol) + evaluator, err := policy.NewEvaluator(pol) if err != nil { t.Fatalf("failed to create evaluator: %v", err) } @@ -83,10 +82,10 @@ func TestRepository_PolicyEnforcement(t *testing.T) { } func TestRepository_Clone_Policy(t *testing.T) { - pol := &configuration.Policy{ - Default: configuration.PolicyRequirements{&configuration.InsecureAcceptAnything{}}, + pol := &policy.Policy{ + Default: policy.PolicyRequirements{&policy.InsecureAcceptAnything{}}, } - evaluator, err := configuration.NewEvaluator(pol) + evaluator, err := policy.NewEvaluator(pol) if err != nil { t.Fatalf("failed to create evaluator: %v", err) } @@ -106,10 +105,10 @@ func TestRepository_Clone_Policy(t *testing.T) { func TestRepository_Fetch_PolicyCheck(t *testing.T) { // This test verifies that Fetch calls checkPolicy // We use a reject policy to ensure Fetch fails before attempting network calls - pol := &configuration.Policy{ - Default: configuration.PolicyRequirements{&configuration.Reject{}}, + pol := &policy.Policy{ + Default: policy.PolicyRequirements{&policy.Reject{}}, } - evaluator, err := configuration.NewEvaluator(pol) + evaluator, err := policy.NewEvaluator(pol) if err != nil { t.Fatalf("failed to create evaluator: %v", err) } @@ -136,10 +135,10 @@ func TestRepository_Fetch_PolicyCheck(t *testing.T) { func TestRepository_Push_PolicyCheck(t *testing.T) { // This test verifies that Push calls checkPolicy - pol := &configuration.Policy{ - Default: configuration.PolicyRequirements{&configuration.Reject{}}, + pol := &policy.Policy{ + Default: policy.PolicyRequirements{&policy.Reject{}}, } - evaluator, err := configuration.NewEvaluator(pol) + evaluator, err := policy.NewEvaluator(pol) if err != nil { t.Fatalf("failed to create evaluator: %v", err) } @@ -168,10 +167,10 @@ func TestRepository_Push_PolicyCheck(t *testing.T) { func TestRepository_Resolve_PolicyCheck(t *testing.T) { // This test verifies that Resolve calls checkPolicy - pol := &configuration.Policy{ - Default: configuration.PolicyRequirements{&configuration.Reject{}}, + pol := &policy.Policy{ + Default: policy.PolicyRequirements{&policy.Reject{}}, } - evaluator, err := configuration.NewEvaluator(pol) + evaluator, err := policy.NewEvaluator(pol) if err != nil { t.Fatalf("failed to create evaluator: %v", err) } @@ -192,17 +191,17 @@ func TestRepository_Resolve_PolicyCheck(t *testing.T) { func TestRepository_ScopeSpecificPolicy(t *testing.T) { // Test that scope-specific policies work correctly - pol := &configuration.Policy{ - Default: configuration.PolicyRequirements{&configuration.Reject{}}, - Transports: map[configuration.TransportName]configuration.TransportScopes{ - configuration.TransportDocker: { + pol := &policy.Policy{ + Default: policy.PolicyRequirements{&policy.Reject{}}, + Transports: map[policy.TransportName]policy.TransportScopes{ + policy.TransportNameDocker: { // Allow all docker repositories - "": configuration.PolicyRequirements{&configuration.InsecureAcceptAnything{}}, + "": policy.PolicyRequirements{&policy.InsecureAcceptAnything{}}, }, }, } - evaluator, err := configuration.NewEvaluator(pol) + evaluator, err := policy.NewEvaluator(pol) if err != nil { t.Fatalf("failed to create evaluator: %v", err) } @@ -218,12 +217,3 @@ func TestRepository_ScopeSpecificPolicy(t *testing.T) { t.Errorf("checkPolicy() should succeed for allowed docker transport, got: %v", err) } } - -// mockReadCloser is a simple mock for testing -type mockReadCloser struct { - io.Reader -} - -func (m *mockReadCloser) Close() error { - return nil -} From f548cdf8cfa5af2da68e242b122da151e9b83b89 Mon Sep 17 00:00:00 2001 From: Terry Howe Date: Sat, 28 Mar 2026 16:19:16 +0100 Subject: [PATCH 3/3] fix(policy): address PR review comments for spec compliance - Implement longest-prefix scope matching with wildcard subdomain support in GetRequirementsForImage per containers-policy.json spec - Enforce exactly one key source in PRSignedBy (keyPath, keyPaths, or keyData) per spec - Remove non-spec SignedByKeyData struct and KeyDatas field from PRSignedBy - Change PRSigstoreSigned.KeyDatas to []string per spec - Remove non-spec SigstoreKeyData struct - Require OIDCIssuer and SubjectEmail in FulcioConfig.Validate per spec - Validate that Policy.Default is non-empty per spec - Use 0600/0700 file permissions in Save for security policy files - Remove duplicate Load function, keep only LoadPolicy - Unexport policy path constants (policyConfUserDir, etc.) - Use errdef.ErrMissingReference sentinel for nil policy error Signed-off-by: Terry Howe --- registry/remote/policy/evaluator.go | 4 +- registry/remote/policy/example_test.go | 6 +- registry/remote/policy/policy.go | 102 ++++++++---- registry/remote/policy/policy_test.go | 172 +++++++++++++++++---- registry/remote/policy/requirement.go | 46 +++--- registry/remote/policy/requirement_test.go | 169 +++++++------------- 6 files changed, 298 insertions(+), 201 deletions(-) diff --git a/registry/remote/policy/evaluator.go b/registry/remote/policy/evaluator.go index 8ef125c58..39019bc66 100644 --- a/registry/remote/policy/evaluator.go +++ b/registry/remote/policy/evaluator.go @@ -75,7 +75,7 @@ func WithSigstoreVerifier(v SigstoreVerifier) EvaluatorOption { // NewEvaluator creates a new policy evaluator func NewEvaluator(policy *Policy, opts ...EvaluatorOption) (*Evaluator, error) { if policy == nil { - return nil, fmt.Errorf("policy cannot be nil") + return nil, fmt.Errorf("policy cannot be nil: %w", errdef.ErrMissingReference) } if err := policy.Validate(); err != nil { @@ -98,7 +98,7 @@ func (e *Evaluator) IsImageAllowed(ctx context.Context, image ImageReference) (b reqs := e.policy.GetRequirementsForImage(image.Transport, image.Scope) if len(reqs) == 0 { - // No requirements means reject by default for safety + // No requirements: treat as a policy error and reject by default for safety. return false, fmt.Errorf("no policy requirements found for %s:%s", image.Transport, image.Scope) } diff --git a/registry/remote/policy/example_test.go b/registry/remote/policy/example_test.go index dbc59b550..b4bd0feb4 100644 --- a/registry/remote/policy/example_test.go +++ b/registry/remote/policy/example_test.go @@ -67,8 +67,8 @@ func ExamplePolicy_signedBy() { // Output: Policy requires GPG signatures for docker.io/myorg } -// ExampleLoad demonstrates loading a policy from a file -func ExampleLoad() { +// ExampleLoadPolicy demonstrates loading a policy from a file +func ExampleLoadPolicy() { // Create a temporary policy file tmpDir := os.TempDir() policyPath := filepath.Join(tmpDir, "example-policy.json") @@ -89,7 +89,7 @@ func ExampleLoad() { defer os.Remove(policyPath) // Load the policy - loaded, err := policy.Load(policyPath) + loaded, err := policy.LoadPolicy(policyPath) if err != nil { log.Fatalf("Failed to load policy: %v", err) } diff --git a/registry/remote/policy/policy.go b/registry/remote/policy/policy.go index ead588556..737c0d33e 100644 --- a/registry/remote/policy/policy.go +++ b/registry/remote/policy/policy.go @@ -24,17 +24,14 @@ import ( "fmt" "os" "path/filepath" + "strings" ) const ( - // PolicyConfUserDir is the user-level configuration directory for policy.json - PolicyConfUserDir = ".config/containers" - // PolicyConfFileName is the name of the policy configuration file - PolicyConfFileName = "policy.json" - // PolicyConfSystemPath is the system-wide policy.json path (Linux only). - // On non-Linux platforms, this path is not used by default. - // Use [Load] or [LoadPolicy] with an explicit path for cross-platform usage. - PolicyConfSystemPath = "/etc/containers/policy.json" + // policyConfUserDir is the user-level configuration directory for policy.json + policyConfUserDir = ".config/containers" + // policyConfFileName is the name of the policy configuration file + policyConfFileName = "policy.json" ) // Policy represents a containers-policy.json configuration @@ -109,7 +106,7 @@ func (p *Policy) SetTransportScope(transport TransportName, scope string, reqs . // On Linux, the system-wide path is /etc/containers/policy.json. // On other platforms (macOS, Windows, etc.), only the user-level path // ($HOME/.config/containers/policy.json) is checked, since the system-wide -// path is Linux-specific. Use [Load] or [LoadPolicy] with an explicit path +// path is Linux-specific. Use [LoadPolicy] with an explicit path // for cross-platform usage. func GetDefaultPolicyPath() (string, error) { homeDir, err := os.UserHomeDir() @@ -118,7 +115,7 @@ func GetDefaultPolicyPath() (string, error) { } // Try user-specific path first (works on all platforms) - userPath := filepath.Join(homeDir, PolicyConfUserDir, PolicyConfFileName) + userPath := filepath.Join(homeDir, policyConfUserDir, policyConfFileName) if _, err := os.Stat(userPath); err == nil { return userPath, nil } @@ -131,11 +128,6 @@ func GetDefaultPolicyPath() (string, error) { return "", fmt.Errorf("no policy.json found at %s and no system-wide default is available on this platform", userPath) } -// Load loads a policy from the specified file path. -func Load(path string) (*Policy, error) { - return LoadPolicy(path) -} - // LoadPolicy loads a policy from the specified file path. func LoadPolicy(path string) (*Policy, error) { data, err := os.ReadFile(path) @@ -172,11 +164,11 @@ func (p *Policy) Save(path string) error { // Ensure directory exists dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0755); err != nil { + if err := os.MkdirAll(dir, 0700); err != nil { return fmt.Errorf("failed to create directory %s: %w", dir, err) } - if err := os.WriteFile(path, data, 0644); err != nil { + if err := os.WriteFile(path, data, 0600); err != nil { return fmt.Errorf("failed to write policy file %s: %w", path, err) } @@ -185,6 +177,10 @@ func (p *Policy) Save(path string) error { // Validate validates the policy configuration. func (p *Policy) Validate() error { + if len(p.Default) == 0 { + return fmt.Errorf("default policy requirements must not be empty") + } + // Validate default requirements for _, req := range p.Default { if err := req.Validate(); err != nil { @@ -206,22 +202,76 @@ func (p *Policy) Validate() error { return nil } +// isPathPrefix reports whether prefix is a path prefix of s, +// matching only at "/" boundaries. An empty prefix matches everything. +func isPathPrefix(s, prefix string) bool { + if prefix == "" { + return true + } + if !strings.HasPrefix(s, prefix) { + return false + } + // Exact match or the next character is a path separator + return len(s) == len(prefix) || s[len(prefix)] == '/' +} + // GetRequirementsForImage returns the policy requirements for a given transport and scope. -// It follows the precedence rules: specific scope > transport default > global default. +// It follows the precedence rules from the containers-policy.json spec: +// 1. Exact scope match +// 2. Longest prefix match (at "/" boundary) +// 3. Wildcard subdomain match (*.example.com matches sub.example.com/...) +// 4. Transport default (empty scope "") +// 5. Global default func (p *Policy) GetRequirementsForImage(transport TransportName, scope string) PolicyRequirements { - // Check for transport-specific scope - if transportScopes, ok := p.Transports[transport]; ok { - // Try exact scope match first - if reqs, ok := transportScopes[scope]; ok { - return reqs + transportScopes, ok := p.Transports[transport] + if !ok { + return p.Default + } + + // 1. Try exact scope match + if reqs, ok := transportScopes[scope]; ok { + return reqs + } + + // 2. Try longest prefix match (at "/" boundary) + bestMatch := "" + for candidate := range transportScopes { + if candidate == "" { + continue } + if strings.HasPrefix(candidate, "*.") { + continue // skip wildcards in prefix matching + } + if isPathPrefix(scope, candidate) && len(candidate) > len(bestMatch) { + bestMatch = candidate + } + } + if bestMatch != "" { + return transportScopes[bestMatch] + } - // Try transport default (empty scope) - if reqs, ok := transportScopes[""]; ok { + // 3. Try wildcard subdomain match (*.example.com matches sub.example.com/...) + for candidate, reqs := range transportScopes { + if !strings.HasPrefix(candidate, "*.") { + continue + } + // *.example.com should match sub.example.com and sub.example.com/repo + suffix := candidate[1:] // ".example.com" + // Extract the host part of the scope (before first "/") + host := scope + if idx := strings.Index(scope, "/"); idx != -1 { + host = scope[:idx] + } + if strings.HasSuffix(host, suffix) { return reqs } } - // Fall back to global default + // 4. Try transport default (empty scope) + if reqs, ok := transportScopes[""]; ok { + return reqs + } + + // 5. Fall back to global default return p.Default } diff --git a/registry/remote/policy/policy_test.go b/registry/remote/policy/policy_test.go index be3993c23..a3ff37eac 100644 --- a/registry/remote/policy/policy_test.go +++ b/registry/remote/policy/policy_test.go @@ -69,6 +69,78 @@ func TestPolicy_GetRequirementsForImage(t *testing.T) { scope: "docker.io/library/nginx", wantType: TypeReject, }, + { + name: "prefix match at path boundary", + policy: &Policy{ + Default: PolicyRequirements{&Reject{}}, + Transports: map[TransportName]TransportScopes{ + TransportNameDocker: { + "docker.io/myorg": PolicyRequirements{&InsecureAcceptAnything{}}, + }, + }, + }, + transport: TransportNameDocker, + scope: "docker.io/myorg/myrepo", + wantType: TypeInsecureAcceptAnything, + }, + { + name: "longest prefix wins", + policy: &Policy{ + Default: PolicyRequirements{&Reject{}}, + Transports: map[TransportName]TransportScopes{ + TransportNameDocker: { + "docker.io": PolicyRequirements{&InsecureAcceptAnything{}}, + "docker.io/myorg": PolicyRequirements{&Reject{}}, + "docker.io/myorg/myrepo": PolicyRequirements{&InsecureAcceptAnything{}}, + }, + }, + }, + transport: TransportNameDocker, + scope: "docker.io/myorg/myrepo", + wantType: TypeInsecureAcceptAnything, + }, + { + name: "prefix does not match partial path segment", + policy: &Policy{ + Default: PolicyRequirements{&Reject{}}, + Transports: map[TransportName]TransportScopes{ + TransportNameDocker: { + "docker.io/my": PolicyRequirements{&InsecureAcceptAnything{}}, + }, + }, + }, + transport: TransportNameDocker, + scope: "docker.io/myorg/myrepo", + wantType: TypeReject, + }, + { + name: "wildcard subdomain match", + policy: &Policy{ + Default: PolicyRequirements{&Reject{}}, + Transports: map[TransportName]TransportScopes{ + TransportNameDocker: { + "*.example.com": PolicyRequirements{&InsecureAcceptAnything{}}, + }, + }, + }, + transport: TransportNameDocker, + scope: "sub.example.com/myrepo", + wantType: TypeInsecureAcceptAnything, + }, + { + name: "wildcard does not match non-subdomain", + policy: &Policy{ + Default: PolicyRequirements{&Reject{}}, + Transports: map[TransportName]TransportScopes{ + TransportNameDocker: { + "*.example.com": PolicyRequirements{&InsecureAcceptAnything{}}, + }, + }, + }, + transport: TransportNameDocker, + scope: "notexample.com/myrepo", + wantType: TypeReject, + }, } for _, tt := range tests { @@ -150,7 +222,7 @@ func TestPolicy_SaveAndLoad(t *testing.T) { } // Load - loaded, err := Load(policyPath) + loaded, err := LoadPolicy(policyPath) if err != nil { t.Fatalf("failed to load policy: %v", err) } @@ -178,6 +250,13 @@ func TestPolicy_Validate(t *testing.T) { }, wantErr: false, }, + { + name: "empty default requirements", + policy: &Policy{ + Default: PolicyRequirements{}, + }, + wantErr: true, + }, { name: "invalid signedBy requirement", policy: &Policy{ @@ -357,7 +436,7 @@ func TestGetDefaultPolicyPath(t *testing.T) { path, err := GetDefaultPolicyPath() homeDir, _ := os.UserHomeDir() - userPath := filepath.Join(homeDir, PolicyConfUserDir, PolicyConfFileName) + userPath := filepath.Join(homeDir, policyConfUserDir, policyConfFileName) if _, statErr := os.Stat(userPath); statErr == nil { // User policy file exists — should succeed with that path @@ -372,8 +451,8 @@ func TestGetDefaultPolicyPath(t *testing.T) { if err != nil { t.Fatalf("GetDefaultPolicyPath() error = %v", err) } - if path != PolicyConfSystemPath { - t.Errorf("GetDefaultPolicyPath() = %v, want %v", path, PolicyConfSystemPath) + if path != systemPolicyPath { + t.Errorf("GetDefaultPolicyPath() = %v, want %v", path, systemPolicyPath) } } else { // No user policy and no system path (non-Linux) — should error @@ -391,8 +470,8 @@ func TestLoadDefault(t *testing.T) { t.Skip("Cannot get home directory") } - userPolicyDir := filepath.Join(homeDir, PolicyConfUserDir) - userPolicyPath := filepath.Join(userPolicyDir, PolicyConfFileName) + userPolicyDir := filepath.Join(homeDir, policyConfUserDir) + userPolicyPath := filepath.Join(userPolicyDir, policyConfFileName) // Clean up any existing test policy defer os.Remove(userPolicyPath) @@ -505,6 +584,13 @@ func TestNewEvaluator_Invalid(t *testing.T) { }, wantErr: true, }, + { + name: "invalid policy - empty default", + policy: &Policy{ + Default: PolicyRequirements{}, + }, + wantErr: true, + }, } for _, tt := range tests { @@ -596,16 +682,16 @@ func TestPolicy_ValidateTransportScopes(t *testing.T) { } } -// Test Load with non-existent file -func TestLoad_NonExistent(t *testing.T) { - _, err := Load("/nonexistent/path/policy.json") +// Test LoadPolicy with non-existent file +func TestLoadPolicy_NonExistent(t *testing.T) { + _, err := LoadPolicy("/nonexistent/path/policy.json") if err == nil { - t.Error("Load() should fail for non-existent file") + t.Error("LoadPolicy() should fail for non-existent file") } } -// Test Load with invalid JSON -func TestLoad_InvalidJSON(t *testing.T) { +// Test LoadPolicy with invalid JSON +func TestLoadPolicy_InvalidJSON(t *testing.T) { tmpDir := t.TempDir() policyPath := filepath.Join(tmpDir, "policy.json") @@ -614,9 +700,9 @@ func TestLoad_InvalidJSON(t *testing.T) { t.Fatalf("failed to write test file: %v", err) } - _, err := Load(policyPath) + _, err := LoadPolicy(policyPath) if err == nil { - t.Error("Load() should fail for invalid JSON") + t.Error("LoadPolicy() should fail for invalid JSON") } } @@ -654,7 +740,7 @@ func TestGetDefaultPolicyPath_NoUserPolicy(t *testing.T) { if path == "" { t.Error("GetDefaultPolicyPath() returned empty path") } - if path != PolicyConfSystemPath { + if path != systemPolicyPath { if !filepath.IsAbs(path) { t.Errorf("GetDefaultPolicyPath() returned non-absolute path: %s", path) } @@ -662,7 +748,7 @@ func TestGetDefaultPolicyPath_NoUserPolicy(t *testing.T) { } else { // On non-Linux without user policy, should return an error homeDir, _ := os.UserHomeDir() - userPath := filepath.Join(homeDir, PolicyConfUserDir, PolicyConfFileName) + userPath := filepath.Join(homeDir, policyConfUserDir, policyConfFileName) if _, statErr := os.Stat(userPath); statErr != nil { // User policy doesn't exist either — expect error if err == nil { @@ -701,10 +787,15 @@ func TestPolicy_MultipleRequirements(t *testing.T) { } } -// Test no requirements in policy -func TestEvaluator_NoRequirements(t *testing.T) { +// Test no requirements found for scope after matching +func TestEvaluator_NoRequirementsForScope(t *testing.T) { policy := &Policy{ - Default: PolicyRequirements{}, + Default: PolicyRequirements{&Reject{}}, + Transports: map[TransportName]TransportScopes{ + TransportNameDocker: { + "docker.io/library/nginx": PolicyRequirements{}, + }, + }, } evaluator, err := NewEvaluator(policy) @@ -720,7 +811,7 @@ func TestEvaluator_NoRequirements(t *testing.T) { _, err = evaluator.IsImageAllowed(context.Background(), image) if err == nil { - t.Error("IsImageAllowed() should fail when no requirements are defined") + t.Error("IsImageAllowed() should fail when no requirements are defined for the scope") } } @@ -780,17 +871,11 @@ func TestPolicyRequirements_JSONRoundTrip(t *testing.T) { reqs: PolicyRequirements{&Reject{}}, }, { - name: "signedBy with all fields", + name: "signedBy with keyPath", reqs: PolicyRequirements{ &PRSignedBy{ - KeyType: "GPGKeys", - KeyPath: "/path/to/key.gpg", - KeyData: "key data", - KeyPaths: []string{"/path1", "/path2"}, - KeyDatas: []SignedByKeyData{ - {KeyPath: "/path/key1.gpg"}, - {KeyData: "inline key data"}, - }, + KeyType: "GPGKeys", + KeyPath: "/path/to/key.gpg", SignedIdentity: &SignedIdentity{ Type: IdentityMatchExact, }, @@ -802,11 +887,6 @@ func TestPolicyRequirements_JSONRoundTrip(t *testing.T) { reqs: PolicyRequirements{ &PRSigstoreSigned{ KeyPath: "/path/to/key.pub", - KeyData: []byte("key data"), - KeyDatas: []SigstoreKeyData{ - {PublicKeyFile: "/path/key.pub"}, - {PublicKeyData: []byte("inline key")}, - }, Fulcio: &FulcioConfig{ CAPath: "/path/ca.pem", CAData: []byte("ca data"), @@ -908,3 +988,27 @@ func TestPolicyRequirements_UnmarshalJSON_Errors(t *testing.T) { }) } } + +func TestIsPathPrefix(t *testing.T) { + tests := []struct { + s string + prefix string + want bool + }{ + {"docker.io/myorg/myrepo", "docker.io/myorg", true}, + {"docker.io/myorg/myrepo", "docker.io/myorg/myrepo", true}, + {"docker.io/myorg/myrepo", "docker.io/my", false}, + {"docker.io/myorg", "docker.io/myorg/myrepo", false}, + {"docker.io/myorg/myrepo", "", true}, + {"docker.io/myorg", "docker.io/myorg", true}, + } + + for _, tt := range tests { + t.Run(tt.s+"_"+tt.prefix, func(t *testing.T) { + got := isPathPrefix(tt.s, tt.prefix) + if got != tt.want { + t.Errorf("isPathPrefix(%q, %q) = %v, want %v", tt.s, tt.prefix, got, tt.want) + } + }) + } +} diff --git a/registry/remote/policy/requirement.go b/registry/remote/policy/requirement.go index 15f39b1dc..a612a2057 100644 --- a/registry/remote/policy/requirement.go +++ b/registry/remote/policy/requirement.go @@ -75,14 +75,6 @@ const ( IdentityMatchRemap IdentityMatch = "remapIdentity" ) -// SignedByKeyData represents GPG key data for signature verification -type SignedByKeyData struct { - // KeyPath is the path to the GPG key file - KeyPath string `json:"keyPath,omitempty"` - // KeyData is the inline GPG key data - KeyData string `json:"keyData,omitempty"` -} - // PRSignedBy represents a simple signing policy requirement type PRSignedBy struct { // KeyType specifies the type of key (e.g., "GPGKeys") @@ -93,8 +85,6 @@ type PRSignedBy struct { KeyData string `json:"keyData,omitempty"` // KeyPaths is a list of key paths (alternative to KeyPath) KeyPaths []string `json:"keyPaths,omitempty"` - // KeyDatas is a list of inline key data (alternative to KeyData) - KeyDatas []SignedByKeyData `json:"keyDatas,omitempty"` // SignedIdentity specifies the identity matching rules SignedIdentity *SignedIdentity `json:"signedIdentity,omitempty"` } @@ -110,10 +100,19 @@ func (r *PRSignedBy) Validate() error { return fmt.Errorf("keyType is required") } - // Validate that at least one key source is provided - hasKey := r.KeyPath != "" || r.KeyData != "" || len(r.KeyPaths) > 0 || len(r.KeyDatas) > 0 - if !hasKey { - return fmt.Errorf("at least one key source (keyPath, keyData, keyPaths, or keyDatas) must be specified") + // Exactly one of keyPath, keyData, or keyPaths must be specified + count := 0 + if r.KeyPath != "" { + count++ + } + if r.KeyData != "" { + count++ + } + if len(r.KeyPaths) > 0 { + count++ + } + if count != 1 { + return fmt.Errorf("exactly one of keyPath, keyData, or keyPaths must be specified") } return validateSignedIdentity(r.SignedIdentity) @@ -172,22 +171,14 @@ func validateSignedIdentity(si *SignedIdentity) error { return nil } -// SigstoreKeyData represents a sigstore public key -type SigstoreKeyData struct { - // PublicKeyFile is the path to the public key file - PublicKeyFile string `json:"publicKeyFile,omitempty"` - // PublicKeyData is inline public key data - PublicKeyData []byte `json:"publicKeyData,omitempty"` -} - // PRSigstoreSigned represents a sigstore signature policy requirement type PRSigstoreSigned struct { // KeyPath is the path to the public key KeyPath string `json:"keyPath,omitempty"` // KeyData is inline public key data KeyData []byte `json:"keyData,omitempty"` - // KeyDatas is a list of key data - KeyDatas []SigstoreKeyData `json:"keyDatas,omitempty"` + // KeyDatas is a list of inline public key data + KeyDatas []string `json:"keyDatas,omitempty"` // Fulcio specifies Fulcio certificate verification Fulcio *FulcioConfig `json:"fulcio,omitempty"` // RekorPublicKeyPath is the path to the Rekor public key @@ -239,6 +230,13 @@ func (fc *FulcioConfig) Validate() error { return fmt.Errorf("either caPath or caData must be specified") } + if fc.OIDCIssuer == "" { + return fmt.Errorf("oidcIssuer is required for fulcio configuration") + } + if fc.SubjectEmail == "" { + return fmt.Errorf("subjectEmail is required for fulcio configuration") + } + return nil } diff --git a/registry/remote/policy/requirement_test.go b/registry/remote/policy/requirement_test.go index 9616247ce..29557a8f9 100644 --- a/registry/remote/policy/requirement_test.go +++ b/registry/remote/policy/requirement_test.go @@ -153,25 +153,22 @@ func TestPRSignedBy_ValidateKeySources(t *testing.T) { wantErr: false, }, { - name: "valid with keyDatas", + name: "invalid with multiple key sources (keyPath and keyData)", req: &PRSignedBy{ KeyType: "GPGKeys", - KeyDatas: []SignedByKeyData{ - {KeyPath: "/path/key.gpg"}, - }, + KeyPath: "/path/key.gpg", + KeyData: "inline data", }, - wantErr: false, + wantErr: true, }, { - name: "valid with multiple key sources", + name: "invalid with multiple key sources (keyPath and keyPaths)", req: &PRSignedBy{ KeyType: "GPGKeys", KeyPath: "/path/key.gpg", - KeyData: "inline data", KeyPaths: []string{"/another.gpg"}, - KeyDatas: []SignedByKeyData{{KeyData: "more data"}}, }, - wantErr: false, + wantErr: true, }, { name: "missing keyType", @@ -246,9 +243,7 @@ func TestPRSigstoreSigned_Validate(t *testing.T) { { name: "valid with keyDatas", req: &PRSigstoreSigned{ - KeyDatas: []SigstoreKeyData{ - {PublicKeyFile: "/path/key.pub"}, - }, + KeyDatas: []string{"key1data", "key2data"}, }, wantErr: false, }, @@ -256,7 +251,9 @@ func TestPRSigstoreSigned_Validate(t *testing.T) { name: "valid with fulcio", req: &PRSigstoreSigned{ Fulcio: &FulcioConfig{ - CAPath: "/path/ca.pem", + CAPath: "/path/ca.pem", + OIDCIssuer: "https://oauth.example.com", + SubjectEmail: "user@example.com", }, }, wantErr: false, @@ -269,10 +266,30 @@ func TestPRSigstoreSigned_Validate(t *testing.T) { wantErr: true, }, { - name: "invalid fulcio config", + name: "invalid fulcio config - missing CA", + req: &PRSigstoreSigned{ + Fulcio: &FulcioConfig{ + OIDCIssuer: "https://oauth.example.com", + SubjectEmail: "user@example.com", + }, + }, + wantErr: true, + }, + { + name: "invalid fulcio config - missing oidcIssuer", req: &PRSigstoreSigned{ Fulcio: &FulcioConfig{ - // Missing both CAPath and CAData + CAPath: "/path/ca.pem", + SubjectEmail: "user@example.com", + }, + }, + wantErr: true, + }, + { + name: "invalid fulcio config - missing subjectEmail", + req: &PRSigstoreSigned{ + Fulcio: &FulcioConfig{ + CAPath: "/path/ca.pem", OIDCIssuer: "https://oauth.example.com", }, }, @@ -290,11 +307,9 @@ func TestPRSigstoreSigned_Validate(t *testing.T) { wantErr: true, }, { - name: "valid with all optional fields", + name: "valid with keyPath and optional fields", req: &PRSigstoreSigned{ KeyPath: "/path/key.pub", - KeyData: []byte("key data"), - KeyDatas: []SigstoreKeyData{{PublicKeyFile: "/key.pub"}}, RekorPublicKeyPath: "/path/rekor.pub", RekorPublicKeyData: []byte("rekor key"), SignedIdentity: &SignedIdentity{ @@ -333,24 +348,20 @@ func TestFulcioConfig_Validate(t *testing.T) { wantErr bool }{ { - name: "valid with CAPath", - config: &FulcioConfig{ - CAPath: "/path/ca.pem", - }, - wantErr: false, - }, - { - name: "valid with CAData", + name: "valid with CAPath and required fields", config: &FulcioConfig{ - CAData: []byte("ca certificate data"), + CAPath: "/path/ca.pem", + OIDCIssuer: "https://oauth.example.com", + SubjectEmail: "user@example.com", }, wantErr: false, }, { - name: "valid with both CAPath and CAData", + name: "valid with CAData and required fields", config: &FulcioConfig{ - CAPath: "/path/ca.pem", - CAData: []byte("ca data"), + CAData: []byte("ca certificate data"), + OIDCIssuer: "https://oauth.example.com", + SubjectEmail: "user@example.com", }, wantErr: false, }, @@ -377,6 +388,22 @@ func TestFulcioConfig_Validate(t *testing.T) { }, wantErr: true, }, + { + name: "missing oidcIssuer", + config: &FulcioConfig{ + CAPath: "/path/ca.pem", + SubjectEmail: "user@example.com", + }, + wantErr: true, + }, + { + name: "missing subjectEmail", + config: &FulcioConfig{ + CAPath: "/path/ca.pem", + OIDCIssuer: "https://oauth.example.com", + }, + wantErr: true, + }, } for _, tt := range tests { @@ -444,35 +471,6 @@ func TestRequirementType_Constants(t *testing.T) { } } -// Test KeyData structures -func TestSignedByKeyData_Structure(t *testing.T) { - keyData := SignedByKeyData{ - KeyPath: "/path/to/key.gpg", - KeyData: "inline key data", - } - - if keyData.KeyPath != "/path/to/key.gpg" { - t.Errorf("KeyPath not preserved") - } - if keyData.KeyData != "inline key data" { - t.Errorf("KeyData not preserved") - } -} - -func TestSigstoreKeyData_Structure(t *testing.T) { - keyData := SigstoreKeyData{ - PublicKeyFile: "/path/to/key.pub", - PublicKeyData: []byte("inline key data"), - } - - if keyData.PublicKeyFile != "/path/to/key.pub" { - t.Errorf("PublicKeyFile not preserved") - } - if string(keyData.PublicKeyData) != "inline key data" { - t.Errorf("PublicKeyData not preserved") - } -} - // Test edge case: empty string fields func TestSignedIdentity_EmptyFields(t *testing.T) { tests := []struct { @@ -525,56 +523,3 @@ func TestSignedIdentity_EmptyFields(t *testing.T) { }) } } - -// Test complex PRSignedBy with SignedByKeyData variations -func TestPRSignedBy_KeyDatasVariations(t *testing.T) { - tests := []struct { - name string - keyData []SignedByKeyData - wantErr bool - }{ - { - name: "keyData with path", - keyData: []SignedByKeyData{ - {KeyPath: "/path/key1.gpg"}, - }, - wantErr: false, - }, - { - name: "keyData with data", - keyData: []SignedByKeyData{ - {KeyData: "inline key"}, - }, - wantErr: false, - }, - { - name: "keyData with both", - keyData: []SignedByKeyData{ - {KeyPath: "/path/key.gpg", KeyData: "inline key"}, - }, - wantErr: false, - }, - { - name: "multiple keyDatas", - keyData: []SignedByKeyData{ - {KeyPath: "/path/key1.gpg"}, - {KeyData: "inline key"}, - {KeyPath: "/path/key2.gpg", KeyData: "more data"}, - }, - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - req := &PRSignedBy{ - KeyType: "GPGKeys", - KeyDatas: tt.keyData, - } - err := req.Validate() - if (err != nil) != tt.wantErr { - t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -}