Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 46 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ The repository contains the core abstractions plus concrete integrations for bot
| `Json.Masker.Abstract` | Attribute, masking strategies, context accessors, and the default masking service. This transient package isn't published to NuGet; it just keeps the shared bits tidy for the two adapters. |
| `Json.Masker.Newtonsoft` | Plug-and-play `ContractResolver` that wraps sensitive members before Newtonsoft writes them out. |
| `Json.Masker.SystemTextJson` | `JsonTypeInfo` modifier that swaps in masking converters when the built-in source generator runs. |
| `Json.Masker.AspNet` | Middleware and helpers that toggle masking per request and hook the System.Text.Json stack into ASP.NET Core. |
| `Json.Masker.AspNet.Newtonsoft` | Middleware and helpers that wire the Newtonsoft.Json integration into ASP.NET Core's MVC pipeline. |

All packages version together and ship to NuGet whenever `main` is updated.

Expand All @@ -22,8 +24,14 @@ Install the package that matches your serializer:
dotnet add package Json.Masker.Newtonsoft
# or
dotnet add package Json.Masker.SystemTextJson
# optional web helpers
dotnet add package Json.Masker.AspNet
dotnet add package Json.Masker.AspNet.Newtonsoft
```

The ASP.NET Core helpers are optional but recommended whenever you want to flip masking on or off per request without writing
boilerplate middleware.

### Wire it up

Pick the style that fits your app:
Expand All @@ -33,6 +41,7 @@ Pick the style that fits your app:
* `IJsonMaskingConfigurator` as a singleton, wired to the serializer-specific implementation (`NewtonsoftJsonMaskingConfigurator` or `SystemTextJsonMaskingConfigurator`).

Once that extension is in place you can inject `IJsonMaskingConfigurator` wherever you configure the serializer (for example in MVC setup) and let it bolt on the masking bits for you.
Pair it with `app.UseNewtonsoftJsonMasking()` or `app.UseTextJsonMasking()` from the ASP.NET helper packages to flip masking on and off per request.
* **Wire it manually** if you're configuring the serializer yourself or aren't using DI. Just new up `DefaultMaskingService` (or your own implementation) and pass it into the resolver/modifier shown in the manual samples below.

The options expose a writeable `MaskingService` property, so you can swap in your own masking logic:
Expand All @@ -54,19 +63,30 @@ Either way, masking kicks in once you mark your models and flip the context swit
{
public string Name { get; set; } = string.Empty;

[Sensitive(MaskingStrategy.Creditcard)]
public string CreditCard { get; set; } = string.Empty;
[Sensitive(MaskingStrategy.Creditcard)]
public string CreditCard { get; set; } = string.Empty;

[Sensitive(MaskingStrategy.Ssn)]
public string SSN { get; set; } = string.Empty;

[Sensitive(MaskingStrategy.Email)]
public string Email { get; set; } = string.Empty;

[Sensitive(MaskingStrategy.Ssn)]
public string SSN { get; set; } = string.Empty;
[Sensitive(MaskingStrategy.Iban)]
public string BankAccount { get; set; } = string.Empty;

[Sensitive]
public int Age { get; set; }
[Sensitive]
public int Age { get; set; }

[Sensitive(MaskingStrategy.Redacted)]
public List<string> Hobbies { get; set; } = [];
[Sensitive(MaskingStrategy.Redacted)]
public List<string> Hobbies { get; set; } = [];

[Sensitive("##-****-####")]
public string LoyaltyNumber { get; set; } = string.Empty;
}
```
The optional string parameter on <code>[Sensitive]</code> uses <code>#</code> to copy a character from the source value and
<code>*</code> to mask it, allowing simple custom formats without a bespoke masking service.
2. Turn masking on for the current request or operation by setting the ambient context (middleware is a great place for this):
```csharp
MaskingContextAccessor.Set(new MaskingContext { Enabled = true });
Expand Down Expand Up @@ -96,6 +116,11 @@ builder.Services
configurator.Configure(opts.SerializerSettings));

var app = builder.Build();

app.UseNewtonsoftJsonMasking();

app.MapControllers();
app.Run();
```

Swap out `AddNewtonsoftJson()` for the default `System.Text.Json` stack and tweak the options wiring:
Expand All @@ -112,6 +137,13 @@ builder.Services
.AddOptions<JsonOptions>()
.Configure<IJsonMaskingConfigurator>((opts, configurator) =>
configurator.Configure(opts.SerializerOptions));

var app = builder.Build();

app.UseTextJsonMasking();

app.MapControllers();
app.Run();
```

In both cases the extension ensures DI knows about:
Expand Down Expand Up @@ -142,8 +174,11 @@ Masked output ends up looking like:
"Name": "Alice",
"CreditCard": "****-****-****-1234",
"SSN": "***-**-6789",
"Email": "a*****@g****.com",
"BankAccount": "GB** **** **** **** 1234",
"Age": "****",
"Hobbies": ["<redacted>", "<redacted>"]
"Hobbies": ["<redacted>", "<redacted>"],
"LoyaltyNumber": "12-****-3456"
}
```

Expand Down Expand Up @@ -209,6 +244,8 @@ The built-in `DefaultMaskingService` supports a few common strategies:
| `MaskingStrategy.Creditcard` | `****-****-****-1234` (keeps the last four digits) |
| `MaskingStrategy.Ssn` | `***-**-6789` |
| `MaskingStrategy.Redacted` | `<redacted>` |
| `MaskingStrategy.Email` | `a*****@d****.com` (keeps the first character and domain suffix) |
| `MaskingStrategy.Iban` | `GB** **** **** **** 1234` (keeps the country code and last four characters) |

You can roll your own `IMaskingService` and plug it in through `MaskingOptions` if you want custom behavior (different patterns, role-based rules, etc.).

Expand Down
82 changes: 41 additions & 41 deletions src/Json.Masker.Abstract/DefaultMaskingService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
// ReSharper disable VirtualMemberNeverOverridden.Global

// ReSharper disable VirtualMemberNeverOverridden.Global
namespace Json.Masker.Abstract;

/// <summary>
Expand All @@ -18,7 +18,6 @@ public partial class DefaultMaskingService : IMaskingService
/// <inheritdoc />
public virtual string Mask(ReadOnlySpan<char> str, MaskingStrategy strategy, string? pattern)
{

if (!string.IsNullOrWhiteSpace(pattern))
{
return ApplyCustomPattern(str, pattern);
Expand Down Expand Up @@ -77,18 +76,6 @@ protected virtual string MaskSsn(ReadOnlySpan<char> raw)
return $"***-**-{properLast4}";
}

private static void PadLeft(Span<char> destination, ReadOnlySpan<char> source, char paddingChar)
{
if (source.Length > destination.Length)
{
throw new ArgumentException("Source is longer than destination.");
}

var padLength = destination.Length - source.Length;
destination[..padLength].Fill(paddingChar);
source.CopyTo(destination[padLength..]);
}

/// <summary>
/// Masks an email address by obscuring parts of the user and domain sections.
/// </summary>
Expand Down Expand Up @@ -158,32 +145,6 @@ protected virtual string MaskEmail(ReadOnlySpan<char> raw)
return new string(buffer[..outputIndex]);
}

private static bool TrySplitEmail(ReadOnlySpan<char> email, out ReadOnlySpan<char> user, out ReadOnlySpan<char> domain)
{
user = default;
domain = default;

var at = email.IndexOf('@');
if (at <= 0 || at >= email.Length - 1)
{
return false;
}

if (email.Slice(at + 1).IndexOf('@') >= 0)
{
return false; // must be exactly one '@'
}

if (email.IndexOfAny([' ', '\t', '\r', '\n']) >= 0)
{
return false; // no whitespace
}

user = email[..at];
domain = email[(at + 1)..];
return true;
}

/// <summary>
/// Masks an International Bank Account Number (IBAN), revealing only key sections.
/// </summary>
Expand Down Expand Up @@ -248,6 +209,7 @@ private static int NormalizeIban(ReadOnlySpan<char> src, Span<char> dst)

dst[c++] = char.ToUpperInvariant(ch);
}

return c;
}

Expand Down Expand Up @@ -281,7 +243,45 @@ private static bool LooksLikeIban(ReadOnlySpan<char> iban)
return false;
}
}


return true;
}

private static void PadLeft(Span<char> destination, ReadOnlySpan<char> source, char paddingChar)
{
if (source.Length > destination.Length)
{
throw new ArgumentException("Source is longer than destination.");
}

var padLength = destination.Length - source.Length;
destination[..padLength].Fill(paddingChar);
source.CopyTo(destination[padLength..]);
}

private static bool TrySplitEmail(ReadOnlySpan<char> email, out ReadOnlySpan<char> user, out ReadOnlySpan<char> domain)
{
user = default;
domain = default;

var at = email.IndexOf('@');
if (at <= 0 || at >= email.Length - 1)
{
return false;
}

if (email.Slice(at + 1).IndexOf('@') >= 0)
{
return false; // must be exactly one '@'
}

if (email.IndexOfAny([' ', '\t', '\r', '\n']) >= 0)
{
return false; // no whitespace
}

user = email[..at];
domain = email[(at + 1)..];
return true;
}

Expand Down
9 changes: 9 additions & 0 deletions src/Json.Masker.Abstract/DigitNormalizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,19 @@

namespace Json.Masker.Abstract;

/// <summary>
/// Utility methods for extracting digits from strings while preserving their order.
/// </summary>
internal static class DigitNormalizer
{
private static readonly int SmallInputSimdCutoff = Vector<ushort>.Count * 2;

/// <summary>
/// Copies digits from the input span into the destination buffer, skipping non-numeric characters.
/// </summary>
/// <param name="input">The source characters to inspect.</param>
/// <param name="outputBuffer">The destination buffer that receives the digits.</param>
/// <returns>The number of digits written to <paramref name="outputBuffer"/>.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static int Normalize(ReadOnlySpan<char> input, Span<char> outputBuffer)
{
Expand Down
10 changes: 5 additions & 5 deletions src/Json.Masker.Abstract/IMaskingService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ namespace Json.Masker.Abstract;
/// </summary>
public interface IMaskingService
{
/// <summary>
/// Gets the default mask applied when no specific strategy is provided.
/// </summary>
string DefaultMask { get; }

/// <summary>
/// Produces a masked representation of the provided value using the supplied context and strategy.
/// </summary>
Expand All @@ -13,9 +18,4 @@ public interface IMaskingService
/// <param name="pattern">Custom masking pattern to apply.</param>
/// <returns>The masked value.</returns>
string Mask(ReadOnlySpan<char> str, MaskingStrategy strategy, string? pattern);

/// <summary>
/// Gets the default mask applied when no specific strategy is provided.
/// </summary>
string DefaultMask { get; }
}
16 changes: 16 additions & 0 deletions src/Json.Masker.Abstract/UtilExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,30 @@

namespace Json.Masker.Abstract;

/// <summary>
/// Provides helper extensions for converting values to invariant string representations.
/// </summary>
public static class UtilExtensions
{
/// <summary>
/// Attempts to convert the provided value into its invariant string representation.
/// </summary>
/// <typeparam name="T">The value type.</typeparam>
/// <param name="value">The value to convert.</param>
/// <param name="str">When this method returns, contains the converted string if the conversion was successful; otherwise, <see langword="null"/>.</param>
/// <returns><see langword="true"/> when conversion succeeds; otherwise, <see langword="false"/>.</returns>
public static bool TryConvertToString<T>(this T? value, out string? str)
{
str = value.ToInvariantString();
return str != null;
}

/// <summary>
/// Converts a value to an invariant string when possible.
/// </summary>
/// <typeparam name="T">The value type.</typeparam>
/// <param name="value">The value to convert.</param>
/// <returns>The invariant string representation, or <see langword="null"/> when conversion is not possible.</returns>
private static string? ToInvariantString<T>(this T? value)
{
switch (value)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
using Json.Masker.Abstract;
using Json.Masker.Abstract;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;

namespace Json.Masker.AspNet.Newtonsoft;

/// <summary>
/// Extension methods that enable JSON masking for ASP.NET Core applications that use Newtonsoft.Json.
/// </summary>
public static class JsonMaskingMiddlewareExtensions
{
/// <summary>
/// Registers middleware that controls masking and updates MVC's <see cref="MvcNewtonsoftJsonOptions"/> to use masked output.
/// </summary>
/// <param name="app">The application builder used to configure the HTTP request pipeline.</param>
/// <param name="shouldMask">Optional predicate that decides whether masking is enabled for the current request.</param>
/// <returns>The same <see cref="IApplicationBuilder"/> instance to allow fluent calls.</returns>
public static IApplicationBuilder UseNewtonsoftJsonMasking(
this IApplicationBuilder app,
Func<HttpContext, bool>? shouldMask = null)
Expand All @@ -26,4 +35,4 @@ public static IApplicationBuilder UseNewtonsoftJsonMasking(

return app;
}
}
}
15 changes: 7 additions & 8 deletions src/Json.Masker.AspNet/DecideEnablingMaskingMiddleware.cs
Original file line number Diff line number Diff line change
@@ -1,23 +1,22 @@
using Json.Masker.Abstract;
using Json.Masker.Abstract;
using Microsoft.AspNetCore.Http;
#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member

namespace Json.Masker.AspNet;

/// <summary>
/// A middleware
/// Middleware that toggles the ambient <see cref="MaskingContext"/> for a request based on a predicate.
/// </summary>
/// <param name="next">a delegate to execute next in chain of the delegate</param>
/// <param name="shouldMask">A delegate to decide whether to mask any serialization of the current context or not</param>
/// <param name="next">The next middleware delegate in the ASP.NET Core pipeline.</param>
/// <param name="shouldMask">Predicate that determines whether masking should be enabled for the current request.</param>
// ReSharper disable once ClassNeverInstantiated.Global
public class DecideEnablingMaskingMiddleware(RequestDelegate next, Func<HttpContext, bool>? shouldMask)
{
private readonly Func<HttpContext, bool> _shouldMask = shouldMask ?? (_ => false);

/// <summary>
/// Invokes the middleware
/// Executes the middleware pipeline while applying the masking context if required.
/// </summary>
/// <param name="context">HttpContext of the current, ongoing call</param>
/// <param name="context">The <see cref="HttpContext"/> for the current request.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
public async Task InvokeAsync(HttpContext context)
{
Expand All @@ -36,4 +35,4 @@ public async Task InvokeAsync(HttpContext context)
MaskingContextAccessor.Set(new MaskingContext { Enabled = currentEnabled });
}
}
}
}
Loading
Loading