fix: phone negative lookbehind, docx runs guard, 15 tests
Deploy contracts-flask / validate (push) Successful in 0s
Deploy contracts-flask / validate (push) Successful in 0s
This commit is contained in:
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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 = ""
|
||||||
|
|||||||
Reference in New Issue
Block a user