diff --git a/acceptance/examples/sigstore_attestation_only.rego b/acceptance/examples/sigstore_attestation_only.rego new file mode 100644 index 000000000..ac096a50b --- /dev/null +++ b/acceptance/examples/sigstore_attestation_only.rego @@ -0,0 +1,74 @@ +package sigstore_attestation_only + +import rego.v1 + +# METADATA +# custom: +# short_name: valid +deny contains result if { + some error in _errors + result := {"code": "sigstore_attestation_only.valid", "msg": error} +} + +_errors contains error if { + not _image_ref + error := "input.image.ref not set" +} + +_errors contains error if { + not _sigstore_opts + error := "default sigstore options not set" +} + +_errors contains error if { + info := ec.sigstore.verify_attestation(_image_ref, _sigstore_opts) + some raw_error in info.errors + error := sprintf("image attestation verification failed: %s", [raw_error]) +} + +_errors contains error if { + info := ec.sigstore.verify_attestation(_image_ref, _sigstore_opts) + count(info.attestations) == 0 + error := "verification successful, but no attestations found" +} + +_errors contains error if { + info := ec.sigstore.verify_attestation(_image_ref, _sigstore_opts) + some att in info.attestations + count(att.signatures) == 0 + error := sprintf("attestation has no signatures: %s", [att]) +} + +_errors contains error if { + info := ec.sigstore.verify_attestation(_image_ref, _sigstore_opts) + some att in info.attestations + + not _is_supported_slsa_predicate(att.statement.predicateType) + error := sprintf("unexpected statement predicate: %s", [att.statement.predicateType]) +} + +_errors contains error if { + info := ec.sigstore.verify_attestation(_image_ref, _sigstore_opts) + some att in info.attestations + builder_id := _builder_id(att) + builder_id != "https://tekton.dev/chains/v2" + error := sprintf("unexpected builder ID: %s", [builder_id]) +} + +_image_ref := input.image.ref + +_sigstore_opts := data.config.default_sigstore_opts + +_builder_id(att) := value if { + value := att.statement.predicate.builder.id +} else := value if { + value := att.statement.predicate.runDetails.builder.id +} else := "MISSING" + +_is_supported_slsa_predicate(predicate_type) if { + predicate_type == "https://slsa.dev/provenance/v0.2" +} + +_is_supported_slsa_predicate(predicate_type) if { + predicate_type == "https://slsa.dev/provenance/v1" +} diff --git a/acceptance/image/image.go b/acceptance/image/image.go index a6c22125d..f13e1cf1e 100644 --- a/acceptance/image/image.go +++ b/acceptance/image/image.go @@ -54,6 +54,7 @@ import ( "github.com/sigstore/cosign/v3/pkg/oci/static" cosigntypes "github.com/sigstore/cosign/v3/pkg/types" rc "github.com/sigstore/rekor/pkg/client" + "github.com/sigstore/rekor/pkg/generated/models" "github.com/sigstore/sigstore/pkg/cryptoutils" "github.com/sigstore/sigstore/pkg/signature" "gopkg.in/go-jose/go-jose.v2/json" @@ -208,7 +209,7 @@ func createSignatureData(ctx context.Context, imageName string, digestImage name } // Upload to transparency log to get bundle information like Tekton Chains does - rekorBundle, err := uploadToTransparencyLog(ctx, payload, rawSignature, signer) + rekorBundle, _, err := uploadToTransparencyLog(ctx, payload, rawSignature, signer) if err != nil { return nil, err } @@ -300,42 +301,43 @@ func CreateAndPushImageSignature(ctx context.Context, imageName string, keyName } // uploadToTransparencyLog uploads a signature to the transparency log and returns the bundle -func uploadToTransparencyLog(ctx context.Context, payload []byte, rawSignature []byte, signer signature.SignerVerifier) (*bundle.RekorBundle, error) { +// along with the raw tlog entry (needed for creating protobuf bundles). +func uploadToTransparencyLog(ctx context.Context, payload []byte, rawSignature []byte, signer signature.SignerVerifier) (*bundle.RekorBundle, *models.LogEntryAnon, error) { // Get public key or cert for transparency log upload pkoc, err := getPublicKeyOrCert(signer) if err != nil { - return nil, fmt.Errorf("failed to get public key or cert: %w", err) + return nil, nil, fmt.Errorf("failed to get public key or cert: %w", err) } // Get Rekor URL rekorURL, err := rekor.StubRekor(ctx) if err != nil { - return nil, fmt.Errorf("failed to get stub rekor URL: %w", err) + return nil, nil, fmt.Errorf("failed to get stub rekor URL: %w", err) } rekorClient, err := rc.GetRekorClient(rekorURL) if err != nil { - return nil, fmt.Errorf("failed to get rekor client: %w", err) + return nil, nil, fmt.Errorf("failed to get rekor client: %w", err) } // Compute payload checksum checksum := sha256.New() if _, err := checksum.Write(payload); err != nil { - return nil, fmt.Errorf("error checksuming payload: %w", err) + return nil, nil, fmt.Errorf("error checksuming payload: %w", err) } tlogEntry, err := cosign.TLogUpload(ctx, rekorClient, rawSignature, checksum, pkoc) if err != nil { - return nil, fmt.Errorf("failed to upload to transparency log: %w", err) + return nil, nil, fmt.Errorf("failed to upload to transparency log: %w", err) } // Create bundle from the actual transparency log entry rekorBundle := bundle.EntryToBundle(tlogEntry) if rekorBundle == nil { - return nil, fmt.Errorf("rekorBundle is nil after EntryToBundle") + return nil, nil, fmt.Errorf("rekorBundle is nil after EntryToBundle") } - return rekorBundle, nil + return rekorBundle, tlogEntry, nil } // getImageDigestAndRef returns the image, its digest, and digest reference for signing @@ -533,7 +535,7 @@ func createAndPushAttestationInternal(ctx context.Context, imageName, keyName st } // Upload to transparency log to get bundle information like Tekton Chains does - rekorBundle, err := uploadToTransparencyLog(ctx, signedAttestation, rawSignature, signer) + rekorBundle, _, err := uploadToTransparencyLog(ctx, signedAttestation, rawSignature, signer) if err != nil { return ctx, err } @@ -670,8 +672,8 @@ func CreateAndPushAttestationReferrer(ctx context.Context, imageName, keyName st return ctx, fmt.Errorf("error stubbing rekor endpoints for attestation: %w", err) } - // Upload to transparency log to get bundle information - rekorBundle, err := uploadToTransparencyLog(ctx, signedAttestation, rawSignature, signer) + // Upload to transparency log to get bundle information like Tekton Chains does + rekorBundle, _, err := uploadToTransparencyLog(ctx, signedAttestation, rawSignature, signer) if err != nil { return ctx, err } @@ -717,6 +719,111 @@ func CreateAndPushAttestationReferrer(ctx context.Context, imageName, keyName st return ctx, nil } +// CreateAndPushBundleAttestationReferrer creates a protobuf Sigstore bundle attestation +// and pushes it as an OCI referrer with the bundle media type that cosign recognizes. +func CreateAndPushBundleAttestationReferrer(ctx context.Context, imageName, keyName string) (context.Context, error) { + var state *imageState + ctx, err := testenv.SetupState(ctx, &state) + if err != nil { + return ctx, err + } + + if state.ReferrerAttestations[imageName] != "" { + return ctx, nil + } + + image, digest, _, err := getImageDigestAndRef(ctx, imageName) + if err != nil { + return ctx, err + } + + statement, err := attestation.CreateStatementFor(imageName, image) + if err != nil { + return ctx, err + } + + signedAttestation, err := attestation.SignStatement(ctx, keyName, statement) + if err != nil { + return ctx, err + } + + var sig *cosign.Signatures + sig, err = unmarshallSignatures(signedAttestation) + if err != nil { + return ctx, err + } + if sig == nil { + return ctx, fmt.Errorf("failed to extract signature from attestation: no signatures found") + } + + state.ReferrerAttestationSignatures[imageName] = Signature{ + KeyID: sig.KeyID, + Signature: sig.Sig, + } + + var rawSignature []byte + if sig.Sig != "" { + rawSignature, err = base64.StdEncoding.DecodeString(sig.Sig) + if err != nil { + return ctx, fmt.Errorf("failed to decode signature: %w", err) + } + } + + signer, err := crypto.SignerWithKey(ctx, keyName) + if err != nil { + return ctx, err + } + + publicKey, err := signer.PublicKey() + if err != nil { + return ctx, fmt.Errorf("failed to get public key: %w", err) + } + + publicKeyBytes, err := cryptoutils.MarshalPublicKeyToPEM(publicKey) + if err != nil { + return ctx, fmt.Errorf("failed to marshal public key: %w", err) + } + + err = rekor.StubRekorEntryCreationForAttestation(ctx, signedAttestation, publicKeyBytes) + if err != nil { + return ctx, fmt.Errorf("error stubbing rekor endpoints for attestation: %w", err) + } + + _, tlogEntry, err := uploadToTransparencyLog(ctx, signedAttestation, rawSignature, signer) + if err != nil { + return ctx, err + } + + statementPayload, err := json.Marshal(statement) + if err != nil { + return ctx, fmt.Errorf("failed to marshal statement: %w", err) + } + + bundleBytes, err := bundle.MakeNewBundle(publicKey, tlogEntry, statementPayload, signedAttestation, publicKeyBytes, nil) + if err != nil { + return ctx, fmt.Errorf("failed to create protobuf bundle: %w", err) + } + + digestRef, err := getDigestRefForImage(ctx, imageName, digest) + if err != nil { + return ctx, err + } + + err = cosignRemote.WriteAttestationNewBundleFormat( + digestRef, + bundleBytes, + statement.PredicateType, + cosignRemote.WithRemoteOptions(remote.WithContext(ctx)), + ) + if err != nil { + return ctx, fmt.Errorf("failed to write attestation bundle referrer: %w", err) + } + + state.ReferrerAttestations[imageName] = digestRef.String() + + return ctx, nil +} + // CreateAndPushV1Attestation for a named image creates a SLSA v1.0 attestation // and pushes it to the stub registry func CreateAndPushV1Attestation(ctx context.Context, imageName, keyName string) (context.Context, error) { @@ -1197,12 +1304,15 @@ func AttestationSignaturesFrom(ctx context.Context, prefix string) (map[string]s state := testenv.FetchState[imageState](ctx) signatures := map[string]string{} - for name, signature := range state.AttestationSignatures { - if signature.KeyID != "" { - signatures[fmt.Sprintf("%s_KEY_ID_%s", prefix, name)] = signature.KeyID - } - if signature.Signature != "" { - signatures[fmt.Sprintf("%s_%s", prefix, name)] = signature.Signature + // Referrer entries first so legacy entries take precedence (overwrite) for same image name + for _, m := range []map[string]Signature{state.ReferrerAttestationSignatures, state.AttestationSignatures} { + for name, signature := range m { + if signature.KeyID != "" { + signatures[fmt.Sprintf("%s_KEY_ID_%s", prefix, name)] = signature.KeyID + } + if signature.Signature != "" { + signatures[fmt.Sprintf("%s_%s", prefix, name)] = signature.Signature + } } } @@ -1217,8 +1327,11 @@ func RawAttestationSignaturesFrom(ctx context.Context) map[string]string { state := testenv.FetchState[imageState](ctx) ret := map[string]string{} - for ref, signature := range state.AttestationSignatures { - ret[fmt.Sprintf("ATTESTATION_SIGNATURE_%s", ref)] = signature.Signature + // Referrer entries first so legacy entries take precedence (overwrite) for same image name + for _, m := range []map[string]Signature{state.ReferrerAttestationSignatures, state.AttestationSignatures} { + for ref, signature := range m { + ret[fmt.Sprintf("ATTESTATION_SIGNATURE_%s", ref)] = signature.Signature + } } return ret @@ -1232,12 +1345,15 @@ func ImageSignaturesFrom(ctx context.Context, prefix string) (map[string]string, state := testenv.FetchState[imageState](ctx) ret := map[string]string{} - for name, signature := range state.ImageSignatures { - if signature.KeyID != "" { - ret[fmt.Sprintf("%s_KEY_ID_%s", prefix, name)] = signature.KeyID - } - if signature.Signature != "" { - ret[fmt.Sprintf("%s_%s", prefix, name)] = signature.Signature + // Referrer entries first so legacy entries take precedence (overwrite) for same image name + for _, m := range []map[string]Signature{state.ReferrerImageSignatures, state.ImageSignatures} { + for name, signature := range m { + if signature.KeyID != "" { + ret[fmt.Sprintf("%s_KEY_ID_%s", prefix, name)] = signature.KeyID + } + if signature.Signature != "" { + ret[fmt.Sprintf("%s_%s", prefix, name)] = signature.Signature + } } } @@ -1252,8 +1368,11 @@ func RawImageSignaturesFrom(ctx context.Context) map[string]string { state := testenv.FetchState[imageState](ctx) ret := map[string]string{} - for ref, signature := range state.ImageSignatures { - ret[fmt.Sprintf("IMAGE_SIGNATURE_%s", ref)] = signature.Signature + // Referrer entries first so legacy entries take precedence (overwrite) for same image name + for _, m := range []map[string]Signature{state.ReferrerImageSignatures, state.ImageSignatures} { + for ref, signature := range m { + ret[fmt.Sprintf("IMAGE_SIGNATURE_%s", ref)] = signature.Signature + } } return ret @@ -1412,4 +1531,5 @@ func AddStepsTo(sc *godog.ScenarioContext) { sc.Step(`^an OCI blob with content "([^"]*)" in the repo "([^"]*)"$`, createAndPushLayer) sc.Step(`^a valid image signature referrer of "([^"]*)" image signed by the "([^"]*)" key$`, CreateAndPushImageSignatureReferrer) sc.Step(`^a valid attestation referrer of "([^"]*)" signed by the "([^"]*)" key$`, CreateAndPushAttestationReferrer) + sc.Step(`^a valid bundle-format attestation referrer of "([^"]*)" signed by the "([^"]*)" key$`, CreateAndPushBundleAttestationReferrer) } diff --git a/acceptance/rekor/rekor.go b/acceptance/rekor/rekor.go index 5822bd275..b047b6254 100644 --- a/acceptance/rekor/rekor.go +++ b/acceptance/rekor/rekor.go @@ -182,6 +182,8 @@ func computeLogEntryForSignature(ctx context.Context, publicKey, data, signature // Use current Unix timestamp for integrated time time := time.Now().Unix() + checkpoint := fmt.Sprintf("rekor.sigstore.dev - %s\n%d\n%s\n\n", logID, treeSize, rootHashHex) + // fill in the entry with the attestation and in-toto entry for the log // and add the verification logEntry = &models.LogEntryAnon{ @@ -191,10 +193,11 @@ func computeLogEntryForSignature(ctx context.Context, publicKey, data, signature Body: base64.StdEncoding.EncodeToString(logBodyBytes), Verification: &models.LogEntryAnonVerification{ InclusionProof: &models.InclusionProof{ - RootHash: &rootHashHex, - Hashes: hashes, - LogIndex: &logIndex, - TreeSize: &treeSize, + Checkpoint: &checkpoint, + RootHash: &rootHashHex, + Hashes: hashes, + LogIndex: &logIndex, + TreeSize: &treeSize, }, }, IntegratedTime: &time, @@ -301,6 +304,8 @@ func computeLogEntryForAttestation(ctx context.Context, publicKey []byte, attest // Use current Unix timestamp for integrated time time := time.Now().Unix() + checkpoint := fmt.Sprintf("rekor.sigstore.dev - %s\n%d\n%s\n\n", logID, treeSize, rootHashHex) + // fill in the entry with the attestation and in-toto entry for the log // and add the verification logEntry = &models.LogEntryAnon{ @@ -310,10 +315,11 @@ func computeLogEntryForAttestation(ctx context.Context, publicKey []byte, attest Body: base64.StdEncoding.EncodeToString(logBodyBytes), Verification: &models.LogEntryAnonVerification{ InclusionProof: &models.InclusionProof{ - RootHash: &rootHashHex, - Hashes: hashes, - LogIndex: &logIndex, - TreeSize: &treeSize, + Checkpoint: &checkpoint, + RootHash: &rootHashHex, + Hashes: hashes, + LogIndex: &logIndex, + TreeSize: &treeSize, }, }, IntegratedTime: &time, @@ -376,10 +382,9 @@ func StubRekorEntryCreationForSignature(ctx context.Context, data []byte, signat return fmt.Errorf("failed to compute signed entry timestamp: %w", err) } - // Add the verification section with the signed entry timestamp - logEntry.Verification = &models.LogEntryAnonVerification{ - SignedEntryTimestamp: strfmt.Base64(signedTimestamp), - } + // Add the signed entry timestamp to the existing verification section + // (preserves the InclusionProof already set by the compute function) + logEntry.Verification.SignedEntryTimestamp = strfmt.Base64(signedTimestamp) // Create the response format that Rekor creation endpoint returns: {uuid: logEntry} response := map[string]*models.LogEntryAnon{ @@ -443,10 +448,9 @@ func StubRekorEntryCreationForAttestation(ctx context.Context, attestationData [ return fmt.Errorf("failed to compute signed entry timestamp: %w", err) } - // Add the verification section with the signed entry timestamp - logEntry.Verification = &models.LogEntryAnonVerification{ - SignedEntryTimestamp: strfmt.Base64(signedTimestamp), - } + // Add the signed entry timestamp to the existing verification section + // (preserves the InclusionProof already set by the compute function) + logEntry.Verification.SignedEntryTimestamp = strfmt.Base64(signedTimestamp) // Create the response format that Rekor creation endpoint returns: {uuid: logEntry} response := map[string]*models.LogEntryAnon{ diff --git a/features/__snapshots__/validate_image.snap b/features/__snapshots__/validate_image.snap index e79ac27a2..8aca1ad7d 100644 --- a/features/__snapshots__/validate_image.snap +++ b/features/__snapshots__/validate_image.snap @@ -3612,6 +3612,1233 @@ Error: success criteria not met --- +[TestFeatures/sigstore functions with bundle-format attestations:stdout - 1] +{ + "success": true, + "components": [ + { + "name": "Unnamed", + "containerImage": "${REGISTRY}/acceptance/sigstore-bundles@sha256:${REGISTRY_acceptance/sigstore-bundles:latest_DIGEST}", + "source": {}, + "successes": [ + { + "msg": "Pass", + "metadata": { + "code": "builtin.attestation.signature_check" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "builtin.attestation.syntax_check" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "builtin.image.signature_check" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "sigstore.valid" + } + } + ], + "success": true, + "signatures": [ + { + "keyid": "", + "sig": "" + } + ], + "attestations": [ + { + "type": "https://in-toto.io/Statement/v0.1", + "predicateType": "https://slsa.dev/provenance/v0.2", + "signatures": [ + { + "keyid": "", + "sig": "${ATTESTATION_SIGNATURE_acceptance/sigstore-bundles}" + } + ] + } + ] + } + ], + "key": "${bundle_PUBLIC_KEY_JSON}", + "policy": { + "sources": [ + { + "policy": [ + "git::${GITHOST}/git/sigstore-bundles.git?ref=${LATEST_COMMIT}" + ] + } + ], + "publicKey": "${bundle_PUBLIC_KEY}" + }, + "ec-version": "${EC_VERSION}", + "effective-time": "${TIMESTAMP}" +} +--- + +[TestFeatures/sigstore functions with bundle-format attestations:stderr - 1] + +--- + +[TestFeatures/many components and sources:stdout - 1] +{ + "success": true, + "snapshot": "acceptance/multitude", + "components": [ + { + "name": "component9", + "containerImage": "${REGISTRY}/multitude/image-9@sha256:${REGISTRY_multitude/image-9:latest_DIGEST}", + "source": {}, + "successes": [ + { + "msg": "Pass", + "metadata": { + "code": "builtin.attestation.signature_check" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "builtin.attestation.syntax_check" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "builtin.image.signature_check" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + } + ], + "success": true, + "signatures": [ + { + "keyid": "", + "sig": "${IMAGE_SIGNATURE_multitude/image-9}" + } + ], + "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_multitude/image-9}" + } + ] + } + ] + }, + { + "name": "component8", + "containerImage": "${REGISTRY}/multitude/image-8@sha256:${REGISTRY_multitude/image-8:latest_DIGEST}", + "source": {}, + "successes": [ + { + "msg": "Pass", + "metadata": { + "code": "builtin.attestation.signature_check" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "builtin.attestation.syntax_check" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "builtin.image.signature_check" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + } + ], + "success": true, + "signatures": [ + { + "keyid": "", + "sig": "${IMAGE_SIGNATURE_multitude/image-8}" + } + ], + "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_multitude/image-8}" + } + ] + } + ] + }, + { + "name": "component7", + "containerImage": "${REGISTRY}/multitude/image-7@sha256:${REGISTRY_multitude/image-7:latest_DIGEST}", + "source": {}, + "successes": [ + { + "msg": "Pass", + "metadata": { + "code": "builtin.attestation.signature_check" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "builtin.attestation.syntax_check" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "builtin.image.signature_check" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + } + ], + "success": true, + "signatures": [ + { + "keyid": "", + "sig": "${IMAGE_SIGNATURE_multitude/image-7}" + } + ], + "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_multitude/image-7}" + } + ] + } + ] + }, + { + "name": "component6", + "containerImage": "${REGISTRY}/multitude/image-6@sha256:${REGISTRY_multitude/image-6:latest_DIGEST}", + "source": {}, + "successes": [ + { + "msg": "Pass", + "metadata": { + "code": "builtin.attestation.signature_check" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "builtin.attestation.syntax_check" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "builtin.image.signature_check" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + } + ], + "success": true, + "signatures": [ + { + "keyid": "", + "sig": "${IMAGE_SIGNATURE_multitude/image-6}" + } + ], + "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_multitude/image-6}" + } + ] + } + ] + }, + { + "name": "component5", + "containerImage": "${REGISTRY}/multitude/image-5@sha256:${REGISTRY_multitude/image-5:latest_DIGEST}", + "source": {}, + "successes": [ + { + "msg": "Pass", + "metadata": { + "code": "builtin.attestation.signature_check" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "builtin.attestation.syntax_check" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "builtin.image.signature_check" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + } + ], + "success": true, + "signatures": [ + { + "keyid": "", + "sig": "${IMAGE_SIGNATURE_multitude/image-5}" + } + ], + "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_multitude/image-5}" + } + ] + } + ] + }, + { + "name": "component4", + "containerImage": "${REGISTRY}/multitude/image-4@sha256:${REGISTRY_multitude/image-4:latest_DIGEST}", + "source": {}, + "successes": [ + { + "msg": "Pass", + "metadata": { + "code": "builtin.attestation.signature_check" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "builtin.attestation.syntax_check" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "builtin.image.signature_check" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + } + ], + "success": true, + "signatures": [ + { + "keyid": "", + "sig": "${IMAGE_SIGNATURE_multitude/image-4}" + } + ], + "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_multitude/image-4}" + } + ] + } + ] + }, + { + "name": "component3", + "containerImage": "${REGISTRY}/multitude/image-3@sha256:${REGISTRY_multitude/image-3:latest_DIGEST}", + "source": {}, + "successes": [ + { + "msg": "Pass", + "metadata": { + "code": "builtin.attestation.signature_check" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "builtin.attestation.syntax_check" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "builtin.image.signature_check" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + } + ], + "success": true, + "signatures": [ + { + "keyid": "", + "sig": "${IMAGE_SIGNATURE_multitude/image-3}" + } + ], + "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_multitude/image-3}" + } + ] + } + ] + }, + { + "name": "component2", + "containerImage": "${REGISTRY}/multitude/image-2@sha256:${REGISTRY_multitude/image-2:latest_DIGEST}", + "source": {}, + "successes": [ + { + "msg": "Pass", + "metadata": { + "code": "builtin.attestation.signature_check" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "builtin.attestation.syntax_check" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "builtin.image.signature_check" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + } + ], + "success": true, + "signatures": [ + { + "keyid": "", + "sig": "${IMAGE_SIGNATURE_multitude/image-2}" + } + ], + "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_multitude/image-2}" + } + ] + } + ] + }, + { + "name": "component1", + "containerImage": "${REGISTRY}/multitude/image-1@sha256:${REGISTRY_multitude/image-1:latest_DIGEST}", + "source": {}, + "successes": [ + { + "msg": "Pass", + "metadata": { + "code": "builtin.attestation.signature_check" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "builtin.attestation.syntax_check" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "builtin.image.signature_check" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + } + ], + "success": true, + "signatures": [ + { + "keyid": "", + "sig": "${IMAGE_SIGNATURE_multitude/image-1}" + } + ], + "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_multitude/image-1}" + } + ] + } + ] + }, + { + "name": "component0", + "containerImage": "${REGISTRY}/multitude/image-0@sha256:${REGISTRY_multitude/image-0:latest_DIGEST}", + "source": {}, + "successes": [ + { + "msg": "Pass", + "metadata": { + "code": "builtin.attestation.signature_check" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "builtin.attestation.syntax_check" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "builtin.image.signature_check" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + }, + { + "msg": "Pass", + "metadata": { + "code": "main.acceptor" + } + } + ], + "success": true, + "signatures": [ + { + "keyid": "", + "sig": "${IMAGE_SIGNATURE_multitude/image-0}" + } + ], + "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_multitude/image-0}" + } + ] + } + ] + } + ], + "key": "${known_PUBLIC_KEY_JSON}", + "policy": { + "sources": [ + { + "policy": [ + "git::${GITHOST}/git/multitude-policy.git?ref=${LATEST_COMMIT}" + ], + "ruleData": { + "key": "value" + } + }, + { + "policy": [ + "git::${GITHOST}/git/multitude-policy.git?ref=${LATEST_COMMIT}" + ], + "ruleData": { + "something": "here" + } + }, + { + "policy": [ + "git::${GITHOST}/git/multitude-policy.git?ref=${LATEST_COMMIT}" + ], + "ruleData": { + "key": "different" + } + }, + { + "policy": [ + "git::${GITHOST}/git/multitude-policy.git?ref=${LATEST_COMMIT}" + ], + "ruleData": { + "hello": "world" + } + }, + { + "policy": [ + "git::${GITHOST}/git/multitude-policy.git?ref=${LATEST_COMMIT}" + ], + "ruleData": { + "foo": "bar" + } + }, + { + "policy": [ + "git::${GITHOST}/git/multitude-policy.git?ref=${LATEST_COMMIT}" + ], + "ruleData": { + "peek": "poke" + } + }, + { + "policy": [ + "git::${GITHOST}/git/multitude-policy.git?ref=${LATEST_COMMIT}" + ], + "ruleData": { + "hide": "seek" + } + }, + { + "policy": [ + "git::${GITHOST}/git/multitude-policy.git?ref=${LATEST_COMMIT}" + ], + "ruleData": { + "hokus": "pokus" + } + }, + { + "policy": [ + "git::${GITHOST}/git/multitude-policy.git?ref=${LATEST_COMMIT}" + ], + "ruleData": { + "mr": "mxyzptlk" + } + }, + { + "policy": [ + "git::${GITHOST}/git/multitude-policy.git?ref=${LATEST_COMMIT}" + ], + "ruleData": { + "more": "data" + } + } + ], + "rekorUrl": "${REKOR}", + "publicKey": "${known_PUBLIC_KEY}" + }, + "ec-version": "${EC_VERSION}", + "effective-time": "${TIMESTAMP}" +} +--- + +[TestFeatures/many components and sources:stderr - 1] + +--- + [TestFeatures/Format options:stdout - 1] Success: false Result: FAILURE diff --git a/features/validate_image.feature b/features/validate_image.feature index 6e0518747..373fcb5c1 100644 --- a/features/validate_image.feature +++ b/features/validate_image.feature @@ -1278,6 +1278,29 @@ Feature: evaluate enterprise contract Then the exit status should be 0 Then the output should match the snapshot + Scenario: sigstore functions with bundle-format attestations + Given a key pair named "bundle" + Given an image named "acceptance/sigstore-bundles" + Given a valid image signature referrer of "acceptance/sigstore-bundles" image signed by the "bundle" key + Given a valid bundle-format attestation referrer of "acceptance/sigstore-bundles" signed by the "bundle" key + Given a git repository named "sigstore-bundles" with + | main.rego | examples/sigstore.rego | + Given policy configuration named "ec-policy" with specification + """ + { + "sources": [ + { + "policy": [ + "git::https://${GITHOST}/git/sigstore-bundles.git" + ] + } + ] + } + """ + When ec command is run with "validate image --image ${REGISTRY}/acceptance/sigstore-bundles --policy acceptance/ec-policy --public-key ${bundle_PUBLIC_KEY} --ignore-rekor --show-successes --output json" + Then the exit status should be 0 + Then the output should match the snapshot + # Commented out as part of EC-1023. This will be enabled once the issue is resolved. # Scenario: many components and sources # Given a key pair named "known" 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 3149d6839..eaecddc05 100644 --- a/internal/evaluation_target/application_snapshot_image/application_snapshot_image.go +++ b/internal/evaluation_target/application_snapshot_image/application_snapshot_image.go @@ -32,7 +32,6 @@ 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" @@ -123,9 +122,8 @@ func (a *ApplicationSnapshotImage) SetImageURL(url string) error { } func (a *ApplicationSnapshotImage) hasBundles(ctx context.Context) bool { - regOpts := []ociremote.Option{ociremote.WithRemoteOptions(oci.CreateRemoteOptions(ctx)...)} - bundles, _, err := cosign.GetBundles(ctx, a.reference, regOpts) - return err == nil && len(bundles) > 0 + found, err := oci.NewClient(ctx).HasBundles(ctx, a.reference) + return err == nil && found } func (a *ApplicationSnapshotImage) FetchImageConfig(ctx context.Context) error { 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 5cefaa2f0..e010ef9cb 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 @@ -476,12 +476,13 @@ func TestValidateImageSignatureClaims(t *testing.T) { ctx := o.WithClient(context.Background(), &c) + c.On("HasBundles", mock.Anything, ref).Return(false, nil) c.On("VerifyImageSignatures", ref, mock.Anything).Return([]oci.Signature{}, false, nil) err := a.ValidateImageSignature(ctx) require.NoError(t, err) - call := c.Calls[0] + call := c.Calls[1] checkOpts := call.Arguments.Get(1).(*cosign.CheckOpts) assert.NotNil(t, checkOpts) @@ -597,12 +598,13 @@ func TestValidateAttestationSignatureClaims(t *testing.T) { ctx := o.WithClient(context.Background(), &c) + c.On("HasBundles", mock.Anything, ref).Return(false, nil) c.On("VerifyImageAttestations", ref, mock.Anything).Return([]oci.Signature{}, false, nil) err := a.ValidateAttestationSignature(ctx) require.NoError(t, err) - call := c.Calls[0] + call := c.Calls[1] checkOpts := call.Arguments.Get(1).(*cosign.CheckOpts) assert.NotNil(t, checkOpts) @@ -739,6 +741,7 @@ func TestValidateImageSignatureWithCertificates(t *testing.T) { ) require.NoError(t, err) + c.On("HasBundles", mock.Anything, ref).Return(false, nil) c.On("VerifyImageSignatures", ref, mock.Anything).Return([]oci.Signature{sig}, false, nil) err = a.ValidateImageSignature(ctx) @@ -1339,6 +1342,7 @@ func TestValidateAttestationSignature(t *testing.T) { a := ApplicationSnapshotImage{reference: ref} client := fake.FakeClient{} + client.On("HasBundles", mock.Anything, ref).Return(false, nil) client.On("VerifyImageAttestations", ref, mock.Anything).Return(tc.signatures, false, tc.verifyErr) ctx := o.WithClient(context.Background(), &client) diff --git a/internal/image/validate_test.go b/internal/image/validate_test.go index 2ef778900..1c49077cf 100644 --- a/internal/image/validate_test.go +++ b/internal/image/validate_test.go @@ -80,6 +80,7 @@ func TestBuiltinChecks(t *testing.T) { name: "simple success", setup: func(c *fake.FakeClient) { c.On("Head", ref).Return(&v1.Descriptor{MediaType: types.OCIManifestSchema1}, nil) + c.On("HasBundles", mock.Anything, refNoTag).Return(false, nil) c.On("VerifyImageSignatures", refNoTag, mock.Anything).Return([]oci.Signature{validSignature}, true, nil) c.On("VerifyImageAttestations", refNoTag, mock.Anything).Return([]oci.Signature{validAttestation}, true, nil) }, @@ -106,6 +107,7 @@ func TestBuiltinChecks(t *testing.T) { name: "no image signatures", setup: func(c *fake.FakeClient) { c.On("Head", ref).Return(&v1.Descriptor{MediaType: types.OCIManifestSchema1}, nil) + c.On("HasBundles", mock.Anything, refNoTag).Return(false, nil) c.On("VerifyImageSignatures", refNoTag, mock.Anything).Return(nil, false, errors.New("no image signatures client error")) c.On("VerifyImageAttestations", refNoTag, mock.Anything).Return([]oci.Signature{validAttestation}, true, nil) }, @@ -122,6 +124,7 @@ func TestBuiltinChecks(t *testing.T) { name: "no image attestations", setup: func(c *fake.FakeClient) { c.On("Head", ref).Return(&v1.Descriptor{MediaType: types.OCIManifestSchema1}, nil) + c.On("HasBundles", mock.Anything, refNoTag).Return(false, nil) c.On("VerifyImageSignatures", refNoTag, mock.Anything).Return(validSignature, true, nil) c.On("VerifyImageAttestations", refNoTag, mock.Anything).Return(nil, false, errors.New("no image attestations client error")) }, @@ -325,6 +328,7 @@ func TestEvaluatorLifecycle(t *testing.T) { ctx = ecoci.WithClient(ctx, &client) client.On("Image", name.MustParseReference(imageRegistry+"@sha256:"+imageDigest), mock.Anything).Return(empty.Image, nil) client.On("Head", ref).Return(&v1.Descriptor{MediaType: types.OCIManifestSchema1}, nil) + client.On("HasBundles", mock.Anything, refNoTag).Return(false, nil) client.On("VerifyImageSignatures", refNoTag, mock.Anything).Return([]oci.Signature{validSignature}, true, nil) client.On("VerifyImageAttestations", refNoTag, mock.Anything).Return([]oci.Signature{validAttestation}, true, nil) client.On("ResolveDigest", refNoTag).Return("@sha256:"+imageDigest, nil) @@ -367,6 +371,7 @@ func TestComponentNamePassedToEvaluator(t *testing.T) { client := fake.FakeClient{} client.On("Head", mock.Anything).Return(&v1.Descriptor{MediaType: types.OCIManifestSchema1}, nil) client.On("Image", name.MustParseReference(imageRegistry+"@sha256:"+imageDigest), mock.Anything).Return(empty.Image, nil) + client.On("HasBundles", mock.Anything, refNoTag).Return(false, nil) client.On("VerifyImageSignatures", refNoTag, mock.Anything).Return([]oci.Signature{validSignature}, true, nil) client.On("VerifyImageAttestations", refNoTag, mock.Anything).Return([]oci.Signature{validAttestation}, true, nil) client.On("ResolveDigest", refNoTag).Return("@sha256:"+imageDigest, nil) @@ -609,6 +614,7 @@ func TestValidateImageSkipImageSigCheck(t *testing.T) { // Set up minimal fake data for image to pass accessibility check // and signature/attestation checks (they will fail but create results) 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")) diff --git a/internal/rego/sigstore/sigstore.go b/internal/rego/sigstore/sigstore.go index 82e6ad769..fdc1460d2 100644 --- a/internal/rego/sigstore/sigstore.go +++ b/internal/rego/sigstore/sigstore.go @@ -131,10 +131,25 @@ func sigstoreVerifyImage(bctx rego.BuiltinContext, refTerm *ast.Term, optsTerm * logger.WithField("error", err).Debug("failed to parse check opts") return signatureFailedResult(fmt.Errorf("opts parameter: %w", err)) } - checkOpts.ClaimVerifier = cosign.SimpleClaimVerifier - logger.Debug("verifying image signatures") - signatures, _, err := ecoci.NewClient(ctx).VerifyImageSignatures(ref, checkOpts) + client := ecoci.NewClient(ctx) + useBundles, err := client.HasBundles(ctx, ref) + if err != nil { + logger.WithField("error", err).Debug("bundle detection failed, falling back to legacy path") + useBundles = false + } + + var signatures []oci.Signature + if useBundles { + logger.Debug("bundles detected, using bundle verification path") + checkOpts.NewBundleFormat = true + checkOpts.ClaimVerifier = cosign.IntotoSubjectClaimVerifier + signatures, _, err = client.VerifyImageAttestations(ref, checkOpts) + } else { + checkOpts.ClaimVerifier = cosign.SimpleClaimVerifier + logger.Debug("verifying image signatures") + signatures, _, err = client.VerifyImageSignatures(ref, checkOpts) + } if err != nil { logger.WithField("error", err).Debug("failed to verify image signature") return signatureFailedResult(fmt.Errorf("verify image signature: %w", err)) @@ -202,15 +217,26 @@ func sigstoreVerifyAttestation(bctx rego.BuiltinContext, refTerm *ast.Term, opts } checkOpts.ClaimVerifier = cosign.IntotoSubjectClaimVerifier + client := ecoci.NewClient(ctx) + useBundles, err := client.HasBundles(ctx, ref) + if err != nil { + logger.WithField("error", err).Debug("bundle detection failed, falling back to legacy path") + useBundles = false + } + if useBundles { + logger.Debug("bundles detected, using bundle verification path") + checkOpts.NewBundleFormat = true + } + logger.Debug("verifying image attestations") - attestations, _, err := ecoci.NewClient(ctx).VerifyImageAttestations(ref, checkOpts) + attestations, _, err := client.VerifyImageAttestations(ref, checkOpts) if err != nil { logger.WithField("error", err).Debug("failed to verify image attestation signature") return attestationFailedResult(fmt.Errorf("verify image attestation signature: %w", err)) } logger.WithField("attestations_count", len(attestations)).Debug("attestation verification complete") - return attestationResult(attestations, nil) + return attestationResult(attestations, useBundles, nil) } func parseCheckOpts(ctx context.Context, optsTerm *ast.Term) (*cosign.CheckOpts, error) { @@ -340,10 +366,10 @@ func signatureResult(signatures []oci.Signature, err error) (*ast.Term, error) { } func attestationFailedResult(err error) (*ast.Term, error) { - return attestationResult(nil, err) + return attestationResult(nil, false, err) } -func attestationResult(attestations []oci.Signature, err error) (*ast.Term, error) { +func attestationResult(attestations []oci.Signature, useBundles bool, err error) (*ast.Term, error) { var errorTerms []*ast.Term var attestationTerms []*ast.Term @@ -352,9 +378,33 @@ func attestationResult(attestations []oci.Signature, err error) (*ast.Term, erro } for _, s := range attestations { - att, err := attestation.ProvenanceFromSignature(s) - if err != nil { - errorTerms = append(errorTerms, ast.StringTerm(fmt.Sprintf("parsing attestation: %s", err))) + var att attestation.Attestation + var parseErr error + + if useBundles { + payload, pErr := s.Payload() + if pErr != nil { + log.WithField("error", pErr).Debug("skipping bundle entry: cannot read payload") + continue + } + var dsseEnvelope struct { + PayloadType string `json:"payloadType"` + } + if jErr := json.Unmarshal(payload, &dsseEnvelope); jErr != nil { + log.WithField("error", jErr).Debug("skipping bundle entry: not a valid DSSE envelope") + continue + } + if dsseEnvelope.PayloadType != "application/vnd.in-toto+json" { + log.WithField("payloadType", dsseEnvelope.PayloadType).Debug("skipping bundle entry with non-in-toto payloadType") + continue + } + att, parseErr = attestation.ProvenanceFromBundlePayload(s, payload) + } else { + att, parseErr = attestation.ProvenanceFromSignature(s) + } + + if parseErr != nil { + errorTerms = append(errorTerms, ast.StringTerm(fmt.Sprintf("parsing attestation: %s", parseErr))) continue } diff --git a/internal/rego/sigstore/sigstore_test.go b/internal/rego/sigstore/sigstore_test.go index 4414a2816..2820a09c9 100644 --- a/internal/rego/sigstore/sigstore_test.go +++ b/internal/rego/sigstore/sigstore_test.go @@ -21,6 +21,7 @@ package sigstore import ( "context" "encoding/base64" + "encoding/json" "errors" "fmt" "testing" @@ -189,6 +190,7 @@ func TestSigstoreVerifyImage(t *testing.T) { ) require.NoError(t, err) + c.On("HasBundles", mock.Anything, goodImage).Return(false, nil) verifyCall := c.On( "VerifyImageSignatures", goodImage, mock.Anything, ).Return([]oci.Signature{sig}, false, tt.sigError) @@ -378,6 +380,7 @@ func TestSigstoreVerifyAttestation(t *testing.T) { c := fake.FakeClient{} ctx := o.WithClient(context.Background(), &c) + c.On("HasBundles", mock.Anything, goodImage).Return(false, nil) verifyCall := c.On( "VerifyImageAttestations", goodImage, mock.Anything, ).Return(tt.sigs, false, tt.sigError) @@ -396,3 +399,291 @@ func TestSigstoreVerifyAttestation(t *testing.T) { }) } } + +// bundleDSSEPayload creates a DSSE envelope JSON for bundle-format attestations. +func bundleDSSEPayload(payloadType string, statement any) []byte { + statementBytes, err := json.Marshal(statement) + if err != nil { + panic(err) + } + envelope := map[string]string{ + "payloadType": payloadType, + "payload": base64.StdEncoding.EncodeToString(statementBytes), + } + data, err := json.Marshal(envelope) + if err != nil { + panic(err) + } + return data +} + +func TestSigstoreVerifyAttestationWithBundles(t *testing.T) { + goodImage := name.MustParseReference( + "registry.local/spam@sha256:4e388ab32b10dc8dbc7e28144f552830adc74787c1e2c0824032078a79f227fb", + ) + + // Valid in-toto statement for bundle path + statement := map[string]any{ + "_type": "https://in-toto.io/Statement/v0.1", + "predicateType": "https://slsa.dev/provenance/v0.2", + "subject": []any{}, + "predicate": map[string]any{}, + } + + validBundlePayload := bundleDSSEPayload("application/vnd.in-toto+json", statement) + validBundleSig, err := static.NewSignature( + validBundlePayload, + "signature", + static.WithLayerMediaType(types.MediaType(cosignTypes.DssePayloadType)), + ) + require.NoError(t, err) + + // Valid DSSE envelope structure but with corrupted base64 payload data + malformedBundlePayload := []byte(`{"payloadType":"application/vnd.in-toto+json","payload":"!!!bad-base64!!!"}`) + malformedBundleSig, err := static.NewSignature( + malformedBundlePayload, + "signature", + static.WithLayerMediaType(types.MediaType(cosignTypes.DssePayloadType)), + ) + require.NoError(t, err) + + nonInTotoPayload := bundleDSSEPayload("application/octet-stream", "binary-data") + nonInTotoSig, err := static.NewSignature( + nonInTotoPayload, + "signature", + static.WithLayerMediaType(types.MediaType(cosignTypes.DssePayloadType)), + ) + require.NoError(t, err) + + cases := []struct { + name string + success *ast.Term + errors *ast.Term + sigs []oci.Signature + }{ + { + name: "bundle attestation verification succeeds", + success: ast.BooleanTerm(true), + errors: ast.ArrayTerm(), + sigs: []oci.Signature{validBundleSig}, + }, + { + name: "malformed bundle payload produces parsing error", + success: ast.BooleanTerm(false), + errors: ast.ArrayTerm( + ast.StringTerm("parsing attestation: malformed attestation data: illegal base64 data at input byte 0"), + ), + sigs: []oci.Signature{malformedBundleSig}, + }, + { + name: "non-in-toto payload type is skipped", + success: ast.BooleanTerm(true), + errors: ast.ArrayTerm(), + sigs: []oci.Signature{nonInTotoSig}, + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + utils.SetTestRekorPublicKey(t) + utils.SetTestFulcioRoots(t) + utils.SetTestCTLogPublicKey(t) + + c := fake.FakeClient{} + ctx := o.WithClient(context.Background(), &c) + + c.On("HasBundles", mock.Anything, goodImage).Return(true, nil) + c.On( + "VerifyImageAttestations", goodImage, mock.Anything, + ).Return(tt.sigs, false, nil).Run(func(args mock.Arguments) { + checkOpts := args.Get(1).(*cosign.CheckOpts) + require.True(t, checkOpts.NewBundleFormat) + }) + + bctx := rego.BuiltinContext{Context: ctx} + + result, err := sigstoreVerifyAttestation( + bctx, + ast.StringTerm(goodImage.String()), + options{ignoreRekor: true, publicKey: utils.TestPublicKey}.toTerm(), + ) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, tt.errors, result.Get(ast.StringTerm("errors"))) + require.Equal(t, tt.success, result.Get(ast.StringTerm("success"))) + }) + } +} + +func TestSigstoreVerifyAttestationBundleFallback(t *testing.T) { + goodImage := name.MustParseReference( + "registry.local/spam@sha256:4e388ab32b10dc8dbc7e28144f552830adc74787c1e2c0824032078a79f227fb", + ) + + // Legacy-format signature (same as existing tests) + goodSig, err := static.NewSignature( + []byte(fmt.Sprintf(`{"payload": "%s"}`, base64.StdEncoding.EncodeToString([]byte("{}")))), + "signature", + static.WithLayerMediaType(types.MediaType(cosignTypes.DssePayloadType)), + ) + require.NoError(t, err) + + cases := []struct { + name string + hasBundles bool + bundleErr error + }{ + { + name: "HasBundles returns error falls back to legacy", + hasBundles: false, + bundleErr: errors.New("registry error"), + }, + { + name: "HasBundles returns false uses legacy path", + hasBundles: false, + bundleErr: nil, + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + utils.SetTestRekorPublicKey(t) + utils.SetTestFulcioRoots(t) + utils.SetTestCTLogPublicKey(t) + + c := fake.FakeClient{} + ctx := o.WithClient(context.Background(), &c) + + c.On("HasBundles", mock.Anything, goodImage).Return(tt.hasBundles, tt.bundleErr) + c.On( + "VerifyImageAttestations", goodImage, mock.Anything, + ).Return([]oci.Signature{goodSig}, false, nil) + + bctx := rego.BuiltinContext{Context: ctx} + + result, err := sigstoreVerifyAttestation( + bctx, + ast.StringTerm(goodImage.String()), + options{ignoreRekor: true, publicKey: utils.TestPublicKey}.toTerm(), + ) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, ast.BooleanTerm(true), result.Get(ast.StringTerm("success"))) + require.Equal(t, ast.ArrayTerm(), result.Get(ast.StringTerm("errors"))) + }) + } +} + +func TestSigstoreVerifyImageWithBundles(t *testing.T) { + goodImage := name.MustParseReference( + "registry.local/spam@sha256:4e388ab32b10dc8dbc7e28144f552830adc74787c1e2c0824032078a79f227fb", + ) + + sig, err := static.NewSignature( + []byte(`image`), + "signature", + static.WithLayerMediaType(types.MediaType(cosignTypes.DssePayloadType)), + ) + require.NoError(t, err) + + cases := []struct { + name string + success *ast.Term + errors *ast.Term + }{ + { + name: "bundle-format image verification succeeds", + success: ast.BooleanTerm(true), + errors: ast.ArrayTerm(), + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + utils.SetTestRekorPublicKey(t) + utils.SetTestFulcioRoots(t) + utils.SetTestCTLogPublicKey(t) + + c := fake.FakeClient{} + ctx := o.WithClient(context.Background(), &c) + + c.On("HasBundles", mock.Anything, goodImage).Return(true, nil) + c.On( + "VerifyImageAttestations", goodImage, mock.Anything, + ).Return([]oci.Signature{sig}, false, nil).Run(func(args mock.Arguments) { + checkOpts := args.Get(1).(*cosign.CheckOpts) + require.True(t, checkOpts.NewBundleFormat) + }) + + bctx := rego.BuiltinContext{Context: ctx} + + result, err := sigstoreVerifyImage( + bctx, + ast.StringTerm(goodImage.String()), + options{ignoreRekor: true, publicKey: utils.TestPublicKey}.toTerm(), + ) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, tt.errors, result.Get(ast.StringTerm("errors"))) + require.Equal(t, tt.success, result.Get(ast.StringTerm("success"))) + }) + } +} + +func TestSigstoreVerifyImageBundleFallback(t *testing.T) { + goodImage := name.MustParseReference( + "registry.local/spam@sha256:4e388ab32b10dc8dbc7e28144f552830adc74787c1e2c0824032078a79f227fb", + ) + + sig, err := static.NewSignature( + []byte(`image`), + "signature", + static.WithLayerMediaType(types.MediaType(cosignTypes.DssePayloadType)), + ) + require.NoError(t, err) + + cases := []struct { + name string + hasBundles bool + bundleErr error + }{ + { + name: "HasBundles returns error falls back to legacy", + hasBundles: false, + bundleErr: errors.New("registry error"), + }, + { + name: "HasBundles returns false uses legacy path", + hasBundles: false, + bundleErr: nil, + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + utils.SetTestRekorPublicKey(t) + utils.SetTestFulcioRoots(t) + utils.SetTestCTLogPublicKey(t) + + c := fake.FakeClient{} + ctx := o.WithClient(context.Background(), &c) + + c.On("HasBundles", mock.Anything, goodImage).Return(tt.hasBundles, tt.bundleErr) + c.On( + "VerifyImageSignatures", goodImage, mock.Anything, + ).Return([]oci.Signature{sig}, false, nil) + + bctx := rego.BuiltinContext{Context: ctx} + + result, err := sigstoreVerifyImage( + bctx, + ast.StringTerm(goodImage.String()), + options{ignoreRekor: true, publicKey: utils.TestPublicKey}.toTerm(), + ) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, ast.BooleanTerm(true), result.Get(ast.StringTerm("success"))) + require.Equal(t, ast.ArrayTerm(), result.Get(ast.StringTerm("errors"))) + }) + } +} diff --git a/internal/utils/oci/client.go b/internal/utils/oci/client.go index 0fbbe377c..3b9f97c66 100644 --- a/internal/utils/oci/client.go +++ b/internal/utils/oci/client.go @@ -76,6 +76,7 @@ func CreateRemoteOptions(ctx context.Context) []remote.Option { type Client interface { VerifyImageSignatures(name.Reference, *cosign.CheckOpts) ([]oci.Signature, bool, error) VerifyImageAttestations(name.Reference, *cosign.CheckOpts) ([]oci.Signature, bool, error) + HasBundles(context.Context, name.Reference) (bool, error) Head(name.Reference) (*v1.Descriptor, error) ResolveDigest(name.Reference) (string, error) Image(name.Reference) (v1.Image, error) @@ -129,6 +130,15 @@ func (c *defaultClient) VerifyImageAttestations(ref name.Reference, opts *cosign return cosign.VerifyImageAttestations(c.ctx, ref, opts) } +func (c *defaultClient) HasBundles(ctx context.Context, ref name.Reference) (bool, error) { + regOpts := []ociremote.Option{ociremote.WithRemoteOptions(c.opts...)} + bundles, _, err := cosign.GetBundles(ctx, ref, regOpts) + if err != nil { + return false, err + } + return len(bundles) > 0, nil +} + func (c *defaultClient) Head(ref name.Reference) (*v1.Descriptor, error) { if trace.IsEnabled() { region := trace.StartRegion(c.ctx, "ec:oci-head") diff --git a/internal/utils/oci/fake/client.go b/internal/utils/oci/fake/client.go index 9d7741467..b18bcf891 100644 --- a/internal/utils/oci/fake/client.go +++ b/internal/utils/oci/fake/client.go @@ -101,6 +101,11 @@ func (m *FakeClient) VerifyImageAttestations(ref name.Reference, opts *cosign.Ch return sigs, args.Bool(1), args.Error(2) } +func (m *FakeClient) HasBundles(ctx context.Context, ref name.Reference) (bool, error) { + args := m.Called(ctx, ref) + return args.Bool(0), args.Error(1) +} + func (m *FakeClient) Head(ref name.Reference) (*v1.Descriptor, error) { args := m.Called(ref) var desc *v1.Descriptor