Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 1 addition & 15 deletions features/__snapshots__/task_validate_image.snap
Original file line number Diff line number Diff line change
Expand Up @@ -263,11 +263,7 @@ true
"signatures": [
{
"keyid": "",
"sig": ""
},
{
"keyid": "",
"sig": ""
"sig": "MEUCID1cJkxyk1oGvXcoAVkDST9A1vfX2gxPEz+LUzN10nDmAiEAxh9rp79yr4fZmAWWOit0dZ5QWK+uYIU8fQVb0/rLIyM="
}
],
"attestations": [
Expand All @@ -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="
}
]
}
]
}
Expand Down
6 changes: 0 additions & 6 deletions features/__snapshots__/validate_image.snap
Original file line number Diff line number Diff line change
Expand Up @@ -3647,12 +3647,6 @@ Error: success criteria not met
}
],
"success": true,
"signatures": [
{
"keyid": "",
"sig": ""
}
],
"attestations": [
{
"type": "https://in-toto.io/Statement/v0.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package application_snapshot_image
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
Expand Down Expand Up @@ -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)
Expand All @@ -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
}
Comment thread
simonbaird marked this conversation as resolved.

// 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.
Comment thread
simonbaird marked this conversation as resolved.
// 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
}
Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading