shadow-vault 0.3.0__py3-none-any.whl

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,75 @@
1
+ """
2
+ shadow-vault: A bi-directional, format-preserving reversible PII proxy for AI agents,
3
+ LangChain/LangGraph, and MCP tools.
4
+ """
5
+
6
+ from shadow_vault.detectors import (
7
+ DetectedEntity,
8
+ EntityType,
9
+ calculate_verhoeff,
10
+ detect_aadhaar,
11
+ detect_api_keys,
12
+ detect_credit_cards,
13
+ detect_emails,
14
+ detect_entities,
15
+ detect_pan_cards,
16
+ detect_phones,
17
+ detect_ssns,
18
+ detect_upi_ids,
19
+ is_luhn_valid,
20
+ is_verhoeff_valid,
21
+ )
22
+ from shadow_vault.engine import ShadowVault
23
+ from shadow_vault.integrations.decorators import vault_guard
24
+ from shadow_vault.integrations.langchain import (
25
+ ProtectedTool,
26
+ ShadowVaultChatWrapper,
27
+ protect_tool,
28
+ )
29
+ from shadow_vault.integrations.mcp_proxy import MCPVaultInterceptor
30
+ from shadow_vault.integrations.proxy_server import create_proxy_app
31
+ from shadow_vault.store import VaultStore
32
+ from shadow_vault.stream import StreamHydrator
33
+ from shadow_vault.synthesizer import (
34
+ SYNTHETIC_PREFIXES,
35
+ calculate_luhn_check_digit,
36
+ is_synthetic,
37
+ synthesize_token,
38
+ )
39
+
40
+ __version__ = "0.3.0"
41
+
42
+ __all__ = [
43
+ # Engine & Store
44
+ "ShadowVault",
45
+ "VaultStore",
46
+ "StreamHydrator",
47
+ # Integrations
48
+ "vault_guard",
49
+ "protect_tool",
50
+ "ProtectedTool",
51
+ "ShadowVaultChatWrapper",
52
+ "MCPVaultInterceptor",
53
+ "create_proxy_app",
54
+ # Synthesis
55
+ "synthesize_token",
56
+ "is_synthetic",
57
+ "calculate_luhn_check_digit",
58
+ "SYNTHETIC_PREFIXES",
59
+ # Detection
60
+ "DetectedEntity",
61
+ "EntityType",
62
+ "detect_entities",
63
+ "detect_upi_ids",
64
+ "detect_emails",
65
+ "detect_credit_cards",
66
+ "detect_aadhaar",
67
+ "detect_pan_cards",
68
+ "detect_phones",
69
+ "detect_api_keys",
70
+ "detect_ssns",
71
+ # Mathematical Checksums
72
+ "is_luhn_valid",
73
+ "is_verhoeff_valid",
74
+ "calculate_verhoeff",
75
+ ]
@@ -0,0 +1,490 @@
1
+ """
2
+ Zero-dependency regex and mathematical checksum PII detectors.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import re
8
+ from enum import Enum
9
+ from typing import Final, Iterable
10
+ from pydantic import BaseModel, ConfigDict, Field
11
+
12
+
13
+ class EntityType(str, Enum):
14
+ """Supported sensitive PII entity types."""
15
+
16
+ UPI_ID = "UPI_ID"
17
+ EMAIL = "EMAIL"
18
+ CREDIT_CARD = "CREDIT_CARD"
19
+ AADHAAR = "AADHAAR"
20
+ PAN_CARD = "PAN_CARD"
21
+ PHONE = "PHONE"
22
+ API_KEY = "API_KEY"
23
+ SSN = "SSN"
24
+
25
+
26
+ class DetectedEntity(BaseModel):
27
+ """Immutable representation of an identified PII entity span."""
28
+
29
+ model_config = ConfigDict(frozen=True)
30
+
31
+ entity_type: str
32
+ value: str
33
+ start: int
34
+ end: int
35
+ score: float = Field(default=1.0, ge=0.0, le=1.0)
36
+
37
+
38
+ # ============================================================================
39
+ # Mathematical Checksum Validation: Luhn Algorithm (Credit Cards)
40
+ # ============================================================================
41
+
42
+
43
+ def is_luhn_valid(num_str: str) -> bool:
44
+ """Validate string using the standard Luhn (mod 10) algorithm.
45
+
46
+ Credit card numbers range from 13 to 19 digits. First digit cannot be 0.
47
+ """
48
+ digits = [int(c) for c in num_str if c.isdigit()]
49
+ if len(digits) < 13 or len(digits) > 19:
50
+ return False
51
+
52
+ # Standard major industry identifiers (MII) are 1-9
53
+ if digits[0] == 0:
54
+ return False
55
+
56
+ # All-identical digits (e.g., 000...000 or 111...111) that might trivially pass
57
+ # are rejected if length is purely monotonous without diversity
58
+ if len(set(digits)) == 1:
59
+ return False
60
+
61
+ checksum = 0
62
+ reverse_digits = digits[::-1]
63
+ for idx, digit in enumerate(reverse_digits):
64
+ if idx % 2 == 1:
65
+ doubled = digit * 2
66
+ checksum += doubled - 9 if doubled > 9 else doubled
67
+ else:
68
+ checksum += digit
69
+
70
+ return checksum % 10 == 0
71
+
72
+
73
+ # ============================================================================
74
+ # Mathematical Checksum Validation: Verhoeff Algorithm (Indian Aadhaar)
75
+ # ============================================================================
76
+
77
+ # Multiplication table d (dihedral group D5)
78
+ VERHOEFF_D: Final[list[list[int]]] = [
79
+ [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
80
+ [1, 2, 3, 4, 0, 6, 7, 8, 9, 5],
81
+ [2, 3, 4, 0, 1, 7, 8, 9, 5, 6],
82
+ [3, 4, 0, 1, 2, 8, 9, 5, 6, 7],
83
+ [4, 0, 1, 2, 3, 9, 5, 6, 7, 8],
84
+ [5, 9, 8, 7, 6, 0, 4, 3, 2, 1],
85
+ [6, 5, 9, 8, 7, 1, 0, 4, 3, 2],
86
+ [7, 6, 5, 9, 8, 2, 1, 0, 4, 3],
87
+ [8, 7, 6, 5, 9, 3, 2, 1, 0, 4],
88
+ [9, 8, 7, 6, 5, 4, 3, 2, 1, 0],
89
+ ]
90
+
91
+ # Permutation table p
92
+ VERHOEFF_P: Final[list[list[int]]] = [
93
+ [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
94
+ [1, 5, 7, 6, 2, 8, 3, 0, 9, 4],
95
+ [5, 8, 0, 3, 7, 9, 6, 1, 4, 2],
96
+ [8, 9, 1, 6, 0, 4, 3, 5, 2, 7],
97
+ [9, 4, 5, 3, 1, 2, 6, 8, 7, 0],
98
+ [4, 2, 8, 6, 5, 7, 3, 9, 0, 1],
99
+ [2, 7, 9, 3, 8, 0, 6, 4, 1, 5],
100
+ [7, 0, 4, 6, 9, 1, 3, 2, 5, 8],
101
+ ]
102
+
103
+ # Inverse table inv
104
+ VERHOEFF_INV: Final[list[int]] = [0, 4, 3, 2, 1, 5, 6, 7, 8, 9]
105
+
106
+
107
+ def calculate_verhoeff(num_str: str) -> int:
108
+ """Calculate the Verhoeff check digit for a string of digits."""
109
+ digits = [int(c) for c in num_str if c.isdigit()]
110
+ c = 0
111
+ for idx, digit in enumerate(reversed(digits)):
112
+ c = VERHOEFF_D[c][VERHOEFF_P[(idx + 1) % 8][digit]]
113
+ return VERHOEFF_INV[c]
114
+
115
+
116
+ def is_verhoeff_valid(num_str: str) -> bool:
117
+ """Validate string using the dihedral group D5 Verhoeff checksum algorithm.
118
+
119
+ Detects 100% of single-digit substitution errors and adjacent transposition errors.
120
+ """
121
+ digits = [int(c) for c in num_str if c.isdigit()]
122
+ if not digits:
123
+ return False
124
+
125
+ c = 0
126
+ for idx, digit in enumerate(reversed(digits)):
127
+ c = VERHOEFF_D[c][VERHOEFF_P[idx % 8][digit]]
128
+
129
+ return c == 0
130
+
131
+
132
+ # ============================================================================
133
+ # Regular Expression Patterns & Known Constants
134
+ # ============================================================================
135
+
136
+ # Known Indian UPI handles (evaluated strictly before Email)
137
+ UPI_HANDLES: Final[frozenset[str]] = frozenset(
138
+ {
139
+ "okaxis",
140
+ "okhdfcbank",
141
+ "okicici",
142
+ "oksbi",
143
+ "ybl",
144
+ "ibl",
145
+ "axl",
146
+ "paytm",
147
+ "apl",
148
+ "upi",
149
+ }
150
+ )
151
+
152
+ # UPI regex matching user specification with negative lookahead to prevent matching standard email domains
153
+ UPI_REGEX = re.compile(
154
+ r"\b[a-zA-Z0-9.\-_]{2,256}@(?:okaxis|okhdfcbank|okicici|oksbi|ybl|ibl|axl|paytm|apl|upi)\b(?!\.[a-zA-Z])",
155
+ re.IGNORECASE,
156
+ )
157
+
158
+ # Practical RFC-5322 compliant email regex
159
+ EMAIL_REGEX = re.compile(
160
+ r"\b[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+\b"
161
+ )
162
+
163
+ # Credit card regex: matches formatted (4-4-4-4, 4-6-5, 4-6-4) or contiguous 13-19 digits
164
+ CREDIT_CARD_REGEX = re.compile(
165
+ r"\b(?<!\d)(?:[1-9]\d{3}[ -]\d{4}[ -]\d{4}[ -]\d{1,7}|[1-9]\d{3}[ -]\d{6}[ -]\d{4,5}|[1-9]\d{12,18})(?!\d)\b"
166
+ )
167
+
168
+ # Indian Aadhaar: 12 digits, first digit [2-9], optional space or dash
169
+ AADHAAR_REGEX = re.compile(
170
+ r"\b[2-9]{1}[0-9]{3}[\s-]?[0-9]{4}[\s-]?[0-9]{4}\b"
171
+ )
172
+
173
+ # Indian PAN: 5 uppercase letters, 4 digits, 1 uppercase letter
174
+ PAN_CARD_REGEX = re.compile(
175
+ r"\b[A-Z]{5}[0-9]{4}[A-Z]{1}\b",
176
+ re.IGNORECASE,
177
+ )
178
+
179
+ # US Social Security Number: matches standard format with negative lookaheads for invalid area/group/serial numbers
180
+ SSN_REGEX = re.compile(
181
+ r"\b(?!000|666|9\d\d)\d{3}-(?!00)\d{2}-(?!0000)\d{4}\b"
182
+ )
183
+
184
+ # API Keys: OpenAI sk-, GitHub PAT ghp-, Google AIza, and Bearer tokens
185
+ API_KEY_PATTERNS = [
186
+ r"\bsk-[a-zA-Z0-9\-_]{20,}\b",
187
+ r"\bghp_[a-zA-Z0-9]{36}\b",
188
+ r"\bAIza[0-9A-Za-z\-_]{35}\b",
189
+ r"\b(?:Bearer\s+)[a-zA-Z0-9_\-\.]{20,}\b",
190
+ r"\bgithub_pat_[a-zA-Z0-9_]{82}\b",
191
+ r"\bAKIA[0-9A-Z]{16}\b",
192
+ ]
193
+ API_KEY_REGEX = re.compile("|".join(f"(?:{p})" for p in API_KEY_PATTERNS))
194
+
195
+ # Phone patterns: Indian mobile (+91), North American NANP, and International E.164
196
+ PHONE_INDIAN_REGEX = re.compile(r"(?<!\d)(?:\+91[\-\s]?)?[6-9]\d{9}\b")
197
+ PHONE_US_REGEX = re.compile(
198
+ r"(?<!\d)(?:(?:\+?1[\s.-]?)?(?:\([0-9]{3}\)|[0-9]{3})[\s.-]?[0-9]{3}[\s.-]?[0-9]{4})\b"
199
+ )
200
+ PHONE_E164_REGEX = re.compile(r"(?<!\d)\+[1-9]\d{0,3}[\s.-]?(?:\d[\s.-]?){6,12}\d\b")
201
+
202
+
203
+ # Specificity priorities for overlap resolution (higher value = higher precedence)
204
+ SPECIFICITY_PRIORITY: Final[dict[str, int]] = {
205
+ EntityType.API_KEY.value: 100,
206
+ EntityType.AADHAAR.value: 90,
207
+ EntityType.CREDIT_CARD.value: 90,
208
+ EntityType.PAN_CARD.value: 85,
209
+ EntityType.SSN.value: 85,
210
+ EntityType.UPI_ID.value: 80,
211
+ EntityType.EMAIL.value: 70,
212
+ EntityType.PHONE.value: 60,
213
+ }
214
+
215
+
216
+ # ============================================================================
217
+ # Individual Entity Detector Functions
218
+ # ============================================================================
219
+
220
+
221
+ def detect_upi_ids(text: str) -> list[DetectedEntity]:
222
+ """Detect Indian UPI IDs (must run before email)."""
223
+ entities: list[DetectedEntity] = []
224
+ for match in UPI_REGEX.finditer(text):
225
+ entities.append(
226
+ DetectedEntity(
227
+ entity_type=EntityType.UPI_ID.value,
228
+ value=match.group(0),
229
+ start=match.start(),
230
+ end=match.end(),
231
+ score=1.0,
232
+ )
233
+ )
234
+ return entities
235
+
236
+
237
+ def detect_emails(text: str) -> list[DetectedEntity]:
238
+ """Detect RFC-5322 emails, ignoring @shadow.vault and known UPI handles."""
239
+ entities: list[DetectedEntity] = []
240
+ for match in EMAIL_REGEX.finditer(text):
241
+ val = match.group(0)
242
+ parts = val.split("@")
243
+ if len(parts) != 2:
244
+ continue
245
+
246
+ domain = parts[1].lower()
247
+
248
+ # Ignore shadow-vault synthetic domains to prevent recursive re-masking
249
+ if domain == "shadow.vault" or domain.endswith(".shadow.vault"):
250
+ continue
251
+
252
+ # Ignore known UPI handles
253
+ domain_without_tld = domain.split(".")[0]
254
+ if domain in UPI_HANDLES or domain_without_tld in UPI_HANDLES:
255
+ continue
256
+
257
+ entities.append(
258
+ DetectedEntity(
259
+ entity_type=EntityType.EMAIL.value,
260
+ value=val,
261
+ start=match.start(),
262
+ end=match.end(),
263
+ score=1.0,
264
+ )
265
+ )
266
+ return entities
267
+
268
+
269
+ def detect_credit_cards(text: str) -> list[DetectedEntity]:
270
+ """Detect 13-19 digit credit card numbers validated by Luhn checksum."""
271
+ entities: list[DetectedEntity] = []
272
+ for match in CREDIT_CARD_REGEX.finditer(text):
273
+ raw_val = match.group(0)
274
+ if is_luhn_valid(raw_val):
275
+ entities.append(
276
+ DetectedEntity(
277
+ entity_type=EntityType.CREDIT_CARD.value,
278
+ value=raw_val,
279
+ start=match.start(),
280
+ end=match.end(),
281
+ score=1.0,
282
+ )
283
+ )
284
+ return entities
285
+
286
+
287
+ def detect_aadhaar(text: str) -> list[DetectedEntity]:
288
+ """Detect 12-digit Indian Aadhaar numbers validated by Verhoeff checksum."""
289
+ entities: list[DetectedEntity] = []
290
+ for match in AADHAAR_REGEX.finditer(text):
291
+ raw_val = match.group(0)
292
+ digits = [c for c in raw_val if c.isdigit()]
293
+ if len(digits) == 12 and is_verhoeff_valid(raw_val):
294
+ entities.append(
295
+ DetectedEntity(
296
+ entity_type=EntityType.AADHAAR.value,
297
+ value=raw_val,
298
+ start=match.start(),
299
+ end=match.end(),
300
+ score=1.0,
301
+ )
302
+ )
303
+ return entities
304
+
305
+
306
+ def detect_pan_cards(text: str) -> list[DetectedEntity]:
307
+ """Detect Indian PAN cards (5 letters, 4 digits, 1 letter)."""
308
+ entities: list[DetectedEntity] = []
309
+ for match in PAN_CARD_REGEX.finditer(text):
310
+ entities.append(
311
+ DetectedEntity(
312
+ entity_type=EntityType.PAN_CARD.value,
313
+ value=match.group(0),
314
+ start=match.start(),
315
+ end=match.end(),
316
+ score=1.0,
317
+ )
318
+ )
319
+ return entities
320
+
321
+
322
+ def detect_phones(text: str) -> list[DetectedEntity]:
323
+ """Detect phone numbers while strictly avoiding collisions with Aadhaar and Credit Cards."""
324
+ candidates: list[tuple[int, int, str]] = []
325
+
326
+ # 1. Indian mobile numbers
327
+ for match in PHONE_INDIAN_REGEX.finditer(text):
328
+ candidates.append((match.start(), match.end(), match.group(0)))
329
+
330
+ # 2. US / NANP numbers
331
+ for match in PHONE_US_REGEX.finditer(text):
332
+ candidates.append((match.start(), match.end(), match.group(0)))
333
+
334
+ # 3. International E.164 numbers
335
+ for match in PHONE_E164_REGEX.finditer(text):
336
+ candidates.append((match.start(), match.end(), match.group(0)))
337
+
338
+ entities: list[DetectedEntity] = []
339
+ for start, end, raw_val in candidates:
340
+ digits = [c for c in raw_val if c.isdigit()]
341
+ digit_count = len(digits)
342
+
343
+ # Collision avoidance: Reject if candidate is a 12-digit Verhoeff-valid Aadhaar
344
+ if digit_count == 12 and is_verhoeff_valid(raw_val):
345
+ continue
346
+
347
+ # Collision avoidance: Reject if candidate is a 13-19 digit Luhn-valid Credit Card
348
+ if 13 <= digit_count <= 19 and is_luhn_valid(raw_val):
349
+ continue
350
+
351
+ # Reject unformatted domestic strings that do not meet mobile criteria
352
+ if not raw_val.startswith("+") and "(" not in raw_val and "-" not in raw_val and " " not in raw_val:
353
+ if digit_count != 10 or digits[0] not in "6789":
354
+ continue
355
+
356
+ entities.append(
357
+ DetectedEntity(
358
+ entity_type=EntityType.PHONE.value,
359
+ value=raw_val,
360
+ start=start,
361
+ end=end,
362
+ score=0.9,
363
+ )
364
+ )
365
+ return entities
366
+
367
+
368
+ def detect_api_keys(text: str) -> list[DetectedEntity]:
369
+ """Detect sensitive API keys, tokens, and Bearer authorizations."""
370
+ entities: list[DetectedEntity] = []
371
+ for match in API_KEY_REGEX.finditer(text):
372
+ entities.append(
373
+ DetectedEntity(
374
+ entity_type=EntityType.API_KEY.value,
375
+ value=match.group(0),
376
+ start=match.start(),
377
+ end=match.end(),
378
+ score=1.0,
379
+ )
380
+ )
381
+ return entities
382
+
383
+
384
+ def detect_ssns(text: str) -> list[DetectedEntity]:
385
+ """Detect US Social Security Numbers."""
386
+ entities: list[DetectedEntity] = []
387
+ for match in SSN_REGEX.finditer(text):
388
+ entities.append(
389
+ DetectedEntity(
390
+ entity_type=EntityType.SSN.value,
391
+ value=match.group(0),
392
+ start=match.start(),
393
+ end=match.end(),
394
+ score=1.0,
395
+ )
396
+ )
397
+ return entities
398
+
399
+
400
+ # ============================================================================
401
+ # Unified Detection Pipeline with Overlap Resolution
402
+ # ============================================================================
403
+
404
+
405
+ def detect_entities(
406
+ text: str,
407
+ enabled_types: Iterable[EntityType | str] | None = None,
408
+ ) -> list[DetectedEntity]:
409
+ """Detect sensitive PII entities with mathematical checksums and overlap resolution.
410
+
411
+ Executes detectors in order of strict specificity (UPI before Email), accumulates
412
+ candidates, and resolves overlapping character spans by prioritizing higher
413
+ specificity and longer span lengths.
414
+
415
+ Args:
416
+ text: The input text to scan.
417
+ enabled_types: Optional subset of entity types to detect. Defaults to all.
418
+
419
+ Returns:
420
+ List of DetectedEntity instances sorted strictly by start index ascending.
421
+ """
422
+ if not text:
423
+ return []
424
+
425
+ if enabled_types is not None:
426
+ active_types = {t.value if isinstance(t, EntityType) else str(t) for t in enabled_types}
427
+ else:
428
+ active_types = {e.value for e in EntityType}
429
+
430
+ candidates: list[DetectedEntity] = []
431
+
432
+ # 1. API Keys (high specificity tokens)
433
+ if EntityType.API_KEY.value in active_types:
434
+ candidates.extend(detect_api_keys(text))
435
+
436
+ # 2. Aadhaar (12-digit Indian national ID with Verhoeff checksum)
437
+ if EntityType.AADHAAR.value in active_types:
438
+ candidates.extend(detect_aadhaar(text))
439
+
440
+ # 3. Credit Card (13-19 digit card numbers with Luhn checksum)
441
+ if EntityType.CREDIT_CARD.value in active_types:
442
+ candidates.extend(detect_credit_cards(text))
443
+
444
+ # 4. PAN Card (Indian tax identity)
445
+ if EntityType.PAN_CARD.value in active_types:
446
+ candidates.extend(detect_pan_cards(text))
447
+
448
+ # 5. SSN (US Social Security Number)
449
+ if EntityType.SSN.value in active_types:
450
+ candidates.extend(detect_ssns(text))
451
+
452
+ # 6. UPI_ID: MUST run BEFORE EMAIL as required by specification
453
+ if EntityType.UPI_ID.value in active_types:
454
+ candidates.extend(detect_upi_ids(text))
455
+
456
+ # 7. EMAIL: RFC-5322 regex ignoring @shadow.vault and UPI handles
457
+ if EntityType.EMAIL.value in active_types:
458
+ candidates.extend(detect_emails(text))
459
+
460
+ # 8. PHONE: Domestic and international mobile numbers
461
+ if EntityType.PHONE.value in active_types:
462
+ candidates.extend(detect_phones(text))
463
+
464
+ # Overlap Resolution Strategy:
465
+ # Sort candidates by:
466
+ # 1. Higher specificity priority descending
467
+ # 2. Longer span length (end - start) descending
468
+ # 3. Earlier start index ascending
469
+ sorted_candidates = sorted(
470
+ candidates,
471
+ key=lambda entity: (
472
+ -SPECIFICITY_PRIORITY.get(entity.entity_type, 0),
473
+ -(entity.end - entity.start),
474
+ entity.start,
475
+ ),
476
+ )
477
+
478
+ accepted_entities: list[DetectedEntity] = []
479
+ for cand in sorted_candidates:
480
+ # Check for any character-level overlap with already accepted entities
481
+ has_overlap = any(
482
+ max(cand.start, acc.start) < min(cand.end, acc.end)
483
+ for acc in accepted_entities
484
+ )
485
+ if not has_overlap:
486
+ accepted_entities.append(cand)
487
+
488
+ # Return final non-overlapping entities ordered by start index ascending
489
+ accepted_entities.sort(key=lambda entity: entity.start)
490
+ return accepted_entities