Skip to content

Commit d4a4b99

Browse files
[FEAT] Add wildcard property name matching
1 parent c3cf98d commit d4a4b99

File tree

11 files changed

+379
-12
lines changed

11 files changed

+379
-12
lines changed

Changelog.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog for Serilog.Enrichers.Sensitive
22

3+
## 2.1.0
4+
5+
Add support for wildcard matching on properties to mask.
6+
See [here](README.md#wildcard-matching-properties) for usage details.
7+
38
## 2.0.0
49

510
This release introduces support for configuring `MaskProperties` via JSON. As the version number indicates this is a breaking change as the C# equivalent had to be changed to match.

Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<Project>
22
<PropertyGroup>
3-
<Version>2.0.0.0</Version>
3+
<Version>2.1.0.0</Version>
44
<Authors>Sander van Vliet, Huibert Jan Nieuwkamer, Scott Toberman</Authors>
55
<Company>Codenizer BV</Company>
66
<Copyright>2025 Sander van Vliet</Copyright>

README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,37 @@ logger.Information("This is a sensitive {Email}", "[email protected]");
234234

235235
the rendered log message comes out as: `"This is a sensitive [email protected]"`
236236

237+
### Wildcard matching properties
238+
239+
You can specify a wildcard to match property names to mask. Supported forms are:
240+
241+
- `*Prop`
242+
- `Prop*`
243+
- `*Prop*`
244+
245+
Note that `Pr*p` is explicitly not supported.
246+
247+
When you want to use a wildcard you will have to set the `WildcardMatch` property on `MaskOptions` to true:
248+
249+
```csharp
250+
var logger = new LoggerConfiguration()
251+
.Enrich.WithSensitiveDataMasking(
252+
options => options
253+
.MaskProperties
254+
.Add(new MaskProperty
255+
{
256+
Name = "*Prop",
257+
Options = new MaskOptions
258+
{
259+
WildcardMatch = true
260+
}
261+
}))
262+
.WriteTo.Sink(inMemorySink)
263+
.CreateLogger();
264+
```
265+
266+
If you do not set `WildcardMatch` then the property name will be treated as-is and only mask a property called `*Prop`.
267+
Note that you will not get any error in this scenario.
237268

238269
## Extending to additional use cases
239270

src/Serilog.Enrichers.Sensitive/MaskOptions.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ public class MaskOptions : IEquatable<MaskOptions>
99
public int ShowFirst { get; set; } = NotSet;
1010
public int ShowLast { get; set; } = NotSet;
1111
public bool PreserveLength { get; set; } = true;
12+
public bool WildcardMatch { get; set; }
1213

1314
public bool Equals(MaskOptions? other)
1415
{
@@ -22,7 +23,12 @@ public bool Equals(MaskOptions? other)
2223
return true;
2324
}
2425

25-
return ShowFirst == other.ShowFirst && ShowLast == other.ShowLast && PreserveLength == other.PreserveLength;
26+
if (other.GetType() != GetType())
27+
{
28+
return false;
29+
}
30+
31+
return ShowFirst == other.ShowFirst && ShowLast == other.ShowLast && PreserveLength == other.PreserveLength && WildcardMatch == other.WildcardMatch;
2632
}
2733

2834
public override bool Equals(object? obj)
Lines changed: 113 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,122 @@
1-
namespace Serilog.Enrichers.Sensitive;
1+
using System;
2+
using System.Collections.Generic;
3+
4+
namespace Serilog.Enrichers.Sensitive;
25

36
public class MaskProperty
47
{
8+
private MaskOptions _options = new();
9+
// Use StringComparer.OrdinalIgnoreCase to ensure we'd match on SomePROP and SomeProp
10+
private readonly HashSet<string> _matchingProperties = new(StringComparer.OrdinalIgnoreCase);
11+
private string? _match;
12+
private int? _minLength;
13+
private MatchMode _matchMode = MatchMode.Unknown;
514
public string Name { get; set; }
6-
public MaskOptions Options { get; set; } = new();
15+
16+
public MaskOptions Options
17+
{
18+
get => _options;
19+
set
20+
{
21+
_options = value;
22+
23+
if (_options.WildcardMatch)
24+
{
25+
if (Name[0] == '*')
26+
{
27+
if (Name[Name.Length - 1] == '*')
28+
{
29+
_match = Name.Substring(1, Name.Length - 2);
30+
_minLength = _match.Length + 2;
31+
_matchMode = MatchMode.Middle;
32+
}
33+
else
34+
{
35+
_match = Name.Substring(1);
36+
_minLength = _match.Length + 1;
37+
_matchMode = MatchMode.End;
38+
}
39+
}
40+
else if (Name[Name.Length - 1] == '*')
41+
{
42+
_match = Name.Substring(0, Name.Length - 2);
43+
_minLength = _match.Length + 1;
44+
_matchMode = MatchMode.Start;
45+
}
46+
else
47+
{
48+
// If someone sets WildcardMatch to true but there
49+
// is no wildcard character in the property name then
50+
// set WildcardMatch to false so we short-circuit
51+
// property name matching.
52+
//
53+
// Note: consider throwing an exception in this scenario.
54+
_options.WildcardMatch = false;
55+
}
56+
}
57+
}
58+
}
759

860
public static MaskProperty WithDefaults(string propertyName)
961
{
1062
return new MaskProperty { Name = propertyName };
1163
}
12-
}
64+
65+
public bool IsMatch(string propertyName)
66+
{
67+
if (Name.Equals(propertyName, StringComparison.OrdinalIgnoreCase))
68+
{
69+
return true;
70+
}
71+
72+
return Options.WildcardMatch && PropertyNameMatchesWildcard(propertyName);
73+
}
74+
75+
protected bool PropertyNameMatchesWildcard(string propertyName)
76+
{
77+
// If the property name is shorter than the match + at least one char
78+
// then it won't match so exit early.
79+
if (propertyName.Length < _minLength)
80+
{
81+
return false;
82+
}
83+
84+
if (_matchingProperties.Contains(propertyName))
85+
{
86+
return true;
87+
}
88+
89+
bool isMatch;
90+
91+
switch (_matchMode)
92+
{
93+
case MatchMode.Start:
94+
isMatch = propertyName.StartsWith(_match!, StringComparison.OrdinalIgnoreCase);
95+
break;
96+
case MatchMode.End:
97+
isMatch = propertyName.EndsWith(_match!, StringComparison.OrdinalIgnoreCase);
98+
break;
99+
case MatchMode.Middle:
100+
isMatch = propertyName.IndexOf(_match!, StringComparison.OrdinalIgnoreCase) > 0;
101+
break;
102+
default:
103+
return false;
104+
}
105+
106+
if (isMatch)
107+
{
108+
_matchingProperties.Add(propertyName);
109+
}
110+
111+
return isMatch;
112+
}
113+
114+
private enum MatchMode
115+
{
116+
Unknown,
117+
Start,
118+
Middle,
119+
End
120+
}
121+
}
122+

src/Serilog.Enrichers.Sensitive/SensitiveDataEnricher.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,10 @@ public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
114114
return (false, null);
115115
}
116116

117-
var matchingProperty = _maskProperties.SingleOrDefault(p => p.Name.Equals(property.Key, StringComparison.OrdinalIgnoreCase));
117+
var matchingProperty = _maskProperties.SingleOrDefault(p => p.IsMatch(property.Key));
118118
if(matchingProperty != null)
119119
{
120-
if (matchingProperty.Options == MaskOptions.Default)
120+
if (matchingProperty.Options.Equals(MaskOptions.Default))
121121
{
122122
return (true, new ScalarValue(_maskValue));
123123
}
Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,64 @@
1-
namespace Serilog.Enrichers.Sensitive;
1+
using System;
22

3-
public class UriMaskOptions : MaskOptions
3+
namespace Serilog.Enrichers.Sensitive;
4+
5+
public sealed class UriMaskOptions : MaskOptions, IEquatable<UriMaskOptions>
46
{
57
public bool ShowScheme { get; set; } = true;
68
public bool ShowHost { get; set; } = true;
79
public bool ShowPath { get; set; } = false;
810
public bool ShowQueryString { get; set; } = false;
11+
12+
public bool Equals(UriMaskOptions? other)
13+
{
14+
if (other is null)
15+
{
16+
return false;
17+
}
18+
19+
if (ReferenceEquals(this, other))
20+
{
21+
return true;
22+
}
23+
24+
if (other.GetType() != GetType())
25+
{
26+
return false;
27+
}
28+
29+
return base.Equals(other) && ShowScheme == other.ShowScheme && ShowHost == other.ShowHost && ShowPath == other.ShowPath && ShowQueryString == other.ShowQueryString;
30+
}
31+
32+
public override bool Equals(object? obj)
33+
{
34+
if (obj is null)
35+
{
36+
return false;
37+
}
38+
39+
if (ReferenceEquals(this, obj))
40+
{
41+
return true;
42+
}
43+
44+
if (obj.GetType() != GetType())
45+
{
46+
return false;
47+
}
48+
49+
return Equals((UriMaskOptions)obj);
50+
}
51+
52+
public override int GetHashCode()
53+
{
54+
unchecked
55+
{
56+
int hashCode = base.GetHashCode();
57+
hashCode = (hashCode * 397) ^ ShowScheme.GetHashCode();
58+
hashCode = (hashCode * 397) ^ ShowHost.GetHashCode();
59+
hashCode = (hashCode * 397) ^ ShowPath.GetHashCode();
60+
hashCode = (hashCode * 397) ^ ShowQueryString.GetHashCode();
61+
return hashCode;
62+
}
63+
}
964
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
using System;
2+
using BenchmarkDotNet.Attributes;
3+
using BenchmarkDotNet.Engines;
4+
5+
namespace Serilog.Enrichers.Sensitive.Tests.Benchmark
6+
{
7+
[SimpleJob(RunStrategy.Throughput, warmupCount: 1)]
8+
public class BenchmarkWildcardPropertyMatch
9+
{
10+
private readonly MaskPropertyForTest _maskProperty;
11+
12+
public BenchmarkWildcardPropertyMatch()
13+
{
14+
_maskProperty = new MaskPropertyForTest
15+
{
16+
Name = "*Prop",
17+
Options = new MaskOptions
18+
{
19+
WildcardMatch = true
20+
}
21+
};
22+
}
23+
24+
[Params(10000)] public int N;
25+
26+
[Benchmark(Baseline = true)]
27+
public bool Baseline()
28+
{
29+
var result = _maskProperty.Invoke("SomeProp");
30+
31+
return result;
32+
}
33+
34+
[Benchmark(Baseline = false)]
35+
public bool WithCaching()
36+
{
37+
var result = _maskProperty.Invoke("SomeProp");
38+
39+
return result;
40+
}
41+
}
42+
43+
public class MaskPropertyForTest : MaskProperty
44+
{
45+
public bool Invoke(string propertyName)
46+
{
47+
return PropertyNameMatchesWildcard(propertyName);
48+
}
49+
50+
public bool InvokeOriginal(string propertyName)
51+
{
52+
if (Name[0] == '*')
53+
{
54+
if (Name[Name.Length - 1] == '*')
55+
{
56+
var match = Name.Substring(1, Name.Length - 2);
57+
return propertyName.IndexOf(match, StringComparison.OrdinalIgnoreCase) > 0;
58+
}
59+
else
60+
{
61+
var match = Name.Substring(1);
62+
return propertyName.EndsWith(match, StringComparison.OrdinalIgnoreCase);
63+
}
64+
}
65+
66+
if (Name[Name.Length - 1] == '*')
67+
{
68+
var match = Name.Substring(0, Name.Length - 2);
69+
return propertyName.StartsWith(match, StringComparison.OrdinalIgnoreCase);
70+
}
71+
72+
return false;
73+
}
74+
}
75+
}

test/Serilog.Enrichers.Sensitive.Tests.Benchmark/Program.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@ internal class Program
66
{
77
private static void Main(string[] args)
88
{
9-
BenchmarkRunner.Run<BenchmarkCompiledEmailRegex>();
10-
BenchmarkRunner.Run<BenchmarkCompiledIbanRegex>();
11-
BenchmarkRunner.Run<CreditCardMarkingBenchmarks>();
9+
// BenchmarkRunner.Run<BenchmarkCompiledEmailRegex>();
10+
// BenchmarkRunner.Run<BenchmarkCompiledIbanRegex>();
11+
// BenchmarkRunner.Run<CreditCardMarkingBenchmarks>();
12+
BenchmarkRunner.Run<BenchmarkWildcardPropertyMatch>();
1213
}
1314
}
1415
}

test/Serilog.Enrichers.Sensitive.Tests.Benchmark/Serilog.Enrichers.Sensitive.Tests.Benchmark.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,8 @@
1111
<PackageReference Include="BenchmarkDotNet" Version="0.13.2" />
1212
</ItemGroup>
1313

14+
<ItemGroup>
15+
<ProjectReference Include="..\..\src\Serilog.Enrichers.Sensitive\Serilog.Enrichers.Sensitive.csproj" />
16+
</ItemGroup>
17+
1418
</Project>

0 commit comments

Comments
 (0)