PiiNet 0.1.2
See the version list below for details.
dotnet add package PiiNet --version 0.1.2
NuGet\Install-Package PiiNet -Version 0.1.2
<PackageReference Include="PiiNet" Version="0.1.2" />
<PackageVersion Include="PiiNet" Version="0.1.2" />
<PackageReference Include="PiiNet" />
paket add PiiNet --version 0.1.2
#r "nuget: PiiNet, 0.1.2"
#:package PiiNet@0.1.2
#addin nuget:?package=PiiNet&version=0.1.2
#tool nuget:?package=PiiNet&version=0.1.2
Pii.NET
Deterministic PII detection and redaction for text leaving your perimeter: LLM prompts, logs, support tickets, exports. Zero external dependencies.
using PiiNet;
var safe = Pii.Redact(userMessage).Text; // then send it to any model
Every team wiring user text into a third-party LLM has the same problem: the prompt is a support ticket, and the support ticket contains a card number, a bank verification number, and a session token. Under GDPR, Nigeria's NDPA, or PCI DSS, that content should never have crossed the wire. Python teams reach for presidio-analyzer (6.3M downloads a month); it is Python-only, and the managed .NET answer is a paid cloud service. Microsoft.Extensions.Compliance.Redaction redacts values you already classified with attributes; it cannot find a card number leaked into free text. Pii.NET does, offline and deterministically, before the wire.
Honest scope
Pii.NET is deterministic: it finds PII that has structure and checksums, and it validates instead of pattern-matching alone. A 16-digit number that fails the Luhn checksum is not reported as a card. That is the moat over regex-only tools, and also the boundary: names, addresses, and free-form PII are out of scope in this version. If you need NER, use Presidio or an ML service; this is the zero-dependency 80% that runs in-process on every prompt and log line.
Install
dotnet add package PiiNet
Recognizers
| Recognizer | Name | Validation | Confidence |
|---|---|---|---|
| Email addresses | email |
RFC-pragmatic structure | High |
| Phone numbers | phone |
E.164 or formatted, 8-15 digits, digit-run guards, date/SSN/time shapes rejected | High (+), Medium (formatted) |
| Payment cards | card |
Luhn checksum, 13-19 digits, groups joined by dashes or short whitespace bridges (double spaces, tabs, line wraps), network identification: Visa, Mastercard, Amex, Verve, Discover, JCB | High (known network), Medium |
| IBAN | iban |
ISO 13616 mod-97 checksum plus exact per-country length; case-normalized, so lowercase IBANs match | High |
| Nigerian NIN | nin |
11 digits, context-gated (nin, national id, ...) |
Medium |
| Nigerian BVN | bvn |
11 digits, context-gated (bvn, bank verification) |
Medium |
| US SSN | ssn |
AAA-GG-SSSS with never-issued ranges rejected (000, 666, 900+, group 00, serial 0000) | Medium |
| IP addresses | ip |
v4 with octet range validation; v6 verified by the framework parser | High (v4 near ip/host/server context), Medium (bare v4, v6) |
| MAC addresses | mac |
Six hex pairs, uniform separator, boundary guards | High |
| Secrets | secret |
JWT (header base64url-decodes to a JSON object with alg), API keys with named provenance (sk-, AKIA, ghp_, xoxb-), bearer tokens |
High, Medium (bearer) |
| Dates of birth | dob |
Date shapes gated on context (dob, born, birth, ...); a date alone is not PII |
Medium |
The Nigerian coverage is deliberate: NIN, BVN, and the Verve card network (prefix blocks 506099-506198, 507865-507964, 650002-650027, checked before Discover's 65 range) ship in the box. NIN and BVN have no checksum, so they are context-gated: eleven digits only match near a keyword, which keeps invoice and reference numbers out of your redactions.
Scanning
using PiiNet;
var matches = Pii.Scan("card 4111 1111 1111 1111, contact chidi@example.com");
foreach (var match in matches)
{
Console.WriteLine($"{match.Type} [{match.Start}..{match.End}) {match.Confidence} {match.Metadata}");
}
// PaymentCard [5..24) High visa
// Email [34..51) High
PiiMatch carries Type, Start, Length, Value, Confidence (High/Medium), and type-specific Metadata: the card network, the IBAN country, the IP version, the secret provenance, or the phone form.
Compose a subset, or your own recognizer:
using PiiNet;
var scanner = new PiiScanner(RecognizerNames.Card, RecognizerNames.Iban);
var matches = scanner.Scan("pay GB82WEST12345698765432 or 5060 9900 0000 0008");
Console.WriteLine(string.Join(", ", matches.Select(m => $"{m.Type}:{m.Metadata}")));
// Iban:GB, PaymentCard:verve
Overlaps are resolved deterministically: the longest match wins, ties go to the leftmost, then to the higher confidence, then to the earlier-registered recognizer. An email whose local part contains a phone-shaped digit run is one email, not an email plus a phone.
Fail-loud configuration: an unknown recognizer name throws, and an empty recognizer set throws. A redaction library that silently scans for nothing is the worst possible bug.
Redaction modes
Mask (default)
Type-aware, length-preserving, PCI-style for cards (first six and last four kept per PCI DSS 3.4, configurable to full):
using PiiNet;
Console.WriteLine(Pii.Redact("card 4111 1111 1111 1111, ssn 123-45-6789, call +234 703 205 0662").Text);
// card 4111 11** **** 1111, ssn ***-**-6789, call +234 *** *** **62
| Type | Example output | Policy |
|---|---|---|
| Card | 4111 11** **** 1111 |
first6/last4 (or full with MaskCardKeepFirstSixLastFour = false) |
********@*********** |
everything but @ (domain kept with MaskEmailKeepDomain = true) |
|
| Phone | +234 *** *** **62 |
country code and last two digits |
| SSN | ***-**-6789 |
last four |
| IBAN | GB****************5432 |
country code and last four |
| NIN/BVN/Secret | *********** |
everything |
| IP/MAC/DOB | ***.***.**.** |
separators kept |
Placeholder
Stable typed tokens. The shapes are a documented contract: <EMAIL>, <PHONE>, <CARD:network:last4>, <IBAN:CC>, <NIN>, <BVN>, <SSN>, <IP:v4|v6>, <MAC>, <SECRET:provenance>, <DOB>. A Luhn-valid card outside the known network tables uses the sentinel network unknown, as in <CARD:unknown:9995>.
using PiiNet;
var options = new RedactionOptions { Mode = RedactionMode.Placeholder };
Console.WriteLine(PiiRedactor.Redact("Verve 5060 9900 0000 0008 and token sk-AbCdEf1234567890GhIjKl", options).Text);
// Verve <CARD:verve:0008> and token <SECRET:openai-api-key>
Hash
Correlation-preserving pseudonymization: lowercase SHA-256 hex of the value, with an optional salt and truncation. The same value always produces the same token, so joins and analytics survive.
using PiiNet;
var options = new RedactionOptions { Mode = RedactionMode.Hash, HashSalt = "tenant-42", HashLength = 16 };
Console.WriteLine(PiiRedactor.Redact("chidi@example.com wrote to chidi@example.com", options).Text);
// both addresses become the same 16-hex-char token
Supply a salt for anything security-relevant: unsalted hashes of guessable values (phone numbers, emails) can be reversed by hashing guesses.
Vault (reversible)
Replace with tokens, keep the originals, restore later:
using PiiNet;
var result = PiiRedactor.Redact("mail chidi@example.com about card 4111111111111111",
new RedactionOptions { Mode = RedactionMode.Vault });
Console.WriteLine(result.Text); // mail <EMAIL:1:a3f2b9> about card <CARD:2:a3f2b9> (nonce varies per call)
Console.WriteLine(result.Vault!.Restore(result.Text)); // the original text, exactly
Vault tokens have the shape <TYPE:n:nonce>: n is the match ordinal and the nonce is six lowercase hex characters generated once per redaction call from a cryptographic source (RandomNumberGenerator). The nonce exists because input text can already contain token-shaped strings like <CARD:1>, accidentally or planted by an attacker who wants restoration to write PII where it never was; Restore replaces exact nonce-bearing tokens only, so such literals pass through untouched. The consequence is that vault tokens, unlike everything else in this library, are not deterministic across calls.
Send the redacted text to the model, then vault.Restore(modelResponse) re-inserts the originals into whatever the model echoed back. Security caveat: the vault is in-memory only and holds the original PII in clear text. Whoever holds the vault can undo the redaction. Persistence, encryption, and retention are deliberately yours.
API surface
| Call | Behavior |
|---|---|
Pii.Scan(text) / Pii.Redact(text) |
Safe defaults: all recognizers, mask mode |
new PiiScanner(names...) / .Scan(text) |
Configurable subset; unknown or empty set throws |
PiiRedactor.Redact(text, options) |
Full control: mode, recognizers, mask/hash options |
RedactionOptions.Scanner |
A fully configured PiiScanner (including custom IPiiRecognizer implementations) that then drives every redaction mode; mutually exclusive with Recognizers |
Pii.TryScan / Pii.TryRedact / PiiScanner.TryScan / PiiRedactor.TryRedact |
Never throw, fuzz-tested; null in, false out |
Null arguments to non-Try methods throw ArgumentNullException; configuration errors (unknown recognizer name, empty recognizer set, Scanner and Recognizers both set) throw ArgumentException immediately, never degrade to a no-op scan.
Compliance framing
Pii.NET is an engineering control, not a compliance certification. It helps you implement data minimization before text reaches a third party (GDPR art. 5(1)(c) and the NDPA's equivalent minimization duty) and PCI DSS 3.4-style PAN masking in logs and tickets. Whether a given redaction satisfies a given obligation is a question for your DPO, not for a NuGet package.
Limitations
Read this before you ship.
- No names, no addresses, no NER. Deterministic structure and checksums only. "My name is Chidi Okafor and I live at 14 Marina Road" passes through untouched.
- Phone detection trades recall for precision. Bare unformatted digit runs (
07032050662) are not matched, because they are indistinguishable from invoice numbers; a number only matches with a leading+or internal formatting, and date shapes, SSN shapes, and candidates directly followed by a:mmtime are rejected. Formatted runs that are not phone numbers (1234-5678) can still match. On business documents dense with reference numbers, consider acting only onHighconfidence phone matches (the ones with a+country code). - The Luhn checksum filters, it does not identify. About 10% of random digit runs of a given length pass Luhn, and EAN/GTIN barcode numbers use the same check digit algorithm, so a 13-digit EAN-13 can validate as a card-shaped number. On inventory-heavy text, filter matches to known networks (
match.Metadata is not null) instead of accepting the medium-confidence unknown-network matches. - Two card numbers separated by a single space merge and drop. Whitespace bridging (which catches double-spaced and line-wrapped cards) means
4111111111111111 5500005555555559reads as one 32-digit run, which is outside 13-19 digits and matches nothing. This is pinned by a test; put a non-whitespace delimiter between adjacent PANs or scan them line by line. - A card followed by bridged digit noise merges and drops. Because bridging is greedy, a real card followed by up to two whitespace characters and a trailing digit run (a CVV in the next table cell, a stray reference number) grows past 19 digits and matches nothing. Also pinned by a test. Put a non-digit token (
CVV:,|) between adjacent digit fields, or scan tabular data line by line. - A bare dotted quad is structurally identical to a version number.
1.2.3.4in "app v1.2.3.4" and a real IP address cannot be told apart by shape, so bare IPv4 is medium confidence (high needs nearbyip/host/server-style context). For CI output, dependency reports, and changelogs, either disable theiprecognizer or act only onHigh. - Context gating is a heuristic. NIN, BVN, dates of birth, and IPv4 confidence use a keyword window (40 characters). An identifier without its keyword is missed; eleven digits near the word "bvn" that are not a BVN are matched.
- Redaction is idempotent by construction (masked output never re-triggers a recognizer), with one documented exception: in hash mode, a hex digest that lands in a live gating context (right after the word
Bearer, for example) can be re-detected as a token. - Vault tokens are not deterministic. Every other output of this library is a pure function of its input; vault tokens embed a per-call cryptographic nonce (see the vault section) precisely so that they cannot be predicted or planted.
- Offsets are UTF-16 code units into the original string, the same units
string.Substringuses. Emoji and RTL neighborhoods are covered by tests. - IPv6 is pragmatic. Candidates are verified with the framework parser, so times and MAC addresses never match, but exotic textual forms may be missed.
- The scanner is culture-independent and ASCII-focused; fullwidth digits and other exotic Unicode digit shapes are not treated as digits.
Performance
Scanning is linear in input size on both realistic and hostile corpora. That claim is scoped to what the committed tests actually assert (Release, .NET 8; each test pins a doubling ratio under 3x and an absolute ceiling, so a quadratic regression fails CI):
- Realistic mixed document, all recognizers (
LinearityTests): 10 MB in ~0.5 s with ~160,000 matches; 5 MB in ~0.23 s. - Hostile corpora (
HostileCorpusLinearityTests): dotted digit runs (1.2.3.repeated, the email backtracking corpus) ~0.2 s for 2 MB; date-dense text (a birthdate every 15 characters) ~0.4 s for 2 MB; secret-dense text ~0.13 s for 2 MB.
The numbers are indicative for one machine; the linearity assertions are the contract.
Roadmap
Pii.Net.Extensions.AI:IChatClientmiddleware that redacts prompts on the way into anyMicrosoft.Extensions.AIpipeline, as a separate package- NER adapter interface so an ML name/address detector can plug into the same
PiiScannerand redaction modes - Streaming spans: scan
ReadOnlySpan<char>windows over large files without materializing strings - More national identifiers where a deterministic rule exists
Sibling packages
Sanctions.Net screens who you deal with; Pii.NET screens what you say about them.
License
MIT. See LICENSE.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net8.0
- No dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.