PiiNet 0.1.3
dotnet add package PiiNet --version 0.1.3
NuGet\Install-Package PiiNet -Version 0.1.3
<PackageReference Include="PiiNet" Version="0.1.3" />
<PackageVersion Include="PiiNet" Version="0.1.3" />
<PackageReference Include="PiiNet" />
paket add PiiNet --version 0.1.3
#r "nuget: PiiNet, 0.1.3"
#:package PiiNet@0.1.3
#addin nuget:?package=PiiNet&version=0.1.3
#tool nuget:?package=PiiNet&version=0.1.3
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. - A card is only extracted from a contiguous run or across whitespace/dash boundaries, never from the middle of one long unseparated digit run. Whitespace bridging still catches double-spaced and line-wrapped cards, and when a bridged run is not itself a single valid card (a PAN followed by an amount, reference, or CVV column, or two PANs side by side) the card-length Luhn-valid windows aligned to those boundaries are extracted so the real card redacts while the trailing digits are left intact. What is deliberately not done is sub-windowing a single contiguous digit run: a Luhn-valid 16-digit window buried inside a longer unseparated reference number is left alone, because roughly 10% of random digit runs pass Luhn and blindly windowing would over-redact. If a PAN is concatenated with no separator to other digits, put a non-digit token between the fields.
- 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 found by a single linear hand-scan and 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, including the colon-hex vector (long IPv6/MAC/config-style blobs), which the IPv6 candidate scan now walks once instead of matching with a backtracking regex. That claim is scoped to what the committed tests actually assert (Release, .NET 8; each test pins a doubling ratio 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. - Colon-hex blobs (
ColonHexLinearityTests): a 256 KBabcd:-repeated blob scans in tens of milliseconds; the pre-fix candidate regex took roughly four seconds at 128 KB and grew quadratically.
Defense in depth: every source-generated regex now carries a conservative match timeout. A timeout surfaces as a RegexMatchTimeoutException out of Scan/Redact (fail loud) and as false from the Try* variants; it is never swallowed in a way that returns the original, unredacted text as if it were clean. The linear scanners mean the timeout should never actually fire.
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.