diff --git a/registry/remote/policy/evaluator.go b/registry/remote/policy/evaluator.go new file mode 100644 index 00000000..39019bc6 --- /dev/null +++ b/registry/remote/policy/evaluator.go @@ -0,0 +1,169 @@ +/* +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" + "fmt" + + "github.com/oras-project/oras-go/v3/errdef" +) + +// 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 +} + +// 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 + 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, opts ...EvaluatorOption) (*Evaluator, error) { + if policy == nil { + return nil, fmt.Errorf("policy cannot be nil: %w", errdef.ErrMissingReference) + } + + if err := policy.Validate(); err != nil { + return nil, fmt.Errorf("invalid policy: %w", err) + } + + e := &Evaluator{ + policy: policy, + } + + for _, opt := range opts { + opt(e) + } + + return e, 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: 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) + } + + // 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 +func (e *Evaluator) evaluateSignedBy(ctx context.Context, req *PRSignedBy, image ImageReference) (bool, error) { + 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 +func (e *Evaluator) evaluateSigstoreSigned(ctx context.Context, req *PRSigstoreSigned, image ImageReference) (bool, error) { + 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 +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/policy/evaluator_test.go b/registry/remote/policy/evaluator_test.go new file mode 100644 index 00000000..a65dbba0 --- /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/policy/example_test.go b/registry/remote/policy/example_test.go new file mode 100644 index 00000000..b4bd0feb --- /dev/null +++ b/registry/remote/policy/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 policy_test + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + + "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 := &policy.Policy{ + Default: policy.PolicyRequirements{&policy.Reject{}}, + } + + // Add a transport-specific policy for docker that accepts anything + p.Transports = map[policy.TransportName]policy.TransportScopes{ + policy.TransportNameDocker: { + "": policy.PolicyRequirements{&policy.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 := &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: &policy.SignedIdentity{ + Type: policy.IdentityMatchRepository, + }, + }, + }, + }, + }, + } + _ = 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 := &policy.Policy{ + Default: policy.PolicyRequirements{&policy.Reject{}}, + Transports: map[policy.TransportName]policy.TransportScopes{ + policy.TransportNameDocker: { + "": policy.PolicyRequirements{&policy.InsecureAcceptAnything{}}, + }, + }, + } + + if err := p.Save(policyPath); err != nil { + log.Fatalf("Failed to save policy: %v", err) + } + defer os.Remove(policyPath) + + // Load the policy + loaded, err := policy.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 := &policy.Policy{ + Default: policy.PolicyRequirements{&policy.InsecureAcceptAnything{}}, + } + + // Create an evaluator + evaluator, err := policy.NewEvaluator(p) + if err != nil { + log.Fatalf("Failed to create evaluator: %v", err) + } + + // Check if an image is allowed + image := policy.ImageReference{ + Transport: policy.TransportNameDocker, + 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 := &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(policy.TransportNameDocker, "docker.io/library/nginx") + fmt.Printf("Nginx requirements: %s\n", nginxReqs[0].Type()) + + // Get requirements for other docker images + otherReqs := p.GetRequirementsForImage(policy.TransportNameDocker, "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 := &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: &policy.FulcioConfig{ + CAPath: "/path/to/fulcio-ca.pem", + OIDCIssuer: "https://oauth2.sigstore.dev/auth", + SubjectEmail: "user@example.com", + }, + RekorPublicKeyPath: "/path/to/rekor.pub", + SignedIdentity: &policy.SignedIdentity{ + Type: policy.IdentityMatchRepository, + }, + }, + }, + }, + }, + } + _ = 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/policy/policy.go b/registry/remote/policy/policy.go new file mode 100644 index 00000000..737c0d33 --- /dev/null +++ b/registry/remote/policy/policy.go @@ -0,0 +1,277 @@ +/* +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 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 policy + +import ( + "encoding/json" + "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" +) + +// 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 +} + +// 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 +// 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 [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 (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 (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) +} + +// 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 +} + +// 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 + } + + return LoadPolicy(path) +} + +// 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) + } + + // Ensure directory exists + dir := filepath.Dir(path) + 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, 0600); 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 { + 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 { + 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 +} + +// 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 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 { + 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] + } + + // 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 + } + } + + // 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_linux.go b/registry/remote/policy/policy_linux.go new file mode 100644 index 00000000..3c5f2eee --- /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 00000000..5ecddf71 --- /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/policy/policy_test.go b/registry/remote/policy/policy_test.go new file mode 100644 index 00000000..a3ff37ea --- /dev/null +++ b/registry/remote/policy/policy_test.go @@ -0,0 +1,1014 @@ +/* +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" + "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: TransportNameDocker, + scope: "docker.io/library/nginx", + wantType: TypeReject, + }, + { + name: "transport default", + policy: &Policy{ + Default: PolicyRequirements{&Reject{}}, + Transports: map[TransportName]TransportScopes{ + TransportNameDocker: { + "": PolicyRequirements{&InsecureAcceptAnything{}}, + }, + }, + }, + transport: TransportNameDocker, + scope: "docker.io/library/nginx", + wantType: TypeInsecureAcceptAnything, + }, + { + name: "specific scope", + policy: &Policy{ + Default: PolicyRequirements{&Reject{}}, + Transports: map[TransportName]TransportScopes{ + TransportNameDocker: { + "": PolicyRequirements{&InsecureAcceptAnything{}}, + "docker.io/library/nginx": PolicyRequirements{&Reject{}}, + }, + }, + }, + transport: TransportNameDocker, + 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 { + 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{ + TransportNameDocker: { + "": PolicyRequirements{&InsecureAcceptAnything{}}, + "docker.io/library/nginx": PolicyRequirements{ + &PRSignedBy{ + KeyType: "GPGKeys", + KeyPath: "/path/to/key.gpg", + SignedIdentity: &SignedIdentity{ + Type: IdentityMatchExact, + }, + }, + }, + }, + }, + } + + // 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[TransportNameDocker] + 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{ + TransportNameDocker: { + "": PolicyRequirements{&InsecureAcceptAnything{}}, + }, + }, + } + + // Save + if err := original.Save(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[TransportNameDocker][""]) != 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: "empty default requirements", + policy: &Policy{ + Default: PolicyRequirements{}, + }, + wantErr: true, + }, + { + 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: IdentityMatchExactReference, + // 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: TransportNameDocker, + 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: TransportNameDocker, + 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: TransportNameDocker, + 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() + + homeDir, _ := os.UserHomeDir() + userPath := filepath.Join(homeDir, policyConfUserDir, policyConfFileName) + + 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 != systemPolicyPath { + t.Errorf("GetDefaultPolicyPath() = %v, want %v", path, systemPolicyPath) + } + } 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 LoadDefault +func TestLoadDefault(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 := testPolicy.Save(userPolicyPath); err != nil { + t.Fatalf("failed to save test policy: %v", err) + } + + // Test LoadDefault + loaded, err := LoadDefault() + if err != nil { + t.Errorf("LoadDefault() error = %v", err) + } + if loaded == nil { + t.Error("LoadDefault() 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: TransportNameDocker, + 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: TransportNameDocker, + Scope: "docker.io/library/nginx", + Reference: "docker.io/library/nginx:latest", + }, + wantResult: false, + wantErr: false, + }, + { + name: "nil policy", + policy: nil, + image: ImageReference{ + Transport: TransportNameDocker, + 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, + }, + { + name: "invalid policy - empty default", + policy: &Policy{ + Default: PolicyRequirements{}, + }, + 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: TransportNameDocker, + 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{ + TransportNameDocker: { + "docker.io": PolicyRequirements{&InsecureAcceptAnything{}}, + }, + }, + }, + wantErr: false, + }, + { + name: "invalid transport requirement", + policy: &Policy{ + Default: PolicyRequirements{&Reject{}}, + Transports: map[TransportName]TransportScopes{ + TransportNameDocker: { + "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 Policy.Save with invalid path +func TestPolicy_Save_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 := policy.Save(policyPath) + if err == nil { + t.Error("Save() should fail for read-only directory") + } +} + +// Test GetDefaultPolicyPath when user policy doesn't exist +func TestGetDefaultPolicyPath_NoUserPolicy(t *testing.T) { + path, err := GetDefaultPolicyPath() + + 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 != systemPolicyPath { + 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") + } + } + } +} + +// 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: TransportNameDocker, + 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 found for scope after matching +func TestEvaluator_NoRequirementsForScope(t *testing.T) { + policy := &Policy{ + Default: PolicyRequirements{&Reject{}}, + Transports: map[TransportName]TransportScopes{ + TransportNameDocker: { + "docker.io/library/nginx": PolicyRequirements{}, + }, + }, + } + + evaluator, err := NewEvaluator(policy) + if err != nil { + t.Fatalf("failed to create evaluator: %v", err) + } + + image := ImageReference{ + Transport: TransportNameDocker, + 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 for the scope") + } +} + +// Test all transport types +func TestPolicy_AllTransports(t *testing.T) { + transports := []TransportName{ + TransportNameDocker, + TransportNameAtomic, + TransportNameContainersStorage, + TransportNameDir, + TransportNameDockerArchive, + TransportNameDockerDaemon, + TransportNameOCI, + TransportNameOCIArchive, + TransportNameSIF, + TransportNameTarball, + } + + 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 keyPath", + reqs: PolicyRequirements{ + &PRSignedBy{ + KeyType: "GPGKeys", + KeyPath: "/path/to/key.gpg", + SignedIdentity: &SignedIdentity{ + Type: IdentityMatchExact, + }, + }, + }, + }, + { + name: "sigstoreSigned with fulcio", + reqs: PolicyRequirements{ + &PRSigstoreSigned{ + KeyPath: "/path/to/key.pub", + 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: IdentityMatchRepository, + }, + }, + }, + }, + { + 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) + } + }) + } +} + +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 new file mode 100644 index 00000000..a612a205 --- /dev/null +++ b/registry/remote/policy/requirement.go @@ -0,0 +1,337 @@ +/* +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 ( + "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 +} + +// IdentityMatch represents the type of identity matching +type IdentityMatch string + +const ( + // 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" +) + +// 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"` + // 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") + } + + // 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) +} + +// SignedIdentity represents identity matching rules +type SignedIdentity struct { + // Type is the identity match type + Type IdentityMatch `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 IdentityMatchExact, IdentityMatchRepoDigestOrExact: + // No additional fields required + return nil + case IdentityMatchRepository: + // No additional fields required + return nil + case IdentityMatchExactReference: + if si.DockerReference == "" { + return fmt.Errorf("dockerReference is required for exactReference type") + } + return nil + case IdentityMatchExactRepository: + if si.DockerRepository == "" { + return fmt.Errorf("dockerRepository is required for exactRepository type") + } + return nil + case IdentityMatchRemap: + 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 +} + +// 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 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 + 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") + } + + 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 +} + +// 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/policy/requirement_test.go b/registry/remote/policy/requirement_test.go new file mode 100644 index 00000000..29557a8f --- /dev/null +++ b/registry/remote/policy/requirement_test.go @@ -0,0 +1,525 @@ +/* +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 ( + "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: IdentityMatchExact, + }, + wantErr: false, + }, + { + name: "matchRepoDigestOrExact valid", + identity: &SignedIdentity{ + Type: IdentityMatchRepoDigestOrExact, + }, + wantErr: false, + }, + { + name: "matchRepository valid", + identity: &SignedIdentity{ + Type: IdentityMatchRepository, + }, + wantErr: false, + }, + { + name: "exactReference valid", + identity: &SignedIdentity{ + Type: IdentityMatchExactReference, + DockerReference: "docker.io/library/nginx:latest", + }, + wantErr: false, + }, + { + name: "exactReference missing dockerReference", + identity: &SignedIdentity{ + Type: IdentityMatchExactReference, + }, + wantErr: true, + }, + { + name: "exactRepository valid", + identity: &SignedIdentity{ + Type: IdentityMatchExactRepository, + DockerRepository: "docker.io/library/nginx", + }, + wantErr: false, + }, + { + name: "exactRepository missing dockerRepository", + identity: &SignedIdentity{ + Type: IdentityMatchExactRepository, + }, + wantErr: true, + }, + { + name: "remapIdentity valid", + identity: &SignedIdentity{ + Type: IdentityMatchRemap, + Prefix: "docker.io/", + SignedPrefix: "quay.io/", + }, + wantErr: false, + }, + { + name: "remapIdentity missing prefix", + identity: &SignedIdentity{ + Type: IdentityMatchRemap, + SignedPrefix: "quay.io/", + }, + wantErr: true, + }, + { + name: "remapIdentity missing signedPrefix", + identity: &SignedIdentity{ + Type: IdentityMatchRemap, + 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: "invalid with multiple key sources (keyPath and keyData)", + req: &PRSignedBy{ + KeyType: "GPGKeys", + KeyPath: "/path/key.gpg", + KeyData: "inline data", + }, + wantErr: true, + }, + { + name: "invalid with multiple key sources (keyPath and keyPaths)", + req: &PRSignedBy{ + KeyType: "GPGKeys", + KeyPath: "/path/key.gpg", + KeyPaths: []string{"/another.gpg"}, + }, + wantErr: true, + }, + { + 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: IdentityMatchRepository, + }, + }, + wantErr: false, + }, + { + name: "invalid signed identity", + req: &PRSignedBy{ + KeyType: "GPGKeys", + KeyPath: "/path/to/key.gpg", + SignedIdentity: &SignedIdentity{ + Type: IdentityMatchExactReference, + // 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: []string{"key1data", "key2data"}, + }, + wantErr: false, + }, + { + name: "valid with fulcio", + req: &PRSigstoreSigned{ + Fulcio: &FulcioConfig{ + CAPath: "/path/ca.pem", + OIDCIssuer: "https://oauth.example.com", + SubjectEmail: "user@example.com", + }, + }, + wantErr: false, + }, + { + name: "missing verification method", + req: &PRSigstoreSigned{ + RekorPublicKeyPath: "/path/rekor.pub", + }, + wantErr: true, + }, + { + 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{ + 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", + }, + }, + wantErr: true, + }, + { + name: "invalid signed identity", + req: &PRSigstoreSigned{ + KeyPath: "/path/key.pub", + SignedIdentity: &SignedIdentity{ + Type: IdentityMatchExactRepository, + // Missing DockerRepository + }, + }, + wantErr: true, + }, + { + name: "valid with keyPath and optional fields", + req: &PRSigstoreSigned{ + KeyPath: "/path/key.pub", + RekorPublicKeyPath: "/path/rekor.pub", + RekorPublicKeyData: []byte("rekor key"), + SignedIdentity: &SignedIdentity{ + Type: IdentityMatchExact, + }, + }, + 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 and required fields", + config: &FulcioConfig{ + CAPath: "/path/ca.pem", + OIDCIssuer: "https://oauth.example.com", + SubjectEmail: "user@example.com", + }, + wantErr: false, + }, + { + name: "valid with CAData and required fields", + config: &FulcioConfig{ + CAData: []byte("ca certificate data"), + OIDCIssuer: "https://oauth.example.com", + SubjectEmail: "user@example.com", + }, + 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, + }, + { + 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 { + 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 IdentityMatch constants +func TestIdentityMatch_Constants(t *testing.T) { + types := []IdentityMatch{ + IdentityMatchExact, + IdentityMatchRepoDigestOrExact, + IdentityMatchRepository, + IdentityMatchExactReference, + IdentityMatchExactRepository, + IdentityMatchRemap, + } + + 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 IdentityMatchExactReference: + identity.DockerReference = "docker.io/library/nginx:latest" + case IdentityMatchExactRepository: + identity.DockerRepository = "docker.io/library/nginx" + case IdentityMatchRemap: + 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 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: IdentityMatchExactReference, + DockerReference: "", + }, + wantErr: true, + }, + { + name: "exactRepository with empty dockerRepository", + identity: &SignedIdentity{ + Type: IdentityMatchExactRepository, + DockerRepository: "", + }, + wantErr: true, + }, + { + name: "remapIdentity with empty prefix", + identity: &SignedIdentity{ + Type: IdentityMatchRemap, + Prefix: "", + SignedPrefix: "quay.io/", + }, + wantErr: true, + }, + { + name: "remapIdentity with empty signedPrefix", + identity: &SignedIdentity{ + Type: IdentityMatchRemap, + 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) + } + }) + } +} diff --git a/registry/remote/policy/transport.go b/registry/remote/policy/transport.go new file mode 100644 index 00000000..b018c0ff --- /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 21cdb722..e7b1b8ad 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/policy" "github.com/oras-project/oras-go/v3/registry/remote/internal/errutil" ) @@ -151,6 +152,15 @@ 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. + // 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 *policy.Evaluator + // NOTE: Must keep fields in sync with clone(). // referrersState represents that if the repository supports Referrers API. @@ -207,6 +217,7 @@ func (r *Repository) clone() *Repository { MaxMetadataBytes: r.MaxMetadataBytes, SkipReferrersGC: r.SkipReferrersGC, HandleWarning: r.HandleWarning, + Policy: r.Policy, } } @@ -277,13 +288,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 := policy.ImageReference{ + Transport: policy.TransportNameDocker, + 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 +369,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 00000000..6f9b15d6 --- /dev/null +++ b/registry/remote/repository_policy_test.go @@ -0,0 +1,219 @@ +/* +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" + "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/policy" +) + +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 := &policy.Policy{ + Default: policy.PolicyRequirements{&policy.Reject{}}, + } + evaluator, err := policy.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 := &policy.Policy{ + Default: policy.PolicyRequirements{&policy.InsecureAcceptAnything{}}, + } + evaluator, err := policy.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 := &policy.Policy{ + Default: policy.PolicyRequirements{&policy.InsecureAcceptAnything{}}, + } + evaluator, err := policy.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 := &policy.Policy{ + Default: policy.PolicyRequirements{&policy.Reject{}}, + } + evaluator, err := policy.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 := &policy.Policy{ + Default: policy.PolicyRequirements{&policy.Reject{}}, + } + evaluator, err := policy.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 := &policy.Policy{ + Default: policy.PolicyRequirements{&policy.Reject{}}, + } + evaluator, err := policy.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 := &policy.Policy{ + Default: policy.PolicyRequirements{&policy.Reject{}}, + Transports: map[policy.TransportName]policy.TransportScopes{ + policy.TransportNameDocker: { + // Allow all docker repositories + "": policy.PolicyRequirements{&policy.InsecureAcceptAnything{}}, + }, + }, + } + + evaluator, err := policy.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) + } +}