fix: Opus review — all 5 critical bugs fixed
Deploy contracts-flask / validate (push) Successful in 0s

This commit is contained in:
2026-06-29 15:25:27 +04:00
parent 12416115ad
commit d8713561a4
3 changed files with 105 additions and 82 deletions
@@ -0,0 +1,21 @@
# Результаты ревью Опуса — DrHider v0.1
29.06.2026
## Критичные баги
| # | Баг | Серьёзность |
|---|-----|-------------|
| 1 | `inn_ul` (10 цифр) перед `inn_fl` (12) — 12-значный матчится как 10 | 🔴 |
| 2 | Префиксы (ИНН/ОГРН/КПП/БИК/р/с/к/с) теряются при замене | 🔴 |
| 3 | `passport` матчит любые 10-11 цифр (куски р/с, ОГРН, ИНН) | 🔴 |
| 4 | `.doc` не обрабатывается — текст пустой, файл уходит битым | 🔴 |
| 5 | PDF возвращается как `.pdf` но содержит UTF-8 текст → битый PDF | 🔴 |
| 6 | `_workdir` создаётся и удаляется, но не используется | 🟡 |
| 7 | `_apply_replacements` без границ слов — подстроки чисел могут зацепиться | 🟡 |
| 8 | CSV-тип для `inn_fl` ошибочно помечается как `inn_ul` | 🟡 |
| 9 | `.doc` в accept шаблона, но конвертация не реализована | 🟡 |
## Безопасность — ОК
`_mapping.clear()` в finally, нет логов с реальными данными, path traversal закрыт, лимиты 500/500MB есть, `esc()` в HTML.
+42 -41
View File
@@ -15,9 +15,7 @@ import random
import string import string
import zipfile import zipfile
import logging import logging
import tempfile
import os import os
import shutil
from typing import Dict, List, Tuple, Callable, Optional from typing import Dict, List, Tuple, Callable, Optional
log = logging.getLogger("drhider") log = logging.getLogger("drhider")
@@ -29,14 +27,15 @@ log = logging.getLogger("drhider")
ENTITY_PATTERNS: Dict[str, str] = { ENTITY_PATTERNS: Dict[str, str] = {
"phone": r'(?:\+7|8)[\s\-]?\(?\d{3}\)?[\s\-]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2}', "phone": r'(?:\+7|8)[\s\-]?\(?\d{3}\)?[\s\-]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2}',
"email": r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}', "email": r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}',
"inn_ul": r'ИНН\s*\d{10}', # 12-значный ИНН ДО 10-значного (иначе 12-значный матчится как 10)
"inn_fl": r'ИНН\s*\d{12}', "inn_fl": r'ИНН\s*\d{12}',
"inn_ul": r'ИНН\s*\d{10}',
"ogrn": r'ОГРН\s*\d{13}', "ogrn": r'ОГРН\s*\d{13}',
"kpp": r'КПП\s*\d{9}', "kpp": r'КПП\s*\d{9}',
"bik": r'БИК\s*\d{9}', "bik": r'БИК\s*\d{9}',
"rs": r'(?:р/с|расч[её]тный\s*сч[её]т)\s*\d{20}', "rs": r'(?:р/с|расч[её]тный\s*сч[её]т)\s*\d{20}',
"ks": r'(?:к/с|корр?[еи]?спондентский\s*сч[её]т)\s*\d{20}', "ks": r'(?:к/с|корр?[еи]?спондентский\s*сч[её]т)\s*\d{20}',
"passport": r'\d{2}\s*\d{2}\s*\d{6,7}', "passport": r'(?:паспорт|серия\s+номер)[\s:№]*\d{2}\s*\d{2}\s*\d{6,7}',
} }
# Фирмы — обнаружение по шаблону # Фирмы — обнаружение по шаблону
@@ -110,39 +109,47 @@ def generate_email(original: str) -> str:
return f"{local}@{domain}" return f"{local}@{domain}"
def generate_inn10(_: str) -> str: def generate_inn10(original: str) -> str:
prefix = re.match(r'ИНН\s*', original).group(0)
base = f"{random.randint(1,9)}{_random_digits(8)}" base = f"{random.randint(1,9)}{_random_digits(8)}"
return base + _checksum_inn10(base) return prefix + base + _checksum_inn10(base)
def generate_inn12(_: str) -> str: def generate_inn12(original: str) -> str:
prefix = re.match(r'ИНН\s*', original).group(0)
base = f"{random.randint(1,9)}{_random_digits(9)}" base = f"{random.randint(1,9)}{_random_digits(9)}"
return base + _checksum_inn12(base) return prefix + base + _checksum_inn12(base)
def generate_ogrn(_: str) -> str: def generate_ogrn(original: str) -> str:
prefix = re.match(r'ОГРН\s*', original).group(0)
base = "1" + _random_digits(11) base = "1" + _random_digits(11)
return base + _checksum_ogrn(base) return prefix + base + _checksum_ogrn(base)
def generate_kpp(_: str) -> str: def generate_kpp(original: str) -> str:
return _random_digits(4) + random.choice(["01", "43", "77"]) + _random_digits(3) prefix = re.match(r'КПП\s*', original).group(0)
return prefix + _random_digits(4) + random.choice(["01", "43", "77"]) + _random_digits(3)
def generate_bik(_: str) -> str: def generate_bik(original: str) -> str:
return "04" + _random_digits(7) prefix = re.match(r'БИК\s*', original).group(0)
return prefix + "04" + _random_digits(7)
def generate_rs(_: str) -> str: def generate_rs(original: str) -> str:
return "40702" + _random_digits(15) prefix = re.match(r'(?:р/с|расч[её]тный\s*сч[её]т)\s*', original).group(0)
return prefix + "40702" + _random_digits(15)
def generate_ks(_: str) -> str: def generate_ks(original: str) -> str:
return "30101" + _random_digits(15) prefix = re.match(r'(?:к/с|корр?[еи]?спондентский\s*сч[её]т)\s*', original).group(0)
return prefix + "30101" + _random_digits(15)
def generate_passport(_: str) -> str: def generate_passport(original: str) -> str:
return f"{random.randint(10,99)} {random.randint(10,99)} {_random_digits(6)}" prefix = re.match(r'(?:паспорт|серия\s+номер)[\s:№]*', original, re.IGNORECASE).group(0)
return prefix + f"{random.randint(10,99)} {random.randint(10,99)} {_random_digits(6)}"
def generate_company(_: str) -> str: def generate_company(_: str) -> str:
@@ -177,7 +184,6 @@ class TwoPassObfuscator:
self._mapping: Dict[str, str] = {} # оригинал → замена (только для точных текстовых совпадений) self._mapping: Dict[str, str] = {} # оригинал → замена (только для точных текстовых совпадений)
self._regex_replacements: List[Tuple[str, str, Callable]] = [] # (pattern, type, generator) self._regex_replacements: List[Tuple[str, str, Callable]] = [] # (pattern, type, generator)
self._llm_client = llm_client self._llm_client = llm_client
self._workdir: Optional[str] = None
def obfuscate(self, files: List[Tuple[str, bytes, str]]) -> Tuple[bytes, str]: def obfuscate(self, files: List[Tuple[str, bytes, str]]) -> Tuple[bytes, str]:
""" """
@@ -189,14 +195,13 @@ class TwoPassObfuscator:
Returns: Returns:
(zip_bytes, mapping_csv_string) (zip_bytes, mapping_csv_string)
""" """
self._workdir = tempfile.mkdtemp(prefix="drhider_") # --- Распаковать ZIP-файлы ---
try: files = self._expand_zips(files)
# --- Распаковать ZIP-файлы ---
files = self._expand_zips(files)
try:
# --- Проход 1: сбор сущностей --- # --- Проход 1: сбор сущностей ---
all_texts: Dict[str, str] = {} # filename → text for LLM NER all_texts: Dict[str, str] = {}
all_docx: Dict[str, object] = {} # filename → docx Document for replacement all_docx: Dict[str, object] = {}
for fname, content, ctype in files: for fname, content, ctype in files:
text, doc = self._extract_text(fname, content, ctype) text, doc = self._extract_text(fname, content, ctype)
@@ -205,7 +210,6 @@ class TwoPassObfuscator:
all_docx[fname] = doc all_docx[fname] = doc
self._scan_regex(text) self._scan_regex(text)
# LLM NER для имён/адресов (если есть клиент)
if self._llm_client: if self._llm_client:
self._scan_llm_ner(all_texts) self._scan_llm_ner(all_texts)
@@ -214,24 +218,20 @@ class TwoPassObfuscator:
for fname, content, ctype in files: for fname, content, ctype in files:
obf_content = content obf_content = content
if fname in all_docx: if fname in all_docx:
# Замена внутри docx
obf_content = self._replace_in_docx(all_docx[fname]) obf_content = self._replace_in_docx(all_docx[fname])
elif fname.endswith('.pdf'): elif fname.endswith('.pdf'):
# PDF: read-only, создаём новый
obf_content = self._replace_in_text(all_texts.get(fname, ''), fname)
else:
# .doc или другой — замена по тексту
txt = all_texts.get(fname, '') txt = all_texts.get(fname, '')
obf_content = self._replace_in_text(txt, fname) obf_content = self._replace_in_text(txt, fname)
fname = fname[:-4] + '.txt'
else:
txt = all_texts.get(fname, '')
obf_content = self._replace_in_text(txt, fname)
results.append((fname, obf_content)) results.append((fname, obf_content))
# Собираем ZIP + CSV
csv_str = self._build_mapping_csv() csv_str = self._build_mapping_csv()
return self._build_zip(results, csv_str), csv_str return self._build_zip(results, csv_str), csv_str
finally: finally:
shutil.rmtree(self._workdir, ignore_errors=True)
self._mapping.clear() self._mapping.clear()
self._regex_replacements.clear() self._regex_replacements.clear()
@@ -302,8 +302,8 @@ class TwoPassObfuscator:
text += "\n" + " | ".join(str(c) if c else "" for c in row) text += "\n" + " | ".join(str(c) if c else "" for c in row)
elif ext == '.doc': elif ext == '.doc':
# .doc конвертируется в .docx ДО вызова — здесь уже docx # .doc: читаем как текст (конвертация через libreoffice не используется)
pass text = content.decode('utf-8', errors='replace')
else: else:
text = content.decode('utf-8', errors='replace') text = content.decode('utf-8', errors='replace')
@@ -421,10 +421,11 @@ class TwoPassObfuscator:
writer = csv.writer(buf) writer = csv.writer(buf)
writer.writerow(["тип_данных", "оригинал", "замена"]) writer.writerow(["тип_данных", "оригинал", "замена"])
for original, replacement in sorted(self._mapping.items()): for original, replacement in sorted(self._mapping.items()):
# Определяем тип по паттерну
etype = "text" etype = "text"
for t, pat in ENTITY_PATTERNS.items(): # inn_fl ДО inn_ul (12 цифр vs 10)
if re.match(pat, original, re.IGNORECASE): for t in ["phone", "email", "inn_fl", "inn_ul", "ogrn", "kpp", "bik", "rs", "ks", "passport"]:
pat = ENTITY_PATTERNS.get(t, "")
if pat and re.match(pat, original, re.IGNORECASE):
etype = t etype = t
break break
if COMPANY_PATTERN.match(original): if COMPANY_PATTERN.match(original):
+42 -41
View File
@@ -15,9 +15,7 @@ import random
import string import string
import zipfile import zipfile
import logging import logging
import tempfile
import os import os
import shutil
from typing import Dict, List, Tuple, Callable, Optional from typing import Dict, List, Tuple, Callable, Optional
log = logging.getLogger("drhider") log = logging.getLogger("drhider")
@@ -29,14 +27,15 @@ log = logging.getLogger("drhider")
ENTITY_PATTERNS: Dict[str, str] = { ENTITY_PATTERNS: Dict[str, str] = {
"phone": r'(?:\+7|8)[\s\-]?\(?\d{3}\)?[\s\-]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2}', "phone": r'(?:\+7|8)[\s\-]?\(?\d{3}\)?[\s\-]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2}',
"email": r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}', "email": r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}',
"inn_ul": r'ИНН\s*\d{10}', # 12-значный ИНН ДО 10-значного (иначе 12-значный матчится как 10)
"inn_fl": r'ИНН\s*\d{12}', "inn_fl": r'ИНН\s*\d{12}',
"inn_ul": r'ИНН\s*\d{10}',
"ogrn": r'ОГРН\s*\d{13}', "ogrn": r'ОГРН\s*\d{13}',
"kpp": r'КПП\s*\d{9}', "kpp": r'КПП\s*\d{9}',
"bik": r'БИК\s*\d{9}', "bik": r'БИК\s*\d{9}',
"rs": r'(?:р/с|расч[её]тный\s*сч[её]т)\s*\d{20}', "rs": r'(?:р/с|расч[её]тный\s*сч[её]т)\s*\d{20}',
"ks": r'(?:к/с|корр?[еи]?спондентский\s*сч[её]т)\s*\d{20}', "ks": r'(?:к/с|корр?[еи]?спондентский\s*сч[её]т)\s*\d{20}',
"passport": r'\d{2}\s*\d{2}\s*\d{6,7}', "passport": r'(?:паспорт|серия\s+номер)[\s:№]*\d{2}\s*\d{2}\s*\d{6,7}',
} }
# Фирмы — обнаружение по шаблону # Фирмы — обнаружение по шаблону
@@ -110,39 +109,47 @@ def generate_email(original: str) -> str:
return f"{local}@{domain}" return f"{local}@{domain}"
def generate_inn10(_: str) -> str: def generate_inn10(original: str) -> str:
prefix = re.match(r'ИНН\s*', original).group(0)
base = f"{random.randint(1,9)}{_random_digits(8)}" base = f"{random.randint(1,9)}{_random_digits(8)}"
return base + _checksum_inn10(base) return prefix + base + _checksum_inn10(base)
def generate_inn12(_: str) -> str: def generate_inn12(original: str) -> str:
prefix = re.match(r'ИНН\s*', original).group(0)
base = f"{random.randint(1,9)}{_random_digits(9)}" base = f"{random.randint(1,9)}{_random_digits(9)}"
return base + _checksum_inn12(base) return prefix + base + _checksum_inn12(base)
def generate_ogrn(_: str) -> str: def generate_ogrn(original: str) -> str:
prefix = re.match(r'ОГРН\s*', original).group(0)
base = "1" + _random_digits(11) base = "1" + _random_digits(11)
return base + _checksum_ogrn(base) return prefix + base + _checksum_ogrn(base)
def generate_kpp(_: str) -> str: def generate_kpp(original: str) -> str:
return _random_digits(4) + random.choice(["01", "43", "77"]) + _random_digits(3) prefix = re.match(r'КПП\s*', original).group(0)
return prefix + _random_digits(4) + random.choice(["01", "43", "77"]) + _random_digits(3)
def generate_bik(_: str) -> str: def generate_bik(original: str) -> str:
return "04" + _random_digits(7) prefix = re.match(r'БИК\s*', original).group(0)
return prefix + "04" + _random_digits(7)
def generate_rs(_: str) -> str: def generate_rs(original: str) -> str:
return "40702" + _random_digits(15) prefix = re.match(r'(?:р/с|расч[её]тный\s*сч[её]т)\s*', original).group(0)
return prefix + "40702" + _random_digits(15)
def generate_ks(_: str) -> str: def generate_ks(original: str) -> str:
return "30101" + _random_digits(15) prefix = re.match(r'(?:к/с|корр?[еи]?спондентский\s*сч[её]т)\s*', original).group(0)
return prefix + "30101" + _random_digits(15)
def generate_passport(_: str) -> str: def generate_passport(original: str) -> str:
return f"{random.randint(10,99)} {random.randint(10,99)} {_random_digits(6)}" prefix = re.match(r'(?:паспорт|серия\s+номер)[\s:№]*', original, re.IGNORECASE).group(0)
return prefix + f"{random.randint(10,99)} {random.randint(10,99)} {_random_digits(6)}"
def generate_company(_: str) -> str: def generate_company(_: str) -> str:
@@ -177,7 +184,6 @@ class TwoPassObfuscator:
self._mapping: Dict[str, str] = {} # оригинал → замена (только для точных текстовых совпадений) self._mapping: Dict[str, str] = {} # оригинал → замена (только для точных текстовых совпадений)
self._regex_replacements: List[Tuple[str, str, Callable]] = [] # (pattern, type, generator) self._regex_replacements: List[Tuple[str, str, Callable]] = [] # (pattern, type, generator)
self._llm_client = llm_client self._llm_client = llm_client
self._workdir: Optional[str] = None
def obfuscate(self, files: List[Tuple[str, bytes, str]]) -> Tuple[bytes, str]: def obfuscate(self, files: List[Tuple[str, bytes, str]]) -> Tuple[bytes, str]:
""" """
@@ -189,14 +195,13 @@ class TwoPassObfuscator:
Returns: Returns:
(zip_bytes, mapping_csv_string) (zip_bytes, mapping_csv_string)
""" """
self._workdir = tempfile.mkdtemp(prefix="drhider_") # --- Распаковать ZIP-файлы ---
try: files = self._expand_zips(files)
# --- Распаковать ZIP-файлы ---
files = self._expand_zips(files)
try:
# --- Проход 1: сбор сущностей --- # --- Проход 1: сбор сущностей ---
all_texts: Dict[str, str] = {} # filename → text for LLM NER all_texts: Dict[str, str] = {}
all_docx: Dict[str, object] = {} # filename → docx Document for replacement all_docx: Dict[str, object] = {}
for fname, content, ctype in files: for fname, content, ctype in files:
text, doc = self._extract_text(fname, content, ctype) text, doc = self._extract_text(fname, content, ctype)
@@ -205,7 +210,6 @@ class TwoPassObfuscator:
all_docx[fname] = doc all_docx[fname] = doc
self._scan_regex(text) self._scan_regex(text)
# LLM NER для имён/адресов (если есть клиент)
if self._llm_client: if self._llm_client:
self._scan_llm_ner(all_texts) self._scan_llm_ner(all_texts)
@@ -214,24 +218,20 @@ class TwoPassObfuscator:
for fname, content, ctype in files: for fname, content, ctype in files:
obf_content = content obf_content = content
if fname in all_docx: if fname in all_docx:
# Замена внутри docx
obf_content = self._replace_in_docx(all_docx[fname]) obf_content = self._replace_in_docx(all_docx[fname])
elif fname.endswith('.pdf'): elif fname.endswith('.pdf'):
# PDF: read-only, создаём новый
obf_content = self._replace_in_text(all_texts.get(fname, ''), fname)
else:
# .doc или другой — замена по тексту
txt = all_texts.get(fname, '') txt = all_texts.get(fname, '')
obf_content = self._replace_in_text(txt, fname) obf_content = self._replace_in_text(txt, fname)
fname = fname[:-4] + '.txt'
else:
txt = all_texts.get(fname, '')
obf_content = self._replace_in_text(txt, fname)
results.append((fname, obf_content)) results.append((fname, obf_content))
# Собираем ZIP + CSV
csv_str = self._build_mapping_csv() csv_str = self._build_mapping_csv()
return self._build_zip(results, csv_str), csv_str return self._build_zip(results, csv_str), csv_str
finally: finally:
shutil.rmtree(self._workdir, ignore_errors=True)
self._mapping.clear() self._mapping.clear()
self._regex_replacements.clear() self._regex_replacements.clear()
@@ -302,8 +302,8 @@ class TwoPassObfuscator:
text += "\n" + " | ".join(str(c) if c else "" for c in row) text += "\n" + " | ".join(str(c) if c else "" for c in row)
elif ext == '.doc': elif ext == '.doc':
# .doc конвертируется в .docx ДО вызова — здесь уже docx # .doc: читаем как текст (конвертация через libreoffice не используется)
pass text = content.decode('utf-8', errors='replace')
else: else:
text = content.decode('utf-8', errors='replace') text = content.decode('utf-8', errors='replace')
@@ -421,10 +421,11 @@ class TwoPassObfuscator:
writer = csv.writer(buf) writer = csv.writer(buf)
writer.writerow(["тип_данных", "оригинал", "замена"]) writer.writerow(["тип_данных", "оригинал", "замена"])
for original, replacement in sorted(self._mapping.items()): for original, replacement in sorted(self._mapping.items()):
# Определяем тип по паттерну
etype = "text" etype = "text"
for t, pat in ENTITY_PATTERNS.items(): # inn_fl ДО inn_ul (12 цифр vs 10)
if re.match(pat, original, re.IGNORECASE): for t in ["phone", "email", "inn_fl", "inn_ul", "ogrn", "kpp", "bik", "rs", "ks", "passport"]:
pat = ENTITY_PATTERNS.get(t, "")
if pat and re.match(pat, original, re.IGNORECASE):
etype = t etype = t
break break
if COMPANY_PATTERN.match(original): if COMPANY_PATTERN.match(original):