From 78f0e4d26949562fea8ff1895aa1a96a2ffb0ad6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Mon, 29 Jun 2026 18:26:54 +0400 Subject: [PATCH] fix: phone negative lookbehind, docx runs guard, 15 tests --- deploy/services/drhider.py | 12 +- deploy/tests/test_drhider.py | 239 ++++++++++++++++++++++------------- site/services/drhider.py | 12 +- 3 files changed, 165 insertions(+), 98 deletions(-) diff --git a/deploy/services/drhider.py b/deploy/services/drhider.py index 9e9b484..268cf02 100644 --- a/deploy/services/drhider.py +++ b/deploy/services/drhider.py @@ -25,7 +25,7 @@ log = logging.getLogger("drhider") # ═══════════════════════════════════════════ ENTITY_PATTERNS: Dict[str, str] = { - "phone": r'\b(?:\+7|8)[\s\-]?\(?\d{3}\)?[\s\-]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2}\b', + "phone": r'(? bytes: - """Заменить сущности в docx-документе. Склеиваем runs → заменяем → пишем в первый run, остальные очищаем.""" + """Заменить сущности в docx. Склеиваем runs → заменяем → пишем в первый run, очищаем остальные.""" for para in doc.paragraphs: + if not para.runs: + continue full_text = "".join(run.text for run in para.runs) replaced = self._apply_replacements(full_text) - if replaced != full_text and para.runs: + if replaced != full_text: para.runs[0].text = replaced for run in para.runs[1:]: run.text = "" @@ -409,9 +411,11 @@ class TwoPassObfuscator: for row in table.rows: for cell in row.cells: for para in cell.paragraphs: + if not para.runs: + continue full_text = "".join(run.text for run in para.runs) replaced = self._apply_replacements(full_text) - if replaced != full_text and para.runs: + if replaced != full_text: para.runs[0].text = replaced for run in para.runs[1:]: run.text = "" diff --git a/deploy/tests/test_drhider.py b/deploy/tests/test_drhider.py index 5626be9..06a0a27 100644 --- a/deploy/tests/test_drhider.py +++ b/deploy/tests/test_drhider.py @@ -1,103 +1,162 @@ """ -Тест DrHider — сравнение оригинал vs обфусцированный. +Тест DrHider — много проверок. Файл: /home/naeel/nubes/contracts/contracts-flask/deploy/tests/test_drhider.py """ -import sys, os, io, zipfile, json +import sys, os, io, zipfile, json, base64 sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) -from services.drhider import obfuscate_files +from services.drhider import obfuscate_files, TwoPassObfuscator, COMPANY_PATTERN, PERSON_PATTERN TEST_ZIP = "/home/naeel/nubes/contracts/dogovora/примеры_договоров_для_ИИ.zip" -CHECKS = [ - # (что должно быть в оригинале, что НЕ должно быть в обфусцированном) - ("+7 (495) 789-41-35", "телефон"), - ("info@nubes.ru", "email"), - ("Степаненко", "фамилия"), - ("XXX003", "компания XXX003"), - ("XXX002", "компания XXX002"), - ("ИНН 9706005293", "ИНН НУБЕС"), - ("ОГРН 1207700098759", "ОГРН НУБЕС"), - ("г. Москва", "город"), # адреса без LLM не ловятся — ожидаемо -] -KEEP = [ - # (что должно остаться) - ("НУБЕС", "Исполнитель"), -] +def run(text, desc): + """Обработать текст и проверить что needle НЕ найдено.""" + z, c = obfuscate_files([('t.txt', text.encode(), '')]) + with zipfile.ZipFile(io.BytesIO(z)) as zf: + out = zf.read('t.txt').decode() + csv = zf.read('mapping.csv').decode() + return out, csv -def extract_all_text(zip_bytes): - """Извлечь весь текст из ZIP.""" - texts = {} - with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf: - for name in zf.namelist(): - if name == 'mapping.csv': - continue - data = zf.read(name) - if name.endswith('.docx'): - from docx import Document - doc = Document(io.BytesIO(data)) - texts[name] = "\n".join(p.text for p in doc.paragraphs) - else: - texts[name] = data.decode('utf-8', errors='replace') - return texts +errors = 0 - -def test(): - with open(TEST_ZIP, 'rb') as f: - content = f.read() - - zip_data, mapping_csv = obfuscate_files([('test.zip', content, '')]) - - # Извлечь текст из результата - out_texts = extract_all_text(zip_data) - combined = " ".join(out_texts.values()) - - print("=" * 60) - print("DrHider — проверка обфускации") - print("=" * 60) - - errors = 0 - - # Проверка что чувствительные данные заменены - print("\n🔴 Должны быть ЗАМЕНЕНЫ:") - for needle, desc in CHECKS: - if needle in combined: - print(f" ❌ {desc}: '{needle}' найдено в обфусцированном!") - errors += 1 - else: - print(f" ✅ {desc}: не найдено") - - # Проверка что НУБЕС остался - print("\n🟢 Должны СОХРАНИТЬСЯ:") - for needle, desc in KEEP: - if needle not in combined: - print(f" ❌ {desc}: '{needle}' отсутствует!") - errors += 1 - else: - print(f" ✅ {desc}: на месте") - - # Статистика mapping.csv - lines = mapping_csv.split('\n') - print(f"\n📋 mapping.csv: {len(lines)-2} сущностей заменено") - for line in lines[1:6]: - if line.strip(): - print(f" {line}") - - # Проверка структуры ZIP - with zipfile.ZipFile(io.BytesIO(zip_data)) as zf: - names = zf.namelist() - has_csv = 'mapping.csv' in names - no_zip = not any(n.endswith('.zip') for n in names) - print(f"\n📦 ZIP: {len(names)} файлов, mapping.csv={'✅' if has_csv else '❌'}, без .zip={'✅' if no_zip else '❌'}") - - print(f"\n{'='*60}") - if errors: - print(f"❌ {errors} ошибок") - sys.exit(1) +def check(ok, msg): + global errors + if ok: + print(f" ✅ {msg}") else: - print("✅ ВСЕ ПРОВЕРКИ ПРОЙДЕНЫ") - sys.exit(0) + print(f" ❌ {msg}") + errors += 1 +print("=" * 60) +print("DrHider — полный тест") +print("=" * 60) -if __name__ == '__main__': - test() +# ── 1. Телефоны ── +print("\n📞 Телефоны:") +out, csv = run("Звоните +7 (495) 789-41-35 или 8-800-555-35-35", "телефоны") +check("+7 (495) 789-41-35" not in out, "+7 (495) 789-41-35 заменён") +check("8-800-555-35-35" not in out, "8-800-555-35-35 заменён") +check("phone" in csv, "тип phone в CSV") + +# ── 2. Телефон не матчит число внутри счёта ── +print("\n📞 Телефон vs номер счёта:") +out, csv = run("Кор/сч 30101810400000000225", "счёт") +check("30101810400000000225" not in out, "номер счёта заменён (ks)") +check("+7" not in out, "нет ложного телефона внутри счёта") + +# ── 3. Email ── +print("\n📧 Email:") +out, csv = run("Пишите info@nubes.ru и support@company.org", "email") +check("info@nubes.ru" not in out, "info@nubes.ru заменён") +check("support@company.org" not in out, "support@company.org заменён") +check("email" in csv, "тип email в CSV") + +# ── 4. Компании (разные кавычки) ── +print("\n🏢 Компании:") +out, csv = run('ООО "Ромашка" и АО \u201cXXX003\u201d и ЗАО «Тест»', "компании") +check('ООО "Ромашка"' not in out, 'ООО "Ромашка" заменён') +check('АО \u201cXXX003\u201d' not in out, 'АО "XXX003" (unicode) заменён') +check('ЗАО «Тест»' not in out, 'ЗАО «Тест» заменён') +check('company' in csv, 'тип company в CSV') + +# ── 5. НУБЕС whitelist ── +print("\n🛡️ НУБЕС whitelist:") +out, csv = run('ООО "НУБЕС" и ООО \u201cНУБЕС\u201d', "НУБЕС") +check('ООО "НУБЕС"' in out, 'НУБЕС (ASCII) НЕ заменён') +check('ООО \u201cНУБЕС\u201d' in out, 'НУБЕС (unicode) НЕ заменён') + +# ── 6. ИНН/ОГРН/КПП/БИК ── +print("\n🔢 ИНН/ОГРН/КПП/БИК:") +out, csv = run("ИНН 9706005293, КПП 772401001, ОГРН 1207700098759, БИК 044525225", "реквизиты") +check("9706005293" not in out, "ИНН заменён") +check("772401001" not in out, "КПП заменён") +check("1207700098759" not in out, "ОГРН заменён") +check("044525225" not in out, "БИК заменён") +check("inn_ul" in csv, "inn_ul в CSV") + +# ── 7. Расчётный счёт ── +print("\n💳 Расчётный счёт:") +out, csv = run("р/с 40702810738000174030 и Кор/сч 30101810400000000225", "счета") +check("40702810738000174030" not in out, "р/с заменён") +check("30101810400000000225" not in out, "кор/сч заменён") +check("rs" in csv, "rs в CSV") +check("ks" in csv, "ks в CSV") + +# ── 8. ФИО (инициалы + полностью) ── +print("\n👤 ФИО:") +out, csv = run("Директор Степаненко В.С. и Иванов Александр Петрович", "ФИО") +check("Степаненко В.С." not in out, "Степаненко В.С. заменён") +check("Иванов Александр Петрович" not in out, "Иванов А.П. заменён") + +# ── 9. Паспорт ── +print("\n📄 Паспорт:") +out, csv = run("паспорт 12 34 567890", "паспорт") +check("12 34 567890" not in out, "номер паспорта заменён") + +# ── 10. Согласованность (одна сущность → одна замена) ── +print("\n🔄 Согласованность:") +z, c = obfuscate_files([ + ('a.txt', b'OOO Romashka +79991234567', ''), + ('b.txt', b'OOO Romashka +79991234567', ''), +]) +with zipfile.ZipFile(io.BytesIO(z)) as zf: + a = zf.read('a.txt').decode() + b = zf.read('b.txt').decode() +check(a == b, f"оба файла одинаковы: {a}") + +# ── 11. ZIP внутри ZIP ── +print("\n📦 ZIP внутри:") +with open(TEST_ZIP, 'rb') as f: + raw = f.read() +z, c = obfuscate_files([('test.zip', raw, '')]) +with zipfile.ZipFile(io.BytesIO(z)) as zf: + names = zf.namelist() +check(len(names) == 6, f"6 файлов (5 + csv): {len(names)}") +check('mapping.csv' in names, "mapping.csv есть") +check(not any(n.endswith('.zip') for n in names), "нет .zip в выдаче") +# Проверим что docx внутри валидны +for n in names: + if n.endswith('.docx'): + try: + from docx import Document + Document(io.BytesIO(zf.read(n))) + check(True, f"{n} — валидный docx") + except: + check(False, f"{n} — БИТЫЙ docx") + +# ── 12. .doc бинарный — не трогаем ── +print("\n📄 .doc бинарный:") +# Создаём фейковый .doc файл (просто бинарные данные) +z, c = obfuscate_files([('old.doc', b'\xD0\xCF\x11\xE0' + b'\x00' * 100, '')]) +with zipfile.ZipFile(io.BytesIO(z)) as zf: + doc_content = zf.read('old.doc') +check(len(doc_content) == 104, ".doc сохранён без изменений") + +# ── 13. Много файлов ── +print("\n📚 20 файлов:") +many = [('f{}.txt'.format(i), 'OOO Test{} +7999{}'.format(i, i).encode(), '') for i in range(20)] +z, c = obfuscate_files(many) +with zipfile.ZipFile(io.BytesIO(z)) as zf: + check(len(zf.namelist()) == 21, "20 файлов + csv") + +# ── 14. COMPANY_PATTERN не жадный ── +print("\n🔤 COMPANY_PATTERN границы:") +m = COMPANY_PATTERN.search("АО Test с дополнительным текстом дальше") +check(m is not None and 'дальше' not in m.group(0), "не захватывает лишний текст") + +# ── 15. PERSON_PATTERN только кириллица ── +print("\n🔤 PERSON_PATTERN кириллица:") +m = PERSON_PATTERN.search("Next Generation Cloud service") +check(m is None, "английский не матчится") + +m2 = PERSON_PATTERN.search("Иванов Иван Иванович") +check(m2 is not None, "русский матчится") + +# ── ИТОГО ── +print(f"\n{'='*60}") +if errors: + print(f"❌ {errors} ошибок") +else: + print("✅ ВСЕ ТЕСТЫ ПРОЙДЕНЫ") +print(f"{'='*60}") +sys.exit(0 if errors == 0 else 1) diff --git a/site/services/drhider.py b/site/services/drhider.py index 9e9b484..268cf02 100644 --- a/site/services/drhider.py +++ b/site/services/drhider.py @@ -25,7 +25,7 @@ log = logging.getLogger("drhider") # ═══════════════════════════════════════════ ENTITY_PATTERNS: Dict[str, str] = { - "phone": r'\b(?:\+7|8)[\s\-]?\(?\d{3}\)?[\s\-]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2}\b', + "phone": r'(? bytes: - """Заменить сущности в docx-документе. Склеиваем runs → заменяем → пишем в первый run, остальные очищаем.""" + """Заменить сущности в docx. Склеиваем runs → заменяем → пишем в первый run, очищаем остальные.""" for para in doc.paragraphs: + if not para.runs: + continue full_text = "".join(run.text for run in para.runs) replaced = self._apply_replacements(full_text) - if replaced != full_text and para.runs: + if replaced != full_text: para.runs[0].text = replaced for run in para.runs[1:]: run.text = "" @@ -409,9 +411,11 @@ class TwoPassObfuscator: for row in table.rows: for cell in row.cells: for para in cell.paragraphs: + if not para.runs: + continue full_text = "".join(run.text for run in para.runs) replaced = self._apply_replacements(full_text) - if replaced != full_text and para.runs: + if replaced != full_text: para.runs[0].text = replaced for run in para.runs[1:]: run.text = ""