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 +51 -0
- redact0/cli.py +28 -0
- redact0/compat/__init__.py +3 -0
- redact0/compat/llm_guard.py +70 -0
- redact0/py.typed +3 -0
- redact0/rules.py +305 -0
- redact0/validators.py +223 -0
- redact0/vault.py +447 -0
- redact0-1.0.0.dist-info/METADATA +129 -0
- redact0-1.0.0.dist-info/RECORD +12 -0
- redact0-1.0.0.dist-info/WHEEL +4 -0
- redact0-1.0.0.dist-info/entry_points.txt +2 -0
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,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
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,,
|