using System.Reflection; using System.Runtime.CompilerServices; using System.Text; using MS.Microservice.Core.Reflection; namespace MS.Microservice.Core.Security.SensitiveData; public enum SensitiveKind { Phone, Email, Secret } /// Classifies an output property; enable the relevant JSON/log/audit adapter to apply it. [AttributeUsage(AttributeTargets.Property, Inherited = true)] public sealed class SensitiveAttribute : Attribute { public SensitiveAttribute(SensitiveKind kind) { if (!Enum.IsDefined(kind)) throw new ArgumentOutOfRangeException(nameof(kind)); Kind = kind; } public SensitiveKind Kind { get; } } public static class SensitiveValues { public const string Redacted = "[REDACTED]"; private static readonly ConditionalWeakTable Plans = new(); /// Masks a supported phone/email; secrets return null and unrecognized formats are fully redacted. public static string? Protect(string? value, SensitiveKind kind) { if (!Enum.IsDefined(kind)) throw new ArgumentOutOfRangeException(nameof(kind)); if (kind == SensitiveKind.Secret || value is null) return null; if (value.Length == 0) return ""; if (kind == SensitiveKind.Phone) { var offset = value[0] == '+' ? 1 : 0; if (value.Length - offset is < 8 or > 15 || value.AsSpan(offset).ContainsAnyExceptInRange('0', '9')) return Redacted; return string.Create(value.Length, value, static (output, source) => { source.AsSpan().CopyTo(output); output.Slice(3, source.Length - 7).Fill('*'); }); } var at = value.IndexOf('@'); if (value.Length > 320 || at < 1 || at != value.LastIndexOf('@') || at == value.Length - 1 || value.Any(character => char.IsWhiteSpace(character) || char.IsControl(character))) return Redacted; var domain = value.AsSpan(at + 1); if (domain.IndexOf('.') < 1 || domain[^1] == '.' || !Rune.TryGetRuneAt(value, 0, out var first)) return Redacted; foreach (var character in domain) if (!char.IsAsciiLetterOrDigit(character) && character is not '.' and not '-') return Redacted; return string.Concat(first.ToString(), "***", value.AsSpan(at)); } /// Creates a flat protected projection for logs or exports, leaving the source object unchanged. /// Unmarked scalar properties are retained. Nested objects are omitted; this runtime-accessor path is not AOT compatible. public static IReadOnlyDictionary Project(T value) where T : class { ArgumentNullException.ThrowIfNull(value); var fields = Plans.GetValue(value.GetType(), BuildPlan); var output = new Dictionary(fields.Length, StringComparer.Ordinal); foreach (var field in fields) { if (field.Kind == SensitiveKind.Secret) continue; var raw = field.Get(value); output.Add(field.Name, field.Kind is { } kind ? Protect((string?)raw, kind) : raw); } return output; } public static SensitiveKind? ClassificationOf(MemberInfo member) => (Attribute.GetCustomAttribute(member, typeof(SensitiveAttribute), inherit: true) as SensitiveAttribute)?.Kind; private static Field[] BuildPlan(Type type) { var fields = new List(); foreach (var accessor in PropertyAccessors.Get(type)) { if (accessor.Property.GetMethod?.IsPublic != true || accessor.GetValue is not { } getter) continue; var kind = ClassificationOf(accessor.Property); if (kind is SensitiveKind.Phone or SensitiveKind.Email && accessor.PropertyType != typeof(string)) throw new InvalidOperationException("Phone and email classifications require string properties."); var scalar = Nullable.GetUnderlyingType(accessor.PropertyType) ?? accessor.PropertyType; if (kind == SensitiveKind.Secret || scalar.IsPrimitive || scalar.IsEnum || scalar == typeof(string) || scalar == typeof(decimal) || scalar == typeof(Guid) || scalar == typeof(DateTime) || scalar == typeof(DateTimeOffset) || scalar == typeof(DateOnly) || scalar == typeof(TimeOnly)) fields.Add(new(accessor.Name, getter, kind)); } return fields.ToArray(); } private sealed record Field(string Name, Func Get, SensitiveKind? Kind); }