Token Classification
Transformers
Safetensors
Russian
English
bert
ner
pii
secret-detection
credentials
masking
russian
Instructions to use fef2/ner_rus_bert-secret_detection with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use fef2/ner_rus_bert-secret_detection with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("token-classification", model="fef2/ner_rus_bert-secret_detection")# Load model directly from transformers import AutoTokenizer, AutoModelForTokenClassification tokenizer = AutoTokenizer.from_pretrained("fef2/ner_rus_bert-secret_detection") model = AutoModelForTokenClassification.from_pretrained("fef2/ner_rus_bert-secret_detection", device_map="auto") - Notebooks
- Google Colab
- Kaggle
Upload folder using huggingface_hub
Browse files- README.md +260 -1
- config.json +62 -0
- core/__init__.py +0 -0
- core/_shim.py +83 -0
- core/scrubber.py +751 -0
- core/slot_ontology.yaml +20 -0
- gitleaks.toml +0 -0
- model.safetensors +3 -0
- predict.py +274 -0
- special_tokens_map.json +7 -0
- tokenizer.json +0 -0
- tokenizer_config.json +15 -0
- vocab.txt +0 -0
README.md
CHANGED
|
@@ -1,3 +1,262 @@
|
|
| 1 |
---
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|