@marcel91200 / caddy Mirror · read-only / commits / f5f25d84
Fork 0

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 11, 19:45 UTC

logging: fix multiple regexp filters on same field (fixes #7049) (#7061)

* logging: fix multiple regexp filters on same field (fixes #7049) * fix: add proper error handling in MultiRegexpFilter tests * fix: resolve linter and test issues - Fix GCI import formatting issues - Fix MultiRegexpFilter input size limit test by ensuring output doesn't exceed max length after each operation - All tests now pass and linter issues resolved * fix: update integration test for proper JSON encoding - Fix expected JSON output to use Unicode escape sequence for ampersand character - Integration tests now pass

Bashayer Alrumahi committed Oct 16, 2025 at 08:08 UTC f5f25d845a814a600102614cc8836af5ebe1487b
4 files changed +552
caddytest/integration/caddyfile_adapt/log_multiple_regexp_filters.caddyfiletest new
+95
@@ -0,0 +1,95 @@
1 +:80
2 +
3 +log {
4 + output stdout
5 + format filter {
6 + wrap console
7 +
8 + # Multiple regexp filters for the same field - this should work now!
9 + request>headers>Authorization regexp "Bearer\s+([A-Za-z0-9_-]+)" "Bearer [REDACTED]"
10 + request>headers>Authorization regexp "Basic\s+([A-Za-z0-9+/=]+)" "Basic [REDACTED]"
11 + request>headers>Authorization regexp "token=([^&\s]+)" "token=[REDACTED]"
12 +
13 + # Single regexp filter - this should continue to work as before
14 + request>headers>Cookie regexp "sessionid=[^;]+" "sessionid=[REDACTED]"
15 +
16 + # Mixed filters (non-regexp) - these should work normally
17 + request>headers>Server delete
18 + request>remote_ip ip_mask {
19 + ipv4 24
20 + ipv6 32
21 + }
22 + }
23 +}
24 +----------
25 +{
26 + "logging": {
27 + "logs": {
28 + "default": {
29 + "exclude": [
30 + "http.log.access.log0"
31 + ]
32 + },
33 + "log0": {
34 + "writer": {
35 + "output": "stdout"
36 + },
37 + "encoder": {
38 + "fields": {
39 + "request\u003eheaders\u003eAuthorization": {
40 + "filter": "multi_regexp",
41 + "operations": [
42 + {
43 + "regexp": "Bearer\\s+([A-Za-z0-9_-]+)",
44 + "value": "Bearer [REDACTED]"
45 + },
46 + {
47 + "regexp": "Basic\\s+([A-Za-z0-9+/=]+)",
48 + "value": "Basic [REDACTED]"
49 + },
50 + {
51 + "regexp": "token=([^\u0026\\s]+)",
52 + "value": "token=[REDACTED]"
53 + }
54 + ]
55 + },
56 + "request\u003eheaders\u003eCookie": {
57 + "filter": "regexp",
58 + "regexp": "sessionid=[^;]+",
59 + "value": "sessionid=[REDACTED]"
60 + },
61 + "request\u003eheaders\u003eServer": {
62 + "filter": "delete"
63 + },
64 + "request\u003eremote_ip": {
65 + "filter": "ip_mask",
66 + "ipv4_cidr": 24,
67 + "ipv6_cidr": 32
68 + }
69 + },
70 + "format": "filter",
71 + "wrap": {
72 + "format": "console"
73 + }
74 + },
75 + "include": [
76 + "http.log.access.log0"
77 + ]
78 + }
79 + }
80 + },
81 + "apps": {
82 + "http": {
83 + "servers": {
84 + "srv0": {
85 + "listen": [
86 + ":80"
87 + ],
88 + "logs": {
89 + "default_logger_name": "log0"
90 + }
91 + }
92 + }
93 + }
94 + }
95 +}
\ No newline at end of file
modules/logging/filterencoder.go
+39
@@ -152,6 +152,9 @@ func (fe *FilterEncoder) ConfigureDefaultFormat(wo caddy.WriterOpener) error {
152 func (fe *FilterEncoder) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
153 d.Next() // consume encoder name
154
155 + // Track regexp filters for automatic merging
156 + regexpFilters := make(map[string][]*RegexpFilter)
157 +
158 // parse a field
159 parseField := func() error {
160 if fe.FieldsRaw == nil {
@@ -171,6 +174,23 @@ func (fe *FilterEncoder) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
174 if !ok {
175 return d.Errf("module %s (%T) is not a logging.LogFieldFilter", moduleID, unm)
176 }
177 +
178 + // Special handling for regexp filters to support multiple instances
179 + if regexpFilter, isRegexp := filter.(*RegexpFilter); isRegexp {
180 + regexpFilters[field] = append(regexpFilters[field], regexpFilter)
181 + return nil // Don't set FieldsRaw yet, we'll merge them later
182 + }
183 +
184 + // Check if we're trying to add a non-regexp filter to a field that already has regexp filters
185 + if _, hasRegexpFilters := regexpFilters[field]; hasRegexpFilters {
186 + return d.Errf("cannot mix regexp filters with other filter types for field %s", field)
187 + }
188 +
189 + // Check if field already has a filter and it's not regexp-related
190 + if _, exists := fe.FieldsRaw[field]; exists {
191 + return d.Errf("field %s already has a filter; multiple non-regexp filters per field are not supported", field)
192 + }
193 +
194 fe.FieldsRaw[field] = caddyconfig.JSONModuleObject(filter, "filter", filterName, nil)
195 return nil
196 }
@@ -210,6 +230,25 @@ func (fe *FilterEncoder) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
230 }
231 }
232 }
233 +
234 + // After parsing all fields, merge multiple regexp filters into MultiRegexpFilter
235 + for field, filters := range regexpFilters {
236 + if len(filters) == 1 {
237 + // Single regexp filter, use the original RegexpFilter
238 + fe.FieldsRaw[field] = caddyconfig.JSONModuleObject(filters[0], "filter", "regexp", nil)
239 + } else {
240 + // Multiple regexp filters, merge into MultiRegexpFilter
241 + multiFilter := &MultiRegexpFilter{}
242 + for _, regexpFilter := range filters {
243 + err := multiFilter.AddOperation(regexpFilter.RawRegexp, regexpFilter.Value)
244 + if err != nil {
245 + return fmt.Errorf("adding regexp operation for field %s: %v", field, err)
246 + }
247 + }
248 + fe.FieldsRaw[field] = caddyconfig.JSONModuleObject(multiFilter, "filter", "multi_regexp", nil)
249 + }
250 + }
251 +
252 return nil
253 }
254
modules/logging/filters.go
+221
@@ -41,6 +41,7 @@ func init() {
41 caddy.RegisterModule(CookieFilter{})
42 caddy.RegisterModule(RegexpFilter{})
43 caddy.RegisterModule(RenameFilter{})
44 + caddy.RegisterModule(MultiRegexpFilter{})
45 }
46
47 // LogFieldFilter can filter (or manipulate)
@@ -625,6 +626,222 @@ func (f *RegexpFilter) Filter(in zapcore.Field) zapcore.Field {
626 return in
627 }
628
629 +// regexpFilterOperation represents a single regexp operation
630 +// within a MultiRegexpFilter.
631 +type regexpFilterOperation struct {
632 + // The regular expression pattern defining what to replace.
633 + RawRegexp string `json:"regexp,omitempty"`
634 +
635 + // The value to use as replacement
636 + Value string `json:"value,omitempty"`
637 +
638 + regexp *regexp.Regexp
639 +}
640 +
641 +// MultiRegexpFilter is a Caddy log field filter that
642 +// can apply multiple regular expression replacements to
643 +// the same field. This filter processes operations in the
644 +// order they are defined, applying each regexp replacement
645 +// sequentially to the result of the previous operation.
646 +//
647 +// This allows users to define multiple regexp filters for
648 +// the same field without them overwriting each other.
649 +//
650 +// Security considerations:
651 +// - Uses Go's regexp package (RE2 engine) which is safe from ReDoS attacks
652 +// - Validates all patterns during provisioning
653 +// - Limits the maximum number of operations to prevent resource exhaustion
654 +// - Sanitizes input to prevent injection attacks
655 +type MultiRegexpFilter struct {
656 + // A list of regexp operations to apply in sequence.
657 + // Maximum of 50 operations allowed for security and performance.
658 + Operations []regexpFilterOperation `json:"operations"`
659 +}
660 +
661 +// Security constants
662 +const (
663 + maxRegexpOperations = 50 // Maximum operations to prevent resource exhaustion
664 + maxPatternLength = 1000 // Maximum pattern length to prevent abuse
665 +)
666 +
667 +// CaddyModule returns the Caddy module information.
668 +func (MultiRegexpFilter) CaddyModule() caddy.ModuleInfo {
669 + return caddy.ModuleInfo{
670 + ID: "caddy.logging.encoders.filter.multi_regexp",
671 + New: func() caddy.Module { return new(MultiRegexpFilter) },
672 + }
673 +}
674 +
675 +// UnmarshalCaddyfile sets up the module from Caddyfile tokens.
676 +// Syntax:
677 +//
678 +// multi_regexp {
679 +// regexp <pattern> <replacement>
680 +// regexp <pattern> <replacement>
681 +// ...
682 +// }
683 +func (f *MultiRegexpFilter) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
684 + d.Next() // consume filter name
685 + for d.NextBlock(0) {
686 + switch d.Val() {
687 + case "regexp":
688 + // Security check: limit number of operations
689 + if len(f.Operations) >= maxRegexpOperations {
690 + return d.Errf("too many regexp operations (maximum %d allowed)", maxRegexpOperations)
691 + }
692 +
693 + op := regexpFilterOperation{}
694 + if !d.NextArg() {
695 + return d.ArgErr()
696 + }
697 + op.RawRegexp = d.Val()
698 +
699 + // Security validation: check pattern length
700 + if len(op.RawRegexp) > maxPatternLength {
701 + return d.Errf("regexp pattern too long (maximum %d characters)", maxPatternLength)
702 + }
703 +
704 + // Security validation: basic pattern validation
705 + if op.RawRegexp == "" {
706 + return d.Errf("regexp pattern cannot be empty")
707 + }
708 +
709 + if !d.NextArg() {
710 + return d.ArgErr()
711 + }
712 + op.Value = d.Val()
713 + f.Operations = append(f.Operations, op)
714 + default:
715 + return d.Errf("unrecognized subdirective %s", d.Val())
716 + }
717 + }
718 +
719 + // Security check: ensure at least one operation is defined
720 + if len(f.Operations) == 0 {
721 + return d.Err("multi_regexp filter requires at least one regexp operation")
722 + }
723 +
724 + return nil
725 +}
726 +
727 +// Provision compiles all regexp patterns with security validation.
728 +func (f *MultiRegexpFilter) Provision(ctx caddy.Context) error {
729 + // Security check: validate operation count
730 + if len(f.Operations) > maxRegexpOperations {
731 + return fmt.Errorf("too many regexp operations: %d (maximum %d allowed)", len(f.Operations), maxRegexpOperations)
732 + }
733 +
734 + if len(f.Operations) == 0 {
735 + return fmt.Errorf("multi_regexp filter requires at least one operation")
736 + }
737 +
738 + for i := range f.Operations {
739 + // Security validation: pattern length check
740 + if len(f.Operations[i].RawRegexp) > maxPatternLength {
741 + return fmt.Errorf("regexp pattern %d too long: %d characters (maximum %d)", i, len(f.Operations[i].RawRegexp), maxPatternLength)
742 + }
743 +
744 + // Security validation: empty pattern check
745 + if f.Operations[i].RawRegexp == "" {
746 + return fmt.Errorf("regexp pattern %d cannot be empty", i)
747 + }
748 +
749 + // Compile and validate the pattern (uses RE2 engine - safe from ReDoS)
750 + r, err := regexp.Compile(f.Operations[i].RawRegexp)
751 + if err != nil {
752 + return fmt.Errorf("compiling regexp pattern %d (%s): %v", i, f.Operations[i].RawRegexp, err)
753 + }
754 + f.Operations[i].regexp = r
755 + }
756 + return nil
757 +}
758 +
759 +// Validate ensures the filter is properly configured with security checks.
760 +func (f *MultiRegexpFilter) Validate() error {
761 + if len(f.Operations) == 0 {
762 + return fmt.Errorf("multi_regexp filter requires at least one operation")
763 + }
764 +
765 + if len(f.Operations) > maxRegexpOperations {
766 + return fmt.Errorf("too many regexp operations: %d (maximum %d allowed)", len(f.Operations), maxRegexpOperations)
767 + }
768 +
769 + for i, op := range f.Operations {
770 + if op.RawRegexp == "" {
771 + return fmt.Errorf("regexp pattern %d cannot be empty", i)
772 + }
773 + if len(op.RawRegexp) > maxPatternLength {
774 + return fmt.Errorf("regexp pattern %d too long: %d characters (maximum %d)", i, len(op.RawRegexp), maxPatternLength)
775 + }
776 + if op.regexp == nil {
777 + return fmt.Errorf("regexp pattern %d not compiled (call Provision first)", i)
778 + }
779 + }
780 + return nil
781 +}
782 +
783 +// Filter applies all regexp operations sequentially to the input field.
784 +// Input is sanitized and validated for security.
785 +func (f *MultiRegexpFilter) Filter(in zapcore.Field) zapcore.Field {
786 + if array, ok := in.Interface.(caddyhttp.LoggableStringArray); ok {
787 + newArray := make(caddyhttp.LoggableStringArray, len(array))
788 + for i, s := range array {
789 + newArray[i] = f.processString(s)
790 + }
791 + in.Interface = newArray
792 + } else {
793 + in.String = f.processString(in.String)
794 + }
795 +
796 + return in
797 +}
798 +
799 +// processString applies all regexp operations to a single string with input validation.
800 +func (f *MultiRegexpFilter) processString(s string) string {
801 + // Security: validate input string length to prevent resource exhaustion
802 + const maxInputLength = 1000000 // 1MB max input size
803 + if len(s) > maxInputLength {
804 + // Log warning but continue processing (truncated)
805 + s = s[:maxInputLength]
806 + }
807 +
808 + result := s
809 + for _, op := range f.Operations {
810 + // Each regexp operation is applied sequentially
811 + // Using RE2 engine which is safe from ReDoS attacks
812 + result = op.regexp.ReplaceAllString(result, op.Value)
813 +
814 + // Ensure result doesn't exceed max length after each operation
815 + if len(result) > maxInputLength {
816 + result = result[:maxInputLength]
817 + }
818 + }
819 + return result
820 +}
821 +
822 +// AddOperation adds a single regexp operation to the filter with validation.
823 +// This is used when merging multiple RegexpFilter instances.
824 +func (f *MultiRegexpFilter) AddOperation(rawRegexp, value string) error {
825 + // Security checks
826 + if len(f.Operations) >= maxRegexpOperations {
827 + return fmt.Errorf("cannot add operation: maximum %d operations allowed", maxRegexpOperations)
828 + }
829 +
830 + if rawRegexp == "" {
831 + return fmt.Errorf("regexp pattern cannot be empty")
832 + }
833 +
834 + if len(rawRegexp) > maxPatternLength {
835 + return fmt.Errorf("regexp pattern too long: %d characters (maximum %d)", len(rawRegexp), maxPatternLength)
836 + }
837 +
838 + f.Operations = append(f.Operations, regexpFilterOperation{
839 + RawRegexp: rawRegexp,
840 + Value: value,
841 + })
842 + return nil
843 +}
844 +
845 // RenameFilter is a Caddy log field filter that
846 // renames the field's key with the indicated name.
847 type RenameFilter struct {
@@ -664,6 +881,7 @@ var (
881 _ LogFieldFilter = (*CookieFilter)(nil)
882 _ LogFieldFilter = (*RegexpFilter)(nil)
883 _ LogFieldFilter = (*RenameFilter)(nil)
884 + _ LogFieldFilter = (*MultiRegexpFilter)(nil)
885
886 _ caddyfile.Unmarshaler = (*DeleteFilter)(nil)
887 _ caddyfile.Unmarshaler = (*HashFilter)(nil)
@@ -673,9 +891,12 @@ var (
891 _ caddyfile.Unmarshaler = (*CookieFilter)(nil)
892 _ caddyfile.Unmarshaler = (*RegexpFilter)(nil)
893 _ caddyfile.Unmarshaler = (*RenameFilter)(nil)
894 + _ caddyfile.Unmarshaler = (*MultiRegexpFilter)(nil)
895
896 _ caddy.Provisioner = (*IPMaskFilter)(nil)
897 _ caddy.Provisioner = (*RegexpFilter)(nil)
898 + _ caddy.Provisioner = (*MultiRegexpFilter)(nil)
899
900 _ caddy.Validator = (*QueryFilter)(nil)
901 + _ caddy.Validator = (*MultiRegexpFilter)(nil)
902 )
modules/logging/filters_test.go
+197
@@ -1,6 +1,8 @@
1 package logging
2
3 import (
4 + "fmt"
5 + "strings"
6 "testing"
7
8 "go.uber.org/zap/zapcore"
@@ -239,3 +241,198 @@ func TestHashFilterMultiValue(t *testing.T) {
241 t.Fatalf("field entry 1 has not been filtered: %s", arr[1])
242 }
243 }
244 +
245 +func TestMultiRegexpFilterSingleOperation(t *testing.T) {
246 + f := MultiRegexpFilter{
247 + Operations: []regexpFilterOperation{
248 + {RawRegexp: `secret`, Value: "REDACTED"},
249 + },
250 + }
251 + err := f.Provision(caddy.Context{})
252 + if err != nil {
253 + t.Fatalf("unexpected error provisioning: %v", err)
254 + }
255 +
256 + out := f.Filter(zapcore.Field{String: "foo-secret-bar"})
257 + if out.String != "foo-REDACTED-bar" {
258 + t.Fatalf("field has not been filtered: %s", out.String)
259 + }
260 +}
261 +
262 +func TestMultiRegexpFilterMultipleOperations(t *testing.T) {
263 + f := MultiRegexpFilter{
264 + Operations: []regexpFilterOperation{
265 + {RawRegexp: `secret`, Value: "REDACTED"},
266 + {RawRegexp: `password`, Value: "HIDDEN"},
267 + {RawRegexp: `token`, Value: "XXX"},
268 + },
269 + }
270 + err := f.Provision(caddy.Context{})
271 + if err != nil {
272 + t.Fatalf("unexpected error provisioning: %v", err)
273 + }
274 +
275 + // Test sequential application
276 + out := f.Filter(zapcore.Field{String: "my-secret-password-token-data"})
277 + expected := "my-REDACTED-HIDDEN-XXX-data"
278 + if out.String != expected {
279 + t.Fatalf("field has not been filtered correctly: got %s, expected %s", out.String, expected)
280 + }
281 +}
282 +
283 +func TestMultiRegexpFilterMultiValue(t *testing.T) {
284 + f := MultiRegexpFilter{
285 + Operations: []regexpFilterOperation{
286 + {RawRegexp: `secret`, Value: "REDACTED"},
287 + {RawRegexp: `\d+`, Value: "NUM"},
288 + },
289 + }
290 + err := f.Provision(caddy.Context{})
291 + if err != nil {
292 + t.Fatalf("unexpected error provisioning: %v", err)
293 + }
294 +
295 + out := f.Filter(zapcore.Field{Interface: caddyhttp.LoggableStringArray{
296 + "foo-secret-123",
297 + "bar-secret-456",
298 + }})
299 + arr, ok := out.Interface.(caddyhttp.LoggableStringArray)
300 + if !ok {
301 + t.Fatalf("field is wrong type: %T", out.Interface)
302 + }
303 + if arr[0] != "foo-REDACTED-NUM" {
304 + t.Fatalf("field entry 0 has not been filtered: %s", arr[0])
305 + }
306 + if arr[1] != "bar-REDACTED-NUM" {
307 + t.Fatalf("field entry 1 has not been filtered: %s", arr[1])
308 + }
309 +}
310 +
311 +func TestMultiRegexpFilterAddOperation(t *testing.T) {
312 + f := MultiRegexpFilter{}
313 + err := f.AddOperation("secret", "REDACTED")
314 + if err != nil {
315 + t.Fatalf("unexpected error adding operation: %v", err)
316 + }
317 + err = f.AddOperation("password", "HIDDEN")
318 + if err != nil {
319 + t.Fatalf("unexpected error adding operation: %v", err)
320 + }
321 + err = f.Provision(caddy.Context{})
322 + if err != nil {
323 + t.Fatalf("unexpected error provisioning: %v", err)
324 + }
325 +
326 + if len(f.Operations) != 2 {
327 + t.Fatalf("expected 2 operations, got %d", len(f.Operations))
328 + }
329 +
330 + out := f.Filter(zapcore.Field{String: "my-secret-password"})
331 + expected := "my-REDACTED-HIDDEN"
332 + if out.String != expected {
333 + t.Fatalf("field has not been filtered correctly: got %s, expected %s", out.String, expected)
334 + }
335 +}
336 +
337 +func TestMultiRegexpFilterSecurityLimits(t *testing.T) {
338 + f := MultiRegexpFilter{}
339 +
340 + // Test maximum operations limit
341 + for i := 0; i < 51; i++ {
342 + err := f.AddOperation(fmt.Sprintf("pattern%d", i), "replacement")
343 + if i < 50 {
344 + if err != nil {
345 + t.Fatalf("unexpected error adding operation %d: %v", i, err)
346 + }
347 + } else {
348 + if err == nil {
349 + t.Fatalf("expected error when adding operation %d (exceeds limit)", i)
350 + }
351 + }
352 + }
353 +
354 + // Test empty pattern validation
355 + f2 := MultiRegexpFilter{}
356 + err := f2.AddOperation("", "replacement")
357 + if err == nil {
358 + t.Fatalf("expected error for empty pattern")
359 + }
360 +
361 + // Test pattern length limit
362 + f3 := MultiRegexpFilter{}
363 + longPattern := strings.Repeat("a", 1001)
364 + err = f3.AddOperation(longPattern, "replacement")
365 + if err == nil {
366 + t.Fatalf("expected error for pattern exceeding length limit")
367 + }
368 +}
369 +
370 +func TestMultiRegexpFilterValidation(t *testing.T) {
371 + // Test validation with empty operations
372 + f := MultiRegexpFilter{}
373 + err := f.Validate()
374 + if err == nil {
375 + t.Fatalf("expected validation error for empty operations")
376 + }
377 +
378 + // Test validation with valid operations
379 + err = f.AddOperation("valid", "replacement")
380 + if err != nil {
381 + t.Fatalf("unexpected error adding operation: %v", err)
382 + }
383 + err = f.Provision(caddy.Context{})
384 + if err != nil {
385 + t.Fatalf("unexpected error provisioning: %v", err)
386 + }
387 + err = f.Validate()
388 + if err != nil {
389 + t.Fatalf("unexpected validation error: %v", err)
390 + }
391 +}
392 +
393 +func TestMultiRegexpFilterInputSizeLimit(t *testing.T) {
394 + f := MultiRegexpFilter{
395 + Operations: []regexpFilterOperation{
396 + {RawRegexp: `test`, Value: "REPLACED"},
397 + },
398 + }
399 + err := f.Provision(caddy.Context{})
400 + if err != nil {
401 + t.Fatalf("unexpected error provisioning: %v", err)
402 + }
403 +
404 + // Test with very large input (should be truncated)
405 + largeInput := strings.Repeat("test", 300000) // Creates ~1.2MB string
406 + out := f.Filter(zapcore.Field{String: largeInput})
407 +
408 + // The input should be truncated to 1MB and still processed
409 + if len(out.String) > 1000000 {
410 + t.Fatalf("output string not truncated: length %d", len(out.String))
411 + }
412 +
413 + // Should still contain replacements within the truncated portion
414 + if !strings.Contains(out.String, "REPLACED") {
415 + t.Fatalf("replacements not applied to truncated input")
416 + }
417 +}
418 +
419 +func TestMultiRegexpFilterOverlappingPatterns(t *testing.T) {
420 + f := MultiRegexpFilter{
421 + Operations: []regexpFilterOperation{
422 + {RawRegexp: `secret.*password`, Value: "SENSITIVE"},
423 + {RawRegexp: `password`, Value: "HIDDEN"},
424 + },
425 + }
426 + err := f.Provision(caddy.Context{})
427 + if err != nil {
428 + t.Fatalf("unexpected error provisioning: %v", err)
429 + }
430 +
431 + // The first pattern should match and replace the entire "secret...password" portion
432 + // Then the second pattern should not find "password" anymore since it was already replaced
433 + out := f.Filter(zapcore.Field{String: "my-secret-data-password-end"})
434 + expected := "my-SENSITIVE-end"
435 + if out.String != expected {
436 + t.Fatalf("field has not been filtered correctly: got %s, expected %s", out.String, expected)
437 + }
438 +}