maskvault 0.1.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.
- maskvault/__init__.py +121 -0
- maskvault/__main__.py +11 -0
- maskvault/anonymizer.py +495 -0
- maskvault/ciphers/__init__.py +25 -0
- maskvault/ciphers/base.py +51 -0
- maskvault/ciphers/fernet.py +133 -0
- maskvault/ciphers/plaintext.py +34 -0
- maskvault/cli/__init__.py +187 -0
- maskvault/cli/anonymize_command.py +77 -0
- maskvault/cli/command.py +66 -0
- maskvault/cli/exit_codes.py +29 -0
- maskvault/cli/explain_command.py +113 -0
- maskvault/cli/policy_command.py +52 -0
- maskvault/cli/reader.py +33 -0
- maskvault/cli/restore_command.py +85 -0
- maskvault/constants.py +188 -0
- maskvault/detectors/__init__.py +41 -0
- maskvault/detectors/adjustments.py +88 -0
- maskvault/detectors/entropy.py +58 -0
- maskvault/detectors/general.py +77 -0
- maskvault/detectors/identifiers.py +68 -0
- maskvault/detectors/registry.py +54 -0
- maskvault/detectors/shapes.py +119 -0
- maskvault/detectors/vendor.py +124 -0
- maskvault/entropy.py +176 -0
- maskvault/exceptions/__init__.py +32 -0
- maskvault/exceptions/base.py +11 -0
- maskvault/exceptions/detector.py +16 -0
- maskvault/exceptions/structure.py +21 -0
- maskvault/exceptions/token_format.py +19 -0
- maskvault/exceptions/vault.py +16 -0
- maskvault/exceptions/whitelist.py +14 -0
- maskvault/formats.py +160 -0
- maskvault/levels.py +145 -0
- maskvault/models/__init__.py +31 -0
- maskvault/models/action.py +12 -0
- maskvault/models/detector.py +67 -0
- maskvault/models/entropy_thresholds.py +93 -0
- maskvault/models/entropy_verdict.py +17 -0
- maskvault/models/finding.py +29 -0
- maskvault/models/level.py +19 -0
- maskvault/models/substitution.py +24 -0
- maskvault/models/token_format.py +207 -0
- maskvault/models/vault.py +475 -0
- maskvault/models/whitelist.py +282 -0
- maskvault/patterns.py +252 -0
- maskvault/personal.py +244 -0
- maskvault/py.typed +0 -0
- maskvault/scopes.py +108 -0
- maskvault/stores/__init__.py +53 -0
- maskvault/stores/backend.py +51 -0
- maskvault/stores/base.py +64 -0
- maskvault/stores/database.py +249 -0
- maskvault/stores/expiry.py +73 -0
- maskvault/stores/memory.py +103 -0
- maskvault/stores/serialized.py +120 -0
- maskvault/structured/__init__.py +108 -0
- maskvault/structured/restorer.py +92 -0
- maskvault/structured/sampler.py +86 -0
- maskvault/structured/text_bytes.py +162 -0
- maskvault/structured/walker.py +165 -0
- maskvault/utils/__init__.py +16 -0
- maskvault/utils/letters.py +40 -0
- maskvault/utils/text.py +62 -0
- maskvault/vocabularies/__init__.py +64 -0
- maskvault/vocabularies/criminal.py +50 -0
- maskvault/vocabularies/health.py +123 -0
- maskvault/vocabularies/homonyms.py +38 -0
- maskvault/vocabularies/identity.py +56 -0
- maskvault/vocabularies/origin.py +58 -0
- maskvault/vocabularies/politics.py +61 -0
- maskvault/vocabularies/religion.py +84 -0
- maskvault/vocabularies/safe_values.py +80 -0
- maskvault-0.1.0.dist-info/METADATA +651 -0
- maskvault-0.1.0.dist-info/RECORD +78 -0
- maskvault-0.1.0.dist-info/WHEEL +4 -0
- maskvault-0.1.0.dist-info/entry_points.txt +3 -0
- maskvault-0.1.0.dist-info/licenses/LICENSE +21 -0
maskvault/__init__.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Reversible anonymization with a vault.
|
|
2
|
+
|
|
3
|
+
Replace confidential values with tokens, pass the text through anything,
|
|
4
|
+
restore the originals afterwards. The destination is not the library's
|
|
5
|
+
business: it serves a prompt sent to a model, a log line shipped to a vendor,
|
|
6
|
+
a dataset handed to a contractor, a bug report pasted into a tracker, or a
|
|
7
|
+
support transcript read by a third party.
|
|
8
|
+
|
|
9
|
+
from maskvault import Anonymizer, Level
|
|
10
|
+
|
|
11
|
+
anonymizer = Anonymizer(level=Level.STRICT, terms={"ORG": ["Acme Corp"]})
|
|
12
|
+
|
|
13
|
+
safe, vault = anonymizer.anonymize("I work at Acme Corp, me@acme.com")
|
|
14
|
+
# "I work at [[ORG_1]], [[EMAIL_1]]"
|
|
15
|
+
|
|
16
|
+
result = send_somewhere(safe)
|
|
17
|
+
final = anonymizer.restore(result, vault)
|
|
18
|
+
|
|
19
|
+
Two kinds of value, handled in opposite ways:
|
|
20
|
+
|
|
21
|
+
**Reversible** — identifying but legitimate. An employer, a phone number, a
|
|
22
|
+
salary, a health condition. Tokenized, then restored, so the third party never
|
|
23
|
+
sees it and your reader still gets a correct answer.
|
|
24
|
+
|
|
25
|
+
**Destroyed** — must never arrive and must never come back. An API key, a
|
|
26
|
+
private key, a card number, a connection string. Replaced with a notice and
|
|
27
|
+
not restored, because its presence means something went wrong upstream and
|
|
28
|
+
restoring it would hide the mistake.
|
|
29
|
+
|
|
30
|
+
An exact round trip therefore holds only when nothing was destroyed.
|
|
31
|
+
|
|
32
|
+
Strictness is a choice made once, with `Level`, and `Whitelist` lets anything
|
|
33
|
+
back through — by label, category, exact value, word, substring, prefix,
|
|
34
|
+
suffix, domain, regex, or an arbitrary predicate. Nothing is allowed by
|
|
35
|
+
default, and `Anonymizer.preview()` reports what would be replaced so a
|
|
36
|
+
policy can be built from real input rather than guesswork.
|
|
37
|
+
|
|
38
|
+
No dependencies, which is deliberate: a privacy filter nobody can audit is
|
|
39
|
+
not much of a privacy filter.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from maskvault.anonymizer import Anonymizer
|
|
43
|
+
from maskvault.constants import PHONE_MAX_DIGITS, PHONE_MIN_DIGITS
|
|
44
|
+
from maskvault.detectors import (
|
|
45
|
+
DEFAULT_DETECTORS,
|
|
46
|
+
PERSONAL_DATA,
|
|
47
|
+
VENDOR_CREDENTIALS,
|
|
48
|
+
EntropyDetector,
|
|
49
|
+
PhoneShape,
|
|
50
|
+
Registry,
|
|
51
|
+
)
|
|
52
|
+
from maskvault.entropy import Entropy
|
|
53
|
+
from maskvault.formats import (
|
|
54
|
+
ANGLES,
|
|
55
|
+
AT_SIGNS,
|
|
56
|
+
BRACKETS,
|
|
57
|
+
DEFAULT_FORMATS,
|
|
58
|
+
MATH_BRACKETS,
|
|
59
|
+
PIPE_BANG,
|
|
60
|
+
PIPE_BRACKETS,
|
|
61
|
+
TokenFormats,
|
|
62
|
+
)
|
|
63
|
+
from maskvault.levels import Levels
|
|
64
|
+
from maskvault.models import (
|
|
65
|
+
Action,
|
|
66
|
+
Detector,
|
|
67
|
+
EntropyThresholds,
|
|
68
|
+
EntropyVerdict,
|
|
69
|
+
Finding,
|
|
70
|
+
Level,
|
|
71
|
+
TokenFormat,
|
|
72
|
+
Vault,
|
|
73
|
+
Whitelist,
|
|
74
|
+
)
|
|
75
|
+
from maskvault.personal import STRUCTURED_PERSONAL, SpecialCategories
|
|
76
|
+
from maskvault.structured import anonymize_data, restore_data
|
|
77
|
+
from maskvault.vocabularies import (
|
|
78
|
+
COMMON_SAFE_VALUES,
|
|
79
|
+
SPECIAL_CATEGORIES,
|
|
80
|
+
TECHNICAL_HOMONYMS,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
__all__ = [
|
|
84
|
+
"ANGLES",
|
|
85
|
+
"AT_SIGNS",
|
|
86
|
+
"BRACKETS",
|
|
87
|
+
"COMMON_SAFE_VALUES",
|
|
88
|
+
"DEFAULT_DETECTORS",
|
|
89
|
+
"DEFAULT_FORMATS",
|
|
90
|
+
"MATH_BRACKETS",
|
|
91
|
+
"PERSONAL_DATA",
|
|
92
|
+
"PHONE_MAX_DIGITS",
|
|
93
|
+
"PHONE_MIN_DIGITS",
|
|
94
|
+
"PIPE_BANG",
|
|
95
|
+
"PIPE_BRACKETS",
|
|
96
|
+
"SPECIAL_CATEGORIES",
|
|
97
|
+
"STRUCTURED_PERSONAL",
|
|
98
|
+
"TECHNICAL_HOMONYMS",
|
|
99
|
+
"VENDOR_CREDENTIALS",
|
|
100
|
+
"Action",
|
|
101
|
+
"Anonymizer",
|
|
102
|
+
"Detector",
|
|
103
|
+
"Entropy",
|
|
104
|
+
"EntropyDetector",
|
|
105
|
+
"EntropyThresholds",
|
|
106
|
+
"EntropyVerdict",
|
|
107
|
+
"Finding",
|
|
108
|
+
"Level",
|
|
109
|
+
"Levels",
|
|
110
|
+
"PhoneShape",
|
|
111
|
+
"Registry",
|
|
112
|
+
"SpecialCategories",
|
|
113
|
+
"TokenFormat",
|
|
114
|
+
"TokenFormats",
|
|
115
|
+
"Vault",
|
|
116
|
+
"Whitelist",
|
|
117
|
+
"__version__",
|
|
118
|
+
"anonymize_data",
|
|
119
|
+
"restore_data",
|
|
120
|
+
]
|
|
121
|
+
__version__ = "0.1.0"
|
maskvault/__main__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Lets the tool run as `python -m maskvault`, not only as `maskvault`.
|
|
2
|
+
|
|
3
|
+
Useful when the package is installed somewhere the console script is not on
|
|
4
|
+
PATH, which is common in a container.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from maskvault.cli import run
|
|
10
|
+
|
|
11
|
+
run()
|
maskvault/anonymizer.py
ADDED
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
"""Replacing sensitive values before a prompt leaves the process.
|
|
2
|
+
|
|
3
|
+
The reason this exists separately from redaction: redaction is one-way and
|
|
4
|
+
protects the *owner's* key. This is reversible and protects the *owner's
|
|
5
|
+
privacy* from the provider.
|
|
6
|
+
|
|
7
|
+
An owner can have a bot that answers "who do you work for?" correctly while
|
|
8
|
+
the provider only ever sees "[[ORG_1]]". The mapping stays in this process,
|
|
9
|
+
is never sent anywhere, and is applied to the answer on the way back.
|
|
10
|
+
|
|
11
|
+
anonymizer = Anonymizer(terms={"ORG": ["Acme Corp", "Initech"]})
|
|
12
|
+
safe, vault = anonymizer.anonymize(prompt)
|
|
13
|
+
reply = model(safe) # provider sees tokens only
|
|
14
|
+
answer = anonymizer.restore(reply, vault) # visitor sees real names
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
|
|
21
|
+
from maskvault.formats import DEFAULT_FORMATS, FORMATS_BY_NAME, TokenFormats
|
|
22
|
+
from maskvault.levels import Levels
|
|
23
|
+
from maskvault.models.action import Action
|
|
24
|
+
from maskvault.models.detector import Detector
|
|
25
|
+
from maskvault.models.finding import Finding
|
|
26
|
+
from maskvault.models.level import Level
|
|
27
|
+
from maskvault.models.substitution import Substitution
|
|
28
|
+
from maskvault.models.token_format import TokenFormat
|
|
29
|
+
from maskvault.models.vault import Vault
|
|
30
|
+
from maskvault.models.whitelist import Whitelist
|
|
31
|
+
from maskvault.patterns import NOTICE as _NOTICE
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Anonymizer:
|
|
35
|
+
"""Substitutes sensitive values for tokens, and puts them back.
|
|
36
|
+
|
|
37
|
+
Every detector runs by default — vendor credentials, personal data,
|
|
38
|
+
generic patterns and an entropy net for formats nobody anticipated. That
|
|
39
|
+
is deliberate: the value that leaks is the one nobody thought to
|
|
40
|
+
configure. `whitelist` is how you let something back through.
|
|
41
|
+
|
|
42
|
+
`terms` lets a caller name things no pattern could find — employers,
|
|
43
|
+
client names, product names, a village they grew up in. That is the part
|
|
44
|
+
that actually matters for a CV: patterns catch emails, but only the owner
|
|
45
|
+
knows which company name they would rather a provider never logged.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
level: Level | str = Level.STANDARD,
|
|
51
|
+
*,
|
|
52
|
+
detectors: tuple[Detector, ...] | None = None,
|
|
53
|
+
terms: dict[str, list[str]] | None = None,
|
|
54
|
+
whitelist: Whitelist | dict | None = None,
|
|
55
|
+
token_format: TokenFormat | str | None = None,
|
|
56
|
+
format_candidates: tuple[TokenFormat, ...] | None = None,
|
|
57
|
+
enabled: bool = True,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Build an anonymizer.
|
|
60
|
+
|
|
61
|
+
Everything after `level` is keyword-only. There is no sensible
|
|
62
|
+
positional order for four independent options, and a caller who
|
|
63
|
+
passed `terms` where `detectors` was expected would get silent
|
|
64
|
+
under-protection rather than an error.
|
|
65
|
+
"""
|
|
66
|
+
# Default-deny within the chosen level: every detector it includes
|
|
67
|
+
# runs unless the whitelist says otherwise. Deny is the safe default,
|
|
68
|
+
# because the value that leaks is the one nobody thought to configure.
|
|
69
|
+
self._whitelist = (
|
|
70
|
+
whitelist if isinstance(whitelist, Whitelist) else Whitelist.from_dict(whitelist)
|
|
71
|
+
)
|
|
72
|
+
self._level = Level(level) if isinstance(level, str) else level
|
|
73
|
+
chosen = Levels.detectors_for(self._level) if detectors is None else detectors
|
|
74
|
+
self._detectors = tuple(
|
|
75
|
+
detector for detector in chosen if not self._whitelist.allows_label(detector.label)
|
|
76
|
+
)
|
|
77
|
+
self._enabled = enabled
|
|
78
|
+
self._terms = self._compile_terms(terms or {})
|
|
79
|
+
# None means choose per operation, which avoids a collision with
|
|
80
|
+
# whatever markup the text already uses. A named or explicit format
|
|
81
|
+
# pins it, for a caller who needs the same delimiters every time.
|
|
82
|
+
self._fixed_format = (
|
|
83
|
+
FORMATS_BY_NAME[token_format] if isinstance(token_format, str) else token_format
|
|
84
|
+
)
|
|
85
|
+
# The candidates auto-selection tries, in preference order. A caller
|
|
86
|
+
# can supply their own, so "pick one that fits the text" does not
|
|
87
|
+
# mean "pick one of ours".
|
|
88
|
+
self._candidates = format_candidates or DEFAULT_FORMATS
|
|
89
|
+
|
|
90
|
+
def anonymize(self, text: str) -> tuple[str, Vault]:
|
|
91
|
+
"""Return text safe to send, plus the vault needed to restore it."""
|
|
92
|
+
token_format = self._fixed_format or TokenFormats.choose_format(text, self._candidates)
|
|
93
|
+
vault = Vault(token_format=token_format)
|
|
94
|
+
return self.anonymize_into(text, vault), vault
|
|
95
|
+
|
|
96
|
+
def anonymize_into(self, text: str, vault: Vault) -> str:
|
|
97
|
+
"""Replace values in one string, accumulating into an existing vault.
|
|
98
|
+
|
|
99
|
+
Nothing about the operation is stored on this object, which is what
|
|
100
|
+
makes one anonymizer safe to share across concurrent callers — the
|
|
101
|
+
shape nearly every web application takes. The format and the counters
|
|
102
|
+
both live on the vault, and the vault belongs to the caller.
|
|
103
|
+
|
|
104
|
+
This is what lets a caller anonymize many strings under one vault — a
|
|
105
|
+
structure walked field by field, or a conversation turn by turn. A
|
|
106
|
+
value appearing in two of them keeps one token, so whatever reads the
|
|
107
|
+
result can still tell the mentions refer to the same thing.
|
|
108
|
+
"""
|
|
109
|
+
if not self._enabled or not text:
|
|
110
|
+
return text
|
|
111
|
+
|
|
112
|
+
# Caller-supplied terms first: they are the most specific and the most
|
|
113
|
+
# sensitive, and a literal must win over a generic pattern.
|
|
114
|
+
for label, pattern in self._terms:
|
|
115
|
+
text = self._substitute(text, Substitution(label, pattern), vault)
|
|
116
|
+
for detector in self._detectors:
|
|
117
|
+
if detector.action is Action.DESTROY:
|
|
118
|
+
text = self._destroy(text, detector, vault)
|
|
119
|
+
else:
|
|
120
|
+
text = self._substitute(
|
|
121
|
+
text,
|
|
122
|
+
Substitution(
|
|
123
|
+
detector.label.upper(),
|
|
124
|
+
detector.pattern,
|
|
125
|
+
detector.validate,
|
|
126
|
+
detector.group,
|
|
127
|
+
),
|
|
128
|
+
vault,
|
|
129
|
+
)
|
|
130
|
+
return text
|
|
131
|
+
|
|
132
|
+
def restore(self, text: str, vault: Vault) -> str:
|
|
133
|
+
"""Put the real values back into a model's answer.
|
|
134
|
+
|
|
135
|
+
Only tokens present in this vault are restored, so a model that
|
|
136
|
+
invents `[[ORG_9]]` leaves it visible rather than resolving to
|
|
137
|
+
something unrelated — a visible oddity beats a silent wrong answer.
|
|
138
|
+
"""
|
|
139
|
+
if not vault.values or not text:
|
|
140
|
+
return text
|
|
141
|
+
for token, original in vault.snapshot().items():
|
|
142
|
+
text = text.replace(token, original)
|
|
143
|
+
return text
|
|
144
|
+
|
|
145
|
+
# -- inspection --------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
def explain(self, text: str) -> list[Finding]:
|
|
148
|
+
"""Report what this anonymizer would do to the text, and why.
|
|
149
|
+
|
|
150
|
+
The point of this is to make configuration cheap. Rather than guessing
|
|
151
|
+
a whitelist up front, run real input through it, read the findings, and
|
|
152
|
+
allow the entries that turn out to be harmless. `suggest_whitelist`
|
|
153
|
+
turns that reading straight into config.
|
|
154
|
+
|
|
155
|
+
Findings include values the whitelist already allows, marked as such,
|
|
156
|
+
so a developer can see the policy working rather than inferring it.
|
|
157
|
+
|
|
158
|
+
Overlaps are resolved the way `anonymize` resolves them, so the two
|
|
159
|
+
agree. Reporting every detector's matches independently counted a
|
|
160
|
+
value twice whenever two patterns claimed the same span — an IP
|
|
161
|
+
address is also a punctuated digit run, so `explain` said nine where
|
|
162
|
+
`anonymize` replaced six, and the number a developer read was not the
|
|
163
|
+
number they would get.
|
|
164
|
+
"""
|
|
165
|
+
findings: list[Finding] = []
|
|
166
|
+
for label, pattern in self._terms:
|
|
167
|
+
# A caller-supplied term behaves exactly like a reversible
|
|
168
|
+
# detector, so it is reported through the same path.
|
|
169
|
+
findings.extend(
|
|
170
|
+
self._find(
|
|
171
|
+
text, Detector(label=label, pattern=pattern, action=Action.REVERSIBLE)
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
for detector in self._detectors:
|
|
175
|
+
findings.extend(self._find(text, detector))
|
|
176
|
+
return self._resolve_overlaps(findings)
|
|
177
|
+
|
|
178
|
+
@staticmethod
|
|
179
|
+
def _resolve_overlaps(findings: list[Finding]) -> list[Finding]:
|
|
180
|
+
"""Keep one finding per span, picking the same winner substitution does.
|
|
181
|
+
|
|
182
|
+
Substitution runs each detector over the text in turn, so the one
|
|
183
|
+
earliest in the detector list claims a span — not the one that starts
|
|
184
|
+
earliest in the text. `findings` arrives in detector order, so walking
|
|
185
|
+
it in that order and skipping anything overlapping an earlier claim
|
|
186
|
+
gives the same answer.
|
|
187
|
+
|
|
188
|
+
Getting this backwards is subtle: sorting by position first reports
|
|
189
|
+
`high_entropy` for a Stripe key, because the entropy candidate starts
|
|
190
|
+
a few characters earlier while `stripe_key` is the detector that
|
|
191
|
+
actually replaces it.
|
|
192
|
+
"""
|
|
193
|
+
kept: list[Finding] = []
|
|
194
|
+
claimed: list[tuple[int, int]] = []
|
|
195
|
+
for finding in findings:
|
|
196
|
+
overlaps = any(
|
|
197
|
+
finding.start < end and start < finding.end for start, end in claimed
|
|
198
|
+
)
|
|
199
|
+
if overlaps:
|
|
200
|
+
continue
|
|
201
|
+
kept.append(finding)
|
|
202
|
+
claimed.append((finding.start, finding.end))
|
|
203
|
+
# Earliest first, so the report reads in the order of the text.
|
|
204
|
+
kept.sort(key=lambda finding: (finding.start, finding.label))
|
|
205
|
+
return kept
|
|
206
|
+
|
|
207
|
+
def preview(self, text: str) -> str:
|
|
208
|
+
"""Render what would happen. For a terminal, not a log.
|
|
209
|
+
|
|
210
|
+
This prints real values, which is the whole point — a developer
|
|
211
|
+
deciding what to allow has to see what was found. It is not safe to
|
|
212
|
+
log; `Vault.summary()` is what goes in a log.
|
|
213
|
+
"""
|
|
214
|
+
findings = self.explain(text)
|
|
215
|
+
if not findings:
|
|
216
|
+
return "nothing would be replaced"
|
|
217
|
+
|
|
218
|
+
lines = [f"{len(findings)} findings at level {self._level.value}:"]
|
|
219
|
+
for finding in findings:
|
|
220
|
+
state = "allowed" if finding.allowed else finding.action.value
|
|
221
|
+
lines.append(f" {state:11} {finding.label:18} {finding.value[:48]}")
|
|
222
|
+
remaining = sum(1 for finding in findings if not finding.allowed)
|
|
223
|
+
lines.append(
|
|
224
|
+
f"{remaining} would be replaced, {len(findings) - remaining} allowed through"
|
|
225
|
+
)
|
|
226
|
+
return "\n".join(lines)
|
|
227
|
+
|
|
228
|
+
def suggest_whitelist(self, text: str, *, only_reversible: bool = True) -> dict:
|
|
229
|
+
"""Draft a whitelist covering everything found, ready to edit.
|
|
230
|
+
|
|
231
|
+
Deliberately a *draft*. It allows everything, which is not a policy
|
|
232
|
+
anyone should ship — the intended use is to delete the lines that
|
|
233
|
+
should stay protected and keep the rest. Credentials are left out
|
|
234
|
+
entirely by default, because allowing one is almost never correct.
|
|
235
|
+
"""
|
|
236
|
+
findings = [finding for finding in self.explain(text) if not finding.allowed]
|
|
237
|
+
if only_reversible:
|
|
238
|
+
findings = [finding for finding in findings if finding.action is Action.REVERSIBLE]
|
|
239
|
+
return {
|
|
240
|
+
"values": sorted({finding.value.strip() for finding in findings}),
|
|
241
|
+
"labels": [],
|
|
242
|
+
"note": "a draft: remove anything that should stay protected",
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def token_format(self) -> TokenFormat | None:
|
|
247
|
+
"""The pinned format, or None when one is chosen per operation."""
|
|
248
|
+
return self._fixed_format
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def format_candidates(self) -> tuple[TokenFormat, ...]:
|
|
252
|
+
"""The formats auto-selection will try, in preference order."""
|
|
253
|
+
return self._candidates
|
|
254
|
+
|
|
255
|
+
@property
|
|
256
|
+
def level(self) -> Level:
|
|
257
|
+
"""The strictness level in force."""
|
|
258
|
+
return self._level
|
|
259
|
+
|
|
260
|
+
@property
|
|
261
|
+
def whitelist(self) -> Whitelist:
|
|
262
|
+
"""The allowlist in force, for logging what the policy was."""
|
|
263
|
+
return self._whitelist
|
|
264
|
+
|
|
265
|
+
def describe_policy(self) -> str:
|
|
266
|
+
"""Summarize what this anonymizer will do, safe to log at startup."""
|
|
267
|
+
state = "on" if self._enabled else "OFF"
|
|
268
|
+
return (
|
|
269
|
+
f"anonymizer {state} at {self._level.value}: {len(self._detectors)} detectors, "
|
|
270
|
+
f"{len(self._terms)} terms, {self._whitelist.describe()}"
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
def leaked(self, text: str, vault: Vault) -> list[str]:
|
|
274
|
+
"""Tokens still in the text after restoring, in any shape.
|
|
275
|
+
|
|
276
|
+
The check to run on every model reply. A model that altered a token
|
|
277
|
+
leaves one behind, and restoring cannot match it — so without this the
|
|
278
|
+
reply has quietly lost a value and nothing says so.
|
|
279
|
+
|
|
280
|
+
Catches both well-formed tokens this vault does not know and tokens
|
|
281
|
+
the model changed: inner spaces, dropped or swapped delimiters, a
|
|
282
|
+
lower-cased label, one broken over a line break.
|
|
283
|
+
|
|
284
|
+
**Over-reports rather than under-reports, deliberately.** A reply
|
|
285
|
+
discussing a column literally named `EMAIL_1` is flagged, which costs
|
|
286
|
+
a fallback on a request that was fine. The alternative is a value
|
|
287
|
+
silently missing from an answer somebody acts on, and between those
|
|
288
|
+
two the choice is not close.
|
|
289
|
+
"""
|
|
290
|
+
restored = self.restore(text, vault)
|
|
291
|
+
# Two kinds of leftover, and the second is the common one.
|
|
292
|
+
#
|
|
293
|
+
# A well-formed token this vault does not know: the model invented
|
|
294
|
+
# `!|EMAIL_9|!`, or the wrong vault was used. Found by the vault's own
|
|
295
|
+
# pattern, so a token written in one format is not judged against
|
|
296
|
+
# another.
|
|
297
|
+
found = {match.group(0) for match in vault.pattern.finditer(restored)}
|
|
298
|
+
# A token the model *altered*, which restoring could not match and
|
|
299
|
+
# the pattern cannot see: `!| EMAIL_1 |!`, `EMAIL_1`, `[EMAIL_1]`,
|
|
300
|
+
# `!|email_1|!`, one split across a line break. Checking only for
|
|
301
|
+
# well-formed tokens reported nothing for any of those, so a reply
|
|
302
|
+
# that had silently lost its value looked clean.
|
|
303
|
+
found.update(self._mangled(restored, vault))
|
|
304
|
+
return sorted(found)
|
|
305
|
+
|
|
306
|
+
def resolved(self, text: str, vault: Vault) -> list[str]:
|
|
307
|
+
"""Which of this vault's tokens appear intact in the text.
|
|
308
|
+
|
|
309
|
+
For the question `leaked()` does not answer. Three values go out and
|
|
310
|
+
a reply comes back mentioning two of them: that is normal — a reply
|
|
311
|
+
need not repeat everything it was given — so nothing is wrong and
|
|
312
|
+
`leaked()` is correctly silent.
|
|
313
|
+
|
|
314
|
+
But *whether* it is normal is the application's call, not this
|
|
315
|
+
library's. A summarizer mentioning two of three contacts is fine; a
|
|
316
|
+
form-filler returning two of three fields is a failure. So the counts
|
|
317
|
+
are reported rather than judged:
|
|
318
|
+
|
|
319
|
+
sent = anonymizer.resolved(reply, vault)
|
|
320
|
+
if len(sent) < len(vault.values):
|
|
321
|
+
... # some values were not referred to
|
|
322
|
+
|
|
323
|
+
Combined with `leaked()` the three outcomes separate cleanly:
|
|
324
|
+
|
|
325
|
+
resolved 3, leaked 0 everything came back
|
|
326
|
+
resolved 2, leaked 0 one was not mentioned — usually fine
|
|
327
|
+
resolved 2, leaked 1 one was mangled — do not trust the reply
|
|
328
|
+
|
|
329
|
+
Tokens for destroyed values are never in the vault, so they cannot
|
|
330
|
+
appear here. A destroyed value has no token to return.
|
|
331
|
+
"""
|
|
332
|
+
return sorted(token for token in vault.values if token in text)
|
|
333
|
+
|
|
334
|
+
def unresolved(self, text: str, vault: Vault) -> list[str]:
|
|
335
|
+
"""Which of this vault's tokens do not appear intact in the text.
|
|
336
|
+
|
|
337
|
+
The complement of `resolved`. Includes both the values a reply simply
|
|
338
|
+
did not mention and the ones it mangled — `leaked()` is what tells
|
|
339
|
+
those two apart, and only the second is a reason to distrust a reply.
|
|
340
|
+
"""
|
|
341
|
+
return sorted(token for token in vault.values if token not in text)
|
|
342
|
+
|
|
343
|
+
@staticmethod
|
|
344
|
+
def _mangled(text: str, vault: Vault) -> set[str]:
|
|
345
|
+
"""Find label-and-index cores from this vault that survived in the text.
|
|
346
|
+
|
|
347
|
+
The delimiters are what a model changes; the `LABEL_1` core is what it
|
|
348
|
+
tends to keep. So the core is what to look for, case-insensitively and
|
|
349
|
+
tolerating whitespace where the model may have broken a line.
|
|
350
|
+
|
|
351
|
+
Reported as the core rather than as whatever surrounds it, because the
|
|
352
|
+
caller's question is "did a token fail to restore", not "what exactly
|
|
353
|
+
did the model write".
|
|
354
|
+
"""
|
|
355
|
+
cores = set()
|
|
356
|
+
pattern = vault.token_format.pattern
|
|
357
|
+
for token in vault.snapshot():
|
|
358
|
+
match = pattern.fullmatch(token)
|
|
359
|
+
if match:
|
|
360
|
+
cores.add(f"{match.group(1)}_{match.group(2)}")
|
|
361
|
+
if not cores:
|
|
362
|
+
return set()
|
|
363
|
+
# Whitespace-tolerant, so a core broken over a line break is still
|
|
364
|
+
# recognized: "EMAIL\n_1" is the same failure as "EMAIL_1".
|
|
365
|
+
alternation = "|".join(
|
|
366
|
+
re.escape(core).replace("_", r"\s*_\s*") for core in sorted(cores)
|
|
367
|
+
)
|
|
368
|
+
return {
|
|
369
|
+
re.sub(r"\s+", "", match.group(0)).upper()
|
|
370
|
+
for match in re.finditer(alternation, text, re.IGNORECASE)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
@staticmethod
|
|
374
|
+
def _compile_terms(terms: dict[str, list[str]]) -> list[tuple[str, re.Pattern[str]]]:
|
|
375
|
+
"""Owner-supplied literals, longest first.
|
|
376
|
+
|
|
377
|
+
Longest first so "Acme Corporation" is matched before "Acme", which
|
|
378
|
+
would otherwise leave "Corporation" dangling next to a token.
|
|
379
|
+
"""
|
|
380
|
+
compiled = []
|
|
381
|
+
for label, values in terms.items():
|
|
382
|
+
for value in sorted(
|
|
383
|
+
{v.strip() for v in values if v.strip()}, key=len, reverse=True
|
|
384
|
+
):
|
|
385
|
+
compiled.append((label.upper(), re.compile(rf"\b{re.escape(value)}\b", re.I)))
|
|
386
|
+
return compiled
|
|
387
|
+
|
|
388
|
+
# ------------------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
def _substitute(
|
|
391
|
+
self,
|
|
392
|
+
text: str,
|
|
393
|
+
rule: Substitution,
|
|
394
|
+
vault: Vault,
|
|
395
|
+
) -> str:
|
|
396
|
+
"""Replace each distinct match with a stable token.
|
|
397
|
+
|
|
398
|
+
The same value gets the same token throughout, so a model can still
|
|
399
|
+
tell that two mentions refer to one thing.
|
|
400
|
+
"""
|
|
401
|
+
|
|
402
|
+
def swap(match: re.Match[str]) -> str:
|
|
403
|
+
whole = match.group(0)
|
|
404
|
+
original = rule.value_of(match)
|
|
405
|
+
if self._is_own_output(original, vault.token_format):
|
|
406
|
+
return whole
|
|
407
|
+
if not original or self._whitelist.allows_value(original):
|
|
408
|
+
return whole
|
|
409
|
+
if rule.validator is not None and not rule.validator(original):
|
|
410
|
+
return whole
|
|
411
|
+
# The vault allocates: it holds the counters and the reverse
|
|
412
|
+
# mapping, so giving a repeated value its existing token and
|
|
413
|
+
# minting a new one are one atomic step rather than two.
|
|
414
|
+
token = vault.mint(rule.label, original)
|
|
415
|
+
# Replace only the value, so a keyword-led match keeps its keyword.
|
|
416
|
+
return whole.replace(original, token, 1)
|
|
417
|
+
|
|
418
|
+
return rule.pattern.sub(swap, text)
|
|
419
|
+
|
|
420
|
+
def _is_own_output(self, candidate: str, token_format: TokenFormat) -> bool:
|
|
421
|
+
"""Whether a candidate is something this anonymizer already inserted.
|
|
422
|
+
|
|
423
|
+
Detectors run one after another over progressively replaced text, so a
|
|
424
|
+
later detector sees the tokens and notices the earlier ones left
|
|
425
|
+
behind. At PARANOID — a lower entropy bar and symbols in the alphabet
|
|
426
|
+
— `@@IP_ADDRESS_1@@` measures as a high-entropy value and was
|
|
427
|
+
destroyed, which lost the original permanently and left a vault whose
|
|
428
|
+
tokens no longer appeared in the text. Restoring could never work.
|
|
429
|
+
|
|
430
|
+
Checked before the whitelist, because this is not a policy decision a
|
|
431
|
+
caller should have to make: replacing a replacement is never what
|
|
432
|
+
anyone wanted.
|
|
433
|
+
"""
|
|
434
|
+
if not candidate:
|
|
435
|
+
return False
|
|
436
|
+
# Searched, not fullmatched. A candidate often *contains* a token
|
|
437
|
+
# rather than equalling one: the paranoid entropy pattern took
|
|
438
|
+
# `user=@@EMAIL_1@@` as a single run, so an exact comparison let it
|
|
439
|
+
# through and the token inside was destroyed with the rest.
|
|
440
|
+
if token_format.pattern.search(candidate):
|
|
441
|
+
return True
|
|
442
|
+
# The core without its delimiters. A detector reading already-replaced
|
|
443
|
+
# text does not always get the whole token: the entropy net's
|
|
444
|
+
# candidate pattern stops at `!|`, so it sees `EMAIL_QRMZVB_1` on its
|
|
445
|
+
# own. With a wide random scope that core measures as a secret, and at
|
|
446
|
+
# twelve letters 11% of tokens were destroyed — the value lost and the
|
|
447
|
+
# vault left holding a token no longer in the text.
|
|
448
|
+
#
|
|
449
|
+
# Fullmatched, not searched, so a real secret that happens to contain
|
|
450
|
+
# something token-shaped is still judged on its own merits.
|
|
451
|
+
if token_format.core_pattern.fullmatch(candidate):
|
|
452
|
+
return True
|
|
453
|
+
# A notice is prose in brackets, and the only prose this library puts
|
|
454
|
+
# into the text. Matching the shape rather than the exact strings
|
|
455
|
+
# keeps a caller's custom notice covered too.
|
|
456
|
+
return bool(_NOTICE.search(candidate))
|
|
457
|
+
|
|
458
|
+
def _destroy(self, text: str, detector: Detector, vault: Vault) -> str:
|
|
459
|
+
"""Remove a value that must never be sent or restored."""
|
|
460
|
+
|
|
461
|
+
def swap(match: re.Match[str]) -> str:
|
|
462
|
+
original = match.group(0)
|
|
463
|
+
if self._is_own_output(original, vault.token_format):
|
|
464
|
+
return original
|
|
465
|
+
if self._whitelist.allows_value(original):
|
|
466
|
+
return original
|
|
467
|
+
if detector.validate is not None and not detector.validate(original):
|
|
468
|
+
return original
|
|
469
|
+
vault.destroy(detector.label)
|
|
470
|
+
return detector.notice
|
|
471
|
+
|
|
472
|
+
return detector.pattern.sub(swap, text)
|
|
473
|
+
|
|
474
|
+
# ------------------------------------------------------------------
|
|
475
|
+
|
|
476
|
+
def _find(self, text: str, detector: Detector) -> list[Finding]:
|
|
477
|
+
"""Every match of one detector, as reportable findings."""
|
|
478
|
+
label = detector.label.upper()
|
|
479
|
+
found = []
|
|
480
|
+
for match in detector.pattern.finditer(text):
|
|
481
|
+
value = detector.value_of(match)
|
|
482
|
+
if not value or not detector.matches(value):
|
|
483
|
+
continue
|
|
484
|
+
start, end = detector.span_of(match)
|
|
485
|
+
found.append(
|
|
486
|
+
Finding(
|
|
487
|
+
label=label,
|
|
488
|
+
value=value,
|
|
489
|
+
action=detector.action,
|
|
490
|
+
start=start,
|
|
491
|
+
end=end,
|
|
492
|
+
allowed=self._whitelist.allows_value(value),
|
|
493
|
+
)
|
|
494
|
+
)
|
|
495
|
+
return found
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Encrypting a vault before it is stored.
|
|
2
|
+
|
|
3
|
+
A stored vault holds every original value, and it usually goes somewhere with
|
|
4
|
+
wider access than the application. The key stays in the application while the
|
|
5
|
+
ciphertext sits in the store, which makes these different trust domains — and
|
|
6
|
+
is why encrypting here is worth doing, unlike encrypting a value in memory
|
|
7
|
+
where the key would sit beside it.
|
|
8
|
+
|
|
9
|
+
VaultCipher the port. A KMS, an HSM, a transit backend — yours.
|
|
10
|
+
FernetCipher authenticated encryption, via the `encryption` extra.
|
|
11
|
+
PlaintextCipher the explicit opt-out, named so that choosing it shows.
|
|
12
|
+
|
|
13
|
+
A store cannot be built without one of these. There is no secure default to
|
|
14
|
+
fall back to, because a key cannot be defaulted: a random key per process
|
|
15
|
+
would leave the node that restores unable to decrypt what the node that
|
|
16
|
+
anonymized wrote.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from maskvault.ciphers.base import VaultCipher
|
|
22
|
+
from maskvault.ciphers.fernet import FernetCipher
|
|
23
|
+
from maskvault.ciphers.plaintext import PlaintextCipher
|
|
24
|
+
|
|
25
|
+
__all__ = ["FernetCipher", "PlaintextCipher", "VaultCipher"]
|