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:
@@ -0,0 +1,127 @@
|
||||
using FluentAssertions;
|
||||
using PiiRedaction.Core.Abstractions;
|
||||
using PiiRedaction.Core.Detection;
|
||||
using PiiRedaction.Core.Models;
|
||||
|
||||
namespace PiiRedaction.Core.Tests.Detection;
|
||||
|
||||
[TestFixture]
|
||||
public sealed class CompositePiiDetectorTests
|
||||
{
|
||||
[Test]
|
||||
public void Detect_AdjacentSpans_KeepsBothEntities()
|
||||
{
|
||||
var composite = new CompositePiiDetector([new FixedDetector(
|
||||
new PiiEntity(PiiEntityType.Email, "a@b.co", 0, 6, PiiDetectionSource.Regex),
|
||||
new PiiEntity(PiiEntityType.Phone, "9876543210", 6, 10, PiiDetectionSource.Regex))]);
|
||||
|
||||
var entities = composite.Detect("abcdef9876543210padding");
|
||||
|
||||
entities.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Detect_NestedSpan_KeepsLongerSpan()
|
||||
{
|
||||
var composite = new CompositePiiDetector([new FixedDetector(
|
||||
new PiiEntity(PiiEntityType.Aadhaar, "123456789012", 0, 12, PiiDetectionSource.Regex),
|
||||
new PiiEntity(PiiEntityType.Phone, "4567890123", 2, 10, PiiDetectionSource.Regex))]);
|
||||
|
||||
var entities = composite.Detect("123456789012");
|
||||
|
||||
entities.Should().ContainSingle(entity => entity.Type == PiiEntityType.Aadhaar);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Detect_OverlappingSameStart_LongerSpanWins()
|
||||
{
|
||||
var composite = new CompositePiiDetector([new FixedDetector(
|
||||
new PiiEntity(PiiEntityType.LoanNumber, "LN-456789", 0, 9, PiiDetectionSource.Domain),
|
||||
new PiiEntity(PiiEntityType.Phone, "456789", 3, 6, PiiDetectionSource.Regex))]);
|
||||
|
||||
var entities = composite.Detect("LN-456789");
|
||||
|
||||
entities.Should().ContainSingle(entity => entity.Type == PiiEntityType.LoanNumber);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Detect_OverlappingDifferentPriority_DomainBeatsRegex()
|
||||
{
|
||||
var composite = new CompositePiiDetector([new FixedDetector(
|
||||
new PiiEntity(PiiEntityType.LoanNumber, "LN-456789", 0, 9, PiiDetectionSource.Domain),
|
||||
new PiiEntity(PiiEntityType.Phone, "456789", 0, 6, PiiDetectionSource.Regex))]);
|
||||
|
||||
var entities = composite.Detect("LN-456789");
|
||||
|
||||
entities.Should().ContainSingle(entity =>
|
||||
entity.Type == PiiEntityType.LoanNumber &&
|
||||
entity.Source == PiiDetectionSource.Domain);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Detect_OverlappingPriority_RegexBeatsNer()
|
||||
{
|
||||
var composite = new CompositePiiDetector([new FixedDetector(
|
||||
new PiiEntity(PiiEntityType.Email, "a@b.co", 0, 6, PiiDetectionSource.Regex),
|
||||
new PiiEntity(PiiEntityType.Person, "a@b", 0, 3, PiiDetectionSource.Ner))]);
|
||||
|
||||
var entities = composite.Detect("a@b.co");
|
||||
|
||||
entities.Should().ContainSingle(entity => entity.Source == PiiDetectionSource.Regex);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Detect_OverlappingPriority_DomainBeatsNer()
|
||||
{
|
||||
var composite = new CompositePiiDetector([new FixedDetector(
|
||||
new PiiEntity(PiiEntityType.LoanNumber, "LN-456789", 0, 9, PiiDetectionSource.Domain),
|
||||
new PiiEntity(PiiEntityType.Person, "LN-456", 0, 6, PiiDetectionSource.Ner))]);
|
||||
|
||||
var entities = composite.Detect("LN-456789");
|
||||
|
||||
entities.Should().ContainSingle(entity => entity.Source == PiiDetectionSource.Domain);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Detect_DuplicateOverlappingSpan_KeepsFirstAccepted()
|
||||
{
|
||||
var composite = new CompositePiiDetector([new FixedDetector(
|
||||
new PiiEntity(PiiEntityType.Phone, "9876543210", 0, 10, PiiDetectionSource.Regex),
|
||||
new PiiEntity(PiiEntityType.Phone, "9876543210", 0, 10, PiiDetectionSource.Regex))]);
|
||||
|
||||
var entities = composite.Detect("9876543210");
|
||||
|
||||
entities.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Detect_AadhaarWithEmbeddedPhone_PrefersAadhaarSpan()
|
||||
{
|
||||
var detector = new CompositePiiDetector(
|
||||
[
|
||||
new DomainRulePiiDetector(),
|
||||
new RegexPiiDetector()
|
||||
]);
|
||||
|
||||
var entities = detector.Detect("Aadhaar 987654321012 phone 9876543210.");
|
||||
|
||||
entities.Should().Contain(entity => entity.Type == PiiEntityType.Aadhaar && entity.Value == "987654321012");
|
||||
entities.Should().Contain(entity => entity.Type == PiiEntityType.Phone && entity.Value == "9876543210");
|
||||
entities.Count(entity => entity.Type == PiiEntityType.Phone).Should().Be(1);
|
||||
}
|
||||
|
||||
[TestCase(null)]
|
||||
[TestCase("")]
|
||||
[TestCase(" ")]
|
||||
public void Detect_InvalidInput_ThrowsArgumentException(string? text)
|
||||
{
|
||||
var composite = new CompositePiiDetector([new RegexPiiDetector()]);
|
||||
var action = () => composite.Detect(text!);
|
||||
action.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
private sealed class FixedDetector(params PiiEntity[] entities) : IPiiDetector
|
||||
{
|
||||
public IReadOnlyList<PiiEntity> Detect(string text) => entities;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using FluentAssertions;
|
||||
using PiiRedaction.Core.Detection;
|
||||
using PiiRedaction.Core.Models;
|
||||
|
||||
namespace PiiRedaction.Core.Tests.Detection;
|
||||
|
||||
[TestFixture]
|
||||
public sealed class DomainRulePiiDetectorTests
|
||||
{
|
||||
private readonly DomainRulePiiDetector _detector = new();
|
||||
|
||||
[Test]
|
||||
public void Detect_PositiveLoanNumber_ReturnsValueOnly()
|
||||
{
|
||||
var entities = _detector.Detect("Loan LN-456789 active.");
|
||||
|
||||
entities.Should().ContainSingle(entity =>
|
||||
entity.Type == PiiEntityType.LoanNumber &&
|
||||
entity.Value == "LN-456789" &&
|
||||
entity.Source == PiiDetectionSource.Domain);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Detect_PositiveCustomerId_ReturnsEntity()
|
||||
{
|
||||
var entities = _detector.Detect("CustomerId CID-1234 found.");
|
||||
|
||||
entities.Should().ContainSingle(entity =>
|
||||
entity.Type == PiiEntityType.CustomerId &&
|
||||
entity.Value == "CID-1234");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Detect_PositiveAccountNumber_ReturnsEntity()
|
||||
{
|
||||
var entities = _detector.Detect("Account ACC-123456 open.");
|
||||
|
||||
entities.Should().ContainSingle(entity =>
|
||||
entity.Type == PiiEntityType.AccountNumber &&
|
||||
entity.Value == "ACC-123456");
|
||||
}
|
||||
|
||||
[TestCase("LN-12345")]
|
||||
[TestCase("XLN-456789")]
|
||||
[TestCase("CID-123")]
|
||||
[TestCase("ACC-12345")]
|
||||
public void Detect_InvalidDomainIds_ReturnsEmpty(string text)
|
||||
{
|
||||
_detector.Detect(text).Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Detect_LoanNumberLabel_PreservesLabelInSurroundingText()
|
||||
{
|
||||
const string text = "LoanNumber LN-456789 end.";
|
||||
var entity = _detector.Detect(text).Single();
|
||||
|
||||
entity.Value.Should().Be("LN-456789");
|
||||
entity.StartIndex.Should().Be("LoanNumber ".Length);
|
||||
}
|
||||
|
||||
[TestCase(null)]
|
||||
[TestCase("")]
|
||||
[TestCase(" ")]
|
||||
public void Detect_InvalidInput_ThrowsArgumentException(string? text)
|
||||
{
|
||||
var action = () => _detector.Detect(text!);
|
||||
action.Should().Throw<ArgumentException>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using FluentAssertions;
|
||||
using PiiRedaction.Core.Detection;
|
||||
using PiiRedaction.Core.Models;
|
||||
using PiiRedaction.Core.Tests.TestSupport;
|
||||
|
||||
namespace PiiRedaction.Core.Tests.Detection;
|
||||
|
||||
[TestFixture]
|
||||
public sealed class OnnxNerPiiDetectorTests
|
||||
{
|
||||
[Test]
|
||||
public void Detect_ModelAvailable_ReturnsRunnerEntities()
|
||||
{
|
||||
var runner = new FakeOnnxNerModelRunner
|
||||
{
|
||||
IsModelAvailable = true,
|
||||
EntitiesToReturn =
|
||||
[
|
||||
new PiiEntity(PiiEntityType.Person, "Onnx Person", 0, 11, PiiDetectionSource.Ner)
|
||||
]
|
||||
};
|
||||
|
||||
var detector = new OnnxNerPiiDetector(runner);
|
||||
var entities = detector.Detect("Any text");
|
||||
|
||||
entities.Should().ContainSingle(entity => entity.Value == "Onnx Person");
|
||||
runner.LastPredictedText.Should().Be("Any text");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Detect_ModelUnavailable_ReturnsEmpty()
|
||||
{
|
||||
var detector = CreateDetector(modelAvailable: false);
|
||||
var entities = detector.Detect("Customer Ravi Kumar with email test@x.com.");
|
||||
|
||||
entities.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[TestCase(null)]
|
||||
[TestCase("")]
|
||||
[TestCase(" ")]
|
||||
public void Detect_InvalidInput_ThrowsArgumentException(string? text)
|
||||
{
|
||||
var detector = CreateDetector(modelAvailable: false);
|
||||
var action = () => detector.Detect(text!);
|
||||
action.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
private static OnnxNerPiiDetector CreateDetector(bool modelAvailable)
|
||||
{
|
||||
var runner = new FakeOnnxNerModelRunner { IsModelAvailable = modelAvailable };
|
||||
return new OnnxNerPiiDetector(runner);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using FluentAssertions;
|
||||
using PiiRedaction.Core.Detection;
|
||||
using PiiRedaction.Core.Models;
|
||||
|
||||
namespace PiiRedaction.Core.Tests.Detection;
|
||||
|
||||
[TestFixture]
|
||||
public sealed class RegexPiiDetectorTests
|
||||
{
|
||||
private readonly RegexPiiDetector _detector = new();
|
||||
|
||||
[Test]
|
||||
public void Detect_PositiveEmail_ReturnsEntity()
|
||||
{
|
||||
const string text = "Contact ravi.kumar@gmail.com now.";
|
||||
var entities = _detector.Detect(text);
|
||||
|
||||
entities.Should().ContainSingle(entity =>
|
||||
entity.Type == PiiEntityType.Email &&
|
||||
entity.Value == "ravi.kumar@gmail.com" &&
|
||||
entity.Source == PiiDetectionSource.Regex);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Detect_PositivePhone_ReturnsTenDigitEntity()
|
||||
{
|
||||
var entities = _detector.Detect("Call 9876543210 today.");
|
||||
|
||||
entities.Should().ContainSingle(entity =>
|
||||
entity.Type == PiiEntityType.Phone &&
|
||||
entity.Value == "9876543210" &&
|
||||
entity.Source == PiiDetectionSource.Regex);
|
||||
}
|
||||
|
||||
[TestCase("Aadhaar 1234 5678 9012 linked.", "1234 5678 9012")]
|
||||
[TestCase("Aadhaar 123456789012 linked.", "123456789012")]
|
||||
public void Detect_PositiveAadhaar_ReturnsEntity(string text, string expectedValue)
|
||||
{
|
||||
var entities = _detector.Detect(text);
|
||||
|
||||
entities.Should().ContainSingle(entity =>
|
||||
entity.Type == PiiEntityType.Aadhaar &&
|
||||
entity.Value == expectedValue);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Detect_PositivePan_ReturnsUppercaseEntity()
|
||||
{
|
||||
var entities = _detector.Detect("PAN ABCDE1234F verified.");
|
||||
|
||||
entities.Should().ContainSingle(entity => entity.Type == PiiEntityType.Pan);
|
||||
entities[0].Value.Should().MatchRegex("^[A-Z]{5}\\d{4}[A-Z]$");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Detect_PositiveCreditCard_ReturnsEntity()
|
||||
{
|
||||
var entities = _detector.Detect("Card 4111-1111-1111-1111 used.");
|
||||
|
||||
entities.Should().ContainSingle(entity => entity.Type == PiiEntityType.CreditCard);
|
||||
}
|
||||
|
||||
[TestCase("not-an-email")]
|
||||
[TestCase("@missing.com")]
|
||||
[TestCase("pan abcde1234f")]
|
||||
[TestCase("Number 987654321")]
|
||||
public void Detect_NegativePatterns_ReturnsNoMatch(string text)
|
||||
{
|
||||
_detector.Detect(text).Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Detect_EmailSpan_HasCorrectIndices()
|
||||
{
|
||||
const string text = "Email ravi@test.com end.";
|
||||
var entities = _detector.Detect(text);
|
||||
|
||||
var email = entities.Single(entity => entity.Type == PiiEntityType.Email);
|
||||
email.StartIndex.Should().Be(6);
|
||||
email.Length.Should().Be("ravi@test.com".Length);
|
||||
text[email.StartIndex..email.EndIndex].Should().Be("ravi@test.com");
|
||||
}
|
||||
|
||||
[TestCase(null)]
|
||||
[TestCase("")]
|
||||
[TestCase(" ")]
|
||||
public void Detect_InvalidInput_ThrowsArgumentException(string? text)
|
||||
{
|
||||
var action = () => _detector.Detect(text!);
|
||||
action.Should().Throw<ArgumentException>();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user