redact0 1.0.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.
redact0/__init__.py ADDED
@@ -0,0 +1,51 @@
1
+ from .rules import BUILTIN_RULES, DetectorRule
2
+ from .validators import (
3
+ is_valid_aadhaar,
4
+ is_valid_de_tax_id,
5
+ is_valid_dea,
6
+ is_valid_iban,
7
+ is_valid_ipv4,
8
+ is_valid_jwt,
9
+ is_valid_luhn,
10
+ is_valid_mbi,
11
+ is_valid_nhs,
12
+ is_valid_npi,
13
+ is_valid_ssn,
14
+ shannon_entropy,
15
+ )
16
+ from .vault import (
17
+ Finding,
18
+ StreamRestorer,
19
+ Vault,
20
+ create_vault,
21
+ redact,
22
+ redact_detailed,
23
+ redact_fhir,
24
+ scan,
25
+ )
26
+
27
+ __all__ = [
28
+ "BUILTIN_RULES",
29
+ "DetectorRule",
30
+ "Finding",
31
+ "StreamRestorer",
32
+ "Vault",
33
+ "create_vault",
34
+ "is_valid_aadhaar",
35
+ "is_valid_de_tax_id",
36
+ "is_valid_dea",
37
+ "is_valid_iban",
38
+ "is_valid_ipv4",
39
+ "is_valid_jwt",
40
+ "is_valid_luhn",
41
+ "is_valid_mbi",
42
+ "is_valid_nhs",
43
+ "is_valid_npi",
44
+ "is_valid_ssn",
45
+ "redact",
46
+ "redact_detailed",
47
+ "redact_fhir",
48
+ "scan",
49
+ "shannon_entropy",
50
+ ]
51
+ __version__ = "1.0.0"
redact0/cli.py ADDED
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+
7
+ from .vault import redact_detailed, scan
8
+
9
+
10
+ def main(argv: list[str] | None = None) -> int:
11
+ parser = argparse.ArgumentParser(prog="redact0", description="0-dep reversible PII & secret redactor")
12
+ parser.add_argument("--preset", default="default", choices=["default", "secrets", "hipaa", "gdpr", "strict"])
13
+ parser.add_argument("--scan", action="store_true", help="Output findings metadata as JSON without raw values")
14
+ args = parser.parse_args(argv)
15
+
16
+ text = sys.stdin.read()
17
+ if args.scan:
18
+ findings = [f.__dict__ for f in scan(text, preset=args.preset)]
19
+ print(json.dumps(findings, indent=2))
20
+ return 1 if findings else 0
21
+
22
+ res = redact_detailed(text, preset=args.preset)
23
+ sys.stdout.write(res.value)
24
+ return 0
25
+
26
+
27
+ if __name__ == "__main__":
28
+ raise SystemExit(main())
@@ -0,0 +1,3 @@
1
+ from .llm_guard import Anonymize, Deanonymize, Sensitive, Vault, scan_output, scan_prompt
2
+
3
+ __all__ = ["Anonymize", "Deanonymize", "Sensitive", "Vault", "scan_output", "scan_prompt"]
@@ -0,0 +1,70 @@
1
+ """
2
+ Drop-in zero-dependency compatibility layer for archived `llm-guard` (Protect AI).
3
+ Replaces Anonymize / Deanonymize / Sensitive / Vault / scan_prompt / scan_output
4
+ without PyTorch, spaCy, or presidio-anonymizer.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+
10
+ from ..vault import Vault as CoreVault
11
+
12
+
13
+ class Vault(CoreVault):
14
+ """Drop-in replacement for `llm_guard.vault.Vault`."""
15
+
16
+
17
+ class Anonymize:
18
+ def __init__(self, vault: CoreVault | None = None, preset: str = "default", **kwargs: Any) -> None:
19
+ self.vault = vault if vault is not None else CoreVault(preset=preset, **kwargs)
20
+
21
+ def scan(self, prompt: str) -> tuple[str, bool, float]:
22
+ res = self.vault.redact_text(prompt)
23
+ is_valid = res.count == 0
24
+ risk_score = 0.0 if is_valid else 1.0
25
+ return res.value, is_valid, risk_score
26
+
27
+
28
+ class Deanonymize:
29
+ def __init__(self, vault: CoreVault) -> None:
30
+ self.vault = vault
31
+
32
+ def scan(self, _prompt: str, output: str) -> tuple[str, bool, float]:
33
+ restored = self.vault.restore(output)
34
+ return restored, True, 0.0
35
+
36
+
37
+ class Sensitive:
38
+ def __init__(self, preset: str = "strict", **kwargs: Any) -> None:
39
+ self.vault = CoreVault(preset=preset, **kwargs)
40
+
41
+ def scan(self, _prompt: str, output: str) -> tuple[str, bool, float]:
42
+ res = self.vault.redact_text(output)
43
+ is_valid = res.count == 0
44
+ return res.value, is_valid, 0.0 if is_valid else 1.0
45
+
46
+
47
+ def scan_prompt(scanners: list[Any], prompt: str) -> tuple[str, dict[str, bool], dict[str, float]]:
48
+ current = prompt
49
+ results_valid: dict[str, bool] = {}
50
+ results_score: dict[str, float] = {}
51
+ for scanner in scanners:
52
+ name = type(scanner).__name__
53
+ current, is_valid, score = scanner.scan(current)
54
+ results_valid[name] = is_valid
55
+ results_score[name] = score
56
+ return current, results_valid, results_score
57
+
58
+
59
+ def scan_output(
60
+ scanners: list[Any], prompt: str, output: str
61
+ ) -> tuple[str, dict[str, bool], dict[str, float]]:
62
+ current = output
63
+ results_valid: dict[str, bool] = {}
64
+ results_score: dict[str, float] = {}
65
+ for scanner in scanners:
66
+ name = type(scanner).__name__
67
+ current, is_valid, score = scanner.scan(prompt, current)
68
+ results_valid[name] = is_valid
69
+ results_score[name] = score
70
+ return current, results_valid, results_score
redact0/py.typed ADDED
@@ -0,0 +1,3 @@
1
+ ```
2
+
3
+ ### `python/src/redact0/__init__.py`
redact0/rules.py ADDED
@@ -0,0 +1,305 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from typing import Callable, Pattern
6
+
7
+ from .validators import (
8
+ is_valid_aadhaar,
9
+ is_valid_de_tax_id,
10
+ is_valid_dea,
11
+ is_valid_iban,
12
+ is_valid_ipv4,
13
+ is_valid_jwt,
14
+ is_valid_luhn,
15
+ is_valid_mbi,
16
+ is_valid_nhs,
17
+ is_valid_npi,
18
+ is_valid_ssn,
19
+ shannon_entropy,
20
+ )
21
+
22
+ ALL_PRESETS = ("default", "secrets", "hipaa", "gdpr", "strict")
23
+ PII_PRESETS = ("default", "hipaa", "gdpr", "strict")
24
+ HIPAA_STRICT = ("hipaa", "strict")
25
+ GDPR_STRICT = ("gdpr", "strict")
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class DetectorRule:
30
+ id: str
31
+ category: str
32
+ pattern: Pattern[str]
33
+ priority: int
34
+ presets: tuple[str, ...]
35
+ capture_group: int | None = None
36
+ context_regex: Pattern[str] | None = None
37
+ context_window: int = 40
38
+ validate: Callable[[str, str, int], bool] | None = None
39
+
40
+
41
+ def _cc_validate(val: str, _full: str, _start: int) -> bool:
42
+ digits = re.sub(r"\D", "", val)
43
+ return bool(
44
+ re.fullmatch(
45
+ r"(?:4\d{12}(?:\d{3})?(?:\d{3})?|5[1-5]\d{14}|2(?:2[2-9]|[3-6]\d|7[01]|720)\d{12}|3[47]\d{13}|6(?:011|5\d\d)\d{12}|3(?:0[0-5]|[68]\d)\d{11}|(?:2131|1800|35\d{3})\d{11})",
46
+ digits,
47
+ )
48
+ ) and is_valid_luhn(digits)
49
+
50
+
51
+ def _phone_validate(val: str, _full: str, _start: int) -> bool:
52
+ digits = re.sub(r"\D", "", val)
53
+ return 10 <= len(digits) <= 15 and len(set(digits)) > 1
54
+
55
+
56
+ BUILTIN_RULES: tuple[DetectorRule, ...] = (
57
+ DetectorRule(
58
+ id="pem-private-key",
59
+ category="PRIVATE_KEY",
60
+ priority=100,
61
+ presets=ALL_PRESETS,
62
+ pattern=re.compile(
63
+ r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY-----[\s\S]{16,8192}?-----END (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY-----"
64
+ ),
65
+ ),
66
+ DetectorRule(
67
+ id="connection-uri-password",
68
+ category="CONNECTION_URI",
69
+ priority=96,
70
+ presets=ALL_PRESETS,
71
+ capture_group=1,
72
+ pattern=re.compile(
73
+ r"\b(?:postgres(?:ql)?|mysql|mariadb|mongodb(?:\+srv)?|redis(?:s)?|amqp(?:s)?|mssql|clickhouse|cockroachdb)://[^:\s/@]+:([^@\s/]{3,256})@[^\s\"'<>]+",
74
+ re.I,
75
+ ),
76
+ ),
77
+ DetectorRule(
78
+ id="anthropic-key",
79
+ category="API_KEY",
80
+ priority=95,
81
+ presets=ALL_PRESETS,
82
+ pattern=re.compile(r"\bsk-ant-(?:api\d{2}|admin\d{2}|oat\d{2}|[a-z0-9]+)-[A-Za-z0-9_-]{24,}\b"),
83
+ ),
84
+ DetectorRule(
85
+ id="openai-key",
86
+ category="API_KEY",
87
+ priority=95,
88
+ presets=ALL_PRESETS,
89
+ pattern=re.compile(r"\bsk-(?:proj|svcacct|admin)-[A-Za-z0-9_-]{24,}\b|\bsk-[A-Za-z0-9]{16,}T3BlbkFJ[A-Za-z0-9]{16,}\b"),
90
+ ),
91
+ DetectorRule(
92
+ id="github-token",
93
+ category="API_KEY",
94
+ priority=95,
95
+ presets=ALL_PRESETS,
96
+ pattern=re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9_]{36,}|github_pat_[A-Za-z0-9]{22}_[A-Za-z0-9]{40,})\b"),
97
+ ),
98
+ DetectorRule(
99
+ id="aws-access-key",
100
+ category="API_KEY",
101
+ priority=94,
102
+ presets=ALL_PRESETS,
103
+ pattern=re.compile(r"\b(?:AKIA|ASIA|AROA|AIDA)[0-9A-Z]{16}\b"),
104
+ ),
105
+ DetectorRule(
106
+ id="stripe-secret",
107
+ category="API_KEY",
108
+ priority=94,
109
+ presets=ALL_PRESETS,
110
+ pattern=re.compile(r"\b(?:sk_live|rk_live|whsec)_[A-Za-z0-9]{20,}\b"),
111
+ ),
112
+ DetectorRule(
113
+ id="google-api-key",
114
+ category="API_KEY",
115
+ priority=93,
116
+ presets=ALL_PRESETS,
117
+ pattern=re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b"),
118
+ ),
119
+ DetectorRule(
120
+ id="slack-token",
121
+ category="TOKEN",
122
+ priority=93,
123
+ presets=ALL_PRESETS,
124
+ pattern=re.compile(r"\bxox[baprs]-[0-9A-Za-z-]{14,}\b|https://hooks\.slack\.com/services/T[A-Z0-9]+/B[A-Z0-9]+/[A-Za-z0-9]+"),
125
+ ),
126
+ DetectorRule(
127
+ id="discord-webhook",
128
+ category="SECRET",
129
+ priority=92,
130
+ presets=ALL_PRESETS,
131
+ pattern=re.compile(r"https://discord(?:app)?\.com/api/webhooks/\d{16,22}/[A-Za-z0-9_-]{40,}"),
132
+ ),
133
+ DetectorRule(
134
+ id="provider-prefixed-keys",
135
+ category="API_KEY",
136
+ priority=92,
137
+ presets=ALL_PRESETS,
138
+ pattern=re.compile(
139
+ r"\b(?:hf_[A-Za-z0-9]{30,}|gsk_[A-Za-z0-9]{36,}|xai-[A-Za-z0-9]{36,}|pplx-[A-Za-z0-9]{36,}|sbp_[A-Za-z0-9]{36,}|sb_secret_[A-Za-z0-9_-]{24,}|re_[A-Za-z0-9_]{24,}|npm_[A-Za-z0-9]{36}|pypi-AgEIcHlwaS5vcmc[A-Za-z0-9_-]{50,}|SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43})\b"
140
+ ),
141
+ ),
142
+ DetectorRule(
143
+ id="jwt-token",
144
+ category="JWT",
145
+ priority=90,
146
+ presets=ALL_PRESETS,
147
+ pattern=re.compile(r"\beyJ[A-Za-z0-9_-]{6,}\.eyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{8,}\b"),
148
+ validate=lambda v, _f, _s: is_valid_jwt(v),
149
+ ),
150
+ DetectorRule(
151
+ id="bearer-auth-header",
152
+ category="TOKEN",
153
+ priority=88,
154
+ presets=ALL_PRESETS,
155
+ capture_group=1,
156
+ pattern=re.compile(
157
+ r"\b(?:Authorization|Proxy-Authorization)\s*[:=]\s*[\"']?(?:Bearer|Basic|Token)\s+([A-Za-z0-9._~+/=-]{12,})[\"']?",
158
+ re.I,
159
+ ),
160
+ ),
161
+ DetectorRule(
162
+ id="contextual-secret-assignment",
163
+ category="SECRET",
164
+ priority=72,
165
+ presets=ALL_PRESETS,
166
+ capture_group=1,
167
+ pattern=re.compile(
168
+ r"\b(?:api[_-]?key|secret[_-]?key|access[_-]?token|refresh[_-]?token|auth[_-]?token|client[_-]?secret|private[_-]?key|aws_secret_access_key|password|passwd|pwd|signing[_-]?secret)\b\s*[:=]\s*[\"']?([^\s\"',;}{]{10,128})[\"']?",
169
+ re.I,
170
+ ),
171
+ validate=lambda v, _f, _s: not v.startswith("[") and not v.startswith("<") and shannon_entropy(v) >= 2.8,
172
+ ),
173
+ DetectorRule(
174
+ id="medicare-mbi",
175
+ category="MBI",
176
+ priority=84,
177
+ presets=HIPAA_STRICT,
178
+ pattern=re.compile(r"\b[1-9][AC-HJKMNP-RT-Y][0-9AC-HJKMNP-RT-Y]-?\d[AC-HJKMNP-RT-Y][0-9AC-HJKMNP-RT-Y]-?\d[AC-HJKMNP-RT-Y]{2}\d{2}\b", re.I),
179
+ validate=lambda v, _f, _s: is_valid_mbi(v),
180
+ ),
181
+ DetectorRule(
182
+ id="us-dea",
183
+ category="DEA",
184
+ priority=83,
185
+ presets=HIPAA_STRICT,
186
+ pattern=re.compile(r"\b[ABFGMPRX][A-Z9]\d{7}\b"),
187
+ validate=lambda v, _f, _s: is_valid_dea(v),
188
+ ),
189
+ DetectorRule(
190
+ id="us-npi",
191
+ category="NPI",
192
+ priority=80,
193
+ presets=HIPAA_STRICT,
194
+ context_regex=re.compile(r"\b(?:npi|provider|prescriber|practitioner|physician)\b", re.I),
195
+ pattern=re.compile(r"\b[12]\d{9}\b"),
196
+ validate=lambda v, _f, _s: is_valid_npi(v),
197
+ ),
198
+ DetectorRule(
199
+ id="uk-nhs-number",
200
+ category="NHS",
201
+ priority=80,
202
+ presets=("hipaa", "gdpr", "strict"),
203
+ context_regex=re.compile(r"\b(?:nhs|patient|health|care|medical)\b", re.I),
204
+ pattern=re.compile(r"\b\d{3}[-\s]?\d{3}[-\s]?\d{4}\b"),
205
+ validate=lambda v, _f, _s: is_valid_nhs(v),
206
+ ),
207
+ DetectorRule(
208
+ id="medical-record-number",
209
+ category="MRN",
210
+ priority=78,
211
+ presets=HIPAA_STRICT,
212
+ capture_group=1,
213
+ pattern=re.compile(r"\b(?:mrn|medical[\s_-]?record(?:[\s_-]?number)?|patient[\s_-]?id)\s*[:=#]?\s*([A-Z0-9-]{5,20})\b", re.I),
214
+ ),
215
+ DetectorRule(
216
+ id="iban",
217
+ category="IBAN",
218
+ priority=84,
219
+ presets=PII_PRESETS,
220
+ pattern=re.compile(r"\b[A-Z]{2}\d{2}(?:[\s-]?[A-Z0-9]){11,30}\b"),
221
+ validate=lambda v, _f, _s: is_valid_iban(v),
222
+ ),
223
+ DetectorRule(
224
+ id="credit-card",
225
+ category="CREDIT_CARD",
226
+ priority=82,
227
+ presets=PII_PRESETS,
228
+ pattern=re.compile(r"\b(?:\d[ -]?){13,18}\d\b"),
229
+ validate=_cc_validate,
230
+ ),
231
+ DetectorRule(
232
+ id="us-ssn",
233
+ category="SSN",
234
+ priority=81,
235
+ presets=PII_PRESETS,
236
+ pattern=re.compile(r"\b\d{3}[-\s]\d{2}[-\s]\d{4}\b|\b\d{9}\b"),
237
+ validate=is_valid_ssn,
238
+ ),
239
+ DetectorRule(
240
+ id="aadhaar-in",
241
+ category="AADHAAR",
242
+ priority=76,
243
+ presets=("gdpr", "strict"),
244
+ context_regex=re.compile(r"\b(?:aadhaar|aadhar|uidai)\b", re.I),
245
+ pattern=re.compile(r"\b[2-9]\d{3}[-\s]?\d{4}[-\s]?\d{4}\b"),
246
+ validate=lambda v, _f, _s: is_valid_aadhaar(v),
247
+ ),
248
+ DetectorRule(
249
+ id="de-tax-id",
250
+ category="TAX_ID",
251
+ priority=76,
252
+ presets=GDPR_STRICT,
253
+ context_regex=re.compile(r"\b(?:steuer|tax|tin|idnr|identifikationsnummer)\b", re.I),
254
+ pattern=re.compile(r"\b[1-9]\d{10}\b"),
255
+ validate=lambda v, _f, _s: is_valid_de_tax_id(v),
256
+ ),
257
+ DetectorRule(
258
+ id="email",
259
+ category="EMAIL",
260
+ priority=75,
261
+ presets=PII_PRESETS,
262
+ pattern=re.compile(r"\b[A-Za-z0-9._%+-]{1,64}@(?:[A-Za-z0-9-]{1,63}\.)+[A-Za-z]{2,24}\b"),
263
+ ),
264
+ DetectorRule(
265
+ id="phone",
266
+ category="PHONE",
267
+ priority=65,
268
+ presets=PII_PRESETS,
269
+ pattern=re.compile(r"(?:\+\d{1,3}[-.\s]?)?(?:\(\d{2,4}\)[-.\s]?|\b\d{3}[-.\s])\d{3,4}[-.\s]\d{4}\b|\+\d{1,3}[-.\s]?\d{2,4}[-.\s]?\d{3,4}[-.\s]?\d{3,4}\b"),
270
+ validate=_phone_validate,
271
+ ),
272
+ DetectorRule(
273
+ id="ipv4",
274
+ category="IP_ADDRESS",
275
+ priority=60,
276
+ presets=PII_PRESETS,
277
+ pattern=re.compile(r"\b(?:25[0-5]|2[0-4]\d|1?\d?\d)(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3}\b"),
278
+ validate=lambda v, _f, _s: is_valid_ipv4(v),
279
+ ),
280
+ DetectorRule(
281
+ id="mac-address",
282
+ category="MAC_ADDRESS",
283
+ priority=58,
284
+ presets=("hipaa", "gdpr", "strict"),
285
+ pattern=re.compile(r"\b(?:[0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}\b"),
286
+ ),
287
+ DetectorRule(
288
+ id="labeled-person-name",
289
+ category="PERSON",
290
+ priority=55,
291
+ presets=HIPAA_STRICT,
292
+ capture_group=1,
293
+ pattern=re.compile(
294
+ r"\b(?:Patient(?:\s+Name)?|Dr\.|Doctor|Clinician|Provider|Mr\.|Mrs\.|Ms\.|Full\s+Name|Beneficiary)\s*[:.]?\s*([A-Z][a-z]{1,24}(?:\s+[A-Z]\.?)?(?:\s+[A-Z][a-z]{1,24}){1,2})"
295
+ ),
296
+ ),
297
+ DetectorRule(
298
+ id="dob-or-clinical-date",
299
+ category="DATE",
300
+ priority=52,
301
+ presets=HIPAA_STRICT,
302
+ context_regex=re.compile(r"\b(?:dob|birth|born|admit(?:ted)?|discharg(?:e|ed)|visit|encounter|date)\b", re.I),
303
+ pattern=re.compile(r"\b(?:\d{4}-\d{2}-\d{2}|(?:0?[1-9]|1[0-2])[/-](?:0?[1-9]|[12]\d|3[01])[/-](?:19|20)\d{2})\b"),
304
+ ),
305
+ )
redact0/validators.py ADDED
@@ -0,0 +1,223 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import math
5
+ import re
6
+ from collections import Counter
7
+
8
+ _IBAN_LENGTHS: dict[str, int] = {
9
+ "AD": 24, "AE": 23, "AL": 28, "AT": 20, "AZ": 28, "BA": 20, "BE": 16, "BG": 22, "BH": 22,
10
+ "BR": 29, "BY": 28, "CH": 21, "CR": 22, "CY": 28, "CZ": 24, "DE": 22, "DK": 18, "DO": 28,
11
+ "EE": 20, "EG": 29, "ES": 24, "FI": 18, "FO": 18, "FR": 27, "GB": 22, "GE": 22, "GI": 23,
12
+ "GL": 18, "GR": 27, "GT": 28, "HR": 21, "HU": 28, "IE": 22, "IL": 23, "IQ": 23, "IS": 26,
13
+ "IT": 27, "JO": 30, "KW": 30, "KZ": 20, "LB": 28, "LC": 32, "LI": 21, "LT": 20, "LU": 20,
14
+ "LV": 21, "MC": 27, "MD": 24, "ME": 22, "MK": 19, "MR": 27, "MT": 31, "MU": 30, "NL": 18,
15
+ "NO": 15, "PK": 24, "PL": 28, "PS": 29, "PT": 25, "QA": 29, "RO": 24, "RS": 22, "SA": 24,
16
+ "SE": 24, "SI": 19, "SK": 24, "SM": 27, "ST": 25, "SV": 28, "TL": 23, "TN": 24, "TR": 26,
17
+ "UA": 29, "VA": 22, "VG": 24, "XK": 20,
18
+ }
19
+
20
+ _MBI_ALPHA = set("ACDEFGHJKMNPQRTUVWXY")
21
+ _MBI_ALNUM = _MBI_ALPHA | set("0123456789")
22
+
23
+ _VERHOEFF_D = (
24
+ (0, 1, 2, 3, 4, 5, 6, 7, 8, 9),
25
+ (1, 2, 3, 4, 0, 6, 7, 8, 9, 5),
26
+ (2, 3, 4, 0, 1, 7, 8, 9, 5, 6),
27
+ (3, 4, 0, 1, 2, 8, 9, 5, 6, 7),
28
+ (4, 0, 1, 2, 3, 9, 5, 6, 7, 8),
29
+ (5, 9, 8, 7, 6, 0, 4, 3, 2, 1),
30
+ (6, 5, 9, 8, 7, 1, 0, 4, 3, 2),
31
+ (7, 6, 5, 9, 8, 2, 1, 0, 4, 3),
32
+ (8, 7, 6, 5, 9, 3, 2, 1, 0, 4),
33
+ (9, 8, 7, 6, 5, 4, 3, 2, 1, 0),
34
+ )
35
+ _VERHOEFF_P = (
36
+ (0, 1, 2, 3, 4, 5, 6, 7, 8, 9),
37
+ (1, 5, 7, 6, 2, 8, 3, 0, 9, 4),
38
+ (5, 8, 0, 3, 7, 9, 6, 1, 4, 2),
39
+ (8, 9, 1, 6, 0, 4, 3, 5, 2, 7),
40
+ (9, 4, 5, 3, 1, 2, 6, 8, 7, 0),
41
+ (4, 2, 8, 6, 5, 7, 3, 9, 0, 1),
42
+ (2, 7, 9, 3, 8, 0, 6, 4, 1, 5),
43
+ (7, 0, 4, 6, 9, 1, 3, 2, 5, 8),
44
+ )
45
+
46
+
47
+ def _digits_only(val: str) -> str:
48
+ return re.sub(r"\D", "", val)
49
+
50
+
51
+ def is_valid_luhn(val: str) -> bool:
52
+ digits = _digits_only(val)
53
+ if not (13 <= len(digits) <= 19) or len(set(digits)) == 1:
54
+ return False
55
+ total = 0
56
+ double = False
57
+ for ch in reversed(digits):
58
+ d = ord(ch) - 48
59
+ if double:
60
+ d *= 2
61
+ if d > 9:
62
+ d -= 9
63
+ total += d
64
+ double = not double
65
+ return total % 10 == 0
66
+
67
+
68
+ def is_valid_npi(val: str) -> bool:
69
+ digits = _digits_only(val)
70
+ if not re.fullmatch(r"[12]\d{9}", digits):
71
+ return False
72
+ full = "80840" + digits
73
+ total = 0
74
+ double = False
75
+ for ch in reversed(full):
76
+ d = ord(ch) - 48
77
+ if double:
78
+ d *= 2
79
+ if d > 9:
80
+ d -= 9
81
+ total += d
82
+ double = not double
83
+ return total % 10 == 0
84
+
85
+
86
+ def is_valid_dea(val: str) -> bool:
87
+ clean = val.strip().upper()
88
+ if not re.fullmatch(r"[ABFGMPRX][A-Z9]\d{7}", clean):
89
+ return False
90
+ d = [ord(c) - 48 for c in clean[2:]]
91
+ return ((d[0] + d[2] + d[4]) + 2 * (d[1] + d[3] + d[5])) % 10 == d[6]
92
+
93
+
94
+ def is_valid_mbi(val: str) -> bool:
95
+ c = val.replace("-", "").upper()
96
+ if len(c) != 11:
97
+ return False
98
+ return (
99
+ "1" <= c[0] <= "9"
100
+ and c[1] in _MBI_ALPHA
101
+ and c[2] in _MBI_ALNUM
102
+ and c[3].isdigit()
103
+ and c[4] in _MBI_ALPHA
104
+ and c[5] in _MBI_ALNUM
105
+ and c[6].isdigit()
106
+ and c[7] in _MBI_ALPHA
107
+ and c[8] in _MBI_ALPHA
108
+ and c[9:11].isdigit()
109
+ )
110
+
111
+
112
+ def is_valid_iban(val: str) -> bool:
113
+ clean = re.sub(r"[\s-]+", "", val).upper()
114
+ if not re.fullmatch(r"[A-Z]{2}\d{2}[A-Z0-9]{11,30}", clean):
115
+ return False
116
+ expected = _IBAN_LENGTHS.get(clean[:2])
117
+ if expected is not None and len(clean) != expected:
118
+ return False
119
+ rearranged = clean[4:] + clean[:4]
120
+ rem = 0
121
+ for ch in rearranged:
122
+ if "0" <= ch <= "9":
123
+ rem = (rem * 10 + (ord(ch) - 48)) % 97
124
+ elif "A" <= ch <= "Z":
125
+ rem = (rem * 100 + (ord(ch) - 55)) % 97
126
+ else:
127
+ return False
128
+ return rem == 1
129
+
130
+
131
+ def is_valid_nhs(val: str) -> bool:
132
+ digits = _digits_only(val)
133
+ if len(digits) != 10 or len(set(digits)) == 1:
134
+ return False
135
+ total = sum((ord(digits[i]) - 48) * (10 - i) for i in range(9))
136
+ check = 11 - (total % 11)
137
+ if check == 11:
138
+ check = 0
139
+ if check == 10:
140
+ return False
141
+ return check == (ord(digits[9]) - 48)
142
+
143
+
144
+ def is_valid_ssn(val: str, full_text: str = "", start: int = 0) -> bool:
145
+ clean = val.strip()
146
+ has_sep = bool(re.search(r"[-\s]", clean))
147
+ digits = _digits_only(clean)
148
+ if len(digits) != 9 or len(set(digits)) == 1:
149
+ return False
150
+ area, group, serial = int(digits[:3]), int(digits[3:5]), int(digits[5:])
151
+ if area in (0, 666) or area >= 900 or group == 0 or serial == 0:
152
+ return False
153
+ if not has_sep and full_text:
154
+ prefix = full_text[max(0, start - 36) : start]
155
+ return bool(re.search(r"\b(ssn|social[\s_-]?security|tin)\b", prefix, re.I))
156
+ return True
157
+
158
+
159
+ def is_valid_aadhaar(val: str) -> bool:
160
+ digits = _digits_only(val)
161
+ if not re.fullmatch(r"[2-9]\d{11}", digits):
162
+ return False
163
+ c = 0
164
+ for i, ch in enumerate(reversed(digits)):
165
+ c = _VERHOEFF_D[c][_VERHOEFF_P[i % 8][ord(ch) - 48]]
166
+ return c == 0
167
+
168
+
169
+ def is_valid_de_tax_id(val: str) -> bool:
170
+ digits = _digits_only(val)
171
+ if not re.fullmatch(r"[1-9]\d{10}", digits):
172
+ return False
173
+ counts = Counter(digits[:10])
174
+ if sum(1 for v in counts.values() if v in (2, 3)) != 1 or max(counts.values()) > 3:
175
+ return False
176
+ product = 10
177
+ for ch in digits[:10]:
178
+ s = (ord(ch) - 48 + product) % 10
179
+ if s == 0:
180
+ s = 10
181
+ product = (s * 2) % 11
182
+ check = 11 - product
183
+ if check == 10:
184
+ check = 0
185
+ return check == (ord(digits[10]) - 48)
186
+
187
+
188
+ def is_valid_jwt(val: str) -> bool:
189
+ parts = val.split(".")
190
+ if len(parts) != 3 or not parts[0].startswith("eyJ"):
191
+ return False
192
+ try:
193
+ padded = parts[0] + "=" * ((4 - len(parts[0]) % 4) % 4)
194
+ decoded = base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8")
195
+ return decoded.startswith("{") and '"' in decoded
196
+ except Exception:
197
+ return False
198
+
199
+
200
+ def is_valid_ipv4(val: str) -> bool:
201
+ parts = val.split(".")
202
+ if len(parts) != 4:
203
+ return False
204
+ for p in parts:
205
+ if not p or (len(p) > 1 and p.startswith("0")) or not p.isdigit():
206
+ return False
207
+ if not (0 <= int(p) <= 255):
208
+ return False
209
+ return True
210
+
211
+
212
+ def is_private_or_loopback_ipv4(val: str) -> bool:
213
+ parts = [int(x) for x in val.split(".")]
214
+ a, b = parts[0], parts[1]
215
+ return a in (10, 127, 0) or (a == 192 and b == 168) or (a == 172 and 16 <= b <= 31) or (a == 169 and b == 254)
216
+
217
+
218
+ def shannon_entropy(val: str) -> float:
219
+ if not val:
220
+ return 0.0
221
+ counts = Counter(val)
222
+ length = len(val)
223
+ return -sum((c / length) * math.log2(c / length) for c in counts.values())
redact0/vault.py ADDED
@@ -0,0 +1,447 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+ import re
6
+ import secrets
7
+ from dataclasses import dataclass
8
+ from datetime import datetime, timedelta, timezone
9
+ from typing import Any, Iterator
10
+
11
+ from .rules import BUILTIN_RULES, DetectorRule
12
+ from .validators import is_private_or_loopback_ipv4
13
+
14
+ _RESERVED_TOKEN_RE = re.compile(r"\[[A-Z_]+_\d+(?:#[a-f0-9]{4,8})?\]|<[A-Z_]+_\d+(?:#[a-f0-9]{4,8})?>")
15
+ _SENSITIVE_KEY_RE = re.compile(
16
+ r"^(?:password|passwd|pwd|passphrase|secret|api[_-]?key|access[_-]?token|refresh[_-]?token|auth[_-]?token|authorization|cookie|set-cookie|x-api-key|client[_-]?secret|private[_-]?key|ssn|social[_-]?security(?:[_-]?number)?|cvv|cvc|pin|iban|dob|birth[_-]?date|mrn)$",
17
+ re.I,
18
+ )
19
+
20
+ RESTRICTED_ZIP3_CONSERVATIVE = frozenset(
21
+ {
22
+ "036", "059", "063", "102", "203", "205", "369", "556", "692",
23
+ "790", "821", "823", "830", "831", "878", "879", "884", "890", "893",
24
+ }
25
+ )
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Finding:
30
+ id: str
31
+ category: str
32
+ start: int
33
+ end: int
34
+ token: str
35
+ length: int
36
+ fingerprint: str
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class RedactResult:
41
+ value: Any
42
+ findings: list[Finding]
43
+
44
+ @property
45
+ def count(self) -> int:
46
+ return len(self.findings)
47
+
48
+
49
+ def _hmac_hex(key: str, msg: str, length: int = 12) -> str:
50
+ return hmac.new(key.encode("utf-8"), msg.encode("utf-8"), hashlib.sha256).hexdigest()[:length]
51
+
52
+
53
+ class Vault:
54
+ def __init__(
55
+ self,
56
+ preset: str = "default",
57
+ *,
58
+ categories: list[str] | None = None,
59
+ exclude_categories: list[str] | None = None,
60
+ rules: list[DetectorRule] | None = None,
61
+ literals: list[str] | None = None,
62
+ allowlist: list[str] | None = None,
63
+ secret: str | None = None,
64
+ token_format: str = "bracket",
65
+ redact_private_ips: bool | None = None,
66
+ mask_char: str = "*",
67
+ ) -> None:
68
+ self.preset = preset
69
+ self.literals = literals or []
70
+ self.allowlist = set(allowlist or [])
71
+ self._key = secret or secrets.token_hex(16)
72
+ self.token_format = token_format
73
+ self.mask_char = mask_char
74
+ self.redact_private_ips = (
75
+ redact_private_ips if redact_private_ips is not None else preset in ("strict", "hipaa")
76
+ )
77
+
78
+ active = list(rules) if rules is not None else [r for r in BUILTIN_RULES if preset in r.presets]
79
+ if categories:
80
+ allowed = set(categories)
81
+ active = [r for r in active if r.category in allowed]
82
+ if exclude_categories:
83
+ blocked = set(exclude_categories)
84
+ active = [r for r in active if r.category not in blocked]
85
+ self._rules = active
86
+
87
+ self._forward: dict[tuple[str, str], str] = {}
88
+ self._reverse: dict[str, tuple[str, str]] = {}
89
+ self._counters: dict[str, int] = {}
90
+ self._reserved: set[str] = set()
91
+ self.version = 0
92
+
93
+ def _reserve_existing(self, text: str) -> None:
94
+ for m in _RESERVED_TOKEN_RE.finditer(text):
95
+ tok = m.group(0)
96
+ if tok not in self._reverse:
97
+ self._reserved.add(tok)
98
+
99
+ def tokenize(self, category: str, value: str) -> str:
100
+ key = (category, value)
101
+ if key in self._forward:
102
+ return self._forward[key]
103
+ if self.token_format == "mask":
104
+ return self.mask_char * min(max(len(value), 4), 16)
105
+ if self.token_format == "hash":
106
+ tok = f"[{category}:{_hmac_hex(self._key, f'{category}:{value}', 8)}]"
107
+ self._forward[key] = tok
108
+ self._reverse[tok] = key
109
+ self.version += 1
110
+ return tok
111
+
112
+ idx = self._counters.get(category, 0)
113
+ while True:
114
+ idx += 1
115
+ if self.token_format == "angle":
116
+ tok = f"<{category}_{idx}>"
117
+ elif self.token_format == "signed":
118
+ tok = f"[{category}_{idx}#{_hmac_hex(self._key, f'{category}:{idx}:{value}', 4)}]"
119
+ else:
120
+ tok = f"[{category}_{idx}]"
121
+ if tok not in self._reserved and tok not in self._reverse:
122
+ break
123
+
124
+ self._counters[category] = idx
125
+ self._forward[key] = tok
126
+ self._reverse[tok] = key
127
+ self.version += 1
128
+ return tok
129
+
130
+ def _collect(self, text: str) -> list[tuple[str, str, int, int, int, str]]:
131
+ candidates: list[tuple[str, str, int, int, int, str]] = []
132
+ for lit in self.literals:
133
+ if len(lit) < 4:
134
+ continue
135
+ idx = 0
136
+ while True:
137
+ found = text.find(lit, idx)
138
+ if found == -1:
139
+ break
140
+ candidates.append(("literal-secret", "SECRET", 99, found, found + len(lit), lit))
141
+ idx = found + len(lit)
142
+
143
+ for rule in self._rules:
144
+ for m in rule.pattern.finditer(text):
145
+ if rule.capture_group is not None and m.group(rule.capture_group) is not None:
146
+ val = m.group(rule.capture_group)
147
+ start = m.start(rule.capture_group)
148
+ end = m.end(rule.capture_group)
149
+ else:
150
+ val = m.group(0)
151
+ start = m.start()
152
+ end = m.end()
153
+ if not val or val in self.allowlist:
154
+ continue
155
+ if rule.id == "ipv4" and not self.redact_private_ips and is_private_or_loopback_ipv4(val):
156
+ continue
157
+ if rule.context_regex is not None:
158
+ left = text[max(0, start - rule.context_window) : start]
159
+ right = text[end : min(len(text), end + rule.context_window)]
160
+ if not (rule.context_regex.search(left) or rule.context_regex.search(right)):
161
+ continue
162
+ if rule.validate is not None and not rule.validate(val, text, start):
163
+ continue
164
+ candidates.append((rule.id, rule.category, rule.priority, start, end, val))
165
+
166
+ candidates.sort(key=lambda x: (-x[2], -(x[4] - x[3]), x[3]))
167
+ accepted: list[tuple[str, str, int, int, int, str]] = []
168
+ for cand in candidates:
169
+ if not any(cand[3] < a[4] and cand[4] > a[3] for a in accepted):
170
+ accepted.append(cand)
171
+ accepted.sort(key=lambda x: x[3])
172
+ return accepted
173
+
174
+ def scan(self, text: str) -> list[Finding]:
175
+ if not text:
176
+ return []
177
+ return [
178
+ Finding(
179
+ id=rid,
180
+ category=cat,
181
+ start=s,
182
+ end=e,
183
+ token=f"[{cat}]",
184
+ length=len(val),
185
+ fingerprint=hashlib.sha256(val.encode("utf-8")).hexdigest()[:12],
186
+ )
187
+ for rid, cat, _prio, s, e, val in self._collect(text)
188
+ ]
189
+
190
+ def redact_text(self, text: str) -> RedactResult:
191
+ if not text:
192
+ return RedactResult(value=text, findings=[])
193
+ self._reserve_existing(text)
194
+ matches = self._collect(text)
195
+ if not matches:
196
+ return RedactResult(value=text, findings=[])
197
+ parts: list[str] = []
198
+ findings: list[Finding] = []
199
+ cursor = 0
200
+ for rid, cat, _prio, s, e, val in matches:
201
+ parts.append(text[cursor:s])
202
+ tok = self.tokenize(cat, val)
203
+ parts.append(tok)
204
+ cursor = e
205
+ findings.append(
206
+ Finding(
207
+ id=rid,
208
+ category=cat,
209
+ start=s,
210
+ end=e,
211
+ token=tok,
212
+ length=len(val),
213
+ fingerprint=_hmac_hex(self._key, val, 12),
214
+ )
215
+ )
216
+ parts.append(text[cursor:])
217
+ return RedactResult(value="".join(parts), findings=findings)
218
+
219
+ def redact(self, data: Any) -> RedactResult:
220
+ if isinstance(data, str):
221
+ return self.redact_text(data)
222
+ findings: list[Finding] = []
223
+ seen: dict[int, Any] = {}
224
+
225
+ def walk(node: Any, key_hint: str | None = None, depth: int = 0) -> Any:
226
+ if node is None or isinstance(node, (bool, int, float)):
227
+ return node
228
+ if isinstance(node, str):
229
+ if key_hint and _SENSITIVE_KEY_RE.match(key_hint) and node and node not in self._reverse:
230
+ tok = self.tokenize("SECRET", node)
231
+ findings.append(
232
+ Finding(
233
+ id="sensitive-key",
234
+ category="SECRET",
235
+ start=0,
236
+ end=len(node),
237
+ token=tok,
238
+ length=len(node),
239
+ fingerprint=_hmac_hex(self._key, node, 12),
240
+ )
241
+ )
242
+ return tok
243
+ res = self.redact_text(node)
244
+ findings.extend(res.findings)
245
+ return res.value
246
+ if depth >= 32:
247
+ return "[MAX_DEPTH_TRUNCATED]"
248
+ obj_id = id(node)
249
+ if obj_id in seen:
250
+ return seen[obj_id]
251
+ if isinstance(node, list):
252
+ arr: list[Any] = []
253
+ seen[obj_id] = arr
254
+ for item in node:
255
+ arr.append(walk(item, key_hint, depth + 1))
256
+ return arr
257
+ if isinstance(node, tuple):
258
+ return tuple(walk(item, key_hint, depth + 1) for item in node)
259
+ if isinstance(node, dict):
260
+ out: dict[Any, Any] = {}
261
+ seen[obj_id] = out
262
+ for k, v in node.items():
263
+ out[k] = walk(v, str(k) if isinstance(k, str) else None, depth + 1)
264
+ return out
265
+ if isinstance(node, BaseException):
266
+ return self.redact_text(str(node)).value
267
+ return node
268
+
269
+ return RedactResult(value=walk(data), findings=findings)
270
+
271
+ def restore(self, text: str) -> str:
272
+ if not text or not self._reverse:
273
+ return text
274
+ tokens = sorted(self._reverse.keys(), key=len, reverse=True)
275
+ pattern = re.compile("|".join(re.escape(t) for t in tokens))
276
+ return pattern.sub(lambda m: self._reverse[m.group(0)][1], text)
277
+
278
+ def restore_object(self, data: Any) -> Any:
279
+ if isinstance(data, str):
280
+ return self.restore(data)
281
+ if isinstance(data, list):
282
+ return [self.restore_object(x) for x in data]
283
+ if isinstance(data, dict):
284
+ return {k: self.restore_object(v) for k, v in data.items()}
285
+ return data
286
+
287
+ def entries(self) -> list[tuple[str, str, str]]:
288
+ return [(tok, meta[0], meta[1]) for tok, meta in self._reverse.items()]
289
+
290
+
291
+ class StreamRestorer:
292
+ def __init__(self, vault: Vault) -> None:
293
+ self.vault = vault
294
+ self._cached_version = -1
295
+ self._token_map: dict[str, str] = {}
296
+ self._prefixes: set[str] = set()
297
+ self._first_chars: set[str] = set()
298
+ self._max_len = 0
299
+ self._pending = ""
300
+
301
+ def _rebuild(self) -> None:
302
+ if self.vault.version == self._cached_version:
303
+ return
304
+ self._cached_version = self.vault.version
305
+ self._token_map.clear()
306
+ self._prefixes.clear()
307
+ self._first_chars.clear()
308
+ self._max_len = 0
309
+ for tok, _cat, val in self.vault.entries():
310
+ self._token_map[tok] = val
311
+ self._first_chars.add(tok[0])
312
+ self._max_len = max(self._max_len, len(tok))
313
+ for i in range(1, len(tok)):
314
+ self._prefixes.add(tok[:i])
315
+
316
+ def _process(self, is_flush: bool) -> str:
317
+ self._rebuild()
318
+ if not self._pending:
319
+ return ""
320
+ if not self._token_map:
321
+ out, self._pending = self._pending, ""
322
+ return out
323
+ out: list[str] = []
324
+ pos = 0
325
+ n = len(self._pending)
326
+ while pos < n:
327
+ ch = self._pending[pos]
328
+ if ch not in self._first_chars:
329
+ out.append(ch)
330
+ pos += 1
331
+ continue
332
+ rem = self._pending[pos:]
333
+ if not is_flush and len(rem) < self._max_len and rem in self._prefixes:
334
+ break
335
+ matched: str | None = None
336
+ for length in range(min(self._max_len, len(rem)), 0, -1):
337
+ candidate = rem[:length]
338
+ if candidate in self._token_map:
339
+ matched = candidate
340
+ break
341
+ if matched is not None:
342
+ out.append(self._token_map[matched])
343
+ pos += len(matched)
344
+ else:
345
+ out.append(ch)
346
+ pos += 1
347
+ self._pending = self._pending[pos:]
348
+ return "".join(out)
349
+
350
+ def push(self, chunk: str) -> str:
351
+ if not chunk:
352
+ return ""
353
+ self._pending += chunk
354
+ return self._process(False)
355
+
356
+ def flush(self) -> str:
357
+ return self._process(True)
358
+
359
+ def iter_restore(self, chunks: Iterator[str]) -> Iterator[str]:
360
+ for ch in chunks:
361
+ out = self.push(ch)
362
+ if out:
363
+ yield out
364
+ tail = self.flush()
365
+ if tail:
366
+ yield tail
367
+
368
+
369
+ def create_vault(preset: str = "default", **kwargs: Any) -> Vault:
370
+ return Vault(preset=preset, **kwargs)
371
+
372
+
373
+ def redact(data: Any, preset: str = "default", **kwargs: Any) -> Any:
374
+ return Vault(preset=preset, **kwargs).redact(data).value
375
+
376
+
377
+ def redact_detailed(data: Any, preset: str = "default", **kwargs: Any) -> RedactResult:
378
+ return Vault(preset=preset, **kwargs).redact(data)
379
+
380
+
381
+ def scan(text: str, preset: str = "default", **kwargs: Any) -> list[Finding]:
382
+ return Vault(preset=preset, **kwargs).scan(text)
383
+
384
+
385
+ def redact_fhir(
386
+ resource: dict[str, Any],
387
+ *,
388
+ vault: Vault | None = None,
389
+ date_shift_days: int | None = None,
390
+ cap_age_over_89: bool = True,
391
+ ) -> tuple[dict[str, Any], Vault]:
392
+ v = vault or Vault(preset="hipaa")
393
+ seed = str(resource.get("id", "default"))
394
+ if date_shift_days is None:
395
+ h = int(hashlib.sha256(seed.encode("utf-8")).hexdigest()[:8], 16)
396
+ mag = 30 + (h % 151)
397
+ shift_days = mag if h % 2 == 0 else -mag
398
+ else:
399
+ shift_days = date_shift_days
400
+
401
+ clinical_keys = {"resourceType", "coding", "code", "system", "unit", "valueQuantity", "gender", "status"}
402
+ cur_year = datetime.now(timezone.utc).year
403
+
404
+ def shift_date(val: str, is_birth: bool = False) -> str:
405
+ m = re.match(r"^(\d{4})-(\d{2})-(\d{2})(.*)$", val)
406
+ if is_birth and cap_age_over_89 and m and (cur_year - int(m.group(1)) > 89):
407
+ return "90+"
408
+ if not m:
409
+ return val
410
+ dt = datetime(int(m.group(1)), int(m.group(2)), int(m.group(3)), tzinfo=timezone.utc) + timedelta(days=shift_days)
411
+ return dt.strftime("%Y-%m-%d") + m.group(4)
412
+
413
+ def walk(node: Any, path: tuple[str, ...], preserve: bool) -> Any:
414
+ cur = path[-1] if path else ""
415
+ if isinstance(node, str):
416
+ if preserve and cur != "display":
417
+ return node
418
+ if cur == "postalCode":
419
+ digits = re.sub(r"\D", "", node)
420
+ z3 = digits[:3] if len(digits) >= 3 else "000"
421
+ return "000" if z3 in RESTRICTED_ZIP3_CONSERVATIVE else z3
422
+ if cur == "birthDate":
423
+ return shift_date(node, is_birth=True)
424
+ if cur in ("date", "effectiveDateTime", "authoredOn", "onsetDateTime", "recordedDate", "issued"):
425
+ return shift_date(node, is_birth=False)
426
+ if cur in ("family", "given", "prefix", "suffix"):
427
+ return v.tokenize("PERSON", node)
428
+ if cur in ("line", "city"):
429
+ return v.tokenize("ADDRESS", node)
430
+ if cur == "id" or ("identifier" in path and cur == "value"):
431
+ return v.tokenize("MRN", node)
432
+ return v.redact_text(node).value
433
+ if isinstance(node, list):
434
+ return [walk(x, path, preserve) for x in node]
435
+ if isinstance(node, dict):
436
+ out: dict[str, Any] = {}
437
+ for k, val in node.items():
438
+ if k == "resourceType":
439
+ out[k] = val
440
+ elif k == "photo":
441
+ out[k] = []
442
+ else:
443
+ out[k] = walk(val, (*path, k), preserve or (k in clinical_keys))
444
+ return out
445
+ return node
446
+
447
+ return walk(resource, (), False), v
@@ -0,0 +1,129 @@
1
+ Metadata-Version: 2.5
2
+ Name: redact0
3
+ Version: 1.0.0
4
+ Summary: Zero-dependency reversible PII, secret, FHIR HIPAA Safe Harbor, and streaming LLM boundary redactor. Drop-in migration for llm-guard.
5
+ License: MIT
6
+ Keywords: ai,fhir,gdpr,hipaa,llm-guard,pii,redaction,secrets
7
+ Classifier: Development Status :: 5 - Production/Stable
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Topic :: Security
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+
20
+ # redact0
21
+
22
+ Zero-runtime-dependency reversible PII, secret, FHIR Safe Harbor and streaming redaction for JavaScript/TypeScript and Python.
23
+
24
+ > Security note: this package is a best-effort detector/redactor, not a compliance certification or a substitute for a privacy/security review. Validate it against your own data and regulatory requirements before production use.
25
+
26
+ ## Requirements
27
+
28
+ - Node.js 18+ for the npm package
29
+ - Python 3.10+ for the PyPI package
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ npm install redact0
35
+ ```
36
+
37
+ ```bash
38
+ python -m pip install redact0
39
+ ```
40
+
41
+ ## JavaScript / TypeScript quick start
42
+
43
+ ```ts
44
+ import { createVault, redact } from 'redact0';
45
+
46
+ const safe = redact({
47
+ email: 'alice@example.com',
48
+ message: 'Contact alice@example.com',
49
+ });
50
+
51
+ const vault = createVault();
52
+ const scrubbed = vault.redact('Send the report to alice@example.com').value;
53
+ const restored = vault.restore(scrubbed);
54
+ ```
55
+
56
+ ## Python quick start
57
+
58
+ ```python
59
+ from redact0 import Vault, redact
60
+
61
+ safe = redact({"email": "alice@example.com"})
62
+ vault = Vault()
63
+ scrubbed = vault.redact("Contact alice@example.com").value
64
+ restored = vault.restore(scrubbed)
65
+ ```
66
+
67
+ ## Build and verify from this repository
68
+
69
+ ```bash
70
+ npm install
71
+ npm run typecheck
72
+ npm run build
73
+ npm test
74
+ npm run check:size
75
+ ```
76
+
77
+ Python checks (from the repository root):
78
+
79
+ ```bash
80
+ python -m venv .venv
81
+ # Windows PowerShell: .venv\Scripts\Activate.ps1
82
+ # macOS/Linux: source .venv/bin/activate
83
+ python -m pip install -e ./python
84
+ python -m pip install pytest
85
+ python -m pytest python/tests
86
+ ```
87
+
88
+ The Python package itself declares no runtime dependencies. `pytest` is only needed to run its tests.
89
+
90
+ ## CLI
91
+
92
+ After building the npm package:
93
+
94
+ ```bash
95
+ echo "email: alice@example.com" | node dist/cli.cjs
96
+ npx redact0 init
97
+ npx redact0 scan . --preset secrets
98
+ npx redact0 mcp
99
+ ```
100
+
101
+ For Python, after installation:
102
+
103
+ ```bash
104
+ echo "email: alice@example.com" | redact0
105
+ ```
106
+
107
+ ## Exports
108
+
109
+ The npm package defines these subpath exports:
110
+
111
+ - `redact0/stream`
112
+ - `redact0/fhir`
113
+ - `redact0/ai`
114
+ - `redact0/mcp`
115
+ - `redact0/logger`
116
+
117
+ The Python package includes `redact0.compat.llm_guard` for compatibility helpers.
118
+
119
+ ## Project layout
120
+
121
+ - `src/`: TypeScript implementation and adapters
122
+ - `tests/`: TypeScript tests
123
+ - `python/src/redact0/`: Python implementation
124
+ - `python/tests/`: Python tests
125
+ - `llms.txt`, `llms-full.txt`, `context7.json`, `AGENTS.md`: AI-agent and documentation discovery resources
126
+
127
+ ## License
128
+
129
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,12 @@
1
+ redact0/__init__.py,sha256=6fStbaoSdHJi9Ha6StFjmZxMmqHePnWxgHeWY83DyJQ,924
2
+ redact0/cli.py,sha256=ruvbn3072h1vE0L4-gdEvQGAgddJccM8A-nU3PBNenY,895
3
+ redact0/py.typed,sha256=GnrsPzRHw0kxJF-41yISiGc7tdrYsH-TYiLY2AyInbg,42
4
+ redact0/rules.py,sha256=zMTQLsOalUny1S09aZm-Lwv7gCrQiFpcCwjWlP52Dyc,10384
5
+ redact0/validators.py,sha256=1flqPmmBQ_YMAnSQU-MkDSsbtxihMVvI8xibkL99Eak,6830
6
+ redact0/vault.py,sha256=hLnwFtunsc053MAiagWP6bmxziyEhWy68-I_e7hHtHk,16565
7
+ redact0/compat/__init__.py,sha256=LX6YwHIXKIcJom1qPKzCOUorPxzMwdIkYXHqharw-VM,182
8
+ redact0/compat/llm_guard.py,sha256=4_9rLZcQvHz1f9uS36qTsqLvPaRoiqsTUHWKXVtQP8M,2394
9
+ redact0-1.0.0.dist-info/METADATA,sha256=We7AzVI6VnXqQmFV6w-zAbv3zBBC11Ss8XLHy9MZc9E,3221
10
+ redact0-1.0.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
11
+ redact0-1.0.0.dist-info/entry_points.txt,sha256=1rspXGfmcuVazdgJCOIAybpx_ifher9CWmBy-_UF0oY,45
12
+ redact0-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ redact0 = redact0.cli:main