From c504a191f4d31c454858673f65596c7844da230c Mon Sep 17 00:00:00 2001 From: arewm Date: Fri, 27 Feb 2026 16:29:43 -0500 Subject: [PATCH 1/2] 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 | 44 ++++++++++++- internal/rego/sigstore/sigstore_test.go | 86 +++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 1 deletion(-) diff --git a/internal/rego/sigstore/sigstore.go b/internal/rego/sigstore/sigstore.go index 82e6ad769..d0001dd34 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,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 := sha256.Sum256([]byte(optsTerm.String())) + optsHash := hex.EncodeToString(h[:8]) // First 8 bytes is enough for dedup + 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", From c8a13b98e1f18bb5f8f7e4d9cf3a22d04ea1564c Mon Sep 17 00:00:00 2001 From: arewm Date: Fri, 27 Feb 2026 22:36:01 -0500 Subject: [PATCH 2/2] sigstore: Use full SHA-512 for verify_image cache key The truncated 8-byte SHA-256 is vulnerable to offline collision attacks since cache key inputs are observable across invocations. SHA-512 provides adequate post-quantum collision resistance for this security-sensitive cache key. Assisted-by: Claude Code (Sonnet 4.6) Signed-off-by: arewm --- internal/rego/sigstore/sigstore.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/rego/sigstore/sigstore.go b/internal/rego/sigstore/sigstore.go index d0001dd34..d562afd32 100644 --- a/internal/rego/sigstore/sigstore.go +++ b/internal/rego/sigstore/sigstore.go @@ -22,7 +22,7 @@ package sigstore import ( "context" - "crypto/sha256" + "crypto/sha512" "encoding/hex" "encoding/json" "fmt" @@ -176,8 +176,8 @@ func doVerifyImage(bctx rego.BuiltinContext, logger *log.Entry, uri ast.String, } func buildCacheKey(ref string, optsTerm *ast.Term) string { - h := sha256.Sum256([]byte(optsTerm.String())) - optsHash := hex.EncodeToString(h[:8]) // First 8 bytes is enough for dedup + h := sha512.Sum512([]byte(optsTerm.String())) + optsHash := hex.EncodeToString(h[:]) return ref + "|" + optsHash }