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:
@@ -3,3 +3,8 @@ __pycache__/
|
|||||||
.autosave/
|
.autosave/
|
||||||
Сгенерированные_документы/
|
Сгенерированные_документы/
|
||||||
*.result.docx
|
*.result.docx
|
||||||
|
|
||||||
|
# Секреты и локальные настройки — НЕ коммитить
|
||||||
|
api_settings.json
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|||||||
+10
-4
@@ -13,11 +13,17 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
# ВНИМАНИЕ: реальные ключи НЕ хранятся в коде (утечка в git-историю — см. аудит).
|
||||||
|
# Источники ключей (по приоритету):
|
||||||
|
# 1) переменные окружения DADATA_TOKEN / DEEPSEEK_API_KEY / DEEPSEEK_MODEL / DEEPSEEK_BASE_URL
|
||||||
|
# 2) файл api_settings.json рядом с программой
|
||||||
|
# 3) DEFAULT_SETTINGS ниже (пустые значения)
|
||||||
|
|
||||||
DEFAULT_SETTINGS = {
|
DEFAULT_SETTINGS = {
|
||||||
'dadata_token': '431aa11706bf83f571cef0319259289aa750bcb1',
|
'dadata_token': os.environ.get('DADATA_TOKEN', ''),
|
||||||
'ai_api_key': 'sk-3c7e6673f245412697d0e14b6096435f',
|
'ai_api_key': os.environ.get('DEEPSEEK_API_KEY', ''),
|
||||||
'ai_model': 'deepseek-chat',
|
'ai_model': os.environ.get('DEEPSEEK_MODEL', 'deepseek-chat'),
|
||||||
'ai_base_url': 'https://api.deepseek.com',
|
'ai_base_url': os.environ.get('DEEPSEEK_BASE_URL', 'https://api.deepseek.com'),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Старые ключи (для совместимости со старым файлом api_settings.json)
|
# Старые ключи (для совместимости со старым файлом api_settings.json)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -692,9 +692,6 @@ def _highlight_unmatched(doc):
|
|||||||
pos = m.end()
|
pos = m.end()
|
||||||
if pos < len(full):
|
if pos < len(full):
|
||||||
_add_run_with_tabs(para, full[pos:], first)
|
_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:
|
for para in doc.paragraphs:
|
||||||
_process_para(para)
|
_process_para(para)
|
||||||
|
|||||||
@@ -77,13 +77,13 @@
|
|||||||
"name": "Должность ответственного за ПДн",
|
"name": "Должность ответственного за ПДн",
|
||||||
"var": "ispdnPosition",
|
"var": "ispdnPosition",
|
||||||
"cell": "",
|
"cell": "",
|
||||||
"keywords": "должность.*защит.*информ|должность.*администрат"
|
"keywords": "должность.*защит.*информ|должность.*ответственн.*пдн"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "ФИО ответственного за ПДн",
|
"name": "ФИО ответственного за ПДн",
|
||||||
"var": "ispdnFio",
|
"var": "ispdnFio",
|
||||||
"cell": "",
|
"cell": "",
|
||||||
"keywords": "фио.*защит.*информ|фио.*администрат"
|
"keywords": "фио.*защит.*информ|фио.*ответственн.*пдн"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Email ответственного за ПДн",
|
"name": "Email ответственного за ПДн",
|
||||||
|
|||||||
@@ -69,8 +69,10 @@ DEFAULT_SETTINGS = {
|
|||||||
_f('Основной ОКВЭД', 'okved', keywords=r'основной оквэд'),
|
_f('Основной ОКВЭД', 'okved', keywords=r'основной оквэд'),
|
||||||
_f('Должность руководителя', 'chiefPosition', keywords=r'должность руководителя'),
|
_f('Должность руководителя', 'chiefPosition', keywords=r'должность руководителя'),
|
||||||
_f('ФИО руководителя', 'chiefFio', keywords=r'фио руководителя'),
|
_f('ФИО руководителя', 'chiefFio', keywords=r'фио руководителя'),
|
||||||
_f('Должность ответственного за ПДн', 'ispdnPosition', keywords=r'должность.*защит.*информ|должность.*администрат'),
|
_f('Должность администратора безопасности', 'administratorPosition', keywords=r'должность.*администрат.*(?:безопас|защит|пд)'),
|
||||||
_f('ФИО ответственного за ПДн', 'ispdnFio', keywords=r'фио.*защит.*информ|фио.*администрат'),
|
_f('ФИО администратора безопасности', 'administratorFio', keywords=r'фио.*администрат.*(?:безопас|защит|пд)'),
|
||||||
|
_f('Должность ответственного за ПДн', 'ispdnPosition', keywords=r'должность.*защит.*информ|должность.*ответственн.*пдн'),
|
||||||
|
_f('ФИО ответственного за ПДн', 'ispdnFio', keywords=r'фио.*защит.*информ|фио.*ответственн.*пдн'),
|
||||||
_f('Email ответственного за ПДн', 'ispdnEmail', keywords=r'электронная почта.*защит|электронная почта.*ответств|электронная почта.*специалист'),
|
_f('Email ответственного за ПДн', 'ispdnEmail', keywords=r'электронная почта.*защит|электронная почта.*ответств|электронная почта.*специалист'),
|
||||||
_f('Телефон ответственного за ПДн', 'ispdnPhone', keywords=r'телефон.*защит|телефон.*ответств|телефон.*специалист'),
|
_f('Телефон ответственного за ПДн', 'ispdnPhone', keywords=r'телефон.*защит|телефон.*ответств|телефон.*специалист'),
|
||||||
_f('Структурное подразделение по безопасности', 'ispdnDepartment', keywords=r'структур.*подраздел'),
|
_f('Структурное подразделение по безопасности', 'ispdnDepartment', keywords=r'структур.*подраздел'),
|
||||||
@@ -80,8 +82,6 @@ DEFAULT_SETTINGS = {
|
|||||||
_f('Место хранения документов', 'paperStorage', keywords=r'место.*хранен'),
|
_f('Место хранения документов', 'paperStorage', keywords=r'место.*хранен'),
|
||||||
_f('Должность ответственного за обработку ПДн', 'responsiblePosition', keywords=r'должность.*ответственн'),
|
_f('Должность ответственного за обработку ПДн', 'responsiblePosition', keywords=r'должность.*ответственн'),
|
||||||
_f('ФИО ответственного за обработку ПДн', 'responsibleFio', keywords=r'фио.*ответственн'),
|
_f('ФИО ответственного за обработку ПДн', 'responsibleFio', keywords=r'фио.*ответственн'),
|
||||||
_f('Должность администратора безопасности', 'administratorPosition', keywords=r'должность.*администрат.*(?:безопас|защит|пд)'),
|
|
||||||
_f('ФИО администратора безопасности', 'administratorFio', keywords=r'фио.*администрат.*(?:безопас|защит|пд)'),
|
|
||||||
_f('Сайт организации', 'site_name', keywords=r'сайт организации'),
|
_f('Сайт организации', 'site_name', keywords=r'сайт организации'),
|
||||||
_f('Должность ответственного за сайт', 'site_responsible_position', keywords=r'должность ответственного за сайт|должность.*отв.*сайт'),
|
_f('Должность ответственного за сайт', 'site_responsible_position', keywords=r'должность ответственного за сайт|должность.*отв.*сайт'),
|
||||||
_f('ФИО ответственного за сайт', 'site_responsible_fio', keywords=r'фио.*отв.*сайт|фио.*сайт'),
|
_f('ФИО ответственного за сайт', 'site_responsible_fio', keywords=r'фио.*отв.*сайт|фио.*сайт'),
|
||||||
|
|||||||
+15
-2
@@ -425,19 +425,32 @@ def _import_152_is_list(wb, data, settings, log_fn):
|
|||||||
log_fn(f" ⚠ ИС без названия — использовано имя листа: {is_obj['name']}")
|
log_fn(f" ⚠ ИС без названия — использовано имя листа: {is_obj['name']}")
|
||||||
|
|
||||||
# Автоопределение категорий ПДн
|
# Автоопределение категорий ПДн
|
||||||
|
# По 152-ФЗ категории взаимоисключающие: если найдены специальные/биометрические —
|
||||||
|
# «иные» не добавляем, иначе расчёт УЗ по ПП-1119 пойдёт по неверной категории.
|
||||||
pd_cats = []
|
pd_cats = []
|
||||||
all_pd_text = ' '.join(p.lower() for p in pd_items_raw)
|
all_pd_text = ' '.join(p.lower() for p in pd_items_raw)
|
||||||
special_kw = ['состоян.*здоров', 'национальн', 'политическ', 'религиозн', 'философ', 'судимост', 'интимн']
|
special_kw = ['состоян.*здоров', 'национальн', 'политическ', 'религиозн', 'философ', 'судимост', 'интимн']
|
||||||
if any(re.search(kw, all_pd_text) for kw in special_kw):
|
if any(re.search(kw, all_pd_text) for kw in special_kw):
|
||||||
pd_cats.append('специальные')
|
pd_cats.append('специальные')
|
||||||
bio_kw = ['биометрическ', 'изображен.*лиц', 'голос.*человек', 'папилляр', 'дактилоскоп', 'фото.*изображен']
|
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('биометрические')
|
pd_cats.append('биометрические')
|
||||||
if pd_items_raw:
|
if pd_items_raw and not pd_cats:
|
||||||
pd_cats.append('иные')
|
pd_cats.append('иные')
|
||||||
if pd_cats:
|
if pd_cats:
|
||||||
is_obj['personalDataCategory'] = pd_cats
|
is_obj['personalDataCategory'] = pd_cats
|
||||||
log_fn(f" Категории ПДн: {', '.join(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)
|
is_list.append(is_obj)
|
||||||
log_fn(f" ✅ {is_obj['name']} — импортирована")
|
log_fn(f" ✅ {is_obj['name']} — импортирована")
|
||||||
|
|||||||
+2
-2
@@ -2,7 +2,7 @@
|
|||||||
"""DokoGen — Модели данных (только 152-ФЗ, только нужные поля)"""
|
"""DokoGen — Модели данных (только 152-ФЗ, только нужные поля)"""
|
||||||
|
|
||||||
from dataclasses import dataclass, field, asdict
|
from dataclasses import dataclass, field, asdict
|
||||||
from typing import List, Dict, Any
|
from typing import List, Dict, Any, Optional
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -20,7 +20,7 @@ class InformationSystem:
|
|||||||
description: str = ""
|
description: str = ""
|
||||||
is_local_network: bool = False
|
is_local_network: bool = False
|
||||||
is_internet: bool = False
|
is_internet: bool = False
|
||||||
defence_level: str = ""
|
defence_level: Optional[str] = None # None = «не рассчитано» (в отличие от пустой строки)
|
||||||
threat_type: str = "3"
|
threat_type: str = "3"
|
||||||
category: str = ""
|
category: str = ""
|
||||||
pd_list: str = ""
|
pd_list: str = ""
|
||||||
|
|||||||
+4
-36
@@ -23,6 +23,7 @@ from .verify import (
|
|||||||
check_declension_ai, check_company_ai,
|
check_declension_ai, check_company_ai,
|
||||||
COMPANY_FIELDS, compare_company_values,
|
COMPANY_FIELDS, compare_company_values,
|
||||||
)
|
)
|
||||||
|
from .defence import calc_defence_level
|
||||||
from .api_settings import load_api_settings, save_api_settings
|
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)
|
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
|
is_employees = has_only_employees or len(subj_items) == 0
|
||||||
|
|
||||||
# Таблица ПП №1119: (категория, тип_субъектов, объём, тип_угроз) → УЗ
|
# Расчёт УЗ по эталонной таблице ПП РФ №1119 (п.9-12) — dokogen/defence.py
|
||||||
def lookup(cat_group, employees, large, threat):
|
level = calc_defence_level(categories, is_employees, is_large, threat_type)
|
||||||
# Общедоступные → всегда 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)
|
|
||||||
self.is_defence_var.set(str(level))
|
self.is_defence_var.set(str(level))
|
||||||
|
|
||||||
def _on_categories_or_count_changed(self):
|
def _on_categories_or_count_changed(self):
|
||||||
@@ -1959,6 +1926,7 @@ class DokoGenApp:
|
|||||||
room=is_data.get('room', ''),
|
room=is_data.get('room', ''),
|
||||||
structure=is_data.get('structure', ''),
|
structure=is_data.get('structure', ''),
|
||||||
pd_actions_list=is_data.get('pd_actions', []),
|
pd_actions_list=is_data.get('pd_actions', []),
|
||||||
|
defence_level=is_data.get('defenceLevel', ''),
|
||||||
)
|
)
|
||||||
self.model.information_systems.append(isys)
|
self.model.information_systems.append(isys)
|
||||||
self._refresh_is_listbox()
|
self._refresh_is_listbox()
|
||||||
|
|||||||
+17
-4
@@ -139,8 +139,14 @@ def build_replacements(
|
|||||||
doc_number: str = "",
|
doc_number: str = "",
|
||||||
doc_date: str = "",
|
doc_date: str = "",
|
||||||
direction: str = "152fz",
|
direction: str = "152fz",
|
||||||
|
extended_fields: Dict = None,
|
||||||
) -> Dict[str, str]:
|
) -> Dict[str, str]:
|
||||||
"""Строит полный словарь замен {{переменная}} → значение."""
|
"""Строит полный словарь замен {{переменная}} → значение.
|
||||||
|
|
||||||
|
extended_fields — доп. поля для контекста (например, {'defence_level': '2'}).
|
||||||
|
Если не задано — уровень берётся из первой ИС (is_list[0].defence_level),
|
||||||
|
чтобы {{is_security_requirements}} не подставлял всегда УЗ-4.
|
||||||
|
"""
|
||||||
if is_list is None:
|
if is_list is None:
|
||||||
is_list = []
|
is_list = []
|
||||||
|
|
||||||
@@ -148,6 +154,10 @@ def build_replacements(
|
|||||||
commission_data = _build_commission_data(company)
|
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 = {
|
ctx = {
|
||||||
'company': company,
|
'company': company,
|
||||||
'is_list': is_list,
|
'is_list': is_list,
|
||||||
@@ -155,6 +165,7 @@ def build_replacements(
|
|||||||
'doc_number': doc_number or '',
|
'doc_number': doc_number or '',
|
||||||
'doc_date': doc_date or '',
|
'doc_date': doc_date or '',
|
||||||
'direction': direction,
|
'direction': direction,
|
||||||
|
'extended_fields': extended_fields or {},
|
||||||
}
|
}
|
||||||
|
|
||||||
replacements = {}
|
replacements = {}
|
||||||
@@ -447,7 +458,7 @@ def _resolve(source: str, ctx: Dict) -> str:
|
|||||||
field_map = {
|
field_map = {
|
||||||
'name': 'name',
|
'name': 'name',
|
||||||
'description': 'description',
|
'description': 'description',
|
||||||
'category': 'category',
|
'category': lambda: ', '.join(first.personal_data_category) or first.category or '—',
|
||||||
'defence_level': 'defence_level',
|
'defence_level': 'defence_level',
|
||||||
'threat_type': 'threat_type',
|
'threat_type': 'threat_type',
|
||||||
'pd_list': lambda: ', '.join(first.personal_data_list or []),
|
'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:
|
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', [])
|
is_list = ctx.get('is_list', [])
|
||||||
if not defence_level and 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]
|
dl = str(defence_level).strip()[0]
|
||||||
|
|
||||||
TEXTS = {
|
TEXTS = {
|
||||||
|
|||||||
@@ -389,6 +389,65 @@ def compare_company_values(old_data, new_data):
|
|||||||
return diffs
|
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):
|
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):
|
if not isinstance(fields, dict):
|
||||||
fields = {}
|
fields = {}
|
||||||
|
|
||||||
|
# Валидация контрольных сумм реквизитов перед применением (фикс 3.3)
|
||||||
|
proposals, req_notes = validate_requisites(proposals)
|
||||||
|
notes = list(notes) + req_notes
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'source': 'ИИ',
|
'source': 'ИИ',
|
||||||
'source_url': 'openai-совместимый API',
|
'source_url': 'openai-совместимый API',
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Юнит-тесты расчёта УЗ по ПП РФ № 1119 (все комбинации таблицы).
|
||||||
|
|
||||||
|
Эталонные значения — из текста ПП-1119 п. 9–12 (сверено с 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())
|
||||||
Reference in New Issue
Block a user