diff --git a/cmd/tester/main.go b/cmd/tester/main.go index b3772b2b1..8aacae394 100644 --- a/cmd/tester/main.go +++ b/cmd/tester/main.go @@ -18,19 +18,14 @@ package main import ( "context" "encoding/json" - "errors" "flag" "fmt" - "io" "log" - "net/http" "os" "strings" "github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/name" - "github.com/google/go-containerregistry/pkg/v1/remote" - ociremote "github.com/sigstore/cosign/v2/pkg/oci/remote" "go.uber.org/zap" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "knative.dev/pkg/apis" @@ -38,21 +33,11 @@ import ( "sigs.k8s.io/release-utils/version" "sigs.k8s.io/yaml" - "github.com/sigstore/policy-controller/pkg/apis/glob" - "github.com/sigstore/policy-controller/pkg/apis/policy/v1alpha1" + "github.com/sigstore/policy-controller/pkg/policy" "github.com/sigstore/policy-controller/pkg/webhook" - webhookcip "github.com/sigstore/policy-controller/pkg/webhook/clusterimagepolicy" ) var ( - ns = "unused" - - remoteOpts = []ociremote.Option{ - ociremote.WithRemoteOptions( - remote.WithAuthFromKeychain(authn.DefaultKeychain), - ), - } - ctx = logging.WithLogger(context.Background(), func() *zap.SugaredLogger { x, _ := zap.NewDevelopmentConfig().Build() return x.Sugar() @@ -60,9 +45,8 @@ var ( ) type output struct { - Errors []string `json:"errors,omitempty"` - Warnings []string `json:"warnings,omitempty"` - Result *webhook.PolicyResult `json:"result"` + Errors []string `json:"errors,omitempty"` + Warnings []string `json:"warnings,omitempty"` } func main() { @@ -83,42 +67,46 @@ func main() { os.Exit(1) } - var cipRaw []byte - var err error + pols := make([]policy.Source, 0, 1) + if strings.HasPrefix(*cipFilePath, "https://") || strings.HasPrefix(*cipFilePath, "http://") { - log.Printf("Fetching CIP from: %s", *cipFilePath) - resp, err := http.Get(*cipFilePath) - if err != nil { - log.Fatal(err) - } - cipRaw, err = io.ReadAll(resp.Body) - resp.Body.Close() - if err != nil { - log.Fatal(err) - } + pols = append(pols, policy.Source{ + URL: *cipFilePath, + }) } else { - cipRaw, err = os.ReadFile(*cipFilePath) - if err != nil { - log.Fatal(err) + pols = append(pols, policy.Source{ + Path: *cipFilePath, + }) + } + + v := policy.Verification{ + NoMatchPolicy: "deny", + Policies: &pols, + } + if err := v.Validate(ctx); err != nil { + // CIP validation can return Warnings so let's just go through them + // and only exit if there are Errors. + if warnFE := err.Filter(apis.WarningLevel); warnFE != nil { + log.Printf("CIP has warnings:\n%s\n", warnFE.Error()) + } + if errorFE := err.Filter(apis.ErrorLevel); errorFE != nil { + log.Fatalf("CIP is invalid: %s", errorFE.Error()) } } - // TODO(jdolitsky): This should use v1beta1 once there exists a - // webhookcip.ConvertClusterImagePolicyV1beta1ToWebhook() method - var v1alpha1cip v1alpha1.ClusterImagePolicy - if err := yaml.UnmarshalStrict(cipRaw, &v1alpha1cip); err != nil { + ref, err := name.ParseReference(*image) + if err != nil { log.Fatal(err) } - v1alpha1cip.SetDefaults(ctx) - // Show what the defaults look like - defaulted, err := yaml.Marshal(v1alpha1cip) + warningStrings := []string{} + vfy, err := policy.Compile(ctx, v, func(s string, i ...interface{}) { + warningStrings = append(warningStrings, fmt.Sprintf(s, i...)) + }) if err != nil { - log.Fatalf("Failed to marshal the defaulted cip: %s", err) + log.Fatal(err) } - log.Printf("Using the following cip:\n%s", defaulted) - if *resourceFilePath != "" { raw, err := os.ReadFile(*resourceFilePath) if err != nil { @@ -152,76 +140,22 @@ func main() { ctx = webhook.IncludeTypeMeta(ctx, typeMeta) } - validateErrs := v1alpha1cip.Validate(ctx) - if validateErrs != nil { - // CIP validation can return Warnings so let's just go through them - // and only exit if there are Errors. - if warnFE := validateErrs.Filter(apis.WarningLevel); warnFE != nil { - log.Printf("CIP has warnings:\n%s\n", warnFE.Error()) - } - if errorFE := validateErrs.Filter(apis.ErrorLevel); errorFE != nil { - log.Fatalf("CIP is invalid: %s", errorFE.Error()) - } - } - cip := webhookcip.ConvertClusterImagePolicyV1alpha1ToWebhook(&v1alpha1cip) - - // We have to marshal/unmarshal the CIP since that handles converting - // inlined Data into PublicKey objects that validator uses. - webhookCip, err := json.Marshal(cip) - if err != nil { - log.Fatalf("Failed to marshal the webhook cip: %s", err) - } - if err := json.Unmarshal(webhookCip, &cip); err != nil { - log.Fatalf("Failed to unmarshal the webhook CIP: %s", err) - } - ref, err := name.ParseReference(*image) - if err != nil { - log.Fatal(err) - } - - matches := false - for _, pattern := range cip.Images { - if pattern.Glob != "" { - if matched, err := glob.Match(pattern.Glob, *image); err != nil { - log.Fatalf("Failed to match glob: %s", err) - } else if matched { - log.Printf("image matches glob %q", pattern.Glob) - matches = true - } - } - } - if !matches { - log.Fatalf("Image does not match any of the provided globs") - } - - result, errs := webhook.ValidatePolicy(ctx, ns, ref, *cip, authn.DefaultKeychain, remoteOpts...) errStrings := []string{} - warningStrings := []string{} - for _, err := range errs { - var fe *apis.FieldError - if errors.As(err, &fe) { - if warnFE := fe.Filter(apis.WarningLevel); warnFE != nil { - warningStrings = append(warningStrings, strings.Trim(warnFE.Error(), "\n")) - } - if errorFE := fe.Filter(apis.ErrorLevel); errorFE != nil { - errStrings = append(errStrings, strings.Trim(errorFE.Error(), "\n")) - } - } else { - errStrings = append(errStrings, strings.Trim(err.Error(), "\n")) - } + if err := vfy.Verify(ctx, ref, authn.DefaultKeychain); err != nil { + errStrings = append(errStrings, strings.Trim(err.Error(), "\n")) } + var o []byte o, err = json.Marshal(&output{ Errors: errStrings, Warnings: warningStrings, - Result: result, }) if err != nil { log.Fatal(err) } fmt.Println(string(o)) - if len(errs) > 0 { + if len(errStrings) > 0 { os.Exit(1) } } diff --git a/pkg/policy/README.md b/pkg/policy/README.md new file mode 100644 index 000000000..376745e06 --- /dev/null +++ b/pkg/policy/README.md @@ -0,0 +1,105 @@ +# Integrating Policy Verification + +The goal of this package is to make it easy for downstream tools to incorporate +the verification capabilities of `ClusterImagePolicy` in other contexts where +OCI artifacts are consumed. + +The most straightforward example of this is to enable OCI build tooling to +incorporate policies over the base images on top of which an application image +is built (e.g. `ko`, `kaniko`). However, this can be used by other tooling +that stores artifacts in OCI registries to verify those as well, examples of +this could include the way Buildpacks v3 and Crossplane store elements in OCI +registries. + +## Configuration + +Verification is configured via `policy.Verification`: + +```golang +type Verification struct { + // NoMatchPolicy specifies the behavior when a base image doesn't match any + // of the listed policies. It allows the values: allow, deny, and warn. + NoMatchPolicy string `yaml:"no-match-policy,omitempty"` + + // Policies specifies a collection of policies to use to cover the base + // images used as part of evaluation. See "policy" below for usage. + // Policies can be nil so that we can distinguish between an explicitly + // specified empty list and when policies is unspecified. + Policies *[]Source `yaml:"policies,omitempty"` +} +``` + +`NoMatchPolicy` controls the behavior when an image reference is passed that +does not match any of the configured policies. + +`Policies` can be specified via three possible sources: + +```golang +// Source contains a set of options for specifying policies. Exactly +// one of the fields may be specified for each Source entry. +type Source struct { + // Data is a collection of one or more ClusterImagePolicy resources. + Data string `yaml:"data,omitempty"` + + // Path is a path to a file containing one or more ClusterImagePolicy + // resources. + // TODO(mattmoor): Make this support taking a directory similar to kubectl. + // TODO(mattmoor): How do we want to handle something like -R? Perhaps we + // don't and encourage folks to list each directory individually? + Path string `yaml:"path,omitempty"` + + // URL links to a file containing one or more ClusterImagePolicy resources. + URL string `yaml:"url,omitempty"` +} +``` + +### With `spf13/viper` + +Many tools leverage `spf13/viper` for configuration, and `policy.Verification` +may be used in conjunction with viper via: + +```golang + vfy := policy.Verification{} + if err := v.UnmarshalKey("verification", &vfy); err != nil { ... } +``` + +This allows a section of the viper config: + +```yaml +verification: + noMatchPolicy: deny + policies: + - data: ... # Inline policies + - url: ... # URL to policies + ... +``` + +## Compilation + +The `policy.Verification` can be compiled into a `policy.Verifier` using +`policy.Compile`, which also takes a `context.Context` and a function that +controls how warnings are surfaced: + +```golang + verifier, err := policy.Compile(ctx, verification, + func(s string, i ...interface{}) { + // Handle warnings your own way! + }) + if err != nil { ... } +``` + +The compilation process will surface compilation warnings via the supplied +function and return any errors resolving or compiling the policies immediately. + +## Verification + +With a compiled `policy.Verifier` many image references can be verified against +the compiled policies by invoking `Verify`: +```golang +// Verifier is the interface for checking that a given image digest satisfies +// the policies backing this interface. +type Verifier interface { + // Verify checks that the provided reference satisfies the backing policies. + Verify(context.Context, name.Reference, authn.Keychain) error +} +``` diff --git a/pkg/policy/parse.go b/pkg/policy/parse.go new file mode 100644 index 000000000..bb8068e30 --- /dev/null +++ b/pkg/policy/parse.go @@ -0,0 +1,114 @@ +// Copyright 2023 The Sigstore 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" + "fmt" + "strings" + + "github.com/sigstore/policy-controller/pkg/apis/policy/v1alpha1" + "github.com/sigstore/policy-controller/pkg/apis/policy/v1beta1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "knative.dev/pkg/apis" + "sigs.k8s.io/yaml" +) + +// Parse decodes a provided YAML document containing zero or more objects into +// a collection of unstructured.Unstructured objects. +func Parse(ctx context.Context, document string) ([]*unstructured.Unstructured, error) { + docs := strings.Split(document, "\n---\n") + + objs := make([]*unstructured.Unstructured, 0, len(docs)) + for i, doc := range docs { + doc = strings.TrimSpace(doc) + if doc == "" { + continue + } + var obj unstructured.Unstructured + if err := yaml.Unmarshal([]byte(doc), &obj); err != nil { + return nil, fmt.Errorf("decoding object[%d]: %w", i, err) + } + if obj.GetAPIVersion() == "" { + return nil, apis.ErrMissingField("apiVersion").ViaIndex(i) + } + if obj.GetName() == "" { + return nil, apis.ErrMissingField("metadata.name").ViaIndex(i) + } + objs = append(objs, &obj) + } + return objs, nil +} + +// ParseClusterImagePolicies returns ClusterImagePolicy objects found in the +// policy document. +func ParseClusterImagePolicies(ctx context.Context, document string) (cips []*v1alpha1.ClusterImagePolicy, warns error, err error) { + if warns, err = Validate(ctx, document); err != nil { + return nil, warns, err + } + + ol, err := Parse(ctx, document) + if err != nil { + // "Validate" above calls "Parse", so this is unreachable. + return nil, warns, err + } + + cips = make([]*v1alpha1.ClusterImagePolicy, 0, len(ol)) + for _, obj := range ol { + gv, err := schema.ParseGroupVersion(obj.GetAPIVersion()) + if err != nil { + // Practically speaking unstructured.Unstructured won't let this happen. + return nil, warns, fmt.Errorf("error parsing apiVersion of: %w", err) + } + + cip := &v1alpha1.ClusterImagePolicy{} + + switch gv.WithKind(obj.GetKind()) { + case v1beta1.SchemeGroupVersion.WithKind("ClusterImagePolicy"): + v1b1 := &v1beta1.ClusterImagePolicy{} + if err := convert(obj, v1b1); err != nil { + return nil, warns, err + } + if err := cip.ConvertFrom(ctx, v1b1); err != nil { + return nil, warns, err + } + + case v1alpha1.SchemeGroupVersion.WithKind("ClusterImagePolicy"): + // This is allowed, but we should convert things. + if err := convert(obj, cip); err != nil { + return nil, warns, err + } + + default: + continue + } + + cips = append(cips, cip) + } + return cips, warns, nil +} + +func convert(from interface{}, to interface{}) error { + bs, err := json.Marshal(from) + if err != nil { + return fmt.Errorf("Marshal() = %w", err) + } + if err := json.Unmarshal(bs, to); err != nil { + return fmt.Errorf("Unmarshal() = %w", err) + } + return nil +} diff --git a/pkg/policy/parse_test.go b/pkg/policy/parse_test.go new file mode 100644 index 000000000..daa7cc666 --- /dev/null +++ b/pkg/policy/parse_test.go @@ -0,0 +1,309 @@ +// Copyright 2023 The Sigstore 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/google/go-cmp/cmp" + "github.com/sigstore/policy-controller/pkg/apis/policy/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "knative.dev/pkg/apis" +) + +func TestParse(t *testing.T) { + tests := []struct { + name string + doc string + want []*unstructured.Unstructured + wantErr error + }{{ + name: "good single object", + doc: ` +apiVersion: policy.sigstore.dev/v1beta1 +kind: ClusterImagePolicy +metadata: + name: blah +spec: {} +`, + want: []*unstructured.Unstructured{{ + Object: map[string]interface{}{ + "apiVersion": "policy.sigstore.dev/v1beta1", + "kind": "ClusterImagePolicy", + "metadata": map[string]interface{}{ + "name": "blah", + }, + "spec": map[string]interface{}{}, + }, + }}, + }, { + name: "good multi-object", + doc: ` +apiVersion: policy.sigstore.dev/v1beta1 +kind: ClusterImagePolicy +metadata: + name: blah +spec: {} +--- +--- +apiVersion: policy.sigstore.dev/v1beta1 +kind: ClusterImagePolicy +metadata: + name: foo +spec: {} +--- +--- +apiVersion: policy.sigstore.dev/v1beta1 +kind: ClusterImagePolicy +metadata: + name: bar +spec: {} +`, + want: []*unstructured.Unstructured{{ + Object: map[string]interface{}{ + "apiVersion": "policy.sigstore.dev/v1beta1", + "kind": "ClusterImagePolicy", + "metadata": map[string]interface{}{ + "name": "blah", + }, + "spec": map[string]interface{}{}, + }, + }, { + Object: map[string]interface{}{ + "apiVersion": "policy.sigstore.dev/v1beta1", + "kind": "ClusterImagePolicy", + "metadata": map[string]interface{}{ + "name": "foo", + }, + "spec": map[string]interface{}{}, + }, + }, { + Object: map[string]interface{}{ + "apiVersion": "policy.sigstore.dev/v1beta1", + "kind": "ClusterImagePolicy", + "metadata": map[string]interface{}{ + "name": "bar", + }, + "spec": map[string]interface{}{}, + }, + }}, + }, { + name: "bad missing apiVersion", + doc: ` +apiVersion: policy.sigstore.dev/v1beta1 +kind: ClusterImagePolicy +metadata: + name: blah +spec: {} +--- +# Missing: apiVersion: policy.sigstore.dev/v1beta1 +kind: ClusterImagePolicy +metadata: + name: foo +spec: {} +--- +apiVersion: policy.sigstore.dev/v1beta1 +kind: ClusterImagePolicy +metadata: + name: bar +spec: {} +`, + wantErr: apis.ErrMissingField("[1].apiVersion"), + }, { + name: "bad missing kind", + doc: ` +apiVersion: policy.sigstore.dev/v1beta1 +kind: ClusterImagePolicy +metadata: + name: blah +spec: {} +--- +apiVersion: policy.sigstore.dev/v1beta1 +kind: ClusterImagePolicy +metadata: + name: foo +spec: {} +--- +apiVersion: policy.sigstore.dev/v1beta1 +# Missing: kind: ClusterImagePolicy +metadata: + name: bar +spec: {} +`, + wantErr: errors.New(`decoding object[2]: error unmarshaling JSON: while decoding JSON: Object 'Kind' is missing in '{"apiVersion":"policy.sigstore.dev/v1beta1","metadata":{"name":"bar"},"spec":{}}'`), + }, { + name: "bad missing apiVersion", + doc: ` +apiVersion: policy.sigstore.dev/v1beta1 +kind: ClusterImagePolicy +metadata: + # Missing: name: blah +sp dec: {} +--- +apiVersion: policy.sigstore.dev/v1beta1 +kind: ClusterImagePolicy +metadata: + name: foo +spec: {} +--- +apiVersion: policy.sigstore.dev/v1beta1 +kind: ClusterImagePolicy +metadata: + name: bar +spec: {} +`, + wantErr: apis.ErrMissingField("[0].metadata.name"), + }} + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, gotErr := Parse(context.Background(), test.doc) + + switch { + case (gotErr != nil) != (test.wantErr != nil): + t.Fatalf("Parse() = %v, wanted %v", gotErr, test.wantErr) + case gotErr != nil && gotErr.Error() != test.wantErr.Error(): + t.Fatalf("Parse() = %v, wanted %v", gotErr, test.wantErr) + case gotErr != nil: + return // This was an error test. + } + + if diff := cmp.Diff(got, test.want); diff != "" { + t.Errorf("Parse (-got, +want) = %s", diff) + } + }) + } +} + +func TestParseCIP(t *testing.T) { + tests := []struct { + name string + doc string + want []*v1alpha1.ClusterImagePolicy + wantErr error + }{{ + name: "good alpha object", + doc: ` +apiVersion: policy.sigstore.dev/v1alpha1 +kind: ClusterImagePolicy +metadata: + name: blah +spec: + images: + - glob: '**' + authorities: + - static: + action: pass +`, + want: []*v1alpha1.ClusterImagePolicy{{ + TypeMeta: v1.TypeMeta{ + APIVersion: "policy.sigstore.dev/v1alpha1", + Kind: "ClusterImagePolicy", + }, + ObjectMeta: v1.ObjectMeta{ + Name: "blah", + }, + Spec: v1alpha1.ClusterImagePolicySpec{ + Images: []v1alpha1.ImagePattern{{ + Glob: "**", + }}, + Authorities: []v1alpha1.Authority{{ + Static: &v1alpha1.StaticRef{ + Action: "pass", + }, + }}, + }, + }}, + }, { + name: "good beta object", + doc: ` +apiVersion: policy.sigstore.dev/v1beta1 +kind: ClusterImagePolicy +metadata: + name: blah +spec: + images: + - glob: '**' + authorities: + - static: + action: pass +`, + want: []*v1alpha1.ClusterImagePolicy{{ + // TODO(mattmoor): We should be setting TypeMeta when converting. + // TypeMeta: v1.TypeMeta{ + // APIVersion: "policy.sigstore.dev/v1alpha1", + // Kind: "ClusterImagePolicy", + // }, + ObjectMeta: v1.ObjectMeta{ + Name: "blah", + }, + Spec: v1alpha1.ClusterImagePolicySpec{ + Images: []v1alpha1.ImagePattern{{ + Glob: "**", + }}, + Authorities: []v1alpha1.Authority{{ + Static: &v1alpha1.StaticRef{ + Action: "pass", + }, + }}, + }, + }}, + }, { + name: "early validation failure", + doc: ` +apiVersion: policy.sigstore.dev/v1beta1 +kind: ClusterImagePolicy +metadata: + name: blah +spec: + bad: field +`, + wantErr: errors.New(`unable to unmarshal: json: unknown field "bad"`), + }, { + name: "non CIP resource", + doc: ` +apiVersion: v1 +kind: Secret +metadata: + name: blah + namespace: cosign-system +stringData: + key: value +`, + want: []*v1alpha1.ClusterImagePolicy{}, + }} + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, _, gotErr := ParseClusterImagePolicies(context.Background(), test.doc) + + switch { + case (gotErr != nil) != (test.wantErr != nil): + t.Fatalf("Parse() = %v, wanted %v", gotErr, test.wantErr) + case gotErr != nil && gotErr.Error() != test.wantErr.Error(): + t.Fatalf("Parse() = %v, wanted %v", gotErr, test.wantErr) + case gotErr != nil: + return // This was an error test. + } + + if diff := cmp.Diff(got, test.want); diff != "" { + t.Errorf("Parse (-got, +want) = %s", diff) + } + }) + } +} diff --git a/pkg/policy/policy.go b/pkg/policy/policy.go new file mode 100644 index 000000000..957599d87 --- /dev/null +++ b/pkg/policy/policy.go @@ -0,0 +1,148 @@ +// Copyright 2023 The Sigstore 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" + "io" + "net/http" + "os" + + "k8s.io/apimachinery/pkg/util/sets" + "knative.dev/pkg/apis" +) + +type Verification struct { + // NoMatchPolicy specifies the behavior when a base image doesn't match any + // of the listed policies. It allows the values: allow, deny, and warn. + NoMatchPolicy string `yaml:"no-match-policy,omitempty"` + + // Policies specifies a set of Sources for fetching policies to use to cover + // images used as part of evaluation. For more information about what each + // Source supports, see its usage. + // Policies can be nil so that we can distinguish between an explicitly + // specified empty list and when policies is unspecified. + Policies *[]Source `yaml:"policies,omitempty"` +} + +// Source contains a set of options for specifying policies. Exactly +// one of the fields may be specified for each Source entry. +type Source struct { + // Data is a collection of one or more ClusterImagePolicy resources. + Data string `yaml:"data,omitempty"` + + // Path is a path to a file containing one or more ClusterImagePolicy + // resources. + Path string `yaml:"path,omitempty"` + + // URL links to a file containing one or more ClusterImagePolicy resources. + URL string `yaml:"url,omitempty"` +} + +func (v *Verification) Validate(ctx context.Context) (errs *apis.FieldError) { + switch v.NoMatchPolicy { + case "allow", "deny", "warn": + // Good! + case "": + errs = errs.Also(apis.ErrMissingField("noMatchPolicy")) + default: + errs = errs.Also(apis.ErrInvalidValue(v.NoMatchPolicy, "noMatchPolicy")) + } + + if v.Policies == nil { + errs = errs.Also(apis.ErrMissingField("policies")) + } else { + for i, p := range *v.Policies { + errs = errs.Also(p.Validate(ctx).ViaFieldIndex("policies", i)) + } + } + + return errs +} + +func (pd *Source) Validate(ctx context.Context) *apis.FieldError { + // Check that exactly one of the fields is set. + set := sets.NewString() + if pd.Data != "" { + set.Insert("data") + } + if pd.Path != "" { + set.Insert("path") + } + if pd.URL != "" { + set.Insert("url") + } + // This returns eagerly to avoid confusing `oneof` validation with errors + // along multiple paths of the oneof. + switch set.Len() { + case 0: + return apis.ErrMissingOneOf("data", "path", "url") + case 1: + // What we want. + default: + // This will be unreachable until we add more than one thing + // to our oneof. + return apis.ErrMultipleOneOf(set.List()...) + } + // We know (from the switch above) there is exactly one field name. + field, _ := set.PopAny() + + content, err := pd.fetch(ctx) + if err != nil { + return &apis.FieldError{ + Message: err.Error(), + Paths: []string{field}, + } + } + if _, _, err := ParseClusterImagePolicies(ctx, content); err != nil { + return apis.ErrInvalidValue(err.Error(), field) + } + return nil +} + +func (pd *Source) fetch(ctx context.Context) (string, error) { + switch { + case pd.Data != "": + return pd.Data, nil + + case pd.Path != "": + raw, err := os.ReadFile(pd.Path) + if err != nil { + return "", err + } + return string(raw), nil + + case pd.URL != "": + req, err := http.NewRequestWithContext(ctx, http.MethodGet, pd.URL, nil) + if err != nil { + return "", err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + return string(raw), nil + + default: + // This should never happen for a validated policy. + return "", fmt.Errorf("unsupported policy shape: %v", pd) + } +} diff --git a/pkg/policy/policy_test.go b/pkg/policy/policy_test.go new file mode 100644 index 000000000..77611592a --- /dev/null +++ b/pkg/policy/policy_test.go @@ -0,0 +1,220 @@ +// Copyright 2023 The Sigstore 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" +) + +const ( + // This is an example of what the default ko policy should be + // as of 2023/01/03. + goodPolicy = ` +apiVersion: policy.sigstore.dev/v1beta1 +kind: ClusterImagePolicy +metadata: + name: ko-default-base-image-policy +spec: + images: + - glob: cgr.dev/chainguard/static* + authorities: + - keyless: + url: https://fulcio.sigstore.dev + identities: + - issuer: https://token.actions.githubusercontent.com + subject: https://github.com/chainguard-images/images/.github/workflows/release.yaml@refs/heads/main + ctlog: + url: https://rekor.sigstore.dev +` + + // This is a policy that has warnings when compiled because it is missing + // identity verification in its keyless block. + warnPolicy = ` +apiVersion: policy.sigstore.dev/v1beta1 +kind: ClusterImagePolicy +metadata: + name: ko-default-base-image-policy +spec: + images: + - glob: cgr.dev/chainguard/static* + authorities: + - keyless: + url: https://fulcio.sigstore.dev + # TODO(https://github.com/sigstore/policy-controller/issues/479): + # Remove this once the above is fixed. + ctlog: + url: https://rekor.sigstore.dev +` + + badPolicy = ` +apiVersion: policy.sigstore.dev/v1beta1 +kind: ClusterImagePolicy +metadata: + name: ko-default-base-image-policy +spec: + bad: field +` +) + +func TestVerificationValidate(t *testing.T) { + tests := []struct { + name string + v Verification + wantErr error + }{{ + name: "legacy compatibility settings", + v: Verification{ + NoMatchPolicy: "allow", + Policies: &[]Source{}, + }, + }, { + name: "sample ko default settings", + v: Verification{ + NoMatchPolicy: "warn", + Policies: &[]Source{{ + Data: goodPolicy, + }}, + }, + }, { + name: "sample strict settings", + v: Verification{ + NoMatchPolicy: "deny", + Policies: &[]Source{{ + Data: goodPolicy, + }}, + }, + }, { + name: "sample URL settings", + v: Verification{ + NoMatchPolicy: "deny", + Policies: &[]Source{{ + URL: "https://raw.githubusercontent.com/sigstore/policy-controller/d6ef1f37c9c634fdb2693c11f8aa91c19e76e7d8/examples/policies/allow-only-pods.yaml", + }}, + }, + }, { + name: "sample path settings", + v: Verification{ + NoMatchPolicy: "deny", + Policies: &[]Source{{ + Path: "../../examples/policies/allow-only-pods.yaml", + }}, + }, + }, { + name: "missing no match policy", + v: Verification{ + NoMatchPolicy: "", + Policies: &[]Source{{ + Data: goodPolicy, + }}, + }, + wantErr: errors.New(`missing field(s): noMatchPolicy`), + }, { + name: "bad no match policy", + v: Verification{ + NoMatchPolicy: "bad", + Policies: &[]Source{{ + Data: goodPolicy, + }}, + }, + wantErr: errors.New(`invalid value: bad: noMatchPolicy`), + }, { + name: "missing policies", + v: Verification{ + NoMatchPolicy: "warn", + }, + wantErr: errors.New(`missing field(s): policies`), + }, { + name: "missing policy data", + v: Verification{ + NoMatchPolicy: "warn", + Policies: &[]Source{{ + // NO BODY + }}, + }, + wantErr: errors.New(`expected exactly one, got neither: policies[0].data, policies[0].path, policies[0].url`), + }, { + name: "bad policy data", + v: Verification{ + NoMatchPolicy: "warn", + Policies: &[]Source{{ + Data: badPolicy, + }}, + }, + wantErr: errors.New(`invalid value: unable to unmarshal: json: unknown field "bad": policies[0].data`), + }, { + name: "bad URL", + v: Verification{ + NoMatchPolicy: "deny", + Policies: &[]Source{{ + URL: "bad", + }}, + }, + wantErr: errors.New(`Get "bad": unsupported protocol scheme "": policies[0].url`), + }, { + name: "bad URL content", + v: Verification{ + NoMatchPolicy: "deny", + Policies: &[]Source{{ + URL: "https://raw.githubusercontent.com/sigstore/policy-controller/d6ef1f37c9c634fdb2693c11f8aa91c19e76e7d8/README.md", + }}, + }, + wantErr: errors.New(`invalid value: decoding object[0]: error unmarshaling JSON: while decoding JSON: json: cannot unmarshal string into Go value of type map[string]interface {}: policies[0].url`), + }, { + name: "both provided", + v: Verification{ + NoMatchPolicy: "deny", + Policies: &[]Source{{ + Data: goodPolicy, + URL: "https://raw.githubusercontent.com/sigstore/policy-controller/d6ef1f37c9c634fdb2693c11f8aa91c19e76e7d8/examples/policies/allow-only-pods.yaml", + }}, + }, + wantErr: errors.New(`expected exactly one, got both: policies[0].data, policies[0].url`), + }, { + name: "path not found", + v: Verification{ + NoMatchPolicy: "deny", + Policies: &[]Source{{ + Path: "not-found.yaml", + }}, + }, + wantErr: errors.New(`open not-found.yaml: no such file or directory: policies[0].path`), + }} + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + testContext := context.Background() + gotErr := test.v.Validate(testContext) + if (gotErr != nil) != (test.wantErr != nil) { + t.Fatalf("Validate() = %v, wanted %v", gotErr, test.wantErr) + } + if gotErr != nil && gotErr.Error() != test.wantErr.Error() { + t.Fatalf("Validate() = %v, wanted %v", gotErr, test.wantErr) + } + }) + + t.Run("compile: "+test.name, func(t *testing.T) { + testContext := context.Background() + _, gotErr := Compile(testContext, test.v, t.Logf) + if (gotErr != nil) != (test.wantErr != nil) { + t.Fatalf("Validate() = %v, wanted %v", gotErr, test.wantErr) + } + if gotErr != nil && gotErr.Error() != test.wantErr.Error() { + t.Fatalf("Validate() = %v, wanted %v", gotErr, test.wantErr) + } + }) + } +} diff --git a/pkg/policy/validate.go b/pkg/policy/validate.go new file mode 100644 index 000000000..9f8cad248 --- /dev/null +++ b/pkg/policy/validate.go @@ -0,0 +1,113 @@ +// Copyright 2023 The Sigstore 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 ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/sigstore/policy-controller/pkg/apis/policy/v1alpha1" + "github.com/sigstore/policy-controller/pkg/apis/policy/v1beta1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "knative.dev/pkg/apis" +) + +var ( + // ErrEmptyDocument is the error returned when no document body is + // specified. + ErrEmptyDocument = errors.New("document is required to create policy") + + // ErrUnknownType is the error returned when a type contained in the policy + // is unrecognized. + ErrUnknownType = errors.New("unknown type") +) + +// Validate decodes a provided YAML document containing zero or more objects +// and performs limited validation on them, after applying defaulting (to +// simulate the mutating webhook running before the validating webhook). +func Validate(ctx context.Context, document string) (warns error, err error) { + if len(document) == 0 { + return nil, ErrEmptyDocument + } + + uol, err := Parse(ctx, document) + if err != nil { + return nil, err + } + + for i, uo := range uol { + switch uo.GroupVersionKind() { + case v1beta1.SchemeGroupVersion.WithKind("ClusterImagePolicy"): + if warns, err = validate(ctx, uo, &v1beta1.ClusterImagePolicy{}); err != nil { + return + } + + case v1alpha1.SchemeGroupVersion.WithKind("ClusterImagePolicy"): + if warns, err = validate(ctx, uo, &v1alpha1.ClusterImagePolicy{}); err != nil { + return + } + + case corev1.SchemeGroupVersion.WithKind("Secret"): + if uo.GetNamespace() != "cosign-system" { + return warns, apis.ErrInvalidValue(uo.GetNamespace(), "metadata.namespace").ViaIndex(i) + } + // Any additional validation worth performing? Should we check the + // schema of the secret matches the expectations of cosigned? + + default: + return warns, fmt.Errorf("%w: %v", ErrUnknownType, uo.GroupVersionKind()) + } + } + return warns, nil +} + +type crd interface { + apis.Validatable + apis.Defaultable +} + +func validate(ctx context.Context, uo *unstructured.Unstructured, v crd) (warns error, err error) { + b, err := json.Marshal(uo) + if err != nil { + return nil, fmt.Errorf("unable to marshal: %w", err) + } + + dec := json.NewDecoder(bytes.NewBuffer(b)) + dec.DisallowUnknownFields() + if err := dec.Decode(v); err != nil { + return nil, fmt.Errorf("unable to unmarshal: %w", err) + } + + // Apply defaulting to simulate the defaulting webhook that runs prior + // to validation. + v.SetDefaults(ctx) + + // We can't just return v.Validate(ctx) because of Go's typed nils. + // nolint:revive + if ve := v.Validate(ctx); ve != nil { + // Separate validation warnings from errors so the caller can discern between them. + if warnFE := ve.Filter(apis.WarningLevel); warnFE != nil { + warns = warnFE + } + if errorFE := ve.Filter(apis.ErrorLevel); errorFE != nil { + err = errorFE + } + } + return +} diff --git a/pkg/policy/validate_test.go b/pkg/policy/validate_test.go new file mode 100644 index 000000000..82bce26c2 --- /dev/null +++ b/pkg/policy/validate_test.go @@ -0,0 +1,203 @@ +// Copyright 2023 The Sigstore 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" + + policycontrollerconfig "github.com/sigstore/policy-controller/pkg/config" + "knative.dev/pkg/apis" +) + +func TestValidate(t *testing.T) { + tests := []struct { + name string + doc string + wantWarns error + wantErr error + allowEmptyAuthorities bool + }{{ + name: "good single object", + doc: ` +apiVersion: policy.sigstore.dev/v1beta1 +kind: ClusterImagePolicy +metadata: + name: blah +spec: + images: + - glob: '*' + authorities: + - keyless: + identities: + - issuer: https://issuer.example.com + subject: foo@example.com + url: https://fulcio.sigstore.dev +`, + wantErr: nil, + }, { + name: "good CIP and Secret", + doc: ` +apiVersion: policy.sigstore.dev/v1beta1 +kind: ClusterImagePolicy +metadata: + name: blah +spec: + images: + - glob: '*' + authorities: + - keyless: + identities: + - issuer: https://issuer.example.com + subject: foo@example.com + url: https://fulcio.sigstore.dev +--- +apiVersion: v1 +kind: Secret +metadata: + name: foo + namespace: cosign-system +stringData: + foo: bar +`, + wantErr: nil, + }, { + name: "bad secret namespace", + doc: ` +apiVersion: v1 +kind: Secret +metadata: + name: foo + namespace: something-system +stringData: + foo: bar +`, + wantErr: errors.New(`invalid value: something-system: [0].metadata.namespace`), + }, { + name: "bad image policy", + doc: ` +apiVersion: policy.sigstore.dev/v1alpha1 +kind: ClusterImagePolicy +metadata: + name: blah +spec: + images: + - glob: '*' + authorities: + - key: {} +`, + wantErr: apis.ErrMissingOneOf("data", "kms", "secretref").ViaField("key").ViaFieldIndex("authorities", 0).ViaField("spec"), + }, { + name: "empty document", + doc: ``, + wantErr: ErrEmptyDocument, + }, { + name: "object missing kind", + doc: ` +apiVersion: policy.sigstore.dev/v1beta1 +# Missing: kind: ClusterImagePolicy +metadata: + name: blah +spec: {} +`, + wantErr: errors.New(`decoding object[0]: error unmarshaling JSON: while decoding JSON: Object 'Kind' is missing in '{"apiVersion":"policy.sigstore.dev/v1beta1","metadata":{"name":"blah"},"spec":{}}'`), + }, { + name: "unknown field", + doc: ` +apiVersion: policy.sigstore.dev/v1beta1 +kind: ClusterImagePolicy +metadata: + name: blah +spec: + asdf: dfsadf +`, + wantErr: errors.New(`unable to unmarshal: json: unknown field "asdf"`), + }, { + name: "unknown type", + doc: ` +apiVersion: unknown.dev/v1 +kind: OtherPolicy +metadata: + name: blah +spec: {} +`, + wantErr: errors.New(`unknown type: unknown.dev/v1, Kind=OtherPolicy`), + }, { + name: "warning - missing field", + doc: ` +apiVersion: policy.sigstore.dev/v1beta1 +kind: ClusterImagePolicy +metadata: + name: blah +spec: + images: + - glob: '*' + authorities: + - keyless: + url: https://fulcio.sigstore.dev +`, + wantWarns: errors.New("missing field(s): spec.authorities[0].keyless.identities"), + wantErr: nil, + }, + { + name: "admit - missing authorities", + doc: ` +apiVersion: policy.sigstore.dev/v1beta1 +kind: ClusterImagePolicy +metadata: + name: blah +spec: + images: + - glob: '*' +`, + wantErr: nil, + allowEmptyAuthorities: true, + }, { + name: "deny - missing authorities", + doc: ` +apiVersion: policy.sigstore.dev/v1beta1 +kind: ClusterImagePolicy +metadata: + name: blah +spec: + images: + - glob: '*' +`, + wantErr: errors.New("missing field(s): spec.authorities"), + }} + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + testContext := context.Background() + if test.allowEmptyAuthorities { + testContext = policycontrollerconfig.ToContext(testContext, &policycontrollerconfig.PolicyControllerConfig{FailOnEmptyAuthorities: false}) + } + gotWarns, gotErr := Validate(testContext, test.doc) + if (gotErr != nil) != (test.wantErr != nil) { + t.Fatalf("Validate() = %v, wanted %v", gotErr, test.wantErr) + } + if (gotWarns != nil) != (test.wantWarns != nil) { + t.Fatalf("Validate() = %v, wanted %v", gotWarns, test.wantWarns) + } + if gotErr != nil && gotErr.Error() != test.wantErr.Error() { + t.Fatalf("Validate() = %v, wanted %v", gotErr, test.wantErr) + } + if gotWarns != nil && gotWarns.Error() != test.wantWarns.Error() { + t.Fatalf("Validate() = %v, wanted %v", gotWarns, test.wantWarns) + } + }) + } +} diff --git a/pkg/policy/verifier.go b/pkg/policy/verifier.go new file mode 100644 index 000000000..e20e0fa91 --- /dev/null +++ b/pkg/policy/verifier.go @@ -0,0 +1,195 @@ +// Copyright 2023 The Sigstore 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" + "fmt" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" + ociremote "github.com/sigstore/cosign/v2/pkg/oci/remote" + "github.com/sigstore/policy-controller/pkg/apis/config" + "github.com/sigstore/policy-controller/pkg/webhook" + webhookcip "github.com/sigstore/policy-controller/pkg/webhook/clusterimagepolicy" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "knative.dev/pkg/apis" +) + +// Verifier is the interface for checking that a given image digest satisfies +// the policies backing this interface. +type Verifier interface { + // Verify checks that the provided reference satisfies the backing policies. + // + // For policies specifying `match:` criteria with apiVersion/kind, the + // TypeMeta should be associated with `ctx` here using: + // webhook.GetIncludeTypeMeta(ctx) + // + // For policies specifying `match:` criteria with label selectors, the + // ObjectMeta should be associated with `ctx` here using: + // webhook.GetIncludeObjectMeta(ctx) + Verify(context.Context, name.Reference, authn.Keychain, ...ociremote.Option) error +} + +// WarningWriter is used to surface warning messages in a manner that +// is customizable by callers that's suitable for their execution +// environment. The signature is intended to match the standard format string +// signature (e.g. Printf, Infof, Logf, Errorf, Fatalf, ...), so functions like +// log.Printf or t.Errorf can be passed here directly. +type WarningWriter func(string, ...interface{}) + +// Compile turns a Verification into an executable Verifier. +// Any compilation errors are returned here. +func Compile(ctx context.Context, v Verification, ww WarningWriter) (Verifier, error) { + if err := v.Validate(ctx); err != nil { + return nil, err + } + + ipc, err := gather(ctx, v, ww) + if err != nil { + // This should never hit for validated policies. + return nil, err + } + + return &impl{ + verification: v, + ipc: ipc, + ww: ww, + }, nil +} + +func gather(ctx context.Context, v Verification, ww WarningWriter) (*config.ImagePolicyConfig, error) { + pol := *v.Policies + ipc := &config.ImagePolicyConfig{ + Policies: make(map[string]webhookcip.ClusterImagePolicy, len(pol)), + } + + for i, p := range pol { + content, err := p.fetch(ctx) + if err != nil { + return nil, err + } + + l, warns, err := ParseClusterImagePolicies(ctx, content) + if err != nil { + // This path should be unreachable, since we already parse + // things during compilation. + return nil, fmt.Errorf("parsing policies: %w", err) + } + if warns != nil { + ww("policy %d: %v", i, warns) + } + + // TODO(mattmoor): Add additional checks for unsupported things, + // like Match, IncludeSpec, etc. + + for _, cip := range l { + cip.SetDefaults(ctx) + if _, ok := ipc.Policies[cip.Name]; ok { + ww("duplicate policy named %q, skipping", cip.Name) + continue + } + // We need to roundtrip the policy through JSON here because + // the compiled policy expects to be decoded from JSON and only + // sets up certain fields when being unmarshalled from JSON, so + // things like keyful verification only work when we roundtrip + // through JSON. + var compiled webhookcip.ClusterImagePolicy + if err := convert(webhookcip.ConvertClusterImagePolicyV1alpha1ToWebhook(cip), &compiled); err != nil { + ww("roundtripping policy %v", err) + continue + } + ipc.Policies[cip.Name] = compiled + } + } + + return ipc, nil +} + +type impl struct { + verification Verification + + ipc *config.ImagePolicyConfig + ww WarningWriter +} + +// Check that impl implements Verifier +var _ Verifier = (*impl)(nil) + +// Verify implements Verifier +func (i *impl) Verify(ctx context.Context, ref name.Reference, kc authn.Keychain, opts ...ociremote.Option) error { + tm := getTypeMeta(ctx) + om := getObjectMeta(ctx) + matches, err := i.ipc.GetMatchingPolicies(ref.Name(), tm.Kind, tm.APIVersion, om.Labels) + if err != nil { + return err + } + + if len(matches) == 0 { + switch i.verification.NoMatchPolicy { + case "allow": + return nil + case "warn": + i.ww("%s is uncovered by policy", ref) + case "deny": + return fmt.Errorf("%s is uncovered by policy", ref) + default: + // This is unreachable for a validated Verification. + return fmt.Errorf("unsupported noMatchPolicy: %q", i.verification.NoMatchPolicy) + } + } + + // Add the keychain to our (optional) list of options. + opts = append(opts, ociremote.WithRemoteOptions(remote.WithAuthFromKeychain(kc))) + + for _, p := range matches { + _, errs := webhook.ValidatePolicy(ctx, "" /* namespace */, ref, p, kc, opts...) + for _, err := range errs { + var fe *apis.FieldError + if errors.As(err, &fe) { + if warnFE := fe.Filter(apis.WarningLevel); warnFE != nil { + i.ww("%v", warnFE) + } + if errorFE := fe.Filter(apis.ErrorLevel); errorFE != nil { + return errorFE + } + } else { + return err + } + } + } + + return nil +} + +func getTypeMeta(ctx context.Context) (tm metav1.TypeMeta) { + raw := webhook.GetIncludeTypeMeta(ctx) + if raw == nil { + return + } + _ = convert(raw, &tm) + return +} + +func getObjectMeta(ctx context.Context) (om metav1.ObjectMeta) { + raw := webhook.GetIncludeObjectMeta(ctx) + if raw == nil { + return + } + _ = convert(raw, &om) + return +} diff --git a/pkg/policy/verifier_test.go b/pkg/policy/verifier_test.go new file mode 100644 index 000000000..3a27c2209 --- /dev/null +++ b/pkg/policy/verifier_test.go @@ -0,0 +1,176 @@ +// Copyright 2023 The Sigstore 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" + "fmt" + "testing" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" +) + +const ( + // This is the digest of cgr.dev/chainguard/static as of 2023/01/03. + // It is verifiable with goodPolicy. + staticDigest = "sha256:39ae0654d64cb72003216f6148e581e6d7cf239ac32325867af46666e31739d2" + + // This is the digest of ghcr.io/distroless/static as of 2023/01/03. + // It is not verifiable with goodPolicy. + ancientDigest = "sha256:a9650a15060275287ebf4530b34020b8d998bd2de9aea00d113c332d8c41eb0b" +) + +func TestVerifierDeny(t *testing.T) { + tests := []struct { + name string + v Verification + d name.Digest + wantErr error + }{{ + name: "successful policy evaluation", + v: Verification{ + NoMatchPolicy: "deny", + Policies: &[]Source{{ + Data: goodPolicy, + }}, + }, + d: name.MustParseReference("cgr.dev/chainguard/static@" + staticDigest).(name.Digest), + }, { + name: "no match policy failure", + v: Verification{ + NoMatchPolicy: "deny", + Policies: &[]Source{{ + Data: goodPolicy, + }}, + }, + d: name.MustParseReference("cgr.dev/chainguard/busybox@" + staticDigest).(name.Digest), + wantErr: errors.New("cgr.dev/chainguard/busybox@sha256:39ae0654d64cb72003216f6148e581e6d7cf239ac32325867af46666e31739d2 is uncovered by policy"), + }, { + name: "policy evaluation failure", + v: Verification{ + NoMatchPolicy: "deny", + Policies: &[]Source{{ + Data: goodPolicy, + }}, + }, + d: name.MustParseReference("cgr.dev/chainguard/static@" + ancientDigest).(name.Digest), + wantErr: errors.New("signature keyless validation failed for authority authority-0 for cgr.dev/chainguard/static@sha256:a9650a15060275287ebf4530b34020b8d998bd2de9aea00d113c332d8c41eb0b: no matching signatures:\nnone of the expected identities matched what was in the certificate: "), + }} + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + vfy, err := Compile(context.Background(), test.v, + t.Errorf /* we expect no warnings! */) + if err != nil { + t.Fatalf("Compile() = %v", err) + } + + gotErr := vfy.Verify(context.Background(), test.d, authn.DefaultKeychain) + if (gotErr != nil) != (test.wantErr != nil) { + t.Fatalf("Verify() = %v, wanted %v", gotErr, test.wantErr) + } + if gotErr != nil && gotErr.Error() != test.wantErr.Error() { + t.Fatalf("Verify() = %v, wanted %v", gotErr, test.wantErr) + } + }) + } +} + +func TestVerifierWarn(t *testing.T) { + tests := []struct { + name string + v Verification + d name.Digest + wantErr error + }{{ + name: "successful policy evaluation (warn mode)", + v: Verification{ + NoMatchPolicy: "warn", + Policies: &[]Source{{ + Data: goodPolicy, + }}, + }, + d: name.MustParseReference("cgr.dev/chainguard/static@" + staticDigest).(name.Digest), + }, { + name: "no match policy failure", + v: Verification{ + NoMatchPolicy: "warn", + Policies: &[]Source{{ + Data: goodPolicy, + }}, + }, + d: name.MustParseReference("cgr.dev/chainguard/busybox@" + staticDigest).(name.Digest), + wantErr: errors.New("cgr.dev/chainguard/busybox@sha256:39ae0654d64cb72003216f6148e581e6d7cf239ac32325867af46666e31739d2 is uncovered by policy"), + }, { + name: "policy evaluation failure (warn mode)", + v: Verification{ + NoMatchPolicy: "deny", + Policies: &[]Source{{ + Data: goodPolicy + " mode: warn", + }}, + }, + d: name.MustParseReference("cgr.dev/chainguard/static@" + ancientDigest).(name.Digest), + wantErr: errors.New("signature keyless validation failed for authority authority-0 for cgr.dev/chainguard/static@sha256:a9650a15060275287ebf4530b34020b8d998bd2de9aea00d113c332d8c41eb0b: no matching signatures:\nnone of the expected identities matched what was in the certificate: "), + }, { + name: "duplicate policies", + v: Verification{ + NoMatchPolicy: "deny", // This is always surfaced as a warning. + Policies: &[]Source{{ + Data: goodPolicy, + }, { + Data: goodPolicy, + }}, + }, + d: name.MustParseReference("cgr.dev/chainguard/static@" + staticDigest).(name.Digest), + wantErr: errors.New(`duplicate policy named "ko-default-base-image-policy", skipping`), + }, { + name: "compilation warnings", + v: Verification{ + NoMatchPolicy: "deny", // This is always surfaced as a warning. + Policies: &[]Source{{ + Data: warnPolicy, + }}, + }, + d: name.MustParseReference("cgr.dev/chainguard/static@" + ancientDigest).(name.Digest), + wantErr: errors.New(`policy 0: missing field(s): spec.authorities[0].keyless.identities`), + }} + + for _, test := range tests { + t.Run("warn: "+test.name, func(t *testing.T) { + var gotErr error + vfy, err := Compile(context.Background(), test.v, + func(s string, i ...interface{}) { + gotErr = fmt.Errorf(s, i...) + }) + if err != nil { + t.Fatalf("Compile() = %v", err) + } + + err = vfy.Verify(context.Background(), test.d, authn.DefaultKeychain) + if err != nil { + t.Fatalf("Verify() = %v", err) + } + + if (gotErr != nil) != (test.wantErr != nil) { + t.Fatalf("Verify() = %v, wanted %v", gotErr, test.wantErr) + } + if gotErr != nil && gotErr.Error() != test.wantErr.Error() { + t.Fatalf("Verify() = %v, wanted %v", gotErr, test.wantErr) + } + }) + } +}