jev-secrets 1.0.0__tar.gz

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 (36) hide show
  1. jev_secrets-1.0.0/.gitignore +11 -0
  2. jev_secrets-1.0.0/LICENSE +21 -0
  3. jev_secrets-1.0.0/PKG-INFO +342 -0
  4. jev_secrets-1.0.0/README.md +324 -0
  5. jev_secrets-1.0.0/catalogue/alphabets.json +130 -0
  6. jev_secrets-1.0.0/catalogue/fixtures.json +1793 -0
  7. jev_secrets-1.0.0/catalogue/patterns.json +129 -0
  8. jev_secrets-1.0.0/docs/dates.md +55 -0
  9. jev_secrets-1.0.0/docs/evidence.md +90 -0
  10. jev_secrets-1.0.0/docs/generation.md +99 -0
  11. jev_secrets-1.0.0/docs/performance.md +35 -0
  12. jev_secrets-1.0.0/pyproject.toml +51 -0
  13. jev_secrets-1.0.0/python/src/jev_secrets/__init__.py +18 -0
  14. jev_secrets-1.0.0/python/src/jev_secrets/_catalogue.py +44 -0
  15. jev_secrets-1.0.0/python/src/jev_secrets/_config.py +290 -0
  16. jev_secrets-1.0.0/python/src/jev_secrets/_dates.py +333 -0
  17. jev_secrets-1.0.0/python/src/jev_secrets/_detection.py +345 -0
  18. jev_secrets-1.0.0/python/src/jev_secrets/_generation.py +366 -0
  19. jev_secrets-1.0.0/python/src/jev_secrets/_pseudonymiser.py +489 -0
  20. jev_secrets-1.0.0/python/src/jev_secrets/_secrets.py +51 -0
  21. jev_secrets-1.0.0/python/src/jev_secrets/_text.py +87 -0
  22. jev_secrets-1.0.0/python/src/jev_secrets/_vault.py +150 -0
  23. jev_secrets-1.0.0/python/src/jev_secrets/errors.py +31 -0
  24. jev_secrets-1.0.0/python/src/jev_secrets/py.typed +0 -0
  25. jev_secrets-1.0.0/python/src/jev_secrets/typesafe.py +105 -0
  26. jev_secrets-1.0.0/python/tests/conftest.py +19 -0
  27. jev_secrets-1.0.0/python/tests/test_config.py +68 -0
  28. jev_secrets-1.0.0/python/tests/test_dates.py +103 -0
  29. jev_secrets-1.0.0/python/tests/test_evidence.py +62 -0
  30. jev_secrets-1.0.0/python/tests/test_fixtures.py +78 -0
  31. jev_secrets-1.0.0/python/tests/test_generator.py +83 -0
  32. jev_secrets-1.0.0/python/tests/test_key_and_overrides.py +95 -0
  33. jev_secrets-1.0.0/python/tests/test_package.py +10 -0
  34. jev_secrets-1.0.0/python/tests/test_restore.py +65 -0
  35. jev_secrets-1.0.0/python/tests/test_typesafe.py +78 -0
  36. jev_secrets-1.0.0/spec/config.schema.json +192 -0
@@ -0,0 +1,11 @@
1
+ /vendor/
2
+ /node_modules/
3
+ /js/dist/
4
+ .env
5
+ .phpunit.result.cache
6
+ __pycache__/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ .venv/
10
+ /dist/
11
+ *.egg-info/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fox Islam
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,342 @@
1
+ Metadata-Version: 2.5
2
+ Name: jev-secrets
3
+ Version: 1.0.0
4
+ Summary: Reversible PII redaction for Jev requests
5
+ Author-email: Fox Islam <foxislam@outlook.com>
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Keywords: jev,pii,pseudonymisation,redaction,system-one,typesafe
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python :: 3 :: Only
11
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
12
+ Classifier: Topic :: Security
13
+ Classifier: Typing :: Typed
14
+ Requires-Python: >=3.10
15
+ Provides-Extra: sdk
16
+ Requires-Dist: typesafe-sdk<1,>=0.7; extra == 'sdk'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # jev-secrets
20
+
21
+ Replaces names, keys and other secrets in a [Jev](https://docs.typesafe.ai) request with
22
+ stand-ins before it is sent, and puts the originals back in the answers. For the
23
+ [PHP SDK](https://github.com/Fox-Islam/typesafe-sdk-php) and the official
24
+ [JavaScript](https://www.npmjs.com/package/@typesafe-ai/sdk) and
25
+ [Python](https://pypi.org/project/typesafe-sdk/) SDKs, or any code that builds the request body
26
+ itself.
27
+
28
+ ```
29
+ John Smith <john.smith@acme.com> was charged twice on 2024-03-15. Key sk_live_4eC39HqLyjWD
30
+ Tozd Cpabl <tozd.cpabl@ulco.zuy> was charged twice on 2024-03-15. Key sk_live_3uF21GlNlxHG
31
+ ```
32
+
33
+ The second line is what Jev sees, with `John Smith` passed as a value and the rest found by the
34
+ default patterns. In each replaced word every letter becomes another letter of the same case,
35
+ vowels stay vowels, digits become digits, and punctuation stays, so a name still reads as a name
36
+ and an email address as an email address. The same word gets the same stand-in everywhere in the
37
+ request, including in the questions. The answers come back with the original question ids and
38
+ choice labels.
39
+
40
+ This is pseudonymisation, not anonymisation: the mapping is held in memory in order to be able
41
+ to reverse the process on the way back to the caller.
42
+
43
+ **Contents:** [Install](#install) · [Use](#use) · [What gets replaced](#what-gets-replaced) ·
44
+ [What a stand-in looks like](#what-a-stand-in-looks-like) · [The key](#the-key) ·
45
+ [What is rewritten and restored](#what-is-rewritten-and-restored) · [Config](#config) ·
46
+ [When it refuses to send](#when-it-refuses-to-send) · [What it does not do](#what-it-does-not-do) ·
47
+ [Does it change the answers?](#does-it-change-the-answers)
48
+
49
+ ## Install
50
+
51
+ ```sh
52
+ composer require phox/jev-secrets # PHP 8.3+, with ext-intl and ext-mbstring
53
+ npm install @phox-js/jev-secrets # Node 20.19+
54
+ pip install jev-secrets # Python 3.10+; jev-secrets[sdk] adds the SDK wrapper
55
+ ```
56
+
57
+ | | Tested on | SDK versions the wrapper is tested with |
58
+ | --- | --- | --- |
59
+ | PHP | 8.3, 8.4, 8.5 | `phox/typesafe-sdk-php` 0.3 and 0.4 |
60
+ | JavaScript | Node 20.19, 22, 24 | `@typesafe-ai/sdk` 0.5.7 and 0.6.0 |
61
+ | Python | 3.10 to 3.14 | `typesafe-sdk` 0.7.0 and 0.7.1 |
62
+
63
+ A request of 1000 strings, 500 KB of JSON, takes 0.4-0.5 s in PHP and JavaScript and 1.1-1.4 s
64
+ in Python; [docs/performance.md](docs/performance.md) has the measurements.
65
+
66
+ ## Use
67
+
68
+ With the SDK, wrap the client. Each call takes the values to hide in that request, on top of
69
+ whatever the config finds:
70
+
71
+ ```php
72
+ use Phox\JevSecrets\Secrets;
73
+ use Phox\JevSecrets\TypeSafe\SecretClient;
74
+
75
+ $client = new SecretClient(new \Phox\TypeSafe\Client(), new Secrets(['terms' => ['Acme Ltd']]));
76
+
77
+ $response = $client->systemOne()
78
+ ->state(['ticket' => $ticket])
79
+ ->noul('refund', 'Does John Smith ask for a refund?')
80
+ ->values(['John Smith'])
81
+ ->send();
82
+ ```
83
+
84
+ ```js
85
+ import { TypeSafeClient } from '@typesafe-ai/sdk';
86
+ import { Secrets, withSecrets } from '@phox-js/jev-secrets';
87
+
88
+ const client = withSecrets(new TypeSafeClient(), new Secrets({ terms: ['Acme Ltd'] }));
89
+ const { answers } = await client.systemOne(
90
+ { state: { ticket }, questions: { refund: { type: 'noul', instructions: 'Does John Smith ask for a refund?' } } },
91
+ { values: ['John Smith'] },
92
+ );
93
+ ```
94
+
95
+ ```python
96
+ from typesafe_sdk import TypeSafeClient
97
+ from jev_secrets import Secrets
98
+ from jev_secrets.typesafe import SecretClient
99
+
100
+ client = SecretClient(TypeSafeClient(), Secrets({'terms': ['Acme Ltd']}))
101
+ result = client.system_one(
102
+ state={'ticket': ticket},
103
+ questions={'refund': {'type': 'noul', 'instructions': 'Does John Smith ask for a refund?'}},
104
+ values=['John Smith'],
105
+ )
106
+ ```
107
+
108
+ `jev_secrets.typesafe.AsyncSecretClient` wraps the async Python client. The wrappers sit above
109
+ the SDK, not in its transport, because the SDKs log request bodies at debug level before the
110
+ transport sees them.
111
+
112
+ Any config option can be changed at call time. `scope` and `dates` merge entry by entry; any
113
+ other option given replaces the configured one:
114
+
115
+ ```php
116
+ $client->systemOne()->state($ticket)->noul('refund', 'Refund?')
117
+ ->overrides(['disable' => ['phone'], 'dates' => ['order' => 'mdy']])
118
+ ->send();
119
+ ```
120
+
121
+ In JavaScript it is `options.overrides`, in Python `overrides=`, and without an SDK the third
122
+ argument of `pseudo()`.
123
+
124
+ Without an SDK, rewrite the body yourself and restore the decoded response:
125
+
126
+ ```php
127
+ $done = (new Secrets())->pseudo($body, ['John Smith']);
128
+ $answers = $done->restore(json_decode(send($done->request), true));
129
+ ```
130
+
131
+ `pseudo()` is also available as `pseudonymise()`, `pseudonymize()` and `mask()`, and `restore()`
132
+ as `unmask()`.
133
+
134
+ `$done->replacements` lists what was replaced, as a path into the sent request and the source
135
+ that found it, without the values themselves.
136
+
137
+ ## What gets replaced
138
+
139
+ The sources, all optional:
140
+
141
+ | Source | What it matches |
142
+ | --- | --- |
143
+ | Values passed to the call | those strings |
144
+ | Config | `terms` (fixed strings), `fields` (paths whose whole value is replaced), `patterns` (your regexes) |
145
+ | Defaults | the patterns below, and the value under a key such as `password`, `token` or `api_key` |
146
+
147
+ `secretKeys` controls those key names: `false` for none, a list for exactly those, or
148
+ `{"add": [...], "remove": [...]}` to change the default list. Names compare lowercased,
149
+ ignoring spaces, hyphens and underscores, so `member_number` covers `Member-Number`.
150
+
151
+ Values and terms match case-insensitively, at word boundaries. Their words may be joined by
152
+ any run of spaces, underscores, hyphens or dots, so `Ellen Park` is also found in
153
+ `is_from_ellen_park` and `ellen.park@example.com`. A value shorter than 2 characters is refused.
154
+ The value found at a field path is also replaced wherever else it appears in the request.
155
+
156
+ **Names cannot be found by pattern.** Pass them as values, list them as terms, or name the
157
+ fields they're in.
158
+
159
+ Dates, and numbers the default patterns do not cover, are not replaced by default. Pass one as a
160
+ value, or name its field, and a number is replaced digit by digit, a date or time moved
161
+ ([below](#what-a-stand-in-looks-like)).
162
+ With `dates.detect: true` every date and time in the request is moved.
163
+
164
+ <!-- patterns -->
165
+ | Default pattern | Finds |
166
+ | --- | --- |
167
+ | `email` | an email address |
168
+ | `ipv4` | a dotted-quad IPv4 address |
169
+ | `ipv6` | an IPv6 address, full or compressed, with at least one decimal digit |
170
+ | `card` | a card number of 12 to 19 digits that passes the Luhn check |
171
+ | `iban` | an IBAN that passes the mod-97 check |
172
+ | `phone` | a phone number written with a leading `+` and 8 to 15 digits |
173
+ | `us-ssn` | a US Social Security number written with dashes |
174
+ | `uk-nino` | a UK National Insurance number |
175
+ | `aws-access-key` | an AWS access key id, keeping its prefix, such as `AKIA` |
176
+ | `github-token` | a GitHub token, keeping its prefix, such as `ghp_` |
177
+ | `github-pat` | a fine-grained GitHub token, keeping the `github_pat_` prefix |
178
+ | `stripe-key` | a Stripe secret or restricted key, keeping its prefix, such as `sk_live_` |
179
+ | `slack-token` | a Slack token, keeping its prefix, such as `xoxb-` |
180
+ | `anthropic-key` | an Anthropic API key, keeping its prefix, such as `sk-ant-api03-` |
181
+ | `openai-key` | an OpenAI API key, keeping its prefix, such as `sk-proj-` |
182
+ | `google-api-key` | a Google API key, keeping the `AIza` prefix |
183
+ | `jwt` | a JSON Web Token, keeping the leading `eyJ` |
184
+ | `private-key` | the body of a PEM private key, keeping the BEGIN and END lines |
185
+ | `bearer-token` | a bearer token of 16 or more characters, keeping the word `Bearer` |
186
+ | `credential-assignment` | a value after `password=`, `api_key:`, `token=` and the like in text, keeping the name |
187
+ <!-- /patterns -->
188
+
189
+ Where two matches overlap they merge, so a value inside an email address takes the rest of the
190
+ address with it. `catalogue/patterns.json` holds the patterns and the key names; the PHP,
191
+ JavaScript and Python implementations read the same file.
192
+
193
+ ## What a stand-in looks like
194
+
195
+ Recorded outputs of the character generator under a fixed key, from `catalogue/fixtures.json`:
196
+
197
+ <!-- stand-ins -->
198
+ | Input | Stand-in |
199
+ | --- | --- |
200
+ | `John` / `JOHN` | `Tozd` / `TOZD` |
201
+ | `O'Brien-Smythe` | `I'Ghees-Dmjcbu` |
202
+ | `José Müller` | `Taré Hömsif` |
203
+ | `Дмитрий Иванов` | `Ьхэвлаӗ Ычозюв` |
204
+ | `김민준` | `펇쫹떀` |
205
+ | `2024-12-31 23:59` | `1011-11-21 12:33` |
206
+ | `10.0.0.1` | `22.9.9.7` |
207
+ <!-- /stand-ins -->
208
+
209
+ - **Letters:** each becomes another from the same alphabet and case. With `letters: "shape"`,
210
+ the default, vowels stay vowels and consonants stay consonants, so the stand-in is
211
+ pronounceable. Latin, Greek, Cyrillic, Armenian, Georgian, Hebrew, Arabic, Devanagari, Thai,
212
+ kana, Hangul and CJK are drawn from their own scripts; a letter from any other script becomes
213
+ an ASCII letter. Accents stay on.
214
+ - **Digits:** with `digits: "lower"`, the default, 0 and 1 stay and every other digit becomes a
215
+ lower one of at least 1, so an IP address or a version stays valid. A span made only of 0s
216
+ and 1s, like `10.0.0.1`, is drawn under `digits: "any"` instead.
217
+ - **Dates and times** are moved, not rewritten: every date in the request by the same number of
218
+ weeks, every time by the same number of minutes, without crossing midnight. Order, intervals
219
+ and weekdays survive, and the format stays as written, so `Monday, 16 March 2026 at 3:40 pm`
220
+ becomes `Monday, 12 October 2026 at 11:46 am`. `dates.order` decides whether `03/04/2026` is
221
+ 3 April (`dmy`, the default) or 4 March (`mdy`); a year written first is always year, month,
222
+ day. With `dates.mode: "digits"` they get the digit rule instead, which is how the
223
+ `2024-12-31 23:59` row above was made. [docs/dates.md](docs/dates.md) lists the formats.
224
+ - **Everything else** stays: spaces, punctuation, `@`, `.`, `-`, `_`.
225
+
226
+ A stand-in is drawn again until it differs from every word already in the request and from
227
+ every other stand-in. Short numbers under the digit rule can run out of stand-ins and share one;
228
+ [docs/generation.md](docs/generation.md) has the exact rules.
229
+
230
+ ## The key
231
+
232
+ Stand-ins and date offsets are drawn under a key:
233
+
234
+ | `key` | What each call uses |
235
+ | --- | --- |
236
+ | left out | the `JEV_SECRETS_KEY` environment variable, read on every call; without it, a random key |
237
+ | a string | that string |
238
+ | `false` | a random key, whatever the environment holds |
239
+
240
+ With a random key the stand-ins change from call to call. With a fixed one the same request
241
+ gives the same stand-ins every time, in every implementation. Nothing is stored between calls,
242
+ so rotating the key costs nothing: calls after the change get different stand-ins. The library
243
+ reads the process environment, not a `.env` file; load that the way your framework does.
244
+
245
+ ## What is rewritten and restored
246
+
247
+ | Part of the request | Rewritten | Restored in the answers |
248
+ | --- | --- | --- |
249
+ | `state` strings and numbers | yes | - |
250
+ | `state` object keys | with `scope.keys` | - |
251
+ | question `instructions` and `criteria` text | yes | a score's `legend`, exactly as sent |
252
+ | question ids | where they contain a match | the answer keys |
253
+ | choice labels | where they contain a match | `choice` and the keys of `probabilities` |
254
+ | noul criteria keys, `type`, `model`, anything else at the top level | never | - |
255
+
256
+ A score's legend keys and probability keys are rubric indices and are never restored, nor is
257
+ any number in the response. A string field in an answer that this library does not know gets
258
+ word-by-word replacement of the stand-ins.
259
+
260
+ ## Config
261
+
262
+ The same object in PHP, JavaScript and Python.
263
+ [spec/config.schema.json](spec/config.schema.json) has every option and its default.
264
+
265
+ ```json
266
+ {
267
+ "terms": ["Acme Ltd", "Project Nightingale"],
268
+ "fields": ["state.customer.name", "state.orders.*.email"],
269
+ "patterns": [{ "id": "order-id", "pattern": "ORD-(?<value>[0-9]{6})" }],
270
+ "allow": ["support@acme.com"],
271
+ "disable": ["ipv4"],
272
+ "scope": { "keys": true },
273
+ "secretKeys": { "add": ["member_number"] },
274
+ "dates": { "order": "mdy", "detect": true }
275
+ }
276
+ ```
277
+
278
+ `allow` protects a string from terms, fields, patterns and the defaults; a value passed to a
279
+ call still replaces it.
280
+
281
+ ## When it refuses to send
282
+
283
+ After rewriting, every original value is looked for again across every string and key under
284
+ `state` and `questions`. If one is still there, the call throws `LeakException` (`LeakError` in
285
+ JavaScript and Python) and nothing is sent. The message names the path and the source, never
286
+ the value, and suggests the fix for the usual causes: a value in an object key while
287
+ `scope.keys` is off, or in the questions while `scope.questions` is off.
288
+
289
+ In PHP, a request that cannot be searched is refused too: text that is not valid UTF-8, or a
290
+ regex that stops at PCRE's backtrack limit (`MatchException`). Matching cannot say what it
291
+ missed, so nothing is sent. JavaScript and Python strings are always Unicode, and their regexes
292
+ have no such limit.
293
+
294
+ A config that cannot work is refused when it is made: an unknown option, a pattern that does not
295
+ compile, a term with no letter or digit or shorter than `minLength`. A value passed to a call is
296
+ checked the same way when the call is made.
297
+
298
+ ## What it does not do
299
+
300
+ - **Find names by itself.** Only values, terms and fields hide names.
301
+ - **Hide shape.** A stand-in has the same length, capitals, punctuation and word count as the
302
+ original, and the same word always gets the same stand-in, so the number of times a name
303
+ appears is visible.
304
+ - **Hide every digit.** Under the default digit rule 0 and 1 are kept and 2 can only become 1.
305
+ - **Keep order between numbers.** Each number is lowered on its own, so 19 and 22 can come out
306
+ as 17 and 11, and two short numbers can share a stand-in when the rule leaves no other in
307
+ range. Dates and times keep their order unless `dates.mode` is `digits`.
308
+ - **Keep a date's relation to the real world.** A moved date is up to a year away from the
309
+ original, so "is this overdue?" against today, "last Friday" or an age written in words no
310
+ longer line up.
311
+ - **Catch everything the defaults target.** The patterns trade recall for precision: a phone
312
+ number without a leading `+` is missed, a version string like `1.2.3.4` reads as an IPv4
313
+ address, and about one in ten 12 to 19 digit numbers passes the Luhn check by chance.
314
+ - **Unlink calls under a fixed key.** With a fixed key, from the config or `JEV_SECRETS_KEY`, a
315
+ word gets the same stand-in in every call, so whoever receives the requests can tell that two
316
+ calls mention the same person, and anyone holding the key can test a guessed name against a
317
+ stand-in. Use a random key unless you need repeatable output.
318
+ - **Keep an object keyed `"0"`, `"1"`, ... an object in PHP.** PHP decodes it, and `{}`, as a
319
+ list, so its keys are not rewritten and it is sent as a JSON array. The PHP SDK has the same
320
+ limit. For the same reason `secretKeys: {}` means no secret keys in PHP, and no change to the
321
+ default list in JavaScript and Python.
322
+ - **Keep numeric-keyed requests identical in JavaScript.** JavaScript orders integer-like object
323
+ keys ascending, and stand-ins that collide are redrawn in walk order.
324
+
325
+ ## Does it change the answers?
326
+
327
+ Over 67 labelled questions on 50 PII-heavy requests ([docs/evidence.md](docs/evidence.md)):
328
+
329
+ - The default config got 64 of 67 right in both runs; tags got 58, the plain request 67. The
330
+ default moved answers least, by 0.05 on average against 0.17 for tags.
331
+ - All 16 questions comparing dates or times kept their answers: every date and time moves by
332
+ the same offset. Names in Chinese, Arabic, Greek and Japanese script made no difference.
333
+ - What replacing removes is lost: whether an email domain is a company's, or `+44` a UK number.
334
+ - Comparing two plain numbers passed as values fails, since the digit rule keeps neither size
335
+ nor order. Leave out of `values` any number a question compares.
336
+
337
+ ## Documentation
338
+
339
+ [docs/generation.md](docs/generation.md) and [docs/dates.md](docs/dates.md) are the byte-level
340
+ contract the implementations share. `catalogue/fixtures.json` holds the cases each must
341
+ reproduce: `php scripts/golden.php` records them from the PHP implementation, and the JavaScript
342
+ and Python suites require the same output.