From dce0eacfcfddbbe5edacfdd511f24f15933aecbf Mon Sep 17 00:00:00 2001 From: Stefan Walcz Date: Sat, 3 Oct 2026 16:06:04 +0000 Subject: [PATCH] fix(pii): bound the protected matcher cache Term-file reloads retain each compiled matcher indefinitely. Limit the cache to 32 lists and verify that evicted lists still match on reload. Assisted-by: Codex:GPT-6 Signed-off-by: Stefan Walcz --- core/services/routing/pii/protected.go | 27 ++++++++++++++++----- core/services/routing/pii/protected_test.go | 15 ++++++++++++ 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/core/services/routing/pii/protected.go b/core/services/routing/pii/protected.go index fefa0e981..1eeceb8e5 100644 --- a/core/services/routing/pii/protected.go +++ b/core/services/routing/pii/protected.go @@ -35,10 +35,18 @@ type shield struct { scanStart, scanEnd int } -var protectedMatchers sync.Map // key: "\x00"-joined normalised terms -> *regexp.Regexp +// File reloads can produce indefinitely many distinct lists. Retain only a +// bounded set of compiled matchers; eviction changes no detection policy. +var protectedMatchers = struct { + sync.Mutex + entries map[string]*regexp.Regexp + order []string +}{entries: make(map[string]*regexp.Regexp)} -// protectedMatcher compiles (once per distinct term list) a -// case-insensitive matcher for the terms, longest first so that "Hotel +const maxProtectedMatchers = 32 + +// protectedMatcher caches a case-insensitive matcher for the terms, +// longest first so that "Hotel // Seeblick Spa" wins over "Hotel Seeblick". Whitespace inside a term // matches any whitespace run, so a term still matches across a line wrap. // Returns nil when no usable term remains. @@ -62,8 +70,10 @@ func protectedMatcher(terms []string) *regexp.Regexp { }) norm = slices.Compact(norm) key := strings.Join(norm, "\x00") - if re, ok := protectedMatchers.Load(key); ok { - return re.(*regexp.Regexp) + protectedMatchers.Lock() + defer protectedMatchers.Unlock() + if re, ok := protectedMatchers.entries[key]; ok { + return re } alts := make([]string, len(norm)) for i, t := range norm { @@ -74,7 +84,12 @@ func protectedMatcher(terms []string) *regexp.Regexp { alts[i] = strings.Join(words, `\s+`) } re := regexp.MustCompile(`(?i)(?:` + strings.Join(alts, "|") + `)`) - protectedMatchers.Store(key, re) + if len(protectedMatchers.order) == maxProtectedMatchers { + delete(protectedMatchers.entries, protectedMatchers.order[0]) + protectedMatchers.order = protectedMatchers.order[1:] + } + protectedMatchers.entries[key] = re + protectedMatchers.order = append(protectedMatchers.order, key) return re } diff --git a/core/services/routing/pii/protected_test.go b/core/services/routing/pii/protected_test.go index d3d77c54c..c2f3d617b 100644 --- a/core/services/routing/pii/protected_test.go +++ b/core/services/routing/pii/protected_test.go @@ -155,3 +155,18 @@ var _ = Describe("LoadProtectedTerms", func() { To(Equal([]string{"x-inline"})) }) }) + +var _ = Describe("Protected matcher cache", func() { + It("releases obsolete term lists while preserving matching after eviction", func() { + terms := []string{"Evicted Supplier"} + first := protectedMatcher(terms) + for i := 0; i < 64; i++ { + protectedMatcher([]string{strings.Repeat("x", i+3)}) + } + reloaded := protectedMatcher(terms) + Expect(reloaded).NotTo(BeIdenticalTo(first), "old file revisions must not remain cached indefinitely") + text, regions := shieldProtected("Evicted Supplier, customer Lena", reloaded) + Expect(text).To(Equal(protectedPlaceholder + ", customer Lena")) + Expect(regions).To(HaveLen(1)) + }) +})