diff --git a/internal/rego/sigstore/sigstore.go b/internal/rego/sigstore/sigstore.go index 82e6ad769..d562afd32 100644 --- a/internal/rego/sigstore/sigstore.go +++ b/internal/rego/sigstore/sigstore.go @@ -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" @@ -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" @@ -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", @@ -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 { @@ -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") @@ -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")}, @@ -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() diff --git a/internal/rego/sigstore/sigstore_test.go b/internal/rego/sigstore/sigstore_test.go index 4414a2816..ff6a217cb 100644 --- a/internal/rego/sigstore/sigstore_test.go +++ b/internal/rego/sigstore/sigstore_test.go @@ -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) @@ -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",