diff --git a/.github/workflows/kind-cluster-image-policy-with-attestations.yaml b/.github/workflows/kind-cluster-image-policy-with-attestations.yaml new file mode 100644 index 00000000000..c52d490d592 --- /dev/null +++ b/.github/workflows/kind-cluster-image-policy-with-attestations.yaml @@ -0,0 +1,97 @@ +# Copyright 2022 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. + +name: Test cosigned with ClusterImagePolicy with attestations + +on: + pull_request: + branches: [ 'main', 'release-*' ] + +defaults: + run: + shell: bash + +permissions: read-all + +jobs: + cip-test: + name: ClusterImagePolicy e2e tests + runs-on: ubuntu-latest + + strategy: + matrix: + k8s-version: + - v1.21.x + - v1.22.x + # Try without this one now, might have problems with job restartings + # may require upstream changes. + - v1.23.x + + env: + KNATIVE_VERSION: "1.1.0" + KO_DOCKER_REPO: "registry.local:5000/cosigned" + SCAFFOLDING_RELEASE_VERSION: "v0.2.8" + GO111MODULE: on + GOFLAGS: -ldflags=-s -ldflags=-w + KOCACHE: ~/ko + COSIGN_EXPERIMENTAL: true + + steps: + - uses: actions/checkout@dcd71f646680f2efd8db4afa5ad64fdcba30e748 # v2.4.0 + - uses: actions/setup-go@f6164bd8c8acb4a71fb2791a8b6c4024ff038dab # v2.2.0 + with: + go-version: '1.17.x' + + # will use the latest release available for ko + - uses: imjasonh/setup-ko@2c3450ca27f6e6f2b02e72a40f2163c281a1f675 # v0.4 + + - uses: imranismail/setup-kustomize@8fa954828ed3cfa7a487a2ba9f7104899bb48b2f # v1.6.1 + + - name: Install yq + uses: mikefarah/yq@ed5b811f37384d92f62898492ddd81b6dc3af38f # v4.16.2 + + - name: Setup mirror + uses: chainguard-dev/actions/setup-mirror@main + with: + mirror: mirror.gcr.io + + - name: build cosign + run: | + make cosign + + - name: Install cluster + cosign + uses: sigstore/scaffolding/actions/setup@main + + - name: Install cosigned + env: + GIT_HASH: ${{ github.sha }} + GIT_VERSION: ci + LDFLAGS: "" + COSIGNED_YAML: cosigned-e2e.yaml + KO_PREFIX: registry.local:5000/cosigned + COSIGNED_ARCHS: linux/amd64 + run: | + make ko-cosigned + kubectl apply -f cosigned-e2e.yaml + + # Wait for the webhook to come up and become Ready + kubectl rollout status --timeout 5m --namespace cosign-system deployments/webhook + + - name: Run Cluster Image Policy Tests with attestations + run: | + ./test/e2e_test_cluster_image_policy_with_attestations.sh + + - name: Collect diagnostics + if: ${{ failure() }} + uses: chainguard-dev/actions/kind-diag@84c993eaf02da1c325854fb272a4df9184bd80fc # main diff --git a/config/300-clusterimagepolicy.yaml b/config/300-clusterimagepolicy.yaml index 5301b2cb5fa..cd5545806f1 100644 --- a/config/300-clusterimagepolicy.yaml +++ b/config/300-clusterimagepolicy.yaml @@ -44,6 +44,36 @@ spec: items: type: object properties: + attestations: + type: array + items: + type: object + properties: + name: + description: Name of the attestation. These can then be referenced at the CIP level policy. + type: string + policy: + type: object + properties: + configMapRef: + type: object + properties: + name: + description: Name is unique within a namespace to reference a configmap resource. + type: string + namespace: + description: Namespace defines the space within which the configmap name must be unique. + type: string + data: + type: string + type: + description: Which kind of policy this is, currently only rego or cue are supported. Furthermore, only cue is tested :) + type: string + url: + type: string + predicateType: + description: Which predicate type to verify. Matches cosign verify-attestation options. + type: string ctlog: type: object properties: @@ -99,6 +129,9 @@ spec: type: string url: type: string + name: + description: Name is the name for this authority. Used by the CIP Policy validator to be able to reference matching signature or attestation verifications. If not specified, the name will be authority- + type: string source: type: array items: @@ -115,3 +148,23 @@ spec: type: string regex: type: string + policy: + description: Policy is an optional policy that can be applied against all the successfully validated Authorities. If no authorities pass, this does not even get evaluated, as the Policy is considered failed. + type: object + properties: + configMapRef: + type: object + properties: + name: + description: Name is unique within a namespace to reference a configmap resource. + type: string + namespace: + description: Namespace defines the space within which the configmap name must be unique. + type: string + data: + type: string + type: + description: Which kind of policy this is, currently only rego or cue are supported. Furthermore, only cue is tested :) + type: string + url: + type: string diff --git a/pkg/apis/config/image_policies.go b/pkg/apis/config/image_policies.go index 3bce8bc80fc..40a6fa5164c 100644 --- a/pkg/apis/config/image_policies.go +++ b/pkg/apis/config/image_policies.go @@ -77,15 +77,15 @@ func parseEntry(entry string, out interface{}) error { // GetMatchingPolicies returns all matching Policies and their Authorities that // need to be matched for the given Image. -// Returned map contains the name of the CIP as the key, and an array of -// authorities from that Policy that must be validated against. -func (p *ImagePolicyConfig) GetMatchingPolicies(image string) (map[string][]webhookcip.Authority, error) { +// Returned map contains the name of the CIP as the key, and a normalized +// ClusterImagePolicy for it. +func (p *ImagePolicyConfig) GetMatchingPolicies(image string) (map[string]webhookcip.ClusterImagePolicy, error) { if p == nil { return nil, errors.New("config is nil") } var lastError error - ret := map[string][]webhookcip.Authority{} + ret := make(map[string]webhookcip.ClusterImagePolicy) // TODO(vaikas): this is very inefficient, we should have a better // way to go from image to Authorities, but just seeing if this is even @@ -94,13 +94,13 @@ func (p *ImagePolicyConfig) GetMatchingPolicies(image string) (map[string][]webh for _, pattern := range v.Images { if pattern.Glob != "" { if GlobMatch(image, pattern.Glob) { - ret[k] = append(ret[k], v.Authorities...) + ret[k] = v } } else if pattern.Regex != "" { if regex, err := regexp.Compile(pattern.Regex); err != nil { lastError = err } else if regex.MatchString(image) { - ret[k] = append(ret[k], v.Authorities...) + ret[k] = v } } } diff --git a/pkg/apis/config/image_policies_test.go b/pkg/apis/config/image_policies_test.go index ff7146b53b3..0336bb80afe 100644 --- a/pkg/apis/config/image_policies_test.go +++ b/pkg/apis/config/image_policies_test.go @@ -51,7 +51,7 @@ func TestGetAuthorities(t *testing.T) { checkGetMatches(t, c, err) matchedPolicy := "cluster-image-policy-0" want := "inlinedata here" - if got := c[matchedPolicy][0].Key.Data; got != want { + if got := c[matchedPolicy].Authorities[0].Key.Data; got != want { t.Errorf("Did not get what I wanted %q, got %+v", want, got) } // Make sure glob matches 'randomstuff*' @@ -59,22 +59,22 @@ func TestGetAuthorities(t *testing.T) { checkGetMatches(t, c, err) matchedPolicy = "cluster-image-policy-1" want = "otherinline here" - if got := c[matchedPolicy][0].Key.Data; got != want { + if got := c[matchedPolicy].Authorities[0].Key.Data; got != want { t.Errorf("Did not get what I wanted %q, got %+v", want, got) } c, err = defaults.GetMatchingPolicies("rando3") checkGetMatches(t, c, err) matchedPolicy = "cluster-image-policy-2" want = "cacert chilling here" - if got := c[matchedPolicy][0].Keyless.CACert.Data; got != want { + if got := c[matchedPolicy].Authorities[0].Keyless.CACert.Data; got != want { t.Errorf("Did not get what I wanted %q, got %+v", want, got) } want = "issuer" - if got := c[matchedPolicy][0].Keyless.Identities[0].Issuer; got != want { + if got := c[matchedPolicy].Authorities[0].Keyless.Identities[0].Issuer; got != want { t.Errorf("Did not get what I wanted %q, got %+v", want, got) } want = "subject" - if got := c[matchedPolicy][0].Keyless.Identities[0].Subject; got != want { + if got := c[matchedPolicy].Authorities[0].Keyless.Identities[0].Subject; got != want { t.Errorf("Did not get what I wanted %q, got %+v", want, got) } // Make sure regex matches ".*regexstring.*" @@ -82,30 +82,30 @@ func TestGetAuthorities(t *testing.T) { checkGetMatches(t, c, err) matchedPolicy = "cluster-image-policy-4" want = inlineKeyData - if got := c[matchedPolicy][0].Key.Data; got != want { + if got := c[matchedPolicy].Authorities[0].Key.Data; got != want { t.Errorf("Did not get what I wanted %q, got %+v", want, got) } - checkPublicKey(t, c[matchedPolicy][0].Key.PublicKeys[0]) + checkPublicKey(t, c[matchedPolicy].Authorities[0].Key.PublicKeys[0]) // Test multiline yaml cert c, err = defaults.GetMatchingPolicies("inlinecert") checkGetMatches(t, c, err) matchedPolicy = "cluster-image-policy-3" want = inlineKeyData - if got := c[matchedPolicy][0].Key.Data; got != want { + if got := c[matchedPolicy].Authorities[0].Key.Data; got != want { t.Errorf("Did not get what I wanted %q, got %+v", want, got) } - checkPublicKey(t, c[matchedPolicy][0].Key.PublicKeys[0]) + checkPublicKey(t, c[matchedPolicy].Authorities[0].Key.PublicKeys[0]) // Test multiline cert but json encoded c, err = defaults.GetMatchingPolicies("ghcr.io/example/*") checkGetMatches(t, c, err) matchedPolicy = "cluster-image-policy-json" want = inlineKeyData - if got := c[matchedPolicy][0].Key.Data; got != want { + if got := c[matchedPolicy].Authorities[0].Key.Data; got != want { t.Errorf("Did not get what I wanted %q, got %+v", want, got) } - checkPublicKey(t, c[matchedPolicy][0].Key.PublicKeys[0]) + checkPublicKey(t, c[matchedPolicy].Authorities[0].Key.PublicKeys[0]) // Test multiple matches c, err = defaults.GetMatchingPolicies("regexstringtoo") @@ -115,19 +115,48 @@ func TestGetAuthorities(t *testing.T) { } matchedPolicy = "cluster-image-policy-4" want = inlineKeyData - if got := c[matchedPolicy][0].Key.Data; got != want { + if got := c[matchedPolicy].Authorities[0].Key.Data; got != want { t.Errorf("Did not get what I wanted %q, got %+v", want, got) } - checkPublicKey(t, c[matchedPolicy][0].Key.PublicKeys[0]) + checkPublicKey(t, c[matchedPolicy].Authorities[0].Key.PublicKeys[0]) matchedPolicy = "cluster-image-policy-5" want = "inlinedata here" - if got := c[matchedPolicy][0].Key.Data; got != want { + if got := c[matchedPolicy].Authorities[0].Key.Data; got != want { + t.Errorf("Did not get what I wanted %q, got %+v", want, got) + } + + // Test attestations + top level policy + c, err = defaults.GetMatchingPolicies("withattestations") + checkGetMatches(t, c, err) + if len(c) != 1 { + t.Errorf("Wanted 1 match, got %d", len(c)) + } + matchedPolicy = "cluster-image-policy-with-policy-attestations" + want = "attestation-0" + if got := c[matchedPolicy].Authorities[0].Name; got != want { + t.Errorf("Did not get what I wanted %q, got %+v", want, got) + } + // Both top & authority policy is using cue + want = "cue" + if got := c[matchedPolicy].Policy.Type; got != want { + t.Errorf("Did not get what I wanted %q, got %+v", want, got) + } + want = "cip level cue here" + if got := c[matchedPolicy].Policy.Data; got != want { + t.Errorf("Did not get what I wanted %q, got %+v", want, got) + } + want = "cue" + if got := c[matchedPolicy].Authorities[0].Attestations[0].Type; got != want { + t.Errorf("Did not get what I wanted %q, got %+v", want, got) + } + want = "test-cue-here" + if got := c[matchedPolicy].Authorities[0].Attestations[0].Data; got != want { t.Errorf("Did not get what I wanted %q, got %+v", want, got) } } -func checkGetMatches(t *testing.T, c map[string][]webhookcip.Authority, err error) { +func checkGetMatches(t *testing.T, c map[string]webhookcip.ClusterImagePolicy, err error) { t.Helper() if err != nil { t.Error("GetMatches Failed =", err) @@ -136,7 +165,7 @@ func checkGetMatches(t *testing.T, c map[string][]webhookcip.Authority, err erro t.Error("Wanted a config, got none.") } for _, v := range c { - if v != nil || len(v) > 0 { + if v.Authorities != nil || len(v.Authorities) > 0 { return } } diff --git a/pkg/apis/config/testdata/config-image-policies.yaml b/pkg/apis/config/testdata/config-image-policies.yaml index ad7154c3f02..23a0d0f6c79 100644 --- a/pkg/apis/config/testdata/config-image-policies.yaml +++ b/pkg/apis/config/testdata/config-image-policies.yaml @@ -31,21 +31,25 @@ data: images: - glob: rando authorities: - - key: + - name: attestation-0 + key: data: inlinedata here - - key: + - name: attestation-1 + key: kms: whatevs cluster-image-policy-1: | images: - glob: randomstuff* authorities: - - key: + - name: attestation-0 + key: data: otherinline here cluster-image-policy-2: | images: - glob: rando3 authorities: - - keyless: + - name: attestation-0 + keyless: ca-cert: data: cacert chilling here url: http://keylessurl.here @@ -56,7 +60,8 @@ data: images: - glob: inlinecert authorities: - - key: + - name: attestation-0 + key: data: |- -----BEGIN PUBLIC KEY----- MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAExB6+H6054/W1SJgs5JR6AJr6J35J @@ -66,7 +71,8 @@ data: images: - regex: .*regexstring.* authorities: - - key: + - name: attestation-0 + key: data: |- -----BEGIN PUBLIC KEY----- MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAExB6+H6054/W1SJgs5JR6AJr6J35J @@ -76,7 +82,26 @@ data: images: - regex: .*regexstringtoo.* authorities: - - key: + - name: attestation-0 + key: data: inlinedata here cluster-image-policy-json: "{\"images\":[{\"glob\":\"ghcr.io/example/*\",\"regex\":\"\"}],\"authorities\":[{\"key\":{\"data\":\"-----BEGIN PUBLIC KEY-----\\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAExB6+H6054/W1SJgs5JR6AJr6J35J\\nRCTfQ5s1kD+hGMSE1rH7s46hmXEeyhnlRnaGF8eMU/SBJE/2NKPnxE7WzQ==\\n-----END PUBLIC KEY-----\"}}]}" - + cluster-image-policy-with-policy-attestations: | + images: + - glob: withattestations + authorities: + - name: attestation-0 + keyless: + ca-cert: + data: cacert chilling here + url: http://keylessurl.here + identities: + - issuer: issuer + subject: subject + attestations: + - predicateType: vuln + type: cue + data: "test-cue-here" + policy: + type: cue + data: "cip level cue here" diff --git a/pkg/apis/cosigned/v1alpha1/clusterimagepolicy_defaults.go b/pkg/apis/cosigned/v1alpha1/clusterimagepolicy_defaults.go index 66761c5ddc6..431a68d9516 100644 --- a/pkg/apis/cosigned/v1alpha1/clusterimagepolicy_defaults.go +++ b/pkg/apis/cosigned/v1alpha1/clusterimagepolicy_defaults.go @@ -14,8 +14,20 @@ package v1alpha1 -import "context" +import ( + "context" + "fmt" +) // SetDefaults implements apis.Defaultable -func (*ClusterImagePolicy) SetDefaults(ctx context.Context) { +func (c *ClusterImagePolicy) SetDefaults(ctx context.Context) { + c.Spec.SetDefaults(ctx) +} + +func (spec *ClusterImagePolicySpec) SetDefaults(ctx context.Context) { + for i, authority := range spec.Authorities { + if authority.Name == "" { + spec.Authorities[i].Name = fmt.Sprintf("authority-%d", i) + } + } } diff --git a/pkg/apis/cosigned/v1alpha1/clusterimagepolicy_defaults_test.go b/pkg/apis/cosigned/v1alpha1/clusterimagepolicy_defaults_test.go new file mode 100644 index 00000000000..0a1ba8e27bf --- /dev/null +++ b/pkg/apis/cosigned/v1alpha1/clusterimagepolicy_defaults_test.go @@ -0,0 +1,63 @@ +// Copyright 2022 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 v1alpha1 + +import ( + "context" + + "testing" + + "knative.dev/pkg/apis" +) + +func TestNameDefaulting(t *testing.T) { + tests := []struct { + in *ClusterImagePolicy + wantNames []string + }{ + {in: cipWithNames([]string{""}), + wantNames: []string{"authority-0"}, + }, + {in: cipWithNames([]string{"", "vuln-scan"}), + wantNames: []string{"authority-0", "vuln-scan"}, + }, + {in: cipWithNames([]string{"vuln-scan", ""}), + wantNames: []string{"vuln-scan", "authority-1"}, + }, + {in: cipWithNames([]string{"first", "second"}), + wantNames: []string{"first", "second"}, + }} + for _, tc := range tests { + tc.in.SetDefaults(context.TODO()) + if len(tc.in.Spec.Authorities) != len(tc.wantNames) { + t.Fatalf("Mismatch number of wantNames: %d vs authorities: %d", len(tc.wantNames), len(tc.in.Spec.Authorities)) + } + for i, wantName := range tc.wantNames { + if tc.in.Spec.Authorities[i].Name != wantName { + t.Errorf("Wanted name: %s got %s", wantName, tc.in.Spec.Authorities[i].Name) + } + } + } +} + +func cipWithNames(names []string) *ClusterImagePolicy { + cip := &ClusterImagePolicy{ + Spec: ClusterImagePolicySpec{}, + } + for _, name := range names { + cip.Spec.Authorities = append(cip.Spec.Authorities, Authority{Name: name, Keyless: &KeylessRef{URL: &apis.URL{Host: "tests.example.com"}}}) + } + return cip +} diff --git a/pkg/apis/cosigned/v1alpha1/clusterimagepolicy_types.go b/pkg/apis/cosigned/v1alpha1/clusterimagepolicy_types.go index d8f30028da4..bad7c36483b 100644 --- a/pkg/apis/cosigned/v1alpha1/clusterimagepolicy_types.go +++ b/pkg/apis/cosigned/v1alpha1/clusterimagepolicy_types.go @@ -46,7 +46,7 @@ var ( ) // GetGroupVersionKind implements kmeta.OwnerRefable -func (*ClusterImagePolicy) GetGroupVersionKind() schema.GroupVersionKind { +func (c *ClusterImagePolicy) GetGroupVersionKind() schema.GroupVersionKind { return SchemeGroupVersion.WithKind("ClusterImagePolicy") } @@ -54,6 +54,11 @@ func (*ClusterImagePolicy) GetGroupVersionKind() schema.GroupVersionKind { type ClusterImagePolicySpec struct { Images []ImagePattern `json:"images"` Authorities []Authority `json:"authorities"` + // Policy is an optional policy that can be applied against all the + // successfully validated Authorities. If no authorities pass, this does + // not even get evaluated, as the Policy is considered failed. + // +optional + Policy *Policy `json:"policy,omitempty"` } // ImagePattern defines a pattern and its associated authorties @@ -75,6 +80,11 @@ type ImagePattern struct { // image. type Authority struct { + // Name is the name for this authority. Used by the CIP Policy + // validator to be able to reference matching signature or attestation + // verifications. + // If not specified, the name will be authority- + Name string `json:"name"` // +optional Key *KeyRef `json:"key,omitempty"` // +optional @@ -83,6 +93,8 @@ type Authority struct { Sources []Source `json:"source,omitempty"` // +optional CTLog *TLog `json:"ctlog,omitempty"` + // +optional + Attestations []Attestation `json:"attestations,omitempty"` } // This references a public verification key stored in @@ -124,6 +136,45 @@ type KeylessRef struct { CACert *KeyRef `json:"ca-cert,omitempty"` } +// Attestation defines the type of attestation to validate and optionally +// apply a policy decision to it. Authority block is used to verify the +// specified attestation types, and if Policy is specified, then it's applied +// only after the validation of the Attestation signature has been verified. +type Attestation struct { + // Name of the attestation. These can then be referenced at the CIP level + // policy. + Name string `json:"name"` + // Which predicate type to verify. Matches cosign verify-attestation options. + PredicateType string `json:"predicateType"` + // +optional + Policy *Policy `json:"policy,omitempty"` +} + +// Policy specifies a policy to use for Attestation validation. +// Exactly one of Data, URL, or ConfigMapReference must be specified. +type Policy struct { + // Which kind of policy this is, currently only rego or cue are supported. + // Furthermore, only cue is tested :) + Type string `json:"type"` + // +optional + Data string `json:"data,omitempty"` + // +optional + URL *apis.URL `json:"url,omitempty"` + // +optional + ConfigMapRef *ConfigMapReference `json:"configMapRef,omitempty"` +} + +// ConfigMapReference is cut&paste from SecretReference, but for the life of me +// couldn't find one in the public types. If there's one, use it. +type ConfigMapReference struct { + // Name is unique within a namespace to reference a configmap resource. + // +optional + Name string `json:"name,omitempty"` + // Namespace defines the space within which the configmap name must be unique. + // +optional + Namespace string `json:"namespace,omitempty"` +} + // Identity may contain the issuer and/or the subject found in the transparency log. // Either field supports a pattern glob. type Identity struct { diff --git a/pkg/apis/cosigned/v1alpha1/clusterimagepolicy_validation.go b/pkg/apis/cosigned/v1alpha1/clusterimagepolicy_validation.go index 81d0f2065fe..a307f33a834 100644 --- a/pkg/apis/cosigned/v1alpha1/clusterimagepolicy_validation.go +++ b/pkg/apis/cosigned/v1alpha1/clusterimagepolicy_validation.go @@ -25,8 +25,8 @@ import ( ) // Validate implements apis.Validatable -func (policy *ClusterImagePolicy) Validate(ctx context.Context) *apis.FieldError { - return policy.Spec.Validate(ctx).ViaField("spec") +func (c *ClusterImagePolicy) Validate(ctx context.Context) *apis.FieldError { + return c.Spec.Validate(ctx).ViaField("spec") } func (spec *ClusterImagePolicySpec) Validate(ctx context.Context) (errors *apis.FieldError) { @@ -42,6 +42,7 @@ func (spec *ClusterImagePolicySpec) Validate(ctx context.Context) (errors *apis. for i, authority := range spec.Authorities { errors = errors.Also(authority.Validate(ctx).ViaFieldIndex("authorities", i)) } + errors = errors.Also(spec.Policy.Validate(ctx)) return } @@ -83,10 +84,12 @@ func (authority *Authority) Validate(ctx context.Context) *apis.FieldError { errs = errs.Also(authority.Keyless.Validate(ctx).ViaField("keyless")) } - if len(authority.Sources) > 0 { - for _, source := range authority.Sources { - errs = errs.Also(source.Validate(ctx).ViaField("source")) - } + for _, source := range authority.Sources { + errs = errs.Also(source.Validate(ctx).ViaField("source")) + } + + for _, att := range authority.Attestations { + errs = errs.Also(att.Validate(ctx).ViaField("attestations")) } return errs @@ -144,6 +147,39 @@ func (source *Source) Validate(ctx context.Context) *apis.FieldError { return errs } +func (a *Attestation) Validate(ctx context.Context) *apis.FieldError { + var errs *apis.FieldError + if a.Name == "" { + errs = errs.Also(apis.ErrMissingField("name")) + } + if a.PredicateType == "" { + errs = errs.Also(apis.ErrMissingField("predicateType")) + } else if a.PredicateType != "custom" && a.PredicateType != "slsaprovenance" && a.PredicateType != "spdx" && a.PredicateType != "link" && a.PredicateType != "vuln" { + // TODO(vaikas): The above should be using something like: + // if _, ok := options.PredicateTypeMap[a.PrecicateType]; !ok { + // But it causes an import loop. That refactor can be part of + // another PR. + errs = errs.Also(apis.ErrInvalidValue(a.PredicateType, "predicateType", "unsupported precicate type")) + } + errs = errs.Also(a.Policy.Validate(ctx).ViaField("policy")) + return errs +} + +func (p *Policy) Validate(ctx context.Context) *apis.FieldError { + if p == nil { + return nil + } + var errs *apis.FieldError + if p.Type != "cue" { + errs = errs.Also(apis.ErrInvalidValue(p.Type, "type", "only cue is supported at the moment")) + } + if p.Data == "" { + errs = errs.Also(apis.ErrMissingField("data")) + } + // TODO(vaikas): How to validate the cue / rego bytes here (data). + return errs +} + func (identity *Identity) Validate(ctx context.Context) *apis.FieldError { var errs *apis.FieldError if identity.Issuer == "" && identity.Subject == "" { diff --git a/pkg/apis/cosigned/v1alpha1/clusterimagepolicy_validation_test.go b/pkg/apis/cosigned/v1alpha1/clusterimagepolicy_validation_test.go index 5c288188e9d..9848f0a15e5 100644 --- a/pkg/apis/cosigned/v1alpha1/clusterimagepolicy_validation_test.go +++ b/pkg/apis/cosigned/v1alpha1/clusterimagepolicy_validation_test.go @@ -446,6 +446,46 @@ func TestAuthoritiesValidation(t *testing.T) { }, }, }, + { + name: "Should pass with multiple source oci is present", + expectErr: false, + policy: ClusterImagePolicy{ + Spec: ClusterImagePolicySpec{ + Images: []ImagePattern{{Regex: ".*"}}, + Authorities: []Authority{ + { + Key: &KeyRef{KMS: "kms://key/path"}, + Sources: []Source{ + {OCI: "registry1"}, + {OCI: "registry2"}, + }, + }, + }, + }, + }, + }, + { + name: "Should pass with attestations present", + expectErr: false, + policy: ClusterImagePolicy{ + Spec: ClusterImagePolicySpec{ + Images: []ImagePattern{{Regex: ".*"}}, + Authorities: []Authority{ + { + Key: &KeyRef{KMS: "kms://key/path"}, + Attestations: []Attestation{ + {Name: "first", PredicateType: "vuln"}, + {Name: "second", PredicateType: "custom", Policy: &Policy{ + Type: "cue", + Data: `predicateType: "cosign.sigstore.dev/attestation/vuln/v1"`, + }, + }, + }, + }, + }, + }, + }, + }, } for _, test := range tests { @@ -461,6 +501,72 @@ func TestAuthoritiesValidation(t *testing.T) { } } +func TestAttestationsValidation(t *testing.T) { + tests := []struct { + name string + expectErr bool + errorString string + attestation Attestation + }{{ + name: "vuln", + attestation: Attestation{Name: "first", PredicateType: "vuln"}, + }, { + name: "missing name", + attestation: Attestation{PredicateType: "vuln"}, + expectErr: true, + errorString: "missing field(s): name", + }, { + name: "missing predicatetype", + attestation: Attestation{Name: "first"}, + expectErr: true, + errorString: "missing field(s): predicateType", + }, { + name: "invalid predicatetype", + attestation: Attestation{Name: "first", PredicateType: "notsupported"}, + expectErr: true, + errorString: "invalid value: notsupported: predicateType\nunsupported precicate type", + }, { + name: "custom with invalid policy type", + attestation: Attestation{Name: "second", PredicateType: "custom", + Policy: &Policy{ + Type: "not-cue", + Data: `predicateType: "cosign.sigstore.dev/attestation/vuln/v1"`, + }, + }, + expectErr: true, + errorString: "invalid value: not-cue: policy.type\nonly cue is supported at the moment", + }, { + name: "custom with missing policy data", + attestation: Attestation{Name: "second", PredicateType: "custom", + Policy: &Policy{ + Type: "cue", + }, + }, + expectErr: true, + errorString: "missing field(s): policy.data", + }, { + name: "custom with policy", + attestation: Attestation{Name: "second", PredicateType: "custom", + Policy: &Policy{ + Type: "cue", + Data: `predicateType: "cosign.sigstore.dev/attestation/vuln/v1"`, + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := test.attestation.Validate(context.TODO()) + if test.expectErr { + require.NotNil(t, err) + require.EqualError(t, err, test.errorString) + } else { + require.Nil(t, err) + } + }) + } +} func TestIdentitiesValidation(t *testing.T) { tests := []struct { name string diff --git a/pkg/apis/cosigned/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/cosigned/v1alpha1/zz_generated.deepcopy.go index 2c1c886712c..c6926846691 100644 --- a/pkg/apis/cosigned/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/cosigned/v1alpha1/zz_generated.deepcopy.go @@ -25,6 +25,27 @@ import ( apis "knative.dev/pkg/apis" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Attestation) DeepCopyInto(out *Attestation) { + *out = *in + if in.Policy != nil { + in, out := &in.Policy, &out.Policy + *out = new(Policy) + (*in).DeepCopyInto(*out) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Attestation. +func (in *Attestation) DeepCopy() *Attestation { + if in == nil { + return nil + } + out := new(Attestation) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Authority) DeepCopyInto(out *Authority) { *out = *in @@ -48,6 +69,13 @@ func (in *Authority) DeepCopyInto(out *Authority) { *out = new(TLog) (*in).DeepCopyInto(*out) } + if in.Attestations != nil { + in, out := &in.Attestations, &out.Attestations + *out = make([]Attestation, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } return } @@ -136,6 +164,11 @@ func (in *ClusterImagePolicySpec) DeepCopyInto(out *ClusterImagePolicySpec) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.Policy != nil { + in, out := &in.Policy, &out.Policy + *out = new(Policy) + (*in).DeepCopyInto(*out) + } return } @@ -149,6 +182,22 @@ func (in *ClusterImagePolicySpec) DeepCopy() *ClusterImagePolicySpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ConfigMapReference) DeepCopyInto(out *ConfigMapReference) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConfigMapReference. +func (in *ConfigMapReference) DeepCopy() *ConfigMapReference { + if in == nil { + return nil + } + out := new(ConfigMapReference) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Identity) DeepCopyInto(out *Identity) { *out = *in @@ -233,6 +282,32 @@ func (in *KeylessRef) DeepCopy() *KeylessRef { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Policy) DeepCopyInto(out *Policy) { + *out = *in + if in.URL != nil { + in, out := &in.URL, &out.URL + *out = new(apis.URL) + (*in).DeepCopyInto(*out) + } + if in.ConfigMapRef != nil { + in, out := &in.ConfigMapRef, &out.ConfigMapRef + *out = new(ConfigMapReference) + **out = **in + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Policy. +func (in *Policy) DeepCopy() *Policy { + if in == nil { + return nil + } + out := new(Policy) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Source) DeepCopyInto(out *Source) { *out = *in diff --git a/pkg/cosign/kubernetes/webhook/clusterimagepolicy/clusterimagepolicy_types.go b/pkg/cosign/kubernetes/webhook/clusterimagepolicy/clusterimagepolicy_types.go index 5da8c7f83f2..cbda0c7c0fb 100644 --- a/pkg/cosign/kubernetes/webhook/clusterimagepolicy/clusterimagepolicy_types.go +++ b/pkg/cosign/kubernetes/webhook/clusterimagepolicy/clusterimagepolicy_types.go @@ -36,9 +36,17 @@ import ( type ClusterImagePolicy struct { Images []v1alpha1.ImagePattern `json:"images"` Authorities []Authority `json:"authorities"` + // Policy is an optional policy used to evaluate the results of valid + // Authorities. Will not get evaluated unless at least one Authority + // succeeds. + Policy *AttestationPolicy `json:"policy,omitempty"` } type Authority struct { + // Name is the name for this authority. Used by the CIP Policy + // validator to be able to reference matching signature or attestation + // verifications. + Name string `json:"name"` // +optional Key *KeyRef `json:"key,omitempty"` // +optional @@ -51,6 +59,8 @@ type Authority struct { // RemoteOpts will be populated by the Authority UnmarshalJSON override // +optional RemoteOpts []remote.Option `json:"-"` + // +optional + Attestations []AttestationPolicy `json:"attestations,omitempty"` } // This references a public verification key stored in @@ -74,6 +84,18 @@ type KeylessRef struct { CACert *KeyRef `json:"ca-cert,omitempty"` } +type AttestationPolicy struct { + // Name of the Attestation + Name string `json:"name"` + // PredicateType to attest, one of the accepted in verify-attestation + PredicateType string `json:"predicateType"` + // Type specifies how to evaluate policy, only rego/cue are understood. + Type string `json:"type,omitempty"` + // Data is the inlined version of the Policy used to evaluate the + // Attestation. + Data string `json:"data,omitempty"` +} + // UnmarshalJSON populates the PublicKeys using Data because // JSON unmashalling errors for *big.Int func (k *KeyRef) UnmarshalJSON(data []byte) error { @@ -136,22 +158,50 @@ func ConvertClusterImagePolicyV1alpha1ToWebhook(in *v1alpha1.ClusterImagePolicy) outAuthorities = append(outAuthorities, *outAuthority) } + // If there's a ClusterImagePolicy level AttestationPolicy, convert it here. + var cipAttestationPolicy *AttestationPolicy + if in.Spec.Policy != nil { + cipAttestationPolicy = &AttestationPolicy{ + Type: in.Spec.Policy.Type, + Data: in.Spec.Policy.Data, + } + } return &ClusterImagePolicy{ Images: copyIn.Spec.Images, Authorities: outAuthorities, + Policy: cipAttestationPolicy, } } func convertAuthorityV1Alpha1ToWebhook(in v1alpha1.Authority) *Authority { keyRef := convertKeyRefV1Alpha1ToWebhook(in.Key) keylessRef := convertKeylessRefV1Alpha1ToWebhook(in.Keyless) + attestations := convertAttestationsV1Alpha1ToWebhook(in.Attestations) return &Authority{ - Key: keyRef, - Keyless: keylessRef, - Sources: in.Sources, - CTLog: in.CTLog, + Name: in.Name, + Key: keyRef, + Keyless: keylessRef, + Sources: in.Sources, + CTLog: in.CTLog, + Attestations: attestations, + } +} + +func convertAttestationsV1Alpha1ToWebhook(in []v1alpha1.Attestation) []AttestationPolicy { + ret := []AttestationPolicy{} + for _, inAtt := range in { + outAtt := AttestationPolicy{ + Name: inAtt.Name, + PredicateType: inAtt.PredicateType, + } + if inAtt.Policy != nil { + outAtt.Type = inAtt.Policy.Type + outAtt.Data = inAtt.Policy.Data + } + ret = append(ret, outAtt) } + return ret } func convertKeyRefV1Alpha1ToWebhook(in *v1alpha1.KeyRef) *KeyRef { diff --git a/pkg/cosign/kubernetes/webhook/validation.go b/pkg/cosign/kubernetes/webhook/validation.go index be74173daa7..278cc5439fc 100644 --- a/pkg/cosign/kubernetes/webhook/validation.go +++ b/pkg/cosign/kubernetes/webhook/validation.go @@ -36,7 +36,7 @@ import ( "github.com/sigstore/sigstore/pkg/signature" ) -func valid(ctx context.Context, ref name.Reference, keys []crypto.PublicKey, opts ...ociremote.Option) ([]oci.Signature, error) { +func valid(ctx context.Context, ref name.Reference, rekorClient *client.Rekor, keys []crypto.PublicKey, opts ...ociremote.Option) ([]oci.Signature, error) { if len(keys) == 0 { // If there are no keys, then verify against the fulcio root. sps, err := validSignaturesWithFulcio(ctx, ref, fulcioroots.Get(), nil /* rekor */, nil /* no identities */, opts...) @@ -58,7 +58,7 @@ func valid(ctx context.Context, ref name.Reference, keys []crypto.PublicKey, opt continue } - sps, err := validSignatures(ctx, ref, verifier, opts...) + sps, err := validSignatures(ctx, ref, verifier, rekorClient, opts...) if err != nil { logging.FromContext(ctx).Errorf("error validating signatures: %v", err) lastErr = err @@ -74,11 +74,13 @@ func valid(ctx context.Context, ref name.Reference, keys []crypto.PublicKey, opt // For testing var cosignVerifySignatures = cosign.VerifyImageSignatures +var cosignVerifyAttestations = cosign.VerifyImageAttestations -func validSignatures(ctx context.Context, ref name.Reference, verifier signature.Verifier, opts ...ociremote.Option) ([]oci.Signature, error) { +func validSignatures(ctx context.Context, ref name.Reference, verifier signature.Verifier, rekorClient *client.Rekor, opts ...ociremote.Option) ([]oci.Signature, error) { sigs, _, err := cosignVerifySignatures(ctx, ref, &cosign.CheckOpts{ RegistryClientOpts: opts, SigVerifier: verifier, + RekorClient: rekorClient, ClaimVerifier: cosign.SimpleClaimVerifier, }) return sigs, err @@ -101,6 +103,34 @@ func validSignaturesWithFulcio(ctx context.Context, ref name.Reference, fulcioRo return sigs, err } +func validAttestations(ctx context.Context, ref name.Reference, verifier signature.Verifier, rekorClient *client.Rekor, opts ...ociremote.Option) ([]oci.Signature, error) { + attestations, _, err := cosignVerifyAttestations(ctx, ref, &cosign.CheckOpts{ + RegistryClientOpts: opts, + SigVerifier: verifier, + RekorClient: rekorClient, + ClaimVerifier: cosign.IntotoSubjectClaimVerifier, + }) + return attestations, err +} + +// validAttestationsWithFulcio expects a Fulcio Cert to verify against. An +// optional rekorClient can also be given, if nil passed, default is assumed. +func validAttestationsWithFulcio(ctx context.Context, ref name.Reference, fulcioRoots *x509.CertPool, rekorClient *client.Rekor, identities []v1alpha1.Identity, opts ...ociremote.Option) ([]oci.Signature, error) { + ids := make([]cosign.Identity, len(identities)) + for i, id := range identities { + ids[i] = cosign.Identity{Issuer: id.Issuer, Subject: id.Subject} + } + + attestations, _, err := cosignVerifyAttestations(ctx, ref, &cosign.CheckOpts{ + RegistryClientOpts: opts, + RootCerts: fulcioRoots, + RekorClient: rekorClient, + ClaimVerifier: cosign.IntotoSubjectClaimVerifier, + Identities: ids, + }) + return attestations, err +} + func getKeys(ctx context.Context, cfg map[string][]byte) ([]crypto.PublicKey, *apis.FieldError) { keys := []crypto.PublicKey{} errs := []error{} diff --git a/pkg/cosign/kubernetes/webhook/validator.go b/pkg/cosign/kubernetes/webhook/validator.go index dc9ad4c037e..7590892a589 100644 --- a/pkg/cosign/kubernetes/webhook/validator.go +++ b/pkg/cosign/kubernetes/webhook/validator.go @@ -17,10 +17,11 @@ package webhook import ( "context" + "crypto" "crypto/x509" + "encoding/json" "fmt" - "github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/authn/k8schain" "github.com/google/go-containerregistry/pkg/name" "github.com/google/go-containerregistry/pkg/v1/remote" @@ -29,9 +30,11 @@ import ( webhookcip "github.com/sigstore/cosign/pkg/cosign/kubernetes/webhook/clusterimagepolicy" "github.com/sigstore/cosign/pkg/oci" ociremote "github.com/sigstore/cosign/pkg/oci/remote" + "github.com/sigstore/cosign/pkg/policy" "github.com/sigstore/fulcio/pkg/api" rekor "github.com/sigstore/rekor/pkg/client" "github.com/sigstore/rekor/pkg/generated/client" + "github.com/sigstore/sigstore/pkg/signature" corev1 "k8s.io/api/core/v1" "k8s.io/client-go/kubernetes" listersv1 "k8s.io/client-go/listers/core/v1" @@ -168,15 +171,20 @@ func (v *Validator) validatePodSpec(ctx context.Context, ps *corev1.PodSpec, opt // If there is at least one policy that matches, that means it // has to be satisfied. if len(policies) > 0 { - signatures, fieldErrors := validatePolicies(ctx, ref, kc, policies) + signatures, fieldErrors := validatePolicies(ctx, ref, policies, ociremote.WithRemoteOptions(remote.WithAuthFromKeychain(kc))) + if len(signatures) != len(policies) { logging.FromContext(ctx).Warnf("Failed to validate at least one policy for %s", ref.Name()) // Do we really want to add all the error details here? // Seems like we can just say which policy failed, so // doing that for now. - for failingPolicy := range fieldErrors { + for failingPolicy, policyErrs := range fieldErrors { errorField := apis.ErrGeneric(fmt.Sprintf("failed policy: %s", failingPolicy), "image").ViaFieldIndex(field, i) - errorField.Details = c.Image + errDetails := c.Image + for _, policyErr := range policyErrs { + errDetails = errDetails + " " + policyErr.Error() + } + errorField.Details = errDetails errs = errs.Also(errorField) } // Because there was at least one policy that was @@ -203,7 +211,7 @@ func (v *Validator) validatePodSpec(ctx context.Context, ps *corev1.PodSpec, opt continue } - if _, err := valid(ctx, ref, containerKeys, ociremote.WithRemoteOptions(remote.WithAuthFromKeychain(kc))); err != nil { + if _, err := valid(ctx, ref, nil, containerKeys, ociremote.WithRemoteOptions(remote.WithAuthFromKeychain(kc))); err != nil { errorField := apis.ErrGeneric(err.Error(), "image").ViaFieldIndex(field, i) errorField.Details = c.Image errs = errs.Also(errorField) @@ -227,9 +235,9 @@ func (v *Validator) validatePodSpec(ctx context.Context, ps *corev1.PodSpec, opt // Note that if an image does not match any policies, it's perfectly // reasonable that the return value is 0, nil since there were no errors, but // the image was not validated against any matching policy and hence authority. -func validatePolicies(ctx context.Context, ref name.Reference, kc authn.Keychain, policies map[string][]webhookcip.Authority, remoteOpts ...ociremote.Option) (map[string][]oci.Signature, map[string][]error) { - // Gather all validated signatures here. - signatures := map[string][]oci.Signature{} +func validatePolicies(ctx context.Context, ref name.Reference, policies map[string]webhookcip.ClusterImagePolicy, remoteOpts ...ociremote.Option) (map[string]*PolicyResult, map[string][]error) { + // Gather all validated policies here. + policyResults := make(map[string]*PolicyResult) // For a policy that does not pass at least one authority, gather errors // here so that we can give meaningful errors to the user. ret := map[string][]error{} @@ -240,85 +248,232 @@ func validatePolicies(ctx context.Context, ref name.Reference, kc authn.Keychain // policies must be satisfied for the image to be admitted." // If none of the Authorities for a given policy pass the checks, gather // the errors here. If one passes, do not return the errors. - authorityErrors := []error{} - for p, authorities := range policies { - logging.FromContext(ctx).Debugf("Checking Policy: %s", p) - sigs, errs := ValidatePolicy(ctx, ref, kc, authorities, remoteOpts...) + for cipName, cip := range policies { + logging.FromContext(ctx).Debugf("Checking Policy: %s", cipName) + policyResult, errs := ValidatePolicy(ctx, ref, cip, remoteOpts...) if len(errs) > 0 { - ret[p] = append(ret[p], authorityErrors...) + ret[cipName] = append(ret[cipName], errs...) } else { - signatures[p] = append(signatures[p], sigs...) + // Ok, at least one Authority on the policy passed. If there's a CIP level + // policy, apply it against the results of the successful Authorities + // outputs. + if cip.Policy != nil { + logging.FromContext(ctx).Infof("Validating CIP level policy for %s", cipName) + policyJSON, err := json.Marshal(policyResult) + if err != nil { + ret[cipName] = append(ret[cipName], errors.Wrap(err, "marshaling policyresult")) + } else { + logging.FromContext(ctx).Infof("Validating CIP level policy against %s", string(policyJSON)) + err = policy.EvaluatePolicyAgainstJSON(ctx, "ClusterImagePolicy", cip.Policy.Type, cip.Policy.Data, policyJSON) + if err != nil { + ret[cipName] = append(ret[cipName], err) + } else { + policyResults[cipName] = policyResult + } + } + } else { + policyResults[cipName] = policyResult + } } } - return signatures, ret + return policyResults, ret } -// ValidatePolicy will go through all the Authorities for a given image and -// return a success if at least one of the Authorities validated the signatures. -// Returns the validated signatures, or the errors encountered. -func ValidatePolicy(ctx context.Context, ref name.Reference, kc authn.Keychain, authorities []webhookcip.Authority, remoteOpts ...ociremote.Option) ([]oci.Signature, []error) { - remoteOpts = append(remoteOpts, ociremote.WithRemoteOptions(remote.WithAuthFromKeychain(kc))) - +// ValidatePolicy will go through all the Authorities for a given image/policy +// and return a success if at least one of the Authorities validated the +// signatures OR attestations if atttestations were specified. +// Returns PolicyResult, or errors encountered if none of the authorities +// passed. +func ValidatePolicy(ctx context.Context, ref name.Reference, cip webhookcip.ClusterImagePolicy, remoteOpts ...ociremote.Option) (*PolicyResult, []error) { // If none of the Authorities for a given policy pass the checks, gather // the errors here. If one passes, do not return the errors. authorityErrors := []error{} - for _, authority := range authorities { + // We collect all the successfully satisfied Authorities into this and + // return it. + policyResult := PolicyResult{AuthorityMatches: make(map[string]AuthorityMatch)} + for _, authority := range cip.Authorities { + logging.FromContext(ctx).Debugf("Checking Authority: %s", authority.Name) // Assignment for appendAssign lint error authorityRemoteOpts := remoteOpts authorityRemoteOpts = append(authorityRemoteOpts, authority.RemoteOpts...) - switch { - case authority.Key != nil && len(authority.Key.PublicKeys) > 0: - // TODO(vaikas): What should happen if there are multiple keys - // Is it even allowed? 'valid' returns success if any key - // matches. - // https://github.com/sigstore/cosign/issues/1652 - sps, err := valid(ctx, ref, authority.Key.PublicKeys, authorityRemoteOpts...) + if len(authority.Attestations) > 0 { + // We're doing the verify-attestations path, so validate (.att) + validatedAttestations, err := ValidatePolicyAttestationsForAuthority(ctx, ref, authority, authorityRemoteOpts...) if err != nil { - authorityErrors = append(authorityErrors, errors.Wrap(err, "failed to validate keys")) - continue + authorityErrors = append(authorityErrors, err) } else { - if len(sps) > 0 { - logging.FromContext(ctx).Debugf("validated signature for %s, got %d signatures", ref.Name(), len(sps)) - return sps, nil - } - logging.FromContext(ctx).Errorf("no validSignatures found for %s", ref.Name()) - authorityErrors = append(authorityErrors, fmt.Errorf("no valid signatures found for %s", ref.Name())) + policyResult.AuthorityMatches[authority.Name] = AuthorityMatch{Attestations: validatedAttestations} } - case authority.Keyless != nil: - if authority.Keyless != nil && authority.Keyless.URL != nil { - logging.FromContext(ctx).Debugf("Fetching FulcioRoot for %s : From: %s ", ref.Name(), authority.Keyless.URL) - fulcioroot, err := getFulcioCert(authority.Keyless.URL) - if err != nil { - authorityErrors = append(authorityErrors, errors.Wrap(err, "fetching FulcioRoot")) - continue - } - var rekorClient *client.Rekor - if authority.CTLog != nil && authority.CTLog.URL != nil { - logging.FromContext(ctx).Debugf("Using CTLog %s for %s", authority.CTLog.URL, ref.Name()) - rekorClient, err = rekor.GetRekorClient(authority.CTLog.URL.String()) - if err != nil { - logging.FromContext(ctx).Errorf("failed creating rekor client: +v", err) - authorityErrors = append(authorityErrors, errors.Wrap(err, "creating Rekor client")) - continue - } - } - sps, err := validSignaturesWithFulcio(ctx, ref, fulcioroot, rekorClient, authority.Keyless.Identities, authorityRemoteOpts...) - if err != nil { - logging.FromContext(ctx).Errorf("failed validSignatures with fulcio for %s: %v", ref.Name(), err) - authorityErrors = append(authorityErrors, errors.Wrap(err, "validate signatures with fulcio")) - } else { - if len(sps) > 0 { - logging.FromContext(ctx).Debugf("validated signature for %s, got %d signatures", ref.Name(), len(sps)) - return sps, nil - } - logging.FromContext(ctx).Errorf("no validSignatures found for %s", ref.Name()) - authorityErrors = append(authorityErrors, fmt.Errorf("no valid signatures found for %s", ref.Name())) - } + } else { + // We're doing the verify path, so validate image signatures (.sig) + validatedSignatures, err := ValidatePolicySignaturesForAuthority(ctx, ref, authority, authorityRemoteOpts...) + if err != nil { + authorityErrors = append(authorityErrors, err) + } else { + policyResult.AuthorityMatches[authority.Name] = AuthorityMatch{Signatures: validatedSignatures} + } + } + } + if len(authorityErrors) > 0 { + return nil, authorityErrors + } + return &policyResult, authorityErrors +} + +func ociSignatureToPolicySignature(ctx context.Context, sigs []oci.Signature) []PolicySignature { + // TODO(vaikas): Validate whether these are useful at all, or if we should + // simplify at least for starters. + ret := []PolicySignature{} + for _, ociSig := range sigs { + logging.FromContext(ctx).Debugf("Converting signature %+v", ociSig) + ret = append(ret, PolicySignature{Subject: "PLACEHOLDER", Issuer: "PLACEHOLDER"}) + } + return ret +} + +// ValidatePolicySignaturesForAuthority takes the Authority and tries to +// verify a signature against it. +func ValidatePolicySignaturesForAuthority(ctx context.Context, ref name.Reference, authority webhookcip.Authority, remoteOpts ...ociremote.Option) ([]PolicySignature, error) { + name := authority.Name + + var rekorClient *client.Rekor + var err error + if authority.CTLog != nil && authority.CTLog.URL != nil { + logging.FromContext(ctx).Debugf("Using CTLog %s for %s", authority.CTLog.URL, ref.Name()) + rekorClient, err = rekor.GetRekorClient(authority.CTLog.URL.String()) + if err != nil { + logging.FromContext(ctx).Errorf("failed creating rekor client: +v", err) + return nil, errors.Wrap(err, "creating Rekor client") + } + } + + switch { + case authority.Key != nil && len(authority.Key.PublicKeys) > 0: + // TODO(vaikas): What should happen if there are multiple keys + // Is it even allowed? 'valid' returns success if any key + // matches. + // https://github.com/sigstore/cosign/issues/1652 + sps, err := valid(ctx, ref, rekorClient, authority.Key.PublicKeys, remoteOpts...) + if err != nil { + return nil, errors.Wrap(err, fmt.Sprintf("failed to validate public keys with authority %s for %s", name, ref.Name())) + } else if len(sps) > 0 { + logging.FromContext(ctx).Debugf("validated signature for %s with authority %s got %d signatures", ref.Name(), authority.Name, len(sps)) + return ociSignatureToPolicySignature(ctx, sps), nil + } + logging.FromContext(ctx).Errorf("no validSignatures found with authority %s for %s", name, ref.Name()) + return nil, fmt.Errorf("no valid signatures found with authority %s for %s", name, ref.Name()) + case authority.Keyless != nil: + if authority.Keyless != nil && authority.Keyless.URL != nil { + logging.FromContext(ctx).Debugf("Fetching FulcioRoot for %s : From: %s ", ref.Name(), authority.Keyless.URL) + fulcioroot, err := getFulcioCert(authority.Keyless.URL) + if err != nil { + return nil, errors.Wrap(err, "fetching FulcioRoot") + } + sps, err := validSignaturesWithFulcio(ctx, ref, fulcioroot, rekorClient, authority.Keyless.Identities, remoteOpts...) + if err != nil { + logging.FromContext(ctx).Errorf("failed validSignatures for authority %s with fulcio for %s: %v", name, ref.Name(), err) + return nil, errors.Wrap(err, "validate signatures with fulcio") + } else if len(sps) > 0 { + logging.FromContext(ctx).Debugf("validated signature for %s, got %d signatures", ref.Name(), len(sps)) + return ociSignatureToPolicySignature(ctx, sps), nil + } + logging.FromContext(ctx).Errorf("no validSignatures found for %s", ref.Name()) + return nil, fmt.Errorf("no valid signatures found with authority %s for %s", name, ref.Name()) + } + } + // This should never happen because authority has to have been + // validated to be either having a Key or Keyless + return nil, fmt.Errorf("authority has neither key or keyless specified") +} + +// ValidatePolicyAttestationsForAuthority takes the Authority and tries to +// verify attestations against it. +func ValidatePolicyAttestationsForAuthority(ctx context.Context, ref name.Reference, authority webhookcip.Authority, remoteOpts ...ociremote.Option) (map[string][]PolicySignature, error) { + name := authority.Name + var rekorClient *client.Rekor + var err error + if authority.CTLog != nil && authority.CTLog.URL != nil { + logging.FromContext(ctx).Debugf("Using CTLog %s for %s", authority.CTLog.URL, ref.Name()) + rekorClient, err = rekor.GetRekorClient(authority.CTLog.URL.String()) + if err != nil { + logging.FromContext(ctx).Errorf("failed creating rekor client: +v", err) + return nil, errors.Wrap(err, "creating Rekor client") + } + } + + verifiedAttestations := []oci.Signature{} + switch { + case authority.Key != nil && len(authority.Key.PublicKeys) > 0: + for _, k := range authority.Key.PublicKeys { + verifier, err := signature.LoadVerifier(k, crypto.SHA256) + if err != nil { + logging.FromContext(ctx).Errorf("error creating verifier: %v", err) + return nil, errors.Wrap(err, "creating verifier") + } + va, err := validAttestations(ctx, ref, verifier, rekorClient, remoteOpts...) + if err != nil { + logging.FromContext(ctx).Errorf("error validating attestations: %v", err) + return nil, errors.Wrap(err, "validating attestations") + } + verifiedAttestations = append(verifiedAttestations, va...) + } + logging.FromContext(ctx).Debug("No valid signatures were found.") + case authority.Keyless != nil: + if authority.Keyless != nil && authority.Keyless.URL != nil { + logging.FromContext(ctx).Debugf("Fetching FulcioRoot for %s : From: %s ", ref.Name(), authority.Keyless.URL) + fulcioroot, err := getFulcioCert(authority.Keyless.URL) + if err != nil { + return nil, errors.Wrap(err, "fetching FulcioRoot") + } + va, err := validAttestationsWithFulcio(ctx, ref, fulcioroot, rekorClient, authority.Keyless.Identities, remoteOpts...) + if err != nil { + logging.FromContext(ctx).Errorf("failed validAttestationsWithFulcio for authority %s with fulcio for %s: %v", name, ref.Name(), err) + return nil, errors.Wrap(err, "validate signatures with fulcio") + } + verifiedAttestations = append(verifiedAttestations, va...) + } + } + // If we didn't get any verified attestations either from the Key or Keyless + // path, then error out + if len(verifiedAttestations) == 0 { + logging.FromContext(ctx).Errorf("no valid attestations found with authority %s for %s", name, ref.Name()) + return nil, fmt.Errorf("no valid attestations found with authority %s for %s", name, ref.Name()) + } + logging.FromContext(ctx).Debugf("Found %d valid attestations, validating policies for them", len(verifiedAttestations)) + // Now spin through the Attestations that the user specified and validate + // them. + // TODO(vaikas): Pretty inefficient here, figure out a better way if + // possible. + ret := map[string][]PolicySignature{} + for _, wantedAttestation := range authority.Attestations { + // If there's no type / policy to do more checking against, + // then we're done here. It matches all the attestations + if wantedAttestation.Type == "" { + ret[wantedAttestation.Name] = ociSignatureToPolicySignature(ctx, verifiedAttestations) + continue + } + // There's a particular type, so we need to go through all the verified + // attestations and make sure that our particular one is satisfied. + for _, va := range verifiedAttestations { + attBytes, err := policy.AttestationToPayloadJSON(ctx, wantedAttestation.PredicateType, va) + if err != nil { + return nil, errors.Wrap(err, "failed to convert attestation payload to json") + } + if attBytes == nil { + // This happens when we ask for a predicate type that this + // attestation is not for. It's not an error, so we skip it. + continue + } + if err := policy.EvaluatePolicyAgainstJSON(ctx, wantedAttestation.Name, wantedAttestation.Type, wantedAttestation.Data, attBytes); err != nil { + return nil, err } + // Ok, so this passed aok, jot it down to our result set as + // verified attestation with the predicate type match + ret[wantedAttestation.Name] = ociSignatureToPolicySignature(ctx, verifiedAttestations) } } - return nil, authorityErrors + return ret, nil } // ResolvePodSpecable implements duckv1.PodSpecValidator diff --git a/pkg/cosign/kubernetes/webhook/validator_result.go b/pkg/cosign/kubernetes/webhook/validator_result.go new file mode 100644 index 00000000000..16a7b549c69 --- /dev/null +++ b/pkg/cosign/kubernetes/webhook/validator_result.go @@ -0,0 +1,60 @@ +// +// Copyright 2022 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 webhook + +// PolicyResult is the result of a successful ValidatePolicy call. +// These are meant to be consumed by a higher level Policy engine that +// can reason about validated results. The 'first' level pass will verify +// signatures and attestations, and make the results then available for +// a policy that can be used to gate a passing of a ClusterImagePolicy. +// Some examples are, at least 'vulnerability' has to have been done +// and the scan must have been attested by a particular entity (sujbect/issuer) +// or a particular key. +// Other examples are N-of-M must be satisfied and so forth. +// We do not expose the low level details of signatures / attestations here +// since they have already been validated as per the Authority configuration +// and optionally by the Attestations which contain a particular policy that +// can be used to validate the Attestations (say vulnerability scanner must not +// have any High sev issues). +type PolicyResult struct { + // AuthorityMatches will have an entry for each successful Authority check + // on it. Key in the map is the Attestation.Name + AuthorityMatches map[string]AuthorityMatch `json:"authorityMatches"` +} + +// AuthorityMatch returns either Signatures (if there are no Attestations +// specified), or Attestations if there are Attestations specified. +type AuthorityMatch struct { + // All of the matching signatures for this authority + // Wonder if for consistency this should also have the matching + // attestations name, aka, make this into a map. + Signatures []PolicySignature `json:"signatures"` + + // Mapping from attestation name to all of verified attestations + Attestations map[string][]PolicySignature `json:"attestations"` +} + +// PolicySignature contains a normalized result of a validated signature, where +// signature could be a signature on the Image (.sig) or on an Attestation +// (.att). +type PolicySignature struct { + // Subject that was found to match on the Cert. + Subject string `json:"subject"` + // Issure that was found to match on the Cert. + Issuer string `json:"issuer"` + // TODO(vaikas): Add all the Fulcio specific extensions here too. + // https://github.com/sigstore/fulcio/blob/main/docs/oid-info.md +} diff --git a/pkg/cosign/kubernetes/webhook/validator_test.go b/pkg/cosign/kubernetes/webhook/validator_test.go index 6b1ca61fffc..79593ef39cd 100644 --- a/pkg/cosign/kubernetes/webhook/validator_test.go +++ b/pkg/cosign/kubernetes/webhook/validator_test.go @@ -23,8 +23,11 @@ import ( "crypto/elliptic" "crypto/x509" "errors" + "fmt" "net/http" "net/http/httptest" + "reflect" + "strings" "testing" "time" @@ -286,10 +289,10 @@ UoJou2P8sbDxpLiE/v3yLw1/jyOrCPWYHWFXnyyeGlkgSVefG54tNoK7Uw== want: func() *apis.FieldError { var errs *apis.FieldError fe := apis.ErrGeneric("failed policy: cluster-image-policy-keyless", "image").ViaFieldIndex("initContainers", 0) - fe.Details = digest.String() + fe.Details = fmt.Sprintf("%s %s", digest.String(), `fetching FulcioRoot: getting root cert: parse "http://http:%2F%2Fexample.com%2F/api/v1/rootCert": invalid port ":%2F%2Fexample.com%2F" after host`) errs = errs.Also(fe) fe2 := apis.ErrGeneric("failed policy: cluster-image-policy-keyless", "image").ViaFieldIndex("containers", 0) - fe2.Details = digest.String() + fe2.Details = fmt.Sprintf("%s %s", digest.String(), `fetching FulcioRoot: getting root cert: parse "http://http:%2F%2Fexample.com%2F/api/v1/rootCert": invalid port ":%2F%2Fexample.com%2F" after host`) errs = errs.Also(fe2) return errs }(), @@ -329,14 +332,47 @@ UoJou2P8sbDxpLiE/v3yLw1/jyOrCPWYHWFXnyyeGlkgSVefG54tNoK7Uw== want: func() *apis.FieldError { var errs *apis.FieldError fe := apis.ErrGeneric("failed policy: cluster-image-policy-keyless", "image").ViaFieldIndex("initContainers", 0) - fe.Details = digest.String() + fe.Details = fmt.Sprintf("%s validate signatures with fulcio: bad signature", digest.String()) errs = errs.Also(fe) fe2 := apis.ErrGeneric("failed policy: cluster-image-policy-keyless", "image").ViaFieldIndex("containers", 0) - fe2.Details = digest.String() + fe2.Details = fmt.Sprintf("%s validate signatures with fulcio: bad signature", digest.String()) errs = errs.Also(fe2) return errs }(), cvs: fail, + }, { + name: "simple, no error, authority keyless, good fulcio", + ps: &corev1.PodSpec{ + InitContainers: []corev1.Container{{ + Name: "setup-stuff", + Image: digest.String(), + }}, + Containers: []corev1.Container{{ + Name: "user-container", + Image: digest.String(), + }}, + }, + customContext: config.ToContext(context.Background(), + &config.Config{ + ImagePolicyConfig: &config.ImagePolicyConfig{ + Policies: map[string]webhookcip.ClusterImagePolicy{ + "cluster-image-policy-keyless": { + Images: []v1alpha1.ImagePattern{{ + Regex: ".*", + }}, + Authorities: []webhookcip.Authority{ + { + Keyless: &webhookcip.KeylessRef{ + URL: fulcioURL, + }, + }, + }, + }, + }, + }, + }, + ), + cvs: pass, }, { name: "simple, error, authority keyless, good fulcio, bad rekor", ps: &corev1.PodSpec{ @@ -375,10 +411,10 @@ UoJou2P8sbDxpLiE/v3yLw1/jyOrCPWYHWFXnyyeGlkgSVefG54tNoK7Uw== want: func() *apis.FieldError { var errs *apis.FieldError fe := apis.ErrGeneric("failed policy: cluster-image-policy-keyless", "image").ViaFieldIndex("initContainers", 0) - fe.Details = digest.String() + fe.Details = fmt.Sprintf("%s validate signatures with fulcio: bad signature", digest.String()) errs = errs.Also(fe) fe2 := apis.ErrGeneric("failed policy: cluster-image-policy-keyless", "image").ViaFieldIndex("containers", 0) - fe2.Details = digest.String() + fe2.Details = fmt.Sprintf("%s validate signatures with fulcio: bad signature", digest.String()) errs = errs.Also(fe2) return errs }(), @@ -1104,3 +1140,213 @@ UoJou2P8sbDxpLiE/v3yLw1/jyOrCPWYHWFXnyyeGlkgSVefG54tNoK7Uw== }) } } + +func TestValidatePolicy(t *testing.T) { + // Resolved via crane digest on 2021/09/25 + digest := name.MustParseReference("gcr.io/distroless/static:nonroot@sha256:be5d77c62dbe7fedfb0a4e5ec2f91078080800ab1f18358e5f31fcc8faa023c4") + + ctx, _ := rtesting.SetupFakeContext(t) + si := fakesecret.Get(ctx) + + secretName := "blah" + + // Non-existent URL for testing complete failure + badURL := apis.HTTP("http://example.com/") + t.Logf("badURL: %s", badURL.String()) + + // Spin up a Fulcio that responds with a Root Cert + fulcioServer := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + rw.Write([]byte(fulcioRootCert)) + })) + t.Cleanup(fulcioServer.Close) + fulcioURL, err := apis.ParseURL(fulcioServer.URL) + if err != nil { + t.Fatalf("Failed to parse fake Fulcio URL") + } + t.Logf("fulcioURL: %s", fulcioURL.String()) + + rekorServer := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + rw.Write([]byte(rekorResponse)) + })) + t.Cleanup(rekorServer.Close) + rekorURL, err := apis.ParseURL(rekorServer.URL) + if err != nil { + t.Fatalf("Failed to parse fake Rekor URL") + } + t.Logf("rekorURL: %s", rekorURL.String()) + var authorityKeyCosignPub *ecdsa.PublicKey + // Random public key (cosign generate-key-pair) 2022-03-18 + authorityKeyCosignPubString := `-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAENAyijLvRu5QpCPp2uOj8C79ZW1VJ +SID/4H61ZiRzN4nqONzp+ZF22qQTk3MFO3D0/ZKmWHAosIf2pf2GHH7myA== +-----END PUBLIC KEY-----` + + pems := parsePems([]byte(authorityKeyCosignPubString)) + if len(pems) > 0 { + key, _ := x509.ParsePKIXPublicKey(pems[0].Bytes) + authorityKeyCosignPub = key.(*ecdsa.PublicKey) + } else { + t.Errorf("Error parsing authority key from string") + } + + si.Informer().GetIndexer().Add(&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: system.Namespace(), + Name: secretName, + }, + Data: map[string][]byte{ + // Random public key (cosign generate-key-pair) 2021-09-25 + "cosign.pub": []byte(`-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEapTW568kniCbL0OXBFIhuhOboeox +UoJou2P8sbDxpLiE/v3yLw1/jyOrCPWYHWFXnyyeGlkgSVefG54tNoK7Uw== +-----END PUBLIC KEY----- +`), + }, + }) + + cvs := cosignVerifySignatures + defer func() { + cosignVerifySignatures = cvs + }() + // Let's just say that everything is verified. + pass := func(_ context.Context, _ name.Reference, _ *cosign.CheckOpts) (checkedSignatures []oci.Signature, bundleVerified bool, err error) { + sig, err := static.NewSignature(nil, "") + if err != nil { + return nil, false, err + } + return []oci.Signature{sig}, true, nil + } + // Let's just say that everything is not verified. + fail := func(_ context.Context, _ name.Reference, _ *cosign.CheckOpts) (checkedSignatures []oci.Signature, bundleVerified bool, err error) { + return nil, false, errors.New("bad signature") + } + + // Let's say it is verified if it is the expected Public Key + authorityPublicKeyCVS := func(ctx context.Context, signedImgRef name.Reference, co *cosign.CheckOpts) (checkedSignatures []oci.Signature, bundleVerified bool, err error) { + actualPublicKey, _ := co.SigVerifier.PublicKey() + actualECDSAPubkey := actualPublicKey.(*ecdsa.PublicKey) + actualKeyData := elliptic.Marshal(actualECDSAPubkey, actualECDSAPubkey.X, actualECDSAPubkey.Y) + + expectedKeyData := elliptic.Marshal(authorityKeyCosignPub, authorityKeyCosignPub.X, authorityKeyCosignPub.Y) + + if bytes.Equal(actualKeyData, expectedKeyData) { + return pass(ctx, signedImgRef, co) + } + + return fail(ctx, signedImgRef, co) + } + + tests := []struct { + name string + policy webhookcip.ClusterImagePolicy + want *PolicyResult + wantErrs []string + cva func(context.Context, name.Reference, *cosign.CheckOpts) ([]oci.Signature, bool, error) + cvs func(context.Context, name.Reference, *cosign.CheckOpts) ([]oci.Signature, bool, error) + customContext context.Context + }{{ + name: "simple, public key, no matches", + policy: webhookcip.ClusterImagePolicy{ + Authorities: []webhookcip.Authority{{ + Name: "authority-0", + Key: &webhookcip.KeyRef{ + PublicKeys: []crypto.PublicKey{authorityKeyCosignPub}, + }, + }}, + }, + wantErrs: []string{"failed to validate public keys with authority authority-0 for gcr.io/distroless/static@sha256:be5d77c62dbe7fedfb0a4e5ec2f91078080800ab1f18358e5f31fcc8faa023c4: bad signature"}, + cvs: fail, + }, { + name: "simple, public key, works", + policy: webhookcip.ClusterImagePolicy{ + Authorities: []webhookcip.Authority{{ + Name: "authority-0", + Key: &webhookcip.KeyRef{ + PublicKeys: []crypto.PublicKey{authorityKeyCosignPub}, + }, + }}, + }, + want: &PolicyResult{ + AuthorityMatches: map[string]AuthorityMatch{ + "authority-0": { + Signatures: []PolicySignature{{ + Subject: "PLACEHOLDER", + Issuer: "PLACEHOLDER"}}, + }}, + }, + cvs: pass, + }, { + name: "simple, public key, no error", + policy: webhookcip.ClusterImagePolicy{ + Authorities: []webhookcip.Authority{{ + Name: "authority-0", + Key: &webhookcip.KeyRef{ + PublicKeys: []crypto.PublicKey{authorityKeyCosignPub}, + }, + }}, + }, + want: &PolicyResult{ + AuthorityMatches: map[string]AuthorityMatch{ + "authority-0": { + Signatures: []PolicySignature{{ + Subject: "PLACEHOLDER", + Issuer: "PLACEHOLDER"}}, + }}, + }, + cvs: authorityPublicKeyCVS, + }, { + name: "simple, keyless attestation, works", + policy: webhookcip.ClusterImagePolicy{ + Authorities: []webhookcip.Authority{{ + Name: "authority-0", + Keyless: &webhookcip.KeylessRef{ + URL: fulcioURL, + }, + Attestations: []webhookcip.AttestationPolicy{{ + Name: "test-att", + PredicateType: "custom", + }}, + }, + }, + }, + want: &PolicyResult{ + AuthorityMatches: map[string]AuthorityMatch{ + "authority-0": { + Attestations: map[string][]PolicySignature{"test-att": {{ + Subject: "PLACEHOLDER", + Issuer: "PLACEHOLDER"}}, + }}}, + }, + cva: pass, + }} + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cosignVerifySignatures = test.cvs + cosignVerifyAttestations = test.cva + testContext := context.Background() + + if test.customContext != nil { + testContext = test.customContext + } + got, gotErrs := ValidatePolicy(testContext, digest, test.policy) + validateErrors(t, test.wantErrs, gotErrs) + if !reflect.DeepEqual(test.want, got) { + t.Errorf("unexpected PolicyResult, want: %+v got: %+v", test.want, got) + } + }) + } +} + +func validateErrors(t *testing.T, wantErr []string, got []error) { + t.Helper() + if len(wantErr) != len(got) { + t.Errorf("Wanted %d errors got %d", len(wantErr), len(got)) + } else { + for i, want := range wantErr { + if !strings.Contains(got[i].Error(), want) { + t.Errorf("Unwanted error at %d want: %s got: %s", i, want, got[i]) + } + } + } +} diff --git a/pkg/policy/eval.go b/pkg/policy/eval.go new file mode 100644 index 00000000000..9ef6e82fd02 --- /dev/null +++ b/pkg/policy/eval.go @@ -0,0 +1,69 @@ +// +// Copyright 2022 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" + + "cuelang.org/go/cue/cuecontext" + cuejson "cuelang.org/go/encoding/json" + + "knative.dev/pkg/logging" +) + +// EvaluatePolicyAgainstJson is used to run a policy engine against JSON bytes. +// These bytes can be for example Attestations, or ClusterImagePolicy result +// types. +// name - which attestation are we evaluating +// policyType - cue|rego +// policyBody - String representing either cue or rego language +// jsonBytes - Bytes to evaluate against the policyBody in the given language +func EvaluatePolicyAgainstJSON(ctx context.Context, name, policyType string, policyBody string, jsonBytes []byte) error { + logging.FromContext(ctx).Debugf("Evaluating JSON: %s against policy: %s", string(jsonBytes), policyBody) + switch policyType { + case "cue": + cueValidationErr := evaluateCue(ctx, jsonBytes, policyBody) + if cueValidationErr != nil { + return fmt.Errorf("failed evaluating cue policy for %s : %s", name, cueValidationErr.Error()) // nolint + } + case "rego": + regoValidationErr := evaluateRego(ctx, jsonBytes, policyBody) + if regoValidationErr != nil { + return fmt.Errorf("failed evaluating rego policy for type %s", name) + } + default: + return fmt.Errorf("sorry Type %s is not supported yet", policyType) + } + return nil +} + +// evaluateCue evaluates a cue policy `evaluator` against `attestation` +func evaluateCue(ctx context.Context, attestation []byte, evaluator string) error { + logging.FromContext(ctx).Infof("Evaluating attestation: %s", string(attestation)) + cueCtx := cuecontext.New() + v := cueCtx.CompileString(evaluator) + return cuejson.Validate(attestation, v) +} + +// evaluateRego evaluates a rego policy `evaluator` against `attestation` +func evaluateRego(ctx context.Context, attestation []byte, evaluator string) error { + // TODO(vaikas) Fix this + // The existing stuff wants files, and it doesn't work. There must be + // a way to load it from a []byte like we can do with cue. Tomorrows problem + // regoValidationErrs := rego.ValidateJSON(payload, regoPolicies) + return fmt.Errorf("TODO(vaikas): Don't know how to this from bytes yet") +} diff --git a/pkg/policy/eval_test.go b/pkg/policy/eval_test.go new file mode 100644 index 00000000000..9c1a3cae74f --- /dev/null +++ b/pkg/policy/eval_test.go @@ -0,0 +1,143 @@ +// +// Copyright 2022 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" + "strings" + "testing" +) + +const ( + customAttestation = ` + { + "_type": "https://in-toto.io/Statement/v0.1", + "predicateType": "cosign.sigstore.dev/attestation/v1", + "subject": [ + { + "name": "registry.local:5000/cosigned/demo", + "digest": { + "sha256": "416cc82c76114b1744ea58bcbf2f411a0f2de4b0456703bf1bb83d33656951bc" + } + } + ], + "predicate": { + "Data": "foobar e2e test", + "Timestamp": "2022-04-20T18:17:19Z" + } + }` + + vulnAttestation = ` + { + "_type": "https://in-toto.io/Statement/v0.1", + "predicateType": "cosign.sigstore.dev/attestation/vuln/v1", + "subject": [ + { + "name": "registry.local:5000/cosigned/demo", + "digest": { + "sha256": "416cc82c76114b1744ea58bcbf2f411a0f2de4b0456703bf1bb83d33656951bc" + } + } + ], + "predicate": { + "invocation": { + "parameters": null, + "uri": "invocation.example.com/cosign-testing", + "event_id": "", + "builder.id": "" + }, + "scanner": { + "uri": "fakescanner.example.com/cosign-testing", + "version": "", + "db": { + "uri": "", + "version": "" + }, + "result": null + }, + "metadata": { + "scanStartedOn": "2022-04-12T00:00:00Z", + "scanFinishedOn": "2022-04-12T00:10:00Z" + } + } + }` + + // TODO(vaikas): Enable tests once we sort this out. + // cipAttestation = "authorityMatches:{\"key-att\":{\"signatures\":null,\"attestations\":{\"custom-match-predicate\":[{\"subject\":\"PLACEHOLDER\",\"issuer\":\"PLACEHOLDER\"}]}},\"key-signature\":{\"signatures\":[{\"subject\":\"PLACEHOLDER\",\"issuer\":\"PLACEHOLDER\"}],\"attestations\":null},\"keyless-att\":{\"signatures\":null,\"attestations\":{\"custom-keyless\":[{\"subject\":\"PLACEHOLDER\",\"issuer\":\"PLACEHOLDER\"}]}},\"keyless-signature\":{\"signatures\":[{\"subject\":\"PLACEHOLDER\",\"issuer\":\"PLACEHOLDER\"}],\"attestations\":null}}" +) + +func TestEvalPolicy(t *testing.T) { + // TODO(vaikas): Consider moving the attestations/cue files into testdata + // directory. + tests := []struct { + name string + json string + policyType string + policyFile string + wantErr bool + wantErrSub string + }{{ + name: "custom attestation, mismatched predicateType", + json: customAttestation, + policyType: "cue", + policyFile: `predicateType: "cosign.sigstore.dev/attestation/vuln/v1"`, + wantErr: true, + wantErrSub: `conflicting values "cosign.sigstore.dev/attestation/v1" and "cosign.sigstore.dev/attestation/vuln/v1"`, + }, { + name: "custom attestation, predicateType and data checks out", + json: customAttestation, + policyType: "cue", + policyFile: `predicateType: "cosign.sigstore.dev/attestation/v1" + predicate: Data: "foobar e2e test"`, + }, { + name: "custom attestation, data mismatch", + json: customAttestation, + policyType: "cue", + policyFile: `predicateType: "cosign.sigstore.dev/attestation/v1" + predicate: Data: "invalid data here"`, + wantErr: true, + wantErrSub: `predicate.Data: conflicting values "foobar e2e test" and "invalid data here"`, + }, { + name: "vuln attestation, wrong invocation url", + json: vulnAttestation, + policyType: "cue", + policyFile: `predicateType: "cosign.sigstore.dev/attestation/vuln/v1" + predicate: invocation: uri: "invocation.example.com/wrong-url-here"`, + wantErr: true, + wantErrSub: `conflicting values "invocation.example.com/cosign-testing" and "invocation.example.com/wrong-url-here"`, + }, { + name: "vuln attestation, checks out", + json: vulnAttestation, + policyType: "cue", + policyFile: `predicateType: "cosign.sigstore.dev/attestation/vuln/v1" + predicate: invocation: uri: "invocation.example.com/cosign-testing"`, + }} + for _, tc := range tests { + ctx := context.Background() + err := EvaluatePolicyAgainstJSON(ctx, tc.name, tc.policyType, tc.policyFile, []byte(tc.json)) + if tc.wantErr { + if err == nil { + t.Errorf("Did not get an error, wanted %s", tc.wantErrSub) + } else if !strings.Contains(err.Error(), tc.wantErrSub) { + t.Errorf("Unexpected error, want: %s got: %s", tc.wantErrSub, err.Error()) + } + } else { + if !tc.wantErr && err != nil { + t.Errorf("Unexpected error, wanted none, got: %s", err.Error()) + } + } + } +} diff --git a/pkg/reconciler/clusterimagepolicy/clusterimagepolicy_test.go b/pkg/reconciler/clusterimagepolicy/clusterimagepolicy_test.go index 1220f8304bc..ee6e667be52 100644 --- a/pkg/reconciler/clusterimagepolicy/clusterimagepolicy_test.go +++ b/pkg/reconciler/clusterimagepolicy/clusterimagepolicy_test.go @@ -64,10 +64,10 @@ RCTfQ5s1kD+hGMSE1rH7s46hmXEeyhnlRnaGF8eMU/SBJE/2NKPnxE7WzQ== -----END PUBLIC KEY-----` // This is the patch for replacing a single entry in the ConfigMap - replaceCIPPatch = `[{"op":"replace","path":"/data/test-cip","value":"{\"images\":[{\"glob\":\"ghcr.io/example/*\"}],\"authorities\":[{\"key\":{\"data\":\"-----BEGIN PUBLIC KEY-----\\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAExB6+H6054/W1SJgs5JR6AJr6J35J\\nRCTfQ5s1kD+hGMSE1rH7s46hmXEeyhnlRnaGF8eMU/SBJE/2NKPnxE7WzQ==\\n-----END PUBLIC KEY-----\"}}]}"}]` + replaceCIPPatch = `[{"op":"replace","path":"/data/test-cip","value":"{\"images\":[{\"glob\":\"ghcr.io/example/*\"}],\"authorities\":[{\"name\":\"authority-0\",\"key\":{\"data\":\"-----BEGIN PUBLIC KEY-----\\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAExB6+H6054/W1SJgs5JR6AJr6J35J\\nRCTfQ5s1kD+hGMSE1rH7s46hmXEeyhnlRnaGF8eMU/SBJE/2NKPnxE7WzQ==\\n-----END PUBLIC KEY-----\"}}]}"}]` // This is the patch for adding an entry for non-existing KMS for cipName2 - addCIP2Patch = `[{"op":"add","path":"/data/test-cip-2","value":"{\"images\":[{\"glob\":\"ghcr.io/example/*\"}],\"authorities\":[{\"key\":{\"data\":\"azure-kms://foo/bar\"}}]}"}]` + addCIP2Patch = `[{"op":"add","path":"/data/test-cip-2","value":"{\"images\":[{\"glob\":\"ghcr.io/example/*\"}],\"authorities\":[{\"name\":\"authority-0\",\"key\":{\"data\":\"azure-kms://foo/bar\"}}]}"}]` // This is the patch for removing the last entry, leaving just the // configmap objectmeta, no data. @@ -82,7 +82,7 @@ RCTfQ5s1kD+hGMSE1rH7s46hmXEeyhnlRnaGF8eMU/SBJE/2NKPnxE7WzQ== removeSingleEntryKeylessPatch = `[{"op":"remove","path":"/data/test-cip-2"}]` // This is the patch for inlined secret for keyless cakey ref data - inlinedSecretKeylessPatch = `[{"op":"replace","path":"/data/test-cip-2","value":"{\"images\":[{\"glob\":\"ghcr.io/example/*\"}],\"authorities\":[{\"keyless\":{\"ca-cert\":{\"data\":\"-----BEGIN PUBLIC KEY-----\\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAExB6+H6054/W1SJgs5JR6AJr6J35J\\nRCTfQ5s1kD+hGMSE1rH7s46hmXEeyhnlRnaGF8eMU/SBJE/2NKPnxE7WzQ==\\n-----END PUBLIC KEY-----\"}}}]}"}]` + inlinedSecretKeylessPatch = `[{"op":"replace","path":"/data/test-cip-2","value":"{\"images\":[{\"glob\":\"ghcr.io/example/*\"}],\"authorities\":[{\"name\":\"authority-0\",\"keyless\":{\"ca-cert\":{\"data\":\"-----BEGIN PUBLIC KEY-----\\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAExB6+H6054/W1SJgs5JR6AJr6J35J\\nRCTfQ5s1kD+hGMSE1rH7s46hmXEeyhnlRnaGF8eMU/SBJE/2NKPnxE7WzQ==\\n-----END PUBLIC KEY-----\"}}}]}"}]` ) func TestReconcile(t *testing.T) { @@ -576,7 +576,7 @@ func makeConfigMap() *corev1.ConfigMap { Name: config.ImagePoliciesConfigName, }, Data: map[string]string{ - cipName: `{"images":[{"glob":"ghcr.io/example/*"}],"authorities":[{"key":{"data":"-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAExB6+H6054/W1SJgs5JR6AJr6J35J\nRCTfQ5s1kD+hGMSE1rH7s46hmXEeyhnlRnaGF8eMU/SBJE/2NKPnxE7WzQ==\n-----END PUBLIC KEY-----"}}]}`, + cipName: `{"images":[{"glob":"ghcr.io/example/*"}],"authorities":[{"name":"authority-0","key":{"data":"-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAExB6+H6054/W1SJgs5JR6AJr6J35J\nRCTfQ5s1kD+hGMSE1rH7s46hmXEeyhnlRnaGF8eMU/SBJE/2NKPnxE7WzQ==\n-----END PUBLIC KEY-----"}}]}`, }, } } @@ -587,7 +587,7 @@ func patchKMS(ctx context.Context, t *testing.T, kmsKey string) clientgotesting. t.Fatalf("Failed to read KMS key ID %q: %v", kmsKey, err) } - patch := `[{"op":"add","path":"/data","value":{"test-kms-cip":"{\"images\":[{\"glob\":\"ghcr.io/example/*\"}],\"authorities\":[{\"key\":{\"data\":\"` + strings.ReplaceAll(pubKey, "\n", "\\\\n") + `\"}}]}"}}]` + patch := `[{"op":"add","path":"/data","value":{"test-kms-cip":"{\"images\":[{\"glob\":\"ghcr.io/example/*\"}],\"authorities\":[{\"name\":\"authority-0\",\"key\":{\"data\":\"` + strings.ReplaceAll(pubKey, "\n", "\\\\n") + `\"}}]}"}}]` return clientgotesting.PatchActionImpl{ ActionImpl: clientgotesting.ActionImpl{ @@ -606,7 +606,7 @@ func makeDifferentConfigMap() *corev1.ConfigMap { Name: config.ImagePoliciesConfigName, }, Data: map[string]string{ - cipName: `{"images":[{"glob":"ghcr.io/example/*"}],"authorities":[{"key":{"data":"-----BEGIN NOTPUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAExB6+H6054/W1SJgs5JR6AJr6J35J\nRCTfQ5s1kD+hGMSE1rH7s46hmXEeyhnlRnaGF8eMU/SBJE/2NKPnxE7WzQ==\n-----END NOTPUBLIC KEY-----"}}]}`, + cipName: `{"images":[{"glob":"ghcr.io/example/*"}],"authorities":[{"name":"authority-0","key":{"data":"-----BEGIN NOTPUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAExB6+H6054/W1SJgs5JR6AJr6J35J\nRCTfQ5s1kD+hGMSE1rH7s46hmXEeyhnlRnaGF8eMU/SBJE/2NKPnxE7WzQ==\n-----END NOTPUBLIC KEY-----"}}]}`, }, } } @@ -619,7 +619,7 @@ func makeConfigMapWithTwoEntries() *corev1.ConfigMap { Name: config.ImagePoliciesConfigName, }, Data: map[string]string{ - cipName: `{"images":[{"glob":"ghcr.io/example/*"}],"authorities":[{"key":{"data":"-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAExB6+H6054/W1SJgs5JR6AJr6J35J\nRCTfQ5s1kD+hGMSE1rH7s46hmXEeyhnlRnaGF8eMU/SBJE/2NKPnxE7WzQ==\n-----END PUBLIC KEY-----"}}]}`, + cipName: `{"images":[{"glob":"ghcr.io/example/*"}],"authorities":[{"name":"authority-0","key":{"data":"-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAExB6+H6054/W1SJgs5JR6AJr6J35J\nRCTfQ5s1kD+hGMSE1rH7s46hmXEeyhnlRnaGF8eMU/SBJE/2NKPnxE7WzQ==\n-----END PUBLIC KEY-----"}}]}`, cipName2: "remove me please", }, } diff --git a/test/e2e_test_cluster_image_policy.sh b/test/e2e_test_cluster_image_policy.sh index 45eb1712bb5..6753758526d 100755 --- a/test/e2e_test_cluster_image_policy.sh +++ b/test/e2e_test_cluster_image_policy.sh @@ -138,14 +138,14 @@ echo '::endgroup::' # This has correct issuer/subject, so should work echo '::group:: test job success with identities' if ! kubectl create -n demo-keyless-signing job demo-identities-works --image=${demoimage} ; then - echo Failed to create Job in namespace without label! + echo Failed to create Job in namespace with matching issuer/subject! exit 1 else - echo Succcessfully created Job with signed image + echo Succcessfully created Job with signed image keyless fi echo '::endgroup::' -echo '::group:: Add cip with identities that match issuer/subject' +echo '::group:: Add cip with identities that do not match issuer/subject' kubectl apply -f ./test/testdata/cosigned/e2e/cip-keyless-with-identities-mismatch.yaml # make sure the reconciler has enough time to update the configmap sleep 5 @@ -160,8 +160,8 @@ else fi echo '::endgroup::' -echo '::group:: Remove mismatching cip' -kubectl delete cip image-policy-keyless-with-identities-mismatch +echo '::group:: Remove mismatching cip, start fresh for key' +kubectl delete cip --all sleep 5 echo '::endgroup::' @@ -189,17 +189,17 @@ fi echo '::endgroup::' echo '::group:: Sign demoimage with cosign key' -COSIGN_PASSWORD="" ./cosign sign --key cosign-colocated-signing.key --force --allow-insecure-registry ${demoimage} +COSIGN_PASSWORD="" ./cosign sign --key cosign-colocated-signing.key --force --allow-insecure-registry --rekor-url ${REKOR_URL} ${demoimage} echo '::endgroup::' echo '::group:: Verify demoimage with cosign key' -./cosign verify --key cosign-colocated-signing.pub --allow-insecure-registry ${demoimage} +./cosign verify --key cosign-colocated-signing.pub --allow-insecure-registry --rekor-url ${REKOR_URL} ${demoimage} echo '::endgroup::' echo '::group:: test job success' # We signed this above, this should work if ! kubectl create -n demo-key-signing job demo --image=${demoimage} ; then - echo Failed to create Job in namespace without label! + echo Failed to create Job in namespace after signing with key! exit 1 else echo Succcessfully created Job with signed image @@ -230,7 +230,7 @@ yq '. | .metadata.name = "image-policy-remote-source" echo '::endgroup::' echo '::group:: Sign demoimage with cosign remote key' -COSIGN_REPOSITORY="${KO_DOCKER_REPO}/remote-signature" ./cosign sign --key cosign-remote-signing.key --force --allow-insecure-registry ${demoimage} +COSIGN_PASSWORD="" COSIGN_REPOSITORY="${KO_DOCKER_REPO}/remote-signature" ./cosign sign --key cosign-remote-signing.key --force --allow-insecure-registry ${demoimage} echo '::endgroup::' echo '::group:: Verify demoimage with cosign remote key' @@ -277,5 +277,6 @@ echo '::endgroup::' echo '::group::' Cleanup kubectl delete cip --all -kubectl delete ns demo-key-signing demo-keyless-signing +kubectl delete ns demo-key-signing demo-keyless-signing demo-key-remote rm cosign*.key cosign*.pub +echo '::endgroup::' diff --git a/test/e2e_test_cluster_image_policy_with_attestations.sh b/test/e2e_test_cluster_image_policy_with_attestations.sh new file mode 100755 index 00000000000..e16f535fcbd --- /dev/null +++ b/test/e2e_test_cluster_image_policy_with_attestations.sh @@ -0,0 +1,243 @@ +#!/usr/bin/env bash +# +# Copyright 2022 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. + + +set -ex + +if [[ -z "${OIDC_TOKEN}" ]]; then + if [[ -z "${TOKEN_ISSUER}" ]]; then + echo "Must specify either env variable OIDC_TOKEN or TOKEN_ISSUER" + exit 1 + else + export OIDC_TOKEN=`curl -s ${ISSUER_URL}` + fi +fi + +if [[ -z "${KO_DOCKER_REPO}" ]]; then + echo "Must specify env variable KO_DOCKER_REPO" + exit 1 +fi + +if [[ -z "${FULCIO_URL}" ]]; then + echo "Must specify env variable FULCIO_URL" + exit 1 +fi + +if [[ -z "${REKOR_URL}" ]]; then + echo "Must specify env variable REKOR_URL" + exit 1 +fi + +if [[ -z "${SIGSTORE_CT_LOG_PUBLIC_KEY_FILE}" ]]; then + echo "must specify env variable SIGSTORE_CT_LOG_PUBLIC_KEY_FILE" + exit 1 +fi + +if [[ "${NON_REPRODUCIBLE}"=="1" ]]; then + echo "creating non-reproducible build by adding a timestamp" + export TIMESTAMP=`date +%s` +else + export TIMESTAMP="TIMESTAMP" +fi + +# Trust our own custom Rekor API +export SIGSTORE_TRUST_REKOR_API_PUBLIC_KEY=1 + +# To simplify testing failures, use this function to execute a kubectl to create +# our job and verify that the failure is expected. +assert_error() { + local KUBECTL_OUT_FILE="/tmp/kubectl.failure.out" + match="$@" + echo looking for ${match} + if kubectl create -n ${NS} job demo --image=${demoimage} 2> ${KUBECTL_OUT_FILE} ; then + echo Failed to block unsigned Job creation! + exit 1 + else + echo Successfully blocked Job creation with expected error: "${match}" + if ! grep -q "${match}" ${KUBECTL_OUT_FILE} ; then + echo Did not get expected failure message, wanted "${match}", got + cat ${KUBECTL_OUT_FILE} + exit 1 + fi + fi +} + +# Publish test image +echo '::group:: publish test image demoimage' +pushd $(mktemp -d) +go mod init example.com/demo +cat < main.go +package main +import "fmt" +func main() { + fmt.Println("hello world TIMESTAMP") +} +EOF + +sed -i'' -e "s@TIMESTAMP@${TIMESTAMP}@g" main.go +cat main.go +export demoimage=`ko publish -B example.com/demo` +echo Created image $demoimage +popd +echo '::endgroup::' + +echo '::group:: Create and label new namespace for verification' +kubectl create namespace demo-attestations +kubectl label namespace demo-attestations cosigned.sigstore.dev/include=true +export NS=demo-attestations +echo '::endgroup::' + +echo '::group:: Create CIP that requires keyless signature and custom attestation with policy' +kubectl apply -f ./test/testdata/cosigned/e2e/cip-keyless-with-attestations.yaml +# allow things to propagate +sleep 5 +echo '::endgroup::' + +# This image has not been signed at all, so should get auto-reject +echo '::group:: test job rejection' +expected_error='no matching signatures' +assert_error ${expected_error} +echo '::endgroup::' + +echo '::group:: Sign demoimage with keyless' +COSIGN_EXPERIMENTAL=1 ./cosign sign --rekor-url ${REKOR_URL} --fulcio-url ${FULCIO_URL} --force --allow-insecure-registry ${demoimage} --identity-token ${OIDC_TOKEN} +echo '::endgroup::' + +# This image has been signed, but does not have an attestation, so should fail. +echo '::group:: test job rejection' +expected_error='no matching attestations' +assert_error ${expected_error} +echo '::endgroup::' + +# Ok, cool. So attest and it should pass. +echo '::group:: Create one keyless attestation and verify it' +echo -n 'foobar e2e test' > ./predicate-file-custom +COSIGN_EXPERIMENTAL=1 ./cosign attest --predicate ./predicate-file-custom --fulcio-url ${FULCIO_URL} --rekor-url ${REKOR_URL} --allow-insecure-registry --force ${demoimage} --identity-token ${OIDC_TOKEN} + +COSIGN_EXPERIMENTAL=1 ./cosign verify-attestation --type=custom --rekor-url ${REKOR_URL} --allow-insecure-registry ${demoimage} +echo '::endgroup::' + +echo '::group:: test job success' +# We signed this with keyless and it has a keyless attestation, so should +# pass. +export KUBECTL_SUCCESS_FILE="/tmp/kubectl.success.out" +if ! kubectl create -n ${NS} job demo --image=${demoimage} 2> ${KUBECTL_SUCCESS_FILE} ; then + echo Failed to create job with keyless signature and an attestation + cat ${KUBECTL_SUCCESS_FILE} + exit 1 +else + echo Created the job with keyless signature and an attestation +fi +echo '::endgroup::' + +echo '::group:: Generate New Signing Key that we use for key-ful signing' +COSIGN_PASSWORD="" ./cosign generate-key-pair +echo '::endgroup::' + +# Ok, so now we have satisfied the keyless requirements, one signature, one +# custom attestation. Let's now do it for 'keyful' one. +echo '::group:: Create CIP that requires a keyful signature and an attestation' +yq '. | .spec.authorities[0].key.data |= load_str("cosign.pub") | .spec.authorities[1].key.data |= load_str("cosign.pub")' ./test/testdata/cosigned/e2e/cip-key-with-attestations.yaml | kubectl apply -f - +# allow things to propagate +sleep 5 +echo '::endgroup::' + +# This image has been signed with keyless, but does not have a keyful signature +# so should fail +echo '::group:: test job rejection' +expected_error='no matching signatures' +assert_error ${expected_error} +echo '::endgroup::' + +# Sign it with key +echo '::group:: Sign demoimage with key, and add to rekor' +COSIGN_EXPERIMENTAL=1 COSIGN_PASSWORD="" ./cosign sign --key cosign.key --force --allow-insecure-registry --rekor-url ${REKOR_URL} ${demoimage} +echo '::endgroup::' + +echo '::group:: Verify demoimage with cosign key' +COSIGN_EXPERIMENTAL=1 ./cosign verify --key cosign.pub --rekor-url ${REKOR_URL} --allow-insecure-registry ${demoimage} +echo '::endgroup::' + +# This image has been signed with key, but does not have a key attestation +# so should fail +echo '::group:: test job rejection' +expected_error='no matching attestations' +assert_error ${expected_error} +echo '::endgroup::' + +# Fine, so create an attestation for it that's different from the keyless one +echo '::group:: create keyful attestation, add add to rekor' +echo -n 'foobar key e2e test' > ./predicate-file-key-custom +COSIGN_EXPERIMENTAL=1 COSIGN_PASSWORD="" ./cosign attest --predicate ./predicate-file-key-custom --rekor-url ${REKOR_URL} --key ./cosign.key --allow-insecure-registry --force ${demoimage} + +COSIGN_EXPERIMENTAL=1 ./cosign verify-attestation --key ./cosign.pub --allow-insecure-registry --rekor-url ${REKOR_URL} ${demoimage} +echo '::endgroup::' + +echo '::group:: test job success with key / keyless' +# We signed this with keyless and key and it has a key/keyless attestation, so +# should pass. +if ! kubectl create -n ${NS} job demo2 --image=${demoimage} 2> ${KUBECTL_SUCCESS_FILE} ; then + echo Failed to create job with both key/keyless signatures and attestations + cat ${KUBECTL_SUCCESS_FILE} + exit 1 +else + echo Created the job with keyless/key signature and an attestations +fi +echo '::endgroup::' + +# So at this point, we have two CIP, one that requires keyless/key sig +# and attestations with both. Let's take it up a notch. +# Let's create a policy that requires both a keyless and keyful +# signature on the image, as well as two attestations signed by the keyless and +# one custom attestation that's signed by key. +# Note we have to bake in the inline data from the keys above +echo '::group:: Add cip for two signatures and two attestations' +yq '. | .spec.authorities[1].key.data |= load_str("cosign.pub") | .spec.authorities[3].key.data |= load_str("cosign.pub")' ./test/testdata/cosigned/e2e/cip-requires-two-signatures-and-two-attestations.yaml | kubectl apply -f - +echo '::endgroup::' + +# TODO(vaikas): Enable the remaining tests once we sort out how to write +# a valid CUE policy, or once #1787 goes in try implementing a Rego one. +echo 'Not testing the CIP policy evaluation yet' +exit 0 + +# The CIP policy is the one that should fail now because it doesn't have enough +# attestations +echo '::group:: test job rejection' +expected_error='no matching attestations' +assert_error ${expected_error} +echo '::endgroup::' + +echo '::group:: Create vuln keyless attestation and verify it' +COSIGN_EXPERIMENTAL=1 ./cosign attest --predicate ./test/testdata/attestations/vuln-predicate.json --type=vuln --fulcio-url ${FULCIO_URL} --rekor-url ${REKOR_URL} --allow-insecure-registry --force ${demoimage} --identity-token ${OIDC_TOKEN} + +COSIGN_EXPERIMENTAL=1 ./cosign verify-attestation --type=vuln --rekor-url ${REKOR_URL} --allow-insecure-registry ${demoimage} +echo '::endgroup::' + +echo '::group:: test job success' +# We signed this with key and keyless and it has two keyless attestations and +# it has one key attestation, so it should succeed. +if ! kubectl create -n ${NS} job demo3 --image=${demoimage} 2> ./${KUBECTL_OUT_FILE} ; then + echo Failed to create job that has two signatures and 3 attestations + cat ${KUBECTL_OUT_FILE} + exit 1 +fi +echo '::endgroup::' + +echo '::group::' Cleanup +kubectl delete cip --all +kubectl delete ns demo-attestations +rm cosign.key cosign.pub +echo '::endgroup::' diff --git a/test/testdata/cosigned/e2e/cip-key-with-attestations.yaml b/test/testdata/cosigned/e2e/cip-key-with-attestations.yaml new file mode 100644 index 00000000000..089dade05ab --- /dev/null +++ b/test/testdata/cosigned/e2e/cip-key-with-attestations.yaml @@ -0,0 +1,48 @@ +# Copyright 2022 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 +# +# https://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. + +apiVersion: cosigned.sigstore.dev/v1alpha1 +kind: ClusterImagePolicy +metadata: + name: image-policy-key-with-attestations +spec: + images: + - glob: registry.local:5000/cosigned/demo* + authorities: + - name: verify custom attestation + key: + data: | + -----BEGIN PUBLIC KEY----- + MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEZxAfzrQG1EbWyCI8LiSB7YgSFXoI + FNGTyQGKHFc6/H8TQumT9VLS78pUwtv3w7EfKoyFZoP32KrO7nzUy2q6Cw== + -----END PUBLIC KEY----- + ctlog: + url: http://rekor.rekor-system.svc + attestations: + - name: custom-match-predicate + predicateType: custom + policy: + type: cue + data: | + predicateType: "cosign.sigstore.dev/attestation/v1" + predicate: Data: "foobar key e2e test" + - name: verify signature + key: + data: | + -----BEGIN PUBLIC KEY----- + MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEZxAfzrQG1EbWyCI8LiSB7YgSFXoI + FNGTyQGKHFc6/H8TQumT9VLS78pUwtv3w7EfKoyFZoP32KrO7nzUy2q6Cw== + -----END PUBLIC KEY----- + ctlog: + url: http://rekor.rekor-system.svc diff --git a/test/testdata/cosigned/e2e/cip-key.yaml b/test/testdata/cosigned/e2e/cip-key.yaml index d4d8334905d..7b7784bacdf 100644 --- a/test/testdata/cosigned/e2e/cip-key.yaml +++ b/test/testdata/cosigned/e2e/cip-key.yaml @@ -26,4 +26,5 @@ spec: MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEZxAfzrQG1EbWyCI8LiSB7YgSFXoI FNGTyQGKHFc6/H8TQumT9VLS78pUwtv3w7EfKoyFZoP32KrO7nzUy2q6Cw== -----END PUBLIC KEY----- - + ctlog: + url: http://rekor.rekor-system.svc diff --git a/test/testdata/cosigned/e2e/cip-keyless-with-attestations.yaml b/test/testdata/cosigned/e2e/cip-keyless-with-attestations.yaml new file mode 100644 index 00000000000..77999559fdd --- /dev/null +++ b/test/testdata/cosigned/e2e/cip-keyless-with-attestations.yaml @@ -0,0 +1,40 @@ +# Copyright 2022 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 +# +# https://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. + +apiVersion: cosigned.sigstore.dev/v1alpha1 +kind: ClusterImagePolicy +metadata: + name: image-policy-keyless-with-attestations +spec: + images: + - glob: registry.local:5000/cosigned/demo* + authorities: + - name: verify custom attestation + keyless: + url: http://fulcio.fulcio-system.svc + ctlog: + url: http://rekor.rekor-system.svc + attestations: + - name: custom-match-predicate + predicateType: custom + policy: + type: cue + data: | + predicateType: "cosign.sigstore.dev/attestation/v1" + predicate: Data: "foobar e2e test" + - name: verify signature + keyless: + url: http://fulcio.fulcio-system.svc + ctlog: + url: http://rekor.rekor-system.svc diff --git a/test/testdata/cosigned/e2e/cip-requires-two-signatures-and-two-attestations.yaml b/test/testdata/cosigned/e2e/cip-requires-two-signatures-and-two-attestations.yaml new file mode 100644 index 00000000000..80b44ece51a --- /dev/null +++ b/test/testdata/cosigned/e2e/cip-requires-two-signatures-and-two-attestations.yaml @@ -0,0 +1,142 @@ +# Copyright 2022 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 +# +# https://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. + +apiVersion: cosigned.sigstore.dev/v1alpha1 +kind: ClusterImagePolicy +metadata: + name: image-policy-requires-two-signatures-two-attestations +spec: + images: + - glob: registry.local:5000/cosigned/demo* + authorities: + - name: keyless-att + keyless: + url: http://fulcio.fulcio-system.svc + ctlog: + url: http://rekor.rekor-system.svc + attestations: + - predicateType: custom + name: custom-keyless + policy: + type: cue + data: | + import "time" + before: time.Parse(time.RFC3339, "2049-10-09T17:10:27Z") + predicateType: "cosign.sigstore.dev/attestation/v1" + predicate: { + Data: "foobar e2e test" + Timestamp: after + scanFinishedOn: after + } + } + - name: key-att + key: + data: | + -----BEGIN PUBLIC KEY----- + MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEOz9FcbJM/oOkC26Wfo9paG2tYGBL + usDLHze93DzgLaAPDsyJrygpVnL9M6SOyfyXEsjpBTUu6uFZqHua8hwAlA== + -----END PUBLIC KEY----- + ctlog: + url: http://rekor.rekor-system.svc + attestations: + - name: custom-match-predicate + predicateType: custom + policy: + type: cue + data: | + predicateType: "cosign.sigstore.dev/attestation/v1" + predicate: Data: "foobar key e2e test" + - name: keyless-signature + keyless: + url: http://fulcio.fulcio-system.svc + ctlog: + url: http://rekor.rekor-system.svc + - name: key-signature + key: + data: | + -----BEGIN PUBLIC KEY----- + MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEOz9FcbJM/oOkC26Wfo9paG2tYGBL + usDLHze93DzgLaAPDsyJrygpVnL9M6SOyfyXEsjpBTUu6uFZqHua8hwAlA== + -----END PUBLIC KEY----- + ctlog: + url: http://rekor.rekor-system.svc + policy: + type: cue + data: | + if len(authorityMatches."keyless-att".attestations) < 2 { + keylessAttestationsErr: "error" + keylessAttestationsErr: "Did not get both keyless attestations" + } + if len(authorityMatches."key-att".attestations) < 1 { + keyAttestationsErr: 1 + keyAttestationsErr: "Did not get key attestation" + } + if len(authorityMatches."keyless-signature".signatures) < 1 { + keylessSignatureErr: 1 + keylessSignatureErr: "Did not get keyless signature" + } + if len(authorityMatches."key-signature".signatures) < 1 { + keySignatureErr: 1 + keySignatureErr: "Did not get key signature" + } + authorityMatches: { + key-att: { + attestations: { + "vuln-key": [ + {subject: "PLACEHOLDER", issuer: "PLACEHOLDER"}, + ] + } + } + keyless-att: { + attestations: { + "vuln-keyless": [ + {subject: "PLACEHOLDER", issuer: "PLACEHOLDER"}, + ], + "custom-keyless": [ + {subject: "PLACEHOLDER", issuer: "PLACEHOLDER"}, + ], + } + } + keyless-signature: { + signatures: [ + {subject: "PLACEHOLDER", issuer: "PLACEHOLDER"}, + ] + } + key-signature: { + signatures: [ + {subject: "PLACEHOLDER", issuer: "PLACEHOLDER"}, + ] + } + } diff --git a/test/testdata/cosigned/valid/valid-policy-regex.yaml b/test/testdata/cosigned/valid/valid-policy-regex.yaml index 804ff4127ec..26f46c05391 100644 --- a/test/testdata/cosigned/valid/valid-policy-regex.yaml +++ b/test/testdata/cosigned/valid/valid-policy-regex.yaml @@ -26,10 +26,51 @@ spec: secretRef: name: ca-cert-secret namespace: some-namespacemak - - keyless: + - name: "keyless signatures" + keyless: + identities: + - issuer: "issue-details" + subject: "subject-details" + - name: "keyless attestations" + keyless: identities: - issuer: "issue-details" subject: "subject-details" + attestations: + - name: custom-predicate-type-validation + predicateType: custom + policy: + type: cue + data: | + import "time" + before: time.Parse(time.RFC3339, "2049-10-09T17:10:27Z") + predicateType: "cosign.sigstore.dev/attestation/v1" + predicate: { + Timestamp: after + scanFinishedOn: after + } + } - keyless: identities: - issuer: "issue-details1"