diff --git a/features/__snapshots__/task_validate_image.snap b/features/__snapshots__/task_validate_image.snap index a0fa5d105..19d9acd8d 100644 --- a/features/__snapshots__/task_validate_image.snap +++ b/features/__snapshots__/task_validate_image.snap @@ -263,11 +263,7 @@ true "signatures": [ { "keyid": "", - "sig": "" - }, - { - "keyid": "", - "sig": "" + "sig": "MEUCID1cJkxyk1oGvXcoAVkDST9A1vfX2gxPEz+LUzN10nDmAiEAxh9rp79yr4fZmAWWOit0dZ5QWK+uYIU8fQVb0/rLIyM=" } ], "attestations": [ @@ -280,16 +276,6 @@ true "sig": "MEUCIQC5bGm4zzbExXBMrZCmqZ98iqUhi8TV/maq/8dJ/c3POAIgCNw+RkeO7PAkT6JDWIvISZ2AjILu9YuPQ0qqfNwCqug=" } ] - }, - { - "type": "https://in-toto.io/Statement/v0.1", - "predicateType": "https://sigstore.dev/cosign/sign/v1", - "signatures": [ - { - "keyid": "", - "sig": "MEUCID1cJkxyk1oGvXcoAVkDST9A1vfX2gxPEz+LUzN10nDmAiEAxh9rp79yr4fZmAWWOit0dZ5QWK+uYIU8fQVb0/rLIyM=" - } - ] } ] } diff --git a/features/__snapshots__/validate_image.snap b/features/__snapshots__/validate_image.snap index 8aca1ad7d..634014ad9 100644 --- a/features/__snapshots__/validate_image.snap +++ b/features/__snapshots__/validate_image.snap @@ -3647,12 +3647,6 @@ Error: success criteria not met } ], "success": true, - "signatures": [ - { - "keyid": "", - "sig": "" - } - ], "attestations": [ { "type": "https://in-toto.io/Statement/v0.1", 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 b96714fc1..8bdf86513 100644 --- a/internal/evaluation_target/application_snapshot_image/application_snapshot_image.go +++ b/internal/evaluation_target/application_snapshot_image/application_snapshot_image.go @@ -19,6 +19,7 @@ package application_snapshot_image import ( "bytes" "context" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -155,11 +156,15 @@ func (a *ApplicationSnapshotImage) FetchImageFiles(ctx context.Context) error { func (a *ApplicationSnapshotImage) ValidateImageSignature(ctx context.Context) error { opts := a.checkOpts client := oci.NewClient(ctx) + useBundles := a.hasBundles(ctx) var sigs []cosignOCI.Signature var err error - if a.hasBundles(ctx) { + if useBundles { + // In v3 bundles, image signatures and attestations are both stored as + // "attestations", so we use VerifyImageAttestations to retrieve image + // signatures (unlike v2 where they were separate). opts.NewBundleFormat = true opts.ClaimVerifier = cosign.IntotoSubjectClaimVerifier sigs, _, err = client.VerifyImageAttestations(a.reference, &opts) @@ -172,11 +177,32 @@ func (a *ApplicationSnapshotImage) ValidateImageSignature(ctx context.Context) e } for _, s := range sigs { - es, err := signature.NewEntitySignature(s) - if err != nil { - return err + if useBundles { + // Filter out provenance attestations, keeping only image signatures + // (predicateType cosign/sign/v1) for the "signatures" output. + if !isImageSignatureAttestation(s) { + log.Debugf("Skipping non-image signature attestation") + continue + } + + // For bundle image signatures produced by cosign v3, the old + // method of accessing the signatures doesn't work. Instead we have + // to extract them from the bundle. And the bundle actually has + // signatures for both the image itself and the attestation. + signatures, err := extractSignaturesFromBundle(s) + if err != nil { + return fmt.Errorf("cannot extract signatures from bundle: %w", err) + } + a.signatures = append(a.signatures, signatures...) + } else { + // For older non-bundle image signatures produced by cosign v2. + // Note that filtering isn't needed, since we have only image sigs here. + es, err := signature.NewEntitySignature(s) + if err != nil { + return err + } + a.signatures = append(a.signatures, es) } - a.signatures = append(a.signatures, es) } return nil } @@ -197,6 +223,8 @@ func (a *ApplicationSnapshotImage) ValidateAttestationSignature(ctx context.Cont return err } + // TODO: The non-bundle code path validates attestation syntax; do the same + // for bundles. if useBundles { return a.parseAttestationsFromBundles(layers) } @@ -249,8 +277,19 @@ func (a *ApplicationSnapshotImage) ValidateAttestationSignature(ctx context.Cont // parseAttestationsFromBundles extracts attestations from Sigstore bundles. // Bundle-wrapped layers report an incorrect media type, so we unmarshal the // DSSE envelope from the raw payload directly. +// +// Note: For v3 bundles, this function filters out image signature attestations +// (https://sigstore.dev/cosign/sign/v1) since those are handled in ValidateImageSignature. +// Only provenance and other attestations are added to the attestations array. func (a *ApplicationSnapshotImage) parseAttestationsFromBundles(layers []cosignOCI.Signature) error { for _, sig := range layers { + // For v3 bundles, filter out image signature attestations - those are handled + // in ValidateImageSignature. Only add provenance attestations here. + if isImageSignatureAttestation(sig) { + log.Debugf("Skipping image signature attestation - handled in ValidateImageSignature") + continue + } + payload, err := sig.Payload() if err != nil { log.Debugf("Skipping bundle entry: cannot read payload: %v", err) @@ -273,8 +312,8 @@ func (a *ApplicationSnapshotImage) parseAttestationsFromBundles(layers []cosignO if err != nil { return fmt.Errorf("unable to parse bundle attestation: %w", err) } - t := att.PredicateType() - log.Debugf("Found bundle attestation with predicateType: %s", t) + log.Debugf("Found bundle attestation with predicateType: %s", att.PredicateType()) + a.attestations = append(a.attestations, att) } return nil @@ -507,7 +546,7 @@ func (a *ApplicationSnapshotImage) WriteInputFile(ctx context.Context) (string, log.Debugf("Created dir %s", inputDir) inputJSONPath := path.Join(inputDir, "input.json") - f, err := fs.OpenFile(inputJSONPath, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0644) + f, err := fs.OpenFile(inputJSONPath, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o644) if err != nil { log.Debugf("Problem creating file in %s", inputDir) return "", nil, err @@ -526,3 +565,83 @@ func (a *ApplicationSnapshotImage) WriteInputFile(ctx context.Context) (string, log.Debugf("Done preparing input file:\n%s", inputJSONPath) return inputJSONPath, inputJSON, nil } + +const cosignSignPredicateType = "https://sigstore.dev/cosign/sign/v1" + +// isImageSignatureAttestation checks if a bundle entry represents an image +// signature (vs. a provenance or other attestation). For DSSE envelopes it +// inspects the predicateType of the inner in-toto statement. For non-DSSE +// payloads (e.g. simple signing) it returns true since those are always +// image signatures. +func isImageSignatureAttestation(sig cosignOCI.Signature) bool { + payload, err := sig.Payload() + if err != nil { + log.Debugf("Cannot read signature payload: %v", err) + return false + } + + var dsseEnvelope struct { + PayloadType string `json:"payloadType"` + Payload string `json:"payload"` + } + if err := json.Unmarshal(payload, &dsseEnvelope); err != nil || dsseEnvelope.PayloadType == "" { + // Not a DSSE envelope — treat as a simple signing image signature + return true + } + + innerPayload, err := base64.StdEncoding.DecodeString(dsseEnvelope.Payload) + if err != nil { + log.Debugf("Cannot decode DSSE payload: %v", err) + return false + } + + var statement struct { + PredicateType string `json:"predicateType"` + } + if err := json.Unmarshal(innerPayload, &statement); err != nil { + log.Debugf("Cannot parse inner statement: %v", err) + return false + } + + return statement.PredicateType == cosignSignPredicateType +} + +// extractSignaturesFromBundle extracts signature information from a bundle +// image signature attestation, using the same pattern as createEntitySignatures. +// +// TODO: Certificate information is not yet extracted from v3 bundles because it +// requires different handling than v2. Investigate achieving full parity. +func extractSignaturesFromBundle(sig cosignOCI.Signature) ([]signature.EntitySignature, error) { + payload, err := sig.Payload() + if err != nil { + return nil, fmt.Errorf("cannot read signature data: %w", err) + } + + var attestationPayload cosign.AttestationPayload + if err := json.Unmarshal(payload, &attestationPayload); err != nil { + return nil, fmt.Errorf("cannot parse DSSE envelope: %w", err) + } + + // Create the base EntitySignature from the oci.Signature (for certificate info) + es, err := signature.NewEntitySignature(sig) + if err != nil { + return nil, fmt.Errorf("cannot create base signature: %w", err) + } + + var results []signature.EntitySignature + for _, s := range attestationPayload.Signatures { + esNew := es + // Prefer values from oci.Signature, fall back to cosign.Signatures. + // (Same pattern as createEntitySignatures in internal/attestation — + // keyless vs long-lived key workflows populate different locations.) + if esNew.Signature == "" { + esNew.Signature = s.Sig + } + if esNew.KeyID == "" { + esNew.KeyID = s.KeyID + } + results = append(results, esNew) + } + + return results, nil +} 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 e53a95baf..8f535d137 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 @@ -1461,3 +1461,158 @@ func TestValidateAttestationSignature(t *testing.T) { }) } } + +func TestIsImageSignatureAttestation(t *testing.T) { + cases := []struct { + name string + sig oci.Signature + expected bool + }{ + { + name: "DSSE with cosign sign predicate", + sig: createBundleDSSESignature(t, map[string]string{ + "predicateType": cosignSignPredicateType, + }), + expected: true, + }, + { + name: "DSSE with SLSA provenance predicate", + sig: createBundleDSSESignature(t, map[string]string{ + "predicateType": v02.PredicateSLSAProvenance, + }), + expected: false, + }, + { + name: "non-DSSE payload treated as simple signing", + sig: func() oci.Signature { + s, err := static.NewSignature([]byte(`{"some": "data"}`), "sig") + require.NoError(t, err) + return s + }(), + expected: true, + }, + { + name: "empty payload treated as simple signing", + sig: func() oci.Signature { + s, err := static.NewSignature([]byte(`{}`), "sig") + require.NoError(t, err) + return s + }(), + expected: true, + }, + { + name: "DSSE with invalid base64 payload", + sig: func() oci.Signature { + envelope, err := json.Marshal(map[string]string{ + "payloadType": "application/vnd.in-toto+json", + "payload": "!!!not-base64!!!", + }) + require.NoError(t, err) + s, err := static.NewSignature(envelope, "sig") + require.NoError(t, err) + return s + }(), + expected: false, + }, + { + name: "DSSE with non-JSON inner payload", + sig: func() oci.Signature { + envelope, err := json.Marshal(map[string]string{ + "payloadType": "application/vnd.in-toto+json", + "payload": base64.StdEncoding.EncodeToString([]byte("not json")), + }) + require.NoError(t, err) + s, err := static.NewSignature(envelope, "sig") + require.NoError(t, err) + return s + }(), + expected: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, isImageSignatureAttestation(tc.sig)) + }) + } +} + +func TestExtractSignaturesFromBundle(t *testing.T) { + t.Run("single signature", func(t *testing.T) { + sig := makeBundleSig(t, []cosign.Signatures{ + {KeyID: "key-1", Sig: "c2lnLTE="}, + }) + + results, err := extractSignaturesFromBundle(sig) + require.NoError(t, err) + require.Len(t, results, 1) + assert.Equal(t, "c2lnLTE=", results[0].Signature) + assert.Equal(t, "key-1", results[0].KeyID) + }) + + t.Run("multiple signatures", func(t *testing.T) { + sig := makeBundleSig(t, []cosign.Signatures{ + {KeyID: "key-a", Sig: "c2lnLWE="}, + {KeyID: "key-b", Sig: "c2lnLWI="}, + }) + + results, err := extractSignaturesFromBundle(sig) + require.NoError(t, err) + require.Len(t, results, 2) + assert.Equal(t, "c2lnLWE=", results[0].Signature) + assert.Equal(t, "key-a", results[0].KeyID) + assert.Equal(t, "c2lnLWI=", results[1].Signature) + assert.Equal(t, "key-b", results[1].KeyID) + }) + + t.Run("base signature takes priority over cosign signature", func(t *testing.T) { + sig := makeBundleSigWithBase64(t, "base-sig-value", []cosign.Signatures{ + {KeyID: "key-1", Sig: "should-not-override"}, + }) + + results, err := extractSignaturesFromBundle(sig) + require.NoError(t, err) + require.Len(t, results, 1) + assert.Equal(t, "base-sig-value", results[0].Signature) + }) + + t.Run("invalid payload returns error", func(t *testing.T) { + sig, err := static.NewSignature([]byte("not json"), "sig") + require.NoError(t, err) + + _, err = extractSignaturesFromBundle(sig) + assert.ErrorContains(t, err, "cannot parse DSSE envelope") + }) + + t.Run("empty signatures list returns empty results", func(t *testing.T) { + sig := makeBundleSig(t, []cosign.Signatures{}) + + results, err := extractSignaturesFromBundle(sig) + require.NoError(t, err) + assert.Empty(t, results) + }) +} + +// makeBundleSig creates a static.Signature whose Uncompressed() returns a +// cosign.AttestationPayload with the given signatures. The base Base64Signature +// is empty so the cosign.Signatures values are used. +func makeBundleSig(t *testing.T, sigs []cosign.Signatures) oci.Signature { + t.Helper() + return makeBundleSigWithBase64(t, "", sigs) +} + +func makeBundleSigWithBase64(t *testing.T, base64Sig string, sigs []cosign.Signatures) oci.Signature { + t.Helper() + + ap := cosign.AttestationPayload{ + PayloadType: "application/vnd.in-toto+json", + PayLoad: base64.StdEncoding.EncodeToString([]byte(`{}`)), + Signatures: sigs, + } + payload, err := json.Marshal(ap) + require.NoError(t, err) + + s, err := static.NewSignature(payload, base64Sig) + require.NoError(t, err) + return s +}