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.
Files changed (78) hide show
  1. maskvault/__init__.py +121 -0
  2. maskvault/__main__.py +11 -0
  3. maskvault/anonymizer.py +495 -0
  4. maskvault/ciphers/__init__.py +25 -0
  5. maskvault/ciphers/base.py +51 -0
  6. maskvault/ciphers/fernet.py +133 -0
  7. maskvault/ciphers/plaintext.py +34 -0
  8. maskvault/cli/__init__.py +187 -0
  9. maskvault/cli/anonymize_command.py +77 -0
  10. maskvault/cli/command.py +66 -0
  11. maskvault/cli/exit_codes.py +29 -0
  12. maskvault/cli/explain_command.py +113 -0
  13. maskvault/cli/policy_command.py +52 -0
  14. maskvault/cli/reader.py +33 -0
  15. maskvault/cli/restore_command.py +85 -0
  16. maskvault/constants.py +188 -0
  17. maskvault/detectors/__init__.py +41 -0
  18. maskvault/detectors/adjustments.py +88 -0
  19. maskvault/detectors/entropy.py +58 -0
  20. maskvault/detectors/general.py +77 -0
  21. maskvault/detectors/identifiers.py +68 -0
  22. maskvault/detectors/registry.py +54 -0
  23. maskvault/detectors/shapes.py +119 -0
  24. maskvault/detectors/vendor.py +124 -0
  25. maskvault/entropy.py +176 -0
  26. maskvault/exceptions/__init__.py +32 -0
  27. maskvault/exceptions/base.py +11 -0
  28. maskvault/exceptions/detector.py +16 -0
  29. maskvault/exceptions/structure.py +21 -0
  30. maskvault/exceptions/token_format.py +19 -0
  31. maskvault/exceptions/vault.py +16 -0
  32. maskvault/exceptions/whitelist.py +14 -0
  33. maskvault/formats.py +160 -0
  34. maskvault/levels.py +145 -0
  35. maskvault/models/__init__.py +31 -0
  36. maskvault/models/action.py +12 -0
  37. maskvault/models/detector.py +67 -0
  38. maskvault/models/entropy_thresholds.py +93 -0
  39. maskvault/models/entropy_verdict.py +17 -0
  40. maskvault/models/finding.py +29 -0
  41. maskvault/models/level.py +19 -0
  42. maskvault/models/substitution.py +24 -0
  43. maskvault/models/token_format.py +207 -0
  44. maskvault/models/vault.py +475 -0
  45. maskvault/models/whitelist.py +282 -0
  46. maskvault/patterns.py +252 -0
  47. maskvault/personal.py +244 -0
  48. maskvault/py.typed +0 -0
  49. maskvault/scopes.py +108 -0
  50. maskvault/stores/__init__.py +53 -0
  51. maskvault/stores/backend.py +51 -0
  52. maskvault/stores/base.py +64 -0
  53. maskvault/stores/database.py +249 -0
  54. maskvault/stores/expiry.py +73 -0
  55. maskvault/stores/memory.py +103 -0
  56. maskvault/stores/serialized.py +120 -0
  57. maskvault/structured/__init__.py +108 -0
  58. maskvault/structured/restorer.py +92 -0
  59. maskvault/structured/sampler.py +86 -0
  60. maskvault/structured/text_bytes.py +162 -0
  61. maskvault/structured/walker.py +165 -0
  62. maskvault/utils/__init__.py +16 -0
  63. maskvault/utils/letters.py +40 -0
  64. maskvault/utils/text.py +62 -0
  65. maskvault/vocabularies/__init__.py +64 -0
  66. maskvault/vocabularies/criminal.py +50 -0
  67. maskvault/vocabularies/health.py +123 -0
  68. maskvault/vocabularies/homonyms.py +38 -0
  69. maskvault/vocabularies/identity.py +56 -0
  70. maskvault/vocabularies/origin.py +58 -0
  71. maskvault/vocabularies/politics.py +61 -0
  72. maskvault/vocabularies/religion.py +84 -0
  73. maskvault/vocabularies/safe_values.py +80 -0
  74. maskvault-0.1.0.dist-info/METADATA +651 -0
  75. maskvault-0.1.0.dist-info/RECORD +78 -0
  76. maskvault-0.1.0.dist-info/WHEEL +4 -0
  77. maskvault-0.1.0.dist-info/entry_points.txt +3 -0
  78. 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()
@@ -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"]