| | | 1 | | using System; |
| | | 2 | | using System.Collections.Concurrent; |
| | | 3 | | using System.Collections.Generic; |
| | | 4 | | using System.Linq; |
| | | 5 | | using System.Text.RegularExpressions; |
| | | 6 | | using System.Threading; |
| | | 7 | | |
| | | 8 | | namespace AmbientServices; |
| | | 9 | | |
| | | 10 | | /// <summary> |
| | | 11 | | /// Registry of per-caller regex filters used to mask sensitive field names in structured log output. |
| | | 12 | | /// Multiple unrelated assemblies may each register filters; all active filters are applied when rendering log data. |
| | | 13 | | /// </summary> |
| | | 14 | | /// <remarks> |
| | | 15 | | /// <pitch>Process-wide scrubbing of secrets from structured logs: register a field-name pattern ("password", "token", … |
| | | 16 | | /// <pledge> |
| | | 17 | | /// Filters match field names, never values, and a match replaces the value with a fixed mask string. Registrations are |
| | | 18 | | /// Registration, disposal, and mask checks are thread-safe and take effect immediately; each check consults the set of |
| | | 19 | | /// </pledge> |
| | | 20 | | /// <plan>A <see cref="ConcurrentDictionary{TKey, TValue}"/> of <see cref="Regex"/>es keyed by an <see cref="Interlocked |
| | | 21 | | /// <priority> |
| | | 22 | | /// 1. Masking a field that did not need it over letting one through: filters match field <em>names</em> rather than val |
| | | 23 | | /// 2. Independent registrations over a single coordinated list: each assembly registers its own patterns and disposes o |
| | | 24 | | /// 3. Immediate effect over cached filter sets: every check consults the filters registered at that instant, so protect |
| | | 25 | | /// </priority> |
| | | 26 | | /// </remarks> |
| | | 27 | | public static class AmbientLogSensitiveFieldFilters |
| | | 28 | | { |
| | | 29 | | /// <summary> |
| | | 30 | | /// The string substituted for values whose field names match a registered sensitive-field pattern. |
| | | 31 | | /// </summary> |
| | | 32 | | public const string MaskedValue = "***"; |
| | | 33 | | |
| | | 34 | | private static int _nextRegistrationId; |
| | 2 | 35 | | private static readonly ConcurrentDictionary<int, Regex> _filters = new(); |
| | | 36 | | |
| | | 37 | | /// <summary> |
| | | 38 | | /// Registers a regex that matches sensitive log field names (property or dictionary keys). |
| | | 39 | | /// </summary> |
| | | 40 | | /// <remarks> |
| | | 41 | | /// Unregister by disposing the returned <see cref="IDisposable"/> (for example with <c>using</c> or by calling <see |
| | | 42 | | /// Each caller should keep and dispose its own registration; disposing one registration does not affect filters reg |
| | | 43 | | /// </remarks> |
| | | 44 | | /// <param name="fieldNamePattern">A regex matched against field names (not values).</param> |
| | | 45 | | /// <returns>An <see cref="IDisposable"/> that removes this filter when disposed.</returns> |
| | | 46 | | public static IDisposable RegisterFieldNameFilter(Regex fieldNamePattern) |
| | | 47 | | { |
| | | 48 | | #if NET5_0_OR_GREATER |
| | 2 | 49 | | ArgumentNullException.ThrowIfNull(fieldNamePattern); |
| | | 50 | | #else |
| | | 51 | | if (fieldNamePattern is null) throw new ArgumentNullException(nameof(fieldNamePattern)); |
| | | 52 | | #endif |
| | 2 | 53 | | int id = Interlocked.Increment(ref _nextRegistrationId); |
| | 2 | 54 | | _filters[id] = fieldNamePattern; |
| | 2 | 55 | | return new Registration(id); |
| | | 56 | | } |
| | | 57 | | |
| | | 58 | | /// <summary> |
| | | 59 | | /// Registers a regex that matches sensitive log field names (property or dictionary keys). Dispose the returned ins |
| | | 60 | | /// </summary> |
| | | 61 | | /// <param name="pattern">The regex pattern string.</param> |
| | | 62 | | /// <param name="options">Regex options. Defaults to <see cref="RegexOptions.IgnoreCase"/> | <see cref="RegexOptions |
| | | 63 | | /// <returns>An <see cref="IDisposable"/> that removes this filter when disposed.</returns> |
| | | 64 | | public static IDisposable RegisterFieldNameFilter(string pattern, RegexOptions options = RegexOptions.IgnoreCase | R |
| | | 65 | | { |
| | | 66 | | #if NET5_0_OR_GREATER |
| | 2 | 67 | | ArgumentNullException.ThrowIfNull(pattern); |
| | | 68 | | #else |
| | | 69 | | if (pattern is null) throw new ArgumentNullException(nameof(pattern)); |
| | | 70 | | #endif |
| | 2 | 71 | | return RegisterFieldNameFilter(new Regex(pattern, options)); |
| | | 72 | | } |
| | | 73 | | |
| | | 74 | | /// <summary> |
| | | 75 | | /// Returns whether <paramref name="fieldName"/> matches any currently registered sensitive-field filter. |
| | | 76 | | /// </summary> |
| | | 77 | | public static bool ShouldMaskFieldName(string fieldName) |
| | | 78 | | { |
| | 2 | 79 | | if (string.IsNullOrEmpty(fieldName)) return false; |
| | 2 | 80 | | foreach (Regex filter in _filters.Values) |
| | | 81 | | { |
| | 2 | 82 | | if (filter.IsMatch(fieldName)) return true; |
| | | 83 | | } |
| | 2 | 84 | | return false; |
| | 2 | 85 | | } |
| | | 86 | | |
| | | 87 | | /// <summary> |
| | | 88 | | /// Masks <paramref name="value"/> when <paramref name="fieldName"/> matches a registered filter; otherwise returns |
| | | 89 | | /// </summary> |
| | | 90 | | public static object? MaskValueIfSensitive(string fieldName, object? value) |
| | | 91 | | { |
| | 2 | 92 | | return ShouldMaskFieldName(fieldName) ? MaskedValue : value; |
| | | 93 | | } |
| | | 94 | | |
| | | 95 | | /// <summary> |
| | | 96 | | /// Returns a snapshot of the currently registered filters (for diagnostics and tests). |
| | | 97 | | /// </summary> |
| | | 98 | | public static IReadOnlyCollection<Regex> GetRegisteredFilters() |
| | | 99 | | { |
| | 2 | 100 | | return _filters.Values.ToArray(); |
| | | 101 | | } |
| | | 102 | | |
| | | 103 | | private sealed class Registration : IDisposable |
| | | 104 | | { |
| | | 105 | | private readonly int _id; |
| | | 106 | | private int _disposed; |
| | | 107 | | |
| | 2 | 108 | | public Registration(int id) => _id = id; |
| | | 109 | | |
| | | 110 | | public void Dispose() |
| | | 111 | | { |
| | 2 | 112 | | if (Interlocked.Exchange(ref _disposed, 1) != 0) return; |
| | 2 | 113 | | _ = _filters.TryRemove(_id, out _); |
| | 2 | 114 | | } |
| | | 115 | | } |
| | | 116 | | } |