maisecrets 0.8.28__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 (79) hide show
  1. maisecrets/__init__.py +1 -0
  2. maisecrets/detection/__init__.py +20 -0
  3. maisecrets/detection/__main__.py +6 -0
  4. maisecrets/detection/checksums.py +343 -0
  5. maisecrets/detection/contract.py +252 -0
  6. maisecrets/detection/core.py +3436 -0
  7. maisecrets/detection/deadline.py +47 -0
  8. maisecrets/detection/detect-1.schema.json +110 -0
  9. maisecrets/detection/encoded_mail.py +99 -0
  10. maisecrets/detection/fields.py +271 -0
  11. maisecrets/detection/keyword_tail.py +147 -0
  12. maisecrets/detection/lazy_regex.py +27 -0
  13. maisecrets/detection/leaves.py +493 -0
  14. maisecrets/detection/normalise.py +333 -0
  15. maisecrets/detection/own_rules.py +388 -0
  16. maisecrets/detection/personal.py +2908 -0
  17. maisecrets/detection/phone_forms.py +40 -0
  18. maisecrets/detection/prefilter.py +300 -0
  19. maisecrets/detection/regions.py +166 -0
  20. maisecrets/detection/registry.py +200 -0
  21. maisecrets/detection/remote.py +573 -0
  22. maisecrets/detection/rule_filter.py +301 -0
  23. maisecrets/detection/rules/BETTERLEAKS_VERSION +1 -0
  24. maisecrets/detection/rules/DETECT_SECRETS_VERSION +1 -0
  25. maisecrets/detection/rules/LICENSE-betterleaks +21 -0
  26. maisecrets/detection/rules/LICENSE-bip39 +26 -0
  27. maisecrets/detection/rules/LICENSE-detect-secrets +201 -0
  28. maisecrets/detection/rules/LICENSE-gitleaks +21 -0
  29. maisecrets/detection/rules/LICENSE-presidio +21 -0
  30. maisecrets/detection/rules/LICENSE-scowl +24 -0
  31. maisecrets/detection/rules/PRESIDIO_VERSION +1 -0
  32. maisecrets/detection/rules/SCOWL_VERSION +1 -0
  33. maisecrets/detection/rules/betterleaks.json +5243 -0
  34. maisecrets/detection/rules/betterleaks.toml +11000 -0
  35. maisecrets/detection/rules/bip39_english.txt +2048 -0
  36. maisecrets/detection/rules/detect_secrets.json +81 -0
  37. maisecrets/detection/rules/labels/cs.txt +11 -0
  38. maisecrets/detection/rules/labels/da.txt +12 -0
  39. maisecrets/detection/rules/labels/de.txt +7 -0
  40. maisecrets/detection/rules/labels/en.txt +10 -0
  41. maisecrets/detection/rules/labels/es.txt +8 -0
  42. maisecrets/detection/rules/labels/fi.txt +12 -0
  43. maisecrets/detection/rules/labels/fr.txt +25 -0
  44. maisecrets/detection/rules/labels/it.txt +9 -0
  45. maisecrets/detection/rules/labels/ja.txt +15 -0
  46. maisecrets/detection/rules/labels/ko.txt +13 -0
  47. maisecrets/detection/rules/labels/nb.txt +11 -0
  48. maisecrets/detection/rules/labels/nl.txt +9 -0
  49. maisecrets/detection/rules/labels/pl.txt +9 -0
  50. maisecrets/detection/rules/labels/pt.txt +12 -0
  51. maisecrets/detection/rules/labels/ru.txt +14 -0
  52. maisecrets/detection/rules/labels/sv.txt +10 -0
  53. maisecrets/detection/rules/labels/th.txt +9 -0
  54. maisecrets/detection/rules/labels/tr.txt +12 -0
  55. maisecrets/detection/rules/labels/zh.txt +14 -0
  56. maisecrets/detection/rules/leading_literals.json +2658 -0
  57. maisecrets/detection/rules/own_secret_rules.json +1938 -0
  58. maisecrets/detection/rules/patched_upstream.txt +9 -0
  59. maisecrets/detection/rules/prefixes.txt +400 -0
  60. maisecrets/detection/rules/presidio.json +2476 -0
  61. maisecrets/detection/rules/public_test_values.txt +89 -0
  62. maisecrets/detection/rules/words_en.txt +20991 -0
  63. maisecrets/detection/rulespec.py +189 -0
  64. maisecrets/detection/text_index.py +166 -0
  65. maisecrets/detection/types.json +94 -0
  66. maisecrets/detection/value_checks.py +1215 -0
  67. maisecrets/detection/vocabulary.py +21 -0
  68. maisecrets/py.typed +0 -0
  69. maisecrets-0.8.28.dist-info/METADATA +452 -0
  70. maisecrets-0.8.28.dist-info/RECORD +79 -0
  71. maisecrets-0.8.28.dist-info/WHEEL +4 -0
  72. maisecrets-0.8.28.dist-info/licenses/LICENSE +202 -0
  73. maisecrets-0.8.28.dist-info/licenses/NOTICE +67 -0
  74. maisecrets-0.8.28.dist-info/licenses/maisecrets/detection/rules/LICENSE-betterleaks +21 -0
  75. maisecrets-0.8.28.dist-info/licenses/maisecrets/detection/rules/LICENSE-bip39 +26 -0
  76. maisecrets-0.8.28.dist-info/licenses/maisecrets/detection/rules/LICENSE-detect-secrets +201 -0
  77. maisecrets-0.8.28.dist-info/licenses/maisecrets/detection/rules/LICENSE-gitleaks +21 -0
  78. maisecrets-0.8.28.dist-info/licenses/maisecrets/detection/rules/LICENSE-presidio +21 -0
  79. maisecrets-0.8.28.dist-info/licenses/maisecrets/detection/rules/LICENSE-scowl +24 -0
maisecrets/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """maisecrets: keep secrets and PII out of cloud models, locally."""
@@ -0,0 +1,20 @@
1
+ """The detection core of maisecrets: it finds secrets and personal data in text, and holds no plugin, home folder or
2
+ reference form of its own. A caller gives those to a Detector:
3
+
4
+ from maisecrets.detection import Active, Detector, Host, NO_REFERENCES, NO_SOURCES
5
+ d = Detector(regions=Active(regions=("generic", "de"), languages=("en", "de"), source="mine"),
6
+ sources=NO_SOURCES, references=NO_REFERENCES, host=Host(user="me", year=2026))
7
+ hits = d.scan(text, channel="prompt")
8
+
9
+ Each argument left None takes the plugin's default when the plugin is there (the config of this computer, the
10
+ readers of the home folder, the placeholder form, this computer's user name and year). The names below are the API.
11
+ The rule tables and the helpers of maisecrets/detection/core.py are not: maisecrets.detect keeps them for the
12
+ plugin and its tests, and they change with the rules.
13
+ """
14
+ from .core import NO_REFERENCES, NO_SOURCES, THIS_COMPUTER, Detector, Host, Match, References, Rule, Sources, user_rule
15
+ from .deadline import ScanIncomplete
16
+ from .regions import Active, resolve as resolve_regions
17
+ from .vocabulary import TYPES
18
+
19
+ __all__ = ["Active", "Detector", "Host", "Match", "NO_REFERENCES", "NO_SOURCES", "References", "Rule", "ScanIncomplete",
20
+ "Sources", "THIS_COMPUTER", "TYPES", "resolve_regions", "user_rule"]
@@ -0,0 +1,6 @@
1
+ """python3 -m maisecrets.detection < request.json: the contract maisecrets.detect/1 (contract.py)."""
2
+ import sys
3
+
4
+ from .contract import main
5
+
6
+ sys.exit(main())
@@ -0,0 +1,343 @@
1
+ """Check digits and checksums for the detector (detect.py): national identifiers that Presidio validates but whose
2
+ validators were not yet ported, the identifiers of regions Presidio has no rules for (Switzerland, Austria), the German
3
+ document number, and three kinds of crypto wallet secret (a BIP39 seed phrase, a WIF private key, a BIP32 extended
4
+ private key).
5
+
6
+ Each function takes the matched text and returns True only for a value whose checksum holds. A function that cannot
7
+ parse its value returns False. No function here reads a file except the BIP39 word list, once.
8
+
9
+ Sources of the algorithms: Presidio's validate_result of each recognizer (MIT, see rules/LICENSE-presidio) for PESEL,
10
+ NIF, NIE, NHS, ABA and the codice fiscale; ICAO Doc 9303 Part 3 for the 7-3-1 check digit; eCH-0097 for the Swiss UID;
11
+ the AHV number is an EAN-13; the Austrian numbers follow the published algorithms of the Hauptverband (VSNR) and of the
12
+ BMF (UID). The Swiss rules follow a fork of this project (Apache-2.0, see NOTICE). BIP39, BIP32 and the WIF format are
13
+ the Bitcoin Improvement Proposals 39 and 32 and the Base58Check encoding.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import base64
18
+ import binascii
19
+ import hashlib
20
+ import re
21
+ import string
22
+ from pathlib import Path
23
+
24
+ RULES_DIR = Path(__file__).resolve().parent / "rules"
25
+
26
+
27
+ def _digits(v: str) -> str:
28
+ return re.sub(r"\D", "", v)
29
+
30
+
31
+ def _luhn_sum(digits: str) -> int:
32
+ """The Luhn sum of a digit string whose last digit is not doubled (a complete number)."""
33
+ total = 0
34
+ for i, ch in enumerate(reversed(digits)):
35
+ d = int(ch)
36
+ if i % 2:
37
+ d = d * 2 - 9 if d > 4 else d * 2
38
+ total += d
39
+ return total % 10
40
+
41
+
42
+ # ------------------------------------------------------------------ Germany --
43
+ # ICAO Doc 9303 Part 3, 4.9: a digit counts its value, A to Z count 10 to 35, the filler < counts 0; weights 7, 3, 1
44
+ def icao_check_digit(s: str) -> int:
45
+ total = 0
46
+ for i, c in enumerate(s):
47
+ if c.isdigit():
48
+ val = int(c)
49
+ elif "A" <= c <= "Z":
50
+ val = ord(c) - 55
51
+ elif c == "<":
52
+ val = 0
53
+ else:
54
+ raise ValueError(c)
55
+ total += val * (7, 3, 1)[i % 3]
56
+ return total % 10
57
+
58
+
59
+ # the characters of a German ID card or passport number since 2010/2007: the digits and the consonants
60
+ # C F G H J K L M N P R T V W X Y Z (no vowel and no B, D, Q or S, which a reader confuses with 8, 0, O and 5)
61
+ DE_DOCUMENT_CHARS = frozenset("CFGHJKLMNPRTVWXYZ" + string.digits)
62
+
63
+
64
+ def de_document_ok(v: str) -> bool:
65
+ """A German ID card or passport number: 9 characters of DE_DOCUMENT_CHARS, as the card prints it, or the same 9
66
+ and the ICAO check digit, as the machine-readable zone prints it. The check digit is not part of the printed number:
67
+ its 9th character can be a letter (the official specimen card's number ends in a digit by chance)."""
68
+ v = v.strip().upper()
69
+ if len(v) not in (9, 10) or any(c not in DE_DOCUMENT_CHARS for c in v[:9]):
70
+ return False
71
+ if len(v) == 10:
72
+ return v[9].isdigit() and icao_check_digit(v[:9]) == int(v[9])
73
+ return True
74
+
75
+
76
+ # ---------------------------------------------------------------- Presidio --
77
+ def pl_pesel_ok(v: str) -> bool:
78
+ """PESEL: weights 1 3 7 9 1 3 7 9 1 3, the check digit is (10 - sum mod 10) mod 10."""
79
+ if len(v) != 11 or not v.isdigit():
80
+ return False
81
+ total = sum(int(d) * w for d, w in zip(v[:10], (1, 3, 7, 9, 1, 3, 7, 9, 1, 3)))
82
+ return (10 - total % 10) % 10 == int(v[10])
83
+
84
+
85
+ _NIF_LETTERS = "TRWAGMYFPDXBNJZSQVHLCKE"
86
+
87
+
88
+ def es_nif_ok(v: str) -> bool:
89
+ """Spanish DNI/NIF: the letter is _NIF_LETTERS[number mod 23] (Presidio removes - and spaces first)."""
90
+ v = re.sub(r"[- ]", "", v.strip()).upper()
91
+ digits = _digits(v)
92
+ return bool(digits) and v[-1:] == _NIF_LETTERS[int(digits) % 23]
93
+
94
+
95
+ def es_nie_ok(v: str) -> bool:
96
+ """Spanish NIE: X, Y or Z (read as 0, 1, 2), the digits, and the NIF letter of that whole number."""
97
+ v = re.sub(r"[- ]", "", v.strip()).upper()
98
+ if not 8 <= len(v) <= 9 or v[:1] not in ("X", "Y", "Z") or not v[1:-1].isdigit():
99
+ return False
100
+ return v[-1] == _NIF_LETTERS[int(str("XYZ".index(v[0])) + v[1:-1]) % 23]
101
+
102
+
103
+ def uk_nhs_ok(v: str) -> bool:
104
+ """NHS number: the ten digits weighted 10 down to 1 sum to a multiple of 11."""
105
+ d = re.sub(r"[- ]", "", v.strip())
106
+ if len(d) != 10 or not d.isdigit() or len(set(d)) == 1:
107
+ return False # 000 000 0000 passes the sum and is a placeholder
108
+ return sum(int(c) * w for c, w in zip(d, range(10, 0, -1))) % 11 == 0
109
+
110
+
111
+ def us_aba_ok(v: str) -> bool:
112
+ """ABA routing number: weights 3 7 1, the sum is a multiple of 10, and the first two digits a Federal Reserve
113
+ routing symbol (00 to 12, 21 to 32, 61 to 72, 80). 000000000 passes the sum and is a placeholder."""
114
+ d = re.sub(r"[- ]", "", v.strip())
115
+ if len(d) != 9 or not d.isdigit() or len(set(d)) == 1:
116
+ return False
117
+ head = int(d[:2])
118
+ if not (head <= 12 or 21 <= head <= 32 or 61 <= head <= 72 or head == 80):
119
+ return False
120
+ return sum(int(c) * w for c, w in zip(d, (3, 7, 1) * 3)) % 10 == 0
121
+
122
+
123
+
124
+ def us_npi_ok(v: str) -> bool:
125
+ """US NPI (CMS): Luhn over the prefix 80840 and the ten digits; a body of one repeated digit is no number."""
126
+ d = re.sub(r"[- ]", "", v.strip())
127
+ return len(d) == 10 and d.isdigit() and len(set(d[:-1])) > 1 and _luhn_sum("80840" + d) == 0
128
+
129
+
130
+ def se_organisationsnummer_ok(v: str) -> bool:
131
+ """Swedish organisationsnummer: ten digits, the third at least 2, Luhn."""
132
+ d = _digits(v)
133
+ if len(d) == 12 and d.startswith("16"):
134
+ d = d[2:] # the century form 16NNNNNN-NNNN of an organisation
135
+ return len(d) == 10 and int(d[2]) >= 2 and _luhn_sum(d) == 0
136
+
137
+
138
+ def uk_vehicle_registration_ok(v: str) -> bool:
139
+ """A UK number plate: in the current form (two letters, an age identifier, three letters) the age identifier is 02
140
+ to 29 or 51 to 79. Presidio leaves the older prefix and suffix forms unjudged; they pass."""
141
+ d = re.sub(r"[- ]", "", v.strip()).upper()
142
+ if len(d) == 7 and d[:2].isalpha() and d[2:4].isdigit() and d[4:].isalpha():
143
+ return 2 <= int(d[2:4]) <= 29 or 51 <= int(d[2:4]) <= 79
144
+ return True
145
+
146
+ # codice fiscale: the value of a character at an odd place (1st, 3rd, ...); at an even place a digit counts its value
147
+ # and a letter its place in the alphabet from 0
148
+ _IT_ODD = dict(zip(string.digits + string.ascii_uppercase,
149
+ (1, 0, 5, 7, 9, 13, 15, 17, 19, 21, 1, 0, 5, 7, 9, 13, 15, 17, 19, 21, 2, 4, 18, 20, 11, 3, 6, 8,
150
+ 12, 14, 16, 10, 22, 25, 24, 23)))
151
+
152
+
153
+ def it_fiscal_code_ok(v: str) -> bool:
154
+ """Italian codice fiscale: the 16th character is the check letter of the first 15."""
155
+ v = v.strip().upper()
156
+ if not re.fullmatch(r"[A-Z0-9]{15}[A-Z]", v):
157
+ return False
158
+ total = sum(_IT_ODD[c] if i % 2 == 0 else (int(c) if c.isdigit() else ord(c) - 65) for i, c in enumerate(v[:15]))
159
+ return chr(65 + total % 26) == v[15]
160
+
161
+
162
+ # -------------------------------------------------------------- Switzerland --
163
+ def ch_ahv_ok(v: str) -> bool:
164
+ """AHV/AVS number (AHVN13): 756 and ten digits, the last an EAN-13 check digit (weights 1 and 3 from the left)."""
165
+ d = _digits(v)
166
+ if len(d) != 13 or not d.startswith("756"):
167
+ return False
168
+ total = sum(int(c) * (3 if i % 2 else 1) for i, c in enumerate(d[:12]))
169
+ return (10 - total % 10) % 10 == int(d[12])
170
+
171
+
172
+ def ch_uid_ok(v: str) -> bool:
173
+ """Swiss company UID (eCH-0097): CHE, eight digits and a mod-11 check digit, weights 5 4 3 2 7 6 5 4. A check of 10
174
+ is never issued."""
175
+ m = re.match(r"\s*CHE[-\s]?(\d{3})\.?(\d{3})\.?(\d{3})", v.upper())
176
+ if not m:
177
+ return False
178
+ d = "".join(m.groups())
179
+ check = 11 - sum(int(c) * w for c, w in zip(d[:8], (5, 4, 3, 2, 7, 6, 5, 4))) % 11
180
+ return check != 10 and check % 11 == int(d[8])
181
+
182
+
183
+ # ------------------------------------------------------------------ Austria --
184
+ def at_uid_ok(v: str) -> bool:
185
+ """Austrian VAT number: ATU and eight digits, the last (6 - Luhn sum of the seven before it) mod 10."""
186
+ m = re.fullmatch(r"ATU(\d{8})", re.sub(r"[\s.-]", "", v.upper()))
187
+ if not m:
188
+ return False
189
+ d = m.group(1)
190
+ return (6 - _luhn_sum(d[:7])) % 10 == int(d[7])
191
+
192
+
193
+ def at_social_security_ok(v: str) -> bool:
194
+ """Austrian social insurance number (VSNR): a serial of three digits, a check digit, and the birth date DDMMYY. The
195
+ check digit is the sum of the other nine digits, weighted 3 7 9 . 5 8 4 2 1 6, mod 11; a sum of 10 is never
196
+ issued."""
197
+ d = re.sub(r"\s", "", v)
198
+ if len(d) != 10 or not d.isdigit() or d[0] == "0":
199
+ return False
200
+ total = sum(int(c) * w for c, w in zip(d, (3, 7, 9, 0, 5, 8, 4, 2, 1, 6))) % 11
201
+ return total != 10 and total == int(d[3])
202
+
203
+
204
+ # ------------------------------------------------------------------- crypto --
205
+ B58_ALPHABET = string.digits[1:] + "".join(c for c in string.ascii_uppercase if c not in "IO") + "".join(
206
+ c for c in string.ascii_lowercase if c != "l")
207
+ _B58_INDEX = {c: i for i, c in enumerate(B58_ALPHABET)}
208
+
209
+
210
+ def b58check_decode(s: str) -> bytes | None:
211
+ """The payload of a Base58Check string, or None when a character is not Base58 or the checksum (the first four
212
+ bytes of SHA-256 twice) does not hold."""
213
+ n = 0
214
+ for c in s:
215
+ i = _B58_INDEX.get(c)
216
+ if i is None:
217
+ return None
218
+ n = n * 58 + i
219
+ raw = n.to_bytes((n.bit_length() + 7) // 8, "big")
220
+ raw = b"\0" * (len(s) - len(s.lstrip("1"))) + raw
221
+ if len(raw) < 5:
222
+ return None
223
+ payload, check = raw[:-4], raw[-4:]
224
+ if hashlib.sha256(hashlib.sha256(payload).digest()).digest()[:4] != check:
225
+ return None
226
+ return payload
227
+
228
+
229
+ _SECP256K1_N = int("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141", 16)
230
+
231
+
232
+ def _private_key_ok(k: bytes) -> bool:
233
+ return len(k) == 32 and 0 < int.from_bytes(k, "big") < _SECP256K1_N
234
+
235
+
236
+ def wif_ok(s: str) -> bool:
237
+ """A Bitcoin private key in Wallet Import Format: Base58Check of 0x80 (0xEF on testnet), the 32-byte key, and 0x01
238
+ for a key whose public key is compressed."""
239
+ p = b58check_decode(s)
240
+ if p is None or p[:1] not in (b"\x80", b"\xef"):
241
+ return False
242
+ if len(p) == 34:
243
+ return p[33] == 1 and _private_key_ok(p[1:33])
244
+ return len(p) == 33 and _private_key_ok(p[1:])
245
+
246
+
247
+ # BIP32 extended private keys and the variants of SLIP-0132: xprv, yprv, zprv, Yprv, Zprv on mainnet, tprv, uprv, vprv,
248
+ # Uprv, Vprv on testnet. The key data is 0x00 and the 32-byte private key
249
+ _XPRV_VERSIONS = frozenset(bytes.fromhex(h) for h in ("0488ADE4", "049D7878", "04B2430C", "0295B005", "02AA7A99",
250
+ "04358394", "044A4E28", "045F18BC", "024285B5", "02575048"))
251
+
252
+
253
+ def xprv_ok(s: str) -> bool:
254
+ p = b58check_decode(s)
255
+ return (p is not None and len(p) == 78 and p[:4] in _XPRV_VERSIONS and p[45] == 0
256
+ and _private_key_ok(p[46:78]))
257
+
258
+
259
+ def discord_bot_token_ok(v: str) -> bool:
260
+ """A Discord bot token's first part is the bot's user id (a snowflake of 17 to 20 digits) in base64."""
261
+ head = v.split(".", 1)[0]
262
+ try:
263
+ raw = base64.urlsafe_b64decode(head + "=" * (-len(head) % 4))
264
+ except (ValueError, binascii.Error):
265
+ return False
266
+ return 17 <= len(raw) <= 20 and raw.isdigit() and raw[:1] != b"0"
267
+
268
+
269
+ _BIP39: list = []
270
+
271
+
272
+ def bip39_words() -> dict:
273
+ """The English BIP39 word list (rules/bip39_english.txt): word -> index."""
274
+ if not _BIP39:
275
+ words = (RULES_DIR / "bip39_english.txt").read_text(encoding="utf-8").split()
276
+ _BIP39.append({w: i for i, w in enumerate(words)})
277
+ return _BIP39[0]
278
+
279
+
280
+ def _bip39_bits_ok(bits: str) -> bool:
281
+ """The checksum of a phrase given as its 11-bit indexes in one bit string."""
282
+ cs = len(bits) // 33
283
+ entropy = int(bits[:-cs], 2).to_bytes((len(bits) - cs) // 8, "big")
284
+ return f"{hashlib.sha256(entropy).digest()[0]:08b}"[:cs] == bits[-cs:]
285
+
286
+
287
+ def bip39_ok(words: list) -> bool:
288
+ """12, 15, 18, 21 or 24 words of the list whose last bits (one per 3 words) are the first bits of SHA-256 of the
289
+ rest: a random choice of 12 words passes once in 16, of 24 words once in 256."""
290
+ index = bip39_words()
291
+ if len(words) not in (12, 15, 18, 21, 24):
292
+ return False
293
+ try:
294
+ return _bip39_bits_ok("".join(f"{index[w]:011b}" for w in words))
295
+ except KeyError:
296
+ return False
297
+
298
+
299
+ # a run of 12 or more lowercase words of 3 to 8 letters, one space, one line break or a comma between two of them
300
+ _WORD_RUN_RE = re.compile(r"(?<![A-Za-z0-9_'-])[a-z]{3,8}(?:(?:,?[ \t]{1,3}|[ \t]*\r?\n[ \t]*)[a-z]{3,8}){11,}"
301
+ r"(?![A-Za-z0-9_'-])")
302
+ _WORD_RE = re.compile(r"[a-z]+")
303
+ # a run of list words much longer than a phrase is a word list (the BIP39 list itself, a dictionary), not a phrase:
304
+ # with dozens of windows one passes by chance, and the list file was masked whole (Gate B of 0.6.19)
305
+ BIP39_MAX_RUN = 48
306
+
307
+
308
+ def bip39_spans(text: str) -> list:
309
+ """(start, end, words) of each seed phrase in the text, words the length of its longest valid window. Within each
310
+ run of 12 to BIP39_MAX_RUN words of the list, every window of 12, 15, 18, 21 or 24 words whose checksum holds is a
311
+ phrase, and the span runs from the first such window to the end of the last: a word of the list next to the phrase
312
+ ("seed phrase: ...") can start a window that passes by chance (one in 16 for 12 words), and taking only that window
313
+ left the phrase's last word in the clear."""
314
+ out = []
315
+ index = None
316
+ for run in _WORD_RUN_RE.finditer(text):
317
+ if index is None:
318
+ index = bip39_words()
319
+ words = list(_WORD_RE.finditer(run.group()))
320
+ i = 0
321
+ while i + 12 <= len(words):
322
+ if words[i].group() not in index:
323
+ i += 1
324
+ continue
325
+ j = i
326
+ while j < len(words) and words[j].group() in index:
327
+ j += 1 # words[i:j] are all on the list
328
+ if j - i > BIP39_MAX_RUN:
329
+ i = j + 1
330
+ continue
331
+ bits = "".join(f"{index[w.group()]:011b}" for w in words[i:j])
332
+ first = last = None
333
+ longest = 0
334
+ for a in range(i, j - 11):
335
+ for n in (12, 15, 18, 21, 24):
336
+ if a + n <= j and _bip39_bits_ok(bits[(a - i) * 11:(a - i + n) * 11]):
337
+ first = a if first is None else first
338
+ last = max(last or 0, a + n)
339
+ longest = max(longest, n)
340
+ if first is not None:
341
+ out.append((run.start() + words[first].start(), run.start() + words[last - 1].end(), longest))
342
+ i = j + 1
343
+ return out
@@ -0,0 +1,252 @@
1
+ """The JSON entry point of the detection core: the contract maisecrets.detect/1 (docs/DETECTION.md, "The JSON entry
2
+ point"; the schema is detect-1.schema.json next to this file).
3
+
4
+ python3 -m maisecrets.detection < request.json > response.json
5
+
6
+ One request on stdin, one response on stdout, nothing on stderr. The response holds offsets, types and rule ids, never
7
+ a value of the text: the caller has the text. An error is a response too, with a code of ERRORS and the path of the
8
+ field; its message is a fixed text of this module and never holds a value of the request."""
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import time
14
+ from pathlib import Path
15
+
16
+ CONTRACT = "maisecrets.detect/1"
17
+ SCHEMA = Path(__file__).with_name("detect-1.schema.json")
18
+ CHANNELS = ("prompt", "tool")
19
+ # the exit status of each outcome; any other status (1 from the interpreter itself) means no response was written
20
+ EXIT = {"ok": 0, "invalid_json": 2, "unsupported_contract": 2, "invalid_request": 2, "internal_error": 3}
21
+ ERRORS = ("invalid_json", "unsupported_contract", "invalid_request", "internal_error")
22
+ _KEYS = {"contract", "text", "channel", "regions", "languages", "context"}
23
+ _CONTEXT_KEYS = {"path", "fields"}
24
+ _FIELD_KEYS = {"start", "end", "name", "format"}
25
+
26
+
27
+ class RequestError(Exception):
28
+ """A request the engine does not take. `message` is a fixed text of this module, never a part of the request."""
29
+
30
+ def __init__(self, code: str, path: str, message: str):
31
+ super().__init__(code)
32
+ self.code, self.path, self.message = code, path, message
33
+
34
+
35
+ def known_regions() -> frozenset:
36
+ """Every region a rule names: the regional rules, the Presidio recognizers and the regions with a language."""
37
+ from . import core, regions
38
+ presidio = json.loads((core.RULES_DIR / "presidio.json").read_text(encoding="utf-8"))["recognizers"]
39
+ return frozenset({"generic"} | set(regions.REGION_LANGUAGE) | {d["region"] for d in core.REGIONAL_RULES}
40
+ | {core.presidio_region(r["id"]) for r in presidio})
41
+
42
+
43
+ def _no_duplicate_keys(pairs: list) -> dict:
44
+ out = {}
45
+ for k, v in pairs:
46
+ if k in out:
47
+ raise RequestError("invalid_json", "", "a key appears twice in one object")
48
+ out[k] = v
49
+ return out
50
+
51
+
52
+ def _no_constant(_name: str):
53
+ raise RequestError("invalid_json", "", "NaN and Infinity are no JSON")
54
+
55
+
56
+ def _strings(value, path: str, allowed: frozenset, what: str) -> list:
57
+ if not isinstance(value, list):
58
+ raise RequestError("invalid_request", path, "must be a list of strings")
59
+ for i, v in enumerate(value):
60
+ if not isinstance(v, str):
61
+ raise RequestError("invalid_request", f"{path}[{i}]", "must be a string")
62
+ if v not in allowed:
63
+ raise RequestError("invalid_request", f"{path}[{i}]", f"is no known {what}")
64
+ return list(set(value))
65
+
66
+
67
+ def parse(raw: bytes) -> dict:
68
+ """The request of these bytes, checked: {text, channel, regions, languages, path}. Raises RequestError."""
69
+ try:
70
+ doc = json.loads(raw.decode("utf-8-sig"), object_pairs_hook=_no_duplicate_keys, parse_constant=_no_constant)
71
+ except RequestError:
72
+ raise
73
+ except (UnicodeDecodeError, ValueError, RecursionError):
74
+ raise RequestError("invalid_json", "", "the request is not one JSON document in UTF-8") from None
75
+ if not isinstance(doc, dict):
76
+ raise RequestError("invalid_request", "", "the request must be a JSON object")
77
+ if "contract" not in doc:
78
+ raise RequestError("invalid_request", "contract", "is required")
79
+ if doc["contract"] != CONTRACT:
80
+ raise RequestError("unsupported_contract", "contract", "this engine speaks " + CONTRACT)
81
+ if set(doc) - _KEYS:
82
+ # the key is not named: a key of a request is input like its value
83
+ raise RequestError("invalid_request", "", "holds a key the contract does not define")
84
+ for key in ("text", "channel", "regions", "languages"):
85
+ if key not in doc:
86
+ raise RequestError("invalid_request", key, "is required")
87
+ if not isinstance(doc["text"], str):
88
+ raise RequestError("invalid_request", "text", "must be a string")
89
+ if doc["channel"] not in CHANNELS:
90
+ raise RequestError("invalid_request", "channel", "must be prompt or tool")
91
+ from .regions import label_languages_available
92
+ regions = _strings(doc["regions"], "regions", known_regions(), "region")
93
+ languages = _strings(doc["languages"], "languages", frozenset(label_languages_available()), "label language")
94
+ context = doc.get("context", {})
95
+ if not isinstance(context, dict):
96
+ raise RequestError("invalid_request", "context", "must be an object")
97
+ if set(context) - _CONTEXT_KEYS:
98
+ raise RequestError("invalid_request", "context", "holds a key the contract does not define")
99
+ path = context.get("path", "")
100
+ if not isinstance(path, str):
101
+ raise RequestError("invalid_request", "context.path", "must be a string")
102
+ return {"text": doc["text"], "channel": doc["channel"], "path": path,
103
+ "fields": _fields(context["fields"], len(doc["text"])) if "fields" in context else None,
104
+ # generic and English are always on, as in the plugin (regions.resolve): a request may name them. The
105
+ # order of a list says nothing: sorted, the same sets give the same ruleset digest
106
+ "regions": ("generic", *sorted(r for r in regions if r != "generic")),
107
+ "languages": ("en", *sorted(x for x in languages if x != "en"))}
108
+
109
+
110
+ def _integer(v) -> bool:
111
+ return isinstance(v, int) and not isinstance(v, bool)
112
+
113
+
114
+ def _fields(given, length: int) -> tuple:
115
+ """context.fields as Detector.findings takes them: ((start, end, name path), ...). A field is
116
+ {start, end, name, format?}: the span of a value in code points of the text and its field path, outermost first."""
117
+ if not isinstance(given, list):
118
+ raise RequestError("invalid_request", "context.fields", "must be a list")
119
+ out = []
120
+ for i, f in enumerate(given):
121
+ at = f"context.fields[{i}]"
122
+ if not isinstance(f, dict):
123
+ raise RequestError("invalid_request", at, "must be an object")
124
+ if set(f) - _FIELD_KEYS:
125
+ raise RequestError("invalid_request", at, "holds a key the contract does not define")
126
+ for key in ("start", "end", "name"):
127
+ if key not in f:
128
+ raise RequestError("invalid_request", f"{at}.{key}", "is required")
129
+ for key in ("start", "end"):
130
+ if not _integer(f[key]):
131
+ raise RequestError("invalid_request", f"{at}.{key}", "must be an integer")
132
+ if not 0 <= f["start"] < f["end"] <= length:
133
+ raise RequestError("invalid_request", at, "must have 0 <= start < end <= the length of the text")
134
+ name = f["name"]
135
+ if not isinstance(name, list) or not name:
136
+ raise RequestError("invalid_request", f"{at}.name", "must be a non-empty list")
137
+ for j, part in enumerate(name):
138
+ if not (isinstance(part, str) or _integer(part)):
139
+ raise RequestError("invalid_request", f"{at}.name[{j}]", "must be a string or an integer")
140
+ if not isinstance(f.get("format", ""), str):
141
+ raise RequestError("invalid_request", f"{at}.format", "must be a string")
142
+ out.append((f["start"], f["end"], tuple(name)))
143
+ return tuple(out)
144
+
145
+
146
+ def engine_version() -> str:
147
+ """The version of this copy: the plugin's manifest next to the package (a checkout, the plugin), else the
148
+ installed wheel's metadata."""
149
+ manifest = Path(__file__).resolve().parent.parent.parent / ".claude-plugin" / "plugin.json"
150
+ try:
151
+ return str(json.loads(manifest.read_text(encoding="utf-8"))["version"])
152
+ except (OSError, ValueError, KeyError, TypeError):
153
+ pass
154
+ try:
155
+ from importlib import metadata
156
+ return metadata.version("maisecrets")
157
+ except Exception: # noqa: BLE001 - neither a manifest nor metadata: a copy of the folder
158
+ return "unknown"
159
+
160
+
161
+ def ruleset(detector) -> str:
162
+ """sha256 over the rule list the detector loaded, in scan order, with what each rule matches and checks, and over
163
+ the regions, the label languages and the year of its host. It is computed from the loaded rules, not from the
164
+ files: a line end of a checkout (CRLF on Windows) and the split files of the release tree give the same digest.
165
+ A betterleaks filter (Rule.drop, a function) and the code of the passes are named by the engine version."""
166
+ active = detector.regions()
167
+ rows = []
168
+ for r in detector.rules():
169
+ rx = r.regex
170
+ rows.append([r.key, r.type, str(getattr(rx, "pattern", "")), int(getattr(rx, "flags", 0)), list(r.keywords),
171
+ r.entropy, r.secret_group, [[str(p.pattern), int(p.flags), t] for p, t in r.allow_regexes],
172
+ list(r.stopwords), r.validator, r.score, list(r.context), r.require_context, r.whole_match,
173
+ r.quote_group, r.form, r.matcher, sorted(r.filters or ()), r.region, r.phase])
174
+ doc = {"regions": list(active.regions), "languages": list(active.languages), "year": detector.host.this_year(),
175
+ "rules": rows}
176
+ data = json.dumps(doc, ensure_ascii=True, separators=(",", ":"))
177
+ return "sha256:" + hashlib.sha256(data.encode("ascii")).hexdigest()
178
+
179
+
180
+ def detect(request: dict) -> dict:
181
+ """The response to a checked request (parse)."""
182
+ from . import NO_REFERENCES, NO_SOURCES, Active, Detector, Host
183
+ # the year is read once, so the scan and the ruleset digest name the same one; no user name: the request says
184
+ # nothing of a host, and a home path then is a finding whoever runs the engine
185
+ d = Detector(regions=Active(regions=request["regions"], languages=request["languages"], source=CONTRACT),
186
+ sources=NO_SOURCES, references=NO_REFERENCES, host=Host(user="", year=time.gmtime().tm_year))
187
+ found = d.findings(request["text"], channel=request["channel"], path=request["path"], fields=request.get("fields"))
188
+ rows = sorted({(m.start, m.end, m.type, m.kind) for m in found})
189
+ return {"contract": CONTRACT,
190
+ "engine": {"name": "maisecrets", "version": engine_version(), "ruleset": ruleset(d)},
191
+ "findings": [{"start": s, "end": e, "type": t,
192
+ "provenance": {"rule": k, "origin": "builtin", "layer": "deterministic"}}
193
+ for s, e, t, k in rows]}
194
+
195
+
196
+ _ORIGINS = (("user:", "user"), ("term:", "term"), ("import:", "import"), ("remote:", "model"))
197
+
198
+
199
+ def finding(m) -> dict:
200
+ """One finding of a response: offsets, type and provenance; `score` only for a model finding."""
201
+ origin = next((o for prefix, o in _ORIGINS if m.kind.startswith(prefix)), "builtin")
202
+ row = {"start": m.start, "end": m.end, "type": m.type,
203
+ "provenance": {"rule": m.kind, "origin": origin, "layer": "model" if origin == "model" else "deterministic"}}
204
+ if m.score is not None:
205
+ row["score"] = m.score
206
+ return row
207
+
208
+
209
+ def result_response(detector, result) -> dict:
210
+ """The maisecrets.detect/1 response for a Result of Detector.findings_with, for a host that embeds the library
211
+ and sends its findings on. A finding of the remote detector carries its `score`; `engine.remote` names the wire
212
+ format, the model the operator stated (the service's answer names none) and what the service did in this call.
213
+ The entry point (detect) never asks a remote detector: it reads no network."""
214
+ engine = {"name": "maisecrets", "version": engine_version(), "ruleset": ruleset(detector)}
215
+ st = result.remote
216
+ if st.state != "off":
217
+ engine["remote"] = {"format": st.format, "model": st.model, "state": st.state}
218
+ if st.reason:
219
+ engine["remote"]["reason"] = st.reason
220
+ rows = sorted(result.findings, key=lambda m: (m.start, m.end, m.type, m.kind))
221
+ return {"contract": CONTRACT, "engine": engine, "findings": [finding(m) for m in rows]}
222
+
223
+
224
+ def error(code: str, path: str, message: str) -> dict:
225
+ return {"contract": CONTRACT, "error": {"code": code, "path": path, "message": message}}
226
+
227
+
228
+ def respond(raw: bytes, argv: list) -> tuple:
229
+ """(exit status, response line) for the bytes of a request. Never raises, and never puts a part of the request
230
+ into an error."""
231
+ import warnings
232
+ try:
233
+ with warnings.catch_warnings():
234
+ warnings.simplefilter("ignore") # nothing on stderr
235
+ if argv:
236
+ raise RequestError("invalid_request", "", "the entry point takes no arguments: the request comes on "
237
+ "stdin")
238
+ out, status = detect(parse(raw)), EXIT["ok"]
239
+ except RequestError as e:
240
+ out, status = error(e.code, e.path, e.message), EXIT[e.code]
241
+ except Exception as e: # noqa: BLE001 - the class name only: an exception's text can hold a value of the text
242
+ out, status = error("internal_error", "", "the engine failed: " + type(e).__name__), EXIT["internal_error"]
243
+ return status, json.dumps(out, ensure_ascii=True, separators=(",", ":")) + "\n"
244
+
245
+
246
+ def main(argv: list | None = None) -> int:
247
+ import sys
248
+ raw = sys.stdin.buffer.read()
249
+ status, line = respond(raw, sys.argv[1:] if argv is None else argv)
250
+ sys.stdout.write(line)
251
+ sys.stdout.flush()
252
+ return status