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.
- maisecrets/__init__.py +1 -0
- maisecrets/detection/__init__.py +20 -0
- maisecrets/detection/__main__.py +6 -0
- maisecrets/detection/checksums.py +343 -0
- maisecrets/detection/contract.py +252 -0
- maisecrets/detection/core.py +3436 -0
- maisecrets/detection/deadline.py +47 -0
- maisecrets/detection/detect-1.schema.json +110 -0
- maisecrets/detection/encoded_mail.py +99 -0
- maisecrets/detection/fields.py +271 -0
- maisecrets/detection/keyword_tail.py +147 -0
- maisecrets/detection/lazy_regex.py +27 -0
- maisecrets/detection/leaves.py +493 -0
- maisecrets/detection/normalise.py +333 -0
- maisecrets/detection/own_rules.py +388 -0
- maisecrets/detection/personal.py +2908 -0
- maisecrets/detection/phone_forms.py +40 -0
- maisecrets/detection/prefilter.py +300 -0
- maisecrets/detection/regions.py +166 -0
- maisecrets/detection/registry.py +200 -0
- maisecrets/detection/remote.py +573 -0
- maisecrets/detection/rule_filter.py +301 -0
- maisecrets/detection/rules/BETTERLEAKS_VERSION +1 -0
- maisecrets/detection/rules/DETECT_SECRETS_VERSION +1 -0
- maisecrets/detection/rules/LICENSE-betterleaks +21 -0
- maisecrets/detection/rules/LICENSE-bip39 +26 -0
- maisecrets/detection/rules/LICENSE-detect-secrets +201 -0
- maisecrets/detection/rules/LICENSE-gitleaks +21 -0
- maisecrets/detection/rules/LICENSE-presidio +21 -0
- maisecrets/detection/rules/LICENSE-scowl +24 -0
- maisecrets/detection/rules/PRESIDIO_VERSION +1 -0
- maisecrets/detection/rules/SCOWL_VERSION +1 -0
- maisecrets/detection/rules/betterleaks.json +5243 -0
- maisecrets/detection/rules/betterleaks.toml +11000 -0
- maisecrets/detection/rules/bip39_english.txt +2048 -0
- maisecrets/detection/rules/detect_secrets.json +81 -0
- maisecrets/detection/rules/labels/cs.txt +11 -0
- maisecrets/detection/rules/labels/da.txt +12 -0
- maisecrets/detection/rules/labels/de.txt +7 -0
- maisecrets/detection/rules/labels/en.txt +10 -0
- maisecrets/detection/rules/labels/es.txt +8 -0
- maisecrets/detection/rules/labels/fi.txt +12 -0
- maisecrets/detection/rules/labels/fr.txt +25 -0
- maisecrets/detection/rules/labels/it.txt +9 -0
- maisecrets/detection/rules/labels/ja.txt +15 -0
- maisecrets/detection/rules/labels/ko.txt +13 -0
- maisecrets/detection/rules/labels/nb.txt +11 -0
- maisecrets/detection/rules/labels/nl.txt +9 -0
- maisecrets/detection/rules/labels/pl.txt +9 -0
- maisecrets/detection/rules/labels/pt.txt +12 -0
- maisecrets/detection/rules/labels/ru.txt +14 -0
- maisecrets/detection/rules/labels/sv.txt +10 -0
- maisecrets/detection/rules/labels/th.txt +9 -0
- maisecrets/detection/rules/labels/tr.txt +12 -0
- maisecrets/detection/rules/labels/zh.txt +14 -0
- maisecrets/detection/rules/leading_literals.json +2658 -0
- maisecrets/detection/rules/own_secret_rules.json +1938 -0
- maisecrets/detection/rules/patched_upstream.txt +9 -0
- maisecrets/detection/rules/prefixes.txt +400 -0
- maisecrets/detection/rules/presidio.json +2476 -0
- maisecrets/detection/rules/public_test_values.txt +89 -0
- maisecrets/detection/rules/words_en.txt +20991 -0
- maisecrets/detection/rulespec.py +189 -0
- maisecrets/detection/text_index.py +166 -0
- maisecrets/detection/types.json +94 -0
- maisecrets/detection/value_checks.py +1215 -0
- maisecrets/detection/vocabulary.py +21 -0
- maisecrets/py.typed +0 -0
- maisecrets-0.8.28.dist-info/METADATA +452 -0
- maisecrets-0.8.28.dist-info/RECORD +79 -0
- maisecrets-0.8.28.dist-info/WHEEL +4 -0
- maisecrets-0.8.28.dist-info/licenses/LICENSE +202 -0
- maisecrets-0.8.28.dist-info/licenses/NOTICE +67 -0
- maisecrets-0.8.28.dist-info/licenses/maisecrets/detection/rules/LICENSE-betterleaks +21 -0
- maisecrets-0.8.28.dist-info/licenses/maisecrets/detection/rules/LICENSE-bip39 +26 -0
- maisecrets-0.8.28.dist-info/licenses/maisecrets/detection/rules/LICENSE-detect-secrets +201 -0
- maisecrets-0.8.28.dist-info/licenses/maisecrets/detection/rules/LICENSE-gitleaks +21 -0
- maisecrets-0.8.28.dist-info/licenses/maisecrets/detection/rules/LICENSE-presidio +21 -0
- 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,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
|