Skip to content

Commit 90a9d03

Browse files
st3pentaclaude
andcommitted
Support tag-based refs in ec.oci.blob
The ec.oci.blob builtin only accepted digest refs (repo@sha256:...) via name.NewDigest(). Tag refs returned by ec.oci.image_tag_refs (e.g. repo:sha256-abc.sbom) were rejected, causing errors during legacy cosign SBOM discovery. Add a fallback for cosign tag suffixes (.sbom, .att, .sig) that resolves the tag to an image and extracts the first layer. Preserve the original digest-based verification for digest refs and use layer-reported digest for tag refs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent bf7cd76 commit 90a9d03

2 files changed

Lines changed: 142 additions & 16 deletions

File tree

internal/rego/oci/oci.go

Lines changed: 67 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import (
3030
"fmt"
3131
"io"
3232
"path"
33+
"regexp"
3334
"runtime"
3435
"strings"
3536
"sync"
@@ -68,6 +69,10 @@ const (
6869

6970
var maxTarEntrySize int64 = maxTarEntrySizeConst // Use var to allow override in tests
7071

72+
// cosignTagPattern matches legacy cosign tag refs produced by ec.oci.image_tag_refs.
73+
// Format: sha256-<hex>.(sbom|att|sig) — e.g. "sha256-d34db33f...abcd.sbom"
74+
var cosignTagPattern = regexp.MustCompile(`:sha256-[0-9a-f]+\.(sbom|att|sig)$`)
75+
7176
func registerOCIBlob() {
7277
decl := rego.Function{
7378
Name: ociBlobName,
@@ -515,22 +520,70 @@ func ociBlobInternal(bctx rego.BuiltinContext, a *ast.Term, verifyDigest bool) (
515520
}
516521
logger.Debug("Starting blob retrieval")
517522

518-
ref, err := name.NewDigest(refStr)
519-
if err != nil {
520-
logger.WithFields(log.Fields{
521-
"action": "new digest",
522-
"error": err,
523-
}).Error("failed to create new digest")
524-
return nil, nil //nolint:nilerr // intentional: return nil to signal failure without OPA error
525-
}
523+
client := oci.NewClient(bctx.Context)
526524

527-
rawLayer, err := oci.NewClient(bctx.Context).Layer(ref)
525+
var rawLayer v1.Layer
526+
var expectedDigest string
527+
ref, err := name.NewDigest(refStr)
528528
if err != nil {
529-
logger.WithFields(log.Fields{
530-
"action": "fetch layer",
531-
"error": err,
532-
}).Error("failed to fetch OCI layer")
533-
return nil, nil //nolint:nilerr
529+
// Fall back to tag-based fetch: resolve the tag to an image and
530+
// extract the first layer. This supports legacy cosign tag refs
531+
// (e.g. .sbom, .att) returned by ec.oci.image_tag_refs.
532+
if !cosignTagPattern.MatchString(refStr) {
533+
logger.WithFields(log.Fields{
534+
"action": "parse digest",
535+
"error": err,
536+
// Debug, not Error: this is the expected path for non-cosign refs, not a failure.
537+
}).Debug("ref is not a digest ref and not a cosign tag artifact")
538+
return nil, nil //nolint:nilerr
539+
}
540+
tagRef, tagErr := name.ParseReference(refStr)
541+
if tagErr != nil {
542+
logger.WithFields(log.Fields{
543+
"action": "parse reference",
544+
"error": tagErr,
545+
}).Error("failed to parse reference")
546+
return nil, nil //nolint:nilerr
547+
}
548+
img, imgErr := client.Image(tagRef)
549+
if imgErr != nil {
550+
logger.WithFields(log.Fields{
551+
"action": "fetch image",
552+
"error": imgErr,
553+
}).Error("failed to fetch image for tag reference")
554+
return nil, nil //nolint:nilerr
555+
}
556+
layers, layersErr := img.Layers()
557+
if layersErr != nil || len(layers) == 0 {
558+
logger.WithFields(log.Fields{
559+
"action": "get layers",
560+
"error": layersErr,
561+
}).Error("failed to get layers from image")
562+
return nil, nil //nolint:nilerr
563+
}
564+
rawLayer = layers[0]
565+
layerDigest, digestErr := rawLayer.Digest()
566+
if digestErr != nil {
567+
logger.WithFields(log.Fields{
568+
"action": "get layer digest",
569+
"error": digestErr,
570+
}).Error("failed to get layer digest")
571+
return nil, nil //nolint:nilerr
572+
}
573+
// For tag refs, digest verification is intentionally weak: it only checks
574+
// transport integrity (computed vs registry-reported digest), not a
575+
// caller-specified expectation, since there is no digest in the ref.
576+
expectedDigest = layerDigest.String()
577+
} else {
578+
expectedDigest = ref.DigestStr()
579+
rawLayer, err = client.Layer(ref)
580+
if err != nil {
581+
logger.WithFields(log.Fields{
582+
"action": "fetch layer",
583+
"error": err,
584+
}).Error("failed to fetch OCI layer")
585+
return nil, nil //nolint:nilerr
586+
}
534587
}
535588

536589
layer, err := rawLayer.Uncompressed()
@@ -572,7 +625,6 @@ func ociBlobInternal(bctx rego.BuiltinContext, a *ast.Term, verifyDigest bool) (
572625
// that's not as easy as it sounds, since it may require another
573626
// io.Copy which could be inefficient. For now let's just skip it.
574627
//
575-
expectedDigest := ref.DigestStr()
576628
if verifyDigest {
577629
computedDigest := fmt.Sprintf("sha256:%x", hasher.Sum(nil))
578630
if computedDigest != expectedDigest {

internal/rego/oci/oci_test.go

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import (
3535
"github.com/google/go-containerregistry/pkg/name"
3636
"github.com/google/go-containerregistry/pkg/registry"
3737
v1 "github.com/google/go-containerregistry/pkg/v1"
38+
"github.com/google/go-containerregistry/pkg/v1/empty"
3839
v1fake "github.com/google/go-containerregistry/pkg/v1/fake"
3940
"github.com/google/go-containerregistry/pkg/v1/mutate"
4041
"github.com/google/go-containerregistry/pkg/v1/partial"
@@ -62,6 +63,10 @@ func TestOCIBlob(t *testing.T) {
6263
uri *ast.Term
6364
err bool
6465
remoteErr error
66+
tagRef bool // when true, mock Image() instead of Layer() for tag-based fetch
67+
imageErr error
68+
noLayers bool // when true with tagRef, mock Image() to return an image with no layers
69+
layersErr error // when set with tagRef, mock Image() to return a fake image that errors on Layers()
6570
}{
6671
{
6772
name: "success",
@@ -99,14 +104,83 @@ func TestOCIBlob(t *testing.T) {
99104
uri: ast.StringTerm("registry.local/spam@sha256:4bbf56a3a9231f752d3b9c174637975f0f83ed2b15e65799837c571e4ef3374b"),
100105
err: true,
101106
},
107+
{
108+
name: "tag reference fetches first layer from image",
109+
data: `{"bomFormat": "CycloneDX", "specVersion": "1.6"}`,
110+
uri: ast.StringTerm("registry.local/spam:sha256-abc123def456.sbom"),
111+
tagRef: true,
112+
},
113+
{
114+
name: "tag reference with image fetch error",
115+
data: `{"spam": "maps"}`,
116+
uri: ast.StringTerm("registry.local/spam:sha256-abc123def456.sbom"),
117+
tagRef: true,
118+
imageErr: errors.New("image not found"),
119+
err: true,
120+
},
121+
{
122+
name: "tag reference with no layers",
123+
data: `{"spam": "maps"}`,
124+
uri: ast.StringTerm("registry.local/spam:sha256-abc123def456.sbom"),
125+
tagRef: true,
126+
noLayers: true,
127+
err: true,
128+
},
129+
{
130+
name: "tag reference layers error",
131+
data: `{"spam": "maps"}`,
132+
uri: ast.StringTerm("registry.local/spam:sha256-abc123def456.sbom"),
133+
tagRef: true,
134+
layersErr: errors.New("layers failed"),
135+
err: true,
136+
},
137+
{
138+
name: "non-cosign tag reference returns nil without image fetch",
139+
data: `{"spam": "maps"}`,
140+
uri: ast.StringTerm("registry.local/spam:latest"),
141+
err: true,
142+
},
143+
{
144+
name: "non-cosign tag with cosign suffix but wrong format",
145+
data: `{"spam": "maps"}`,
146+
uri: ast.StringTerm("registry.local/spam:release.sbom"),
147+
err: true,
148+
},
149+
{
150+
name: "tag reference fetches first layer from image with .att suffix",
151+
data: `{"bomFormat": "CycloneDX", "specVersion": "1.6"}`,
152+
uri: ast.StringTerm("registry.local/spam:sha256-abc123def456.att"),
153+
tagRef: true,
154+
},
155+
{
156+
name: "tag reference fetches first layer from image with .sig suffix",
157+
data: `{"bomFormat": "CycloneDX", "specVersion": "1.6"}`,
158+
uri: ast.StringTerm("registry.local/spam:sha256-abc123def456.sig"),
159+
tagRef: true,
160+
},
102161
}
103162

104163
for _, c := range cases {
105164
t.Run(c.name, func(t *testing.T) {
106165
ClearCaches() // Clear cache before each subtest
107166

108167
client := fake.FakeClient{}
109-
if c.remoteErr != nil {
168+
if c.tagRef {
169+
if c.imageErr != nil {
170+
client.On("Image", mock.Anything).Return(nil, c.imageErr)
171+
} else if c.noLayers {
172+
client.On("Image", mock.Anything).Return(empty.Image, nil)
173+
} else if c.layersErr != nil {
174+
fakeImg := &v1fake.FakeImage{}
175+
fakeImg.LayersReturns(nil, c.layersErr)
176+
client.On("Image", mock.Anything).Return(fakeImg, nil)
177+
} else {
178+
layer := static.NewLayer([]byte(c.data), types.OCIUncompressedLayer)
179+
img, imgErr := mutate.AppendLayers(empty.Image, layer)
180+
require.NoError(t, imgErr)
181+
client.On("Image", mock.Anything).Return(img, nil)
182+
}
183+
} else if c.remoteErr != nil {
110184
client.On("Layer", mock.Anything, mock.Anything).Return(nil, c.remoteErr)
111185
} else {
112186
layer := static.NewLayer([]byte(c.data), types.OCIUncompressedLayer)

0 commit comments

Comments
 (0)