From f44fc907b98e11bada5e5919dcc1fd3ca5f13e91 Mon Sep 17 00:00:00 2001 From: Yongxuan Zhang Date: Fri, 2 Jun 2023 17:09:12 +0000 Subject: [PATCH] [TEP-0091] support remote v1 pipeline verification This commit adds the support for v1 pipeline verification. Previously we only support v1beta1 verification. Signed-off-by: Yongxuan Zhang yongxuanzhang@google.com --- docs/trusted-resources.md | 2 +- .../pipelinerun/pipelinerun_test.go | 390 +++++++++++++++++- .../pipelinerun/resources/pipelineref.go | 4 +- .../pipelinerun/resources/pipelineref_test.go | 379 +++++++++++++++++ 4 files changed, 770 insertions(+), 5 deletions(-) diff --git a/docs/trusted-resources.md b/docs/trusted-resources.md index f8fca4f74d5..678e2c01fed 100644 --- a/docs/trusted-resources.md +++ b/docs/trusted-resources.md @@ -14,7 +14,7 @@ weight: 312 ## Overview -Trusted Resources is a feature which can be used to sign Tekton Resources and verify them. Details of design can be found at [TEP--0091](https://github.com/tektoncd/community/blob/main/teps/0091-trusted-resources.md). This is an alpha feature and supports `v1beta1` version of `Task` and `Pipeline`. +Trusted Resources is a feature which can be used to sign Tekton Resources and verify them. Details of design can be found at [TEP--0091](https://github.com/tektoncd/community/blob/main/teps/0091-trusted-resources.md). This is an alpha feature and supports `v1beta1` version of `Task`, `Pipeline` and `v1` `Pipeline`. **Note**: Currently, trusted resources only support verifying Tekton resources that come from remote places i.e. git, OCI registry and Artifact Hub. To use [cluster resolver](./cluster-resolver.md) for in-cluster resources, make sure to set all default values for the resources before applied to cluster, because the mutating webhook will update the default fields if not given and fail the verification. diff --git a/pkg/reconciler/pipelinerun/pipelinerun_test.go b/pkg/reconciler/pipelinerun/pipelinerun_test.go index 004916d8b91..c7611ccb08b 100644 --- a/pkg/reconciler/pipelinerun/pipelinerun_test.go +++ b/pkg/reconciler/pipelinerun/pipelinerun_test.go @@ -17,7 +17,9 @@ limitations under the License. package pipelinerun import ( + "bytes" "context" + "crypto/sha256" "encoding/base64" "encoding/json" "errors" @@ -33,8 +35,10 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/go-containerregistry/pkg/registry" + "github.com/sigstore/sigstore/pkg/signature" "github.com/tektoncd/pipeline/pkg/apis/config" "github.com/tektoncd/pipeline/pkg/apis/pipeline" + pipelinev1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1alpha1" "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1" resolutionv1beta1 "github.com/tektoncd/pipeline/pkg/apis/resolution/v1beta1" @@ -11536,7 +11540,7 @@ spec: } } -func TestReconcile_verifyResolvedPipeline_Success(t *testing.T) { +func TestReconcile_verifyResolved_V1beta1Pipeline_NoError(t *testing.T) { resolverName := "foobar" ts := parse.MustParseV1beta1Task(t, ` @@ -11687,7 +11691,7 @@ spec: } } -func TestReconcile_verifyResolvedPipeline_Error(t *testing.T) { +func TestReconcile_verifyResolved_V1beta1Pipeline_Error(t *testing.T) { resolverName := "foobar" // Case1: unsigned Pipeline refers to unsigned Task @@ -11870,6 +11874,341 @@ spec: } } +func TestReconcile_verifyResolved_V1Pipeline_NoError(t *testing.T) { + resolverName := "foobar" + + ts := parse.MustParseV1Task(t, ` +metadata: + name: test-task + namespace: foo +spec: + steps: + - name: simple-step + image: foo + command: ["/mycmd"] + env: + - name: foo + value: bar +`) + + signer, _, vps := test.SetupMatchAllVerificationPolicies(t, ts.Namespace) + signedTask, err := getSignedV1Task(ts, signer, "test-task") + if err != nil { + t.Fatal("fail to sign task", err) + } + signedTaskBytes, err := yaml.Marshal(signedTask) + if err != nil { + t.Fatal("fail to marshal task", err) + } + + ps := parse.MustParseV1Pipeline(t, fmt.Sprintf(` +metadata: + name: test-pipeline + namespace: foo +spec: + tasks: + - name: test-1 + taskRef: + resolver: %s +`, resolverName)) + + signedPipeline, err := getSignedV1Pipeline(ps, signer, "test-pipeline") + if err != nil { + t.Fatal("fail to sign pipeline", err) + } + signedPipelineBytes, err := yaml.Marshal(signedPipeline) + if err != nil { + t.Fatal("fail to marshal task", err) + } + + noMatchPolicy := []*v1alpha1.VerificationPolicy{{ + ObjectMeta: metav1.ObjectMeta{ + Name: "no-match", + Namespace: ts.Namespace, + }, + Spec: v1alpha1.VerificationPolicySpec{ + Resources: []v1alpha1.ResourcePattern{{Pattern: "no-match"}}, + }}} + // warnPolicy doesn't contain keys so it will fail verification but doesn't fail the run + warnPolicy := []*v1alpha1.VerificationPolicy{{ + ObjectMeta: metav1.ObjectMeta{ + Name: "warn-policy", + Namespace: ts.Namespace, + }, + Spec: v1alpha1.VerificationPolicySpec{ + Resources: []v1alpha1.ResourcePattern{{Pattern: ".*"}}, + Mode: v1alpha1.ModeWarn, + }}} + + prs := parse.MustParseV1beta1PipelineRun(t, fmt.Sprintf(` +metadata: + name: test-pipelinerun + namespace: foo + selfLink: /pipeline/1234 +spec: + pipelineRef: + resolver: %s +`, resolverName)) + failNoMatchCondition := &apis.Condition{ + Type: trustedresources.ConditionTrustedResourcesVerified, + Status: corev1.ConditionFalse, + Message: fmt.Sprintf("failed to get matched policies: %s: no matching policies are found for resource: %s against source: %s", trustedresources.ErrNoMatchedPolicies, ps.Name, ""), + } + passCondition := &apis.Condition{ + Type: trustedresources.ConditionTrustedResourcesVerified, + Status: corev1.ConditionTrue, + } + failNoKeysCondition := &apis.Condition{ + Type: trustedresources.ConditionTrustedResourcesVerified, + Status: corev1.ConditionFalse, + Message: fmt.Sprintf("failed to get verifiers for resource %s from namespace %s: %s", ps.Name, ps.Namespace, verifier.ErrEmptyPublicKeys), + } + testCases := []struct { + name string + task []*v1beta1.Task + noMatchPolicy string + verificationPolicies []*v1alpha1.VerificationPolicy + wantTrustedResourcesCondition *apis.Condition + }{{ + name: "ignore no match policy", + noMatchPolicy: config.IgnoreNoMatchPolicy, + verificationPolicies: noMatchPolicy, + wantTrustedResourcesCondition: nil, + }, { + name: "warn no match policy", + noMatchPolicy: config.WarnNoMatchPolicy, + verificationPolicies: noMatchPolicy, + wantTrustedResourcesCondition: failNoMatchCondition, + }, { + name: "pass enforce policy", + noMatchPolicy: config.FailNoMatchPolicy, + verificationPolicies: vps, + wantTrustedResourcesCondition: passCondition, + }, { + name: "only fail warn policy", + noMatchPolicy: config.FailNoMatchPolicy, + verificationPolicies: warnPolicy, + wantTrustedResourcesCondition: failNoKeysCondition, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + cms := []*corev1.ConfigMap{ + { + ObjectMeta: metav1.ObjectMeta{Name: config.GetFeatureFlagsConfigName(), Namespace: system.Namespace()}, + Data: map[string]string{ + "trusted-resources-verification-no-match-policy": tc.noMatchPolicy, + "enable-api-fields": config.BetaAPIFields, + }, + }, + } + + pipelineReq := getResolvedResolutionRequest(t, resolverName, signedPipelineBytes, prs.Namespace, prs.Name) + taskReq := getResolvedResolutionRequest(t, resolverName, signedTaskBytes, prs.Namespace, prs.Name+"-"+ps.Spec.Tasks[0].Name) + + d := test.Data{ + PipelineRuns: []*v1beta1.PipelineRun{prs}, + VerificationPolicies: tc.verificationPolicies, + ConfigMaps: cms, + ResolutionRequests: []*resolutionv1beta1.ResolutionRequest{&pipelineReq, &taskReq}, + } + prt := newPipelineRunTest(t, d) + defer prt.Cancel() + + reconciledRun, _ := prt.reconcileRun("foo", "test-pipelinerun", []string{}, false) + + checkPipelineRunConditionStatusAndReason(t, reconciledRun, corev1.ConditionUnknown, v1beta1.PipelineRunReasonRunning.String()) + gotVerificationCondition := reconciledRun.Status.GetCondition(trustedresources.ConditionTrustedResourcesVerified) + if d := cmp.Diff(tc.wantTrustedResourcesCondition, gotVerificationCondition, ignoreLastTransitionTime); d != "" { + t.Error(diff.PrintWantGot(d)) + } + }) + } +} + +func TestReconcile_verifyResolved_V1Pipeline_Error(t *testing.T) { + resolverName := "foobar" + + // Case1: unsigned Pipeline refers to unsigned Task + unsignedTask := parse.MustParseV1beta1Task(t, ` +metadata: + name: test-task + namespace: foo +spec: + steps: + - name: simple-step + image: foo + command: ["/mycmd"] + env: + - name: foo + value: bar +`) + unsignedTaskBytes, err := yaml.Marshal(unsignedTask) + if err != nil { + t.Fatal("fail to marshal task", err) + } + + unsignedPipeline := parse.MustParseV1beta1Pipeline(t, fmt.Sprintf(` +metadata: + name: test-pipeline + namespace: foo +spec: + tasks: + - name: test-1 + taskRef: + resolver: %s +`, resolverName)) + unsignedPipelineBytes, err := yaml.Marshal(unsignedPipeline) + if err != nil { + t.Fatal("fail to marshal task", err) + } + + // Case2: signed Pipeline refers to unsigned Task + signer, _, vps := test.SetupMatchAllVerificationPolicies(t, unsignedTask.Namespace) + signedPipelineWithUnsignedTask, err := test.GetSignedPipeline(unsignedPipeline, signer, "test-pipeline") + if err != nil { + t.Fatal("fail to sign pipeline", err) + } + signedPipelineWithUnsignedTaskBytes, err := yaml.Marshal(signedPipelineWithUnsignedTask) + if err != nil { + t.Fatal("fail to marshal task", err) + } + + // Case3: signed Pipeline refers to modified Task + signedTask, err := test.GetSignedTask(unsignedTask, signer, "test-task") + if err != nil { + t.Fatal("fail to sign task", err) + } + signedTaskBytes, err := yaml.Marshal(signedTask) + if err != nil { + t.Fatal("fail to marshal task", err) + } + modifiedTask := signedTask.DeepCopy() + if modifiedTask.Annotations == nil { + modifiedTask.Annotations = make(map[string]string) + } + modifiedTask.Annotations["random"] = "attack" + modifiedTaskBytes, err := yaml.Marshal(modifiedTask) + if err != nil { + t.Fatal("fail to marshal task", err) + } + + ps := parse.MustParseV1beta1Pipeline(t, fmt.Sprintf(` +metadata: + name: test-pipeline + namespace: foo +spec: + tasks: + - name: test-1 + taskRef: + resolver: %s +`, resolverName)) + signedPipelineWithModifiedTask, err := test.GetSignedPipeline(ps, signer, "test-pipeline") + if err != nil { + t.Fatal("fail to sign pipeline", err) + } + signedPipelineWithModifiedTaskBytes, err := yaml.Marshal(signedPipelineWithModifiedTask) + if err != nil { + t.Fatal("fail to marshal task", err) + } + + // Case4: modified Pipeline refers to signed Task + ps = parse.MustParseV1beta1Pipeline(t, fmt.Sprintf(` +metadata: + name: test-pipeline + namespace: foo +spec: + tasks: + - name: test-1 + taskRef: + resolver: %s +`, resolverName)) + signedPipeline, err := test.GetSignedPipeline(ps, signer, "test-pipeline") + if err != nil { + t.Fatal("fail to sign pipeline", err) + } + modifiedPipeline := signedPipeline.DeepCopy() + if modifiedPipeline.Annotations == nil { + modifiedPipeline.Annotations = make(map[string]string) + } + modifiedPipeline.Annotations["random"] = "attack" + modifiedPipelineBytes, err := yaml.Marshal(modifiedPipeline) + if err != nil { + t.Fatal("fail to marshal task", err) + } + + cms := []*corev1.ConfigMap{ + { + ObjectMeta: metav1.ObjectMeta{Name: config.GetFeatureFlagsConfigName(), Namespace: system.Namespace()}, + Data: map[string]string{ + "trusted-resources-verification-no-match-policy": config.FailNoMatchPolicy, + "enable-api-fields": config.BetaAPIFields, + }, + }, + } + + pr := parse.MustParseV1beta1PipelineRun(t, fmt.Sprintf(` +metadata: + name: test-pipelinerun + namespace: foo + selfLink: /pipeline/1234 +spec: + pipelineRef: + resolver: %s + serviceAccountName: default +`, resolverName)) + + testCases := []struct { + name string + pipelinerun []*v1beta1.PipelineRun + pipelineBytes []byte + taskBytes []byte + }{ + { + name: "unsigned pipeline fails verification", + pipelineBytes: unsignedPipelineBytes, + taskBytes: unsignedTaskBytes, + }, + { + name: "signed pipeline with unsigned task fails verification", + pipelineBytes: signedPipelineWithUnsignedTaskBytes, + taskBytes: unsignedTaskBytes, + }, + { + name: "signed pipeline with modified task fails verification", + pipelineBytes: signedPipelineWithModifiedTaskBytes, + taskBytes: modifiedTaskBytes, + }, + { + name: "modified pipeline with signed task fails verification", + pipelineBytes: modifiedPipelineBytes, + taskBytes: signedTaskBytes, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + pipelineReq := getResolvedResolutionRequest(t, resolverName, tc.pipelineBytes, pr.Namespace, pr.Name) + taskReq := getResolvedResolutionRequest(t, resolverName, tc.taskBytes, pr.Namespace, pr.Name+"-"+ps.Spec.Tasks[0].Name) + + d := test.Data{ + PipelineRuns: []*v1beta1.PipelineRun{pr}, + ConfigMaps: cms, + VerificationPolicies: vps, + ResolutionRequests: []*resolutionv1beta1.ResolutionRequest{&pipelineReq, &taskReq}, + } + prt := newPipelineRunTest(t, d) + defer prt.Cancel() + + reconciledRun, _ := prt.reconcileRun("foo", "test-pipelinerun", []string{}, true) + checkPipelineRunConditionStatusAndReason(t, reconciledRun, corev1.ConditionFalse, ReasonResourceVerificationFailed) + gotVerificationCondition := reconciledRun.Status.GetCondition(trustedresources.ConditionTrustedResourcesVerified) + if gotVerificationCondition == nil || gotVerificationCondition.Status != corev1.ConditionFalse { + t.Errorf("Expected to have false condition, but had %v", gotVerificationCondition) + } + }) + } +} + // getResolvedResolutionRequest is a helper function to return the ResolutionRequest and the data is filled with resourceBytes, // the ResolutionRequest's name is generated by resolverName, namespace and runName. func getResolvedResolutionRequest(t *testing.T, resolverName string, resourceBytes []byte, namespace string, runName string) resolutionv1beta1.ResolutionRequest { @@ -12021,3 +12360,50 @@ spec: t.Errorf("Expected PipelineRun to still be Succeeded, but reason is %s", reconciledRun.Status.GetCondition(apis.ConditionSucceeded)) } } + +func getSignedV1Pipeline(unsigned *pipelinev1.Pipeline, signer signature.Signer, name string) (*pipelinev1.Pipeline, error) { + signed := unsigned.DeepCopy() + signed.Name = name + if signed.Annotations == nil { + signed.Annotations = map[string]string{} + } + signature, err := signInterface(signer, signed) + if err != nil { + return nil, err + } + signed.Annotations[trustedresources.SignatureAnnotation] = base64.StdEncoding.EncodeToString(signature) + return signed, nil +} + +func getSignedV1Task(unsigned *pipelinev1.Task, signer signature.Signer, name string) (*pipelinev1.Task, error) { + signed := unsigned.DeepCopy() + signed.Name = name + if signed.Annotations == nil { + signed.Annotations = map[string]string{} + } + signature, err := signInterface(signer, signed) + if err != nil { + return nil, err + } + signed.Annotations[trustedresources.SignatureAnnotation] = base64.StdEncoding.EncodeToString(signature) + return signed, nil +} + +func signInterface(signer signature.Signer, i interface{}) ([]byte, error) { + if signer == nil { + return nil, fmt.Errorf("signer is nil") + } + b, err := json.Marshal(i) + if err != nil { + return nil, err + } + h := sha256.New() + h.Write(b) + + sig, err := signer.SignMessage(bytes.NewReader(h.Sum(nil))) + if err != nil { + return nil, err + } + + return sig, nil +} diff --git a/pkg/reconciler/pipelinerun/resources/pipelineref.go b/pkg/reconciler/pipelinerun/resources/pipelineref.go index 63e635a7c4c..79bc349dc85 100644 --- a/pkg/reconciler/pipelinerun/resources/pipelineref.go +++ b/pkg/reconciler/pipelinerun/resources/pipelineref.go @@ -155,7 +155,7 @@ func readRuntimeObjectAsPipeline(ctx context.Context, obj runtime.Object, k8s ku vr := trustedresources.VerifyResource(ctx, obj, k8s, refSource, verificationPolicies) return obj, &vr, nil case *v1.Pipeline: - // TODO(#6356): Support V1 Task verification + vr := trustedresources.VerifyResource(ctx, obj, k8s, refSource, verificationPolicies) // Validation of beta fields must happen before the V1 Pipeline is converted into the storage version of the API. // TODO(#6592): Decouple API versioning from feature versioning if err := obj.Spec.ValidateBetaFields(ctx); err != nil { @@ -170,7 +170,7 @@ func readRuntimeObjectAsPipeline(ctx context.Context, obj runtime.Object, k8s ku if err := t.ConvertFrom(ctx, obj); err != nil { return nil, nil, fmt.Errorf("failed to convert obj %s into Pipeline", obj.GetObjectKind().GroupVersionKind().String()) } - return t, nil, nil + return t, &vr, nil } return nil, nil, errors.New("resource is not a pipeline") diff --git a/pkg/reconciler/pipelinerun/resources/pipelineref_test.go b/pkg/reconciler/pipelinerun/resources/pipelineref_test.go index 022fc994532..68db32296d9 100644 --- a/pkg/reconciler/pipelinerun/resources/pipelineref_test.go +++ b/pkg/reconciler/pipelinerun/resources/pipelineref_test.go @@ -17,7 +17,10 @@ package resources_test import ( + "bytes" "context" + "crypto/sha256" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -28,7 +31,9 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-containerregistry/pkg/registry" + "github.com/sigstore/sigstore/pkg/signature" "github.com/tektoncd/pipeline/pkg/apis/config" + pipelinev1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1alpha1" "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1" "github.com/tektoncd/pipeline/pkg/client/clientset/versioned/fake" @@ -66,6 +71,24 @@ var ( EntryPoint: "foo/bar", } + unsignedV1Pipeline = &pipelinev1.Pipeline{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "tekton.dev/v1beta1", + Kind: "Pipeline"}, + ObjectMeta: metav1.ObjectMeta{ + Name: "pipeline", + Namespace: "trusted-resources", + Annotations: map[string]string{"foo": "bar"}, + }, + Spec: pipelinev1.PipelineSpec{ + Tasks: []pipelinev1.PipelineTask{ + { + Name: "task", + }, + }, + }, + } + verificationResultCmp = cmp.Comparer(func(x, y trustedresources.VerificationResult) bool { return x.VerificationResultType == y.VerificationResultType && (errors.Is(x.Err, y.Err) || errors.Is(y.Err, x.Err)) }) @@ -793,6 +816,329 @@ func TestGetPipelineFunc_VerifyError(t *testing.T) { } } +func TestGetPipelineFunc_V1Pipeline_VerifyNoError(t *testing.T) { + ctx := context.Background() + signer, _, k8sclient, vps := test.SetupVerificationPolicies(t) + tektonclient := fake.NewSimpleClientset() + + v1beta1UnsignedPipeline := &v1beta1.Pipeline{ + TypeMeta: metav1.TypeMeta{ + Kind: "Pipeline", + APIVersion: "tekton.dev/v1beta1", + }, + } + if err := v1beta1UnsignedPipeline.ConvertFrom(ctx, unsignedV1Pipeline.DeepCopy()); err != nil { + t.Error(err) + } + + unsignedPipelineBytes, err := json.Marshal(unsignedV1Pipeline) + if err != nil { + t.Fatal("fail to marshal pipeline", err) + } + noMatchPolicyRefSource := &v1beta1.RefSource{ + URI: "abc.com", + Digest: map[string]string{ + "sha1": "a123", + }, + EntryPoint: "foo/bar", + } + resolvedUnmatched := test.NewResolvedResource(unsignedPipelineBytes, nil, noMatchPolicyRefSource, nil) + requesterUnmatched := test.NewRequester(resolvedUnmatched, nil) + + signedPipeline, err := getSignedV1Pipeline(unsignedV1Pipeline, signer, "signed") + if err != nil { + t.Fatal("fail to sign pipeline", err) + } + + v1beta1SignedPipeline := &v1beta1.Pipeline{ + TypeMeta: metav1.TypeMeta{ + Kind: "Pipeline", + APIVersion: "tekton.dev/v1beta1", + }, + } + if err := v1beta1SignedPipeline.ConvertFrom(ctx, signedPipeline.DeepCopy()); err != nil { + t.Error(err) + } + + signedPipelineBytes, err := json.Marshal(signedPipeline) + if err != nil { + t.Fatal("fail to marshal pipeline", err) + } + matchPolicyRefSource := &v1beta1.RefSource{ + URI: " https://github.com/tektoncd/catalog.git", + Digest: map[string]string{ + "sha1": "a123", + }, + EntryPoint: "foo/bar", + } + resolvedMatched := test.NewResolvedResource(signedPipelineBytes, nil, matchPolicyRefSource, nil) + requesterMatched := test.NewRequester(resolvedMatched, nil) + + pipelineRef := &v1beta1.PipelineRef{ + Name: signedPipeline.Name, + ResolverRef: v1beta1.ResolverRef{ + Resolver: "git", + }, + } + + pr := v1beta1.PipelineRun{ + ObjectMeta: metav1.ObjectMeta{Namespace: "trusted-resources"}, + Spec: v1beta1.PipelineRunSpec{ + PipelineRef: pipelineRef, + ServiceAccountName: "default", + }, + } + + prWithStatus := v1beta1.PipelineRun{ + ObjectMeta: metav1.ObjectMeta{Namespace: "trusted-resources"}, + Spec: v1beta1.PipelineRunSpec{ + PipelineRef: pipelineRef, + ServiceAccountName: "default", + }, + Status: v1beta1.PipelineRunStatus{ + PipelineRunStatusFields: v1beta1.PipelineRunStatusFields{ + PipelineSpec: &v1beta1SignedPipeline.Spec, + Provenance: &v1beta1.Provenance{ + RefSource: &v1beta1.RefSource{ + URI: "abc.com", + Digest: map[string]string{"sha1": "a123"}, + EntryPoint: "foo/bar", + }, + }, + }, + }, + } + + warnPolicyRefSource := &v1beta1.RefSource{ + URI: " warnVP", + } + resolvedUnsignedMatched := test.NewResolvedResource(unsignedPipelineBytes, nil, warnPolicyRefSource, nil) + requesterUnsignedMatched := test.NewRequester(resolvedUnsignedMatched, nil) + + testcases := []struct { + name string + requester *test.Requester + verificationNoMatchPolicy string + pipelinerun v1beta1.PipelineRun + policies []*v1alpha1.VerificationPolicy + expected runtime.Object + expectedRefSource *v1beta1.RefSource + expectedVerificationResult *trustedresources.VerificationResult + }{{ + name: "signed pipeline with matching policy pass verification with enforce no match policy", + requester: requesterMatched, + verificationNoMatchPolicy: config.FailNoMatchPolicy, + pipelinerun: pr, + policies: vps, + expected: v1beta1SignedPipeline, + expectedRefSource: matchPolicyRefSource, + expectedVerificationResult: &trustedresources.VerificationResult{VerificationResultType: trustedresources.VerificationPass}, + }, { + name: "signed pipeline with matching policy pass verification with warn no match policy", + requester: requesterMatched, + verificationNoMatchPolicy: config.WarnNoMatchPolicy, + pipelinerun: pr, + policies: vps, + expected: v1beta1SignedPipeline, + expectedRefSource: matchPolicyRefSource, + expectedVerificationResult: &trustedresources.VerificationResult{VerificationResultType: trustedresources.VerificationPass}, + }, { + name: "signed pipeline with matching policy pass verification with ignore no match policy", + requester: requesterMatched, + verificationNoMatchPolicy: config.IgnoreNoMatchPolicy, + pipelinerun: pr, + policies: vps, + expected: v1beta1SignedPipeline, + expectedRefSource: matchPolicyRefSource, + expectedVerificationResult: &trustedresources.VerificationResult{VerificationResultType: trustedresources.VerificationPass}, + }, { + name: "warn unsigned pipeline without matching policies", + requester: requesterUnmatched, + verificationNoMatchPolicy: config.WarnNoMatchPolicy, + pipelinerun: pr, + policies: vps, + expected: v1beta1UnsignedPipeline, + expectedRefSource: noMatchPolicyRefSource, + expectedVerificationResult: &trustedresources.VerificationResult{VerificationResultType: trustedresources.VerificationWarn, Err: trustedresources.ErrNoMatchedPolicies}, + }, { + name: "unsigned pipeline fails warn mode policies doesn't return error", + requester: requesterUnsignedMatched, + verificationNoMatchPolicy: config.FailNoMatchPolicy, + pipelinerun: pr, + policies: vps, + expected: v1beta1UnsignedPipeline, + expectedRefSource: warnPolicyRefSource, + expectedVerificationResult: &trustedresources.VerificationResult{VerificationResultType: trustedresources.VerificationWarn, Err: trustedresources.ErrResourceVerificationFailed}, + }, { + name: "ignore unsigned pipeline without matching policies", + requester: requesterUnmatched, + verificationNoMatchPolicy: config.IgnoreNoMatchPolicy, + pipelinerun: pr, + policies: vps, + expected: v1beta1UnsignedPipeline, + expectedRefSource: noMatchPolicyRefSource, + expectedVerificationResult: &trustedresources.VerificationResult{VerificationResultType: trustedresources.VerificationSkip}, + }, { + name: "signed pipeline in status no need to verify", + requester: requesterMatched, + verificationNoMatchPolicy: config.FailNoMatchPolicy, + pipelinerun: prWithStatus, + policies: vps, + expected: &v1beta1.Pipeline{ + ObjectMeta: metav1.ObjectMeta{ + Name: signedPipeline.Name, + Namespace: signedPipeline.Namespace, + }, + Spec: v1beta1SignedPipeline.Spec, + }, + expectedRefSource: noMatchPolicyRefSource, + expectedVerificationResult: nil, + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + ctx = test.SetupTrustedResourceConfig(ctx, tc.verificationNoMatchPolicy) + fn := resources.GetPipelineFunc(ctx, k8sclient, tektonclient, tc.requester, &tc.pipelinerun, tc.policies) + + gotResolvedPipeline, gotSource, gotVerificationResult, err := fn(ctx, pipelineRef.Name) + if err != nil { + t.Fatalf("Received unexpected error ( %#v )", err) + } + if d := cmp.Diff(tc.expected, gotResolvedPipeline); d != "" { + t.Errorf("resolvedPipeline did not match: %s", diff.PrintWantGot(d)) + } + if d := cmp.Diff(tc.expectedRefSource, gotSource); d != "" { + t.Errorf("configSources did not match: %s", diff.PrintWantGot(d)) + } + if tc.expectedVerificationResult == nil { + if gotVerificationResult != nil { + t.Errorf("VerificationResult did not match: want %v, got %v", tc.expectedVerificationResult, gotVerificationResult) + } + return + } + if d := cmp.Diff(gotVerificationResult, tc.expectedVerificationResult, verificationResultCmp); d != "" { + t.Errorf("VerificationResult did not match:%s", diff.PrintWantGot(d)) + } + }) + } +} + +func TestGetPipelineFunc_V1Pipeline_VerifyError(t *testing.T) { + ctx := context.Background() + tektonclient := fake.NewSimpleClientset() + signer, _, k8sclient, vps := test.SetupVerificationPolicies(t) + + unsignedPipelineBytes, err := json.Marshal(unsignedV1Pipeline) + if err != nil { + t.Fatal("fail to marshal pipeline", err) + } + matchPolicyRefSource := &v1beta1.RefSource{ + URI: "https://github.com/tektoncd/catalog.git", + Digest: map[string]string{ + "sha1": "a123", + }, + EntryPoint: "foo/bar", + } + + resolvedUnsigned := test.NewResolvedResource(unsignedPipelineBytes, nil, matchPolicyRefSource, nil) + requesterUnsigned := test.NewRequester(resolvedUnsigned, nil) + + signedPipeline, err := getSignedV1Pipeline(unsignedV1Pipeline, signer, "signed") + if err != nil { + t.Fatal("fail to sign pipeline", err) + } + signedPipelineBytes, err := json.Marshal(signedPipeline) + if err != nil { + t.Fatal("fail to marshal pipeline", err) + } + + noMatchPolicyRefSource := &v1beta1.RefSource{ + URI: "abc.com", + Digest: map[string]string{ + "sha1": "a123", + }, + EntryPoint: "foo/bar", + } + resolvedUnmatched := test.NewResolvedResource(signedPipelineBytes, nil, noMatchPolicyRefSource, nil) + requesterUnmatched := test.NewRequester(resolvedUnmatched, nil) + + modifiedPipeline := signedPipeline.DeepCopy() + modifiedPipeline.Annotations["random"] = "attack" + modifiedPipelineBytes, err := json.Marshal(modifiedPipeline) + if err != nil { + t.Fatal("fail to marshal pipeline", err) + } + resolvedModified := test.NewResolvedResource(modifiedPipelineBytes, nil, matchPolicyRefSource, nil) + requesterModified := test.NewRequester(resolvedModified, nil) + + pipelineRef := &v1beta1.PipelineRef{ResolverRef: v1beta1.ResolverRef{Resolver: "git"}} + + testcases := []struct { + name string + requester *test.Requester + verificationNoMatchPolicy string + expectedVerificationResult *trustedresources.VerificationResult + }{ + { + name: "unsigned pipeline fails verification with fail no match policy", + requester: requesterUnsigned, + verificationNoMatchPolicy: config.FailNoMatchPolicy, + expectedVerificationResult: &trustedresources.VerificationResult{VerificationResultType: trustedresources.VerificationError, Err: trustedresources.ErrResourceVerificationFailed}, + }, { + name: "unsigned pipeline fails verification with warn no match policy", + requester: requesterUnsigned, + verificationNoMatchPolicy: config.WarnNoMatchPolicy, + expectedVerificationResult: &trustedresources.VerificationResult{VerificationResultType: trustedresources.VerificationError, Err: trustedresources.ErrResourceVerificationFailed}, + }, { + name: "unsigned pipeline fails verification with ignore no match policy", + requester: requesterUnsigned, + verificationNoMatchPolicy: config.IgnoreNoMatchPolicy, + expectedVerificationResult: &trustedresources.VerificationResult{VerificationResultType: trustedresources.VerificationError, Err: trustedresources.ErrResourceVerificationFailed}, + }, { + name: "modified pipeline fails verification with fail no match policy", + requester: requesterModified, + verificationNoMatchPolicy: config.FailNoMatchPolicy, + expectedVerificationResult: &trustedresources.VerificationResult{VerificationResultType: trustedresources.VerificationError, Err: trustedresources.ErrResourceVerificationFailed}, + }, { + name: "modified pipeline fails verification with warn no match policy", + requester: requesterModified, + verificationNoMatchPolicy: config.WarnNoMatchPolicy, + expectedVerificationResult: &trustedresources.VerificationResult{VerificationResultType: trustedresources.VerificationError, Err: trustedresources.ErrResourceVerificationFailed}, + }, { + name: "modified pipeline fails verification with ignore no match policy", + requester: requesterModified, + verificationNoMatchPolicy: config.IgnoreNoMatchPolicy, + expectedVerificationResult: &trustedresources.VerificationResult{VerificationResultType: trustedresources.VerificationError, Err: trustedresources.ErrResourceVerificationFailed}, + }, { + name: "unmatched pipeline fails with fail no match policy", + requester: requesterUnmatched, + verificationNoMatchPolicy: config.FailNoMatchPolicy, + expectedVerificationResult: &trustedresources.VerificationResult{VerificationResultType: trustedresources.VerificationError, Err: trustedresources.ErrNoMatchedPolicies}, + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + ctx = test.SetupTrustedResourceConfig(ctx, tc.verificationNoMatchPolicy) + pr := &v1beta1.PipelineRun{ + ObjectMeta: metav1.ObjectMeta{Namespace: "trusted-resources"}, + Spec: v1beta1.PipelineRunSpec{ + PipelineRef: pipelineRef, + ServiceAccountName: "default", + }, + } + fn := resources.GetPipelineFunc(ctx, k8sclient, tektonclient, tc.requester, pr, vps) + + _, _, gotVerificationResult, err := fn(ctx, pipelineRef.Name) + if err != nil { + t.Errorf("want err nil but got %v", err) + } + if d := cmp.Diff(gotVerificationResult, tc.expectedVerificationResult, verificationResultCmp); d != "" { + t.Errorf("VerificationResult did not match:%s", diff.PrintWantGot(d)) + } + }) + } +} + func TestGetPipelineFunc_GetFuncError(t *testing.T) { ctx := context.Background() tektonclient := fake.NewSimpleClientset() @@ -954,3 +1300,36 @@ spec: - name: name value: test-task ` + +func getSignedV1Pipeline(unsigned *pipelinev1.Pipeline, signer signature.Signer, name string) (*pipelinev1.Pipeline, error) { + signed := unsigned.DeepCopy() + signed.Name = name + if signed.Annotations == nil { + signed.Annotations = map[string]string{} + } + signature, err := signInterface(signer, signed) + if err != nil { + return nil, err + } + signed.Annotations[trustedresources.SignatureAnnotation] = base64.StdEncoding.EncodeToString(signature) + return signed, nil +} + +func signInterface(signer signature.Signer, i interface{}) ([]byte, error) { + if signer == nil { + return nil, fmt.Errorf("signer is nil") + } + b, err := json.Marshal(i) + if err != nil { + return nil, err + } + h := sha256.New() + h.Write(b) + + sig, err := signer.SignMessage(bytes.NewReader(h.Sum(nil))) + if err != nil { + return nil, err + } + + return sig, nil +}