Skip to content

Commit 7d970c4

Browse files
committed
Fix duplicate .att/.sig OCI layers for same-digest type hints
When a Task declares the same OCI image through multiple type-hint formats (IMAGE_URL/IMAGE_DIGEST and IMAGES), Chains produces duplicate layers in cosign .att/.sig manifests. This adds dedup at two levels: 1. Extraction: track seen digests in ExtractOCIImagesFromResults to avoid returning the same digest from different type-hint formats 2. Storage: check existing layers in AttestationStorer and SimpleStorer before appending, preventing duplicates from independent signable types (TaskRunArtifact + OCIArtifact) storing to the same tag Also adds debug logging when existing layer checks fail instead of silently swallowing errors. Fixes #1596
1 parent 31743e6 commit 7d970c4

6 files changed

Lines changed: 317 additions & 1 deletion

File tree

pkg/artifacts/signable.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ func (oa *OCIArtifact) ExtractObjects(ctx context.Context, obj objects.TektonObj
164164
func ExtractOCIImagesFromResults(ctx context.Context, results []objects.Result) []interface{} {
165165
logger := logging.FromContext(ctx)
166166
objs := []interface{}{}
167+
seen := map[string]bool{}
167168

168169
extractor := structuredSignableExtractor{
169170
uriSuffix: OCIImageURLResultName,
@@ -176,6 +177,11 @@ func ExtractOCIImagesFromResults(ctx context.Context, results []objects.Result)
176177
logger.Errorf("error getting digest: %v", err)
177178
continue
178179
}
180+
if seen[dgst.DigestStr()] {
181+
logger.Debugf("skipping duplicate digest %s", dgst.DigestStr())
182+
continue
183+
}
184+
seen[dgst.DigestStr()] = true
179185
objs = append(objs, dgst)
180186
}
181187

@@ -196,6 +202,11 @@ func ExtractOCIImagesFromResults(ctx context.Context, results []objects.Result)
196202
logger.Errorf("error getting digest for img %s: %v", trimmed, err)
197203
continue
198204
}
205+
if seen[dgst.DigestStr()] {
206+
logger.Debugf("skipping duplicate digest %s", dgst.DigestStr())
207+
continue
208+
}
209+
seen[dgst.DigestStr()] = true
199210
objs = append(objs, dgst)
200211
}
201212
}

pkg/artifacts/signable_test.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,6 @@ func TestExtractOCIImagesFromResults(t *testing.T) {
188188
want := []interface{}{
189189
createDigest(t, fmt.Sprintf("img1@%s", digest1)),
190190
createDigest(t, fmt.Sprintf("img2@%s", digest2)),
191-
createDigest(t, fmt.Sprintf("img3@%s", digest1)),
192191
}
193192
ctx := logtesting.TestContextWithLogger(t)
194193
got := ExtractOCIImagesFromResults(ctx, results)
@@ -202,6 +201,24 @@ func TestExtractOCIImagesFromResults(t *testing.T) {
202201
}
203202
}
204203

204+
func TestExtractOCIImagesFromResults_CrossFormatDedup(t *testing.T) {
205+
// Same image appears via IMAGE_URL/IMAGE_DIGEST and IMAGES — should be deduplicated.
206+
results := []objects.Result{
207+
{Name: "IMAGE_URL", Value: *v1.NewStructuredValues("img1")},
208+
{Name: "IMAGE_DIGEST", Value: *v1.NewStructuredValues(digest1)},
209+
{Name: "IMAGES", Value: *v1.NewStructuredValues(fmt.Sprintf("img1@%s", digest1))},
210+
}
211+
212+
want := []interface{}{
213+
createDigest(t, fmt.Sprintf("img1@%s", digest1)),
214+
}
215+
ctx := logtesting.TestContextWithLogger(t)
216+
got := ExtractOCIImagesFromResults(ctx, results)
217+
if !cmp.Equal(got, want, ignore...) {
218+
t.Fatalf("expected dedup across type-hint formats, got %s", cmp.Diff(want, got, ignore...))
219+
}
220+
}
221+
205222
func TestExtractSignableTargetFromResults(t *testing.T) {
206223
tr := &v1.TaskRun{
207224
Status: v1.TaskRunStatus{

pkg/chains/storage/oci/attestation.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,25 @@ func (s *AttestationStorer) Store(ctx context.Context, req *api.StoreRequest[nam
7777
if err != nil {
7878
return nil, err
7979
}
80+
81+
// Check if an attestation with the same digest already exists.
82+
newDigest, err := att.Digest()
83+
if err != nil {
84+
return nil, errors.Wrap(err, "getting new attestation digest")
85+
}
86+
if existingAtts, err := se.Attestations(); err != nil {
87+
logger.Debugf("Could not fetch existing attestations for %s, skipping dedup check: %v", req.Artifact.String(), err)
88+
} else if layers, err := existingAtts.Get(); err != nil {
89+
logger.Debugf("Could not get attestation layers for %s, skipping dedup check: %v", req.Artifact.String(), err)
90+
} else {
91+
for _, l := range layers {
92+
if d, err := l.Digest(); err == nil && d == newDigest {
93+
logger.Infof("Attestation with digest %s already exists for %s, skipping", newDigest, req.Artifact.String())
94+
return &api.StoreResponse{}, nil
95+
}
96+
}
97+
}
98+
8099
newImage, err := mutate.AttachAttestationToEntity(se, att)
81100
if err != nil {
82101
return nil, err

pkg/chains/storage/oci/attestation_test.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"github.com/google/go-containerregistry/pkg/v1/remote"
2727
"github.com/google/go-containerregistry/pkg/v1/types"
2828
intoto "github.com/in-toto/attestation/go/v1"
29+
ociremote "github.com/sigstore/cosign/v2/pkg/oci/remote"
2930
"github.com/tektoncd/chains/pkg/chains/signing"
3031
"github.com/tektoncd/chains/pkg/chains/storage/api"
3132
logtesting "knative.dev/pkg/logging/testing"
@@ -109,3 +110,127 @@ func TestAttestationStorer_Store(t *testing.T) {
109110
})
110111
}
111112
}
113+
114+
func TestAttestationStorer_Store_Dedup(t *testing.T) {
115+
s := httptest.NewServer(registry.New())
116+
defer s.Close()
117+
registryName := strings.TrimPrefix(s.URL, "http://")
118+
119+
img, err := random.Image(1024, 2)
120+
if err != nil {
121+
t.Fatalf("failed to create random image: %s", err)
122+
}
123+
imgDigest, err := img.Digest()
124+
if err != nil {
125+
t.Fatalf("failed to get image digest: %v", err)
126+
}
127+
ref, err := name.NewDigest(fmt.Sprintf("%s/test/img@%s", registryName, imgDigest))
128+
if err != nil {
129+
t.Fatalf("failed to parse digest: %v", err)
130+
}
131+
if err := remote.Write(ref, img); err != nil {
132+
t.Fatalf("failed to write image to mock registry: %v", err)
133+
}
134+
135+
storer, err := NewAttestationStorer(WithTargetRepository(ref.Repository))
136+
if err != nil {
137+
t.Fatalf("failed to create storer: %v", err)
138+
}
139+
140+
ctx := logtesting.TestContextWithLogger(t)
141+
req := &api.StoreRequest[name.Digest, *intoto.Statement]{
142+
Artifact: ref,
143+
Payload: &intoto.Statement{},
144+
Bundle: &signing.Bundle{Signature: []byte("sig1")},
145+
}
146+
147+
// Store the same attestation twice.
148+
if _, err := storer.Store(ctx, req); err != nil {
149+
t.Fatalf("first Store() failed: %s", err)
150+
}
151+
if _, err := storer.Store(ctx, req); err != nil {
152+
t.Fatalf("second Store() failed: %s", err)
153+
}
154+
155+
// Verify only one attestation layer exists.
156+
se, err := ociremote.SignedEntity(ref)
157+
if err != nil {
158+
t.Fatalf("failed to get signed entity: %v", err)
159+
}
160+
atts, err := se.Attestations()
161+
if err != nil {
162+
t.Fatalf("failed to get attestations: %v", err)
163+
}
164+
layers, err := atts.Get()
165+
if err != nil {
166+
t.Fatalf("failed to get attestation layers: %v", err)
167+
}
168+
if got := len(layers); got != 1 {
169+
t.Errorf("expected 1 attestation layer, got %d", got)
170+
}
171+
}
172+
173+
func TestAttestationStorer_Store_DistinctNotDeduped(t *testing.T) {
174+
s := httptest.NewServer(registry.New())
175+
defer s.Close()
176+
registryName := strings.TrimPrefix(s.URL, "http://")
177+
178+
img, err := random.Image(1024, 2)
179+
if err != nil {
180+
t.Fatalf("failed to create random image: %s", err)
181+
}
182+
imgDigest, err := img.Digest()
183+
if err != nil {
184+
t.Fatalf("failed to get image digest: %v", err)
185+
}
186+
ref, err := name.NewDigest(fmt.Sprintf("%s/test/img@%s", registryName, imgDigest))
187+
if err != nil {
188+
t.Fatalf("failed to parse digest: %v", err)
189+
}
190+
if err := remote.Write(ref, img); err != nil {
191+
t.Fatalf("failed to write image to mock registry: %v", err)
192+
}
193+
194+
storer, err := NewAttestationStorer(WithTargetRepository(ref.Repository))
195+
if err != nil {
196+
t.Fatalf("failed to create storer: %v", err)
197+
}
198+
199+
ctx := logtesting.TestContextWithLogger(t)
200+
201+
// Store two attestations with different signatures (different layer content).
202+
req1 := &api.StoreRequest[name.Digest, *intoto.Statement]{
203+
Artifact: ref,
204+
Payload: &intoto.Statement{},
205+
Bundle: &signing.Bundle{Signature: []byte("sig1")},
206+
}
207+
req2 := &api.StoreRequest[name.Digest, *intoto.Statement]{
208+
Artifact: ref,
209+
Payload: &intoto.Statement{},
210+
Bundle: &signing.Bundle{Signature: []byte("sig2")},
211+
}
212+
213+
if _, err := storer.Store(ctx, req1); err != nil {
214+
t.Fatalf("first Store() failed: %s", err)
215+
}
216+
if _, err := storer.Store(ctx, req2); err != nil {
217+
t.Fatalf("second Store() failed: %s", err)
218+
}
219+
220+
// Verify both attestation layers are kept.
221+
se, err := ociremote.SignedEntity(ref)
222+
if err != nil {
223+
t.Fatalf("failed to get signed entity: %v", err)
224+
}
225+
atts, err := se.Attestations()
226+
if err != nil {
227+
t.Fatalf("failed to get attestations: %v", err)
228+
}
229+
layers, err := atts.Get()
230+
if err != nil {
231+
t.Fatalf("failed to get attestation layers: %v", err)
232+
}
233+
if got := len(layers); got != 2 {
234+
t.Errorf("expected 2 distinct attestation layers, got %d", got)
235+
}
236+
}

pkg/chains/storage/oci/simple.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,25 @@ func (s *SimpleStorer) Store(ctx context.Context, req *api.StoreRequest[name.Dig
7474
if err != nil {
7575
return nil, err
7676
}
77+
78+
// Check if a signature with the same payload digest already exists.
79+
newDigest, err := sig.Digest()
80+
if err != nil {
81+
return nil, errors.Wrap(err, "getting new signature digest")
82+
}
83+
if existingSigs, err := se.Signatures(); err != nil {
84+
logger.Debugf("Could not fetch existing signatures for %s, skipping dedup check: %v", req.Artifact.String(), err)
85+
} else if layers, err := existingSigs.Get(); err != nil {
86+
logger.Debugf("Could not get signature layers for %s, skipping dedup check: %v", req.Artifact.String(), err)
87+
} else {
88+
for _, l := range layers {
89+
if d, err := l.Digest(); err == nil && d == newDigest {
90+
logger.Infof("Signature with digest %s already exists, skipping", newDigest)
91+
return &api.StoreResponse{}, nil
92+
}
93+
}
94+
}
95+
7796
// Attach the signature to the entity.
7897
newSE, err := mutate.AttachSignatureToEntity(se, sig)
7998
if err != nil {

pkg/chains/storage/oci/simple_test.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"github.com/google/go-containerregistry/pkg/v1/random"
2626
"github.com/google/go-containerregistry/pkg/v1/remote"
2727
"github.com/google/go-containerregistry/pkg/v1/types"
28+
ociremote "github.com/sigstore/cosign/v2/pkg/oci/remote"
2829
"github.com/tektoncd/chains/pkg/chains/formats/simple"
2930
"github.com/tektoncd/chains/pkg/chains/signing"
3031
"github.com/tektoncd/chains/pkg/chains/storage/api"
@@ -108,3 +109,127 @@ func TestSimpleStorer_Store(t *testing.T) {
108109
})
109110
}
110111
}
112+
113+
func TestSimpleStorer_Store_Dedup(t *testing.T) {
114+
s := httptest.NewServer(registry.New())
115+
defer s.Close()
116+
registryName := strings.TrimPrefix(s.URL, "http://")
117+
118+
img, err := random.Image(1024, 2)
119+
if err != nil {
120+
t.Fatalf("failed to create random image: %s", err)
121+
}
122+
imgDigest, err := img.Digest()
123+
if err != nil {
124+
t.Fatalf("failed to get image digest: %v", err)
125+
}
126+
ref, err := name.NewDigest(fmt.Sprintf("%s/test/img@%s", registryName, imgDigest))
127+
if err != nil {
128+
t.Fatalf("failed to parse digest: %v", err)
129+
}
130+
if err := remote.Write(ref, img); err != nil {
131+
t.Fatalf("failed to write image to mock registry: %v", err)
132+
}
133+
134+
storer, err := NewSimpleStorerFromConfig(WithTargetRepository(ref.Repository))
135+
if err != nil {
136+
t.Fatalf("failed to create storer: %v", err)
137+
}
138+
139+
ctx := logtesting.TestContextWithLogger(t)
140+
req := &api.StoreRequest[name.Digest, simple.SimpleContainerImage]{
141+
Artifact: ref,
142+
Payload: simple.NewSimpleStruct(ref),
143+
Bundle: &signing.Bundle{Content: []byte("payload"), Signature: []byte("sig1")},
144+
}
145+
146+
// Store the same signature twice.
147+
if _, err := storer.Store(ctx, req); err != nil {
148+
t.Fatalf("first Store() failed: %s", err)
149+
}
150+
if _, err := storer.Store(ctx, req); err != nil {
151+
t.Fatalf("second Store() failed: %s", err)
152+
}
153+
154+
// Verify only one signature layer exists.
155+
se, err := ociremote.SignedEntity(ref)
156+
if err != nil {
157+
t.Fatalf("failed to get signed entity: %v", err)
158+
}
159+
sigs, err := se.Signatures()
160+
if err != nil {
161+
t.Fatalf("failed to get signatures: %v", err)
162+
}
163+
layers, err := sigs.Get()
164+
if err != nil {
165+
t.Fatalf("failed to get signature layers: %v", err)
166+
}
167+
if got := len(layers); got != 1 {
168+
t.Errorf("expected 1 signature layer, got %d", got)
169+
}
170+
}
171+
172+
func TestSimpleStorer_Store_DistinctNotDeduped(t *testing.T) {
173+
s := httptest.NewServer(registry.New())
174+
defer s.Close()
175+
registryName := strings.TrimPrefix(s.URL, "http://")
176+
177+
img, err := random.Image(1024, 2)
178+
if err != nil {
179+
t.Fatalf("failed to create random image: %s", err)
180+
}
181+
imgDigest, err := img.Digest()
182+
if err != nil {
183+
t.Fatalf("failed to get image digest: %v", err)
184+
}
185+
ref, err := name.NewDigest(fmt.Sprintf("%s/test/img@%s", registryName, imgDigest))
186+
if err != nil {
187+
t.Fatalf("failed to parse digest: %v", err)
188+
}
189+
if err := remote.Write(ref, img); err != nil {
190+
t.Fatalf("failed to write image to mock registry: %v", err)
191+
}
192+
193+
storer, err := NewSimpleStorerFromConfig(WithTargetRepository(ref.Repository))
194+
if err != nil {
195+
t.Fatalf("failed to create storer: %v", err)
196+
}
197+
198+
ctx := logtesting.TestContextWithLogger(t)
199+
200+
// Store two signatures with different content (different layer digests).
201+
req1 := &api.StoreRequest[name.Digest, simple.SimpleContainerImage]{
202+
Artifact: ref,
203+
Payload: simple.NewSimpleStruct(ref),
204+
Bundle: &signing.Bundle{Content: []byte("payload1"), Signature: []byte("sig1")},
205+
}
206+
req2 := &api.StoreRequest[name.Digest, simple.SimpleContainerImage]{
207+
Artifact: ref,
208+
Payload: simple.NewSimpleStruct(ref),
209+
Bundle: &signing.Bundle{Content: []byte("payload2"), Signature: []byte("sig2")},
210+
}
211+
212+
if _, err := storer.Store(ctx, req1); err != nil {
213+
t.Fatalf("first Store() failed: %s", err)
214+
}
215+
if _, err := storer.Store(ctx, req2); err != nil {
216+
t.Fatalf("second Store() failed: %s", err)
217+
}
218+
219+
// Verify both signature layers are kept.
220+
se, err := ociremote.SignedEntity(ref)
221+
if err != nil {
222+
t.Fatalf("failed to get signed entity: %v", err)
223+
}
224+
sigs, err := se.Signatures()
225+
if err != nil {
226+
t.Fatalf("failed to get signatures: %v", err)
227+
}
228+
layers, err := sigs.Get()
229+
if err != nil {
230+
t.Fatalf("failed to get signature layers: %v", err)
231+
}
232+
if got := len(layers); got != 2 {
233+
t.Errorf("expected 2 distinct signature layers, got %d", got)
234+
}
235+
}

0 commit comments

Comments
 (0)