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.
- shadow_vault/__init__.py +75 -0
- shadow_vault/detectors.py +490 -0
- shadow_vault/engine.py +217 -0
- shadow_vault/integrations/__init__.py +21 -0
- shadow_vault/integrations/decorators.py +92 -0
- shadow_vault/integrations/langchain.py +291 -0
- shadow_vault/integrations/mcp_proxy.py +131 -0
- shadow_vault/integrations/proxy_server.py +162 -0
- shadow_vault/store.py +342 -0
- shadow_vault/stream.py +120 -0
- shadow_vault/synthesizer.py +140 -0
- shadow_vault-0.3.0.dist-info/METADATA +305 -0
- shadow_vault-0.3.0.dist-info/RECORD +15 -0
- shadow_vault-0.3.0.dist-info/WHEEL +4 -0
- shadow_vault-0.3.0.dist-info/licenses/LICENSE +21 -0
shadow_vault/__init__.py
ADDED
|
@@ -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
|