File size: 16,919 Bytes
4a83520
0936c1f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4a83520
0936c1f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8bdd8da
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
---
language:
- ru
- en
license: apache-2.0
base_model: DeepPavlov/rubert-base-cased
library_name: transformers
pipeline_tag: token-classification
tags:
- ner
- token-classification
- pii
- secret-detection
- credentials
- masking
- russian
- bert
---

# ner_rus_bert-secret_detection

Дообученная голова `BertForTokenClassification` поверх `DeepPavlov/rubert-base-cased`,
которая размечает в русскоязычном тексте **учётные данные** (логины, пароли,
токены, ключи), **персональные данные** (ФИО, организации, локации) и **номера
договоров**. Основной сценарий, под который она обучалась - маскирование
пользовательских сообщений и фрагментов конфигов перед отправкой во внешнюю LLM.

> **Модель — половина системы.** В проде она работает **в объединении с
> детерминированным regex-слоем**, и все опубликованные ниже цифры покрытия
> учётных данных относятся к объединению `NER ∪ regex`. Одна NER заметно слабее
> по recall — так и задумано: её задача добирать то, что не ловится регулярками
> (естественная формулировка, кириллица, контекст), не переусердствуя с ложными
> срабатываниями. Обе половины лежат в этом репозитории: `core/scrubber.py` +
> `gitleaks.toml`
> См. [Детерминированный слой](#детерминированный-слой).

## Метки

17 BIO-тегов, 8 типов сущностей:

| Группа | Метки |
|---|---|
| Учётные данные | `LOGIN`, `PASSWORD`, `AUTH_TOKEN`, `SECRET_KEY` |
| ПДн | `PERSON`, `LOCATION`, `ORGANIZATION` |
| Прочее | `CONTRACT_NUMBER` |

Схема — `B-`/`I-`/`O`, полный список в `config.json` (`id2label`).

## Быстрый старт

```python
from transformers import pipeline

ner = pipeline(
    "token-classification",
    model="fef2/ner_rus_bert-secret_detection",
    aggregation_strategy="simple",
)

text = 'user = svc_billing, password = "Xt7#pQm2Zr", api_key: sk-live-4f9a2b7c1e6d8f3a0b5c9d2e'
for e in ner(text):
    print(f"{e['entity_group']:<12} {e['score']:.3f} {text[e['start']:e['end']]!r}")
```

```
LOGIN        0.936 'svc_billing'
PASSWORD     0.993 'Xt7#pQm2Zr'
SECRET_KEY   0.996 'sk-live-4f9a2b7c1e6d8f3a0b5c9d2e'
```

Обратите внимание: режьте исходную строку по `start`/`end`, а **не** берите поле
`word` — pipeline отдаёт его склеенным из wordpiece, с лишними пробелами
(`'svc _ billing'`).

**У `pipeline()` три ограничения, из-за которых он годится только для проб.**
Он не режет длинный вход на окна — текст длиннее 512 wordpiece падает с
`RuntimeError: The size of tensor a (1218) must match the size of tensor b (512)`;
не нормализует юникод; и это **только NER**, без детерминированного слоя. Для
настоящей работы возьмите `predict.py` из этого репозитория.

## Правильный путь: `predict.py`

`predict.py` — перенос инференс-логики развёрнутого сервиса целиком:
NFC-нормализация, нарезка на окна по 384 реальных wordpiece по границам
предложений, BIO-декод, детерминированный слой, смещения в символах, склейка
пересекающихся спанов. Нужны только `torch` и `transformers` — regex-половина
на чистом stdlib.

```bash
python predict.py --text 'password = "Xt7#pQm2Zr"'      # замаскированный текст
python predict.py --file документ.txt --spans           # спаны построчно
cat лог.txt | python predict.py --json                  # спаны в JSON
cat лог.txt | python predict.py --json --no-rules       # только NER, без regex
```

```python
from predict import Detector

det = Detector("fef2/ner_rus_bert-secret_detection", device="cpu")   # cuda / mps

text = ('Коллеги, доступ к стенду: user = svc_billing, password = "Xt7#pQm2Zr", '
        'api_key: sk-live-4f9a2b7c1e6d8f3a0b5c9d2e. Ответственный — Петров Сергей '
        'Иванович (ПАО «Сбербанк», Санкт-Петербург), договор № 77-АБ/2025-4412.')

for s in det.spans(text):
    print(f"{s.start:>4} {s.end:>4}  {s.label:<16} {s.source:<5} {s.text!r}")

print(det.mask(text))
```

```
  33   44  LOGIN            ner   'svc_billing'
  58   68  PASSWORD         both  'Xt7#pQm2Zr'
  80  112  SECRET_KEY       both  'sk-live-4f9a2b7c1e6d8f3a0b5c9d2e'
 130  152  PERSON           ner   'Петров Сергей Иванович'
 154  167  ORGANIZATION     ner   'ПАО «Сбербанк'
 170  185  LOCATION         ner   'Санкт-Петербург'
 198  213  CONTRACT_NUMBER  ner   '77-АБ/2025-4412'
```

`source` говорит, кто нашёл спан: `ner`, `regex` или `both` (нашли оба —
самый спокойный случай). У чисто regex-спанов `score` равен `None`: у
детерминированного правила нет вероятности, и подставлять туда единицу было бы
враньём.

Документ на 6800 символов режется на 4 окна и размечается целиком — граница окна
не теряет спаны, потому что нарезка идёт по концам предложений и абзацев.

### Четыре контракта, которые легко нарушить

**Окно — 384 *реальных* wordpiece.** Модель обучалась на окнах такого размера,
и `plan_windows` закладывает 384 токена **без** учёта `[CLS]`/`[SEP]`, то есть
`max_length=386` при токенизации. Если считать по-хагингфейсовски — 384 вместе со
служебными, — в окно попадёт 382 реальных токена, и предсказания на длинных
текстах разойдутся: два лишних wordpiece меняют каждый контекстный эмбеддинг
последовательности. На эталонном сплите в этот зазор попадают 280 строк из 6312,
и 5 из них предсказываются иначе.

**Смещения — по NFC-нормализованному тексту.** `Detector.spans()` сам приводит
вход к NFC, и `start`/`end` — индексы кодовых точек **нормализованной** строки.
Для латиницы и обычной кириллицы это то же самое, но текст с составными символами
(диакритика, вставки из PDF) после нормализации меняет длину, и спаны «поедут».
Режьте по спанам нормализованную строку:

```python
import unicodedata
normalized = unicodedata.normalize("NFC", text)   # ровно то, что видела модель
fragment = normalized[span.start:span.end]
```

**Батчи, а не по одному.** Пропускная способность здесь берётся из батчей;
`Detector(batch_size=...)` задаёт размер микро-батча окон. На CPU замеренная
скорость NER-половины — около **9.5k токенов/с**; на GPU в FP16 (`fp16=True`,
включается автоматически при `device="cuda"`) она перестаёт быть узким местом
задолго до regex-слоя.

**Модель не решает, что делать со спаном.** `mask()` подставляет
`[REDACTED:LABEL]` — это пример, а не политика. Обратимое маскирование
(плейсхолдеры с обратной подстановкой), пороги по `score`, отдельная политика на
`PERSON` против `SECRET_KEY` — всё это ваш слой поверх спанов.

## Детерминированный слой

Рядом с моделью по тому же тексту работает второй, полностью детерминированный
детектор — `core.scrubber.credential_sites`. Итоговый набор спанов есть
**объединение** двух источников с последующей склейкой. Он даёт постоянный, не
зависящий от чекпоинта пол по типовым форматам, а NER добирает то, что регуляркой
не описывается. Внутри — три детектора:

| Детектор | Что ищет | Метки |
|---|---|---|
| `kv` | `ключ = значение` с онтологией ключей (`password`, `пароль`, `api_key`, `токен`, `login`…) | `PASSWORD` `SECRET_KEY` `AUTH_TOKEN` `LOGIN` |
| `cli` | значения у известных флагов известных команд (`--password=…`, `curl -u user:pass`) | `PASSWORD` `AUTH_TOKEN` |
| `opaque` | непрозрачные высокоэнтропийные значения + правила gitleaks | `AUTH_TOKEN` `SECRET_KEY` `SECRET` |

Правила gitleaks отбираются по идентификатору (`…token…``AUTH_TOKEN`,
`…key…`/`…secret…``SECRET_KEY`), из 222 правил конфига до рантайма доходят
**168** — остальные отсеиваются фильтром по имени либо не компилируются под
`re` без предупреждений. `load_gitleaks_rules` возвращает вторым значением
именно счётчик отброшенных.

`cli` бьёт по таблице «команда → флаг» и отдаёт только само значение пароля или
токена; логин из `-u user:pass` в объединение не попадает, а голая строка
подключения (`postgres://user:pass@host`) не покрывается вовсе — это осознанная
граница релиза, см. [Ограничения](#ограничения).

Слой умеет отличать живое значение от инертного: `${DB_PASSWORD}`,
`os.environ["TOKEN"]`, `<your-key-here>`, `changeme` и уже проставленные маркеры
`[REDACTED:…]` он не трогает (`core._shim.is_inert_value`). Спан с меткой
`SECRET` — это опознанное непрозрачное значение, тип которого установить не
удалось; в модельный набор из 17 меток он не входит и приходит только отсюда.

Замеренная цена этой половины на тестовом сплите: 2891 срабатывание, из них 452
не задевают ни одного gold-спана; вычёркивается 3.03 % всех символов, из которых
0.37 % корпуса лежит вне gold-спанов. Слой чисто питоновский и держит GIL —
около 0.7 мс на текст (~460k токенов/с на ядро), так что в проде именно он, а не
GPU, упирается в потолок пропускной способности.

Отключается через `Detector(rules=None)` или `--no-rules` — но тогда цифры ниже
к вам не относятся.


## Обучение

| | |
|---|---|
| Базовая модель | `DeepPavlov/rubert-base-cased` (vocab 119547, 12 слоёв, 768) |
| Шаги / batch / lr | 8000 · 32 · 3e-5, warmup 800, weight decay 0.1, grad clip 1.0, AMP |
| Окно | 384 wordpiece |
| Выбор чекпоинта | по `credential_any_f1` на валидации (максимизируется вклад NER в детекцию учётных данных: пол от регулярок постоянен) |
| Данные | синтетические семейства учётных данных в русскоязычном контексте (kv-pair в сообщениях, конфигах и коде) + replay-доля 0.35 на общий русский NER, чтобы не потерять ПДн |

## Ограничения

* **CLI и DSN — вне зоны ответственности.** Строки подключения
  (`postgres://user:pass@host`) и пароли в аргументах команд сознательно не входят
  в целевую поверхность релиза и на ней не измерялись. Модель на них ведёт себя
  неровно: `psql -U reporter -W 'Qw!7823ml' -h db.corp.ru` → спан `PASSWORD`
  захватывает открывающую кавычку, а хвост имени хоста уезжает в `LOGIN`.
  Детерминированный слой подстраховывает только известные флаги известных команд.
  Целевая поверхность — **kv-pair**: `ключ = значение` в сообщениях, конфигах и
  коде.
* **Границы спанов не всегда аккуратны.** Модель может отрезать закрывающую
  кавычку (`'ПАО «Сбербанк'`) или разделить фразу на два спана. Для маскирования
  это безопасно (спан всё равно вычёркивается), для извлечения сущностей —
  учитывайте.
* **Не детектор «всех секретов».** Формат, не похожий ни на одно из обученных
  семейств и не покрытый регуляркой, будет пропущен. Recall 0.967 — это про
  объединение на распределении сплита, а не гарантия на вашем корпусе.
  Измерьте на своих данных.
* **Один seed.** Веса выпущены под пилот, из одного прогона; трёхсидовая
  сертификация релиза не проводилась.
* **Происхождение данных.** Общая NER-часть (`PERSON`/`ORGANIZATION`/`LOCATION`)
  опирается на публичные русские NER-корпуса; учётные данные — синтетика.
  Настоящих секретов в обучающем корпусе нет.

## Файлы

```
config.json · model.safetensors     веса (BertForTokenClassification, fp32, 709 МБ)
tokenizer.json · vocab.txt · …      токенизатор (fast, BertTokenizer, cased)
predict.py                          инференс: окна · NFC · BIO-декод · объединение
gitleaks.toml                       правила детерминированного слоя
core/scrubber.py                    детерминированный слой: kv · cli · opaque
core/_shim.py                       инертные значения, NFC, вспомогательное
core/slot_ontology.yaml             онтология ключей для kv-детектора
```

`core/` скопирован без изменений из `masking-service/core` бандла
`r3-2026-07-22` — того же кода, что крутится в проде; `predict.py` вызывает
`load_gitleaks_rules` и `credential_sites` из него, а не переписывает их. У
`scrubber.py` есть ещё и CLI датасетного пайплайна (`python -m core.scrubber`) - к инференсу он отношения не имеет, его можно игнорировать.