Skip to content
Open
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
44 changes: 43 additions & 1 deletion internal/rego/sigstore/sigstore.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,12 @@ package sigstore

import (
"context"
"crypto/sha512"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"sync"

"github.com/google/go-containerregistry/pkg/name"
"github.com/open-policy-agent/opa/v1/ast"
Expand All @@ -35,6 +38,7 @@ import (
"github.com/sigstore/cosign/v3/pkg/oci"
"github.com/sigstore/sigstore/pkg/tuf"
log "github.com/sirupsen/logrus"
"golang.org/x/sync/singleflight"

"github.com/conforma/cli/internal/attestation"
"github.com/conforma/cli/internal/policy"
Expand All @@ -58,6 +62,11 @@ const (
rekorPublicKeyAttribute = "rekor_public_key"
)

var (
verifyImageCache sync.Map
verifyImageFlight singleflight.Group
)

var ociImageReferenceParameter = types.Named("ref", types.S).Description("OCI image reference")

var sigstoreOptsParameter = types.Named("opts",
Expand Down Expand Up @@ -111,7 +120,6 @@ func registerSigstoreVerifyImage() {

func sigstoreVerifyImage(bctx rego.BuiltinContext, refTerm *ast.Term, optsTerm *ast.Term) (*ast.Term, error) {
logger := log.WithField("function", sigstoreVerifyImageName)
ctx := bctx.Context

uri, err := builtins.StringOperand(refTerm.Value, 0)
if err != nil {
Expand All @@ -120,6 +128,29 @@ func sigstoreVerifyImage(bctx rego.BuiltinContext, refTerm *ast.Term, optsTerm *
}
logger = logger.WithField("ref", string(uri))

// Build cache key from ref + opts hash
cacheKey := buildCacheKey(string(uri), optsTerm)

// Check cache first
if cached, ok := verifyImageCache.Load(cacheKey); ok {
logger.Debug("using cached image signature verification result")
return cached.(*ast.Term), nil
}

// Use singleflight to deduplicate concurrent requests. doVerifyImage never returns a Go
// error — failures are embedded in the result term — so the error is intentionally discarded.
resultIface, _, _ := verifyImageFlight.Do(cacheKey, func() (interface{}, error) {
return doVerifyImage(bctx, logger, uri, optsTerm)
})

result := resultIface.(*ast.Term)
verifyImageCache.Store(cacheKey, result)
return result, nil
}

func doVerifyImage(bctx rego.BuiltinContext, logger *log.Entry, uri ast.String, optsTerm *ast.Term) (*ast.Term, error) {
ctx := bctx.Context

ref, err := name.NewDigest(string(uri))
if err != nil {
logger.WithField("error", err).Debug("failed to create new digest")
Expand All @@ -144,6 +175,12 @@ func sigstoreVerifyImage(bctx rego.BuiltinContext, refTerm *ast.Term, optsTerm *
return signatureResult(signatures, nil)
}

func buildCacheKey(ref string, optsTerm *ast.Term) string {
h := sha512.Sum512([]byte(optsTerm.String()))
optsHash := hex.EncodeToString(h[:])
return ref + "|" + optsHash
}

func registerSigstoreVerifyAttestation() {
attestationType := types.Named("attestation", types.NewObject([]*types.StaticProperty{
{Key: "statement", Value: types.Named("statement", types.A).Description("statement from attestation")},
Expand Down Expand Up @@ -390,6 +427,11 @@ func attestationResult(attestations []oci.Signature, err error) (*ast.Term, erro
), nil
}

// ClearCaches clears the verify_image cache. Used for testing.
func ClearCaches() {
verifyImageCache = sync.Map{}
}

func init() {
registerSigstoreVerifyImage()
registerSigstoreVerifyAttestation()
Expand Down
86 changes: 86 additions & 0 deletions internal/rego/sigstore/sigstore_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ func TestSigstoreVerifyImage(t *testing.T) {

for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
ClearCaches()
utils.SetTestRekorPublicKey(t)
utils.SetTestFulcioRoots(t)
utils.SetTestCTLogPublicKey(t)
Expand Down Expand Up @@ -208,6 +209,91 @@ func TestSigstoreVerifyImage(t *testing.T) {
}
}

func TestVerifyImageCache(t *testing.T) {
image1 := name.MustParseReference(
"registry.local/spam@sha256:4e388ab32b10dc8dbc7e28144f552830adc74787c1e2c0824032078a79f227fb",
)
image2 := name.MustParseReference(
"registry.local/eggs@sha256:5e388ab32b10dc8dbc7e28144f552830adc74787c1e2c0824032078a79f227fb",
)
opts1 := options{ignoreRekor: true, publicKey: utils.TestPublicKey}
opts2 := options{publicKey: utils.TestPublicKey, rekorURL: "https://rekor.local"}

newFakeContext := func(t *testing.T) (*fake.FakeClient, rego.BuiltinContext) {
t.Helper()
utils.SetTestRekorPublicKey(t)
utils.SetTestFulcioRoots(t)
utils.SetTestCTLogPublicKey(t)

sig, err := static.NewSignature(
[]byte(`image`),
"signature",
static.WithLayerMediaType(types.MediaType(cosignTypes.DssePayloadType)),
)
require.NoError(t, err)

c := &fake.FakeClient{}
c.On("VerifyImageSignatures", mock.Anything, mock.Anything).Return([]oci.Signature{sig}, false, nil)
ctx := o.WithClient(context.Background(), c)
return c, rego.BuiltinContext{Context: ctx}
}

t.Run("same ref and opts returns cached result", func(t *testing.T) {
ClearCaches()
c, bctx := newFakeContext(t)

r1, err := sigstoreVerifyImage(bctx, ast.StringTerm(image1.String()), opts1.toTerm())
require.NoError(t, err)

r2, err := sigstoreVerifyImage(bctx, ast.StringTerm(image1.String()), opts1.toTerm())
require.NoError(t, err)

require.Equal(t, r1, r2)
c.AssertNumberOfCalls(t, "VerifyImageSignatures", 1)
})

t.Run("different ref is a cache miss", func(t *testing.T) {
ClearCaches()
c, bctx := newFakeContext(t)

_, err := sigstoreVerifyImage(bctx, ast.StringTerm(image1.String()), opts1.toTerm())
require.NoError(t, err)

_, err = sigstoreVerifyImage(bctx, ast.StringTerm(image2.String()), opts1.toTerm())
require.NoError(t, err)

c.AssertNumberOfCalls(t, "VerifyImageSignatures", 2)
})

t.Run("different opts is a cache miss", func(t *testing.T) {
ClearCaches()
c, bctx := newFakeContext(t)

_, err := sigstoreVerifyImage(bctx, ast.StringTerm(image1.String()), opts1.toTerm())
require.NoError(t, err)

_, err = sigstoreVerifyImage(bctx, ast.StringTerm(image1.String()), opts2.toTerm())
require.NoError(t, err)

c.AssertNumberOfCalls(t, "VerifyImageSignatures", 2)
})

t.Run("ClearCaches forces re-verification", func(t *testing.T) {
ClearCaches()
c, bctx := newFakeContext(t)

_, err := sigstoreVerifyImage(bctx, ast.StringTerm(image1.String()), opts1.toTerm())
require.NoError(t, err)
c.AssertNumberOfCalls(t, "VerifyImageSignatures", 1)

ClearCaches()

_, err = sigstoreVerifyImage(bctx, ast.StringTerm(image1.String()), opts1.toTerm())
require.NoError(t, err)
c.AssertNumberOfCalls(t, "VerifyImageSignatures", 2)
})
}

func TestSigstoreVerifyAttestation(t *testing.T) {
goodImage := name.MustParseReference(
"registry.local/spam@sha256:4e388ab32b10dc8dbc7e28144f552830adc74787c1e2c0824032078a79f227fb",
Expand Down
Loading