Add PII redaction POC for secure LLM prompting.

Implements detect-redact-sanitize pipeline with regex, domain rules, and ONNX NER before the LLM boundary, plus NUnit tests and Xenovex push documentation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Bilal Nazer Ali
2026-07-07 13:05:07 +05:30
commit dfc81dea28
60 changed files with 3283 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
namespace PiiRedaction.Core.Abstractions;
public interface ILlmPromptService
{
Task<string> SendPromptAsync(string sanitizedPrompt, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,8 @@
using PiiRedaction.Core.Models;
namespace PiiRedaction.Core.Abstractions;
public interface IPiiDetector
{
IReadOnlyList<PiiEntity> Detect(string text);
}

View File

@@ -0,0 +1,8 @@
using PiiRedaction.Core.Models;
namespace PiiRedaction.Core.Abstractions;
public interface IPiiRedactor
{
RedactionResult Redact(string text, IReadOnlyList<PiiEntity> entities);
}

View File

@@ -0,0 +1,8 @@
using PiiRedaction.Core.Models;
namespace PiiRedaction.Core.Abstractions;
public interface IPromptSanitizer
{
SanitizationResult Sanitize(SanitizationRequest request);
}

View File

@@ -0,0 +1,8 @@
namespace PiiRedaction.Core.Configuration;
public sealed class PiiRedactionOptions
{
public const string SectionName = "PiiRedaction";
public string OnnxModelPath { get; set; } = "models/ner-model.onnx";
}

View File

@@ -0,0 +1,55 @@
using PiiRedaction.Core.Abstractions;
using PiiRedaction.Core.Models;
namespace PiiRedaction.Core.Detection;
/// <summary>
/// Aggregates multiple PII detectors and merges overlapping spans.
/// Detectors are applied in registration order; earlier detectors win on overlap.
/// </summary>
public sealed class CompositePiiDetector : IPiiDetector
{
private readonly IReadOnlyList<IPiiDetector> _detectors;
public CompositePiiDetector(IEnumerable<IPiiDetector> detectors)
{
_detectors = detectors.ToList();
}
public IReadOnlyList<PiiEntity> Detect(string text)
{
ArgumentException.ThrowIfNullOrWhiteSpace(text);
var candidates = _detectors
.SelectMany(detector => detector.Detect(text))
.OrderBy(entity => entity.StartIndex)
.ThenByDescending(entity => entity.Length)
.ThenByDescending(entity => GetSourcePriority(entity.Source))
.ToList();
var merged = new List<PiiEntity>();
foreach (var candidate in candidates)
{
if (merged.Any(existing => Overlaps(existing, candidate)))
{
continue;
}
merged.Add(candidate);
}
return merged.OrderBy(entity => entity.StartIndex).ToList();
}
private static bool Overlaps(PiiEntity left, PiiEntity right) =>
left.StartIndex < right.EndIndex && right.StartIndex < left.EndIndex;
private static int GetSourcePriority(PiiDetectionSource source) => source switch
{
PiiDetectionSource.Domain => 3,
PiiDetectionSource.Regex => 2,
PiiDetectionSource.Ner => 1,
_ => 0
};
}

View File

@@ -0,0 +1,56 @@
using System.Text.RegularExpressions;
using PiiRedaction.Core.Abstractions;
using PiiRedaction.Core.Models;
namespace PiiRedaction.Core.Detection;
/// <summary>
/// Detects organization-specific identifiers using business domain rules.
/// Domain rules are needed because identifiers like loan numbers and customer IDs
/// are defined by internal systems and cannot be inferred reliably by generic NER or public regex alone.
/// </summary>
public sealed partial class DomainRulePiiDetector : IPiiDetector
{
private static readonly (PiiEntityType Type, Regex Pattern)[] Patterns =
[
(PiiEntityType.LoanNumber, LoanNumberPattern()),
(PiiEntityType.CustomerId, CustomerIdPattern()),
(PiiEntityType.AccountNumber, AccountNumberPattern())
];
public IReadOnlyList<PiiEntity> Detect(string text)
{
ArgumentException.ThrowIfNullOrWhiteSpace(text);
var entities = new List<PiiEntity>();
foreach (var (type, pattern) in Patterns)
{
foreach (Match match in pattern.Matches(text))
{
if (!match.Success)
{
continue;
}
entities.Add(new PiiEntity(
type,
match.Value,
match.Index,
match.Length,
PiiDetectionSource.Domain));
}
}
return entities;
}
[GeneratedRegex(@"(?i)\bLN-\d{6,}\b", RegexOptions.Compiled)]
private static partial Regex LoanNumberPattern();
[GeneratedRegex(@"(?i)\bCID-\d{4,}\b", RegexOptions.Compiled)]
private static partial Regex CustomerIdPattern();
[GeneratedRegex(@"(?i)\bACC-\d{6,}\b", RegexOptions.Compiled)]
private static partial Regex AccountNumberPattern();
}

View File

@@ -0,0 +1,11 @@
using PiiRedaction.Core.Abstractions;
using PiiRedaction.Core.Models;
namespace PiiRedaction.Core.Detection;
public interface IOnnxNerModelRunner
{
bool IsModelAvailable { get; }
IReadOnlyList<PiiEntity> PredictEntities(string text);
}

View File

@@ -0,0 +1,31 @@
using PiiRedaction.Core.Abstractions;
using PiiRedaction.Core.Models;
namespace PiiRedaction.Core.Detection;
/// <summary>
/// Detects contextual entities such as person names using ONNX-based NER.
/// NER is used for entities that lack rigid formats and vary in surface form across prompts.
/// Requires a loaded ONNX model; returns no person entities when the model is unavailable.
/// </summary>
public sealed class OnnxNerPiiDetector : IPiiDetector
{
private readonly IOnnxNerModelRunner _modelRunner;
public OnnxNerPiiDetector(IOnnxNerModelRunner modelRunner)
{
_modelRunner = modelRunner;
}
public IReadOnlyList<PiiEntity> Detect(string text)
{
ArgumentException.ThrowIfNullOrWhiteSpace(text);
if (!_modelRunner.IsModelAvailable)
{
return [];
}
return _modelRunner.PredictEntities(text);
}
}

View File

@@ -0,0 +1,64 @@
using System.Text.RegularExpressions;
using PiiRedaction.Core.Abstractions;
using PiiRedaction.Core.Models;
namespace PiiRedaction.Core.Detection;
/// <summary>
/// Detects PII using deterministic regular-expression patterns.
/// Regex is used for format-bound identifiers (email, PAN, phone, Aadhaar, credit card)
/// where rules are stable, auditable, and produce predictable matches without model inference.
/// </summary>
public sealed partial class RegexPiiDetector : IPiiDetector
{
private static readonly (PiiEntityType Type, Regex Pattern)[] Patterns =
[
(PiiEntityType.Email, EmailPattern()),
(PiiEntityType.Phone, PhonePattern()),
(PiiEntityType.Aadhaar, AadhaarPattern()),
(PiiEntityType.Pan, PanPattern()),
(PiiEntityType.CreditCard, CreditCardPattern())
];
public IReadOnlyList<PiiEntity> Detect(string text)
{
ArgumentException.ThrowIfNullOrWhiteSpace(text);
var entities = new List<PiiEntity>();
foreach (var (type, pattern) in Patterns)
{
foreach (Match match in pattern.Matches(text))
{
if (!match.Success)
{
continue;
}
entities.Add(new PiiEntity(
type,
match.Value,
match.Index,
match.Length,
PiiDetectionSource.Regex));
}
}
return entities;
}
[GeneratedRegex(@"[\w.+-]+@[\w.-]+\.\w+", RegexOptions.Compiled)]
private static partial Regex EmailPattern();
[GeneratedRegex(@"(?<!\d)\d{10}(?!\d)", RegexOptions.Compiled)]
private static partial Regex PhonePattern();
[GeneratedRegex(@"\b\d{4}\s?\d{4}\s?\d{4}\b", RegexOptions.Compiled)]
private static partial Regex AadhaarPattern();
[GeneratedRegex(@"\b[A-Z]{5}\d{4}[A-Z]\b", RegexOptions.Compiled)]
private static partial Regex PanPattern();
[GeneratedRegex(@"\b(?:\d{4}[-\s]?){3}\d{4}\b", RegexOptions.Compiled)]
private static partial Regex CreditCardPattern();
}

View File

@@ -0,0 +1,8 @@
namespace PiiRedaction.Core.Models;
public enum PiiDetectionSource
{
Regex,
Domain,
Ner
}

View File

@@ -0,0 +1,12 @@
namespace PiiRedaction.Core.Models;
public sealed record PiiEntity(
PiiEntityType Type,
string Value,
int StartIndex,
int Length,
PiiDetectionSource Source,
double? Confidence = null)
{
public int EndIndex => StartIndex + Length;
}

View File

@@ -0,0 +1,14 @@
namespace PiiRedaction.Core.Models;
public enum PiiEntityType
{
Person,
Email,
Phone,
Pan,
Aadhaar,
CreditCard,
LoanNumber,
CustomerId,
AccountNumber
}

View File

@@ -0,0 +1,5 @@
namespace PiiRedaction.Core.Models;
public sealed record RedactionResult(
string SanitizedText,
IReadOnlyDictionary<string, string> PlaceholderMap);

View File

@@ -0,0 +1,3 @@
namespace PiiRedaction.Core.Models;
public sealed record SanitizationRequest(string OriginalPrompt);

View File

@@ -0,0 +1,7 @@
namespace PiiRedaction.Core.Models;
public sealed record SanitizationResult(
string OriginalPrompt,
string SanitizedPrompt,
IReadOnlyList<PiiEntity> DetectedEntities,
RedactionResult Redaction);

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.9" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,58 @@
using PiiRedaction.Core.Abstractions;
using PiiRedaction.Core.Models;
namespace PiiRedaction.Core.Redaction;
public sealed class PlaceholderPiiRedactor : IPiiRedactor
{
public RedactionResult Redact(string text, IReadOnlyList<PiiEntity> entities)
{
ArgumentException.ThrowIfNullOrWhiteSpace(text);
ArgumentNullException.ThrowIfNull(entities);
var placeholderByValue = new Dictionary<string, string>(StringComparer.Ordinal);
var counters = new Dictionary<PiiEntityType, int>();
var orderedEntities = entities.OrderByDescending(entity => entity.StartIndex).ToList();
var sanitized = text;
foreach (var entity in orderedEntities)
{
var mapKey = CreateValueKey(entity);
if (!placeholderByValue.TryGetValue(mapKey, out var placeholder))
{
counters.TryGetValue(entity.Type, out var count);
count++;
counters[entity.Type] = count;
placeholder = $"<{ToPlaceholderPrefix(entity.Type)}_{count}>";
placeholderByValue[mapKey] = placeholder;
}
sanitized = string.Concat(
sanitized.AsSpan(0, entity.StartIndex),
placeholder,
sanitized.AsSpan(entity.EndIndex));
}
var placeholderMap = placeholderByValue
.ToDictionary(pair => pair.Value, pair => pair.Key.Split('|', 2)[1], StringComparer.Ordinal);
return new RedactionResult(sanitized, placeholderMap);
}
private static string CreateValueKey(PiiEntity entity) =>
$"{entity.Type}|{entity.Value}";
private static string ToPlaceholderPrefix(PiiEntityType type) => type switch
{
PiiEntityType.Person => "PERSON",
PiiEntityType.Email => "EMAIL",
PiiEntityType.Phone => "PHONE",
PiiEntityType.Pan => "PAN",
PiiEntityType.Aadhaar => "AADHAAR",
PiiEntityType.CreditCard => "CREDIT_CARD",
PiiEntityType.LoanNumber => "LOAN_NUMBER",
PiiEntityType.CustomerId => "CUSTOMER_ID",
PiiEntityType.AccountNumber => "ACCOUNT_NUMBER",
_ => type.ToString().ToUpperInvariant()
};
}

View File

@@ -0,0 +1,31 @@
using PiiRedaction.Core.Abstractions;
using PiiRedaction.Core.Models;
namespace PiiRedaction.Core.Sanitization;
public sealed class PromptSanitizer : IPromptSanitizer
{
private readonly IPiiDetector _detector;
private readonly IPiiRedactor _redactor;
public PromptSanitizer(IPiiDetector detector, IPiiRedactor redactor)
{
_detector = detector;
_redactor = redactor;
}
public SanitizationResult Sanitize(SanitizationRequest request)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(request.OriginalPrompt);
var entities = _detector.Detect(request.OriginalPrompt);
var redaction = _redactor.Redact(request.OriginalPrompt, entities);
return new SanitizationResult(
request.OriginalPrompt,
redaction.SanitizedText,
entities,
redaction);
}
}