pii-protect 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Musaib Altaf
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,377 @@
1
+ Metadata-Version: 2.4
2
+ Name: pii-protect
3
+ Version: 0.1.0
4
+ Summary: Pluggable, on-premise-first PII masking, unmasking, and redaction library.
5
+ Author: Musaib Altaf
6
+ License: Proprietary
7
+ Project-URL: Repository, https://example.com/innowave360/pii-shield
8
+ Keywords: pii,masking,redaction,privacy,ner,encryption
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Topic :: Security
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: cryptography>=42.0.0
21
+ Provides-Extra: postgres
22
+ Requires-Dist: asyncpg>=0.29.0; extra == "postgres"
23
+ Provides-Extra: redis
24
+ Requires-Dist: redis>=5.0.0; extra == "redis"
25
+ Provides-Extra: spacy
26
+ Requires-Dist: spacy>=3.7.0; extra == "spacy"
27
+ Provides-Extra: privacy-filter
28
+ Requires-Dist: transformers>=4.50.0; extra == "privacy-filter"
29
+ Requires-Dist: torch>=2.0.0; extra == "privacy-filter"
30
+ Requires-Dist: accelerate>=1.0; extra == "privacy-filter"
31
+ Requires-Dist: safetensors>=0.4.0; extra == "privacy-filter"
32
+ Provides-Extra: all
33
+ Requires-Dist: asyncpg>=0.29.0; extra == "all"
34
+ Requires-Dist: redis>=5.0.0; extra == "all"
35
+ Requires-Dist: spacy>=3.7.0; extra == "all"
36
+ Requires-Dist: transformers>=4.50.0; extra == "all"
37
+ Requires-Dist: torch>=2.0.0; extra == "all"
38
+ Requires-Dist: accelerate>=1.0; extra == "all"
39
+ Requires-Dist: safetensors>=0.4.0; extra == "all"
40
+ Provides-Extra: dev
41
+ Requires-Dist: pytest>=8.2.0; extra == "dev"
42
+ Requires-Dist: pytest-asyncio>=0.23.6; extra == "dev"
43
+ Dynamic: license-file
44
+
45
+ # pii-shield
46
+
47
+ A pluggable, on-premise-first PII masking, unmasking, and redaction library for Python.
48
+
49
+ `pii-shield` detects personally identifiable and sensitive business
50
+ information in free text (emails, phone numbers, GST/PAN/IBAN numbers,
51
+ person and organisation names, bank details, invoice/PO numbers, and more),
52
+ and gives you three ways to handle it:
53
+
54
+ - **mask** it into a reversible placeholder token, encrypted at rest
55
+ - **unmask** a previously masked token back to its original value
56
+ - **redact** it permanently, with no way to recover the original
57
+
58
+ There is no server, no API, and no hard dependency on any particular
59
+ database — it's a library you import and call directly. Where your
60
+ encrypted PII values live is a pluggable choice: in-memory, a local file,
61
+ Redis, or PostgreSQL, or a backend you write yourself.
62
+
63
+ ---
64
+
65
+ ## Install
66
+
67
+ ```bash
68
+ pip install pii-shield # core: regex detection + in-memory/filesystem storage
69
+ pip install "pii-shield[postgres]" # + PostgreSQL storage backend
70
+ pip install "pii-shield[redis]" # + Redis storage backend
71
+ pip install "pii-shield[spacy]" # + spaCy NER layer (PERSON/ORG/GPE)
72
+ pip install "pii-shield[privacy-filter]" # + transformer token-classification layer
73
+ pip install "pii-shield[all]" # everything
74
+ ```
75
+
76
+ Only `cryptography` is a hard dependency. `asyncpg`, `redis`, `spacy`, and
77
+ `transformers`/`torch` are all opt-in extras. If you use a backend or
78
+ detection layer without installing its extra, you get a clear
79
+ `OptionalDependencyMissingError` telling you exactly what to install —
80
+ never a bare `ImportError` or a silent failure.
81
+
82
+ ---
83
+
84
+ ## Quick start
85
+
86
+ ```python
87
+ import asyncio
88
+ from pii_shield import PIIMaskingEngine
89
+ from pii_shield.storage import InMemoryStorage
90
+
91
+ async def main():
92
+ async with PIIMaskingEngine(storage=InMemoryStorage()) as engine:
93
+ result = await engine.mask("Contact john@acme.com about GST 27AAPFU0939F1ZV")
94
+ print(result.masked_text)
95
+ # "Contact {{EMAIL:abcc2}} about GST {{GST:9a03b}}"
96
+
97
+ original = await engine.unmask(result.masked_text)
98
+ print(original)
99
+ # "Contact john@acme.com about GST 27AAPFU0939F1ZV"
100
+
101
+ print(engine.redact("Contact john@acme.com about GST 27AAPFU0939F1ZV"))
102
+ # "Contact [REDACTED:EMAIL] about GST [REDACTED:GST]" — irreversible, nothing stored
103
+
104
+ asyncio.run(main())
105
+ ```
106
+
107
+ `PIIMaskingEngine` is an async context manager — `initialise()` connects
108
+ the storage backend, `close()` releases it. Use `async with` unless you
109
+ need to control that lifecycle yourself.
110
+
111
+ ---
112
+
113
+ ## The three operations
114
+
115
+ | Method | Reversible? | Touches storage? | Use for |
116
+ |---|---|---|---|
117
+ | `mask(text, scope=None)` | Yes, via `unmask()` | Yes | Sending documents to an LLM/cloud service while keeping raw PII on-premise |
118
+ | `unmask(masked_text, scope=None)` | — | Yes (read) | Restoring original values before writing back to source systems |
119
+ | `redact(text)` | **No** | **No** | Logs, analytics exports, anything that must never contain recoverable PII |
120
+
121
+ ```python
122
+ result = await engine.mask(text, scope="invoice-2026-00417")
123
+ # result.masked_text -> text with {{TYPE:xxxxx}} placeholders
124
+ # result.token_count -> number of PII spans masked
125
+ # result.entity_counts -> {"EMAIL": 1, "GST": 1, ...}
126
+ # result.entities -> per-span detail (type, offsets, token, confidence, source)
127
+
128
+ text_back = await engine.unmask(result.masked_text, scope="invoice-2026-00417")
129
+
130
+ scrubbed = engine.redact(text) # synchronous — no storage or encryption involved
131
+ ```
132
+
133
+ `mask_dict()` / `unmask_dict()` do the same over JSON-serialisable dicts,
134
+ masking all string leaf values in one pass so a repeated value across
135
+ fields still maps to the same token.
136
+
137
+ `scope` is a free-form string (e.g. a document or invoice ID). The same
138
+ PII value repeated within one scope deduplicates to a single stored token
139
+ instead of being encrypted and stored twice — pass the same scope to
140
+ `mask()` and the matching `unmask()` call.
141
+
142
+ ---
143
+
144
+ ## Architecture
145
+
146
+ ```
147
+ ┌─────────────────────────────────────────┐
148
+ │ PIIMaskingEngine │
149
+ │ (pii_shield.engine) │
150
+ │ │
151
+ │ mask() unmask() redact() │
152
+ └───────┬───────────┬───────────┬───────────┘
153
+ │ │ │
154
+ ┌─────────────┘ │ └─── (redact never
155
+ │ │ leaves this box —
156
+ ▼ │ no encrypt, no store)
157
+ ┌─────────────────────┐ │
158
+ │ NEREngine │ │
159
+ │ (pii_shield.ner) │ │
160
+ │ │ │
161
+ │ RegexNERLayer (always on) │
162
+ │ SpacyNERLayer (optional) │
163
+ │ PrivacyFilterLayer (optional) │
164
+ │ │ │
165
+ │ TokenizerSafeSpanMerger │
166
+ │ SpanConflictResolver │
167
+ └──────────┬─────────────┘ │
168
+ │ DetectedSpan[] │
169
+ ▼ │
170
+ ┌─────────────────────┐ │
171
+ │ DeterministicToken- │ │
172
+ │ Generator │ │
173
+ │ (pii_shield.tokens) │ │
174
+ │ │ │
175
+ │ {{TYPE:xxxxx}} │ │
176
+ │ find_tokens_in_text() │◄────────────┘
177
+ └──────────┬─────────────┘
178
+ │ token, value_hash
179
+
180
+ ┌─────────────────────┐ ┌───────────────────────────────┐
181
+ │ AESGCMCipher │ │ StorageBackend │
182
+ │ (pii_shield.crypto) │──────▶ (pii_shield.storage) │
183
+ │ │ │ │
184
+ │ encrypt() / decrypt() │ │ InMemoryStorage │
185
+ │ AES-256-GCM │ │ FileSystemStorage │
186
+ └────────────────────────┘ │ RedisStorage (extra) │
187
+ │ PostgresStorage (extra) │
188
+ └───────────────────────────────┘
189
+ ```
190
+
191
+ ### Components
192
+
193
+ **`PIIMaskingEngine`** (`pii_shield.engine`) is the single public entry
194
+ point. It owns one `NEREngine`, one `DeterministicTokenGenerator`, one
195
+ `AESGCMCipher`, and one `StorageBackend`, and wires them together for
196
+ `mask()` / `unmask()` / `redact()`. This is the only class most callers
197
+ need to import.
198
+
199
+ **`NEREngine`** (`pii_shield.ner`) does detection only — it never touches
200
+ encryption or storage. It runs one or more layers over the input text and
201
+ merges their output into a single non-overlapping span list:
202
+
203
+ - `RegexNERLayer` — always on, no extra dependencies. High-precision
204
+ patterns for structured PII: GST, PAN, TAN, ABN, VAT, IBAN, SWIFT,
205
+ account/sort-code/routing numbers, credit cards, email, phone
206
+ (India + international), invoice and PO references.
207
+ - `SpacyNERLayer` — optional. Adds PERSON / ORGANISATION / ADDRESS
208
+ detection via a local spaCy model. Requires `pii-shield[spacy]`.
209
+ - `PrivacyFilterLayer` — optional. Adds detection via any HuggingFace
210
+ token-classification model you point it at, run entirely on-premise
211
+ through `transformers.pipeline`. Requires `pii-shield[privacy-filter]`.
212
+
213
+ When layers disagree or overlap, `SpanConflictResolver` picks a winner
214
+ (regex-validated spans win first, then financial-entity-over-phone,
215
+ then higher confidence, then longer span), and
216
+ `TokenizerSafeSpanMerger` stitches back together sub-word fragments
217
+ that some transformer models emit at token boundaries.
218
+
219
+ **`DeterministicTokenGenerator`** (`pii_shield.tokens`) turns a detected
220
+ span into a `{{ENTITY_TYPE:xxxxx}}` placeholder — a 5-hex-character
221
+ suffix derived from `SHA-256(value | entity_type | salt)`. Same value +
222
+ same entity type always produces the same token within one salted
223
+ instance, which is what makes within-document deduplication and
224
+ `find_tokens_in_text()` (used by `unmask()` to locate placeholders) work.
225
+ It also computes an unsalted `value_hash` used purely for storage-side
226
+ deduplication, so multiple engine instances backed by the same storage
227
+ can recognise a value they've each seen before, even though their
228
+ salted tokens differ.
229
+
230
+ **`AESGCMCipher`** (`pii_shield.crypto`) is the only component that ever
231
+ sees plaintext PII outside of the `NEREngine`. Each value is encrypted
232
+ with AES-256-GCM using a fresh 96-bit IV, with the entity type bound in
233
+ as additional authenticated data (AAD) — so a stored ciphertext can't be
234
+ replayed under a different entity type. Storage backends only ever
235
+ receive ciphertext, IV, and tag; they never see plaintext.
236
+
237
+ **`StorageBackend`** (`pii_shield.storage`) is an abstract interface with
238
+ five methods a backend must implement: `put`, `get`, `get_many`,
239
+ `find_by_value_hash`, `touch` (plus an optional `log_access` audit hook).
240
+ `PIIMaskingEngine` depends only on this interface, which is what makes
241
+ storage swappable without touching detection, tokenisation, or
242
+ encryption code. Four implementations ship out of the box:
243
+
244
+ | Backend | Persistence | Extra required | Notes |
245
+ |---|---|---|---|
246
+ | `InMemoryStorage` | None (process lifetime) | — | tests, short scripts |
247
+ | `FileSystemStorage` | Single JSON file, atomic writes | — | single-process, no external infra |
248
+ | `RedisStorage` | Redis hashes per token | `pii-shield[redis]` | shared across processes/hosts |
249
+ | `PostgresStorage` | Relational table, auto-migrated schema | `pii-shield[postgres]` | shared, queryable, audit-loggable |
250
+
251
+ Writing a fifth backend (S3, DynamoDB, Vault, etc.) means subclassing
252
+ `StorageBackend` and implementing those five methods — `PIIMaskingEngine`
253
+ needs no changes.
254
+
255
+ ### Data flow
256
+
257
+ **mask()**: `NEREngine.detect()` finds spans → for each span, compute
258
+ `value_hash` and check the backend for an existing token in this `scope`
259
+ (dedup) → if new, `AESGCMCipher.encrypt()` the value → `StorageBackend.put()`
260
+ the ciphertext/IV/tag → splice the `{{TYPE:xxxxx}}` token into the text in
261
+ place of the original span.
262
+
263
+ **unmask()**: `DeterministicTokenGenerator.find_tokens_in_text()` locates
264
+ every placeholder → `StorageBackend.get_many()` fetches all matching
265
+ records in one round trip → `AESGCMCipher.decrypt()` each → splice the
266
+ decrypted values back into the text. Tokens with no matching record are
267
+ left in place with a `[UNRESOLVED]` suffix rather than raising, so a
268
+ partially-available vault degrades instead of failing the whole call.
269
+
270
+ **redact()**: `NEREngine.detect()` finds spans → each span is replaced
271
+ in-place with `[REDACTED:ENTITY_TYPE]`. Nothing downstream of detection
272
+ is invoked — no cipher, no storage — which is what makes it genuinely
273
+ irreversible rather than just "not currently reversed."
274
+
275
+ ### Design choices worth knowing about
276
+
277
+ - **Encryption keys are supplied by the caller**, not derived from or
278
+ stored alongside vault data. This keeps a compromised storage backend
279
+ from being sufficient on its own to decrypt anything, and keeps key
280
+ rotation an application-level concern independent of which storage
281
+ backend you choose.
282
+ - **Detection is fully separated from storage.** You can swap
283
+ `InMemoryStorage` for `PostgresStorage` without changing anything about
284
+ how PII is found, and you can add spaCy/transformer layers without
285
+ touching storage at all.
286
+ - **All storage backend methods are `async`**, including `InMemoryStorage`
287
+ and `FileSystemStorage` — so the same calling code works unmodified
288
+ whether the backend is a Python dict or a networked database.
289
+
290
+ ---
291
+
292
+ ## Configuring detection
293
+
294
+ ```python
295
+ from pii_shield import NEREngine, PIIMaskingEngine
296
+ from pii_shield.storage import InMemoryStorage
297
+
298
+ ner = NEREngine(
299
+ enable_spacy=True, # PERSON / ORGANISATION / ADDRESS
300
+ spacy_model="en_core_web_sm",
301
+ enable_privacy_filter=True, # any HF token-classification model
302
+ privacy_filter_model="your/token-classification-model",
303
+ privacy_filter_threshold=0.5,
304
+ privacy_filter_device="cpu",
305
+ )
306
+
307
+ engine = PIIMaskingEngine(storage=InMemoryStorage(), ner_engine=ner)
308
+ ```
309
+
310
+ `NEREngine()` with no arguments runs regex detection only, with zero
311
+ extra dependencies.
312
+
313
+ ---
314
+
315
+ ## Encryption key
316
+
317
+ ```python
318
+ from pii_shield.crypto import AESGCMCipher
319
+
320
+ engine = PIIMaskingEngine(storage=..., encryption_key="<64-char hex string>")
321
+ # or
322
+ engine = PIIMaskingEngine(storage=..., encryption_key=AESGCMCipher.generate_key())
323
+ ```
324
+
325
+ If you omit `encryption_key`, an ephemeral one is generated and a warning
326
+ is logged — anything masked in that session becomes permanently
327
+ unrecoverable once the process exits. Always pass a stable key outside of
328
+ quick experiments; losing the key makes every previously masked value
329
+ permanently unrecoverable, by design.
330
+
331
+ ---
332
+
333
+ ## Storage backend examples
334
+
335
+ ```python
336
+ from pii_shield.storage import InMemoryStorage, FileSystemStorage, RedisStorage, PostgresStorage
337
+
338
+ InMemoryStorage()
339
+ FileSystemStorage("./vault.json")
340
+ RedisStorage("redis://localhost:6379/0")
341
+ PostgresStorage("postgresql://user:pass@host:5432/mydb") # creates its own schema on connect()
342
+ ```
343
+
344
+ Custom backend:
345
+
346
+ ```python
347
+ from pii_shield.storage import StorageBackend
348
+ from pii_shield.types import TokenRecord
349
+
350
+ class MyBackend(StorageBackend):
351
+ async def put(self, record: TokenRecord) -> None: ...
352
+ async def get(self, token_value: str) -> TokenRecord | None: ...
353
+ async def get_many(self, token_values: list[str]) -> dict[str, TokenRecord]: ...
354
+ async def find_by_value_hash(self, value_hash: str, scope: str | None) -> str | None: ...
355
+ async def touch(self, token_value: str) -> None: ...
356
+ ```
357
+
358
+ ---
359
+
360
+ ## Error handling
361
+
362
+ ```python
363
+ from pii_shield import (
364
+ PIIShieldError, # base class for everything below
365
+ EngineNotInitialisedError, # mask()/unmask() called before initialise()
366
+ DecryptionError, # AES-GCM tag verification failed
367
+ StorageBackendError, # backend-specific I/O/connection failure
368
+ OptionalDependencyMissingError, # used a backend/layer without its extra installed
369
+ )
370
+ ```
371
+
372
+ ## Running the tests
373
+
374
+ ```bash
375
+ pip install -e ".[dev]"
376
+ pytest
377
+ ```