From 90a9d03e44ec5bd1b9ffcc4fa6126850ebcb444f Mon Sep 17 00:00:00 2001 From: Stefano Pentassuglia Date: Tue, 31 Mar 2026 14:35:51 +0200 Subject: [PATCH] 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 --- internal/rego/oci/oci.go | 82 ++++++++++++++++++++++++++++------- internal/rego/oci/oci_test.go | 76 +++++++++++++++++++++++++++++++- 2 files changed, 142 insertions(+), 16 deletions(-) diff --git a/internal/rego/oci/oci.go b/internal/rego/oci/oci.go index 17c54b8d1..e1019c311 100644 --- a/internal/rego/oci/oci.go +++ b/internal/rego/oci/oci.go @@ -30,6 +30,7 @@ import ( "fmt" "io" "path" + "regexp" "runtime" "strings" "sync" @@ -68,6 +69,10 @@ const ( var maxTarEntrySize int64 = maxTarEntrySizeConst // Use var to allow override in tests +// cosignTagPattern matches legacy cosign tag refs produced by ec.oci.image_tag_refs. +// Format: sha256-.(sbom|att|sig) — e.g. "sha256-d34db33f...abcd.sbom" +var cosignTagPattern = regexp.MustCompile(`:sha256-[0-9a-f]+\.(sbom|att|sig)$`) + func registerOCIBlob() { decl := rego.Function{ Name: ociBlobName, @@ -515,22 +520,70 @@ func ociBlobInternal(bctx rego.BuiltinContext, a *ast.Term, verifyDigest bool) ( } logger.Debug("Starting blob retrieval") - ref, err := name.NewDigest(refStr) - if err != nil { - logger.WithFields(log.Fields{ - "action": "new digest", - "error": err, - }).Error("failed to create new digest") - return nil, nil //nolint:nilerr // intentional: return nil to signal failure without OPA error - } + client := oci.NewClient(bctx.Context) - rawLayer, err := oci.NewClient(bctx.Context).Layer(ref) + var rawLayer v1.Layer + var expectedDigest string + ref, err := name.NewDigest(refStr) if err != nil { - logger.WithFields(log.Fields{ - "action": "fetch layer", - "error": err, - }).Error("failed to fetch OCI layer") - return nil, nil //nolint:nilerr + // Fall back to tag-based fetch: resolve the tag to an image and + // extract the first layer. This supports legacy cosign tag refs + // (e.g. .sbom, .att) returned by ec.oci.image_tag_refs. + if !cosignTagPattern.MatchString(refStr) { + logger.WithFields(log.Fields{ + "action": "parse digest", + "error": err, + // Debug, not Error: this is the expected path for non-cosign refs, not a failure. + }).Debug("ref is not a digest ref and not a cosign tag artifact") + return nil, nil //nolint:nilerr + } + tagRef, tagErr := name.ParseReference(refStr) + if tagErr != nil { + logger.WithFields(log.Fields{ + "action": "parse reference", + "error": tagErr, + }).Error("failed to parse reference") + return nil, nil //nolint:nilerr + } + img, imgErr := client.Image(tagRef) + if imgErr != nil { + logger.WithFields(log.Fields{ + "action": "fetch image", + "error": imgErr, + }).Error("failed to fetch image for tag reference") + return nil, nil //nolint:nilerr + } + layers, layersErr := img.Layers() + if layersErr != nil || len(layers) == 0 { + logger.WithFields(log.Fields{ + "action": "get layers", + "error": layersErr, + }).Error("failed to get layers from image") + return nil, nil //nolint:nilerr + } + rawLayer = layers[0] + layerDigest, digestErr := rawLayer.Digest() + if digestErr != nil { + logger.WithFields(log.Fields{ + "action": "get layer digest", + "error": digestErr, + }).Error("failed to get layer digest") + return nil, nil //nolint:nilerr + } + // For tag refs, digest verification is intentionally weak: it only checks + // transport integrity (computed vs registry-reported digest), not a + // caller-specified expectation, since there is no digest in the ref. + expectedDigest = layerDigest.String() + } else { + expectedDigest = ref.DigestStr() + rawLayer, err = client.Layer(ref) + if err != nil { + logger.WithFields(log.Fields{ + "action": "fetch layer", + "error": err, + }).Error("failed to fetch OCI layer") + return nil, nil //nolint:nilerr + } } layer, err := rawLayer.Uncompressed() @@ -572,7 +625,6 @@ func ociBlobInternal(bctx rego.BuiltinContext, a *ast.Term, verifyDigest bool) ( // that's not as easy as it sounds, since it may require another // io.Copy which could be inefficient. For now let's just skip it. // - expectedDigest := ref.DigestStr() if verifyDigest { computedDigest := fmt.Sprintf("sha256:%x", hasher.Sum(nil)) if computedDigest != expectedDigest { diff --git a/internal/rego/oci/oci_test.go b/internal/rego/oci/oci_test.go index 8156b4d97..e5ae1e98b 100644 --- a/internal/rego/oci/oci_test.go +++ b/internal/rego/oci/oci_test.go @@ -35,6 +35,7 @@ import ( "github.com/google/go-containerregistry/pkg/name" "github.com/google/go-containerregistry/pkg/registry" v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" v1fake "github.com/google/go-containerregistry/pkg/v1/fake" "github.com/google/go-containerregistry/pkg/v1/mutate" "github.com/google/go-containerregistry/pkg/v1/partial" @@ -62,6 +63,10 @@ func TestOCIBlob(t *testing.T) { uri *ast.Term err bool remoteErr error + tagRef bool // when true, mock Image() instead of Layer() for tag-based fetch + imageErr error + noLayers bool // when true with tagRef, mock Image() to return an image with no layers + layersErr error // when set with tagRef, mock Image() to return a fake image that errors on Layers() }{ { name: "success", @@ -99,6 +104,60 @@ func TestOCIBlob(t *testing.T) { uri: ast.StringTerm("registry.local/spam@sha256:4bbf56a3a9231f752d3b9c174637975f0f83ed2b15e65799837c571e4ef3374b"), err: true, }, + { + name: "tag reference fetches first layer from image", + data: `{"bomFormat": "CycloneDX", "specVersion": "1.6"}`, + uri: ast.StringTerm("registry.local/spam:sha256-abc123def456.sbom"), + tagRef: true, + }, + { + name: "tag reference with image fetch error", + data: `{"spam": "maps"}`, + uri: ast.StringTerm("registry.local/spam:sha256-abc123def456.sbom"), + tagRef: true, + imageErr: errors.New("image not found"), + err: true, + }, + { + name: "tag reference with no layers", + data: `{"spam": "maps"}`, + uri: ast.StringTerm("registry.local/spam:sha256-abc123def456.sbom"), + tagRef: true, + noLayers: true, + err: true, + }, + { + name: "tag reference layers error", + data: `{"spam": "maps"}`, + uri: ast.StringTerm("registry.local/spam:sha256-abc123def456.sbom"), + tagRef: true, + layersErr: errors.New("layers failed"), + err: true, + }, + { + name: "non-cosign tag reference returns nil without image fetch", + data: `{"spam": "maps"}`, + uri: ast.StringTerm("registry.local/spam:latest"), + err: true, + }, + { + name: "non-cosign tag with cosign suffix but wrong format", + data: `{"spam": "maps"}`, + uri: ast.StringTerm("registry.local/spam:release.sbom"), + err: true, + }, + { + name: "tag reference fetches first layer from image with .att suffix", + data: `{"bomFormat": "CycloneDX", "specVersion": "1.6"}`, + uri: ast.StringTerm("registry.local/spam:sha256-abc123def456.att"), + tagRef: true, + }, + { + name: "tag reference fetches first layer from image with .sig suffix", + data: `{"bomFormat": "CycloneDX", "specVersion": "1.6"}`, + uri: ast.StringTerm("registry.local/spam:sha256-abc123def456.sig"), + tagRef: true, + }, } for _, c := range cases { @@ -106,7 +165,22 @@ func TestOCIBlob(t *testing.T) { ClearCaches() // Clear cache before each subtest client := fake.FakeClient{} - if c.remoteErr != nil { + if c.tagRef { + if c.imageErr != nil { + client.On("Image", mock.Anything).Return(nil, c.imageErr) + } else if c.noLayers { + client.On("Image", mock.Anything).Return(empty.Image, nil) + } else if c.layersErr != nil { + fakeImg := &v1fake.FakeImage{} + fakeImg.LayersReturns(nil, c.layersErr) + client.On("Image", mock.Anything).Return(fakeImg, nil) + } else { + layer := static.NewLayer([]byte(c.data), types.OCIUncompressedLayer) + img, imgErr := mutate.AppendLayers(empty.Image, layer) + require.NoError(t, imgErr) + client.On("Image", mock.Anything).Return(img, nil) + } + } else if c.remoteErr != nil { client.On("Layer", mock.Anything, mock.Anything).Return(nil, c.remoteErr) } else { layer := static.NewLayer([]byte(c.data), types.OCIUncompressedLayer)