fix: phone negative lookbehind, docx runs guard, 15 tests
Deploy contracts-flask / validate (push) Successful in 0s

This commit is contained in:
2026-06-29 18:26:54 +04:00
parent 2ebdaad2aa
commit 78f0e4d269
3 changed files with 165 additions and 98 deletions
+8 -4
View File
@@ -25,7 +25,7 @@ log = logging.getLogger("drhider")
# ═══════════════════════════════════════════ # ═══════════════════════════════════════════
ENTITY_PATTERNS: Dict[str, str] = { 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'(?<!\d)(?:\+7|8)[\s\-]?\(?\d{3}\)?[\s\-]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2}(?!\d)',
"email": r'\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b', "email": r'\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b',
# 12-значный ИНН ДО 10-значного (иначе 12-значный матчится как 10) # 12-значный ИНН ДО 10-значного (иначе 12-значный матчится как 10)
"inn_fl": r'ИНН\s*\d{12}', "inn_fl": r'ИНН\s*\d{12}',
@@ -396,11 +396,13 @@ class TwoPassObfuscator:
# --- Проход 2: замена --- # --- Проход 2: замена ---
def _replace_in_docx(self, doc) -> bytes: def _replace_in_docx(self, doc) -> bytes:
"""Заменить сущности в docx-документе. Склеиваем runs → заменяем → пишем в первый run, остальные очищаем.""" """Заменить сущности в docx. Склеиваем runs → заменяем → пишем в первый run, очищаем остальные."""
for para in doc.paragraphs: for para in doc.paragraphs:
if not para.runs:
continue
full_text = "".join(run.text for run in para.runs) full_text = "".join(run.text for run in para.runs)
replaced = self._apply_replacements(full_text) replaced = self._apply_replacements(full_text)
if replaced != full_text and para.runs: if replaced != full_text:
para.runs[0].text = replaced para.runs[0].text = replaced
for run in para.runs[1:]: for run in para.runs[1:]:
run.text = "" run.text = ""
@@ -409,9 +411,11 @@ class TwoPassObfuscator:
for row in table.rows: for row in table.rows:
for cell in row.cells: for cell in row.cells:
for para in cell.paragraphs: for para in cell.paragraphs:
if not para.runs:
continue
full_text = "".join(run.text for run in para.runs) full_text = "".join(run.text for run in para.runs)
replaced = self._apply_replacements(full_text) replaced = self._apply_replacements(full_text)
if replaced != full_text and para.runs: if replaced != full_text:
para.runs[0].text = replaced para.runs[0].text = replaced
for run in para.runs[1:]: for run in para.runs[1:]:
run.text = "" run.text = ""
+149 -90
View File
@@ -1,103 +1,162 @@
""" """
Тест DrHider — сравнение оригинал vs обфусцированный. Тест DrHider — много проверок.
Файл: /home/naeel/nubes/contracts/contracts-flask/deploy/tests/test_drhider.py Файл: /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__), '..')) 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" 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): errors = 0
"""Извлечь весь текст из 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
def check(ok, msg):
def test(): global errors
with open(TEST_ZIP, 'rb') as f: if ok:
content = f.read() print(f"{msg}")
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)
else: else:
print("✅ ВСЕ ПРОВЕРКИ ПРОЙДЕНЫ") print(f"{msg}")
sys.exit(0) errors += 1
print("=" * 60)
print("DrHider — полный тест")
print("=" * 60)
if __name__ == '__main__': # ── 1. Телефоны ──
test() 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)
+8 -4
View File
@@ -25,7 +25,7 @@ log = logging.getLogger("drhider")
# ═══════════════════════════════════════════ # ═══════════════════════════════════════════
ENTITY_PATTERNS: Dict[str, str] = { 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'(?<!\d)(?:\+7|8)[\s\-]?\(?\d{3}\)?[\s\-]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2}(?!\d)',
"email": r'\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b', "email": r'\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b',
# 12-значный ИНН ДО 10-значного (иначе 12-значный матчится как 10) # 12-значный ИНН ДО 10-значного (иначе 12-значный матчится как 10)
"inn_fl": r'ИНН\s*\d{12}', "inn_fl": r'ИНН\s*\d{12}',
@@ -396,11 +396,13 @@ class TwoPassObfuscator:
# --- Проход 2: замена --- # --- Проход 2: замена ---
def _replace_in_docx(self, doc) -> bytes: def _replace_in_docx(self, doc) -> bytes:
"""Заменить сущности в docx-документе. Склеиваем runs → заменяем → пишем в первый run, остальные очищаем.""" """Заменить сущности в docx. Склеиваем runs → заменяем → пишем в первый run, очищаем остальные."""
for para in doc.paragraphs: for para in doc.paragraphs:
if not para.runs:
continue
full_text = "".join(run.text for run in para.runs) full_text = "".join(run.text for run in para.runs)
replaced = self._apply_replacements(full_text) replaced = self._apply_replacements(full_text)
if replaced != full_text and para.runs: if replaced != full_text:
para.runs[0].text = replaced para.runs[0].text = replaced
for run in para.runs[1:]: for run in para.runs[1:]:
run.text = "" run.text = ""
@@ -409,9 +411,11 @@ class TwoPassObfuscator:
for row in table.rows: for row in table.rows:
for cell in row.cells: for cell in row.cells:
for para in cell.paragraphs: for para in cell.paragraphs:
if not para.runs:
continue
full_text = "".join(run.text for run in para.runs) full_text = "".join(run.text for run in para.runs)
replaced = self._apply_replacements(full_text) replaced = self._apply_replacements(full_text)
if replaced != full_text and para.runs: if replaced != full_text:
para.runs[0].text = replaced para.runs[0].text = replaced
for run in para.runs[1:]: for run in para.runs[1:]:
run.text = "" run.text = ""