diff --git a/cmd/validate/image.go b/cmd/validate/image.go index 336f1080d..961794e0b 100644 --- a/cmd/validate/image.go +++ b/cmd/validate/image.go @@ -211,6 +211,7 @@ func validateImageCmd(validate imageValidationFunc) *cobra.Command { }, IgnoreRekor: data.ignoreRekor, SkipImageSigCheck: data.skipImageSigCheck, + SkipAttSigCheck: data.skipAttSigCheck, PolicyRef: data.policyConfiguration, PublicKey: data.publicKey, RekorURL: data.rekorURL, @@ -498,6 +499,9 @@ func validateImageCmd(validate imageValidationFunc) *cobra.Command { cmd.Flags().BoolVar(&data.skipImageSigCheck, "skip-image-sig-check", data.skipImageSigCheck, "Skip image signature validation checks.") + cmd.Flags().BoolVar(&data.skipAttSigCheck, "skip-att-sig-check", data.skipAttSigCheck, + "Skip attestation signature validation checks.") + cmd.Flags().StringVar(&data.certificateIdentity, "certificate-identity", data.certificateIdentity, "URL of the certificate identity for keyless verification") @@ -636,6 +640,7 @@ type imageData struct { input string ignoreRekor bool skipImageSigCheck bool + skipAttSigCheck bool output []string outputFile string policy policy.Policy diff --git a/docs/modules/ROOT/pages/ec_validate_image.adoc b/docs/modules/ROOT/pages/ec_validate_image.adoc index d2d67263f..2eadb4315 100644 --- a/docs/modules/ROOT/pages/ec_validate_image.adoc +++ b/docs/modules/ROOT/pages/ec_validate_image.adoc @@ -151,6 +151,7 @@ mark (?) sign, for example: --output text=output.txt?show-successes=false * inline JSON ('{sources: {...}, identity: {...}}')") -k, --public-key:: path to the public key. Overrides publicKey from EnterpriseContractPolicy -r, --rekor-url:: Rekor URL. Overrides rekorURL from EnterpriseContractPolicy +--skip-att-sig-check:: Skip attestation signature validation checks. (Default: false) --skip-image-sig-check:: Skip image signature validation checks. (Default: false) --snapshot:: Provide the AppStudio Snapshot as a source of the images to validate, as inline JSON of the "spec" or a reference to a Kubernetes object [/] diff --git a/features/__snapshots__/validate_image.snap b/features/__snapshots__/validate_image.snap index 8aca1ad7d..6e4c935cc 100644 --- a/features/__snapshots__/validate_image.snap +++ b/features/__snapshots__/validate_image.snap @@ -5780,3 +5780,74 @@ Error: success criteria not met [TestFeatures/discover artifact referrers via OCI Referrers API:stderr - 1] --- + +[TestFeatures/happy day with skip-att-sig-check flag:stdout - 1] +{ + "success": true, + "components": [ + { + "name": "Unnamed", + "containerImage": "${REGISTRY}/acceptance/ec-happy-day@sha256:${REGISTRY_acceptance/ec-happy-day:latest_DIGEST}", + "source": {}, + "successes": [ + { + "msg": "Pass", + "metadata": { + "code": "builtin.attestation.syntax_check" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "builtin.image.signature_check" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + } + ], + "success": true, + "signatures": [ + { + "keyid": "", + "sig": "${IMAGE_SIGNATURE_acceptance/ec-happy-day}" + } + ], + "attestations": [ + { + "type": "https://in-toto.io/Statement/v0.1", + "predicateType": "https://slsa.dev/provenance/v0.2", + "predicateBuildType": "https://tekton.dev/attestations/chains/pipelinerun@v2", + "signatures": [ + { + "keyid": "", + "sig": "${ATTESTATION_SIGNATURE_acceptance/ec-happy-day}" + } + ] + } + ] + } + ], + "key": "${known_PUBLIC_KEY_JSON}", + "policy": { + "sources": [ + { + "policy": [ + "git::${GITHOST}/git/happy-day-policy.git?ref=${LATEST_COMMIT}" + ] + } + ], + "rekorUrl": "${REKOR}", + "publicKey": "${known_PUBLIC_KEY}" + }, + "ec-version": "${EC_VERSION}", + "effective-time": "${TIMESTAMP}" +} +--- + +[TestFeatures/happy day with skip-att-sig-check flag:stderr - 1] + +--- diff --git a/features/validate_image.feature b/features/validate_image.feature index 373fcb5c1..7b89f9de7 100644 --- a/features/validate_image.feature +++ b/features/validate_image.feature @@ -56,6 +56,31 @@ Feature: evaluate enterprise contract # builtin.attestation.image_check is not found in the success output Then the output should match the snapshot + Scenario: happy day with skip-att-sig-check flag + Given a key pair named "known" + Given an image named "acceptance/ec-happy-day" + Given a valid image signature of "acceptance/ec-happy-day" image signed by the "known" key + Given a valid attestation of "acceptance/ec-happy-day" signed by the "known" key + Given a git repository named "happy-day-policy" with + | main.rego | examples/happy_day.rego | + Given policy configuration named "ec-policy" with specification + """ + { + "sources": [ + { + "policy": [ + "git::https://${GITHOST}/git/happy-day-policy.git" + ] + } + ] + } + """ + When ec command is run with "validate image --image ${REGISTRY}/acceptance/ec-happy-day --policy acceptance/ec-policy --public-key ${known_PUBLIC_KEY} --skip-att-sig-check --rekor-url ${REKOR} --show-successes --output json" + Then the exit status should be 0 + # The only difference to the happy day scenario is that + # builtin.attestation.signature_check is not found in the success output + Then the output should match the snapshot + Scenario: happy day with git config and yaml Given a key pair named "known" Given an image named "acceptance/ec-happy-day" diff --git a/internal/evaluation_target/application_snapshot_image/application_snapshot_image.go b/internal/evaluation_target/application_snapshot_image/application_snapshot_image.go index eaecddc05..739044106 100644 --- a/internal/evaluation_target/application_snapshot_image/application_snapshot_image.go +++ b/internal/evaluation_target/application_snapshot_image/application_snapshot_image.go @@ -32,6 +32,7 @@ import ( "github.com/santhosh-tekuri/jsonschema/v5" "github.com/sigstore/cosign/v3/pkg/cosign" cosignOCI "github.com/sigstore/cosign/v3/pkg/oci" + ociremote "github.com/sigstore/cosign/v3/pkg/oci/remote" log "github.com/sirupsen/logrus" "github.com/spf13/afero" @@ -201,9 +202,49 @@ func (a *ApplicationSnapshotImage) ValidateAttestationSignature(ctx context.Cont return a.parseAttestationsFromBundles(layers) } - // Extract the signatures from the attestations here in order to also validate that - // the signatures do exist in the expected format. - for _, sig := range layers { + return a.parseAttestationsFromSignatures(layers) +} + +// FetchAttestationsWithoutVerification fetches attestations from the registry +// without performing signature verification. This is used when --skip-att-sig-check +// is enabled but we still need the attestation data for policy evaluation. +func (a *ApplicationSnapshotImage) FetchAttestationsWithoutVerification(ctx context.Context) error { + if trace.IsEnabled() { + region := trace.StartRegion(ctx, "ec:fetch-attestations-without-verification") + defer region.End() + } + + remoteOpts := oci.CreateRemoteOptions(ctx) + signedEntity, err := ociremote.SignedEntity(a.reference, ociremote.WithRemoteOptions(remoteOpts...)) + if err != nil { + return fmt.Errorf("failed to fetch signed entity: %w", err) + } + + layers, err := signedEntity.Attestations() + if err != nil { + return fmt.Errorf("failed to fetch attestations: %w", err) + } + + // Check if using bundles + useBundles := a.hasBundles(ctx) + if useBundles { + sigs, err := layers.Get() + if err != nil { + return fmt.Errorf("failed to get attestation signatures: %w", err) + } + return a.parseAttestationsFromBundles(sigs) + } + + sigs, err := layers.Get() + if err != nil { + return fmt.Errorf("failed to get attestation signatures: %w", err) + } + + return a.parseAttestationsFromSignatures(sigs) +} + +func (a *ApplicationSnapshotImage) parseAttestationsFromSignatures(sigs []cosignOCI.Signature) error { + for _, sig := range sigs { att, err := attestation.ProvenanceFromSignature(sig) if err != nil { return fmt.Errorf("unable to parse untyped provenance: %w", err) @@ -212,10 +253,8 @@ func (a *ApplicationSnapshotImage) ValidateAttestationSignature(ctx context.Cont log.Debugf("Found attestation with predicateType: %s", t) switch t { case attestation.PredicateSLSAProvenance: - // SLSAProvenanceFromSignature does the payload extraction - // and decoding that was done in ProvenanceFromSignature - // over again. We could refactor so we're not doing that twice, - // but it's not super important IMO. + // SLSAProvenanceFromSignature re-does the payload extraction from + // ProvenanceFromSignature. Could be deduplicated but not important. sp, err := attestation.SLSAProvenanceFromSignature(sig) if err != nil { return fmt.Errorf("unable to parse as SLSA v0.2: %w", err) @@ -223,7 +262,6 @@ func (a *ApplicationSnapshotImage) ValidateAttestationSignature(ctx context.Cont a.attestations = append(a.attestations, sp) case attestation.PredicateSLSAProvenanceV1: - // SLSA Provenance v1.0 sp, err := attestation.SLSAProvenanceFromSignatureV1(sig) if err != nil { return fmt.Errorf("unable to parse as SLSA v1: %w", err) @@ -231,15 +269,11 @@ func (a *ApplicationSnapshotImage) ValidateAttestationSignature(ctx context.Cont a.attestations = append(a.attestations, sp) case attestation.PredicateSpdxDocument: - // It's an SPDX format SBOM - // Todo maybe: We could unmarshal it into a suitable SPDX struct - // similar to how it's done for SLSA above a.attestations = append(a.attestations, att) // Todo: CycloneDX format SBOM default: - // It's some other kind of attestation a.attestations = append(a.attestations, att) } } diff --git a/internal/evaluation_target/application_snapshot_image/application_snapshot_image_test.go b/internal/evaluation_target/application_snapshot_image/application_snapshot_image_test.go index e010ef9cb..02510dcc7 100644 --- a/internal/evaluation_target/application_snapshot_image/application_snapshot_image_test.go +++ b/internal/evaluation_target/application_snapshot_image/application_snapshot_image_test.go @@ -1081,6 +1081,156 @@ func createBundleDSSESignature(t *testing.T, statement any) oci.Signature { } // createDSSESignature creates a test signature with a DSSE envelope containing the given statement +func TestParseAttestationsFromSignatures(t *testing.T) { + ref := name.MustParseReference("registry.io/repository/image:tag") + + //nolint:staticcheck + slsaV02Statement := in_toto.ProvenanceStatementSLSA02{ + StatementHeader: in_toto.StatementHeader{ + Type: in_toto.StatementInTotoV01, + PredicateType: v02.PredicateSLSAProvenance, + Subject: []in_toto.Subject{ + { + Name: "test-image", + Digest: common.DigestSet{"sha256": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"}, + }, + }, + }, + Predicate: v02.ProvenancePredicate{ + BuildType: pipelineRunBuildType, + Builder: common.ProvenanceBuilder{ID: "https://tekton.dev/chains/v2"}, + }, + } + + //nolint:staticcheck + slsaV1Statement := in_toto.ProvenanceStatementSLSA1{ + StatementHeader: in_toto.StatementHeader{ + Type: in_toto.StatementInTotoV01, + PredicateType: "https://slsa.dev/provenance/v1", + Subject: []in_toto.Subject{ + { + Name: "test-image", + Digest: common.DigestSet{"sha256": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"}, + }, + }, + }, + Predicate: slsav1.ProvenancePredicate{ + BuildDefinition: slsav1.ProvenanceBuildDefinition{ + BuildType: "https://tekton.dev/attestations/chains/pipelinerun@v2", + ExternalParameters: json.RawMessage(`{}`), + }, + RunDetails: slsav1.ProvenanceRunDetails{ + Builder: slsav1.Builder{ID: "https://tekton.dev/chains/v2"}, + }, + }, + } + + //nolint:staticcheck + spdxStatement := in_toto.Statement{ + StatementHeader: in_toto.StatementHeader{ + Type: in_toto.StatementInTotoV01, + PredicateType: "https://spdx.dev/Document", + Subject: []in_toto.Subject{ + { + Name: "test-image", + Digest: common.DigestSet{"sha256": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"}, + }, + }, + }, + Predicate: json.RawMessage(`{"spdxVersion":"SPDX-2.3"}`), + } + + //nolint:staticcheck + unknownStatement := in_toto.Statement{ + StatementHeader: in_toto.StatementHeader{ + Type: in_toto.StatementInTotoV01, + PredicateType: "https://example.com/unknown/v1", + Subject: []in_toto.Subject{ + { + Name: "test-image", + Digest: common.DigestSet{"sha256": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"}, + }, + }, + }, + Predicate: json.RawMessage(`{"custom":"data"}`), + } + + cases := []struct { + name string + signatures []oci.Signature + expectErr bool + expectedAttCount int + expectedPredTypes []string + }{ + { + name: "no signatures", + signatures: []oci.Signature{}, + expectedAttCount: 0, + }, + { + name: "SLSA v0.2", + signatures: []oci.Signature{createDSSESignature(t, slsaV02Statement)}, + expectedAttCount: 1, + expectedPredTypes: []string{v02.PredicateSLSAProvenance}, + }, + { + name: "SLSA v1.0", + signatures: []oci.Signature{createDSSESignature(t, slsaV1Statement)}, + expectedAttCount: 1, + expectedPredTypes: []string{"https://slsa.dev/provenance/v1"}, + }, + { + name: "SPDX document", + signatures: []oci.Signature{createDSSESignature(t, spdxStatement)}, + expectedAttCount: 1, + expectedPredTypes: []string{"https://spdx.dev/Document"}, + }, + { + name: "unknown predicate type", + signatures: []oci.Signature{createDSSESignature(t, unknownStatement)}, + expectedAttCount: 1, + expectedPredTypes: []string{"https://example.com/unknown/v1"}, + }, + { + name: "multiple mixed types", + signatures: []oci.Signature{ + createDSSESignature(t, slsaV02Statement), + createDSSESignature(t, slsaV1Statement), + createDSSESignature(t, spdxStatement), + createDSSESignature(t, unknownStatement), + }, + expectedAttCount: 4, + expectedPredTypes: []string{ + v02.PredicateSLSAProvenance, + "https://slsa.dev/provenance/v1", + "https://spdx.dev/Document", + "https://example.com/unknown/v1", + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + a := ApplicationSnapshotImage{reference: ref} + + err := a.parseAttestationsFromSignatures(tc.signatures) + + if tc.expectErr { + require.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tc.expectedAttCount, len(a.attestations)) + + if tc.expectedPredTypes != nil { + for i, expectedType := range tc.expectedPredTypes { + assert.Equal(t, expectedType, a.attestations[i].PredicateType()) + } + } + } + }) + } +} + func createDSSESignature(t *testing.T, statement any) oci.Signature { t.Helper() diff --git a/internal/image/validate.go b/internal/image/validate.go index cf80840fa..beb4a182c 100644 --- a/internal/image/validate.go +++ b/internal/image/validate.go @@ -81,9 +81,19 @@ func ValidateImage(ctx context.Context, comp app.SnapshotComponent, snap *app.Sn out.SetImageSignatureCheckFromError(a.ValidateImageSignature(ctx)) } - out.SetAttestationSignatureCheckFromError(a.ValidateAttestationSignature(ctx)) - if !out.AttestationSignatureCheck.Passed { - return out, nil + // Handle attestation signature validation + if p.SkipAttSigCheck() { + log.Debug("Attestation signature check skipped, fetching attestations without verification") + if err := a.FetchAttestationsWithoutVerification(ctx); err != nil { + log.Warnf("Failed to fetch attestations without verification: %v", err) + out.SetAttestationSignatureCheckFromError(fmt.Errorf("failed to fetch attestations (signature check skipped): %w", err)) + return out, nil + } + } else { + out.SetAttestationSignatureCheckFromError(a.ValidateAttestationSignature(ctx)) + if !out.AttestationSignatureCheck.Passed { + return out, nil + } } out.Signatures = a.Signatures() diff --git a/internal/image/validate_test.go b/internal/image/validate_test.go index 1c49077cf..5941bdd0a 100644 --- a/internal/image/validate_test.go +++ b/internal/image/validate_test.go @@ -708,3 +708,122 @@ func TestValidateImageSkipImageSigCheck(t *testing.T) { }) } } + +func TestValidateImageSkipAttSigCheck(t *testing.T) { + tests := []struct { + name string + skipAttSigCheck bool + expectAttSigResult bool + expectImageSigCheckCall bool + }{ + { + name: "skip attestation signature check disabled (default)", + skipAttSigCheck: false, + expectAttSigResult: true, + expectImageSigCheckCall: true, + }, + { + name: "skip attestation signature check enabled", + skipAttSigCheck: true, + expectAttSigResult: false, + expectImageSigCheckCall: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := afero.NewMemMapFs() + ctx := utils.WithFS(context.Background(), fs) + + comp := app.SnapshotComponent{ + ContainerImage: imageRef, + } + + ctx = withImageConfig(ctx, comp.ContainerImage) + client := ecoci.NewClient(ctx) + fakeClient := client.(*fake.FakeClient) + + fakeClient.On("Head", ref).Return(&v1.Descriptor{MediaType: types.OCIManifestSchema1}, nil) + fakeClient.On("HasBundles", mock.Anything, refNoTag).Return(false, nil) + fakeClient.On("VerifyImageSignatures", refNoTag, mock.Anything).Return([]oci.Signature{}, false, fmt.Errorf("no signatures found")) + fakeClient.On("VerifyImageAttestations", refNoTag, mock.Anything).Return([]oci.Signature{}, false, fmt.Errorf("no attestations found")) + + opts := policy.Options{ + EffectiveTime: policy.Now, + SkipAttSigCheck: tt.skipAttSigCheck, + Identity: cosign.Identity{ + Issuer: "https://example.com/oidc", + Subject: "test@example.com", + }, + } + + updatedPolicy, _, err := policy.PreProcessPolicy(ctx, opts) + require.NoError(t, err) + + snap := &app.SnapshotSpec{} + evaluators := []evaluator.Evaluator{} + + output, err := ValidateImage(ctx, comp, snap, updatedPolicy, evaluators, false) + + require.NoError(t, err) + require.NotNil(t, output) + + if tt.expectAttSigResult { + assert.NotNil(t, output.AttestationSignatureCheck.Result, "Expected AttestationSignatureCheck to have a result") + } else { + assert.Nil(t, output.AttestationSignatureCheck.Result, "Expected AttestationSignatureCheck to not have a result when skipped") + } + + // ImageSignatureCheck should always have a result (not affected by this flag) + assert.NotNil(t, output.ImageSignatureCheck.Result, "ImageSignatureCheck should always have a result") + + violations := output.Violations() + successes := output.Successes() + + if tt.skipAttSigCheck { + for _, violation := range violations { + if violation.Metadata != nil { + code, ok := violation.Metadata["code"].(string) + if ok { + assert.NotEqual(t, "builtin.attestation.signature_check", code, + "Skipped attestation signature check should not appear in violations") + } + } + } + + for _, success := range successes { + if success.Metadata != nil { + code, ok := success.Metadata["code"].(string) + if ok { + assert.NotEqual(t, "builtin.attestation.signature_check", code, + "Skipped attestation signature check should not appear in successes") + } + } + } + } + + if !tt.skipAttSigCheck { + foundAttestationCheck := false + for _, violation := range violations { + if violation.Metadata != nil { + code, ok := violation.Metadata["code"].(string) + if ok && code == "builtin.attestation.signature_check" { + foundAttestationCheck = true + break + } + } + } + for _, success := range successes { + if success.Metadata != nil { + code, ok := success.Metadata["code"].(string) + if ok && code == "builtin.attestation.signature_check" { + foundAttestationCheck = true + break + } + } + } + assert.True(t, foundAttestationCheck, "AttestationSignatureCheck should appear in results when not skipped") + } + }) + } +} diff --git a/internal/policy/policy.go b/internal/policy/policy.go index b8122bf6c..b76fbd260 100644 --- a/internal/policy/policy.go +++ b/internal/policy/policy.go @@ -76,6 +76,7 @@ type Policy interface { Spec() ecc.EnterpriseContractPolicySpec EffectiveTime() time.Time SkipImageSigCheck() bool + SkipAttSigCheck() bool AttestationTime(time.Time) Identity() cosign.Identity Keyless() bool @@ -91,6 +92,7 @@ type policy struct { identity cosign.Identity ignoreRekor bool skipImageSigCheck bool + skipAttSigCheck bool } // PublicKeyPEM returns the PublicKey in PEM format. When SigVerifier is not @@ -169,6 +171,7 @@ type Options struct { Identity cosign.Identity IgnoreRekor bool SkipImageSigCheck bool + SkipAttSigCheck bool PolicyRef string PublicKey string RekorURL string @@ -266,6 +269,7 @@ func NewPolicy(ctx context.Context, opts Options) (Policy, error) { p.ignoreRekor = opts.IgnoreRekor p.skipImageSigCheck = opts.SkipImageSigCheck + p.skipAttSigCheck = opts.SkipAttSigCheck if opts.PublicKey != "" && opts.PublicKey != p.PublicKey { p.PublicKey = opts.PublicKey @@ -409,6 +413,10 @@ func (p policy) SkipImageSigCheck() bool { return p.skipImageSigCheck } +func (p policy) SkipAttSigCheck() bool { + return p.skipAttSigCheck +} + func isNow(choosenTime string) bool { return strings.EqualFold(choosenTime, Now) } diff --git a/internal/policy/policy_test.go b/internal/policy/policy_test.go index d1a8c3a76..c528ffb31 100644 --- a/internal/policy/policy_test.go +++ b/internal/policy/policy_test.go @@ -1615,6 +1615,41 @@ type FakeCosignClient struct { publicKey string } +func TestSkipAttSigCheck(t *testing.T) { + ctx := context.Background() + ctx = withSignatureClient(ctx, &FakeCosignClient{publicKey: utils.TestPublicKey}) + utils.SetTestRekorPublicKey(t) + utils.SetTestFulcioRoots(t) + utils.SetTestCTLogPublicKey(t) + + t.Run("disabled by default", func(t *testing.T) { + p, err := NewPolicy(ctx, Options{ + PolicyRef: toJson(&ecc.EnterpriseContractPolicySpec{PublicKey: utils.TestPublicKey}), + EffectiveTime: Now, + Identity: cosign.Identity{ + Issuer: "https://example.com/oidc", + Subject: "test@example.com", + }, + }) + require.NoError(t, err) + assert.False(t, p.SkipAttSigCheck()) + }) + + t.Run("enabled when set", func(t *testing.T) { + p, err := NewPolicy(ctx, Options{ + PolicyRef: toJson(&ecc.EnterpriseContractPolicySpec{PublicKey: utils.TestPublicKey}), + EffectiveTime: Now, + SkipAttSigCheck: true, + Identity: cosign.Identity{ + Issuer: "https://example.com/oidc", + Subject: "test@example.com", + }, + }) + require.NoError(t, err) + assert.True(t, p.SkipAttSigCheck()) + }) +} + func (c *FakeCosignClient) publicKeyFromKeyRef(ctx context.Context, publicKey string) (sigstoreSig.Verifier, error) { if strings.Contains(publicKey, "invalid:") { return nil, fmt.Errorf("invalid public key reference format")