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:
+10
-4
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
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)
|
||||
|
||||
@@ -77,13 +77,13 @@
|
||||
"name": "Должность ответственного за ПДн",
|
||||
"var": "ispdnPosition",
|
||||
"cell": "",
|
||||
"keywords": "должность.*защит.*информ|должность.*администрат"
|
||||
"keywords": "должность.*защит.*информ|должность.*ответственн.*пдн"
|
||||
},
|
||||
{
|
||||
"name": "ФИО ответственного за ПДн",
|
||||
"var": "ispdnFio",
|
||||
"cell": "",
|
||||
"keywords": "фио.*защит.*информ|фио.*администрат"
|
||||
"keywords": "фио.*защит.*информ|фио.*ответственн.*пдн"
|
||||
},
|
||||
{
|
||||
"name": "Email ответственного за ПДн",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user