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:
6
src/PiiRedaction.Core/Abstractions/ILlmPromptService.cs
Normal file
6
src/PiiRedaction.Core/Abstractions/ILlmPromptService.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace PiiRedaction.Core.Abstractions;
|
||||
|
||||
public interface ILlmPromptService
|
||||
{
|
||||
Task<string> SendPromptAsync(string sanitizedPrompt, CancellationToken cancellationToken = default);
|
||||
}
|
||||
8
src/PiiRedaction.Core/Abstractions/IPiiDetector.cs
Normal file
8
src/PiiRedaction.Core/Abstractions/IPiiDetector.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
using PiiRedaction.Core.Models;
|
||||
|
||||
namespace PiiRedaction.Core.Abstractions;
|
||||
|
||||
public interface IPiiDetector
|
||||
{
|
||||
IReadOnlyList<PiiEntity> Detect(string text);
|
||||
}
|
||||
8
src/PiiRedaction.Core/Abstractions/IPiiRedactor.cs
Normal file
8
src/PiiRedaction.Core/Abstractions/IPiiRedactor.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
using PiiRedaction.Core.Models;
|
||||
|
||||
namespace PiiRedaction.Core.Abstractions;
|
||||
|
||||
public interface IPiiRedactor
|
||||
{
|
||||
RedactionResult Redact(string text, IReadOnlyList<PiiEntity> entities);
|
||||
}
|
||||
8
src/PiiRedaction.Core/Abstractions/IPromptSanitizer.cs
Normal file
8
src/PiiRedaction.Core/Abstractions/IPromptSanitizer.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
using PiiRedaction.Core.Models;
|
||||
|
||||
namespace PiiRedaction.Core.Abstractions;
|
||||
|
||||
public interface IPromptSanitizer
|
||||
{
|
||||
SanitizationResult Sanitize(SanitizationRequest request);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
55
src/PiiRedaction.Core/Detection/CompositePiiDetector.cs
Normal file
55
src/PiiRedaction.Core/Detection/CompositePiiDetector.cs
Normal 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
|
||||
};
|
||||
}
|
||||
56
src/PiiRedaction.Core/Detection/DomainRulePiiDetector.cs
Normal file
56
src/PiiRedaction.Core/Detection/DomainRulePiiDetector.cs
Normal 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();
|
||||
}
|
||||
11
src/PiiRedaction.Core/Detection/IOnnxNerModelRunner.cs
Normal file
11
src/PiiRedaction.Core/Detection/IOnnxNerModelRunner.cs
Normal 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);
|
||||
}
|
||||
31
src/PiiRedaction.Core/Detection/OnnxNerPiiDetector.cs
Normal file
31
src/PiiRedaction.Core/Detection/OnnxNerPiiDetector.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
64
src/PiiRedaction.Core/Detection/RegexPiiDetector.cs
Normal file
64
src/PiiRedaction.Core/Detection/RegexPiiDetector.cs
Normal 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();
|
||||
}
|
||||
8
src/PiiRedaction.Core/Models/PiiDetectionSource.cs
Normal file
8
src/PiiRedaction.Core/Models/PiiDetectionSource.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace PiiRedaction.Core.Models;
|
||||
|
||||
public enum PiiDetectionSource
|
||||
{
|
||||
Regex,
|
||||
Domain,
|
||||
Ner
|
||||
}
|
||||
12
src/PiiRedaction.Core/Models/PiiEntity.cs
Normal file
12
src/PiiRedaction.Core/Models/PiiEntity.cs
Normal 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;
|
||||
}
|
||||
14
src/PiiRedaction.Core/Models/PiiEntityType.cs
Normal file
14
src/PiiRedaction.Core/Models/PiiEntityType.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace PiiRedaction.Core.Models;
|
||||
|
||||
public enum PiiEntityType
|
||||
{
|
||||
Person,
|
||||
Email,
|
||||
Phone,
|
||||
Pan,
|
||||
Aadhaar,
|
||||
CreditCard,
|
||||
LoanNumber,
|
||||
CustomerId,
|
||||
AccountNumber
|
||||
}
|
||||
5
src/PiiRedaction.Core/Models/RedactionResult.cs
Normal file
5
src/PiiRedaction.Core/Models/RedactionResult.cs
Normal file
@@ -0,0 +1,5 @@
|
||||
namespace PiiRedaction.Core.Models;
|
||||
|
||||
public sealed record RedactionResult(
|
||||
string SanitizedText,
|
||||
IReadOnlyDictionary<string, string> PlaceholderMap);
|
||||
3
src/PiiRedaction.Core/Models/SanitizationRequest.cs
Normal file
3
src/PiiRedaction.Core/Models/SanitizationRequest.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace PiiRedaction.Core.Models;
|
||||
|
||||
public sealed record SanitizationRequest(string OriginalPrompt);
|
||||
7
src/PiiRedaction.Core/Models/SanitizationResult.cs
Normal file
7
src/PiiRedaction.Core/Models/SanitizationResult.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace PiiRedaction.Core.Models;
|
||||
|
||||
public sealed record SanitizationResult(
|
||||
string OriginalPrompt,
|
||||
string SanitizedPrompt,
|
||||
IReadOnlyList<PiiEntity> DetectedEntities,
|
||||
RedactionResult Redaction);
|
||||
13
src/PiiRedaction.Core/PiiRedaction.Core.csproj
Normal file
13
src/PiiRedaction.Core/PiiRedaction.Core.csproj
Normal 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>
|
||||
58
src/PiiRedaction.Core/Redaction/PlaceholderPiiRedactor.cs
Normal file
58
src/PiiRedaction.Core/Redaction/PlaceholderPiiRedactor.cs
Normal 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()
|
||||
};
|
||||
}
|
||||
31
src/PiiRedaction.Core/Sanitization/PromptSanitizer.cs
Normal file
31
src/PiiRedaction.Core/Sanitization/PromptSanitizer.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user