fef2 commited on
Commit
0936c1f
·
verified ·
1 Parent(s): 4a83520

Upload folder using huggingface_hub

Browse files
README.md CHANGED
@@ -1,3 +1,262 @@
1
  ---
2
- license: mit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ language:
3
+ - ru
4
+ - en
5
+ license: apache-2.0
6
+ base_model: DeepPavlov/rubert-base-cased
7
+ library_name: transformers
8
+ pipeline_tag: token-classification
9
+ tags:
10
+ - ner
11
+ - token-classification
12
+ - pii
13
+ - secret-detection
14
+ - credentials
15
+ - masking
16
+ - russian
17
+ - bert
18
  ---
19
+
20
+ # ner_rus_bert-secret_detection
21
+
22
+ Дообученная голова `BertForTokenClassification` поверх `DeepPavlov/rubert-base-cased`,
23
+ которая размечает в русскоязычном тексте **учётные данные** (логины, пароли,
24
+ токены, ключи), **персональные данные** (ФИО, организации, локации) и **номера
25
+ договоров**. Основной сценарий, под который она обучалась - маскирование
26
+ пользовательских сообщений и фрагментов конфигов перед отправкой во внешнюю LLM.
27
+
28
+ > **Модель — половина системы.** В проде она работает **в объединении с
29
+ > детерминированным regex-слоем**, и все опубликованные ниже цифры покрытия
30
+ > учётных данных относятся к объединению `NER ∪ regex`. Одна NER заметно слабее
31
+ > по recall — так и задумано: её задача добирать то, что не ловится регулярками
32
+ > (естественная формулировка, кириллица, контекст), не переусердствуя с ложными
33
+ > срабатываниями. Обе половины лежат в этом репозитории: `core/scrubber.py` +
34
+ > `gitleaks.toml`
35
+ > См. [Детерминированный слой](#детерминированный-слой).
36
+
37
+ ## Метки
38
+
39
+ 17 BIO-тегов, 8 типов сущностей:
40
+
41
+ | Группа | Метки |
42
+ |---|---|
43
+ | Учётные данные | `LOGIN`, `PASSWORD`, `AUTH_TOKEN`, `SECRET_KEY` |
44
+ | ПДн | `PERSON`, `LOCATION`, `ORGANIZATION` |
45
+ | Прочее | `CONTRACT_NUMBER` |
46
+
47
+ Схема — `B-`/`I-`/`O`, полный список в `config.json` (`id2label`).
48
+
49
+ ## Быстрый старт
50
+
51
+ Репозиторий приватный: сначала `hf auth login` (или `HF_TOKEN=…` в окружении),
52
+ иначе загрузка по id вернёт 401.
53
+
54
+ ```python
55
+ from transformers import pipeline
56
+
57
+ ner = pipeline(
58
+ "token-classification",
59
+ model="fef2/ner_rus_bert-secret_detection",
60
+ aggregation_strategy="simple",
61
+ )
62
+
63
+ text = 'user = svc_billing, password = "Xt7#pQm2Zr", api_key: sk-live-4f9a2b7c1e6d8f3a0b5c9d2e'
64
+ for e in ner(text):
65
+ print(f"{e['entity_group']:<12} {e['score']:.3f} {text[e['start']:e['end']]!r}")
66
+ ```
67
+
68
+ ```
69
+ LOGIN 0.936 'svc_billing'
70
+ PASSWORD 0.993 'Xt7#pQm2Zr'
71
+ SECRET_KEY 0.996 'sk-live-4f9a2b7c1e6d8f3a0b5c9d2e'
72
+ ```
73
+
74
+ Обратите внимание: режьте исходную строку по `start`/`end`, а **не** берите поле
75
+ `word` — pipeline отдаёт его склеенным из wordpiece, с лишними пробелами
76
+ (`'svc _ billing'`).
77
+
78
+ **У `pipeline()` три ограничения, из-за которых он годится только для проб.**
79
+ Он не режет длинный вход на окна — текст длиннее 512 wordpiece падает с
80
+ `RuntimeError: The size of tensor a (1218) must match the size of tensor b (512)`;
81
+ не нормализует юникод; и это **только NER**, без детерминированного слоя. Для
82
+ настоящей работы возьмите `predict.py` из этого репозитория.
83
+
84
+ ## Правильный путь: `predict.py`
85
+
86
+ `predict.py` — перенос инференс-логики развёрнутого сервиса целиком:
87
+ NFC-нормализация, нарезка на окна по 384 реальных wordpiece по границам
88
+ предложений, BIO-декод, детерминированный слой, смещения в символах, склейка
89
+ пересекающихся спанов. Нужны только `torch` и `transformers` — regex-половина
90
+ на чистом stdlib.
91
+
92
+ ```bash
93
+ python predict.py --text 'password = "Xt7#pQm2Zr"' # замаскированный текст
94
+ python predict.py --file документ.txt --spans # спаны построчно
95
+ cat лог.txt | python predict.py --json # спаны в JSON
96
+ cat лог.txt | python predict.py --json --no-rules # только NER, без regex
97
+ ```
98
+
99
+ ```python
100
+ from predict import Detector
101
+
102
+ det = Detector("fef2/ner_rus_bert-secret_detection", device="cpu") # cuda / mps
103
+
104
+ text = ('Коллеги, доступ к стенду: user = svc_billing, password = "Xt7#pQm2Zr", '
105
+ 'api_key: sk-live-4f9a2b7c1e6d8f3a0b5c9d2e. Ответственный — Петров Сергей '
106
+ 'Иванович (ПАО «Сбербанк», Санкт-Петербург), договор № 77-АБ/2025-4412.')
107
+
108
+ for s in det.spans(text):
109
+ print(f"{s.start:>4} {s.end:>4} {s.label:<16} {s.source:<5} {s.text!r}")
110
+
111
+ print(det.mask(text))
112
+ ```
113
+
114
+ ```
115
+ 33 44 LOGIN ner 'svc_billing'
116
+ 58 68 PASSWORD both 'Xt7#pQm2Zr'
117
+ 80 112 SECRET_KEY both 'sk-live-4f9a2b7c1e6d8f3a0b5c9d2e'
118
+ 130 152 PERSON ner 'Петров Сергей Иванович'
119
+ 154 167 ORGANIZATION ner 'ПАО «Сбербанк'
120
+ 170 185 LOCATION ner 'Санкт-Петербург'
121
+ 198 213 CONTRACT_NUMBER ner '77-АБ/2025-4412'
122
+ ```
123
+
124
+ `source` говорит, кто нашёл спан: `ner`, `regex` или `both` (нашли оба —
125
+ самый спокойный случай). У чисто regex-спанов `score` равен `None`: у
126
+ детерминированного правила нет вероятности, и подставлять туда единицу было бы
127
+ враньём.
128
+
129
+ Документ на 6800 символов режется на 4 окна и размечается целиком — граница окна
130
+ не теряет спаны, потому что нарезка идёт по концам предложений и абзацев.
131
+
132
+ ### Четыре контракта, которые легко нарушить
133
+
134
+ **Окно — 384 *реальных* wordpiece.** Модель обучалась на окнах такого размера,
135
+ и `plan_windows` закладывает 384 токена **без** учёта `[CLS]`/`[SEP]`, то есть
136
+ `max_length=386` при токенизации. Если считать по-хагингфейсовски — 384 вместе со
137
+ служебными, — в окно попадёт 382 реальных токена, и предсказания на длинных
138
+ текстах разойдутся: два лишних wordpiece меняют каждый контекстный эмбеддинг
139
+ последовательности. На эталонном сплите в этот зазор попадают 280 строк из 6312,
140
+ и 5 из них предсказываются иначе.
141
+
142
+ **Смещения — по NFC-нормализованному тексту.** `Detector.spans()` сам приводит
143
+ вход к NFC, и `start`/`end` — индексы кодовых точек **нормализованной** строки.
144
+ Для латиницы и обычной кириллицы это то же самое, но текст с составными символами
145
+ (диакритика, вставки из PDF) после нормализации меняет длину, и спаны «поедут».
146
+ Режьте по спанам нормализованную строку:
147
+
148
+ ```python
149
+ import unicodedata
150
+ normalized = unicodedata.normalize("NFC", text) # ровно то, что видела модель
151
+ fragment = normalized[span.start:span.end]
152
+ ```
153
+
154
+ **Батчи, а не по одному.** Пропускная способность здесь берётся из батчей;
155
+ `Detector(batch_size=...)` задаёт размер микро-батча окон. На CPU замеренная
156
+ скорость NER-половины — около **9.5k токенов/с**; на GPU в FP16 (`fp16=True`,
157
+ включается автоматически при `device="cuda"`) она перестаёт быть узким местом
158
+ задолго до regex-слоя.
159
+
160
+ **Модель не решает, что делать со спаном.** `mask()` подставляет
161
+ `[REDACTED:LABEL]` — это пример, а не политика. Обратимое маскирование
162
+ (плейсхолдеры с обратной подстановкой), пороги по `score`, отдельная политика на
163
+ `PERSON` против `SECRET_KEY` — всё это ваш слой поверх спанов.
164
+
165
+ ## Детерминированный слой
166
+
167
+ Рядом с моделью по тому же тексту работает второй, полностью детерминированный
168
+ детектор — `core.scrubber.credential_sites`. Итоговый набор спанов есть
169
+ **объединение** двух источников с последующей склейкой. Он даёт постоянный, не
170
+ зависящий от чекпоинта пол по типовым форматам, а NER добирает то, что регуляркой
171
+ не описывается. Внутри — три детектора:
172
+
173
+ | Детектор | Что ищет | Метки |
174
+ |---|---|---|
175
+ | `kv` | `ключ = значение` с онтологией ключей (`password`, `пароль`, `api_key`, `токен`, `login`…) | `PASSWORD` `SECRET_KEY` `AUTH_TOKEN` `LOGIN` |
176
+ | `cli` | значения у известных флагов известных команд (`--password=…`, `curl -u user:pass`) | `PASSWORD` `AUTH_TOKEN` |
177
+ | `opaque` | непрозрачные высокоэнтропийные значения + правила gitleaks | `AUTH_TOKEN` `SECRET_KEY` `SECRET` |
178
+
179
+ Правила gitleaks отбираются по идентификатору (`…token…` → `AUTH_TOKEN`,
180
+ `…key…`/`…secret…` → `SECRET_KEY`), из 222 правил конфига до рантайма доходят
181
+ **168** — остальные отсеиваются фильтром по имени либо не компилируются под
182
+ `re` без предупреждений. `load_gitleaks_rules` возвращает вторым значением
183
+ именно счётчик отброшенных.
184
+
185
+ `cli` бьёт по таблице «команда → флаг» и отдаёт только само значение пароля или
186
+ токена; логин из `-u user:pass` в объединение не попадает, а голая строка
187
+ подключения (`postgres://user:pass@host`) не покрывается вовсе — это осознанная
188
+ граница релиза, см. [Ограничения](#ограничения).
189
+
190
+ Слой умеет отличать живое значение от инертного: `${DB_PASSWORD}`,
191
+ `os.environ["TOKEN"]`, `<your-key-here>`, `changeme` и уже проставленные маркеры
192
+ `[REDACTED:…]` он не трогает (`core._shim.is_inert_value`). Спан с меткой
193
+ `SECRET` — это опознанное непрозрачное значение, тип которого установить не
194
+ удалось; в модельный набор из 17 меток он не входит и приходит только отсюда.
195
+
196
+ Замеренная цена этой половины на тестовом сплите: 2891 срабатывание, из них 452
197
+ не задевают ни одного gold-спана; вычёркивается 3.03 % всех символов, из которых
198
+ 0.37 % корпуса лежит вне gold-спанов. Слой чисто питоновский и держит GIL —
199
+ около 0.7 мс на текст (~460k токенов/с на ядро), так что в проде именно он, а не
200
+ GPU, упирается в потолок пропускной способности.
201
+
202
+ Отключается через `Detector(rules=None)` или `--no-rules` — но тогда цифры ниже
203
+ к вам не относятся.
204
+
205
+
206
+ ## Обучение
207
+
208
+ | | |
209
+ |---|---|
210
+ | Базовая модель | `DeepPavlov/rubert-base-cased` (vocab 119547, 12 слоёв, 768) |
211
+ | Шаги / batch / lr | 8000 · 32 · 3e-5, warmup 800, weight decay 0.1, grad clip 1.0, AMP |
212
+ | Окно | 384 wordpiece |
213
+ | Выбор чекпоинта | по `credential_any_f1` на валидации (максимизируется вклад NER в детекцию учётных данных: пол от регулярок постоянен) |
214
+ | Данные | синтетические семейства учётных данных в русскоязычном контексте (kv-pair в сообщениях, конфигах и коде) + replay-доля 0.35 на общий русский NER, чтобы не потерять ПДн |
215
+
216
+ ## Ограничения
217
+
218
+ * **CLI и DSN — вне зоны ответственности.** Строки подключения
219
+ (`postgres://user:pass@host`) и пароли в аргументах команд сознательно не входят
220
+ в целевую поверхность релиза и на ней не измерялись. Модель на них ведёт себя
221
+ неровно: `psql -U reporter -W 'Qw!7823ml' -h db.corp.ru` → спан `PASSWORD`
222
+ захватывает открывающую кавычку, а хвост имени хоста уезжает в `LOGIN`.
223
+ Детерминированный слой подстраховывает только известные флаги известных команд.
224
+ Целевая поверхность — **kv-pair**: `ключ = значение` в сообщениях, конфигах и
225
+ коде.
226
+ * **Границы спанов не всегда аккуратны.** Модель может отрезать закрывающую
227
+ кавычку (`'ПАО «Сбербанк'`) или разделить фразу на два спана. Для маскирования
228
+ это безопасно (спан всё равно вычёркивается), для извлечения сущностей —
229
+ учитывайте.
230
+ * **Не детектор «всех секретов».** Формат, не похожий ни на одно из обученных
231
+ семейств и не покрытый регуляркой, бу��ет пропущен. Recall 0.967 — это про
232
+ объединение на распределении сплита, а не гарантия на вашем корпусе.
233
+ Измерьте на своих данных.
234
+ * **Один seed.** Веса выпущены под пилот, из одного прогона; трёхсидовая
235
+ сертификация релиза не проводилась.
236
+ * **Происхождение данных.** Общая NER-часть (`PERSON`/`ORGANIZATION`/`LOCATION`)
237
+ опирается на публичные русские NER-корпуса; учётные данные — синтетика.
238
+ Настоящих секретов в обучающем корпусе нет.
239
+
240
+ ## Файлы
241
+
242
+ ```
243
+ config.json · model.safetensors веса (BertForTokenClassification, fp32, 709 МБ)
244
+ tokenizer.json · vocab.txt · … токенизатор (fast, BertTokenizer, cased)
245
+ predict.py инференс: окна · NFC · BIO-декод · объединение
246
+ gitleaks.toml правила детерминированного слоя
247
+ core/scrubber.py детерминированный слой: kv · cli · opaque
248
+ core/_shim.py инертные значения, NFC, вспомогательное
249
+ core/slot_ontology.yaml онтология ключей для kv-детектора
250
+ ```
251
+
252
+ `core/` скопирован без изменений из `masking-service/core` бандла
253
+ `r3-2026-07-22` — того же кода, что крутится в проде; `predict.py` вызывает
254
+ `load_gitleaks_rules` и `credential_sites` из него, а не переписывает их. У
255
+ `scrubber.py` есть ещё и CLI датасетного пайплайна (`python -m core.scrubber`) - к инференсу он отношения не имеет, его можно игнорировать.
256
+
257
+ ## Лицензия
258
+
259
+ Веса выпущены под Apache 2.0 — вслед за базовой `DeepPavlov/rubert-base-cased`.
260
+ `gitleaks.toml` — производная от конфига правил
261
+ [gitleaks](https://github.com/gitleaks/gitleaks) (MIT), из которого оставлены
262
+ правила категорий token/key/secret.
config.json ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "architectures": [
3
+ "BertForTokenClassification"
4
+ ],
5
+ "attention_probs_dropout_prob": 0.1,
6
+ "classifier_dropout": null,
7
+ "dtype": "float32",
8
+ "hidden_act": "gelu",
9
+ "hidden_dropout_prob": 0.1,
10
+ "hidden_size": 768,
11
+ "id2label": {
12
+ "0": "O",
13
+ "1": "B-PERSON",
14
+ "2": "I-PERSON",
15
+ "3": "B-LOCATION",
16
+ "4": "I-LOCATION",
17
+ "5": "B-ORGANIZATION",
18
+ "6": "I-ORGANIZATION",
19
+ "7": "B-LOGIN",
20
+ "8": "I-LOGIN",
21
+ "9": "B-PASSWORD",
22
+ "10": "I-PASSWORD",
23
+ "11": "B-AUTH_TOKEN",
24
+ "12": "I-AUTH_TOKEN",
25
+ "13": "B-SECRET_KEY",
26
+ "14": "I-SECRET_KEY",
27
+ "15": "B-CONTRACT_NUMBER",
28
+ "16": "I-CONTRACT_NUMBER"
29
+ },
30
+ "initializer_range": 0.02,
31
+ "intermediate_size": 3072,
32
+ "label2id": {
33
+ "B-AUTH_TOKEN": 11,
34
+ "B-CONTRACT_NUMBER": 15,
35
+ "B-LOCATION": 3,
36
+ "B-LOGIN": 7,
37
+ "B-ORGANIZATION": 5,
38
+ "B-PASSWORD": 9,
39
+ "B-PERSON": 1,
40
+ "B-SECRET_KEY": 13,
41
+ "I-AUTH_TOKEN": 12,
42
+ "I-CONTRACT_NUMBER": 16,
43
+ "I-LOCATION": 4,
44
+ "I-LOGIN": 8,
45
+ "I-ORGANIZATION": 6,
46
+ "I-PASSWORD": 10,
47
+ "I-PERSON": 2,
48
+ "I-SECRET_KEY": 14,
49
+ "O": 0
50
+ },
51
+ "layer_norm_eps": 1e-12,
52
+ "max_position_embeddings": 512,
53
+ "model_type": "bert",
54
+ "num_attention_heads": 12,
55
+ "num_hidden_layers": 12,
56
+ "pad_token_id": 0,
57
+ "position_embedding_type": "absolute",
58
+ "transformers_version": "4.57.6",
59
+ "type_vocab_size": 2,
60
+ "use_cache": true,
61
+ "vocab_size": 119547
62
+ }
core/__init__.py ADDED
File without changes
core/_shim.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import hashlib
5
+ import json
6
+ import random
7
+ import re
8
+ import unicodedata
9
+ from pathlib import Path
10
+ from typing import Any, Iterable, Iterator
11
+
12
+
13
+ SLOT_RE = re.compile(r"⟦SLOT:([A-Z_]+):(\d+)⟧")
14
+ PLACEHOLDER_RE = re.compile(
15
+ r"^(?:\$\{[^}]+\}|<[^>]+>|YOUR[_-]?[A-Z_]+|changeme|example|placeholder|replace(?:-me)?|)$",
16
+ re.I,
17
+ )
18
+ REFERENCE_RE = re.compile(
19
+ r"^(?:[A-Za-z_]\w*|\$\{[^}]+\}|os\.environ\[[^]]+\]|[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+)$"
20
+ )
21
+ LIVE_PREFIX_RE = re.compile(r"^(?:gh[opsru]_|xox[baprs]-|sk-|AKIA|AIza|glpat-|eyJ)", re.I)
22
+ INTERPOLATION_RE = re.compile(r"^(?:[#$%]?\{[\w.\[\]'\"|-]{1,64}\}|%\([\w.-]{1,64}\)[a-z])$")
23
+ CALL_RE = re.compile(r"^[A-Za-z_$][\w$.]*\([^()]{0,80}\)?$")
24
+ INDEX_RE = re.compile(r"^[A-Za-z_$][\w$.]*\[[\w\"'.-]{0,64}\]?$")
25
+ MARKER_VALUE_RE = re.compile(r"^\[(?:PII_)?REDACTED(?::[A-Z_]+)?\]$")
26
+
27
+
28
+ def is_inert_value(value: str, quoted: bool = False) -> bool:
29
+ if not value or not any(character.isalnum() for character in value):
30
+ return True
31
+ if PLACEHOLDER_RE.match(value) or MARKER_VALUE_RE.match(value) or SLOT_RE.search(value) or INTERPOLATION_RE.match(value):
32
+ return True
33
+ if LIVE_PREFIX_RE.match(value):
34
+ return False
35
+ if quoted:
36
+ return False
37
+ return bool(REFERENCE_RE.match(value) or CALL_RE.match(value) or INDEX_RE.match(value) or value.startswith(("//", "http://", "https://", "{", ":")))
38
+
39
+
40
+ def nfc(value: str) -> str:
41
+ return unicodedata.normalize("NFC", value)
42
+
43
+
44
+ def stable_id(*parts: object, length: int = 20) -> str:
45
+ return hashlib.sha256("\0".join(map(str, parts)).encode()).hexdigest()[:length]
46
+
47
+
48
+ def seeded_rng(seed: int, *namespace: object) -> random.Random:
49
+ return random.Random(int(stable_id(seed, *namespace), 16))
50
+
51
+
52
+ def read_jsonl(path: Path) -> Iterator[dict[str, Any]]:
53
+ if not path.exists():
54
+ return
55
+ with path.open(encoding="utf-8") as stream:
56
+ for line_number, line in enumerate(stream, 1):
57
+ if line.strip():
58
+ try:
59
+ yield json.loads(line)
60
+ except json.JSONDecodeError as exc:
61
+ raise ValueError(f"invalid JSONL in {path}:{line_number}") from exc
62
+
63
+
64
+ def write_jsonl(path: Path, rows: Iterable[dict[str, Any]]) -> int:
65
+ path.parent.mkdir(parents=True, exist_ok=True)
66
+ count = 0
67
+ with path.open("w", encoding="utf-8", newline="\n") as stream:
68
+ for row in rows:
69
+ stream.write(json.dumps(row, ensure_ascii=False, sort_keys=True, separators=(",", ":")))
70
+ stream.write("\n")
71
+ count += 1
72
+ return count
73
+
74
+
75
+ def write_report(staging: Path, name: str, **counts: object) -> None:
76
+ report = staging / "reports" / f"{name}.json"
77
+ report.parent.mkdir(parents=True, exist_ok=True)
78
+ report.write_text(json.dumps(counts, ensure_ascii=False, sort_keys=True, indent=2) + "\n", encoding="utf-8")
79
+
80
+
81
+ def add_common_args(parser: argparse.ArgumentParser) -> None:
82
+ parser.add_argument("--seed", type=int, default=42)
83
+ parser.add_argument("--staging", type=Path, default=Path("staging"))
core/scrubber.py ADDED
@@ -0,0 +1,751 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import math
6
+ import multiprocessing
7
+ import os
8
+ import re
9
+ import sys
10
+ import warnings
11
+ from collections import Counter
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Literal
15
+
16
+ from ._shim import LIVE_PREFIX_RE, PLACEHOLDER_RE, REFERENCE_RE, SLOT_RE, add_common_args, is_inert_value, nfc, read_jsonl, seeded_rng, stable_id, write_jsonl, write_report
17
+
18
+ MARKER_RE = re.compile(r"\[REDACTED:([A-Z_]+)\]")
19
+ KV_RE = re.compile(r"(?im)(?P<key>[A-Za-zА-Яа-яЁё][\w.\-/ ]{0,64}?)[\"']?\s*(?P<sep>[:=])\s*(?:(?P<quote>[\"'])(?P<quoted_value>[^\n\"']*)(?P=quote)|[«“](?P<smart_value>[^\n»”]*)[»”]|(?P<bare_value>[^\s,;&\]\}\n]+))")
20
+ EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b")
21
+ PHONE_RE = re.compile(r"(?<!\w)(?:\+7|8)[\s(\-]*\d{3}[\s)\-]*\d{3}[\s\-]*\d{2}[\s\-]*\d{2}(?!\w)")
22
+ INN_RE = re.compile(r"(?iu)\b(?:инн|inn)\D{0,12}(\d{10}(?:\d{2})?)")
23
+ SNILS_RE = re.compile(r"(?<!\d)\d{3}-\d{3}-\d{3}\s?\d{2}(?!\d)")
24
+ CARD_RE = re.compile(r"(?<!\d)(?:\d[ -]?){15}\d(?!\d)")
25
+ PASSPORT_RE = re.compile(r"(?iu)\b(?:паспорт|passport)\D{0,12}(\d{4}\s?\d{6})")
26
+ CONTRACT_RE = re.compile(r"(?iu)\b(?:договор|контракт|соглашени[ея])\s*(?:№|N[oо.]?)?\s*([A-ZА-Я0-9][A-ZА-Я0-9\-/]{7,})")
27
+ OPAQUE_RE = re.compile(r"(?<![A-Za-z0-9+/=_-])[A-Za-z0-9+/=_-]{16,}(?![A-Za-z0-9+/=_-])")
28
+ GitleaksRule = "tuple[re.Pattern[str], str | None, tuple[str, ...], int, float]"
29
+
30
+ _ONTOLOGY_PATH = Path(__file__).with_name("slot_ontology.yaml")
31
+ _ONTOLOGY = json.loads(_ONTOLOGY_PATH.read_text(encoding="utf-8"))
32
+ MARKER_LABELS = {"PASSWORD": "PASSWORD", "TOKEN": "AUTH_TOKEN", "AUTH_TOKEN": "AUTH_TOKEN", "API_KEY": "SECRET_KEY", "SECRET": "SECRET_KEY", "KEY": "SECRET_KEY", "LOGIN": "LOGIN", "USERNAME": "LOGIN", "USER": "LOGIN", "CONTRACT": "CONTRACT_NUMBER"}
33
+ _PROJECT_PII_RECOGNIZERS: list[object] | None = None
34
+ SCRUBBER_POLICY_VERSION = "cli-credentials-v2"
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class CredentialSite:
39
+ start: int
40
+ end: int
41
+ label: str
42
+ detector: str
43
+ disposition: str
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class CliCredentialFlag:
48
+ commands: frozenset[str] | None
49
+ mode: Literal["userinfo", "password", "token"]
50
+ attached: bool = False
51
+
52
+
53
+ def _entropy(value: str) -> float:
54
+ frequencies = Counter(value)
55
+ return -sum((count / len(value)) * math.log2(count / len(value)) for count in frequencies.values()) if value else 0.0
56
+
57
+
58
+ def _key_parts(key: str) -> tuple[str, list[str]]:
59
+ camel = re.sub(r"(?<=[a-zа-яё])(?=[A-ZА-ЯЁ])", "_", key)
60
+ camel = re.sub(r"(?<=[A-ZА-ЯЁ])(?=[A-ZА-ЯЁ][a-zа-яё])", "_", camel)
61
+ normalized = re.sub(r"[\s.\-/]+", "_", camel.lower()).strip("_")
62
+ return normalized, [part for part in normalized.split("_") if part]
63
+
64
+
65
+ _FILE_EXT_RE = re.compile(r"\.(?:md|mdx|go|js|mjs|cjs|ts|tsx|jsx|py|sh|rb|rs|java|kt|cs|cpp|cc|c|h|hpp|yml|yaml|json|toml|txt|cfg|conf|ini|env|html|css|scss|sql|log|xml|lock)(?:$|[-:]\d)", re.I)
66
+
67
+
68
+ def classify_key(key: str) -> str | None:
69
+ if "/" in key or _FILE_EXT_RE.search(key.strip()):
70
+ return None
71
+ normalized, parts = _key_parts(key)
72
+ if normalized in set(_ONTOLOGY["stop"]) or any(part in set(_ONTOLOGY["stop"]) for part in parts):
73
+ return None
74
+ for label, names in _ONTOLOGY["exact"].items():
75
+ if normalized in names:
76
+ return label
77
+ for label, segments in _ONTOLOGY["strong"].items():
78
+ if any(part in segments for part in parts):
79
+ return label
80
+ for label, weak in _ONTOLOGY["weak"].items():
81
+ for segment, qualifiers in weak.items():
82
+ if segment not in parts:
83
+ continue
84
+ if not qualifiers or any(part in qualifiers for part in parts):
85
+ return label
86
+ return None
87
+
88
+
89
+ def _is_live_opaque(value: str) -> bool:
90
+ compact = value.strip("\"'«»“”")
91
+ return len(compact) >= 16 and any(character.isdigit() for character in compact) and re.fullmatch(r"[A-Za-z0-9+/=_-]+", compact) is not None and _entropy(compact) >= 3.5
92
+
93
+
94
+ def _format_preserving(value: str, *, seed: int, record_key: str, offset: int) -> str:
95
+ rng = seeded_rng(seed, record_key, offset)
96
+ if value.isdigit():
97
+ return "".join(rng.choice("0123456789") for _ in value)
98
+ if re.fullmatch(r"[0-9A-Fa-f]+", value):
99
+ alphabet = "0123456789ABCDEF" if value.upper() == value else "0123456789abcdef"
100
+ return "".join(rng.choice(alphabet) for _ in value)
101
+ replacement = []
102
+ for character in value:
103
+ if character.isdigit():
104
+ replacement.append(rng.choice("0123456789"))
105
+ elif character.isupper():
106
+ replacement.append(rng.choice("ABCDEFGHIJKLMNOPQRSTUVWXYZ"))
107
+ elif character.islower():
108
+ replacement.append(rng.choice("abcdefghijklmnopqrstuvwxyz"))
109
+ else:
110
+ replacement.append(character)
111
+ return "".join(replacement)
112
+
113
+
114
+ _LITERAL_ESCAPE_RE = re.compile(r"\\+[nrt]")
115
+ _STRIP_CHARS = "\"'«»“”\\()"
116
+
117
+
118
+ def _kv_value(match: re.Match[str]) -> tuple[int, int, str, bool]:
119
+ for group, quoted in (("quoted_value", True), ("smart_value", True), ("bare_value", False)):
120
+ if match.group(group) is not None:
121
+ start, end = match.span(group)
122
+ value = match.group(group)
123
+ if not quoted:
124
+ cut = _LITERAL_ESCAPE_RE.search(value)
125
+ if cut and cut.start() > 0:
126
+ value = value[:cut.start()]; end = start + len(value)
127
+ text = match.string
128
+ opened = value.count("{") - value.count("}") + value.count("[") - value.count("]")
129
+ while opened > 0 and end < len(text) and text[end] in "}]":
130
+ value += text[end]; end += 1; opened -= 1
131
+ return start, end, value, quoted
132
+ raise AssertionError("K/V regex produced no value")
133
+
134
+
135
+ def _kv_core(start: int, end: int, value: str) -> tuple[int, int, str, bool]:
136
+ core = value.strip(_STRIP_CHARS)
137
+ lead = value.find(core) if core else 0
138
+ opening, closing = value[:lead], value[lead + len(core):]
139
+ quotes = "\"'«»“”"
140
+ wrapped = any(character in quotes for character in opening) and any(character in quotes for character in closing)
141
+ return start + lead, start + lead + len(core), core, wrapped
142
+
143
+
144
+ def _kv_matches(text: str):
145
+ tokens = [match.span() for match in SLOT_RE.finditer(text)]
146
+ position = 0
147
+ while True:
148
+ match = KV_RE.search(text, position)
149
+ if match is None:
150
+ return
151
+ key_start = match.start("key")
152
+ enclosing = next((span for span in tokens if span[0] <= key_start < span[1]), None)
153
+ if enclosing:
154
+ position = enclosing[1]
155
+ continue
156
+ yield match
157
+ position = match.end("key")
158
+
159
+
160
+ def _overlaps(start: int, end: int, replacements: list[tuple[int, int, str]]) -> bool:
161
+ return any(not (end <= left or start >= right) for left, right, _ in replacements)
162
+
163
+
164
+ def _replace(text: str, replacements: list[tuple[int, int, str]]) -> str:
165
+ for start, end, replacement in sorted(replacements, reverse=True):
166
+ text = text[:start] + replacement + text[end:]
167
+ return text
168
+
169
+
170
+ def _merge_ranges(ranges: list[list[int]]) -> list[list[int]]:
171
+ merged: list[list[int]] = []
172
+ for start, end in sorted(ranges):
173
+ if merged and start <= merged[-1][1]:
174
+ merged[-1][1] = max(merged[-1][1], end)
175
+ else:
176
+ merged.append([start, end])
177
+ return merged
178
+
179
+
180
+ def sanitize_synthetic_text(text: str, covered_ranges: list[list[int]], *, seed: int, record_key: str, rules: list[GitleaksRule], max_rounds: int = 64) -> tuple[str, list[list[int]], int, int]:
181
+ covered = _merge_ranges([list(item) for item in covered_ranges]); added: list[list[int]] = []; replaced_count = 0; rounds = 0
182
+ for _iteration in range(max_rounds):
183
+ pending: list[tuple[int, int, str]] = []
184
+
185
+ def add_uncovered(start: int, end: int) -> None:
186
+ pieces = [(start, end)]
187
+ for covered_start, covered_end in covered:
188
+ remainder = []
189
+ for piece_start, piece_end in pieces:
190
+ if piece_end <= covered_start or piece_start >= covered_end:
191
+ remainder.append((piece_start, piece_end))
192
+ else:
193
+ if piece_start < covered_start:
194
+ remainder.append((piece_start, covered_start))
195
+ if covered_end < piece_end:
196
+ remainder.append((covered_end, piece_end))
197
+ pieces = remainder
198
+ for piece_start, piece_end in pieces:
199
+ if piece_start >= piece_end or SLOT_RE.search(text[piece_start:piece_end]) or _overlaps(piece_start, piece_end, pending):
200
+ continue
201
+ pending.append((piece_start, piece_end, _format_preserving(text[piece_start:piece_end], seed=seed, record_key=record_key, offset=piece_start)))
202
+
203
+ for site in credential_sites(text, rules):
204
+ if site.disposition != "quarantine":
205
+ add_uncovered(site.start, site.end)
206
+ if not pending:
207
+ break
208
+ text = _replace(text, pending)
209
+ fresh = [[start, end] for start, end, _replacement in pending]
210
+ added.extend(fresh); covered = _merge_ranges([*covered, *fresh])
211
+ replaced_count += len(pending); rounds += 1
212
+ return text, _merge_ranges(added), replaced_count, rounds
213
+
214
+
215
+ _CREDENTIAL_ENTITIES = frozenset({"PASSWORD", "LOGIN", "JWT", "BEARER_TOKEN", "API_KEY", "PRIVATE_KEY", "DB_URL"})
216
+
217
+
218
+ def _pii_recognizers() -> list:
219
+ global _PROJECT_PII_RECOGNIZERS
220
+ if _PROJECT_PII_RECOGNIZERS is None:
221
+ try:
222
+ candidates = (Path(__file__).resolve().parents[2] / "presidio", Path("/app"))
223
+ for directory in candidates:
224
+ if (directory / "recognizers").is_dir() and str(directory) not in sys.path:
225
+ sys.path.insert(0, str(directory))
226
+ from recognizers import ALL_RECOGNIZERS
227
+ selected = []
228
+ for factory in ALL_RECOGNIZERS:
229
+ recognizer = factory()
230
+ supported = set(getattr(recognizer, "supported_entities", []) or [])
231
+ if supported and supported.isdisjoint(_CREDENTIAL_ENTITIES):
232
+ selected.append(recognizer)
233
+ _PROJECT_PII_RECOGNIZERS = selected
234
+ except Exception:
235
+ _PROJECT_PII_RECOGNIZERS = []
236
+ return _PROJECT_PII_RECOGNIZERS
237
+
238
+
239
+ def pii_recognizer_names() -> list[str]:
240
+ return sorted(type(recognizer).__name__ for recognizer in _pii_recognizers())
241
+
242
+
243
+ def _project_pii_matches(text: str) -> list[tuple[int, int]]:
244
+ matches = []
245
+ for recognizer in _pii_recognizers():
246
+ try:
247
+ matches.extend((result.start, result.end) for result in recognizer.analyze(text, entities=None, nlp_artifacts=None))
248
+ except Exception:
249
+ continue
250
+ return matches
251
+
252
+
253
+ def load_gitleaks_rules(path: Path | None) -> tuple[list[GitleaksRule], int]:
254
+ if path is None or not path.exists():
255
+ return [], 0
256
+ try:
257
+ import tomllib
258
+ raw_rules = tomllib.loads(path.read_text(encoding="utf-8")).get("rules", [])
259
+ except ImportError:
260
+ raw_rules = []
261
+ for block in path.read_text(encoding="utf-8").split("[[rules]]")[1:]:
262
+ identifier = re.search(r'^id\s*=\s*"([^"]+)"', block, re.M)
263
+ regex = re.search(r"^regex\s*=\s*'''(.*?)'''", block, re.M | re.S)
264
+ if identifier and regex:
265
+ keywords = re.search(r"^keywords\s*=\s*\[(.*?)\]", block, re.M | re.S)
266
+ secret_group = re.search(r"^secretGroup\s*=\s*(\d+)", block, re.M)
267
+ entropy = re.search(r"^entropy\s*=\s*([\d.]+)", block, re.M)
268
+ raw_rules.append({"id": identifier.group(1), "regex": regex.group(1), "keywords": re.findall(r'"([^"]+)"', keywords.group(1)) if keywords else [], "secretGroup": int(secret_group.group(1)) if secret_group else None, "entropy": float(entropy.group(1)) if entropy else None})
269
+ rules = []; skipped = 0
270
+ for rule in raw_rules:
271
+ identifier = str(rule.get("id", "")).lower(); regex = rule.get("regex")
272
+ if not isinstance(regex, str):
273
+ continue
274
+ label = "AUTH_TOKEN" if "token" in identifier else "SECRET_KEY" if "key" in identifier or "secret" in identifier else None
275
+ if label is None:
276
+ continue
277
+ try:
278
+ keywords = tuple(str(item).lower() for item in rule.get("keywords", []) if isinstance(item, str))
279
+ with warnings.catch_warnings():
280
+ warnings.simplefilter("error", DeprecationWarning)
281
+ warnings.simplefilter("error", FutureWarning)
282
+ pattern = re.compile(regex)
283
+ group = int(rule.get("secretGroup") or (1 if pattern.groups else 0))
284
+ rules.append((pattern, label, keywords, min(group, pattern.groups), float(rule.get("entropy") or 0.0)))
285
+ except (re.error, DeprecationWarning, FutureWarning):
286
+ skipped += 1
287
+ return rules, skipped
288
+
289
+
290
+ def _value_allowed(label: str, value: str, quoted: bool) -> bool:
291
+ value = value.strip()
292
+ if value.isdigit() and label != "PASSWORD":
293
+ return False
294
+ if label == "LOGIN":
295
+ return bool(re.fullmatch(r"[\w.@][\w.@\-]{2,63}", value))
296
+ return 4 <= len(value) <= 256 and (quoted or not any(character.isspace() for character in value))
297
+
298
+
299
+ def detector_core(text: str, start: int, end: int) -> tuple[int, int]:
300
+ value = text[start:end]
301
+ if "=" in value:
302
+ prefix, suffix = value.split("=", 1)
303
+ if suffix and re.fullmatch(r"[?&]?[A-Za-zА-Яа-яЁё_][\w.-]{0,64}", prefix):
304
+ start += len(prefix) + 1
305
+ while start < end and text[start] in "/?&":
306
+ start += 1
307
+ while end > start and text[end - 1] in ".,;)]}":
308
+ end -= 1
309
+ return start, end
310
+
311
+
312
+ def _kv_site(match: re.Match[str], text: str) -> tuple[int, int, str, str | None, str | None]:
313
+ start, end, value, quoted = _kv_value(match)
314
+ start, end, value, wrapped = _kv_core(start, end, value); quoted = quoted or wrapped
315
+ label = classify_key(match.group("key"))
316
+ if not label or value.startswith("[PII_REDACTED"):
317
+ return start, end, value, None, None
318
+ if PLACEHOLDER_RE.match(value):
319
+ return start, end, value, None, "placeholder"
320
+ if is_inert_value(value, quoted):
321
+ return start, end, value, None, "code_reference"
322
+ if not _value_allowed(label, value, quoted) or _looks_like_user_sentence(match, text, end, quoted):
323
+ return start, end, value, None, None
324
+ start, end = detector_core(text, start, end)
325
+ value = text[start:end]
326
+ if not value:
327
+ return start, end, value, None, "empty_core"
328
+ return start, end, value, label, None
329
+
330
+
331
+ def kv_credential_sites(text: str) -> list[tuple[int, int, str, str]]:
332
+ sites = []
333
+ for match in _kv_matches(text):
334
+ start, end, value, label, _reason = _kv_site(match, text)
335
+ if label:
336
+ sites.append((start, end, value, label))
337
+ return sites
338
+
339
+
340
+ _CLI_CREDENTIAL_FLAGS = {
341
+ "-u": CliCredentialFlag(frozenset({"curl"}), "userinfo", True), "--user": CliCredentialFlag(frozenset({"curl"}), "userinfo"),
342
+ "-U": CliCredentialFlag(frozenset({"curl"}), "userinfo", True), "--proxy-user": CliCredentialFlag(frozenset({"curl"}), "userinfo"),
343
+ "--oauth2-bearer": CliCredentialFlag(frozenset({"curl"}), "token"),
344
+ "--password": CliCredentialFlag(None, "password"), "--passwd": CliCredentialFlag(None, "password"), "--pass": CliCredentialFlag(None, "password"),
345
+ "--http-password": CliCredentialFlag(frozenset({"wget"}), "password"), "--proxy-password": CliCredentialFlag(frozenset({"wget"}), "password"), "--ftp-password": CliCredentialFlag(frozenset({"wget"}), "password"),
346
+ "--auth": CliCredentialFlag(frozenset({"http", "https"}), "userinfo"), "-a": CliCredentialFlag(frozenset({"http", "https"}), "userinfo", True),
347
+ "-p": CliCredentialFlag(frozenset({"mysql"}), "password", True),
348
+ }
349
+ _CLI_FLAG_RE = re.compile(r"(?<![\w=-])(--[A-Za-z][\w-]*|-[A-Za-z])")
350
+ _CLI_REFERENCE_RE = re.compile(r"^(?:\$\w+|\$\{[A-Za-z_]\w*\}|%\w+%|\$\([^)]*\)|`[^`]*`)$")
351
+ _CLI_WRAPPERS = frozenset({"sudo", "env", "command", "xargs"})
352
+
353
+
354
+ def _cli_read_value(text: str, position: int) -> tuple[int, int, str] | None:
355
+ if position >= len(text):
356
+ return None
357
+ if text[position] in "\"'":
358
+ closing = text.find(text[position], position + 1)
359
+ if closing == -1:
360
+ return None
361
+ return position + 1, closing, text[position + 1:closing]
362
+ match = _CLI_VALUE_RE.match(text, position)
363
+ if not match:
364
+ return None
365
+ start, end = match.start(), match.end()
366
+ while end > start:
367
+ char = text[end - 1]
368
+ if char in "\"',;":
369
+ end -= 1
370
+ elif char in ")]}" and {")": "(", "]": "[", "}": "{"}[char] not in text[start:end - 1]:
371
+ end -= 1
372
+ else:
373
+ break
374
+ return (start, end, text[start:end]) if end > start else None
375
+
376
+
377
+ def _cli_part_kind(part: str) -> str:
378
+ part = part.strip().strip("\"'")
379
+ if not part:
380
+ return "empty"
381
+ if re.fullmatch(r"\$\{[A-Za-z_]\w*:-\}", part):
382
+ return "empty"
383
+ if re.fullmatch(r"\$\{[A-Za-z_]\w*:-.+\}", part):
384
+ return "dynamic_with_literal_fallback"
385
+ if _CLI_REFERENCE_RE.match(part) or PLACEHOLDER_RE.match(part) or is_inert_value(part, quoted=True):
386
+ return "reference"
387
+ return "literal"
388
+
389
+
390
+ def _cli_read_shell_value(text: str, position: int) -> tuple[int, int, str, str] | None:
391
+ if position >= len(text) or text[position].isspace(): return None
392
+ start = position; quote = None; escaped = backtick = saw_escape = False; parens = braces = 0
393
+ while position < len(text):
394
+ char = text[position]
395
+ if escaped: escaped = False; position += 1; continue
396
+ if char == "\\": escaped = saw_escape = True; position += 1; continue
397
+ if quote:
398
+ if char == quote: quote = None
399
+ position += 1; continue
400
+ if backtick:
401
+ if char == "`": backtick = False
402
+ position += 1; continue
403
+ if char in "\"'": quote = char; position += 1; continue
404
+ if char == "`": backtick = True; position += 1; continue
405
+ if text.startswith("$(", position): parens += 1; position += 2; continue
406
+ if text.startswith("${", position): braces += 1; position += 2; continue
407
+ if char == ")" and parens: parens -= 1; position += 1; continue
408
+ if char == "}" and braces: braces -= 1; position += 1; continue
409
+ if char.isspace() and not parens and not braces: break
410
+ position += 1
411
+ end = position
412
+ malformed = any((quote, escaped, backtick, parens, braces, saw_escape))
413
+ wrapped = end - start >= 2 and text[start] in "\"'" and text[end - 1] == text[start]
414
+ if wrapped:
415
+ start += 1; end -= 1
416
+ elif not malformed:
417
+ while end > start and text[end - 1] in "\"',;": end -= 1
418
+ value = text[start:end]
419
+ if not value: return None
420
+ kind = "ambiguous" if malformed else _cli_part_kind(value)
421
+ if not malformed and (value.startswith("$(") or value.startswith("${") or value.startswith("`")):
422
+ kind = "reference"
423
+ return start, end, value, kind
424
+
425
+
426
+ def _cli_command(text: str, position: int) -> str | None:
427
+ segment = re.split(r"(?:;|&&|\|\| |\|)", text[:position])[-1]
428
+ embedded = re.findall(r"(?:^|[\s\"'])((?:curl|wget|mysql|http|https))(?:\.exe)?(?=$|[\s\"'])", segment, re.I)
429
+ if embedded:
430
+ return embedded[-1].lower()
431
+ for word in re.findall(r"(?:[^\s'\"]+|'[^']*'|\"[^\"]*\")+", segment):
432
+ token = re.sub(r"\.exe$", "", word.strip("'\"").rsplit("/", 1)[-1].lower())
433
+ if token in _CLI_WRAPPERS or token.startswith("-") or re.fullmatch(r"[A-Za-z_]\w*=.*", token): continue
434
+ return token
435
+ return None
436
+
437
+
438
+ def _cli_credential_parts(text: str):
439
+ for flag_match in _CLI_FLAG_RE.finditer(text):
440
+ flag = flag_match.group(1)
441
+ config = _CLI_CREDENTIAL_FLAGS.get(flag)
442
+ if config is None:
443
+ continue
444
+ command = _cli_command(text, flag_match.start())
445
+ if config.commands is not None and command not in config.commands:
446
+ continue
447
+ after = flag_match.end()
448
+ if after < len(text) and text[after] == "=":
449
+ read = _cli_read_shell_value(text, after + 1)
450
+ elif config.attached and after < len(text) and not text[after].isspace():
451
+ read = _cli_read_shell_value(text, after)
452
+ else:
453
+ cursor = after
454
+ while cursor < len(text) and text[cursor] in " \t":
455
+ cursor += 1
456
+ read = _cli_read_shell_value(text, cursor) if cursor > after or (after < len(text) and text[after].isspace()) else None
457
+ if read is None:
458
+ continue
459
+ value_start, value_end, value, value_kind = read
460
+ part = {"password": None, "username": None}
461
+ if config.mode == "userinfo":
462
+ colon = value.find(":")
463
+ if colon == -1:
464
+ continue
465
+ username = value[:colon]
466
+ part["username"] = (value_start, value_start + colon, username, "LOGIN", _cli_part_kind(username))
467
+ password_kind = value_kind if value_kind in {"ambiguous", "dynamic_with_literal_fallback"} else _cli_part_kind(value[colon + 1:])
468
+ part["password"] = (value_start + colon + 1, value_end, value[colon + 1:], "PASSWORD", password_kind)
469
+ else:
470
+ part["password"] = (value_start, value_end, value, "AUTH_TOKEN" if config.mode == "token" else "PASSWORD", value_kind)
471
+ yield part
472
+
473
+
474
+ def cli_credential_sites(text: str) -> list[tuple[int, int, str, str, str]]:
475
+ sites: list[tuple[int, int, str, str, str]] = []
476
+ for part in _cli_credential_parts(text):
477
+ password = part["password"]
478
+ if password and password[4] != "empty":
479
+ sites.append(password)
480
+ return sites
481
+
482
+
483
+ def cli_credential_slots(text: str) -> list[tuple[int, int, str, str]]:
484
+ slots: list[tuple[int, int, str, str]] = []
485
+ for part in _cli_credential_parts(text):
486
+ password = part["password"]
487
+ if not password or password[4] != "literal":
488
+ continue
489
+ username = part["username"]
490
+ if username and username[4] == "literal":
491
+ slots.append((username[0], username[1], "LOGIN", "cli"))
492
+ slots.append((password[0], password[1], password[3], "cli"))
493
+ return slots
494
+
495
+
496
+ def credential_sites(text: str, rules: list[GitleaksRule]) -> list[CredentialSite]:
497
+ sites = [CredentialSite(start, end, label, "kv", "slot") for start, end, _value, label in kv_credential_sites(text)]
498
+ sites += [CredentialSite(start, end, label, "cli", "slot" if kind == "literal" else "quarantine") for start, end, _value, label, kind in cli_credential_sites(text) if kind in {"literal", "ambiguous", "dynamic_with_literal_fallback"}]
499
+ sites += [CredentialSite(start, end, label or "SECRET", "opaque", "replace") for start, end, label, _match_start, _match_end in _find_live(text, rules)]
500
+ return sorted(set(sites), key=lambda item: (item.start, item.end, item.label, item.detector))
501
+
502
+
503
+ def _looks_like_user_sentence(match: re.Match[str], text: str, value_end: int, quoted: bool) -> bool:
504
+ key = match.group("key")
505
+ _, parts = _key_parts(key)
506
+ if quoted:
507
+ return False
508
+ remainder = text[value_end:text.find("\n", value_end) if text.find("\n", value_end) != -1 else len(text)]
509
+ continues_as_sentence = bool(re.match(r"\s+[\wА-Яа-яЁё]{2,}", remainder))
510
+ return continues_as_sentence and (
511
+ "user" in parts or
512
+ (match.group("sep") == ":" and " " in key and len(parts) >= 3)
513
+ )
514
+
515
+
516
+ def _find_live(text: str, rules: list[GitleaksRule]) -> list[tuple[int, int, str | None, int, int]]:
517
+ found = []
518
+ for match in OPAQUE_RE.finditer(text):
519
+ if not _is_live_opaque(match.group()):
520
+ continue
521
+ start, end, value, label = match.start(), match.end(), match.group(), None
522
+ if "=" in value:
523
+ prefix, suffix = value.split("=", 1)
524
+ classified = classify_key(prefix)
525
+ if classified and suffix:
526
+ start += len(prefix) + 1; value = suffix; label = classified
527
+ found.append((start, end, label, match.start(), match.end()))
528
+ lowered = text.lower()
529
+ for pattern, label, keywords, group, entropy in rules:
530
+ if keywords and not any(keyword in lowered for keyword in keywords):
531
+ continue
532
+ for match in pattern.finditer(text):
533
+ start, end = match.span(group)
534
+ if start < 0 or end <= start:
535
+ start, end = match.span()
536
+ if entropy and _entropy(text[start:end]) < entropy:
537
+ continue
538
+ found.append((start, end, label, match.start(), match.end()))
539
+ normalized = []
540
+ for start, end, label, match_start, match_end in found:
541
+ start, end = detector_core(text, start, end)
542
+ if start < end:
543
+ normalized.append((start, end, label, match_start, match_end))
544
+ return sorted(set(normalized), key=lambda item: (item[0], item[1], item[2] or ""))
545
+
546
+
547
+ def scrub_row(raw: dict, slot_counter: int, *, seed: int = 42, gitleaks_rules: list[GitleaksRule] | None = None) -> tuple[dict | None, list[dict], dict[str, int], int]:
548
+ text = nfc(str(raw.get("content", raw.get("text", ""))))
549
+ record_key = str(raw.get("group_id") or raw.get("conversation_id") or raw.get("session_id") or stable_id(text))
550
+ counts: Counter[str] = Counter(); slots: list[dict] = []; flags = list(raw.get("flags", [])); negative_reason = raw.get("negative_reason")
551
+ pii_replacements: list[tuple[int, int, str]] = []
552
+ for pattern in (EMAIL_RE, PHONE_RE, SNILS_RE, CARD_RE, PASSPORT_RE, INN_RE):
553
+ for match in pattern.finditer(text):
554
+ start, end = match.span(1) if pattern in {INN_RE, PASSPORT_RE} else match.span()
555
+ if not _overlaps(start, end, pii_replacements):
556
+ pii_replacements.append((start, end, "[PII_REDACTED]")); counts["pii_replaced"] += 1
557
+ for start, end in _project_pii_matches(text):
558
+ if not _overlaps(start, end, pii_replacements):
559
+ pii_replacements.append((start, end, "[PII_REDACTED]")); counts["pii_replaced"] += 1
560
+ text = _replace(text, pii_replacements)
561
+ if pii_replacements:
562
+ flags.append("pii_replaced")
563
+ replacements: list[tuple[int, int, str]] = []; entropy_replacements: list[tuple[int, int, str]] = []
564
+
565
+ def add_slot(start: int, end: int, label: str, slot_type: str) -> None:
566
+ nonlocal slot_counter
567
+ if _overlaps(start, end, replacements):
568
+ return
569
+ token = f"⟦SLOT:{label}:{slot_counter}⟧"; slot_counter += 1
570
+ replacements.append((start, end, token))
571
+ slots.append({"slot_id": token, "record_key": record_key, "klass": label, "injection_slot_type": slot_type})
572
+ counts["slots"] += 1
573
+
574
+ for marker in MARKER_RE.finditer(text):
575
+ kind = marker.group(1); label = MARKER_LABELS.get(kind) or classify_key(kind)
576
+ if kind == "ENV":
577
+ context = text[max(0, marker.start() - 120):marker.end() + 120]
578
+ key_match = re.search(r"([\w.-]+)\s*[:=]\s*\[REDACTED:ENV\]", context, re.I)
579
+ label = classify_key(key_match.group(1)) if key_match else None
580
+ if label:
581
+ add_slot(marker.start(), marker.end(), label, "redacted_marker")
582
+ cli_quarantine_ranges = [(site.start, site.end) for site in credential_sites(text, gitleaks_rules or []) if site.detector == "cli" and site.disposition == "quarantine"]
583
+ for match in _kv_matches(text):
584
+ start, end, _value, label, reason = _kv_site(match, text)
585
+ if any(not (end <= left or start >= right) for left, right in cli_quarantine_ranges):
586
+ continue
587
+ if reason:
588
+ negative_reason = negative_reason or reason; counts[reason] += 1; continue
589
+ if label:
590
+ add_slot(start, end, label, "dsn" if "://" in text[max(0, match.start() - 50):match.end() + 50] else "kv_pair")
591
+ for match in CONTRACT_RE.finditer(text):
592
+ if not _overlaps(match.start(1), match.end(1), replacements):
593
+ add_slot(match.start(1), match.end(1), "CONTRACT_NUMBER", "kv_pair")
594
+ for start, end, label, slot_type in cli_credential_slots(text):
595
+ add_slot(start, end, label, slot_type)
596
+
597
+ for start, end, _label, _match_start, _match_end in _find_live(text, gitleaks_rules or []):
598
+ if _overlaps(start, end, replacements):
599
+ overlapping = sorted((left, right) for left, right, _ in replacements if left < end and right > start)
600
+ leftovers = []; cursor = start
601
+ for left, right in overlapping:
602
+ if left > cursor:
603
+ leftovers.append((cursor, left))
604
+ cursor = max(cursor, right)
605
+ if cursor < end:
606
+ leftovers.append((cursor, end))
607
+ if any(_is_live_opaque(text[piece_start:piece_end]) for piece_start, piece_end in leftovers):
608
+ counts["quarantined_conflict"] += 1
609
+ return None, [], dict(counts), slot_counter
610
+ continue
611
+ replacement = _format_preserving(text[start:end], seed=seed, record_key=record_key, offset=start)
612
+ item = (start, end, replacement); replacements.append(item); entropy_replacements.append(item); counts["entropy_replaced_values"] += 1
613
+ safe_text = _replace(text, replacements)
614
+ final_ranges = []; delta = 0
615
+ entropy_starts = {(start, end, replacement) for start, end, replacement in entropy_replacements}
616
+ for start, end, replacement in sorted(replacements):
617
+ final_start = start + delta; final_end = final_start + len(replacement)
618
+ if (start, end, replacement) in entropy_starts:
619
+ final_ranges.append([final_start, final_end])
620
+ delta += len(replacement) - (end - start)
621
+ final_ranges = _merge_ranges(final_ranges)
622
+ if entropy_replacements:
623
+ flags.append("entropy_replaced")
624
+ for _iteration in range(64):
625
+ pending: list[tuple[int, int, str]] = []
626
+
627
+ def add_post_replacement(start: int, end: int) -> None:
628
+ pieces = [(start, end)]
629
+ for covered_start, covered_end in final_ranges:
630
+ remainder = []
631
+ for piece_start, piece_end in pieces:
632
+ if piece_end <= covered_start or piece_start >= covered_end:
633
+ remainder.append((piece_start, piece_end))
634
+ else:
635
+ if piece_start < covered_start:
636
+ remainder.append((piece_start, covered_start))
637
+ if covered_end < piece_end:
638
+ remainder.append((covered_end, piece_end))
639
+ pieces = remainder
640
+ for piece_start, piece_end in pieces:
641
+ if piece_start >= piece_end or SLOT_RE.search(safe_text[piece_start:piece_end]) or _overlaps(piece_start, piece_end, pending):
642
+ continue
643
+ pending.append((piece_start, piece_end, _format_preserving(safe_text[piece_start:piece_end], seed=seed, record_key=record_key, offset=piece_start)))
644
+
645
+ for site in credential_sites(safe_text, gitleaks_rules or []):
646
+ if site.disposition != "quarantine":
647
+ add_post_replacement(site.start, site.end)
648
+ if not pending:
649
+ break
650
+ safe_text = _replace(safe_text, pending)
651
+ final_ranges = _merge_ranges([*final_ranges, *[[start, end] for start, end, _replacement in pending]])
652
+ counts["postscan_replaced_values"] += len(pending)
653
+ counts["postscan_rounds"] += 1
654
+ if "entropy_replaced" not in flags:
655
+ flags.append("entropy_replaced")
656
+ slot_spans = [list(match.span()) for match in SLOT_RE.finditer(safe_text)]
657
+ covered = _merge_ranges([*final_ranges, *slot_spans])
658
+ for site in credential_sites(safe_text, gitleaks_rules or []):
659
+ if site.detector == "cli" and site.disposition == "quarantine" and not any(left <= site.start and site.end <= right for left, right in covered):
660
+ counts["cli_credential_quarantine"] += 1
661
+ return None, [], dict(counts), slot_counter
662
+ row = dict(raw); row.pop("content", None); row["text"] = safe_text; row["flags"] = sorted(set(flags)); row["negative_reason"] = negative_reason; row["entropy_replaced_ranges"] = final_ranges
663
+ return row, slots, dict(counts), slot_counter
664
+
665
+
666
+ def unresolved_live_values(row: dict, rules: list[GitleaksRule]) -> int:
667
+ ranges = _merge_ranges([list(item) for item in row.get("entropy_replaced_ranges", [])])
668
+ slots = [match.span() for match in SLOT_RE.finditer(row["text"])]
669
+ def covered(start: int, end: int) -> bool:
670
+ return any(left <= start and end <= right for left, right in [*ranges, *slots])
671
+ return sum(not covered(site.start, site.end) for site in credential_sites(row["text"], rules))
672
+
673
+
674
+ _SLOT_BASE_STRIDE = 65536
675
+ _WORKER_RULES: list[GitleaksRule] = []
676
+
677
+
678
+ def _init_worker(config: str) -> None:
679
+ global _WORKER_RULES
680
+ _WORKER_RULES = load_gitleaks_rules(Path(config))[0] if config else []
681
+
682
+
683
+ def _scrub_chunk(payload: tuple[int, int, list[str]]) -> tuple[list[str], list[dict], dict[str, int]]:
684
+ seed, start_index, lines = payload
685
+ out_rows: list[str] = []; out_slots: list[dict] = []; counts: Counter[str] = Counter()
686
+ for offset, line in enumerate(lines):
687
+ raw = json.loads(line); counts["input"] += 1
688
+ safe, found, row_counts, _ = scrub_row(raw, (start_index + offset) * _SLOT_BASE_STRIDE, seed=seed, gitleaks_rules=_WORKER_RULES)
689
+ counts.update(row_counts)
690
+ if safe is None:
691
+ counts["quarantined"] += 1
692
+ else:
693
+ counts["safe"] += 1; out_rows.append(json.dumps(safe, ensure_ascii=False, sort_keys=True, separators=(",", ":"))); out_slots.extend(found)
694
+ return out_rows, out_slots, dict(counts)
695
+
696
+
697
+ def _unresolved_chunk(lines: list[str]) -> int:
698
+ return sum(unresolved_live_values(json.loads(line), _WORKER_RULES) for line in lines)
699
+
700
+
701
+ def _line_chunks(paths: list[Path], size: int):
702
+ index = 0; buffer: list[str] = []
703
+ for path in paths:
704
+ with path.open(encoding="utf-8") as stream:
705
+ for line in stream:
706
+ if not line.strip():
707
+ continue
708
+ buffer.append(line)
709
+ if len(buffer) >= size:
710
+ yield index, buffer; index += len(buffer); buffer = []
711
+ if buffer:
712
+ yield index, buffer
713
+
714
+
715
+ def main() -> None:
716
+ parser = argparse.ArgumentParser(description=__doc__); parser.add_argument("--input", type=Path, action="append", help="turns JSONL; defaults to both extract outputs"); parser.add_argument("--gitleaks-config", type=Path, default=Path("../dataset/gitleaks/config/gitleaks.toml")); parser.add_argument("--workers", type=int, default=max(1, (os.cpu_count() or 2) - 2)); parser.add_argument("--verify-only", action="store_true", help="re-scan the existing scrubbed_turns.jsonl without rewriting it"); parser.add_argument("--allow-missing-pii-recognizers", action="store_true", help="proceed without personal-data recognizers; the output is then NOT safe to export"); add_common_args(parser); args = parser.parse_args()
717
+ inputs = args.input or [args.staging / "codechat_turns.jsonl", args.staging / "swechat_turns.jsonl"]
718
+ recognizers = pii_recognizer_names()
719
+ if not recognizers and not args.allow_missing_pii_recognizers:
720
+ raise SystemExit("PII recognizers unavailable (is presidio_analyzer installed?); "
721
+ "re-run inside the pipeline image or pass --allow-missing-pii-recognizers")
722
+ rules, skipped_rules = load_gitleaks_rules(args.gitleaks_config); slots: list[dict] = []; counts: Counter[str] = Counter()
723
+ config = str(args.gitleaks_config) if args.gitleaks_config and Path(args.gitleaks_config).exists() else ""
724
+ pool = multiprocessing.get_context("spawn").Pool(args.workers, initializer=_init_worker, initargs=(config,))
725
+ scrubbed_path = args.staging / "scrubbed_turns.jsonl"; scrubbed_path.parent.mkdir(parents=True, exist_ok=True)
726
+ if args.verify_only:
727
+ report_path = args.staging / "quarantine_report.json"
728
+ report = json.loads(report_path.read_text(encoding="utf-8")) if report_path.is_file() else {}
729
+ if report.get("scrubber_policy_version") != SCRUBBER_POLICY_VERSION:
730
+ raise SystemExit("staging was not produced by the current scrubber policy")
731
+ with pool:
732
+ unresolved = sum(pool.imap_unordered(_unresolved_chunk, (lines for _, lines in _line_chunks([scrubbed_path], 2000))))
733
+ print(json.dumps({"unresolved_live_values": unresolved, "workers": args.workers}, sort_keys=True))
734
+ raise SystemExit(1 if unresolved else 0)
735
+ with pool, scrubbed_path.open("w", encoding="utf-8", newline="\n") as out:
736
+ payloads = ((args.seed, index, lines) for index, lines in _line_chunks(inputs, 2000))
737
+ for out_rows, out_slots, chunk_counts in pool.imap(_scrub_chunk, payloads):
738
+ counts.update(chunk_counts); slots.extend(out_slots)
739
+ for line in out_rows:
740
+ out.write(line + "\n")
741
+ write_jsonl(args.staging / "slots.jsonl", slots)
742
+ out.flush()
743
+ unresolved = sum(pool.imap_unordered(_unresolved_chunk, (lines for _, lines in _line_chunks([scrubbed_path], 2000))))
744
+ report = {"scrubber_policy_version": SCRUBBER_POLICY_VERSION, "input": counts["input"], "safe": counts["safe"], "quarantined": counts["quarantined"], "unresolved_live_values": unresolved, "gitleaks_rules": len(rules), "gitleaks_rules_skipped": skipped_rules, "workers": args.workers, "pii_recognizers": recognizers, "by_type": dict(sorted(counts.items()))}
745
+ (args.staging / "quarantine_report.json").write_text(json.dumps(report, ensure_ascii=False, sort_keys=True, indent=2) + "\n", encoding="utf-8"); write_report(args.staging, "scrubber", **report); print(json.dumps(report, ensure_ascii=False, sort_keys=True))
746
+ if unresolved:
747
+ raise SystemExit(1)
748
+
749
+
750
+ if __name__ == "__main__":
751
+ main()
core/slot_ontology.yaml ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "exact": {
3
+ "PASSWORD": ["db_password", "password", "passwd", "pwd", "пароль"],
4
+ "SECRET_KEY": ["client_secret", "api_key", "private_key", "signing_key", "encryption_key"],
5
+ "AUTH_TOKEN": ["access_token", "refresh_token", "auth_token", "id_token", "токен"],
6
+ "LOGIN": ["login", "username", "user_name", "логин"]
7
+ },
8
+ "strong": {
9
+ "PASSWORD": ["password", "passwd", "pwd", "пароль"],
10
+ "SECRET_KEY": ["secret"],
11
+ "AUTH_TOKEN": ["bearer", "jwt"]
12
+ },
13
+ "weak": {
14
+ "SECRET_KEY": {"key": ["api", "client", "secret", "access", "private", "signing", "encryption"]},
15
+ "AUTH_TOKEN": {"token": []},
16
+ "LOGIN": {"user": [], "login": []}
17
+ },
18
+ "stop": ["tokenizer", "max_tokens", "input_tokens", "output_tokens", "tokens_used", "token_count", "user_agent", "useragent", "keyboard", "keyword", "keywords", "monkey", "turkey", "bypass", "passenger", "passage",
19
+ "expiry", "expires", "expiration", "ttl", "timeout", "time", "hours", "days", "minutes", "seconds", "port", "limit", "duration", "interval", "lifetime", "count", "size", "length"]
20
+ }
gitleaks.toml ADDED
The diff for this file is too large to render. See raw diff
 
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6a2c875d02398554ec69384f489a0bf4fe3505fc347c6cdd3385d4fd31ef21a4
3
+ size 709127044
predict.py ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import bisect
3
+ import json
4
+ import re
5
+ import sys
6
+ import unicodedata
7
+ from dataclasses import asdict, dataclass
8
+ from pathlib import Path
9
+ from typing import Callable, List, Optional, Sequence, Tuple
10
+
11
+ _HERE = Path(__file__).resolve().parent
12
+ if str(_HERE) not in sys.path: # чтобы `core/` нашёлся из любого cwd
13
+ sys.path.insert(0, str(_HERE))
14
+
15
+ WINDOW_WORDPIECES = 384
16
+ DEFAULT_RULES = _HERE / "gitleaks.toml"
17
+ _BOUNDARY_RE = re.compile(r"(?:\n+|[.!?]\s+)")
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Span:
22
+ start: int
23
+ end: int
24
+ label: str
25
+ source: str # ner | regex | both
26
+ score: Optional[float] # None у спанов детерминированного слоя
27
+ text: str
28
+
29
+
30
+ def plan_windows(
31
+ text: str, count: Callable[[str], int], maximum: int = WINDOW_WORDPIECES
32
+ ) -> List[Tuple[int, int]]:
33
+ """Режет текст на куски не длиннее `maximum` wordpiece, по границам
34
+ предложений и абзацев там, где это возможно. Возвращает пары (начало, конец)
35
+ в символах."""
36
+ if not text:
37
+ return []
38
+ if count(text) <= maximum:
39
+ return [(0, len(text))]
40
+
41
+ boundaries = sorted({0, *(m.end() for m in _BOUNDARY_RE.finditer(text)), len(text)})
42
+
43
+ def fitting_end(left: int) -> int:
44
+ low, step = left, maximum * 4
45
+ high = min(len(text), left + step)
46
+ while high < len(text) and count(text[left:high]) <= maximum:
47
+ low, step = high, step * 2
48
+ high = min(len(text), left + step)
49
+ if high == len(text) and count(text[left:high]) <= maximum:
50
+ return high
51
+ while low + 1 < high:
52
+ middle = (low + high) // 2
53
+ if count(text[left:middle]) <= maximum:
54
+ low = middle
55
+ else:
56
+ high = middle
57
+ return low
58
+
59
+ windows: List[Tuple[int, int]] = []
60
+ start = 0
61
+ while start < len(text):
62
+ candidate = fitting_end(start)
63
+ if candidate <= start:
64
+ raise RuntimeError(f"ни один непустой префикс не влезает в {maximum} wordpiece на {start}")
65
+ boundary = boundaries[bisect.bisect_right(boundaries, candidate) - 1]
66
+ end = boundary if boundary > start else candidate
67
+ if count(text[start:end]) > maximum:
68
+ end = candidate
69
+ windows.append((start, end))
70
+ if end >= len(text):
71
+ break
72
+ start = end
73
+ return windows
74
+
75
+
76
+ def extract_entities(bio: Sequence[str]) -> List[Tuple[int, int, str]]:
77
+ """BIO-теги → (начало, конец, метка) в индексах токенов. I- без своего B-
78
+ открывает сущность: модель не обязана быть согласованной, а терять
79
+ предсказание из-за этого нельзя."""
80
+ ents: List[Tuple[int, int, str]] = []
81
+ cur_label: Optional[str] = None
82
+ cur_start = 0
83
+ for i, tag in enumerate(list(bio) + ["O"]):
84
+ if tag.startswith("B-"):
85
+ if cur_label is not None:
86
+ ents.append((cur_start, i, cur_label))
87
+ cur_label, cur_start = tag[2:], i
88
+ elif tag.startswith("I-"):
89
+ label = tag[2:]
90
+ if cur_label == label:
91
+ continue
92
+ if cur_label is not None:
93
+ ents.append((cur_start, i, cur_label))
94
+ cur_label, cur_start = label, i
95
+ else:
96
+ if cur_label is not None:
97
+ ents.append((cur_start, i, cur_label))
98
+ cur_label = None
99
+ return ents
100
+
101
+
102
+ def _lower_score(left: Optional[float], right: Optional[float]) -> Optional[float]:
103
+ present = [s for s in (left, right) if s is not None]
104
+ return min(present) if present else None
105
+
106
+
107
+ def merge(spans: List[Span], text: str) -> List[Span]:
108
+ """Склеивает пересекающиеся и примыкающие однометочные спаны. Метку задаёт
109
+ первый спан — самый левый, затем самый длинный, затем NER вперёд regex."""
110
+ if not spans:
111
+ return []
112
+ ordered = sorted(
113
+ spans,
114
+ key=lambda s: (s.start, -(s.end - s.start), 0 if s.source == "ner" else 1),
115
+ )
116
+ out: List[Span] = [ordered[0]]
117
+ for sp in ordered[1:]:
118
+ prev = out[-1]
119
+ if sp.start < prev.end or (sp.start == prev.end and sp.label == prev.label):
120
+ end = max(prev.end, sp.end)
121
+ out[-1] = Span(
122
+ prev.start, end, prev.label,
123
+ prev.source if sp.source == prev.source else "both",
124
+ prev.score if sp.start < prev.end else _lower_score(prev.score, sp.score),
125
+ text[prev.start:end],
126
+ )
127
+ else:
128
+ out.append(sp)
129
+ return out
130
+
131
+
132
+ class Detector:
133
+ def __init__(
134
+ self,
135
+ model_id: str = "fef2/ner_rus_bert-secret_detection",
136
+ device: str = "cpu",
137
+ fp16: Optional[bool] = None,
138
+ window_wordpieces: int = WINDOW_WORDPIECES,
139
+ batch_size: int = 32,
140
+ rules: Optional[str | Path] = DEFAULT_RULES,
141
+ ) -> None:
142
+ import torch
143
+ from transformers import AutoModelForTokenClassification, AutoTokenizer
144
+
145
+ self.torch = torch
146
+ self.device = device
147
+ self.window = window_wordpieces
148
+ self.max_length = window_wordpieces + 2 # [CLS] … [SEP]
149
+ self.batch_size = max(1, batch_size)
150
+
151
+ self.tokenizer = AutoTokenizer.from_pretrained(model_id, use_fast=True)
152
+ if not self.tokenizer.is_fast:
153
+ raise SystemExit("нужен fast-токенизатор: без offset_mapping смещения не восстановить")
154
+
155
+ model = AutoModelForTokenClassification.from_pretrained(model_id).eval()
156
+ if fp16 is None:
157
+ fp16 = device.startswith("cuda")
158
+ if fp16:
159
+ model = model.half()
160
+ self.model = model.to(device)
161
+ self.id2label = {int(k): v for k, v in model.config.id2label.items()}
162
+
163
+ self.rules: list = []
164
+ if rules is not None:
165
+ from core.scrubber import load_gitleaks_rules
166
+
167
+ self.rules, skipped = load_gitleaks_rules(Path(rules))
168
+ if not self.rules:
169
+ raise SystemExit(f"правила не загрузились: {rules}")
170
+ self.rules_skipped = skipped
171
+
172
+ def _count(self, text: str) -> int:
173
+ return len(self.tokenizer(text, add_special_tokens=False)["input_ids"])
174
+
175
+ def spans(self, text: str) -> List[Span]:
176
+ """Спаны по NFC-нормализованному тексту. Нормализуйте вход тем же NFC,
177
+ прежде чем резать его по этим индексам."""
178
+ text = unicodedata.normalize("NFC", text)
179
+ found: List[Span] = []
180
+ windows = plan_windows(text, self._count, self.window)
181
+ for i in range(0, len(windows), self.batch_size):
182
+ found.extend(self._forward(text, windows[i:i + self.batch_size]))
183
+ found.extend(self.regex_spans(text))
184
+ return merge(found, text)
185
+
186
+ def regex_spans(self, text: str) -> List[Span]:
187
+ """Детерминированный слой: kv-детектор, CLI-детектор и правила gitleaks
188
+ по непрозрачным значениям. Без модели, без GPU."""
189
+ if not self.rules:
190
+ return []
191
+ from core.scrubber import credential_sites
192
+
193
+ return [Span(s.start, s.end, s.label, "regex", None, text[s.start:s.end])
194
+ for s in credential_sites(text, self.rules)]
195
+
196
+ def _forward(self, text: str, windows: Sequence[Tuple[int, int]]) -> List[Span]:
197
+ torch = self.torch
198
+ enc = self.tokenizer(
199
+ [text[a:b] for a, b in windows],
200
+ return_offsets_mapping=True,
201
+ return_special_tokens_mask=True,
202
+ truncation=True,
203
+ max_length=self.max_length,
204
+ padding=True,
205
+ return_tensors="pt",
206
+ )
207
+ with torch.inference_mode():
208
+ logits = self.model(
209
+ input_ids=enc["input_ids"].to(self.device),
210
+ attention_mask=enc["attention_mask"].to(self.device),
211
+ ).logits
212
+ preds = logits.argmax(-1).cpu()
213
+ confidence = logits.float().softmax(-1).max(-1).values.cpu()
214
+ offsets = enc["offset_mapping"].tolist()
215
+ special = enc["special_tokens_mask"].tolist()
216
+
217
+ out: List[Span] = []
218
+ for bi, (base, _) in enumerate(windows):
219
+ real = [i for i, m in enumerate(special[bi]) if m == 0]
220
+ bio = [self.id2label[int(preds[bi, i])] for i in real]
221
+ for begin, end, label in extract_entities(bio):
222
+ start = base + offsets[bi][real[begin]][0]
223
+ stop = base + offsets[bi][real[end - 1]][1]
224
+ if stop > start:
225
+ score = min(float(confidence[bi, real[i]]) for i in range(begin, end))
226
+ out.append(Span(start, stop, label, "ner", round(score, 4), text[start:stop]))
227
+ return out
228
+
229
+ def mask(self, text: str, template: str = "[REDACTED:{label}]") -> str:
230
+ text = unicodedata.normalize("NFC", text)
231
+ parts, cursor = [], 0
232
+ for sp in self.spans(text):
233
+ parts.append(text[cursor:sp.start])
234
+ parts.append(template.format(label=sp.label))
235
+ cursor = sp.end
236
+ parts.append(text[cursor:])
237
+ return "".join(parts)
238
+
239
+
240
+ def main() -> int:
241
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
242
+ src = ap.add_mutually_exclusive_group()
243
+ src.add_argument("--text", help="текст прямо в аргументе")
244
+ src.add_argument("--file", help="файл с текстом (иначе — stdin)")
245
+ ap.add_argument("--model", default="fef2/ner_rus_bert-secret_detection",
246
+ help="id на Hub или локальный каталог")
247
+ ap.add_argument("--device", default="cpu", help="cpu | cuda | cuda:0 | mps")
248
+ ap.add_argument("--rules", default=str(DEFAULT_RULES), help="путь к gitleaks.toml")
249
+ ap.add_argument("--no-rules", action="store_true", help="только NER, без детерминированного слоя")
250
+ ap.add_argument("--json", action="store_true", help="спаны в JSON")
251
+ ap.add_argument("--spans", action="store_true", help="спаны построчно")
252
+ args = ap.parse_args()
253
+
254
+ if args.text is not None:
255
+ text = args.text
256
+ elif args.file:
257
+ text = open(args.file, encoding="utf-8").read()
258
+ else:
259
+ text = sys.stdin.read()
260
+
261
+ det = Detector(args.model, device=args.device, rules=None if args.no_rules else args.rules)
262
+ if args.json:
263
+ print(json.dumps([asdict(s) for s in det.spans(text)], ensure_ascii=False, indent=2))
264
+ elif args.spans:
265
+ for s in det.spans(text):
266
+ score = " ----" if s.score is None else f"{s.score:.4f}"
267
+ print(f"{s.start:>7} {s.end:>7} {s.label:<16} {s.source:<5} {score} {s.text!r}")
268
+ else:
269
+ print(det.mask(text))
270
+ return 0
271
+
272
+
273
+ if __name__ == "__main__":
274
+ raise SystemExit(main())
special_tokens_map.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "cls_token": "[CLS]",
3
+ "mask_token": "[MASK]",
4
+ "pad_token": "[PAD]",
5
+ "sep_token": "[SEP]",
6
+ "unk_token": "[UNK]"
7
+ }
tokenizer.json ADDED
The diff for this file is too large to render. See raw diff
 
tokenizer_config.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "clean_up_tokenization_spaces": true,
3
+ "cls_token": "[CLS]",
4
+ "do_basic_tokenize": true,
5
+ "do_lower_case": false,
6
+ "mask_token": "[MASK]",
7
+ "model_max_length": 1000000000000000019884624838656,
8
+ "never_split": null,
9
+ "pad_token": "[PAD]",
10
+ "sep_token": "[SEP]",
11
+ "strip_accents": null,
12
+ "tokenize_chinese_chars": true,
13
+ "tokenizer_class": "BertTokenizer",
14
+ "unk_token": "[UNK]"
15
+ }
vocab.txt ADDED
The diff for this file is too large to render. See raw diff