fix: аудит ветки source — критические и высокие замечания

- api_settings: убраны реальные API-ключи из кода (чтение из env/файла) — 1.1
- variables: {{is_security_requirements}} больше не подставляет всегда УЗ-4;
  extended_fields/defence_level передаются в контекст — 1.2
- defence.py: эталонная таблица ПП-1119 (п.9-12), 48 комбинаций;
  ui.py: авторасчёт УЗ переведён на неё (public больше не 'всегда 4',
  спец/био/общедоступные/иные разделены) — 2.1
- generator: убран дубль хвоста параграфа в _highlight_unmatched — 2.2
- importer: категории ПДн взаимоисключающие ('иные' только если нет
  спец/био); УЗ пересчитывается при импорте — 2.3
- import_settings(.py/.json): убран перехват 'администратора безопасности'
  правилом ответственного за ПДн — 3.1
- variables: {{category}}/{{is.category}} резолвятся из personal_data_category — 3.2
- verify: ИИ-предложения ИНН/ОГРН/КПП валидируются контрольными суммами
  перед применением — 3.3
- models: defence_level None вместо '' (не маскирует 'не рассчитано')
- .gitignore: api_settings.json/.env не попадают в git
- test_defence.py: юнит-тест всех 48 комбинаций таблицы УЗ
This commit is contained in:
LocoAgent
2026-08-05 08:58:17 +04:00
parent ac122451b3
commit 230a392a15
12 changed files with 277 additions and 57 deletions
+5
View File
@@ -3,3 +3,8 @@ __pycache__/
.autosave/
Сгенерированные_документы/
*.result.docx
# Секреты и локальные настройки — НЕ коммитить
api_settings.json
.env
.env.local
+10 -4
View File
@@ -13,11 +13,17 @@ import os
import sys
import json
# ВНИМАНИЕ: реальные ключи НЕ хранятся в коде (утечка в git-историю — см. аудит).
# Источники ключей (по приоритету):
# 1) переменные окружения DADATA_TOKEN / DEEPSEEK_API_KEY / DEEPSEEK_MODEL / DEEPSEEK_BASE_URL
# 2) файл api_settings.json рядом с программой
# 3) DEFAULT_SETTINGS ниже (пустые значения)
DEFAULT_SETTINGS = {
'dadata_token': '431aa11706bf83f571cef0319259289aa750bcb1',
'ai_api_key': 'sk-3c7e6673f245412697d0e14b6096435f',
'ai_model': 'deepseek-chat',
'ai_base_url': 'https://api.deepseek.com',
'dadata_token': os.environ.get('DADATA_TOKEN', ''),
'ai_api_key': os.environ.get('DEEPSEEK_API_KEY', ''),
'ai_model': os.environ.get('DEEPSEEK_MODEL', 'deepseek-chat'),
'ai_base_url': os.environ.get('DEEPSEEK_BASE_URL', 'https://api.deepseek.com'),
}
# Старые ключи (для совместимости со старым файлом api_settings.json)
+86
View File
@@ -0,0 +1,86 @@
# -*- coding: utf-8 -*-
"""DokoGen — расчёт уровня защищённости ПДн по ПП РФ № 1119 от 01.11.2012.
Эталонная таблица (п. 9–12 ПП-1119), сверено с текстом постановления:
Специальные категории:
- угрозы 1 типа → УЗ-1 (любое количество, п.9а)
- угрозы 2 типа: >100к не-сотрудников → УЗ-1 (п.9б); сотрудники или ≤100к → УЗ-2 (п.10б)
- угрозы 3 типа: >100к не-сотрудников → УЗ-2 (п.10е); сотрудники или ≤100к → УЗ-3 (п.11в)
Биометрические:
- угрозы 1 типа → УЗ-1 (п.9а); угрозы 2 типа → УЗ-2 (п.10в); угрозы 3 типа → УЗ-3 (п.11г)
(без разбивки по количеству)
Общедоступные:
- угрозы 1 типа → УЗ-2 (п.10а)
- угрозы 2 типа: >100к → УЗ-2 (п.10г); сотрудники или ≤100к → УЗ-3 (п.11а)
- угрозы 3 типа → УЗ-4 (п.12а)
Иные категории:
- угрозы 1 типа → УЗ-1 (п.9а)
- угрозы 2 типа: >100к не-сотрудников → УЗ-2 (п.10д); сотрудники или ≤100к → УЗ-3 (п.11б)
- угрозы 3 типа: >100к не-сотрудников → УЗ-3 (п.11д); сотрудники или ≤100к → УЗ-4 (п.12б)
Используется и в авторасчёте (ui.py), и при импорте (importer.py).
"""
from typing import List
def calc_defence_level(
categories: List[str],
is_employees: bool,
is_large: bool,
threat_type: str,
) -> int:
"""Расчёт уровня защищённости ПДн (1–4) по ПП РФ № 1119.
categories — список категорий ПДн ('специальные', 'биометрические',
'общедоступные', 'иные').
is_employees — True, если обрабатываются ПДн только сотрудников оператора.
is_large — True, если количество субъектов > 100 000.
threat_type — тип актуальных угроз: '1', '2' или '3'.
"""
threat = str(threat_type or '3').strip()
has = lambda *names: any(n in categories for n in names)
# Приоритет категорий: специальные > биометрические > общедоступные > иные
if has('специальные'):
return _special(threat, is_employees, is_large)
if has('биометрические'):
return _bio(threat)
if has('общедоступные') and not has('иные'):
return _public(threat, is_employees, is_large)
return _other(threat, is_employees, is_large)
def _special(threat: str, employees: bool, large: bool) -> int:
if threat == '1':
return 1
if threat == '2':
return 1 if (not employees and large) else 2
# threat == '3'
return 2 if (not employees and large) else 3
def _bio(threat: str) -> int:
return {'1': 1, '2': 2, '3': 3}.get(threat, 3)
def _public(threat: str, employees: bool, large: bool) -> int:
if threat == '1':
return 2
if threat == '2':
return 2 if (not employees and large) else 3
# threat == '3'
return 4
def _other(threat: str, employees: bool, large: bool) -> int:
if threat == '1':
return 1
if threat == '2':
return 2 if (not employees and large) else 3
# threat == '3'
return 3 if (not employees and large) else 4
-3
View File
@@ -692,9 +692,6 @@ def _highlight_unmatched(doc):
pos = m.end()
if pos < len(full):
_add_run_with_tabs(para, full[pos:], first)
run = para.add_run(full[pos:])
if first is not None:
_copy_run_style(first, run)
for para in doc.paragraphs:
_process_para(para)
+2 -2
View File
@@ -77,13 +77,13 @@
"name": "Должность ответственного за ПДн",
"var": "ispdnPosition",
"cell": "",
"keywords": "должность.*защит.*информ|должность.*администрат"
"keywords": "должность.*защит.*информ|должность.*ответственн.*пдн"
},
{
"name": "ФИО ответственного за ПДн",
"var": "ispdnFio",
"cell": "",
"keywords": "фио.*защит.*информ|фио.*администрат"
"keywords": "фио.*защит.*информ|фио.*ответственн.*пдн"
},
{
"name": "Email ответственного за ПДн",
+4 -4
View File
@@ -69,8 +69,10 @@ DEFAULT_SETTINGS = {
_f('Основной ОКВЭД', 'okved', keywords=r'основной оквэд'),
_f('Должность руководителя', 'chiefPosition', keywords=r'должность руководителя'),
_f('ФИО руководителя', 'chiefFio', keywords=r'фио руководителя'),
_f('Должность ответственного за ПДн', 'ispdnPosition', keywords=r'должность.*защит.*информ|должность.*администрат'),
_f('ФИО ответственного за ПДн', 'ispdnFio', keywords=r'фио.*защит.*информ|фио.*администрат'),
_f('Должность администратора безопасности', 'administratorPosition', keywords=r'должность.*администрат.*(?:безопас|защит|пд)'),
_f('ФИО администратора безопасности', 'administratorFio', keywords=r'фио.*администрат.*(?:безопас|защит|пд)'),
_f('Должность ответственного за ПДн', 'ispdnPosition', keywords=r'должность.*защит.*информ|должность.*ответственн.*пдн'),
_f('ФИО ответственного за ПДн', 'ispdnFio', keywords=r'фио.*защит.*информ|фио.*ответственн.*пдн'),
_f('Email ответственного за ПДн', 'ispdnEmail', keywords=r'электронная почта.*защит|электронная почта.*ответств|электронная почта.*специалист'),
_f('Телефон ответственного за ПДн', 'ispdnPhone', keywords=r'телефон.*защит|телефон.*ответств|телефон.*специалист'),
_f('Структурное подразделение по безопасности', 'ispdnDepartment', keywords=r'структур.*подраздел'),
@@ -80,8 +82,6 @@ DEFAULT_SETTINGS = {
_f('Место хранения документов', 'paperStorage', keywords=r'место.*хранен'),
_f('Должность ответственного за обработку ПДн', 'responsiblePosition', keywords=r'должность.*ответственн'),
_f('ФИО ответственного за обработку ПДн', 'responsibleFio', keywords=r'фио.*ответственн'),
_f('Должность администратора безопасности', 'administratorPosition', keywords=r'должность.*администрат.*(?:безопас|защит|пд)'),
_f('ФИО администратора безопасности', 'administratorFio', keywords=r'фио.*администрат.*(?:безопас|защит|пд)'),
_f('Сайт организации', 'site_name', keywords=r'сайт организации'),
_f('Должность ответственного за сайт', 'site_responsible_position', keywords=r'должность ответственного за сайт|должность.*отв.*сайт'),
_f('ФИО ответственного за сайт', 'site_responsible_fio', keywords=r'фио.*отв.*сайт|фио.*сайт'),
+15 -2
View File
@@ -425,19 +425,32 @@ def _import_152_is_list(wb, data, settings, log_fn):
log_fn(f" ⚠ ИС без названия — использовано имя листа: {is_obj['name']}")
# Автоопределение категорий ПДн
# По 152-ФЗ категории взаимоисключающие: если найдены специальные/биометрические —
# «иные» не добавляем, иначе расчёт УЗ по ПП-1119 пойдёт по неверной категории.
pd_cats = []
all_pd_text = ' '.join(p.lower() for p in pd_items_raw)
special_kw = ['состоян.*здоров', 'национальн', 'политическ', 'религиозн', 'философ', 'судимост', 'интимн']
if any(re.search(kw, all_pd_text) for kw in special_kw):
pd_cats.append('специальные')
bio_kw = ['биометрическ', 'изображен.*лиц', 'голос.*человек', 'папилляр', 'дактилоскоп', 'фото.*изображен']
if any(re.search(kw, all_pd_text) for kw in bio_kw):
if not pd_cats and any(re.search(kw, all_pd_text) for kw in bio_kw):
pd_cats.append('биометрические')
if pd_items_raw:
if pd_items_raw and not pd_cats:
pd_cats.append('иные')
if pd_cats:
is_obj['personalDataCategory'] = pd_cats
log_fn(f" Категории ПДн: {', '.join(pd_cats)}")
# Автопересчёт УЗ по ПП-1119 (тип угроз по умолчанию — 3, оператор уточнит)
try:
from .defence import calc_defence_level
subj = is_obj.get('pd_subjects_list') or []
has_only_employees = len(subj) > 0 and all('сотрудник' in s.lower() for s in subj)
is_employees = has_only_employees or len(subj) == 0
is_large = 'более' in str(is_obj.get('personalDataCount', '')).lower()
is_obj['defenceLevel'] = str(calc_defence_level(pd_cats, is_employees, is_large, '3'))
log_fn(f" УЗ (авто, угрозы 3 типа): {is_obj['defenceLevel']}")
except Exception:
pass
is_list.append(is_obj)
log_fn(f"{is_obj['name']} — импортирована")
+2 -2
View File
@@ -2,7 +2,7 @@
"""DokoGen — Модели данных (только 152-ФЗ, только нужные поля)"""
from dataclasses import dataclass, field, asdict
from typing import List, Dict, Any
from typing import List, Dict, Any, Optional
@dataclass
@@ -20,7 +20,7 @@ class InformationSystem:
description: str = ""
is_local_network: bool = False
is_internet: bool = False
defence_level: str = ""
defence_level: Optional[str] = None # None = «не рассчитано» (в отличие от пустой строки)
threat_type: str = "3"
category: str = ""
pd_list: str = ""
+4 -36
View File
@@ -23,6 +23,7 @@ from .verify import (
check_declension_ai, check_company_ai,
COMPANY_FIELDS, compare_company_values,
)
from .defence import calc_defence_level
from .api_settings import load_api_settings, save_api_settings
@@ -1311,42 +1312,8 @@ class DokoGenApp:
has_only_employees = len(subj_items) > 0 and all('сотрудник' in s.lower() for s in subj_items)
is_employees = has_only_employees or len(subj_items) == 0
# Таблица ПП №1119: (категория, тип_субъектов, объём, тип_угроз) → УЗ
def lookup(cat_group, employees, large, threat):
# Общедоступные → всегда 4
if cat_group == 'public':
return 4
# Специальные/биометрические
if cat_group == 'special_bio':
tbl = {
('emp', False, '1'): 2, ('emp', True, '1'): 1,
('other', False, '1'): 1, ('other', True, '1'): 1,
('emp', False, '2'): 2, ('emp', True, '2'): 1,
('other', False, '2'): 2, ('other', True, '2'): 1,
('emp', False, '3'): 3, ('emp', True, '3'): 2,
('other', False, '3'): 3, ('other', True, '3'): 2,
}
else: # иные
tbl = {
('emp', False, '1'): 3, ('emp', True, '1'): 2,
('other', False, '1'): 2, ('other', True, '1'): 1,
('emp', False, '2'): 4, ('emp', True, '2'): 3,
('other', False, '2'): 3, ('other', True, '2'): 2,
('emp', False, '3'): 4, ('emp', True, '3'): 4,
('other', False, '3'): 4, ('other', True, '3'): 3,
}
key = ('emp' if employees else 'other', large, threat)
return tbl.get(key, 4)
# Определяем группу категорий
if 'общедоступные' in categories and not any(c in categories for c in ['специальные', 'биометрические', 'иные']):
cat_group = 'public'
elif any(c in categories for c in ['специальные', 'биометрические']):
cat_group = 'special_bio'
else:
cat_group = 'other'
level = lookup(cat_group, is_employees, is_large, threat_type)
# Расчёт УЗ по эталонной таблице ПП РФ №1119 (п.9-12) — dokogen/defence.py
level = calc_defence_level(categories, is_employees, is_large, threat_type)
self.is_defence_var.set(str(level))
def _on_categories_or_count_changed(self):
@@ -1959,6 +1926,7 @@ class DokoGenApp:
room=is_data.get('room', ''),
structure=is_data.get('structure', ''),
pd_actions_list=is_data.get('pd_actions', []),
defence_level=is_data.get('defenceLevel', ''),
)
self.model.information_systems.append(isys)
self._refresh_is_listbox()
+17 -4
View File
@@ -139,8 +139,14 @@ def build_replacements(
doc_number: str = "",
doc_date: str = "",
direction: str = "152fz",
extended_fields: Dict = None,
) -> Dict[str, str]:
"""Строит полный словарь замен {{переменная}} → значение."""
"""Строит полный словарь замен {{переменная}} → значение.
extended_fields доп. поля для контекста (например, {'defence_level': '2'}).
Если не задано уровень берётся из первой ИС (is_list[0].defence_level),
чтобы {{is_security_requirements}} не подставлял всегда УЗ-4.
"""
if is_list is None:
is_list = []
@@ -148,6 +154,10 @@ def build_replacements(
commission_data = _build_commission_data(company)
# ---------- Контекст ----------
if not extended_fields and is_list:
dl = getattr(is_list[0], 'defence_level', '') or ''
if dl:
extended_fields = {'defence_level': dl}
ctx = {
'company': company,
'is_list': is_list,
@@ -155,6 +165,7 @@ def build_replacements(
'doc_number': doc_number or '',
'doc_date': doc_date or '',
'direction': direction,
'extended_fields': extended_fields or {},
}
replacements = {}
@@ -447,7 +458,7 @@ def _resolve(source: str, ctx: Dict) -> str:
field_map = {
'name': 'name',
'description': 'description',
'category': 'category',
'category': lambda: ', '.join(first.personal_data_category) or first.category or '',
'defence_level': 'defence_level',
'threat_type': 'threat_type',
'pd_list': lambda: ', '.join(first.personal_data_list or []),
@@ -502,10 +513,12 @@ def _get_fio(company: Company, who: str) -> str:
def _get_security_text(ctx: Dict) -> str:
"""Возвращает текст требований для уровня защищённости."""
defence_level = ctx.get('extended_fields', {}).get('defence_level', '4')
defence_level = ctx.get('extended_fields', {}).get('defence_level') or ''
is_list = ctx.get('is_list', [])
if not defence_level and is_list:
defence_level = str(getattr(is_list[0], 'defence_level', '4') or '4')
defence_level = str(getattr(is_list[0], 'defence_level', '') or '')
if not defence_level:
defence_level = '4'
dl = str(defence_level).strip()[0]
TEXTS = {
+63
View File
@@ -389,6 +389,65 @@ def compare_company_values(old_data, new_data):
return diffs
# ============================================================
# ВАЛИДАЦИЯ РЕКВИЗИТОВ (контрольные суммы ИНН/ОГРН/КПП)
# ============================================================
def _digits(s) -> str:
return re.sub(r'\D', '', str(s or ''))
def valid_inn(inn) -> bool:
"""Проверка контрольной суммы ИНН (10 цифр — юрлицо, 12 — ИП)."""
d = _digits(inn)
if len(d) == 10:
w = [2, 4, 10, 3, 5, 9, 4, 6, 8]
return (sum(int(d[i]) * w[i] for i in range(9)) % 11) % 10 == int(d[9])
if len(d) == 12:
w1 = [7, 2, 4, 10, 3, 5, 9, 4, 6, 8]
w2 = [3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8]
n10 = (sum(int(d[i]) * w1[i] for i in range(10)) % 11) % 10
n11 = (sum(int(d[i]) * w2[i] for i in range(11)) % 11) % 10
return n10 == int(d[10]) and n11 == int(d[11])
return False
def valid_ogrn(ogrn) -> bool:
"""Проверка контрольной суммы ОГРН (13 цифр) / ОГРНИП (15 цифр)."""
d = _digits(ogrn)
if len(d) == 13:
return (int(d[:-1]) % 11) % 10 == int(d[-1])
if len(d) == 15:
return (int(d[:-1]) % 13) % 10 == int(d[-1])
return False
def valid_kpp(kpp) -> bool:
"""КПП: 9 цифр (контрольной суммы нет — проверяем формат)."""
d = _digits(kpp)
return len(d) == 9
def validate_requisites(proposals: dict) -> dict:
"""Фильтрует предложения ИИ: невалидные ИНН/ОГРН/КПП не применяются.
Возвращает (валидные_пропозалы, список_замечаний).
"""
if not isinstance(proposals, dict):
return {}, []
validators = {'inn': valid_inn, 'ogrn': valid_ogrn, 'kpp': valid_kpp}
notes = []
out = dict(proposals)
for key, fn in validators.items():
val = out.get(key)
if val and not fn(val):
notes.append(f"ИИ предложил невалидный {key.upper()} ({val}) — значение не применено")
out.pop(key, None)
return out, notes
# ============================================================
# ИИ-ПРОВЕРКА
# ============================================================
def check_company_ai(company_data, api_key, model='deepseek-chat', base_url=None):
"""ИИ-проверка реквизитов организации.
@@ -425,6 +484,10 @@ def check_company_ai(company_data, api_key, model='deepseek-chat', base_url=None
if not isinstance(fields, dict):
fields = {}
# Валидация контрольных сумм реквизитов перед применением (фикс 3.3)
proposals, req_notes = validate_requisites(proposals)
notes = list(notes) + req_notes
return {
'source': 'ИИ',
'source_url': 'openai-совместимый API',
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Юнит-тесты расчёта УЗ по ПП РФ № 1119 (все комбинации таблицы).
Эталонные значения из текста ПП-1119 п. 912 (сверено с legalacts.ru).
Комбинации: 4 группы категорий × 2 признака «только сотрудники» ×
2 объёма (>100к) × 3 типа угроз = 48.
"""
import sys, os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from dokogen.defence import calc_defence_level
def _cases():
"""(categories, employees, large, threat, expected_uz)"""
c = []
# --- Специальные ---
for emp in (False, True):
for large in (False, True):
c.append((['специальные'], emp, large, '1', 1)) # п.9а
c.append((['специальные'], emp, large, '2',
1 if (not emp and large) else 2)) # п.9б / п.10б
c.append((['специальные'], emp, large, '3',
2 if (not emp and large) else 3)) # п.10е / п.11в
# --- Биометрические (без разбивки по количеству) ---
for emp in (False, True):
for large in (False, True):
c.append((['биометрические'], emp, large, '1', 1)) # п.9а
c.append((['биометрические'], emp, large, '2', 2)) # п.10в
c.append((['биометрические'], emp, large, '3', 3)) # п.11г
# --- Общедоступные ---
for emp in (False, True):
for large in (False, True):
c.append((['общедоступные'], emp, large, '1', 2)) # п.10а
c.append((['общедоступные'], emp, large, '2',
2 if (not emp and large) else 3)) # п.10г / п.11а
c.append((['общедоступные'], emp, large, '3', 4)) # п.12а
# --- Иные ---
for emp in (False, True):
for large in (False, True):
c.append((['иные'], emp, large, '1', 1)) # п.9а
c.append((['иные'], emp, large, '2',
2 if (not emp and large) else 3)) # п.10д / п.11б
c.append((['иные'], emp, large, '3',
3 if (not emp and large) else 4)) # п.11д / п.12б
return c
def main():
cases = _cases()
assert len(cases) == 48, f"ожидалось 48 комбинаций, получено {len(cases)}"
failed = 0
for cats, emp, large, threat, expected in cases:
got = calc_defence_level(cats, emp, large, threat)
if got != expected:
failed += 1
print(f"{cats} emp={emp} large={large} threat={threat}: "
f"ожидалось УЗ-{expected}, получено УЗ-{got}")
if failed:
print(f"\nFAILED: {failed} из {len(cases)}")
return 1
print(f"✅ УЗ по ПП-1119: все {len(cases)} комбинаций верны")
return 0
if __name__ == '__main__':
sys.exit(main())