diff --git a/pkg/apis/pipeline/internal/checksum/checksum.go b/pkg/apis/pipeline/internal/checksum/checksum.go new file mode 100644 index 00000000000..29cca04f777 --- /dev/null +++ b/pkg/apis/pipeline/internal/checksum/checksum.go @@ -0,0 +1,57 @@ +package checksum + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + // SignatureAnnotation is the key of signature in annotation map + SignatureAnnotation = "tekton.dev/signature" +) + +// PrepareObjectMeta will remove annotations not configured from user side -- "kubectl-client-side-apply" and "kubectl.kubernetes.io/last-applied-configuration" +// (added when an object is created with `kubectl apply`) to avoid verification failure and extract the signature. +// Returns a copy of the input object metadata with the annotations removed and the object's signature, +// if it is present in the metadata. +func PrepareObjectMeta(in metav1.Object) metav1.ObjectMeta { + outMeta := metav1.ObjectMeta{} + + // exclude the fields populated by system. + outMeta.Name = in.GetName() + outMeta.GenerateName = in.GetGenerateName() + outMeta.Namespace = in.GetNamespace() + + if in.GetLabels() != nil { + outMeta.Labels = make(map[string]string) + for k, v := range in.GetLabels() { + outMeta.Labels[k] = v + } + } + + outMeta.Annotations = make(map[string]string) + for k, v := range in.GetAnnotations() { + outMeta.Annotations[k] = v + } + + // exclude the annotations added by other components + delete(outMeta.Annotations, "kubectl-client-side-apply") + delete(outMeta.Annotations, "kubectl.kubernetes.io/last-applied-configuration") + delete(outMeta.Annotations, SignatureAnnotation) + + return outMeta +} + +// ComputeSha256Checksum computes the sha256 checksum of the tekton object. +func ComputeSha256Checksum(obj interface{}) ([]byte, error) { + ts, err := json.Marshal(obj) + if err != nil { + return nil, fmt.Errorf("failed to marshal the object: %w", err) + } + h := sha256.New() + h.Write(ts) + return h.Sum(nil), nil +} diff --git a/pkg/apis/pipeline/internal/checksum/checksum_test.go b/pkg/apis/pipeline/internal/checksum/checksum_test.go new file mode 100644 index 00000000000..1b9f6b4c446 --- /dev/null +++ b/pkg/apis/pipeline/internal/checksum/checksum_test.go @@ -0,0 +1,103 @@ +/* +Copyright 2022 The Tekton 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 checksum + +import ( + "encoding/hex" + "testing" + + "github.com/google/go-cmp/cmp" + + "github.com/tektoncd/pipeline/test/diff" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestPrepareObjectMeta(t *testing.T) { + unsigned := metav1.ObjectMeta{ + Name: "test-task", + Annotations: map[string]string{"foo": "bar"}, + } + namespace := "" + signed := unsigned.DeepCopy() + sig := "tY805zV53PtwDarK3VD6dQPx5MbIgctNcg/oSle+MG0=" + signed.Annotations = map[string]string{SignatureAnnotation: sig} + + signedWithLabels := signed.DeepCopy() + signedWithLabels.Labels = map[string]string{"label": "foo"} + + signedWithExtraAnnotations := signed.DeepCopy() + signedWithExtraAnnotations.Annotations["kubectl-client-side-apply"] = "client" + signedWithExtraAnnotations.Annotations["kubectl.kubernetes.io/last-applied-configuration"] = "config" + + tcs := []struct { + name string + objectmeta *metav1.ObjectMeta + expected metav1.ObjectMeta + }{{ + name: "Prepare signed objectmeta without labels", + objectmeta: signed, + expected: metav1.ObjectMeta{ + Name: "test-task", + Namespace: namespace, + Annotations: map[string]string{}, + }, + }, { + name: "Prepare signed objectmeta with labels", + objectmeta: signedWithLabels, + expected: metav1.ObjectMeta{ + Name: "test-task", + Namespace: namespace, + Labels: map[string]string{"label": "foo"}, + Annotations: map[string]string{}, + }, + }, { + name: "Prepare signed objectmeta with extra annotations", + objectmeta: signedWithExtraAnnotations, + expected: metav1.ObjectMeta{ + Name: "test-task", + Namespace: namespace, + Annotations: map[string]string{}, + }, + }, { + name: "resource without signature shouldn't fail", + objectmeta: &unsigned, + expected: metav1.ObjectMeta{ + Name: "test-task", + Namespace: namespace, + Annotations: map[string]string{"foo": "bar"}, + }, + }} + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + task := PrepareObjectMeta(tc.objectmeta) + if d := cmp.Diff(task, tc.expected); d != "" { + t.Error(diff.PrintWantGot(d)) + } + }) + } +} + +func TestComputeSha256Checksum(t *testing.T) { + sha, err := ComputeSha256Checksum("hello") + if err != nil { + t.Fatalf("Could not marshal hello %v", err) + } + if d := cmp.Diff(hex.EncodeToString(sha), "5aa762ae383fbb727af3c7a36d4940a5b8c40a989452d2304fc958ff3f354e7a"); d != "" { + t.Error(diff.PrintWantGot(d)) + } +} diff --git a/pkg/apis/pipeline/v1/pipeline_types.go b/pkg/apis/pipeline/v1/pipeline_types.go index e0ce5ec6777..19f97cbf222 100644 --- a/pkg/apis/pipeline/v1/pipeline_types.go +++ b/pkg/apis/pipeline/v1/pipeline_types.go @@ -18,6 +18,7 @@ package v1 import ( "github.com/tektoncd/pipeline/pkg/apis/pipeline" + "github.com/tektoncd/pipeline/pkg/apis/pipeline/internal/checksum" "github.com/tektoncd/pipeline/pkg/reconciler/pipeline/dag" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -70,6 +71,27 @@ func (*Pipeline) GetGroupVersionKind() schema.GroupVersionKind { return SchemeGroupVersion.WithKind(pipeline.PipelineControllerName) } +// Checksum computes the sha256 checksum of the pipeline object. +// Prior to computing the checksum, it performs some preprocessing on the +// metadata of the object where it removes system provided annotations. +// Only the name, namespace, generateName, user-provided labels and annotations +// and the pipelineSpec are included for the checksum computation. +func (p *Pipeline) Checksum() ([]byte, error) { + objectMeta := checksum.PrepareObjectMeta(p) + preprocessedPipeline := Pipeline{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "tekton.dev/v1", + Kind: "Pipeline"}, + ObjectMeta: objectMeta, + Spec: p.Spec, + } + sha256Checksum, err := checksum.ComputeSha256Checksum(preprocessedPipeline) + if err != nil { + return nil, err + } + return sha256Checksum, nil +} + // PipelineSpec defines the desired state of Pipeline. type PipelineSpec struct { // DisplayName is a user-facing name of the pipeline that may be diff --git a/pkg/apis/pipeline/v1/pipeline_types_test.go b/pkg/apis/pipeline/v1/pipeline_types_test.go index c09eb89d0d8..d2821388d8b 100644 --- a/pkg/apis/pipeline/v1/pipeline_types_test.go +++ b/pkg/apis/pipeline/v1/pipeline_types_test.go @@ -18,12 +18,14 @@ package v1 import ( "context" + "encoding/hex" "testing" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/tektoncd/pipeline/pkg/apis/config" "github.com/tektoncd/pipeline/test/diff" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/sets" "knative.dev/pkg/apis" @@ -969,3 +971,56 @@ func TestEmbeddedTask_IsCustomTask(t *testing.T) { }) } } + +func TestPipelineChecksum(t *testing.T) { + tests := []struct { + name string + pipeline *Pipeline + }{{ + name: "pipeline ignore uid", + pipeline: &Pipeline{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "tekton.dev/v1", + Kind: "Pipeline"}, + ObjectMeta: metav1.ObjectMeta{ + Name: "pipeline", + Namespace: "pipeline-ns", + UID: "abc", + Labels: map[string]string{"label": "foo"}, + Annotations: map[string]string{"foo": "bar"}, + }, + Spec: PipelineSpec{}, + }, + }, { + name: "pipeline ignore system annotations", + pipeline: &Pipeline{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "tekton.dev/v1", + Kind: "Pipeline"}, + ObjectMeta: metav1.ObjectMeta{ + Name: "pipeline", + Namespace: "pipeline-ns", + UID: "abc", + Labels: map[string]string{"label": "foo"}, + Annotations: map[string]string{ + "foo": "bar", + "kubectl-client-side-apply": "client", + "kubectl.kubernetes.io/last-applied-configuration": "config", + }, + }, + Spec: PipelineSpec{}, + }, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sha, err := tt.pipeline.Checksum() + if err != nil { + t.Fatalf("Error computing checksuum: %v", err) + } + + if d := cmp.Diff(hex.EncodeToString(sha), "98bc732636b8fbc08f3d353932147e4eff4e667f0c1af675656a48efdc8178e3"); d != "" { + t.Error(diff.PrintWantGot(d)) + } + }) + } +} diff --git a/pkg/apis/pipeline/v1/task_types.go b/pkg/apis/pipeline/v1/task_types.go index 9a46de41b85..894590508ff 100644 --- a/pkg/apis/pipeline/v1/task_types.go +++ b/pkg/apis/pipeline/v1/task_types.go @@ -18,6 +18,7 @@ package v1 import ( "github.com/tektoncd/pipeline/pkg/apis/pipeline" + "github.com/tektoncd/pipeline/pkg/apis/pipeline/internal/checksum" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" @@ -52,6 +53,27 @@ func (*Task) GetGroupVersionKind() schema.GroupVersionKind { return SchemeGroupVersion.WithKind(pipeline.TaskControllerName) } +// Checksum computes the sha256 checksum of the task object. +// Prior to computing the checksum, it performs some preprocessing on the +// metadata of the object where it removes system provided annotations. +// Only the name, namespace, generateName, user-provided labels and annotations +// and the taskSpec are included for the checksum computation. +func (t *Task) Checksum() ([]byte, error) { + objectMeta := checksum.PrepareObjectMeta(t) + preprocessedTask := Task{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "tekton.dev/v1", + Kind: "Task"}, + ObjectMeta: objectMeta, + Spec: t.Spec, + } + sha256Checksum, err := checksum.ComputeSha256Checksum(preprocessedTask) + if err != nil { + return nil, err + } + return sha256Checksum, nil +} + // TaskSpec defines the desired state of Task. type TaskSpec struct { // Params is a list of input parameters required to run the task. Params diff --git a/pkg/apis/pipeline/v1/task_types_test.go b/pkg/apis/pipeline/v1/task_types_test.go new file mode 100644 index 00000000000..6b10b4edcde --- /dev/null +++ b/pkg/apis/pipeline/v1/task_types_test.go @@ -0,0 +1,90 @@ +/* +Copyright 2023 The Tekton 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 v1_test + +import ( + "encoding/hex" + "testing" + + "github.com/google/go-cmp/cmp" + v1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" + "github.com/tektoncd/pipeline/test/diff" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestTask_Checksum(t *testing.T) { + tests := []struct { + name string + task *v1.Task + }{{ + name: "task ignore uid", + task: &v1.Task{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "tekton.dev/v1", + Kind: "Task"}, + ObjectMeta: metav1.ObjectMeta{ + Name: "task", + Namespace: "task-ns", + UID: "abc", + Labels: map[string]string{"label": "foo"}, + Annotations: map[string]string{"foo": "bar"}, + }, + Spec: v1.TaskSpec{ + Steps: []v1.Step{{ + Image: "ubuntu", + Name: "echo", + }}, + }, + }, + }, { + name: "task ignore system annotations", + task: &v1.Task{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "tekton.dev/v1", + Kind: "Task"}, + ObjectMeta: metav1.ObjectMeta{ + Name: "task", + Namespace: "task-ns", + UID: "abc", + Labels: map[string]string{"label": "foo"}, + Annotations: map[string]string{ + "foo": "bar", + "kubectl-client-side-apply": "client", + "kubectl.kubernetes.io/last-applied-configuration": "config", + }, + }, + Spec: v1.TaskSpec{ + Steps: []v1.Step{{ + Image: "ubuntu", + Name: "echo", + }}, + }, + }, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sha, err := tt.task.Checksum() + if err != nil { + t.Fatalf("Error computing checksuum: %v", err) + } + + if d := cmp.Diff(hex.EncodeToString(sha), "0cf41a775529eaaa55ff115eebe5db01a3b6bf2f4b924606888736274ceb267a"); d != "" { + t.Error(diff.PrintWantGot(d)) + } + }) + } +} diff --git a/pkg/apis/pipeline/v1beta1/pipeline_types.go b/pkg/apis/pipeline/v1beta1/pipeline_types.go index 4426d5ddb78..eba6cba2727 100644 --- a/pkg/apis/pipeline/v1beta1/pipeline_types.go +++ b/pkg/apis/pipeline/v1beta1/pipeline_types.go @@ -18,6 +18,7 @@ package v1beta1 import ( "github.com/tektoncd/pipeline/pkg/apis/pipeline" + "github.com/tektoncd/pipeline/pkg/apis/pipeline/internal/checksum" "github.com/tektoncd/pipeline/pkg/reconciler/pipeline/dag" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -77,6 +78,27 @@ func (*Pipeline) GetGroupVersionKind() schema.GroupVersionKind { return SchemeGroupVersion.WithKind(pipeline.PipelineControllerName) } +// Checksum computes the sha256 checksum of the task object. +// Prior to computing the checksum, it performs some preprocessing on the +// metadata of the object where it removes system provided annotations. +// Only the name, namespace, generateName, user-provided labels and annotations +// and the pipelineSpec are included for the checksum computation. +func (p *Pipeline) Checksum() ([]byte, error) { + objectMeta := checksum.PrepareObjectMeta(p) + preprocessedPipeline := Pipeline{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "tekton.dev/v1beta1", + Kind: "Pipeline"}, + ObjectMeta: objectMeta, + Spec: p.Spec, + } + sha256Checksum, err := checksum.ComputeSha256Checksum(preprocessedPipeline) + if err != nil { + return nil, err + } + return sha256Checksum, nil +} + // PipelineSpec defines the desired state of Pipeline. type PipelineSpec struct { // DisplayName is a user-facing name of the pipeline that may be diff --git a/pkg/apis/pipeline/v1beta1/pipeline_types_test.go b/pkg/apis/pipeline/v1beta1/pipeline_types_test.go index 6e4f7372048..03c1a301f73 100644 --- a/pkg/apis/pipeline/v1beta1/pipeline_types_test.go +++ b/pkg/apis/pipeline/v1beta1/pipeline_types_test.go @@ -18,12 +18,14 @@ package v1beta1 import ( "context" + "encoding/hex" "testing" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/tektoncd/pipeline/pkg/apis/config" "github.com/tektoncd/pipeline/test/diff" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/sets" "knative.dev/pkg/apis" @@ -953,3 +955,56 @@ func TestEmbeddedTask_IsCustomTask(t *testing.T) { }) } } + +func TestPipelineChecksum(t *testing.T) { + tests := []struct { + name string + pipeline *Pipeline + }{{ + name: "pipeline ignore uid", + pipeline: &Pipeline{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "tekton.dev/v1beta1", + Kind: "Pipeline"}, + ObjectMeta: metav1.ObjectMeta{ + Name: "pipeline", + Namespace: "pipeline-ns", + UID: "abc", + Labels: map[string]string{"label": "foo"}, + Annotations: map[string]string{"foo": "bar"}, + }, + Spec: PipelineSpec{}, + }, + }, { + name: "pipeline ignore system annotations", + pipeline: &Pipeline{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "tekton.dev/v1beta1", + Kind: "Pipeline"}, + ObjectMeta: metav1.ObjectMeta{ + Name: "pipeline", + Namespace: "pipeline-ns", + UID: "abc", + Labels: map[string]string{"label": "foo"}, + Annotations: map[string]string{ + "foo": "bar", + "kubectl-client-side-apply": "client", + "kubectl.kubernetes.io/last-applied-configuration": "config", + }, + }, + Spec: PipelineSpec{}, + }, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sha, err := tt.pipeline.Checksum() + if err != nil { + t.Fatalf("Error computing checksuum: %v", err) + } + + if d := cmp.Diff(hex.EncodeToString(sha), "ef400089e645c69a588e71fe629ce2a989743e303c058073b0829c6c6338ab8a"); d != "" { + t.Error(diff.PrintWantGot(d)) + } + }) + } +} diff --git a/pkg/apis/pipeline/v1beta1/task_types.go b/pkg/apis/pipeline/v1beta1/task_types.go index 85530925667..2961ade3be6 100644 --- a/pkg/apis/pipeline/v1beta1/task_types.go +++ b/pkg/apis/pipeline/v1beta1/task_types.go @@ -18,6 +18,7 @@ package v1beta1 import ( "github.com/tektoncd/pipeline/pkg/apis/pipeline" + "github.com/tektoncd/pipeline/pkg/apis/pipeline/internal/checksum" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" @@ -68,6 +69,27 @@ func (*Task) GetGroupVersionKind() schema.GroupVersionKind { return SchemeGroupVersion.WithKind(pipeline.TaskControllerName) } +// Checksum computes the sha256 checksum of the task object. +// Prior to computing the checksum, it performs some preprocessing on the +// metadata of the object where it removes system provided annotations. +// Only the name, namespace, generateName, user-provided labels and annotations +// and the taskSpec are included for the checksum computation. +func (t *Task) Checksum() ([]byte, error) { + objectMeta := checksum.PrepareObjectMeta(t) + preprocessedTask := Task{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "tekton.dev/v1beta1", + Kind: "Task"}, + ObjectMeta: objectMeta, + Spec: t.Spec, + } + sha256Checksum, err := checksum.ComputeSha256Checksum(preprocessedTask) + if err != nil { + return nil, err + } + return sha256Checksum, nil +} + // TaskSpec defines the desired state of Task. type TaskSpec struct { // Resources is a list input and output resource to run the task diff --git a/pkg/apis/pipeline/v1beta1/task_types_test.go b/pkg/apis/pipeline/v1beta1/task_types_test.go new file mode 100644 index 00000000000..956abbf32c0 --- /dev/null +++ b/pkg/apis/pipeline/v1beta1/task_types_test.go @@ -0,0 +1,90 @@ +/* +Copyright 2023 The Tekton 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 v1beta1_test + +import ( + "encoding/hex" + "testing" + + "github.com/google/go-cmp/cmp" + v1beta1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1" + "github.com/tektoncd/pipeline/test/diff" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestTask_Checksum(t *testing.T) { + tests := []struct { + name string + task *v1beta1.Task + }{{ + name: "task ignore uid", + task: &v1beta1.Task{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "tekton.dev/v1beta1", + Kind: "Task"}, + ObjectMeta: metav1.ObjectMeta{ + Name: "task", + Namespace: "task-ns", + UID: "abc", + Labels: map[string]string{"label": "foo"}, + Annotations: map[string]string{"foo": "bar"}, + }, + Spec: v1beta1.TaskSpec{ + Steps: []v1beta1.Step{{ + Image: "ubuntu", + Name: "echo", + }}, + }, + }, + }, { + name: "task ignore system annotations", + task: &v1beta1.Task{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "tekton.dev/v1beta1", + Kind: "Task"}, + ObjectMeta: metav1.ObjectMeta{ + Name: "task", + Namespace: "task-ns", + UID: "abc", + Labels: map[string]string{"label": "foo"}, + Annotations: map[string]string{ + "foo": "bar", + "kubectl-client-side-apply": "client", + "kubectl.kubernetes.io/last-applied-configuration": "config", + }, + }, + Spec: v1beta1.TaskSpec{ + Steps: []v1beta1.Step{{ + Image: "ubuntu", + Name: "echo", + }}, + }, + }, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sha, err := tt.task.Checksum() + if err != nil { + t.Fatalf("Error computing checksuum: %v", err) + } + + if d := cmp.Diff(hex.EncodeToString(sha), "c913fb33ce186f8a98e77eb2885495da71103de323a1dc420d1df1809a10dfd4"); d != "" { + t.Error(diff.PrintWantGot(d)) + } + }) + } +} diff --git a/pkg/resolution/resolver/cluster/resolver.go b/pkg/resolution/resolver/cluster/resolver.go index 8d63178b33d..b289960d0b6 100644 --- a/pkg/resolution/resolver/cluster/resolver.go +++ b/pkg/resolution/resolver/cluster/resolver.go @@ -18,7 +18,6 @@ package cluster import ( "context" - "crypto/sha256" "encoding/hex" "errors" "fmt" @@ -104,6 +103,7 @@ func (r *Resolver) Resolve(ctx context.Context, origParams []pipelinev1.Param) ( var data []byte var spec []byte + var sha256Checksum []byte var uid string groupVersion := pipelinev1.SchemeGroupVersion.String() @@ -122,6 +122,10 @@ func (r *Resolver) Resolve(ctx context.Context, origParams []pipelinev1.Param) ( logger.Infof("failed to marshal task %s from namespace %s: %v", params[NameParam], params[NamespaceParam], err) return nil, err } + sha256Checksum, err = task.Checksum() + if err != nil { + return nil, err + } spec, err = yaml.Marshal(task.Spec) if err != nil { @@ -143,6 +147,11 @@ func (r *Resolver) Resolve(ctx context.Context, origParams []pipelinev1.Param) ( return nil, err } + sha256Checksum, err = pipeline.Checksum() + if err != nil { + return nil, err + } + spec, err = yaml.Marshal(pipeline.Spec) if err != nil { logger.Infof("failed to marshal the spec of the pipeline %s from namespace %s: %v", params[NameParam], params[NamespaceParam], err) @@ -159,6 +168,7 @@ func (r *Resolver) Resolve(ctx context.Context, origParams []pipelinev1.Param) ( Name: params[NameParam], Namespace: params[NamespaceParam], Identifier: fmt.Sprintf("/apis/%s/namespaces/%s/%s/%s@%s", groupVersion, params[NamespaceParam], params[KindParam], params[NameParam], uid), + Checksum: sha256Checksum, }, nil } @@ -190,6 +200,8 @@ type ResolvedClusterResource struct { // Resource URI is the namespace-scoped uri i.e. /apis/GROUP/VERSION/namespaces/NAMESPACE/RESOURCETYPE/NAME. // https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-uris Identifier string + // Sha256 Checksum of the cluster resource + Checksum []byte } var _ framework.ResolvedResource = &ResolvedClusterResource{} @@ -210,14 +222,10 @@ func (r *ResolvedClusterResource) Annotations() map[string]string { // RefSource is the source reference of the remote data that records where the remote // file came from including the url, digest and the entrypoint. func (r ResolvedClusterResource) RefSource() *pipelinev1.RefSource { - h := sha256.New() - h.Write(r.Spec) - sha256CheckSum := hex.EncodeToString(h.Sum(nil)) - return &pipelinev1.RefSource{ URI: r.Identifier, Digest: map[string]string{ - "sha256": sha256CheckSum, + "sha256": hex.EncodeToString(r.Checksum), }, } } diff --git a/pkg/resolution/resolver/cluster/resolver_test.go b/pkg/resolution/resolver/cluster/resolver_test.go index 100e9f0f442..783a701a01f 100644 --- a/pkg/resolution/resolver/cluster/resolver_test.go +++ b/pkg/resolution/resolver/cluster/resolver_test.go @@ -19,7 +19,6 @@ package cluster_test import ( "context" - "crypto/sha256" "encoding/base64" "encoding/hex" "errors" @@ -211,13 +210,13 @@ func TestResolve(t *testing.T) { }}, }, } - taskAsYAML, err := yaml.Marshal(exampleTask) + taskChecksum, err := exampleTask.Checksum() if err != nil { - t.Fatalf("couldn't marshal task: %v", err) + t.Fatalf("couldn't checksum task: %v", err) } - taskSpec, err := yaml.Marshal(exampleTask.Spec) + taskAsYAML, err := yaml.Marshal(exampleTask) if err != nil { - t.Fatalf("couldn't marshal task spec: %v", err) + t.Fatalf("couldn't marshal task: %v", err) } examplePipeline := &pipelinev1.Pipeline{ @@ -241,13 +240,13 @@ func TestResolve(t *testing.T) { }}, }, } - pipelineAsYAML, err := yaml.Marshal(examplePipeline) + pipelineChecksum, err := examplePipeline.Checksum() if err != nil { - t.Fatalf("couldn't marshal pipeline: %v", err) + t.Fatalf("couldn't checksum pipeline: %v", err) } - pipelineSpec, err := yaml.Marshal(examplePipeline.Spec) + pipelineAsYAML, err := yaml.Marshal(examplePipeline) if err != nil { - t.Fatalf("couldn't marshal pipeline spec: %v", err) + t.Fatalf("couldn't marshal pipeline: %v", err) } testCases := []struct { @@ -272,7 +271,7 @@ func TestResolve(t *testing.T) { RefSource: &pipelinev1.RefSource{ URI: "/apis/tekton.dev/v1/namespaces/task-ns/task/example-task@a123", Digest: map[string]string{ - "sha256": sha256CheckSum(taskSpec), + "sha256": hex.EncodeToString(taskChecksum), }, }, }, @@ -289,7 +288,7 @@ func TestResolve(t *testing.T) { RefSource: &pipelinev1.RefSource{ URI: "/apis/tekton.dev/v1/namespaces/pipeline-ns/pipeline/example-pipeline@b123", Digest: map[string]string{ - "sha256": sha256CheckSum(pipelineSpec), + "sha256": hex.EncodeToString(pipelineChecksum), }, }, }, @@ -305,7 +304,7 @@ func TestResolve(t *testing.T) { RefSource: &pipelinev1.RefSource{ URI: "/apis/tekton.dev/v1/namespaces/pipeline-ns/pipeline/example-pipeline@b123", Digest: map[string]string{ - "sha256": sha256CheckSum(pipelineSpec), + "sha256": hex.EncodeToString(pipelineChecksum), }, }, }, @@ -321,7 +320,7 @@ func TestResolve(t *testing.T) { RefSource: &pipelinev1.RefSource{ URI: "/apis/tekton.dev/v1/namespaces/task-ns/task/example-task@a123", Digest: map[string]string{ - "sha256": sha256CheckSum(taskSpec), + "sha256": hex.EncodeToString(taskChecksum), }, }, }, @@ -471,9 +470,3 @@ func createRequest(kind, name, namespace string) *v1beta1.ResolutionRequest { func resolverDisabledContext() context.Context { return frtesting.ContextWithClusterResolverDisabled(context.Background()) } - -func sha256CheckSum(input []byte) string { - h := sha256.New() - h.Write(input) - return hex.EncodeToString(h.Sum(nil)) -} diff --git a/pkg/trustedresources/verify.go b/pkg/trustedresources/verify.go index 26ae838aa93..88941a90a3e 100644 --- a/pkg/trustedresources/verify.go +++ b/pkg/trustedresources/verify.go @@ -19,9 +19,7 @@ package trustedresources import ( "bytes" "context" - "crypto/sha256" "encoding/base64" - "encoding/json" "errors" "fmt" "regexp" @@ -52,6 +50,10 @@ const ( VerificationError ) +type Hashable interface { + Checksum() ([]byte, error) +} + // VerificationResultType indicates different cases of a verification result type VerificationResultType int @@ -95,50 +97,11 @@ func VerifyResource(ctx context.Context, resource metav1.Object, k8s kubernetes. } return VerificationResult{VerificationResultType: VerificationError, Err: fmt.Errorf("failed to get matched policies: %w", err)} } - objectMeta, signature, err := prepareObjectMeta(resource) + signature, err := extractSignature(resource) if err != nil { return VerificationResult{VerificationResultType: VerificationError, Err: err} } - switch v := resource.(type) { - case *v1beta1.Task: - task := v1beta1.Task{ - TypeMeta: metav1.TypeMeta{ - APIVersion: "tekton.dev/v1beta1", - Kind: "Task"}, - ObjectMeta: objectMeta, - Spec: v.TaskSpec(), - } - return verifyResource(ctx, &task, k8s, signature, matchedPolicies) - case *v1.Task: - task := v1.Task{ - TypeMeta: metav1.TypeMeta{ - APIVersion: "tekton.dev/v1", - Kind: "Task"}, - ObjectMeta: objectMeta, - Spec: v.Spec, - } - return verifyResource(ctx, &task, k8s, signature, matchedPolicies) - case *v1beta1.Pipeline: - pipeline := v1beta1.Pipeline{ - TypeMeta: metav1.TypeMeta{ - APIVersion: "tekton.dev/v1beta1", - Kind: "Pipeline"}, - ObjectMeta: objectMeta, - Spec: v.PipelineSpec(), - } - return verifyResource(ctx, &pipeline, k8s, signature, matchedPolicies) - case *v1.Pipeline: - pipeline := v1.Pipeline{ - TypeMeta: metav1.TypeMeta{ - APIVersion: "tekton.dev/v1", - Kind: "Pipeline"}, - ObjectMeta: objectMeta, - Spec: v.Spec, - } - return verifyResource(ctx, &pipeline, k8s, signature, matchedPolicies) - default: - return VerificationResult{VerificationResultType: VerificationError, Err: fmt.Errorf("%w: got resource %v but v1beta1.Task and v1beta1.Pipeline are currently supported", ErrResourceNotSupported, resource)} - } + return verifyResource(ctx, resource, k8s, signature, matchedPolicies) } // VerifyTask is the deprecated, this is to keep backward compatibility @@ -192,13 +155,19 @@ func verifyResource(ctx context.Context, resource metav1.Object, k8s kubernetes. } } + // get the checksum of the resource + checksumBytes, err := getChecksum(resource) + if err != nil { + return VerificationResult{VerificationResultType: VerificationError, Err: err} + } + // first evaluate all enforce policies. Return VerificationError type of VerificationResult if any policy fails. for _, p := range enforcePolicies { verifiers, err := verifier.FromPolicy(ctx, k8s, p) if err != nil { return VerificationResult{VerificationResultType: VerificationError, Err: fmt.Errorf("failed to get verifiers from policy: %w", err)} } - passVerification := doesAnyVerifierPass(resource, signature, verifiers) + passVerification := doesAnyVerifierPass(ctx, checksumBytes, signature, verifiers) if !passVerification { return VerificationResult{VerificationResultType: VerificationError, Err: fmt.Errorf("%w: resource %s in namespace %s fails verification", ErrResourceVerificationFailed, resource.GetName(), resource.GetNamespace())} } @@ -212,7 +181,7 @@ func verifyResource(ctx context.Context, resource metav1.Object, k8s kubernetes. logger.Warnf(warn.Error()) return VerificationResult{VerificationResultType: VerificationWarn, Err: warn} } - passVerification := doesAnyVerifierPass(resource, signature, verifiers) + passVerification := doesAnyVerifierPass(ctx, checksumBytes, signature, verifiers) if !passVerification { warn := fmt.Errorf("%w: resource %s in namespace %s fails verification", ErrResourceVerificationFailed, resource.GetName(), resource.GetNamespace()) logger.Warnf(warn.Error()) @@ -223,79 +192,50 @@ func verifyResource(ctx context.Context, resource metav1.Object, k8s kubernetes. return VerificationResult{VerificationResultType: VerificationPass} } -// doesAnyVerifierPass loop over verifiers to verify the resource, return true if any verifier pass verification. -func doesAnyVerifierPass(resource metav1.Object, signature []byte, verifiers []signature.Verifier) bool { +// doesAnyVerifierPass loop over verifiers to verify the checksum and the signature, return true if any verifier pass verification. +func doesAnyVerifierPass(ctx context.Context, checksumBytes []byte, signature []byte, verifiers []signature.Verifier) bool { + logger := logging.FromContext(ctx) passVerification := false for _, verifier := range verifiers { - // if one of the verifier passes verification, then this policy passes verification - if err := verifyInterface(resource, verifier, signature); err == nil { + if err := verifier.VerifySignature(bytes.NewReader(signature), bytes.NewReader(checksumBytes)); err == nil { + // if one of the verifier passes verification, then this policy passes verification passVerification = true break + } else { + // FixMe: changing %v to %w breaks integration tests. + warn := fmt.Errorf("%w:%v", ErrResourceVerificationFailed, err.Error()) + logger.Warnf(warn.Error()) } } return passVerification } -// verifyInterface get the checksum of json marshalled object and verify it. -func verifyInterface(obj interface{}, verifier signature.Verifier, signature []byte) error { - ts, err := json.Marshal(obj) - if err != nil { - return fmt.Errorf("failed to marshal the object: %w", err) - } - - h := sha256.New() - h.Write(ts) - - if err := verifier.VerifySignature(bytes.NewReader(signature), bytes.NewReader(h.Sum(nil))); err != nil { - // FixMe: changing %v to %w breaks integration tests. - return fmt.Errorf("%w:%v", ErrResourceVerificationFailed, err.Error()) - } - - return nil -} - -// prepareObjectMeta will remove annotations not configured from user side -- "kubectl-client-side-apply" and "kubectl.kubernetes.io/last-applied-configuration" -// (added when an object is created with `kubectl apply`) to avoid verification failure and extract the signature. -// Returns a copy of the input object metadata with the annotations removed and the object's signature, -// if it is present in the metadata. +// extractSignature extracts the signature if it is present in the metadata. // Returns a non-nil error if the signature cannot be decoded. -func prepareObjectMeta(in metav1.Object) (metav1.ObjectMeta, []byte, error) { - out := metav1.ObjectMeta{} - - // exclude the fields populated by system. - out.Name = in.GetName() - out.GenerateName = in.GetGenerateName() - out.Namespace = in.GetNamespace() - - if in.GetLabels() != nil { - out.Labels = make(map[string]string) - for k, v := range in.GetLabels() { - out.Labels[k] = v - } - } - - out.Annotations = make(map[string]string) - for k, v := range in.GetAnnotations() { - out.Annotations[k] = v - } - - // exclude the annotations added by other components - // Task annotations are unlikely to be changed, we need to make sure other components - // like resolver doesn't modify the annotations, otherwise the verification will fail - delete(out.Annotations, "kubectl-client-side-apply") - delete(out.Annotations, "kubectl.kubernetes.io/last-applied-configuration") - +func extractSignature(in metav1.Object) ([]byte, error) { // signature should be contained in annotation sig, ok := in.GetAnnotations()[SignatureAnnotation] if !ok { - return out, nil, nil + return nil, nil } // extract signature signature, err := base64.StdEncoding.DecodeString(sig) if err != nil { - return out, nil, err + return nil, err } - delete(out.Annotations, SignatureAnnotation) + return signature, nil +} - return out, signature, nil +// getChecksum gets the sha256 checksum of the resource. +// Returns a non-nil error if the checksum cannot be computed or the resource is of unknown type. +func getChecksum(resource metav1.Object) ([]byte, error) { + h, ok := resource.(Hashable) + if !ok { + return nil, fmt.Errorf("%w: got resource %v but v1.Task, v1beta1.Task, v1.Pipeline and v1beta1.Pipeline are currently supported", ErrResourceNotSupported, resource) + } + checksumBytes, err := h.Checksum() + if err != nil { + return nil, err + } + return checksumBytes, nil } diff --git a/pkg/trustedresources/verify_test.go b/pkg/trustedresources/verify_test.go index 73117838773..40b15bfaafa 100644 --- a/pkg/trustedresources/verify_test.go +++ b/pkg/trustedresources/verify_test.go @@ -28,7 +28,6 @@ import ( "fmt" "testing" - "github.com/google/go-cmp/cmp" "github.com/sigstore/sigstore/pkg/signature" "github.com/tektoncd/pipeline/pkg/apis/config" v1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" @@ -36,7 +35,6 @@ import ( "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1" "github.com/tektoncd/pipeline/pkg/trustedresources/verifier" test "github.com/tektoncd/pipeline/test" - "github.com/tektoncd/pipeline/test/diff" "go.uber.org/zap/zaptest" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "knative.dev/pkg/logging" @@ -79,89 +77,6 @@ var unsignedPipeline = v1.Pipeline{ }, } -func TestVerifyInterface_Task_Success(t *testing.T) { - sv, _, err := signature.NewDefaultECDSASignerVerifier() - if err != nil { - t.Fatalf("failed to get signerverifier %v", err) - } - - unsignedTask := test.GetUnsignedTask("test-task") - signedTask, err := test.GetSignedV1beta1Task(unsignedTask, sv, "signed") - if err != nil { - t.Fatalf("Failed to get signed task %v", err) - } - - signature := []byte{} - - if sig, ok := signedTask.Annotations[SignatureAnnotation]; ok { - delete(signedTask.Annotations, SignatureAnnotation) - signature, err = base64.StdEncoding.DecodeString(sig) - if err != nil { - t.Fatal(err) - } - } - - err = verifyInterface(signedTask, sv, signature) - if err != nil { - t.Fatalf("VerifyInterface() get err %v", err) - } -} - -func TestVerifyInterface_Task_Error(t *testing.T) { - sv, _, err := signature.NewDefaultECDSASignerVerifier() - if err != nil { - t.Fatalf("failed to get signerverifier %v", err) - } - - unsignedTask := test.GetUnsignedTask("test-task") - - signedTask, err := test.GetSignedV1beta1Task(unsignedTask, sv, "signed") - if err != nil { - t.Fatalf("Failed to get signed task %v", err) - } - - tamperedTask := signedTask.DeepCopy() - tamperedTask.Name = "tampered" - - tcs := []struct { - name string - task *v1beta1.Task - expectedError error - }{{ - name: "Unsigned Task Fail Verification", - task: unsignedTask, - expectedError: ErrResourceVerificationFailed, - }, { - name: "Empty task Fail Verification", - task: nil, - expectedError: ErrResourceVerificationFailed, - }, { - name: "Tampered task Fail Verification", - task: tamperedTask, - expectedError: ErrResourceVerificationFailed, - }} - for _, tc := range tcs { - t.Run(tc.name, func(t *testing.T) { - signature := []byte{} - - if tc.task != nil { - if sig, ok := tc.task.Annotations[SignatureAnnotation]; ok { - delete(tc.task.Annotations, SignatureAnnotation) - signature, err = base64.StdEncoding.DecodeString(sig) - if err != nil { - t.Fatal(err) - } - } - } - - err := verifyInterface(tc.task, sv, signature) - if !errors.Is(err, tc.expectedError) { - t.Errorf("verifyInterface got: %v, want: %v", err, tc.expectedError) - } - }) - } -} - func TestVerifyResource_Task_Success(t *testing.T) { signer256, _, k8sclient, vps := test.SetupVerificationPolicies(t) unsignedTask := test.GetUnsignedTask("test-task") @@ -601,81 +516,6 @@ func TestVerifyResource_TypeNotSupported(t *testing.T) { } } -func TestPrepareObjectMeta(t *testing.T) { - unsigned := test.GetUnsignedTask("test-task").ObjectMeta - - signed := unsigned.DeepCopy() - sig := "tY805zV53PtwDarK3VD6dQPx5MbIgctNcg/oSle+MG0=" - signed.Annotations = map[string]string{SignatureAnnotation: sig} - - signedWithLabels := signed.DeepCopy() - signedWithLabels.Labels = map[string]string{"label": "foo"} - - signedWithExtraAnnotations := signed.DeepCopy() - signedWithExtraAnnotations.Annotations["kubectl-client-side-apply"] = "client" - signedWithExtraAnnotations.Annotations["kubectl.kubernetes.io/last-applied-configuration"] = "config" - - tcs := []struct { - name string - objectmeta *metav1.ObjectMeta - expected metav1.ObjectMeta - expectedSignature string - }{{ - name: "Prepare signed objectmeta without labels", - objectmeta: signed, - expected: metav1.ObjectMeta{ - Name: "test-task", - Namespace: namespace, - Annotations: map[string]string{}, - }, - expectedSignature: sig, - }, { - name: "Prepare signed objectmeta with labels", - objectmeta: signedWithLabels, - expected: metav1.ObjectMeta{ - Name: "test-task", - Namespace: namespace, - Labels: map[string]string{"label": "foo"}, - Annotations: map[string]string{}, - }, - expectedSignature: sig, - }, { - name: "Prepare signed objectmeta with extra annotations", - objectmeta: signedWithExtraAnnotations, - expected: metav1.ObjectMeta{ - Name: "test-task", - Namespace: namespace, - Annotations: map[string]string{}, - }, - expectedSignature: sig, - }, { - name: "resource without signature shouldn't fail", - objectmeta: &unsigned, - expected: metav1.ObjectMeta{ - Name: "test-task", - Namespace: namespace, - Annotations: map[string]string{"foo": "bar"}, - }, - expectedSignature: "", - }} - - for _, tc := range tcs { - t.Run(tc.name, func(t *testing.T) { - task, signature, err := prepareObjectMeta(tc.objectmeta) - if err != nil { - t.Fatalf("got unexpected err: %v", err) - } - if d := cmp.Diff(task, tc.expected); d != "" { - t.Error(diff.PrintWantGot(d)) - } - got := base64.StdEncoding.EncodeToString(signature) - if d := cmp.Diff(got, tc.expectedSignature); d != "" { - t.Error(diff.PrintWantGot(d)) - } - }) - } -} - func signInterface(signer signature.Signer, i interface{}) ([]byte, error) { if signer == nil { return nil, fmt.Errorf("signer is nil") diff --git a/test/status_test.go b/test/status_test.go index 3496bd66f79..6f10b6d8c73 100644 --- a/test/status_test.go +++ b/test/status_test.go @@ -21,7 +21,6 @@ package test import ( "context" - "crypto/sha256" "encoding/hex" "fmt" "testing" @@ -35,7 +34,6 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" knativetest "knative.dev/pkg/test" "knative.dev/pkg/test/helpers" - "sigs.k8s.io/yaml" ) var ( @@ -136,14 +134,14 @@ func TestProvenanceFieldInPipelineRunTaskRunStatus(t *testing.T) { if err != nil { t.Fatalf("Failed to create Task `%s`: %s", taskName, err) } - taskSpec, err := yaml.Marshal(exampleTask.Spec) + checksum, err := exampleTask.Checksum() if err != nil { - t.Fatalf("couldn't marshal task spec: %v", err) + t.Fatalf("couldn't extract the checksum: %s", err) } expectedTaskRunProvenance := &v1.Provenance{ RefSource: &v1.RefSource{ URI: fmt.Sprintf("/apis/%s/namespaces/%s/%s/%s@%s", v1.SchemeGroupVersion.String(), namespace, "task", exampleTask.Name, exampleTask.UID), - Digest: map[string]string{"sha256": sha256CheckSum(taskSpec)}, + Digest: map[string]string{"sha256": hex.EncodeToString(checksum)}, }, } @@ -153,14 +151,14 @@ func TestProvenanceFieldInPipelineRunTaskRunStatus(t *testing.T) { if err != nil { t.Fatalf("Failed to create Pipeline `%s`: %s", pipelineName, err) } - pipelineSpec, err := yaml.Marshal(examplePipeline.Spec) + checksum, err = examplePipeline.Checksum() if err != nil { - t.Fatalf("couldn't marshal pipeline spec: %v", err) + t.Fatalf("couldn't extract the checksum: %s", err) } expectedPipelineRunProvenance := &v1.Provenance{ RefSource: &v1.RefSource{ URI: fmt.Sprintf("/apis/%s/namespaces/%s/%s/%s@%s", v1.SchemeGroupVersion.String(), namespace, "pipeline", examplePipeline.Name, examplePipeline.UID), - Digest: map[string]string{"sha256": sha256CheckSum(pipelineSpec)}, + Digest: map[string]string{"sha256": hex.EncodeToString(checksum)}, }, FeatureFlags: &config.FeatureFlags{ EnableAPIFields: config.DefaultEnableAPIFields, @@ -284,9 +282,3 @@ spec: value: %s `, prName, namespace, pipelineName, namespace)) } - -func sha256CheckSum(input []byte) string { - h := sha256.New() - h.Write(input) - return hex.EncodeToString(h.Sum(nil)) -}