From e145df4ced7664ebb0d5281b4c468f8799d0a7e2 Mon Sep 17 00:00:00 2001 From: arewm Date: Fri, 27 Feb 2026 16:29:43 -0500 Subject: [PATCH] sigstore: Cache verify_image results across policy evaluations Add a sync.Map + singleflight.Group cache for ec.sigstore.verify_image results keyed by ref + opts hash. OPA's Memoize only deduplicates within a single Eval() call, but components are validated in separate Eval() calls. Since bundles are pinned by digest, verification results are stable and can be safely cached for the process lifetime. This prevents redundant signature verification when many components share the same task bundles (e.g., 100 components using git-clone). Ref: EC-1545 Assisted-by: Claude Code (Sonnet 4.6) Signed-off-by: arewm --- internal/rego/sigstore/sigstore.go | 48 ++++++++++++- internal/rego/sigstore/sigstore_test.go | 89 +++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 1 deletion(-) diff --git a/internal/rego/sigstore/sigstore.go b/internal/rego/sigstore/sigstore.go index fdc1460d2..0dda2c320 100644 --- a/internal/rego/sigstore/sigstore.go +++ b/internal/rego/sigstore/sigstore.go @@ -22,9 +22,12 @@ package sigstore import ( "context" + "crypto/sha256" + "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,33 @@ 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) + }) + + // We cache successes and failures here. It would be possible to avoid caching + // failures such as transient network problems that might suceeed if tried again, + // but I think it's likely not worth the effort, so we won't do that now. + 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") @@ -159,6 +194,12 @@ func sigstoreVerifyImage(bctx rego.BuiltinContext, refTerm *ast.Term, optsTerm * return signatureResult(signatures, nil) } +func buildCacheKey(ref string, optsTerm *ast.Term) string { + h := sha256.Sum256([]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")}, @@ -440,6 +481,11 @@ func attestationResult(attestations []oci.Signature, useBundles bool, err error) ), 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 2820a09c9..88d8c12f1 100644 --- a/internal/rego/sigstore/sigstore_test.go +++ b/internal/rego/sigstore/sigstore_test.go @@ -176,6 +176,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) @@ -210,6 +211,92 @@ 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("HasBundles", mock.Anything, mock.Anything).Return(false, nil) + 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", @@ -600,6 +687,7 @@ func TestSigstoreVerifyImageWithBundles(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) @@ -661,6 +749,7 @@ func TestSigstoreVerifyImageBundleFallback(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)